Эх сурвалжийг харах

nft_bridge fixes

Change-Id: I9420863384e752725cfc75c8b5a21f64be2792b1
Evan Gray 4 жил өмнө
parent
commit
9ea0369ab0

+ 444 - 0
bridge_ui/src/components/NFT/Recovery.tsx

@@ -0,0 +1,444 @@
+import {
+  ChainId,
+  CHAIN_ID_BSC,
+  CHAIN_ID_ETH,
+  CHAIN_ID_SOLANA,
+  getEmitterAddressEth,
+  getEmitterAddressSolana,
+  getSignedVAA,
+  parseSequenceFromLogEth,
+  parseSequenceFromLogSolana,
+} from "@certusone/wormhole-sdk";
+import {
+  Box,
+  Button,
+  Dialog,
+  DialogActions,
+  DialogContent,
+  DialogTitle,
+  Divider,
+  Fab,
+  makeStyles,
+  MenuItem,
+  TextField,
+  Tooltip,
+  Typography,
+} from "@material-ui/core";
+import { Restore } from "@material-ui/icons";
+import { Alert } from "@material-ui/lab";
+import { Connection } from "@solana/web3.js";
+import { BigNumber, ethers } from "ethers";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import { useEthereumProvider } from "../../contexts/EthereumProviderContext";
+import {
+  selectNFTSignedVAAHex,
+  selectNFTSourceChain,
+} from "../../store/selectors";
+import { setSignedVAAHex, setStep, setTargetChain } from "../../store/nftSlice";
+import {
+  hexToNativeString,
+  hexToUint8Array,
+  uint8ArrayToHex,
+} from "../../utils/array";
+import {
+  CHAINS,
+  ETH_BRIDGE_ADDRESS,
+  ETH_NFT_BRIDGE_ADDRESS,
+  SOLANA_HOST,
+  SOL_NFT_BRIDGE_ADDRESS,
+  WORMHOLE_RPC_HOST,
+} from "../../utils/consts";
+import KeyAndBalance from "../KeyAndBalance";
+import { METADATA_REPLACE } from "../../utils/metaplex";
+
+const useStyles = makeStyles((theme) => ({
+  fab: {
+    position: "fixed",
+    bottom: theme.spacing(2),
+    right: theme.spacing(2),
+  },
+}));
+
+async function eth(provider: ethers.providers.Web3Provider, tx: string) {
+  try {
+    const receipt = await provider.getTransactionReceipt(tx);
+    const sequence = parseSequenceFromLogEth(receipt, ETH_BRIDGE_ADDRESS);
+    const emitterAddress = getEmitterAddressEth(ETH_NFT_BRIDGE_ADDRESS);
+    const { vaaBytes } = await getSignedVAA(
+      WORMHOLE_RPC_HOST,
+      CHAIN_ID_ETH,
+      emitterAddress,
+      sequence.toString()
+    );
+    return uint8ArrayToHex(vaaBytes);
+  } catch (e) {
+    console.error(e);
+  }
+  return "";
+}
+
+async function solana(tx: string) {
+  try {
+    const connection = new Connection(SOLANA_HOST, "confirmed");
+    const info = await connection.getTransaction(tx);
+    if (!info) {
+      throw new Error("An error occurred while fetching the transaction info");
+    }
+    const sequence = parseSequenceFromLogSolana(info);
+    const emitterAddress = await getEmitterAddressSolana(
+      SOL_NFT_BRIDGE_ADDRESS
+    );
+    const { vaaBytes } = await getSignedVAA(
+      WORMHOLE_RPC_HOST,
+      CHAIN_ID_SOLANA,
+      emitterAddress,
+      sequence.toString()
+    );
+    return uint8ArrayToHex(vaaBytes);
+  } catch (e) {
+    console.error(e);
+  }
+  return "";
+}
+
+// note: actual first byte is message type
+//     0   [u8; 32] token_address
+//     32  u16      token_chain
+//     34  [u8; 32] symbol
+//     66  [u8; 32] name
+//     98  u256     tokenId
+//     130 u8       uri_len
+//     131 [u8;len] uri
+//     ?   [u8; 32] recipient
+//     ?   u16      recipient_chain
+
+// TODO: move to wasm / sdk, share with solana
+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))
+    .toString("utf8")
+    .replace(METADATA_REPLACE, "");
+  const name = Buffer.from(arr.slice(67, 67 + 32))
+    .toString("utf8")
+    .replace(METADATA_REPLACE, "");
+  const tokenId = BigNumber.from(arr.slice(99, 99 + 32));
+  const uri_len = arr.readUInt8(131);
+  const uri = Buffer.from(arr.slice(132, 132 + uri_len))
+    .toString("utf8")
+    .replace(METADATA_REPLACE, "");
+  const target_offset = 132 + uri_len;
+  const targetAddress = arr
+    .slice(target_offset, target_offset + 32)
+    .toString("hex");
+  const targetChain = arr.readUInt16BE(target_offset + 32) as ChainId;
+  return {
+    originAddress,
+    originChain,
+    symbol,
+    name,
+    tokenId,
+    uri,
+    targetAddress,
+    targetChain,
+  };
+};
+
+function RecoveryDialogContent({
+  onClose,
+  disabled,
+}: {
+  onClose: () => void;
+  disabled: boolean;
+}) {
+  const dispatch = useDispatch();
+  const { provider } = useEthereumProvider();
+  const currentSourceChain = useSelector(selectNFTSourceChain);
+  const [recoverySourceChain, setRecoverySourceChain] =
+    useState(currentSourceChain);
+  const [recoverySourceTx, setRecoverySourceTx] = useState("");
+  const currentSignedVAA = useSelector(selectNFTSignedVAAHex);
+  const [recoverySignedVAA, setRecoverySignedVAA] = useState(currentSignedVAA);
+  const [recoveryParsedVAA, setRecoveryParsedVAA] = useState<any>(null);
+  useEffect(() => {
+    if (!recoverySignedVAA) {
+      setRecoverySourceTx("");
+      setRecoverySourceChain(currentSourceChain);
+    }
+  }, [recoverySignedVAA, currentSourceChain]);
+  useEffect(() => {
+    if (recoverySourceTx) {
+      let cancelled = false;
+      if (recoverySourceChain === CHAIN_ID_ETH && provider) {
+        (async () => {
+          const vaa = await eth(provider, recoverySourceTx);
+          if (!cancelled) {
+            setRecoverySignedVAA(vaa);
+          }
+        })();
+      } else if (recoverySourceChain === CHAIN_ID_SOLANA) {
+        (async () => {
+          const vaa = await solana(recoverySourceTx);
+          if (!cancelled) {
+            setRecoverySignedVAA(vaa);
+          }
+        })();
+      }
+      return () => {
+        cancelled = true;
+      };
+    }
+  }, [recoverySourceChain, recoverySourceTx, provider]);
+  useEffect(() => {
+    setRecoverySignedVAA(currentSignedVAA);
+  }, [currentSignedVAA]);
+  const handleSourceChainChange = useCallback((event) => {
+    setRecoverySourceTx("");
+    setRecoverySourceChain(event.target.value);
+  }, []);
+  const handleSourceTxChange = useCallback((event) => {
+    setRecoverySourceTx(event.target.value);
+  }, []);
+  const handleSignedVAAChange = useCallback((event) => {
+    setRecoverySignedVAA(event.target.value);
+  }, []);
+  useEffect(() => {
+    let cancelled = false;
+    if (recoverySignedVAA) {
+      (async () => {
+        try {
+          const { parse_vaa } = await import(
+            "@certusone/wormhole-sdk/lib/solana/core/bridge"
+          );
+          const parsedVAA = parse_vaa(hexToUint8Array(recoverySignedVAA));
+          if (!cancelled) {
+            setRecoveryParsedVAA(parsedVAA);
+          }
+        } catch (e) {
+          console.log(e);
+          if (!cancelled) {
+            setRecoveryParsedVAA(null);
+          }
+        }
+      })();
+    }
+    return () => {
+      cancelled = true;
+    };
+  }, [recoverySignedVAA]);
+  const parsedPayload = useMemo(
+    () =>
+      recoveryParsedVAA?.payload
+        ? parsePayload(Buffer.from(new Uint8Array(recoveryParsedVAA.payload)))
+        : null,
+    [recoveryParsedVAA]
+  );
+  const parsedPayloadTargetChain = parsedPayload?.targetChain;
+  const enableRecovery = recoverySignedVAA && parsedPayloadTargetChain;
+  const handleRecoverClick = useCallback(() => {
+    if (enableRecovery && recoverySignedVAA && parsedPayloadTargetChain) {
+      // TODO: make recovery reducer
+      dispatch(setSignedVAAHex(recoverySignedVAA));
+      dispatch(setTargetChain(parsedPayloadTargetChain));
+      dispatch(setStep(3));
+      onClose();
+    }
+  }, [
+    dispatch,
+    enableRecovery,
+    recoverySignedVAA,
+    parsedPayloadTargetChain,
+    onClose,
+  ]);
+  return (
+    <>
+      <DialogContent>
+        <Alert severity="info">
+          If you have sent your tokens but have not redeemed them, you may paste
+          in the Source Transaction ID (from Step 3) to resume your transfer.
+        </Alert>
+        <TextField
+          select
+          label="Source Chain"
+          disabled={!!recoverySignedVAA}
+          value={recoverySourceChain}
+          onChange={handleSourceChainChange}
+          fullWidth
+          margin="normal"
+        >
+          {CHAINS.filter(
+            ({ id }) => id === CHAIN_ID_ETH || id === CHAIN_ID_SOLANA
+          ).map(({ id, name }) => (
+            <MenuItem key={id} value={id}>
+              {name}
+            </MenuItem>
+          ))}
+        </TextField>
+        {recoverySourceChain === CHAIN_ID_ETH ||
+        recoverySourceChain === CHAIN_ID_BSC ? (
+          <KeyAndBalance chainId={recoverySourceChain} />
+        ) : null}
+        <TextField
+          label="Source Tx"
+          disabled={!!recoverySignedVAA}
+          value={recoverySourceTx}
+          onChange={handleSourceTxChange}
+          fullWidth
+          margin="normal"
+        />
+        <Box mt={4}>
+          <Typography>or</Typography>
+        </Box>
+        <TextField
+          label="Signed VAA (Hex)"
+          value={recoverySignedVAA || ""}
+          onChange={handleSignedVAAChange}
+          fullWidth
+          margin="normal"
+        />
+        <Box my={4}>
+          <Divider />
+        </Box>
+        <TextField
+          label="Emitter Chain"
+          disabled
+          value={recoveryParsedVAA?.emitter_chain || ""}
+          fullWidth
+          margin="normal"
+        />
+        <TextField
+          label="Emitter Address"
+          disabled
+          value={
+            (recoveryParsedVAA &&
+              hexToNativeString(
+                recoveryParsedVAA.emitter_address,
+                recoveryParsedVAA.emitter_chain
+              )) ||
+            ""
+          }
+          fullWidth
+          margin="normal"
+        />
+        <TextField
+          label="Sequence"
+          disabled
+          value={recoveryParsedVAA?.sequence || ""}
+          fullWidth
+          margin="normal"
+        />
+        <TextField
+          label="Timestamp"
+          disabled
+          value={
+            (recoveryParsedVAA &&
+              new Date(recoveryParsedVAA.timestamp * 1000).toLocaleString()) ||
+            ""
+          }
+          fullWidth
+          margin="normal"
+        />
+        <Box my={4}>
+          <Divider />
+        </Box>
+        <TextField
+          label="Origin Chain"
+          disabled
+          value={parsedPayload?.originChain.toString() || ""}
+          fullWidth
+          margin="normal"
+        />
+        <TextField
+          label="Origin Token Address"
+          disabled
+          value={
+            (parsedPayload &&
+              hexToNativeString(
+                parsedPayload.originAddress,
+                parsedPayload.originChain
+              )) ||
+            ""
+          }
+          fullWidth
+          margin="normal"
+        />
+        <TextField
+          label="Origin Token ID"
+          disabled
+          value={parsedPayload?.tokenId || ""}
+          fullWidth
+          margin="normal"
+        />
+        <TextField
+          label="Target Chain"
+          disabled
+          value={parsedPayload?.targetChain.toString() || ""}
+          fullWidth
+          margin="normal"
+        />
+        <TextField
+          label="Target Address"
+          disabled
+          value={
+            (parsedPayload &&
+              hexToNativeString(
+                parsedPayload.targetAddress,
+                parsedPayload.targetChain
+              )) ||
+            ""
+          }
+          fullWidth
+          margin="normal"
+        />
+        <Box my={4}>
+          <Divider />
+        </Box>
+      </DialogContent>
+      <DialogActions>
+        <Button onClick={onClose} variant="outlined" color="default">
+          Cancel
+        </Button>
+        <Button
+          onClick={handleRecoverClick}
+          variant="contained"
+          color="primary"
+          disabled={!enableRecovery || disabled}
+        >
+          Recover
+        </Button>
+      </DialogActions>
+    </>
+  );
+}
+
+export default function Recovery({
+  open,
+  setOpen,
+  disabled,
+}: {
+  open: boolean;
+  setOpen: (open: boolean) => void;
+  disabled: boolean;
+}) {
+  const classes = useStyles();
+  const handleOpenClick = useCallback(() => {
+    setOpen(true);
+  }, [setOpen]);
+  const handleCloseClick = useCallback(() => {
+    setOpen(false);
+  }, [setOpen]);
+  return (
+    <>
+      <Tooltip title="Open Recovery Dialog">
+        <Fab className={classes.fab} onClick={handleOpenClick}>
+          <Restore />
+        </Fab>
+      </Tooltip>
+      <Dialog open={open} onClose={handleCloseClick} maxWidth="md" fullWidth>
+        <DialogTitle>Recovery</DialogTitle>
+        <RecoveryDialogContent onClose={handleCloseClick} disabled={disabled} />
+      </Dialog>
+    </>
+  );
+}

+ 1 - 1
bridge_ui/src/components/NFT/Send.tsx

@@ -49,7 +49,7 @@ function Send() {
       >
         Transfer
       </ButtonWithLoader>
-      <TransferProgress />
+      <TransferProgress nft />
     </>
   );
 }

+ 9 - 4
bridge_ui/src/components/NFT/Source.tsx

@@ -1,5 +1,6 @@
 import { CHAIN_ID_ETH, CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk";
-import { makeStyles, MenuItem, TextField } from "@material-ui/core";
+import { Button, makeStyles, MenuItem, TextField } from "@material-ui/core";
+import { Restore } from "@material-ui/icons";
 import { useCallback } from "react";
 import { useDispatch, useSelector } from "react-redux";
 import useIsWalletReady from "../../hooks/useIsWalletReady";
@@ -24,7 +25,11 @@ const useStyles = makeStyles((theme) => ({
   },
 }));
 
-function Source() {
+function Source({
+  setIsRecoveryOpen,
+}: {
+  setIsRecoveryOpen: (open: boolean) => void;
+}) {
   const classes = useStyles();
   const dispatch = useDispatch();
   const sourceChain = useSelector(selectNFTSourceChain);
@@ -48,14 +53,14 @@ function Source() {
         <div style={{ display: "flex", alignItems: "center" }}>
           Select an NFT to send through the Wormhole NFT Bridge.
           <div style={{ flexGrow: 1 }} />
-          {/* <Button
+          <Button
             onClick={() => setIsRecoveryOpen(true)}
             size="small"
             variant="outlined"
             endIcon={<Restore />}
           >
             Perform Recovery
-          </Button> */}
+          </Button>
         </div>
       </StepDescription>
       <TextField

+ 23 - 24
bridge_ui/src/components/NFT/Target.tsx

@@ -1,11 +1,14 @@
 import { CHAIN_ID_ETH, CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk";
 import { makeStyles, MenuItem, TextField } from "@material-ui/core";
 import { useCallback, useMemo } from "react";
+import { ethers } from "ethers";
 import { useDispatch, useSelector } from "react-redux";
 import useIsWalletReady from "../../hooks/useIsWalletReady";
 import useSyncTargetAddress from "../../hooks/useSyncTargetAddress";
+import { incrementStep, setTargetChain } from "../../store/nftSlice";
 import {
   selectNFTIsTargetComplete,
+  selectNFTOriginTokenId,
   selectNFTShouldLockFields,
   selectNFTSourceChain,
   selectNFTTargetAddressHex,
@@ -14,14 +17,10 @@ import {
   selectNFTTargetChain,
   selectNFTTargetError,
 } from "../../store/selectors";
-import { incrementStep, setTargetChain } from "../../store/nftSlice";
 import { hexToNativeString } from "../../utils/array";
 import { CHAINS } from "../../utils/consts";
 import ButtonWithLoader from "../ButtonWithLoader";
 import KeyAndBalance from "../KeyAndBalance";
-import SolanaCreateAssociatedAddress, {
-  useAssociatedAccountExistsState,
-} from "../SolanaCreateAssociatedAddress";
 import StepDescription from "../StepDescription";
 
 const useStyles = makeStyles((theme) => ({
@@ -41,6 +40,7 @@ function Target() {
   const targetChain = useSelector(selectNFTTargetChain);
   const targetAddressHex = useSelector(selectNFTTargetAddressHex);
   const targetAsset = useSelector(selectNFTTargetAsset);
+  const originTokenId = useSelector(selectNFTOriginTokenId);
   const readableTargetAddress =
     hexToNativeString(targetAddressHex, targetChain) || "";
   const uiAmountString = useSelector(selectNFTTargetBalanceString);
@@ -48,12 +48,6 @@ function Target() {
   const isTargetComplete = useSelector(selectNFTIsTargetComplete);
   const shouldLockFields = useSelector(selectNFTShouldLockFields);
   const { statusMessage } = useIsWalletReady(targetChain);
-  const { associatedAccountExists, setAssociatedAccountExists } =
-    useAssociatedAccountExistsState(
-      targetChain,
-      targetAsset,
-      readableTargetAddress
-    );
   useSyncTargetAddress(!shouldLockFields, true);
   const handleTargetChange = useCallback(
     (event) => {
@@ -90,21 +84,26 @@ function Target() {
         value={readableTargetAddress}
         disabled={true}
       />
-      {targetChain === CHAIN_ID_SOLANA && targetAsset ? (
-        <SolanaCreateAssociatedAddress
-          mintAddress={targetAsset}
-          readableTargetAddress={readableTargetAddress}
-          associatedAccountExists={associatedAccountExists}
-          setAssociatedAccountExists={setAssociatedAccountExists}
-        />
+      {targetAsset !== ethers.constants.AddressZero ? (
+        <>
+          <TextField
+            label="Token Address"
+            fullWidth
+            className={classes.transferField}
+            value={targetAsset || ""}
+            disabled={true}
+          />
+          {targetChain === CHAIN_ID_ETH ? (
+            <TextField
+              label="TokenId"
+              fullWidth
+              className={classes.transferField}
+              value={originTokenId || ""}
+              disabled={true}
+            />
+          ) : null}
+        </>
       ) : null}
-      <TextField
-        label="Token Address"
-        fullWidth
-        className={classes.transferField}
-        value={targetAsset || ""}
-        disabled={true}
-      />
       <ButtonWithLoader
         disabled={!isTargetComplete} //|| !associatedAccountExists}
         onClick={handleNextClick}

+ 6 - 8
bridge_ui/src/components/NFT/index.tsx

@@ -6,7 +6,7 @@ import {
   StepContent,
   Stepper,
 } from "@material-ui/core";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
 import { useDispatch, useSelector } from "react-redux";
 import useCheckIfWormholeWrapped from "../../hooks/useCheckIfWormholeWrapped";
 import useFetchTargetAsset from "../../hooks/useFetchTargetAsset";
@@ -18,7 +18,7 @@ import {
   selectNFTIsSending,
 } from "../../store/selectors";
 import { setStep } from "../../store/nftSlice";
-// import Recovery from "./Recovery";
+import Recovery from "./Recovery";
 import Redeem from "./Redeem";
 import RedeemPreview from "./RedeemPreview";
 import Send from "./Send";
@@ -38,7 +38,7 @@ function NFT() {
   const classes = useStyles();
   useCheckIfWormholeWrapped(true);
   useFetchTargetAsset(true);
-  // const [isRecoveryOpen, setIsRecoveryOpen] = useState(false);
+  const [isRecoveryOpen, setIsRecoveryOpen] = useState(false);
   const dispatch = useDispatch();
   const activeStep = useSelector(selectNFTActiveStep);
   const isSending = useSelector(selectNFTIsSending);
@@ -68,9 +68,7 @@ function NFT() {
           <StepButton onClick={() => dispatch(setStep(0))}>Source</StepButton>
           <StepContent>
             {activeStep === 0 ? (
-              <Source
-              // setIsRecoveryOpen={setIsRecoveryOpen}
-              />
+              <Source setIsRecoveryOpen={setIsRecoveryOpen} />
             ) : (
               <SourcePreview />
             )}
@@ -103,11 +101,11 @@ function NFT() {
           </StepContent>
         </Step>
       </Stepper>
-      {/* <Recovery
+      <Recovery
         open={isRecoveryOpen}
         setOpen={setIsRecoveryOpen}
         disabled={preventNavigation}
-      /> */}
+      />
     </Container>
   );
 }

+ 13 - 4
bridge_ui/src/components/TransferProgress.tsx

@@ -5,6 +5,9 @@ import { useEffect, useState } from "react";
 import { useSelector } from "react-redux";
 import { useEthereumProvider } from "../contexts/EthereumProviderContext";
 import {
+  selectNFTIsSendComplete,
+  selectNFTSourceChain,
+  selectNFTTransferTx,
   selectTransferIsSendComplete,
   selectTransferSourceChain,
   selectTransferTransferTx,
@@ -21,11 +24,17 @@ const useStyles = makeStyles((theme) => ({
   },
 }));
 
-export default function TransferProgress() {
+export default function TransferProgress({ nft }: { nft?: boolean }) {
   const classes = useStyles();
-  const sourceChain = useSelector(selectTransferSourceChain);
-  const transferTx = useSelector(selectTransferTransferTx);
-  const isSendComplete = useSelector(selectTransferIsSendComplete);
+  const sourceChain = useSelector(
+    nft ? selectNFTSourceChain : selectTransferSourceChain
+  );
+  const transferTx = useSelector(
+    nft ? selectNFTTransferTx : selectTransferTransferTx
+  );
+  const isSendComplete = useSelector(
+    nft ? selectNFTIsSendComplete : selectTransferIsSendComplete
+  );
   const { provider } = useEthereumProvider();
   const [currentBlock, setCurrentBlock] = useState(0);
   useEffect(() => {

+ 0 - 2
bridge_ui/src/hooks/useCheckIfWormholeWrapped.ts

@@ -75,7 +75,6 @@ function useCheckIfWormholeWrapped(nft?: boolean) {
     let cancelled = false;
     (async () => {
       if (sourceChain === CHAIN_ID_ETH && provider && sourceAsset) {
-        console.log("getting wrapped info");
         const wrappedInfo = makeStateSafe(
           await (nft
             ? getOriginalAssetEthNFT(
@@ -90,7 +89,6 @@ function useCheckIfWormholeWrapped(nft?: boolean) {
                 sourceAsset
               ))
         );
-        console.log(wrappedInfo);
         if (!cancelled) {
           dispatch(setSourceWormholeWrappedInfo(wrappedInfo));
         }

+ 3 - 3
bridge_ui/src/hooks/useFetchTargetAsset.ts

@@ -10,6 +10,8 @@ import {
   getForeignAssetEth as getForeignAssetEthNFT,
   getForeignAssetSol as getForeignAssetSolNFT,
 } from "@certusone/wormhole-sdk/lib/nft_bridge";
+import { BigNumber } from "@ethersproject/bignumber";
+import { arrayify } from "@ethersproject/bytes";
 import { Connection } from "@solana/web3.js";
 import { LCDClient } from "@terra-money/terra.js";
 import { useEffect } from "react";
@@ -106,7 +108,7 @@ function useFetchTargetAsset(nft?: boolean) {
                 SOL_NFT_BRIDGE_ADDRESS,
                 originChain,
                 hexToUint8Array(originAsset),
-                new Uint8Array([0, 0, 0, 0]) //tokenId // TODO: string
+                arrayify(BigNumber.from(tokenId || "0"))
               )
             : getForeignAssetSolana(
                 connection,
@@ -114,12 +116,10 @@ function useFetchTargetAsset(nft?: boolean) {
                 originChain,
                 hexToUint8Array(originAsset)
               ));
-          console.log("asset", asset);
           if (!cancelled) {
             dispatch(setTargetAsset(asset));
           }
         } catch (e) {
-          console.log(e);
           if (!cancelled) {
             // TODO: warning for this
             dispatch(setTargetAsset(null));

+ 3 - 3
bridge_ui/src/hooks/useHandleNFTTransfer.ts

@@ -13,8 +13,8 @@ import {
 } from "@certusone/wormhole-sdk/lib/nft_bridge";
 import { WalletContextState } from "@solana/wallet-adapter-react";
 import { Connection } from "@solana/web3.js";
-import { Signer } from "ethers";
-import { zeroPad } from "ethers/lib/utils";
+import { BigNumber, Signer } from "ethers";
+import { arrayify, zeroPad } from "ethers/lib/utils";
 import { useSnackbar } from "notistack";
 import { useCallback, useMemo } from "react";
 import { useDispatch, useSelector } from "react-redux";
@@ -120,7 +120,7 @@ async function solana(
       targetChain,
       originAddress,
       originChain,
-      new Uint8Array([0, 0, 0, 0]) //originTokenId //TODO: string
+      arrayify(BigNumber.from(originTokenId || "0"))
     );
     const txid = await signSendAndConfirm(wallet, connection, transaction);
     enqueueSnackbar("Transaction confirmed", { variant: "success" });

+ 4 - 7
bridge_ui/src/store/selectors.ts

@@ -116,13 +116,10 @@ export const selectNFTTargetError = (state: RootState) => {
   if (!state.nft.targetChain) {
     return "Select a target chain";
   }
-  if (!state.nft.targetAsset) {
-    return UNREGISTERED_ERROR_MESSAGE;
-  }
-  if (
-    state.nft.targetChain === CHAIN_ID_ETH &&
-    state.nft.targetAsset === ethers.constants.AddressZero
-  ) {
+  if (state.nft.targetChain === CHAIN_ID_SOLANA && !state.nft.targetAsset) {
+    // target asset is only required for solana
+    // in the cases of new transfers, target asset will not exist and be created on redeem
+    // Solana requires the derived address to derive the associated token account which is the target on the vaa
     return UNREGISTERED_ERROR_MESSAGE;
   }
   if (!state.nft.targetAddressHex) {

+ 1 - 1
bridge_ui/src/utils/metaplex.ts

@@ -5,7 +5,7 @@ import { BinaryReader, BinaryWriter } from "borsh";
 const base58: any = require("bs58");
 
 // eslint-disable-next-line
-const METADATA_REPLACE = new RegExp("\u0000", "g");
+export const METADATA_REPLACE = new RegExp("\u0000", "g");
 export const EDITION_MARKER_BIT_SIZE = 248;
 export const METADATA_PREFIX = "metadata";
 export const EDITION = "edition";

+ 6 - 2
ethereum/scripts/deploy_test_token.js

@@ -22,8 +22,8 @@ module.exports = async function(callback) {
 
     const nftAddress = (
       await ERC721.new(
-        "Not an APE",
-        "APE",
+        "Not an APE 🐒",
+        "APE🐒",
         "https://cloudflare-ipfs.com/ipfs/QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/"
       )
     ).address;
@@ -32,6 +32,10 @@ module.exports = async function(callback) {
       from: accounts[0],
       gas: 1000000,
     });
+    await nft.methods.mint(accounts[0]).send({
+      from: accounts[0],
+      gas: 1000000,
+    });
 
     console.log("NFT deployed at: " + nftAddress);
 

+ 1 - 1
ethereum/scripts/register_solana_chain.js

@@ -19,7 +19,7 @@ module.exports = async function (callback) {
             gasLimit: 2000000
         });
 
-        await nftBridge.methods.registerChain("0x010000000001008ac4e21c24172fd5f4bdf0b5211f0232cd350a407751a64900fe65d7555384767440eeeae8541dc777e994feb343d59c61dfe72d14206ef77591f8b2b3d8e6280000000001000000010001000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000004e4654427269646765010000000105718b324065244262a50875000f903525d5204dc7feff0fd5a26270682cd7ff").send({
+        await nftBridge.methods.registerChain("0x010000000001007985ba742002ae745c19722fea4d82102e68526c7c9d94d0e5d0a809071c98451c9693b230b3390f4ca9555a3ba9a9abbe87cf6f9e400682213e4fbbe1dabb9e0100000001000000010001000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000004e4654427269646765010000000196ee982293251b48729804c8e8b24b553eb6b887867024948d2236fd37a577ab").send({
             value: 0,
             from: accounts[0],
             gasLimit: 2000000

+ 4 - 1
sdk/js/package.json

@@ -12,8 +12,11 @@
   "scripts": {
     "build-contracts": "npm run build --prefix ../../ethereum && node scripts/copyContracts.js && typechain --target=ethers-v5 --out-dir=src/ethers-contracts contracts/*.json",
     "build-abis": "typechain --target=ethers-v5 --out-dir=src/ethers-contracts/abi src/abi/Wormhole.abi.json",
+    "build-deps": "npm run build-abis && npm run build-contracts",
+    "build-lib": "tsc && node scripts/copyEthersTypes.js && node scripts/copyWasm.js",
+    "build-all": "npm run build-deps && npm run build-lib",
     "test": "echo \"Error: no test specified\" && exit 1",
-    "build": "npm run build-abis && npm run build-contracts && tsc && node scripts/copyEthersTypes.js && node scripts/copyWasm.js",
+    "build": "npm run build-all",
     "format": "echo \"disabled: prettier --write \"src/**/*.ts\"\"",
     "lint": "tslint -p tsconfig.json",
     "prepare": "npm run build",

+ 5 - 3
sdk/js/src/nft_bridge/getOriginalAsset.ts

@@ -1,5 +1,5 @@
 import { Connection, PublicKey } from "@solana/web3.js";
-import { ethers } from "ethers";
+import { BigNumber, ethers } from "ethers";
 import { arrayify, zeroPad } from "ethers/lib/utils";
 import { TokenImplementation__factory } from "../ethers-contracts";
 import { ChainId, CHAIN_ID_ETH, CHAIN_ID_SOLANA } from "../utils";
@@ -37,11 +37,11 @@ export async function getOriginalAssetEth(
     );
     const chainId = (await token.chainId()) as ChainId; // origin chain
     const assetAddress = await token.nativeContract(); // origin address
-    // TODO: tokenId
     return {
       isWrapped: true,
       chainId,
       assetAddress: arrayify(assetAddress),
+      tokenId, // tokenIds are maintained across EVM chains
     };
   }
   return {
@@ -79,11 +79,13 @@ export async function getOriginalAssetSol(
     );
     if (wrappedMetaAccountInfo) {
       const parsed = parse_wrapped_meta(wrappedMetaAccountInfo.data);
+      const token_id_arr = parsed.token_id as BigUint64Array;
+      const token_id = BigNumber.from(token_id_arr.reverse()).toString();
       return {
         isWrapped: true,
         chainId: parsed.chain,
         assetAddress: parsed.token_address,
-        tokenId: parsed.token_id,
+        tokenId: token_id,
       };
     }
   }

+ 3 - 1
sdk/js/src/nft_bridge/redeem.ts

@@ -25,7 +25,7 @@ export async function redeemOnSolana(
   const { parse_vaa } = await import("../solana/core/bridge");
   const parsedVAA = parse_vaa(signedVAA);
   const isSolanaNative =
-    Buffer.from(new Uint8Array(parsedVAA.payload)).readUInt16BE(65) ===
+    Buffer.from(new Uint8Array(parsedVAA.payload)).readUInt16BE(33) ===
     CHAIN_ID_SOLANA;
   const { complete_transfer_wrapped_ix, complete_transfer_native_ix } =
     await import("../solana/nft/nft_bridge");
@@ -37,6 +37,7 @@ export async function redeemOnSolana(
           tokenBridgeAddress,
           bridgeAddress,
           payerAddress,
+          payerAddress, //TODO: allow for a different address than payer
           signedVAA
         )
       )
@@ -48,6 +49,7 @@ export async function redeemOnSolana(
           tokenBridgeAddress,
           bridgeAddress,
           payerAddress,
+          payerAddress, //TODO: allow for a different address than payer
           signedVAA
         )
       )

+ 1 - 1
solana/devnet_setup.sh

@@ -64,7 +64,7 @@ echo "Created NFT account $nft_account"
 spl-token mint "$nft" 1 "$nft_account"
 
 # Create meta for token
-token-bridge-client create-meta "$nft" "Not a PUNK" "PUNK" "https://wrappedpunks.com:3000/api/punks/metadata/39"
+token-bridge-client create-meta "$nft" "Not a PUNK🎸" "PUNK🎸" "https://wrappedpunks.com:3000/api/punks/metadata/39"
 
 # Create the bridge contract at a known address
 # OK to fail on subsequent attempts (already created).

+ 3 - 3
solana/modules/nft_bridge/program/src/api/complete_transfer.rs

@@ -129,7 +129,7 @@ pub fn complete_native(
             accs.to_authority.info().key,
             accs.mint.info().key,
         );
-        if *accs.to_authority.info().key != associated_addr {
+        if *accs.to.info().key != associated_addr {
             return Err(InvalidAssociatedAccount.into());
         }
         // Create associated token account
@@ -294,7 +294,7 @@ pub fn complete_wrapped(
             accs.to_authority.info().key,
             accs.mint.info().key,
         );
-        if *accs.to_authority.info().key != associated_addr {
+        if *accs.to.info().key != associated_addr {
             return Err(InvalidAssociatedAccount.into());
         }
         // Create associated token account
@@ -303,7 +303,7 @@ pub fn complete_wrapped(
             accs.to_authority.info().key,
             accs.mint.info().key,
         );
-        invoke(&ix, ctx.accounts)?;
+        invoke_signed(&ix, ctx.accounts, &[])?;
     } else if *accs.mint.info().key != accs.to.mint {
         return Err(InvalidMint.into());
     }

+ 2 - 2
solana/modules/nft_bridge/program/src/api/transfer.rs

@@ -154,7 +154,7 @@ pub fn transfer_native(
     }
 
     // Token must have metadata
-    if !accs.spl_metadata.data_is_empty() {
+    if accs.spl_metadata.data_is_empty() {
         return Err(TokenNotNFT.into());
     }
 
@@ -348,7 +348,7 @@ pub fn transfer_wrapped(
         .verify_derivation(ctx.program_id, &derivation_data)?;
 
     // Token must have metadata
-    if !accs.spl_metadata.data_is_empty() {
+    if accs.spl_metadata.data_is_empty() {
         return Err(TokenNotNFT.into());
     }
 

+ 1 - 0
solana/modules/nft_bridge/program/src/instructions.rs

@@ -206,6 +206,7 @@ pub fn complete_wrapped(
             AccountMeta::new_readonly(bridge_id, false),
             AccountMeta::new_readonly(spl_token::id(), false),
             AccountMeta::new_readonly(spl_associated_token_account::id(), false),
+            AccountMeta::new_readonly(spl_token_metadata::id(), false),
         ],
         data: (crate::instruction::Instruction::CompleteWrapped, data).try_to_vec()?,
     })

+ 2 - 2
solana/modules/nft_bridge/program/src/wasm.rs

@@ -113,7 +113,7 @@ pub fn transfer_wrapped_ix(
     target_addr.copy_from_slice(target_address.as_slice());
     let mut token_addr = [0u8; 32];
     token_addr.copy_from_slice(token_address.as_slice());
-    let token_id = U256::from_little_endian(token_id.as_slice());
+    let token_id = U256::from_big_endian(token_id.as_slice());
 
     let ix = transfer_wrapped(
         program_id,
@@ -329,7 +329,7 @@ pub fn wrapped_address(
     let program_id = Pubkey::from_str(program_id.as_str()).unwrap();
     let mut t_addr = [0u8; 32];
     t_addr.copy_from_slice(&token_address);
-    let token_id = U256::from_little_endian(token_id.as_slice());
+    let token_id = U256::from_big_endian(token_id.as_slice());
 
     let wrapped_addr = WrappedMint::<'_, { AccountState::Initialized }>::key(
         &WrappedDerivationData {