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 { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import {
incrementStep,
setSourceAsset,
setSourceChain,
} from "../../store/attestSlice";
import { import {
selectAttestIsSourceComplete, selectAttestIsSourceComplete,
selectAttestShouldLockFields, selectAttestShouldLockFields,
selectAttestSourceAsset, selectAttestSourceAsset,
selectAttestSourceChain, selectAttestSourceChain,
} from "../../store/selectors"; } from "../../store/selectors";
import {
incrementStep,
setSourceAsset,
setSourceChain,
} from "../../store/attestSlice";
import { CHAINS } from "../../utils/consts"; import { CHAINS } from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance"; import KeyAndBalance from "../KeyAndBalance";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
@ -40,12 +41,9 @@ function Source() {
}, },
[dispatch] [dispatch]
); );
const handleNextClick = useCallback( const handleNextClick = useCallback(() => {
(event) => { dispatch(incrementStep());
dispatch(incrementStep()); }, [dispatch]);
},
[dispatch]
);
return ( return (
<> <>
<TextField <TextField
@ -70,14 +68,13 @@ function Source() {
onChange={handleAssetChange} onChange={handleAssetChange}
disabled={shouldLockFields} disabled={shouldLockFields}
/> />
<Button <ButtonWithLoader
disabled={!isSourceComplete} disabled={!isSourceComplete}
onClick={handleNextClick} onClick={handleNextClick}
variant="contained" showLoader={false}
color="primary"
> >
Next 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 { useCallback, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { incrementStep, setTargetChain } from "../../store/attestSlice";
import { import {
selectAttestIsTargetComplete, selectAttestIsTargetComplete,
selectAttestShouldLockFields, selectAttestShouldLockFields,
selectAttestSourceChain, selectAttestSourceChain,
selectAttestTargetChain, selectAttestTargetChain,
} from "../../store/selectors"; } from "../../store/selectors";
import { incrementStep, setTargetChain } from "../../store/attestSlice";
import { CHAINS } from "../../utils/consts"; import { CHAINS } from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance"; import KeyAndBalance from "../KeyAndBalance";
function Target() { function Target() {
@ -27,12 +28,9 @@ function Target() {
}, },
[dispatch] [dispatch]
); );
const handleNextClick = useCallback( const handleNextClick = useCallback(() => {
(event) => { dispatch(incrementStep());
dispatch(incrementStep()); }, [dispatch]);
},
[dispatch]
);
return ( return (
<> <>
<TextField <TextField
@ -50,14 +48,13 @@ function Target() {
</TextField> </TextField>
{/* TODO: determine "to" token address */} {/* TODO: determine "to" token address */}
<KeyAndBalance chainId={targetChain} /> <KeyAndBalance chainId={targetChain} />
<Button <ButtonWithLoader
disabled={!isTargetComplete} disabled={!isTargetComplete}
onClick={handleNextClick} onClick={handleNextClick}
variant="contained" showLoader={false}
color="primary"
> >
Next 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"; import { ReactChild } from "react";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
@ -17,38 +22,51 @@ const useStyles = makeStyles((theme) => ({
marginLeft: -12, marginLeft: -12,
marginBottom: 6, marginBottom: 6,
}, },
error: {
marginTop: theme.spacing(1),
textAlign: "center",
},
})); }));
export default function ButtonWithLoader({ export default function ButtonWithLoader({
disabled, disabled,
onClick, onClick,
showLoader, showLoader,
error,
children, children,
}: { }: {
disabled: boolean; disabled: boolean;
onClick: () => void; onClick: () => void;
showLoader: boolean; showLoader: boolean;
error?: string;
children: ReactChild; children: ReactChild;
}) { }) {
const classes = useStyles(); const classes = useStyles();
return ( return (
<div className={classes.root}> <>
<Button <div className={classes.root}>
color="primary" <Button
variant="contained" color="primary"
className={classes.button} variant="contained"
disabled={disabled} className={classes.button}
onClick={onClick} disabled={disabled}
> onClick={onClick}
{children} >
</Button> {children}
{showLoader ? ( </Button>
<CircularProgress {showLoader ? (
size={24} <CircularProgress
color="inherit" size={24}
className={classes.loader} color="inherit"
/> className={classes.loader}
/>
) : null}
</div>
{error ? (
<Typography color="error" className={classes.error}>
{error}
</Typography>
) : null} ) : null}
</div> </>
); );
} }

View File

@ -9,6 +9,7 @@ const useStyles = makeStyles((theme) => ({
bottom: 0, bottom: 0,
left: 0, left: 0,
background: `radial-gradient(100% 100% at 100% 125%,${theme.palette.secondary.dark} 0,rgba(255,255,255,0) 100%)`, background: `radial-gradient(100% 100% at 100% 125%,${theme.palette.secondary.dark} 0,rgba(255,255,255,0) 100%)`,
zIndex: -1,
}, },
hole: { hole: {
position: "fixed", position: "fixed",
@ -16,6 +17,7 @@ const useStyles = makeStyles((theme) => ({
right: 0, right: 0,
opacity: 0.3, opacity: 0.3,
filter: "blur(1px)", 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 DisconnectIcon from "@material-ui/icons/LinkOff";
import { import {
WalletDisconnectButton, WalletDisconnectButton,
@ -6,18 +6,31 @@ import {
} from "@solana/wallet-adapter-material-ui"; } from "@solana/wallet-adapter-material-ui";
import { useSolanaWallet } from "../contexts/SolanaWalletContext"; 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 SolanaWalletKey = () => {
const classes = useStyles();
const wallet = useSolanaWallet(); const wallet = useSolanaWallet();
return ( return (
<Toolbar style={{ display: "flex" }}> <div className={classes.root}>
<WalletMultiButton /> <WalletMultiButton />
{wallet && ( {wallet && (
<WalletDisconnectButton <WalletDisconnectButton
startIcon={<DisconnectIcon />} startIcon={<DisconnectIcon />}
style={{ marginLeft: 8 }} className={classes.disconnectButton}
/> />
)} )}
</Toolbar> </div>
); );
}; };

View File

@ -1,12 +1,16 @@
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { useHandleTransfer } from "../../hooks/useHandleTransfer"; import { useHandleTransfer } from "../../hooks/useHandleTransfer";
import { selectTransferSourceChain } from "../../store/selectors"; import {
selectTransferSourceChain,
selectTransferTargetError,
} from "../../store/selectors";
import ButtonWithLoader from "../ButtonWithLoader"; import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance"; import KeyAndBalance from "../KeyAndBalance";
function Send() { function Send() {
const { handleClick, disabled, showLoader } = useHandleTransfer(); const { handleClick, disabled, showLoader } = useHandleTransfer();
const sourceChain = useSelector(selectTransferSourceChain); const sourceChain = useSelector(selectTransferSourceChain);
const error = useSelector(selectTransferTargetError);
return ( return (
<> <>
<KeyAndBalance chainId={sourceChain} /> <KeyAndBalance chainId={sourceChain} />
@ -14,6 +18,7 @@ function Send() {
disabled={disabled} disabled={disabled}
onClick={handleClick} onClick={handleClick}
showLoader={showLoader} showLoader={showLoader}
error={error}
> >
Transfer Transfer
</ButtonWithLoader> </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 { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import useIsWalletReady from "../../hooks/useIsWalletReady"; import useIsWalletReady from "../../hooks/useIsWalletReady";
@ -8,6 +8,7 @@ import {
selectTransferShouldLockFields, selectTransferShouldLockFields,
selectTransferSourceBalanceString, selectTransferSourceBalanceString,
selectTransferSourceChain, selectTransferSourceChain,
selectTransferSourceError,
} from "../../store/selectors"; } from "../../store/selectors";
import { import {
incrementStep, incrementStep,
@ -15,6 +16,7 @@ import {
setSourceChain, setSourceChain,
} from "../../store/transferSlice"; } from "../../store/transferSlice";
import { CHAINS } from "../../utils/consts"; import { CHAINS } from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance"; import KeyAndBalance from "../KeyAndBalance";
import { TokenSelector } from "../TokenSelectors/SourceTokenSelector"; import { TokenSelector } from "../TokenSelectors/SourceTokenSelector";
@ -30,6 +32,7 @@ function Source() {
const sourceChain = useSelector(selectTransferSourceChain); const sourceChain = useSelector(selectTransferSourceChain);
const uiAmountString = useSelector(selectTransferSourceBalanceString); const uiAmountString = useSelector(selectTransferSourceBalanceString);
const amount = useSelector(selectTransferAmount); const amount = useSelector(selectTransferAmount);
const error = useSelector(selectTransferSourceError);
const isSourceComplete = useSelector(selectTransferIsSourceComplete); const isSourceComplete = useSelector(selectTransferIsSourceComplete);
const shouldLockFields = useSelector(selectTransferShouldLockFields); const shouldLockFields = useSelector(selectTransferShouldLockFields);
const isWalletReady = useIsWalletReady(sourceChain); const isWalletReady = useIsWalletReady(sourceChain);
@ -45,12 +48,9 @@ function Source() {
}, },
[dispatch] [dispatch]
); );
const handleNextClick = useCallback( const handleNextClick = useCallback(() => {
(event) => { dispatch(incrementStep());
dispatch(incrementStep()); }, [dispatch]);
},
[dispatch]
);
return ( return (
<> <>
<TextField <TextField
@ -82,14 +82,14 @@ function Source() {
onChange={handleAmountChange} onChange={handleAmountChange}
disabled={shouldLockFields} disabled={shouldLockFields}
/> />
<Button <ButtonWithLoader
disabled={!isSourceComplete} disabled={!isSourceComplete}
onClick={handleNextClick} onClick={handleNextClick}
variant="contained" showLoader={false}
color="primary" error={error}
> >
Next 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 { useCallback, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk";
import useSyncTargetAddress from "../../hooks/useSyncTargetAddress"; import useSyncTargetAddress from "../../hooks/useSyncTargetAddress";
import { import {
selectTransferIsTargetComplete, selectTransferIsTargetComplete,
@ -11,10 +11,12 @@ import {
selectTransferTargetAsset, selectTransferTargetAsset,
selectTransferTargetBalanceString, selectTransferTargetBalanceString,
selectTransferTargetChain, selectTransferTargetChain,
selectTransferTargetError,
} from "../../store/selectors"; } from "../../store/selectors";
import { incrementStep, setTargetChain } from "../../store/transferSlice"; import { incrementStep, setTargetChain } from "../../store/transferSlice";
import { hexToNativeString } from "../../utils/array"; import { hexToNativeString } from "../../utils/array";
import { CHAINS } from "../../utils/consts"; import { CHAINS } from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance"; import KeyAndBalance from "../KeyAndBalance";
import SolanaCreateAssociatedAddress from "../SolanaCreateAssociatedAddress"; import SolanaCreateAssociatedAddress from "../SolanaCreateAssociatedAddress";
@ -33,11 +35,12 @@ function Target() {
[sourceChain] [sourceChain]
); );
const targetChain = useSelector(selectTransferTargetChain); const targetChain = useSelector(selectTransferTargetChain);
const targetAddressHex = useSelector(selectTransferTargetAddressHex); // TODO: make readable const targetAddressHex = useSelector(selectTransferTargetAddressHex);
const targetAsset = useSelector(selectTransferTargetAsset); const targetAsset = useSelector(selectTransferTargetAsset);
const readableTargetAddress = const readableTargetAddress =
hexToNativeString(targetAddressHex, targetChain) || ""; hexToNativeString(targetAddressHex, targetChain) || "";
const uiAmountString = useSelector(selectTransferTargetBalanceString); const uiAmountString = useSelector(selectTransferTargetBalanceString);
const error = useSelector(selectTransferTargetError);
const isTargetComplete = useSelector(selectTransferIsTargetComplete); const isTargetComplete = useSelector(selectTransferIsTargetComplete);
const shouldLockFields = useSelector(selectTransferShouldLockFields); const shouldLockFields = useSelector(selectTransferShouldLockFields);
useSyncTargetAddress(!shouldLockFields); useSyncTargetAddress(!shouldLockFields);
@ -47,12 +50,9 @@ function Target() {
}, },
[dispatch] [dispatch]
); );
const handleNextClick = useCallback( const handleNextClick = useCallback(() => {
(event) => { dispatch(incrementStep());
dispatch(incrementStep()); }, [dispatch]);
},
[dispatch]
);
return ( return (
<> <>
<TextField <TextField
@ -89,14 +89,14 @@ function Target() {
value={targetAsset || ""} value={targetAsset || ""}
disabled={true} disabled={true}
/> />
<Button <ButtonWithLoader
disabled={!isTargetComplete} disabled={!isTargetComplete}
onClick={handleNextClick} onClick={handleNextClick}
variant="contained" showLoader={false}
color="primary" error={error}
> >
Next Next
</Button> </ButtonWithLoader>
</> </>
); );
} }

View File

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

View File

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

View File

@ -21,9 +21,6 @@ export const selectAttestIsSending = (state: RootState) =>
state.attest.isSending; state.attest.isSending;
export const selectAttestIsCreating = (state: RootState) => export const selectAttestIsCreating = (state: RootState) =>
state.attest.isCreating; state.attest.isCreating;
// safety checks
// TODO: could make this return a string with a user informative message
export const selectAttestIsSourceComplete = (state: RootState) => export const selectAttestIsSourceComplete = (state: RootState) =>
!!state.attest.sourceChain && !!state.attest.sourceAsset; !!state.attest.sourceChain && !!state.attest.sourceAsset;
// TODO: check wrapped asset exists or is native attest // TODO: check wrapped asset exists or is native attest
@ -74,49 +71,85 @@ export const selectTransferIsSending = (state: RootState) =>
state.transfer.isSending; state.transfer.isSending;
export const selectTransferIsRedeeming = (state: RootState) => export const selectTransferIsRedeeming = (state: RootState) =>
state.transfer.isRedeeming; state.transfer.isRedeeming;
export const selectTransferSourceError = (state: RootState) => {
// safety checks if (!state.transfer.sourceChain) {
// TODO: could make this return a string with a user informative message return "Select a source chain";
export const selectTransferIsSourceComplete = (state: RootState) => { }
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 { try {
return ( // these may trigger error: fractional component exceeds decimals
!!state.transfer.sourceChain && if (
!!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
parseUnits( parseUnits(
state.transfer.amount, state.transfer.amount,
state.transfer.sourceParsedTokenAccount.decimals state.transfer.sourceParsedTokenAccount.decimals
).gt(0) && ).lte(0)
// may trigger error: fractional component exceeds decimals ) {
return "Amount must be greater than zero";
}
if (
parseUnits( parseUnits(
state.transfer.amount, state.transfer.amount,
state.transfer.sourceParsedTokenAccount.decimals state.transfer.sourceParsedTokenAccount.decimals
).lte( ).gt(
parseUnits( parseUnits(
state.transfer.sourceParsedTokenAccount.uiAmountString, state.transfer.sourceParsedTokenAccount.uiAmountString,
state.transfer.sourceParsedTokenAccount.decimals state.transfer.sourceParsedTokenAccount.decimals
) )
) )
); ) {
return "Amount may not be greater than balance";
}
} catch (e) { } 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) => export const selectTransferIsTargetComplete = (state: RootState) =>
selectTransferIsSourceComplete(state) && !selectTransferTargetError(state);
!!state.transfer.targetChain &&
!!state.transfer.targetAsset &&
(state.transfer.targetChain !== CHAIN_ID_ETH ||
state.transfer.targetAsset !== ethers.constants.AddressZero) &&
!!state.transfer.targetAddressHex;
export const selectTransferIsSendComplete = (state: RootState) => export const selectTransferIsSendComplete = (state: RootState) =>
!!selectTransferSignedVAAHex(state); !!selectTransferSignedVAAHex(state);
export const selectTransferShouldLockFields = (state: RootState) => export const selectTransferShouldLockFields = (state: RootState) =>