bridge_ui: nft origin verifier

Change-Id: I7a76d5ca5ba462553a306348ba9ec9f5fce0364b
This commit is contained in:
Evan Gray 2021-09-22 15:49:24 -04:00
parent 6e8668e56d
commit 5c7c350e8e
6 changed files with 385 additions and 35 deletions

View File

@ -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() {
<div className={classes.spacer} />
<Hidden implementation="css" xsDown>
<div style={{ display: "flex", alignItems: "center" }}>
{ENABLE_NFT ? (
<Tooltip title="Transfer NFTs to another blockchain">
<Link component={NavLink} to="/nft" className={classes.link}>
NFTs
</Link>
</Tooltip>
) : (
<Tooltip title="Coming Soon">
<Typography
className={classes.link}
style={{ color: "#ffffff80", cursor: "default" }}
>
NFTs
</Typography>
</Tooltip>
)}
<Tooltip title="Transfer NFTs to another blockchain">
<Link component={NavLink} to="/nft" className={classes.link}>
NFTs
</Link>
</Tooltip>
<Tooltip title="Transfer tokens to another blockchain">
<Link
component={NavLink}
@ -157,11 +147,12 @@ function App() {
)}
<div className={classes.content}>
<Switch>
{ENABLE_NFT ? (
<Route exact path="/nft">
<NFT />
</Route>
) : null}
<Route exact path="/nft">
<NFT />
</Route>
<Route exact path="/nft-origin-verifier">
<NFTOriginVerifier />
</Route>
<Route exact path="/transfer">
<Transfer />
</Route>

View File

@ -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({
<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>
<div className={classes.buttonWrapper}>
<Button
onClick={() => setIsRecoveryOpen(true)}
size="small"
variant="outlined"
endIcon={<Restore />}
>
Perform Recovery
</Button>
</div>
<div className={classes.buttonWrapper}>
<Button
component={Link}
to="/nft-origin-verifier"
size="small"
variant="outlined"
endIcon={<VerifiedUser />}
className={classes.nftOriginVerifierButton}
>
NFT Origin Verifier
</Button>
</div>
</div>
</div>
</StepDescription>
<TextField

View File

@ -0,0 +1,334 @@
import {
CHAIN_ID_BSC,
CHAIN_ID_ETH,
CHAIN_ID_SOLANA,
} from "@certusone/wormhole-sdk";
import {
getOriginalAssetEth,
getOriginalAssetSol,
WormholeWrappedNFTInfo,
} from "@certusone/wormhole-sdk/lib/nft_bridge";
import {
Button,
Card,
CircularProgress,
Container,
makeStyles,
MenuItem,
TextField,
Typography,
} from "@material-ui/core";
import { Launch } from "@material-ui/icons";
import { Alert } from "@material-ui/lab";
import { Connection } from "@solana/web3.js";
import { useCallback, useEffect, useState } from "react";
import { useEthereumProvider } from "../contexts/EthereumProviderContext";
import useIsWalletReady from "../hooks/useIsWalletReady";
import { getMetaplexData } from "../hooks/useMetaplexData";
import { COLORS } from "../muiTheme";
import { NFTParsedTokenAccount } from "../store/nftSlice";
import { hexToNativeString, uint8ArrayToHex } from "../utils/array";
import {
CHAINS,
ETH_NFT_BRIDGE_ADDRESS,
SOLANA_HOST,
SOL_NFT_BRIDGE_ADDRESS,
} from "../utils/consts";
import {
ethNFTToNFTParsedTokenAccount,
getEthereumNFT,
isNFT,
isValidEthereumAddress,
} from "../utils/ethereum";
import KeyAndBalance from "./KeyAndBalance";
import NFTViewer from "./TokenSelectors/NFTViewer";
const useStyles = makeStyles((theme) => ({
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 (
<div>
<Container maxWidth="md">
<div className={classes.centeredContainer}>
<Typography variant="h2" component="h1" className={classes.header}>
<span className={classes.linearGradient}>NFT Origin Verifier</span>
</Typography>
</div>
</Container>
<Container maxWidth="sm">
<Card className={classes.mainCard}>
<Alert severity="info">
This page allows you to find where a Wormhole-bridged NFT was
originally minted so you can verify its authenticity.
</Alert>
<TextField
select
label="Chain"
value={lookupChain}
onChange={handleChainChange}
fullWidth
margin="normal"
>
{CHAINS.filter(
({ id }) => id === CHAIN_ID_ETH || id === CHAIN_ID_SOLANA
).map(({ id, name }) => (
<MenuItem key={id} value={id}>
{name}
</MenuItem>
))}
</TextField>
{lookupChain === CHAIN_ID_ETH || lookupChain === CHAIN_ID_BSC ? (
<KeyAndBalance chainId={lookupChain} />
) : null}
<TextField
fullWidth
margin="normal"
label="Paste an address"
value={lookupAsset}
onChange={handleAssetChange}
/>
{lookupChain === CHAIN_ID_ETH ? (
<TextField
fullWidth
margin="normal"
label="Paste a tokenId"
value={lookupTokenId}
onChange={handleTokenIdChange}
/>
) : null}
{displayError ? (
<Typography color="error">{displayError}</Typography>
) : null}
{isLoading ? (
<div className={classes.loaderWrapper}>
<CircularProgress />
</div>
) : null}
{parsedTokenAccount ? (
<NFTViewer value={parsedTokenAccount} chainId={lookupChain} />
) : null}
{originInfo ? (
<>
<Typography
variant="h5"
gutterBottom
className={classes.originHeader}
>
Origin Info
</Typography>
<Typography variant="body2" gutterBottom>
Address: {readableAddress}
</Typography>
{originInfo.chainId === CHAIN_ID_SOLANA ? null : (
<Typography variant="body2" gutterBottom>
Token ID: {originInfo.tokenId}
</Typography>
)}
<div className={classes.viewButtonWrapper}>
{originInfo.chainId === CHAIN_ID_SOLANA ? (
<Button
href={`https://solscan.io/token/${readableAddress}`}
target="_blank"
endIcon={<Launch />}
className={classes.viewButton}
variant="outlined"
>
View on Solscan
</Button>
) : (
<Button
href={`https://opensea.io/assets/${readableAddress}/${originInfo.tokenId}`}
target="_blank"
endIcon={<Launch />}
className={classes.viewButton}
variant="outlined"
>
View on OpenSea
</Button>
)}
</div>
</>
) : null}
</Card>
</Container>
</div>
);
}

View File

@ -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),

View File

@ -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));

View File

@ -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;