From c8aee80b1deec7e676c684592fe28042fb3d5601 Mon Sep 17 00:00:00 2001 From: Chase Moran Date: Tue, 14 Sep 2021 14:47:25 -0400 Subject: [PATCH] bridge_ui: migration view Change-Id: I1c9f564dbdb77ae71e63934b55f395f7410869f6 --- bridge_ui/src/App.js | 4 + .../src/components/Migration/Workflow.tsx | 472 ++++++++++++++++++ bridge_ui/src/components/Migration/index.tsx | 32 ++ bridge_ui/src/components/ShowTx.tsx | 2 +- .../SolanaCreateAssociatedAddress.tsx | 2 +- bridge_ui/src/hooks/useMetaplexData.ts | 18 +- bridge_ui/src/utils/consts.ts | 14 + lp_ui/src/utils/consts.ts | 2 +- 8 files changed, 536 insertions(+), 10 deletions(-) create mode 100644 bridge_ui/src/components/Migration/Workflow.tsx create mode 100644 bridge_ui/src/components/Migration/index.tsx diff --git a/bridge_ui/src/App.js b/bridge_ui/src/App.js index 91adf6684..abf9198fb 100644 --- a/bridge_ui/src/App.js +++ b/bridge_ui/src/App.js @@ -20,6 +20,7 @@ import Attest from "./components/Attest"; import Home from "./components/Home"; import NFT from "./components/NFT"; import Transfer from "./components/Transfer"; +import Migration from "./components/Migration"; import wormholeLogo from "./icons/wormhole.svg"; import { ENABLE_NFT } from "./utils/consts"; @@ -160,6 +161,9 @@ function App() { + + + diff --git a/bridge_ui/src/components/Migration/Workflow.tsx b/bridge_ui/src/components/Migration/Workflow.tsx new file mode 100644 index 000000000..686d5ac1a --- /dev/null +++ b/bridge_ui/src/components/Migration/Workflow.tsx @@ -0,0 +1,472 @@ +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 { + ASSOCIATED_TOKEN_PROGRAM_ID, + Token, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import { Connection, PublicKey } from "@solana/web3.js"; +import { parseUnits } from "ethers/lib/utils"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSolanaWallet } from "../../contexts/SolanaWalletContext"; +import useIsWalletReady from "../../hooks/useIsWalletReady"; +import useMetaplexData from "../../hooks/useMetaplexData"; +import useSolanaTokenMap from "../../hooks/useSolanaTokenMap"; +import { MIGRATION_PROGRAM_ADDRESS, SOLANA_HOST } from "../../utils/consts"; +import { + getMultipleAccounts, + shortenAddress, + signSendAndConfirm, +} from "../../utils/solana"; +import ButtonWithLoader from "../ButtonWithLoader"; +import ShowTx from "../ShowTx"; +import SolanaCreateAssociatedAddress, { + useAssociatedAccountExistsState, +} from "../SolanaCreateAssociatedAddress"; +import SolanaWalletKey from "../SolanaWalletKey"; + +const useStyles = makeStyles(() => ({ + mainPaper: { + textAlign: "center", + padding: "2rem", + "& > h, p ": { + margin: "1rem", + }, + }, + divider: { + margin: "2rem 0rem 2rem 0rem", + }, + spacer: { + height: "2rem", + }, +})); + +//TODO move to utils/solana +const getDecimals = async ( + connection: Connection, + mint: string, + setter: (decimals: number | undefined) => void +) => { + setter(undefined); + if (mint) { + try { + const pk = new PublicKey(mint); + const info = await connection.getParsedAccountInfo(pk); + // @ts-ignore + const decimals = info.value?.data.parsed.info.decimals; + setter(decimals); + } catch (e) { + console.log(`Unable to determine decimals of ${mint}`); + } + } +}; + +//TODO move to utils/solana +const getBalance = async ( + connection: Connection, + address: string | undefined, + setter: (balance: string | undefined) => void +) => { + setter(undefined); + if (address) { + try { + const pk = new PublicKey(address); + const info = await connection.getParsedAccountInfo(pk); + // @ts-ignore + const balance = info.value?.data.parsed.info.tokenAmount.uiAmountString; + console.log(`${address} has a balance of ${balance}`); + setter(balance); + } catch (e) { + console.log(`Unable to determine balance of ${address}`); + } + } +}; + +export default function Workflow({ + fromMint, + toMint, +}: { + fromMint: string; + toMint: string; +}) { + const classes = useStyles(); + + const connection = useMemo( + () => new Connection(SOLANA_HOST, "confirmed"), + [] + ); //TODO confirmed or finalized? + const wallet = useSolanaWallet(); + const { isReady } = useIsWalletReady(CHAIN_ID_SOLANA); + const solanaTokenMap = useSolanaTokenMap(); + const metaplexArray = useMemo(() => [fromMint, toMint], [fromMint, toMint]); + const metaplexData = useMetaplexData(metaplexArray); + + const [poolAddress, setPoolAddress] = useState(""); + const [poolExists, setPoolExists] = useState(undefined); + const [fromTokenAccount, setFromTokenAccount] = useState( + undefined + ); + const [fromTokenAccountBalance, setFromTokenAccountBalance] = useState< + string | undefined + >(undefined); + const [toTokenAccount, setToTokenAccount] = useState( + undefined + ); + const [toTokenAccountBalance, setToTokenAccountBalance] = useState< + string | undefined + >(undefined); + const [fromMintDecimals, setFromMintDecimals] = useState( + undefined + ); + + const { + associatedAccountExists: fromTokenAccountExists, + //setAssociatedAccountExists: setFromTokenAccountExists, + } = useAssociatedAccountExistsState( + CHAIN_ID_SOLANA, + fromMint, + fromTokenAccount + ); + const { + associatedAccountExists: toTokenAccountExists, + setAssociatedAccountExists: setToTokenAccountExists, + } = useAssociatedAccountExistsState(CHAIN_ID_SOLANA, toMint, toTokenAccount); + + const [toCustodyAddress, setToCustodyAddress] = useState( + undefined + ); + const [toCustodyBalance, setToCustodyBalance] = useState( + undefined + ); + + const [migrationAmount, setMigrationAmount] = useState(""); + const [migrationIsProcessing, setMigrationIsProcessing] = useState(false); + const [error, setError] = useState(""); + const [transaction, setTransaction] = useState(null); + + /* Effects + */ + useEffect(() => { + getDecimals(connection, fromMint, setFromMintDecimals); + }, [connection, fromMint]); + + //Retrieve user balance when fromTokenAccount changes + useEffect(() => { + // TODO: cancellable + if (fromTokenAccount && fromTokenAccountExists) { + getBalance(connection, fromTokenAccount, setFromTokenAccountBalance); + } else { + setFromTokenAccountBalance(undefined); + } + }, [ + connection, + fromTokenAccountExists, + fromTokenAccount, + setFromTokenAccountBalance, + ]); + + useEffect(() => { + // TODO: cancellable + if (toTokenAccount && toTokenAccountExists) { + getBalance(connection, toTokenAccount, setToTokenAccountBalance); + } else { + setToTokenAccountBalance(undefined); + } + }, [ + connection, + toTokenAccountExists, + toTokenAccount, + setFromTokenAccountBalance, + ]); + + useEffect(() => { + // TODO: cancellable + if (toCustodyAddress) { + getBalance(connection, toCustodyAddress, setToCustodyBalance); + } else { + setToCustodyAddress(undefined); + } + }, [connection, toCustodyAddress, setToCustodyBalance]); + + //Retrieve pool address on selectedTokens change + useEffect(() => { + if (toMint && fromMint) { + setPoolAddress(""); + setPoolExists(undefined); + getPoolAddress(MIGRATION_PROGRAM_ADDRESS, fromMint, toMint).then( + (result) => { + const key = new PublicKey(result).toString(); + setPoolAddress(key); + }, + (error) => console.log("Could not calculate pool address.") + ); + } + }, [toMint, fromMint, setPoolAddress]); + + //Retrieve the poolAccount every time the pool address changes. + useEffect(() => { + if (poolAddress) { + setPoolExists(undefined); + try { + getMultipleAccounts( + connection, + [new PublicKey(poolAddress)], + "confirmed" + ).then((result) => { + if (result.length && result[0] !== null) { + setPoolExists(true); + } else if (result.length && result[0] === null) { + setPoolExists(false); + setError("There is no swap pool for this token."); + } else { + setError( + "unexpected error in fetching pool address. Please reload and try again" + ); + } + }); + } catch (e) { + setError("Could not fetch pool address"); + } + } + }, [connection, poolAddress]); + + //Set relevant information derived from poolAddress + useEffect(() => { + getToCustodyAddress(MIGRATION_PROGRAM_ADDRESS, poolAddress).then( + (result: any) => setToCustodyAddress(new PublicKey(result).toString()) + ); + }, [poolAddress]); + + //Set the associated token accounts when the designated mint changes + useEffect(() => { + if (wallet?.publicKey && fromMint) { + Token.getAssociatedTokenAddress( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + new PublicKey(fromMint), + wallet?.publicKey || new PublicKey([]) + ).then( + (result) => { + setFromTokenAccount(result.toString()); + }, + (error) => {} + ); + } + }, [fromMint, wallet?.publicKey]); + + useEffect(() => { + if (wallet?.publicKey && toMint) { + Token.getAssociatedTokenAddress( + ASSOCIATED_TOKEN_PROGRAM_ID, + TOKEN_PROGRAM_ID, + new PublicKey(toMint), + wallet?.publicKey || new PublicKey([]) + ).then( + (result) => { + setToTokenAccount(result.toString()); + }, + (error) => {} + ); + } + }, [toMint, wallet?.publicKey]); + /* + End effects + */ + + const migrateTokens = useCallback(async () => { + try { + setError(""); + const instruction = await migrateTokensTx( + connection, + wallet?.publicKey?.toString() || "", + MIGRATION_PROGRAM_ADDRESS, + fromMint, + toMint, + fromTokenAccount || "", + toTokenAccount || "", + parseUnits(migrationAmount, fromMintDecimals).toBigInt() + ); + setMigrationIsProcessing(true); + signSendAndConfirm(wallet, connection, instruction).then( + (transaction: any) => { + setMigrationIsProcessing(false); + setTransaction(transaction); + }, + (error) => { + console.log(error); + setError("Could not complete the migrateTokens transaction."); + setMigrationIsProcessing(false); + } + ); + } catch (e) { + console.log(e); + setError("Could not complete the migrateTokens transaction."); + setMigrationIsProcessing(false); + } + }, [ + connection, + fromMint, + fromTokenAccount, + migrationAmount, + toMint, + toTokenAccount, + wallet, + fromMintDecimals, + ]); + + const fromParse = (amount: string) => { + return parseUnits(amount, fromMintDecimals).toBigInt(); + }; + + const hasRequisiteData = fromMint && toMint && poolAddress && poolExists; + const accountsReady = + fromTokenAccountExists && toTokenAccountExists && poolExists; + const sufficientBalances = + toCustodyBalance && + fromTokenAccountBalance && + migrationAmount && + fromParse(migrationAmount) <= fromParse(fromTokenAccountBalance) && + parseFloat(migrationAmount) <= parseFloat(toCustodyBalance); + + console.log("rendered"); + + const isReadyToTransfer = + isReady && sufficientBalances && accountsReady && hasRequisiteData; + + const getNotReadyCause = () => { + if (!fromMint || !toMint || !poolAddress || !poolExists) { + return "This asset is not supported."; + } else if (!isReady) { + return "Wallet is not connected."; + } else if (!toTokenAccountExists || !fromTokenAccountExists) { + return "You have not created the necessary token accounts."; + } else if (!migrationAmount) { + return "Enter an amount to transfer."; + } else if (!sufficientBalances) { + return "There are not sufficient funds for this transfer."; + } else { + return ""; + } + }; + + const handleAmountChange = useCallback( + (event) => setMigrationAmount(event.target.value), + [setMigrationAmount] + ); + + const getMetadata = (address: string) => { + const tokenMapItem = solanaTokenMap.data?.find( + (x) => x.address === address + ); + const metaplexItem = metaplexData.data?.get(address); + + return { + symbol: tokenMapItem?.symbol || metaplexItem?.data?.symbol || undefined, + name: tokenMapItem?.name || metaplexItem?.data?.name || undefined, + logo: tokenMapItem?.logoURI || metaplexItem?.data?.uri || undefined, + }; + }; + + const toMetadata = getMetadata(toMint); + const fromMetadata = getMetadata(fromMint); + + const toMintPrettyString = toMetadata.symbol + ? toMetadata.symbol + " (" + shortenAddress(toMint) + ")" + : shortenAddress(toMint); + const fromMintPrettyString = fromMetadata.symbol + ? fromMetadata.symbol + " (" + shortenAddress(fromMint) + ")" + : shortenAddress(fromMint); + + return ( + + + Migrate Legacy Assets + + Convert assets from legacy bridges to Wormhole V2 tokens + + + + + {fromTokenAccount && toTokenAccount && fromTokenAccountBalance ? ( + <> + + This will migrate {fromMintPrettyString} tokens in this account: + + + {shortenAddress(fromTokenAccount) + + ` (Balance: ${fromTokenAccountBalance}${ + fromMetadata.symbol && " " + fromMetadata.symbol + })`} + +
+ + into {toMintPrettyString} tokens in this account: + + + {shortenAddress(toTokenAccount) + + (toTokenAccountExists + ? ` (Balance: ${toTokenAccountBalance}${ + (toMetadata.symbol && " " + toMetadata.symbol) || "" + })` + : " (Not created yet)")} + + + + ) : 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 new file mode 100644 index 000000000..5511de998 --- /dev/null +++ b/bridge_ui/src/components/Migration/index.tsx @@ -0,0 +1,32 @@ +import { 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 { withRouter } from "react-router"; + +interface RouteParams { + legacyAsset: string; +} + +interface Migration extends RouteComponentProps {} + +const MigrationRoot: React.FC = (props) => { + const legacyAsset: string = props.match.params.legacyAsset; + const targetAsset: string | undefined = MIGRATION_ASSET_MAP.get(legacyAsset); + + let fromMint: string | undefined = ""; + let toMint: string | undefined = ""; + try { + fromMint = legacyAsset && new PublicKey(legacyAsset).toString(); + toMint = targetAsset && new PublicKey(targetAsset).toString(); + } catch (e) {} + + if (fromMint && toMint) { + return ; + } else { + return This asset is not eligible for migration.; + } +}; + +export default withRouter(MigrationRoot); diff --git a/bridge_ui/src/components/ShowTx.tsx b/bridge_ui/src/components/ShowTx.tsx index e0a919b6d..d1ad3cb56 100644 --- a/bridge_ui/src/components/ShowTx.tsx +++ b/bridge_ui/src/components/ShowTx.tsx @@ -40,7 +40,7 @@ export default function ShowTx({ return (
- + {tx.id} {showExplorerLink && explorerAddress ? ( diff --git a/bridge_ui/src/components/SolanaCreateAssociatedAddress.tsx b/bridge_ui/src/components/SolanaCreateAssociatedAddress.tsx index dcd17a21d..eead85112 100644 --- a/bridge_ui/src/components/SolanaCreateAssociatedAddress.tsx +++ b/bridge_ui/src/components/SolanaCreateAssociatedAddress.tsx @@ -15,7 +15,7 @@ import ButtonWithLoader from "./ButtonWithLoader"; export function useAssociatedAccountExistsState( targetChain: ChainId, mintAddress: string | null | undefined, - readableTargetAddress: string + readableTargetAddress: string | undefined ) { const [associatedAccountExists, setAssociatedAccountExists] = useState(true); // for now, assume it exists until we confirm it doesn't const solanaWallet = useSolanaWallet(); diff --git a/bridge_ui/src/hooks/useMetaplexData.ts b/bridge_ui/src/hooks/useMetaplexData.ts index 9d41f255d..f72103699 100644 --- a/bridge_ui/src/hooks/useMetaplexData.ts +++ b/bridge_ui/src/hooks/useMetaplexData.ts @@ -1,5 +1,5 @@ import { Connection } from "@solana/web3.js"; -import { useLayoutEffect, useState } from "react"; +import { useLayoutEffect, useMemo, useState } from "react"; import { DataWrapper } from "../store/helpers"; import { SOLANA_HOST } from "../utils/consts"; import { @@ -97,12 +97,16 @@ const useMetaplexData = ( }; }, [addresses, setResults, setIsLoading, setError]); - return { - data: results, - isFetching: isLoading, - error, - receivedAt, - }; + const output = useMemo( + () => ({ + data: results, + isFetching: isLoading, + error, + receivedAt, + }), + [results, isLoading, error, receivedAt] + ); + return output; }; export default useMetaplexData; diff --git a/bridge_ui/src/utils/consts.ts b/bridge_ui/src/utils/consts.ts index 5006de8aa..c373eee93 100644 --- a/bridge_ui/src/utils/consts.ts +++ b/bridge_ui/src/utils/consts.ts @@ -210,3 +210,17 @@ export const ETH_TOKENS_THAT_EXIST_ELSEWHERE = [ getAddress("0x1c5db575e2ff833e46a2e9864c22f4b22e0b37c2"), // renZEC getAddress("0xD5147bc8e386d91Cc5DBE72099DAC6C9b99276F5"), // renFIL ]; + +export const MIGRATION_PROGRAM_ADDRESS = + process.env.REACT_APP_CLUSTER === "mainnet" + ? "whmRZnmyxdr2TkHXcZoFdtvNYRLQ5Jtbkf6ZbGkJjdk" + : process.env.REACT_APP_CLUSTER === "testnet" + ? "" + : "Ex9bCdVMSfx7EzB3pgSi2R4UHwJAXvTw18rBQm5YQ8gK"; + +export const MIGRATION_ASSET_MAP = new Map([ + [ + "2WDq7wSs9zYrpx2kbHDA4RUTRch2CCTP6ZWaH4GNfnQQ", + "ApgUoB1467PXXofoLWFELH2Kz9DKB8WXdU2szGSsFKhX", + ], +]); diff --git a/lp_ui/src/utils/consts.ts b/lp_ui/src/utils/consts.ts index ff13e7ea3..4e89525f9 100644 --- a/lp_ui/src/utils/consts.ts +++ b/lp_ui/src/utils/consts.ts @@ -2,7 +2,7 @@ import { clusterApiUrl } from "@solana/web3.js"; export const MIGRATION_PROGRAM_ADDRESS = process.env.REACT_APP_CLUSTER === "mainnet" - ? "" + ? "whmRZnmyxdr2TkHXcZoFdtvNYRLQ5Jtbkf6ZbGkJjdk" : process.env.REACT_APP_CLUSTER === "testnet" ? "" : "Ex9bCdVMSfx7EzB3pgSi2R4UHwJAXvTw18rBQm5YQ8gK";