bridge_ui: Solana quick migrate page

Change-Id: Ide559e50de4fab0a45c0a0e41a91eb52cee75215
This commit is contained in:
Chase Moran 2021-11-23 02:21:44 -05:00
parent 7f5740754b
commit d9513f9c39
3 changed files with 807 additions and 0 deletions

View File

@ -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() {
<Route exact path="/migrate/BinanceSmartChain/">
<EvmQuickMigrate chainId={CHAIN_ID_BSC} />
</Route>
<Route exact path="/migrate/Solana/">
<SolanaQuickMigrate />
</Route>
<Route exact path="/stats">
<Stats />
</Route>

View File

@ -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 (
<div className={classes.centered}>
<div>
<Typography variant="body2" color="textSecondary">
Failed to load migration information for token
</Typography>
<SmartAddress
chainId={CHAIN_ID_SOLANA}
address={migratorInfo.fromMintKey}
/>
</div>
</div>
);
} else if (transaction) {
return (
<div className={classes.centered}>
<div>
<Typography variant="body2" color="textSecondary">
Successfully migrated your tokens. They will become available once
this transaction confirms.
</Typography>
<ShowTx
chainId={CHAIN_ID_SOLANA}
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.fromAssociatedTokenAccountBalance}
</Typography>
<SmartAddress
chainId={CHAIN_ID_SOLANA}
address={poolInfo.data.fromAssociatedTokenAccount}
symbol={poolInfo.data.fromSymbol || undefined}
tokenName={poolInfo.data.fromName || undefined}
/>
</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.fromAssociatedTokenAccountBalance}
</Typography>
<SmartAddress
chainId={CHAIN_ID_SOLANA}
address={poolInfo.data.toAssociatedTokenAccount}
symbol={poolInfo.data.toSymbol || undefined}
tokenName={poolInfo.data.toName || undefined}
/>
</div>
{!poolInfo.data.toAssociatedTokenAccountExists ? (
<div className={classes.convertButton}>
<SolanaCreateAssociatedAddress
mintAddress={migratorInfo.toMintKey}
readableTargetAddress={poolInfo.data?.toAssociatedTokenAccount}
associatedAccountExists={
poolInfo.data.toAssociatedTokenAccountExists
}
setAssociatedAccountExists={poolInfo.data.setToTokenAccountExists}
/>
</div>
) : (
<div className={classes.convertButton}>
<ButtonWithLoader
showLoader={migrationIsProcessing}
onClick={handleMigrateClick}
error={
poolInfo.error
? poolInfo.error
: migrationError
? migrationError
: precheckError
? precheckError
: ""
}
disabled={
!!poolInfo.error || !!precheckError || migrationIsProcessing
}
>
Convert
</ButtonWithLoader>
</div>
)}
</div>
);
}
}
type DefaultAssociatedTokenAccountInfo = {
fromMintKey: string;
toMintKey: string;
defaultFromTokenAccount: string;
fromAccountInfo: AccountInfo<ParsedAccountData> | null;
};
const getTokenBalances = async (
walletAddress: string,
migrationMap: Map<string, string>
): Promise<DefaultAssociatedTokenAccountInfo[]> => {
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 = (
<div className={classes.containerDiv}>
<Typography variant="h5">
{`This page allows you to convert certain wrapped tokens ${
chainName ? "on " + chainName : ""
} into
Wormhole V2 tokens.`}
</Typography>
<SolanaWalletKey />
{!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((info) => {
return (
<SolanaMigrationLineItem
migratorInfo={info}
onLoadComplete={reportLoadComplete}
/>
);
})}
</div>
</>
)}
</div>
);
return (
<Container maxWidth="md">
<Paper className={classes.mainPaper}>{content}</Paper>
</Container>
);
}

View File

@ -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<string>;
};
//TODO refactor the workflow page to use this hook
export default function useSolanaMigratorInformation(
fromMint: string,
toMint: string,
fromTokenAccount: string
): DataWrapper<SolanaMigratorInformation> {
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<boolean | 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 [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<SolanaMigratorInformation> = 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;
}