bridge_ui: support 2 step solana redeem

Change-Id: Ic2e3fdd97a8fdfb6aae7e22678f2d82a08fed174
This commit is contained in:
Evan Gray 2021-09-21 17:02:42 -04:00
parent 1f917c56d8
commit c67410cd15
14 changed files with 203 additions and 51 deletions

View File

@ -103,7 +103,7 @@ export default function Workflow({
const connection = useMemo( const connection = useMemo(
() => new Connection(SOLANA_HOST, "confirmed"), () => new Connection(SOLANA_HOST, "confirmed"),
[] []
); //TODO confirmed or finalized? );
const wallet = useSolanaWallet(); const wallet = useSolanaWallet();
const { isReady } = useIsWalletReady(CHAIN_ID_SOLANA); const { isReady } = useIsWalletReady(CHAIN_ID_SOLANA);
const solanaTokenMap = useSolanaTokenMap(); const solanaTokenMap = useSolanaTokenMap();

View File

@ -11,6 +11,7 @@ import {
import { import {
Box, Box,
Button, Button,
CircularProgress,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
@ -27,6 +28,7 @@ import { Restore } from "@material-ui/icons";
import { Alert } from "@material-ui/lab"; import { Alert } from "@material-ui/lab";
import { Connection } from "@solana/web3.js"; import { Connection } from "@solana/web3.js";
import { BigNumber, ethers } from "ethers"; import { BigNumber, ethers } from "ethers";
import { useSnackbar } from "notistack";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { useEthereumProvider } from "../../contexts/EthereumProviderContext"; import { useEthereumProvider } from "../../contexts/EthereumProviderContext";
@ -50,6 +52,7 @@ import {
} from "../../utils/consts"; } from "../../utils/consts";
import { getSignedVAAWithRetry } from "../../utils/getSignedVAAWithRetry"; import { getSignedVAAWithRetry } from "../../utils/getSignedVAAWithRetry";
import { METADATA_REPLACE } from "../../utils/metaplex"; import { METADATA_REPLACE } from "../../utils/metaplex";
import parseError from "../../utils/parseError";
import KeyAndBalance from "../KeyAndBalance"; import KeyAndBalance from "../KeyAndBalance";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
@ -60,7 +63,11 @@ const useStyles = makeStyles((theme) => ({
}, },
})); }));
async function eth(provider: ethers.providers.Web3Provider, tx: string) { async function eth(
provider: ethers.providers.Web3Provider,
tx: string,
enqueueSnackbar: any
) {
try { try {
const receipt = await provider.getTransactionReceipt(tx); const receipt = await provider.getTransactionReceipt(tx);
const sequence = parseSequenceFromLogEth(receipt, ETH_BRIDGE_ADDRESS); const sequence = parseSequenceFromLogEth(receipt, ETH_BRIDGE_ADDRESS);
@ -71,14 +78,15 @@ async function eth(provider: ethers.providers.Web3Provider, tx: string) {
sequence.toString(), sequence.toString(),
WORMHOLE_RPC_HOSTS.length WORMHOLE_RPC_HOSTS.length
); );
return uint8ArrayToHex(vaaBytes); return { vaa: uint8ArrayToHex(vaaBytes), error: null };
} catch (e) { } catch (e) {
console.error(e); console.error(e);
enqueueSnackbar(parseError(e), { variant: "error" });
return { vaa: null, error: parseError(e) };
} }
return "";
} }
async function solana(tx: string) { async function solana(tx: string, enqueueSnackbar: any) {
try { try {
const connection = new Connection(SOLANA_HOST, "confirmed"); const connection = new Connection(SOLANA_HOST, "confirmed");
const info = await connection.getTransaction(tx); const info = await connection.getTransaction(tx);
@ -95,11 +103,12 @@ async function solana(tx: string) {
sequence.toString(), sequence.toString(),
WORMHOLE_RPC_HOSTS.length WORMHOLE_RPC_HOSTS.length
); );
return uint8ArrayToHex(vaaBytes); return { vaa: uint8ArrayToHex(vaaBytes), error: null };
} catch (e) { } catch (e) {
console.error(e); console.error(e);
enqueueSnackbar(parseError(e), { variant: "error" });
return { vaa: null, error: parseError(e) };
} }
return "";
} }
// note: actual first byte is message type // note: actual first byte is message type
@ -114,7 +123,7 @@ async function solana(tx: string) {
// ? u16 recipient_chain // ? u16 recipient_chain
// TODO: move to wasm / sdk, share with solana // TODO: move to wasm / sdk, share with solana
const parsePayload = (arr: Buffer) => { export const parsePayload = (arr: Buffer) => {
const originAddress = arr.slice(1, 1 + 32).toString("hex"); const originAddress = arr.slice(1, 1 + 32).toString("hex");
const originChain = arr.readUInt16BE(33) as ChainId; const originChain = arr.readUInt16BE(33) as ChainId;
const symbol = Buffer.from(arr.slice(35, 35 + 32)) const symbol = Buffer.from(arr.slice(35, 35 + 32))
@ -152,12 +161,16 @@ function RecoveryDialogContent({
onClose: () => void; onClose: () => void;
disabled: boolean; disabled: boolean;
}) { }) {
const { enqueueSnackbar } = useSnackbar();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { provider } = useEthereumProvider(); const { provider } = useEthereumProvider();
const currentSourceChain = useSelector(selectNFTSourceChain); const currentSourceChain = useSelector(selectNFTSourceChain);
const [recoverySourceChain, setRecoverySourceChain] = const [recoverySourceChain, setRecoverySourceChain] =
useState(currentSourceChain); useState(currentSourceChain);
const [recoverySourceTx, setRecoverySourceTx] = useState(""); const [recoverySourceTx, setRecoverySourceTx] = useState("");
const [recoverySourceTxIsLoading, setRecoverySourceTxIsLoading] =
useState(false);
const [recoverySourceTxError, setRecoverySourceTxError] = useState("");
const currentSignedVAA = useSelector(selectNFTSignedVAAHex); const currentSignedVAA = useSelector(selectNFTSignedVAAHex);
const [recoverySignedVAA, setRecoverySignedVAA] = useState(currentSignedVAA); const [recoverySignedVAA, setRecoverySignedVAA] = useState(currentSignedVAA);
const [recoveryParsedVAA, setRecoveryParsedVAA] = useState<any>(null); const [recoveryParsedVAA, setRecoveryParsedVAA] = useState<any>(null);
@ -171,17 +184,40 @@ function RecoveryDialogContent({
if (recoverySourceTx) { if (recoverySourceTx) {
let cancelled = false; let cancelled = false;
if (recoverySourceChain === CHAIN_ID_ETH && provider) { if (recoverySourceChain === CHAIN_ID_ETH && provider) {
setRecoverySourceTxError("");
setRecoverySourceTxIsLoading(true);
(async () => { (async () => {
const vaa = await eth(provider, recoverySourceTx); const { vaa, error } = await eth(
provider,
recoverySourceTx,
enqueueSnackbar
);
if (!cancelled) { if (!cancelled) {
setRecoverySignedVAA(vaa); setRecoverySourceTxIsLoading(false);
if (vaa) {
setRecoverySignedVAA(vaa);
}
if (error) {
setRecoverySourceTxError(error);
}
} }
})(); })();
} else if (recoverySourceChain === CHAIN_ID_SOLANA) { } else if (recoverySourceChain === CHAIN_ID_SOLANA) {
setRecoverySourceTxError("");
setRecoverySourceTxIsLoading(true);
(async () => { (async () => {
const vaa = await solana(recoverySourceTx); const { vaa, error } = await solana(
recoverySourceTx,
enqueueSnackbar
);
if (!cancelled) { if (!cancelled) {
setRecoverySignedVAA(vaa); setRecoverySourceTxIsLoading(false);
if (vaa) {
setRecoverySignedVAA(vaa);
}
if (error) {
setRecoverySourceTxError(error);
}
} }
})(); })();
} }
@ -189,7 +225,7 @@ function RecoveryDialogContent({
cancelled = true; cancelled = true;
}; };
} }
}, [recoverySourceChain, recoverySourceTx, provider]); }, [recoverySourceChain, recoverySourceTx, provider, enqueueSnackbar]);
useEffect(() => { useEffect(() => {
setRecoverySignedVAA(currentSignedVAA); setRecoverySignedVAA(currentSignedVAA);
}, [currentSignedVAA]); }, [currentSignedVAA]);
@ -280,23 +316,45 @@ function RecoveryDialogContent({
<KeyAndBalance chainId={recoverySourceChain} /> <KeyAndBalance chainId={recoverySourceChain} />
) : null} ) : null}
<TextField <TextField
label="Source Tx" label="Source Tx (paste here)"
disabled={!!recoverySignedVAA} disabled={!!recoverySignedVAA || recoverySourceTxIsLoading}
value={recoverySourceTx} value={recoverySourceTx}
onChange={handleSourceTxChange} onChange={handleSourceTxChange}
error={!!recoverySourceTxError}
helperText={recoverySourceTxError}
fullWidth fullWidth
margin="normal" margin="normal"
/> />
<Box mt={4}> <Box position="relative">
<Typography>or</Typography> <Box mt={4}>
<Typography>or</Typography>
</Box>
<TextField
label="Signed VAA (Hex)"
disabled={recoverySourceTxIsLoading}
value={recoverySignedVAA || ""}
onChange={handleSignedVAAChange}
fullWidth
margin="normal"
/>
{recoverySourceTxIsLoading ? (
<Box
position="absolute"
style={{
top: 0,
right: 0,
left: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<CircularProgress />
</Box>
) : null}
</Box> </Box>
<TextField
label="Signed VAA (Hex)"
value={recoverySignedVAA || ""}
onChange={handleSignedVAAChange}
fullWidth
margin="normal"
/>
<Box my={4}> <Box my={4}>
<Divider /> <Divider />
</Box> </Box>

View File

@ -12,6 +12,7 @@ import {
import { CHAINS_BY_ID } from "../../utils/consts"; import { CHAINS_BY_ID } from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader"; import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance"; import KeyAndBalance from "../KeyAndBalance";
import ShowTx from "../ShowTx";
import StepDescription from "../StepDescription"; import StepDescription from "../StepDescription";
import TransactionProgress from "../TransactionProgress"; import TransactionProgress from "../TransactionProgress";
import WaitingForWalletMessage from "./WaitingForWalletMessage"; import WaitingForWalletMessage from "./WaitingForWalletMessage";
@ -55,6 +56,7 @@ function Send() {
Transfer Transfer
</ButtonWithLoader> </ButtonWithLoader>
<WaitingForWalletMessage /> <WaitingForWalletMessage />
{transferTx ? <ShowTx chainId={sourceChain} tx={transferTx} /> : null}
<TransactionProgress <TransactionProgress
chainId={sourceChain} chainId={sourceChain}
tx={transferTx} tx={transferTx}

View File

@ -173,7 +173,7 @@ export default function NFTViewer({
cancelled = true; cancelled = true;
}; };
} }
}, [uri, metadata.image]); }, [uri]);
const classes = useStyles(); const classes = useStyles();
const animLower = metadata.animation_url?.toLowerCase(); const animLower = metadata.animation_url?.toLowerCase();
// const has3DModel = animLower?.endsWith('gltf') || animLower?.endsWith('glb') // const has3DModel = animLower?.endsWith('gltf') || animLower?.endsWith('glb')

View File

@ -97,7 +97,7 @@ function useGetBalanceEffect(sourceOrTarget: "source" | "target") {
} catch (e) { } catch (e) {
return; return;
} }
const connection = new Connection(SOLANA_HOST, "finalized"); const connection = new Connection(SOLANA_HOST, "confirmed");
connection connection
.getParsedTokenAccountsByOwner(solPK, { mint }) .getParsedTokenAccountsByOwner(solPK, { mint })
.then(({ value }) => { .then(({ value }) => {

View File

@ -275,7 +275,7 @@ const getSolanaParsedTokenAccounts = (
dispatch: Dispatch, dispatch: Dispatch,
nft: boolean nft: boolean
) => { ) => {
const connection = new Connection(SOLANA_HOST, "finalized"); const connection = new Connection(SOLANA_HOST, "confirmed");
dispatch( dispatch(
nft ? fetchSourceParsedTokenAccountsNFT() : fetchSourceParsedTokenAccounts() nft ? fetchSourceParsedTokenAccountsNFT() : fetchSourceParsedTokenAccounts()
); );
@ -426,7 +426,7 @@ function useGetAvailableTokens(nft: boolean = false) {
//SOLT devnet token //SOLT devnet token
// mintAddresses.push("2WDq7wSs9zYrpx2kbHDA4RUTRch2CCTP6ZWaH4GNfnQQ"); // mintAddresses.push("2WDq7wSs9zYrpx2kbHDA4RUTRch2CCTP6ZWaH4GNfnQQ");
const connection = new Connection(SOLANA_HOST, "finalized"); const connection = new Connection(SOLANA_HOST, "confirmed");
getMultipleAccountsRPC( getMultipleAccountsRPC(
connection, connection,
mintAddresses.map((x) => new PublicKey(x)) mintAddresses.map((x) => new PublicKey(x))

View File

@ -1,28 +1,36 @@
import { import {
CHAIN_ID_ETH, CHAIN_ID_ETH,
CHAIN_ID_SOLANA, CHAIN_ID_SOLANA,
getClaimAddressSolana,
postVaaSolana, postVaaSolana,
} from "@certusone/wormhole-sdk"; } from "@certusone/wormhole-sdk";
import { import {
createMetaOnSolana,
getForeignAssetSol,
isNFTVAASolanaNative,
redeemOnEth, redeemOnEth,
redeemOnSolana, redeemOnSolana,
} from "@certusone/wormhole-sdk/lib/nft_bridge"; } from "@certusone/wormhole-sdk/lib/nft_bridge";
import { arrayify } from "@ethersproject/bytes";
import { WalletContextState } from "@solana/wallet-adapter-react"; import { WalletContextState } from "@solana/wallet-adapter-react";
import { Connection } from "@solana/web3.js"; import { Connection } from "@solana/web3.js";
import { Signer } from "ethers"; import { Signer } from "ethers";
import { useSnackbar } from "notistack"; import { useSnackbar } from "notistack";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { parsePayload } from "../components/NFT/Recovery";
import { useEthereumProvider } from "../contexts/EthereumProviderContext"; import { useEthereumProvider } from "../contexts/EthereumProviderContext";
import { useSolanaWallet } from "../contexts/SolanaWalletContext"; import { useSolanaWallet } from "../contexts/SolanaWalletContext";
import { setIsRedeeming, setRedeemTx } from "../store/nftSlice"; import { setIsRedeeming, setRedeemTx } from "../store/nftSlice";
import { selectNFTIsRedeeming, selectNFTTargetChain } from "../store/selectors"; import { selectNFTIsRedeeming, selectNFTTargetChain } from "../store/selectors";
import { hexToUint8Array } from "../utils/array";
import { import {
ETH_NFT_BRIDGE_ADDRESS, ETH_NFT_BRIDGE_ADDRESS,
SOLANA_HOST, SOLANA_HOST,
SOL_BRIDGE_ADDRESS, SOL_BRIDGE_ADDRESS,
SOL_NFT_BRIDGE_ADDRESS, SOL_NFT_BRIDGE_ADDRESS,
} from "../utils/consts"; } from "../utils/consts";
import { getMetadataAddress } from "../utils/metaplex";
import parseError from "../utils/parseError"; import parseError from "../utils/parseError";
import { signSendAndConfirm } from "../utils/solana"; import { signSendAndConfirm } from "../utils/solana";
import useNFTSignedVAA from "./useNFTSignedVAA"; import useNFTSignedVAA from "./useNFTSignedVAA";
@ -63,24 +71,60 @@ async function solana(
throw new Error("wallet.signTransaction is undefined"); throw new Error("wallet.signTransaction is undefined");
} }
const connection = new Connection(SOLANA_HOST, "confirmed"); const connection = new Connection(SOLANA_HOST, "confirmed");
await postVaaSolana( const claimAddress = await getClaimAddressSolana(
connection,
wallet.signTransaction,
SOL_BRIDGE_ADDRESS,
payerAddress,
Buffer.from(signedVAA)
);
// TODO: how do we retry in between these steps
const transaction = await redeemOnSolana(
connection,
SOL_BRIDGE_ADDRESS,
SOL_NFT_BRIDGE_ADDRESS, SOL_NFT_BRIDGE_ADDRESS,
payerAddress,
signedVAA signedVAA
); );
const txid = await signSendAndConfirm(wallet, connection, transaction); const claimInfo = await connection.getAccountInfo(claimAddress);
// TODO: didn't want to make an info call we didn't need, can we get the block without it by modifying the above call? let txid;
dispatch(setRedeemTx({ id: txid, block: 1 })); if (!claimInfo) {
await postVaaSolana(
connection,
wallet.signTransaction,
SOL_BRIDGE_ADDRESS,
payerAddress,
Buffer.from(signedVAA)
);
// TODO: how do we retry in between these steps
const transaction = await redeemOnSolana(
connection,
SOL_BRIDGE_ADDRESS,
SOL_NFT_BRIDGE_ADDRESS,
payerAddress,
signedVAA
);
txid = await signSendAndConfirm(wallet, connection, transaction);
// TODO: didn't want to make an info call we didn't need, can we get the block without it by modifying the above call?
}
const isNative = await isNFTVAASolanaNative(signedVAA);
if (!isNative) {
const { parse_vaa } = await import(
"@certusone/wormhole-sdk/lib/solana/core/bridge"
);
const parsedVAA = parse_vaa(signedVAA);
const { originChain, originAddress, tokenId } = parsePayload(
Buffer.from(new Uint8Array(parsedVAA.payload))
);
const mintAddress = await getForeignAssetSol(
SOL_NFT_BRIDGE_ADDRESS,
originChain,
hexToUint8Array(originAddress),
arrayify(tokenId)
);
const [metadataAddress] = await getMetadataAddress(mintAddress);
const metadata = await connection.getAccountInfo(metadataAddress);
if (!metadata) {
const transaction = await createMetaOnSolana(
connection,
SOL_BRIDGE_ADDRESS,
SOL_NFT_BRIDGE_ADDRESS,
payerAddress,
signedVAA
);
txid = await signSendAndConfirm(wallet, connection, transaction);
}
}
dispatch(setRedeemTx({ id: txid || "", block: 1 }));
enqueueSnackbar("Transaction confirmed", { variant: "success" }); enqueueSnackbar("Transaction confirmed", { variant: "success" });
} catch (e) { } catch (e) {
enqueueSnackbar(parseError(e), { variant: "error" }); enqueueSnackbar(parseError(e), { variant: "error" });

View File

@ -15,7 +15,7 @@ const getMetaplexData = async (mintAddresses: string[]) => {
promises.push(getMetadataAddress(address)); promises.push(getMetadataAddress(address));
} }
const metaAddresses = await Promise.all(promises); const metaAddresses = await Promise.all(promises);
const connection = new Connection(SOLANA_HOST, "finalized"); const connection = new Connection(SOLANA_HOST, "confirmed");
const results = await getMultipleAccountsRPC( const results = await getMultipleAccountsRPC(
connection, connection,
metaAddresses.map((pair) => pair && pair[0]) metaAddresses.map((pair) => pair && pair[0])

View File

@ -38,7 +38,7 @@ export async function getMultipleAccountsRPC(
connection: Connection, connection: Connection,
pubkeys: PublicKey[] pubkeys: PublicKey[]
): Promise<(AccountInfo<Buffer> | null)[]> { ): Promise<(AccountInfo<Buffer> | null)[]> {
return getMultipleAccounts(connection, pubkeys, "finalized"); return getMultipleAccounts(connection, pubkeys, "confirmed");
} }
export const getMultipleAccounts = async ( export const getMultipleAccounts = async (

View File

@ -2,6 +2,14 @@
## Unreleased ## Unreleased
## 0.0.5
### Added
NFT Bridge Support
getClaimAddressSolana
createMetaOnSolana
## 0.0.4 ## 0.0.4
### Added ### Added

View File

@ -1,6 +1,6 @@
{ {
"name": "@certusone/wormhole-sdk", "name": "@certusone/wormhole-sdk",
"version": "0.0.4", "version": "0.0.5",
"description": "SDK for interacting with Wormhole", "description": "SDK for interacting with Wormhole",
"homepage": "https://wormholenetwork.com", "homepage": "https://wormholenetwork.com",
"main": "lib/index.js", "main": "lib/index.js",

View File

@ -0,0 +1,9 @@
import { PublicKey } from "@solana/web3.js";
export async function getClaimAddressSolana(
programAddress: string,
signedVAA: Uint8Array
) {
const { claim_address } = await import("../solana/core/bridge");
return new PublicKey(claim_address(programAddress, signedVAA));
}

View File

@ -1,2 +1,3 @@
export * from "./getClaimAddress";
export * from "./getEmitterAddress"; export * from "./getEmitterAddress";
export * from "./parseSequenceFromLog"; export * from "./parseSequenceFromLog";

View File

@ -15,6 +15,15 @@ export async function redeemOnEth(
return receipt; return receipt;
} }
export async function isNFTVAASolanaNative(signedVAA: Uint8Array) {
const { parse_vaa } = await import("../solana/core/bridge");
const parsedVAA = parse_vaa(signedVAA);
const isSolanaNative =
Buffer.from(new Uint8Array(parsedVAA.payload)).readUInt16BE(33) ===
CHAIN_ID_SOLANA;
return isSolanaNative;
}
export async function redeemOnSolana( export async function redeemOnSolana(
connection: Connection, connection: Connection,
bridgeAddress: string, bridgeAddress: string,
@ -22,11 +31,7 @@ export async function redeemOnSolana(
payerAddress: string, payerAddress: string,
signedVAA: Uint8Array signedVAA: Uint8Array
) { ) {
const { parse_vaa } = await import("../solana/core/bridge"); const isSolanaNative = await isNFTVAASolanaNative(signedVAA);
const parsedVAA = parse_vaa(signedVAA);
const isSolanaNative =
Buffer.from(new Uint8Array(parsedVAA.payload)).readUInt16BE(33) ===
CHAIN_ID_SOLANA;
const { complete_transfer_wrapped_ix, complete_transfer_native_ix } = const { complete_transfer_wrapped_ix, complete_transfer_native_ix } =
await import("../solana/nft/nft_bridge"); await import("../solana/nft/nft_bridge");
const ixs = []; const ixs = [];
@ -61,3 +66,28 @@ export async function redeemOnSolana(
transaction.feePayer = new PublicKey(payerAddress); transaction.feePayer = new PublicKey(payerAddress);
return transaction; return transaction;
} }
export async function createMetaOnSolana(
connection: Connection,
bridgeAddress: string,
tokenBridgeAddress: string,
payerAddress: string,
signedVAA: Uint8Array
) {
const { complete_transfer_wrapped_meta_ix } = await import(
"../solana/nft/nft_bridge"
);
const ix = ixFromRust(
complete_transfer_wrapped_meta_ix(
tokenBridgeAddress,
bridgeAddress,
payerAddress,
signedVAA
)
);
const transaction = new Transaction().add(ix);
const { blockhash } = await connection.getRecentBlockhash();
transaction.recentBlockhash = blockhash;
transaction.feePayer = new PublicKey(payerAddress);
return transaction;
}