diff --git a/bridge_ui/src/App.js b/bridge_ui/src/App.js
index 8fc82b2df..254e1eb00 100644
--- a/bridge_ui/src/App.js
+++ b/bridge_ui/src/App.js
@@ -18,11 +18,12 @@ 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 Migration from "./components/Migration";
+import NFT from "./components/NFT";
+import NFTOriginVerifier from "./components/NFTOriginVerifier";
+import Transfer from "./components/Transfer";
import wormholeLogo from "./icons/wormhole.svg";
-import { CLUSTER, ENABLE_NFT } from "./utils/consts";
+import { CLUSTER } from "./utils/consts";
const useStyles = makeStyles((theme) => ({
appBar: {
@@ -78,22 +79,11 @@ function App() {
- {ENABLE_NFT ? (
-
-
- NFTs
-
-
- ) : (
-
-
- NFTs
-
-
- )}
+
+
+ NFTs
+
+
- {ENABLE_NFT ? (
-
-
-
- ) : null}
+
+
+
+
+
+
diff --git a/bridge_ui/src/components/NFT/Source.tsx b/bridge_ui/src/components/NFT/Source.tsx
index 3c336dc0c..ec76afb5a 100644
--- a/bridge_ui/src/components/NFT/Source.tsx
+++ b/bridge_ui/src/components/NFT/Source.tsx
@@ -1,6 +1,6 @@
import { CHAIN_ID_ETH, CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk";
import { Button, makeStyles, MenuItem, TextField } from "@material-ui/core";
-import { Restore } from "@material-ui/icons";
+import { Restore, VerifiedUser } from "@material-ui/icons";
import { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import useIsWalletReady from "../../hooks/useIsWalletReady";
@@ -19,11 +19,18 @@ import StepDescription from "../StepDescription";
import { TokenSelector } from "../TokenSelectors/SourceTokenSelector";
import { Alert } from "@material-ui/lab";
import LowBalanceWarning from "../LowBalanceWarning";
+import { Link } from "react-router-dom";
const useStyles = makeStyles((theme) => ({
transferField: {
marginTop: theme.spacing(5),
},
+ buttonWrapper: {
+ textAlign: "right",
+ },
+ nftOriginVerifierButton: {
+ marginTop: theme.spacing(0.5),
+ },
}));
function Source({
@@ -54,14 +61,30 @@ function Source({
Select an NFT to send through the Wormhole NFT Bridge.
-
setIsRecoveryOpen(true)}
- size="small"
- variant="outlined"
- endIcon={ }
- >
- Perform Recovery
-
+
+
+ setIsRecoveryOpen(true)}
+ size="small"
+ variant="outlined"
+ endIcon={ }
+ >
+ Perform Recovery
+
+
+
+ }
+ className={classes.nftOriginVerifierButton}
+ >
+ NFT Origin Verifier
+
+
+
({
+ centeredContainer: {
+ textAlign: "center",
+ width: "100%",
+ },
+ header: {
+ marginTop: theme.spacing(12),
+ marginBottom: theme.spacing(4),
+ [theme.breakpoints.down("sm")]: {
+ marginBottom: theme.spacing(4),
+ },
+ },
+ linearGradient: {
+ background: `linear-gradient(to left, ${COLORS.blue}, ${COLORS.green});`,
+ WebkitBackgroundClip: "text",
+ backgroundClip: "text",
+ WebkitTextFillColor: "transparent",
+ MozBackgroundClip: "text",
+ MozTextFillColor: "transparent",
+ filter: `drop-shadow( 0px 0px 8px ${COLORS.nearBlack}) drop-shadow( 0px 0px 14px ${COLORS.nearBlack}) drop-shadow( 0px 0px 24px ${COLORS.nearBlack})`,
+ },
+ mainCard: {
+ padding: theme.spacing(1),
+ borderRadius: "5px",
+ backgroundColor: COLORS.nearBlackWithMinorTransparency,
+ },
+ originHeader: {
+ marginTop: theme.spacing(4),
+ },
+ viewButtonWrapper: {
+ textAlign: "center",
+ },
+ viewButton: {
+ marginTop: theme.spacing(1),
+ },
+ loaderWrapper: {
+ margin: theme.spacing(2),
+ textAlign: "center",
+ },
+}));
+
+export default function NFTOriginVerifier() {
+ const classes = useStyles();
+ const { provider, signerAddress } = useEthereumProvider();
+ const [lookupChain, setLookupChain] = useState(CHAIN_ID_ETH);
+ const { isReady, statusMessage } = useIsWalletReady(lookupChain);
+ const [lookupAsset, setLookupAsset] = useState("");
+ const [lookupTokenId, setLookupTokenId] = useState("");
+ const [lookupError, setLookupError] = useState("");
+ const [parsedTokenAccount, setParsedTokenAccount] = useState<
+ NFTParsedTokenAccount | undefined
+ >(undefined);
+ const [originInfo, setOriginInfo] = useState<
+ WormholeWrappedNFTInfo | undefined
+ >(undefined);
+ const [isLoading, setIsLoading] = useState(false);
+ const handleChainChange = useCallback((event) => {
+ setLookupChain(event.target.value);
+ }, []);
+ const handleAssetChange = useCallback((event) => {
+ setLookupAsset(event.target.value);
+ }, []);
+ const handleTokenIdChange = useCallback((event) => {
+ setLookupTokenId(event.target.value);
+ }, []);
+ useEffect(() => {
+ let cancelled = false;
+ setLookupError("");
+ setParsedTokenAccount(undefined);
+ setOriginInfo(undefined);
+ if (
+ isReady &&
+ provider &&
+ signerAddress &&
+ lookupChain === CHAIN_ID_ETH &&
+ lookupAsset &&
+ lookupTokenId
+ ) {
+ if (isValidEthereumAddress(lookupAsset)) {
+ (async () => {
+ setIsLoading(true);
+ try {
+ const token = await getEthereumNFT(lookupAsset, provider);
+ const result = await isNFT(token);
+ if (result) {
+ const newParsedTokenAccount = await ethNFTToNFTParsedTokenAccount(
+ token,
+ lookupTokenId,
+ signerAddress
+ );
+ const info = await getOriginalAssetEth(
+ ETH_NFT_BRIDGE_ADDRESS,
+ provider,
+ lookupAsset,
+ lookupTokenId
+ );
+ if (!cancelled) {
+ setIsLoading(false);
+ setParsedTokenAccount(newParsedTokenAccount);
+ setOriginInfo(info);
+ }
+ } else if (!cancelled) {
+ setIsLoading(false);
+ setLookupError(
+ "This token does not support ERC-165, ERC-721, and ERC-721 metadata"
+ );
+ }
+ } catch (e) {
+ console.error(e);
+ if (!cancelled) {
+ setIsLoading(false);
+ setLookupError(
+ "This token does not support ERC-165, ERC-721, and ERC-721 metadata"
+ );
+ }
+ }
+ })();
+ } else {
+ setLookupError("Invalid address");
+ }
+ } else if (lookupChain === CHAIN_ID_SOLANA && lookupAsset) {
+ (async () => {
+ try {
+ setIsLoading(true);
+ const [metadata] = await getMetaplexData([lookupAsset]);
+ if (metadata) {
+ const connection = new Connection(SOLANA_HOST, "confirmed");
+ const info = await getOriginalAssetSol(
+ connection,
+ SOL_NFT_BRIDGE_ADDRESS,
+ lookupAsset
+ );
+ if (!cancelled) {
+ setIsLoading(false);
+ setParsedTokenAccount({
+ amount: "0",
+ decimals: 0,
+ mintKey: lookupAsset,
+ publicKey: "",
+ uiAmount: 0,
+ uiAmountString: "0",
+ uri: metadata.data.uri,
+ });
+ setOriginInfo(info);
+ }
+ } else {
+ if (!cancelled) {
+ setIsLoading(false);
+ setLookupError("Error fetching metadata");
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ if (!cancelled) {
+ setIsLoading(false);
+ setLookupError("Invalid token");
+ }
+ }
+ })();
+ }
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ isReady,
+ provider,
+ signerAddress,
+ lookupChain,
+ lookupAsset,
+ lookupTokenId,
+ ]);
+ const readableAddress =
+ originInfo &&
+ originInfo.chainId &&
+ originInfo.assetAddress &&
+ hexToNativeString(
+ uint8ArrayToHex(originInfo.assetAddress),
+ originInfo.chainId
+ );
+ const displayError =
+ (lookupChain === CHAIN_ID_ETH && statusMessage) || lookupError;
+ return (
+
+
+
+
+ NFT Origin Verifier
+
+
+
+
+
+
+ This page allows you to find where a Wormhole-bridged NFT was
+ originally minted so you can verify its authenticity.
+
+
+ {CHAINS.filter(
+ ({ id }) => id === CHAIN_ID_ETH || id === CHAIN_ID_SOLANA
+ ).map(({ id, name }) => (
+
+ {name}
+
+ ))}
+
+ {lookupChain === CHAIN_ID_ETH || lookupChain === CHAIN_ID_BSC ? (
+
+ ) : null}
+
+ {lookupChain === CHAIN_ID_ETH ? (
+
+ ) : null}
+ {displayError ? (
+ {displayError}
+ ) : null}
+ {isLoading ? (
+
+
+
+ ) : null}
+ {parsedTokenAccount ? (
+
+ ) : null}
+ {originInfo ? (
+ <>
+
+ Origin Info
+
+
+ Address: {readableAddress}
+
+ {originInfo.chainId === CHAIN_ID_SOLANA ? null : (
+
+ Token ID: {originInfo.tokenId}
+
+ )}
+
+ {originInfo.chainId === CHAIN_ID_SOLANA ? (
+ }
+ className={classes.viewButton}
+ variant="outlined"
+ >
+ View on Solscan
+
+ ) : (
+ }
+ className={classes.viewButton}
+ variant="outlined"
+ >
+ View on OpenSea
+
+ )}
+
+ >
+ ) : null}
+
+
+
+ );
+}
diff --git a/bridge_ui/src/components/TokenSelectors/NFTViewer.tsx b/bridge_ui/src/components/TokenSelectors/NFTViewer.tsx
index 654860b84..849e3c50a 100644
--- a/bridge_ui/src/components/TokenSelectors/NFTViewer.tsx
+++ b/bridge_ui/src/components/TokenSelectors/NFTViewer.tsx
@@ -110,6 +110,9 @@ const useStyles = makeStyles((theme) => ({
},
mediaContent: {
display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
background: "transparent",
border: "1px solid #ffb347",
margin: theme.spacing(0, 2),
diff --git a/bridge_ui/src/hooks/useMetaplexData.ts b/bridge_ui/src/hooks/useMetaplexData.ts
index 1d7177c6a..fc5510dcd 100644
--- a/bridge_ui/src/hooks/useMetaplexData.ts
+++ b/bridge_ui/src/hooks/useMetaplexData.ts
@@ -9,7 +9,7 @@ import {
} from "../utils/metaplex";
import { getMultipleAccountsRPC } from "../utils/solana";
-const getMetaplexData = async (mintAddresses: string[]) => {
+export const getMetaplexData = async (mintAddresses: string[]) => {
const promises = [];
for (const address of mintAddresses) {
promises.push(getMetadataAddress(address));
diff --git a/bridge_ui/src/utils/consts.ts b/bridge_ui/src/utils/consts.ts
index 7f605954f..e926588a8 100644
--- a/bridge_ui/src/utils/consts.ts
+++ b/bridge_ui/src/utils/consts.ts
@@ -15,7 +15,6 @@ export const CLUSTER: Cluster =
: process.env.REACT_APP_CLUSTER === "testnet"
? "testnet"
: "devnet";
-export const ENABLE_NFT = true;
export interface ChainInfo {
id: ChainId;
name: string;