bridge_ui: more safety checks and feedback messages

fixes https://github.com/certusone/wormhole/issues/372
fixes https://github.com/certusone/wormhole/issues/366

Change-Id: Ieefdd2f04e353d4a68204864bfa91e8e8ebafc30
This commit is contained in:
Evan Gray 2021-09-01 03:42:16 -04:00
parent e11e59095f
commit b234c223b8
17 changed files with 373 additions and 87 deletions

View File

@ -50,6 +50,7 @@
"@craco/craco": "^6.2.0", "@craco/craco": "^6.2.0",
"@truffle/hdwallet-provider": "^1.4.1", "@truffle/hdwallet-provider": "^1.4.1",
"@types/node": "^16.6.1", "@types/node": "^16.6.1",
"@types/react-router-dom": "^5.1.8",
"prettier": "^2.3.2", "prettier": "^2.3.2",
"truffle": "^5.4.1", "truffle": "^5.4.1",
"wasm-loader": "^1.3.0" "wasm-loader": "^1.3.0"
@ -7882,6 +7883,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/history": {
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.9.tgz",
"integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ==",
"dev": true
},
"node_modules/@types/hoist-non-react-statics": { "node_modules/@types/hoist-non-react-statics": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
@ -8080,6 +8087,27 @@
"@babel/runtime": "^7.9.2" "@babel/runtime": "^7.9.2"
} }
}, },
"node_modules/@types/react-router": {
"version": "5.1.16",
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.16.tgz",
"integrity": "sha512-8d7nR/fNSqlTFGHti0R3F9WwIertOaaA1UEB8/jr5l5mDMOs4CidEgvvYMw4ivqrBK+vtVLxyTj2P+Pr/dtgzg==",
"dev": true,
"dependencies": {
"@types/history": "*",
"@types/react": "*"
}
},
"node_modules/@types/react-router-dom": {
"version": "5.1.8",
"resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.8.tgz",
"integrity": "sha512-03xHyncBzG0PmDmf8pf3rehtjY0NpUj7TIN46FrT5n1ZWHPZvXz32gUyNboJ+xsL8cpg8bQVLcllptcQHvocrw==",
"dev": true,
"dependencies": {
"@types/history": "*",
"@types/react": "*",
"@types/react-router": "*"
}
},
"node_modules/@types/react-transition-group": { "node_modules/@types/react-transition-group": {
"version": "4.4.2", "version": "4.4.2",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.2.tgz", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.2.tgz",
@ -45781,6 +45809,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"@types/history": {
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.9.tgz",
"integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ==",
"dev": true
},
"@types/hoist-non-react-statics": { "@types/hoist-non-react-statics": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
@ -45988,6 +46022,27 @@
} }
} }
}, },
"@types/react-router": {
"version": "5.1.16",
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.16.tgz",
"integrity": "sha512-8d7nR/fNSqlTFGHti0R3F9WwIertOaaA1UEB8/jr5l5mDMOs4CidEgvvYMw4ivqrBK+vtVLxyTj2P+Pr/dtgzg==",
"dev": true,
"requires": {
"@types/history": "*",
"@types/react": "*"
}
},
"@types/react-router-dom": {
"version": "5.1.8",
"resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.8.tgz",
"integrity": "sha512-03xHyncBzG0PmDmf8pf3rehtjY0NpUj7TIN46FrT5n1ZWHPZvXz32gUyNboJ+xsL8cpg8bQVLcllptcQHvocrw==",
"dev": true,
"requires": {
"@types/history": "*",
"@types/react": "*",
"@types/react-router": "*"
}
},
"@types/react-transition-group": { "@types/react-transition-group": {
"version": "4.4.2", "version": "4.4.2",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.2.tgz", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.2.tgz",

View File

@ -69,6 +69,7 @@
"@craco/craco": "^6.2.0", "@craco/craco": "^6.2.0",
"@truffle/hdwallet-provider": "^1.4.1", "@truffle/hdwallet-provider": "^1.4.1",
"@types/node": "^16.6.1", "@types/node": "^16.6.1",
"@types/react-router-dom": "^5.1.8",
"prettier": "^2.3.2", "prettier": "^2.3.2",
"truffle": "^5.4.1", "truffle": "^5.4.1",
"wasm-loader": "^1.3.0" "wasm-loader": "^1.3.0"

View File

@ -5,6 +5,8 @@ import {
Link, Link,
makeStyles, makeStyles,
Toolbar, Toolbar,
Tooltip,
Typography,
} from "@material-ui/core"; } from "@material-ui/core";
import { GitHub, Publish, Send } from "@material-ui/icons"; import { GitHub, Publish, Send } from "@material-ui/icons";
import { NavLink, Redirect, Route, Switch } from "react-router-dom"; import { NavLink, Redirect, Route, Switch } from "react-router-dom";
@ -30,6 +32,9 @@ const useStyles = makeStyles((theme) => ({
...theme.typography.body1, ...theme.typography.body1,
color: theme.palette.text.primary, color: theme.palette.text.primary,
marginLeft: theme.spacing(6), marginLeft: theme.spacing(6),
[theme.breakpoints.down("sm")]: {
marginLeft: theme.spacing(4),
},
[theme.breakpoints.down("xs")]: { [theme.breakpoints.down("xs")]: {
marginLeft: theme.spacing(2), marginLeft: theme.spacing(2),
}, },
@ -57,39 +62,65 @@ function App() {
<div className={classes.spacer} /> <div className={classes.spacer} />
<Hidden implementation="css" xsDown> <Hidden implementation="css" xsDown>
<div style={{ display: "flex", alignItems: "center" }}> <div style={{ display: "flex", alignItems: "center" }}>
<Link component={NavLink} to="/transfer" className={classes.link}> <Tooltip title="Coming Soon">
Transfer <Typography
</Link> className={classes.link}
<Link component={NavLink} to="/attest" className={classes.link}> style={{ color: "#ffffff80", cursor: "default" }}
Attest >
</Link> NFTs
<IconButton </Typography>
href="https://github.com/certusone/wormhole" </Tooltip>
target="_blank" <Tooltip title="Transfer tokens to another blockchain">
size="small" <Link
className={classes.link} component={NavLink}
> to="/transfer"
<GitHub /> className={classes.link}
</IconButton> >
Transfer
</Link>
</Tooltip>
<Tooltip title="Register a new wrapped token">
<Link
component={NavLink}
to="/register"
className={classes.link}
>
Register
</Link>
</Tooltip>
<Tooltip title="View the source code">
<IconButton
href="https://github.com/certusone/wormhole"
target="_blank"
size="small"
className={classes.link}
>
<GitHub />
</IconButton>
</Tooltip>
</div> </div>
</Hidden> </Hidden>
<Hidden implementation="css" smUp> <Hidden implementation="css" smUp>
<IconButton <Tooltip title="Transfer tokens to another blockchain">
component={NavLink} <IconButton
to="/transfer" component={NavLink}
size="small" to="/transfer"
className={classes.link} size="small"
> className={classes.link}
<Send /> >
</IconButton> <Send />
<IconButton </IconButton>
component={NavLink} </Tooltip>
to="/attest" <Tooltip title="Register a new wrapped token">
size="small" <IconButton
className={classes.link} component={NavLink}
> to="/register"
<Publish /> size="small"
</IconButton> className={classes.link}
>
<Publish />
</IconButton>
</Tooltip>
</Hidden> </Hidden>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
@ -98,7 +129,7 @@ function App() {
<Route exact path="/transfer"> <Route exact path="/transfer">
<Transfer /> <Transfer />
</Route> </Route>
<Route exact path="/attest"> <Route exact path="/register">
<Attest /> <Attest />
</Route> </Route>
<Route> <Route>

View File

@ -0,0 +1,29 @@
import { Typography } from "@material-ui/core";
import React from "react";
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error(error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<Typography variant="h5" style={{ textAlign: "center", marginTop: 24 }}>
An unexpected error has occurred. Please refresh the page.
</Typography>
);
}
return this.props.children;
}
}

View File

@ -15,6 +15,7 @@ import Create from "./Create";
import Send from "./Send"; import Send from "./Send";
import Source from "./Source"; import Source from "./Source";
import Target from "./Target"; import Target from "./Target";
import { Alert } from "@material-ui/lab";
// TODO: ensure that both wallets are connected to the same known network // TODO: ensure that both wallets are connected to the same known network
@ -24,6 +25,10 @@ function Attest() {
const signedVAAHex = useSelector(selectAttestSignedVAAHex); const signedVAAHex = useSelector(selectAttestSignedVAAHex);
return ( return (
<Container maxWidth="md"> <Container maxWidth="md">
<Alert severity="info">
This form allows you to register a token on a new foreign chain. Tokens
must be registered before they can be transferred.
</Alert>
<Stepper activeStep={activeStep} orientation="vertical"> <Stepper activeStep={activeStep} orientation="vertical">
<Step> <Step>
<StepButton onClick={() => dispatch(setStep(0))}> <StepButton onClick={() => dispatch(setStep(0))}>
@ -54,7 +59,7 @@ function Attest() {
onClick={() => dispatch(setStep(3))} onClick={() => dispatch(setStep(3))}
disabled={!signedVAAHex} disabled={!signedVAAHex}
> >
Create wrapper Create wrapped token
</StepButton> </StepButton>
<StepContent> <StepContent>
<Create /> <Create />

View File

@ -1,3 +1,4 @@
import { ChainId, CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk";
import { Typography } from "@material-ui/core"; import { Typography } from "@material-ui/core";
import { import {
ASSOCIATED_TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID,
@ -5,26 +6,29 @@ import {
TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID,
} from "@solana/spl-token"; } from "@solana/spl-token";
import { Connection, PublicKey, Transaction } from "@solana/web3.js"; import { Connection, PublicKey, Transaction } from "@solana/web3.js";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useSolanaWallet } from "../contexts/SolanaWalletContext"; import { useSolanaWallet } from "../contexts/SolanaWalletContext";
import { SOLANA_HOST } from "../utils/consts"; import { SOLANA_HOST } from "../utils/consts";
import { signSendAndConfirm } from "../utils/solana"; import { signSendAndConfirm } from "../utils/solana";
import ButtonWithLoader from "./ButtonWithLoader"; import ButtonWithLoader from "./ButtonWithLoader";
export default function SolanaCreateAssociatedAddress({ export function useAssociatedAccountExistsState(
mintAddress, targetChain: ChainId,
readableTargetAddress, mintAddress: string | null | undefined,
}: { readableTargetAddress: string
mintAddress: string; ) {
readableTargetAddress: string;
}) {
const [isCreating, setIsCreating] = useState(false);
const [associatedAccountExists, setAssociatedAccountExists] = useState(true); // for now, assume it exists until we confirm it doesn't const [associatedAccountExists, setAssociatedAccountExists] = useState(true); // for now, assume it exists until we confirm it doesn't
const solanaWallet = useSolanaWallet(); const solanaWallet = useSolanaWallet();
const solPK = solanaWallet?.publicKey; const solPK = solanaWallet?.publicKey;
useEffect(() => { useEffect(() => {
setAssociatedAccountExists(true); setAssociatedAccountExists(true);
if (!mintAddress || !readableTargetAddress || !solPK) return; if (
targetChain !== CHAIN_ID_SOLANA ||
!mintAddress ||
!readableTargetAddress ||
!solPK
)
return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
// TODO: share connection in context? // TODO: share connection in context?
@ -52,7 +56,27 @@ export default function SolanaCreateAssociatedAddress({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [mintAddress, readableTargetAddress, solPK]); }, [targetChain, mintAddress, readableTargetAddress, solPK]);
return useMemo(
() => ({ associatedAccountExists, setAssociatedAccountExists }),
[associatedAccountExists]
);
}
export default function SolanaCreateAssociatedAddress({
mintAddress,
readableTargetAddress,
associatedAccountExists,
setAssociatedAccountExists,
}: {
mintAddress: string;
readableTargetAddress: string;
associatedAccountExists: boolean;
setAssociatedAccountExists: (associatedAccountExists: boolean) => void;
}) {
const [isCreating, setIsCreating] = useState(false);
const solanaWallet = useSolanaWallet();
const solPK = solanaWallet?.publicKey;
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if ( if (
associatedAccountExists || associatedAccountExists ||
@ -100,6 +124,7 @@ export default function SolanaCreateAssociatedAddress({
})(); })();
}, [ }, [
associatedAccountExists, associatedAccountExists,
setAssociatedAccountExists,
mintAddress, mintAddress,
solPK, solPK,
readableTargetAddress, readableTargetAddress,

View File

@ -0,0 +1,21 @@
import { makeStyles, Typography } from "@material-ui/core";
import { ReactChild } from "react";
const useStyles = makeStyles((theme) => ({
description: {
marginBottom: theme.spacing(4),
},
}));
export default function StepDescription({
children,
}: {
children: ReactChild;
}) {
const classes = useStyles();
return (
<Typography component="div" variant="body2" className={classes.description}>
{children}
</Typography>
);
}

View File

@ -417,15 +417,20 @@ function RecoveryDialogContent({ onClose }: { onClose: () => void }) {
); );
} }
export default function Recovery() { export default function Recovery({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
const classes = useStyles(); const classes = useStyles();
const [open, setOpen] = useState(false);
const handleOpenClick = useCallback(() => { const handleOpenClick = useCallback(() => {
setOpen(true); setOpen(true);
}, []); }, [setOpen]);
const handleCloseClick = useCallback(() => { const handleCloseClick = useCallback(() => {
setOpen(false); setOpen(false);
}, []); }, [setOpen]);
return ( return (
<> <>
<Fab className={classes.fab} onClick={handleOpenClick}> <Fab className={classes.fab} onClick={handleOpenClick}>

View File

@ -4,6 +4,7 @@ import useIsWalletReady from "../../hooks/useIsWalletReady";
import { selectTransferTargetChain } from "../../store/selectors"; import { selectTransferTargetChain } from "../../store/selectors";
import ButtonWithLoader from "../ButtonWithLoader"; import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance"; import KeyAndBalance from "../KeyAndBalance";
import StepDescription from "../StepDescription";
function Redeem() { function Redeem() {
const { handleClick, disabled, showLoader } = useHandleRedeem(); const { handleClick, disabled, showLoader } = useHandleRedeem();
@ -11,6 +12,7 @@ function Redeem() {
const { isReady, statusMessage } = useIsWalletReady(targetChain); const { isReady, statusMessage } = useIsWalletReady(targetChain);
return ( return (
<> <>
<StepDescription>Receive the tokens on the target chain</StepDescription>
<KeyAndBalance chainId={targetChain} /> <KeyAndBalance chainId={targetChain} />
<ButtonWithLoader <ButtonWithLoader
disabled={!isReady || disabled} disabled={!isReady || disabled}

View File

@ -0,0 +1,47 @@
import { Button } from "@material-ui/core";
import { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useHistory } from "react-router-dom";
import {
setSourceAsset,
setSourceChain,
setStep,
setTargetChain,
} from "../../store/attestSlice";
import {
selectAttestSignedVAAHex,
selectTransferSourceAsset,
selectTransferSourceChain,
selectTransferTargetChain,
} from "../../store/selectors";
export default function RegisterNowButton() {
const dispatch = useDispatch();
const history = useHistory();
const sourceChain = useSelector(selectTransferSourceChain);
const sourceAsset = useSelector(selectTransferSourceAsset);
const targetChain = useSelector(selectTransferTargetChain);
// user might be in the middle of a different attest
const signedVAAHex = useSelector(selectAttestSignedVAAHex);
const canSwitch = sourceAsset && !signedVAAHex;
const handleClick = useCallback(() => {
if (sourceAsset && canSwitch) {
dispatch(setSourceChain(sourceChain));
dispatch(setSourceAsset(sourceAsset));
dispatch(setTargetChain(targetChain));
dispatch(setStep(2));
history.push("/register");
}
}, [dispatch, canSwitch, sourceChain, sourceAsset, targetChain, history]);
if (!canSwitch) return null;
return (
<Button
variant="outlined"
size="small"
style={{ display: "block", margin: "4px auto 0px" }}
onClick={handleClick}
>
Register Now
</Button>
);
}

View File

@ -1,3 +1,4 @@
import { Alert } from "@material-ui/lab";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { useHandleTransfer } from "../../hooks/useHandleTransfer"; import { useHandleTransfer } from "../../hooks/useHandleTransfer";
import useIsWalletReady from "../../hooks/useIsWalletReady"; import useIsWalletReady from "../../hooks/useIsWalletReady";
@ -5,8 +6,10 @@ import {
selectTransferSourceChain, selectTransferSourceChain,
selectTransferTargetError, selectTransferTargetError,
} from "../../store/selectors"; } from "../../store/selectors";
import { CHAINS_BY_ID } from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader"; import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance"; import KeyAndBalance from "../KeyAndBalance";
import StepDescription from "../StepDescription";
function Send() { function Send() {
const { handleClick, disabled, showLoader } = useHandleTransfer(); const { handleClick, disabled, showLoader } = useHandleTransfer();
@ -15,7 +18,14 @@ function Send() {
const { isReady, statusMessage } = useIsWalletReady(sourceChain); const { isReady, statusMessage } = useIsWalletReady(sourceChain);
return ( return (
<> <>
<StepDescription>Transfer the tokens to the worm bridge.</StepDescription>
<KeyAndBalance chainId={sourceChain} /> <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 <ButtonWithLoader
disabled={!isReady || disabled} disabled={!isReady || disabled}
onClick={handleClick} onClick={handleClick}

View File

@ -1,4 +1,5 @@
import { makeStyles, MenuItem, TextField } from "@material-ui/core"; import { Button, makeStyles, MenuItem, TextField } from "@material-ui/core";
import { Restore } from "@material-ui/icons";
import { useCallback } from "react"; import { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import useIsWalletReady from "../../hooks/useIsWalletReady"; import useIsWalletReady from "../../hooks/useIsWalletReady";
@ -18,6 +19,7 @@ import {
import { CHAINS } from "../../utils/consts"; import { CHAINS } from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader"; import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance"; import KeyAndBalance from "../KeyAndBalance";
import StepDescription from "../StepDescription";
import { TokenSelector } from "../TokenSelectors/SourceTokenSelector"; import { TokenSelector } from "../TokenSelectors/SourceTokenSelector";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
@ -26,7 +28,11 @@ const useStyles = makeStyles((theme) => ({
}, },
})); }));
function Source() { function Source({
setIsRecoveryOpen,
}: {
setIsRecoveryOpen: (open: boolean) => void;
}) {
const classes = useStyles(); const classes = useStyles();
const dispatch = useDispatch(); const dispatch = useDispatch();
const sourceChain = useSelector(selectTransferSourceChain); const sourceChain = useSelector(selectTransferSourceChain);
@ -53,6 +59,20 @@ function Source() {
}, [dispatch]); }, [dispatch]);
return ( return (
<> <>
<StepDescription>
<div style={{ display: "flex", alignItems: "center" }}>
Select tokens to send through the worm bridge.
<div style={{ flexGrow: 1 }} />
<Button
onClick={() => setIsRecoveryOpen(true)}
size="small"
variant="outlined"
endIcon={<Restore />}
>
Perform Recovery
</Button>
</div>
</StepDescription>
<TextField <TextField
select select
fullWidth fullWidth
@ -72,7 +92,6 @@ function Source() {
<TokenSelector disabled={shouldLockFields} /> <TokenSelector disabled={shouldLockFields} />
</div> </div>
) : null} ) : null}
{/* TODO: token list for eth, check own */}
<TextField <TextField
label="Amount" label="Amount"
type="number" type="number"

View File

@ -13,13 +13,18 @@ import {
selectTransferTargetBalanceString, selectTransferTargetBalanceString,
selectTransferTargetChain, selectTransferTargetChain,
selectTransferTargetError, selectTransferTargetError,
UNREGISTERED_ERROR_MESSAGE,
} from "../../store/selectors"; } from "../../store/selectors";
import { incrementStep, setTargetChain } from "../../store/transferSlice"; import { incrementStep, setTargetChain } from "../../store/transferSlice";
import { hexToNativeString } from "../../utils/array"; import { hexToNativeString } from "../../utils/array";
import { CHAINS } from "../../utils/consts"; import { CHAINS } from "../../utils/consts";
import ButtonWithLoader from "../ButtonWithLoader"; import ButtonWithLoader from "../ButtonWithLoader";
import KeyAndBalance from "../KeyAndBalance"; import KeyAndBalance from "../KeyAndBalance";
import SolanaCreateAssociatedAddress from "../SolanaCreateAssociatedAddress"; import SolanaCreateAssociatedAddress, {
useAssociatedAccountExistsState,
} from "../SolanaCreateAssociatedAddress";
import StepDescription from "../StepDescription";
import RegisterNowButton from "./RegisterNowButton";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
transferField: { transferField: {
@ -45,6 +50,12 @@ function Target() {
const isTargetComplete = useSelector(selectTransferIsTargetComplete); const isTargetComplete = useSelector(selectTransferIsTargetComplete);
const shouldLockFields = useSelector(selectTransferShouldLockFields); const shouldLockFields = useSelector(selectTransferShouldLockFields);
const { statusMessage } = useIsWalletReady(targetChain); const { statusMessage } = useIsWalletReady(targetChain);
const { associatedAccountExists, setAssociatedAccountExists } =
useAssociatedAccountExistsState(
targetChain,
targetAsset,
readableTargetAddress
);
useSyncTargetAddress(!shouldLockFields); useSyncTargetAddress(!shouldLockFields);
const handleTargetChange = useCallback( const handleTargetChange = useCallback(
(event) => { (event) => {
@ -57,6 +68,7 @@ function Target() {
}, [dispatch]); }, [dispatch]);
return ( return (
<> <>
<StepDescription>Select a recipient chain and address.</StepDescription>
<TextField <TextField
select select
fullWidth fullWidth
@ -72,7 +84,7 @@ function Target() {
</TextField> </TextField>
<KeyAndBalance chainId={targetChain} balance={uiAmountString} /> <KeyAndBalance chainId={targetChain} balance={uiAmountString} />
<TextField <TextField
label="Address" label="Recipient Address"
fullWidth fullWidth
className={classes.transferField} className={classes.transferField}
value={readableTargetAddress} value={readableTargetAddress}
@ -82,23 +94,28 @@ function Target() {
<SolanaCreateAssociatedAddress <SolanaCreateAssociatedAddress
mintAddress={targetAsset} mintAddress={targetAsset}
readableTargetAddress={readableTargetAddress} readableTargetAddress={readableTargetAddress}
associatedAccountExists={associatedAccountExists}
setAssociatedAccountExists={setAssociatedAccountExists}
/> />
) : null} ) : null}
<TextField <TextField
label="Asset" label="Token Address"
fullWidth fullWidth
className={classes.transferField} className={classes.transferField}
value={targetAsset || ""} value={targetAsset || ""}
disabled={true} disabled={true}
/> />
<ButtonWithLoader <ButtonWithLoader
disabled={!isTargetComplete} disabled={!isTargetComplete || !associatedAccountExists}
onClick={handleNextClick} onClick={handleNextClick}
showLoader={false} showLoader={false}
error={statusMessage || error} error={statusMessage || error}
> >
Next Next
</ButtonWithLoader> </ButtonWithLoader>
{!statusMessage && error === UNREGISTERED_ERROR_MESSAGE ? (
<RegisterNowButton />
) : null}
</> </>
); );
} }

View File

@ -5,13 +5,16 @@ import {
StepContent, StepContent,
Stepper, Stepper,
} from "@material-ui/core"; } from "@material-ui/core";
import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import useCheckIfWormholeWrapped from "../../hooks/useCheckIfWormholeWrapped"; import useCheckIfWormholeWrapped from "../../hooks/useCheckIfWormholeWrapped";
import useFetchTargetAsset from "../../hooks/useFetchTargetAsset"; import useFetchTargetAsset from "../../hooks/useFetchTargetAsset";
import useGetBalanceEffect from "../../hooks/useGetBalanceEffect"; import useGetBalanceEffect from "../../hooks/useGetBalanceEffect";
import { import {
selectTransferActiveStep, selectTransferActiveStep,
selectTransferSignedVAAHex, selectTransferIsRedeeming,
selectTransferIsSendComplete,
selectTransferIsSending,
} from "../../store/selectors"; } from "../../store/selectors";
import { setStep } from "../../store/transferSlice"; import { setStep } from "../../store/transferSlice";
import Recovery from "./Recovery"; import Recovery from "./Recovery";
@ -29,26 +32,32 @@ function Transfer() {
useCheckIfWormholeWrapped(); useCheckIfWormholeWrapped();
useFetchTargetAsset(); useFetchTargetAsset();
useGetBalanceEffect("target"); useGetBalanceEffect("target");
const [isRecoveryOpen, setIsRecoveryOpen] = useState(false);
const dispatch = useDispatch(); const dispatch = useDispatch();
const activeStep = useSelector(selectTransferActiveStep); const activeStep = useSelector(selectTransferActiveStep);
const signedVAAHex = useSelector(selectTransferSignedVAAHex); const isSending = useSelector(selectTransferIsSending);
const isSendComplete = useSelector(selectTransferIsSendComplete);
const isRedeeming = useSelector(selectTransferIsRedeeming);
const preventNavigation = isSending || isSendComplete || isRedeeming;
useEffect(() => {
if (preventNavigation) {
window.onbeforeunload = () => true;
return () => {
window.onbeforeunload = null;
};
}
}, [preventNavigation]);
return ( return (
<Container maxWidth="md"> <Container maxWidth="md">
<Stepper activeStep={activeStep} orientation="vertical"> <Stepper activeStep={activeStep} orientation="vertical">
<Step> <Step>
<StepButton onClick={() => dispatch(setStep(0))}> <StepButton onClick={() => dispatch(setStep(0))}>Source</StepButton>
Select a source
</StepButton>
<StepContent> <StepContent>
<Source /> <Source setIsRecoveryOpen={setIsRecoveryOpen} />
</StepContent> </StepContent>
</Step> </Step>
<Step> <Step>
<StepButton onClick={() => dispatch(setStep(1))}> <StepButton onClick={() => dispatch(setStep(1))}>Target</StepButton>
Select a target
</StepButton>
<StepContent> <StepContent>
<Target /> <Target />
</StepContent> </StepContent>
@ -64,7 +73,7 @@ function Transfer() {
<Step> <Step>
<StepButton <StepButton
onClick={() => dispatch(setStep(3))} onClick={() => dispatch(setStep(3))}
disabled={!signedVAAHex} disabled={!isSendComplete}
> >
Redeem tokens Redeem tokens
</StepButton> </StepButton>
@ -73,7 +82,7 @@ function Transfer() {
</StepContent> </StepContent>
</Step> </Step>
</Stepper> </Stepper>
<Recovery /> <Recovery open={isRecoveryOpen} setOpen={setIsRecoveryOpen} />
</Container> </Container>
); );
} }

View File

@ -108,6 +108,7 @@ const getEthereumAccountsCovalent = async (
if (tokens instanceof Array && tokens.length) { if (tokens instanceof Array && tokens.length) {
for (const item of tokens) { for (const item of tokens) {
// TODO: filter?
if ( if (
item.contract_decimals && item.contract_decimals &&
item.contract_ticker_symbol && item.contract_ticker_symbol &&

View File

@ -9,26 +9,31 @@ import RadialGradient from "./components/RadialGradient";
import { EthereumProviderProvider } from "./contexts/EthereumProviderContext"; import { EthereumProviderProvider } from "./contexts/EthereumProviderContext";
import { SolanaWalletProvider } from "./contexts/SolanaWalletContext.tsx"; import { SolanaWalletProvider } from "./contexts/SolanaWalletContext.tsx";
import { TerraWalletProvider } from "./contexts/TerraWalletContext.tsx"; import { TerraWalletProvider } from "./contexts/TerraWalletContext.tsx";
import ErrorBoundary from "./ErrorBoundary";
import { theme } from "./muiTheme"; import { theme } from "./muiTheme";
import { store } from "./store"; import { store } from "./store";
ReactDOM.render( ReactDOM.render(
<Provider store={store}> <ErrorBoundary>
<ThemeProvider theme={theme}> <Provider store={store}>
<CssBaseline /> <ThemeProvider theme={theme}>
<RadialGradient /> <CssBaseline />
<SnackbarProvider maxSnack={3}> <RadialGradient />
<SolanaWalletProvider> <ErrorBoundary>
<EthereumProviderProvider> <SnackbarProvider maxSnack={3}>
<TerraWalletProvider> <SolanaWalletProvider>
<HashRouter> <EthereumProviderProvider>
<App /> <TerraWalletProvider>
</HashRouter> <HashRouter>
</TerraWalletProvider> <App />
</EthereumProviderProvider> </HashRouter>
</SolanaWalletProvider> </TerraWalletProvider>
</SnackbarProvider> </EthereumProviderProvider>
</ThemeProvider> </SolanaWalletProvider>
</Provider>, </SnackbarProvider>
</ErrorBoundary>
</ThemeProvider>
</Provider>
</ErrorBoundary>,
document.getElementById("root") document.getElementById("root")
); );

View File

@ -71,7 +71,9 @@ export const selectTransferIsSending = (state: RootState) =>
state.transfer.isSending; state.transfer.isSending;
export const selectTransferIsRedeeming = (state: RootState) => export const selectTransferIsRedeeming = (state: RootState) =>
state.transfer.isRedeeming; state.transfer.isRedeeming;
export const selectTransferSourceError = (state: RootState) => { export const selectTransferSourceError = (
state: RootState
): string | undefined => {
if (!state.transfer.sourceChain) { if (!state.transfer.sourceChain) {
return "Select a source chain"; return "Select a source chain";
} }
@ -127,6 +129,8 @@ export const selectTransferSourceError = (state: RootState) => {
}; };
export const selectTransferIsSourceComplete = (state: RootState) => export const selectTransferIsSourceComplete = (state: RootState) =>
!selectTransferSourceError(state); !selectTransferSourceError(state);
export const UNREGISTERED_ERROR_MESSAGE =
"Target asset unavailable. Is the token registered?";
export const selectTransferTargetError = (state: RootState) => { export const selectTransferTargetError = (state: RootState) => {
const sourceError = selectTransferSourceError(state); const sourceError = selectTransferSourceError(state);
if (sourceError) { if (sourceError) {
@ -136,13 +140,13 @@ export const selectTransferTargetError = (state: RootState) => {
return "Select a target chain"; return "Select a target chain";
} }
if (!state.transfer.targetAsset) { if (!state.transfer.targetAsset) {
return "Target asset unavailable. Is the token attested?"; return UNREGISTERED_ERROR_MESSAGE;
} }
if ( if (
state.transfer.targetChain === CHAIN_ID_ETH && state.transfer.targetChain === CHAIN_ID_ETH &&
state.transfer.targetAsset === ethers.constants.AddressZero state.transfer.targetAsset === ethers.constants.AddressZero
) { ) {
return "Target asset unavailable. Is the token attested?"; return UNREGISTERED_ERROR_MESSAGE;
} }
if (!state.transfer.targetAddressHex) { if (!state.transfer.targetAddressHex) {
return "Target account unavailable"; return "Target account unavailable";