diff --git a/bridge_ui/src/components/Migration/Workflow.tsx b/bridge_ui/src/components/Migration/Workflow.tsx index 17fe5725..2c98bc01 100644 --- a/bridge_ui/src/components/Migration/Workflow.tsx +++ b/bridge_ui/src/components/Migration/Workflow.tsx @@ -103,7 +103,7 @@ export default function Workflow({ const connection = useMemo( () => new Connection(SOLANA_HOST, "confirmed"), [] - ); //TODO confirmed or finalized? + ); const wallet = useSolanaWallet(); const { isReady } = useIsWalletReady(CHAIN_ID_SOLANA); const solanaTokenMap = useSolanaTokenMap(); diff --git a/bridge_ui/src/components/NFT/Recovery.tsx b/bridge_ui/src/components/NFT/Recovery.tsx index d4908bc4..a6e47a14 100644 --- a/bridge_ui/src/components/NFT/Recovery.tsx +++ b/bridge_ui/src/components/NFT/Recovery.tsx @@ -11,6 +11,7 @@ import { import { Box, Button, + CircularProgress, Dialog, DialogActions, DialogContent, @@ -27,6 +28,7 @@ import { Restore } from "@material-ui/icons"; import { Alert } from "@material-ui/lab"; import { Connection } from "@solana/web3.js"; import { BigNumber, ethers } from "ethers"; +import { useSnackbar } from "notistack"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useEthereumProvider } from "../../contexts/EthereumProviderContext"; @@ -50,6 +52,7 @@ import { } from "../../utils/consts"; import { getSignedVAAWithRetry } from "../../utils/getSignedVAAWithRetry"; import { METADATA_REPLACE } from "../../utils/metaplex"; +import parseError from "../../utils/parseError"; import KeyAndBalance from "../KeyAndBalance"; 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 { const receipt = await provider.getTransactionReceipt(tx); const sequence = parseSequenceFromLogEth(receipt, ETH_BRIDGE_ADDRESS); @@ -71,14 +78,15 @@ async function eth(provider: ethers.providers.Web3Provider, tx: string) { sequence.toString(), WORMHOLE_RPC_HOSTS.length ); - return uint8ArrayToHex(vaaBytes); + return { vaa: uint8ArrayToHex(vaaBytes), error: null }; } catch (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 { const connection = new Connection(SOLANA_HOST, "confirmed"); const info = await connection.getTransaction(tx); @@ -95,11 +103,12 @@ async function solana(tx: string) { sequence.toString(), WORMHOLE_RPC_HOSTS.length ); - return uint8ArrayToHex(vaaBytes); + return { vaa: uint8ArrayToHex(vaaBytes), error: null }; } catch (e) { console.error(e); + enqueueSnackbar(parseError(e), { variant: "error" }); + return { vaa: null, error: parseError(e) }; } - return ""; } // note: actual first byte is message type @@ -114,7 +123,7 @@ async function solana(tx: string) { // ? u16 recipient_chain // 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 originChain = arr.readUInt16BE(33) as ChainId; const symbol = Buffer.from(arr.slice(35, 35 + 32)) @@ -152,12 +161,16 @@ function RecoveryDialogContent({ onClose: () => void; disabled: boolean; }) { + const { enqueueSnackbar } = useSnackbar(); const dispatch = useDispatch(); const { provider } = useEthereumProvider(); const currentSourceChain = useSelector(selectNFTSourceChain); const [recoverySourceChain, setRecoverySourceChain] = useState(currentSourceChain); const [recoverySourceTx, setRecoverySourceTx] = useState(""); + const [recoverySourceTxIsLoading, setRecoverySourceTxIsLoading] = + useState(false); + const [recoverySourceTxError, setRecoverySourceTxError] = useState(""); const currentSignedVAA = useSelector(selectNFTSignedVAAHex); const [recoverySignedVAA, setRecoverySignedVAA] = useState(currentSignedVAA); const [recoveryParsedVAA, setRecoveryParsedVAA] = useState(null); @@ -171,17 +184,40 @@ function RecoveryDialogContent({ if (recoverySourceTx) { let cancelled = false; if (recoverySourceChain === CHAIN_ID_ETH && provider) { + setRecoverySourceTxError(""); + setRecoverySourceTxIsLoading(true); (async () => { - const vaa = await eth(provider, recoverySourceTx); + const { vaa, error } = await eth( + provider, + recoverySourceTx, + enqueueSnackbar + ); if (!cancelled) { - setRecoverySignedVAA(vaa); + setRecoverySourceTxIsLoading(false); + if (vaa) { + setRecoverySignedVAA(vaa); + } + if (error) { + setRecoverySourceTxError(error); + } } })(); } else if (recoverySourceChain === CHAIN_ID_SOLANA) { + setRecoverySourceTxError(""); + setRecoverySourceTxIsLoading(true); (async () => { - const vaa = await solana(recoverySourceTx); + const { vaa, error } = await solana( + recoverySourceTx, + enqueueSnackbar + ); if (!cancelled) { - setRecoverySignedVAA(vaa); + setRecoverySourceTxIsLoading(false); + if (vaa) { + setRecoverySignedVAA(vaa); + } + if (error) { + setRecoverySourceTxError(error); + } } })(); } @@ -189,7 +225,7 @@ function RecoveryDialogContent({ cancelled = true; }; } - }, [recoverySourceChain, recoverySourceTx, provider]); + }, [recoverySourceChain, recoverySourceTx, provider, enqueueSnackbar]); useEffect(() => { setRecoverySignedVAA(currentSignedVAA); }, [currentSignedVAA]); @@ -280,23 +316,45 @@ function RecoveryDialogContent({ ) : null} - - or + + + or + + + {recoverySourceTxIsLoading ? ( + + + + ) : null} - diff --git a/bridge_ui/src/components/NFT/Send.tsx b/bridge_ui/src/components/NFT/Send.tsx index 72ae83b3..a4e52426 100644 --- a/bridge_ui/src/components/NFT/Send.tsx +++ b/bridge_ui/src/components/NFT/Send.tsx @@ -12,6 +12,7 @@ import { import { CHAINS_BY_ID } from "../../utils/consts"; import ButtonWithLoader from "../ButtonWithLoader"; import KeyAndBalance from "../KeyAndBalance"; +import ShowTx from "../ShowTx"; import StepDescription from "../StepDescription"; import TransactionProgress from "../TransactionProgress"; import WaitingForWalletMessage from "./WaitingForWalletMessage"; @@ -55,6 +56,7 @@ function Send() { Transfer + {transferTx ? : null} { diff --git a/bridge_ui/src/hooks/useGetSourceParsedTokenAccounts.ts b/bridge_ui/src/hooks/useGetSourceParsedTokenAccounts.ts index 49659b0a..24f0220e 100644 --- a/bridge_ui/src/hooks/useGetSourceParsedTokenAccounts.ts +++ b/bridge_ui/src/hooks/useGetSourceParsedTokenAccounts.ts @@ -275,7 +275,7 @@ const getSolanaParsedTokenAccounts = ( dispatch: Dispatch, nft: boolean ) => { - const connection = new Connection(SOLANA_HOST, "finalized"); + const connection = new Connection(SOLANA_HOST, "confirmed"); dispatch( nft ? fetchSourceParsedTokenAccountsNFT() : fetchSourceParsedTokenAccounts() ); @@ -426,7 +426,7 @@ function useGetAvailableTokens(nft: boolean = false) { //SOLT devnet token // mintAddresses.push("2WDq7wSs9zYrpx2kbHDA4RUTRch2CCTP6ZWaH4GNfnQQ"); - const connection = new Connection(SOLANA_HOST, "finalized"); + const connection = new Connection(SOLANA_HOST, "confirmed"); getMultipleAccountsRPC( connection, mintAddresses.map((x) => new PublicKey(x)) diff --git a/bridge_ui/src/hooks/useHandleNFTRedeem.ts b/bridge_ui/src/hooks/useHandleNFTRedeem.ts index 92e313eb..75205f01 100644 --- a/bridge_ui/src/hooks/useHandleNFTRedeem.ts +++ b/bridge_ui/src/hooks/useHandleNFTRedeem.ts @@ -1,28 +1,36 @@ import { CHAIN_ID_ETH, CHAIN_ID_SOLANA, + getClaimAddressSolana, postVaaSolana, } from "@certusone/wormhole-sdk"; import { + createMetaOnSolana, + getForeignAssetSol, + isNFTVAASolanaNative, redeemOnEth, redeemOnSolana, } from "@certusone/wormhole-sdk/lib/nft_bridge"; +import { arrayify } from "@ethersproject/bytes"; import { WalletContextState } from "@solana/wallet-adapter-react"; import { Connection } from "@solana/web3.js"; import { Signer } from "ethers"; import { useSnackbar } from "notistack"; import { useCallback, useMemo } from "react"; import { useDispatch, useSelector } from "react-redux"; +import { parsePayload } from "../components/NFT/Recovery"; import { useEthereumProvider } from "../contexts/EthereumProviderContext"; import { useSolanaWallet } from "../contexts/SolanaWalletContext"; import { setIsRedeeming, setRedeemTx } from "../store/nftSlice"; import { selectNFTIsRedeeming, selectNFTTargetChain } from "../store/selectors"; +import { hexToUint8Array } from "../utils/array"; import { ETH_NFT_BRIDGE_ADDRESS, SOLANA_HOST, SOL_BRIDGE_ADDRESS, SOL_NFT_BRIDGE_ADDRESS, } from "../utils/consts"; +import { getMetadataAddress } from "../utils/metaplex"; import parseError from "../utils/parseError"; import { signSendAndConfirm } from "../utils/solana"; import useNFTSignedVAA from "./useNFTSignedVAA"; @@ -63,24 +71,60 @@ async function solana( throw new Error("wallet.signTransaction is undefined"); } const connection = new Connection(SOLANA_HOST, "confirmed"); - 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, + const claimAddress = await getClaimAddressSolana( SOL_NFT_BRIDGE_ADDRESS, - payerAddress, signedVAA ); - const 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? - dispatch(setRedeemTx({ id: txid, block: 1 })); + const claimInfo = await connection.getAccountInfo(claimAddress); + let txid; + 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" }); } catch (e) { enqueueSnackbar(parseError(e), { variant: "error" }); diff --git a/bridge_ui/src/hooks/useMetaplexData.ts b/bridge_ui/src/hooks/useMetaplexData.ts index f7210369..1d7177c6 100644 --- a/bridge_ui/src/hooks/useMetaplexData.ts +++ b/bridge_ui/src/hooks/useMetaplexData.ts @@ -15,7 +15,7 @@ const getMetaplexData = async (mintAddresses: string[]) => { promises.push(getMetadataAddress(address)); } const metaAddresses = await Promise.all(promises); - const connection = new Connection(SOLANA_HOST, "finalized"); + const connection = new Connection(SOLANA_HOST, "confirmed"); const results = await getMultipleAccountsRPC( connection, metaAddresses.map((pair) => pair && pair[0]) diff --git a/bridge_ui/src/utils/solana.ts b/bridge_ui/src/utils/solana.ts index 78521cc2..71eaf751 100644 --- a/bridge_ui/src/utils/solana.ts +++ b/bridge_ui/src/utils/solana.ts @@ -38,7 +38,7 @@ export async function getMultipleAccountsRPC( connection: Connection, pubkeys: PublicKey[] ): Promise<(AccountInfo | null)[]> { - return getMultipleAccounts(connection, pubkeys, "finalized"); + return getMultipleAccounts(connection, pubkeys, "confirmed"); } export const getMultipleAccounts = async ( diff --git a/sdk/js/CHANGELOG.md b/sdk/js/CHANGELOG.md index 48302d65..fb76c566 100644 --- a/sdk/js/CHANGELOG.md +++ b/sdk/js/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +## 0.0.5 + +### Added + +NFT Bridge Support +getClaimAddressSolana +createMetaOnSolana + ## 0.0.4 ### Added diff --git a/sdk/js/package.json b/sdk/js/package.json index a918b879..0afcf040 100644 --- a/sdk/js/package.json +++ b/sdk/js/package.json @@ -1,6 +1,6 @@ { "name": "@certusone/wormhole-sdk", - "version": "0.0.4", + "version": "0.0.5", "description": "SDK for interacting with Wormhole", "homepage": "https://wormholenetwork.com", "main": "lib/index.js", diff --git a/sdk/js/src/bridge/getClaimAddress.ts b/sdk/js/src/bridge/getClaimAddress.ts new file mode 100644 index 00000000..6732e20c --- /dev/null +++ b/sdk/js/src/bridge/getClaimAddress.ts @@ -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)); +} diff --git a/sdk/js/src/bridge/index.ts b/sdk/js/src/bridge/index.ts index 0e3ab864..430fd54c 100644 --- a/sdk/js/src/bridge/index.ts +++ b/sdk/js/src/bridge/index.ts @@ -1,2 +1,3 @@ +export * from "./getClaimAddress"; export * from "./getEmitterAddress"; export * from "./parseSequenceFromLog"; diff --git a/sdk/js/src/nft_bridge/redeem.ts b/sdk/js/src/nft_bridge/redeem.ts index 549dad10..f3f4708e 100644 --- a/sdk/js/src/nft_bridge/redeem.ts +++ b/sdk/js/src/nft_bridge/redeem.ts @@ -15,6 +15,15 @@ export async function redeemOnEth( 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( connection: Connection, bridgeAddress: string, @@ -22,11 +31,7 @@ export async function redeemOnSolana( payerAddress: string, 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; + const isSolanaNative = await isNFTVAASolanaNative(signedVAA); const { complete_transfer_wrapped_ix, complete_transfer_native_ix } = await import("../solana/nft/nft_bridge"); const ixs = []; @@ -61,3 +66,28 @@ export async function redeemOnSolana( transaction.feePayer = new PublicKey(payerAddress); 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; +}