bridge_ui: nft origin verifier
Change-Id: I7a76d5ca5ba462553a306348ba9ec9f5fce0364b
This commit is contained in:
parent
6e8668e56d
commit
5c7c350e8e
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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),
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue