bridge_ui: initial NFT bridge support

Change-Id: Iafb0d4f53541cc11c9d42bd432541383274cd2fc
This commit is contained in:
Evan Gray 2021-09-10 00:22:11 -04:00
parent b77751788b
commit 7711abf29a
44 changed files with 2264 additions and 458 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
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);
}
);
//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);
}
);
});
})
.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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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: {

View File

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

View File

@ -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(selectTransferOriginChain);
const originAsset = useSelector(selectTransferOriginAsset);
const targetChain = useSelector(selectTransferTargetChain);
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 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));
}
}
if (targetChain === CHAIN_ID_SOLANA && originChain && originAsset) {
try {
const asset = await getForeignAssetSol(originChain, originAsset);
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 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,
]);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
]);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
}
},

View File

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

View File

@ -1,35 +1,42 @@
// run this script with truffle exec
const ERC20 = artifacts.require("ERC20PresetMinterPauser")
const ERC721 = artifacts.require("ERC721PresetMinterPauserAutoId")
const ERC20 = artifacts.require("ERC20PresetMinterPauser");
const ERC721 = artifacts.require("ERC721PresetMinterPauserAutoId");
module.exports = async function (callback) {
try {
const accounts = await web3.eth.getAccounts();
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);
// 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);
console.log("Token deployed at: " + tokenAddress);
// mint 1000 units
await token.methods.mint(accounts[0], "1000000000000000000000").send({
from: accounts[0],
gas: 1000000
});
// 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
});
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);
console.log("NFT deployed at: " + nftAddress);
callback();
} catch (e) {
callback(e);
}
}
callback();
} catch (e) {
callback(e);
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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