bridge_ui: migration view

Change-Id: I1c9f564dbdb77ae71e63934b55f395f7410869f6
This commit is contained in:
Chase Moran 2021-09-14 14:47:25 -04:00
parent 0dc9f28bfd
commit c8aee80b1d
8 changed files with 536 additions and 10 deletions

View File

@ -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() {
<Route exact path="/register">
<Attest />
</Route>
<Route exact path="/migrate/:legacyAsset">
<Migration />
</Route>
<Route exact path="/">
<Home />
</Route>

View File

@ -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<boolean | undefined>(undefined);
const [fromTokenAccount, setFromTokenAccount] = useState<string | undefined>(
undefined
);
const [fromTokenAccountBalance, setFromTokenAccountBalance] = useState<
string | undefined
>(undefined);
const [toTokenAccount, setToTokenAccount] = useState<string | undefined>(
undefined
);
const [toTokenAccountBalance, setToTokenAccountBalance] = useState<
string | undefined
>(undefined);
const [fromMintDecimals, setFromMintDecimals] = useState<number | undefined>(
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<string | undefined>(
undefined
);
const [toCustodyBalance, setToCustodyBalance] = useState<string | undefined>(
undefined
);
const [migrationAmount, setMigrationAmount] = useState("");
const [migrationIsProcessing, setMigrationIsProcessing] = useState(false);
const [error, setError] = useState("");
const [transaction, setTransaction] = useState<string | null>(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 (
<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 />
{fromTokenAccount && toTokenAccount && fromTokenAccountBalance ? (
<>
<Typography variant="body2">
This will migrate {fromMintPrettyString} tokens in this account:
</Typography>
<Typography variant="h5">
{shortenAddress(fromTokenAccount) +
` (Balance: ${fromTokenAccountBalance}${
fromMetadata.symbol && " " + fromMetadata.symbol
})`}
</Typography>
<div className={classes.spacer} />
<Typography variant="body2">
into {toMintPrettyString} tokens in this account:
</Typography>
<Typography
variant="h5"
color={toTokenAccountExists ? "textPrimary" : "textSecondary"}
>
{shortenAddress(toTokenAccount) +
(toTokenAccountExists
? ` (Balance: ${toTokenAccountBalance}${
(toMetadata.symbol && " " + toMetadata.symbol) || ""
})`
: " (Not created yet)")}
</Typography>
<SolanaCreateAssociatedAddress
mintAddress={toMint}
readableTargetAddress={toTokenAccount}
associatedAccountExists={toTokenAccountExists}
setAssociatedAccountExists={setToTokenAccountExists}
/>
</>
) : 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}
</Paper>
</Container>
);
}

View File

@ -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<RouteParams> {}
const MigrationRoot: React.FC<Migration> = (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 <Workflow fromMint={fromMint} toMint={toMint} />;
} else {
return <Typography>This asset is not eligible for migration.</Typography>;
}
};
export default withRouter(MigrationRoot);

View File

@ -40,7 +40,7 @@ export default function ShowTx({
return (
<div className={classes.tx}>
<Typography component="div" variant="body2">
<Typography noWrap component="div" variant="body2">
{tx.id}
</Typography>
{showExplorerLink && explorerAddress ? (

View File

@ -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();

View File

@ -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 {
const output = useMemo(
() => ({
data: results,
isFetching: isLoading,
error,
receivedAt,
};
}),
[results, isLoading, error, receivedAt]
);
return output;
};
export default useMetaplexData;

View File

@ -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<string, string>([
[
"2WDq7wSs9zYrpx2kbHDA4RUTRch2CCTP6ZWaH4GNfnQQ",
"ApgUoB1467PXXofoLWFELH2Kz9DKB8WXdU2szGSsFKhX",
],
]);

View File

@ -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";