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"; } from "../../utils/ethereum";
import { shortenAddress } from "../../utils/solana"; import { shortenAddress } from "../../utils/solana";
import OffsetButton from "./OffsetButton"; 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(() => const useStyles = makeStyles(() =>
createStyles({ 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 = { type EthereumSourceTokenSelectorProps = {
value: ParsedTokenAccount | null; value: ParsedTokenAccount | null;
onChange: (newValue: ParsedTokenAccount | null) => void; onChange: (newValue: ParsedTokenAccount | null) => void;
@ -79,18 +89,64 @@ export default function EthereumSourceTokenSelector(
const [advancedModeSymbol, setAdvancedModeSymbol] = useState(""); const [advancedModeSymbol, setAdvancedModeSymbol] = useState("");
const [advancedModeHolderString, setAdvancedModeHolderString] = useState(""); const [advancedModeHolderString, setAdvancedModeHolderString] = useState("");
const [advancedModeError, setAdvancedModeError] = useState(""); const [advancedModeError, setAdvancedModeError] = useState("");
const [autocompleteHolder, setAutocompleteHolder] =
useState<ParsedTokenAccount | null>(null);
const [autocompleteError, setAutocompleteError] = useState("");
const { provider, signerAddress } = useEthereumProvider(); const { provider, signerAddress } = useEthereumProvider();
// const wrappedTestToken = "0x8bf3c393b588bb6ad021e154654493496139f06d";
// const notWrappedTestToken = "0xaaaebe6fe48e54f431b0c390cfaf0b017d09d42d";
useEffect(() => { 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. //This also kicks off the metadata load.
if (advancedMode && value && advancedModeHolderString !== value.mintKey) { if (advancedMode && value && advancedModeHolderString !== value.mintKey) {
setAdvancedModeHolderString(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 //This effect is watching the autocomplete selection.
//TODO move to util or hook //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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
if (!advancedMode || !isValidEthereumAddress(advancedModeHolderString)) { if (!advancedMode || !isValidEthereumAddress(advancedModeHolderString)) {
@ -106,7 +162,36 @@ export default function EthereumSourceTokenSelector(
!cancelled && setAdvancedModeError(""); !cancelled && setAdvancedModeError("");
!cancelled && setAdvancedModeSymbol(""); !cancelled && setAdvancedModeSymbol("");
try { try {
getEthereumToken(advancedModeHolderString, provider).then((token) => { //Validate that the token is not a wormhole v1 asset
const isWormholePromise = isWormholev1(
provider,
advancedModeHolderString
).then(
(result) => {
if (result && !cancelled) {
setAdvancedModeError(
"Wormhole v1 assets are not eligible for transfer."
);
setAdvancedModeLoading(false);
return Promise.reject();
} else {
return Promise.resolve();
}
},
(error) => {
!cancelled &&
setAdvancedModeError(
"Warning: please verify if this is a Wormhole v1 token address. V1 tokens should not be transferred with this bridge"
);
!cancelled && setAdvancedModeLoading(false);
return Promise.resolve(); //Don't allow an error here to tank the workflow
}
);
//Then fetch the asset's information & transform to a parsed token account
isWormholePromise.then(() =>
getEthereumToken(advancedModeHolderString, provider).then(
(token) => {
ethTokenToParsedTokenAccount(token, signerAddress).then( ethTokenToParsedTokenAccount(token, signerAddress).then(
(parsedTokenAccount) => { (parsedTokenAccount) => {
!cancelled && onChange(parsedTokenAccount); !cancelled && onChange(parsedTokenAccount);
@ -115,23 +200,31 @@ export default function EthereumSourceTokenSelector(
(error) => { (error) => {
//These errors can maybe be consolidated //These errors can maybe be consolidated
!cancelled && !cancelled &&
setAdvancedModeError("Failed to find the specified address"); setAdvancedModeError(
"Failed to find the specified address"
);
!cancelled && setAdvancedModeLoading(false); !cancelled && setAdvancedModeLoading(false);
} }
); );
//Also attempt to store off the symbol
token.symbol().then( token.symbol().then(
(result) => { (result) => {
!cancelled && setAdvancedModeSymbol(result); !cancelled && setAdvancedModeSymbol(result);
}, },
(error) => { (error) => {
!cancelled && !cancelled &&
setAdvancedModeError("Failed to find the specified address"); setAdvancedModeError(
"Failed to find the specified address"
);
!cancelled && setAdvancedModeLoading(false); !cancelled && setAdvancedModeLoading(false);
} }
); );
}); },
} catch (error) { (error) => {}
)
);
} catch (e) {
!cancelled && !cancelled &&
setAdvancedModeError("Failed to find the specified address"); setAdvancedModeError("Failed to find the specified address");
!cancelled && setAdvancedModeLoading(false); !cancelled && setAdvancedModeLoading(false);
@ -162,7 +255,10 @@ export default function EthereumSourceTokenSelector(
if (!account) { if (!account) {
return undefined; 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({ const filterConfig = createFilterOptions({
@ -176,19 +272,21 @@ export default function EthereumSourceTokenSelector(
}); });
const toggleAdvancedMode = () => { const toggleAdvancedMode = () => {
setAdvancedModeHolderString("");
setAdvancedModeError("");
setAdvancedModeSymbol("");
setAdvancedMode(!advancedMode); setAdvancedMode(!advancedMode);
}; };
const handleAutocompleteChange = (newValue: ParsedTokenAccount | null) => {
setAutocompleteHolder(newValue);
};
const isLoading = const isLoading =
props.covalent?.isFetching || props.tokenAccounts?.isFetching; props.covalent?.isFetching || props.tokenAccounts?.isFetching;
const symbolString = advancedModeSymbol
? advancedModeSymbol + " "
: getSymbol(value)
? getSymbol(value)?.contract_ticker_symbol + " "
: "";
const autoComplete = ( const autoComplete = (
<>
<Autocomplete <Autocomplete
autoComplete autoComplete
autoHighlight autoHighlight
@ -197,9 +295,9 @@ export default function EthereumSourceTokenSelector(
clearOnBlur clearOnBlur
fullWidth={false} fullWidth={false}
filterOptions={filterConfig} filterOptions={filterConfig}
value={value} value={autocompleteHolder}
onChange={(event, newValue) => { onChange={(event, newValue) => {
onChange(newValue); handleAutocompleteChange(newValue);
}} }}
disabled={disabled} disabled={disabled}
noOptionsText={"No ERC20 tokens found at the moment."} noOptionsText={"No ERC20 tokens found at the moment."}
@ -216,11 +314,15 @@ export default function EthereumSourceTokenSelector(
}} }}
getOptionLabel={(option) => { getOptionLabel={(option) => {
const symbol = getSymbol(option); const symbol = getSymbol(option);
return `${symbol ? symbol : "Unknown"} (Account: ${shortenAddress( return `${symbol ? symbol : "Unknown"} (Address: ${shortenAddress(
option.publicKey option.mintKey
)}, Address: ${shortenAddress(option.mintKey)})`; )})`;
}} }}
/> />
{autocompleteError && (
<Typography color="error">{autocompleteError}</Typography>
)}
</>
); );
const advancedModeToggleButton = ( const advancedModeToggleButton = (
@ -229,12 +331,19 @@ export default function EthereumSourceTokenSelector(
</OffsetButton> </OffsetButton>
); );
const symbol = getSymbol(value) || advancedModeSymbol;
const content = value ? ( const content = value ? (
<> <>
<Typography>{symbolString + value.mintKey}</Typography> <Typography>{(symbol ? symbol + " " : "") + value.mintKey}</Typography>
<OffsetButton onClick={handleClick} disabled={disabled}> <OffsetButton onClick={handleClick} disabled={disabled}>
Clear Clear
</OffsetButton> </OffsetButton>
{!advancedMode && autocompleteError ? (
<Typography color="error">{autocompleteError}</Typography>
) : advancedMode && advancedModeError ? (
<Typography color="error">{advancedModeError}</Typography>
) : null}
</> </>
) : isLoading ? ( ) : isLoading ? (
<CircularProgress /> <CircularProgress />

View File

@ -3,9 +3,10 @@ import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import { Autocomplete } from "@material-ui/lab"; import { Autocomplete } from "@material-ui/lab";
import { createFilterOptions } from "@material-ui/lab/Autocomplete"; import { createFilterOptions } from "@material-ui/lab/Autocomplete";
import { TokenInfo } from "@solana/spl-token-registry"; import { TokenInfo } from "@solana/spl-token-registry";
import React, { useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { DataWrapper } from "../../store/helpers"; import { DataWrapper } from "../../store/helpers";
import { ParsedTokenAccount } from "../../store/transferSlice"; import { ParsedTokenAccount } from "../../store/transferSlice";
import { WORMHOLE_V1_MINT_AUTHORITY } from "../../utils/consts";
import { Metadata } from "../../utils/metaplex"; import { Metadata } from "../../utils/metaplex";
import { shortenAddress } from "../../utils/solana"; import { shortenAddress } from "../../utils/solana";
@ -31,44 +32,7 @@ type SolanaSourceTokenSelectorProps = {
solanaTokenMap: DataWrapper<TokenInfo[]> | undefined; solanaTokenMap: DataWrapper<TokenInfo[]> | undefined;
metaplexData: any; //DataWrapper<(Metadata | undefined)[]> | undefined | null; metaplexData: any; //DataWrapper<(Metadata | undefined)[]> | undefined | null;
disabled: boolean; disabled: boolean;
}; mintAccounts: DataWrapper<Map<String, string | null>> | undefined;
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>
);
}; };
export default function SolanaSourceTokenSelector( 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. //The autocomplete doesn't rerender the option label unless the value changes.
//Thus we should wait for the metadata to arrive before rendering it. //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. //TODO This can flicker dependent on how fast the useEffects in the getSourceAccounts hook complete.
const isLoading = 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 //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. //difficult to do before this point.
@ -152,6 +200,10 @@ export default function SolanaSourceTokenSelector(
}); });
}, [memoizedMetaplex, props.accounts]); }, [memoizedMetaplex, props.accounts]);
const isOptionDisabled = useMemo(() => {
return (value: ParsedTokenAccount) => isWormholev1(value.mintKey);
}, [isWormholev1]);
const autoComplete = ( const autoComplete = (
<Autocomplete <Autocomplete
autoComplete autoComplete
@ -178,6 +230,7 @@ export default function SolanaSourceTokenSelector(
classes classes
); );
}} }}
getOptionDisabled={isOptionDisabled}
getOptionLabel={(option) => { getOptionLabel={(option) => {
const symbol = getSymbol(option); const symbol = getSymbol(option);
return `${symbol ? symbol : "Unknown"} (Account: ${shortenAddress( return `${symbol ? symbol : "Unknown"} (Account: ${shortenAddress(
@ -190,6 +243,7 @@ export default function SolanaSourceTokenSelector(
return ( return (
<React.Fragment> <React.Fragment>
{isLoading ? <CircularProgress /> : autoComplete} {isLoading ? <CircularProgress /> : autoComplete}
{error && <Typography color="error">{error}</Typography>}
</React.Fragment> </React.Fragment>
); );
} }

View File

@ -8,6 +8,7 @@ import { TextField, Typography } from "@material-ui/core";
import { useCallback } from "react"; import { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import useGetSourceParsedTokens from "../../hooks/useGetSourceParsedTokenAccounts"; import useGetSourceParsedTokens from "../../hooks/useGetSourceParsedTokenAccounts";
import useIsWalletReady from "../../hooks/useIsWalletReady";
import { import {
selectTransferSourceChain, selectTransferSourceChain,
selectTransferSourceParsedTokenAccount, selectTransferSourceParsedTokenAccount,
@ -15,6 +16,7 @@ import {
import { import {
ParsedTokenAccount, ParsedTokenAccount,
setSourceParsedTokenAccount, setSourceParsedTokenAccount,
setSourceWalletAddress,
} from "../../store/transferSlice"; } from "../../store/transferSlice";
import EthereumSourceTokenSelector from "./EthereumSourceTokenSelector"; import EthereumSourceTokenSelector from "./EthereumSourceTokenSelector";
import SolanaSourceTokenSelector from "./SolanaSourceTokenSelector"; import SolanaSourceTokenSelector from "./SolanaSourceTokenSelector";
@ -32,13 +34,19 @@ export const TokenSelector = (props: TokenSelectorProps) => {
const sourceParsedTokenAccount = useSelector( const sourceParsedTokenAccount = useSelector(
selectTransferSourceParsedTokenAccount selectTransferSourceParsedTokenAccount
); );
const handleSolanaOnChange = useCallback( const walletIsReady = useIsWalletReady(lookupChain);
const handleOnChange = useCallback(
(newTokenAccount: ParsedTokenAccount | null) => { (newTokenAccount: ParsedTokenAccount | null) => {
if (newTokenAccount !== undefined) { if (!newTokenAccount) {
dispatch(setSourceParsedTokenAccount(newTokenAccount || undefined)); 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(); const maps = useGetSourceParsedTokens();
@ -54,17 +62,18 @@ export const TokenSelector = (props: TokenSelectorProps) => {
) : lookupChain === CHAIN_ID_SOLANA ? ( ) : lookupChain === CHAIN_ID_SOLANA ? (
<SolanaSourceTokenSelector <SolanaSourceTokenSelector
value={sourceParsedTokenAccount || null} value={sourceParsedTokenAccount || null}
onChange={handleSolanaOnChange} onChange={handleOnChange}
disabled={disabled} disabled={disabled}
accounts={maps?.tokenAccounts?.data || []} accounts={maps?.tokenAccounts?.data || []}
solanaTokenMap={maps?.tokenMap} solanaTokenMap={maps?.tokenMap}
metaplexData={maps?.metaplex} metaplexData={maps?.metaplex}
mintAccounts={maps?.mintAccounts}
/> />
) : lookupChain === CHAIN_ID_ETH ? ( ) : lookupChain === CHAIN_ID_ETH ? (
<EthereumSourceTokenSelector <EthereumSourceTokenSelector
value={sourceParsedTokenAccount || null} value={sourceParsedTokenAccount || null}
disabled={disabled} disabled={disabled}
onChange={handleSolanaOnChange} onChange={handleOnChange}
covalent={maps?.covalent || undefined} covalent={maps?.covalent || undefined}
tokenAccounts={maps?.tokenAccounts} //TODO standardize tokenAccounts={maps?.tokenAccounts} //TODO standardize
/> />
@ -72,7 +81,8 @@ export const TokenSelector = (props: TokenSelectorProps) => {
<TerraSourceTokenSelector <TerraSourceTokenSelector
value={sourceParsedTokenAccount || null} value={sourceParsedTokenAccount || null}
disabled={disabled} disabled={disabled}
onChange={handleSolanaOnChange} onChange={handleOnChange}
tokenMap={maps?.terraTokenMap}
/> />
) : ( ) : (
<TextField <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 { LCDClient } from "@terra-money/terra.js";
import { import {
ConnectedWallet, ConnectedWallet,
useConnectedWallet, useConnectedWallet,
} from "@terra-money/wallet-provider"; } from "@terra-money/wallet-provider";
import { formatUnits } from "ethers/lib/utils"; import { formatUnits } from "ethers/lib/utils";
import React, { useCallback, useState } from "react"; import React, { useCallback, useMemo, useState } from "react";
import { createParsedTokenAccount } from "../../hooks/useGetSourceParsedTokenAccounts"; import {
createParsedTokenAccount,
TerraTokenMap,
TerraTokenMetadata,
} from "../../hooks/useGetSourceParsedTokenAccounts";
import { DataWrapper } from "../../store/helpers";
import { ParsedTokenAccount } from "../../store/transferSlice"; import { ParsedTokenAccount } from "../../store/transferSlice";
import { TERRA_HOST } from "../../utils/consts"; import { TERRA_HOST } from "../../utils/consts";
import { shortenAddress } from "../../utils/solana";
import OffsetButton from "./OffsetButton"; import OffsetButton from "./OffsetButton";
const useStyles = makeStyles(() =>
createStyles({
selectInput: { minWidth: "10rem" },
tokenOverviewContainer: {
display: "flex",
"& div": {
margin: ".5rem",
},
},
tokenImage: {
maxHeight: "2.5rem",
},
})
);
type TerraSourceTokenSelectorProps = { type TerraSourceTokenSelectorProps = {
value: ParsedTokenAccount | null; value: ParsedTokenAccount | null;
onChange: (newValue: ParsedTokenAccount | null) => void; onChange: (newValue: ParsedTokenAccount | null) => void;
disabled: boolean; disabled: boolean;
tokenMap: DataWrapper<TerraTokenMap> | undefined; //TODO better type
}; };
//TODO move elsewhere //TODO move elsewhere
@ -58,12 +86,27 @@ const lookupTerraAddress = (
export default function TerraSourceTokenSelector( export default function TerraSourceTokenSelector(
props: TerraSourceTokenSelectorProps props: TerraSourceTokenSelectorProps
) { ) {
const { onChange, value, disabled } = props; const classes = useStyles();
//const advancedMode = true; //const [advancedMode, setAdvancedMode] = useState(true); const { onChange, value, disabled, tokenMap } = props;
const [advancedMode, setAdvancedMode] = useState(false);
const [advancedModeHolderString, setAdvancedModeHolderString] = useState(""); const [advancedModeHolderString, setAdvancedModeHolderString] = useState("");
const [advancedModeError, setAdvancedModeError] = useState(""); const [advancedModeError, setAdvancedModeError] = useState("");
const terraWallet = useConnectedWallet(); 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(() => { const handleClick = useCallback(() => {
onChange(null); onChange(null);
setAdvancedModeHolderString(""); setAdvancedModeHolderString("");
@ -74,22 +117,93 @@ export default function TerraSourceTokenSelector(
[] []
); );
const handleConfirm = () => { const handleConfirm = (address: string | undefined) => {
if (terraWallet === undefined) { if (terraWallet === undefined || address === undefined) {
setAdvancedModeError("Terra wallet not connected."); setAdvancedModeError("Terra wallet not connected.");
return; return;
} }
lookupTerraAddress(advancedModeHolderString, terraWallet).then( setAdvancedModeError("");
lookupTerraAddress(address, terraWallet).then(
(result) => { (result) => {
onChange(result); onChange(result);
}, },
(error) => { (error) => {
setAdvancedModeError("Unable to retrieve address."); setAdvancedModeError("Unable to retrieve this address.");
} }
); );
setAdvancedModeError(""); 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 ? ( const content = value ? (
<> <>
<Typography>{value.mintKey}</Typography> <Typography>{value.mintKey}</Typography>
@ -97,22 +211,32 @@ export default function TerraSourceTokenSelector(
Clear Clear
</OffsetButton> </OffsetButton>
</> </>
) : !advancedMode ? (
autoComplete
) : ( ) : (
<> <>
<TextField <TextField
fullWidth fullWidth
label="Asset Address" label="Enter an asset address"
value={advancedModeHolderString} value={advancedModeHolderString}
onChange={handleOnChange} onChange={handleOnChange}
disabled={disabled} disabled={disabled}
error={advancedModeHolderString !== "" && !!advancedModeError} error={advancedModeHolderString !== "" && !!advancedModeError}
helperText={advancedModeError === "" ? undefined : advancedModeError} helperText={advancedModeError === "" ? undefined : advancedModeError}
/> />
<OffsetButton onClick={handleConfirm} disabled={disabled}> <OffsetButton
onClick={() => handleConfirm(advancedModeHolderString)}
disabled={disabled}
>
Confirm Confirm
</OffsetButton> </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 { useHandleTransfer } from "../../hooks/useHandleTransfer";
import useIsWalletReady from "../../hooks/useIsWalletReady"; import useIsWalletReady from "../../hooks/useIsWalletReady";
import { import {
selectSourceWalletAddress,
selectTransferSourceChain, selectTransferSourceChain,
selectTransferTargetError, selectTransferTargetError,
} from "../../store/selectors"; } from "../../store/selectors";
@ -16,7 +17,18 @@ function Send() {
const { handleClick, disabled, showLoader } = useHandleTransfer(); const { handleClick, disabled, showLoader } = useHandleTransfer();
const sourceChain = useSelector(selectTransferSourceChain); const sourceChain = useSelector(selectTransferSourceChain);
const error = useSelector(selectTransferTargetError); 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 ( return (
<> <>
<StepDescription> <StepDescription>
@ -30,10 +42,10 @@ function Send() {
complete the transfer. complete the transfer.
</Alert> </Alert>
<ButtonWithLoader <ButtonWithLoader
disabled={!isReady || disabled} disabled={isDisabled}
onClick={handleClick} onClick={handleClick}
showLoader={showLoader} showLoader={showLoader}
error={statusMessage || error} error={errorMessage}
> >
Transfer Transfer
</ButtonWithLoader> </ButtonWithLoader>

View File

@ -8,8 +8,8 @@ import React, {
useState, useState,
} from "react"; } from "react";
type Provider = ethers.providers.Web3Provider | undefined; export type Provider = ethers.providers.Web3Provider | undefined;
type Signer = ethers.Signer | undefined; export type Signer = ethers.Signer | undefined;
interface IEthereumProviderContext { interface IEthereumProviderContext {
connect(): void; 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 { Dispatch } from "@reduxjs/toolkit";
import { TOKEN_PROGRAM_ID } from "@solana/spl-token"; import { TOKEN_PROGRAM_ID } from "@solana/spl-token";
import { ENV, TokenListProvider } from "@solana/spl-token-registry"; import { ENV, TokenListProvider } from "@solana/spl-token-registry";
@ -17,27 +21,44 @@ import { useSolanaWallet } from "../contexts/SolanaWalletContext";
import { DataWrapper } from "../store/helpers"; import { DataWrapper } from "../store/helpers";
import { import {
selectSolanaTokenMap, selectSolanaTokenMap,
selectSourceWalletAddress,
selectTerraTokenMap,
selectTransferSourceChain, selectTransferSourceChain,
selectTransferSourceParsedTokenAccounts, selectTransferSourceParsedTokenAccounts,
} from "../store/selectors"; } from "../store/selectors";
import { import {
errorSolanaTokenMap, errorSolanaTokenMap,
errorTerraTokenMap,
fetchSolanaTokenMap, fetchSolanaTokenMap,
fetchTerraTokenMap,
receiveSolanaTokenMap, receiveSolanaTokenMap,
receiveTerraTokenMap,
} from "../store/tokenSlice"; } from "../store/tokenSlice";
import { import {
errorSourceParsedTokenAccounts, errorSourceParsedTokenAccounts,
fetchSourceParsedTokenAccounts, fetchSourceParsedTokenAccounts,
ParsedTokenAccount, ParsedTokenAccount,
receiveSourceParsedTokenAccounts, receiveSourceParsedTokenAccounts,
setAmount,
setSourceParsedTokenAccount,
setSourceParsedTokenAccounts,
setSourceWalletAddress,
} from "../store/transferSlice"; } 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 { import {
decodeMetadata, decodeMetadata,
getMetadataAddress, getMetadataAddress,
Metadata, Metadata,
} from "../utils/metaplex"; } from "../utils/metaplex";
import { getMultipleAccountsRPC } from "../utils/solana"; import {
extractMintAuthorityInfo,
getMultipleAccountsRPC,
} from "../utils/solana";
export function createParsedTokenAccount( export function createParsedTokenAccount(
publicKey: string, 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 = ( const createParsedTokenAccountFromInfo = (
pubkey: PublicKey, pubkey: PublicKey,
item: AccountInfo<ParsedAccountData> item: AccountInfo<ParsedAccountData>
@ -211,6 +245,7 @@ function useGetAvailableTokens() {
const tokenAccounts = useSelector(selectTransferSourceParsedTokenAccounts); const tokenAccounts = useSelector(selectTransferSourceParsedTokenAccounts);
const solanaTokenMap = useSelector(selectSolanaTokenMap); const solanaTokenMap = useSelector(selectSolanaTokenMap);
const terraTokenMap = useSelector(selectTerraTokenMap);
const lookupChain = useSelector(selectTransferSourceChain); const lookupChain = useSelector(selectTransferSourceChain);
const solanaWallet = useSolanaWallet(); const solanaWallet = useSolanaWallet();
@ -228,6 +263,38 @@ function useGetAvailableTokens() {
undefined 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 // Solana metaplex load
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -289,6 +356,53 @@ function useGetAvailableTokens() {
solanaTokenMap, 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 //Ethereum accounts load
useEffect(() => { useEffect(() => {
//const testWallet = "0xf60c2ea62edbfe808163751dd0d8693dcb30019c"; //const testWallet = "0xf60c2ea62edbfe808163751dd0d8693dcb30019c";
@ -333,10 +447,38 @@ function useGetAvailableTokens() {
}, [lookupChain, provider, signerAddress, dispatch]); }, [lookupChain, provider, signerAddress, dispatch]);
//Terra accounts load //Terra accounts load
//At present, we don't have any mechanism for doing this.
useEffect(() => {}, []); useEffect(() => {}, []);
//Terra metadata load //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 return lookupChain === CHAIN_ID_SOLANA
? { ? {
@ -348,6 +490,12 @@ function useGetAvailableTokens() {
error: metaplexError, error: metaplexError,
receivedAt: null, //TODO receivedAt: null, //TODO
} as DataWrapper<Metadata[]>, } as DataWrapper<Metadata[]>,
mintAccounts: {
data: solanaMintAccounts,
isFetching: solanaMintAccountsLoading,
error: solanaMintAccountsError,
receivedAt: null, //TODO
},
} }
: lookupChain === CHAIN_ID_ETH : lookupChain === CHAIN_ID_ETH
? { ? {
@ -359,6 +507,10 @@ function useGetAvailableTokens() {
receivedAt: null, //TODO receivedAt: null, //TODO
}, },
} }
: lookupChain === CHAIN_ID_TERRA
? {
terraTokenMap: terraTokenMap,
}
: undefined; : undefined;
} }

View File

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

View File

@ -48,6 +48,8 @@ export const selectTransferOriginChain = (state: RootState) =>
state.transfer.originChain; state.transfer.originChain;
export const selectTransferOriginAsset = (state: RootState) => export const selectTransferOriginAsset = (state: RootState) =>
state.transfer.originAsset; state.transfer.originAsset;
export const selectSourceWalletAddress = (state: RootState) =>
state.transfer.sourceWalletAddress;
export const selectTransferSourceParsedTokenAccount = (state: RootState) => export const selectTransferSourceParsedTokenAccount = (state: RootState) =>
state.transfer.sourceParsedTokenAccount; state.transfer.sourceParsedTokenAccount;
export const selectTransferSourceParsedTokenAccounts = (state: RootState) => export const selectTransferSourceParsedTokenAccounts = (state: RootState) =>
@ -168,3 +170,7 @@ export const selectTransferShouldLockFields = (state: RootState) =>
export const selectSolanaTokenMap = (state: RootState) => { export const selectSolanaTokenMap = (state: RootState) => {
return state.tokens.solanaTokenMap; 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 { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { TokenInfo } from "@solana/spl-token-registry"; import { TokenInfo } from "@solana/spl-token-registry";
import { TerraTokenMap } from "../hooks/useGetSourceParsedTokenAccounts";
import { import {
DataWrapper, DataWrapper,
errorDataWrapper, errorDataWrapper,
@ -10,10 +11,12 @@ import {
export interface TokenMetadataState { export interface TokenMetadataState {
solanaTokenMap: DataWrapper<TokenInfo[]>; solanaTokenMap: DataWrapper<TokenInfo[]>;
terraTokenMap: DataWrapper<TerraTokenMap>; //TODO make a decent type for this.
} }
const initialState: TokenMetadataState = { const initialState: TokenMetadataState = {
solanaTokenMap: getEmptyDataWrapper(), solanaTokenMap: getEmptyDataWrapper(),
terraTokenMap: getEmptyDataWrapper(),
}; };
export const tokenSlice = createSlice({ export const tokenSlice = createSlice({
@ -29,6 +32,17 @@ export const tokenSlice = createSlice({
errorSolanaTokenMap: (state, action: PayloadAction<string>) => { errorSolanaTokenMap: (state, action: PayloadAction<string>) => {
state.solanaTokenMap = errorDataWrapper(action.payload); 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, reset: () => initialState,
}, },
}); });
@ -37,6 +51,9 @@ export const {
receiveSolanaTokenMap, receiveSolanaTokenMap,
fetchSolanaTokenMap, fetchSolanaTokenMap,
errorSolanaTokenMap, errorSolanaTokenMap,
receiveTerraTokenMap,
fetchTerraTokenMap,
errorTerraTokenMap,
reset, reset,
} = tokenSlice.actions; } = tokenSlice.actions;

View File

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

View File

@ -30,6 +30,10 @@ export const CHAINS =
id: CHAIN_ID_SOLANA, id: CHAIN_ID_SOLANA,
name: "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; CLUSTER === "mainnet" ? 1 : CLUSTER === "testnet" ? 5 : 1337;
export const SOLANA_HOST = export const SOLANA_HOST =
CLUSTER === "testnet" ? clusterApiUrl("testnet") : "http://localhost:8899"; CLUSTER === "testnet" ? clusterApiUrl("testnet") : "http://localhost:8899";
export const TERRA_HOST = {
export const TERRA_HOST =
CLUSTER === "testnet"
? {
URL: "https://tequila-lcd.terra.dev",
chainID: "tequila-0004",
name: "testnet",
}
: {
URL: "http://localhost:1317", URL: "http://localhost:1317",
chainID: "columbus-4", chainID: "columbus-4",
name: "localterra", name: "localterra",
}; };
export const ETH_TEST_TOKEN_ADDRESS = getAddress( export const ETH_TEST_TOKEN_ADDRESS = getAddress(
CLUSTER === "testnet" CLUSTER === "testnet"
? "0xcEE940033DA197F551BBEdED7F4aA55Ee55C582B" ? "0xcEE940033DA197F551BBEdED7F4aA55Ee55C582B"
@ -119,3 +131,24 @@ export const COVALENT_GET_TOKENS_URL = (
}; };
export const COVALENT_ETHEREUM_MAINNET = "1"; 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 { WalletContextState } from "@solana/wallet-adapter-react";
import { import {
AccountInfo, AccountInfo,
@ -17,6 +18,19 @@ export async function signSendAndConfirm(
return txid; 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( export async function getMultipleAccountsRPC(
connection: Connection, connection: Connection,
pubkeys: PublicKey[] pubkeys: PublicKey[]

View File

@ -9,8 +9,9 @@
"lib/**/*" "lib/**/*"
], ],
"scripts": { "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-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", "test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc && node scripts/copyEthersTypes.js && node scripts/copyWasm.js", "build": "tsc && node scripts/copyEthersTypes.js && node scripts/copyWasm.js",
"format": "prettier --write \"src/**/*.ts\"", "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"
}
]