diff --git a/bridge_ui/src/components/Attest/Source.tsx b/bridge_ui/src/components/Attest/Source.tsx index 1d496b7b1..1105d5c98 100644 --- a/bridge_ui/src/components/Attest/Source.tsx +++ b/bridge_ui/src/components/Attest/Source.tsx @@ -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 ( <> - + ); } diff --git a/bridge_ui/src/components/Attest/Target.tsx b/bridge_ui/src/components/Attest/Target.tsx index 5eeb7ed6b..023145dd4 100644 --- a/bridge_ui/src/components/Attest/Target.tsx +++ b/bridge_ui/src/components/Attest/Target.tsx @@ -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 ( <> {/* TODO: determine "to" token address */} - + ); } diff --git a/bridge_ui/src/components/ButtonWithLoader.tsx b/bridge_ui/src/components/ButtonWithLoader.tsx index 8b6e42405..b3da51a0a 100644 --- a/bridge_ui/src/components/ButtonWithLoader.tsx +++ b/bridge_ui/src/components/ButtonWithLoader.tsx @@ -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 ( -
- - {showLoader ? ( - + <> +
+ + {showLoader ? ( + + ) : null} +
+ {error ? ( + + {error} + ) : null} -
+ ); } diff --git a/bridge_ui/src/components/RadialGradient.tsx b/bridge_ui/src/components/RadialGradient.tsx index cf39f2275..798915a8a 100644 --- a/bridge_ui/src/components/RadialGradient.tsx +++ b/bridge_ui/src/components/RadialGradient.tsx @@ -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, }, })); diff --git a/bridge_ui/src/components/SolanaWalletKey.tsx b/bridge_ui/src/components/SolanaWalletKey.tsx index a2afba2c0..a8c4d2903 100644 --- a/bridge_ui/src/components/SolanaWalletKey.tsx +++ b/bridge_ui/src/components/SolanaWalletKey.tsx @@ -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 ( - +
{wallet && ( } - style={{ marginLeft: 8 }} + className={classes.disconnectButton} /> )} - +
); }; diff --git a/bridge_ui/src/components/Transfer/Send.tsx b/bridge_ui/src/components/Transfer/Send.tsx index 0f1c0b5ff..20dbe9ee4 100644 --- a/bridge_ui/src/components/Transfer/Send.tsx +++ b/bridge_ui/src/components/Transfer/Send.tsx @@ -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 ( <> @@ -14,6 +18,7 @@ function Send() { disabled={disabled} onClick={handleClick} showLoader={showLoader} + error={error} > Transfer diff --git a/bridge_ui/src/components/Transfer/Source.tsx b/bridge_ui/src/components/Transfer/Source.tsx index 70b943807..9006685c1 100644 --- a/bridge_ui/src/components/Transfer/Source.tsx +++ b/bridge_ui/src/components/Transfer/Source.tsx @@ -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 ( <> - + ); } diff --git a/bridge_ui/src/components/Transfer/Target.tsx b/bridge_ui/src/components/Transfer/Target.tsx index d00aaecff..3bc75b729 100644 --- a/bridge_ui/src/components/Transfer/Target.tsx +++ b/bridge_ui/src/components/Transfer/Target.tsx @@ -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 ( <> - + ); } diff --git a/bridge_ui/src/hooks/useFetchTargetAsset.ts b/bridge_ui/src/hooks/useFetchTargetAsset.ts index f11a3c020..4dce009eb 100644 --- a/bridge_ui/src/hooks/useFetchTargetAsset.ts +++ b/bridge_ui/src/hooks/useFetchTargetAsset.ts @@ -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) { diff --git a/bridge_ui/src/hooks/useSyncTargetAddress.ts b/bridge_ui/src/hooks/useSyncTargetAddress.ts index 9f9664409..5802f6bc9 100644 --- a/bridge_ui/src/hooks/useSyncTargetAddress.ts +++ b/bridge_ui/src/hooks/useSyncTargetAddress.ts @@ -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 && diff --git a/bridge_ui/src/store/selectors.ts b/bridge_ui/src/store/selectors.ts index 05233a456..641fc3a8d 100644 --- a/bridge_ui/src/store/selectors.ts +++ b/bridge_ui/src/store/selectors.ts @@ -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) =>