bridge_ui: v1 safety checks, wallet desync fix, basic terra token picker

Change-Id: I9e45ce77c573e6940e6280b52ab2a319e6c4472f
This commit is contained in:
chase-45 2021-09-06 19:33:43 -04:00 committed by Evan Gray
parent c47d32ba9c
commit fc300f47e6
16 changed files with 1107 additions and 147 deletions

View File

@ -18,6 +18,8 @@ import {
} 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";
const useStyles = makeStyles(() =>
createStyles({
@ -34,6 +36,14 @@ const useStyles = makeStyles(() =>
})
);
const isWormholev1 = (provider: any, address: string) => {
const connection = WormholeAbi__factory.connect(
WORMHOLE_V1_ETH_ADDRESS,
provider
);
return connection.isWrappedAsset(address);
};
type EthereumSourceTokenSelectorProps = {
value: ParsedTokenAccount | null;
onChange: (newValue: ParsedTokenAccount | null) => void;
@ -79,18 +89,64 @@ export default function EthereumSourceTokenSelector(
const [advancedModeSymbol, setAdvancedModeSymbol] = useState("");
const [advancedModeHolderString, setAdvancedModeHolderString] = useState("");
const [advancedModeError, setAdvancedModeError] = useState("");
const [autocompleteHolder, setAutocompleteHolder] =
useState<ParsedTokenAccount | null>(null);
const [autocompleteError, setAutocompleteError] = useState("");
const { provider, signerAddress } = useEthereumProvider();
// const wrappedTestToken = "0x8bf3c393b588bb6ad021e154654493496139f06d";
// const notWrappedTestToken = "0xaaaebe6fe48e54f431b0c390cfaf0b017d09d42d";
useEffect(() => {
//If we receive a push from our parent, usually on component mount, we set the advancedModeString to synchronize.
//If we receive a push from our parent, usually on component mount, we set our internal value to synchronize
//This also kicks off the metadata load.
if (advancedMode && value && advancedModeHolderString !== value.mintKey) {
setAdvancedModeHolderString(value.mintKey);
}
}, [value, advancedMode, advancedModeHolderString]);
if (!advancedMode && value && !autocompleteHolder) {
setAutocompleteHolder(value);
}
}, [value, advancedMode, advancedModeHolderString, autocompleteHolder]);
//This loads the parsedTokenAccount & symbol from the advancedModeString
//TODO move to util or hook
//This effect is watching the autocomplete selection.
//It checks to make sure the token is a valid choice before putting it on the state.
//At present, that just means it can't be wormholev1
useEffect(() => {
if (advancedMode || !autocompleteHolder || !provider) {
return;
} else {
let cancelled = false;
setAutocompleteError("");
isWormholev1(provider, autocompleteHolder.mintKey).then(
(result) => {
if (!cancelled) {
result
? setAutocompleteError(
"Wormhole v1 tokens cannot be transferred with this bridge."
)
: onChange(autocompleteHolder);
}
},
(error) => {
console.log(error);
if (!cancelled) {
setAutocompleteError(
"Warning: please verify if this is a Wormhole v1 token address. V1 tokens should not be transferred with this bridge"
);
onChange(autocompleteHolder);
}
}
);
return () => {
cancelled = true;
};
}
}, [autocompleteHolder, provider, advancedMode, onChange]);
//This effect watches the advancedModeString, and checks that the selected asset is valid before putting
// it on the state.
useEffect(() => {
let cancelled = false;
if (!advancedMode || !isValidEthereumAddress(advancedModeHolderString)) {
@ -106,32 +162,69 @@ export default function EthereumSourceTokenSelector(
!cancelled && setAdvancedModeError("");
!cancelled && setAdvancedModeSymbol("");
try {
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);
//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
}
);
token.symbol().then(
(result) => {
!cancelled && setAdvancedModeSymbol(result);
//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) => {
!cancelled &&
setAdvancedModeError("Failed to find the specified address");
!cancelled && setAdvancedModeLoading(false);
}
);
});
} catch (error) {
(error) => {}
)
);
} catch (e) {
!cancelled &&
setAdvancedModeError("Failed to find the specified address");
!cancelled && setAdvancedModeLoading(false);
@ -162,7 +255,10 @@ export default function EthereumSourceTokenSelector(
if (!account) {
return undefined;
}
return covalent?.data?.find((x) => x.contract_address === account.mintKey);
const item = covalent?.data?.find(
(x) => x.contract_address === account.mintKey
);
return item ? item.contract_ticker_symbol : undefined;
};
const filterConfig = createFilterOptions({
@ -176,51 +272,57 @@ export default function EthereumSourceTokenSelector(
});
const toggleAdvancedMode = () => {
setAdvancedModeHolderString("");
setAdvancedModeError("");
setAdvancedModeSymbol("");
setAdvancedMode(!advancedMode);
};
const handleAutocompleteChange = (newValue: ParsedTokenAccount | null) => {
setAutocompleteHolder(newValue);
};
const isLoading =
props.covalent?.isFetching || props.tokenAccounts?.isFetching;
const symbolString = advancedModeSymbol
? advancedModeSymbol + " "
: getSymbol(value)
? getSymbol(value)?.contract_ticker_symbol + " "
: "";
const autoComplete = (
<Autocomplete
autoComplete
autoHighlight
autoSelect
blurOnSelect
clearOnBlur
fullWidth={false}
filterOptions={filterConfig}
value={value}
onChange={(event, newValue) => {
onChange(newValue);
}}
disabled={disabled}
noOptionsText={"No ERC20 tokens found at the moment."}
options={tokenAccounts?.data || []}
renderInput={(params) => (
<TextField {...params} label="Token Account" variant="outlined" />
<>
<Autocomplete
autoComplete
autoHighlight
autoSelect
blurOnSelect
clearOnBlur
fullWidth={false}
filterOptions={filterConfig}
value={autocompleteHolder}
onChange={(event, newValue) => {
handleAutocompleteChange(newValue);
}}
disabled={disabled}
noOptionsText={"No ERC20 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
);
}}
getOptionLabel={(option) => {
const symbol = getSymbol(option);
return `${symbol ? symbol : "Unknown"} (Address: ${shortenAddress(
option.mintKey
)})`;
}}
/>
{autocompleteError && (
<Typography color="error">{autocompleteError}</Typography>
)}
renderOption={(option) => {
return renderAccount(
option,
covalent?.data?.find((x) => x.contract_address === option.mintKey),
classes
);
}}
getOptionLabel={(option) => {
const symbol = getSymbol(option);
return `${symbol ? symbol : "Unknown"} (Account: ${shortenAddress(
option.publicKey
)}, Address: ${shortenAddress(option.mintKey)})`;
}}
/>
</>
);
const advancedModeToggleButton = (
@ -229,12 +331,19 @@ export default function EthereumSourceTokenSelector(
</OffsetButton>
);
const symbol = getSymbol(value) || advancedModeSymbol;
const content = value ? (
<>
<Typography>{symbolString + value.mintKey}</Typography>
<Typography>{(symbol ? symbol + " " : "") + value.mintKey}</Typography>
<OffsetButton onClick={handleClick} disabled={disabled}>
Clear
</OffsetButton>
{!advancedMode && autocompleteError ? (
<Typography color="error">{autocompleteError}</Typography>
) : advancedMode && advancedModeError ? (
<Typography color="error">{advancedModeError}</Typography>
) : null}
</>
) : isLoading ? (
<CircularProgress />

View File

@ -3,9 +3,10 @@ import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import { Autocomplete } from "@material-ui/lab";
import { createFilterOptions } from "@material-ui/lab/Autocomplete";
import { TokenInfo } from "@solana/spl-token-registry";
import React, { useMemo } from "react";
import React, { useCallback, useMemo } from "react";
import { DataWrapper } from "../../store/helpers";
import { ParsedTokenAccount } from "../../store/transferSlice";
import { WORMHOLE_V1_MINT_AUTHORITY } from "../../utils/consts";
import { Metadata } from "../../utils/metaplex";
import { shortenAddress } from "../../utils/solana";
@ -31,44 +32,7 @@ type SolanaSourceTokenSelectorProps = {
solanaTokenMap: DataWrapper<TokenInfo[]> | undefined;
metaplexData: any; //DataWrapper<(Metadata | undefined)[]> | undefined | null;
disabled: boolean;
};
const renderAccount = (
account: ParsedTokenAccount,
solanaTokenMap: Map<String, TokenInfo>,
metaplexData: Map<String, Metadata>,
classes: any
) => {
const tokenMapData = solanaTokenMap.get(account.mintKey);
const metaplexValue = metaplexData.get(account.mintKey);
const mintPrettyString = shortenAddress(account.mintKey);
const accountAddressPrettyString = shortenAddress(account.publicKey);
const uri = tokenMapData?.logoURI || metaplexValue?.data?.uri || undefined;
const symbol =
tokenMapData?.symbol || metaplexValue?.data.symbol || "Unknown";
const name = tokenMapData?.name || metaplexValue?.data?.name || "--";
return (
<div className={classes.tokenOverviewContainer}>
<div>
{uri && <img alt="" className={classes.tokenImage} src={uri} />}
</div>
<div>
<Typography variant="subtitle1">{symbol}</Typography>
<Typography variant="subtitle2">{name}</Typography>
</div>
<div>
<Typography variant="body1">{"Mint : " + mintPrettyString}</Typography>
<Typography variant="body1">
{"Account :" + accountAddressPrettyString}
</Typography>
</div>
<div>
<Typography variant="body2">{"Balance"}</Typography>
<Typography variant="h6">{account.uiAmountString}</Typography>
</div>
</div>
);
mintAccounts: DataWrapper<Map<String, string | null>> | undefined;
};
export default function SolanaSourceTokenSelector(
@ -135,11 +99,95 @@ export default function SolanaSourceTokenSelector(
},
});
const isWormholev1 = useCallback(
(address: string) => {
//This is a v1 wormhole token on testnet
//const testAddress = "4QixXecTZ4zdZGa39KH8gVND5NZ2xcaB12wiBhE4S7rn";
if (!props.mintAccounts?.data) {
return true; //These should never be null by this point
}
const mintInfo = props.mintAccounts.data.get(address);
if (!mintInfo) {
return true; //We should never fail to pull the mint of an account.
}
if (mintInfo === WORMHOLE_V1_MINT_AUTHORITY) {
return true; //This means the mint was created by the wormhole v1 contract, and we want to disallow its transfer.
}
return false;
},
[props.mintAccounts]
);
const renderAccount = (
account: ParsedTokenAccount,
solanaTokenMap: Map<String, TokenInfo>,
metaplexData: Map<String, Metadata>,
classes: any
) => {
const tokenMapData = solanaTokenMap.get(account.mintKey);
const metaplexValue = metaplexData.get(account.mintKey);
const mintPrettyString = shortenAddress(account.mintKey);
const accountAddressPrettyString = shortenAddress(account.publicKey);
const uri = tokenMapData?.logoURI || metaplexValue?.data?.uri || undefined;
const symbol =
tokenMapData?.symbol || metaplexValue?.data.symbol || "Unknown";
const name = tokenMapData?.name || metaplexValue?.data?.name || "--";
const content = (
<>
<div className={classes.tokenOverviewContainer}>
<div>
{uri && <img alt="" className={classes.tokenImage} src={uri} />}
</div>
<div>
<Typography variant="subtitle1">{symbol}</Typography>
<Typography variant="subtitle2">{name}</Typography>
</div>
<div>
<Typography variant="body1">
{"Mint : " + mintPrettyString}
</Typography>
<Typography variant="body1">
{"Account :" + accountAddressPrettyString}
</Typography>
</div>
<div>
<Typography variant="body2">{"Balance"}</Typography>
<Typography variant="h6">{account.uiAmountString}</Typography>
</div>
</div>
</>
);
const v1Warning = (
<div>
<Typography variant="body2">
Wormhole v1 tokens are not eligible for transfer.
</Typography>
<div>{content}</div>
</div>
);
return isWormholev1(account.mintKey) ? v1Warning : content;
};
//The autocomplete doesn't rerender the option label unless the value changes.
//Thus we should wait for the metadata to arrive before rendering it.
//TODO This can flicker dependent on how fast the useEffects in the getSourceAccounts hook complete.
const isLoading =
props.metaplexData.isFetching || props.solanaTokenMap?.isFetching;
props.metaplexData.isFetching ||
props.solanaTokenMap?.isFetching ||
props.mintAccounts?.isFetching;
const accountLoadError =
!(props.mintAccounts?.isFetching || props.mintAccounts?.data) &&
"Unable to retrieve your token accounts";
const error = accountLoadError;
//This exists to remove NFTs from the list of potential options. It requires reading the metaplex data, so it would be
//difficult to do before this point.
@ -152,6 +200,10 @@ export default function SolanaSourceTokenSelector(
});
}, [memoizedMetaplex, props.accounts]);
const isOptionDisabled = useMemo(() => {
return (value: ParsedTokenAccount) => isWormholev1(value.mintKey);
}, [isWormholev1]);
const autoComplete = (
<Autocomplete
autoComplete
@ -178,6 +230,7 @@ export default function SolanaSourceTokenSelector(
classes
);
}}
getOptionDisabled={isOptionDisabled}
getOptionLabel={(option) => {
const symbol = getSymbol(option);
return `${symbol ? symbol : "Unknown"} (Account: ${shortenAddress(
@ -190,6 +243,7 @@ export default function SolanaSourceTokenSelector(
return (
<React.Fragment>
{isLoading ? <CircularProgress /> : autoComplete}
{error && <Typography color="error">{error}</Typography>}
</React.Fragment>
);
}

View File

@ -8,6 +8,7 @@ import { TextField, Typography } from "@material-ui/core";
import { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import useGetSourceParsedTokens from "../../hooks/useGetSourceParsedTokenAccounts";
import useIsWalletReady from "../../hooks/useIsWalletReady";
import {
selectTransferSourceChain,
selectTransferSourceParsedTokenAccount,
@ -15,6 +16,7 @@ import {
import {
ParsedTokenAccount,
setSourceParsedTokenAccount,
setSourceWalletAddress,
} from "../../store/transferSlice";
import EthereumSourceTokenSelector from "./EthereumSourceTokenSelector";
import SolanaSourceTokenSelector from "./SolanaSourceTokenSelector";
@ -32,13 +34,19 @@ export const TokenSelector = (props: TokenSelectorProps) => {
const sourceParsedTokenAccount = useSelector(
selectTransferSourceParsedTokenAccount
);
const handleSolanaOnChange = useCallback(
const walletIsReady = useIsWalletReady(lookupChain);
const handleOnChange = useCallback(
(newTokenAccount: ParsedTokenAccount | null) => {
if (newTokenAccount !== undefined) {
dispatch(setSourceParsedTokenAccount(newTokenAccount || undefined));
if (!newTokenAccount) {
dispatch(setSourceParsedTokenAccount(undefined));
dispatch(setSourceWalletAddress(undefined));
} else if (newTokenAccount !== undefined && walletIsReady.walletAddress) {
dispatch(setSourceParsedTokenAccount(newTokenAccount));
dispatch(setSourceWalletAddress(walletIsReady.walletAddress));
}
},
[dispatch]
[dispatch, walletIsReady]
);
const maps = useGetSourceParsedTokens();
@ -54,17 +62,18 @@ export const TokenSelector = (props: TokenSelectorProps) => {
) : lookupChain === CHAIN_ID_SOLANA ? (
<SolanaSourceTokenSelector
value={sourceParsedTokenAccount || null}
onChange={handleSolanaOnChange}
onChange={handleOnChange}
disabled={disabled}
accounts={maps?.tokenAccounts?.data || []}
solanaTokenMap={maps?.tokenMap}
metaplexData={maps?.metaplex}
mintAccounts={maps?.mintAccounts}
/>
) : lookupChain === CHAIN_ID_ETH ? (
<EthereumSourceTokenSelector
value={sourceParsedTokenAccount || null}
disabled={disabled}
onChange={handleSolanaOnChange}
onChange={handleOnChange}
covalent={maps?.covalent || undefined}
tokenAccounts={maps?.tokenAccounts} //TODO standardize
/>
@ -72,7 +81,8 @@ export const TokenSelector = (props: TokenSelectorProps) => {
<TerraSourceTokenSelector
value={sourceParsedTokenAccount || null}
disabled={disabled}
onChange={handleSolanaOnChange}
onChange={handleOnChange}
tokenMap={maps?.terraTokenMap}
/>
) : (
<TextField

View File

@ -1,20 +1,48 @@
import { TextField, Typography } from "@material-ui/core";
import {
createStyles,
makeStyles,
TextField,
Typography,
} from "@material-ui/core";
import { Autocomplete, createFilterOptions } from "@material-ui/lab";
import { LCDClient } from "@terra-money/terra.js";
import {
ConnectedWallet,
useConnectedWallet,
} from "@terra-money/wallet-provider";
import { formatUnits } from "ethers/lib/utils";
import React, { useCallback, useState } from "react";
import { createParsedTokenAccount } from "../../hooks/useGetSourceParsedTokenAccounts";
import React, { useCallback, useMemo, useState } from "react";
import {
createParsedTokenAccount,
TerraTokenMap,
TerraTokenMetadata,
} from "../../hooks/useGetSourceParsedTokenAccounts";
import { DataWrapper } from "../../store/helpers";
import { ParsedTokenAccount } from "../../store/transferSlice";
import { TERRA_HOST } from "../../utils/consts";
import { shortenAddress } from "../../utils/solana";
import OffsetButton from "./OffsetButton";
const useStyles = makeStyles(() =>
createStyles({
selectInput: { minWidth: "10rem" },
tokenOverviewContainer: {
display: "flex",
"& div": {
margin: ".5rem",
},
},
tokenImage: {
maxHeight: "2.5rem",
},
})
);
type TerraSourceTokenSelectorProps = {
value: ParsedTokenAccount | null;
onChange: (newValue: ParsedTokenAccount | null) => void;
disabled: boolean;
tokenMap: DataWrapper<TerraTokenMap> | undefined; //TODO better type
};
//TODO move elsewhere
@ -58,12 +86,27 @@ const lookupTerraAddress = (
export default function TerraSourceTokenSelector(
props: TerraSourceTokenSelectorProps
) {
const { onChange, value, disabled } = props;
//const advancedMode = true; //const [advancedMode, setAdvancedMode] = useState(true);
const classes = useStyles();
const { onChange, value, disabled, tokenMap } = props;
const [advancedMode, setAdvancedMode] = useState(false);
const [advancedModeHolderString, setAdvancedModeHolderString] = useState("");
const [advancedModeError, setAdvancedModeError] = useState("");
const terraWallet = useConnectedWallet();
const isLoading = tokenMap?.isFetching || false;
const terraTokenArray = useMemo(() => {
const values = props.tokenMap?.data?.mainnet;
const items = Object.values(values || {});
return items || [];
}, [props.tokenMap]);
const valueToOption = (fromProps: ParsedTokenAccount | undefined | null) => {
if (!fromProps) return undefined;
else {
return terraTokenArray.find((x) => x.token === fromProps.mintKey);
}
};
const handleClick = useCallback(() => {
onChange(null);
setAdvancedModeHolderString("");
@ -74,22 +117,93 @@ export default function TerraSourceTokenSelector(
[]
);
const handleConfirm = () => {
if (terraWallet === undefined) {
const handleConfirm = (address: string | undefined) => {
if (terraWallet === undefined || address === undefined) {
setAdvancedModeError("Terra wallet not connected.");
return;
}
lookupTerraAddress(advancedModeHolderString, terraWallet).then(
setAdvancedModeError("");
lookupTerraAddress(address, terraWallet).then(
(result) => {
onChange(result);
},
(error) => {
setAdvancedModeError("Unable to retrieve address.");
setAdvancedModeError("Unable to retrieve this address.");
}
);
setAdvancedModeError("");
};
const filterConfig = createFilterOptions({
matchFrom: "any",
stringify: (option: TerraTokenMetadata) => {
const symbol = option.symbol + " " || "";
const mint = option.token + " " || "";
const name = option.protocol + " " || "";
return symbol + mint + name;
},
});
const renderOptionLabel = (option: TerraTokenMetadata) => {
return option.symbol + " (" + shortenAddress(option.token) + ")";
};
const renderOption = (option: TerraTokenMetadata) => {
return (
<div className={classes.tokenOverviewContainer}>
<div>
<img alt="" className={classes.tokenImage} src={option.icon} />
</div>
<div>
<Typography variant="h6">{option.symbol}</Typography>
<Typography variant="body2">{option.protocol}</Typography>
</div>
<div>
<Typography variant="body1">{option.token}</Typography>
</div>
</div>
);
};
const toggleAdvancedMode = () => {
setAdvancedMode(!advancedMode);
};
const advancedModeToggleButton = (
<OffsetButton onClick={toggleAdvancedMode} disabled={disabled}>
{advancedMode ? "Toggle Token Picker" : "Toggle Override"}
</OffsetButton>
);
const autoComplete = (
<>
<Autocomplete
autoComplete
autoHighlight
autoSelect
blurOnSelect
clearOnBlur
fullWidth={false}
filterOptions={filterConfig}
value={valueToOption(value)}
onChange={(event, newValue) => {
handleConfirm(newValue?.token);
}}
disabled={disabled}
noOptionsText={"No CW20 tokens found at the moment."}
options={terraTokenArray}
renderInput={(params) => (
<TextField {...params} label="Token" variant="outlined" />
)}
renderOption={renderOption}
getOptionLabel={renderOptionLabel}
/>
{advancedModeError && (
<Typography color="error">{advancedModeError}</Typography>
)}
</>
);
const content = value ? (
<>
<Typography>{value.mintKey}</Typography>
@ -97,22 +211,32 @@ export default function TerraSourceTokenSelector(
Clear
</OffsetButton>
</>
) : !advancedMode ? (
autoComplete
) : (
<>
<TextField
fullWidth
label="Asset Address"
label="Enter an asset address"
value={advancedModeHolderString}
onChange={handleOnChange}
disabled={disabled}
error={advancedModeHolderString !== "" && !!advancedModeError}
helperText={advancedModeError === "" ? undefined : advancedModeError}
/>
<OffsetButton onClick={handleConfirm} disabled={disabled}>
<OffsetButton
onClick={() => handleConfirm(advancedModeHolderString)}
disabled={disabled}
>
Confirm
</OffsetButton>
</>
);
return <React.Fragment>{content}</React.Fragment>;
return (
<React.Fragment>
{content}
{!value && !isLoading && advancedModeToggleButton}
</React.Fragment>
);
}

View File

@ -3,6 +3,7 @@ import { useSelector } from "react-redux";
import { useHandleTransfer } from "../../hooks/useHandleTransfer";
import useIsWalletReady from "../../hooks/useIsWalletReady";
import {
selectSourceWalletAddress,
selectTransferSourceChain,
selectTransferTargetError,
} from "../../store/selectors";
@ -16,7 +17,18 @@ function Send() {
const { handleClick, disabled, showLoader } = useHandleTransfer();
const sourceChain = useSelector(selectTransferSourceChain);
const error = useSelector(selectTransferTargetError);
const { isReady, statusMessage } = useIsWalletReady(sourceChain);
const { isReady, statusMessage, walletAddress } =
useIsWalletReady(sourceChain);
const sourceWalletAddress = useSelector(selectSourceWalletAddress);
//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>
@ -30,10 +42,10 @@ function Send() {
complete the transfer.
</Alert>
<ButtonWithLoader
disabled={!isReady || disabled}
disabled={isDisabled}
onClick={handleClick}
showLoader={showLoader}
error={statusMessage || error}
error={errorMessage}
>
Transfer
</ButtonWithLoader>

View File

@ -8,8 +8,8 @@ import React, {
useState,
} from "react";
type Provider = ethers.providers.Web3Provider | undefined;
type Signer = ethers.Signer | undefined;
export type Provider = ethers.providers.Web3Provider | undefined;
export type Signer = ethers.Signer | undefined;
interface IEthereumProviderContext {
connect(): void;

View File

@ -1,4 +1,8 @@
import { CHAIN_ID_ETH, CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk";
import {
CHAIN_ID_ETH,
CHAIN_ID_SOLANA,
CHAIN_ID_TERRA,
} from "@certusone/wormhole-sdk";
import { Dispatch } from "@reduxjs/toolkit";
import { TOKEN_PROGRAM_ID } from "@solana/spl-token";
import { ENV, TokenListProvider } from "@solana/spl-token-registry";
@ -17,27 +21,44 @@ import { useSolanaWallet } from "../contexts/SolanaWalletContext";
import { DataWrapper } from "../store/helpers";
import {
selectSolanaTokenMap,
selectSourceWalletAddress,
selectTerraTokenMap,
selectTransferSourceChain,
selectTransferSourceParsedTokenAccounts,
} from "../store/selectors";
import {
errorSolanaTokenMap,
errorTerraTokenMap,
fetchSolanaTokenMap,
fetchTerraTokenMap,
receiveSolanaTokenMap,
receiveTerraTokenMap,
} from "../store/tokenSlice";
import {
errorSourceParsedTokenAccounts,
fetchSourceParsedTokenAccounts,
ParsedTokenAccount,
receiveSourceParsedTokenAccounts,
setAmount,
setSourceParsedTokenAccount,
setSourceParsedTokenAccounts,
setSourceWalletAddress,
} from "../store/transferSlice";
import { CLUSTER, COVALENT_GET_TOKENS_URL, SOLANA_HOST } from "../utils/consts";
import {
CLUSTER,
COVALENT_GET_TOKENS_URL,
SOLANA_HOST,
TERRA_TOKEN_METADATA_URL,
} from "../utils/consts";
import {
decodeMetadata,
getMetadataAddress,
Metadata,
} from "../utils/metaplex";
import { getMultipleAccountsRPC } from "../utils/solana";
import {
extractMintAuthorityInfo,
getMultipleAccountsRPC,
} from "../utils/solana";
export function createParsedTokenAccount(
publicKey: string,
@ -57,6 +78,19 @@ export function createParsedTokenAccount(
};
}
export type TerraTokenMetadata = {
protocol: string;
symbol: string;
token: string;
icon: string;
};
export type TerraTokenMap = {
mainnet: {
[address: string]: TerraTokenMetadata;
};
};
const createParsedTokenAccountFromInfo = (
pubkey: PublicKey,
item: AccountInfo<ParsedAccountData>
@ -211,6 +245,7 @@ function useGetAvailableTokens() {
const tokenAccounts = useSelector(selectTransferSourceParsedTokenAccounts);
const solanaTokenMap = useSelector(selectSolanaTokenMap);
const terraTokenMap = useSelector(selectTerraTokenMap);
const lookupChain = useSelector(selectTransferSourceChain);
const solanaWallet = useSolanaWallet();
@ -228,6 +263,38 @@ function useGetAvailableTokens() {
undefined
);
const [solanaMintAccounts, setSolanaMintAccounts] = useState<any>(undefined);
const [solanaMintAccountsLoading, setSolanaMintAccountsLoading] =
useState(false);
const [solanaMintAccountsError, setSolanaMintAccountsError] = useState<
string | undefined
>(undefined);
const selectedSourceWalletAddress = useSelector(selectSourceWalletAddress);
const currentSourceWalletAddress: string | undefined =
lookupChain === CHAIN_ID_ETH
? signerAddress
: lookupChain === CHAIN_ID_SOLANA
? solPK?.toString()
: undefined;
//TODO this useEffect could be somewhere else in the codebase
//It resets the SourceParsedTokens accounts when the wallet changes
useEffect(() => {
if (
selectedSourceWalletAddress !== undefined &&
currentSourceWalletAddress !== undefined &&
currentSourceWalletAddress !== selectedSourceWalletAddress
) {
dispatch(setSourceWalletAddress(undefined));
dispatch(setSourceParsedTokenAccount(undefined));
dispatch(setSourceParsedTokenAccounts(undefined));
dispatch(setAmount(""));
return;
} else {
}
}, [selectedSourceWalletAddress, currentSourceWalletAddress, dispatch]);
// Solana metaplex load
useEffect(() => {
let cancelled = false;
@ -289,6 +356,53 @@ function useGetAvailableTokens() {
solanaTokenMap,
]);
//Solana Mint Accounts lookup
useEffect(() => {
if (lookupChain !== CHAIN_ID_SOLANA || !tokenAccounts.data?.length) {
return () => {};
}
let cancelled = false;
setSolanaMintAccountsLoading(true);
setSolanaMintAccountsError(undefined);
const mintAddresses = tokenAccounts.data.map((x) => x.mintKey);
//This is a known wormhole v1 token on testnet
//mintAddresses.push("4QixXecTZ4zdZGa39KH8gVND5NZ2xcaB12wiBhE4S7rn");
const connection = new Connection(SOLANA_HOST, "finalized");
getMultipleAccountsRPC(
connection,
mintAddresses.map((x) => new PublicKey(x))
).then(
(results) => {
if (!cancelled) {
const output = new Map<String, string | null>();
results.forEach((result, index) =>
output.set(
mintAddresses[index],
(result && extractMintAuthorityInfo(result)) || null
)
);
setSolanaMintAccounts(output);
setSolanaMintAccountsLoading(false);
}
},
(error) => {
if (!cancelled) {
setSolanaMintAccounts(undefined);
setSolanaMintAccountsLoading(false);
setSolanaMintAccountsError(
"Could not retrieve Solana mint accounts."
);
}
}
);
return () => (cancelled = true);
}, [tokenAccounts.data, lookupChain]);
//Ethereum accounts load
useEffect(() => {
//const testWallet = "0xf60c2ea62edbfe808163751dd0d8693dcb30019c";
@ -333,10 +447,38 @@ function useGetAvailableTokens() {
}, [lookupChain, provider, signerAddress, dispatch]);
//Terra accounts load
//At present, we don't have any mechanism for doing this.
useEffect(() => {}, []);
//Terra metadata load
useEffect(() => {}, []);
useEffect(() => {
let cancelled = false;
if (terraTokenMap.data || lookupChain !== CHAIN_ID_TERRA) {
return; //So we don't fetch the whole list on every mount.
}
dispatch(fetchTerraTokenMap());
axios.get(TERRA_TOKEN_METADATA_URL).then(
(response) => {
if (!cancelled) {
//TODO parse this in a safer manner
dispatch(receiveTerraTokenMap(response.data as TerraTokenMap));
}
},
(error) => {
if (!cancelled) {
dispatch(
errorTerraTokenMap("Failed to retrieve the Terra Token List.")
);
}
}
);
return () => {
cancelled = true;
};
}, [lookupChain, terraTokenMap.data, dispatch]);
return lookupChain === CHAIN_ID_SOLANA
? {
@ -348,6 +490,12 @@ function useGetAvailableTokens() {
error: metaplexError,
receivedAt: null, //TODO
} as DataWrapper<Metadata[]>,
mintAccounts: {
data: solanaMintAccounts,
isFetching: solanaMintAccountsLoading,
error: solanaMintAccountsError,
receivedAt: null, //TODO
},
}
: lookupChain === CHAIN_ID_ETH
? {
@ -359,6 +507,10 @@ function useGetAvailableTokens() {
receivedAt: null, //TODO
},
}
: lookupChain === CHAIN_ID_TERRA
? {
terraTokenMap: terraTokenMap,
}
: undefined;
}

View File

@ -11,14 +11,20 @@ import { useEthereumProvider } from "../contexts/EthereumProviderContext";
import { useSolanaWallet } from "../contexts/SolanaWalletContext";
import { CLUSTER, ETH_NETWORK_CHAIN_ID } from "../utils/consts";
const createWalletStatus = (isReady: boolean, statusMessage: string = "") => ({
const createWalletStatus = (
isReady: boolean,
statusMessage: string = "",
walletAddress?: string
) => ({
isReady,
statusMessage,
walletAddress,
});
function useIsWalletReady(chainId: ChainId): {
isReady: boolean;
statusMessage: string;
walletAddress?: string;
} {
const solanaWallet = useSolanaWallet();
const solPK = solanaWallet?.publicKey;
@ -33,16 +39,20 @@ function useIsWalletReady(chainId: ChainId): {
const hasCorrectEthNetwork = ethChainId === ETH_NETWORK_CHAIN_ID;
return useMemo(() => {
if (chainId === CHAIN_ID_TERRA && hasTerraWallet) {
if (
chainId === CHAIN_ID_TERRA &&
hasTerraWallet &&
terraWallet?.walletAddress
) {
// TODO: terraWallet does not update on wallet changes
return createWalletStatus(true);
return createWalletStatus(true, undefined, terraWallet.walletAddress);
}
if (chainId === CHAIN_ID_SOLANA && solPK) {
return createWalletStatus(true);
return createWalletStatus(true, undefined, solPK.toString());
}
if (chainId === CHAIN_ID_ETH && hasEthInfo) {
if (chainId === CHAIN_ID_ETH && hasEthInfo && signerAddress) {
if (hasCorrectEthNetwork) {
return createWalletStatus(true);
return createWalletStatus(true, undefined, signerAddress);
} else {
if (provider) {
try {
@ -53,7 +63,8 @@ function useIsWalletReady(chainId: ChainId): {
}
return createWalletStatus(
false,
`Wallet is not connected to ${CLUSTER}. Expected Chain ID: ${ETH_NETWORK_CHAIN_ID}`
`Wallet is not connected to ${CLUSTER}. Expected Chain ID: ${ETH_NETWORK_CHAIN_ID}`,
undefined
);
}
}
@ -66,6 +77,8 @@ function useIsWalletReady(chainId: ChainId): {
hasEthInfo,
hasCorrectEthNetwork,
provider,
signerAddress,
terraWallet,
]);
}

View File

@ -48,6 +48,8 @@ export const selectTransferOriginChain = (state: RootState) =>
state.transfer.originChain;
export const selectTransferOriginAsset = (state: RootState) =>
state.transfer.originAsset;
export const selectSourceWalletAddress = (state: RootState) =>
state.transfer.sourceWalletAddress;
export const selectTransferSourceParsedTokenAccount = (state: RootState) =>
state.transfer.sourceParsedTokenAccount;
export const selectTransferSourceParsedTokenAccounts = (state: RootState) =>
@ -168,3 +170,7 @@ export const selectTransferShouldLockFields = (state: RootState) =>
export const selectSolanaTokenMap = (state: RootState) => {
return state.tokens.solanaTokenMap;
};
export const selectTerraTokenMap = (state: RootState) => {
return state.tokens.terraTokenMap;
};

View File

@ -1,5 +1,6 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { TokenInfo } from "@solana/spl-token-registry";
import { TerraTokenMap } from "../hooks/useGetSourceParsedTokenAccounts";
import {
DataWrapper,
errorDataWrapper,
@ -10,10 +11,12 @@ import {
export interface TokenMetadataState {
solanaTokenMap: DataWrapper<TokenInfo[]>;
terraTokenMap: DataWrapper<TerraTokenMap>; //TODO make a decent type for this.
}
const initialState: TokenMetadataState = {
solanaTokenMap: getEmptyDataWrapper(),
terraTokenMap: getEmptyDataWrapper(),
};
export const tokenSlice = createSlice({
@ -29,6 +32,17 @@ export const tokenSlice = createSlice({
errorSolanaTokenMap: (state, action: PayloadAction<string>) => {
state.solanaTokenMap = errorDataWrapper(action.payload);
},
receiveTerraTokenMap: (state, action: PayloadAction<TerraTokenMap>) => {
state.terraTokenMap = receiveDataWrapper(action.payload);
},
fetchTerraTokenMap: (state) => {
state.terraTokenMap = fetchDataWrapper();
},
errorTerraTokenMap: (state, action: PayloadAction<string>) => {
state.terraTokenMap = errorDataWrapper(action.payload);
},
reset: () => initialState,
},
});
@ -37,6 +51,9 @@ export const {
receiveSolanaTokenMap,
fetchSolanaTokenMap,
errorSolanaTokenMap,
receiveTerraTokenMap,
fetchTerraTokenMap,
errorTerraTokenMap,
reset,
} = tokenSlice.actions;

View File

@ -37,6 +37,7 @@ export interface TransferState {
isSourceAssetWormholeWrapped: boolean | undefined;
originChain: ChainId | undefined;
originAsset: string | undefined;
sourceWalletAddress: string | undefined;
sourceParsedTokenAccount: ParsedTokenAccount | undefined;
sourceParsedTokenAccounts: DataWrapper<ParsedTokenAccount[]>;
amount: string;
@ -55,6 +56,7 @@ const initialState: TransferState = {
activeStep: 0,
sourceChain: CHAIN_ID_SOLANA,
isSourceAssetWormholeWrapped: false,
sourceWalletAddress: undefined,
sourceParsedTokenAccount: undefined,
sourceParsedTokenAccounts: getEmptyDataWrapper(),
originChain: undefined,
@ -110,12 +112,26 @@ export const transferSlice = createSlice({
state.originAsset = undefined;
}
},
setSourceWalletAddress: (
state,
action: PayloadAction<string | undefined>
) => {
state.sourceWalletAddress = action.payload;
},
setSourceParsedTokenAccount: (
state,
action: PayloadAction<ParsedTokenAccount | undefined>
) => {
state.sourceParsedTokenAccount = action.payload;
},
setSourceParsedTokenAccounts: (
state,
action: PayloadAction<ParsedTokenAccount[] | undefined>
) => {
state.sourceParsedTokenAccounts = action.payload
? receiveDataWrapper(action.payload)
: getEmptyDataWrapper();
},
fetchSourceParsedTokenAccounts: (state) => {
state.sourceParsedTokenAccounts = fetchDataWrapper();
},
@ -195,7 +211,9 @@ export const {
setStep,
setSourceChain,
setSourceWormholeWrappedInfo,
setSourceWalletAddress,
setSourceParsedTokenAccount,
setSourceParsedTokenAccounts,
receiveSourceParsedTokenAccounts,
errorSourceParsedTokenAccounts,
fetchSourceParsedTokenAccounts,

View File

@ -30,6 +30,10 @@ export const CHAINS =
id: CHAIN_ID_SOLANA,
name: "Solana",
},
{
id: CHAIN_ID_TERRA,
name: "Terra",
},
]
: [
{
@ -62,11 +66,19 @@ export const ETH_NETWORK_CHAIN_ID =
CLUSTER === "mainnet" ? 1 : CLUSTER === "testnet" ? 5 : 1337;
export const SOLANA_HOST =
CLUSTER === "testnet" ? clusterApiUrl("testnet") : "http://localhost:8899";
export const TERRA_HOST = {
URL: "http://localhost:1317",
chainID: "columbus-4",
name: "localterra",
};
export const TERRA_HOST =
CLUSTER === "testnet"
? {
URL: "https://tequila-lcd.terra.dev",
chainID: "tequila-0004",
name: "testnet",
}
: {
URL: "http://localhost:1317",
chainID: "columbus-4",
name: "localterra",
};
export const ETH_TEST_TOKEN_ADDRESS = getAddress(
CLUSTER === "testnet"
? "0xcEE940033DA197F551BBEdED7F4aA55Ee55C582B"
@ -119,3 +131,24 @@ export const COVALENT_GET_TOKENS_URL = (
};
export const COVALENT_ETHEREUM_MAINNET = "1";
export const WORMHOLE_V1_ETH_ADDRESS =
CLUSTER === "testnet"
? "0xdae0Cba01eFc4bfEc1F7Fece73Fe8b8d2Eda65B0"
: CLUSTER === "mainnet"
? "0xf92cD566Ea4864356C5491c177A430C222d7e678"
: "0xf92cD566Ea4864356C5491c177A430C222d7e678"; //TODO something that doesn't explode in localhost
export const WORMHOLE_V1_SOLANA_ADDRESS =
CLUSTER === "testnet"
? "BrdgiFmZN3BKkcY3danbPYyxPKwb8RhQzpM2VY5L97ED"
: "WormT3McKhFJ2RkiGpdw9GKvNCrB2aB54gb2uV9MfQC";
export const TERRA_TOKEN_METADATA_URL =
"https://assets.terra.money/cw20/tokens.json";
export const WORMHOLE_V1_MINT_AUTHORITY =
CLUSTER === "mainnet"
? "9zyPU1mjgzaVyQsYwKJJ7AhVz5bgx5uc1NPABvAcUXsT"
: CLUSTER === "testnet"
? "BJa7dq3bRP216zaTdw4cdcV71WkPc1HXvmnGeFVDi5DC"
: "";

View File

@ -1,3 +1,4 @@
import { MintLayout } from "@solana/spl-token";
import { WalletContextState } from "@solana/wallet-adapter-react";
import {
AccountInfo,
@ -17,6 +18,19 @@ export async function signSendAndConfirm(
return txid;
}
export function extractMintAuthorityInfo(
account: AccountInfo<Buffer>
): string | null {
const data = Buffer.from(account.data);
const mintInfo = MintLayout.decode(data);
const uintArray = mintInfo?.mintAuthority;
const pubkey = new PublicKey(uintArray);
const output = pubkey?.toString();
return output || null;
}
export async function getMultipleAccountsRPC(
connection: Connection,
pubkeys: PublicKey[]

View File

@ -9,8 +9,9 @@
"lib/**/*"
],
"scripts": {
"postinstall": "npm run build-contracts",
"postinstall": "npm run build-abis && npm run build-contracts",
"build-contracts": "npm run build --prefix ../../ethereum && node scripts/copyContracts.js && typechain --target=ethers-v5 --out-dir=src/ethers-contracts contracts/*.json",
"build-abis": "typechain --target=ethers-v5 --out-dir=src/ethers-contracts/abi src/abi/Wormhole.abi.json",
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc && node scripts/copyEthersTypes.js && node scripts/copyWasm.js",
"format": "prettier --write \"src/**/*.ts\"",

View File

@ -7,3 +7,12 @@ fs.readdirSync("src/ethers-contracts").forEach((file) => {
);
}
});
fs.readdirSync("src/ethers-contracts/abi").forEach((file) => {
if (file.endsWith(".d.ts")) {
fs.copyFileSync(
`src/ethers-contracts/abi/${file}`,
`lib/ethers-contracts/abi/${file}`
);
}
});

View File

@ -0,0 +1,388 @@
[
{
"inputs": [
{
"components": [
{
"internalType": "address[]",
"name": "keys",
"type": "address[]"
},
{
"internalType": "uint32",
"name": "expiration_time",
"type": "uint32"
}
],
"internalType": "struct Wormhole.GuardianSet",
"name": "initial_guardian_set",
"type": "tuple"
},
{
"internalType": "address",
"name": "wrapped_asset_master",
"type": "address"
},
{
"internalType": "uint32",
"name": "_guardian_set_expirity",
"type": "uint32"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint32",
"name": "oldGuardianIndex",
"type": "uint32"
},
{
"indexed": false,
"internalType": "uint32",
"name": "newGuardianIndex",
"type": "uint32"
}
],
"name": "LogGuardianSetChanged",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint8",
"name": "target_chain",
"type": "uint8"
},
{
"indexed": false,
"internalType": "uint8",
"name": "token_chain",
"type": "uint8"
},
{
"indexed": false,
"internalType": "uint8",
"name": "token_decimals",
"type": "uint8"
},
{
"indexed": true,
"internalType": "bytes32",
"name": "token",
"type": "bytes32"
},
{
"indexed": true,
"internalType": "bytes32",
"name": "sender",
"type": "bytes32"
},
{
"indexed": false,
"internalType": "bytes32",
"name": "recipient",
"type": "bytes32"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint32",
"name": "nonce",
"type": "uint32"
}
],
"name": "LogTokensLocked",
"type": "event"
},
{
"stateMutability": "payable",
"type": "fallback"
},
{
"inputs": [
{
"internalType": "bytes32",
"name": "",
"type": "bytes32"
}
],
"name": "consumedVAAs",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint32",
"name": "idx",
"type": "uint32"
}
],
"name": "getGuardianSet",
"outputs": [
{
"components": [
{
"internalType": "address[]",
"name": "keys",
"type": "address[]"
},
{
"internalType": "uint32",
"name": "expiration_time",
"type": "uint32"
}
],
"internalType": "struct Wormhole.GuardianSet",
"name": "gs",
"type": "tuple"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "guardian_set_expirity",
"outputs": [
{
"internalType": "uint32",
"name": "",
"type": "uint32"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "guardian_set_index",
"outputs": [
{
"internalType": "uint32",
"name": "",
"type": "uint32"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint32",
"name": "",
"type": "uint32"
}
],
"name": "guardian_sets",
"outputs": [
{
"internalType": "uint32",
"name": "expiration_time",
"type": "uint32"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "isWrappedAsset",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "asset",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
},
{
"internalType": "bytes32",
"name": "recipient",
"type": "bytes32"
},
{
"internalType": "uint8",
"name": "target_chain",
"type": "uint8"
},
{
"internalType": "uint32",
"name": "nonce",
"type": "uint32"
},
{
"internalType": "bool",
"name": "refund_dust",
"type": "bool"
}
],
"name": "lockAssets",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "bytes32",
"name": "recipient",
"type": "bytes32"
},
{
"internalType": "uint8",
"name": "target_chain",
"type": "uint8"
},
{
"internalType": "uint32",
"name": "nonce",
"type": "uint32"
}
],
"name": "lockETH",
"outputs": [],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [
{
"internalType": "bytes",
"name": "vaa",
"type": "bytes"
}
],
"name": "parseAndVerifyVAA",
"outputs": [
{
"components": [
{
"internalType": "uint8",
"name": "version",
"type": "uint8"
},
{
"internalType": "bytes32",
"name": "hash",
"type": "bytes32"
},
{
"internalType": "uint32",
"name": "guardian_set_index",
"type": "uint32"
},
{
"internalType": "uint32",
"name": "timestamp",
"type": "uint32"
},
{
"internalType": "uint8",
"name": "action",
"type": "uint8"
},
{
"internalType": "bytes",
"name": "payload",
"type": "bytes"
}
],
"internalType": "struct Wormhole.ParsedVAA",
"name": "parsed_vaa",
"type": "tuple"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "bytes",
"name": "vaa",
"type": "bytes"
}
],
"name": "submitVAA",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "wrappedAssetMaster",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "bytes32",
"name": "",
"type": "bytes32"
}
],
"name": "wrappedAssets",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"stateMutability": "payable",
"type": "receive"
}
]