浏览代码

bridge_ui: initial NFT bridge support

Change-Id: Iafb0d4f53541cc11c9d42bd432541383274cd2fc
Evan Gray 4 年之前
父节点
当前提交
7711abf29a
共有 44 个文件被更改,包括 2268 次插入462 次删除
  1. 20 3
      bridge_ui/package-lock.json
  2. 2 1
      bridge_ui/package.json
  3. 7 7
      bridge_ui/src/App.js
  4. 29 0
      bridge_ui/src/components/NFT/Redeem.tsx
  5. 42 0
      bridge_ui/src/components/NFT/RedeemPreview.tsx
  6. 57 0
      bridge_ui/src/components/NFT/Send.tsx
  7. 41 0
      bridge_ui/src/components/NFT/SendPreview.tsx
  8. 99 0
      bridge_ui/src/components/NFT/Source.tsx
  9. 46 0
      bridge_ui/src/components/NFT/SourcePreview.tsx
  10. 120 0
      bridge_ui/src/components/NFT/Target.tsx
  11. 38 0
      bridge_ui/src/components/NFT/TargetPreview.tsx
  12. 115 0
      bridge_ui/src/components/NFT/index.tsx
  13. 227 76
      bridge_ui/src/components/TokenSelectors/EthereumSourceTokenSelector.tsx
  14. 86 0
      bridge_ui/src/components/TokenSelectors/NFTViewer.tsx
  15. 9 4
      bridge_ui/src/components/TokenSelectors/SolanaSourceTokenSelector.tsx
  16. 32 7
      bridge_ui/src/components/TokenSelectors/SourceTokenSelector.tsx
  17. 1 1
      bridge_ui/src/components/Transfer/Send.tsx
  18. 3 3
      bridge_ui/src/components/TransferProgress.tsx
  19. 95 12
      bridge_ui/src/hooks/useCheckIfWormholeWrapped.ts
  20. 91 20
      bridge_ui/src/hooks/useFetchTargetAsset.ts
  21. 173 29
      bridge_ui/src/hooks/useGetSourceParsedTokenAccounts.ts
  22. 135 0
      bridge_ui/src/hooks/useHandleNFTRedeem.ts
  23. 245 0
      bridge_ui/src/hooks/useHandleNFTTransfer.ts
  24. 13 0
      bridge_ui/src/hooks/useNFTSignedVAA.ts
  25. 13 0
      bridge_ui/src/hooks/useNFTTargetAddress.ts
  26. 26 6
      bridge_ui/src/hooks/useSyncTargetAddress.ts
  27. 2 0
      bridge_ui/src/store/index.ts
  28. 231 0
      bridge_ui/src/store/nftSlice.ts
  29. 108 1
      bridge_ui/src/store/selectors.ts
  30. 1 1
      bridge_ui/src/store/transferSlice.ts
  31. 19 7
      bridge_ui/src/utils/consts.ts
  32. 44 2
      bridge_ui/src/utils/ethereum.ts
  33. 0 70
      bridge_ui/src/utils/getForeignAsset.ts
  34. 0 63
      bridge_ui/src/utils/getOriginalAsset.ts
  35. 8 7
      ethereum/package-lock.json
  36. 1 1
      ethereum/package.json
  37. 40 33
      ethereum/scripts/deploy_test_token.js
  38. 8 0
      sdk/js/scripts/copyWasm.js
  39. 8 28
      sdk/js/src/nft_bridge/getForeignAsset.ts
  40. 0 9
      sdk/js/src/nft_bridge/getIsWrappedAsset.ts
  41. 19 20
      sdk/js/src/nft_bridge/getOriginalAsset.ts
  42. 7 41
      sdk/js/src/nft_bridge/redeem.ts
  43. 5 3
      sdk/js/src/nft_bridge/transfer.ts
  44. 2 7
      sdk/js/src/token_bridge/redeem.ts

+ 20 - 3
bridge_ui/package-lock.json

@@ -44,7 +44,8 @@
         "react-redux": "^7.2.4",
         "react-router-dom": "^5.2.0",
         "react-scripts": "4.0.3",
-        "redux": "^3.7.2"
+        "redux": "^3.7.2",
+        "use-debounce": "^7.0.0"
       },
       "devDependencies": {
         "@craco/craco": "^6.2.0",
@@ -58,8 +59,7 @@
     },
     "../sdk/js": {
       "name": "@certusone/wormhole-sdk",
-      "version": "0.0.1",
-      "hasInstallScript": true,
+      "version": "0.0.2",
       "license": "Apache-2.0",
       "dependencies": {
         "@improbable-eng/grpc-web": "^0.14.0",
@@ -36535,6 +36535,17 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/use-debounce": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-7.0.0.tgz",
+      "integrity": "sha512-4fvxEEs7ztdNMh+c497HAgysdq2+Ascem6EaDANGlCIap1JzqfL03Xw8xkYc2lShfXm4uO6PA6V5zcXN7gJdFA==",
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
     "node_modules/utf-8-validate": {
       "version": "5.0.5",
       "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.5.tgz",
@@ -69129,6 +69140,12 @@
       "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
       "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ=="
     },
+    "use-debounce": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-7.0.0.tgz",
+      "integrity": "sha512-4fvxEEs7ztdNMh+c497HAgysdq2+Ascem6EaDANGlCIap1JzqfL03Xw8xkYc2lShfXm4uO6PA6V5zcXN7gJdFA==",
+      "requires": {}
+    },
     "utf-8-validate": {
       "version": "5.0.5",
       "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.5.tgz",

+ 2 - 1
bridge_ui/package.json

@@ -38,7 +38,8 @@
     "react-redux": "^7.2.4",
     "react-router-dom": "^5.2.0",
     "react-scripts": "4.0.3",
-    "redux": "^3.7.2"
+    "redux": "^3.7.2",
+    "use-debounce": "^7.0.0"
   },
   "scripts": {
     "preinstall": "npm ci --prefix ../sdk/js && npm run build --prefix ../sdk/js",

+ 7 - 7
bridge_ui/src/App.js

@@ -6,7 +6,6 @@ import {
   makeStyles,
   Toolbar,
   Tooltip,
-  Typography,
 } from "@material-ui/core";
 import { GitHub, Publish, Send } from "@material-ui/icons";
 import {
@@ -18,6 +17,7 @@ import {
 } from "react-router-dom";
 import Attest from "./components/Attest";
 import Home from "./components/Home";
+import NFT from "./components/NFT";
 import Transfer from "./components/Transfer";
 import wormholeLogo from "./icons/wormhole.svg";
 
@@ -75,13 +75,10 @@ function App() {
           <div className={classes.spacer} />
           <Hidden implementation="css" xsDown>
             <div style={{ display: "flex", alignItems: "center" }}>
-              <Tooltip title="Coming Soon">
-                <Typography
-                  className={classes.link}
-                  style={{ color: "#ffffff80", cursor: "default" }}
-                >
+              <Tooltip title="Transfer NFTs to another blockchain">
+                <Link component={NavLink} to="/nft" className={classes.link}>
                   NFTs
-                </Typography>
+                </Link>
               </Tooltip>
               <Tooltip title="Transfer tokens to another blockchain">
                 <Link
@@ -139,6 +136,9 @@ function App() {
       </AppBar>
       <div className={classes.content}>
         <Switch>
+          <Route exact path="/nft">
+            <NFT />
+          </Route>
           <Route exact path="/transfer">
             <Transfer />
           </Route>

+ 29 - 0
bridge_ui/src/components/NFT/Redeem.tsx

@@ -0,0 +1,29 @@
+import { useSelector } from "react-redux";
+import { useHandleNFTRedeem } from "../../hooks/useHandleNFTRedeem";
+import useIsWalletReady from "../../hooks/useIsWalletReady";
+import { selectNFTTargetChain } from "../../store/selectors";
+import ButtonWithLoader from "../ButtonWithLoader";
+import KeyAndBalance from "../KeyAndBalance";
+import StepDescription from "../StepDescription";
+
+function Redeem() {
+  const { handleClick, disabled, showLoader } = useHandleNFTRedeem();
+  const targetChain = useSelector(selectNFTTargetChain);
+  const { isReady, statusMessage } = useIsWalletReady(targetChain);
+  return (
+    <>
+      <StepDescription>Receive the NFT on the target chain</StepDescription>
+      <KeyAndBalance chainId={targetChain} />
+      <ButtonWithLoader
+        disabled={!isReady || disabled}
+        onClick={handleClick}
+        showLoader={showLoader}
+        error={statusMessage}
+      >
+        Redeem
+      </ButtonWithLoader>
+    </>
+  );
+}
+
+export default Redeem;

+ 42 - 0
bridge_ui/src/components/NFT/RedeemPreview.tsx

@@ -0,0 +1,42 @@
+import { makeStyles, Typography } from "@material-ui/core";
+import { useCallback } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import { selectNFTRedeemTx, selectNFTTargetChain } from "../../store/selectors";
+import { reset } from "../../store/nftSlice";
+import ButtonWithLoader from "../ButtonWithLoader";
+import ShowTx from "../ShowTx";
+
+const useStyles = makeStyles((theme) => ({
+  description: {
+    textAlign: "center",
+  },
+}));
+
+export default function RedeemPreview() {
+  const classes = useStyles();
+  const dispatch = useDispatch();
+  const targetChain = useSelector(selectNFTTargetChain);
+  const redeemTx = useSelector(selectNFTRedeemTx);
+  const handleResetClick = useCallback(() => {
+    dispatch(reset());
+  }, [dispatch]);
+
+  const explainerString =
+    "Success! The redeem transaction was submitted. The NFT will become available once the transaction confirms.";
+
+  return (
+    <>
+      <Typography
+        component="div"
+        variant="subtitle2"
+        className={classes.description}
+      >
+        {explainerString}
+      </Typography>
+      {redeemTx ? <ShowTx chainId={targetChain} tx={redeemTx} /> : null}
+      <ButtonWithLoader onClick={handleResetClick}>
+        Transfer Another NFT!
+      </ButtonWithLoader>
+    </>
+  );
+}

+ 57 - 0
bridge_ui/src/components/NFT/Send.tsx

@@ -0,0 +1,57 @@
+import { Alert } from "@material-ui/lab";
+import { useSelector } from "react-redux";
+import { useHandleNFTTransfer } from "../../hooks/useHandleNFTTransfer";
+import useIsWalletReady from "../../hooks/useIsWalletReady";
+import {
+  selectNFTSourceWalletAddress,
+  selectNFTSourceChain,
+  selectNFTTargetError,
+} from "../../store/selectors";
+import { CHAINS_BY_ID } from "../../utils/consts";
+import ButtonWithLoader from "../ButtonWithLoader";
+import KeyAndBalance from "../KeyAndBalance";
+import StepDescription from "../StepDescription";
+import TransferProgress from "../TransferProgress";
+
+function Send() {
+  const { handleClick, disabled, showLoader } = useHandleNFTTransfer();
+  const sourceChain = useSelector(selectNFTSourceChain);
+  const error = useSelector(selectNFTTargetError);
+  const { isReady, statusMessage, walletAddress } =
+    useIsWalletReady(sourceChain);
+  const sourceWalletAddress = useSelector(selectNFTSourceWalletAddress);
+  //The chain ID compare is handled implicitly, as the isWalletReady hook should report !isReady if the wallet is on the wrong chain.
+  const isWrongWallet =
+    sourceWalletAddress &&
+    walletAddress &&
+    sourceWalletAddress !== walletAddress;
+  const isDisabled = !isReady || isWrongWallet || disabled;
+  const errorMessage = isWrongWallet
+    ? "A different wallet is connected than in Step 1."
+    : statusMessage || error || undefined;
+  return (
+    <>
+      <StepDescription>
+        Transfer the NFT to the Wormhole Token Bridge.
+      </StepDescription>
+      <KeyAndBalance chainId={sourceChain} />
+      <Alert severity="warning">
+        This will initiate the transfer on {CHAINS_BY_ID[sourceChain].name} and
+        wait for finalization. If you navigate away from this page before
+        completing Step 4, you will have to perform the recovery workflow to
+        complete the transfer.
+      </Alert>
+      <ButtonWithLoader
+        disabled={isDisabled}
+        onClick={handleClick}
+        showLoader={showLoader}
+        error={errorMessage}
+      >
+        Transfer
+      </ButtonWithLoader>
+      <TransferProgress />
+    </>
+  );
+}
+
+export default Send;

+ 41 - 0
bridge_ui/src/components/NFT/SendPreview.tsx

@@ -0,0 +1,41 @@
+import { makeStyles, Typography } from "@material-ui/core";
+import { useSelector } from "react-redux";
+import {
+  selectNFTSourceChain,
+  selectNFTTransferTx,
+} from "../../store/selectors";
+import ShowTx from "../ShowTx";
+
+const useStyles = makeStyles((theme) => ({
+  description: {
+    textAlign: "center",
+  },
+  tx: {
+    marginTop: theme.spacing(1),
+    textAlign: "center",
+  },
+  viewButton: {
+    marginTop: theme.spacing(1),
+  },
+}));
+
+export default function SendPreview() {
+  const classes = useStyles();
+  const sourceChain = useSelector(selectNFTSourceChain);
+  const transferTx = useSelector(selectNFTTransferTx);
+
+  const explainerString = "The NFT has been sent!";
+
+  return (
+    <>
+      <Typography
+        component="div"
+        variant="subtitle2"
+        className={classes.description}
+      >
+        {explainerString}
+      </Typography>
+      {transferTx ? <ShowTx chainId={sourceChain} tx={transferTx} /> : null}
+    </>
+  );
+}

+ 99 - 0
bridge_ui/src/components/NFT/Source.tsx

@@ -0,0 +1,99 @@
+import { CHAIN_ID_ETH, CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk";
+import { makeStyles, MenuItem, TextField } from "@material-ui/core";
+import { useCallback } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import useIsWalletReady from "../../hooks/useIsWalletReady";
+import {
+  selectNFTIsSourceComplete,
+  selectNFTShouldLockFields,
+  selectNFTSourceBalanceString,
+  selectNFTSourceChain,
+  selectNFTSourceError,
+} from "../../store/selectors";
+import { incrementStep, setSourceChain } from "../../store/nftSlice";
+import { CHAINS } from "../../utils/consts";
+import ButtonWithLoader from "../ButtonWithLoader";
+import KeyAndBalance from "../KeyAndBalance";
+import StepDescription from "../StepDescription";
+import { TokenSelector } from "../TokenSelectors/SourceTokenSelector";
+import { Alert } from "@material-ui/lab";
+
+const useStyles = makeStyles((theme) => ({
+  transferField: {
+    marginTop: theme.spacing(5),
+  },
+}));
+
+function Source() {
+  const classes = useStyles();
+  const dispatch = useDispatch();
+  const sourceChain = useSelector(selectNFTSourceChain);
+  const uiAmountString = useSelector(selectNFTSourceBalanceString);
+  const error = useSelector(selectNFTSourceError);
+  const isSourceComplete = useSelector(selectNFTIsSourceComplete);
+  const shouldLockFields = useSelector(selectNFTShouldLockFields);
+  const { isReady, statusMessage } = useIsWalletReady(sourceChain);
+  const handleSourceChange = useCallback(
+    (event) => {
+      dispatch(setSourceChain(event.target.value));
+    },
+    [dispatch]
+  );
+  const handleNextClick = useCallback(() => {
+    dispatch(incrementStep());
+  }, [dispatch]);
+  return (
+    <>
+      <StepDescription>
+        <div style={{ display: "flex", alignItems: "center" }}>
+          Select an NFT to send through the Wormhole NFT Bridge.
+          <div style={{ flexGrow: 1 }} />
+          {/* <Button
+            onClick={() => setIsRecoveryOpen(true)}
+            size="small"
+            variant="outlined"
+            endIcon={<Restore />}
+          >
+            Perform Recovery
+          </Button> */}
+        </div>
+      </StepDescription>
+      <TextField
+        select
+        fullWidth
+        value={sourceChain}
+        onChange={handleSourceChange}
+        disabled={shouldLockFields}
+      >
+        {CHAINS.filter(
+          ({ id }) => id === CHAIN_ID_ETH || id === CHAIN_ID_SOLANA
+        ).map(({ id, name }) => (
+          <MenuItem key={id} value={id}>
+            {name}
+          </MenuItem>
+        ))}
+      </TextField>
+      {sourceChain === CHAIN_ID_ETH ? (
+        <Alert severity="info">
+          Only NFTs which implement ERC-721 are supported.
+        </Alert>
+      ) : null}
+      <KeyAndBalance chainId={sourceChain} balance={uiAmountString} />
+      {isReady || uiAmountString ? (
+        <div className={classes.transferField}>
+          <TokenSelector disabled={shouldLockFields} nft={true} />
+        </div>
+      ) : null}
+      <ButtonWithLoader
+        disabled={!isSourceComplete}
+        onClick={handleNextClick}
+        showLoader={false}
+        error={statusMessage || error}
+      >
+        Next
+      </ButtonWithLoader>
+    </>
+  );
+}
+
+export default Source;

+ 46 - 0
bridge_ui/src/components/NFT/SourcePreview.tsx

@@ -0,0 +1,46 @@
+import { makeStyles, Typography } from "@material-ui/core";
+import { useSelector } from "react-redux";
+import {
+  selectNFTSourceChain,
+  selectNFTSourceParsedTokenAccount,
+} from "../../store/selectors";
+import { CHAINS_BY_ID } from "../../utils/consts";
+import { shortenAddress } from "../../utils/solana";
+import NFTViewer from "../TokenSelectors/NFTViewer";
+
+const useStyles = makeStyles((theme) => ({
+  description: {
+    textAlign: "center",
+  },
+}));
+
+export default function SourcePreview() {
+  const classes = useStyles();
+  const sourceChain = useSelector(selectNFTSourceChain);
+  const sourceParsedTokenAccount = useSelector(
+    selectNFTSourceParsedTokenAccount
+  );
+
+  const explainerString = sourceParsedTokenAccount
+    ? `You will transfer 1 NFT of ${shortenAddress(
+        sourceParsedTokenAccount?.mintKey
+      )}, from ${shortenAddress(sourceParsedTokenAccount?.publicKey)} on ${
+        CHAINS_BY_ID[sourceChain].name
+      }`
+    : "Step complete.";
+
+  return (
+    <>
+      <Typography
+        component="div"
+        variant="subtitle2"
+        className={classes.description}
+      >
+        {explainerString}
+      </Typography>
+      {sourceParsedTokenAccount ? (
+        <NFTViewer value={sourceParsedTokenAccount} />
+      ) : null}
+    </>
+  );
+}

+ 120 - 0
bridge_ui/src/components/NFT/Target.tsx

@@ -0,0 +1,120 @@
+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 { useDispatch, useSelector } from "react-redux";
+import useIsWalletReady from "../../hooks/useIsWalletReady";
+import useSyncTargetAddress from "../../hooks/useSyncTargetAddress";
+import {
+  selectNFTIsTargetComplete,
+  selectNFTShouldLockFields,
+  selectNFTSourceChain,
+  selectNFTTargetAddressHex,
+  selectNFTTargetAsset,
+  selectNFTTargetBalanceString,
+  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) => ({
+  transferField: {
+    marginTop: theme.spacing(5),
+  },
+}));
+
+function Target() {
+  const classes = useStyles();
+  const dispatch = useDispatch();
+  const sourceChain = useSelector(selectNFTSourceChain);
+  const chains = useMemo(
+    () => CHAINS.filter((c) => c.id !== sourceChain),
+    [sourceChain]
+  );
+  const targetChain = useSelector(selectNFTTargetChain);
+  const targetAddressHex = useSelector(selectNFTTargetAddressHex);
+  const targetAsset = useSelector(selectNFTTargetAsset);
+  const readableTargetAddress =
+    hexToNativeString(targetAddressHex, targetChain) || "";
+  const uiAmountString = useSelector(selectNFTTargetBalanceString);
+  const error = useSelector(selectNFTTargetError);
+  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) => {
+      dispatch(setTargetChain(event.target.value));
+    },
+    [dispatch]
+  );
+  const handleNextClick = useCallback(() => {
+    dispatch(incrementStep());
+  }, [dispatch]);
+  return (
+    <>
+      <StepDescription>Select a recipient chain and address.</StepDescription>
+      <TextField
+        select
+        fullWidth
+        value={targetChain}
+        onChange={handleTargetChange}
+        disabled={true}
+      >
+        {chains
+          .filter(({ id }) => id === CHAIN_ID_ETH || id === CHAIN_ID_SOLANA)
+          .map(({ id, name }) => (
+            <MenuItem key={id} value={id}>
+              {name}
+            </MenuItem>
+          ))}
+      </TextField>
+      <KeyAndBalance chainId={targetChain} balance={uiAmountString} />
+      <TextField
+        label="Recipient Address"
+        fullWidth
+        className={classes.transferField}
+        value={readableTargetAddress}
+        disabled={true}
+      />
+      {targetChain === CHAIN_ID_SOLANA && targetAsset ? (
+        <SolanaCreateAssociatedAddress
+          mintAddress={targetAsset}
+          readableTargetAddress={readableTargetAddress}
+          associatedAccountExists={associatedAccountExists}
+          setAssociatedAccountExists={setAssociatedAccountExists}
+        />
+      ) : null}
+      <TextField
+        label="Token Address"
+        fullWidth
+        className={classes.transferField}
+        value={targetAsset || ""}
+        disabled={true}
+      />
+      <ButtonWithLoader
+        disabled={!isTargetComplete} //|| !associatedAccountExists}
+        onClick={handleNextClick}
+        showLoader={false}
+        error={statusMessage || error}
+      >
+        Next
+      </ButtonWithLoader>
+    </>
+  );
+}
+
+export default Target;

+ 38 - 0
bridge_ui/src/components/NFT/TargetPreview.tsx

@@ -0,0 +1,38 @@
+import { makeStyles, Typography } from "@material-ui/core";
+import { useSelector } from "react-redux";
+import {
+  selectNFTTargetAddressHex,
+  selectNFTTargetChain,
+} from "../../store/selectors";
+import { hexToNativeString } from "../../utils/array";
+import { CHAINS_BY_ID } from "../../utils/consts";
+import { shortenAddress } from "../../utils/solana";
+
+const useStyles = makeStyles((theme) => ({
+  description: {
+    textAlign: "center",
+  },
+}));
+
+export default function TargetPreview() {
+  const classes = useStyles();
+  const targetChain = useSelector(selectNFTTargetChain);
+  const targetAddress = useSelector(selectNFTTargetAddressHex);
+  const targetAddressNative = hexToNativeString(targetAddress, targetChain);
+
+  const explainerString = targetAddressNative
+    ? `to ${shortenAddress(targetAddressNative)} on ${
+        CHAINS_BY_ID[targetChain].name
+      }`
+    : "Step complete.";
+
+  return (
+    <Typography
+      component="div"
+      variant="subtitle2"
+      className={classes.description}
+    >
+      {explainerString}
+    </Typography>
+  );
+}

+ 115 - 0
bridge_ui/src/components/NFT/index.tsx

@@ -0,0 +1,115 @@
+import {
+  Container,
+  makeStyles,
+  Step,
+  StepButton,
+  StepContent,
+  Stepper,
+} from "@material-ui/core";
+import { useEffect } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import useCheckIfWormholeWrapped from "../../hooks/useCheckIfWormholeWrapped";
+import useFetchTargetAsset from "../../hooks/useFetchTargetAsset";
+import {
+  selectNFTActiveStep,
+  selectNFTIsRedeemComplete,
+  selectNFTIsRedeeming,
+  selectNFTIsSendComplete,
+  selectNFTIsSending,
+} from "../../store/selectors";
+import { setStep } from "../../store/nftSlice";
+// import Recovery from "./Recovery";
+import Redeem from "./Redeem";
+import RedeemPreview from "./RedeemPreview";
+import Send from "./Send";
+import SendPreview from "./SendPreview";
+import Source from "./Source";
+import SourcePreview from "./SourcePreview";
+import Target from "./Target";
+import TargetPreview from "./TargetPreview";
+
+const useStyles = makeStyles(() => ({
+  rootContainer: {
+    backgroundColor: "rgba(0,0,0,0.2)",
+  },
+}));
+
+function NFT() {
+  const classes = useStyles();
+  useCheckIfWormholeWrapped(true);
+  useFetchTargetAsset(true);
+  // const [isRecoveryOpen, setIsRecoveryOpen] = useState(false);
+  const dispatch = useDispatch();
+  const activeStep = useSelector(selectNFTActiveStep);
+  const isSending = useSelector(selectNFTIsSending);
+  const isSendComplete = useSelector(selectNFTIsSendComplete);
+  const isRedeeming = useSelector(selectNFTIsRedeeming);
+  const isRedeemComplete = useSelector(selectNFTIsRedeemComplete);
+  const preventNavigation = isSending || isSendComplete || isRedeeming;
+  useEffect(() => {
+    if (preventNavigation) {
+      window.onbeforeunload = () => true;
+      return () => {
+        window.onbeforeunload = null;
+      };
+    }
+  }, [preventNavigation]);
+  return (
+    <Container maxWidth="md">
+      <Stepper
+        activeStep={activeStep}
+        orientation="vertical"
+        className={classes.rootContainer}
+      >
+        <Step
+          expanded={activeStep >= 0}
+          disabled={preventNavigation || isRedeemComplete}
+        >
+          <StepButton onClick={() => dispatch(setStep(0))}>Source</StepButton>
+          <StepContent>
+            {activeStep === 0 ? (
+              <Source
+              // setIsRecoveryOpen={setIsRecoveryOpen}
+              />
+            ) : (
+              <SourcePreview />
+            )}
+          </StepContent>
+        </Step>
+        <Step
+          expanded={activeStep >= 1}
+          disabled={preventNavigation || isRedeemComplete}
+        >
+          <StepButton onClick={() => dispatch(setStep(1))}>Target</StepButton>
+          <StepContent>
+            {activeStep === 1 ? <Target /> : <TargetPreview />}
+          </StepContent>
+        </Step>
+        <Step expanded={activeStep >= 2} disabled={isSendComplete}>
+          <StepButton onClick={() => dispatch(setStep(2))}>Send NFT</StepButton>
+          <StepContent>
+            {activeStep === 2 ? <Send /> : <SendPreview />}
+          </StepContent>
+        </Step>
+        <Step expanded={activeStep >= 3}>
+          <StepButton
+            onClick={() => dispatch(setStep(3))}
+            disabled={!isSendComplete}
+          >
+            Redeem NFT
+          </StepButton>
+          <StepContent>
+            {isRedeemComplete ? <RedeemPreview /> : <Redeem />}
+          </StepContent>
+        </Step>
+      </Stepper>
+      {/* <Recovery
+        open={isRecoveryOpen}
+        setOpen={setIsRecoveryOpen}
+        disabled={preventNavigation}
+      /> */}
+    </Container>
+  );
+}
+
+export default NFT;

+ 227 - 76
bridge_ui/src/components/TokenSelectors/EthereumSourceTokenSelector.tsx

@@ -12,14 +12,20 @@ import { CovalentData } from "../../hooks/useGetSourceParsedTokenAccounts";
 import { DataWrapper } from "../../store/helpers";
 import { ParsedTokenAccount } from "../../store/transferSlice";
 import {
+  ethNFTToNFTParsedTokenAccount,
   ethTokenToParsedTokenAccount,
+  getEthereumNFT,
   getEthereumToken,
+  isNFT,
   isValidEthereumAddress,
 } from "../../utils/ethereum";
 import { shortenAddress } from "../../utils/solana";
 import OffsetButton from "./OffsetButton";
 import { WormholeAbi__factory } from "@certusone/wormhole-sdk/lib/ethers-contracts/abi";
 import { WORMHOLE_V1_ETH_ADDRESS } from "../../utils/consts";
+import { NFTParsedTokenAccount } from "../../store/nftSlice";
+import NFTViewer from "./NFTViewer";
+import { useDebounce } from "use-debounce/lib";
 
 const useStyles = makeStyles(() =>
   createStyles({
@@ -50,6 +56,7 @@ type EthereumSourceTokenSelectorProps = {
   covalent: DataWrapper<CovalentData[]> | undefined;
   tokenAccounts: DataWrapper<ParsedTokenAccount[]> | undefined;
   disabled: boolean;
+  nft?: boolean;
 };
 
 const renderAccount = (
@@ -79,15 +86,48 @@ const renderAccount = (
   );
 };
 
+const renderNFTAccount = (
+  account: NFTParsedTokenAccount,
+  covalentData: CovalentData | undefined,
+  classes: any
+) => {
+  const mintPrettyString = shortenAddress(account.mintKey);
+  const tokenId = account.tokenId;
+  const uri = account.image_256;
+  const symbol = covalentData?.contract_ticker_symbol || "Unknown";
+  const name = account.name || "Unknown";
+  return (
+    <div className={classes.tokenOverviewContainer}>
+      <div>
+        {uri && <img alt="" className={classes.tokenImage} src={uri} />}
+      </div>
+      <div>
+        <Typography>{symbol}</Typography>
+        <Typography>{name}</Typography>
+      </div>
+      <div>
+        <Typography>{mintPrettyString}</Typography>
+        <Typography>{tokenId}</Typography>
+      </div>
+    </div>
+  );
+};
+
 export default function EthereumSourceTokenSelector(
   props: EthereumSourceTokenSelectorProps
 ) {
-  const { value, onChange, covalent, tokenAccounts, disabled } = props;
+  const { value, onChange, covalent, tokenAccounts, disabled, nft } = props;
   const classes = useStyles();
   const [advancedMode, setAdvancedMode] = useState(false);
   const [advancedModeLoading, setAdvancedModeLoading] = useState(false);
   const [advancedModeSymbol, setAdvancedModeSymbol] = useState("");
   const [advancedModeHolderString, setAdvancedModeHolderString] = useState("");
+  const [advancedModeHolderTokenIdRaw, setAdvancedModeHolderTokenId] =
+    useState("");
+  const [advancedModeHolderTokenId] = useDebounce(
+    advancedModeHolderTokenIdRaw,
+    500
+  );
   const [advancedModeError, setAdvancedModeError] = useState("");
 
   const [autocompleteHolder, setAutocompleteHolder] =
@@ -104,11 +144,23 @@ export default function EthereumSourceTokenSelector(
     //This also kicks off the metadata load.
     if (advancedMode && value && advancedModeHolderString !== value.mintKey) {
       setAdvancedModeHolderString(value.mintKey);
+      // @ts-ignore // TODO: could be NFTParsedTokenAccount which has a tokenId, nicer way to represent this?
+      if (nft && advancedModeHolderTokenId !== value.tokenId) {
+        // @ts-ignore
+        setAdvancedModeHolderTokenId(value.tokenId || "");
+      }
     }
     if (!advancedMode && value && !autocompleteHolder) {
       setAutocompleteHolder(value);
     }
-  }, [value, advancedMode, advancedModeHolderString, autocompleteHolder]);
+  }, [
+    value,
+    advancedMode,
+    advancedModeHolderString,
+    autocompleteHolder,
+    nft,
+    advancedModeHolderTokenId,
+  ]);
 
   //This effect is watching the autocomplete selection.
   //It checks to make sure the token is a valid choice before putting it on the state.
@@ -119,6 +171,10 @@ export default function EthereumSourceTokenSelector(
     } else {
       let cancelled = false;
       setAutocompleteError("");
+      if (nft) {
+        onChange(autocompleteHolder);
+        return;
+      }
       isWormholev1(provider, autocompleteHolder.mintKey).then(
         (result) => {
           if (!cancelled) {
@@ -143,7 +199,7 @@ export default function EthereumSourceTokenSelector(
         cancelled = true;
       };
     }
-  }, [autocompleteHolder, provider, advancedMode, onChange]);
+  }, [autocompleteHolder, provider, advancedMode, onChange, nft]);
 
   //This effect watches the advancedModeString, and checks that the selected asset is valid before putting
   // it on the state.
@@ -162,68 +218,111 @@ export default function EthereumSourceTokenSelector(
       !cancelled && setAdvancedModeError("");
       !cancelled && setAdvancedModeSymbol("");
       try {
-        //Validate that the token is not a wormhole v1 asset
-        const isWormholePromise = isWormholev1(
-          provider,
-          advancedModeHolderString
-        ).then(
-          (result) => {
-            if (result && !cancelled) {
-              setAdvancedModeError(
-                "Wormhole v1 assets are not eligible for transfer."
-              );
-              setAdvancedModeLoading(false);
-              return Promise.reject();
-            } else {
-              return Promise.resolve();
-            }
-          },
-          (error) => {
-            !cancelled &&
-              setAdvancedModeError(
-                "Warning: please verify if this is a Wormhole v1 token address. V1 tokens should not be transferred with this bridge"
-              );
-            !cancelled && setAdvancedModeLoading(false);
-            return Promise.resolve(); //Don't allow an error here to tank the workflow
-          }
-        );
-
-        //Then fetch the asset's information & transform to a parsed token account
-        isWormholePromise.then(() =>
-          getEthereumToken(advancedModeHolderString, provider).then(
-            (token) => {
-              ethTokenToParsedTokenAccount(token, signerAddress).then(
-                (parsedTokenAccount) => {
-                  !cancelled && onChange(parsedTokenAccount);
-                  !cancelled && setAdvancedModeLoading(false);
-                },
-                (error) => {
-                  //These errors can maybe be consolidated
-                  !cancelled &&
-                    setAdvancedModeError(
-                      "Failed to find the specified address"
-                    );
-                  !cancelled && setAdvancedModeLoading(false);
-                }
-              );
-
-              //Also attempt to store off the symbol
-              token.symbol().then(
-                (result) => {
-                  !cancelled && setAdvancedModeSymbol(result);
-                },
-                (error) => {
+        if (nft) {
+          getEthereumNFT(advancedModeHolderString, provider)
+            .then((token) => {
+              isNFT(token)
+                .then((result) => {
+                  if (result) {
+                    ethNFTToNFTParsedTokenAccount(
+                      token,
+                      advancedModeHolderTokenId,
+                      signerAddress
+                    )
+                      .then((parsedTokenAccount) => {
+                        !cancelled && onChange(parsedTokenAccount);
+                        !cancelled && setAdvancedModeLoading(false);
+                      })
+                      .catch((error) => {
+                        !cancelled &&
+                          setAdvancedModeError(
+                            "Failed to find the specified tokenId"
+                          );
+                        !cancelled && setAdvancedModeLoading(false);
+                      });
+                  } else {
+                    !cancelled &&
+                      setAdvancedModeError(
+                        "This token does not support ERC-721"
+                      );
+                    !cancelled && setAdvancedModeLoading(false);
+                  }
+                })
+                .catch((error) => {
                   !cancelled &&
-                    setAdvancedModeError(
-                      "Failed to find the specified address"
-                    );
+                    setAdvancedModeError("This token does not support ERC-721");
                   !cancelled && setAdvancedModeLoading(false);
-                }
-              );
+                });
+            })
+            .catch((error) => {
+              !cancelled &&
+                setAdvancedModeError("This token does not support ERC-721");
+              !cancelled && setAdvancedModeLoading(false);
+            });
+        } else {
+          //Validate that the token is not a wormhole v1 asset
+          const isWormholePromise = isWormholev1(
+            provider,
+            advancedModeHolderString
+          ).then(
+            (result) => {
+              if (result && !cancelled) {
+                setAdvancedModeError(
+                  "Wormhole v1 assets are not eligible for transfer."
+                );
+                setAdvancedModeLoading(false);
+                return Promise.reject();
+              } else {
+                return Promise.resolve();
+              }
             },
-            (error) => {}
-          )
-        );
+            (error) => {
+              !cancelled &&
+                setAdvancedModeError(
+                  "Warning: please verify if this is a Wormhole v1 token address. V1 tokens should not be transferred with this bridge"
+                );
+              !cancelled && setAdvancedModeLoading(false);
+              return Promise.resolve(); //Don't allow an error here to tank the workflow
+            }
+          );
+
+          //Then fetch the asset's information & transform to a parsed token account
+          isWormholePromise.then(() =>
+            getEthereumToken(advancedModeHolderString, provider).then(
+              (token) => {
+                ethTokenToParsedTokenAccount(token, signerAddress).then(
+                  (parsedTokenAccount) => {
+                    !cancelled && onChange(parsedTokenAccount);
+                    !cancelled && setAdvancedModeLoading(false);
+                  },
+                  (error) => {
+                    //These errors can maybe be consolidated
+                    !cancelled &&
+                      setAdvancedModeError(
+                        "Failed to find the specified address"
+                      );
+                    !cancelled && setAdvancedModeLoading(false);
+                  }
+                );
+
+                //Also attempt to store off the symbol
+                token.symbol().then(
+                  (result) => {
+                    !cancelled && setAdvancedModeSymbol(result);
+                  },
+                  (error) => {
+                    !cancelled &&
+                      setAdvancedModeError(
+                        "Failed to find the specified address"
+                      );
+                    !cancelled && setAdvancedModeLoading(false);
+                  }
+                );
+              },
+              (error) => {}
+            )
+          );
+        }
       } catch (e) {
         !cancelled &&
           setAdvancedModeError("Failed to find the specified address");
@@ -239,11 +338,14 @@ export default function EthereumSourceTokenSelector(
     provider,
     signerAddress,
     onChange,
+    nft,
+    advancedModeHolderTokenId,
   ]);
 
   const handleClick = useCallback(() => {
     onChange(null);
     setAdvancedModeHolderString("");
+    setAdvancedModeHolderTokenId("");
   }, [onChange]);
 
   const handleOnChange = useCallback(
@@ -251,6 +353,11 @@ export default function EthereumSourceTokenSelector(
     []
   );
 
+  const handleTokenIdOnChange = useCallback(
+    (event) => setAdvancedModeHolderTokenId(event.target.value),
+    []
+  );
+
   const getSymbol = (account: ParsedTokenAccount | null) => {
     if (!account) {
       return undefined;
@@ -271,6 +378,18 @@ export default function EthereumSourceTokenSelector(
     },
   });
 
+  const filterConfigNFT = createFilterOptions({
+    matchFrom: "any",
+    stringify: (option: NFTParsedTokenAccount) => {
+      const symbol = getSymbol(option) + " " || "";
+      const mint = option.mintKey + " ";
+      const name = option.name ? option.name + " " : "";
+      const id = option.tokenId ? option.tokenId + " " : "";
+
+      return symbol + mint + name + id;
+    },
+  });
+
   const toggleAdvancedMode = () => {
     setAdvancedModeHolderString("");
     setAdvancedModeError("");
@@ -294,29 +413,45 @@ export default function EthereumSourceTokenSelector(
         blurOnSelect
         clearOnBlur
         fullWidth={false}
-        filterOptions={filterConfig}
+        filterOptions={nft ? filterConfigNFT : filterConfig}
         value={autocompleteHolder}
         onChange={(event, newValue) => {
           handleAutocompleteChange(newValue);
         }}
         disabled={disabled}
-        noOptionsText={"No ERC20 tokens found at the moment."}
+        noOptionsText={
+          nft
+            ? "No ERC-721 tokens found at the moment."
+            : "No ERC-20 tokens found at the moment."
+        }
         options={tokenAccounts?.data || []}
         renderInput={(params) => (
           <TextField {...params} label="Token Account" variant="outlined" />
         )}
         renderOption={(option) => {
-          return renderAccount(
-            option,
-            covalent?.data?.find((x) => x.contract_address === option.mintKey),
-            classes
-          );
+          return nft
+            ? renderNFTAccount(
+                option,
+                covalent?.data?.find(
+                  (x) => x.contract_address === option.mintKey
+                ),
+                classes
+              )
+            : renderAccount(
+                option,
+                covalent?.data?.find(
+                  (x) => x.contract_address === option.mintKey
+                ),
+                classes
+              );
         }}
         getOptionLabel={(option) => {
           const symbol = getSymbol(option);
-          return `${symbol ? symbol : "Unknown"} (Address: ${shortenAddress(
-            option.mintKey
-          )})`;
+          return `${symbol ? symbol : "Unknown"} ${
+            nft && option.name ? option.name : ""
+          } (Address: ${shortenAddress(option.mintKey)}${
+            nft ? `, ID: ${option.tokenId}` : ""
+          })`;
         }}
       />
       {autocompleteError && (
@@ -335,7 +470,11 @@ export default function EthereumSourceTokenSelector(
 
   const content = value ? (
     <>
-      <Typography>{(symbol ? symbol + " " : "") + value.mintKey}</Typography>
+      {nft ? (
+        <NFTViewer symbol={symbol} value={value} />
+      ) : (
+        <Typography>{(symbol ? symbol + " " : "") + value.mintKey}</Typography>
+      )}
       <OffsetButton onClick={handleClick} disabled={disabled}>
         Clear
       </OffsetButton>
@@ -345,8 +484,6 @@ export default function EthereumSourceTokenSelector(
         <Typography color="error">{advancedModeError}</Typography>
       ) : null}
     </>
-  ) : isLoading ? (
-    <CircularProgress />
   ) : advancedMode ? (
     <>
       <TextField
@@ -362,7 +499,21 @@ export default function EthereumSourceTokenSelector(
         helperText={advancedModeError === "" ? undefined : advancedModeError}
         disabled={disabled || advancedModeLoading}
       />
+      {nft ? (
+        <TextField
+          fullWidth
+          label="Enter a tokenId"
+          value={advancedModeHolderTokenIdRaw}
+          onChange={handleTokenIdOnChange}
+          disabled={disabled || advancedModeLoading}
+        />
+      ) : null}
     </>
+  ) : isLoading ? (
+    <Typography component="div">
+      <CircularProgress size={"1em"} />{" "}
+      {nft ? "Loading (this may take a while)..." : "Loading..."}
+    </Typography>
   ) : (
     autoComplete
   );
@@ -370,7 +521,7 @@ export default function EthereumSourceTokenSelector(
   return (
     <React.Fragment>
       {content}
-      {!value && !isLoading && advancedModeToggleButton}
+      {!value && advancedModeToggleButton}
     </React.Fragment>
   );
 }

+ 86 - 0
bridge_ui/src/components/TokenSelectors/NFTViewer.tsx

@@ -0,0 +1,86 @@
+import {
+  Card,
+  CardContent,
+  CardMedia,
+  makeStyles,
+  Typography,
+} from "@material-ui/core";
+import { NFTParsedTokenAccount } from "../../store/nftSlice";
+
+const safeIPFS = (uri: string) =>
+  uri.startsWith("ipfs://ipfs/")
+    ? uri.replace("ipfs://", "https://cloudflare-ipfs.com/")
+    : uri.startsWith("ipfs://")
+    ? uri.replace("ipfs://", "https://cloudflare-ipfs.com/ipfs/")
+    : uri;
+
+const useStyles = makeStyles((theme) => ({
+  card: {
+    background: "transparent",
+    border: `1px solid ${theme.palette.divider}`,
+    maxWidth: 480,
+    width: 480,
+    margin: `${theme.spacing(1)}px auto`,
+  },
+  textContent: {
+    background: theme.palette.background.paper,
+  },
+  mediaContent: {
+    background: "transparent",
+  },
+}));
+
+export default function NFTViewer({
+  value,
+  symbol,
+}: {
+  value: NFTParsedTokenAccount;
+  symbol?: string;
+}) {
+  const classes = useStyles();
+  const animLower = value.animation_url?.toLowerCase();
+  // const has3DModel = animLower?.endsWith('gltf') || animLower?.endsWith('glb')
+  const hasVideo =
+    !animLower?.startsWith("ipfs://") && // cloudflare ipfs doesn't support streaming video
+    (animLower?.endsWith("webm") ||
+      animLower?.endsWith("mp4") ||
+      animLower?.endsWith("mov") ||
+      animLower?.endsWith("m4v") ||
+      animLower?.endsWith("ogv") ||
+      animLower?.endsWith("ogg"));
+  const hasAudio =
+    animLower?.endsWith("mp3") ||
+    animLower?.endsWith("flac") ||
+    animLower?.endsWith("wav") ||
+    animLower?.endsWith("oga");
+  const image = (
+    <img
+      src={safeIPFS(value.image || "")}
+      alt={value.name || ""}
+      style={{ maxWidth: "100%" }}
+    />
+  );
+  return (
+    <Card className={classes.card} elevation={10}>
+      <CardContent className={classes.textContent}>
+        <Typography>{(symbol ? symbol + " " : "") + value.mintKey}</Typography>
+        <Typography>
+          {value.name} ({value.tokenId})
+        </Typography>
+      </CardContent>
+      <CardMedia className={classes.mediaContent}>
+        {hasVideo ? (
+          <video controls style={{ maxWidth: "100%" }}>
+            <source src={safeIPFS(value.animation_url || "")} />
+            {image}
+          </video>
+        ) : (
+          image
+        )}
+        {hasAudio ? (
+          <audio controls src={safeIPFS(value.animation_url || "")} />
+        ) : null}
+      </CardMedia>
+    </Card>
+  );
+}

+ 9 - 4
bridge_ui/src/components/TokenSelectors/SolanaSourceTokenSelector.tsx

@@ -33,12 +33,13 @@ type SolanaSourceTokenSelectorProps = {
   metaplexData: any; //DataWrapper<(Metadata | undefined)[]> | undefined | null;
   disabled: boolean;
   mintAccounts: DataWrapper<Map<String, string | null>> | undefined;
+  nft?: boolean;
 };
 
 export default function SolanaSourceTokenSelector(
   props: SolanaSourceTokenSelectorProps
 ) {
-  const { value, onChange, disabled } = props;
+  const { value, onChange, disabled, nft } = props;
   const classes = useStyles();
 
   const memoizedTokenMap: Map<String, TokenInfo> = useMemo(() => {
@@ -196,9 +197,9 @@ export default function SolanaSourceTokenSelector(
       //TODO, do a better check which likely involves supply or checking masterEdition.
       const isNFT =
         x.decimals === 0 && memoizedMetaplex.get(x.mintKey)?.data?.uri;
-      return !isNFT;
+      return nft ? isNFT : !isNFT;
     });
-  }, [memoizedMetaplex, props.accounts]);
+  }, [memoizedMetaplex, nft, props.accounts]);
 
   const isOptionDisabled = useMemo(() => {
     return (value: ParsedTokenAccount) => isWormholev1(value.mintKey);
@@ -220,7 +221,11 @@ export default function SolanaSourceTokenSelector(
       disabled={disabled}
       options={filteredOptions}
       renderInput={(params) => (
-        <TextField {...params} label="Token Account" variant="outlined" />
+        <TextField
+          {...params}
+          label={nft ? "NFT Account" : "Token Account"}
+          variant="outlined"
+        />
       )}
       renderOption={(option) => {
         return renderAccount(

+ 32 - 7
bridge_ui/src/components/TokenSelectors/SourceTokenSelector.tsx

@@ -10,32 +10,50 @@ import { useDispatch, useSelector } from "react-redux";
 import useGetSourceParsedTokens from "../../hooks/useGetSourceParsedTokenAccounts";
 import useIsWalletReady from "../../hooks/useIsWalletReady";
 import {
+  selectNFTSourceChain,
+  selectNFTSourceParsedTokenAccount,
   selectTransferSourceChain,
   selectTransferSourceParsedTokenAccount,
 } from "../../store/selectors";
 import {
   ParsedTokenAccount,
-  setSourceParsedTokenAccount,
-  setSourceWalletAddress,
+  setSourceParsedTokenAccount as setTransferSourceParsedTokenAccount,
+  setSourceWalletAddress as setTransferSourceWalletAddress,
 } from "../../store/transferSlice";
+import {
+  setSourceParsedTokenAccount as setNFTSourceParsedTokenAccount,
+  setSourceWalletAddress as setNFTSourceWalletAddress,
+} from "../../store/nftSlice";
 import EthereumSourceTokenSelector from "./EthereumSourceTokenSelector";
 import SolanaSourceTokenSelector from "./SolanaSourceTokenSelector";
 import TerraSourceTokenSelector from "./TerraSourceTokenSelector";
 
 type TokenSelectorProps = {
   disabled: boolean;
+  nft?: boolean;
 };
 
 export const TokenSelector = (props: TokenSelectorProps) => {
-  const { disabled } = props;
+  const { disabled, nft } = props;
   const dispatch = useDispatch();
 
-  const lookupChain = useSelector(selectTransferSourceChain);
+  const lookupChain = useSelector(
+    nft ? selectNFTSourceChain : selectTransferSourceChain
+  );
   const sourceParsedTokenAccount = useSelector(
-    selectTransferSourceParsedTokenAccount
+    nft
+      ? selectNFTSourceParsedTokenAccount
+      : selectTransferSourceParsedTokenAccount
   );
   const walletIsReady = useIsWalletReady(lookupChain);
 
+  const setSourceParsedTokenAccount = nft
+    ? setNFTSourceParsedTokenAccount
+    : setTransferSourceParsedTokenAccount;
+  const setSourceWalletAddress = nft
+    ? setNFTSourceWalletAddress
+    : setTransferSourceWalletAddress;
+
   const handleOnChange = useCallback(
     (newTokenAccount: ParsedTokenAccount | null) => {
       if (!newTokenAccount) {
@@ -46,10 +64,15 @@ export const TokenSelector = (props: TokenSelectorProps) => {
         dispatch(setSourceWalletAddress(walletIsReady.walletAddress));
       }
     },
-    [dispatch, walletIsReady]
+    [
+      dispatch,
+      walletIsReady,
+      setSourceParsedTokenAccount,
+      setSourceWalletAddress,
+    ]
   );
 
-  const maps = useGetSourceParsedTokens();
+  const maps = useGetSourceParsedTokens(nft);
 
   //This is only for errors so bad that we shouldn't even mount the component
   const fatalError =
@@ -68,6 +91,7 @@ export const TokenSelector = (props: TokenSelectorProps) => {
       solanaTokenMap={maps?.tokenMap}
       metaplexData={maps?.metaplex}
       mintAccounts={maps?.mintAccounts}
+      nft={nft}
     />
   ) : lookupChain === CHAIN_ID_ETH ? (
     <EthereumSourceTokenSelector
@@ -76,6 +100,7 @@ export const TokenSelector = (props: TokenSelectorProps) => {
       onChange={handleOnChange}
       covalent={maps?.covalent || undefined}
       tokenAccounts={maps?.tokenAccounts} //TODO standardize
+      nft={nft}
     />
   ) : lookupChain === CHAIN_ID_TERRA ? (
     <TerraSourceTokenSelector

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

@@ -11,7 +11,7 @@ import { CHAINS_BY_ID } from "../../utils/consts";
 import ButtonWithLoader from "../ButtonWithLoader";
 import KeyAndBalance from "../KeyAndBalance";
 import StepDescription from "../StepDescription";
-import TransferProgress from "./TransferProgress";
+import TransferProgress from "../TransferProgress";
 
 function Send() {
   const { handleClick, disabled, showLoader } = useHandleTransfer();

+ 3 - 3
bridge_ui/src/components/Transfer/TransferProgress.tsx → bridge_ui/src/components/TransferProgress.tsx

@@ -3,13 +3,13 @@ import { LinearProgress, makeStyles, Typography } from "@material-ui/core";
 import { Connection } from "@solana/web3.js";
 import { useEffect, useState } from "react";
 import { useSelector } from "react-redux";
-import { useEthereumProvider } from "../../contexts/EthereumProviderContext";
+import { useEthereumProvider } from "../contexts/EthereumProviderContext";
 import {
   selectTransferIsSendComplete,
   selectTransferSourceChain,
   selectTransferTransferTx,
-} from "../../store/selectors";
-import { CHAINS_BY_ID, SOLANA_HOST } from "../../utils/consts";
+} from "../store/selectors";
+import { CHAINS_BY_ID, SOLANA_HOST } from "../utils/consts";
 
 const useStyles = makeStyles((theme) => ({
   root: {

+ 95 - 12
bridge_ui/src/hooks/useCheckIfWormholeWrapped.ts

@@ -1,29 +1,73 @@
 import {
+  ChainId,
   CHAIN_ID_ETH,
   CHAIN_ID_SOLANA,
   CHAIN_ID_TERRA,
+  getOriginalAssetEth,
+  getOriginalAssetSol,
+  getOriginalAssetTerra,
+  WormholeWrappedInfo,
 } from "@certusone/wormhole-sdk";
+import {
+  getOriginalAssetEth as getOriginalAssetEthNFT,
+  getOriginalAssetSol as getOriginalAssetSolNFT,
+} from "@certusone/wormhole-sdk/lib/nft_bridge";
+import { Connection } from "@solana/web3.js";
+import { LCDClient } from "@terra-money/terra.js";
 import { useEffect } from "react";
 import { useDispatch, useSelector } from "react-redux";
 import { useEthereumProvider } from "../contexts/EthereumProviderContext";
 import {
+  selectNFTSourceAsset,
+  selectNFTSourceChain,
+  selectNFTSourceParsedTokenAccount,
   selectTransferSourceAsset,
   selectTransferSourceChain,
 } from "../store/selectors";
-import { setSourceWormholeWrappedInfo } from "../store/transferSlice";
+import { setSourceWormholeWrappedInfo as setNFTSourceWormholeWrappedInfo } from "../store/nftSlice";
+import { setSourceWormholeWrappedInfo as setTransferSourceWormholeWrappedInfo } from "../store/transferSlice";
+import { uint8ArrayToHex } from "../utils/array";
 import {
-  getOriginalAssetEth,
-  getOriginalAssetSol,
-  getOriginalAssetTerra,
-} from "../utils/getOriginalAsset";
+  ETH_NFT_BRIDGE_ADDRESS,
+  ETH_TOKEN_BRIDGE_ADDRESS,
+  SOLANA_HOST,
+  SOL_NFT_BRIDGE_ADDRESS,
+  SOL_TOKEN_BRIDGE_ADDRESS,
+  TERRA_HOST,
+} from "../utils/consts";
+
+export interface StateSafeWormholeWrappedInfo {
+  isWrapped: boolean;
+  chainId: ChainId;
+  assetAddress: string;
+  tokenId?: string;
+}
+
+const makeStateSafe = (
+  info: WormholeWrappedInfo
+): StateSafeWormholeWrappedInfo => ({
+  ...info,
+  assetAddress: uint8ArrayToHex(info.assetAddress),
+});
 
 // Check if the tokens in the configured source chain/address are wrapped
 // tokens. Wrapped tokens are tokens that are non-native, I.E, are locked up on
 // a different chain than this one.
-function useCheckIfWormholeWrapped() {
+function useCheckIfWormholeWrapped(nft?: boolean) {
   const dispatch = useDispatch();
-  const sourceChain = useSelector(selectTransferSourceChain);
-  const sourceAsset = useSelector(selectTransferSourceAsset);
+  const sourceChain = useSelector(
+    nft ? selectNFTSourceChain : selectTransferSourceChain
+  );
+  const sourceAsset = useSelector(
+    nft ? selectNFTSourceAsset : selectTransferSourceAsset
+  );
+  const nftSourceParsedTokenAccount = useSelector(
+    selectNFTSourceParsedTokenAccount
+  );
+  const tokenId = nftSourceParsedTokenAccount?.tokenId || ""; // this should exist by this step for NFT transfers
+  const setSourceWormholeWrappedInfo = nft
+    ? setNFTSourceWormholeWrappedInfo
+    : setTransferSourceWormholeWrappedInfo;
   const { provider } = useEthereumProvider();
   useEffect(() => {
     // TODO: loading state, error state
@@ -31,14 +75,42 @@ function useCheckIfWormholeWrapped() {
     let cancelled = false;
     (async () => {
       if (sourceChain === CHAIN_ID_ETH && provider && sourceAsset) {
-        const wrappedInfo = await getOriginalAssetEth(provider, sourceAsset);
+        console.log("getting wrapped info");
+        const wrappedInfo = makeStateSafe(
+          await (nft
+            ? getOriginalAssetEthNFT(
+                ETH_NFT_BRIDGE_ADDRESS,
+                provider,
+                sourceAsset,
+                tokenId
+              )
+            : getOriginalAssetEth(
+                ETH_TOKEN_BRIDGE_ADDRESS,
+                provider,
+                sourceAsset
+              ))
+        );
+        console.log(wrappedInfo);
         if (!cancelled) {
           dispatch(setSourceWormholeWrappedInfo(wrappedInfo));
         }
       }
       if (sourceChain === CHAIN_ID_SOLANA && sourceAsset) {
         try {
-          const wrappedInfo = await getOriginalAssetSol(sourceAsset);
+          const connection = new Connection(SOLANA_HOST, "confirmed");
+          const wrappedInfo = makeStateSafe(
+            await (nft
+              ? getOriginalAssetSolNFT(
+                  connection,
+                  SOL_NFT_BRIDGE_ADDRESS,
+                  sourceAsset
+                )
+              : getOriginalAssetSol(
+                  connection,
+                  SOL_TOKEN_BRIDGE_ADDRESS,
+                  sourceAsset
+                ))
+          );
           if (!cancelled) {
             dispatch(setSourceWormholeWrappedInfo(wrappedInfo));
           }
@@ -46,7 +118,10 @@ function useCheckIfWormholeWrapped() {
       }
       if (sourceChain === CHAIN_ID_TERRA && sourceAsset) {
         try {
-          const wrappedInfo = await getOriginalAssetTerra(sourceAsset);
+          const lcd = new LCDClient(TERRA_HOST);
+          const wrappedInfo = makeStateSafe(
+            await getOriginalAssetTerra(lcd, sourceAsset)
+          );
           if (!cancelled) {
             dispatch(setSourceWormholeWrappedInfo(wrappedInfo));
           }
@@ -56,7 +131,15 @@ function useCheckIfWormholeWrapped() {
     return () => {
       cancelled = true;
     };
-  }, [dispatch, sourceChain, sourceAsset, provider]);
+  }, [
+    dispatch,
+    sourceChain,
+    sourceAsset,
+    provider,
+    nft,
+    setSourceWormholeWrappedInfo,
+    tokenId,
+  ]);
 }
 
 export default useCheckIfWormholeWrapped;

+ 91 - 20
bridge_ui/src/hooks/useFetchTargetAsset.ts

@@ -2,32 +2,62 @@ import {
   CHAIN_ID_ETH,
   CHAIN_ID_SOLANA,
   CHAIN_ID_TERRA,
+  getForeignAssetEth,
+  getForeignAssetSolana,
+  getForeignAssetTerra,
 } from "@certusone/wormhole-sdk";
+import {
+  getForeignAssetEth as getForeignAssetEthNFT,
+  getForeignAssetSol as getForeignAssetSolNFT,
+} from "@certusone/wormhole-sdk/lib/nft_bridge";
+import { Connection } from "@solana/web3.js";
+import { LCDClient } from "@terra-money/terra.js";
 import { useEffect } from "react";
 import { useDispatch, useSelector } from "react-redux";
 import { useEthereumProvider } from "../contexts/EthereumProviderContext";
+import { setTargetAsset as setNFTTargetAsset } from "../store/nftSlice";
 import {
+  selectNFTIsSourceAssetWormholeWrapped,
+  selectNFTOriginAsset,
+  selectNFTOriginChain,
+  selectNFTOriginTokenId,
+  selectNFTTargetChain,
   selectTransferIsSourceAssetWormholeWrapped,
   selectTransferOriginAsset,
   selectTransferOriginChain,
   selectTransferTargetChain,
 } from "../store/selectors";
-import { setTargetAsset } from "../store/transferSlice";
-import { hexToNativeString } from "../utils/array";
+import { setTargetAsset as setTransferTargetAsset } from "../store/transferSlice";
+import { hexToNativeString, hexToUint8Array } from "../utils/array";
 import {
-  getForeignAssetEth,
-  getForeignAssetSol,
-  getForeignAssetTerra,
-} from "../utils/getForeignAsset";
+  ETH_NFT_BRIDGE_ADDRESS,
+  ETH_TOKEN_BRIDGE_ADDRESS,
+  SOLANA_HOST,
+  SOL_NFT_BRIDGE_ADDRESS,
+  SOL_TOKEN_BRIDGE_ADDRESS,
+  TERRA_HOST,
+  TERRA_TOKEN_BRIDGE_ADDRESS,
+} from "../utils/consts";
 
-function useFetchTargetAsset() {
+function useFetchTargetAsset(nft?: boolean) {
   const dispatch = useDispatch();
   const isSourceAssetWormholeWrapped = useSelector(
-    selectTransferIsSourceAssetWormholeWrapped
+    nft
+      ? selectNFTIsSourceAssetWormholeWrapped
+      : selectTransferIsSourceAssetWormholeWrapped
+  );
+  const originChain = useSelector(
+    nft ? selectNFTOriginChain : selectTransferOriginChain
+  );
+  const originAsset = useSelector(
+    nft ? selectNFTOriginAsset : selectTransferOriginAsset
+  );
+  const originTokenId = useSelector(selectNFTOriginTokenId);
+  const tokenId = originTokenId || ""; // this should exist by this step for NFT transfers
+  const targetChain = useSelector(
+    nft ? selectNFTTargetChain : selectTransferTargetChain
   );
-  const originChain = useSelector(selectTransferOriginChain);
-  const originAsset = useSelector(selectTransferOriginAsset);
-  const targetChain = useSelector(selectTransferTargetChain);
+  const setTargetAsset = nft ? setNFTTargetAsset : setTransferTargetAsset;
   const { provider } = useEthereumProvider();
   useEffect(() => {
     if (isSourceAssetWormholeWrapped && originChain === targetChain) {
@@ -44,36 +74,74 @@ function useFetchTargetAsset() {
         originChain &&
         originAsset
       ) {
-        const asset = await getForeignAssetEth(
-          provider,
-          originChain,
-          originAsset
-        );
-        if (!cancelled) {
-          dispatch(setTargetAsset(asset));
+        try {
+          const asset = await (nft
+            ? getForeignAssetEthNFT(
+                ETH_NFT_BRIDGE_ADDRESS,
+                provider,
+                originChain,
+                hexToUint8Array(originAsset)
+              )
+            : getForeignAssetEth(
+                ETH_TOKEN_BRIDGE_ADDRESS,
+                provider,
+                originChain,
+                hexToUint8Array(originAsset)
+              ));
+          if (!cancelled) {
+            dispatch(setTargetAsset(asset));
+          }
+        } catch (e) {
+          if (!cancelled) {
+            // TODO: warning for this
+            dispatch(setTargetAsset(null));
+          }
         }
       }
       if (targetChain === CHAIN_ID_SOLANA && originChain && originAsset) {
         try {
-          const asset = await getForeignAssetSol(originChain, originAsset);
+          const connection = new Connection(SOLANA_HOST, "confirmed");
+          const asset = await (nft
+            ? getForeignAssetSolNFT(
+                SOL_NFT_BRIDGE_ADDRESS,
+                originChain,
+                hexToUint8Array(originAsset),
+                new Uint8Array([0, 0, 0, 0]) //tokenId // TODO: string
+              )
+            : getForeignAssetSolana(
+                connection,
+                SOL_TOKEN_BRIDGE_ADDRESS,
+                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));
           }
         }
       }
       if (targetChain === CHAIN_ID_TERRA && originChain && originAsset) {
         try {
-          const asset = await getForeignAssetTerra(originChain, originAsset);
+          const lcd = new LCDClient(TERRA_HOST);
+          const asset = await getForeignAssetTerra(
+            TERRA_TOKEN_BRIDGE_ADDRESS,
+            lcd,
+            originChain,
+            hexToUint8Array(originAsset)
+          );
           if (!cancelled) {
             dispatch(setTargetAsset(asset));
           }
         } catch (e) {
           if (!cancelled) {
             // TODO: warning for this
+            dispatch(setTargetAsset(null));
           }
         }
       }
@@ -88,6 +156,9 @@ function useFetchTargetAsset() {
     originAsset,
     targetChain,
     provider,
+    nft,
+    setTargetAsset,
+    tokenId,
   ]);
 }
 

+ 173 - 29
bridge_ui/src/hooks/useGetSourceParsedTokenAccounts.ts

@@ -20,6 +20,18 @@ import { useEthereumProvider } from "../contexts/EthereumProviderContext";
 import { useSolanaWallet } from "../contexts/SolanaWalletContext";
 import { DataWrapper } from "../store/helpers";
 import {
+  errorSourceParsedTokenAccounts as errorSourceParsedTokenAccountsNFT,
+  fetchSourceParsedTokenAccounts as fetchSourceParsedTokenAccountsNFT,
+  NFTParsedTokenAccount,
+  receiveSourceParsedTokenAccounts as receiveSourceParsedTokenAccountsNFT,
+  setSourceParsedTokenAccount as setSourceParsedTokenAccountNFT,
+  setSourceParsedTokenAccounts as setSourceParsedTokenAccountsNFT,
+  setSourceWalletAddress as setSourceWalletAddressNFT,
+} from "../store/nftSlice";
+import {
+  selectNFTSourceChain,
+  selectNFTSourceParsedTokenAccounts,
+  selectNFTSourceWalletAddress,
   selectSolanaTokenMap,
   selectSourceWalletAddress,
   selectTerraTokenMap,
@@ -78,6 +90,36 @@ export function createParsedTokenAccount(
   };
 }
 
+export function createNFTParsedTokenAccount(
+  publicKey: string,
+  mintKey: string,
+  amount: string,
+  decimals: number,
+  uiAmount: number,
+  uiAmountString: string,
+  tokenId: string,
+  animation_url?: string,
+  external_url?: string,
+  image?: string,
+  image_256?: string,
+  name?: string
+): NFTParsedTokenAccount {
+  return {
+    publicKey,
+    mintKey,
+    amount,
+    decimals,
+    uiAmount,
+    uiAmountString,
+    tokenId,
+    animation_url,
+    external_url,
+    image,
+    image_256,
+    name,
+  };
+}
+
 export type TerraTokenMetadata = {
   protocol: string;
   symbol: string;
@@ -119,6 +161,32 @@ const createParsedTokenAccountFromCovalent = (
   };
 };
 
+const createNFTParsedTokenAccountFromCovalent = (
+  walletAddress: string,
+  covalent: CovalentData,
+  nft_data: CovalentNFTData
+): NFTParsedTokenAccount => {
+  return {
+    publicKey: walletAddress,
+    mintKey: covalent.contract_address,
+    amount: nft_data.token_balance,
+    decimals: covalent.contract_decimals,
+    uiAmount: Number(
+      formatUnits(nft_data.token_balance, covalent.contract_decimals)
+    ),
+    uiAmountString: formatUnits(
+      nft_data.token_balance,
+      covalent.contract_decimals
+    ),
+    tokenId: nft_data.token_id,
+    animation_url: nft_data.external_data.animation_url,
+    external_url: nft_data.external_data.external_url,
+    image: nft_data.external_data.image,
+    image_256: nft_data.external_data.image_256,
+    name: nft_data.external_data.name,
+  };
+};
+
 export type CovalentData = {
   contract_decimals: number;
   contract_ticker_symbol: string;
@@ -128,12 +196,28 @@ export type CovalentData = {
   balance: string;
   quote: number | undefined;
   quote_rate: number | undefined;
+  nft_data?: CovalentNFTData[];
+};
+
+export type CovalentNFTExternalData = {
+  animation_url: string | null;
+  external_url: string | null;
+  image: string;
+  image_256: string;
+  name: string;
+};
+
+export type CovalentNFTData = {
+  token_id: string;
+  token_balance: string;
+  external_data: CovalentNFTExternalData;
 };
 
 const getEthereumAccountsCovalent = async (
-  walletAddress: string
+  walletAddress: string,
+  nft?: boolean
 ): Promise<CovalentData[]> => {
-  const url = COVALENT_GET_TOKENS_URL(CHAIN_ID_ETH, walletAddress);
+  const url = COVALENT_GET_TOKENS_URL(CHAIN_ID_ETH, walletAddress, nft);
 
   try {
     const output = [] as CovalentData[];
@@ -144,11 +228,13 @@ const getEthereumAccountsCovalent = async (
       for (const item of tokens) {
         // TODO: filter?
         if (
-          item.contract_decimals &&
+          item.contract_decimals !== undefined &&
           item.contract_ticker_symbol &&
           item.contract_address &&
           item.balance &&
-          item.supports_erc?.includes("erc20")
+          (nft
+            ? item.supports_erc?.includes("erc721")
+            : item.supports_erc?.includes("erc20"))
         ) {
           output.push({ ...item } as CovalentData);
         }
@@ -199,10 +285,13 @@ const getMetaplexData = async (mintAddresses: string[]) => {
 
 const getSolanaParsedTokenAccounts = (
   walletAddress: string,
-  dispatch: Dispatch
+  dispatch: Dispatch,
+  nft: boolean
 ) => {
   const connection = new Connection(SOLANA_HOST, "finalized");
-  dispatch(fetchSourceParsedTokenAccounts());
+  dispatch(
+    nft ? fetchSourceParsedTokenAccountsNFT() : fetchSourceParsedTokenAccounts()
+  );
   return connection
     .getParsedTokenAccountsByOwner(new PublicKey(walletAddress), {
       programId: new PublicKey(TOKEN_PROGRAM_ID),
@@ -212,11 +301,17 @@ const getSolanaParsedTokenAccounts = (
         const mappedItems = result.value.map((item) =>
           createParsedTokenAccountFromInfo(item.pubkey, item.account)
         );
-        dispatch(receiveSourceParsedTokenAccounts(mappedItems));
+        dispatch(
+          nft
+            ? receiveSourceParsedTokenAccountsNFT(mappedItems)
+            : receiveSourceParsedTokenAccounts(mappedItems)
+        );
       },
       (error) => {
         dispatch(
-          errorSourceParsedTokenAccounts("Failed to load token metadata.")
+          nft
+            ? errorSourceParsedTokenAccountsNFT("Failed to load NFT metadata")
+            : errorSourceParsedTokenAccounts("Failed to load token metadata.")
         );
       }
     );
@@ -240,14 +335,20 @@ const getSolanaTokenMap = (dispatch: Dispatch) => {
  * Fetches the balance of an asset for the connected wallet
  * This should handle every type of chain in the future, but only reads the Transfer state.
  */
-function useGetAvailableTokens() {
+function useGetAvailableTokens(nft: boolean = false) {
   const dispatch = useDispatch();
 
-  const tokenAccounts = useSelector(selectTransferSourceParsedTokenAccounts);
+  const tokenAccounts = useSelector(
+    nft
+      ? selectNFTSourceParsedTokenAccounts
+      : selectTransferSourceParsedTokenAccounts
+  );
   const solanaTokenMap = useSelector(selectSolanaTokenMap);
   const terraTokenMap = useSelector(selectTerraTokenMap);
 
-  const lookupChain = useSelector(selectTransferSourceChain);
+  const lookupChain = useSelector(
+    nft ? selectNFTSourceChain : selectTransferSourceChain
+  );
   const solanaWallet = useSolanaWallet();
   const solPK = solanaWallet?.publicKey;
   //const terraWallet = useConnectedWallet(); //TODO
@@ -270,7 +371,9 @@ function useGetAvailableTokens() {
     string | undefined
   >(undefined);
 
-  const selectedSourceWalletAddress = useSelector(selectSourceWalletAddress);
+  const selectedSourceWalletAddress = useSelector(
+    nft ? selectNFTSourceWalletAddress : selectSourceWalletAddress
+  );
   const currentSourceWalletAddress: string | undefined =
     lookupChain === CHAIN_ID_ETH
       ? signerAddress
@@ -286,14 +389,26 @@ function useGetAvailableTokens() {
       currentSourceWalletAddress !== undefined &&
       currentSourceWalletAddress !== selectedSourceWalletAddress
     ) {
-      dispatch(setSourceWalletAddress(undefined));
-      dispatch(setSourceParsedTokenAccount(undefined));
-      dispatch(setSourceParsedTokenAccounts(undefined));
-      dispatch(setAmount(""));
+      dispatch(
+        nft
+          ? setSourceWalletAddressNFT(undefined)
+          : setSourceWalletAddress(undefined)
+      );
+      dispatch(
+        nft
+          ? setSourceParsedTokenAccountNFT(undefined)
+          : setSourceParsedTokenAccount(undefined)
+      );
+      dispatch(
+        nft
+          ? setSourceParsedTokenAccountsNFT(undefined)
+          : setSourceParsedTokenAccounts(undefined)
+      );
+      !nft && dispatch(setAmount(""));
       return;
     } else {
     }
-  }, [selectedSourceWalletAddress, currentSourceWalletAddress, dispatch]);
+  }, [selectedSourceWalletAddress, currentSourceWalletAddress, dispatch, nft]);
 
   // Solana metaplex load
   useEffect(() => {
@@ -333,7 +448,7 @@ function useGetAvailableTokens() {
       if (
         !(tokenAccounts.data || tokenAccounts.isFetching || tokenAccounts.error)
       ) {
-        getSolanaParsedTokenAccounts(solPK.toString(), dispatch);
+        getSolanaParsedTokenAccounts(solPK.toString(), dispatch, nft);
       }
       if (
         !(
@@ -354,6 +469,7 @@ function useGetAvailableTokens() {
     solPK,
     tokenAccounts,
     solanaTokenMap,
+    nft,
   ]);
 
   //Solana Mint Accounts lookup
@@ -406,6 +522,8 @@ function useGetAvailableTokens() {
   //Ethereum accounts load
   useEffect(() => {
     //const testWallet = "0xf60c2ea62edbfe808163751dd0d8693dcb30019c";
+    // const nftTestWallet1 = "0x3f304c6721f35ff9af00fd32650c8e0a982180ab";
+    // const nftTestWallet2 = "0x98ed231428088eb440e8edb5cc8d66dcf913b86e";
     let cancelled = false;
     const walletAddress = signerAddress;
     if (!walletAddress || lookupChain !== CHAIN_ID_ETH) {
@@ -413,27 +531,53 @@ function useGetAvailableTokens() {
     }
     //TODO less cancel
     !cancelled && setCovalentLoading(true);
-    !cancelled && dispatch(fetchSourceParsedTokenAccounts());
-    getEthereumAccountsCovalent(walletAddress).then(
+    !cancelled &&
+      dispatch(
+        nft
+          ? fetchSourceParsedTokenAccountsNFT()
+          : fetchSourceParsedTokenAccounts()
+      );
+    getEthereumAccountsCovalent(walletAddress, nft).then(
       (accounts) => {
         !cancelled && setCovalentLoading(false);
         !cancelled && setCovalentError(undefined);
         !cancelled && setCovalent(accounts);
         !cancelled &&
           dispatch(
-            receiveSourceParsedTokenAccounts(
-              accounts.map((x) =>
-                createParsedTokenAccountFromCovalent(walletAddress, x)
-              )
-            )
+            nft
+              ? receiveSourceParsedTokenAccountsNFT(
+                  accounts.reduce((arr, current) => {
+                    if (current.nft_data) {
+                      current.nft_data.forEach((x) =>
+                        arr.push(
+                          createNFTParsedTokenAccountFromCovalent(
+                            walletAddress,
+                            current,
+                            x
+                          )
+                        )
+                      );
+                    }
+                    return arr;
+                  }, [] as NFTParsedTokenAccount[])
+                )
+              : receiveSourceParsedTokenAccounts(
+                  accounts.map((x) =>
+                    createParsedTokenAccountFromCovalent(walletAddress, x)
+                  )
+                )
           );
       },
       () => {
         !cancelled &&
           dispatch(
-            errorSourceParsedTokenAccounts(
-              "Cannot load your Ethereum tokens at the moment."
-            )
+            nft
+              ? errorSourceParsedTokenAccountsNFT(
+                  "Cannot load your Ethereum NFTs at the moment."
+                )
+              : errorSourceParsedTokenAccounts(
+                  "Cannot load your Ethereum tokens at the moment."
+                )
           );
         !cancelled &&
           setCovalentError("Cannot load your Ethereum tokens at the moment.");
@@ -444,7 +588,7 @@ function useGetAvailableTokens() {
     return () => {
       cancelled = true;
     };
-  }, [lookupChain, provider, signerAddress, dispatch]);
+  }, [lookupChain, provider, signerAddress, dispatch, nft]);
 
   //Terra accounts load
   //At present, we don't have any mechanism for doing this.

+ 135 - 0
bridge_ui/src/hooks/useHandleNFTRedeem.ts

@@ -0,0 +1,135 @@
+import {
+  CHAIN_ID_ETH,
+  CHAIN_ID_SOLANA,
+  postVaaSolana,
+} from "@certusone/wormhole-sdk";
+import {
+  redeemOnEth,
+  redeemOnSolana,
+} 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 { useSnackbar } from "notistack";
+import { useCallback, useMemo } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import { useEthereumProvider } from "../contexts/EthereumProviderContext";
+import { useSolanaWallet } from "../contexts/SolanaWalletContext";
+import { setIsRedeeming, setRedeemTx } from "../store/nftSlice";
+import { selectNFTIsRedeeming, selectNFTTargetChain } from "../store/selectors";
+import {
+  ETH_NFT_BRIDGE_ADDRESS,
+  SOLANA_HOST,
+  SOL_BRIDGE_ADDRESS,
+  SOL_NFT_BRIDGE_ADDRESS,
+} from "../utils/consts";
+import parseError from "../utils/parseError";
+import { signSendAndConfirm } from "../utils/solana";
+import useNFTSignedVAA from "./useNFTSignedVAA";
+
+async function eth(
+  dispatch: any,
+  enqueueSnackbar: any,
+  signer: Signer,
+  signedVAA: Uint8Array
+) {
+  dispatch(setIsRedeeming(true));
+  try {
+    const receipt = await redeemOnEth(
+      ETH_NFT_BRIDGE_ADDRESS,
+      signer,
+      signedVAA
+    );
+    dispatch(
+      setRedeemTx({ id: receipt.transactionHash, block: receipt.blockNumber })
+    );
+    enqueueSnackbar("Transaction confirmed", { variant: "success" });
+  } catch (e) {
+    enqueueSnackbar(parseError(e), { variant: "error" });
+    dispatch(setIsRedeeming(false));
+  }
+}
+
+async function solana(
+  dispatch: any,
+  enqueueSnackbar: any,
+  wallet: WalletContextState,
+  payerAddress: string, //TODO: we may not need this since we have wallet
+  signedVAA: Uint8Array
+) {
+  dispatch(setIsRedeeming(true));
+  try {
+    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,
+      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 }));
+    enqueueSnackbar("Transaction confirmed", { variant: "success" });
+  } catch (e) {
+    enqueueSnackbar(parseError(e), { variant: "error" });
+    dispatch(setIsRedeeming(false));
+  }
+}
+
+export function useHandleNFTRedeem() {
+  const dispatch = useDispatch();
+  const { enqueueSnackbar } = useSnackbar();
+  const targetChain = useSelector(selectNFTTargetChain);
+  const solanaWallet = useSolanaWallet();
+  const solPK = solanaWallet?.publicKey;
+  const { signer } = useEthereumProvider();
+  const signedVAA = useNFTSignedVAA();
+  const isRedeeming = useSelector(selectNFTIsRedeeming);
+  const handleRedeemClick = useCallback(() => {
+    if (targetChain === CHAIN_ID_ETH && !!signer && signedVAA) {
+      eth(dispatch, enqueueSnackbar, signer, signedVAA);
+    } else if (
+      targetChain === CHAIN_ID_SOLANA &&
+      !!solanaWallet &&
+      !!solPK &&
+      signedVAA
+    ) {
+      solana(
+        dispatch,
+        enqueueSnackbar,
+        solanaWallet,
+        solPK.toString(),
+        signedVAA
+      );
+    } else {
+      // enqueueSnackbar("Redeeming on this chain is not yet supported", {
+      //   variant: "error",
+      // });
+    }
+  }, [
+    dispatch,
+    enqueueSnackbar,
+    targetChain,
+    signer,
+    signedVAA,
+    solanaWallet,
+    solPK,
+  ]);
+  return useMemo(
+    () => ({
+      handleClick: handleRedeemClick,
+      disabled: !!isRedeeming,
+      showLoader: !!isRedeeming,
+    }),
+    [handleRedeemClick, isRedeeming]
+  );
+}

+ 245 - 0
bridge_ui/src/hooks/useHandleNFTTransfer.ts

@@ -0,0 +1,245 @@
+import {
+  ChainId,
+  CHAIN_ID_ETH,
+  CHAIN_ID_SOLANA,
+  getEmitterAddressEth,
+  getEmitterAddressSolana,
+  parseSequenceFromLogEth,
+  parseSequenceFromLogSolana,
+} from "@certusone/wormhole-sdk";
+import {
+  transferFromEth,
+  transferFromSolana,
+} 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 { useSnackbar } from "notistack";
+import { useCallback, useMemo } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import { useEthereumProvider } from "../contexts/EthereumProviderContext";
+import { useSolanaWallet } from "../contexts/SolanaWalletContext";
+import {
+  setIsSending,
+  setSignedVAAHex,
+  setTransferTx,
+} from "../store/nftSlice";
+import {
+  selectNFTIsSendComplete,
+  selectNFTIsSending,
+  selectNFTIsTargetComplete,
+  selectNFTOriginAsset,
+  selectNFTOriginChain,
+  selectNFTOriginTokenId,
+  selectNFTSourceAsset,
+  selectNFTSourceChain,
+  selectNFTSourceParsedTokenAccount,
+  selectNFTTargetChain,
+} from "../store/selectors";
+import { hexToUint8Array, uint8ArrayToHex } from "../utils/array";
+import {
+  ETH_BRIDGE_ADDRESS,
+  ETH_NFT_BRIDGE_ADDRESS,
+  SOLANA_HOST,
+  SOL_BRIDGE_ADDRESS,
+  SOL_NFT_BRIDGE_ADDRESS,
+} from "../utils/consts";
+import { getSignedVAAWithRetry } from "../utils/getSignedVAAWithRetry";
+import parseError from "../utils/parseError";
+import { signSendAndConfirm } from "../utils/solana";
+import useNFTTargetAddressHex from "./useNFTTargetAddress";
+
+async function eth(
+  dispatch: any,
+  enqueueSnackbar: any,
+  signer: Signer,
+  tokenAddress: string,
+  tokenId: string,
+  recipientChain: ChainId,
+  recipientAddress: Uint8Array
+) {
+  dispatch(setIsSending(true));
+  try {
+    const receipt = await transferFromEth(
+      ETH_NFT_BRIDGE_ADDRESS,
+      signer,
+      tokenAddress,
+      tokenId,
+      recipientChain,
+      recipientAddress
+    );
+    dispatch(
+      setTransferTx({ id: receipt.transactionHash, block: receipt.blockNumber })
+    );
+    enqueueSnackbar("Transaction confirmed", { variant: "success" });
+    const sequence = parseSequenceFromLogEth(receipt, ETH_BRIDGE_ADDRESS);
+    const emitterAddress = getEmitterAddressEth(ETH_NFT_BRIDGE_ADDRESS);
+    enqueueSnackbar("Fetching VAA", { variant: "info" });
+    const { vaaBytes } = await getSignedVAAWithRetry(
+      CHAIN_ID_ETH,
+      emitterAddress,
+      sequence.toString()
+    );
+    dispatch(setSignedVAAHex(uint8ArrayToHex(vaaBytes)));
+    enqueueSnackbar("Fetched Signed VAA", { variant: "success" });
+  } catch (e) {
+    console.error(e);
+    enqueueSnackbar(parseError(e), { variant: "error" });
+    dispatch(setIsSending(false));
+  }
+}
+
+async function solana(
+  dispatch: any,
+  enqueueSnackbar: any,
+  wallet: WalletContextState,
+  payerAddress: string, //TODO: we may not need this since we have wallet
+  fromAddress: string,
+  mintAddress: string,
+  targetChain: ChainId,
+  targetAddress: Uint8Array,
+  originAddressStr?: string,
+  originChain?: ChainId,
+  originTokenId?: string
+) {
+  dispatch(setIsSending(true));
+  try {
+    const connection = new Connection(SOLANA_HOST, "confirmed");
+    const originAddress = originAddressStr
+      ? zeroPad(hexToUint8Array(originAddressStr), 32)
+      : undefined;
+    const transaction = await transferFromSolana(
+      connection,
+      SOL_BRIDGE_ADDRESS,
+      SOL_NFT_BRIDGE_ADDRESS,
+      payerAddress,
+      fromAddress,
+      mintAddress,
+      targetAddress,
+      targetChain,
+      originAddress,
+      originChain,
+      new Uint8Array([0, 0, 0, 0]) //originTokenId //TODO: string
+    );
+    const txid = await signSendAndConfirm(wallet, connection, transaction);
+    enqueueSnackbar("Transaction confirmed", { variant: "success" });
+    const info = await connection.getTransaction(txid);
+    if (!info) {
+      throw new Error("An error occurred while fetching the transaction info");
+    }
+    dispatch(setTransferTx({ id: txid, block: info.slot }));
+    const sequence = parseSequenceFromLogSolana(info);
+    const emitterAddress = await getEmitterAddressSolana(
+      SOL_NFT_BRIDGE_ADDRESS
+    );
+    enqueueSnackbar("Fetching VAA", { variant: "info" });
+    const { vaaBytes } = await getSignedVAAWithRetry(
+      CHAIN_ID_SOLANA,
+      emitterAddress,
+      sequence
+    );
+
+    dispatch(setSignedVAAHex(uint8ArrayToHex(vaaBytes)));
+    enqueueSnackbar("Fetched Signed VAA", { variant: "success" });
+  } catch (e) {
+    console.error(e);
+    enqueueSnackbar(parseError(e), { variant: "error" });
+    dispatch(setIsSending(false));
+  }
+}
+
+export function useHandleNFTTransfer() {
+  const dispatch = useDispatch();
+  const { enqueueSnackbar } = useSnackbar();
+  const sourceChain = useSelector(selectNFTSourceChain);
+  const sourceAsset = useSelector(selectNFTSourceAsset);
+  const nftSourceParsedTokenAccount = useSelector(
+    selectNFTSourceParsedTokenAccount
+  );
+  const sourceTokenId = nftSourceParsedTokenAccount?.tokenId || ""; // this should exist by this step for NFT transfers
+  const originChain = useSelector(selectNFTOriginChain);
+  const originAsset = useSelector(selectNFTOriginAsset);
+  const originTokenId = useSelector(selectNFTOriginTokenId);
+  const targetChain = useSelector(selectNFTTargetChain);
+  const targetAddress = useNFTTargetAddressHex();
+  const isTargetComplete = useSelector(selectNFTIsTargetComplete);
+  const isSending = useSelector(selectNFTIsSending);
+  const isSendComplete = useSelector(selectNFTIsSendComplete);
+  const { signer } = useEthereumProvider();
+  const solanaWallet = useSolanaWallet();
+  const solPK = solanaWallet?.publicKey;
+  const sourceParsedTokenAccount = useSelector(
+    selectNFTSourceParsedTokenAccount
+  );
+  const sourceTokenPublicKey = sourceParsedTokenAccount?.publicKey;
+  const disabled = !isTargetComplete || isSending || isSendComplete;
+  const handleTransferClick = useCallback(() => {
+    // TODO: we should separate state for transaction vs fetching vaa
+    if (
+      sourceChain === CHAIN_ID_ETH &&
+      !!signer &&
+      !!sourceAsset &&
+      !!sourceTokenId &&
+      !!targetAddress
+    ) {
+      eth(
+        dispatch,
+        enqueueSnackbar,
+        signer,
+        sourceAsset,
+        sourceTokenId,
+        targetChain,
+        targetAddress
+      );
+    } else if (
+      sourceChain === CHAIN_ID_SOLANA &&
+      !!solanaWallet &&
+      !!solPK &&
+      !!sourceAsset &&
+      !!sourceTokenPublicKey &&
+      !!targetAddress
+    ) {
+      solana(
+        dispatch,
+        enqueueSnackbar,
+        solanaWallet,
+        solPK.toString(),
+        sourceTokenPublicKey,
+        sourceAsset,
+        targetChain,
+        targetAddress,
+        originAsset,
+        originChain,
+        originTokenId
+      );
+    } else {
+      // enqueueSnackbar("Transfers from this chain are not yet supported", {
+      //   variant: "error",
+      // });
+    }
+  }, [
+    dispatch,
+    enqueueSnackbar,
+    sourceChain,
+    signer,
+    solanaWallet,
+    solPK,
+    sourceTokenPublicKey,
+    sourceAsset,
+    sourceTokenId,
+    targetChain,
+    targetAddress,
+    originAsset,
+    originChain,
+    originTokenId,
+  ]);
+  return useMemo(
+    () => ({
+      handleClick: handleTransferClick,
+      disabled,
+      showLoader: isSending,
+    }),
+    [handleTransferClick, disabled, isSending]
+  );
+}

+ 13 - 0
bridge_ui/src/hooks/useNFTSignedVAA.ts

@@ -0,0 +1,13 @@
+import { useMemo } from "react";
+import { useSelector } from "react-redux";
+import { selectNFTSignedVAAHex } from "../store/selectors";
+import { hexToUint8Array } from "../utils/array";
+
+export default function useNFTSignedVAA() {
+  const signedVAAHex = useSelector(selectNFTSignedVAAHex);
+  const signedVAA = useMemo(
+    () => (signedVAAHex ? hexToUint8Array(signedVAAHex) : undefined),
+    [signedVAAHex]
+  );
+  return signedVAA;
+}

+ 13 - 0
bridge_ui/src/hooks/useNFTTargetAddress.ts

@@ -0,0 +1,13 @@
+import { useMemo } from "react";
+import { useSelector } from "react-redux";
+import { selectNFTTargetAddressHex } from "../store/selectors";
+import { hexToUint8Array } from "../utils/array";
+
+export default function useNFTTargetAddressHex() {
+  const targetAddressHex = useSelector(selectNFTTargetAddressHex);
+  const targetAddress = useMemo(
+    () => (targetAddressHex ? hexToUint8Array(targetAddressHex) : undefined),
+    [targetAddressHex]
+  );
+  return targetAddress;
+}

+ 26 - 6
bridge_ui/src/hooks/useSyncTargetAddress.ts

@@ -17,25 +17,35 @@ import { useDispatch, useSelector } from "react-redux";
 import { useEthereumProvider } from "../contexts/EthereumProviderContext";
 import { useSolanaWallet } from "../contexts/SolanaWalletContext";
 import {
+  selectNFTTargetAsset,
+  selectNFTTargetChain,
   selectTransferTargetAsset,
   selectTransferTargetChain,
   selectTransferTargetParsedTokenAccount,
 } from "../store/selectors";
-import { setTargetAddressHex } from "../store/transferSlice";
+import { setTargetAddressHex as setNFTTargetAddressHex } from "../store/nftSlice";
+import { setTargetAddressHex as setTransferTargetAddressHex } from "../store/transferSlice";
 import { uint8ArrayToHex } from "../utils/array";
 
-function useSyncTargetAddress(shouldFire: boolean) {
+function useSyncTargetAddress(shouldFire: boolean, nft?: boolean) {
   const dispatch = useDispatch();
-  const targetChain = useSelector(selectTransferTargetChain);
+  const targetChain = useSelector(
+    nft ? selectNFTTargetChain : selectTransferTargetChain
+  );
   const { signerAddress } = useEthereumProvider();
   const solanaWallet = useSolanaWallet();
   const solPK = solanaWallet?.publicKey;
-  const targetAsset = useSelector(selectTransferTargetAsset);
+  const targetAsset = useSelector(
+    nft ? selectNFTTargetAsset : selectTransferTargetAsset
+  );
   const targetParsedTokenAccount = useSelector(
     selectTransferTargetParsedTokenAccount
   );
   const targetTokenAccountPublicKey = targetParsedTokenAccount?.publicKey;
   const terraWallet = useConnectedWallet();
+  const setTargetAddressHex = nft
+    ? setNFTTargetAddressHex
+    : setTransferTargetAddressHex;
   useEffect(() => {
     if (shouldFire) {
       let cancelled = false;
@@ -47,7 +57,11 @@ function useSyncTargetAddress(shouldFire: boolean) {
         );
       }
       // TODO: have the user explicitly select an account on solana
-      else if (targetChain === CHAIN_ID_SOLANA && targetTokenAccountPublicKey) {
+      else if (
+        !nft && // only support existing, non-derived token accounts for token transfers (nft flow doesn't check balance)
+        targetChain === CHAIN_ID_SOLANA &&
+        targetTokenAccountPublicKey
+      ) {
         // use the target's TokenAccount if it exists
         dispatch(
           setTargetAddressHex(
@@ -74,7 +88,11 @@ function useSyncTargetAddress(shouldFire: boolean) {
                 )
               );
             }
-          } catch (e) {}
+          } catch (e) {
+            if (!cancelled) {
+              dispatch(setTargetAddressHex(undefined));
+            }
+          }
         })();
       } else if (
         targetChain === CHAIN_ID_TERRA &&
@@ -104,6 +122,8 @@ function useSyncTargetAddress(shouldFire: boolean) {
     targetAsset,
     targetTokenAccountPublicKey,
     terraWallet,
+    nft,
+    setTargetAddressHex,
   ]);
 }
 

+ 2 - 0
bridge_ui/src/store/index.ts

@@ -1,11 +1,13 @@
 import { configureStore } from "@reduxjs/toolkit";
 import attestReducer from "./attestSlice";
+import nftReducer from "./nftSlice";
 import transferReducer from "./transferSlice";
 import tokenReducer from "./tokenSlice";
 
 export const store = configureStore({
   reducer: {
     attest: attestReducer,
+    nft: nftReducer,
     transfer: transferReducer,
     tokens: tokenReducer,
   },

+ 231 - 0
bridge_ui/src/store/nftSlice.ts

@@ -0,0 +1,231 @@
+import {
+  ChainId,
+  CHAIN_ID_ETH,
+  CHAIN_ID_SOLANA,
+} from "@certusone/wormhole-sdk";
+import { createSlice, PayloadAction } from "@reduxjs/toolkit";
+import { StateSafeWormholeWrappedInfo } from "../hooks/useCheckIfWormholeWrapped";
+import {
+  DataWrapper,
+  errorDataWrapper,
+  fetchDataWrapper,
+  getEmptyDataWrapper,
+  receiveDataWrapper,
+} from "./helpers";
+import { ParsedTokenAccount, Transaction } from "./transferSlice";
+
+const LAST_STEP = 3;
+
+type Steps = 0 | 1 | 2 | 3;
+
+// these all are optional so NFT could share TokenSelectors
+export interface NFTParsedTokenAccount extends ParsedTokenAccount {
+  tokenId?: string;
+  animation_url?: string | null;
+  external_url?: string | null;
+  image?: string;
+  image_256?: string;
+  name?: string;
+}
+
+export interface NFTState {
+  activeStep: Steps;
+  sourceChain: ChainId;
+  isSourceAssetWormholeWrapped: boolean | undefined;
+  originChain: ChainId | undefined;
+  originAsset: string | undefined;
+  originTokenId: string | undefined;
+  sourceWalletAddress: string | undefined;
+  sourceParsedTokenAccount: NFTParsedTokenAccount | undefined;
+  sourceParsedTokenAccounts: DataWrapper<NFTParsedTokenAccount[]>;
+  targetChain: ChainId;
+  targetAddressHex: string | undefined;
+  targetAsset: string | null | undefined;
+  targetParsedTokenAccount: NFTParsedTokenAccount | undefined;
+  transferTx: Transaction | undefined;
+  signedVAAHex: string | undefined;
+  isSending: boolean;
+  isRedeeming: boolean;
+  redeemTx: Transaction | undefined;
+}
+
+const initialState: NFTState = {
+  activeStep: 0,
+  sourceChain: CHAIN_ID_SOLANA,
+  isSourceAssetWormholeWrapped: false,
+  sourceWalletAddress: undefined,
+  sourceParsedTokenAccount: undefined,
+  sourceParsedTokenAccounts: getEmptyDataWrapper(),
+  originChain: undefined,
+  originAsset: undefined,
+  originTokenId: undefined,
+  targetChain: CHAIN_ID_ETH,
+  targetAddressHex: undefined,
+  targetAsset: undefined,
+  targetParsedTokenAccount: undefined,
+  transferTx: undefined,
+  signedVAAHex: undefined,
+  isSending: false,
+  isRedeeming: false,
+  redeemTx: undefined,
+};
+
+export const nftSlice = createSlice({
+  name: "nft",
+  initialState,
+  reducers: {
+    incrementStep: (state) => {
+      if (state.activeStep < LAST_STEP) state.activeStep++;
+    },
+    decrementStep: (state) => {
+      if (state.activeStep > 0) state.activeStep--;
+    },
+    setStep: (state, action: PayloadAction<Steps>) => {
+      state.activeStep = action.payload;
+    },
+    setSourceChain: (state, action: PayloadAction<ChainId>) => {
+      const prevSourceChain = state.sourceChain;
+      state.sourceChain = action.payload;
+      state.sourceParsedTokenAccount = undefined;
+      state.sourceParsedTokenAccounts = getEmptyDataWrapper();
+      if (state.targetChain === action.payload) {
+        state.targetChain = prevSourceChain;
+        state.targetAddressHex = undefined;
+        // clear targetAsset so that components that fire before useFetchTargetAsset don't get stale data
+        state.targetAsset = undefined;
+        state.targetParsedTokenAccount = undefined;
+      }
+    },
+    setSourceWormholeWrappedInfo: (
+      state,
+      action: PayloadAction<StateSafeWormholeWrappedInfo | undefined>
+    ) => {
+      if (action.payload) {
+        state.isSourceAssetWormholeWrapped = action.payload.isWrapped;
+        state.originChain = action.payload.chainId;
+        state.originAsset = action.payload.assetAddress;
+        state.originTokenId = action.payload.tokenId;
+      } else {
+        state.isSourceAssetWormholeWrapped = undefined;
+        state.originChain = undefined;
+        state.originAsset = undefined;
+        state.originTokenId = undefined;
+      }
+    },
+    setSourceWalletAddress: (
+      state,
+      action: PayloadAction<string | undefined>
+    ) => {
+      state.sourceWalletAddress = action.payload;
+    },
+    setSourceParsedTokenAccount: (
+      state,
+      action: PayloadAction<NFTParsedTokenAccount | undefined>
+    ) => {
+      state.sourceParsedTokenAccount = action.payload;
+    },
+    setSourceParsedTokenAccounts: (
+      state,
+      action: PayloadAction<NFTParsedTokenAccount[] | undefined>
+    ) => {
+      state.sourceParsedTokenAccounts = action.payload
+        ? receiveDataWrapper(action.payload)
+        : getEmptyDataWrapper();
+    },
+    fetchSourceParsedTokenAccounts: (state) => {
+      state.sourceParsedTokenAccounts = fetchDataWrapper();
+    },
+    errorSourceParsedTokenAccounts: (
+      state,
+      action: PayloadAction<string | undefined>
+    ) => {
+      state.sourceParsedTokenAccounts = errorDataWrapper(
+        action.payload || "An unknown error occurred."
+      );
+    },
+    receiveSourceParsedTokenAccounts: (
+      state,
+      action: PayloadAction<NFTParsedTokenAccount[]>
+    ) => {
+      state.sourceParsedTokenAccounts = receiveDataWrapper(action.payload);
+    },
+    setTargetChain: (state, action: PayloadAction<ChainId>) => {
+      const prevTargetChain = state.targetChain;
+      state.targetChain = action.payload;
+      state.targetAddressHex = undefined;
+      // clear targetAsset so that components that fire before useFetchTargetAsset don't get stale data
+      state.targetAsset = undefined;
+      state.targetParsedTokenAccount = undefined;
+      if (state.sourceChain === action.payload) {
+        state.sourceChain = prevTargetChain;
+        state.activeStep = 0;
+        state.sourceParsedTokenAccount = undefined;
+        state.sourceParsedTokenAccounts = getEmptyDataWrapper();
+      }
+    },
+    setTargetAddressHex: (state, action: PayloadAction<string | undefined>) => {
+      state.targetAddressHex = action.payload;
+    },
+    setTargetAsset: (
+      state,
+      action: PayloadAction<string | null | undefined>
+    ) => {
+      state.targetAsset = action.payload;
+    },
+    setTargetParsedTokenAccount: (
+      state,
+      action: PayloadAction<NFTParsedTokenAccount | undefined>
+    ) => {
+      state.targetParsedTokenAccount = action.payload;
+    },
+    setTransferTx: (state, action: PayloadAction<Transaction>) => {
+      state.transferTx = action.payload;
+    },
+    setSignedVAAHex: (state, action: PayloadAction<string>) => {
+      state.signedVAAHex = action.payload;
+      state.isSending = false;
+      state.activeStep = 3;
+    },
+    setIsSending: (state, action: PayloadAction<boolean>) => {
+      state.isSending = action.payload;
+    },
+    setIsRedeeming: (state, action: PayloadAction<boolean>) => {
+      state.isRedeeming = action.payload;
+    },
+    setRedeemTx: (state, action: PayloadAction<Transaction>) => {
+      state.redeemTx = action.payload;
+      state.isRedeeming = false;
+    },
+    reset: (state) => ({
+      ...initialState,
+      sourceChain: state.sourceChain,
+      targetChain: state.targetChain,
+    }),
+  },
+});
+
+export const {
+  incrementStep,
+  decrementStep,
+  setStep,
+  setSourceChain,
+  setSourceWormholeWrappedInfo,
+  setSourceWalletAddress,
+  setSourceParsedTokenAccount,
+  setSourceParsedTokenAccounts,
+  receiveSourceParsedTokenAccounts,
+  errorSourceParsedTokenAccounts,
+  fetchSourceParsedTokenAccounts,
+  setTargetChain,
+  setTargetAddressHex,
+  setTargetAsset,
+  setTargetParsedTokenAccount,
+  setTransferTx,
+  setSignedVAAHex,
+  setIsSending,
+  setIsRedeeming,
+  setRedeemTx,
+  reset,
+} = nftSlice.actions;
+
+export default nftSlice.reducer;

+ 108 - 1
bridge_ui/src/store/selectors.ts

@@ -31,6 +31,113 @@ export const selectAttestIsSendComplete = (state: RootState) =>
 export const selectAttestShouldLockFields = (state: RootState) =>
   selectAttestIsSending(state) || selectAttestIsSendComplete(state);
 
+/*
+ * NFT
+ */
+
+export const selectNFTActiveStep = (state: RootState) => state.nft.activeStep;
+export const selectNFTSourceChain = (state: RootState) => state.nft.sourceChain;
+export const selectNFTSourceAsset = (state: RootState) => {
+  return state.nft.sourceParsedTokenAccount?.mintKey || undefined;
+};
+export const selectNFTIsSourceAssetWormholeWrapped = (state: RootState) =>
+  state.nft.isSourceAssetWormholeWrapped;
+export const selectNFTOriginChain = (state: RootState) => state.nft.originChain;
+export const selectNFTOriginAsset = (state: RootState) => state.nft.originAsset;
+export const selectNFTOriginTokenId = (state: RootState) =>
+  state.nft.originTokenId;
+export const selectNFTSourceWalletAddress = (state: RootState) =>
+  state.nft.sourceWalletAddress;
+export const selectNFTSourceParsedTokenAccount = (state: RootState) =>
+  state.nft.sourceParsedTokenAccount;
+export const selectNFTSourceParsedTokenAccounts = (state: RootState) =>
+  state.nft.sourceParsedTokenAccounts;
+export const selectNFTSourceBalanceString = (state: RootState) =>
+  state.nft.sourceParsedTokenAccount?.uiAmountString || "";
+export const selectNFTTargetChain = (state: RootState) => state.nft.targetChain;
+export const selectNFTTargetAddressHex = (state: RootState) =>
+  state.nft.targetAddressHex;
+export const selectNFTTargetAsset = (state: RootState) => state.nft.targetAsset;
+export const selectNFTTargetParsedTokenAccount = (state: RootState) =>
+  state.nft.targetParsedTokenAccount;
+export const selectNFTTargetBalanceString = (state: RootState) =>
+  state.nft.targetParsedTokenAccount?.uiAmountString || "";
+export const selectNFTTransferTx = (state: RootState) => state.nft.transferTx;
+export const selectNFTSignedVAAHex = (state: RootState) =>
+  state.nft.signedVAAHex;
+export const selectNFTIsSending = (state: RootState) => state.nft.isSending;
+export const selectNFTIsRedeeming = (state: RootState) => state.nft.isRedeeming;
+export const selectNFTRedeemTx = (state: RootState) => state.nft.redeemTx;
+export const selectNFTSourceError = (state: RootState): string | undefined => {
+  if (!state.nft.sourceChain) {
+    return "Select a source chain";
+  }
+  if (!state.nft.sourceParsedTokenAccount) {
+    return "Select an NFT";
+  }
+  if (
+    state.nft.sourceChain === CHAIN_ID_SOLANA &&
+    !state.nft.sourceParsedTokenAccount.publicKey
+  ) {
+    return "Token account unavailable";
+  }
+  if (!state.nft.sourceParsedTokenAccount.uiAmountString) {
+    return "Token amount unavailable";
+  }
+  if (state.nft.sourceParsedTokenAccount.decimals !== 0) {
+    // TODO: more advanced NFT check - also check supply and uri
+    return "For non-NFTs, use the Transfer flow";
+  }
+  try {
+    // these may trigger error: fractional component exceeds decimals
+    if (
+      parseUnits(
+        state.nft.sourceParsedTokenAccount.uiAmountString,
+        state.nft.sourceParsedTokenAccount.decimals
+      ).lte(0)
+    ) {
+      return "Balance must be greater than zero";
+    }
+  } catch (e) {
+    if (e?.message) {
+      return e.message.substring(0, e.message.indexOf("("));
+    }
+    return "Invalid amount";
+  }
+  return undefined;
+};
+export const selectNFTIsSourceComplete = (state: RootState) =>
+  !selectNFTSourceError(state);
+export const selectNFTTargetError = (state: RootState) => {
+  const sourceError = selectNFTSourceError(state);
+  if (sourceError) {
+    return `Error in source: ${sourceError}`;
+  }
+  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
+  ) {
+    return UNREGISTERED_ERROR_MESSAGE;
+  }
+  if (!state.nft.targetAddressHex) {
+    return "Target account unavailable";
+  }
+};
+export const selectNFTIsTargetComplete = (state: RootState) =>
+  !selectNFTTargetError(state);
+export const selectNFTIsSendComplete = (state: RootState) =>
+  !!selectNFTSignedVAAHex(state);
+export const selectNFTIsRedeemComplete = (state: RootState) =>
+  !!selectNFTRedeemTx(state);
+export const selectNFTShouldLockFields = (state: RootState) =>
+  selectNFTIsSending(state) || selectNFTIsSendComplete(state);
+
 /*
  * Transfer
  */
@@ -100,7 +207,7 @@ export const selectTransferSourceError = (
   }
   if (state.transfer.sourceParsedTokenAccount.decimals === 0) {
     // TODO: more advanced NFT check - also check supply and uri
-    return "NFTs are not currently supported";
+    return "For NFTs, use the NFT flow";
   }
   try {
     // these may trigger error: fractional component exceeds decimals

+ 1 - 1
bridge_ui/src/store/transferSlice.ts

@@ -4,7 +4,7 @@ import {
   CHAIN_ID_SOLANA,
 } from "@certusone/wormhole-sdk";
 import { createSlice, PayloadAction } from "@reduxjs/toolkit";
-import { StateSafeWormholeWrappedInfo } from "../utils/getOriginalAsset";
+import { StateSafeWormholeWrappedInfo } from "../hooks/useCheckIfWormholeWrapped";
 import {
   DataWrapper,
   errorDataWrapper,

+ 19 - 7
bridge_ui/src/utils/consts.ts

@@ -30,10 +30,10 @@ export const CHAINS =
           id: CHAIN_ID_SOLANA,
           name: "Solana",
         },
-        {
-          id: CHAIN_ID_TERRA,
-          name: "Terra",
-        },
+        // {
+        //   id: CHAIN_ID_TERRA,
+        //   name: "Terra",
+        // },
       ]
     : [
         {
@@ -89,6 +89,11 @@ export const ETH_BRIDGE_ADDRESS = getAddress(
     ? "0x44F3e7c20850B3B5f3031114726A9240911D912a"
     : "0xC89Ce4735882C9F0f0FE26686c53074E09B0D550"
 );
+export const ETH_NFT_BRIDGE_ADDRESS = getAddress(
+  CLUSTER === "testnet"
+    ? "0x26b4afb60d6c903165150c6f0aa14f8016be4aec" // TODO: test address
+    : "0x26b4afb60d6c903165150c6f0aa14f8016be4aec"
+);
 export const ETH_TOKEN_BRIDGE_ADDRESS = getAddress(
   CLUSTER === "testnet"
     ? "0xa6CDAddA6e4B6704705b065E01E52e2486c0FBf6"
@@ -102,6 +107,10 @@ export const SOL_BRIDGE_ADDRESS =
   CLUSTER === "testnet"
     ? "Brdguy7BmNB4qwEbcqqMbyV5CyJd2sxQNUn6NEpMSsUb"
     : "Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o";
+export const SOL_NFT_BRIDGE_ADDRESS =
+  CLUSTER === "testnet"
+    ? "NFTWqJR8YnRVqPDvTJrYuLrQDitTG5AScqbeghi4zSA" // TODO: test address
+    : "NFTWqJR8YnRVqPDvTJrYuLrQDitTG5AScqbeghi4zSA";
 export const SOL_TOKEN_BRIDGE_ADDRESS =
   CLUSTER === "testnet"
     ? "A4Us8EhCC76XdGAN17L4KpRNEK423nMivVHZzZqFqqBg"
@@ -119,14 +128,17 @@ export const COVALENT_API_KEY = process.env.REACT_APP_COVALENT_API_KEY
 
 export const COVALENT_GET_TOKENS_URL = (
   chainId: ChainId,
-  walletAddress: string
+  walletAddress: string,
+  nft?: boolean
 ) => {
   let chainNum = "";
   if (chainId === CHAIN_ID_ETH) {
     chainNum = COVALENT_ETHEREUM_MAINNET;
   }
-
-  return `https://api.covalenthq.com/v1/${chainNum}/address/${walletAddress}/balances_v2/?key=${COVALENT_API_KEY}`;
+  // https://www.covalenthq.com/docs/api/#get-/v1/{chain_id}/address/{address}/balances_v2/
+  return `https://api.covalenthq.com/v1/${chainNum}/address/${walletAddress}/balances_v2/?key=${COVALENT_API_KEY}${
+    nft ? "&nft=true" : ""
+  }`;
 };
 
 export const COVALENT_ETHEREUM_MAINNET = "1";

+ 44 - 2
bridge_ui/src/utils/ethereum.ts

@@ -1,10 +1,15 @@
 import {
+  NFTImplementation,
+  NFTImplementation__factory,
   TokenImplementation,
   TokenImplementation__factory,
 } from "@certusone/wormhole-sdk";
 import { ethers } from "ethers";
-import { formatUnits } from "ethers/lib/utils";
-import { createParsedTokenAccount } from "../hooks/useGetSourceParsedTokenAccounts";
+import { arrayify, formatUnits } from "ethers/lib/utils";
+import {
+  createNFTParsedTokenAccount,
+  createParsedTokenAccount,
+} from "../hooks/useGetSourceParsedTokenAccounts";
 
 //This is a valuable intermediate step to the parsed token account, as the token has metadata information on it.
 export async function getEthereumToken(
@@ -31,6 +36,43 @@ export async function ethTokenToParsedTokenAccount(
   );
 }
 
+//This is a valuable intermediate step to the parsed token account, as the token has metadata information on it.
+export async function getEthereumNFT(
+  tokenAddress: string,
+  provider: ethers.providers.Web3Provider
+) {
+  const token = NFTImplementation__factory.connect(tokenAddress, provider);
+  return token;
+}
+
+export async function isNFT(token: NFTImplementation) {
+  const erc721 = "0x80ac58cd";
+  const erc721metadata = "0x5b5e139f";
+  return (
+    (await token.supportsInterface(arrayify(erc721))) &&
+    (await token.supportsInterface(arrayify(erc721metadata)))
+  );
+}
+
+export async function ethNFTToNFTParsedTokenAccount(
+  token: NFTImplementation,
+  tokenId: string,
+  signerAddress: string
+) {
+  const decimals = 0;
+  const balance = (await token.ownerOf(tokenId)) === signerAddress ? 1 : 0;
+  // const uri = await token.tokenURI(tokenId);
+  return createNFTParsedTokenAccount(
+    signerAddress,
+    token.address,
+    balance.toString(),
+    decimals,
+    Number(formatUnits(balance, decimals)),
+    formatUnits(balance, decimals),
+    tokenId
+  );
+}
+
 export function isValidEthereumAddress(address: string) {
   return ethers.utils.isAddress(address);
 }

+ 0 - 70
bridge_ui/src/utils/getForeignAsset.ts

@@ -1,70 +0,0 @@
-import {
-  ChainId,
-  getForeignAssetEth as getForeignAssetEthTx,
-  getForeignAssetSolana as getForeignAssetSolanaTx,
-  getForeignAssetTerra as getForeignAssetTerraTx,
-} from "@certusone/wormhole-sdk";
-import { Connection } from "@solana/web3.js";
-import { LCDClient } from "@terra-money/terra.js";
-import { ethers } from "ethers";
-import { hexToUint8Array } from "./array";
-import {
-  ETH_TOKEN_BRIDGE_ADDRESS,
-  SOLANA_HOST,
-  SOL_TOKEN_BRIDGE_ADDRESS,
-  TERRA_HOST,
-  TERRA_TOKEN_BRIDGE_ADDRESS,
-} from "./consts";
-
-export async function getForeignAssetEth(
-  provider: ethers.providers.Web3Provider,
-  originChain: ChainId,
-  originAsset: string
-) {
-  try {
-    return await getForeignAssetEthTx(
-      ETH_TOKEN_BRIDGE_ADDRESS,
-      provider,
-      originChain,
-      hexToUint8Array(originAsset)
-    );
-  } catch (e) {
-    return null;
-  }
-}
-
-export async function getForeignAssetSol(
-  originChain: ChainId,
-  originAsset: string
-) {
-  const connection = new Connection(SOLANA_HOST, "confirmed");
-  return await getForeignAssetSolanaTx(
-    connection,
-    SOL_TOKEN_BRIDGE_ADDRESS,
-    originChain,
-    hexToUint8Array(originAsset)
-  );
-}
-
-/**
- * Returns a foreign asset address on Terra for a provided native chain and asset address
- * @param originChain
- * @param originAsset
- * @returns
- */
-export async function getForeignAssetTerra(
-  originChain: ChainId,
-  originAsset: string
-) {
-  try {
-    const lcd = new LCDClient(TERRA_HOST);
-    return await getForeignAssetTerraTx(
-      TERRA_TOKEN_BRIDGE_ADDRESS,
-      lcd,
-      originChain,
-      hexToUint8Array(originAsset)
-    );
-  } catch (e) {
-    return null;
-  }
-}

+ 0 - 63
bridge_ui/src/utils/getOriginalAsset.ts

@@ -1,63 +0,0 @@
-import {
-  ChainId,
-  getOriginalAssetEth as getOriginalAssetEthTx,
-  getOriginalAssetSol as getOriginalAssetSolTx,
-  getOriginalAssetTerra as getOriginalAssetTerraTx,
-  WormholeWrappedInfo,
-} from "@certusone/wormhole-sdk";
-import { Connection } from "@solana/web3.js";
-import { LCDClient } from "@terra-money/terra.js";
-import { ethers } from "ethers";
-import { uint8ArrayToHex } from "./array";
-import {
-  ETH_TOKEN_BRIDGE_ADDRESS,
-  SOLANA_HOST,
-  SOL_TOKEN_BRIDGE_ADDRESS,
-  TERRA_HOST,
-} from "./consts";
-
-export interface StateSafeWormholeWrappedInfo {
-  isWrapped: boolean;
-  chainId: ChainId;
-  assetAddress: string;
-}
-
-const makeStateSafe = (
-  info: WormholeWrappedInfo
-): StateSafeWormholeWrappedInfo => ({
-  ...info,
-  assetAddress: uint8ArrayToHex(info.assetAddress),
-});
-
-export async function getOriginalAssetEth(
-  provider: ethers.providers.Web3Provider,
-  wrappedAddress: string
-): Promise<StateSafeWormholeWrappedInfo> {
-  return makeStateSafe(
-    await getOriginalAssetEthTx(
-      ETH_TOKEN_BRIDGE_ADDRESS,
-      provider,
-      wrappedAddress
-    )
-  );
-}
-
-export async function getOriginalAssetSol(
-  mintAddress: string
-): Promise<StateSafeWormholeWrappedInfo> {
-  const connection = new Connection(SOLANA_HOST, "confirmed");
-  return makeStateSafe(
-    await getOriginalAssetSolTx(
-      connection,
-      SOL_TOKEN_BRIDGE_ADDRESS,
-      mintAddress
-    )
-  );
-}
-
-export async function getOriginalAssetTerra(
-  mintAddress: string
-): Promise<StateSafeWormholeWrappedInfo> {
-  const lcd = new LCDClient(TERRA_HOST);
-  return makeStateSafe(await getOriginalAssetTerraTx(lcd, mintAddress));
-}

+ 8 - 7
ethereum/package-lock.json

@@ -18,7 +18,7 @@
       "devDependencies": {
         "@chainsafe/truffle-plugin-abigen": "0.0.1",
         "@openzeppelin/cli": "^2.8.2",
-        "@openzeppelin/contracts": "^4.1.0",
+        "@openzeppelin/contracts": "^4.3.1",
         "@openzeppelin/test-environment": "^0.1.6",
         "@openzeppelin/test-helpers": "^0.5.9",
         "@truffle/hdwallet-provider": "^1.2.0",
@@ -4095,9 +4095,9 @@
       }
     },
     "node_modules/@openzeppelin/contracts": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-4.1.0.tgz",
-      "integrity": "sha512-TihZitscnaHNcZgXGj9zDLDyCqjziytB4tMCwXq0XimfWkAjBYyk5/pOsDbbwcavhlc79HhpTEpQcrMnPVa1mw==",
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-4.3.1.tgz",
+      "integrity": "sha512-QjgbPPlmDK2clK1hzjw2ROfY8KA5q+PfhDUUxZFEBCZP9fi6d5FuNoh/Uq0oCTMEKPmue69vhX2jcl0N/tFKGw==",
       "dev": true
     },
     "node_modules/@openzeppelin/fuzzy-solidity-import-parser": {
@@ -43868,9 +43868,9 @@
       }
     },
     "@openzeppelin/contracts": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-4.1.0.tgz",
-      "integrity": "sha512-TihZitscnaHNcZgXGj9zDLDyCqjziytB4tMCwXq0XimfWkAjBYyk5/pOsDbbwcavhlc79HhpTEpQcrMnPVa1mw==",
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-4.3.1.tgz",
+      "integrity": "sha512-QjgbPPlmDK2clK1hzjw2ROfY8KA5q+PfhDUUxZFEBCZP9fi6d5FuNoh/Uq0oCTMEKPmue69vhX2jcl0N/tFKGw==",
       "dev": true
     },
     "@openzeppelin/fuzzy-solidity-import-parser": {
@@ -50815,6 +50815,7 @@
       "dev": true,
       "optional": true,
       "requires": {
+        "bitcore-lib": "^8.25.10",
         "unorm": "^1.4.1"
       }
     },

+ 1 - 1
ethereum/package.json

@@ -6,7 +6,7 @@
   "devDependencies": {
     "@chainsafe/truffle-plugin-abigen": "0.0.1",
     "@openzeppelin/cli": "^2.8.2",
-    "@openzeppelin/contracts": "^4.1.0",
+    "@openzeppelin/contracts": "^4.3.1",
     "@openzeppelin/test-environment": "^0.1.6",
     "@openzeppelin/test-helpers": "^0.5.9",
     "@truffle/hdwallet-provider": "^1.2.0",

+ 40 - 33
ethereum/scripts/deploy_test_token.js

@@ -1,35 +1,42 @@
 // run this script with truffle exec
 
-const ERC20 = artifacts.require("ERC20PresetMinterPauser")
-const ERC721 = artifacts.require("ERC721PresetMinterPauserAutoId")
-
-module.exports = async function (callback) {
-    try {
-        const accounts = await web3.eth.getAccounts();
-
-        // deploy token contract
-        const tokenAddress = (await ERC20.new("Ethereum Test Token", "TKN")).address;
-        const token = new web3.eth.Contract(ERC20.abi, tokenAddress);
-
-        console.log("Token deployed at: " + tokenAddress);
-
-        // mint 1000 units
-        await token.methods.mint(accounts[0], "1000000000000000000000").send({
-            from: accounts[0],
-            gas: 1000000
-        });
-
-        const nftAddress = (await ERC721.new("Not an APE", "APE", "https://cloudflare-ipfs.com/ipfs/QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/3287")).address;
-        const nft = new web3.eth.Contract(ERC721.abi, nftAddress);
-        await nft.methods.mint(accounts[0]).send({
-            from: accounts[0],
-            gas: 1000000
-        });
-
-        console.log("NFT deployed at: " + nftAddress);
-
-        callback();
-    } catch (e) {
-        callback(e);
-    }
-}
+const ERC20 = artifacts.require("ERC20PresetMinterPauser");
+const ERC721 = artifacts.require("ERC721PresetMinterPauserAutoId");
+
+module.exports = async function(callback) {
+  try {
+    const accounts = await web3.eth.getAccounts();
+
+    // deploy token contract
+    const tokenAddress = (await ERC20.new("Ethereum Test Token", "TKN"))
+      .address;
+    const token = new web3.eth.Contract(ERC20.abi, tokenAddress);
+
+    console.log("Token deployed at: " + tokenAddress);
+
+    // mint 1000 units
+    await token.methods.mint(accounts[0], "1000000000000000000000").send({
+      from: accounts[0],
+      gas: 1000000,
+    });
+
+    const nftAddress = (
+      await ERC721.new(
+        "Not an APE",
+        "APE",
+        "https://cloudflare-ipfs.com/ipfs/QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/"
+      )
+    ).address;
+    const nft = new web3.eth.Contract(ERC721.abi, nftAddress);
+    await nft.methods.mint(accounts[0]).send({
+      from: accounts[0],
+      gas: 1000000,
+    });
+
+    console.log("NFT deployed at: " + nftAddress);
+
+    callback();
+  } catch (e) {
+    callback(e);
+  }
+};

+ 8 - 0
sdk/js/scripts/copyWasm.js

@@ -7,6 +7,14 @@ fs.copyFileSync(
   "src/solana/core/bridge_bg.wasm.d.ts",
   "lib/solana/core/bridge_bg.wasm.d.ts"
 );
+fs.copyFileSync(
+  "src/solana/nft/nft_bridge_bg.wasm",
+  "lib/solana/nft/nft_bridge_bg.wasm"
+);
+fs.copyFileSync(
+  "src/solana/nft/nft_bridge_bg.wasm.d.ts",
+  "lib/solana/nft/nft_bridge_bg.wasm.d.ts"
+);
 fs.copyFileSync(
   "src/solana/token/token_bridge_bg.wasm",
   "lib/solana/token/token_bridge_bg.wasm"

+ 8 - 28
sdk/js/src/nft_bridge/getForeignAsset.ts

@@ -1,9 +1,7 @@
-import { Connection, PublicKey } from "@solana/web3.js";
+import { PublicKey } from "@solana/web3.js";
 import { ethers } from "ethers";
 import { Bridge__factory } from "../ethers-contracts";
 import { ChainId } from "../utils";
-import { LCDClient } from "@terra-money/terra.js";
-import { fromUint8Array } from "js-base64";
 
 /**
  * Returns a foreign asset address on Ethereum for a provided native chain and asset address, AddressZero if it does not exist
@@ -23,48 +21,30 @@ export async function getForeignAssetEth(
   try {
     return await tokenBridge.wrappedAsset(originChain, originAsset);
   } catch (e) {
-    return ethers.constants.AddressZero;
+    return null;
   }
 }
-
-export async function getForeignAssetTerra(
-  tokenBridgeAddress: string,
-  client: LCDClient,
-  originChain: ChainId,
-  originAsset: Uint8Array
-) {
-  const result: { address: string } = await client.wasm.contractQuery(tokenBridgeAddress, {
-    wrapped_registry: {
-      chain: originChain,
-      address: fromUint8Array(originAsset),
-    },
-  });
-  return result.address;
-}
-
 /**
  * Returns a foreign asset address on Solana for a provided native chain and asset address
- * @param connection
  * @param tokenBridgeAddress
  * @param originChain
  * @param originAsset zero pad to 32 bytes
  * @returns
  */
 export async function getForeignAssetSol(
-  connection: Connection,
   tokenBridgeAddress: string,
   originChain: ChainId,
-  originAsset: Uint8Array
+  originAsset: Uint8Array,
+  tokenId: Uint8Array
 ) {
   const { wrapped_address } = await import("../solana/nft/nft_bridge");
   const wrappedAddress = wrapped_address(
     tokenBridgeAddress,
     originAsset,
-    originChain
+    originChain,
+    tokenId
   );
   const wrappedAddressPK = new PublicKey(wrappedAddress);
-  const wrappedAssetAccountInfo = await connection.getAccountInfo(
-    wrappedAddressPK
-  );
-  return wrappedAssetAccountInfo ? wrappedAddressPK.toString() : null;
+  // we don't require NFT accounts to exist, so don't check them.
+  return wrappedAddressPK.toString();
 }

+ 0 - 9
sdk/js/src/nft_bridge/getIsWrappedAsset.ts

@@ -1,7 +1,6 @@
 import { Connection, PublicKey } from "@solana/web3.js";
 import { ethers } from "ethers";
 import { Bridge__factory } from "../ethers-contracts";
-import { ConnectedWallet as TerraConnectedWallet } from "@terra-money/wallet-provider";
 
 /**
  * Returns whether or not an asset address on Ethereum is a wormhole wrapped asset
@@ -20,14 +19,6 @@ export async function getIsWrappedAssetEth(
   return await tokenBridge.isWrappedAsset(assetAddress);
 }
 
-export async function getIsWrappedAssetTerra(
-  tokenBridgeAddress: string,
-  wallet: TerraConnectedWallet,
-  assetAddress: string
-) {
-  return false;
-}
-
 /**
  * Returns whether or not an asset on Solana is a wormhole wrapped asset
  * @param connection

+ 19 - 20
sdk/js/src/nft_bridge/getOriginalAsset.ts

@@ -1,15 +1,15 @@
 import { Connection, PublicKey } from "@solana/web3.js";
 import { ethers } from "ethers";
-import { arrayify } from "ethers/lib/utils";
+import { arrayify, zeroPad } from "ethers/lib/utils";
 import { TokenImplementation__factory } from "../ethers-contracts";
-import { ChainId, CHAIN_ID_ETH, CHAIN_ID_SOLANA, CHAIN_ID_TERRA } from "../utils";
+import { ChainId, CHAIN_ID_ETH, CHAIN_ID_SOLANA } from "../utils";
 import { getIsWrappedAssetEth } from "./getIsWrappedAsset";
-import { ConnectedWallet as TerraConnectedWallet } from "@terra-money/wallet-provider";
 
-export interface WormholeWrappedInfo {
+export interface WormholeWrappedNFTInfo {
   isWrapped: boolean;
   chainId: ChainId;
   assetAddress: Uint8Array;
+  tokenId?: string;
 }
 
 /**
@@ -22,8 +22,9 @@ export interface WormholeWrappedInfo {
 export async function getOriginalAssetEth(
   tokenBridgeAddress: string,
   provider: ethers.providers.Web3Provider,
-  wrappedAddress: string
-): Promise<WormholeWrappedInfo> {
+  wrappedAddress: string,
+  tokenId: string
+): Promise<WormholeWrappedNFTInfo> {
   const isWrapped = await getIsWrappedAssetEth(
     tokenBridgeAddress,
     provider,
@@ -36,6 +37,7 @@ 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,
@@ -45,19 +47,8 @@ export async function getOriginalAssetEth(
   return {
     isWrapped: false,
     chainId: CHAIN_ID_ETH,
-    assetAddress: arrayify(wrappedAddress),
-  };
-}
-
-export async function getOriginalAssetTerra(
-  tokenBridgeAddress: string,
-  wallet: TerraConnectedWallet,
-  wrappedAddress: string
-): Promise<WormholeWrappedInfo> {
-  return {
-    isWrapped: false,
-    chainId: CHAIN_ID_TERRA,
-    assetAddress: arrayify(""),
+    assetAddress: zeroPad(arrayify(wrappedAddress), 32),
+    tokenId,
   };
 }
 
@@ -72,7 +63,7 @@ export async function getOriginalAssetSol(
   connection: Connection,
   tokenBridgeAddress: string,
   mintAddress: string
-): Promise<WormholeWrappedInfo> {
+): Promise<WormholeWrappedNFTInfo> {
   if (mintAddress) {
     // TODO: share some of this with getIsWrappedAssetSol, like a getWrappedMetaAccountAddress or something
     const { parse_wrapped_meta, wrapped_meta_address } = await import(
@@ -92,9 +83,17 @@ export async function getOriginalAssetSol(
         isWrapped: true,
         chainId: parsed.chain,
         assetAddress: parsed.token_address,
+        tokenId: parsed.token_id,
       };
     }
   }
+  try {
+    return {
+      isWrapped: false,
+      chainId: CHAIN_ID_SOLANA,
+      assetAddress: new PublicKey(mintAddress).toBytes(),
+    };
+  } catch (e) {}
   return {
     isWrapped: false,
     chainId: CHAIN_ID_SOLANA,

+ 7 - 41
sdk/js/src/nft_bridge/redeem.ts

@@ -1,10 +1,6 @@
-import {
-  ASSOCIATED_TOKEN_PROGRAM_ID,
-  Token,
-  TOKEN_PROGRAM_ID,
-} from "@solana/spl-token";
 import { Connection, PublicKey, Transaction } from "@solana/web3.js";
 import { ethers } from "ethers";
+import { CHAIN_ID_SOLANA } from "..";
 import { Bridge__factory } from "../ethers-contracts";
 import { ixFromRust } from "../solana";
 
@@ -24,16 +20,13 @@ export async function redeemOnSolana(
   bridgeAddress: string,
   tokenBridgeAddress: string,
   payerAddress: string,
-  signedVAA: Uint8Array,
-  isSolanaNative: boolean,
-  mintAddress?: string // TODO: read the signedVAA and create the account if it doesn't exist
+  signedVAA: Uint8Array
 ) {
-  // TODO: this gets the target account off the vaa, but is there a way to do this via wasm?
-  // also, would this always be safe to do?
-  // should we rely on this function to create accounts at all?
-  // const { parse_vaa } = await import("../solana/core/bridge")
-  // const parsedVAA = parse_vaa(signedVAA);
-  // const targetAddress = new PublicKey(parsedVAA.payload.slice(67, 67 + 32)).toString()
+  const { parse_vaa } = await import("../solana/core/bridge");
+  const parsedVAA = parse_vaa(signedVAA);
+  const isSolanaNative =
+    Buffer.from(new Uint8Array(parsedVAA.payload)).readUInt16BE(65) ===
+    CHAIN_ID_SOLANA;
   const { complete_transfer_wrapped_ix, complete_transfer_native_ix } =
     await import("../solana/nft/nft_bridge");
   const ixs = [];
@@ -49,33 +42,6 @@ export async function redeemOnSolana(
       )
     );
   } else {
-    // TODO: we should always do this, they could buy wrapped somewhere else and transfer it back for the first time, but again, do it based on vaa
-    if (mintAddress) {
-      const mintPublicKey = new PublicKey(mintAddress);
-      // TODO: re: todo above, this should be swapped for the address from the vaa (may not be the same as the payer)
-      const payerPublicKey = new PublicKey(payerAddress);
-      const associatedAddress = await Token.getAssociatedTokenAddress(
-        ASSOCIATED_TOKEN_PROGRAM_ID,
-        TOKEN_PROGRAM_ID,
-        mintPublicKey,
-        payerPublicKey
-      );
-      const associatedAddressInfo = await connection.getAccountInfo(
-        associatedAddress
-      );
-      if (!associatedAddressInfo) {
-        ixs.push(
-          await Token.createAssociatedTokenAccountInstruction(
-            ASSOCIATED_TOKEN_PROGRAM_ID,
-            TOKEN_PROGRAM_ID,
-            mintPublicKey,
-            associatedAddress,
-            payerPublicKey, // owner
-            payerPublicKey // payer
-          )
-        );
-      }
-    }
     ixs.push(
       ixFromRust(
         complete_transfer_wrapped_ix(

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

@@ -41,7 +41,8 @@ export async function transferFromSolana(
     targetAddress: Uint8Array,
     targetChain: ChainId,
     originAddress?: Uint8Array,
-    originChain?: ChainId
+    originChain?: ChainId,
+    originTokenId?: Uint8Array
 ) {
     const nonce = createNonce().readUInt32LE(0);
     const transferIx = await getBridgeFeeIx(
@@ -65,8 +66,8 @@ export async function transferFromSolana(
     let messageKey = Keypair.generate();
     const isSolanaNative =
         originChain === undefined || originChain === CHAIN_ID_SOLANA;
-    if (!isSolanaNative && !originAddress) {
-        throw new Error("originAddress is required when specifying originChain");
+    if (!isSolanaNative && !originAddress && !originTokenId) {
+        throw new Error("originAddress and originTokenId are required when specifying originChain");
     }
     const ix = ixFromRust(
         isSolanaNative
@@ -90,6 +91,7 @@ export async function transferFromSolana(
                 payerAddress,
                 originChain as number, // checked by isSolanaNative
                 originAddress as Uint8Array, // checked by throw
+                originTokenId as Uint8Array, // checked by throw
                 nonce,
                 targetAddress,
                 targetChain

+ 2 - 7
sdk/js/src/token_bridge/redeem.ts

@@ -1,12 +1,7 @@
-import {
-  ASSOCIATED_TOKEN_PROGRAM_ID,
-  Token,
-  TOKEN_PROGRAM_ID,
-} from "@solana/spl-token";
-import { MsgExecuteContract } from "@terra-money/terra.js";
 import { Connection, PublicKey, Transaction } from "@solana/web3.js";
-import { fromUint8Array } from "js-base64";
+import { MsgExecuteContract } from "@terra-money/terra.js";
 import { ethers } from "ethers";
+import { fromUint8Array } from "js-base64";
 import { Bridge__factory } from "../ethers-contracts";
 import { ixFromRust } from "../solana";
 import { CHAIN_ID_SOLANA } from "../utils";