Browse Source

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

Change-Id: I9e45ce77c573e6940e6280b52ab2a319e6c4472f
chase-45 4 years ago
parent
commit
fc300f47e6

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

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

+ 17 - 7
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 ? (
     <SolanaSourceTokenSelector
       value={sourceParsedTokenAccount || null}
-      onChange={handleSolanaOnChange}
+      onChange={handleOnChange}
       disabled={disabled}
       accounts={maps?.tokenAccounts?.data || []}
       solanaTokenMap={maps?.tokenMap}
       metaplexData={maps?.metaplex}
+      mintAccounts={maps?.mintAccounts}
     />
   ) : lookupChain === CHAIN_ID_ETH ? (
     <EthereumSourceTokenSelector
       value={sourceParsedTokenAccount || null}
       disabled={disabled}
-      onChange={handleSolanaOnChange}
+      onChange={handleOnChange}
       covalent={maps?.covalent || undefined}
       tokenAccounts={maps?.tokenAccounts} //TODO standardize
     />
@@ -72,7 +81,8 @@ export const TokenSelector = (props: TokenSelectorProps) => {
     <TerraSourceTokenSelector
       value={sourceParsedTokenAccount || null}
       disabled={disabled}
-      onChange={handleSolanaOnChange}
+      onChange={handleOnChange}
+      tokenMap={maps?.terraTokenMap}
     />
   ) : (
     <TextField

+ 136 - 12
bridge_ui/src/components/TokenSelectors/TerraSourceTokenSelector.tsx

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

+ 15 - 3
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 (
     <>
       <StepDescription>
@@ -30,10 +42,10 @@ function Send() {
         complete the transfer.
       </Alert>
       <ButtonWithLoader
-        disabled={!isReady || disabled}
+        disabled={isDisabled}
         onClick={handleClick}
         showLoader={showLoader}
-        error={statusMessage || error}
+        error={errorMessage}
       >
         Transfer
       </ButtonWithLoader>

+ 2 - 2
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;

+ 156 - 4
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<ParsedAccountData>
@@ -211,6 +245,7 @@ function useGetAvailableTokens() {
 
   const tokenAccounts = useSelector(selectTransferSourceParsedTokenAccounts);
   const solanaTokenMap = useSelector(selectSolanaTokenMap);
+  const terraTokenMap = useSelector(selectTerraTokenMap);
 
   const lookupChain = useSelector(selectTransferSourceChain);
   const solanaWallet = useSolanaWallet();
@@ -228,6 +263,38 @@ function useGetAvailableTokens() {
     undefined
   );
 
+  const [solanaMintAccounts, setSolanaMintAccounts] = useState<any>(undefined);
+  const [solanaMintAccountsLoading, setSolanaMintAccountsLoading] =
+    useState(false);
+  const [solanaMintAccountsError, setSolanaMintAccountsError] = useState<
+    string | undefined
+  >(undefined);
+
+  const selectedSourceWalletAddress = useSelector(selectSourceWalletAddress);
+  const currentSourceWalletAddress: string | undefined =
+    lookupChain === CHAIN_ID_ETH
+      ? signerAddress
+      : lookupChain === CHAIN_ID_SOLANA
+      ? solPK?.toString()
+      : undefined;
+
+  //TODO this useEffect could be somewhere else in the codebase
+  //It resets the SourceParsedTokens accounts when the wallet changes
+  useEffect(() => {
+    if (
+      selectedSourceWalletAddress !== undefined &&
+      currentSourceWalletAddress !== undefined &&
+      currentSourceWalletAddress !== selectedSourceWalletAddress
+    ) {
+      dispatch(setSourceWalletAddress(undefined));
+      dispatch(setSourceParsedTokenAccount(undefined));
+      dispatch(setSourceParsedTokenAccounts(undefined));
+      dispatch(setAmount(""));
+      return;
+    } else {
+    }
+  }, [selectedSourceWalletAddress, currentSourceWalletAddress, dispatch]);
+
   // Solana metaplex load
   useEffect(() => {
     let cancelled = false;
@@ -289,6 +356,53 @@ function useGetAvailableTokens() {
     solanaTokenMap,
   ]);
 
+  //Solana Mint Accounts lookup
+  useEffect(() => {
+    if (lookupChain !== CHAIN_ID_SOLANA || !tokenAccounts.data?.length) {
+      return () => {};
+    }
+
+    let cancelled = false;
+    setSolanaMintAccountsLoading(true);
+    setSolanaMintAccountsError(undefined);
+    const mintAddresses = tokenAccounts.data.map((x) => x.mintKey);
+    //This is a known wormhole v1 token on testnet
+    //mintAddresses.push("4QixXecTZ4zdZGa39KH8gVND5NZ2xcaB12wiBhE4S7rn");
+
+    const connection = new Connection(SOLANA_HOST, "finalized");
+    getMultipleAccountsRPC(
+      connection,
+      mintAddresses.map((x) => new PublicKey(x))
+    ).then(
+      (results) => {
+        if (!cancelled) {
+          const output = new Map<String, string | null>();
+
+          results.forEach((result, index) =>
+            output.set(
+              mintAddresses[index],
+              (result && extractMintAuthorityInfo(result)) || null
+            )
+          );
+
+          setSolanaMintAccounts(output);
+          setSolanaMintAccountsLoading(false);
+        }
+      },
+      (error) => {
+        if (!cancelled) {
+          setSolanaMintAccounts(undefined);
+          setSolanaMintAccountsLoading(false);
+          setSolanaMintAccountsError(
+            "Could not retrieve Solana mint accounts."
+          );
+        }
+      }
+    );
+
+    return () => (cancelled = true);
+  }, [tokenAccounts.data, lookupChain]);
+
   //Ethereum accounts load
   useEffect(() => {
     //const testWallet = "0xf60c2ea62edbfe808163751dd0d8693dcb30019c";
@@ -333,10 +447,38 @@ function useGetAvailableTokens() {
   }, [lookupChain, provider, signerAddress, dispatch]);
 
   //Terra accounts load
+  //At present, we don't have any mechanism for doing this.
   useEffect(() => {}, []);
 
   //Terra metadata load
-  useEffect(() => {}, []);
+  useEffect(() => {
+    let cancelled = false;
+
+    if (terraTokenMap.data || lookupChain !== CHAIN_ID_TERRA) {
+      return; //So we don't fetch the whole list on every mount.
+    }
+
+    dispatch(fetchTerraTokenMap());
+    axios.get(TERRA_TOKEN_METADATA_URL).then(
+      (response) => {
+        if (!cancelled) {
+          //TODO parse this in a safer manner
+          dispatch(receiveTerraTokenMap(response.data as TerraTokenMap));
+        }
+      },
+      (error) => {
+        if (!cancelled) {
+          dispatch(
+            errorTerraTokenMap("Failed to retrieve the Terra Token List.")
+          );
+        }
+      }
+    );
+
+    return () => {
+      cancelled = true;
+    };
+  }, [lookupChain, terraTokenMap.data, dispatch]);
 
   return lookupChain === CHAIN_ID_SOLANA
     ? {
@@ -348,6 +490,12 @@ function useGetAvailableTokens() {
           error: metaplexError,
           receivedAt: null, //TODO
         } as DataWrapper<Metadata[]>,
+        mintAccounts: {
+          data: solanaMintAccounts,
+          isFetching: solanaMintAccountsLoading,
+          error: solanaMintAccountsError,
+          receivedAt: null, //TODO
+        },
       }
     : lookupChain === CHAIN_ID_ETH
     ? {
@@ -359,6 +507,10 @@ function useGetAvailableTokens() {
           receivedAt: null, //TODO
         },
       }
+    : lookupChain === CHAIN_ID_TERRA
+    ? {
+        terraTokenMap: terraTokenMap,
+      }
     : undefined;
 }
 

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

+ 6 - 0
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;
+};

+ 17 - 0
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<TokenInfo[]>;
+  terraTokenMap: DataWrapper<TerraTokenMap>; //TODO make a decent type for this.
 }
 
 const initialState: TokenMetadataState = {
   solanaTokenMap: getEmptyDataWrapper(),
+  terraTokenMap: getEmptyDataWrapper(),
 };
 
 export const tokenSlice = createSlice({
@@ -29,6 +32,17 @@ export const tokenSlice = createSlice({
     errorSolanaTokenMap: (state, action: PayloadAction<string>) => {
       state.solanaTokenMap = errorDataWrapper(action.payload);
     },
+
+    receiveTerraTokenMap: (state, action: PayloadAction<TerraTokenMap>) => {
+      state.terraTokenMap = receiveDataWrapper(action.payload);
+    },
+    fetchTerraTokenMap: (state) => {
+      state.terraTokenMap = fetchDataWrapper();
+    },
+    errorTerraTokenMap: (state, action: PayloadAction<string>) => {
+      state.terraTokenMap = errorDataWrapper(action.payload);
+    },
+
     reset: () => initialState,
   },
 });
@@ -37,6 +51,9 @@ export const {
   receiveSolanaTokenMap,
   fetchSolanaTokenMap,
   errorSolanaTokenMap,
+  receiveTerraTokenMap,
+  fetchTerraTokenMap,
+  errorTerraTokenMap,
   reset,
 } = tokenSlice.actions;
 

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

+ 38 - 5
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"
+    : "";

+ 14 - 0
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<Buffer>
+): string | null {
+  const data = Buffer.from(account.data);
+  const mintInfo = MintLayout.decode(data);
+
+  const uintArray = mintInfo?.mintAuthority;
+  const pubkey = new PublicKey(uintArray);
+  const output = pubkey?.toString();
+
+  return output || null;
+}
+
 export async function getMultipleAccountsRPC(
   connection: Connection,
   pubkeys: PublicKey[]

+ 2 - 1
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\"",

+ 9 - 0
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}`
+    );
+  }
+});

+ 388 - 0
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"
+  }
+]