From 7f839844108850d57156764453d159f96108d849 Mon Sep 17 00:00:00 2001 From: Chase Moran Date: Sun, 3 Oct 2021 18:35:24 -0400 Subject: [PATCH] bridge_ui: ethereum migration functions Change-Id: I39d12adcdfd5036283572f006a1442a26a3fc143 --- bridge_ui/src/App.js | 12 +- .../Migration/EthereumQuickMigrate.tsx | 366 ++++++++++++++++++ .../components/Migration/EthereumWorkflow.tsx | 233 +++++++++++ .../{Workflow.tsx => SolanaWorkflow.tsx} | 220 +++++------ bridge_ui/src/components/Migration/index.tsx | 99 ++++- .../EthereumSourceTokenSelector.tsx | 32 +- bridge_ui/src/components/Transfer/Source.tsx | 27 +- .../hooks/useEthereumMigratorInformation.tsx | 168 ++++++++ bridge_ui/src/utils/consts.ts | 13 + 9 files changed, 1029 insertions(+), 141 deletions(-) create mode 100644 bridge_ui/src/components/Migration/EthereumQuickMigrate.tsx create mode 100644 bridge_ui/src/components/Migration/EthereumWorkflow.tsx rename bridge_ui/src/components/Migration/{Workflow.tsx => SolanaWorkflow.tsx} (73%) create mode 100644 bridge_ui/src/hooks/useEthereumMigratorInformation.tsx diff --git a/bridge_ui/src/App.js b/bridge_ui/src/App.js index abc4be7f..d046e1d7 100644 --- a/bridge_ui/src/App.js +++ b/bridge_ui/src/App.js @@ -24,6 +24,8 @@ import NFTOriginVerifier from "./components/NFTOriginVerifier"; import Transfer from "./components/Transfer"; import wormholeLogo from "./icons/wormhole.svg"; import { CLUSTER } from "./utils/consts"; +import EthereumQuickMigrate from "./components/Migration/EthereumQuickMigrate"; +import { CHAIN_ID_ETH, CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk"; const useStyles = makeStyles((theme) => ({ appBar: { @@ -185,8 +187,14 @@ function App() { - - + + + + + + + + diff --git a/bridge_ui/src/components/Migration/EthereumQuickMigrate.tsx b/bridge_ui/src/components/Migration/EthereumQuickMigrate.tsx new file mode 100644 index 00000000..b870dc4e --- /dev/null +++ b/bridge_ui/src/components/Migration/EthereumQuickMigrate.tsx @@ -0,0 +1,366 @@ +import { + CHAIN_ID_ETH, + TokenImplementation__factory, +} from "@certusone/wormhole-sdk"; +import { Signer } from "@ethersproject/abstract-signer"; +import { BigNumber } from "@ethersproject/bignumber"; +import { + CircularProgress, + Container, + makeStyles, + Paper, + Typography, +} from "@material-ui/core"; +import ArrowRightAltIcon from "@material-ui/icons/ArrowRightAlt"; +import { parseUnits } from "ethers/lib/utils"; +import { useSnackbar } from "notistack"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEthereumProvider } from "../../contexts/EthereumProviderContext"; +import useEthereumMigratorInformation from "../../hooks/useEthereumMigratorInformation"; +import useIsWalletReady from "../../hooks/useIsWalletReady"; +import { COLORS } from "../../muiTheme"; +import { ETH_MIGRATION_ASSET_MAP } from "../../utils/consts"; +import ButtonWithLoader from "../ButtonWithLoader"; +import EthereumSignerKey from "../EthereumSignerKey"; +import ShowTx from "../ShowTx"; +import SmartAddress from "../SmartAddress"; + +const useStyles = makeStyles((theme) => ({ + spacer: { + height: "2rem", + }, + containerDiv: { + textAlign: "center", + padding: theme.spacing(2), + }, + lineItem: { + display: "flex", + flexWrap: "nowrap", + justifyContent: "space-between", + "& > *": { + alignSelf: "flex-start", + width: "max-content", + }, + }, + flexGrow: { + flewGrow: 1, + }, + mainPaper: { + backgroundColor: COLORS.nearBlackWithMinorTransparency, + textAlign: "center", + padding: "2rem", + "& > h, p ": { + margin: ".5rem", + }, + }, + hidden: { + display: "none", + }, + divider: { + margin: "2rem 0rem 2rem 0rem", + }, + balance: { + display: "inline-block", + }, + convertButton: { + alignSelf: "flex-end", + }, +})); + +//TODO move elsewhere +export const compareWithDecimalOffset = ( + valueA: string, + decimalsA: number, + valueB: string, + decimalsB: number +) => { + //find which is larger, and offset by that amount + const decimalsBasis = decimalsA > decimalsB ? decimalsA : decimalsB; + const normalizedA = parseUnits(valueA, decimalsBasis).toBigInt(); + const normalizedB = parseUnits(valueB, decimalsBasis).toBigInt(); + + if (normalizedA < normalizedB) { + return -1; + } else if (normalizedA === normalizedB) { + return 0; + } else { + return 1; + } +}; + +function EthereumMigrationLineItem({ + migratorAddress, + onLoadComplete, +}: { + migratorAddress: string; + onLoadComplete: () => void; +}) { + const classes = useStyles(); + const { enqueueSnackbar } = useSnackbar(); + const { signer, signerAddress } = useEthereumProvider(); + const poolInfo = useEthereumMigratorInformation( + migratorAddress, + signer, + signerAddress, + false + ); + const [loaded, setLoaded] = useState(false); + const [migrationIsProcessing, setMigrationIsProcessing] = useState(false); + const [transaction, setTransaction] = useState(""); + const [error, setError] = useState(""); + + const sufficientPoolBalance = + poolInfo.data && + compareWithDecimalOffset( + poolInfo.data.fromWalletBalance, + poolInfo.data.fromDecimals, + poolInfo.data.toPoolBalance, + poolInfo.data.toDecimals + ) !== 1; + + useEffect(() => { + if (!loaded && (poolInfo.data || poolInfo.error)) { + console.log("mounted & finished loading"); + onLoadComplete(); + setLoaded(true); + } + }, [loaded, poolInfo, onLoadComplete]); + + //TODO use transaction loader + const migrateTokens = useCallback(async () => { + if (!poolInfo.data) { + enqueueSnackbar("Could not migrate the tokens.", { variant: "error" }); //Should never be hit + return; + } + try { + const migrationAmountAbs = parseUnits( + poolInfo.data.fromWalletBalance, + poolInfo.data.fromDecimals + ); + setMigrationIsProcessing(true); + await poolInfo.data.fromToken.approve( + poolInfo.data.migrator.address, + migrationAmountAbs + ); + const transaction = await poolInfo.data.migrator.migrate( + migrationAmountAbs + ); + await transaction.wait(); + setTransaction(transaction.hash); + enqueueSnackbar(`Successfully migrated the tokens.`, { + variant: "success", + }); + setMigrationIsProcessing(false); + } catch (e) { + console.error(e); + enqueueSnackbar("Could not migrate the tokens.", { variant: "error" }); + setMigrationIsProcessing(false); + setError("Failed to send the transaction."); + } + }, [poolInfo.data, enqueueSnackbar]); + + if (!poolInfo.data) { + return null; + } else if (transaction) { + return ( +
+
+ + Successfully migrated your tokens. They will become available once + this transaction confirms. + + +
+
+ ); + } else { + return ( +
+
+ + Current Token + + + {poolInfo.data.fromWalletBalance} + + +
+
+ + will become + + +
+
+ + Wormhole Token + + + {poolInfo.data.fromWalletBalance} + + +
+
+ + Convert + +
+
+ ); + } +} + +const getAddressBalances = async ( + signer: Signer, + signerAddress: string, + addresses: string[] +): Promise> => { + try { + const promises: Promise[] = []; + const output = new Map(); + addresses.forEach((address) => { + const factory = TokenImplementation__factory.connect(address, signer); + promises.push( + factory.balanceOf(signerAddress).then( + (result) => { + output.set(address, result); + }, + (error) => { + output.set(address, null); + } + ) + ); + }); + await Promise.all(promises); + return output; + } catch (e) { + return Promise.reject("Unable to retrieve token balances."); + } +}; + +export default function EthereumQuickMigrate() { + const classes = useStyles(); + const { signer, signerAddress } = useEthereumProvider(); + const { isReady } = useIsWalletReady(CHAIN_ID_ETH); + const eligibleTokens = useMemo( + () => Array.from(ETH_MIGRATION_ASSET_MAP.keys()), + [] + ); + const [migrators, setMigrators] = useState(null); + const [migratorsError, setMigratorsError] = useState(""); + const [migratorsLoading, setMigratorsLoading] = useState(false); + + //This is for a callback into the line items, so a loader can be displayed while + //they are loading + //TODO don't just swallow loading errors. + const [migratorsFinishedLoading, setMigratorsFinishedLoading] = useState(0); + const reportLoadComplete = useCallback(() => { + setMigratorsFinishedLoading((prevState) => prevState + 1); + }, []); + const isLoading = + migratorsLoading || + (migrators && + migrators.length && + migratorsFinishedLoading < migrators.length); + + useEffect(() => { + if (isReady && signer && signerAddress) { + let cancelled = false; + setMigratorsLoading(true); + setMigratorsError(""); + getAddressBalances(signer, signerAddress, eligibleTokens).then( + (result) => { + if (!cancelled) { + const migratorAddresses = []; + for (const tokenAddress of result.keys()) { + if (result.get(tokenAddress) && result.get(tokenAddress)?.gt(0)) { + const migratorAddress = + ETH_MIGRATION_ASSET_MAP.get(tokenAddress); + if (migratorAddress) { + migratorAddresses.push(migratorAddress); + } + } + } + setMigratorsFinishedLoading(0); + setMigrators(migratorAddresses); + setMigratorsLoading(false); + } + }, + (error) => { + if (!cancelled) { + setMigratorsLoading(false); + setMigratorsError( + "Failed to retrieve available token information." + ); + } + } + ); + + return () => { + cancelled = true; + }; + } + }, [isReady, signer, signerAddress, eligibleTokens]); + + const hasEligibleAssets = migrators && migrators.length > 0; + + const content = ( +
+ + This page allows you to convert certain wrapped tokens on Ethereum into + Wormhole V2 tokens. + + + {!isReady ? ( + Please connect your wallet. + ) : migratorsError ? ( + {migratorsError} + ) : ( + <> +
+ +
+ + {hasEligibleAssets + ? "You have some assets that are eligible for migration! Click the 'Convert' button to swap them for Wormhole tokens." + : "You don't have any assets eligible for migration."} + +
+ {migrators?.map((address) => { + return ( + + ); + })} +
+ + )} +
+ ); + + return ( + + {content} + + ); +} diff --git a/bridge_ui/src/components/Migration/EthereumWorkflow.tsx b/bridge_ui/src/components/Migration/EthereumWorkflow.tsx new file mode 100644 index 00000000..99efd7df --- /dev/null +++ b/bridge_ui/src/components/Migration/EthereumWorkflow.tsx @@ -0,0 +1,233 @@ +import { CHAIN_ID_ETH } from "@certusone/wormhole-sdk"; +import { + CircularProgress, + makeStyles, + TextField, + Typography, +} from "@material-ui/core"; +import { parseUnits } from "ethers/lib/utils"; +import { useSnackbar } from "notistack"; +import { useCallback, useState } from "react"; +import { useEthereumProvider } from "../../contexts/EthereumProviderContext"; +import useEthereumMigratorInformation from "../../hooks/useEthereumMigratorInformation"; +import useIsWalletReady from "../../hooks/useIsWalletReady"; +import ButtonWithLoader from "../ButtonWithLoader"; +import EthereumSignerKey from "../EthereumSignerKey"; +import ShowTx from "../ShowTx"; +import SmartAddress from "../SmartAddress"; + +const useStyles = makeStyles((theme) => ({ + spacer: { + height: "2rem", + }, + containerDiv: { + textAlign: "center", + padding: theme.spacing(2), + }, +})); + +export default function EthereumWorkflow({ + migratorAddress, +}: { + migratorAddress: string; +}) { + const classes = useStyles(); + const { enqueueSnackbar } = useSnackbar(); + const { signer, signerAddress } = useEthereumProvider(); + const { isReady } = useIsWalletReady(CHAIN_ID_ETH); + const [toggleRefresh, setToggleRefresh] = useState(false); + const forceRefresh = useCallback( + () => setToggleRefresh((prevState) => !prevState), + [] + ); + const poolInfo = useEthereumMigratorInformation( + migratorAddress, + signer, + signerAddress, + toggleRefresh + ); + + const [migrationAmount, setMigrationAmount] = useState(""); + const [migrationIsProcessing, setMigrationIsProcessing] = useState(false); + const [error, setError] = useState(""); + const [transaction, setTransaction] = useState(null); + + const fromParse = (amount: string) => { + try { + if (!poolInfo.data?.fromDecimals || !migrationAmount) { + return BigInt(0); + } + return parseUnits(amount, poolInfo.data.fromDecimals).toBigInt(); + } catch (e) { + return BigInt(0); + } + }; + + const hasRequisiteData = poolInfo.data; + const amountGreaterThanZero = fromParse(migrationAmount) > BigInt(0); + const sufficientFromTokens = + poolInfo.data?.fromWalletBalance && + migrationAmount && + fromParse(migrationAmount) <= fromParse(poolInfo.data.fromWalletBalance); + const sufficientPoolBalance = + poolInfo.data?.toPoolBalance && + migrationAmount && + parseFloat(migrationAmount) <= parseFloat(poolInfo.data.toPoolBalance); + + const isReadyToTransfer = + isReady && + amountGreaterThanZero && + sufficientFromTokens && + sufficientPoolBalance && + hasRequisiteData; + + const getNotReadyCause = () => { + if (!isReady) { + return "Connect your wallet to proceed."; + } else if (poolInfo.error) { + return "Unable to retrieve necessary information. This asset may not be supported."; + } else if (!migrationAmount) { + return "Enter an amount to transfer."; + } else if (!amountGreaterThanZero) { + return "The transfer amount must be greater than zero."; + } else if (!sufficientFromTokens) { + return "There are not sufficient funds in your wallet for this transfer."; + } else if (!sufficientPoolBalance) { + return "There are not sufficient funds in the pool for this transfer."; + } else { + return ""; + } + }; + + const handleAmountChange = useCallback( + (event) => setMigrationAmount(event.target.value), + [setMigrationAmount] + ); + + const migrateTokens = useCallback(async () => { + if (!poolInfo.data) { + enqueueSnackbar("Could not migrate the tokens.", { variant: "error" }); //Should never be hit + return; + } + try { + setMigrationIsProcessing(true); + setError(""); + await poolInfo.data.fromToken.approve( + poolInfo.data.migrator.address, + parseUnits(migrationAmount, poolInfo.data.fromDecimals) + ); + const transaction = await poolInfo.data.migrator.migrate( + parseUnits(migrationAmount, poolInfo.data.fromDecimals) + ); + await transaction.wait(); + setTransaction(transaction.hash); + forceRefresh(); + enqueueSnackbar(`Successfully migrated the tokens.`, { + variant: "success", + }); + setMigrationIsProcessing(false); + } catch (e) { + console.error(e); + enqueueSnackbar("Could not migrate the tokens.", { variant: "error" }); + setMigrationIsProcessing(false); + setError("Failed to send the transaction."); + } + }, [poolInfo.data, migrationAmount, enqueueSnackbar, forceRefresh]); + + //TODO tokenName + const toTokenPretty = ( + + ); + const fromTokenPretty = ( + + ); + const poolPretty = ( + + ); + + const fatalError = poolInfo.error + ? "Unable to retrieve necessary information. This asset may not be supported." + : null; + + const explainerContent = ( +
+ This action will convert + + {fromTokenPretty}{" "} + {`(Balance: ${poolInfo.data?.fromWalletBalance || ""})`} + +
+ to + + {toTokenPretty} {`(Balance: ${poolInfo.data?.toWalletBalance || ""})`} + +
+ Utilizing this pool + + {poolPretty} {`(Balance: ${poolInfo.data?.toPoolBalance || ""})`} + +
+ ); + + const mainWorkflow = ( + <> + {explainerContent} +
+ + + {!transaction && ( + + {migrationAmount && isReadyToTransfer + ? "Migrate " + migrationAmount + " Tokens" + : "Migrate"} + + )} + + {(error || !isReadyToTransfer) && ( + {error || getNotReadyCause()} + )} + {transaction ? ( + <> + + Successfully migrated your tokens! They will be available once this + transaction confirms. + + + + ) : null} + + ); + + return ( +
+ + {!isReady ? ( + Please connect your wallet. + ) : poolInfo.isLoading ? ( + + ) : fatalError ? ( + {fatalError} + ) : ( + mainWorkflow + )} +
+ ); +} diff --git a/bridge_ui/src/components/Migration/Workflow.tsx b/bridge_ui/src/components/Migration/SolanaWorkflow.tsx similarity index 73% rename from bridge_ui/src/components/Migration/Workflow.tsx rename to bridge_ui/src/components/Migration/SolanaWorkflow.tsx index 888d362f..99f8f31f 100644 --- a/bridge_ui/src/components/Migration/Workflow.tsx +++ b/bridge_ui/src/components/Migration/SolanaWorkflow.tsx @@ -2,14 +2,7 @@ import { CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk"; import migrateTokensTx from "@certusone/wormhole-sdk/lib/migration/migrateTokens"; import getPoolAddress from "@certusone/wormhole-sdk/lib/migration/poolAddress"; import getToCustodyAddress from "@certusone/wormhole-sdk/lib/migration/toCustodyAddress"; -import { - Container, - Divider, - makeStyles, - Paper, - TextField, - Typography, -} from "@material-ui/core"; +import { makeStyles, TextField, Typography } from "@material-ui/core"; import { ASSOCIATED_TOKEN_PROGRAM_ID, Token, @@ -400,119 +393,106 @@ export default function Workflow({ ); return ( - - - Migrate Legacy Assets - - Convert assets from legacy bridges to Wormhole V2 tokens - - - -
- {fromTokenAccount && toTokenAccount ? ( - <> - - This will migrate - {fromMintPretty} - tokens in this account: - - - - {`(Balance: ${fromTokenAccountBalance}${ - fromMetadata.symbol && " " + fromMetadata.symbol - })`} - -
- - into - {toMintPretty} - tokens in this account: - - - - - {toTokenAccountExists - ? ` (Balance: ${toTokenAccountBalance}${ - (toMetadata.symbol && " " + toMetadata.symbol) || "" - })` - : " (Not created yet)"} - - - - {poolAddress && toCustodyAddress && toCustodyBalance ? ( - <> -
- - Using pool - - holding tokens in this account: - - - - {` (Balance: ${toCustodyBalance}${ - toMetadata.symbol && " " + toMetadata.symbol - })`} - - - ) : null} - - ) : null} -
- - - {!transaction && ( - - {migrationAmount && isReadyToTransfer - ? "Migrate " + migrationAmount + " Tokens" - : "Migrate"} - - )} - {(error || !isReadyToTransfer) && ( - {error || getNotReadyCause()} - )} - {transaction ? ( - <> - - Successfully migrated your tokens! They will be available once - this transaction confirms. - - + +
+ {fromTokenAccount && toTokenAccount ? ( + <> + + This will migrate + {fromMintPretty} + tokens in this account: + + + - - ) : null} - - + {`(Balance: ${fromTokenAccountBalance}${ + fromMetadata.symbol && " " + fromMetadata.symbol + })`} + +
+ + into + {toMintPretty} + tokens in this account: + + + + + {toTokenAccountExists + ? ` (Balance: ${toTokenAccountBalance}${ + (toMetadata.symbol && " " + toMetadata.symbol) || "" + })` + : " (Not created yet)"} + + + + {poolAddress && toCustodyAddress && toCustodyBalance ? ( + <> +
+ + Using pool + + holding tokens in this account: + + + + {` (Balance: ${toCustodyBalance}${ + toMetadata.symbol && " " + toMetadata.symbol + })`} + + + ) : null} + + ) : null} +
+ + + {!transaction && ( + + {migrationAmount && isReadyToTransfer + ? "Migrate " + migrationAmount + " Tokens" + : "Migrate"} + + )} + {(error || !isReadyToTransfer) && ( + {error || getNotReadyCause()} + )} + {transaction ? ( + <> + + Successfully migrated your tokens! They will be available once this + transaction confirms. + + + + ) : null} +
); } diff --git a/bridge_ui/src/components/Migration/index.tsx b/bridge_ui/src/components/Migration/index.tsx index 0e7903e0..d45968e0 100644 --- a/bridge_ui/src/components/Migration/index.tsx +++ b/bridge_ui/src/components/Migration/index.tsx @@ -1,18 +1,53 @@ -import { Typography } from "@material-ui/core"; +import { + Container, + Divider, + makeStyles, + Paper, + Typography, +} from "@material-ui/core"; import { PublicKey } from "@solana/web3.js"; import { RouteComponentProps } from "react-router-dom"; -import { MIGRATION_ASSET_MAP } from "../../utils/consts"; -import Workflow from "./Workflow"; +import { + ETH_MIGRATION_ASSET_MAP, + MIGRATION_ASSET_MAP, +} from "../../utils/consts"; +import SolanaWorkflow from "./SolanaWorkflow"; import { withRouter } from "react-router"; +import { COLORS } from "../../muiTheme"; +import { + ChainId, + CHAIN_ID_ETH, + CHAIN_ID_SOLANA, +} from "@certusone/wormhole-sdk"; +import EthereumWorkflow from "./EthereumWorkflow"; + +const useStyles = makeStyles(() => ({ + mainPaper: { + backgroundColor: COLORS.nearBlackWithMinorTransparency, + textAlign: "center", + padding: "2rem", + "& > h, p ": { + margin: ".5rem", + }, + }, + divider: { + margin: "2rem 0rem 2rem 0rem", + }, + spacer: { + height: "2rem", + }, +})); interface RouteParams { legacyAsset: string; fromTokenAccount: string; } -interface Migration extends RouteComponentProps {} +interface Migration extends RouteComponentProps { + chainId: ChainId; +} -const MigrationRoot: React.FC = (props) => { +const SolanaRoot: React.FC = (props) => { const legacyAsset: string = props.match.params.legacyAsset; const fromTokenAccount: string = props.match.params.fromTokenAccount; const targetAsset: string | undefined = MIGRATION_ASSET_MAP.get(legacyAsset); @@ -27,27 +62,73 @@ const MigrationRoot: React.FC = (props) => { fromTokenAccount && new PublicKey(fromTokenAccount).toString(); } catch (e) {} + let content = null; + if (!fromMint || !toMint) { - return ( + content = ( This asset is not eligible for migration. ); } else if (!fromTokenAcct) { - return ( + content = ( Invalid token account. ); } else { - return ( - ); } + + return content; +}; + +const EthereumRoot: React.FC = (props) => { + const legacyAsset: string = props.match.params.legacyAsset; + const targetPool = ETH_MIGRATION_ASSET_MAP.get(legacyAsset); + + let content = null; + if (!legacyAsset || !targetPool) { + content = ( + + This asset is not eligible for migration. + + ); + } else { + content = ; + } + + return content; +}; + +const MigrationRoot: React.FC = (props) => { + const classes = useStyles(); + let content = null; + + if (props.chainId === CHAIN_ID_SOLANA) { + content = ; + } else if (props.chainId === CHAIN_ID_ETH) { + content = ; + } + + return ( + + + Migrate Assets + + Convert assets from other bridges to Wormhole V2 tokens + + + {content} + + + ); }; export default withRouter(MigrationRoot); diff --git a/bridge_ui/src/components/TokenSelectors/EthereumSourceTokenSelector.tsx b/bridge_ui/src/components/TokenSelectors/EthereumSourceTokenSelector.tsx index 13a9345a..9b7ad5f7 100644 --- a/bridge_ui/src/components/TokenSelectors/EthereumSourceTokenSelector.tsx +++ b/bridge_ui/src/components/TokenSelectors/EthereumSourceTokenSelector.tsx @@ -6,13 +6,16 @@ import { TextField, Typography, } from "@material-ui/core"; -import { Autocomplete, createFilterOptions } from "@material-ui/lab"; +import { Alert, Autocomplete, createFilterOptions } from "@material-ui/lab"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useEthereumProvider } from "../../contexts/EthereumProviderContext"; import { CovalentData } from "../../hooks/useGetSourceParsedTokenAccounts"; import { DataWrapper } from "../../store/helpers"; import { ParsedTokenAccount } from "../../store/transferSlice"; -import { WORMHOLE_V1_ETH_ADDRESS } from "../../utils/consts"; +import { + ETH_MIGRATION_ASSET_MAP, + WORMHOLE_V1_ETH_ADDRESS, +} from "../../utils/consts"; import { ethNFTToNFTParsedTokenAccount, ethTokenToParsedTokenAccount, @@ -57,6 +60,12 @@ const useStyles = makeStyles((theme) => tokenImage: { maxHeight: "2.5rem", //Eyeballing this based off the text size }, + migrationAlert: { + width: "100%", + "& .MuiAlert-message": { + width: "100%", + }, + }, }) ); @@ -82,6 +91,10 @@ const isWormholev1 = (provider: any, address: string) => { return connection.isWrappedAsset(address); }; +const isMigrationEligible = (address: string) => { + return !!ETH_MIGRATION_ASSET_MAP.get(address); +}; + type EthereumSourceTokenSelectorProps = { value: ParsedTokenAccount | null; onChange: (newValue: ParsedTokenAccount | null) => void; @@ -100,7 +113,7 @@ const renderAccount = ( const mintPrettyString = shortenAddress(account.mintKey); const uri = getLogo(account); const symbol = getSymbol(account) || "Unknown"; - return ( + const content = (
{uri && } @@ -121,6 +134,19 @@ const renderAccount = (
); + + const migrationRender = ( +
+ + + This is a legacy asset eligible for migration. + +
{content}
+
+
+ ); + + return isMigrationEligible(account.mintKey) ? migrationRender : content; }; const renderNFTAccount = ( diff --git a/bridge_ui/src/components/Transfer/Source.tsx b/bridge_ui/src/components/Transfer/Source.tsx index c1c36845..c0b0a827 100644 --- a/bridge_ui/src/components/Transfer/Source.tsx +++ b/bridge_ui/src/components/Transfer/Source.tsx @@ -1,4 +1,4 @@ -import { CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk"; +import { CHAIN_ID_ETH, CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk"; import { Button, makeStyles, MenuItem, TextField } from "@material-ui/core"; import { Restore } from "@material-ui/icons"; import { useCallback } from "react"; @@ -19,7 +19,11 @@ import { setAmount, setSourceChain, } from "../../store/transferSlice"; -import { CHAINS, MIGRATION_ASSET_MAP } from "../../utils/consts"; +import { + CHAINS, + ETH_MIGRATION_ASSET_MAP, + MIGRATION_ASSET_MAP, +} from "../../utils/consts"; import ButtonWithLoader from "../ButtonWithLoader"; import KeyAndBalance from "../KeyAndBalance"; import LowBalanceWarning from "../LowBalanceWarning"; @@ -46,10 +50,15 @@ function Source({ selectTransferSourceParsedTokenAccount ); const hasParsedTokenAccount = !!parsedTokenAccount; - const isMigrationAsset = + const isSolanaMigration = sourceChain === CHAIN_ID_SOLANA && !!parsedTokenAccount && !!MIGRATION_ASSET_MAP.get(parsedTokenAccount.mintKey); + const isEthereumMigration = + sourceChain === CHAIN_ID_ETH && + !!parsedTokenAccount && + !!ETH_MIGRATION_ASSET_MAP.get(parsedTokenAccount.mintKey); + const isMigrationAsset = isSolanaMigration || isEthereumMigration; const uiAmountString = useSelector(selectTransferSourceBalanceString); const amount = useSelector(selectTransferAmount); const error = useSelector(selectTransferSourceError); @@ -57,10 +66,14 @@ function Source({ const shouldLockFields = useSelector(selectTransferShouldLockFields); const { isReady, statusMessage } = useIsWalletReady(sourceChain); const handleMigrationClick = useCallback(() => { - history.push( - `/migrate/${parsedTokenAccount?.mintKey}/${parsedTokenAccount?.publicKey}` - ); - }, [history, parsedTokenAccount]); + if (sourceChain === CHAIN_ID_SOLANA) { + history.push( + `/migrate/Solana/${parsedTokenAccount?.mintKey}/${parsedTokenAccount?.publicKey}` + ); + } else if (sourceChain === CHAIN_ID_ETH) { + history.push(`/migrate/Ethereum/${parsedTokenAccount?.mintKey}`); + } + }, [history, parsedTokenAccount, sourceChain]); const handleSourceChange = useCallback( (event) => { dispatch(setSourceChain(event.target.value)); diff --git a/bridge_ui/src/hooks/useEthereumMigratorInformation.tsx b/bridge_ui/src/hooks/useEthereumMigratorInformation.tsx new file mode 100644 index 00000000..b5966675 --- /dev/null +++ b/bridge_ui/src/hooks/useEthereumMigratorInformation.tsx @@ -0,0 +1,168 @@ +import { + Migrator, + Migrator__factory, + TokenImplementation, + TokenImplementation__factory, +} from "@certusone/wormhole-sdk"; +import { Signer } from "@ethersproject/abstract-signer"; +import { formatUnits } from "@ethersproject/units"; +import { useEffect, useMemo, useState } from "react"; + +export type EthMigrationInfo = { + isLoading: boolean; + error: string; + data: RequisiteData | null; +}; + +export type RequisiteData = { + poolAddress: string; + fromAddress: string; + toAddress: string; + fromToken: TokenImplementation; + toToken: TokenImplementation; + migrator: Migrator; + fromSymbol: string; + toSymbol: string; + fromDecimals: number; + toDecimals: number; + sharesDecimals: number; + fromWalletBalance: string; + toWalletBalance: string; + fromPoolBalance: string; + toPoolBalance: string; + walletSharesBalance: string; +}; + +const getRequisiteData = async ( + migrator: Migrator, + signer: Signer, + signerAddress: string +): Promise => { + try { + const poolAddress = migrator.address; + const fromAddress = await migrator.fromAsset(); + const toAddress = await migrator.toAsset(); + + const fromToken = TokenImplementation__factory.connect(fromAddress, signer); + const toToken = TokenImplementation__factory.connect(toAddress, signer); + + const fromSymbol = await fromToken.symbol(); + const toSymbol = await toToken.symbol(); + + const fromDecimals = await (await migrator.fromDecimals()).toNumber(); + const toDecimals = await (await migrator.toDecimals()).toNumber(); + const sharesDecimals = await migrator.decimals(); + + const fromWalletBalance = formatUnits( + await fromToken.balanceOf(signerAddress), + fromDecimals + ); + const toWalletBalance = formatUnits( + await toToken.balanceOf(signerAddress), + toDecimals + ); + + const fromPoolBalance = formatUnits( + await fromToken.balanceOf(poolAddress), + fromDecimals + ); + const toPoolBalance = formatUnits( + await toToken.balanceOf(poolAddress), + toDecimals + ); + + const walletSharesBalance = formatUnits( + await migrator.balanceOf(signerAddress), + sharesDecimals + ); + + return { + poolAddress, + fromAddress, + toAddress, + fromToken, + toToken, + migrator, + fromSymbol, + toSymbol, + fromDecimals, + toDecimals, + fromWalletBalance, + toWalletBalance, + fromPoolBalance, + toPoolBalance, + walletSharesBalance, + sharesDecimals, + }; + } catch (e) { + return Promise.reject("Failed to retrieve required data."); + } +}; + +function useEthereumMigratorInformation( + migratorAddress: string | undefined, + signer: Signer | undefined, + signerAddress: string | undefined, + toggleRefresh: boolean +): EthMigrationInfo { + const migrator = useMemo( + () => + migratorAddress && + signer && + Migrator__factory.connect(migratorAddress, signer), + [migratorAddress, signer] + ); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!signer || !migrator || !signerAddress) { + return; + } + let cancelled = false; + setIsLoading(true); + getRequisiteData(migrator, signer, signerAddress).then( + (result) => { + if (!cancelled) { + setData(result); + setIsLoading(false); + } + }, + (error) => { + if (!cancelled) { + setIsLoading(false); + setError("Failed to retrieve necessary data."); + } + } + ); + + return () => { + cancelled = true; + return; + }; + }, [migrator, signer, signerAddress, toggleRefresh]); + + return useMemo(() => { + if (!migratorAddress || !signer || !signerAddress) { + return { + isLoading: false, + error: + !signer || !signerAddress + ? "Wallet not connected" + : !migratorAddress + ? "No contract address" + : "Error", + data: null, + }; + } else { + return { + isLoading, + error, + data, + }; + } + }, [isLoading, error, data, migratorAddress, signer, signerAddress]); +} + +export default useEthereumMigratorInformation; diff --git a/bridge_ui/src/utils/consts.ts b/bridge_ui/src/utils/consts.ts index 24cc51c3..0a2291c5 100644 --- a/bridge_ui/src/utils/consts.ts +++ b/bridge_ui/src/utils/consts.ts @@ -336,3 +336,16 @@ export const MIGRATION_ASSET_MAP = new Map( // ], ] ); + +export const ETH_MIGRATION_ASSET_MAP = new Map( + CLUSTER === "mainnet" + ? [] + : CLUSTER === "testnet" + ? [] + : [ + // [ + // "0x2D8BE6BF0baA74e0A907016679CaE9190e80dD0A", + // "0xFcCeD5E997E7fb1D0594518D3eD57245bB8ed17E", + // ], + ] +);