bridge_ui: show warnings

fixes https://github.com/certusone/wormhole/issues/361

Change-Id: I69b357a56eaaf25d46c83ab5fd84bc05d3eaee2a
This commit is contained in:
Evan Gray 2021-08-31 21:18:13 -04:00
parent e70db48ef7
commit 49d41733a7
11 changed files with 186 additions and 119 deletions

View File

@ -1,18 +1,19 @@
import { Button, makeStyles, MenuItem, TextField } from "@material-ui/core";
import { makeStyles, MenuItem, TextField } from "@material-ui/core";
import { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
incrementStep,
setSourceAsset,
setSourceChain,
} from "../../store/attestSlice";
import {
selectAttestIsSourceComplete,
selectAttestShouldLockFields,
selectAttestSourceAsset,
selectAttestSourceChain,
} from "../../store/selectors";
import {
incrementStep,
setSourceAsset,
setSourceChain,
} from "../../store/attestSlice";
import { CHAINS } from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance";
const useStyles = makeStyles((theme) => ({
@ -40,12 +41,9 @@ function Source() {
},
[dispatch]
);
const handleNextClick = useCallback(
(event) => {
dispatch(incrementStep());
},
[dispatch]
);
const handleNextClick = useCallback(() => {
dispatch(incrementStep());
}, [dispatch]);
return (
<>
<TextField
@ -70,14 +68,13 @@ function Source() {
onChange={handleAssetChange}
disabled={shouldLockFields}
/>
<Button
<ButtonWithLoader
disabled={!isSourceComplete}
onClick={handleNextClick}
variant="contained"
color="primary"
showLoader={false}
>
Next
</Button>
</ButtonWithLoader>
</>
);
}

View File

@ -1,14 +1,15 @@
import { Button, MenuItem, TextField } from "@material-ui/core";
import { MenuItem, TextField } from "@material-ui/core";
import { useCallback, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { incrementStep, setTargetChain } from "../../store/attestSlice";
import {
selectAttestIsTargetComplete,
selectAttestShouldLockFields,
selectAttestSourceChain,
selectAttestTargetChain,
} from "../../store/selectors";
import { incrementStep, setTargetChain } from "../../store/attestSlice";
import { CHAINS } from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance";
function Target() {
@ -27,12 +28,9 @@ function Target() {
},
[dispatch]
);
const handleNextClick = useCallback(
(event) => {
dispatch(incrementStep());
},
[dispatch]
);
const handleNextClick = useCallback(() => {
dispatch(incrementStep());
}, [dispatch]);
return (
<>
<TextField
@ -50,14 +48,13 @@ function Target() {
</TextField>
{/* TODO: determine "to" token address */}
<KeyAndBalance chainId={targetChain} />
<Button
<ButtonWithLoader
disabled={!isTargetComplete}
onClick={handleNextClick}
variant="contained"
color="primary"
showLoader={false}
>
Next
</Button>
</ButtonWithLoader>
</>
);
}

View File

@ -1,4 +1,9 @@
import { Button, CircularProgress, makeStyles } from "@material-ui/core";
import {
Button,
CircularProgress,
makeStyles,
Typography,
} from "@material-ui/core";
import { ReactChild } from "react";
const useStyles = makeStyles((theme) => ({
@ -17,38 +22,51 @@ const useStyles = makeStyles((theme) => ({
marginLeft: -12,
marginBottom: 6,
},
error: {
marginTop: theme.spacing(1),
textAlign: "center",
},
}));
export default function ButtonWithLoader({
disabled,
onClick,
showLoader,
error,
children,
}: {
disabled: boolean;
onClick: () => void;
showLoader: boolean;
error?: string;
children: ReactChild;
}) {
const classes = useStyles();
return (
<div className={classes.root}>
<Button
color="primary"
variant="contained"
className={classes.button}
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
{showLoader ? (
<CircularProgress
size={24}
color="inherit"
className={classes.loader}
/>
<>
<div className={classes.root}>
<Button
color="primary"
variant="contained"
className={classes.button}
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
{showLoader ? (
<CircularProgress
size={24}
color="inherit"
className={classes.loader}
/>
) : null}
</div>
{error ? (
<Typography color="error" className={classes.error}>
{error}
</Typography>
) : null}
</div>
</>
);
}

View File

@ -9,6 +9,7 @@ const useStyles = makeStyles((theme) => ({
bottom: 0,
left: 0,
background: `radial-gradient(100% 100% at 100% 125%,${theme.palette.secondary.dark} 0,rgba(255,255,255,0) 100%)`,
zIndex: -1,
},
hole: {
position: "fixed",
@ -16,6 +17,7 @@ const useStyles = makeStyles((theme) => ({
right: 0,
opacity: 0.3,
filter: "blur(1px)",
zIndex: -1,
},
}));

View File

@ -1,4 +1,4 @@
import { Toolbar } from "@material-ui/core";
import { makeStyles } from "@material-ui/core";
import DisconnectIcon from "@material-ui/icons/LinkOff";
import {
WalletDisconnectButton,
@ -6,18 +6,31 @@ import {
} from "@solana/wallet-adapter-material-ui";
import { useSolanaWallet } from "../contexts/SolanaWalletContext";
const useStyles = makeStyles((theme) => ({
root: {
textAlign: "center",
margin: `${theme.spacing(1)}px auto`,
width: "100%",
maxWidth: 400,
},
disconnectButton: {
marginLeft: theme.spacing(1),
},
}));
const SolanaWalletKey = () => {
const classes = useStyles();
const wallet = useSolanaWallet();
return (
<Toolbar style={{ display: "flex" }}>
<div className={classes.root}>
<WalletMultiButton />
{wallet && (
<WalletDisconnectButton
startIcon={<DisconnectIcon />}
style={{ marginLeft: 8 }}
className={classes.disconnectButton}
/>
)}
</Toolbar>
</div>
);
};

View File

@ -1,12 +1,16 @@
import { useSelector } from "react-redux";
import { useHandleTransfer } from "../../hooks/useHandleTransfer";
import { selectTransferSourceChain } from "../../store/selectors";
import {
selectTransferSourceChain,
selectTransferTargetError,
} from "../../store/selectors";
import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance";
function Send() {
const { handleClick, disabled, showLoader } = useHandleTransfer();
const sourceChain = useSelector(selectTransferSourceChain);
const error = useSelector(selectTransferTargetError);
return (
<>
<KeyAndBalance chainId={sourceChain} />
@ -14,6 +18,7 @@ function Send() {
disabled={disabled}
onClick={handleClick}
showLoader={showLoader}
error={error}
>
Transfer
</ButtonWithLoader>

View File

@ -1,4 +1,4 @@
import { Button, makeStyles, MenuItem, TextField } from "@material-ui/core";
import { makeStyles, MenuItem, TextField } from "@material-ui/core";
import { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import useIsWalletReady from "../../hooks/useIsWalletReady";
@ -8,6 +8,7 @@ import {
selectTransferShouldLockFields,
selectTransferSourceBalanceString,
selectTransferSourceChain,
selectTransferSourceError,
} from "../../store/selectors";
import {
incrementStep,
@ -15,6 +16,7 @@ import {
setSourceChain,
} from "../../store/transferSlice";
import { CHAINS } from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance";
import { TokenSelector } from "../TokenSelectors/SourceTokenSelector";
@ -30,6 +32,7 @@ function Source() {
const sourceChain = useSelector(selectTransferSourceChain);
const uiAmountString = useSelector(selectTransferSourceBalanceString);
const amount = useSelector(selectTransferAmount);
const error = useSelector(selectTransferSourceError);
const isSourceComplete = useSelector(selectTransferIsSourceComplete);
const shouldLockFields = useSelector(selectTransferShouldLockFields);
const isWalletReady = useIsWalletReady(sourceChain);
@ -45,12 +48,9 @@ function Source() {
},
[dispatch]
);
const handleNextClick = useCallback(
(event) => {
dispatch(incrementStep());
},
[dispatch]
);
const handleNextClick = useCallback(() => {
dispatch(incrementStep());
}, [dispatch]);
return (
<>
<TextField
@ -82,14 +82,14 @@ function Source() {
onChange={handleAmountChange}
disabled={shouldLockFields}
/>
<Button
<ButtonWithLoader
disabled={!isSourceComplete}
onClick={handleNextClick}
variant="contained"
color="primary"
showLoader={false}
error={error}
>
Next
</Button>
</ButtonWithLoader>
</>
);
}

View File

@ -1,7 +1,7 @@
import { Button, makeStyles, MenuItem, TextField } from "@material-ui/core";
import { CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk";
import { makeStyles, MenuItem, TextField } from "@material-ui/core";
import { useCallback, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk";
import useSyncTargetAddress from "../../hooks/useSyncTargetAddress";
import {
selectTransferIsTargetComplete,
@ -11,10 +11,12 @@ import {
selectTransferTargetAsset,
selectTransferTargetBalanceString,
selectTransferTargetChain,
selectTransferTargetError,
} from "../../store/selectors";
import { incrementStep, setTargetChain } from "../../store/transferSlice";
import { hexToNativeString } from "../../utils/array";
import { CHAINS } from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance";
import SolanaCreateAssociatedAddress from "../SolanaCreateAssociatedAddress";
@ -33,11 +35,12 @@ function Target() {
[sourceChain]
);
const targetChain = useSelector(selectTransferTargetChain);
const targetAddressHex = useSelector(selectTransferTargetAddressHex); // TODO: make readable
const targetAddressHex = useSelector(selectTransferTargetAddressHex);
const targetAsset = useSelector(selectTransferTargetAsset);
const readableTargetAddress =
hexToNativeString(targetAddressHex, targetChain) || "";
const uiAmountString = useSelector(selectTransferTargetBalanceString);
const error = useSelector(selectTransferTargetError);
const isTargetComplete = useSelector(selectTransferIsTargetComplete);
const shouldLockFields = useSelector(selectTransferShouldLockFields);
useSyncTargetAddress(!shouldLockFields);
@ -47,12 +50,9 @@ function Target() {
},
[dispatch]
);
const handleNextClick = useCallback(
(event) => {
dispatch(incrementStep());
},
[dispatch]
);
const handleNextClick = useCallback(() => {
dispatch(incrementStep());
}, [dispatch]);
return (
<>
<TextField
@ -89,14 +89,14 @@ function Target() {
value={targetAsset || ""}
disabled={true}
/>
<Button
<ButtonWithLoader
disabled={!isTargetComplete}
onClick={handleNextClick}
variant="contained"
color="primary"
showLoader={false}
error={error}
>
Next
</Button>
</ButtonWithLoader>
</>
);
}

View File

@ -69,7 +69,6 @@ function useFetchTargetAsset() {
try {
const asset = await getForeignAssetTerra(sourceChain, sourceAsset);
if (!cancelled) {
console.log("terra target asset", asset);
dispatch(setTargetAsset(asset));
}
} catch (e) {

View File

@ -59,19 +59,22 @@ function useSyncTargetAddress(shouldFire: boolean) {
} else if (targetChain === CHAIN_ID_SOLANA && solPK && targetAsset) {
// otherwise, use the associated token account (which we create in the case it doesn't exist)
(async () => {
const associatedTokenAccount = await Token.getAssociatedTokenAddress(
ASSOCIATED_TOKEN_PROGRAM_ID,
TOKEN_PROGRAM_ID,
new PublicKey(targetAsset),
solPK
);
if (!cancelled) {
dispatch(
setTargetAddressHex(
uint8ArrayToHex(zeroPad(associatedTokenAccount.toBytes(), 32))
)
);
}
try {
const associatedTokenAccount =
await Token.getAssociatedTokenAddress(
ASSOCIATED_TOKEN_PROGRAM_ID,
TOKEN_PROGRAM_ID,
new PublicKey(targetAsset), // this might error
solPK
);
if (!cancelled) {
dispatch(
setTargetAddressHex(
uint8ArrayToHex(zeroPad(associatedTokenAccount.toBytes(), 32))
)
);
}
} catch (e) {}
})();
} else if (
targetChain === CHAIN_ID_TERRA &&

View File

@ -21,9 +21,6 @@ export const selectAttestIsSending = (state: RootState) =>
state.attest.isSending;
export const selectAttestIsCreating = (state: RootState) =>
state.attest.isCreating;
// safety checks
// TODO: could make this return a string with a user informative message
export const selectAttestIsSourceComplete = (state: RootState) =>
!!state.attest.sourceChain && !!state.attest.sourceAsset;
// TODO: check wrapped asset exists or is native attest
@ -74,49 +71,85 @@ export const selectTransferIsSending = (state: RootState) =>
state.transfer.isSending;
export const selectTransferIsRedeeming = (state: RootState) =>
state.transfer.isRedeeming;
// safety checks
// TODO: could make this return a string with a user informative message
export const selectTransferIsSourceComplete = (state: RootState) => {
export const selectTransferSourceError = (state: RootState) => {
if (!state.transfer.sourceChain) {
return "Select a source chain";
}
if (!state.transfer.sourceParsedTokenAccount) {
return "Select a token";
}
if (!state.transfer.amount) {
return "Enter an amount";
}
if (
state.transfer.sourceChain === CHAIN_ID_SOLANA &&
!state.transfer.sourceParsedTokenAccount.publicKey
) {
return "Token account unavailable";
}
if (!state.transfer.sourceParsedTokenAccount.uiAmountString) {
return "Token amount unavailable";
}
if (state.transfer.sourceParsedTokenAccount.decimals === 0) {
// TODO: more advanced NFT check
return "NFTs are not currently supported";
}
try {
return (
!!state.transfer.sourceChain &&
!!state.transfer.sourceParsedTokenAccount &&
!!state.transfer.amount &&
(state.transfer.sourceChain !== CHAIN_ID_SOLANA ||
!!state.transfer.sourceParsedTokenAccount.publicKey) &&
!!state.transfer.sourceParsedTokenAccount.uiAmountString &&
!!state.transfer.sourceParsedTokenAccount.decimals &&
state.transfer.sourceParsedTokenAccount.decimals > 0 && // TODO: more advanced NFT check
// may trigger error: fractional component exceeds decimals
// these may trigger error: fractional component exceeds decimals
if (
parseUnits(
state.transfer.amount,
state.transfer.sourceParsedTokenAccount.decimals
).gt(0) &&
// may trigger error: fractional component exceeds decimals
).lte(0)
) {
return "Amount must be greater than zero";
}
if (
parseUnits(
state.transfer.amount,
state.transfer.sourceParsedTokenAccount.decimals
).lte(
).gt(
parseUnits(
state.transfer.sourceParsedTokenAccount.uiAmountString,
state.transfer.sourceParsedTokenAccount.decimals
)
)
);
) {
return "Amount may not be greater than balance";
}
} catch (e) {
return false;
if (e?.message) {
return e.message.substring(0, e.message.indexOf("("));
}
return "Invalid amount";
}
return undefined;
};
export const selectTransferIsSourceComplete = (state: RootState) =>
!selectTransferSourceError(state);
export const selectTransferTargetError = (state: RootState) => {
const sourceError = selectTransferSourceError(state);
if (sourceError) {
return `Error in source: ${sourceError}`;
}
if (!state.transfer.targetChain) {
return "Select a target chain";
}
if (!state.transfer.targetAsset) {
return "Target asset unavailable. Is the token attested?";
}
if (
state.transfer.targetChain === CHAIN_ID_ETH &&
state.transfer.targetAsset === ethers.constants.AddressZero
) {
return "Target asset unavailable. Is the token attested?";
}
if (!state.transfer.targetAddressHex) {
return "Target account unavailable";
}
};
// TODO: check wrapped asset exists or is native transfer
export const selectTransferIsTargetComplete = (state: RootState) =>
selectTransferIsSourceComplete(state) &&
!!state.transfer.targetChain &&
!!state.transfer.targetAsset &&
(state.transfer.targetChain !== CHAIN_ID_ETH ||
state.transfer.targetAsset !== ethers.constants.AddressZero) &&
!!state.transfer.targetAddressHex;
!selectTransferTargetError(state);
export const selectTransferIsSendComplete = (state: RootState) =>
!!selectTransferSignedVAAHex(state);
export const selectTransferShouldLockFields = (state: RootState) =>