bridge_ui: Transfer already redeemed feature

Notify the user when they're trying to redeem a transfer that has
already been redeemed.

Added useGetIsTransferCompleted hook
This commit is contained in:
Kevin Peters 2021-12-13 16:46:30 +00:00 committed by Evan Gray
parent e50541912b
commit 86c3423b6a
7 changed files with 235 additions and 13 deletions

View File

@ -335,6 +335,10 @@ export default function Recovery() {
targetAddress: parsedPayload.targetAddress, targetAddress: parsedPayload.targetAddress,
originChain: parsedPayload.originChain, originChain: parsedPayload.originChain,
originAddress: parsedPayload.originAddress, originAddress: parsedPayload.originAddress,
amount:
"amount" in parsedPayload
? parsedPayload.amount.toString()
: "",
}, },
}) })
); );

View File

@ -5,18 +5,33 @@ import {
CHAIN_ID_ETHEREUM_ROPSTEN, CHAIN_ID_ETHEREUM_ROPSTEN,
CHAIN_ID_POLYGON, CHAIN_ID_POLYGON,
CHAIN_ID_SOLANA, CHAIN_ID_SOLANA,
isEVMChain,
MAX_VAA_DECIMALS,
WSOL_ADDRESS, WSOL_ADDRESS,
} from "@certusone/wormhole-sdk"; } from "@certusone/wormhole-sdk";
import { Checkbox, FormControlLabel } from "@material-ui/core"; import { formatUnits } from "@ethersproject/units";
import { useCallback, useState } from "react"; import {
import { useSelector } from "react-redux"; 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 { useHandleRedeem } from "../../hooks/useHandleRedeem";
import useIsWalletReady from "../../hooks/useIsWalletReady"; import useIsWalletReady from "../../hooks/useIsWalletReady";
import useMetadata from "../../hooks/useMetadata";
import { import {
selectTransferAmount,
selectTransferIsRecovery,
selectTransferTargetAsset, selectTransferTargetAsset,
selectTransferTargetChain, selectTransferTargetChain,
} from "../../store/selectors"; } from "../../store/selectors";
import { reset } from "../../store/transferSlice";
import { import {
getHowToAddTokensToWalletUrl,
ROPSTEN_WETH_ADDRESS, ROPSTEN_WETH_ADDRESS,
WAVAX_ADDRESS, WAVAX_ADDRESS,
WBNB_ADDRESS, WBNB_ADDRESS,
@ -25,16 +40,36 @@ import {
} from "../../utils/consts"; } from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader"; import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance"; import KeyAndBalance from "../KeyAndBalance";
import SmartAddress from "../SmartAddress";
import { SolanaCreateAssociatedAddressAlternate } from "../SolanaCreateAssociatedAddress"; import { SolanaCreateAssociatedAddressAlternate } from "../SolanaCreateAssociatedAddress";
import StepDescription from "../StepDescription"; import StepDescription from "../StepDescription";
import AddToMetamask from "./AddToMetamask";
import WaitingForWalletMessage from "./WaitingForWalletMessage"; import WaitingForWalletMessage from "./WaitingForWalletMessage";
const useStyles = makeStyles((theme) => ({
alert: {
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
},
}));
function Redeem() { function Redeem() {
const { handleClick, handleNativeClick, disabled, showLoader } = const { handleClick, handleNativeClick, disabled, showLoader } =
useHandleRedeem(); useHandleRedeem();
const targetChain = useSelector(selectTransferTargetChain); const targetChain = useSelector(selectTransferTargetChain);
const targetAsset = useSelector(selectTransferTargetAsset); 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 { isReady, statusMessage } = useIsWalletReady(targetChain);
const targetAssetArrayed = useMemo(
() => (targetAsset ? [targetAsset] : []),
[targetAsset]
);
const metadata = useMetadata(targetChain, targetAssetArrayed);
//TODO better check, probably involving a hook & the VAA //TODO better check, probably involving a hook & the VAA
const isEthNative = const isEthNative =
targetChain === CHAIN_ID_ETH && targetChain === CHAIN_ID_ETH &&
@ -71,6 +106,18 @@ function Redeem() {
const toggleNativeRedeem = useCallback(() => { const toggleNativeRedeem = useCallback(() => {
setUseNativeRedeem(!useNativeRedeem); setUseNativeRedeem(!useNativeRedeem);
}, [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 ( return (
<> <>
@ -94,16 +141,51 @@ function Redeem() {
<ButtonWithLoader <ButtonWithLoader
//TODO disable when the associated token account is confirmed to not exist //TODO disable when the associated token account is confirmed to not exist
disabled={!isReady || disabled} disabled={
!isReady ||
disabled ||
(isRecovery && (isTransferCompletedLoading || isTransferCompleted))
}
onClick={ onClick={
isNativeEligible && useNativeRedeem ? handleNativeClick : handleClick isNativeEligible && useNativeRedeem ? handleNativeClick : handleClick
} }
showLoader={showLoader} showLoader={showLoader || (isRecovery && isTransferCompletedLoading)}
error={statusMessage} error={statusMessage}
> >
Redeem Redeem
</ButtonWithLoader> </ButtonWithLoader>
<WaitingForWalletMessage /> <WaitingForWalletMessage />
{isRecovery && isReady && isTransferCompleted ? (
<>
<Alert severity="info" variant="outlined" className={classes.alert}>
These tokens have already been redeemed.{" "}
{!isEVMChain(targetChain) && howToAddTokensUrl ? (
<Link
href={howToAddTokensUrl}
target="_blank"
rel="noopener noreferrer"
>
Click here to see how to add them to your wallet.
</Link>
) : null}
</Alert>
{targetAsset ? (
<>
<span>Token Address:</span>
<SmartAddress
chainId={targetChain}
address={targetAsset || undefined}
/>
{formattedTransferAmount ? <span>{`Amount: ${formattedTransferAmount}`}</span> : null}
</>
) : null}
{isEVMChain(targetChain) ? <AddToMetamask /> : null}
<ButtonWithLoader onClick={handleResetClick}>
Transfer More Tokens!
</ButtonWithLoader>
</>
) : null}
</> </>
); );
} }

View File

@ -28,13 +28,11 @@ import {
} from "../store/helpers"; } from "../store/helpers";
import { setTargetAsset as setNFTTargetAsset } from "../store/nftSlice"; import { setTargetAsset as setNFTTargetAsset } from "../store/nftSlice";
import { import {
selectNFTIsRecovery,
selectNFTIsSourceAssetWormholeWrapped, selectNFTIsSourceAssetWormholeWrapped,
selectNFTOriginAsset, selectNFTOriginAsset,
selectNFTOriginChain, selectNFTOriginChain,
selectNFTOriginTokenId, selectNFTOriginTokenId,
selectNFTTargetChain, selectNFTTargetChain,
selectTransferIsRecovery,
selectTransferIsSourceAssetWormholeWrapped, selectTransferIsSourceAssetWormholeWrapped,
selectTransferOriginAsset, selectTransferOriginAsset,
selectTransferOriginChain, selectTransferOriginChain,
@ -74,9 +72,6 @@ function useFetchTargetAsset(nft?: boolean) {
const { provider, chainId: evmChainId } = useEthereumProvider(); const { provider, chainId: evmChainId } = useEthereumProvider();
const correctEvmNetwork = getEvmChainId(targetChain); const correctEvmNetwork = getEvmChainId(targetChain);
const hasCorrectEvmNetwork = evmChainId === correctEvmNetwork; const hasCorrectEvmNetwork = evmChainId === correctEvmNetwork;
const isRecovery = useSelector(
nft ? selectNFTIsRecovery : selectTransferIsRecovery
);
const [lastSuccessfulArgs, setLastSuccessfulArgs] = useState<{ const [lastSuccessfulArgs, setLastSuccessfulArgs] = useState<{
isSourceAssetWormholeWrapped: boolean | undefined; isSourceAssetWormholeWrapped: boolean | undefined;
originChain: ChainId | undefined; originChain: ChainId | undefined;
@ -114,7 +109,7 @@ function useFetchTargetAsset(nft?: boolean) {
] ]
); );
useEffect(() => { useEffect(() => {
if (isRecovery || argsMatchLastSuccess) { if (argsMatchLastSuccess) {
return; return;
} }
setLastSuccessfulArgs(null); setLastSuccessfulArgs(null);
@ -250,7 +245,6 @@ function useFetchTargetAsset(nft?: boolean) {
}; };
}, [ }, [
dispatch, dispatch,
isRecovery,
isSourceAssetWormholeWrapped, isSourceAssetWormholeWrapped,
originChain, originChain,
originAsset, originAsset,

View File

@ -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 };
}

View File

@ -19,7 +19,7 @@ export type GenericMetadata = {
symbol?: string; symbol?: string;
logo?: string; logo?: string;
tokenName?: string; tokenName?: string;
//decimals?: number; decimals?: number;
//TODO more items //TODO more items
raw?: any; raw?: any;
}; };

View File

@ -234,6 +234,7 @@ export const transferSlice = createSlice({
targetAddress: string; targetAddress: string;
originChain: ChainId; originChain: ChainId;
originAddress: string; originAddress: string;
amount: string;
}; };
}> }>
) => { ) => {
@ -252,6 +253,7 @@ export const transferSlice = createSlice({
state.targetAddressHex = action.payload.parsedPayload.targetAddress; state.targetAddressHex = action.payload.parsedPayload.targetAddress;
state.originChain = action.payload.parsedPayload.originChain; state.originChain = action.payload.parsedPayload.originChain;
state.originAsset = action.payload.parsedPayload.originAddress; state.originAsset = action.payload.parsedPayload.originAddress;
state.amount = action.payload.parsedPayload.amount;
state.activeStep = 3; state.activeStep = 3;
state.isRecovery = true; state.isRecovery = true;
}, },

View File

@ -7,6 +7,7 @@ import {
CHAIN_ID_POLYGON, CHAIN_ID_POLYGON,
CHAIN_ID_SOLANA, CHAIN_ID_SOLANA,
CHAIN_ID_TERRA, CHAIN_ID_TERRA,
isEVMChain,
} from "@certusone/wormhole-sdk"; } from "@certusone/wormhole-sdk";
import { clusterApiUrl } from "@solana/web3.js"; import { clusterApiUrl } from "@solana/web3.js";
import { getAddress } from "ethers/lib/utils"; import { getAddress } from "ethers/lib/utils";
@ -814,3 +815,12 @@ export const logoOverrides = new Map<string, string>([
"https://orion.money/assets/ORION-LOGO-2.1-GREEN@256x256.png", "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 "";
};