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