diff --git a/bridge_ui/src/components/TokenSelectors/EthereumSourceTokenSelector.tsx b/bridge_ui/src/components/TokenSelectors/EthereumSourceTokenSelector.tsx index 09614858..9aa0c75c 100644 --- a/bridge_ui/src/components/TokenSelectors/EthereumSourceTokenSelector.tsx +++ b/bridge_ui/src/components/TokenSelectors/EthereumSourceTokenSelector.tsx @@ -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(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 = ( - { - onChange(newValue); - }} - disabled={disabled} - noOptionsText={"No ERC20 tokens found at the moment."} - options={tokenAccounts?.data || []} - renderInput={(params) => ( - + <> + { + handleAutocompleteChange(newValue); + }} + disabled={disabled} + noOptionsText={"No ERC20 tokens found at the moment."} + options={tokenAccounts?.data || []} + renderInput={(params) => ( + + )} + 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 && ( + {autocompleteError} )} - 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( ); + const symbol = getSymbol(value) || advancedModeSymbol; + const content = value ? ( <> - {symbolString + value.mintKey} + {(symbol ? symbol + " " : "") + value.mintKey} Clear + {!advancedMode && autocompleteError ? ( + {autocompleteError} + ) : advancedMode && advancedModeError ? ( + {advancedModeError} + ) : null} ) : isLoading ? ( diff --git a/bridge_ui/src/components/TokenSelectors/SolanaSourceTokenSelector.tsx b/bridge_ui/src/components/TokenSelectors/SolanaSourceTokenSelector.tsx index 97f8bd70..4e9fed6e 100644 --- a/bridge_ui/src/components/TokenSelectors/SolanaSourceTokenSelector.tsx +++ b/bridge_ui/src/components/TokenSelectors/SolanaSourceTokenSelector.tsx @@ -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 | undefined; metaplexData: any; //DataWrapper<(Metadata | undefined)[]> | undefined | null; disabled: boolean; -}; - -const renderAccount = ( - account: ParsedTokenAccount, - solanaTokenMap: Map, - metaplexData: Map, - 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 ( -
-
- {uri && } -
-
- {symbol} - {name} -
-
- {"Mint : " + mintPrettyString} - - {"Account :" + accountAddressPrettyString} - -
-
- {"Balance"} - {account.uiAmountString} -
-
- ); + mintAccounts: DataWrapper> | 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, + metaplexData: Map, + 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 = ( + <> +
+
+ {uri && } +
+
+ {symbol} + {name} +
+
+ + {"Mint : " + mintPrettyString} + + + {"Account :" + accountAddressPrettyString} + +
+
+ {"Balance"} + {account.uiAmountString} +
+
+ + ); + + const v1Warning = ( +
+ + Wormhole v1 tokens are not eligible for transfer. + +
{content}
+
+ ); + + 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 = ( { const symbol = getSymbol(option); return `${symbol ? symbol : "Unknown"} (Account: ${shortenAddress( @@ -190,6 +243,7 @@ export default function SolanaSourceTokenSelector( return ( {isLoading ? : autoComplete} + {error && {error}} ); } diff --git a/bridge_ui/src/components/TokenSelectors/SourceTokenSelector.tsx b/bridge_ui/src/components/TokenSelectors/SourceTokenSelector.tsx index 71e1311f..ecd019a6 100644 --- a/bridge_ui/src/components/TokenSelectors/SourceTokenSelector.tsx +++ b/bridge_ui/src/components/TokenSelectors/SourceTokenSelector.tsx @@ -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 ? ( ) : lookupChain === CHAIN_ID_ETH ? ( @@ -72,7 +81,8 @@ export const TokenSelector = (props: TokenSelectorProps) => { ) : ( + 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 | 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 ( +
+
+ +
+
+ {option.symbol} + {option.protocol} +
+
+ {option.token} +
+
+ ); + }; + + const toggleAdvancedMode = () => { + setAdvancedMode(!advancedMode); + }; + + const advancedModeToggleButton = ( + + {advancedMode ? "Toggle Token Picker" : "Toggle Override"} + + ); + + const autoComplete = ( + <> + { + handleConfirm(newValue?.token); + }} + disabled={disabled} + noOptionsText={"No CW20 tokens found at the moment."} + options={terraTokenArray} + renderInput={(params) => ( + + )} + renderOption={renderOption} + getOptionLabel={renderOptionLabel} + /> + {advancedModeError && ( + {advancedModeError} + )} + + ); + const content = value ? ( <> {value.mintKey} @@ -97,22 +211,32 @@ export default function TerraSourceTokenSelector( Clear + ) : !advancedMode ? ( + autoComplete ) : ( <> - + handleConfirm(advancedModeHolderString)} + disabled={disabled} + > Confirm ); - return {content}; + return ( + + {content} + {!value && !isLoading && advancedModeToggleButton} + + ); } diff --git a/bridge_ui/src/components/Transfer/Send.tsx b/bridge_ui/src/components/Transfer/Send.tsx index 9967bfd9..9f1e7015 100644 --- a/bridge_ui/src/components/Transfer/Send.tsx +++ b/bridge_ui/src/components/Transfer/Send.tsx @@ -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 ( <> @@ -30,10 +42,10 @@ function Send() { complete the transfer. Transfer diff --git a/bridge_ui/src/contexts/EthereumProviderContext.tsx b/bridge_ui/src/contexts/EthereumProviderContext.tsx index 0d8fff52..3796333a 100644 --- a/bridge_ui/src/contexts/EthereumProviderContext.tsx +++ b/bridge_ui/src/contexts/EthereumProviderContext.tsx @@ -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; diff --git a/bridge_ui/src/hooks/useGetSourceParsedTokenAccounts.ts b/bridge_ui/src/hooks/useGetSourceParsedTokenAccounts.ts index caf97e29..f982094b 100644 --- a/bridge_ui/src/hooks/useGetSourceParsedTokenAccounts.ts +++ b/bridge_ui/src/hooks/useGetSourceParsedTokenAccounts.ts @@ -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 @@ -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(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(); + + 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, + 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; } diff --git a/bridge_ui/src/hooks/useIsWalletReady.ts b/bridge_ui/src/hooks/useIsWalletReady.ts index e65bffc2..c5b01436 100644 --- a/bridge_ui/src/hooks/useIsWalletReady.ts +++ b/bridge_ui/src/hooks/useIsWalletReady.ts @@ -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, ]); } diff --git a/bridge_ui/src/store/selectors.ts b/bridge_ui/src/store/selectors.ts index 1633fc9b..c366d325 100644 --- a/bridge_ui/src/store/selectors.ts +++ b/bridge_ui/src/store/selectors.ts @@ -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; +}; diff --git a/bridge_ui/src/store/tokenSlice.ts b/bridge_ui/src/store/tokenSlice.ts index f4f552a8..feb72bb3 100644 --- a/bridge_ui/src/store/tokenSlice.ts +++ b/bridge_ui/src/store/tokenSlice.ts @@ -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; + terraTokenMap: DataWrapper; //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) => { state.solanaTokenMap = errorDataWrapper(action.payload); }, + + receiveTerraTokenMap: (state, action: PayloadAction) => { + state.terraTokenMap = receiveDataWrapper(action.payload); + }, + fetchTerraTokenMap: (state) => { + state.terraTokenMap = fetchDataWrapper(); + }, + errorTerraTokenMap: (state, action: PayloadAction) => { + state.terraTokenMap = errorDataWrapper(action.payload); + }, + reset: () => initialState, }, }); @@ -37,6 +51,9 @@ export const { receiveSolanaTokenMap, fetchSolanaTokenMap, errorSolanaTokenMap, + receiveTerraTokenMap, + fetchTerraTokenMap, + errorTerraTokenMap, reset, } = tokenSlice.actions; diff --git a/bridge_ui/src/store/transferSlice.ts b/bridge_ui/src/store/transferSlice.ts index 1c744933..49138105 100644 --- a/bridge_ui/src/store/transferSlice.ts +++ b/bridge_ui/src/store/transferSlice.ts @@ -37,6 +37,7 @@ export interface TransferState { isSourceAssetWormholeWrapped: boolean | undefined; originChain: ChainId | undefined; originAsset: string | undefined; + sourceWalletAddress: string | undefined; sourceParsedTokenAccount: ParsedTokenAccount | undefined; sourceParsedTokenAccounts: DataWrapper; 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 + ) => { + state.sourceWalletAddress = action.payload; + }, setSourceParsedTokenAccount: ( state, action: PayloadAction ) => { state.sourceParsedTokenAccount = action.payload; }, + setSourceParsedTokenAccounts: ( + state, + action: PayloadAction + ) => { + 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, diff --git a/bridge_ui/src/utils/consts.ts b/bridge_ui/src/utils/consts.ts index 0c9f7bc0..01ca2924 100644 --- a/bridge_ui/src/utils/consts.ts +++ b/bridge_ui/src/utils/consts.ts @@ -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" + : ""; diff --git a/bridge_ui/src/utils/solana.ts b/bridge_ui/src/utils/solana.ts index 2b97b007..575bfa9c 100644 --- a/bridge_ui/src/utils/solana.ts +++ b/bridge_ui/src/utils/solana.ts @@ -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 +): 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[] diff --git a/sdk/js/package.json b/sdk/js/package.json index fbc8673d..8b43a448 100644 --- a/sdk/js/package.json +++ b/sdk/js/package.json @@ -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\"", diff --git a/sdk/js/scripts/copyEthersTypes.js b/sdk/js/scripts/copyEthersTypes.js index e4f2b4fa..a641f9d9 100644 --- a/sdk/js/scripts/copyEthersTypes.js +++ b/sdk/js/scripts/copyEthersTypes.js @@ -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}` + ); + } +}); diff --git a/sdk/js/src/abi/Wormhole.abi.json b/sdk/js/src/abi/Wormhole.abi.json new file mode 100644 index 00000000..4363481c --- /dev/null +++ b/sdk/js/src/abi/Wormhole.abi.json @@ -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" + } +]