Browse Source

Haul in change 1882 state from upstream - p2w envs and configs

Stan Drozd 4 years ago
parent
commit
28906caeea
100 changed files with 3905 additions and 1446 deletions
  1. 4 0
      .dockerignore
  2. 1 0
      .envrc
  3. 13 0
      .run/guardian-0 logs.run.xml
  4. 1 1
      Tiltfile
  5. 4 0
      bridge_ui/src/App.js
  6. 3 1
      bridge_ui/src/components/Attest/index.tsx
  7. 12 3
      bridge_ui/src/components/HeaderText.tsx
  8. 1 20
      bridge_ui/src/components/Home/index.tsx
  9. 3 1
      bridge_ui/src/components/NFTOriginVerifier.tsx
  10. 7 0
      bridge_ui/src/components/Stats/CustodyAddresses.tsx
  11. 4 1
      bridge_ui/src/components/Stats/NFTStats.tsx
  12. 381 0
      bridge_ui/src/components/TokenOriginVerifier.tsx
  13. 3 1
      bridge_ui/src/components/TokenSelectors/TerraTokenPicker.tsx
  14. 1 1
      bridge_ui/src/components/TokenSelectors/TokenPicker.tsx
  15. 23 5
      bridge_ui/src/components/Transfer/RegisterNowButton.tsx
  16. 123 50
      bridge_ui/src/components/Transfer/SendConfirmationDialog.tsx
  17. 17 7
      bridge_ui/src/components/Transfer/Source.tsx
  18. 0 6
      bridge_ui/src/components/Transfer/SourcePreview.tsx
  19. 111 79
      bridge_ui/src/components/Transfer/TokenWarning.tsx
  20. 1 1
      bridge_ui/src/hooks/useEvmMetadata.ts
  21. 119 75
      bridge_ui/src/hooks/useFetchForeignAsset.ts
  22. 46 2
      bridge_ui/src/hooks/useFetchTargetAsset.ts
  23. 52 13
      bridge_ui/src/hooks/useIsWalletReady.ts
  24. 19 14
      bridge_ui/src/hooks/useMetadata.ts
  25. 50 2
      bridge_ui/src/hooks/useNFTTVL.ts
  26. 254 0
      bridge_ui/src/hooks/useOriginalAsset.ts
  27. 63 4
      bridge_ui/src/hooks/useTVL.ts
  28. 86 0
      bridge_ui/src/hooks/useTerraMetadata.ts
  29. 1 1
      bridge_ui/src/muiTheme.js
  30. 1 4
      bridge_ui/src/store/selectors.ts
  31. 46 5
      bridge_ui/src/utils/consts.ts
  32. 1 1
      devnet/bridge-ui.yaml
  33. 1 1
      event_database/cloud_functions/go.mod
  34. 2 0
      event_database/cloud_functions/go.sum
  35. 2 2
      event_database/cloud_functions/readrow.go
  36. 15 1
      event_database/cloud_functions/recent.go
  37. 67 20
      event_database/cloud_functions/shared.go
  38. 2 2
      event_database/cloud_functions/transaction.go
  39. 3 3
      explorer/.env.sample
  40. 116 180
      explorer/package-lock.json
  41. 2 0
      explorer/package.json
  42. 28 10
      explorer/src/components/ExplorerQuery/ExplorerQuery.tsx
  43. 4 4
      explorer/src/components/ExplorerStats/ChainOverviewCard.tsx
  44. 2 5
      explorer/src/components/ExplorerStats/DailyCountLineChart.tsx
  45. 32 27
      explorer/src/components/ExplorerStats/ExplorerStats.tsx
  46. 2 2
      explorer/src/components/ExplorerStats/RecentMessages.tsx
  47. 27 4
      explorer/src/components/ExplorerStats/utils.ts
  48. 43 14
      explorer/src/components/ExplorerSummary/ExplorerSummary.tsx
  49. 34 0
      explorer/src/components/Layout/DefaultLayout.less
  50. 176 73
      explorer/src/components/Layout/DefaultLayout.tsx
  51. 3 3
      explorer/src/components/Payload/DecodePayload.tsx
  52. 1 0
      explorer/src/locales/en.json
  53. 3 3
      explorer/src/pages/explorer.tsx
  54. 2 4
      explorer/src/pages/index.tsx
  55. 3 1
      node/cmd/guardiand/adminclient.go
  56. 5 0
      node/hack/discord_test/discord.go
  57. 53 5
      node/pkg/notify/discord/notify.go
  58. 9 5
      node/pkg/processor/cleanup.go
  59. 20 0
      sdk/js/CHANGELOG.md
  60. 7 0
      sdk/js/jestconfig.json
  61. 1025 589
      sdk/js/package-lock.json
  62. 6 2
      sdk/js/package.json
  63. 32 0
      sdk/js/scripts/copyWasm.js
  64. 2 1
      sdk/js/src/bridge/getClaimAddress.ts
  65. 2 1
      sdk/js/src/bridge/getEmitterAddress.ts
  66. 2 3
      sdk/js/src/migration/addLiquidity.ts
  67. 3 3
      sdk/js/src/migration/authorityAddress.ts
  68. 2 3
      sdk/js/src/migration/claimShares.ts
  69. 2 3
      sdk/js/src/migration/createPool.ts
  70. 3 3
      sdk/js/src/migration/fromCustodyAddress.ts
  71. 2 3
      sdk/js/src/migration/migrateTokens.ts
  72. 3 1
      sdk/js/src/migration/parsePool.ts
  73. 3 3
      sdk/js/src/migration/poolAddress.ts
  74. 2 3
      sdk/js/src/migration/removeLiquidity.ts
  75. 3 3
      sdk/js/src/migration/shareMintAddress.ts
  76. 3 3
      sdk/js/src/migration/toCustodyAddress.ts
  77. 2 1
      sdk/js/src/nft_bridge/getForeignAsset.ts
  78. 2 1
      sdk/js/src/nft_bridge/getIsWrappedAsset.ts
  79. 2 3
      sdk/js/src/nft_bridge/getOriginalAsset.ts
  80. 4 5
      sdk/js/src/nft_bridge/redeem.ts
  81. 98 95
      sdk/js/src/nft_bridge/transfer.ts
  82. 34 0
      sdk/js/src/rpc/getSignedVAAWithRetry.ts
  83. 2 1
      sdk/js/src/solana/getBridgeFeeIx.ts
  84. 4 3
      sdk/js/src/solana/postVaa.ts
  85. 38 0
      sdk/js/src/solana/wasm.ts
  86. 35 0
      sdk/js/src/token_bridge/__tests__/consts.ts
  87. 393 0
      sdk/js/src/token_bridge/__tests__/integration.ts
  88. 4 4
      sdk/js/src/token_bridge/attest.ts
  89. 4 4
      sdk/js/src/token_bridge/createWrapped.ts
  90. 2 1
      sdk/js/src/token_bridge/getForeignAsset.ts
  91. 2 1
      sdk/js/src/token_bridge/getIsWrappedAsset.ts
  92. 3 3
      sdk/js/src/token_bridge/getOriginalAsset.ts
  93. 5 6
      sdk/js/src/token_bridge/redeem.ts
  94. 4 4
      sdk/js/src/token_bridge/transfer.ts
  95. 3 0
      sdk/js/src/utils/array.ts
  96. 8 0
      solana/Dockerfile.wasm
  97. 1 0
      third_party/pyth/Dockerfile.p2w-attest
  98. 1 0
      third_party/pyth/Dockerfile.pyth
  99. 1 1
      third_party/pyth/p2w-relay/package.json
  100. 43 22
      third_party/pyth/p2w-relay/src/index.ts

+ 4 - 0
.dockerignore

@@ -0,0 +1,4 @@
+target
+bin
+**/target
+**/node_modules

+ 1 - 0
.envrc

@@ -0,0 +1 @@
+eval "$(lorri direnv)"

+ 13 - 0
.run/guardian-0 logs.run.xml

@@ -0,0 +1,13 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="guardian-0 logs" type="ShConfigurationType">
+    <option name="INDEPENDENT_SCRIPT_PATH" value="true" />
+    <option name="SCRIPT_PATH" value="$PROJECT_DIR$/scripts/tail.sh" />
+    <option name="SCRIPT_OPTIONS" value="guardian-0" />
+    <option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
+    <option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
+    <option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
+    <option name="INTERPRETER_PATH" value="/bin/bash" />
+    <option name="INTERPRETER_OPTIONS" value="" />
+    <method v="2" />
+  </configuration>
+</component>

+ 1 - 1
Tiltfile

@@ -254,7 +254,7 @@ if bridge_ui:
         "bridge-ui",
         resource_deps = ["proto-gen-web", "wasm-gen"],
         port_forwards = [
-            port_forward(5000, name = "Bridge UI [:5000]"),
+            port_forward(3000, name = "Bridge UI [:3000]"),
         ],
     )
 

+ 4 - 0
bridge_ui/src/App.js

@@ -41,6 +41,7 @@ import { useBetaContext } from "./contexts/BetaContext";
 import { COLORS } from "./muiTheme";
 import { CLUSTER } from "./utils/consts";
 import Stats from "./components/Stats";
+import TokenOriginVerifier from "./components/TokenOriginVerifier";
 
 const useStyles = makeStyles((theme) => ({
   appBar: {
@@ -270,6 +271,9 @@ function App() {
           <Route exact path="/nft-origin-verifier">
             <NFTOriginVerifier />
           </Route>
+          <Route exact path="/token-origin-verifier">
+            <TokenOriginVerifier />
+          </Route>
           <Route exact path="/register">
             <Attest />
           </Route>

+ 3 - 1
bridge_ui/src/components/Attest/index.tsx

@@ -45,7 +45,9 @@ function Attest() {
   }, [preventNavigation]);
   return (
     <Container maxWidth="md">
-      <HeaderText>Token Registration</HeaderText>
+      <HeaderText white small>
+        Token Registration
+      </HeaderText>
       <Alert severity="info">
         This form allows you to register a token on a new foreign chain. Tokens
         must be registered before they can be transferred.

+ 12 - 3
bridge_ui/src/components/HeaderText.tsx

@@ -25,13 +25,22 @@ const useStyles = makeStyles((theme) => ({
   },
 }));
 
-export default function HeaderText({ children }: { children: ReactChild }) {
+export default function HeaderText({
+  children,
+  white,
+  small,
+}: {
+  children: ReactChild;
+  white?: boolean;
+  small?: boolean;
+}) {
   const classes = useStyles();
   return (
     <div className={classes.centeredContainer}>
       <Typography
-        variant="h1"
-        className={clsx(classes.header, classes.linearGradient)}
+        variant={small ? "h2" : "h1"}
+        component="h1"
+        className={clsx(classes.header, { [classes.linearGradient]: !white })}
       >
         {children}
       </Typography>

+ 1 - 20
bridge_ui/src/components/Home/index.tsx

@@ -1,13 +1,11 @@
 import {
   Card,
-  Chip,
   Container,
   Link,
   makeStyles,
   Typography,
 } from "@material-ui/core";
 import { Link as RouterLink } from "react-router-dom";
-import polygonLogo from "../../icons/polygon.svg";
 import { COLORS } from "../../muiTheme";
 import { BETA_CHAINS, CHAINS } from "../../utils/consts";
 import HeaderText from "../HeaderText";
@@ -120,23 +118,6 @@ function Home() {
               </Typography>
             </div>
           ))}
-          <div className={classes.chainCard}>
-            <div className={classes.chainLogoWrapper}>
-              <img
-                src={polygonLogo}
-                alt="Polygon"
-                className={classes.chainLogo}
-              />
-              <Chip label="Coming soon" size="small" className={classes.chip} />
-            </div>
-            <Typography
-              variant="body2"
-              component="div"
-              className={classes.chainName}
-            >
-              <div>Polygon</div>
-            </Typography>
-          </div>
         </div>
       </Container>
       <Container maxWidth="md">
@@ -146,7 +127,7 @@ function Home() {
           </Typography>
           <Typography variant="h6" className={classes.description}>
             The Wormhole Token Bridge allows you to seamlessly transfer
-            tokenized assets across Solana, Ethereum, BSC, and Terra.
+            tokenized assets across Solana, Ethereum, BSC, Terra, and Polygon.
           </Typography>
           <div className={classes.spacer} />
           <Typography variant="subtitle1" className={classes.description}>

+ 3 - 1
bridge_ui/src/components/NFTOriginVerifier.tsx

@@ -215,7 +215,9 @@ export default function NFTOriginVerifier() {
   return (
     <div>
       <Container maxWidth="md">
-        <HeaderText>NFT Origin Verifier</HeaderText>
+        <HeaderText white small>
+          NFT Origin Verifier
+        </HeaderText>
       </Container>
       <Container maxWidth="sm">
         <Card className={classes.mainCard}>

+ 7 - 0
bridge_ui/src/components/Stats/CustodyAddresses.tsx

@@ -1,6 +1,7 @@
 import {
   CHAIN_ID_BSC,
   CHAIN_ID_ETH,
+  CHAIN_ID_POLYGON,
   CHAIN_ID_SOLANA,
   CHAIN_ID_TERRA,
 } from "@certusone/wormhole-sdk";
@@ -60,6 +61,12 @@ const CustodyAddresses: React.FC<any> = () => {
         tokenAddress: getTokenBridgeAddressForChain(CHAIN_ID_TERRA),
         nftAddress: null,
       },
+      {
+        chainName: "Polygon",
+        chainId: CHAIN_ID_POLYGON,
+        tokenAddress: getTokenBridgeAddressForChain(CHAIN_ID_POLYGON),
+        nftAddress: getNFTBridgeAddressForChain(CHAIN_ID_POLYGON),
+      },
     ];
   }, []);
 

+ 4 - 1
bridge_ui/src/components/Stats/NFTStats.tsx

@@ -86,7 +86,10 @@ const useStyles = makeStyles((theme) => ({
   },
 }));
 
-const BLACKLIST = ["D9cX654dGb4GFzqq3RY7rhZbRkQqUkfggDZdnYxqv97g"];
+const BLACKLIST = [
+  "D9cX654dGb4GFzqq3RY7rhZbRkQqUkfggDZdnYxqv97g",
+  "0xfeA43A080297B02F2eBB88a27Cb0FA6DB1b33B1d",
+];
 
 const NFTStats: React.FC<any> = () => {
   const classes = useStyles();

+ 381 - 0
bridge_ui/src/components/TokenOriginVerifier.tsx

@@ -0,0 +1,381 @@
+import {
+  ChainId,
+  CHAIN_ID_SOLANA,
+  CHAIN_ID_TERRA,
+  nativeToHexString,
+} from "@certusone/wormhole-sdk";
+import {
+  Card,
+  CircularProgress,
+  Container,
+  makeStyles,
+  MenuItem,
+  TextField,
+  Typography,
+} from "@material-ui/core";
+import ArrowDropDownIcon from "@material-ui/icons/ArrowDropDown";
+import { useCallback, useMemo, useState } from "react";
+import { useBetaContext } from "../contexts/BetaContext";
+import useFetchForeignAsset, {
+  ForeignAssetInfo,
+} from "../hooks/useFetchForeignAsset";
+import useIsWalletReady from "../hooks/useIsWalletReady";
+import useMetadata from "../hooks/useMetadata";
+import useOriginalAsset, { OriginalAssetInfo } from "../hooks/useOriginalAsset";
+import { COLORS } from "../muiTheme";
+import { BETA_CHAINS, CHAINS, CHAINS_BY_ID } from "../utils/consts";
+import { isEVMChain } from "../utils/ethereum";
+import HeaderText from "./HeaderText";
+import KeyAndBalance from "./KeyAndBalance";
+import SmartAddress from "./SmartAddress";
+import { RegisterNowButtonCore } from "./Transfer/RegisterNowButton";
+
+const useStyles = makeStyles((theme) => ({
+  flexBox: {
+    display: "flex",
+    width: "100%",
+    justifyContent: "center",
+    "& > *": {
+      margin: theme.spacing(2),
+    },
+  },
+  mainCard: {
+    padding: theme.spacing(2),
+    backgroundColor: COLORS.nearBlackWithMinorTransparency,
+  },
+  spacer: {
+    height: theme.spacing(3),
+  },
+  centered: {
+    textAlign: "center",
+  },
+  arrowIcon: {
+    margin: "0 auto",
+    fontSize: "70px",
+  },
+  resultContainer: {
+    margin: theme.spacing(2),
+  },
+}));
+
+function PrimaryAssetInfomation({
+  lookupChain,
+  lookupAsset,
+  originChain,
+  originAsset,
+  showLoader,
+}: {
+  lookupChain: ChainId;
+  lookupAsset: string;
+  originChain: ChainId;
+  originAsset: string;
+  showLoader: boolean;
+}) {
+  const classes = useStyles();
+  const tokenArray = useMemo(() => [originAsset], [originAsset]);
+  const metadata = useMetadata(originChain, tokenArray);
+  const nativeContent = (
+    <div>
+      <Typography>{`This is not a Wormhole wrapped token.`}</Typography>
+    </div>
+  );
+  const wrapped = (
+    <div>
+      <Typography>{`This is wrapped by Wormhole! Here is the original token: `}</Typography>
+      <div className={classes.flexBox}>
+        <Typography>{`Chain: ${CHAINS_BY_ID[originChain].name}`}</Typography>
+        <div>
+          <Typography component="div">
+            {"Token: "}
+            <SmartAddress
+              address={originAsset}
+              chainId={originChain}
+              symbol={metadata.data?.get(originAsset)?.symbol}
+              tokenName={metadata.data?.get(originAsset)?.tokenName}
+            />
+          </Typography>
+        </div>
+      </div>
+    </div>
+  );
+  return lookupChain === originChain ? nativeContent : wrapped;
+}
+
+function SecondaryAssetInformation({
+  chainId,
+  foreignAssetInfo,
+  originAssetInfo,
+}: {
+  chainId: ChainId;
+  foreignAssetInfo?: ForeignAssetInfo;
+  originAssetInfo?: OriginalAssetInfo;
+}) {
+  const classes = useStyles();
+  const tokenArray: string[] = useMemo(() => {
+    //Saved to a variable to help typescript cope
+    const originAddress = originAssetInfo?.originAddress;
+    return originAddress && chainId === originAssetInfo?.originChain
+      ? [originAddress]
+      : foreignAssetInfo?.address
+      ? [foreignAssetInfo?.address]
+      : [];
+  }, [foreignAssetInfo, originAssetInfo, chainId]);
+  const metadata = useMetadata(chainId, tokenArray);
+  //TODO when this is the origin chain
+  return !originAssetInfo ? null : chainId === originAssetInfo.originChain ? (
+    <div>
+      <Typography>{`Transferring to ${CHAINS_BY_ID[chainId].name} will unwrap the token:`}</Typography>
+      <div className={classes.resultContainer}>
+        <SmartAddress
+          chainId={chainId}
+          address={originAssetInfo.originAddress || undefined}
+          symbol={
+            metadata.data?.get(originAssetInfo.originAddress || "")?.symbol ||
+            undefined
+          }
+          tokenName={
+            metadata.data?.get(originAssetInfo.originAddress || "")
+              ?.tokenName || undefined
+          }
+        />
+      </div>
+    </div>
+  ) : !foreignAssetInfo ? null : foreignAssetInfo.doesExist === false ? (
+    <div>
+      <Typography>{`This token has not yet been registered on ${CHAINS_BY_ID[chainId].name}`}</Typography>
+      <RegisterNowButtonCore
+        originChain={originAssetInfo?.originChain || undefined}
+        originAsset={
+          nativeToHexString(
+            originAssetInfo?.originAddress || undefined,
+            originAssetInfo?.originChain || CHAIN_ID_SOLANA // this should exist
+          ) || undefined
+        }
+        targetChain={chainId}
+      />
+    </div>
+  ) : (
+    <div>
+      <Typography>When bridged, this asset becomes: </Typography>
+      <div className={classes.resultContainer}>
+        <SmartAddress
+          chainId={chainId}
+          address={foreignAssetInfo.address || undefined}
+          symbol={
+            metadata.data?.get(foreignAssetInfo.address || "")?.symbol ||
+            undefined
+          }
+          tokenName={
+            metadata.data?.get(foreignAssetInfo.address || "")?.tokenName ||
+            undefined
+          }
+        />
+      </div>
+    </div>
+  );
+}
+
+export default function TokenOriginVerifier() {
+  const classes = useStyles();
+  const isBeta = useBetaContext();
+
+  const [primaryLookupChain, setPrimaryLookupChain] = useState(CHAIN_ID_SOLANA);
+  const [primaryLookupAsset, setPrimaryLookupAsset] = useState("");
+
+  const [secondaryLookupChain, setSecondaryLookupChain] =
+    useState(CHAIN_ID_TERRA);
+
+  const primaryLookupChainOptions = useMemo(
+    () => (isBeta ? CHAINS.filter((x) => !BETA_CHAINS.includes(x.id)) : CHAINS),
+    [isBeta]
+  );
+  const secondaryLookupChainOptions = useMemo(
+    () =>
+      isBeta
+        ? CHAINS.filter(
+            (x) => !BETA_CHAINS.includes(x.id) && x.id !== primaryLookupChain
+          )
+        : CHAINS.filter((x) => x.id !== primaryLookupChain),
+    [isBeta, primaryLookupChain]
+  );
+
+  const handlePrimaryLookupChainChange = useCallback(
+    (e) => {
+      setPrimaryLookupChain(e.target.value);
+      if (secondaryLookupChain === e.target.value) {
+        setSecondaryLookupChain(
+          e.target.value === CHAIN_ID_SOLANA ? CHAIN_ID_TERRA : CHAIN_ID_SOLANA
+        );
+      }
+      setPrimaryLookupAsset("");
+    },
+    [secondaryLookupChain]
+  );
+  const handleSecondaryLookupChainChange = useCallback((e) => {
+    setSecondaryLookupChain(e.target.value);
+  }, []);
+  const handlePrimaryLookupAssetChange = useCallback((event) => {
+    setPrimaryLookupAsset(event.target.value);
+  }, []);
+
+  const originInfo = useOriginalAsset(
+    primaryLookupChain,
+    primaryLookupAsset,
+    false
+  );
+  const foreignAssetInfo = useFetchForeignAsset(
+    originInfo.data?.originChain || 1,
+    originInfo.data?.originAddress || "",
+    secondaryLookupChain
+  );
+
+  const primaryWalletIsActive = !originInfo.data;
+  const secondaryWalletIsActive = !primaryWalletIsActive;
+
+  const primaryWallet = useIsWalletReady(
+    primaryLookupChain,
+    primaryWalletIsActive
+  );
+  const secondaryWallet = useIsWalletReady(
+    secondaryLookupChain,
+    secondaryWalletIsActive
+  );
+
+  const primaryWalletError =
+    isEVMChain(primaryLookupChain) &&
+    primaryLookupAsset &&
+    !originInfo.data &&
+    !originInfo.error &&
+    (!primaryWallet.isReady ? primaryWallet.statusMessage : "");
+  const originError = originInfo.error;
+  const primaryError = primaryWalletError || originError;
+
+  const secondaryWalletError =
+    isEVMChain(secondaryLookupChain) &&
+    originInfo.data?.originAddress &&
+    originInfo.data?.originChain &&
+    !foreignAssetInfo.data &&
+    (!secondaryWallet.isReady ? secondaryWallet.statusMessage : "");
+  const foreignError = foreignAssetInfo.error;
+  const secondaryError = secondaryWalletError || foreignError;
+
+  const primaryContent = (
+    <>
+      <Typography variant="h5">Source Information</Typography>
+      <Typography variant="body1" color="textSecondary">
+        Enter a token from any supported chain to get started.
+      </Typography>
+      <div className={classes.spacer} />
+      <TextField
+        select
+        variant="outlined"
+        label="Chain"
+        value={primaryLookupChain}
+        onChange={handlePrimaryLookupChainChange}
+        fullWidth
+        margin="normal"
+      >
+        {primaryLookupChainOptions.map(({ id, name }) => (
+          <MenuItem key={id} value={id}>
+            {name}
+          </MenuItem>
+        ))}
+      </TextField>
+      <TextField
+        fullWidth
+        variant="outlined"
+        margin="normal"
+        label="Paste an address"
+        value={primaryLookupAsset}
+        onChange={handlePrimaryLookupAssetChange}
+      />
+      <div className={classes.centered}>
+        {isEVMChain(primaryLookupChain) ? (
+          <KeyAndBalance chainId={primaryLookupChain} />
+        ) : null}
+        {primaryError ? (
+          <Typography color="error">{primaryError}</Typography>
+        ) : null}
+        <div className={classes.spacer} />
+        {originInfo.isFetching ? (
+          <CircularProgress />
+        ) : originInfo.data?.originChain && originInfo.data.originAddress ? (
+          <PrimaryAssetInfomation
+            lookupAsset={primaryLookupAsset}
+            lookupChain={primaryLookupChain}
+            originChain={originInfo.data.originChain}
+            originAsset={originInfo.data.originAddress}
+            showLoader={originInfo.isFetching}
+          />
+        ) : null}
+      </div>
+    </>
+  );
+
+  const secondaryContent = originInfo.data ? (
+    <>
+      <Typography variant="h5">Bridge Results</Typography>
+      <Typography variant="body1" color="textSecondary">
+        Select a chain to see the result of bridging this token.
+      </Typography>
+      <div className={classes.spacer} />
+      <TextField
+        select
+        variant="outlined"
+        label="Other Chain"
+        value={secondaryLookupChain}
+        onChange={handleSecondaryLookupChainChange}
+        fullWidth
+        margin="normal"
+      >
+        {secondaryLookupChainOptions.map(({ id, name }) => (
+          <MenuItem key={id} value={id}>
+            {name}
+          </MenuItem>
+        ))}
+      </TextField>
+      <div className={classes.centered}>
+        {isEVMChain(secondaryLookupChain) ? (
+          <KeyAndBalance chainId={secondaryLookupChain} />
+        ) : null}
+        {secondaryError ? (
+          <Typography color="error">{secondaryError}</Typography>
+        ) : null}
+        <div className={classes.spacer} />
+        {foreignAssetInfo.isFetching ? (
+          <CircularProgress />
+        ) : originInfo.data?.originChain && originInfo.data.originAddress ? (
+          <SecondaryAssetInformation
+            foreignAssetInfo={foreignAssetInfo.data || undefined}
+            originAssetInfo={originInfo.data || undefined}
+            chainId={secondaryLookupChain}
+          />
+        ) : null}
+      </div>
+    </>
+  ) : null;
+
+  const content = (
+    <div>
+      <Container maxWidth="md" className={classes.centered}>
+        <HeaderText white small>
+          Token Origin Verifier
+        </HeaderText>
+        <div className={classes.spacer} />
+      </Container>
+      <Container maxWidth="sm">
+        <Card className={classes.mainCard}>{primaryContent}</Card>
+        {secondaryContent ? (
+          <>
+            <div className={classes.centered}>
+              <ArrowDropDownIcon className={classes.arrowIcon} />
+            </div>
+            <Card className={classes.mainCard}>{secondaryContent}</Card>
+          </>
+        ) : null}
+      </Container>
+    </div>
+  );
+
+  return content;
+}

+ 3 - 1
bridge_ui/src/components/TokenSelectors/TerraTokenPicker.tsx

@@ -122,7 +122,9 @@ export default function TerraTokenPicker(props: TerraTokenPickerProps) {
                   balance.balance.toString(),
                   info.decimals,
                   Number(formatUnits(balance.balance, info.decimals)),
-                  formatUnits(balance.balance, info.decimals)
+                  formatUnits(balance.balance, info.decimals),
+                  info.symbol,
+                  info.name
                 );
               } else {
                 throw new Error("Failed to retrieve Terra account.");

+ 1 - 1
bridge_ui/src/components/TokenSelectors/TokenPicker.tsx

@@ -131,7 +131,7 @@ export const BasicAccountRender = (
       </div>
       <div>
         <Typography>{mintPrettyString}</Typography>
-        <Typography>{tokenId}</Typography>
+        <Typography style={{ wordBreak: "break-all" }}>{tokenId}</Typography>
       </div>
     </div>
   );

+ 23 - 5
bridge_ui/src/components/Transfer/RegisterNowButton.tsx

@@ -14,14 +14,19 @@ import {
   selectTransferOriginChain,
   selectTransferTargetChain,
 } from "../../store/selectors";
-import { hexToNativeString } from "@certusone/wormhole-sdk";
+import { ChainId, hexToNativeString } from "@certusone/wormhole-sdk";
 
-export default function RegisterNowButton() {
+export function RegisterNowButtonCore({
+  originChain,
+  originAsset,
+  targetChain,
+}: {
+  originChain: ChainId | undefined;
+  originAsset: string | undefined;
+  targetChain: ChainId;
+}) {
   const dispatch = useDispatch();
   const history = useHistory();
-  const originChain = useSelector(selectTransferOriginChain);
-  const originAsset = useSelector(selectTransferOriginAsset);
-  const targetChain = useSelector(selectTransferTargetChain);
   // user might be in the middle of a different attest
   const signedVAAHex = useSelector(selectAttestSignedVAAHex);
   const canSwitch = originChain && originAsset && !signedVAAHex;
@@ -48,3 +53,16 @@ export default function RegisterNowButton() {
     </Button>
   );
 }
+
+export default function RegisterNowButton() {
+  const originChain = useSelector(selectTransferOriginChain);
+  const originAsset = useSelector(selectTransferOriginAsset);
+  const targetChain = useSelector(selectTransferTargetChain);
+  return (
+    <RegisterNowButtonCore
+      originChain={originChain}
+      originAsset={originAsset}
+      targetChain={targetChain}
+    />
+  );
+}

+ 123 - 50
bridge_ui/src/components/Transfer/SendConfirmationDialog.tsx

@@ -1,3 +1,4 @@
+import { isEVMChain } from "@certusone/wormhole-sdk";
 import {
   Button,
   Dialog,
@@ -7,62 +8,141 @@ import {
   Typography,
 } from "@material-ui/core";
 import { ArrowDownward } from "@material-ui/icons";
-import { Alert } from "@material-ui/lab";
+import { useEffect, useMemo, useState } from "react";
 import { useSelector } from "react-redux";
 import {
+  selectTransferOriginChain,
   selectTransferSourceChain,
   selectTransferSourceParsedTokenAccount,
 } from "../../store/selectors";
-import { CHAINS_BY_ID } from "../../utils/consts";
+import { CHAINS_BY_ID, MULTI_CHAIN_TOKENS } from "../../utils/consts";
 import SmartAddress from "../SmartAddress";
 import { useTargetInfo } from "./Target";
+import TokenWarning from "./TokenWarning";
 
-function SendConfirmationContent() {
+function SendConfirmationContent({
+  open,
+  onClose,
+  onClick,
+}: {
+  open: boolean;
+  onClose: () => void;
+  onClick: () => void;
+}) {
   const sourceChain = useSelector(selectTransferSourceChain);
   const sourceParsedTokenAccount = useSelector(
     selectTransferSourceParsedTokenAccount
   );
   const { targetChain, targetAsset, symbol, tokenName, logo } = useTargetInfo();
-  return (
+  const originChain = useSelector(selectTransferOriginChain);
+
+  //TODO this check is essentially duplicated.
+  const deservesTimeout = useMemo(() => {
+    if (originChain && sourceParsedTokenAccount?.mintKey) {
+      const searchableAddress = isEVMChain(originChain)
+        ? sourceParsedTokenAccount.mintKey.toLowerCase()
+        : sourceParsedTokenAccount.mintKey;
+      return (
+        originChain !== targetChain &&
+        !!MULTI_CHAIN_TOKENS[sourceChain]?.[searchableAddress]
+      );
+    } else {
+      return false;
+    }
+  }, [originChain, targetChain, sourceChain, sourceParsedTokenAccount]);
+  const timeoutDuration = 5;
+
+  const [countdown, setCountdown] = useState(
+    deservesTimeout ? timeoutDuration : 0
+  );
+
+  useEffect(() => {
+    if (!deservesTimeout || countdown === 0) {
+      return;
+    }
+    let cancelled = false;
+
+    setInterval(() => {
+      if (!cancelled) {
+        setCountdown((state) => state - 1);
+      }
+    }, 1000);
+
+    return () => {
+      cancelled = true;
+    };
+  }, [deservesTimeout, countdown]);
+
+  useEffect(() => {
+    if (open && deservesTimeout) {
+      //Countdown starts on mount, but we actually want it to start on open
+      setCountdown(timeoutDuration);
+    }
+  }, [open, deservesTimeout]);
+
+  const sendConfirmationContent = (
     <>
-      {targetAsset ? (
-        <div style={{ textAlign: "center" }}>
-          <SmartAddress
-            variant="h6"
-            chainId={sourceChain}
-            parsedTokenAccount={sourceParsedTokenAccount}
-          />
-          <div>
-            <Typography variant="caption">
-              {CHAINS_BY_ID[sourceChain].name}
-            </Typography>
-          </div>
-          <div style={{ paddingTop: 4 }}>
-            <ArrowDownward fontSize="inherit" />
-          </div>
-          <SmartAddress
-            variant="h6"
-            chainId={targetChain}
-            address={targetAsset}
-            symbol={symbol}
-            tokenName={tokenName}
-            logo={logo}
-          />
-          <div>
-            <Typography variant="caption">
-              {CHAINS_BY_ID[targetChain].name}
+      <DialogTitle>Are you sure?</DialogTitle>
+      <DialogContent>
+        {targetAsset ? (
+          <div style={{ textAlign: "center", marginBottom: 16 }}>
+            <Typography variant="subtitle1" style={{ marginBottom: 8 }}>
+              You are about to perform this transfer:
             </Typography>
+            <SmartAddress
+              variant="h6"
+              chainId={sourceChain}
+              parsedTokenAccount={sourceParsedTokenAccount}
+            />
+            <div>
+              <Typography variant="caption">
+                {CHAINS_BY_ID[sourceChain].name}
+              </Typography>
+            </div>
+            <div style={{ paddingTop: 4 }}>
+              <ArrowDownward fontSize="inherit" />
+            </div>
+            <SmartAddress
+              variant="h6"
+              chainId={targetChain}
+              address={targetAsset}
+              symbol={symbol}
+              tokenName={tokenName}
+              logo={logo}
+            />
+            <div>
+              <Typography variant="caption">
+                {CHAINS_BY_ID[targetChain].name}
+              </Typography>
+            </div>
           </div>
-        </div>
-      ) : null}
-      <Alert severity="warning" variant="outlined" style={{ marginTop: 8 }}>
-        Once the transfer transaction is submitted, the transfer must be
-        completed by redeeming the tokens on the target chain. Please ensure
-        that the token listed above is the desired token and confirm that
-        markets exist on the target chain.
-      </Alert>
+        ) : null}
+        <TokenWarning
+          sourceAsset={sourceParsedTokenAccount?.mintKey}
+          sourceChain={sourceChain}
+          originChain={originChain}
+          targetAsset={targetAsset ?? undefined}
+          targetChain={targetChain}
+        />
+      </DialogContent>
+      <DialogActions>
+        <Button variant="outlined" onClick={onClose}>
+          Cancel
+        </Button>
+        <Button
+          variant="contained"
+          color="primary"
+          onClick={onClick}
+          size={"medium"}
+          disabled={!!countdown}
+        >
+          {!!countdown ? countdown.toString() : "Confirm"}
+        </Button>
+      </DialogActions>
     </>
   );
+
+  return sendConfirmationContent;
 }
 
 export default function SendConfirmationDialog({
@@ -76,18 +156,11 @@ export default function SendConfirmationDialog({
 }) {
   return (
     <Dialog open={open} onClose={onClose}>
-      <DialogTitle>Are you sure?</DialogTitle>
-      <DialogContent>
-        <SendConfirmationContent />
-      </DialogContent>
-      <DialogActions>
-        <Button variant="outlined" onClick={onClose}>
-          Cancel
-        </Button>
-        <Button variant="contained" color="primary" onClick={onClick}>
-          Confirm
-        </Button>
-      </DialogActions>
+      <SendConfirmationContent
+        open={open}
+        onClose={onClose}
+        onClick={onClick}
+      />
     </Dialog>
   );
 }

+ 17 - 7
bridge_ui/src/components/Transfer/Source.tsx

@@ -5,6 +5,8 @@ import {
 } from "@certusone/wormhole-sdk";
 import { getAddress } from "@ethersproject/address";
 import { Button, makeStyles } from "@material-ui/core";
+import { Link } from "react-router-dom";
+import { VerifiedUser } from "@material-ui/icons";
 import { useCallback } from "react";
 import { useDispatch, useSelector } from "react-redux";
 import { useHistory } from "react-router";
@@ -36,7 +38,6 @@ import LowBalanceWarning from "../LowBalanceWarning";
 import NumberTextField from "../NumberTextField";
 import StepDescription from "../StepDescription";
 import { TokenSelector } from "../TokenSelectors/SourceTokenSelector";
-import TokenWarning from "./TokenWarning";
 
 const useStyles = makeStyles((theme) => ({
   transferField: {
@@ -107,7 +108,21 @@ function Source() {
   return (
     <>
       <StepDescription>
-        Select tokens to send through the Wormhole Token Bridge.
+        <div style={{ display: "flex", alignItems: "center" }}>
+          Select tokens to send through the Wormhole Bridge.
+          <div style={{ flexGrow: 1 }} />
+          <div>
+            <Button
+              component={Link}
+              to="/token-origin-verifier"
+              size="small"
+              variant="outlined"
+              endIcon={<VerifiedUser />}
+            >
+              Token Origin Verifier
+            </Button>
+          </div>
+        </div>
       </StepDescription>
       <ChainSelect
         select
@@ -135,11 +150,6 @@ function Source() {
         </Button>
       ) : (
         <>
-          <TokenWarning
-            sourceChain={sourceChain}
-            tokenAddress={parsedTokenAccount?.mintKey}
-            symbol={parsedTokenAccount?.symbol}
-          />
           <LowBalanceWarning chainId={sourceChain} />
           {hasParsedTokenAccount ? (
             <NumberTextField

+ 0 - 6
bridge_ui/src/components/Transfer/SourcePreview.tsx

@@ -8,7 +8,6 @@ import {
 } from "../../store/selectors";
 import { CHAINS_BY_ID } from "../../utils/consts";
 import SmartAddress from "../SmartAddress";
-import TokenWarning from "./TokenWarning";
 
 const useStyles = makeStyles((theme) => ({
   description: {
@@ -54,11 +53,6 @@ export default function SourcePreview() {
       >
         {explainerContent}
       </Typography>
-      <TokenWarning
-        sourceChain={sourceChain}
-        tokenAddress={sourceParsedTokenAccount?.mintKey}
-        symbol={sourceParsedTokenAccount?.symbol}
-      />
     </>
   );
 }

+ 111 - 79
bridge_ui/src/components/Transfer/TokenWarning.tsx

@@ -1,19 +1,10 @@
-import {
-  ChainId,
-  CHAIN_ID_BSC,
-  CHAIN_ID_ETH,
-  CHAIN_ID_SOLANA,
-  WSOL_ADDRESS,
-} from "@certusone/wormhole-sdk";
-import { getAddress } from "@ethersproject/address";
-import { makeStyles } from "@material-ui/core";
+import { ChainId, CHAIN_ID_ETH, isEVMChain } from "@certusone/wormhole-sdk";
+import { Box, Link, makeStyles, Typography } from "@material-ui/core";
 import { Alert } from "@material-ui/lab";
-import { useMemo } from "react";
 import {
-  BSC_MARKET_WARNINGS,
-  ETH_TOKENS_THAT_CAN_BE_SWAPPED_ON_SOLANA,
-  ETH_TOKENS_THAT_EXIST_ELSEWHERE,
-  SOLANA_TOKENS_THAT_EXIST_ELSEWHERE,
+  AVAILABLE_MARKETS_URL,
+  CHAINS_BY_ID,
+  MULTI_CHAIN_TOKENS,
 } from "../../utils/consts";
 
 const useStyles = makeStyles((theme) => ({
@@ -21,81 +12,122 @@ const useStyles = makeStyles((theme) => ({
     marginTop: theme.spacing(2),
     marginBottom: theme.spacing(2),
   },
+  alert: {
+    textAlign: "center",
+  },
+  line: {
+    marginBottom: theme.spacing(2),
+  },
 }));
 
-export default function TokenWarning({
-  sourceChain,
-  tokenAddress,
+function WormholeWrappedWarning() {
+  const classes = useStyles();
+  return (
+    <Alert severity="info" variant="outlined" className={classes.alert}>
+      <Typography component="div" className={classes.line}>
+        The tokens you will receive are{" "}
+        <Box fontWeight={900} display="inline">
+          Wormhole Wrapped Tokens
+        </Box>{" "}
+        and will need to be exchanged for native assets.
+      </Typography>
+      <Typography component="div">
+        <Link
+          href={AVAILABLE_MARKETS_URL}
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          Click here to see available markets for wrapped tokens.
+        </Link>
+      </Typography>
+    </Alert>
+  );
+}
+
+function MultichainWarning({
   symbol,
+  targetChain,
 }: {
-  sourceChain: ChainId;
-  tokenAddress: string | undefined;
-  symbol: string | undefined;
+  symbol: string;
+  targetChain: ChainId;
 }) {
   const classes = useStyles();
-  const tokenConflictingNativeWarning = useMemo(
-    () =>
-      tokenAddress &&
-      ((sourceChain === CHAIN_ID_SOLANA &&
-        SOLANA_TOKENS_THAT_EXIST_ELSEWHERE.includes(tokenAddress)) ||
-        (sourceChain === CHAIN_ID_ETH &&
-          ETH_TOKENS_THAT_EXIST_ELSEWHERE.includes(getAddress(tokenAddress))))
-        ? `Bridging ${
-            symbol ? symbol : "the token"
-          } via Wormhole will not produce native ${
-            symbol ? symbol : "assets"
-          }. It will produce a wrapped version which might have no liquidity or utility on the target chain.`
-        : undefined,
-    [sourceChain, tokenAddress, symbol]
+  return (
+    <Alert severity="warning" variant="outlined" className={classes.alert}>
+      <Typography
+        variant="h6"
+        className={classes.line}
+      >{`You will not receive native ${symbol} on ${CHAINS_BY_ID[targetChain].name}`}</Typography>
+      <Typography
+        className={classes.line}
+      >{`To receive native ${symbol}, you will have to perform a swap with the wrapped tokens once you are done bridging.`}</Typography>
+      <Typography>
+        <Link
+          href={AVAILABLE_MARKETS_URL}
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          Click here to see available markets for wrapped tokens.
+        </Link>
+      </Typography>
+    </Alert>
   );
-  const marketsWarning = useMemo(() => {
-    let show = false;
-    if (sourceChain === CHAIN_ID_SOLANA && tokenAddress === WSOL_ADDRESS) {
-      show = true;
-    } else if (
-      sourceChain === CHAIN_ID_BSC &&
-      tokenAddress &&
-      BSC_MARKET_WARNINGS.includes(getAddress(tokenAddress))
-    ) {
-      show = true;
-    }
-    if (show) {
-      return `As of 10/13/2021, markets have not been established for ${
-        symbol ? "Wormhole-wrapped " + symbol : "this token"
-      }. Please verify this token will be useful on the target chain.`;
-    } else {
-      return null;
-    }
-  }, [sourceChain, tokenAddress, symbol]);
+}
 
-  const content = tokenConflictingNativeWarning ? (
-    <Alert severity="warning" variant="outlined">
-      {tokenConflictingNativeWarning}
-    </Alert>
-  ) : marketsWarning ? (
-    <Alert severity="warning" variant="outlined">
-      {marketsWarning}
-    </Alert>
-  ) : sourceChain === CHAIN_ID_ETH &&
-    tokenAddress &&
-    getAddress(tokenAddress) ===
-      getAddress("0xae7ab96520de3a18e5e111b5eaab095312d7fe84") ? ( // stETH (Lido)
-    <Alert severity="warning" variant="outlined">
+function RewardsWarning() {
+  const classes = useStyles();
+  return (
+    <Alert severity="warning" variant="outlined" className={classes.alert}>
       Lido stETH rewards can only be received on Ethereum. Use the value
       accruing wrapper token wstETH instead.
     </Alert>
-  ) : sourceChain === CHAIN_ID_ETH &&
-    tokenAddress &&
-    ETH_TOKENS_THAT_CAN_BE_SWAPPED_ON_SOLANA.includes(
-      getAddress(tokenAddress)
-    ) ? (
-    //TODO: will this be accurate with Terra support?
-    <Alert severity="info" variant="outlined">
-      Bridging {symbol ? symbol : "the token"} via Wormhole will not produce
-      native {symbol ? symbol : "assets"}. It will produce a wrapped version
-      which can be swapped using a stable swap protocol.
-    </Alert>
-  ) : null;
+  );
+}
 
-  return content ? <div className={classes.container}>{content}</div> : null;
+export default function TokenWarning({
+  sourceChain,
+  sourceAsset,
+  originChain,
+  targetChain,
+  targetAsset,
+}: {
+  sourceChain?: ChainId;
+  sourceAsset?: string;
+  originChain?: ChainId;
+  targetChain?: ChainId;
+  targetAsset?: string;
+}) {
+  if (
+    !(originChain && targetChain && targetAsset && sourceChain && sourceAsset)
+  ) {
+    return null;
+  }
+
+  const searchableAddress = isEVMChain(sourceChain)
+    ? sourceAsset.toLowerCase()
+    : sourceAsset;
+  const isWormholeWrapped = originChain !== targetChain;
+  const multichainSymbol =
+    MULTI_CHAIN_TOKENS[sourceChain]?.[searchableAddress] || undefined;
+  const isMultiChain = !!multichainSymbol;
+  const isRewardsToken =
+    searchableAddress === "0xae7ab96520de3a18e5e111b5eaab095312d7fe84" &&
+    sourceChain === CHAIN_ID_ETH;
+
+  const showMultiChainWarning = isMultiChain && isWormholeWrapped;
+  const showWrappedWarning = !isMultiChain && isWormholeWrapped; //Multichain warning is more important
+  const showRewardsWarning = isRewardsToken;
+
+  return (
+    <>
+      {showMultiChainWarning ? (
+        <MultichainWarning
+          symbol={multichainSymbol || "tokens"}
+          targetChain={targetChain}
+        />
+      ) : null}
+      {showWrappedWarning ? <WormholeWrappedWarning /> : null}
+      {showRewardsWarning ? <RewardsWarning /> : null}
+    </>
+  );
 }

+ 1 - 1
bridge_ui/src/hooks/useEvmMetadata.ts

@@ -57,7 +57,7 @@ function useEvmMetadata(
   addresses: string[],
   chainId: ChainId
 ): DataWrapper<Map<string, EvmMetadata>> {
-  const { isReady } = useIsWalletReady(chainId);
+  const { isReady } = useIsWalletReady(chainId, false);
   const { provider } = useEthereumProvider();
 
   const [isFetching, setIsFetching] = useState(false);

+ 119 - 75
bridge_ui/src/hooks/useFetchForeignAsset.ts

@@ -10,7 +10,7 @@ import {
 import { Connection } from "@solana/web3.js";
 import { LCDClient } from "@terra-money/terra.js";
 import { ethers } from "ethers";
-import { useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
 import { useEthereumProvider } from "../contexts/EthereumProviderContext";
 import { DataWrapper } from "../store/helpers";
 import {
@@ -35,112 +35,156 @@ function useFetchForeignAsset(
   foreignChain: ChainId
 ): DataWrapper<ForeignAssetInfo> {
   const { provider, chainId: evmChainId } = useEthereumProvider();
-  const { isReady, statusMessage } = useIsWalletReady(foreignChain);
+  const { isReady } = useIsWalletReady(foreignChain, false);
   const correctEvmNetwork = getEvmChainId(foreignChain);
   const hasCorrectEvmNetwork = evmChainId === correctEvmNetwork;
 
   const [assetAddress, setAssetAddress] = useState<string | null>(null);
-  const [doesExist, setDoesExist] = useState(false);
+  const [doesExist, setDoesExist] = useState<boolean | null>(null);
   const [error, setError] = useState("");
   const [isLoading, setIsLoading] = useState(false);
-  const originAssetHex = useMemo(
-    () => nativeToHexString(originAsset, originChain),
-    [originAsset, originChain]
-  );
+  const originAssetHex = useMemo(() => {
+    try {
+      return nativeToHexString(originAsset, originChain);
+    } catch (e) {
+      return null;
+    }
+  }, [originAsset, originChain]);
+  const [previousArgs, setPreviousArgs] = useState<{
+    originChain: ChainId;
+    originAsset: string;
+    foreignChain: ChainId;
+  } | null>(null);
+  const argsEqual =
+    !!previousArgs &&
+    previousArgs.originChain === originChain &&
+    previousArgs.originAsset === originAsset &&
+    previousArgs.foreignChain === foreignChain;
+  const setArgs = useCallback(() => {
+    setPreviousArgs({ foreignChain, originChain, originAsset });
+  }, [foreignChain, originChain, originAsset]);
 
   const argumentError = useMemo(
     () =>
+      !originChain ||
+      !originAsset ||
       !foreignChain ||
       !originAssetHex ||
       foreignChain === originChain ||
       (isEVMChain(foreignChain) && !isReady) ||
-      (isEVMChain(foreignChain) && !hasCorrectEvmNetwork),
-    [isReady, foreignChain, originChain, hasCorrectEvmNetwork, originAssetHex]
+      (isEVMChain(foreignChain) && !hasCorrectEvmNetwork) ||
+      argsEqual,
+    [
+      isReady,
+      foreignChain,
+      originAsset,
+      originChain,
+      hasCorrectEvmNetwork,
+      originAssetHex,
+      argsEqual,
+    ]
   );
 
   useEffect(() => {
+    if (!argsEqual) {
+      setAssetAddress(null);
+      setError("");
+      setDoesExist(null);
+      setPreviousArgs(null);
+    }
     if (argumentError || !originAssetHex) {
       return;
     }
 
     let cancelled = false;
     setIsLoading(true);
-    setAssetAddress(null);
-    setError("");
-    setDoesExist(false);
-    const getterFunc: () => Promise<string | null> = isEVMChain(foreignChain)
-      ? () =>
-          getForeignAssetEth(
-            getTokenBridgeAddressForChain(foreignChain),
-            provider as any, //why does this typecheck work elsewhere?
-            originChain,
-            hexToUint8Array(originAssetHex)
-          )
-      : foreignChain === CHAIN_ID_TERRA
-      ? () => {
-          const lcd = new LCDClient(TERRA_HOST);
-          return getForeignAssetTerra(
-            TERRA_TOKEN_BRIDGE_ADDRESS,
-            lcd,
-            originChain,
-            hexToUint8Array(originAssetHex)
-          );
-        }
-      : () => {
-          const connection = new Connection(SOLANA_HOST, "confirmed");
-          return getForeignAssetSolana(
-            connection,
-            SOL_TOKEN_BRIDGE_ADDRESS,
-            originChain,
-            hexToUint8Array(originAssetHex)
-          );
-        };
-
-    const promise = getterFunc();
-
-    promise
-      .then((result) => {
-        if (!cancelled) {
-          if (
-            result &&
-            !(
-              isEVMChain(foreignChain) &&
-              result === ethers.constants.AddressZero
+    try {
+      const getterFunc: () => Promise<string | null> = isEVMChain(foreignChain)
+        ? () =>
+            getForeignAssetEth(
+              getTokenBridgeAddressForChain(foreignChain),
+              provider as any, //why does this typecheck work elsewhere?
+              originChain,
+              hexToUint8Array(originAssetHex)
             )
-          ) {
-            setDoesExist(true);
-            setIsLoading(false);
-            setAssetAddress(result);
-          } else {
-            setDoesExist(false);
+        : foreignChain === CHAIN_ID_TERRA
+        ? () => {
+            const lcd = new LCDClient(TERRA_HOST);
+            return getForeignAssetTerra(
+              TERRA_TOKEN_BRIDGE_ADDRESS,
+              lcd,
+              originChain,
+              hexToUint8Array(originAssetHex)
+            );
+          }
+        : () => {
+            const connection = new Connection(SOLANA_HOST, "confirmed");
+            return getForeignAssetSolana(
+              connection,
+              SOL_TOKEN_BRIDGE_ADDRESS,
+              originChain,
+              hexToUint8Array(originAssetHex)
+            );
+          };
+
+      getterFunc()
+        .then((result) => {
+          if (!cancelled) {
+            if (
+              result &&
+              !(
+                isEVMChain(foreignChain) &&
+                result === ethers.constants.AddressZero
+              )
+            ) {
+              setArgs();
+              setDoesExist(true);
+              setIsLoading(false);
+              setAssetAddress(result);
+            } else {
+              setArgs();
+              setDoesExist(false);
+              setIsLoading(false);
+              setAssetAddress(null);
+            }
+          }
+        })
+        .catch((e) => {
+          if (!cancelled) {
+            setError("Could not retrieve the foreign asset.");
             setIsLoading(false);
-            setAssetAddress(null);
           }
-        }
-      })
-      .catch((e) => {
-        if (!cancelled) {
-          setError("Could not retrieve the foreign asset.");
-          setIsLoading(false);
-        }
-      });
-  }, [argumentError, foreignChain, originAssetHex, originChain, provider]);
+        });
+    } catch (e) {
+      //This catch mostly just detects poorly formatted addresses
+      if (!cancelled) {
+        setError("Could not retrieve the foreign asset.");
+        setIsLoading(false);
+      }
+    }
+  }, [
+    argumentError,
+    foreignChain,
+    originAssetHex,
+    originChain,
+    provider,
+    setArgs,
+    argsEqual,
+  ]);
 
   const compoundError = useMemo(() => {
-    return error
-      ? error
-      : !isReady
-      ? statusMessage
-      : argumentError
-      ? "Invalid arguments."
-      : "";
-  }, [error, isReady, statusMessage, argumentError]);
+    return error ? error : "";
+  }, [error]); //now swallows wallet errors
 
   const output: DataWrapper<ForeignAssetInfo> = useMemo(
     () => ({
       error: compoundError,
       isFetching: isLoading,
-      data: { address: assetAddress, doesExist },
+      data:
+        (assetAddress !== null && assetAddress !== undefined) ||
+        (doesExist !== null && doesExist !== undefined)
+          ? { address: assetAddress, doesExist: !!doesExist }
+          : null,
       receivedAt: null,
     }),
     [compoundError, isLoading, assetAddress, doesExist]

+ 46 - 2
bridge_ui/src/hooks/useFetchTargetAsset.ts

@@ -1,4 +1,5 @@
 import {
+  ChainId,
   CHAIN_ID_SOLANA,
   CHAIN_ID_TERRA,
   getForeignAssetEth,
@@ -16,7 +17,7 @@ import { arrayify } from "@ethersproject/bytes";
 import { Connection } from "@solana/web3.js";
 import { LCDClient } from "@terra-money/terra.js";
 import { ethers } from "ethers";
-import { useEffect } from "react";
+import { useCallback, useEffect, useState } from "react";
 import { useDispatch, useSelector } from "react-redux";
 import { useEthereumProvider } from "../contexts/EthereumProviderContext";
 import {
@@ -76,10 +77,47 @@ function useFetchTargetAsset(nft?: boolean) {
   const isRecovery = useSelector(
     nft ? selectNFTIsRecovery : selectTransferIsRecovery
   );
+  const [lastSuccessfulArgs, setLastSuccessfulArgs] = useState<{
+    isSourceAssetWormholeWrapped: boolean | undefined;
+    originChain: ChainId | undefined;
+    originAsset: string | undefined;
+    targetChain: ChainId;
+    nft?: boolean;
+    tokenId?: string;
+  } | null>(null);
+  const argsMatchLastSuccess =
+    !!lastSuccessfulArgs &&
+    lastSuccessfulArgs.isSourceAssetWormholeWrapped ===
+      isSourceAssetWormholeWrapped &&
+    lastSuccessfulArgs.originChain === originChain &&
+    lastSuccessfulArgs.originAsset === originAsset &&
+    lastSuccessfulArgs.targetChain === targetChain &&
+    lastSuccessfulArgs.nft === nft &&
+    lastSuccessfulArgs.tokenId === tokenId;
+  const setArgs = useCallback(
+    () =>
+      setLastSuccessfulArgs({
+        isSourceAssetWormholeWrapped,
+        originChain,
+        originAsset,
+        targetChain,
+        nft,
+        tokenId,
+      }),
+    [
+      isSourceAssetWormholeWrapped,
+      originChain,
+      originAsset,
+      targetChain,
+      nft,
+      tokenId,
+    ]
+  );
   useEffect(() => {
-    if (isRecovery) {
+    if (isRecovery || argsMatchLastSuccess) {
       return;
     }
+    setLastSuccessfulArgs(null);
     if (isSourceAssetWormholeWrapped && originChain === targetChain) {
       dispatch(
         setTargetAsset(
@@ -89,6 +127,7 @@ function useFetchTargetAsset(nft?: boolean) {
           })
         )
       );
+      setArgs();
       return;
     }
     let cancelled = false;
@@ -124,6 +163,7 @@ function useFetchTargetAsset(nft?: boolean) {
                 })
               )
             );
+            setArgs();
           }
         } catch (e) {
           if (!cancelled) {
@@ -160,6 +200,7 @@ function useFetchTargetAsset(nft?: boolean) {
                 receiveDataWrapper({ doesExist: !!asset, address: asset })
               )
             );
+            setArgs();
           }
         } catch (e) {
           if (!cancelled) {
@@ -189,6 +230,7 @@ function useFetchTargetAsset(nft?: boolean) {
                 receiveDataWrapper({ doesExist: !!asset, address: asset })
               )
             );
+            setArgs();
           }
         } catch (e) {
           if (!cancelled) {
@@ -218,6 +260,8 @@ function useFetchTargetAsset(nft?: boolean) {
     setTargetAsset,
     tokenId,
     hasCorrectEvmNetwork,
+    argsMatchLastSuccess,
+    setArgs,
   ]);
 }
 

+ 52 - 13
bridge_ui/src/hooks/useIsWalletReady.ts

@@ -5,7 +5,7 @@ import {
 } from "@certusone/wormhole-sdk";
 import { hexlify, hexStripZeros } from "@ethersproject/bytes";
 import { useConnectedWallet } from "@terra-money/wallet-provider";
-import { useMemo } from "react";
+import { useCallback, useMemo } from "react";
 import { useEthereumProvider } from "../contexts/EthereumProviderContext";
 import { useSolanaWallet } from "../contexts/SolanaWalletContext";
 import { CLUSTER, getEvmChainId } from "../utils/consts";
@@ -14,18 +14,25 @@ import { isEVMChain } from "../utils/ethereum";
 const createWalletStatus = (
   isReady: boolean,
   statusMessage: string = "",
+  forceNetworkSwitch: () => void,
   walletAddress?: string
 ) => ({
   isReady,
   statusMessage,
+  forceNetworkSwitch,
   walletAddress,
 });
 
-function useIsWalletReady(chainId: ChainId): {
+function useIsWalletReady(
+  chainId: ChainId,
+  enableNetworkAutoswitch: boolean = true
+): {
   isReady: boolean;
   statusMessage: string;
   walletAddress?: string;
+  forceNetworkSwitch: () => void;
 } {
+  const autoSwitch = enableNetworkAutoswitch;
   const solanaWallet = useSolanaWallet();
   const solPK = solanaWallet?.publicKey;
   const terraWallet = useConnectedWallet();
@@ -39,6 +46,19 @@ function useIsWalletReady(chainId: ChainId): {
   const correctEvmNetwork = getEvmChainId(chainId);
   const hasCorrectEvmNetwork = evmChainId === correctEvmNetwork;
 
+  const forceNetworkSwitch = useCallback(() => {
+    if (provider && correctEvmNetwork) {
+      if (!isEVMChain(chainId)) {
+        return;
+      }
+      try {
+        provider.send("wallet_switchEthereumChain", [
+          { chainId: hexStripZeros(hexlify(correctEvmNetwork)) },
+        ]);
+      } catch (e) {}
+    }
+  }, [provider, correctEvmNetwork, chainId]);
+
   return useMemo(() => {
     if (
       chainId === CHAIN_ID_TERRA &&
@@ -46,33 +66,52 @@ function useIsWalletReady(chainId: ChainId): {
       terraWallet?.walletAddress
     ) {
       // TODO: terraWallet does not update on wallet changes
-      return createWalletStatus(true, undefined, terraWallet.walletAddress);
+      return createWalletStatus(
+        true,
+        undefined,
+        forceNetworkSwitch,
+        terraWallet.walletAddress
+      );
     }
     if (chainId === CHAIN_ID_SOLANA && solPK) {
-      return createWalletStatus(true, undefined, solPK.toString());
+      return createWalletStatus(
+        true,
+        undefined,
+        forceNetworkSwitch,
+        solPK.toString()
+      );
     }
     if (isEVMChain(chainId) && hasEthInfo && signerAddress) {
       if (hasCorrectEvmNetwork) {
-        return createWalletStatus(true, undefined, signerAddress);
+        return createWalletStatus(
+          true,
+          undefined,
+          forceNetworkSwitch,
+          signerAddress
+        );
       } else {
-        if (provider && correctEvmNetwork) {
-          try {
-            provider.send("wallet_switchEthereumChain", [
-              { chainId: hexStripZeros(hexlify(correctEvmNetwork)) },
-            ]);
-          } catch (e) {}
+        if (provider && correctEvmNetwork && autoSwitch) {
+          forceNetworkSwitch();
         }
         return createWalletStatus(
           false,
           `Wallet is not connected to ${CLUSTER}. Expected Chain ID: ${correctEvmNetwork}`,
+          forceNetworkSwitch,
           undefined
         );
       }
     }
-    //TODO bsc
-    return createWalletStatus(false, "Wallet not connected");
+
+    return createWalletStatus(
+      false,
+      "Wallet not connected",
+      forceNetworkSwitch,
+      undefined
+    );
   }, [
     chainId,
+    autoSwitch,
+    forceNetworkSwitch,
     hasTerraWallet,
     solPK,
     hasEthInfo,

+ 19 - 14
bridge_ui/src/hooks/useMetadata.ts

@@ -11,13 +11,14 @@ import { Metadata } from "../utils/metaplex";
 import useEvmMetadata, { EvmMetadata } from "./useEvmMetadata";
 import useMetaplexData from "./useMetaplexData";
 import useSolanaTokenMap from "./useSolanaTokenMap";
+import useTerraMetadata, { TerraMetadata } from "./useTerraMetadata";
 import useTerraTokenMap, { TerraTokenMap } from "./useTerraTokenMap";
 
 export type GenericMetadata = {
   symbol?: string;
   logo?: string;
   tokenName?: string;
-  decimals?: number;
+  //decimals?: number;
   //TODO more items
   raw?: any;
 };
@@ -36,9 +37,9 @@ const constructSolanaMetadata = (
     const tokenInfo = solanaTokenMap.data?.find((x) => x.address === address);
     //Both this and the token picker, at present, give priority to the tokenmap
     const obj = {
-      symbol: tokenInfo?.symbol || metaplex?.data.symbol || undefined,
-      logo: tokenInfo?.logoURI || metaplex?.data.uri || undefined, //TODO is URI on metaplex actually the logo? If not, where is it?
-      tokenName: tokenInfo?.name || metaplex?.data.name || undefined,
+      symbol: metaplex?.data?.symbol || tokenInfo?.symbol || undefined,
+      logo: tokenInfo?.logoURI || undefined, //TODO is URI on metaplex actually the logo? If not, where is it?
+      tokenName: metaplex?.data?.name || tokenInfo?.name || undefined,
       decimals: tokenInfo?.decimals || undefined, //TODO decimals are actually on the mint, not the metaplex account.
       raw: metaplex,
     };
@@ -55,19 +56,21 @@ const constructSolanaMetadata = (
 
 const constructTerraMetadata = (
   addresses: string[],
-  tokenMap: DataWrapper<TerraTokenMap>
+  tokenMap: DataWrapper<TerraTokenMap>,
+  terraMetadata: DataWrapper<Map<string, TerraMetadata>>
 ) => {
-  const isFetching = tokenMap.isFetching;
-  const error = tokenMap.error;
-  const receivedAt = tokenMap.receivedAt;
+  const isFetching = tokenMap.isFetching || terraMetadata.isFetching;
+  const error = tokenMap.error || terraMetadata.error;
+  const receivedAt = tokenMap.receivedAt && terraMetadata.receivedAt;
   const data = new Map<string, GenericMetadata>();
   addresses.forEach((address) => {
-    const meta = tokenMap.data?.mainnet[address];
+    const metadata = terraMetadata.data?.get(address);
+    const tokenInfo = tokenMap.data?.mainnet[address];
     const obj = {
-      symbol: meta?.symbol || undefined,
-      logo: meta?.icon || undefined,
-      tokenName: meta?.token || undefined,
-      decimals: undefined, //TODO find a way to get this on terra
+      symbol: metadata?.symbol || tokenInfo?.symbol || undefined,
+      logo: metadata?.logo || tokenInfo?.icon || undefined,
+      tokenName: metadata?.tokenName || tokenInfo?.token || undefined,
+      decimals: metadata?.decimals || undefined,
     };
     data.set(address, obj);
   });
@@ -125,6 +128,7 @@ export default function useMetadata(
   }, [chainId, addresses]);
 
   const metaplexData = useMetaplexData(solanaAddresses);
+  const terraMetadata = useTerraMetadata(terraAddresses);
   const ethMetadata = useEvmMetadata(ethereumAddresses, chainId);
 
   const output: DataWrapper<Map<string, GenericMetadata>> = useMemo(
@@ -134,7 +138,7 @@ export default function useMetadata(
         : isEVMChain(chainId)
         ? constructEthMetadata(ethereumAddresses, ethMetadata)
         : chainId === CHAIN_ID_TERRA
-        ? constructTerraMetadata(terraAddresses, terraTokenMap)
+        ? constructTerraMetadata(terraAddresses, terraTokenMap, terraMetadata)
         : getEmptyDataWrapper(),
     [
       chainId,
@@ -144,6 +148,7 @@ export default function useMetadata(
       ethereumAddresses,
       ethMetadata,
       terraAddresses,
+      terraMetadata,
       terraTokenMap,
     ]
   );

+ 50 - 2
bridge_ui/src/hooks/useNFTTVL.ts

@@ -2,6 +2,7 @@ import {
   ChainId,
   CHAIN_ID_BSC,
   CHAIN_ID_ETH,
+  CHAIN_ID_POLYGON,
   CHAIN_ID_SOLANA,
 } from "@certusone/wormhole-sdk";
 import { TOKEN_PROGRAM_ID } from "@solana/spl-token";
@@ -20,6 +21,7 @@ import {
   COVALENT_GET_TOKENS_URL,
   ETH_NFT_BRIDGE_ADDRESS,
   getNFTBridgeAddressForChain,
+  POLYGON_NFT_BRIDGE_ADDRESS,
   SOLANA_HOST,
   SOL_NFT_CUSTODY_ADDRESS,
 } from "../utils/consts";
@@ -122,6 +124,11 @@ const useNFTTVL = (): DataWrapper<NFTTVL[]> => {
   const [bscCovalentIsLoading, setBscCovalentIsLoading] = useState(false);
   const [bscCovalentError, setBscCovalentError] = useState("");
 
+  const [polygonCovalentData, setPolygonCovalentData] = useState(undefined);
+  const [polygonCovalentIsLoading, setPolygonCovalentIsLoading] =
+    useState(false);
+  const [polygonCovalentError, setPolygonCovalentError] = useState("");
+
   const [solanaCustodyTokens, setSolanaCustodyTokens] = useState<
     { pubkey: PublicKey; account: AccountInfo<ParsedAccountData> }[] | undefined
   >(undefined);
@@ -154,6 +161,11 @@ const useNFTTVL = (): DataWrapper<NFTTVL[]> => {
     [bscCovalentData]
   );
 
+  const polygonTVL = useMemo(
+    () => calcEvmTVL(polygonCovalentData, CHAIN_ID_POLYGON),
+    [polygonCovalentData]
+  );
+
   useEffect(() => {
     let cancelled = false;
     setEthCovalentIsLoading(true);
@@ -210,6 +222,34 @@ const useNFTTVL = (): DataWrapper<NFTTVL[]> => {
       );
   }, []);
 
+  useEffect(() => {
+    let cancelled = false;
+    setPolygonCovalentIsLoading(true);
+    axios
+      .get(
+        COVALENT_GET_TOKENS_URL(
+          CHAIN_ID_POLYGON,
+          POLYGON_NFT_BRIDGE_ADDRESS,
+          true,
+          false
+        )
+      )
+      .then(
+        (results) => {
+          if (!cancelled) {
+            setPolygonCovalentData(results.data);
+            setPolygonCovalentIsLoading(false);
+          }
+        },
+        (error) => {
+          if (!cancelled) {
+            setPolygonCovalentError("Unable to retrieve Polygon TVL.");
+            setPolygonCovalentIsLoading(false);
+          }
+        }
+      );
+  }, []);
+
   useEffect(() => {
     let cancelled = false;
     const connection = new Connection(SOLANA_HOST, "confirmed");
@@ -237,14 +277,19 @@ const useNFTTVL = (): DataWrapper<NFTTVL[]> => {
   }, []);
 
   return useMemo(() => {
-    const tvlArray = [...ethTVL, ...bscTVL, ...solanaTVL];
+    const tvlArray = [...ethTVL, ...bscTVL, ...polygonTVL, ...solanaTVL];
 
     return {
       isFetching:
         ethCovalentIsLoading ||
         bscCovalentIsLoading ||
+        polygonCovalentIsLoading ||
         solanaCustodyTokensLoading,
-      error: ethCovalentError || bscCovalentError || solanaCustodyTokensError,
+      error:
+        ethCovalentError ||
+        bscCovalentError ||
+        polygonCovalentError ||
+        solanaCustodyTokensError,
       receivedAt: null,
       data: tvlArray,
     };
@@ -253,6 +298,9 @@ const useNFTTVL = (): DataWrapper<NFTTVL[]> => {
     ethCovalentIsLoading,
     bscCovalentError,
     bscCovalentIsLoading,
+    polygonTVL,
+    polygonCovalentError,
+    polygonCovalentIsLoading,
     ethTVL,
     bscTVL,
     solanaTVL,

+ 254 - 0
bridge_ui/src/hooks/useOriginalAsset.ts

@@ -0,0 +1,254 @@
+import {
+  ChainId,
+  CHAIN_ID_SOLANA,
+  CHAIN_ID_TERRA,
+  getOriginalAssetEth,
+  getOriginalAssetSol,
+  getOriginalAssetTerra,
+  hexToNativeString,
+  uint8ArrayToHex,
+  uint8ArrayToNative,
+} from "@certusone/wormhole-sdk";
+import {
+  getOriginalAssetEth as getOriginalAssetEthNFT,
+  getOriginalAssetSol as getOriginalAssetSolNFT,
+  WormholeWrappedNFTInfo,
+} from "@certusone/wormhole-sdk/lib/nft_bridge";
+import { Web3Provider } from "@certusone/wormhole-sdk/node_modules/@ethersproject/providers";
+import { ethers } from "@certusone/wormhole-sdk/node_modules/ethers";
+import { Connection } from "@solana/web3.js";
+import { LCDClient } from "@terra-money/terra.js";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+  Provider,
+  useEthereumProvider,
+} from "../contexts/EthereumProviderContext";
+import { DataWrapper } from "../store/helpers";
+import {
+  getNFTBridgeAddressForChain,
+  getTokenBridgeAddressForChain,
+  SOLANA_HOST,
+  SOLANA_SYSTEM_PROGRAM_ADDRESS,
+  SOL_NFT_BRIDGE_ADDRESS,
+  SOL_TOKEN_BRIDGE_ADDRESS,
+  TERRA_HOST,
+} from "../utils/consts";
+import { isEVMChain } from "../utils/ethereum";
+import useIsWalletReady from "./useIsWalletReady";
+
+export type OriginalAssetInfo = {
+  originChain: ChainId | null;
+  originAddress: string | null;
+  originTokenId: string | null;
+};
+
+export async function getOriginalAssetToken(
+  foreignChain: ChainId,
+  foreignNativeStringAddress: string,
+  provider?: Web3Provider
+) {
+  let promise = null;
+  try {
+    if (isEVMChain(foreignChain) && provider) {
+      promise = await getOriginalAssetEth(
+        getTokenBridgeAddressForChain(foreignChain),
+        provider,
+        foreignNativeStringAddress,
+        foreignChain
+      );
+    } else if (foreignChain === CHAIN_ID_SOLANA) {
+      const connection = new Connection(SOLANA_HOST, "confirmed");
+      promise = await getOriginalAssetSol(
+        connection,
+        SOL_TOKEN_BRIDGE_ADDRESS,
+        foreignNativeStringAddress
+      );
+    } else if (foreignChain === CHAIN_ID_TERRA) {
+      const lcd = new LCDClient(TERRA_HOST);
+      promise = await getOriginalAssetTerra(lcd, foreignNativeStringAddress);
+    }
+  } catch (e) {
+    promise = Promise.reject("Invalid foreign arguments.");
+  }
+  if (!promise) {
+    promise = Promise.reject("Invalid foreign arguments.");
+  }
+  return promise;
+}
+
+export async function getOriginalAssetNFT(
+  foreignChain: ChainId,
+  foreignNativeStringAddress: string,
+  tokenId?: string,
+  provider?: Provider
+) {
+  let promise = null;
+  try {
+    if (isEVMChain(foreignChain) && provider && tokenId) {
+      promise = getOriginalAssetEthNFT(
+        getNFTBridgeAddressForChain(foreignChain),
+        provider,
+        foreignNativeStringAddress,
+        tokenId,
+        foreignChain
+      );
+    } else if (foreignChain === CHAIN_ID_SOLANA) {
+      const connection = new Connection(SOLANA_HOST, "confirmed");
+      promise = getOriginalAssetSolNFT(
+        connection,
+        SOL_NFT_BRIDGE_ADDRESS,
+        foreignNativeStringAddress
+      );
+    }
+  } catch (e) {
+    promise = Promise.reject("Invalid foreign arguments.");
+  }
+  if (!promise) {
+    promise = Promise.reject("Invalid foreign arguments.");
+  }
+  return promise;
+}
+
+//TODO refactor useCheckIfWormholeWrapped to use this function, and probably move to SDK
+export async function getOriginalAsset(
+  foreignChain: ChainId,
+  foreignNativeStringAddress: string,
+  nft: boolean,
+  tokenId?: string,
+  provider?: Provider
+): Promise<WormholeWrappedNFTInfo> {
+  const result = nft
+    ? await getOriginalAssetNFT(
+        foreignChain,
+        foreignNativeStringAddress,
+        tokenId,
+        provider
+      )
+    : await getOriginalAssetToken(
+        foreignChain,
+        foreignNativeStringAddress,
+        provider
+      );
+
+  if (
+    isEVMChain(result.chainId) &&
+    uint8ArrayToNative(result.assetAddress, result.chainId) ===
+      ethers.constants.AddressZero
+  ) {
+    throw new Error("Unable to find address.");
+  }
+  if (
+    result.chainId === CHAIN_ID_SOLANA &&
+    uint8ArrayToNative(result.assetAddress, result.chainId) ===
+      SOLANA_SYSTEM_PROGRAM_ADDRESS
+  ) {
+    throw new Error("Unable to find address.");
+  }
+
+  return result;
+}
+
+//This potentially returns the same chain as the foreign chain, in the case where the asset is native
+function useOriginalAsset(
+  foreignChain: ChainId,
+  foreignAddress: string,
+  nft: boolean,
+  tokenId?: string
+): DataWrapper<OriginalAssetInfo> {
+  const { provider } = useEthereumProvider();
+  const { isReady } = useIsWalletReady(foreignChain, false);
+  const [originAddress, setOriginAddress] = useState<string | null>(null);
+  const [originTokenId, setOriginTokenId] = useState<string | null>(null);
+  const [originChain, setOriginChain] = useState<ChainId | null>(null);
+  const [error, setError] = useState("");
+  const [isLoading, setIsLoading] = useState(false);
+  const [previousArgs, setPreviousArgs] = useState<{
+    foreignChain: ChainId;
+    foreignAddress: string;
+    nft: boolean;
+    tokenId?: string;
+  } | null>(null);
+  const argsEqual =
+    !!previousArgs &&
+    previousArgs.foreignChain === foreignChain &&
+    previousArgs.foreignAddress === foreignAddress &&
+    previousArgs.nft === nft &&
+    previousArgs.tokenId === tokenId;
+  const setArgs = useCallback(
+    () => setPreviousArgs({ foreignChain, foreignAddress, nft, tokenId }),
+    [foreignChain, foreignAddress, nft, tokenId]
+  );
+
+  const argumentError = useMemo(
+    () =>
+      !foreignChain ||
+      !foreignAddress ||
+      (isEVMChain(foreignChain) && !isReady) ||
+      (isEVMChain(foreignChain) && nft && !tokenId) ||
+      argsEqual,
+    [isReady, nft, tokenId, argsEqual, foreignChain, foreignAddress]
+  );
+
+  useEffect(() => {
+    if (!argsEqual) {
+      setError("");
+      setOriginAddress(null);
+      setOriginTokenId(null);
+      setOriginChain(null);
+      setPreviousArgs(null);
+    }
+    if (argumentError) {
+      return;
+    }
+    let cancelled = false;
+    setIsLoading(true);
+
+    getOriginalAsset(foreignChain, foreignAddress, nft, tokenId, provider)
+      .then((result) => {
+        if (!cancelled) {
+          setIsLoading(false);
+          setArgs();
+          setOriginAddress(
+            hexToNativeString(
+              uint8ArrayToHex(result.assetAddress),
+              result.chainId
+            ) || null
+          );
+          setOriginTokenId(result.tokenId || null);
+          setOriginChain(result.chainId);
+        }
+      })
+      .catch((e) => {
+        if (!cancelled) {
+          setIsLoading(false);
+          setError("Unable to determine original asset.");
+        }
+      });
+  }, [
+    foreignChain,
+    foreignAddress,
+    nft,
+    provider,
+    setArgs,
+    argumentError,
+    tokenId,
+    argsEqual,
+  ]);
+
+  const output: DataWrapper<OriginalAssetInfo> = useMemo(
+    () => ({
+      error: error,
+      isFetching: isLoading,
+      data:
+        originChain || originAddress || originTokenId
+          ? { originChain, originAddress, originTokenId }
+          : null,
+      receivedAt: null,
+    }),
+    [isLoading, originAddress, originChain, originTokenId, error]
+  );
+
+  return output;
+}
+
+export default useOriginalAsset;

+ 63 - 4
bridge_ui/src/hooks/useTVL.ts

@@ -2,6 +2,7 @@ import {
   ChainId,
   CHAIN_ID_BSC,
   CHAIN_ID_ETH,
+  CHAIN_ID_POLYGON,
   CHAIN_ID_SOLANA,
   CHAIN_ID_TERRA,
 } from "@certusone/wormhole-sdk";
@@ -22,6 +23,7 @@ import {
   CHAINS_BY_ID,
   COVALENT_GET_TOKENS_URL,
   ETH_TOKEN_BRIDGE_ADDRESS,
+  POLYGON_TOKEN_BRIDGE_ADDRESS,
   SOLANA_HOST,
   SOL_CUSTODY_ADDRESS,
   TERRA_SWAPRATE_URL,
@@ -50,6 +52,10 @@ export type TVL = {
   decimals?: number;
 };
 
+const BAD_PRICES_BY_CHAIN = {
+  [CHAIN_ID_BSC]: ["0x04132bf45511d03a58afd4f1d36a29d229ccc574"],
+};
+
 const calcEvmTVL = (covalentReport: any, chainId: ChainId): TVL[] => {
   const output: TVL[] = [];
   if (!covalentReport?.data?.items?.length) {
@@ -58,13 +64,16 @@ const calcEvmTVL = (covalentReport: any, chainId: ChainId): TVL[] => {
 
   covalentReport.data.items.forEach((item: any) => {
     if (item.balance > 0 && item.contract_address) {
+      const hasUnreliablePrice = BAD_PRICES_BY_CHAIN[chainId]?.includes(
+        item.contract_address
+      );
       output.push({
         logo: item.logo_url || undefined,
         symbol: item.contract_ticker_symbol || undefined,
         name: item.contract_name || undefined,
         amount: formatUnits(item.balance, item.contract_decimals),
-        totalValue: item.quote,
-        quotePrice: item.quote_rate,
+        totalValue: hasUnreliablePrice ? 0 : item.quote,
+        quotePrice: hasUnreliablePrice ? 0 : item.quote_rate,
         assetAddress: item.contract_address,
         originChainId: chainId,
         originChain: CHAINS_BY_ID[chainId].name,
@@ -276,6 +285,11 @@ const useTVL = (): DataWrapper<TVL[]> => {
   const [bscCovalentIsLoading, setBscCovalentIsLoading] = useState(false);
   const [bscCovalentError, setBscCovalentError] = useState("");
 
+  const [polygonCovalentData, setPolygonCovalentData] = useState(undefined);
+  const [polygonCovalentIsLoading, setPolygonCovalentIsLoading] =
+    useState(false);
+  const [polygonCovalentError, setPolygonCovalentError] = useState("");
+
   const [solanaCustodyTokens, setSolanaCustodyTokens] = useState<
     { pubkey: PublicKey; account: AccountInfo<ParsedAccountData> }[] | undefined
   >(undefined);
@@ -311,6 +325,10 @@ const useTVL = (): DataWrapper<TVL[]> => {
     () => calcEvmTVL(bscCovalentData, CHAIN_ID_BSC),
     [bscCovalentData]
   );
+  const polygonTVL = useMemo(
+    () => calcEvmTVL(polygonCovalentData, CHAIN_ID_POLYGON),
+    [polygonCovalentData]
+  );
 
   useEffect(() => {
     let cancelled = false;
@@ -358,6 +376,33 @@ const useTVL = (): DataWrapper<TVL[]> => {
       );
   }, []);
 
+  useEffect(() => {
+    let cancelled = false;
+    setPolygonCovalentIsLoading(true);
+    axios
+      .get(
+        COVALENT_GET_TOKENS_URL(
+          CHAIN_ID_POLYGON,
+          POLYGON_TOKEN_BRIDGE_ADDRESS,
+          false
+        )
+      )
+      .then(
+        (results) => {
+          if (!cancelled) {
+            setPolygonCovalentData(results.data);
+            setPolygonCovalentIsLoading(false);
+          }
+        },
+        (error) => {
+          if (!cancelled) {
+            setPolygonCovalentError("Unable to retrieve Polygon TVL.");
+            setPolygonCovalentIsLoading(false);
+          }
+        }
+      );
+  }, []);
+
   useEffect(() => {
     let cancelled = false;
     const connection = new Connection(SOLANA_HOST, "confirmed");
@@ -385,15 +430,26 @@ const useTVL = (): DataWrapper<TVL[]> => {
   }, []);
 
   return useMemo(() => {
-    const tvlArray = [...ethTVL, ...bscTVL, ...solanaTVL, ...terraTVL];
+    const tvlArray = [
+      ...ethTVL,
+      ...bscTVL,
+      ...polygonTVL,
+      ...solanaTVL,
+      ...terraTVL,
+    ];
 
     return {
       isFetching:
         ethCovalentIsLoading ||
         bscCovalentIsLoading ||
+        polygonCovalentIsLoading ||
         solanaCustodyTokensLoading ||
         isTerraLoading,
-      error: ethCovalentError || bscCovalentError || solanaCustodyTokensError,
+      error:
+        ethCovalentError ||
+        bscCovalentError ||
+        polygonCovalentError ||
+        solanaCustodyTokensError,
       receivedAt: null,
       data: tvlArray,
     };
@@ -402,6 +458,9 @@ const useTVL = (): DataWrapper<TVL[]> => {
     ethCovalentIsLoading,
     bscCovalentError,
     bscCovalentIsLoading,
+    polygonCovalentError,
+    polygonCovalentIsLoading,
+    polygonTVL,
     ethTVL,
     bscTVL,
     solanaTVL,

+ 86 - 0
bridge_ui/src/hooks/useTerraMetadata.ts

@@ -0,0 +1,86 @@
+import { LCDClient } from "@terra-money/terra.js";
+import { useLayoutEffect, useMemo, useState } from "react";
+import { DataWrapper } from "../store/helpers";
+import { TERRA_HOST } from "../utils/consts";
+
+export type TerraMetadata = {
+  symbol?: string;
+  logo?: string;
+  tokenName?: string;
+  decimals?: number;
+};
+
+const fetchSingleMetadata = async (address: string, lcd: LCDClient) =>
+  lcd.wasm
+    .contractQuery(address, {
+      token_info: {},
+    })
+    .then(
+      ({ symbol, name: tokenName, decimals }: any) =>
+        ({
+          symbol,
+          tokenName,
+          decimals,
+        } as TerraMetadata)
+    );
+
+const fetchTerraMetadata = async (addresses: string[]) => {
+  const lcd = new LCDClient(TERRA_HOST);
+  const promises: Promise<TerraMetadata>[] = [];
+  addresses.forEach((address) => {
+    promises.push(fetchSingleMetadata(address, lcd));
+  });
+  const resultsArray = await Promise.all(promises);
+  const output = new Map<string, TerraMetadata>();
+  addresses.forEach((address, index) => {
+    output.set(address, resultsArray[index]);
+  });
+
+  return output;
+};
+
+const useTerraMetadata = (
+  addresses: string[]
+): DataWrapper<Map<string, TerraMetadata>> => {
+  const [isFetching, setIsFetching] = useState(false);
+  const [error, setError] = useState("");
+  const [data, setData] = useState<Map<string, TerraMetadata> | null>(null);
+
+  useLayoutEffect(() => {
+    let cancelled = false;
+    if (addresses.length) {
+      setIsFetching(true);
+      setError("");
+      setData(null);
+      fetchTerraMetadata(addresses).then(
+        (results) => {
+          if (!cancelled) {
+            setData(results);
+            setIsFetching(false);
+          }
+        },
+        () => {
+          if (!cancelled) {
+            setError("Could not retrieve contract metadata");
+            setIsFetching(false);
+          }
+        }
+      );
+    }
+    return () => {
+      cancelled = true;
+    };
+  }, [addresses]);
+
+  return useMemo(
+    () => ({
+      data,
+      isFetching,
+      error,
+      receivedAt: null,
+    }),
+    [data, isFetching, error]
+  );
+};
+
+export default useTerraMetadata;

+ 1 - 1
bridge_ui/src/muiTheme.js

@@ -44,7 +44,7 @@ export const theme = responsiveFontSizes(
         fontWeight: "200",
       },
       h2: {
-        fontWeight: "300",
+        fontWeight: "200",
       },
       h4: {
         fontWeight: "500",

+ 1 - 4
bridge_ui/src/store/selectors.ts

@@ -214,10 +214,7 @@ export const selectTransferSourceError = (
   if (!state.transfer.sourceParsedTokenAccount.uiAmountString) {
     return "Token amount unavailable";
   }
-  if (state.transfer.sourceParsedTokenAccount.decimals === 0) {
-    // TODO: more advanced NFT check - also check supply and uri
-    return "For NFTs, use the NFT flow";
-  }
+  // no NFT check - NFTs should be blocked by all token pickers
   try {
     // these may trigger error: fractional component exceeds decimals
     if (

+ 46 - 5
bridge_ui/src/utils/consts.ts

@@ -90,8 +90,7 @@ export const CHAINS =
           logo: terraIcon,
         },
       ];
-export const BETA_CHAINS: ChainId[] =
-  CLUSTER === "mainnet" ? [CHAIN_ID_POLYGON] : [];
+export const BETA_CHAINS: ChainId[] = CLUSTER === "mainnet" ? [] : [];
 export const CHAINS_WITH_NFT_SUPPORT = CHAINS.filter(
   ({ id }) =>
     id === CHAIN_ID_BSC ||
@@ -354,7 +353,7 @@ export const WETH_ADDRESS =
   CLUSTER === "mainnet"
     ? "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
     : CLUSTER === "testnet"
-    ? ""
+    ? "0x0000000000000000000000000000000000000000"
     : "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E";
 export const WETH_DECIMALS = 18;
 
@@ -362,7 +361,7 @@ export const WBNB_ADDRESS =
   CLUSTER === "mainnet"
     ? "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c"
     : CLUSTER === "testnet"
-    ? ""
+    ? "0x0000000000000000000000000000000000000000"
     : "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E";
 export const WBNB_DECIMALS = 18;
 
@@ -370,7 +369,7 @@ export const WMATIC_ADDRESS =
   CLUSTER === "mainnet"
     ? "0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270"
     : CLUSTER === "testnet"
-    ? ""
+    ? "0x0000000000000000000000000000000000000000"
     : "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E";
 export const WMATIC_DECIMALS = 18;
 
@@ -626,6 +625,48 @@ export const VAA_EMITTER_ADDRESSES = [
   `${CHAIN_ID_TERRA}:0000000000000000000000007cf7b764e38a0a5e967972c1df77d432510564e2`, //terra
   `${CHAIN_ID_BSC}:000000000000000000000000b6f6d86a8f9879a9c87f643768d9efc38c1da6e7`, //bsc
   `${CHAIN_ID_BSC}:0000000000000000000000005a58505a96d1dbf8df91cb21b54419fc36e93fde`, //bsc nft
+  `${CHAIN_ID_POLYGON}:0000000000000000000000005a58505a96d1dbf8df91cb21b54419fc36e93fde`, //Polygon
+  `${CHAIN_ID_POLYGON}:00000000000000000000000090bbd86a6fe93d3bc3ed6335935447e75fab7fcf`, //Polygon nft
 ];
 
 export const WORMHOLE_EXPLORER_BASE = "https://wormholenetwork.com/en/explorer";
+
+export type MultiChainInfo = {
+  [key in ChainId]: { [address: string]: string };
+};
+export const MULTI_CHAIN_TOKENS: MultiChainInfo =
+  //EVM chains should format the addresses to all lowercase
+  CLUSTER === "mainnet"
+    ? ({
+        [CHAIN_ID_SOLANA]: {
+          EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v: "USDC",
+          Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB: "USDT",
+        },
+        [CHAIN_ID_ETH]: {
+          "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": "USDC",
+          "0xdac17f958d2ee523a2206206994597c13d831ec7": "USDT",
+        },
+        [CHAIN_ID_TERRA]: {},
+        [CHAIN_ID_BSC]: {
+          "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d": "USDC",
+          "0x55d398326f99059ff775485246999027b3197955": "USDT",
+        },
+        [CHAIN_ID_POLYGON]: {
+          "0x2791bca1f2de4661ed88a30c99a7a9449aa84174": "USDC",
+          "0xc2132d05d31c914a87c6611c10748aeb04b58e8f": "USDT",
+        },
+      } as MultiChainInfo)
+    : ({
+        [CHAIN_ID_SOLANA]: {
+          "2WDq7wSs9zYrpx2kbHDA4RUTRch2CCTP6ZWaH4GNfnQQ": "SOLT",
+        },
+        [CHAIN_ID_ETH]: {},
+        [CHAIN_ID_TERRA]: {},
+        [CHAIN_ID_BSC]: {},
+        [CHAIN_ID_POLYGON]: {},
+      } as MultiChainInfo);
+
+export const AVAILABLE_MARKETS_URL =
+  "https://docs.wormholenetwork.com/wormhole/overview-liquid-markets";
+
+export const SOLANA_SYSTEM_PROGRAM_ADDRESS = "11111111111111111111111111111111";

+ 1 - 1
devnet/bridge-ui.yaml

@@ -40,7 +40,7 @@ spec:
             - -n
           tty: true
           ports:
-            - containerPort: 5000
+            - containerPort: 3000
               name: npxserver
               protocol: TCP
           readinessProbe:

+ 1 - 1
event_database/cloud_functions/go.mod

@@ -11,5 +11,5 @@ require (
 
 require (
 	github.com/GoogleCloudPlatform/functions-framework-go v1.3.0
-	github.com/certusone/wormhole/node v0.0.0-20211027001206-19628733285e
+	github.com/certusone/wormhole/node v0.0.0-20211102011245-d412cb8a936a
 )

+ 2 - 0
event_database/cloud_functions/go.sum

@@ -170,6 +170,8 @@ github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInq
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/certusone/wormhole/node v0.0.0-20211027001206-19628733285e h1:BQJb2Taq7MMbh+o4AVOiNmFxgNtfMbx8nGJGA3tjiek=
 github.com/certusone/wormhole/node v0.0.0-20211027001206-19628733285e/go.mod h1:YncgdSOYam7ELyXFo7PFCj6tUo0pe6cjlj+O3Vt28mo=
+github.com/certusone/wormhole/node v0.0.0-20211102011245-d412cb8a936a h1:XR4jqFpH5MhKlYjWPHnd+agQaxKs7kynj5vKc09A18Y=
+github.com/certusone/wormhole/node v0.0.0-20211102011245-d412cb8a936a/go.mod h1:YncgdSOYam7ELyXFo7PFCj6tUo0pe6cjlj+O3Vt28mo=
 github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=

+ 2 - 2
event_database/cloud_functions/readrow.go

@@ -117,8 +117,8 @@ func ReadRow(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	summary := makeSummary(row)
-	jsonBytes, err := json.Marshal(summary)
+	details := makeDetails(row)
+	jsonBytes, err := json.Marshal(details)
 	if err != nil {
 		w.WriteHeader(http.StatusInternalServerError)
 		w.Write([]byte(err.Error()))

+ 15 - 1
event_database/cloud_functions/recent.go

@@ -250,7 +250,21 @@ func Recent(w http.ResponseWriter, r *http.Request) {
 	for k, v := range recent {
 		sort.Slice(v, func(i, j int) bool {
 			// bigtable rows dont have timestamps, use a cell timestamp all rows will have.
-			return v[i]["MessagePublication"][0].Timestamp > v[j]["MessagePublication"][0].Timestamp
+			var iTimestamp bigtable.Timestamp
+			var jTimestamp bigtable.Timestamp
+			// rows may have: only MessagePublication, only QuorumState, or both.
+			// find a timestamp for each row, try to use MessagePublication, if it exists:
+			if len(v[i]["MessagePublication"]) >= 1 {
+				iTimestamp = v[i]["MessagePublication"][0].Timestamp
+			} else if len(v[i]["QuorumState"]) >= 1 {
+				iTimestamp = v[i]["QuorumState"][0].Timestamp
+			}
+			if len(v[j]["MessagePublication"]) >= 1 {
+				jTimestamp = v[j]["MessagePublication"][0].Timestamp
+			} else if len(v[j]["QuorumState"]) >= 1 {
+				jTimestamp = v[j]["QuorumState"][0].Timestamp
+			}
+			return iTimestamp > jTimestamp
 		})
 		// trim the result down to the requested amount now that sorting is complete
 		num := len(v)

+ 67 - 20
event_database/cloud_functions/shared.go

@@ -9,6 +9,7 @@ import (
 	"sync"
 
 	"cloud.google.com/go/bigtable"
+	"github.com/certusone/wormhole/node/pkg/vaa"
 )
 
 // shared code for the various functions, primarily response formatting.
@@ -43,17 +44,43 @@ var columnFamilies = []string{"MessagePublication", "Signatures", "VAAState", "Q
 
 type (
 	Summary struct {
-		EmitterChain        string
-		EmitterAddress      string
-		Sequence            string
-		InitiatingTxID      string
-		Payload             []byte
-		GuardiansThatSigned []string
-		SignedVAABytes      []byte
-		QuorumTime          string
+		EmitterChain   string
+		EmitterAddress string
+		Sequence       string
+		InitiatingTxID string
+		Payload        []byte
+		SignedVAABytes []byte
+		QuorumTime     string
+	}
+	// Details is a Summary, with the VAA decoded as SignedVAA
+	Details struct {
+		SignedVAA      *vaa.VAA
+		EmitterChain   string
+		EmitterAddress string
+		Sequence       string
+		InitiatingTxID string
+		Payload        []byte
+		SignedVAABytes []byte
+		QuorumTime     string
 	}
 )
 
+func chainIdStringToType(chainId string) vaa.ChainID {
+	switch chainId {
+	case "1":
+		return vaa.ChainIDSolana
+	case "2":
+		return vaa.ChainIDEthereum
+	case "3":
+		return vaa.ChainIDTerra
+	case "4":
+		return vaa.ChainIDBSC
+	case "5":
+		return vaa.ChainIDPolygon
+	}
+	return vaa.ChainIDUnset
+}
+
 func makeSummary(row bigtable.Row) *Summary {
 	summary := &Summary{}
 	if _, ok := row[columnFamilies[0]]; ok {
@@ -72,25 +99,45 @@ func makeSummary(row bigtable.Row) *Summary {
 				summary.Sequence = string(item.Value)
 			}
 		}
-	}
-	if _, ok := row[columnFamilies[1]]; ok {
-		for _, item := range row[columnFamilies[1]] {
-			column := strings.Split(item.Column, ":")
-			summary.GuardiansThatSigned = append(summary.GuardiansThatSigned, column[1])
+	} else {
+		// Some rows have a QuorumState, but no MessagePublication,
+		// so populate Summary values from the rowKey.
+		keyParts := strings.Split(row.Key(), ":")
+		chainId := chainIdStringToType(keyParts[0])
+		summary.EmitterChain = chainId.String()
+		summary.EmitterAddress = keyParts[1]
+		seq := strings.TrimLeft(keyParts[2], "0")
+		if seq == "" {
+			seq = "0"
 		}
+		summary.Sequence = seq
 	}
 	if _, ok := row[columnFamilies[3]]; ok {
-
-		for _, item := range row[columnFamilies[3]] {
-			if item.Column == "QuorumState:SignedVAA" {
-				summary.SignedVAABytes = item.Value
-				summary.QuorumTime = item.Timestamp.Time().String()
-			}
-		}
+		item := row[columnFamilies[3]][0]
+		summary.SignedVAABytes = item.Value
+		summary.QuorumTime = item.Timestamp.Time().String()
 	}
 	return summary
 }
 
+func makeDetails(row bigtable.Row) *Details {
+	sum := makeSummary(row)
+	deets := &Details{
+		EmitterChain:   sum.EmitterChain,
+		EmitterAddress: sum.EmitterAddress,
+		Sequence:       sum.Sequence,
+		InitiatingTxID: sum.InitiatingTxID,
+		Payload:        sum.Payload,
+		SignedVAABytes: sum.SignedVAABytes,
+		QuorumTime:     sum.QuorumTime,
+	}
+	if _, ok := row[columnFamilies[3]]; ok {
+		item := row[columnFamilies[3]][0]
+		deets.SignedVAA, _ = vaa.Unmarshal(item.Value)
+	}
+	return deets
+}
+
 var mux = newMux()
 
 // Entry is the cloud function entry point

+ 2 - 2
event_database/cloud_functions/transaction.go

@@ -99,8 +99,8 @@ func Transaction(w http.ResponseWriter, r *http.Request) {
 		log.Fatalf("Could not read row with key %s: %v", key, err)
 	}
 
-	summary := makeSummary(row)
-	jsonBytes, err := json.Marshal(summary)
+	details := makeDetails(row)
+	jsonBytes, err := json.Marshal(details)
 	if err != nil {
 		w.WriteHeader(http.StatusInternalServerError)
 		w.Write([]byte(err.Error()))

+ 3 - 3
explorer/.env.sample

@@ -87,6 +87,6 @@ GATSBY_MAINNET_BSC_CORE_BRIDGE=0x98f3c9e6E3fAce36bAAd05FE09d375Ef1464288B
 GATSBY_MAINNET_BSC_TOKEN_BRIDGE=0xB6F6D86a8f9879A9c87f643768d9efc38c1Da6E7
 GATSBY_MAINNET_BSC_NFT_BRIDGE=0x5a58505a96D1dbf8dF91cB21B54419FC36e93fdE
 
-GATSBY_MAINNET_POLYGON_CORE_BRIDGE=0x98f3c9e6E3fAce36bAAd05FE09d375Ef1464288B
-GATSBY_MAINNET_POLYGON_TOKEN_BRIDGE=0xB6F6D86a8f9879A9c87f643768d9efc38c1Da6E7
-GATSBY_MAINNET_POLYGON_NFT_BRIDGE=0x5a58505a96D1dbf8dF91cB21B54419FC36e93fdE
+GATSBY_MAINNET_POLYGON_CORE_BRIDGE=0x7A4B5a56256163F07b2C80A7cA55aBE66c4ec4d7
+GATSBY_MAINNET_POLYGON_TOKEN_BRIDGE=0x5a58505a96d1dbf8df91cb21b54419fc36e93fde
+GATSBY_MAINNET_POLYGON_NFT_BRIDGE=0x90bbd86a6fe93d3bc3ed6335935447e75fab7fcf

+ 116 - 180
explorer/package-lock.json

@@ -44,6 +44,7 @@
         "react-dom": "^16.12.0",
         "react-helmet": "^5.2.1",
         "react-time-ago": "^7.1.3",
+        "styled-components": "5.3.3",
         "token_bridge": "file:./wasm/token"
       },
       "devDependencies": {
@@ -66,6 +67,7 @@
         "@types/react-helmet": "^5.0.15",
         "@types/react-intl": "2.3.18",
         "@types/storybook__react": "^5.2.1",
+        "@types/styled-components": "5.1.15",
         "@typescript-eslint/eslint-plugin": "^2.34.0",
         "@typescript-eslint/parser": "^2.20.0",
         "babel-eslint": "^10.1.0",
@@ -1734,20 +1736,6 @@
         }
       }
     },
-    "node_modules/@certusone/wormhole-sdk/node_modules/babel-plugin-styled-components": {
-      "version": "1.13.3",
-      "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-1.13.3.tgz",
-      "integrity": "sha512-meGStRGv+VuKA/q0/jXxrPNWEm4LPfYIqxooDTdmh8kFsP/Ph7jJG5rUPwUPX3QHUvggwdbgdGpo88P/rRYsVw==",
-      "dependencies": {
-        "@babel/helper-annotate-as-pure": "^7.15.4",
-        "@babel/helper-module-imports": "^7.15.4",
-        "babel-plugin-syntax-jsx": "^6.18.0",
-        "lodash": "^4.17.11"
-      },
-      "peerDependencies": {
-        "styled-components": ">= 2"
-      }
-    },
     "node_modules/@certusone/wormhole-sdk/node_modules/bech32": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz",
@@ -1798,46 +1786,6 @@
         "object-assign": "^4.1.1"
       }
     },
-    "node_modules/@certusone/wormhole-sdk/node_modules/styled-components": {
-      "version": "5.3.3",
-      "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.3.tgz",
-      "integrity": "sha512-++4iHwBM7ZN+x6DtPPWkCI4vdtwumQ+inA/DdAsqYd4SVgUKJie5vXyzotA00ttcFdQkCng7zc6grwlfIfw+lw==",
-      "dependencies": {
-        "@babel/helper-module-imports": "^7.0.0",
-        "@babel/traverse": "^7.4.5",
-        "@emotion/is-prop-valid": "^0.8.8",
-        "@emotion/stylis": "^0.8.4",
-        "@emotion/unitless": "^0.7.4",
-        "babel-plugin-styled-components": ">= 1.12.0",
-        "css-to-react-native": "^3.0.0",
-        "hoist-non-react-statics": "^3.0.0",
-        "shallowequal": "^1.1.0",
-        "supports-color": "^5.5.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/styled-components"
-      },
-      "peerDependencies": {
-        "react": ">= 16.8.0",
-        "react-dom": ">= 16.8.0",
-        "react-is": ">= 16.8.0"
-      }
-    },
-    "node_modules/@certusone/wormhole-sdk/node_modules/supports-color": {
-      "version": "5.5.0",
-      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
-      "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
-      "dependencies": {
-        "has-flag": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=4"
-      }
-    },
     "node_modules/@certusone/wormhole-sdk/node_modules/tslib": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz",
@@ -5805,20 +5753,6 @@
         "react-dom": "^17.0.0"
       }
     },
-    "node_modules/@terra-dev/walletconnect/node_modules/babel-plugin-styled-components": {
-      "version": "1.13.3",
-      "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-1.13.3.tgz",
-      "integrity": "sha512-meGStRGv+VuKA/q0/jXxrPNWEm4LPfYIqxooDTdmh8kFsP/Ph7jJG5rUPwUPX3QHUvggwdbgdGpo88P/rRYsVw==",
-      "dependencies": {
-        "@babel/helper-annotate-as-pure": "^7.15.4",
-        "@babel/helper-module-imports": "^7.15.4",
-        "babel-plugin-syntax-jsx": "^6.18.0",
-        "lodash": "^4.17.11"
-      },
-      "peerDependencies": {
-        "styled-components": ">= 2"
-      }
-    },
     "node_modules/@terra-dev/walletconnect/node_modules/qrcode.react": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-1.0.1.tgz",
@@ -5877,46 +5811,6 @@
         "object-assign": "^4.1.1"
       }
     },
-    "node_modules/@terra-dev/walletconnect/node_modules/styled-components": {
-      "version": "5.3.3",
-      "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.3.tgz",
-      "integrity": "sha512-++4iHwBM7ZN+x6DtPPWkCI4vdtwumQ+inA/DdAsqYd4SVgUKJie5vXyzotA00ttcFdQkCng7zc6grwlfIfw+lw==",
-      "dependencies": {
-        "@babel/helper-module-imports": "^7.0.0",
-        "@babel/traverse": "^7.4.5",
-        "@emotion/is-prop-valid": "^0.8.8",
-        "@emotion/stylis": "^0.8.4",
-        "@emotion/unitless": "^0.7.4",
-        "babel-plugin-styled-components": ">= 1.12.0",
-        "css-to-react-native": "^3.0.0",
-        "hoist-non-react-statics": "^3.0.0",
-        "shallowequal": "^1.1.0",
-        "supports-color": "^5.5.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/styled-components"
-      },
-      "peerDependencies": {
-        "react": ">= 16.8.0",
-        "react-dom": ">= 16.8.0",
-        "react-is": ">= 16.8.0"
-      }
-    },
-    "node_modules/@terra-dev/walletconnect/node_modules/supports-color": {
-      "version": "5.5.0",
-      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
-      "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
-      "dependencies": {
-        "has-flag": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=4"
-      }
-    },
     "node_modules/@terra-dev/walletconnect/node_modules/tslib": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz",
@@ -6510,6 +6404,17 @@
         "@storybook/react": "*"
       }
     },
+    "node_modules/@types/styled-components": {
+      "version": "5.1.15",
+      "resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.15.tgz",
+      "integrity": "sha512-4evch8BRI3AKgb0GAZ/sn+mSeB+Dq7meYtMi7J/0Mg98Dt1+r8fySOek7Sjw1W+Wskyjc93565o5xWAT/FdY0Q==",
+      "dev": true,
+      "dependencies": {
+        "@types/hoist-non-react-statics": "*",
+        "@types/react": "*",
+        "csstype": "^3.0.2"
+      }
+    },
     "node_modules/@types/tapable": {
       "version": "1.0.7",
       "integrity": "sha512-0VBprVqfgFD7Ehb2vd8Lh9TG3jP98gvr8rgehQqzztZNI7o8zS8Ad4jyZneKELphpuE212D8J70LnSNQSyO6bQ==",
@@ -27164,6 +27069,60 @@
         "inline-style-parser": "0.1.1"
       }
     },
+    "node_modules/styled-components": {
+      "version": "5.3.3",
+      "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.3.tgz",
+      "integrity": "sha512-++4iHwBM7ZN+x6DtPPWkCI4vdtwumQ+inA/DdAsqYd4SVgUKJie5vXyzotA00ttcFdQkCng7zc6grwlfIfw+lw==",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.0.0",
+        "@babel/traverse": "^7.4.5",
+        "@emotion/is-prop-valid": "^0.8.8",
+        "@emotion/stylis": "^0.8.4",
+        "@emotion/unitless": "^0.7.4",
+        "babel-plugin-styled-components": ">= 1.12.0",
+        "css-to-react-native": "^3.0.0",
+        "hoist-non-react-statics": "^3.0.0",
+        "shallowequal": "^1.1.0",
+        "supports-color": "^5.5.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/styled-components"
+      },
+      "peerDependencies": {
+        "react": ">= 16.8.0",
+        "react-dom": ">= 16.8.0",
+        "react-is": ">= 16.8.0"
+      }
+    },
+    "node_modules/styled-components/node_modules/babel-plugin-styled-components": {
+      "version": "1.13.3",
+      "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-1.13.3.tgz",
+      "integrity": "sha512-meGStRGv+VuKA/q0/jXxrPNWEm4LPfYIqxooDTdmh8kFsP/Ph7jJG5rUPwUPX3QHUvggwdbgdGpo88P/rRYsVw==",
+      "dependencies": {
+        "@babel/helper-annotate-as-pure": "^7.15.4",
+        "@babel/helper-module-imports": "^7.15.4",
+        "babel-plugin-syntax-jsx": "^6.18.0",
+        "lodash": "^4.17.11"
+      },
+      "peerDependencies": {
+        "styled-components": ">= 2"
+      }
+    },
+    "node_modules/styled-components/node_modules/supports-color": {
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+      "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+      "dependencies": {
+        "has-flag": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/stylehacks": {
       "version": "4.0.3",
       "integrity": "sha512-7GlLk9JwlElY4Y6a/rmbH2MhVlTyVmiJd1PfTCqFaIBEGMYNsrO/v3SeGTdhBThLg4Z+NbOk/qFMwCa+J+3p/g==",
@@ -31639,17 +31598,6 @@
             "rxjs": "^7.3.0"
           }
         },
-        "babel-plugin-styled-components": {
-          "version": "1.13.3",
-          "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-1.13.3.tgz",
-          "integrity": "sha512-meGStRGv+VuKA/q0/jXxrPNWEm4LPfYIqxooDTdmh8kFsP/Ph7jJG5rUPwUPX3QHUvggwdbgdGpo88P/rRYsVw==",
-          "requires": {
-            "@babel/helper-annotate-as-pure": "^7.15.4",
-            "@babel/helper-module-imports": "^7.15.4",
-            "babel-plugin-syntax-jsx": "^6.18.0",
-            "lodash": "^4.17.11"
-          }
-        },
         "bech32": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz",
@@ -31694,31 +31642,6 @@
             "object-assign": "^4.1.1"
           }
         },
-        "styled-components": {
-          "version": "5.3.3",
-          "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.3.tgz",
-          "integrity": "sha512-++4iHwBM7ZN+x6DtPPWkCI4vdtwumQ+inA/DdAsqYd4SVgUKJie5vXyzotA00ttcFdQkCng7zc6grwlfIfw+lw==",
-          "requires": {
-            "@babel/helper-module-imports": "^7.0.0",
-            "@babel/traverse": "^7.4.5",
-            "@emotion/is-prop-valid": "^0.8.8",
-            "@emotion/stylis": "^0.8.4",
-            "@emotion/unitless": "^0.7.4",
-            "babel-plugin-styled-components": ">= 1.12.0",
-            "css-to-react-native": "^3.0.0",
-            "hoist-non-react-statics": "^3.0.0",
-            "shallowequal": "^1.1.0",
-            "supports-color": "^5.5.0"
-          }
-        },
-        "supports-color": {
-          "version": "5.5.0",
-          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
-          "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
-          "requires": {
-            "has-flag": "^3.0.0"
-          }
-        },
         "tslib": {
           "version": "2.1.0",
           "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz",
@@ -34590,17 +34513,6 @@
             "styled-components": "^5.0.0"
           }
         },
-        "babel-plugin-styled-components": {
-          "version": "1.13.3",
-          "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-1.13.3.tgz",
-          "integrity": "sha512-meGStRGv+VuKA/q0/jXxrPNWEm4LPfYIqxooDTdmh8kFsP/Ph7jJG5rUPwUPX3QHUvggwdbgdGpo88P/rRYsVw==",
-          "requires": {
-            "@babel/helper-annotate-as-pure": "^7.15.4",
-            "@babel/helper-module-imports": "^7.15.4",
-            "babel-plugin-syntax-jsx": "^6.18.0",
-            "lodash": "^4.17.11"
-          }
-        },
         "qrcode.react": {
           "version": "1.0.1",
           "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-1.0.1.tgz",
@@ -34650,31 +34562,6 @@
             "object-assign": "^4.1.1"
           }
         },
-        "styled-components": {
-          "version": "5.3.3",
-          "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.3.tgz",
-          "integrity": "sha512-++4iHwBM7ZN+x6DtPPWkCI4vdtwumQ+inA/DdAsqYd4SVgUKJie5vXyzotA00ttcFdQkCng7zc6grwlfIfw+lw==",
-          "requires": {
-            "@babel/helper-module-imports": "^7.0.0",
-            "@babel/traverse": "^7.4.5",
-            "@emotion/is-prop-valid": "^0.8.8",
-            "@emotion/stylis": "^0.8.4",
-            "@emotion/unitless": "^0.7.4",
-            "babel-plugin-styled-components": ">= 1.12.0",
-            "css-to-react-native": "^3.0.0",
-            "hoist-non-react-statics": "^3.0.0",
-            "shallowequal": "^1.1.0",
-            "supports-color": "^5.5.0"
-          }
-        },
-        "supports-color": {
-          "version": "5.5.0",
-          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
-          "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
-          "requires": {
-            "has-flag": "^3.0.0"
-          }
-        },
         "tslib": {
           "version": "2.1.0",
           "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz",
@@ -35218,6 +35105,17 @@
         "@storybook/react": "*"
       }
     },
+    "@types/styled-components": {
+      "version": "5.1.15",
+      "resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.15.tgz",
+      "integrity": "sha512-4evch8BRI3AKgb0GAZ/sn+mSeB+Dq7meYtMi7J/0Mg98Dt1+r8fySOek7Sjw1W+Wskyjc93565o5xWAT/FdY0Q==",
+      "dev": true,
+      "requires": {
+        "@types/hoist-non-react-statics": "*",
+        "@types/react": "*",
+        "csstype": "^3.0.2"
+      }
+    },
     "@types/tapable": {
       "version": "1.0.7",
       "integrity": "sha512-0VBprVqfgFD7Ehb2vd8Lh9TG3jP98gvr8rgehQqzztZNI7o8zS8Ad4jyZneKELphpuE212D8J70LnSNQSyO6bQ==",
@@ -51384,6 +51282,44 @@
         "inline-style-parser": "0.1.1"
       }
     },
+    "styled-components": {
+      "version": "5.3.3",
+      "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.3.tgz",
+      "integrity": "sha512-++4iHwBM7ZN+x6DtPPWkCI4vdtwumQ+inA/DdAsqYd4SVgUKJie5vXyzotA00ttcFdQkCng7zc6grwlfIfw+lw==",
+      "requires": {
+        "@babel/helper-module-imports": "^7.0.0",
+        "@babel/traverse": "^7.4.5",
+        "@emotion/is-prop-valid": "^0.8.8",
+        "@emotion/stylis": "^0.8.4",
+        "@emotion/unitless": "^0.7.4",
+        "babel-plugin-styled-components": ">= 1.12.0",
+        "css-to-react-native": "^3.0.0",
+        "hoist-non-react-statics": "^3.0.0",
+        "shallowequal": "^1.1.0",
+        "supports-color": "^5.5.0"
+      },
+      "dependencies": {
+        "babel-plugin-styled-components": {
+          "version": "1.13.3",
+          "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-1.13.3.tgz",
+          "integrity": "sha512-meGStRGv+VuKA/q0/jXxrPNWEm4LPfYIqxooDTdmh8kFsP/Ph7jJG5rUPwUPX3QHUvggwdbgdGpo88P/rRYsVw==",
+          "requires": {
+            "@babel/helper-annotate-as-pure": "^7.15.4",
+            "@babel/helper-module-imports": "^7.15.4",
+            "babel-plugin-syntax-jsx": "^6.18.0",
+            "lodash": "^4.17.11"
+          }
+        },
+        "supports-color": {
+          "version": "5.5.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+          "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+          "requires": {
+            "has-flag": "^3.0.0"
+          }
+        }
+      }
+    },
     "stylehacks": {
       "version": "4.0.3",
       "integrity": "sha512-7GlLk9JwlElY4Y6a/rmbH2MhVlTyVmiJd1PfTCqFaIBEGMYNsrO/v3SeGTdhBThLg4Z+NbOk/qFMwCa+J+3p/g==",

+ 2 - 0
explorer/package.json

@@ -83,6 +83,7 @@
     "react-dom": "^16.12.0",
     "react-helmet": "^5.2.1",
     "react-time-ago": "^7.1.3",
+    "styled-components": "5.3.3",
     "token_bridge": "file:./wasm/token"
   },
   "devDependencies": {
@@ -105,6 +106,7 @@
     "@types/react-helmet": "^5.0.15",
     "@types/react-intl": "2.3.18",
     "@types/storybook__react": "^5.2.1",
+    "@types/styled-components": "5.1.15",
     "@typescript-eslint/eslint-plugin": "^2.34.0",
     "@typescript-eslint/parser": "^2.20.0",
     "babel-eslint": "^10.1.0",

+ 28 - 10
explorer/src/components/ExplorerQuery/ExplorerQuery.tsx

@@ -3,13 +3,14 @@ import { Spin, Typography } from 'antd'
 const { Title } = Typography
 
 import { FormattedMessage } from 'gatsby-plugin-intl'
-import { arrayify, isHexString, zeroPad } from "ethers/lib/utils";
-import { Bech32, toHex } from "@cosmjs/encoding"
+import { arrayify, isHexString, zeroPad, hexlify } from "ethers/lib/utils";
+import { Bech32, toHex, fromHex } from "@cosmjs/encoding"
 import { ExplorerSummary } from '~/components/ExplorerSummary';
 import { titleStyles } from '~/styles';
 import { NetworkContext } from '~/components/NetworkSelect';
 import { getEmitterAddressSolana } from "@certusone/wormhole-sdk";
-import { chainIDs } from '~/utils/misc/constants';
+import { ChainIDs, chainIDs } from '~/utils/misc/constants';
+import { PublicKey } from '@solana/web3.js';
 
 export interface VAA {
     Version: number | string,
@@ -25,10 +26,10 @@ export interface VAA {
 }
 export interface BigTableMessage {
     InitiatingTxID?: string
-    SignedVAABytes: string  // base64 encoded byte array
-    SignedVAA: VAA
-    QuorumTime: string  // "2021-08-11 00:16:11.757 +0000 UTC"
-    EmitterChain: "solana" | "ethereum" | "terra" | "bsc"
+    SignedVAABytes?: string  // base64 encoded byte array
+    SignedVAA?: VAA
+    QuorumTime?: string  // "2021-08-11 00:16:11.757 +0000 UTC"
+    EmitterChain: keyof ChainIDs
     EmitterAddress: string
     Sequence: string
 }
@@ -98,14 +99,31 @@ const ExplorerQuery = (props: ExplorerQuery) => {
 
             if (sequence.length <= 15) {
                 paddedSequence = sequence.padStart(16, "0")
-            } else if (sequence.length >= 17) {
-                paddedSequence = sequence.slice(-16)
             } else {
                 paddedSequence = sequence
             }
             url = `${base}/readrow?emitterChain=${emitterChain}&emitterAddress=${paddedAddress}&sequence=${paddedSequence}`
         } else if (txId) {
-            url = `${base}/transaction?id=${txId}`
+            let transformedTxId = txId
+            if (isHexString(txId)) {
+                // valid hexString, no transformation needed.
+            } else {
+                try {
+                    let pubKey = new PublicKey(txId).toBytes()
+                    let solHex = hexlify(pubKey)
+                    transformedTxId = solHex
+                } catch (_) {
+                    // not solana, try terra
+                    try {
+                        let arr = fromHex(txId)
+                        let terraHex = hexlify(arr)
+                        transformedTxId = terraHex
+                    } catch (_) {
+                        // do nothing
+                    }
+                }
+            }
+            url = `${base}/transaction?id=${transformedTxId}`
         }
 
         fetch(url)

+ 4 - 4
explorer/src/components/ExplorerStats/ChainOverviewCard.tsx

@@ -65,8 +65,8 @@ const ChainOverviewCard: React.FC<ChainOverviewCardProps> = ({ Icon, iconStyle,
             loading ? "loading" : intl.formatMessage({ id: "explorer.comingSoon" })}>
             <Card
                 style={{
-                    width: 200,
-                    paddingTop: 10
+                    width: 190,
+                    paddingTop: 10,
                 }}
                 className="hover-z-index"
                 cover={<Icon style={{ height: 140, ...iconStyle }} />}
@@ -86,11 +86,11 @@ const ChainOverviewCard: React.FC<ChainOverviewCardProps> = ({ Icon, iconStyle,
                 {!!totalCount ? (
                     <>
                         <div style={{ display: 'flex', justifyContent: "space-between", alignItems: 'center', gap: 12 }}>
-                            <div><Text type="secondary" style={{ fontSize: 14 }}>last 24 hours</Text></div>
+                            <div><Text type="secondary" style={{ fontSize: 14 }}>last&nbsp;24&nbsp;hours</Text></div>
                             <div><Text className={animate ? "highlight-new-val" : ""} style={{ fontSize: 26 }}>{lastDayCount}</Text></div>
                         </div>
                         <div style={{ display: 'flex', justifyContent: "center", alignItems: 'center', gap: 12 }}>
-                            <div><Text type="secondary" style={{ fontSize: 14 }}>last {totalDays} days</Text></div>
+                            <div><Text type="secondary" style={{ fontSize: 14 }}>last&nbsp;{totalDays}&nbsp;days</Text></div>
                             <div><Text className={animate ? "highlight-new-val" : ""} style={{ fontSize: 26 }}>{totalCount}</Text></div>
                         </div>
                         {/* <Statistic title={<span>last 24 hours</span>} value={totals?.LastDayCount[dataKey]} style={{ display: 'flex', justifyContent: "space-between", alignItems: 'center', gap: 12 }} valueStyle={{ fontSize: 26 }} /> */}

+ 2 - 5
explorer/src/components/ExplorerStats/DailyCountLineChart.tsx

@@ -98,9 +98,9 @@ const DailyCountLineChart = (props: DailyCountProps) => {
                 colors={({ color }) => color}
                 data={data}
                 curve={"monotoneX"}
-                margin={{ top: 10, right: 40, bottom: 160, left: 40 }}
+                margin={{ top: 10, right: 40, bottom: 160, left: 60 }}
                 xScale={{ type: 'point' }}
-                yScale={{ type: 'linear', min: 'auto', max: 'auto', stacked: false, reverse: false }}
+                yScale={{ type: 'linear', min: 0, max: 'auto', stacked: false, reverse: false }}
                 enableGridX={false}
                 axisTop={null}
                 axisRight={null}
@@ -109,9 +109,6 @@ const DailyCountLineChart = (props: DailyCountProps) => {
                     tickSize: 5,
                     tickPadding: 5,
                     tickRotation: 0,
-                    legend: 'count',
-                    legendOffset: -40,
-                    legendPosition: 'middle'
                 }}
                 pointSize={4}
                 pointColor={{ theme: 'background' }}

+ 32 - 27
explorer/src/components/ExplorerStats/ExplorerStats.tsx

@@ -44,7 +44,8 @@ const Stats: React.FC<StatsProps> = ({ emitterChain, emitterAddress }) => {
 
     const [totals, setTotals] = useState<Totals>()
     const [recent, setRecent] = useState<Recent>()
-    const [polling, setPolling] = useState(true);
+    const [address, setAddress] = useState<StatsProps["emitterAddress"]>()
+    const [chain, setChain] = useState<StatsProps["emitterChain"]>()
     const [lastFetched, setLastFetched] = useState<number>()
     const [pollInterval, setPollInterval] = useState<NodeJS.Timeout>()
     const [controller, setController] = useState<AbortController>(new AbortController())
@@ -125,29 +126,34 @@ const Stats: React.FC<StatsProps> = ({ emitterChain, emitterAddress }) => {
             clearInterval(pollInterval)
             setPollInterval(undefined)
         }
-        if (polling) {
-            // abort any in-flight requests
-            controller.abort()
-            // create a new controller for the new fetches, add it to state
-            const newController = new AbortController();
-            setController(newController)
-            // create a signal for requests
-            const { signal } = newController;
-            // start polling
-            let interval = setInterval(() => {
-                getData({ emitterChain, emitterAddress }, baseUrl, signal).catch(err => {
-                    console.error('failed fetching data. err: ', err)
-                })
-            }, 5000)
-            setPollInterval(interval)
-        }
+        // abort any in-flight requests
+        controller.abort()
+        // create a new controller for the new fetches, add it to state
+        const newController = new AbortController();
+        setController(newController)
+        // create a signal for requests
+        const { signal } = newController;
+        // start polling
+        let interval = setInterval(() => {
+            getData({ emitterChain, emitterAddress }, baseUrl, signal)
+        }, 5000)
+        setPollInterval(interval)
     }
 
+
     useEffect(() => {
+        // getData if first load (no totals or recents), or emitterAddress/emitterChain changed.
+        if (!totals && !recent || emitterAddress !== address || emitterChain !== chain) {
+            getData({ emitterChain, emitterAddress }, activeNetwork.endpoints.bigtableFunctionsBase, new AbortController().signal)
+        }
         controller.abort()
         setTotals(undefined)
         setRecent(undefined)
+
         pollingController(emitterChain, emitterAddress, activeNetwork.endpoints.bigtableFunctionsBase)
+        // hold chain & address in state to detect changes
+        setChain(emitterChain)
+        setAddress(emitterAddress)
     }, [emitterChain, emitterAddress, activeNetwork.endpoints.bigtableFunctionsBase])
 
     useEffect(() => {
@@ -156,7 +162,7 @@ const Stats: React.FC<StatsProps> = ({ emitterChain, emitterAddress }) => {
                 clearInterval(pollInterval)
             }
         };
-    }, [polling, pollInterval, activeNetwork.endpoints.bigtableFunctionsBase])
+    }, [pollInterval, activeNetwork.endpoints.bigtableFunctionsBase])
 
     let title = "Recent messages"
     let hideTableTitles = false
@@ -171,7 +177,7 @@ const Stats: React.FC<StatsProps> = ({ emitterChain, emitterAddress }) => {
     return (
         <>
             {!emitterChain && !emitterAddress &&
-                <div style={{ display: 'flex', justifyContent: 'space-around', alignItems: 'flex-end', flexWrap: 'wrap', marginBottom: 40 }}>
+                <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'flex-end', flexWrap: 'wrap', marginBottom: 40, gap: 20 }}>
                     <ChainOverviewCard totalDays={daysSinceDataStart} totals={totals} dataKey="1" title={ChainID[1]} Icon={SolanaIcon} iconStyle={{ height: 120, margin: '10px 0' }} />
                     <ChainOverviewCard totalDays={daysSinceDataStart} totals={totals} dataKey="2" title={ChainID[2]} Icon={EthereumIcon} />
                     <ChainOverviewCard totalDays={daysSinceDataStart} totals={totals} dataKey="3" title={ChainID[3]} Icon={TerraIcon} />
@@ -179,20 +185,19 @@ const Stats: React.FC<StatsProps> = ({ emitterChain, emitterAddress }) => {
                     <ChainOverviewCard totalDays={daysSinceDataStart} totals={totals} dataKey="5" title={ChainID[5]} Icon={PolygonIcon} />
                 </div>
             }
-            <Spin spinning={!totals && !recent} style={{ width: '100%', height: 500 }} />
-            <div>
-                {totals?.DailyTotals &&
+            <Spin spinning={!totals && !recent} style={{ width: '100%', height: 500 }} >
+                <div>
                     <DailyCountLineChart
-                        dailyCount={totals?.DailyTotals}
+                        dailyCount={totals?.DailyTotals || {}}
                         lastFetched={lastFetched}
                         title="messages/day"
                         emitterChain={emitterChain}
                         emitterAddress={emitterAddress}
-                    />}
-            </div>
-
-            {recent && <RecentMessages recent={recent} lastFetched={lastFetched} title={title} hideTableTitles={hideTableTitles} />}
+                    />
+                </div>
 
+                {recent && <RecentMessages recent={recent} lastFetched={lastFetched} title={title} hideTableTitles={hideTableTitles} />}
+            </Spin>
         </>
     )
 }

+ 2 - 2
explorer/src/components/ExplorerStats/RecentMessages.tsx

@@ -55,13 +55,13 @@ const RecentMessages = (props: RecentMessagesProps) => {
         {
             title: "message",
             key: "payload",
-            render: (item: BigTableMessage) => <DecodePayload
+            render: (item: BigTableMessage) => item.SignedVAABytes ? <DecodePayload
                 base64VAA={item.SignedVAABytes}
                 emitterChainName={item.EmitterChain}
                 emitterAddress={item.EmitterAddress}
                 showType={true}
                 showSummary={true}
-            />
+            /> : null
         },
         {
             title: "sequence",

+ 27 - 4
explorer/src/components/ExplorerStats/utils.ts

@@ -30,7 +30,7 @@ const makeGroupName = (groupKey: string, activeNetwork: ActiveNetwork, emitterCh
 const getNativeAddress = (chainId: number, emitterAddress: string, activeNetwork?: ActiveNetwork): string => {
     let nativeAddress = ""
 
-    if (chainId === chainIDs["ethereum"] || chainId === chainIDs["bsc"]) {
+    if (chainId === chainIDs["ethereum"] || chainId === chainIDs["bsc"] || chainId === chainIDs["polygon"]) {
         // remove zero-padding
         let unpadded = emitterAddress.slice(-40)
         nativeAddress = `0x${unpadded}`.toLowerCase()
@@ -81,7 +81,7 @@ const contractNameFormatter = (address: string, chainId: number, activeNetwork?:
 }
 
 
-const nativeExplorerUri = (chainId: number, address: string, activeNetwork?: ActiveNetwork): string => {
+const nativeExplorerContractUri = (chainId: number, address: string, activeNetwork?: ActiveNetwork): string => {
     if (!activeNetwork) {
         activeNetwork = useContext(NetworkContext).activeNetwork
     }
@@ -100,17 +100,40 @@ const nativeExplorerUri = (chainId: number, address: string, activeNetwork?: Act
         } else if (chainId === chainIDs["bsc"]) {
             let base = "https://bscscan.com/address/"
             return `${base}${nativeAddress}`
+        } else if (chainId === chainIDs["polygon"]) {
+            let base = "https://polygonscan.com/address/"
+            return `${base}${nativeAddress}`
         }
     }
     return ""
 }
+const nativeExplorerTxUri = (chainId: number, transactionId: string): string => {
+    if (chainId === chainIDs["solana"]) {
+        let base = "https://explorer.solana.com/address/"
+        return `${base}${transactionId}`
+    } else if (chainId === chainIDs["ethereum"]) {
+        let base = "https://etherscan.io/tx/"
+        return `${base}${transactionId}`
+    } else if (chainId === chainIDs["terra"]) {
+        let base = "https://finder.terra.money/columbus-5/tx/"
+        return `${base}${transactionId}`
+    } else if (chainId === chainIDs["bsc"]) {
+        let base = "https://bscscan.com/tx/"
+        return `${base}${transactionId}`
+    } else if (chainId === chainIDs["polygon"]) {
+        let base = "https://polygonscan.com/tx/"
+        return `${base}${transactionId}`
+    }
+    return ""
+}
 
 const chainColors: { [chain: string]: string } = {
     "*": "hsl(183, 100%, 61%)",
     "1": "hsl(297, 100%, 61%)",
     "2": "hsl(235, 5%, 43%)",
     "3": "hsl(235, 100%, 61%)",
-    "4": "hsl(54, 100%, 61%)"
+    "4": "hsl(54, 100%, 61%)",
+    "5": "hsl(271, 100%, 61%)",
 }
 
-export { makeDate, makeGroupName, chainColors, truncateAddress, contractNameFormatter, nativeExplorerUri }
+export { makeDate, makeGroupName, chainColors, truncateAddress, contractNameFormatter, nativeExplorerContractUri, nativeExplorerTxUri }

+ 43 - 14
explorer/src/components/ExplorerSummary/ExplorerSummary.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect } from 'react';
+import React from 'react';
 import { Button, Spin, Typography } from 'antd'
 const { Title } = Typography
 import { useIntl, FormattedMessage } from 'gatsby-plugin-intl'
@@ -8,8 +8,10 @@ import ReactTimeAgo from 'react-time-ago'
 import { titleStyles } from '~/styles';
 import { CloseOutlined, ReloadOutlined } from '@ant-design/icons';
 import { Link } from 'gatsby';
-import { contractNameFormatter, nativeExplorerUri } from '../ExplorerStats/utils';
+import { contractNameFormatter, nativeExplorerContractUri, nativeExplorerTxUri } from '../ExplorerStats/utils';
 import { OutboundLink } from 'gatsby-plugin-google-gtag';
+import { chainIDs } from '~/utils/misc/constants';
+import { hexToNativeString } from '@certusone/wormhole-sdk';
 
 interface SummaryProps {
     emitterChain?: number,
@@ -27,6 +29,24 @@ const Summary = (props: SummaryProps) => {
     const intl = useIntl()
     const { SignedVAA, ...message } = props.message
 
+    const { EmitterChain, EmitterAddress, InitiatingTxID } = message
+    // get chainId from chain name
+    let chainId = chainIDs[EmitterChain]
+
+    let transactionId: string | undefined
+    if (InitiatingTxID) {
+        if (chainId === chainIDs["ethereum"] || chainId === chainIDs["bsc"] || chainId === chainIDs["polygon"]) {
+            transactionId = InitiatingTxID
+        } else {
+            if (chainId === chainIDs["solana"]) {
+                const txId = InitiatingTxID.slice(2) // remove the leading "0x"
+                transactionId = hexToNativeString(txId, chainId)
+            } else if (chainId === chainIDs["terra"]) {
+                transactionId = InitiatingTxID.slice(2) // remove the leading "0x"
+            }
+        }
+    }
+
     return (
         <>
             <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'baseline' }}>
@@ -63,18 +83,7 @@ const Summary = (props: SummaryProps) => {
                     style={{ fontSize: 12, marginBottom: 20 }}
                 >{JSON.stringify(SignedVAA, undefined, 2)}</pre>
             </div>
-            <div style={{ display: 'flex', justifyContent: 'space-between' }}>
-
-                {props.emitterChain && props.emitterAddress && nativeExplorerUri(props.emitterChain, props.emitterAddress) ?
-                    <OutboundLink
-                        href={nativeExplorerUri(props.emitterChain, props.emitterAddress)}
-                        target="_blank"
-                        rel="noopener noreferrer"
-                        style={{ fontSize: 16 }}
-                    >
-                        {'View "'}{contractNameFormatter(props.emitterAddress, props.emitterChain)}{'" emitter contract on native explorer'}
-                    </OutboundLink> : <div />}
-
+            <div style={{ display: 'flex', justifyContent: "flex-end" }}>
                 {props.lastFetched ? (
                     <span>
                         <FormattedMessage id="explorer.lastUpdated" />:&nbsp;
@@ -83,6 +92,26 @@ const Summary = (props: SummaryProps) => {
 
                 ) : null}
             </div>
+            <div style={{ display: 'flex', flexDirection: 'column', }}>
+                {EmitterChain && EmitterAddress && nativeExplorerContractUri(chainId, EmitterAddress) ?
+                    <OutboundLink
+                        href={nativeExplorerContractUri(chainId, EmitterAddress)}
+                        target="_blank"
+                        rel="noopener noreferrer"
+                        style={{ fontSize: 16, marginBottom: '6px 0' }}
+                    >
+                        {'View "'}{contractNameFormatter(EmitterAddress, chainId)}{'" contract on native explorer'}
+                    </OutboundLink> : <div />}
+                {transactionId && EmitterChain ?
+                    <OutboundLink
+                        href={nativeExplorerTxUri(chainId, transactionId)}
+                        target="_blank"
+                        rel="noopener noreferrer"
+                        style={{ fontSize: 16, margin: '6px 0' }}
+                    >
+                        {'View transaction "'}{transactionId}{'" on native explorer'}
+                    </OutboundLink> : <div />}
+            </div>
         </>
     )
 }

+ 34 - 0
explorer/src/components/Layout/DefaultLayout.less

@@ -58,3 +58,37 @@ svg:hover.external-icon  {
         display: flex;
     }
 }
+
+// Hamburger menu + popover
+.popover div.nav {
+    /* mobile nav */
+    flex-direction: column;
+    font-size: 28px;
+    font-weight: 400;
+}
+
+.affix {
+    /* fixed position prevents scrolling while the mobile nav popover is open. */
+    position: fixed !important;
+    width: 100%;
+    z-index: 1000;
+}
+
+.nav a {
+    color: rgba(255, 255, 255, 0.85);
+    font-size: 18px;
+}
+.nav a:hover{
+    color: @primary-color;
+}
+
+@media (max-width: 992px) {
+    .site-nav-right {
+        display: none;
+    }
+}
+@media (min-width: 992px) {
+    .popover div.nav {
+        display: none;
+    }
+}

+ 176 - 73
explorer/src/components/Layout/DefaultLayout.tsx

@@ -1,14 +1,15 @@
 /* eslint-disable react/jsx-props-no-spreading */
-import React from 'react';
-import { Layout, Menu, Grid } from 'antd';
+import React, { useEffect, useState } from 'react';
+import { Button, Layout, Grid } from 'antd';
 const { Header, Content, Footer } = Layout;
 const { useBreakpoint } = Grid
-import { MenuOutlined } from '@ant-design/icons';
+import { SendOutlined } from '@ant-design/icons';
 import { useIntl, FormattedMessage } from 'gatsby-plugin-intl';
 import { OutboundLink } from "gatsby-plugin-google-gtag"
-import { useLocation } from '@reach/router';
 import { Link } from 'gatsby'
 import './DefaultLayout.less'
+import styled from "styled-components";
+
 
 import { externalLinks, linkToService, socialLinks, socialAnchorArray } from '~/utils/misc/socials';
 
@@ -16,6 +17,64 @@ import { externalLinks, linkToService, socialLinks, socialAnchorArray } from '~/
 import { ReactComponent as AvatarAndName } from '~/icons/FullLogo_DarkBackground.svg';
 import { ReactComponent as Avatar } from '~/icons/Avatar_DarkBackground.svg';
 
+
+const Toggle = styled.div`
+    display: none;
+    height: 100%;
+    cursor: pointer;
+    padding: 0 4vw;
+    @media (max-width: 992px) {
+        display: flex;
+    }
+`;
+
+const Navbox = styled.div`
+    align-items: center;
+    @media (max-width: 992px) {
+        flex-direction: column;
+        position: fixed;
+        width: 100%;
+        justify-content: flex-start;
+        padding-top: 360px;
+        background-color: #010114;
+        transition: all 0.3s ease-in;
+        left: ${(props: { open: boolean }) => (props.open ? "-100%" : "0")};
+    }
+`;
+
+const Hamburger = styled.div`
+    background-color: #fff;
+    width: 30px;
+    height: 3px;
+    transition: all 0.3s linear;
+    align-self: center;
+    position: relative;
+    transform: ${(props: { open: boolean }) => (props.open ? "rotate(-45deg)" : "inherit")};
+    z-index: 1001;
+    ::before,
+    ::after {
+        width: 30px;
+        height: 3px;
+        background-color: #fff;
+        content: "";
+        position: absolute;
+        transition: all 0.3s linear;
+    }
+    ::before {
+        transform: ${(props) =>
+    props.open
+      ? "rotate(-90deg) translate(-10px, 0px)"
+      : "rotate(0deg)"};
+        top: -10px;
+    }
+    ::after {
+        opacity: ${(props) => (props.open ? "0" : "1")};
+        transform: ${(props) =>
+    props.open ? "rotate(90deg) " : "rotate(0deg)"};
+        top: 10px;
+    }
+`;
+
 const externalLinkProps = { target: "_blank", rel: "noopener noreferrer", className: "no-external-icon" }
 
 const DefaultLayout: React.FC<{}> = ({
@@ -23,10 +82,83 @@ const DefaultLayout: React.FC<{}> = ({
   ...props
 }) => {
   const intl = useIntl()
-  const location = useLocation()
   const screens = useBreakpoint();
+  const [navbarOpen, setNavbarOpen] = useState(false);
   const menuItemProps: { style: { textAlign: CanvasTextAlign, padding: number } } = { style: { textAlign: 'center', padding: 0 } }
 
+  useEffect(() => {
+    if (screens.lg === true) {
+      setNavbarOpen(false)
+    }
+  }, [screens.lg])
+
+  const launchBridge = <div key="bridge" style={{ ...menuItemProps.style, zIndex: 1001 }}>
+    <OutboundLink
+      href={"https://wormholebridge.com"}
+      target="_blank"
+      rel="noopener noreferrer"
+      className="no-external-icon"
+    >
+      <Button
+        style={{
+          height: 40,
+          fontSize: 16,
+          border: "1.5px solid",
+          paddingLeft: 20
+        }}
+        ghost
+        type="primary"
+        shape="round"
+        size="large"
+      >
+        {intl.formatMessage({ id: "nav.bridgeLink" })}
+        <SendOutlined style={{ fontSize: 16, marginRight: 0 }} />
+      </Button>
+    </OutboundLink>
+  </div>
+
+  const menuItems = [
+    <div key="about" {...menuItemProps}>
+      <Link to={`/${intl.locale}/about`}>
+        <FormattedMessage id="nav.aboutLink" />
+      </Link>
+    </div>,
+    <div key="network" {...menuItemProps} >
+      <Link to={`/${intl.locale}/network/`}>
+        <FormattedMessage id="nav.networkLink" />
+      </Link>
+    </div>,
+    <div key="explorer" {...menuItemProps} >
+      <Link to={`/${intl.locale}/explorer`}>
+        <FormattedMessage id="nav.explorerLink" />
+      </Link>
+    </div>,
+    <div key="jobs" {...menuItemProps} >
+      <OutboundLink
+        href={"https://boards.greenhouse.io/wormhole"}
+        target="_blank"
+        rel="noopener noreferrer"
+      >
+        {intl.formatMessage({ id: "nav.jobsLink" })}
+      </OutboundLink>
+    </div>,
+    screens.sm === false || screens.lg === true ? launchBridge : null,
+    screens.lg === false ? (<div key="socials" style={{ ...menuItemProps.style, height: '100%', padding: 0 }}>
+      <div
+        style={{ display: 'flex', justifyContent: 'space-evenly', borderStyle: 'none' }}
+      >
+        {Object.entries(externalLinks).map(([url, Icon]) => <div key={url} {...menuItemProps} style={{ margin: '12px 0' }} >
+          <OutboundLink
+            href={url}
+            {...externalLinkProps}
+            title={intl.formatMessage({ id: `nav.${linkToService[url]}AltText` })}
+          >
+            <Icon style={{ height: 26 }} className="external-icon" />
+          </OutboundLink>
+        </div>)}
+      </div>
+    </div>) : null
+  ]
   return (
     <Layout style={{ minHeight: '100vh' }}>
       <Header style={{
@@ -34,86 +166,57 @@ const DefaultLayout: React.FC<{}> = ({
         height: 70
       }} >
         <div className="center-content">
-          <Menu
-            mode="horizontal"
-            selectedKeys={[location.pathname.split('/')[2]]}
+          <nav
+            className={`max-content-width ${navbarOpen ? " affix" : ""}`}
             style={{
               height: 70,
               display: 'flex',
+              justifyContent: 'space-between',
+              alignItems: 'center',
               width: '100%',
-              padding: !screens.md ? 0 : '0 80px 0 148px'
+              padding: !screens.lg ? 0 : '0 16px 0 0'
             }}
-            overflowedIndicator={<MenuOutlined style={{ fontSize: '24px', verticalAlign: 'middle', marginRight: 0 }} />}
-            className="max-content-width"
           >
-            <Menu.Item key="" className="responsive-padding" >
+            {/* wormhole logo, left side of nav */}
+            <div className="responsive-padding" style={{ zIndex: 1001 }}>
               <Link to={`/${intl.locale}/`} style={{ height: 32 }} title={intl.formatMessage({ id: 'nav.homeLinkAltText' })}>
                 <AvatarAndName style={{ height: 45, margin: 'auto', verticalAlign: 'middle', display: 'inline-block' }} />
               </Link>
-            </Menu.Item>
-            <div style={{ flexGrow: 1, minWidth: '20%' }}>
-              {/* pushes the elements away on both sides */}
             </div>
-            <Menu.Item key="about" {...menuItemProps}>
-              <Link to={`/${intl.locale}/about`}>
-                <FormattedMessage id="nav.aboutLink" />
-              </Link>
-            </Menu.Item>
-            {String(process.env.ENABLE_NETWORK_PAGE) === 'true' ? (
-              <Menu.Item key="network" {...menuItemProps}>
-                <Link to={`/${intl.locale}/network/`}>
-                  <FormattedMessage id="nav.networkLink" />
-                </Link>
-              </Menu.Item>
-            ) : null}
-            {String(process.env.ENABLE_EXPLORER_PAGE) === 'true' ? (
-              <Menu.Item key="explorer" {...menuItemProps}>
-                <Link to={`/${intl.locale}/explorer`}>
-                  <FormattedMessage id="nav.explorerLink" />
-                </Link>
-              </Menu.Item>
-            ) : null}
-            <Menu.Item key="code" {...menuItemProps}>
-              <OutboundLink
-                href={socialLinks['github']}
-                target="_blank"
-                rel="noopener noreferrer"
-              >
-                {intl.formatMessage({ id: "nav.codeLink" })}
-              </OutboundLink>
-            </Menu.Item>
-            <Menu.Item key="jobs" {...menuItemProps}>
-              <OutboundLink
-                href={"https://boards.greenhouse.io/wormhole"}
-                target="_blank"
-                rel="noopener noreferrer"
-              >
-                {intl.formatMessage({ id: "nav.jobsLink" })}
-              </OutboundLink>
-            </Menu.Item>
-
-            {screens.md === false ? (
-              <Menu.Item style={{ height: '100%', padding: 0 }}>
-                <Menu
-                  mode="horizontal"
-                  style={{ display: 'flex', justifyContent: 'space-between', width: '98vw', borderStyle: 'none' }}
-                  selectedKeys={[]} >
-                  {Object.entries(externalLinks).map(([url, Icon]) => <Menu.Item key={url} {...menuItemProps} style={{ margin: '12px 0' }} >
-                    <div style={{ display: 'flex', justifyContent: 'space-evenly', width: '100%' }}>
-                      <OutboundLink
-                        href={url}
-                        {...externalLinkProps}
-                        title={intl.formatMessage({ id: `nav.${linkToService[url]}AltText` })}
-                      >
-                        <Icon style={{ height: 26 }} className="external-icon" />
-                      </OutboundLink>
-                    </div>
-                  </Menu.Item>)}
-                </Menu>
-              </Menu.Item>
+
+            {/* the list of menu items, right side of nav */}
+            <div className="nav site-nav-right">
+              <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 16 }} >
+                {menuItems}
+              </div>
+            </div>
+
+            {/* show the "Launch Bridge" button next to the hamburger menu if the screen is large enough. */}
+            {screens.lg === false && screens.sm === true ? <>
+              <div style={{ flexGrow: 1 }} />
+              {launchBridge}
+            </> : null}
+
+            {/* hambuger button Toggle mobile popover menu*/}
+            <Toggle onClick={() => setNavbarOpen(!navbarOpen)}>
+              {navbarOpen ? <Hamburger open /> : <Hamburger open={false} />}
+            </Toggle>
+
+            {/* nav drawer with links */}
+            {navbarOpen ? (
+              <Navbox open={!navbarOpen}>
+                <div className="popover" style={{ marginTop: 100 }}>
+                  {/* <Navigation data={navigation} /> */}
+                  <div className="nav" style={{ display: 'flex' }}>
+                    {menuItems}
+                  </div>
+                </div>
+              </Navbox>
             ) : null}
-          </Menu>
+
+          </nav>
         </div>
+
         <div
           className="external-links-left"
         >

+ 3 - 3
explorer/src/components/Payload/DecodePayload.tsx

@@ -149,7 +149,7 @@ const parseAssetMetaPayload = (arr: Buffer): AssetMetaPayload => {
     }
 }
 
-function useBase64ToBuffer(base64VAA: string) {
+function useBase64ToBuffer(base64VAA: string = "") {
     const [buf, setBuf] = useState<Buffer>()
 
     function convertbase64ToBinary(base64: string) {
@@ -177,7 +177,7 @@ function useBase64ToBuffer(base64VAA: string) {
     return buf
 }
 interface DecodePayloadProps {
-    base64VAA: string
+    base64VAA?: string
     emitterChainName: keyof ChainIDs
     emitterAddress: string
     showType?: boolean
@@ -260,7 +260,7 @@ const DecodePayload = (props: DecodePayloadProps) => {
 
                     {props.showSummary && payloadBundle ? (
                         payloadBundle.type === "assetMeta" ? (<>
-                            {chainEnums[payloadBundle.payload.tokenChain]}&nbsp; {payloadBundle.payload.symbol} {payloadBundle.payload.name}
+                            {"AssetMeta:"}&nbsp;{chainEnums[payloadBundle.payload.tokenChain]}&nbsp; {payloadBundle.payload.symbol} {payloadBundle.payload.name}
                         </>) :
                             payloadBundle.type === "tokenTransfer" ? (<>
                                 {"native "}{chainEnums[payloadBundle.payload.originChain]}{' asset -> '}{chainEnums[payloadBundle.payload.targetChain]}

+ 1 - 0
explorer/src/locales/en.json

@@ -37,6 +37,7 @@
     "partnersLink": "partners",
     "codeLink": "code",
     "jobsLink": "jobs",
+    "bridgeLink": "Launch Bridge",
     "discordAltText": "Go to Wormhole's Discord",
     "githubAltText": "Go to Wormhole's Github",
     "mediumAltText": "Go to Wormhole's Medium",

+ 3 - 3
explorer/src/pages/explorer.tsx

@@ -16,7 +16,7 @@ import { WithNetwork, NetworkSelect } from '~/components/NetworkSelect'
 import { ExplorerSearchForm, ExplorerTxForm } from '~/components/App/ExplorerSearch';
 import { ChainID } from '~/utils/misc/constants';
 import { OutboundLink } from 'gatsby-plugin-google-gtag';
-import { nativeExplorerUri } from '~/components/ExplorerStats/utils';
+import { nativeExplorerContractUri } from '~/components/ExplorerStats/utils';
 import { CloseOutlined } from '@ant-design/icons';
 
 
@@ -127,9 +127,9 @@ const Explorer: React.FC<ExplorerProps> = ({ location, navigate }) => {
                                     // show heading with the context of the address
                                     <Title level={3} style={{ ...titleStyles }}>
                                         Recent messages from {ChainID[emitterChain]}&nbsp;
-                                        {nativeExplorerUri(emitterChain, emitterAddress) ?
+                                        {nativeExplorerContractUri(emitterChain, emitterAddress) ?
                                             <OutboundLink
-                                                href={nativeExplorerUri(emitterChain, emitterAddress)}
+                                                href={nativeExplorerContractUri(emitterChain, emitterAddress)}
                                                 target="_blank"
                                                 rel="noopener noreferrer"
                                             >

+ 2 - 4
explorer/src/pages/index.tsx

@@ -33,7 +33,7 @@ const OpenForBizSection = ({ intl, smScreen, howAnchor }: { intl: IntlShape, smS
         height: '100%',
         maxWidth: 650,
         display: 'flex', flexDirection: 'column',
-        justifyContent: 'center', zIndex: 2,
+        justifyContent: 'center',
         marginRight: 'auto'
       }}>
         <Title level={1} style={{ ...titleStyles, fontSize: 64 }}>
@@ -92,7 +92,6 @@ const AboutUsSection = ({ intl, smScreen, howAnchor }: { intl: IntlShape, smScre
         justifyContent: smScreen ? 'flex-start' : 'center',
         alignItems: 'flex-start',
         marginBlock: smScreen ? 0 : 200,
-        zIndex: 2,
       }}>
         <div style={{ borderBottom: "0.5px solid #808088", width: 160, marginBottom: 60 }}>
           <Paragraph style={headingStyles} id={howAnchor}>
@@ -112,7 +111,7 @@ const AboutUsSection = ({ intl, smScreen, howAnchor }: { intl: IntlShape, smScre
       {/* background image, ternary for seperate mobile layout */}
       {smScreen ? (
         <div style={{ position: 'relative', marginTop: 60, height: 260, }}>
-          <div style={{ position: 'absolute', right: 40, height: '100%', display: 'flex', alignItems: 'center', zIndex: 1 }}>
+          <div style={{ position: 'absolute', right: 40, height: '100%', display: 'flex', alignItems: 'center', }}>
             <LayeredCircles style={{ height: 260 }} />
           </div>
         </div>
@@ -153,7 +152,6 @@ const NetworkSection = ({ intl, smScreen }: { intl: IntlShape, smScreen: boolean
           display: 'flex', flexDirection: 'column',
           justifyContent: smScreen ? 'flex-start' : 'center',
           paddingBlockStart: smScreen ? 100 : 0,
-          zIndex: 2,
         }}>
           <div style={{ borderBottom: "0.5px solid #808088", width: 160, marginBottom: 90 }}>
             <Paragraph style={headingStyles} type="secondary">

+ 3 - 1
node/cmd/guardiand/adminclient.go

@@ -2,6 +2,7 @@ package guardiand
 
 import (
 	"context"
+	"encoding/hex"
 	"fmt"
 	publicrpcv1 "github.com/certusone/wormhole/node/pkg/proto/publicrpc/v1"
 	"github.com/certusone/wormhole/node/pkg/vaa"
@@ -203,5 +204,6 @@ func runDumpVAAByMessageID(cmd *cobra.Command, args []string) {
 		log.Fatalf("failed to decode VAA: %v", err)
 	}
 
-	log.Printf("VAA with digest %s: %+v", v.HexDigest(), spew.Sdump(v))
+	log.Printf("VAA with digest %s: %+v\n", v.HexDigest(), spew.Sdump(v))
+	fmt.Printf("Bytes:\n%s\n", hex.EncodeToString(resp.VaaBytes))
 }

+ 5 - 0
node/hack/discord_test/discord.go

@@ -50,4 +50,9 @@ func main() {
 		"Certus One", "Not Certus One"}); err != nil {
 		logger.Fatal("failed to send test message", zap.Error(err))
 	}
+
+	if err := d.MissingSignaturesOnTransaction(v, 14, 13, true, []string{
+		"Certus One"}); err != nil {
+		logger.Fatal("failed to send test message", zap.Error(err))
+	}
 }

+ 53 - 5
node/pkg/notify/discord/notify.go

@@ -8,12 +8,16 @@ import (
 	"github.com/diamondburned/arikawa/v3/discord"
 	"go.uber.org/zap"
 	"strings"
+	"sync"
 )
 
 type DiscordNotifier struct {
 	c      *api.Client
 	chans  []discord.Channel
 	logger *zap.Logger
+
+	groupToIDMu sync.RWMutex
+	groupToID   map[string]string
 }
 
 // NewDiscordNotifier returns and initializes a new Discord notifier.
@@ -45,9 +49,10 @@ func NewDiscordNotifier(botToken string, channelName string, logger *zap.Logger)
 	logger.Info("notification channels", zap.Any("channels", chans))
 
 	return &DiscordNotifier{
-		c:      c,
-		chans:  chans,
-		logger: logger,
+		c:         c,
+		chans:     chans,
+		logger:    logger,
+		groupToID: make(map[string]string),
 	}, nil
 }
 
@@ -55,7 +60,42 @@ func wrapCode(in string) string {
 	return fmt.Sprintf("`%s`", in)
 }
 
-func (d DiscordNotifier) MissingSignaturesOnTransaction(v *vaa.VAA, hasSigs, wantSigs int, quorum bool, missing []string) error {
+func (d *DiscordNotifier) LookupGroupID(groupName string) (string, error) {
+	d.groupToIDMu.RLock()
+	if id, ok := d.groupToID[groupName]; ok {
+		d.groupToIDMu.RUnlock()
+		return id, nil
+	}
+	d.groupToIDMu.RUnlock()
+
+	guilds, err := d.c.Guilds(0)
+	if err != nil {
+		return "", fmt.Errorf("failed to retrieve guilds: %w", err)
+	}
+
+	for _, guild := range guilds {
+		gcn, err := d.c.Roles(guild.ID)
+		if err != nil {
+			return "", fmt.Errorf("failed to retrieve roles for %s: %w", guild.ID, err)
+		}
+
+		for _, cn := range gcn {
+			if cn.Name == groupName {
+				m := cn.ID.String()
+
+				d.groupToIDMu.Lock()
+				d.groupToID[groupName] = m
+				d.groupToIDMu.Unlock()
+
+				return m, nil
+			}
+		}
+	}
+
+	return "", fmt.Errorf("failed to find group %s", groupName)
+}
+
+func (d *DiscordNotifier) MissingSignaturesOnTransaction(v *vaa.VAA, hasSigs, wantSigs int, quorum bool, missing []string) error {
 	if len(missing) == 0 {
 		panic("no missing nodes specified")
 	}
@@ -73,7 +113,15 @@ func (d DiscordNotifier) MissingSignaturesOnTransaction(v *vaa.VAA, hasSigs, wan
 
 	missingText := &bytes.Buffer{}
 	for _, m := range missing {
-		if _, err := fmt.Fprintf(missingText, "- %s\n", m); err != nil {
+		groupID, err := d.LookupGroupID(m)
+		if err != nil {
+			d.logger.Error("failed to lookup group id", zap.Error(err), zap.String("name", m))
+			groupID = m
+		} else {
+			groupID = fmt.Sprintf("<@&%s>", groupID)
+		}
+
+		if _, err := fmt.Fprintf(missingText, "- %s\n", groupID); err != nil {
 			panic(err)
 		}
 	}

+ 9 - 5
node/pkg/processor/cleanup.go

@@ -101,11 +101,15 @@ func (p *Processor) handleCleanup(ctx context.Context) {
 						}
 					}
 
-					go func(v *vaa.VAA, hasSigs, wantSigs int, quorum bool, missing []string) {
-						if err := p.notifier.MissingSignaturesOnTransaction(v, hasSigs, wantSigs, quorum, missing); err != nil {
-							p.logger.Error("failed to send notification", zap.Error(err))
-						}
-					}(s.ourVAA, hasSigs, wantSigs, quorum, missing)
+					// Send notification for individual message when quorum has failed or
+					// more than one node is missing.
+					if !quorum || len(missing) > 1 {
+						go func(v *vaa.VAA, hasSigs, wantSigs int, quorum bool, missing []string) {
+							if err := p.notifier.MissingSignaturesOnTransaction(v, hasSigs, wantSigs, quorum, missing); err != nil {
+								p.logger.Error("failed to send notification", zap.Error(err))
+							}
+						}(s.ourVAA, hasSigs, wantSigs, quorum, missing)
+					}
 				}
 			}
 

+ 20 - 0
sdk/js/CHANGELOG.md

@@ -1,5 +1,25 @@
 # Changelog
 
+## 0.0.10
+
+### Added
+
+uint8ArrayToNative utility function for converting to native addresses from the uint8 format
+
+Include node target wasms in lib
+
+## 0.0.9
+
+### Added
+
+Integration tests
+
+NodeJS target wasm
+
+Ability to update attestations on EVM chains & Terra.
+
+nativeToHexString utility function for converting native addresses into VAA hex format.
+
 ## 0.0.8
 
 ### Added

+ 7 - 0
sdk/js/jestconfig.json

@@ -0,0 +1,7 @@
+{
+  "transform": {
+    "^.+\\.(t|j)sx?$": "ts-jest"
+  },
+  "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
+  "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"]
+}

File diff suppressed because it is too large
+ 1025 - 589
sdk/js/package-lock.json


+ 6 - 2
sdk/js/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@certusone/wormhole-sdk",
-  "version": "0.0.8",
+  "version": "0.0.10",
   "description": "SDK for interacting with Wormhole",
   "homepage": "https://wormholenetwork.com",
   "main": "lib/index.js",
@@ -15,7 +15,7 @@
     "build-deps": "npm run build-abis && npm run build-contracts",
     "build-lib": "tsc && node scripts/copyEthersTypes.js && node scripts/copyWasm.js",
     "build-all": "npm run build-deps && npm run build-lib",
-    "test": "echo \"Error: no test specified\" && exit 1",
+    "test": "jest --config jestconfig.json --verbose",
     "build": "npm run build-all",
     "format": "echo \"disabled: prettier --write \"src/**/*.ts\"\"",
     "lint": "tslint -p tsconfig.json",
@@ -38,14 +38,18 @@
   "author": "certusone",
   "license": "Apache-2.0",
   "devDependencies": {
+    "@improbable-eng/grpc-web-node-http-transport": "^0.15.0",
     "@openzeppelin/contracts": "^4.2.0",
     "@typechain/ethers-v5": "^7.0.1",
+    "@types/jest": "^27.0.2",
     "@types/long": "^4.0.1",
     "@types/node": "^16.6.1",
     "@types/react": "^17.0.19",
     "copy-dir": "^1.3.0",
     "ethers": "^5.4.4",
+    "jest": "^27.3.1",
     "prettier": "^2.3.2",
+    "ts-jest": "^27.0.7",
     "tslint": "^6.1.3",
     "tslint-config-prettier": "^1.18.0",
     "typescript": "^4.3.5"

+ 32 - 0
sdk/js/scripts/copyWasm.js

@@ -3,31 +3,63 @@ fs.copyFileSync(
   "src/solana/core/bridge_bg.wasm",
   "lib/solana/core/bridge_bg.wasm"
 );
+fs.copyFileSync(
+  "src/solana/core-node/bridge_bg.wasm",
+  "lib/solana/core-node/bridge_bg.wasm"
+);
 fs.copyFileSync(
   "src/solana/core/bridge_bg.wasm.d.ts",
   "lib/solana/core/bridge_bg.wasm.d.ts"
 );
+fs.copyFileSync(
+  "src/solana/core-node/bridge_bg.wasm.d.ts",
+  "lib/solana/core-node/bridge_bg.wasm.d.ts"
+);
 fs.copyFileSync(
   "src/solana/nft/nft_bridge_bg.wasm",
   "lib/solana/nft/nft_bridge_bg.wasm"
 );
+fs.copyFileSync(
+  "src/solana/nft-node/nft_bridge_bg.wasm",
+  "lib/solana/nft-node/nft_bridge_bg.wasm"
+);
 fs.copyFileSync(
   "src/solana/nft/nft_bridge_bg.wasm.d.ts",
   "lib/solana/nft/nft_bridge_bg.wasm.d.ts"
 );
+fs.copyFileSync(
+  "src/solana/nft-node/nft_bridge_bg.wasm.d.ts",
+  "lib/solana/nft-node/nft_bridge_bg.wasm.d.ts"
+);
 fs.copyFileSync(
   "src/solana/token/token_bridge_bg.wasm",
   "lib/solana/token/token_bridge_bg.wasm"
 );
+fs.copyFileSync(
+  "src/solana/token-node/token_bridge_bg.wasm",
+  "lib/solana/token-node/token_bridge_bg.wasm"
+);
 fs.copyFileSync(
   "src/solana/token/token_bridge_bg.wasm.d.ts",
   "lib/solana/token/token_bridge_bg.wasm.d.ts"
 );
+fs.copyFileSync(
+  "src/solana/token-node/token_bridge_bg.wasm.d.ts",
+  "lib/solana/token-node/token_bridge_bg.wasm.d.ts"
+);
 fs.copyFileSync(
   "src/solana/migration/wormhole_migration_bg.wasm",
   "lib/solana/migration/wormhole_migration_bg.wasm"
 );
+fs.copyFileSync(
+  "src/solana/migration-node/wormhole_migration_bg.wasm",
+  "lib/solana/migration-node/wormhole_migration_bg.wasm"
+);
 fs.copyFileSync(
   "src/solana/migration/wormhole_migration_bg.wasm.d.ts",
   "lib/solana/migration/wormhole_migration_bg.wasm.d.ts"
 );
+fs.copyFileSync(
+  "src/solana/migration-node/wormhole_migration_bg.wasm.d.ts",
+  "lib/solana/migration-node/wormhole_migration_bg.wasm.d.ts"
+);

+ 2 - 1
sdk/js/src/bridge/getClaimAddress.ts

@@ -1,9 +1,10 @@
 import { PublicKey } from "@solana/web3.js";
+import { importCoreWasm } from "../solana/wasm";
 
 export async function getClaimAddressSolana(
   programAddress: string,
   signedVAA: Uint8Array
 ) {
-  const { claim_address } = await import("../solana/core/bridge");
+  const { claim_address } = await importCoreWasm();
   return new PublicKey(claim_address(programAddress, signedVAA));
 }

+ 2 - 1
sdk/js/src/bridge/getEmitterAddress.ts

@@ -1,6 +1,7 @@
 import { PublicKey } from "@solana/web3.js";
 import { bech32 } from "bech32";
 import { arrayify, BytesLike, Hexable, zeroPad } from "ethers/lib/utils";
+import { importTokenWasm } from "../solana/wasm";
 
 export function getEmitterAddressEth(
   contractAddress: number | BytesLike | Hexable
@@ -9,7 +10,7 @@ export function getEmitterAddressEth(
 }
 
 export async function getEmitterAddressSolana(programAddress: string) {
-  const { emitter_address } = await import("../solana/token/token_bridge");
+  const { emitter_address } = await importTokenWasm();
   return Buffer.from(
     zeroPad(new PublicKey(emitter_address(programAddress)).toBytes(), 32)
   ).toString("hex");

+ 2 - 3
sdk/js/src/migration/addLiquidity.ts

@@ -1,6 +1,7 @@
 import { Token, TOKEN_PROGRAM_ID, u64 } from "@solana/spl-token";
 import { Connection, PublicKey, Transaction } from "@solana/web3.js";
 import { ixFromRust } from "../solana";
+import { importMigrationWasm } from "../solana/wasm";
 
 export default async function addLiquidity(
   connection: Connection,
@@ -12,9 +13,7 @@ export default async function addLiquidity(
   lp_share_token_account: string,
   amount: BigInt
 ) {
-  const { authority_address, add_liquidity } = await import(
-    "../solana/migration/wormhole_migration"
-  );
+  const { authority_address, add_liquidity } = await importMigrationWasm();
   const approvalIx = Token.createApproveInstruction(
     TOKEN_PROGRAM_ID,
     new PublicKey(liquidity_token_account),

+ 3 - 3
sdk/js/src/migration/authorityAddress.ts

@@ -1,6 +1,6 @@
+import { importMigrationWasm } from "../solana/wasm";
+
 export default async function authorityAddress(program_id: string) {
-  const { authority_address } = await import(
-    "../solana/migration/wormhole_migration"
-  );
+  const { authority_address } = await importMigrationWasm();
   return authority_address(program_id);
 }

+ 2 - 3
sdk/js/src/migration/claimShares.ts

@@ -1,6 +1,7 @@
 import { Token, TOKEN_PROGRAM_ID, u64 } from "@solana/spl-token";
 import { Connection, PublicKey, Transaction } from "@solana/web3.js";
 import { ixFromRust } from "../solana";
+import { importMigrationWasm } from "../solana/wasm";
 
 export default async function claimShares(
   connection: Connection,
@@ -12,9 +13,7 @@ export default async function claimShares(
   lp_share_token_account: string,
   amount: BigInt
 ) {
-  const { authority_address, claim_shares } = await import(
-    "../solana/migration/wormhole_migration"
-  );
+  const { authority_address, claim_shares } = await importMigrationWasm();
   const approvalIx = Token.createApproveInstruction(
     TOKEN_PROGRAM_ID,
     new PublicKey(lp_share_token_account),

+ 2 - 3
sdk/js/src/migration/createPool.ts

@@ -1,5 +1,6 @@
 import { Connection, PublicKey, Transaction } from "@solana/web3.js";
 import { ixFromRust } from "../solana";
+import { importMigrationWasm } from "../solana/wasm";
 
 export default async function createPool(
   connection: Connection,
@@ -9,9 +10,7 @@ export default async function createPool(
   from_mint: string,
   to_mint: string
 ) {
-  const { create_pool } = await import(
-    "../solana/migration/wormhole_migration"
-  );
+  const { create_pool } = await importMigrationWasm();
   const ix = ixFromRust(create_pool(program_id, payer, from_mint, to_mint));
   const transaction = new Transaction().add(ix);
   const { blockhash } = await connection.getRecentBlockhash();

+ 3 - 3
sdk/js/src/migration/fromCustodyAddress.ts

@@ -1,9 +1,9 @@
+import { importMigrationWasm } from "../solana/wasm";
+
 export default async function fromCustodyAddress(
   program_id: string,
   pool: string
 ) {
-  const { from_custody_address } = await import(
-    "../solana/migration/wormhole_migration"
-  );
+  const { from_custody_address } = await importMigrationWasm();
   return from_custody_address(program_id, pool);
 }

+ 2 - 3
sdk/js/src/migration/migrateTokens.ts

@@ -1,6 +1,7 @@
 import { Token, TOKEN_PROGRAM_ID, u64 } from "@solana/spl-token";
 import { Connection, PublicKey, Transaction } from "@solana/web3.js";
 import { ixFromRust } from "../solana";
+import { importMigrationWasm } from "../solana/wasm";
 
 export default async function migrateTokens(
   connection: Connection,
@@ -12,9 +13,7 @@ export default async function migrateTokens(
   output_token_account: string,
   amount: BigInt
 ) {
-  const { authority_address, migrate_tokens } = await import(
-    "../solana/migration/wormhole_migration"
-  );
+  const { authority_address, migrate_tokens } = await importMigrationWasm();
   const approvalIx = Token.createApproveInstruction(
     TOKEN_PROGRAM_ID,
     new PublicKey(input_token_account),

+ 3 - 1
sdk/js/src/migration/parsePool.ts

@@ -1,4 +1,6 @@
+import { importMigrationWasm } from "../solana/wasm";
+
 export default async function parsePool(data: Uint8Array) {
-  const { parse_pool } = await import("../solana/migration/wormhole_migration");
+  const { parse_pool } = await importMigrationWasm();
   return parse_pool(data);
 }

+ 3 - 3
sdk/js/src/migration/poolAddress.ts

@@ -1,10 +1,10 @@
+import { importMigrationWasm } from "../solana/wasm";
+
 export default async function poolAddress(
   program_id: string,
   from_mint: string,
   to_mint: string
 ) {
-  const { pool_address } = await import(
-    "../solana/migration/wormhole_migration"
-  );
+  const { pool_address } = await importMigrationWasm();
   return pool_address(program_id, from_mint, to_mint);
 }

+ 2 - 3
sdk/js/src/migration/removeLiquidity.ts

@@ -1,6 +1,7 @@
 import { Token, TOKEN_PROGRAM_ID, u64 } from "@solana/spl-token";
 import { Connection, PublicKey, Transaction } from "@solana/web3.js";
 import { ixFromRust } from "../solana";
+import { importMigrationWasm } from "../solana/wasm";
 
 export default async function removeLiquidity(
   connection: Connection,
@@ -12,9 +13,7 @@ export default async function removeLiquidity(
   lp_share_token_account: string,
   amount: BigInt
 ) {
-  const { authority_address, remove_liquidity } = await import(
-    "../solana/migration/wormhole_migration"
-  );
+  const { authority_address, remove_liquidity } = await importMigrationWasm();
   const approvalIx = Token.createApproveInstruction(
     TOKEN_PROGRAM_ID,
     new PublicKey(lp_share_token_account),

+ 3 - 3
sdk/js/src/migration/shareMintAddress.ts

@@ -1,9 +1,9 @@
+import { importMigrationWasm } from "../solana/wasm";
+
 export default async function shareMintAddress(
   program_id: string,
   pool: string
 ) {
-  const { share_mint_address } = await import(
-    "../solana/migration/wormhole_migration"
-  );
+  const { share_mint_address } = await importMigrationWasm();
   return share_mint_address(program_id, pool);
 }

+ 3 - 3
sdk/js/src/migration/toCustodyAddress.ts

@@ -1,9 +1,9 @@
+import { importMigrationWasm } from "../solana/wasm";
+
 export default async function toCustodyAddress(
   program_id: string,
   pool: string
 ) {
-  const { to_custody_address } = await import(
-    "../solana/migration/wormhole_migration"
-  );
+  const { to_custody_address } = await importMigrationWasm();
   return to_custody_address(program_id, pool);
 }

+ 2 - 1
sdk/js/src/nft_bridge/getForeignAsset.ts

@@ -2,6 +2,7 @@ import { PublicKey } from "@solana/web3.js";
 import { ethers } from "ethers";
 import { CHAIN_ID_SOLANA } from "..";
 import { NFTBridge__factory } from "../ethers-contracts";
+import { importNftWasm } from "../solana/wasm";
 import { ChainId } from "../utils";
 
 /**
@@ -47,7 +48,7 @@ export async function getForeignAssetSol(
   originAsset: Uint8Array,
   tokenId: Uint8Array
 ) {
-  const { wrapped_address } = await import("../solana/nft/nft_bridge");
+  const { wrapped_address } = await importNftWasm();
   const wrappedAddress = wrapped_address(
     tokenBridgeAddress,
     originAsset,

+ 2 - 1
sdk/js/src/nft_bridge/getIsWrappedAsset.ts

@@ -1,6 +1,7 @@
 import { Connection, PublicKey } from "@solana/web3.js";
 import { ethers } from "ethers";
 import { Bridge__factory } from "../ethers-contracts";
+import { importNftWasm } from "../solana/wasm";
 
 /**
  * Returns whether or not an asset address on Ethereum is a wormhole wrapped asset
@@ -32,7 +33,7 @@ export async function getIsWrappedAssetSol(
   mintAddress: string
 ) {
   if (!mintAddress) return false;
-  const { wrapped_meta_address } = await import("../solana/nft/nft_bridge");
+  const { wrapped_meta_address } = await importNftWasm();
   const wrappedMetaAddress = wrapped_meta_address(
     tokenBridgeAddress,
     new PublicKey(mintAddress).toBytes()

+ 2 - 3
sdk/js/src/nft_bridge/getOriginalAsset.ts

@@ -2,6 +2,7 @@ import { Connection, PublicKey } from "@solana/web3.js";
 import { BigNumber, ethers } from "ethers";
 import { arrayify, zeroPad } from "ethers/lib/utils";
 import { TokenImplementation__factory } from "../ethers-contracts";
+import { importNftWasm } from "../solana/wasm";
 import { ChainId, CHAIN_ID_SOLANA } from "../utils";
 import { getIsWrappedAssetEth } from "./getIsWrappedAsset";
 
@@ -70,9 +71,7 @@ export async function getOriginalAssetSol(
 ): Promise<WormholeWrappedNFTInfo> {
   if (mintAddress) {
     // TODO: share some of this with getIsWrappedAssetSol, like a getWrappedMetaAccountAddress or something
-    const { parse_wrapped_meta, wrapped_meta_address } = await import(
-      "../solana/nft/nft_bridge"
-    );
+    const { parse_wrapped_meta, wrapped_meta_address } = await importNftWasm();
     const wrappedMetaAddress = wrapped_meta_address(
       tokenBridgeAddress,
       new PublicKey(mintAddress).toBytes()

+ 4 - 5
sdk/js/src/nft_bridge/redeem.ts

@@ -3,6 +3,7 @@ import { ethers } from "ethers";
 import { CHAIN_ID_SOLANA } from "..";
 import { Bridge__factory } from "../ethers-contracts";
 import { ixFromRust } from "../solana";
+import { importCoreWasm, importNftWasm } from "../solana/wasm";
 
 export async function redeemOnEth(
   tokenBridgeAddress: string,
@@ -16,7 +17,7 @@ export async function redeemOnEth(
 }
 
 export async function isNFTVAASolanaNative(signedVAA: Uint8Array) {
-  const { parse_vaa } = await import("../solana/core/bridge");
+  const { parse_vaa } = await importCoreWasm();
   const parsedVAA = parse_vaa(signedVAA);
   const isSolanaNative =
     Buffer.from(new Uint8Array(parsedVAA.payload)).readUInt16BE(33) ===
@@ -33,7 +34,7 @@ export async function redeemOnSolana(
 ) {
   const isSolanaNative = await isNFTVAASolanaNative(signedVAA);
   const { complete_transfer_wrapped_ix, complete_transfer_native_ix } =
-    await import("../solana/nft/nft_bridge");
+    await importNftWasm();
   const ixs = [];
   if (isSolanaNative) {
     ixs.push(
@@ -74,9 +75,7 @@ export async function createMetaOnSolana(
   payerAddress: string,
   signedVAA: Uint8Array
 ) {
-  const { complete_transfer_wrapped_meta_ix } = await import(
-    "../solana/nft/nft_bridge"
-  );
+  const { complete_transfer_wrapped_meta_ix } = await importNftWasm();
   const ix = ixFromRust(
     complete_transfer_wrapped_meta_ix(
       tokenBridgeAddress,

+ 98 - 95
sdk/js/src/nft_bridge/transfer.ts

@@ -1,106 +1,109 @@
-import {Token, TOKEN_PROGRAM_ID} from "@solana/spl-token";
-import {Connection, Keypair, PublicKey, Transaction} from "@solana/web3.js";
-import {ethers} from "ethers";
+import { Token, TOKEN_PROGRAM_ID } from "@solana/spl-token";
+import { Connection, Keypair, PublicKey, Transaction } from "@solana/web3.js";
+import { ethers } from "ethers";
 import {
-    NFTBridge__factory,
-    NFTImplementation__factory,
+  NFTBridge__factory,
+  NFTImplementation__factory,
 } from "../ethers-contracts";
-import {getBridgeFeeIx, ixFromRust} from "../solana";
-import {ChainId, CHAIN_ID_SOLANA, createNonce} from "../utils";
+import { getBridgeFeeIx, ixFromRust } from "../solana";
+import { importNftWasm } from "../solana/wasm";
+import { ChainId, CHAIN_ID_SOLANA, createNonce } from "../utils";
 
 export async function transferFromEth(
-    tokenBridgeAddress: string,
-    signer: ethers.Signer,
-    tokenAddress: string,
-    tokenID: ethers.BigNumberish,
-    recipientChain: ChainId,
-    recipientAddress: Uint8Array
+  tokenBridgeAddress: string,
+  signer: ethers.Signer,
+  tokenAddress: string,
+  tokenID: ethers.BigNumberish,
+  recipientChain: ChainId,
+  recipientAddress: Uint8Array
 ) {
-    //TODO: should we check if token attestation exists on the target chain
-    const token = NFTImplementation__factory.connect(tokenAddress, signer);
-    await (await token.approve(tokenBridgeAddress, tokenID)).wait();
-    const bridge = NFTBridge__factory.connect(tokenBridgeAddress, signer);
-    const v = await bridge.transferNFT(
-        tokenAddress,
-        tokenID,
-        recipientChain,
-        recipientAddress,
-        createNonce()
-    );
-    const receipt = await v.wait();
-    return receipt;
+  //TODO: should we check if token attestation exists on the target chain
+  const token = NFTImplementation__factory.connect(tokenAddress, signer);
+  await (await token.approve(tokenBridgeAddress, tokenID)).wait();
+  const bridge = NFTBridge__factory.connect(tokenBridgeAddress, signer);
+  const v = await bridge.transferNFT(
+    tokenAddress,
+    tokenID,
+    recipientChain,
+    recipientAddress,
+    createNonce()
+  );
+  const receipt = await v.wait();
+  return receipt;
 }
 
 export async function transferFromSolana(
-    connection: Connection,
-    bridgeAddress: string,
-    tokenBridgeAddress: string,
-    payerAddress: string,
-    fromAddress: string,
-    mintAddress: string,
-    targetAddress: Uint8Array,
-    targetChain: ChainId,
-    originAddress?: Uint8Array,
-    originChain?: ChainId,
-    originTokenId?: Uint8Array
+  connection: Connection,
+  bridgeAddress: string,
+  tokenBridgeAddress: string,
+  payerAddress: string,
+  fromAddress: string,
+  mintAddress: string,
+  targetAddress: Uint8Array,
+  targetChain: ChainId,
+  originAddress?: Uint8Array,
+  originChain?: ChainId,
+  originTokenId?: Uint8Array
 ) {
-    const nonce = createNonce().readUInt32LE(0);
-    const transferIx = await getBridgeFeeIx(
-        connection,
-        bridgeAddress,
-        payerAddress
-    );
-    const {
-        transfer_native_ix,
-        transfer_wrapped_ix,
-        approval_authority_address,
-    } = await import("../solana/nft/nft_bridge");
-    const approvalIx = Token.createApproveInstruction(
-        TOKEN_PROGRAM_ID,
-        new PublicKey(fromAddress),
-        new PublicKey(approval_authority_address(tokenBridgeAddress)),
-        new PublicKey(payerAddress),
-        [],
-        Number(1)
-    );
-    let messageKey = Keypair.generate();
-    const isSolanaNative =
-        originChain === undefined || originChain === CHAIN_ID_SOLANA;
-    if (!isSolanaNative && !originAddress && !originTokenId) {
-        throw new Error("originAddress and originTokenId are required when specifying originChain");
-    }
-    const ix = ixFromRust(
-        isSolanaNative
-            ? transfer_native_ix(
-                tokenBridgeAddress,
-                bridgeAddress,
-                payerAddress,
-                messageKey.publicKey.toString(),
-                fromAddress,
-                mintAddress,
-                nonce,
-                targetAddress,
-                targetChain
-            )
-            : transfer_wrapped_ix(
-                tokenBridgeAddress,
-                bridgeAddress,
-                payerAddress,
-                messageKey.publicKey.toString(),
-                fromAddress,
-                payerAddress,
-                originChain as number, // checked by isSolanaNative
-                originAddress as Uint8Array, // checked by throw
-                originTokenId as Uint8Array, // checked by throw
-                nonce,
-                targetAddress,
-                targetChain
-            )
+  const nonce = createNonce().readUInt32LE(0);
+  const transferIx = await getBridgeFeeIx(
+    connection,
+    bridgeAddress,
+    payerAddress
+  );
+  const {
+    transfer_native_ix,
+    transfer_wrapped_ix,
+    approval_authority_address,
+  } = await importNftWasm();
+  const approvalIx = Token.createApproveInstruction(
+    TOKEN_PROGRAM_ID,
+    new PublicKey(fromAddress),
+    new PublicKey(approval_authority_address(tokenBridgeAddress)),
+    new PublicKey(payerAddress),
+    [],
+    Number(1)
+  );
+  let messageKey = Keypair.generate();
+  const isSolanaNative =
+    originChain === undefined || originChain === CHAIN_ID_SOLANA;
+  if (!isSolanaNative && !originAddress && !originTokenId) {
+    throw new Error(
+      "originAddress and originTokenId are required when specifying originChain"
     );
-    const transaction = new Transaction().add(transferIx, approvalIx, ix);
-    const {blockhash} = await connection.getRecentBlockhash();
-    transaction.recentBlockhash = blockhash;
-    transaction.feePayer = new PublicKey(payerAddress);
-    transaction.partialSign(messageKey);
-    return transaction;
+  }
+  const ix = ixFromRust(
+    isSolanaNative
+      ? transfer_native_ix(
+          tokenBridgeAddress,
+          bridgeAddress,
+          payerAddress,
+          messageKey.publicKey.toString(),
+          fromAddress,
+          mintAddress,
+          nonce,
+          targetAddress,
+          targetChain
+        )
+      : transfer_wrapped_ix(
+          tokenBridgeAddress,
+          bridgeAddress,
+          payerAddress,
+          messageKey.publicKey.toString(),
+          fromAddress,
+          payerAddress,
+          originChain as number, // checked by isSolanaNative
+          originAddress as Uint8Array, // checked by throw
+          originTokenId as Uint8Array, // checked by throw
+          nonce,
+          targetAddress,
+          targetChain
+        )
+  );
+  const transaction = new Transaction().add(transferIx, approvalIx, ix);
+  const { blockhash } = await connection.getRecentBlockhash();
+  transaction.recentBlockhash = blockhash;
+  transaction.feePayer = new PublicKey(payerAddress);
+  transaction.partialSign(messageKey);
+  return transaction;
 }

+ 34 - 0
sdk/js/src/rpc/getSignedVAAWithRetry.ts

@@ -0,0 +1,34 @@
+import { ChainId, getSignedVAA } from "..";
+
+export default async function getSignedVAAWithRetry(
+  hosts: string[],
+  emitterChain: ChainId,
+  emitterAddress: string,
+  sequence: string,
+  extraGrpcOpts = {},
+  retryTimeout = 1000,
+  retryAttempts?: number
+) {
+  let currentWormholeRpcHost = -1;
+  const getNextRpcHost = () => ++currentWormholeRpcHost % hosts.length;
+  let result;
+  let attempts = 0;
+  while (!result) {
+    attempts++;
+    await new Promise((resolve) => setTimeout(resolve, retryTimeout));
+    try {
+      result = await getSignedVAA(
+        hosts[getNextRpcHost()],
+        emitterChain,
+        emitterAddress,
+        sequence,
+        extraGrpcOpts
+      );
+    } catch (e) {
+      if (retryAttempts !== undefined && attempts > retryAttempts) {
+        throw e;
+      }
+    }
+  }
+  return result;
+}

+ 2 - 1
sdk/js/src/solana/getBridgeFeeIx.ts

@@ -1,11 +1,12 @@
 import { Connection, PublicKey, SystemProgram } from "@solana/web3.js";
+import { importCoreWasm } from "./wasm";
 
 export async function getBridgeFeeIx(
   connection: Connection,
   bridgeAddress: string,
   payerAddress: string
 ) {
-  const bridge = await import("./core/bridge");
+  const bridge = await importCoreWasm();
   const feeAccount = await bridge.fee_collector_address(bridgeAddress);
   const bridgeStatePK = new PublicKey(bridge.state_address(bridgeAddress));
   const bridgeStateAccountInfo = await connection.getAccountInfo(bridgeStatePK);

+ 4 - 3
sdk/js/src/solana/postVaa.ts

@@ -6,11 +6,12 @@ import {
   TransactionInstruction,
 } from "@solana/web3.js";
 import { ixFromRust } from "./rust";
+import { importCoreWasm } from "./wasm";
 
 // is there a better pattern for this?
 export async function postVaa(
   connection: Connection,
-  signTransaction: (transaction: Transaction) => any,
+  signTransaction: (transaction: Transaction) => Promise<Transaction>,
   bridge_id: string,
   payer: string,
   vaa: Buffer
@@ -20,7 +21,7 @@ export async function postVaa(
     parse_guardian_set,
     verify_signatures_ix,
     post_vaa_ix,
-  } = await import("./core/bridge");
+  } = await importCoreWasm();
   let bridge_state = await getBridgeState(connection, bridge_id);
   let guardian_addr = new PublicKey(
     guardian_set_address(bridge_id, bridge_state.guardian_set_index)
@@ -74,7 +75,7 @@ async function getBridgeState(
   connection: Connection,
   bridge_id: string
 ): Promise<BridgeState> {
-  const { parse_state, state_address } = await import("./core/bridge");
+  const { parse_state, state_address } = await importCoreWasm();
   let bridge_state = new PublicKey(state_address(bridge_id));
   let acc = await connection.getAccountInfo(bridge_state);
   if (acc?.data === undefined) {

+ 38 - 0
sdk/js/src/solana/wasm.ts

@@ -0,0 +1,38 @@
+const coreWasms = {
+  bundler: async () => await import("./core/bridge"),
+  node: async () => await import("./core-node/bridge"),
+};
+const migrationWasms = {
+  bundler: async () => await import("./migration/wormhole_migration"),
+  node: async () => await import("./migration-node/wormhole_migration"),
+};
+const nftWasms = {
+  bundler: async () => await import("./nft/nft_bridge"),
+  node: async () => await import("./nft-node/nft_bridge"),
+};
+const tokenWasms = {
+  bundler: async () => await import("./token/token_bridge"),
+  node: async () => await import("./token-node/token_bridge"),
+};
+let importDefaultCoreWasm = coreWasms.bundler;
+let importDefaultMigrationWasm = migrationWasms.bundler;
+let importDefaultNftWasm = nftWasms.bundler;
+let importDefaultTokenWasm = tokenWasms.bundler;
+export function setDefaultWasm(type: "bundler" | "node") {
+  importDefaultCoreWasm = coreWasms[type];
+  importDefaultMigrationWasm = migrationWasms[type];
+  importDefaultNftWasm = nftWasms[type];
+  importDefaultTokenWasm = tokenWasms[type];
+}
+export async function importCoreWasm() {
+  return await importDefaultCoreWasm();
+}
+export async function importMigrationWasm() {
+  return await importDefaultMigrationWasm();
+}
+export async function importNftWasm() {
+  return await importDefaultNftWasm();
+}
+export async function importTokenWasm() {
+  return await importDefaultTokenWasm();
+}

+ 35 - 0
sdk/js/src/token_bridge/__tests__/consts.ts

@@ -0,0 +1,35 @@
+import { describe, expect, it } from "@jest/globals";
+import { Connection, PublicKey } from "@solana/web3.js";
+
+// see devnet.md
+export const ETH_NODE_URL = "ws://localhost:8545";
+export const ETH_PRIVATE_KEY =
+  "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d";
+export const ETH_CORE_BRIDGE_ADDRESS =
+  "0xC89Ce4735882C9F0f0FE26686c53074E09B0D550";
+export const ETH_TOKEN_BRIDGE_ADDRESS =
+  "0x0290FB167208Af455bB137780163b7B7a9a10C16";
+export const SOLANA_HOST = "http://localhost:8899";
+export const SOLANA_PRIVATE_KEY = new Uint8Array([
+  14, 173, 153, 4, 176, 224, 201, 111, 32, 237, 183, 185, 159, 247, 22, 161, 89,
+  84, 215, 209, 212, 137, 10, 92, 157, 49, 29, 192, 101, 164, 152, 70, 87, 65,
+  8, 174, 214, 157, 175, 126, 98, 90, 54, 24, 100, 177, 247, 77, 19, 112, 47,
+  44, 165, 109, 233, 102, 14, 86, 109, 29, 134, 145, 132, 141,
+]);
+export const SOLANA_CORE_BRIDGE_ADDRESS =
+  "Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o";
+export const SOLANA_TOKEN_BRIDGE_ADDRESS =
+  "B6RHG3mfcckmrYN1UhmJzyS1XX3fZKbkeUcpJe9Sy3FE";
+export const TEST_ERC20 = "0x2D8BE6BF0baA74e0A907016679CaE9190e80dD0A";
+export const TEST_SOLANA_TOKEN = "2WDq7wSs9zYrpx2kbHDA4RUTRch2CCTP6ZWaH4GNfnQQ";
+export const WORMHOLE_RPC_HOSTS = ["http://localhost:7071"];
+
+describe("consts should exist", () => {
+  it("has Solana test token", () => {
+    expect.assertions(1);
+    const connection = new Connection(SOLANA_HOST, "confirmed");
+    return expect(
+      connection.getAccountInfo(new PublicKey(TEST_SOLANA_TOKEN))
+    ).resolves.toBeTruthy();
+  });
+});

+ 393 - 0
sdk/js/src/token_bridge/__tests__/integration.ts

@@ -0,0 +1,393 @@
+import { parseUnits } from "@ethersproject/units";
+import { NodeHttpTransport } from "@improbable-eng/grpc-web-node-http-transport";
+import { describe, jest, test } from "@jest/globals";
+import {
+  ASSOCIATED_TOKEN_PROGRAM_ID,
+  Token,
+  TOKEN_PROGRAM_ID,
+} from "@solana/spl-token";
+import { Connection, Keypair, PublicKey, Transaction } from "@solana/web3.js";
+import { ethers } from "ethers";
+import {
+  approveEth,
+  attestFromEth,
+  attestFromSolana,
+  CHAIN_ID_ETH,
+  CHAIN_ID_SOLANA,
+  createWrappedOnEth,
+  createWrappedOnSolana,
+  getEmitterAddressEth,
+  getEmitterAddressSolana,
+  getForeignAssetSolana,
+  hexToUint8Array,
+  nativeToHexString,
+  parseSequenceFromLogEth,
+  parseSequenceFromLogSolana,
+  postVaaSolana,
+  redeemOnEth,
+  redeemOnSolana,
+  transferFromEth,
+  transferFromSolana,
+} from "../..";
+import getSignedVAAWithRetry from "../../rpc/getSignedVAAWithRetry";
+import { setDefaultWasm } from "../../solana/wasm";
+import {
+  ETH_CORE_BRIDGE_ADDRESS,
+  ETH_NODE_URL,
+  ETH_PRIVATE_KEY,
+  ETH_TOKEN_BRIDGE_ADDRESS,
+  SOLANA_CORE_BRIDGE_ADDRESS,
+  SOLANA_HOST,
+  SOLANA_PRIVATE_KEY,
+  SOLANA_TOKEN_BRIDGE_ADDRESS,
+  TEST_ERC20,
+  TEST_SOLANA_TOKEN,
+  WORMHOLE_RPC_HOSTS,
+} from "./consts";
+
+setDefaultWasm("node");
+
+jest.setTimeout(60000);
+
+// TODO: setup keypair and provider/signer before, destroy provider after
+// TODO: make the repeatable (can't attest an already attested token)
+// TODO: add Terra
+
+describe("Integration Tests", () => {
+  describe("Ethereum to Solana", () => {
+    test("Attest Ethereum ERC-20 to Solana", (done) => {
+      (async () => {
+        try {
+          // create a signer for Eth
+          const provider = new ethers.providers.WebSocketProvider(ETH_NODE_URL);
+          const signer = new ethers.Wallet(ETH_PRIVATE_KEY, provider);
+          // attest the test token
+          const receipt = await attestFromEth(
+            ETH_TOKEN_BRIDGE_ADDRESS,
+            signer,
+            TEST_ERC20
+          );
+          // get the sequence from the logs (needed to fetch the vaa)
+          const sequence = parseSequenceFromLogEth(
+            receipt,
+            ETH_CORE_BRIDGE_ADDRESS
+          );
+          const emitterAddress = getEmitterAddressEth(ETH_TOKEN_BRIDGE_ADDRESS);
+          // poll until the guardian(s) witness and sign the vaa
+          const { vaaBytes: signedVAA } = await getSignedVAAWithRetry(
+            WORMHOLE_RPC_HOSTS,
+            CHAIN_ID_ETH,
+            emitterAddress,
+            sequence,
+            {
+              transport: NodeHttpTransport(),
+            }
+          );
+          // create a keypair for Solana
+          const keypair = Keypair.fromSecretKey(SOLANA_PRIVATE_KEY);
+          const payerAddress = keypair.publicKey.toString();
+          // post vaa to Solana
+          const connection = new Connection(SOLANA_HOST, "confirmed");
+          await postVaaSolana(
+            connection,
+            async (transaction) => {
+              transaction.partialSign(keypair);
+              return transaction;
+            },
+            SOLANA_CORE_BRIDGE_ADDRESS,
+            payerAddress,
+            Buffer.from(signedVAA)
+          );
+          // create wormhole wrapped token (mint and metadata) on solana
+          const transaction = await createWrappedOnSolana(
+            connection,
+            SOLANA_CORE_BRIDGE_ADDRESS,
+            SOLANA_TOKEN_BRIDGE_ADDRESS,
+            payerAddress,
+            signedVAA
+          );
+          // sign, send, and confirm transaction
+          try {
+            transaction.partialSign(keypair);
+            const txid = await connection.sendRawTransaction(
+              transaction.serialize()
+            );
+            await connection.confirmTransaction(txid);
+          } catch (e) {
+            // this could fail because the token is already attested (in an unclean env)
+          }
+          provider.destroy();
+          done();
+        } catch (e) {
+          console.error(e);
+          done(
+            "An error occurred while trying to attest from Ethereum to Solana"
+          );
+        }
+      })();
+    });
+    // TODO: it is attested
+    test("Send Ethereum ERC-20 to Solana", (done) => {
+      (async () => {
+        try {
+          // create a keypair for Solana
+          const connection = new Connection(SOLANA_HOST, "confirmed");
+          const keypair = Keypair.fromSecretKey(SOLANA_PRIVATE_KEY);
+          const payerAddress = keypair.publicKey.toString();
+          // determine destination address - an associated token account
+          const solanaMintKey = new PublicKey(
+            (await getForeignAssetSolana(
+              connection,
+              SOLANA_TOKEN_BRIDGE_ADDRESS,
+              CHAIN_ID_ETH,
+              hexToUint8Array(nativeToHexString(TEST_ERC20, CHAIN_ID_ETH) || "")
+            )) || ""
+          );
+          const recipient = await Token.getAssociatedTokenAddress(
+            ASSOCIATED_TOKEN_PROGRAM_ID,
+            TOKEN_PROGRAM_ID,
+            solanaMintKey,
+            keypair.publicKey
+          );
+          // create the associated token account if it doesn't exist
+          const associatedAddressInfo = await connection.getAccountInfo(
+            recipient
+          );
+          if (!associatedAddressInfo) {
+            const transaction = new Transaction().add(
+              await Token.createAssociatedTokenAccountInstruction(
+                ASSOCIATED_TOKEN_PROGRAM_ID,
+                TOKEN_PROGRAM_ID,
+                solanaMintKey,
+                recipient,
+                keypair.publicKey, // owner
+                keypair.publicKey // payer
+              )
+            );
+            const { blockhash } = await connection.getRecentBlockhash();
+            transaction.recentBlockhash = blockhash;
+            transaction.feePayer = keypair.publicKey;
+            // sign, send, and confirm transaction
+            transaction.partialSign(keypair);
+            const txid = await connection.sendRawTransaction(
+              transaction.serialize()
+            );
+            await connection.confirmTransaction(txid);
+          }
+          // create a signer for Eth
+          const provider = new ethers.providers.WebSocketProvider(ETH_NODE_URL);
+          const signer = new ethers.Wallet(ETH_PRIVATE_KEY, provider);
+          const amount = parseUnits("1", 18);
+          // approve the bridge to spend tokens
+          await approveEth(
+            ETH_TOKEN_BRIDGE_ADDRESS,
+            TEST_ERC20,
+            signer,
+            amount
+          );
+          // transfer tokens
+          const receipt = await transferFromEth(
+            ETH_TOKEN_BRIDGE_ADDRESS,
+            signer,
+            TEST_ERC20,
+            amount,
+            CHAIN_ID_SOLANA,
+            hexToUint8Array(
+              nativeToHexString(recipient.toString(), CHAIN_ID_SOLANA) || ""
+            )
+          );
+          // get the sequence from the logs (needed to fetch the vaa)
+          const sequence = parseSequenceFromLogEth(
+            receipt,
+            ETH_CORE_BRIDGE_ADDRESS
+          );
+          const emitterAddress = getEmitterAddressEth(ETH_TOKEN_BRIDGE_ADDRESS);
+          // poll until the guardian(s) witness and sign the vaa
+          const { vaaBytes: signedVAA } = await getSignedVAAWithRetry(
+            WORMHOLE_RPC_HOSTS,
+            CHAIN_ID_ETH,
+            emitterAddress,
+            sequence,
+            {
+              transport: NodeHttpTransport(),
+            }
+          );
+          // post vaa to Solana
+          await postVaaSolana(
+            connection,
+            async (transaction) => {
+              transaction.partialSign(keypair);
+              return transaction;
+            },
+            SOLANA_CORE_BRIDGE_ADDRESS,
+            payerAddress,
+            Buffer.from(signedVAA)
+          );
+          // redeem tokens on solana
+          const transaction = await redeemOnSolana(
+            connection,
+            SOLANA_CORE_BRIDGE_ADDRESS,
+            SOLANA_TOKEN_BRIDGE_ADDRESS,
+            payerAddress,
+            signedVAA
+          );
+          // sign, send, and confirm transaction
+          transaction.partialSign(keypair);
+          const txid = await connection.sendRawTransaction(
+            transaction.serialize()
+          );
+          await connection.confirmTransaction(txid);
+          provider.destroy();
+          done();
+        } catch (e) {
+          console.error(e);
+          done(
+            "An error occurred while trying to send from Ethereum to Solana"
+          );
+        }
+      })();
+    });
+    // TODO: it has increased balance
+  });
+  describe("Solana to Ethereum", () => {
+    test("Attest Solana SPL to Ethereum", (done) => {
+      (async () => {
+        try {
+          // create a keypair for Solana
+          const keypair = Keypair.fromSecretKey(SOLANA_PRIVATE_KEY);
+          const payerAddress = keypair.publicKey.toString();
+          // attest the test token
+          const connection = new Connection(SOLANA_HOST, "confirmed");
+          const transaction = await attestFromSolana(
+            connection,
+            SOLANA_CORE_BRIDGE_ADDRESS,
+            SOLANA_TOKEN_BRIDGE_ADDRESS,
+            payerAddress,
+            TEST_SOLANA_TOKEN
+          );
+          // sign, send, and confirm transaction
+          transaction.partialSign(keypair);
+          const txid = await connection.sendRawTransaction(
+            transaction.serialize()
+          );
+          await connection.confirmTransaction(txid);
+          const info = await connection.getTransaction(txid);
+          if (!info) {
+            throw new Error(
+              "An error occurred while fetching the transaction info"
+            );
+          }
+          // get the sequence from the logs (needed to fetch the vaa)
+          const sequence = parseSequenceFromLogSolana(info);
+          const emitterAddress = await getEmitterAddressSolana(
+            SOLANA_TOKEN_BRIDGE_ADDRESS
+          );
+          // poll until the guardian(s) witness and sign the vaa
+          const { vaaBytes: signedVAA } = await getSignedVAAWithRetry(
+            WORMHOLE_RPC_HOSTS,
+            CHAIN_ID_SOLANA,
+            emitterAddress,
+            sequence,
+            {
+              transport: NodeHttpTransport(),
+            }
+          );
+          // create a signer for Eth
+          const provider = new ethers.providers.WebSocketProvider(ETH_NODE_URL);
+          const signer = new ethers.Wallet(ETH_PRIVATE_KEY, provider);
+          try {
+            await createWrappedOnEth(
+              ETH_TOKEN_BRIDGE_ADDRESS,
+              signer,
+              signedVAA
+            );
+          } catch (e) {
+            // this could fail because the token is already attested (in an unclean env)
+          }
+          provider.destroy();
+          done();
+        } catch (e) {
+          console.error(e);
+          done(
+            "An error occurred while trying to attest from Solana to Ethereum"
+          );
+        }
+      })();
+    });
+    // TODO: it is attested
+    test("Send Solana SPL to Ethereum", (done) => {
+      (async () => {
+        try {
+          // create a signer for Eth
+          const provider = new ethers.providers.WebSocketProvider(ETH_NODE_URL);
+          const signer = new ethers.Wallet(ETH_PRIVATE_KEY, provider);
+          const targetAddress = await signer.getAddress();
+          // create a keypair for Solana
+          const keypair = Keypair.fromSecretKey(SOLANA_PRIVATE_KEY);
+          const payerAddress = keypair.publicKey.toString();
+          // find the associated token account
+          const fromAddress = (
+            await Token.getAssociatedTokenAddress(
+              ASSOCIATED_TOKEN_PROGRAM_ID,
+              TOKEN_PROGRAM_ID,
+              new PublicKey(TEST_SOLANA_TOKEN),
+              keypair.publicKey
+            )
+          ).toString();
+          // transfer the test token
+          const connection = new Connection(SOLANA_HOST, "confirmed");
+          const amount = parseUnits("1", 9).toBigInt();
+          const transaction = await transferFromSolana(
+            connection,
+            SOLANA_CORE_BRIDGE_ADDRESS,
+            SOLANA_TOKEN_BRIDGE_ADDRESS,
+            payerAddress,
+            fromAddress,
+            TEST_SOLANA_TOKEN,
+            amount,
+            hexToUint8Array(
+              nativeToHexString(targetAddress, CHAIN_ID_ETH) || ""
+            ),
+            CHAIN_ID_ETH
+          );
+          // sign, send, and confirm transaction
+          transaction.partialSign(keypair);
+          const txid = await connection.sendRawTransaction(
+            transaction.serialize()
+          );
+          await connection.confirmTransaction(txid);
+          const info = await connection.getTransaction(txid);
+          if (!info) {
+            throw new Error(
+              "An error occurred while fetching the transaction info"
+            );
+          }
+          // get the sequence from the logs (needed to fetch the vaa)
+          const sequence = parseSequenceFromLogSolana(info);
+          const emitterAddress = await getEmitterAddressSolana(
+            SOLANA_TOKEN_BRIDGE_ADDRESS
+          );
+          // poll until the guardian(s) witness and sign the vaa
+          const { vaaBytes: signedVAA } = await getSignedVAAWithRetry(
+            WORMHOLE_RPC_HOSTS,
+            CHAIN_ID_SOLANA,
+            emitterAddress,
+            sequence,
+            {
+              transport: NodeHttpTransport(),
+            }
+          );
+          await redeemOnEth(ETH_TOKEN_BRIDGE_ADDRESS, signer, signedVAA);
+          provider.destroy();
+          done();
+        } catch (e) {
+          console.error(e);
+          done(
+            "An error occurred while trying to attest from Solana to Ethereum"
+          );
+        }
+      })();
+    });
+    // TODO: it has increased balance
+  });
+});

+ 4 - 4
sdk/js/src/token_bridge/attest.ts

@@ -1,11 +1,11 @@
 import { Connection, Keypair, PublicKey, Transaction } from "@solana/web3.js";
+import { MsgExecuteContract } from "@terra-money/terra.js";
 import { ethers } from "ethers";
+import { isNativeDenom } from "..";
 import { Bridge__factory } from "../ethers-contracts";
 import { getBridgeFeeIx, ixFromRust } from "../solana";
+import { importTokenWasm } from "../solana/wasm";
 import { createNonce } from "../utils/createNonce";
-import { ConnectedWallet as TerraConnectedWallet } from "@terra-money/wallet-provider";
-import { MsgExecuteContract } from "@terra-money/terra.js";
-import { isNativeDenom } from "..";
 
 export async function attestFromEth(
   tokenBridgeAddress: string,
@@ -54,7 +54,7 @@ export async function attestFromSolana(
     bridgeAddress,
     payerAddress
   );
-  const { attest_ix } = await import("../solana/token/token_bridge");
+  const { attest_ix } = await importTokenWasm();
   const messageKey = Keypair.generate();
   const ix = ixFromRust(
     attest_ix(

+ 4 - 4
sdk/js/src/token_bridge/createWrapped.ts

@@ -1,10 +1,10 @@
 import { Connection, PublicKey, Transaction } from "@solana/web3.js";
+import { MsgExecuteContract } from "@terra-money/terra.js";
 import { ethers } from "ethers";
+import { fromUint8Array } from "js-base64";
 import { Bridge__factory } from "../ethers-contracts";
-import { ConnectedWallet as TerraConnectedWallet } from "@terra-money/wallet-provider";
-import { MsgExecuteContract } from "@terra-money/terra.js";
 import { ixFromRust } from "../solana";
-import { fromUint8Array } from "js-base64";
+import { importTokenWasm } from "../solana/wasm";
 
 export async function createWrappedOnEth(
   tokenBridgeAddress: string,
@@ -36,7 +36,7 @@ export async function createWrappedOnSolana(
   payerAddress: string,
   signedVAA: Uint8Array
 ) {
-  const { create_wrapped_ix } = await import("../solana/token/token_bridge");
+  const { create_wrapped_ix } = await importTokenWasm();
   const ix = ixFromRust(
     create_wrapped_ix(
       tokenBridgeAddress,

+ 2 - 1
sdk/js/src/token_bridge/getForeignAsset.ts

@@ -4,6 +4,7 @@ import { Bridge__factory } from "../ethers-contracts";
 import { ChainId } from "../utils";
 import { LCDClient } from "@terra-money/terra.js";
 import { fromUint8Array } from "js-base64";
+import { importTokenWasm } from "../solana/wasm";
 
 /**
  * Returns a foreign asset address on Ethereum for a provided native chain and asset address, AddressZero if it does not exist
@@ -63,7 +64,7 @@ export async function getForeignAssetSolana(
   originChain: ChainId,
   originAsset: Uint8Array
 ) {
-  const { wrapped_address } = await import("../solana/token/token_bridge");
+  const { wrapped_address } = await importTokenWasm();
   const wrappedAddress = wrapped_address(
     tokenBridgeAddress,
     originAsset,

+ 2 - 1
sdk/js/src/token_bridge/getIsWrappedAsset.ts

@@ -2,6 +2,7 @@ import { Connection, PublicKey } from "@solana/web3.js";
 import { ethers } from "ethers";
 import { Bridge__factory } from "../ethers-contracts";
 import { ConnectedWallet as TerraConnectedWallet } from "@terra-money/wallet-provider";
+import { importTokenWasm } from "../solana/wasm";
 
 /**
  * Returns whether or not an asset address on Ethereum is a wormhole wrapped asset
@@ -41,7 +42,7 @@ export async function getIsWrappedAssetSol(
   mintAddress: string
 ) {
   if (!mintAddress) return false;
-  const { wrapped_meta_address } = await import("../solana/token/token_bridge");
+  const { wrapped_meta_address } = await importTokenWasm();
   const wrappedMetaAddress = wrapped_meta_address(
     tokenBridgeAddress,
     new PublicKey(mintAddress).toBytes()

+ 3 - 3
sdk/js/src/token_bridge/getOriginalAsset.ts

@@ -3,6 +3,7 @@ import { LCDClient } from "@terra-money/terra.js";
 import { ethers } from "ethers";
 import { arrayify, zeroPad } from "ethers/lib/utils";
 import { TokenImplementation__factory } from "../ethers-contracts";
+import { importTokenWasm } from "../solana/wasm";
 import { buildNativeId, canonicalAddress, isNativeDenom } from "../terra";
 import { ChainId, CHAIN_ID_SOLANA, CHAIN_ID_TERRA } from "../utils";
 import { getIsWrappedAssetEth } from "./getIsWrappedAsset";
@@ -101,9 +102,8 @@ export async function getOriginalAssetSol(
 ): Promise<WormholeWrappedInfo> {
   if (mintAddress) {
     // TODO: share some of this with getIsWrappedAssetSol, like a getWrappedMetaAccountAddress or something
-    const { parse_wrapped_meta, wrapped_meta_address } = await import(
-      "../solana/token/token_bridge"
-    );
+    const { parse_wrapped_meta, wrapped_meta_address } =
+      await importTokenWasm();
     const wrappedMetaAddress = wrapped_meta_address(
       tokenBridgeAddress,
       new PublicKey(mintAddress).toBytes()

+ 5 - 6
sdk/js/src/token_bridge/redeem.ts

@@ -11,6 +11,7 @@ import { ethers } from "ethers";
 import { fromUint8Array } from "js-base64";
 import { Bridge__factory } from "../ethers-contracts";
 import { ixFromRust } from "../solana";
+import { importCoreWasm, importTokenWasm } from "../solana/wasm";
 import {
   CHAIN_ID_SOLANA,
   WSOL_ADDRESS,
@@ -61,10 +62,8 @@ export async function redeemAndUnwrapOnSolana(
   payerAddress: string,
   signedVAA: Uint8Array
 ) {
-  const { parse_vaa } = await import("../solana/core/bridge");
-  const { complete_transfer_native_ix } = await import(
-    "../solana/token/token_bridge"
-  );
+  const { parse_vaa } = await importCoreWasm();
+  const { complete_transfer_native_ix } = await importTokenWasm();
   const parsedVAA = parse_vaa(signedVAA);
   const parsedPayload = parseTransferPayload(
     Buffer.from(new Uint8Array(parsedVAA.payload))
@@ -151,13 +150,13 @@ export async function redeemOnSolana(
   payerAddress: string,
   signedVAA: Uint8Array
 ) {
-  const { parse_vaa } = await import("../solana/core/bridge");
+  const { parse_vaa } = await importCoreWasm();
   const parsedVAA = parse_vaa(signedVAA);
   const isSolanaNative =
     Buffer.from(new Uint8Array(parsedVAA.payload)).readUInt16BE(65) ===
     CHAIN_ID_SOLANA;
   const { complete_transfer_wrapped_ix, complete_transfer_native_ix } =
-    await import("../solana/token/token_bridge");
+    await importTokenWasm();
   const ixs = [];
   if (isSolanaNative) {
     ixs.push(

+ 4 - 4
sdk/js/src/token_bridge/transfer.ts

@@ -14,6 +14,7 @@ import {
   TokenImplementation__factory,
 } from "../ethers-contracts";
 import { getBridgeFeeIx, ixFromRust } from "../solana";
+import { importTokenWasm } from "../solana/wasm";
 import { ChainId, CHAIN_ID_SOLANA, createNonce, WSOL_ADDRESS } from "../utils";
 
 export async function getAllowanceEth(
@@ -202,9 +203,8 @@ export async function transferNativeSol(
   );
 
   //Normal approve & transfer instructions, except that the wSOL is sent from the ancillary account.
-  const { transfer_native_ix, approval_authority_address } = await import(
-    "../solana/token/token_bridge"
-  );
+  const { transfer_native_ix, approval_authority_address } =
+    await importTokenWasm();
   const nonce = createNonce().readUInt32LE(0);
   const fee = BigInt(0); // for now, this won't do anything, we may add later
   const transferIx = await getBridgeFeeIx(
@@ -286,7 +286,7 @@ export async function transferFromSolana(
     transfer_native_ix,
     transfer_wrapped_ix,
     approval_authority_address,
-  } = await import("../solana/token/token_bridge");
+  } = await importTokenWasm();
   const approvalIx = Token.createApproveInstruction(
     TOKEN_PROGRAM_ID,
     new PublicKey(fromAddress),

+ 3 - 0
sdk/js/src/utils/array.ts

@@ -67,3 +67,6 @@ export const nativeToHexString = (
     return null;
   }
 };
+
+export const uint8ArrayToNative = (a: Uint8Array, chainId: ChainId) =>
+  hexToNativeString(uint8ArrayToHex(a), chainId);

+ 8 - 0
solana/Dockerfile.wasm

@@ -42,6 +42,10 @@ RUN --mount=type=cache,target=/root/.cache \
 	--mount=type=cache,target=migration/target \
     cd migration && /usr/local/cargo/bin/wasm-pack build --target bundler -d bundler -- --features wasm
 
+RUN --mount=type=cache,target=/root/.cache \
+	--mount=type=cache,target=migration/target \
+    cd migration && /usr/local/cargo/bin/wasm-pack build --target nodejs -d nodejs -- --features wasm
+
 # Compile NFT Bridge
 RUN --mount=type=cache,target=/root/.cache \
 	--mount=type=cache,target=modules/nft_bridge/target \
@@ -74,6 +78,10 @@ COPY --from=build /usr/src/bridge/modules/token_bridge/program/bundler explorer/
 COPY --from=build /usr/src/bridge/modules/nft_bridge/program/bundler explorer/wasm/nft
 COPY --from=build /usr/src/bridge/bridge/program/bundler third_party/pyth/p2w-sdk/src/solana/wormhole-core
 
+COPY --from=build /usr/src/bridge/bridge/program/nodejs sdk/js/src/solana/core-node
+COPY --from=build /usr/src/bridge/modules/token_bridge/program/nodejs sdk/js/src/solana/token-node
+COPY --from=build /usr/src/bridge/migration/nodejs sdk/js/src/solana/migration-node
+COPY --from=build /usr/src/bridge/modules/nft_bridge/program/nodejs sdk/js/src/solana/nft-node
 COPY --from=build /usr/src/bridge/bridge/program/nodejs clients/solana/pkg
 COPY --from=build /usr/src/bridge/bridge/program/nodejs clients/token_bridge/pkg/core
 COPY --from=build /usr/src/bridge/bridge/program/nodejs clients/nft_bridge/pkg/core

+ 1 - 0
third_party/pyth/Dockerfile.p2w-attest

@@ -16,3 +16,4 @@ ENV P2W_OWNER_KEYPAIR="/usr/src/solana/keys/p2w_owner.json"
 ENV P2W_ATTESTATIONS_PORT="4343"
 ENV PYTH_PUBLISHER_KEYPAIR="/usr/src/solana/keys/pyth_publisher.json"
 ENV PYTH_PROGRAM_KEYPAIR="/usr/src/solana/keys/pyth_program.json"
+ENV SOL_AIRDROP_AMT="100"

+ 1 - 0
third_party/pyth/Dockerfile.pyth

@@ -44,3 +44,4 @@ USER pyth
 
 ENV PYTH=$PYTH_SRC_ROOT/build/pyth
 ENV READINESS_PORT=2000
+ENV SOL_AIRDROP_AMT=100

+ 1 - 1
third_party/pyth/p2w-relay/package.json

@@ -12,7 +12,7 @@
       "start": "node -r esm lib/index.js",
         "build": "npm run build-eth-types && npm run build-lib",
         "build-lib": "npm run copy-artifacts && tsc",
-        "build-watch": "npm run copy-artifacts && tsc --watch",
+        "build-watch": "npm run build-eth-types && npm run copy-artifacts && tsc --watch",
 	"build-eth-types": "node scripts/copyEthContracts.cjs && typechain --target=ethers-v5 --out-dir=src/ethers-contracts contracts/*.json",
         "copy-artifacts": "node scripts/copyWasm.cjs && node scripts/copyEthersTypes.cjs",
         "lint": "tslint -p tsconfig.json",

+ 43 - 22
third_party/pyth/p2w-relay/src/index.ts

@@ -10,10 +10,11 @@ import {ethers} from "ethers";
 
 import {getSignedAttestation, parseAttestation, p2w_core, sol_addr2buf} from "@certusone/p2w-sdk";
 
-interface AttestationState {
+interface NewAttestationsResponse {
     pendingSeqnos: Array<number>,
 }
 
+
 async function readinessProbeRoutine(port: number) {
     let srv = net.createServer();
 
@@ -22,25 +23,32 @@ async function readinessProbeRoutine(port: number) {
 
 (async () => {
 
+    // p2w-attest exposes an HTTP endpoint that shares the currently pending sequence numbers
     const P2W_ATTESTATIONS_HOST = process.env.P2W_ATTESTATIONS_HOST || "p2w-attest";
     const P2W_ATTESTATIONS_PORT = Number(process.env.P2W_ATTESTATIONS_PORT || "4343");
+    const P2W_ATTESTATIONS_POLL_INTERVAL_MS = Number(process.env.P2W_ATTESTATIONS_POLL_INTERVAL_MS || "5000");
+
     const P2W_SOL_ADDRESS = process.env.P2W_SOL_ADDRESS || "P2WH424242424242424242424242424242424242424";
 
     const READINESS_PROBE_PORT = Number(process.env.READINESS_PROBE_PORT || "2000");
-    const POLL_INTERVAL_MS = 5000;
 
+    const P2W_RELAY_RETRY_COUNT = Number(process.env.P2W_RELAY_RETRY_COUNT || "3");
+
+    // ETH node connection details; Currently, we expect to read BIP44
+    // wallet recovery mnemonics from a text file.
     const ETH_NODE_URL = process.env.ETH_NODE_URL || "ws://eth-devnet:8545";
     const ETH_P2W_CONTRACT = process.env.ETH_P2W_CONTRACT || "0xA94B7f0465E98609391C623d0560C5720a3f2D33";
     const ETH_MNEMONIC_FILE = process.env.ETH_MNEMONIC_FILE || "../../../ethereum/devnet_mnemonic.txt";
     const ETH_HD_WALLET_PATH = process.env.ETH_HD_WALLET_PATH || "m/44'/60'/0'/0/0";
 
+    // Public RPC address for use with signed attestation queries
     const GUARDIAN_RPC_HOST_PORT = process.env.GUARDIAN_RPC_HOST_PORT || "http://guardian:7071";
 
     let readinessProbe = null;
 
-    let seqnoPool: Set<number> = new Set(); // Seqnos we are yet to process
+    let seqnoPool: Map<number, number> = new Map();
 
-    console.log(`Polling attestations endpoint every ${POLL_INTERVAL_MS / 1000} seconds`);
+    console.log(`Polling attestations endpoint every ${P2W_ATTESTATIONS_POLL_INTERVAL_MS / 1000} seconds`);
 
     const wormhole = await import("@certusone/wormhole-sdk/lib/solana/core/bridge");
 
@@ -85,12 +93,12 @@ async function readinessProbeRoutine(port: number) {
 		res.on('end', () => {
 		    let body = chunks.join('');
 
-		    let state: AttestationState = JSON.parse(body);
+		    let response: NewAttestationsResponse = JSON.parse(body);
 
-		    console.log(`Got ${state.pendingSeqnos.length} new seqnos: ${state.pendingSeqnos}`);
+		    console.log(`Got ${response.pendingSeqnos.length} new seqnos: ${response.pendingSeqnos}`);
 
-		    for (let seqno of state.pendingSeqnos) {
-			seqnoPool.add(seqno);
+		    for (let seqno of response.pendingSeqnos) {
+			seqnoPool.set(seqno, 0);
 		    }
 		});
 	    }
@@ -99,43 +107,51 @@ async function readinessProbeRoutine(port: number) {
 	});
 
 	console.log("Processing seqnos:", seqnoPool);
-	for (let seqno of seqnoPool) {
+	for (let poolEntry of seqnoPool) {
+
+	    if (poolEntry[1] >= P2W_RELAY_RETRY_COUNT) {
+		console.log(`[seqno ${poolEntry}] Exceeded retry count, removing from list`);
+		seqnoPool.delete(poolEntry[0]);
+		continue;
+	    }
 
 	    let vaaResponse;
 	    try {
 		vaaResponse = await getSignedAttestation(
 		    GUARDIAN_RPC_HOST_PORT,
 		    P2W_SOL_ADDRESS,
-		    seqno,
+		    poolEntry[0],
 		    {
 			transport: NodeHttpTransport()
 		    }
 		);
 	    }
 	    catch(e) {
-		console.log(`[seqno ${seqno}] Error: Could not call getSignedAttestation:`, e);
+		console.log(`[seqno ${poolEntry}] Error: Could not call getSignedAttestation:`, e);
+
+		seqnoPool.set(poolEntry[0], poolEntry[1] + 1);
 
 		continue;
 	    }
 
-	    console.log(`[seqno ${seqno}] Price attestation VAA bytes:\n`, vaaResponse.vaaBytes);
-
-	    seqnoPool.delete(seqno); // We don't care to retry beyond this point
+	    console.log(`[seqno ${poolEntry}] Price attestation VAA bytes:\n`, vaaResponse.vaaBytes);
 
 	    let parsedVaa = wormhole.parse_vaa(vaaResponse.vaaBytes);
 
-	    console.log(`[seqno ${seqno}] Parsed VAA:\n`, parsedVaa);
+	    console.log(`[seqno ${poolEntry}] Parsed VAA:\n`, parsedVaa);
 
 	    let parsedAttestation = await parseAttestation(parsedVaa.payload);
 
-	    console.log(`[seqno ${seqno}] Parsed price attestation:\n`, parsedAttestation);
+	    console.log(`[seqno ${poolEntry}] Parsed price attestation:\n`, parsedAttestation);
 
 	    try {
 		let tx = await p2w_eth.attestPrice(vaaResponse.vaaBytes, {gasLimit: 1000000});
 		let retval = await tx.wait();
-		console.log(`[seqno ${seqno}] attestPrice() output:\n`, retval);
+		console.log(`[seqno ${poolEntry}] attestPrice() output:\n`, retval);
 	    } catch(e) {
-		console.log(`[seqno ${seqno}] Error: Could not call attestPrice() on ETH:`, e);
+		console.log(`[seqno ${poolEntry}] Error: Could not call attestPrice() on ETH:`, e);
+
+		seqnoPool.set(poolEntry[0], poolEntry[1] + 1);
 
 		continue;
 	    }
@@ -152,18 +168,23 @@ async function readinessProbeRoutine(port: number) {
 
 		latest_attestation = await p2w_eth.latestAttestation(product_id, price_type);
 	    } catch(e) {
-		console.log(`[seqno ${seqno}] Error: Could not call latestAttestation() on ETH:`, e);
+		console.log(`[seqno ${poolEntry}] Error: Could not call latestAttestation() on ETH:`, e);
+
+		seqnoPool.set(poolEntry[0], poolEntry[1] + 1);
+
 		continue;
 	    }
 
-	    console.log(`[seqno ${seqno}] Latest price type ${price_type} attestation of ${product_id} is ${latest_attestation}`);
+	    console.log(`[seqno ${poolEntry}] Latest price type ${price_type} attestation of ${product_id} is ${latest_attestation}`);
 	    if (!readinessProbe) {
-		console.log(`[seqno ${seqno}] Attestation successful. Starting readiness probe.`);
+		console.log(`[seqno ${poolEntry}] Attestation successful. Starting readiness probe.`);
 		readinessProbe = readinessProbeRoutine(READINESS_PROBE_PORT);
 	    }
+
+	    seqnoPool.delete(poolEntry[0]); // Everything went well, seqno no longer pending.
 	}
 
-	await new Promise(f => {setTimeout(f, POLL_INTERVAL_MS);});
+	await new Promise(f => {setTimeout(f, P2W_ATTESTATIONS_POLL_INTERVAL_MS);});
     }
 
 })();

Some files were not shown because too many files changed in this diff