Преглед изворни кода

bridge_ui: ethereum migration functions

Change-Id: I39d12adcdfd5036283572f006a1442a26a3fc143
Chase Moran пре 4 година
родитељ
комит
7f83984410

+ 10 - 2
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() {
           <Route exact path="/register">
             <Attest />
           </Route>
-          <Route exact path="/migrate/:legacyAsset/:fromTokenAccount">
-            <Migration />
+          <Route exact path="/migrate/Solana/:legacyAsset/:fromTokenAccount">
+            <Migration chainId={CHAIN_ID_SOLANA} />
+          </Route>
+          <Route exact path="/migrate/Ethereum/:legacyAsset/">
+            <Migration chainId={CHAIN_ID_ETH} />
+          </Route>
+          <Route exact path="/migrate/Ethereum/">
+            <EthereumQuickMigrate />
           </Route>
           <Route exact path="/">
             <Home />

+ 366 - 0
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 (
+      <div className={classes.lineItem}>
+        <div>
+          <Typography variant="body2" color="textSecondary">
+            Successfully migrated your tokens. They will become available once
+            this transaction confirms.
+          </Typography>
+          <ShowTx chainId={CHAIN_ID_ETH} tx={{ id: transaction, block: 1 }} />
+        </div>
+      </div>
+    );
+  } else {
+    return (
+      <div className={classes.lineItem}>
+        <div>
+          <Typography variant="body2" color="textSecondary">
+            Current Token
+          </Typography>
+          <Typography className={classes.balance}>
+            {poolInfo.data.fromWalletBalance}
+          </Typography>
+          <SmartAddress
+            chainId={CHAIN_ID_ETH}
+            address={poolInfo.data.fromAddress}
+          />
+        </div>
+        <div>
+          <Typography variant="body2" color="textSecondary">
+            will become
+          </Typography>
+          <ArrowRightAltIcon fontSize="large" />
+        </div>
+        <div>
+          <Typography variant="body2" color="textSecondary">
+            Wormhole Token
+          </Typography>
+          <Typography className={classes.balance}>
+            {poolInfo.data.fromWalletBalance}
+          </Typography>
+          <SmartAddress
+            chainId={CHAIN_ID_ETH}
+            address={poolInfo.data.toAddress}
+          />
+        </div>
+        <div className={classes.convertButton}>
+          <ButtonWithLoader
+            showLoader={migrationIsProcessing}
+            onClick={migrateTokens}
+            error={
+              error
+                ? error
+                : !sufficientPoolBalance
+                ? "The swap pool has insufficient funds."
+                : ""
+            }
+            disabled={!sufficientPoolBalance}
+          >
+            Convert
+          </ButtonWithLoader>
+        </div>
+      </div>
+    );
+  }
+}
+
+const getAddressBalances = async (
+  signer: Signer,
+  signerAddress: string,
+  addresses: string[]
+): Promise<Map<string, BigNumber | null>> => {
+  try {
+    const promises: Promise<any>[] = [];
+    const output = new Map<string, BigNumber | null>();
+    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<string[] | 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 && 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 = (
+    <div className={classes.containerDiv}>
+      <Typography variant="h5">
+        This page allows you to convert certain wrapped tokens on Ethereum into
+        Wormhole V2 tokens.
+      </Typography>
+      <EthereumSignerKey />
+      {!isReady ? (
+        <Typography variant="body1">Please connect your wallet.</Typography>
+      ) : migratorsError ? (
+        <Typography variant="h6">{migratorsError}</Typography>
+      ) : (
+        <>
+          <div className={classes.spacer} />
+          <CircularProgress className={isLoading ? "" : classes.hidden} />
+          <div className={!isLoading ? "" : classes.hidden}>
+            <Typography>
+              {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."}
+            </Typography>
+            <div className={classes.spacer} />
+            {migrators?.map((address) => {
+              return (
+                <EthereumMigrationLineItem
+                  key={address}
+                  migratorAddress={address}
+                  onLoadComplete={reportLoadComplete}
+                />
+              );
+            })}
+          </div>
+        </>
+      )}
+    </div>
+  );
+
+  return (
+    <Container maxWidth="md">
+      <Paper className={classes.mainPaper}>{content}</Paper>
+    </Container>
+  );
+}

+ 233 - 0
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<string | null>(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 = (
+    <SmartAddress
+      chainId={CHAIN_ID_ETH}
+      address={poolInfo.data?.toAddress}
+      symbol={poolInfo.data?.toSymbol}
+    />
+  );
+  const fromTokenPretty = (
+    <SmartAddress
+      chainId={CHAIN_ID_ETH}
+      address={poolInfo.data?.fromAddress}
+      symbol={poolInfo.data?.fromSymbol}
+    />
+  );
+  const poolPretty = (
+    <SmartAddress chainId={CHAIN_ID_ETH} address={poolInfo.data?.poolAddress} />
+  );
+
+  const fatalError = poolInfo.error
+    ? "Unable to retrieve necessary information. This asset may not be supported."
+    : null;
+
+  const explainerContent = (
+    <div>
+      <Typography>This action will convert</Typography>
+      <Typography variant="h6">
+        {fromTokenPretty}{" "}
+        {`(Balance: ${poolInfo.data?.fromWalletBalance || ""})`}
+      </Typography>
+      <div className={classes.spacer} />
+      <Typography>to</Typography>
+      <Typography variant="h6">
+        {toTokenPretty} {`(Balance: ${poolInfo.data?.toWalletBalance || ""})`}
+      </Typography>
+      <div className={classes.spacer} />
+      <Typography>Utilizing this pool</Typography>
+      <Typography variant="h6">
+        {poolPretty} {`(Balance: ${poolInfo.data?.toPoolBalance || ""})`}
+      </Typography>
+    </div>
+  );
+
+  const mainWorkflow = (
+    <>
+      {explainerContent}
+      <div className={classes.spacer} />
+      <TextField
+        value={migrationAmount}
+        type="number"
+        onChange={handleAmountChange}
+        label={"Amount"}
+        disabled={!!migrationIsProcessing || !!transaction}
+      ></TextField>
+
+      {!transaction && (
+        <ButtonWithLoader
+          disabled={!isReadyToTransfer || migrationIsProcessing}
+          showLoader={migrationIsProcessing}
+          onClick={migrateTokens}
+        >
+          {migrationAmount && isReadyToTransfer
+            ? "Migrate " + migrationAmount + " Tokens"
+            : "Migrate"}
+        </ButtonWithLoader>
+      )}
+
+      {(error || !isReadyToTransfer) && (
+        <Typography color="error">{error || getNotReadyCause()}</Typography>
+      )}
+      {transaction ? (
+        <>
+          <Typography>
+            Successfully migrated your tokens! They will be available once this
+            transaction confirms.
+          </Typography>
+          <ShowTx tx={{ id: transaction, block: 1 }} chainId={CHAIN_ID_ETH} />
+        </>
+      ) : null}
+    </>
+  );
+
+  return (
+    <div className={classes.containerDiv}>
+      <EthereumSignerKey />
+      {!isReady ? (
+        <Typography variant="body1">Please connect your wallet.</Typography>
+      ) : poolInfo.isLoading ? (
+        <CircularProgress />
+      ) : fatalError ? (
+        <Typography variant="h6">{fatalError}</Typography>
+      ) : (
+        mainWorkflow
+      )}
+    </div>
+  );
+}

+ 100 - 120
bridge_ui/src/components/Migration/Workflow.tsx → 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 (
-    <Container maxWidth="md">
-      <Paper className={classes.mainPaper}>
-        <Typography variant="h5">Migrate Legacy Assets</Typography>
-        <Typography variant="subtitle2">
-          Convert assets from legacy bridges to Wormhole V2 tokens
-        </Typography>
-        <Divider className={classes.divider} />
-        <SolanaWalletKey />
-        <div className={classes.spacer} />
-        {fromTokenAccount && toTokenAccount ? (
-          <>
-            <Typography variant="body2" component="div">
-              <span>This will migrate</span>
-              {fromMintPretty}
-              <span>tokens in this account:</span>
-            </Typography>
-            <Typography variant="h5">
-              <SmartAddress
-                address={fromTokenAccount}
-                chainId={CHAIN_ID_SOLANA}
-              />
-              {`(Balance: ${fromTokenAccountBalance}${
-                fromMetadata.symbol && " " + fromMetadata.symbol
-              })`}
-            </Typography>
-            <div className={classes.spacer} />
-            <Typography variant="body2" component="div">
-              <span>into </span>
-              {toMintPretty}
-              <span> tokens in this account:</span>
-            </Typography>
-            <Typography
-              variant="h5"
-              color={toTokenAccountExists ? "textPrimary" : "textSecondary"}
-            >
-              <SmartAddress
-                address={toTokenAccount}
-                chainId={CHAIN_ID_SOLANA}
-              />
-              <span>
-                {toTokenAccountExists
-                  ? ` (Balance: ${toTokenAccountBalance}${
-                      (toMetadata.symbol && " " + toMetadata.symbol) || ""
-                    })`
-                  : " (Not created yet)"}
-              </span>
-            </Typography>
-            <SolanaCreateAssociatedAddress
-              mintAddress={toMint}
-              readableTargetAddress={toTokenAccount}
-              associatedAccountExists={toTokenAccountExists}
-              setAssociatedAccountExists={setToTokenAccountExists}
-            />
-            {poolAddress && toCustodyAddress && toCustodyBalance ? (
-              <>
-                <div className={classes.spacer} />
-                <Typography variant="body2" component="div">
-                  <span>Using pool </span>
-                  <SmartAddress
-                    address={poolAddress}
-                    chainId={CHAIN_ID_SOLANA}
-                  />
-                  <span> holding tokens in this account:</span>
-                </Typography>
-                <Typography variant="h5">
-                  <SmartAddress
-                    address={toCustodyAddress}
-                    chainId={CHAIN_ID_SOLANA}
-                  />
-                  <span>{` (Balance: ${toCustodyBalance}${
-                    toMetadata.symbol && " " + toMetadata.symbol
-                  })`}</span>
-                </Typography>
-              </>
-            ) : null}
-          </>
-        ) : null}
-        <div className={classes.spacer} />
-        <TextField
-          value={migrationAmount}
-          type="number"
-          onChange={handleAmountChange}
-          label={"Amount"}
-          disabled={!!migrationIsProcessing || !!transaction}
-        ></TextField>
-
-        {!transaction && (
-          <ButtonWithLoader
-            disabled={!isReadyToTransfer || migrationIsProcessing}
-            showLoader={migrationIsProcessing}
-            onClick={migrateTokens}
-          >
-            {migrationAmount && isReadyToTransfer
-              ? "Migrate " + migrationAmount + " Tokens"
-              : "Migrate"}
-          </ButtonWithLoader>
-        )}
-        {(error || !isReadyToTransfer) && (
-          <Typography color="error">{error || getNotReadyCause()}</Typography>
-        )}
-        {transaction ? (
-          <>
-            <Typography>
-              Successfully migrated your tokens! They will be available once
-              this transaction confirms.
-            </Typography>
-            <ShowTx
-              tx={{ id: transaction, block: 1 }}
+    <div>
+      <SolanaWalletKey />
+      <div className={classes.spacer} />
+      {fromTokenAccount && toTokenAccount ? (
+        <>
+          <Typography variant="body2" component="div">
+            <span>This will migrate</span>
+            {fromMintPretty}
+            <span>tokens in this account:</span>
+          </Typography>
+          <Typography variant="h5">
+            <SmartAddress
+              address={fromTokenAccount}
               chainId={CHAIN_ID_SOLANA}
             />
-          </>
-        ) : null}
-      </Paper>
-    </Container>
+            {`(Balance: ${fromTokenAccountBalance}${
+              fromMetadata.symbol && " " + fromMetadata.symbol
+            })`}
+          </Typography>
+          <div className={classes.spacer} />
+          <Typography variant="body2" component="div">
+            <span>into </span>
+            {toMintPretty}
+            <span> tokens in this account:</span>
+          </Typography>
+          <Typography
+            variant="h5"
+            color={toTokenAccountExists ? "textPrimary" : "textSecondary"}
+          >
+            <SmartAddress address={toTokenAccount} chainId={CHAIN_ID_SOLANA} />
+            <span>
+              {toTokenAccountExists
+                ? ` (Balance: ${toTokenAccountBalance}${
+                    (toMetadata.symbol && " " + toMetadata.symbol) || ""
+                  })`
+                : " (Not created yet)"}
+            </span>
+          </Typography>
+          <SolanaCreateAssociatedAddress
+            mintAddress={toMint}
+            readableTargetAddress={toTokenAccount}
+            associatedAccountExists={toTokenAccountExists}
+            setAssociatedAccountExists={setToTokenAccountExists}
+          />
+          {poolAddress && toCustodyAddress && toCustodyBalance ? (
+            <>
+              <div className={classes.spacer} />
+              <Typography variant="body2" component="div">
+                <span>Using pool </span>
+                <SmartAddress address={poolAddress} chainId={CHAIN_ID_SOLANA} />
+                <span> holding tokens in this account:</span>
+              </Typography>
+              <Typography variant="h5">
+                <SmartAddress
+                  address={toCustodyAddress}
+                  chainId={CHAIN_ID_SOLANA}
+                />
+                <span>{` (Balance: ${toCustodyBalance}${
+                  toMetadata.symbol && " " + toMetadata.symbol
+                })`}</span>
+              </Typography>
+            </>
+          ) : null}
+        </>
+      ) : null}
+      <div className={classes.spacer} />
+      <TextField
+        value={migrationAmount}
+        type="number"
+        onChange={handleAmountChange}
+        label={"Amount"}
+        disabled={!!migrationIsProcessing || !!transaction}
+      ></TextField>
+
+      {!transaction && (
+        <ButtonWithLoader
+          disabled={!isReadyToTransfer || migrationIsProcessing}
+          showLoader={migrationIsProcessing}
+          onClick={migrateTokens}
+        >
+          {migrationAmount && isReadyToTransfer
+            ? "Migrate " + migrationAmount + " Tokens"
+            : "Migrate"}
+        </ButtonWithLoader>
+      )}
+      {(error || !isReadyToTransfer) && (
+        <Typography color="error">{error || getNotReadyCause()}</Typography>
+      )}
+      {transaction ? (
+        <>
+          <Typography>
+            Successfully migrated your tokens! They will be available once this
+            transaction confirms.
+          </Typography>
+          <ShowTx
+            tx={{ id: transaction, block: 1 }}
+            chainId={CHAIN_ID_SOLANA}
+          />
+        </>
+      ) : null}
+    </div>
   );
 }

+ 90 - 9
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<RouteParams> {}
+interface Migration extends RouteComponentProps<RouteParams> {
+  chainId: ChainId;
+}
 
-const MigrationRoot: React.FC<Migration> = (props) => {
+const SolanaRoot: React.FC<Migration> = (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<Migration> = (props) => {
       fromTokenAccount && new PublicKey(fromTokenAccount).toString();
   } catch (e) {}
 
+  let content = null;
+
   if (!fromMint || !toMint) {
-    return (
+    content = (
       <Typography style={{ textAlign: "center" }}>
         This asset is not eligible for migration.
       </Typography>
     );
   } else if (!fromTokenAcct) {
-    return (
+    content = (
       <Typography style={{ textAlign: "center" }}>
         Invalid token account.
       </Typography>
     );
   } else {
-    return (
-      <Workflow
+    content = (
+      <SolanaWorkflow
         fromMint={fromMint}
         toMint={toMint}
         fromTokenAccount={fromTokenAcct}
       />
     );
   }
+
+  return content;
+};
+
+const EthereumRoot: React.FC<Migration> = (props) => {
+  const legacyAsset: string = props.match.params.legacyAsset;
+  const targetPool = ETH_MIGRATION_ASSET_MAP.get(legacyAsset);
+
+  let content = null;
+  if (!legacyAsset || !targetPool) {
+    content = (
+      <Typography style={{ textAlign: "center" }}>
+        This asset is not eligible for migration.
+      </Typography>
+    );
+  } else {
+    content = <EthereumWorkflow migratorAddress={targetPool} />;
+  }
+
+  return content;
+};
+
+const MigrationRoot: React.FC<Migration> = (props) => {
+  const classes = useStyles();
+  let content = null;
+
+  if (props.chainId === CHAIN_ID_SOLANA) {
+    content = <SolanaRoot {...props} />;
+  } else if (props.chainId === CHAIN_ID_ETH) {
+    content = <EthereumRoot {...props} />;
+  }
+
+  return (
+    <Container maxWidth="md">
+      <Paper className={classes.mainPaper}>
+        <Typography variant="h5">Migrate Assets</Typography>
+        <Typography variant="subtitle2">
+          Convert assets from other bridges to Wormhole V2 tokens
+        </Typography>
+        <Divider className={classes.divider} />
+        {content}
+      </Paper>
+    </Container>
+  );
 };
 
 export default withRouter(MigrationRoot);

+ 29 - 3
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 = (
     <div className={classes.tokenOverviewContainer}>
       <div className={classes.tokenImageContainer}>
         {uri && <img alt="" className={classes.tokenImage} src={uri} />}
@@ -121,6 +134,19 @@ const renderAccount = (
       </div>
     </div>
   );
+
+  const migrationRender = (
+    <div className={classes.migrationAlert}>
+      <Alert severity="warning">
+        <Typography variant="body2">
+          This is a legacy asset eligible for migration.
+        </Typography>
+        <div>{content}</div>
+      </Alert>
+    </div>
+  );
+
+  return isMigrationEligible(account.mintKey) ? migrationRender : content;
 };
 
 const renderNFTAccount = (

+ 20 - 7
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));

+ 168 - 0
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<RequisiteData> => {
+  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<any | null>(null);
+  const [isLoading, setIsLoading] = useState<boolean>(false);
+  const [error, setError] = useState<string>("");
+
+  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;

+ 13 - 0
bridge_ui/src/utils/consts.ts

@@ -336,3 +336,16 @@ export const MIGRATION_ASSET_MAP = new Map<string, string>(
         // ],
       ]
 );
+
+export const ETH_MIGRATION_ASSET_MAP = new Map<string, string>(
+  CLUSTER === "mainnet"
+    ? []
+    : CLUSTER === "testnet"
+    ? []
+    : [
+        // [
+        //   "0x2D8BE6BF0baA74e0A907016679CaE9190e80dD0A",
+        //   "0xFcCeD5E997E7fb1D0594518D3eD57245bB8ed17E",
+        // ],
+      ]
+);