diff --git a/bridge_ui/src/App.js b/bridge_ui/src/App.js index d52552f8..e35f6a16 100644 --- a/bridge_ui/src/App.js +++ b/bridge_ui/src/App.js @@ -42,6 +42,7 @@ import { COLORS } from "./muiTheme"; import { CLUSTER } from "./utils/consts"; import Stats from "./components/Stats"; import TokenOriginVerifier from "./components/TokenOriginVerifier"; +import SolanaQuickMigrate from "./components/Migration/SolanaQuickMigrate"; const useStyles = makeStyles((theme) => ({ appBar: { @@ -292,6 +293,9 @@ function App() { + + + diff --git a/bridge_ui/src/components/Migration/SolanaQuickMigrate.tsx b/bridge_ui/src/components/Migration/SolanaQuickMigrate.tsx new file mode 100644 index 00000000..f3d62c1d --- /dev/null +++ b/bridge_ui/src/components/Migration/SolanaQuickMigrate.tsx @@ -0,0 +1,373 @@ +import { CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk"; +import { + CircularProgress, + Container, + makeStyles, + Paper, + Typography, +} from "@material-ui/core"; +import ArrowRightAltIcon from "@material-ui/icons/ArrowRightAlt"; +import { TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { + AccountInfo, + Connection, + ParsedAccountData, + PublicKey, +} from "@solana/web3.js"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import useIsWalletReady from "../../hooks/useIsWalletReady"; +import useSolanaMigratorInformation from "../../hooks/useSolanaMigratorInformation"; +import { COLORS } from "../../muiTheme"; +import { + CHAINS_BY_ID, + getMigrationAssetMap, + SOLANA_HOST, +} from "../../utils/consts"; +import ButtonWithLoader from "../ButtonWithLoader"; +import ShowTx from "../ShowTx"; +import SmartAddress from "../SmartAddress"; +import SolanaCreateAssociatedAddress from "../SolanaCreateAssociatedAddress"; +import SolanaWalletKey from "../SolanaWalletKey"; + +const useStyles = makeStyles((theme) => ({ + spacer: { + height: "2rem", + }, + containerDiv: { + textAlign: "center", + padding: theme.spacing(2), + }, + centered: { + textAlign: "center", + }, + 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", + }, +})); + +function SolanaMigrationLineItem({ + migratorInfo, + onLoadComplete, +}: { + migratorInfo: DefaultAssociatedTokenAccountInfo; + onLoadComplete: () => void; +}) { + const classes = useStyles(); + const poolInfo = useSolanaMigratorInformation( + migratorInfo.fromMintKey, + migratorInfo.toMintKey, + migratorInfo.defaultFromTokenAccount + ); + + const [migrationIsProcessing, setMigrationIsProcessing] = useState(false); + const [transaction, setTransaction] = useState(""); + const [migrationError, setMigrationError] = useState(""); + + const handleMigrateClick = useCallback(() => { + if (!poolInfo.data) { + return; + } + setMigrationIsProcessing(true); + setMigrationError(""); + poolInfo.data + .migrateTokens(poolInfo.data.fromAssociatedTokenAccountBalance) + .then((result) => { + setMigrationIsProcessing(false); + setTransaction(result); + }) + .catch((e) => { + setMigrationError("Unable to perform migration."); + setMigrationIsProcessing(false); + }); + }, [poolInfo.data]); + + const precheckError = + poolInfo.data && + poolInfo.data.getNotReadyCause( + poolInfo.data.fromAssociatedTokenAccountBalance + ); + + useEffect(() => { + if (poolInfo.data || poolInfo.error) { + onLoadComplete(); + } + }, [poolInfo, onLoadComplete]); + + if (!poolInfo.data) { + return ( +
+
+ + Failed to load migration information for token + + +
+
+ ); + } else if (transaction) { + return ( +
+
+ + Successfully migrated your tokens. They will become available once + this transaction confirms. + + +
+
+ ); + } else { + return ( +
+
+ + Current Token + + + {poolInfo.data.fromAssociatedTokenAccountBalance} + + +
+
+ + will become + + +
+
+ + Wormhole Token + + + {poolInfo.data.fromAssociatedTokenAccountBalance} + + +
+ {!poolInfo.data.toAssociatedTokenAccountExists ? ( +
+ +
+ ) : ( +
+ + Convert + +
+ )} +
+ ); + } +} + +type DefaultAssociatedTokenAccountInfo = { + fromMintKey: string; + toMintKey: string; + defaultFromTokenAccount: string; + fromAccountInfo: AccountInfo | null; +}; + +const getTokenBalances = async ( + walletAddress: string, + migrationMap: Map +): Promise => { + try { + const connection = new Connection(SOLANA_HOST); + const output: DefaultAssociatedTokenAccountInfo[] = []; + const tokenAccounts = await connection.getParsedTokenAccountsByOwner( + new PublicKey(walletAddress), + { programId: TOKEN_PROGRAM_ID }, + "confirmed" + ); + tokenAccounts.value.forEach((item) => { + if ( + item.account != null && + item.account.data?.parsed?.info?.tokenAmount?.uiAmountString && + item.account.data?.parsed.info?.tokenAmount?.amount !== "0" + ) { + const fromMintKey = item.account.data.parsed.info.mint; + const toMintKey = migrationMap.get(fromMintKey); + if (toMintKey) { + output.push({ + fromMintKey, + toMintKey: toMintKey, + defaultFromTokenAccount: item.pubkey.toString(), + fromAccountInfo: item.account, + }); + } + } + }); + + return output; + } catch (e) { + console.error(e); + return Promise.reject("Unable to retrieve token balances."); + } +}; + +export default function SolanaQuickMigrate() { + const chainId = CHAIN_ID_SOLANA; + const classes = useStyles(); + const { isReady, walletAddress } = useIsWalletReady(chainId); + const migrationMap = useMemo(() => getMigrationAssetMap(chainId), [chainId]); + const [migrators, setMigrators] = useState< + DefaultAssociatedTokenAccountInfo[] | null + >(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 && walletAddress) { + let cancelled = false; + setMigratorsLoading(true); + setMigratorsError(""); + getTokenBalances(walletAddress, migrationMap).then( + (result) => { + if (!cancelled) { + setMigratorsFinishedLoading(0); + setMigrators(result.filter((x) => x.fromAccountInfo && x)); + setMigratorsLoading(false); + } + }, + (error) => { + if (!cancelled) { + setMigratorsLoading(false); + setMigratorsError( + "Failed to retrieve available token information." + ); + } + } + ); + + return () => { + cancelled = true; + }; + } + }, [isReady, walletAddress, migrationMap]); + + const hasEligibleAssets = migrators && migrators.length > 0; + const chainName = CHAINS_BY_ID[chainId]?.name; + + const content = ( +
+ + {`This page allows you to convert certain wrapped tokens ${ + chainName ? "on " + chainName : "" + } 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((info) => { + return ( + + ); + })} +
+ + )} +
+ ); + + return ( + + {content} + + ); +} diff --git a/bridge_ui/src/hooks/useSolanaMigratorInformation.tsx b/bridge_ui/src/hooks/useSolanaMigratorInformation.tsx new file mode 100644 index 00000000..1c891877 --- /dev/null +++ b/bridge_ui/src/hooks/useSolanaMigratorInformation.tsx @@ -0,0 +1,430 @@ +import { CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk"; +import migrateTokensTx from "@certusone/wormhole-sdk/lib/esm/migration/migrateTokens"; +import getPoolAddress from "@certusone/wormhole-sdk/lib/esm/migration/poolAddress"; +import getToCustodyAddress from "@certusone/wormhole-sdk/lib/esm/migration/toCustodyAddress"; +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 { useAssociatedAccountExistsState } from "../components/SolanaCreateAssociatedAddress"; +import { useSolanaWallet } from "../contexts/SolanaWalletContext"; +import useIsWalletReady from "../hooks/useIsWalletReady"; +import useMetaplexData from "../hooks/useMetaplexData"; +import useSolanaTokenMap from "../hooks/useSolanaTokenMap"; +import { DataWrapper } from "../store/helpers"; +import { MIGRATION_PROGRAM_ADDRESS, SOLANA_HOST } from "../utils/consts"; +import { getMultipleAccounts, signSendAndConfirm } from "../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}`); + } + } +}; + +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; + setter(balance); + } catch (e) { + console.log(`Unable to determine balance of ${address}`); + } + } +}; + +//If the pool doesn't exist in this app, it's an error. +export type SolanaMigratorInformation = { + poolAddress: string; + fromMint: string; + toMint: string; + fromMintDecimals: number; + fromAssociatedTokenAccountExists: boolean; + toAssociatedTokenAccountExists: boolean; + setToTokenAccountExists: any; + fromAssociatedTokenAccount: string; + toAssociatedTokenAccount: string; + fromAssociatedTokenAccountBalance: string; + toAssociatedTokenAccountBalance: string | null; + toCustodyAddress: string; + toCustodyBalance: string; + + fromName: string | null; + fromSymbol: string | null; + fromLogo: string | null; + toName: string | null; + toSymbol: string | null; + toLogo: string | null; + + getNotReadyCause: (amount: string) => string | null; + + migrateTokens: (amount: string) => Promise; +}; + +//TODO refactor the workflow page to use this hook +export default function useSolanaMigratorInformation( + fromMint: string, + toMint: string, + fromTokenAccount: string +): DataWrapper { + const connection = useMemo( + () => new Connection(SOLANA_HOST, "confirmed"), + [] + ); + const wallet = useSolanaWallet(); + const { isReady } = useIsWalletReady(CHAIN_ID_SOLANA, false); + const solanaTokenMap = useSolanaTokenMap(); + const metaplexArray = useMemo(() => [fromMint, toMint], [fromMint, toMint]); + const metaplexData = useMetaplexData(metaplexArray); + + const [poolAddress, setPoolAddress] = useState(""); + const [poolExists, setPoolExists] = 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 [error, setError] = useState(""); + + /* 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 { + setToCustodyBalance(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(() => { + if (poolAddress) { + getToCustodyAddress(MIGRATION_PROGRAM_ADDRESS, poolAddress) + .then((result: any) => + setToCustodyAddress(new PublicKey(result).toString()) + ) + .catch((e) => { + setToCustodyAddress(undefined); + }); + } else { + setToCustodyAddress(undefined); + } + }, [poolAddress]); + + useEffect(() => { + if (wallet && 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]); + /* + End effects + */ + + const migrateTokens = useCallback( + async (amount) => { + const instruction = await migrateTokensTx( + connection, + wallet.publicKey?.toString() || "", + MIGRATION_PROGRAM_ADDRESS, + fromMint, + toMint, + fromTokenAccount || "", + toTokenAccount || "", + parseUnits(amount, fromMintDecimals).toBigInt() + ); + return await signSendAndConfirm(wallet, connection, instruction); + }, + [ + connection, + fromMint, + fromTokenAccount, + toMint, + toTokenAccount, + wallet, + fromMintDecimals, + ] + ); + + const fromParse = useCallback( + (amount: string) => { + try { + return parseUnits(amount, fromMintDecimals).toBigInt(); + } catch (e) { + return BigInt(0); + } + }, + [fromMintDecimals] + ); + + const getNotReadyCause = useCallback( + (amount: string) => { + const hasRequisiteData = fromMint && toMint && poolAddress && poolExists; + const accountsReady = fromTokenAccountExists && toTokenAccountExists; + const amountGreaterThanZero = fromParse(amount) > BigInt(0); + const sufficientFromTokens = + fromTokenAccountBalance && + amount && + fromParse(amount) <= fromParse(fromTokenAccountBalance); + const sufficientPoolBalance = + toCustodyBalance && + amount && + parseFloat(amount) <= parseFloat(toCustodyBalance); + + if (!hasRequisiteData) { + return "This asset is not supported."; + } else if (!isReady) { + return "Wallet is not connected."; + } else if (!accountsReady) { + return "You have not created the necessary token accounts."; + } else if (!amount) { + return "Enter an amount to transfer."; + } else if (!amountGreaterThanZero) { + return "Enter an amount 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 ""; + } + }, + [ + fromMint, + fromParse, + fromTokenAccountBalance, + fromTokenAccountExists, + isReady, + poolAddress, + poolExists, + toCustodyBalance, + toMint, + toTokenAccountExists, + ] + ); + + const getMetadata = useCallback( + (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, + }; + }, + [metaplexData.data, solanaTokenMap.data] + ); + + const isFetching = solanaTokenMap.isFetching || metaplexData.isFetching; //TODO add loading state on the actual Solana information + const hasRequisiteData = !!( + fromMintDecimals !== null && + fromMintDecimals !== undefined && + toTokenAccount && + fromTokenAccountBalance && + toCustodyAddress && + toCustodyBalance + ); + + const output: DataWrapper = useMemo(() => { + let data: SolanaMigratorInformation | null = null; + if (hasRequisiteData) { + data = { + poolAddress, + fromMint, + toMint, + fromMintDecimals, + fromAssociatedTokenAccountExists: fromTokenAccountExists, + toAssociatedTokenAccountExists: toTokenAccountExists, + fromAssociatedTokenAccount: fromTokenAccount, + toAssociatedTokenAccount: toTokenAccount, + fromAssociatedTokenAccountBalance: fromTokenAccountBalance, + toAssociatedTokenAccountBalance: toTokenAccountBalance || null, + toCustodyAddress, + toCustodyBalance, + + fromName: getMetadata(fromMint)?.name || null, + fromSymbol: getMetadata(fromMint)?.symbol || null, + fromLogo: getMetadata(fromMint)?.logo || null, + toName: getMetadata(toMint)?.name || null, + toSymbol: getMetadata(toMint)?.symbol || null, + toLogo: getMetadata(toMint)?.logo || null, + + setToTokenAccountExists, + + getNotReadyCause: getNotReadyCause, + + migrateTokens, + }; + } + + return { + isFetching: isFetching, + error: error || !hasRequisiteData, + receivedAt: null, + data, + }; + }, [ + error, + isFetching, + hasRequisiteData, + poolAddress, + fromMint, + toMint, + fromMintDecimals, + fromTokenAccountExists, + toTokenAccountExists, + fromTokenAccount, + toTokenAccount, + fromTokenAccountBalance, + toTokenAccountBalance, + toCustodyAddress, + toCustodyBalance, + getMetadata, + getNotReadyCause, + migrateTokens, + setToTokenAccountExists, + ]); + + return output; +}