diff --git a/bridge_ui/src/components/Recovery.tsx b/bridge_ui/src/components/Recovery.tsx index 2947155fc..33d93929c 100644 --- a/bridge_ui/src/components/Recovery.tsx +++ b/bridge_ui/src/components/Recovery.tsx @@ -335,6 +335,10 @@ export default function Recovery() { targetAddress: parsedPayload.targetAddress, originChain: parsedPayload.originChain, originAddress: parsedPayload.originAddress, + amount: + "amount" in parsedPayload + ? parsedPayload.amount.toString() + : "", }, }) ); diff --git a/bridge_ui/src/components/Transfer/Redeem.tsx b/bridge_ui/src/components/Transfer/Redeem.tsx index 353e837d5..3a184d184 100644 --- a/bridge_ui/src/components/Transfer/Redeem.tsx +++ b/bridge_ui/src/components/Transfer/Redeem.tsx @@ -5,18 +5,33 @@ import { CHAIN_ID_ETHEREUM_ROPSTEN, CHAIN_ID_POLYGON, CHAIN_ID_SOLANA, + isEVMChain, + MAX_VAA_DECIMALS, WSOL_ADDRESS, } from "@certusone/wormhole-sdk"; -import { Checkbox, FormControlLabel } from "@material-ui/core"; -import { useCallback, useState } from "react"; -import { useSelector } from "react-redux"; +import { formatUnits } from "@ethersproject/units"; +import { + Checkbox, + FormControlLabel, + Link, + makeStyles, +} from "@material-ui/core"; +import { Alert } from "@material-ui/lab"; +import { useCallback, useMemo, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import useGetIsTransferCompleted from "../../hooks/useGetIsTransferCompleted"; import { useHandleRedeem } from "../../hooks/useHandleRedeem"; import useIsWalletReady from "../../hooks/useIsWalletReady"; +import useMetadata from "../../hooks/useMetadata"; import { + selectTransferAmount, + selectTransferIsRecovery, selectTransferTargetAsset, selectTransferTargetChain, } from "../../store/selectors"; +import { reset } from "../../store/transferSlice"; import { + getHowToAddTokensToWalletUrl, ROPSTEN_WETH_ADDRESS, WAVAX_ADDRESS, WBNB_ADDRESS, @@ -25,16 +40,36 @@ import { } from "../../utils/consts"; import ButtonWithLoader from "../ButtonWithLoader"; import KeyAndBalance from "../KeyAndBalance"; +import SmartAddress from "../SmartAddress"; import { SolanaCreateAssociatedAddressAlternate } from "../SolanaCreateAssociatedAddress"; import StepDescription from "../StepDescription"; +import AddToMetamask from "./AddToMetamask"; import WaitingForWalletMessage from "./WaitingForWalletMessage"; +const useStyles = makeStyles((theme) => ({ + alert: { + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + }, +})); + function Redeem() { const { handleClick, handleNativeClick, disabled, showLoader } = useHandleRedeem(); const targetChain = useSelector(selectTransferTargetChain); const targetAsset = useSelector(selectTransferTargetAsset); + const isRecovery = useSelector(selectTransferIsRecovery); + const transferAmount = useSelector(selectTransferAmount); + const { isTransferCompletedLoading, isTransferCompleted } = + useGetIsTransferCompleted(true); + const classes = useStyles(); + const dispatch = useDispatch(); const { isReady, statusMessage } = useIsWalletReady(targetChain); + const targetAssetArrayed = useMemo( + () => (targetAsset ? [targetAsset] : []), + [targetAsset] + ); + const metadata = useMetadata(targetChain, targetAssetArrayed); //TODO better check, probably involving a hook & the VAA const isEthNative = targetChain === CHAIN_ID_ETH && @@ -71,6 +106,18 @@ function Redeem() { const toggleNativeRedeem = useCallback(() => { setUseNativeRedeem(!useNativeRedeem); }, [useNativeRedeem]); + const handleResetClick = useCallback(() => { + dispatch(reset()); + }, [dispatch]); + const howToAddTokensUrl = getHowToAddTokensToWalletUrl(targetChain); + + const formattedTransferAmount = useMemo(() => { + const decimals = + (targetAsset && metadata.data?.get(targetAsset)?.decimals) || undefined; + return decimals + ? formatUnits(transferAmount, Math.min(decimals, MAX_VAA_DECIMALS)) + : undefined; + }, [targetAsset, metadata, transferAmount]); return ( <> @@ -94,16 +141,51 @@ function Redeem() { Redeem + + {isRecovery && isReady && isTransferCompleted ? ( + <> + + These tokens have already been redeemed.{" "} + {!isEVMChain(targetChain) && howToAddTokensUrl ? ( + + Click here to see how to add them to your wallet. + + ) : null} + + {targetAsset ? ( + <> + Token Address: + + {formattedTransferAmount ? {`Amount: ${formattedTransferAmount}`} : null} + + ) : null} + {isEVMChain(targetChain) ? : null} + + Transfer More Tokens! + + + ) : null} ); } diff --git a/bridge_ui/src/hooks/useFetchTargetAsset.ts b/bridge_ui/src/hooks/useFetchTargetAsset.ts index bd594e94b..f24c4845e 100644 --- a/bridge_ui/src/hooks/useFetchTargetAsset.ts +++ b/bridge_ui/src/hooks/useFetchTargetAsset.ts @@ -28,13 +28,11 @@ import { } from "../store/helpers"; import { setTargetAsset as setNFTTargetAsset } from "../store/nftSlice"; import { - selectNFTIsRecovery, selectNFTIsSourceAssetWormholeWrapped, selectNFTOriginAsset, selectNFTOriginChain, selectNFTOriginTokenId, selectNFTTargetChain, - selectTransferIsRecovery, selectTransferIsSourceAssetWormholeWrapped, selectTransferOriginAsset, selectTransferOriginChain, @@ -74,9 +72,6 @@ function useFetchTargetAsset(nft?: boolean) { const { provider, chainId: evmChainId } = useEthereumProvider(); const correctEvmNetwork = getEvmChainId(targetChain); const hasCorrectEvmNetwork = evmChainId === correctEvmNetwork; - const isRecovery = useSelector( - nft ? selectNFTIsRecovery : selectTransferIsRecovery - ); const [lastSuccessfulArgs, setLastSuccessfulArgs] = useState<{ isSourceAssetWormholeWrapped: boolean | undefined; originChain: ChainId | undefined; @@ -114,7 +109,7 @@ function useFetchTargetAsset(nft?: boolean) { ] ); useEffect(() => { - if (isRecovery || argsMatchLastSuccess) { + if (argsMatchLastSuccess) { return; } setLastSuccessfulArgs(null); @@ -250,7 +245,6 @@ function useFetchTargetAsset(nft?: boolean) { }; }, [ dispatch, - isRecovery, isSourceAssetWormholeWrapped, originChain, originAsset, diff --git a/bridge_ui/src/hooks/useGetIsTransferCompleted.ts b/bridge_ui/src/hooks/useGetIsTransferCompleted.ts new file mode 100644 index 000000000..a1123c9c0 --- /dev/null +++ b/bridge_ui/src/hooks/useGetIsTransferCompleted.ts @@ -0,0 +1,130 @@ +import { + CHAIN_ID_SOLANA, + CHAIN_ID_TERRA, + getIsTransferCompletedEth, + getIsTransferCompletedSolana, + getIsTransferCompletedTerra, + isEVMChain, +} from "@certusone/wormhole-sdk"; +import { Connection } from "@solana/web3.js"; +import { useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import { useEthereumProvider } from "../contexts/EthereumProviderContext"; +import { + selectTransferIsRecovery, + selectTransferTargetAddressHex, + selectTransferTargetChain, +} from "../store/selectors"; +import { + getEvmChainId, + getTokenBridgeAddressForChain, + SOLANA_HOST, + TERRA_GAS_PRICES_URL, + TERRA_HOST, +} from "../utils/consts"; +import useTransferSignedVAA from "./useTransferSignedVAA"; +import { LCDClient } from "@terra-money/terra.js"; +import useIsWalletReady from "./useIsWalletReady"; + +/** + * @param recoveryOnly Only fire when in recovery mode + */ +export default function useGetIsTransferCompleted(recoveryOnly: boolean): { + isTransferCompletedLoading: boolean; + isTransferCompleted: boolean; +} { + const [isLoading, setIsLoading] = useState(false); + const [isTransferCompleted, setIsTransferCompleted] = useState(false); + + const isRecovery = useSelector(selectTransferIsRecovery); + const targetAddress = useSelector(selectTransferTargetAddressHex); + const targetChain = useSelector(selectTransferTargetChain); + + const { isReady, walletAddress } = useIsWalletReady(targetChain, false); + const { provider, chainId: evmChainId } = useEthereumProvider(); + const signedVAA = useTransferSignedVAA(); + + const hasCorrectEvmNetwork = evmChainId === getEvmChainId(targetChain); + const shouldFire = !recoveryOnly || isRecovery; + + useEffect(() => { + if (!shouldFire) { + return; + } + + let cancelled = false; + let transferCompleted = false; + if (targetChain && targetAddress && signedVAA && isReady) { + if (isEVMChain(targetChain) && hasCorrectEvmNetwork && provider) { + setIsLoading(true); + (async () => { + try { + transferCompleted = await getIsTransferCompletedEth( + getTokenBridgeAddressForChain(targetChain), + provider, + signedVAA + ); + } catch (error) { + console.error(error); + } + if (!cancelled) { + setIsTransferCompleted(transferCompleted); + setIsLoading(false); + } + })(); + } else if (targetChain === CHAIN_ID_SOLANA) { + setIsLoading(true); + (async () => { + try { + const connection = new Connection(SOLANA_HOST, "confirmed"); + transferCompleted = await getIsTransferCompletedSolana( + getTokenBridgeAddressForChain(targetChain), + signedVAA, + connection + ); + } catch (error) { + console.error(error); + } + if (!cancelled) { + setIsTransferCompleted(transferCompleted); + setIsLoading(false); + } + })(); + } else if (targetChain === CHAIN_ID_TERRA && walletAddress) { + setIsLoading(true); + (async () => { + try { + const lcdClient = new LCDClient(TERRA_HOST); + transferCompleted = await getIsTransferCompletedTerra( + getTokenBridgeAddressForChain(targetChain), + signedVAA, + walletAddress, + lcdClient, + TERRA_GAS_PRICES_URL + ); + } catch (error) { + console.error(error); + } + if (!cancelled) { + setIsTransferCompleted(transferCompleted); + setIsLoading(false); + } + })(); + } + } + return () => { + cancelled = true; + }; + }, [ + shouldFire, + hasCorrectEvmNetwork, + targetChain, + targetAddress, + signedVAA, + isReady, + walletAddress, + provider, + ]); + + return { isTransferCompletedLoading: isLoading, isTransferCompleted }; +} diff --git a/bridge_ui/src/hooks/useMetadata.ts b/bridge_ui/src/hooks/useMetadata.ts index b68a7784a..1f2a9274d 100644 --- a/bridge_ui/src/hooks/useMetadata.ts +++ b/bridge_ui/src/hooks/useMetadata.ts @@ -19,7 +19,7 @@ export type GenericMetadata = { symbol?: string; logo?: string; tokenName?: string; - //decimals?: number; + decimals?: number; //TODO more items raw?: any; }; diff --git a/bridge_ui/src/store/transferSlice.ts b/bridge_ui/src/store/transferSlice.ts index a382fb0c1..b48cbfb66 100644 --- a/bridge_ui/src/store/transferSlice.ts +++ b/bridge_ui/src/store/transferSlice.ts @@ -234,6 +234,7 @@ export const transferSlice = createSlice({ targetAddress: string; originChain: ChainId; originAddress: string; + amount: string; }; }> ) => { @@ -252,6 +253,7 @@ export const transferSlice = createSlice({ state.targetAddressHex = action.payload.parsedPayload.targetAddress; state.originChain = action.payload.parsedPayload.originChain; state.originAsset = action.payload.parsedPayload.originAddress; + state.amount = action.payload.parsedPayload.amount; state.activeStep = 3; state.isRecovery = true; }, diff --git a/bridge_ui/src/utils/consts.ts b/bridge_ui/src/utils/consts.ts index 555e2fa8f..2dc6c3daf 100644 --- a/bridge_ui/src/utils/consts.ts +++ b/bridge_ui/src/utils/consts.ts @@ -7,6 +7,7 @@ import { CHAIN_ID_POLYGON, CHAIN_ID_SOLANA, CHAIN_ID_TERRA, + isEVMChain, } from "@certusone/wormhole-sdk"; import { clusterApiUrl } from "@solana/web3.js"; import { getAddress } from "ethers/lib/utils"; @@ -814,3 +815,12 @@ export const logoOverrides = new Map([ "https://orion.money/assets/ORION-LOGO-2.1-GREEN@256x256.png", ], ]); + +export const getHowToAddTokensToWalletUrl = (chainId: ChainId) => { + if (isEVMChain(chainId)) { + return "https://docs.wormholenetwork.com/wormhole/video-tutorial-how-to-manually-add-tokens-to-your-wallet#1.-metamask-ethereum-polygon-and-bsc"; + } else if (chainId === CHAIN_ID_TERRA) { + return "https://docs.wormholenetwork.com/wormhole/video-tutorial-how-to-manually-add-tokens-to-your-wallet#2.-terra-station"; + } + return ""; +};