浏览代码

devnet Explorer

- Add network switcher.
- Explorer landing page with light stats:
  - summary of recent messages per chain.
  - shows most recent messages, polls for updates.
- chain-details view.
- super basic message details view: json.
- runs in devnet.
- add network switcher to guardian-list page.
- links to native explorers (etherscan, etc.)
- polygon support
- animations when data updates

Change-Id: I26616afab551a7555421a45dee9f016beecbf93e
justinschuldt 4 年之前
父节点
当前提交
e5045f1c8c
共有 37 个文件被更改,包括 3250 次插入405 次删除
  1. 73 10
      explorer/.env.sample
  2. 23 7
      explorer/gatsby-node.js
  3. 0 23
      explorer/generate-wasm.sh
  4. 895 41
      explorer/package-lock.json
  5. 8 2
      explorer/package.json
  6. 3 0
      explorer/src/AntdTheme.js
  7. 29 0
      explorer/src/components/App/App.less
  8. 219 0
      explorer/src/components/App/ExplorerSearch/ExplorerSearchForm.tsx
  9. 152 0
      explorer/src/components/App/ExplorerSearch/ExplorerTxForm.tsx
  10. 2 0
      explorer/src/components/App/ExplorerSearch/index.ts
  11. 74 36
      explorer/src/components/ExplorerQuery/ExplorerQuery.tsx
  12. 15 0
      explorer/src/components/ExplorerStats/ChainOverviewCard.less
  13. 106 0
      explorer/src/components/ExplorerStats/ChainOverviewCard.tsx
  14. 120 0
      explorer/src/components/ExplorerStats/DailyCountColumnChart.tsx
  15. 197 0
      explorer/src/components/ExplorerStats/DailyCountLineChart.tsx
  16. 198 0
      explorer/src/components/ExplorerStats/ExplorerStats.tsx
  17. 17 0
      explorer/src/components/ExplorerStats/RecentMessages.less
  18. 135 0
      explorer/src/components/ExplorerStats/RecentMessages.tsx
  19. 1 0
      explorer/src/components/ExplorerStats/index.ts
  20. 116 0
      explorer/src/components/ExplorerStats/utils.ts
  21. 45 13
      explorer/src/components/ExplorerSummary/ExplorerSummary.tsx
  22. 7 4
      explorer/src/components/GuardiansTable/GuardiansTable.tsx
  23. 28 0
      explorer/src/components/NetworkSelect/NetworkSelect.tsx
  24. 85 0
      explorer/src/components/NetworkSelect/WithNetwork.tsx
  25. 3 0
      explorer/src/components/NetworkSelect/index.ts
  26. 34 0
      explorer/src/components/NetworkSelect/network-context.ts
  27. 296 0
      explorer/src/components/Payload/DecodePayload.tsx
  28. 1 0
      explorer/src/components/Payload/index.tsx
  29. 0 1
      explorer/src/components/wasm/index.tsx
  30. 0 52
      explorer/src/components/wasm/wasm.tsx
  31. 4 0
      explorer/src/icons/polygon.svg
  32. 31 2
      explorer/src/locales/en.json
  33. 119 174
      explorer/src/pages/explorer.tsx
  34. 33 17
      explorer/src/pages/network.tsx
  35. 174 20
      explorer/src/utils/misc/constants.ts
  36. 4 3
      explorer/tsconfig.json
  37. 3 0
      solana/Dockerfile.wasm

+ 73 - 10
explorer/.env.sample

@@ -1,12 +1,20 @@
 # General
 GATSBY_TELEMETRY_DISABLED=1
-GATSBY_SITE_URL=http://localhost:8000
+GATSBY_SITE_URL=http://localhost:8001
 GATSBY_GA_TAG=G-tag-goes-here
 GATSBY_ENVIRONMENT=development
 
 GATSBY_APP_RPC_URL=http://localhost:7071
 
-GATSBY_BIGTABLE_URL=https://us-central1-wormhole-315720.cloudfunctions.net/BT-reader-test
+GATSBY_GUARDIAN_DEVNET_RPC_URL=http://localhost:7071
+GATSBY_GUARDIAN_TESTNET_RPC_URL=https://wormhole-v2-testnet-api.certus.one
+GATSBY_GUARDIAN_MAINNET_RPC_URL=https://wormhole-v2-mainnet-api.certus.one
+
+GATSBY_BIGTABLE_FUNCTIONS_DEVNET_BASE_URL=http://localhost:8090
+GATSBY_BIGTABLE_FUNCTIONS_TESTNET_BASE_URL=https://europe-west3-wormhole-315720.cloudfunctions.net/testnet
+GATSBY_BIGTABLE_FUNCTIONS_MAINNET_BASE_URL=https://europe-west3-wormhole-315720.cloudfunctions.net/mainnet
+
+GATSBY_DEFAULT_NETWORK=devnet
 
 # Profiling
 ENABLE_BUNDLE_ANALYZER=0
@@ -16,14 +24,69 @@ ENABLE_NETWORK_PAGE=true
 ENABLE_EXPLORER_PAGE=true
 
 
-ETH_CORE_BRIDGE=0x254dffcd3277c0b1660f6d42efbb754edababc2b
-ETH_TOKEN_BRIDGE=0xe982e462b094850f12af94d21d470e21be9d0e9c
+# contract addresses
+
+## devnet addresses
+GATSBY_DEVNET_SOLANA_CORE_BRIDGE=Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o
+GATSBY_DEVNET_SOLANA_TOKEN_BRIDGE=B6RHG3mfcckmrYN1UhmJzyS1XX3fZKbkeUcpJe9Sy3FE
+GATSBY_DEVNET_SOLANA_NFT_BRIDGE=NFTWqJR8YnRVqPDvTJrYuLrQDitTG5AScqbeghi4zSA
+
+GATSBY_DEVNET_ETHEREUM_CORE_BRIDGE=0xC89Ce4735882C9F0f0FE26686c53074E09B0D550
+GATSBY_DEVNET_ETHEREUM_TOKEN_BRIDGE=0x0290FB167208Af455bB137780163b7B7a9a10C16
+GATSBY_DEVNET_ETHEREUM_NFT_BRIDGE=0x26b4afb60d6c903165150c6f0aa14f8016be4aec
+
+GATSBY_DEVNET_TERRA_CORE_BRIDGE=terra18vd8fpwxzck93qlwghaj6arh4p7c5n896xzem5
+GATSBY_DEVNET_TERRA_TOKEN_BRIDGE=terra10pyejy66429refv3g35g2t7am0was7ya7kz2a4
+GATSBY_DEVNET_TERRA_NFT_BRIDGE=terra10nmmwe8r3g99a9newtqa7a75xfgs2e8z87r2sf
+
+GATSBY_DEVNET_BSC_CORE_BRIDGE=0xC89Ce4735882C9F0f0FE26686c53074E09B0D550
+GATSBY_DEVNET_BSC_TOKEN_BRIDGE=0x0290FB167208Af455bB137780163b7B7a9a10C16
+GATSBY_DEVNET_BSC_NFT_BRIDGE=0x26b4afb60d6c903165150c6f0aa14f8016be4aec
+
+GATSBY_DEVNET_POLYGON_CORE_BRIDGE=0xC89Ce4735882C9F0f0FE26686c53074E09B0D550
+GATSBY_DEVNET_POLYGON_TOKEN_BRIDGE=0x0290FB167208Af455bB137780163b7B7a9a10C16
+GATSBY_DEVNET_POLYGON_NFT_BRIDGE=0x26b4afb60d6c903165150c6f0aa14f8016be4aec
+
+
+## testnet addresses
+GATSBY_TESTNET_SOLANA_CORE_BRIDGE=Brdguy7BmNB4qwEbcqqMbyV5CyJd2sxQNUn6NEpMSsUb
+GATSBY_TESTNET_SOLANA_TOKEN_BRIDGE=A4Us8EhCC76XdGAN17L4KpRNEK423nMivVHZzZqFqqBg
+GATSBY_TESTNET_SOLANA_NFT_BRIDGE=NFTWqJR8YnRVqPDvTJrYuLrQDitTG5AScqbeghi4zSA
+
+GATSBY_TESTNET_ETHEREUM_CORE_BRIDGE=0x44F3e7c20850B3B5f3031114726A9240911D912a
+GATSBY_TESTNET_ETHEREUM_TOKEN_BRIDGE=0xa6CDAddA6e4B6704705b065E01E52e2486c0FBf6
+GATSBY_TESTNET_ETHEREUM_NFT_BRIDGE=0x26b4afb60d6c903165150c6f0aa14f8016be4aec
+
+GATSBY_TESTNET_TERRA_CORE_BRIDGE=terra18vd8fpwxzck93qlwghaj6arh4p7c5n896xzem5
+GATSBY_TESTNET_TERRA_TOKEN_BRIDGE=terra10pyejy66429refv3g35g2t7am0was7ya7kz2a4
+GATSBY_TESTNET_TERRA_NFT_BRIDGE=terra10nmmwe8r3g99a9newtqa7a75xfgs2e8z87r2sf
+
+GATSBY_TESTNET_BSC_CORE_BRIDGE=0xC89Ce4735882C9F0f0FE26686c53074E09B0D550
+GATSBY_TESTNET_BSC_TOKEN_BRIDGE=0x0290FB167208Af455bB137780163b7B7a9a10C16
+GATSBY_TESTNET_BSC_NFT_BRIDGE=0x26b4afb60d6c903165150c6f0aa14f8016be4aec
+
+GATSBY_TESTNET_POLYGON_CORE_BRIDGE=0xC89Ce4735882C9F0f0FE26686c53074E09B0D550
+GATSBY_TESTNET_POLYGON_TOKEN_BRIDGE=0x0290FB167208Af455bB137780163b7B7a9a10C16
+GATSBY_TESTNET_POLYGON_NFT_BRIDGE=0x26b4afb60d6c903165150c6f0aa14f8016be4aec
+
+
+## mainnet addresses
+GATSBY_MAINNET_SOLANA_CORE_BRIDGE=worm2ZoG2kUd4vFXhvjh93UUH596ayRfgQ2MgjNMTth
+GATSBY_MAINNET_SOLANA_TOKEN_BRIDGE=wormDTUJ6AWPNvk59vGQbDvGJmqbDTdgWgAqcLBCgUb
+GATSBY_MAINNET_SOLANA_NFT_BRIDGE=WnFt12ZrnzZrFZkt2xsNsaNWoQribnuQ5B5FrDbwDhD
+
+GATSBY_MAINNET_ETHEREUM_CORE_BRIDGE=0x98f3c9e6E3fAce36bAAd05FE09d375Ef1464288B
+GATSBY_MAINNET_ETHEREUM_TOKEN_BRIDGE=0x3ee18B2214AFF97000D974cf647E7C347E8fa585
+GATSBY_MAINNET_ETHEREUM_NFT_BRIDGE=0x6FFd7EdE62328b3Af38FCD61461Bbfc52F5651fE
 
-BSC_CORE_BRIDGE=0x254dffcd3277c0b1660f6d42efbb754edababc2b
-BSC_TOKEN_BRIDGE=0xe982e462b094850f12af94d21d470e21be9d0e9c
+GATSBY_MAINNET_TERRA_CORE_BRIDGE=terra1dq03ugtd40zu9hcgdzrsq6z2z4hwhc9tqk2uy5
+GATSBY_MAINNET_TERRA_TOKEN_BRIDGE=terra10nmmwe8r3g99a9newtqa7a75xfgs2e8z87r2sf
+GATSBY_MAINNET_TERRA_NFT_BRIDGE=terra10pyejy66429refv3g35g2t7am0was7ya7kz2a4
 
-SOL_CORE_BRIDGE=Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o
-SOL_TOKEN_BRIDGE=B6RHG3mfcckmrYN1UhmJzyS1XX3fZKbkeUcpJe9Sy3FE
+GATSBY_MAINNET_BSC_CORE_BRIDGE=0x98f3c9e6E3fAce36bAAd05FE09d375Ef1464288B
+GATSBY_MAINNET_BSC_TOKEN_BRIDGE=0xB6F6D86a8f9879A9c87f643768d9efc38c1Da6E7
+GATSBY_MAINNET_BSC_NFT_BRIDGE=0x5a58505a96D1dbf8dF91cB21B54419FC36e93fdE
 
-LUN_CORE_BRIDGE=terra18eezxhys9jwku67cm4w84xhnzt4xjj77w2qt62
-LUN_TOKEN_BRIDGE=terra1hqrdl6wstt8qzshwc6mrumpjk9338k0l93hqyd
+GATSBY_MAINNET_POLYGON_CORE_BRIDGE=0x98f3c9e6E3fAce36bAAd05FE09d375Ef1464288B
+GATSBY_MAINNET_POLYGON_TOKEN_BRIDGE=0xB6F6D86a8f9879A9c87f643768d9efc38c1Da6E7
+GATSBY_MAINNET_POLYGON_NFT_BRIDGE=0x5a58505a96D1dbf8dF91cB21B54419FC36e93fdE

+ 23 - 7
explorer/gatsby-node.js

@@ -24,22 +24,38 @@ export const onCreateWebpackConfig = function addPathMapping({
     devtool: 'eval-source-map',
   });
 
+  const wasmExtensionRegExp = /\.wasm$/;
+
   actions.setWebpackConfig({
     module: {
       rules: [
         {
-          test: /\.wasm$/,
-          use: [
-            'wasm-loader'
-          ],
+          test: wasmExtensionRegExp,
+          include: /node_modules\/(bridge|token-bridge|nft)/,
+          use: ['wasm-loader'],
           type: "javascript/auto"
         }
       ]
     }
   });
-  const config = getConfig();
-  config.resolve.extensions.push(".wasm");
-  actions.replaceWebpackConfig(config);
+
+  if (stage === 'build-html') {
+    // exclude wasm from SSR
+    actions.setWebpackConfig({
+      externals: getConfig().externals.concat(function (context, request, callback) {
+        const regex = wasmExtensionRegExp;
+        // exclude wasm from being bundled in SSR html, it will be loaded async at runtime.
+        if (regex.test(request)) {
+          return callback(null, 'commonjs ' + request); // use commonjs for wasm modules
+        }
+        const bridge = new RegExp('/wormhole-sdk/')
+        if (bridge.test(request)) {
+          return callback(null, 'commonjs ' + request);
+        }
+        callback();
+      })
+    });
+  }
 
   // Attempt to improve webpack vender code splitting
   if (stage === 'build-javascript') {

+ 0 - 23
explorer/generate-wasm.sh

@@ -1,23 +0,0 @@
-#!/usr/bin/env bash
-# Regenerate explorer
-set -euo pipefail
-
-(
-  cd ../solana
-  mkdir -p ../explorer/wasm/core
-  mkdir -p ../explorer/wasm/token
-
-  docker build -t localhost/certusone/wormhole-wasmpack:latest -f Dockerfile.wasm .
-
-  docker run --rm -it --workdir /usr/src/bridge/bridge/program \
-    -v $(pwd)/../explorer/wasm/core:/usr/src/bridge/bridge/program/pkg \
-    -e EMITTER_ADDRESS=11111111111111111111111111111115 \
-    localhost/certusone/wormhole-wasmpack:latest \
-    /usr/local/cargo/bin/wasm-pack build --target bundler -- --features wasm
-
-  docker run --rm -it --workdir /usr/src/bridge/modules/token_bridge/program \
-    -v $(pwd)/../explorer/wasm/token:/usr/src/bridge/modules/token_bridge/program/pkg \
-    -e EMITTER_ADDRESS=11111111111111111111111111111115 \
-    localhost/certusone/wormhole-wasmpack:latest \
-    /usr/local/cargo/bin/wasm-pack build --target bundler -- --features wasm
-)

文件差异内容过多而无法显示
+ 895 - 41
explorer/package-lock.json


+ 8 - 2
explorer/package.json

@@ -28,7 +28,7 @@
   "scripts": {
     "build": "NODE_ENV=production NODE_OPTIONS='-r esm' gatsby build",
     "clean": "rm -rf public && rm -rf .cache",
-    "dev": "npm run clean &&  NODE_OPTIONS='-r esm' node --max-http-header-size=16385 node_modules/.bin/gatsby develop --port=8001",
+    "dev": "npm run clean &&  NODE_OPTIONS='-r esm' node --max-http-header-size=16385 node_modules/.bin/gatsby develop --port=8000",
     "debug": "npm run clean && NODE_OPTIONS='-r esm' node --nolazy --inspect-brk node_modules/.bin/gatsby develop",
     "serve": "NODE_OPTIONS='-r esm' node --max-http-header-size=16385 node_modules/.bin/gatsby serve --port=8001 --host=0.0.0.0",
     "build-and-serve": "npm run build && npm run serve",
@@ -49,9 +49,14 @@
   },
   "dependencies": {
     "@ant-design/icons": "^4.6.2",
+    "@certusone/wormhole-sdk": "^0.0.6",
+    "@cosmjs/encoding": "^0.26.2",
     "@fontsource/sora": "^4.5.0",
     "@improbable-eng/grpc-web": "^0.14.0",
+    "@nivo/bar": "^0.73.1",
+    "@nivo/line": "^0.73.0",
     "@reach/router": "^1.3.1",
+    "@solana/web3.js": "^1.29.2",
     "@svgr/webpack": "^5.1.0",
     "antd": "^4.15.4",
     "babel-plugin-module-resolver": "^4.0.0",
@@ -72,6 +77,7 @@
     "gatsby-plugin-sitemap": "^2.12.0",
     "gatsby-plugin-svgr": "^2.0.2",
     "gatsby-plugin-typescript": "^2.1.27",
+    "nft": "file:./wasm/nft",
     "react": "^16.12.0",
     "react-dom": "^16.12.0",
     "react-helmet": "^5.2.1",
@@ -130,7 +136,7 @@
     "ts-essentials": "^6.0.1",
     "ts-jest": "^25.2.1",
     "ts-loader": "^6.2.1",
-    "typescript": "^3.8.2",
+    "typescript": "^4.3.5",
     "wasm-loader": "^1.3.0"
   }
 }

+ 3 - 0
explorer/src/AntdTheme.js

@@ -18,6 +18,9 @@ export default {
   'menu-inline-submenu-bg': '@layout-body-background',
   'menu-popup-bg': '@layout-body-background',
 
+  // shadow when hovering a card
+  'card-shadow': '0 0px 0px rgba(0, 239, 216, 0.80), 0 5px 32px 0 rgba(0, 239, 216, 0.60), 0 4px 12px 4px rgba(0, 239, 216, 0.4)',
+
   // table styles
   'table-header-bg': '#212130',
   'table-row-hover-bg': '#212130',

+ 29 - 0
explorer/src/components/App/App.less

@@ -45,7 +45,36 @@ not including links to the current domain */
         padding: 0px 16px 0px 16px;
     }
 }
+.wider-responsive-padding {
+    // small left padding for mobile, more left padding for larger screens.
+    @media only screen and (min-width: 767px) {
+        padding: 0px 16px 0px 80px;
+    }
+    @media only screen and (max-width: 767px) {
+        padding: 0px 16px 0px 16px;
+    }
+}
 
 .background-mask-from-left {
     background: linear-gradient(to right, @body-background, rgba(@body-background, 0));
 }
+
+.hover-z-index:hover {
+    z-index: 10;
+}
+
+// scroll bar styles for wide <pre></pre> elements (ExplorerSummary)
+.styled-scrollbar{
+    pre::-webkit-scrollbar {
+      width: 1em;
+    }
+
+    pre::-webkit-scrollbar-track {
+      box-shadow: inset 0 0 6px darken(@primary-color, 20%);
+    }
+
+    pre::-webkit-scrollbar-thumb {
+      background-color: darken(@primary-color, 12%);
+      outline: 1px solid darken(@primary-color, 10%);
+    }
+}

+ 219 - 0
explorer/src/components/App/ExplorerSearch/ExplorerSearchForm.tsx

@@ -0,0 +1,219 @@
+import React, { ChangeEventHandler, useEffect, useState } from 'react';
+import { PageProps } from "gatsby"
+import { Grid, Form, Input, Button, Radio, RadioChangeEvent } from 'antd';
+const { TextArea } = Input
+const { useBreakpoint } = Grid
+import { SearchOutlined } from '@ant-design/icons';
+import { FormattedMessage, useIntl } from 'gatsby-plugin-intl';
+
+
+import { ExplorerQuery } from '~/components/ExplorerQuery'
+import { ChainID, chainIDs } from '~/utils/misc/constants';
+
+
+// form props
+interface ExplorerFormValues {
+    emitterChain: number,
+    emitterAddress: string,
+    sequence: string
+}
+const formFields = ['emitterChain', 'emitterAddress', 'sequence']
+const emitterChains = [
+    { label: ChainID[1], value: chainIDs['solana'] },
+    { label: ChainID[2], value: chainIDs['ethereum'] },
+    { label: ChainID[3], value: chainIDs['terra'] },
+    { label: ChainID[4], value: chainIDs['bsc'] },
+    { label: ChainID[5], value: chainIDs['polygon'] },
+
+]
+
+interface ExplorerSearchProps {
+    location: PageProps["location"],
+    navigate: PageProps["navigate"]
+}
+const ExplorerSearchForm: React.FC<ExplorerSearchProps> = ({ location, navigate }) => {
+    const intl = useIntl()
+    const screens = useBreakpoint()
+    const [, forceUpdate] = useState({});
+    const [form] = Form.useForm<ExplorerFormValues>();
+    const [emitterChain, setEmitterChain] = useState<ExplorerFormValues["emitterChain"]>()
+    const [emitterAddress, setEmitterAddress] = useState<ExplorerFormValues["emitterAddress"]>()
+    const [sequence, setSequence] = useState<ExplorerFormValues["sequence"]>()
+
+    useEffect(() => {
+        // To disable submit button on first load.
+        forceUpdate({});
+    }, [])
+
+    useEffect(() => {
+
+        if (location.search) {
+            // take searchparams from the URL and set the values in the form
+            const searchParams = new URLSearchParams(location.search);
+
+            const chain = searchParams.get('emitterChain')
+            const address = searchParams.get('emitterAddress')
+            const seqQuery = searchParams.get('sequence')
+
+            // get the current values from the form fields
+            const { emitterChain, emitterAddress, sequence: seqForm } = form.getFieldsValue(true)
+
+            // if the search params are different form values, update the form.
+            if (Number(chain) !== emitterChain) {
+                form.setFieldsValue({ emitterChain: Number(chain) })
+            }
+            setEmitterChain(Number(chain))
+
+            if (address !== emitterAddress) {
+                form.setFieldsValue({ emitterAddress: address || undefined })
+            }
+            setEmitterAddress(address || undefined)
+
+            if (seqQuery !== seqForm) {
+                form.setFieldsValue({ sequence: seqQuery || undefined })
+            }
+            setSequence(seqQuery || undefined)
+        } else {
+            // clear state
+            setEmitterChain(undefined)
+            setEmitterAddress(undefined)
+            setSequence(undefined)
+        }
+    }, [location.search])
+
+
+
+    const onFinish = ({ emitterChain, emitterAddress, sequence }: ExplorerFormValues) => {
+        // pushing to the history stack will cause the component to get new props, and useEffect will run.
+        navigate(`/${intl.locale}/explorer/?emitterChain=${emitterChain}&emitterAddress=${emitterAddress}&sequence=${sequence}`)
+    };
+
+    const onChain = (e: RadioChangeEvent) => {
+        if (e.target.value) {
+            setEmitterChain(e.target.value)
+        }
+    }
+    const onAddress: ChangeEventHandler<HTMLTextAreaElement> = (e) => {
+        if (e.currentTarget.value) {
+            // trim whitespace
+            form.setFieldsValue({ emitterAddress: e.currentTarget.value.replace(/\s/g, "") })
+        }
+
+    }
+    const onSequence: ChangeEventHandler<HTMLInputElement> = (e) => {
+        if (e.currentTarget.value) {
+            // remove everything except numbers
+            form.setFieldsValue({ sequence: e.currentTarget.value.replace(/\D/g, '') })
+        }
+    }
+    const formatLabel = (textKey: string) => (
+        <span style={{ fontSize: 16 }}>
+            <FormattedMessage id={textKey} />
+        </span>
+
+    )
+    const formatHelp = (textKey: string) => (
+        <span style={{ fontSize: 14 }}>
+            <FormattedMessage id={textKey} />
+        </span>
+    )
+
+
+    return (
+        <>
+
+            <div style={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
+                <Form
+                    layout="vertical"
+                    form={form}
+                    name="explorer-message-query"
+                    onFinish={onFinish}
+                    size="large"
+                    style={{ width: '90%', maxWidth: 800, fontSize: 14 }}
+                    colon={false}
+                    requiredMark={false}
+                    validateMessages={{ required: "'${label}' is required", }}
+                >
+                    <Form.Item
+                        name="emitterAddress"
+                        label={formatLabel("explorer.emitterAddress")}
+                        help={formatHelp("explorer.emitterAddressHelp")}
+                        rules={[{ required: true }]}
+                    >
+                        <TextArea onChange={onAddress} allowClear autoSize />
+                    </Form.Item>
+
+                    <Form.Item
+                        name="emitterChain"
+                        label={formatLabel("explorer.emitterChain")}
+                        help={formatHelp("explorer.emitterChainHelp")}
+                        rules={[{ required: true }]}
+                        style={
+                            screens.md === false ? {
+                                display: 'block', width: '100%'
+                            } : {
+                                display: 'inline-block', width: '60%'
+                            }}
+                    >
+                        <Radio.Group
+                            optionType="button"
+                            options={emitterChains}
+                            onChange={onChain}
+                        />
+                    </Form.Item>
+
+                    <Form.Item shouldUpdate
+                        style={
+                            screens.md === false ? {
+                                display: 'block', width: '100%'
+                            } : {
+                                display: 'inline-block', width: '40%'
+                            }}
+                    >
+                        {() => (
+
+                            <Form.Item
+                                name="sequence"
+                                label={formatLabel("explorer.sequence")}
+                                help={formatHelp("explorer.sequenceHelp")}
+                                rules={[{ required: true }]}
+                            >
+
+                                <Input
+                                    onChange={onSequence}
+                                    style={{ padding: "0 0 0 14px" }}
+
+                                    allowClear
+                                    suffix={
+                                        <Button
+                                            size="large"
+                                            type="primary"
+                                            style={{ width: 80 }}
+                                            icon={
+                                                <SearchOutlined style={{ fontSize: 16, color: 'black' }} />
+                                            }
+                                            htmlType="submit"
+                                            disabled={
+                                                // true if the value of any field is falsey, or
+                                                (Object.values({ ...form.getFieldsValue(formFields) }).some(v => !v)) ||
+                                                // true if the length of the errors array is true.
+                                                !!form.getFieldsError().filter(({ errors }) => errors.length).length
+                                            }
+                                        />
+                                    }
+                                />
+
+                            </Form.Item>
+                        )}
+                    </Form.Item>
+
+                </Form>
+            </div>
+            {emitterChain && emitterAddress && sequence ? (
+                <ExplorerQuery emitterChain={emitterChain} emitterAddress={emitterAddress} sequence={sequence} />
+            ) : null}
+        </ >
+    )
+};
+
+export default ExplorerSearchForm

+ 152 - 0
explorer/src/components/App/ExplorerSearch/ExplorerTxForm.tsx

@@ -0,0 +1,152 @@
+import React, { ChangeEventHandler, useEffect, useState } from 'react';
+import { PageProps } from "gatsby"
+import { Grid, Form, Input, Button, } from 'antd';
+const { useBreakpoint } = Grid
+import { SearchOutlined } from '@ant-design/icons';
+import { FormattedMessage, useIntl } from 'gatsby-plugin-intl';
+
+import { ExplorerQuery } from '~/components/ExplorerQuery'
+
+
+// form props
+interface ExplorerTxValues {
+    txId: string,
+}
+const formFields = ['txId']
+
+interface ExplorerSearchProps {
+    location: PageProps["location"],
+    navigate: PageProps["navigate"]
+}
+const ExplorerTxForm: React.FC<ExplorerSearchProps> = ({ location, navigate }) => {
+    const intl = useIntl()
+    const screens = useBreakpoint()
+    const [, forceUpdate] = useState({});
+    const [form] = Form.useForm<ExplorerTxValues>();
+    const [txId, setTxId] = useState<ExplorerTxValues["txId"]>()
+
+    useEffect(() => {
+        // To disable submit button on first load.
+        forceUpdate({});
+    }, [])
+
+    useEffect(() => {
+
+        if (location.search) {
+            // take searchparams from the URL and set the values in the form
+            const searchParams = new URLSearchParams(location.search);
+
+            const txQuery = searchParams.get('txId')
+
+            // get the current values from the form fields
+            const { txId: txForm } = form.getFieldsValue(true)
+
+            // if the search params are different form values, update the form.
+            if (txQuery) {
+                if (txQuery !== txForm) {
+                    form.setFieldsValue({ txId: txQuery })
+                }
+                setTxId(txQuery)
+            }
+        } else {
+            // clear state
+            setTxId(undefined)
+        }
+    }, [location.search])
+
+
+
+    const onFinish = ({ txId }: ExplorerTxValues) => {
+        // pushing to the history stack will cause the component to get new props, and useEffect will run.
+        navigate(`/${intl.locale}/explorer/?txId=${txId}`)
+    };
+
+    const onTxId: ChangeEventHandler<HTMLInputElement> = (e) => {
+        if (e.currentTarget.value) {
+            // trim whitespace
+            form.setFieldsValue({ txId: e.currentTarget.value.replace(/\s/g, "") })
+        }
+    }
+    const formatLabel = (textKey: string) => (
+        <span style={{ fontSize: 16 }}>
+            <FormattedMessage id={textKey} />
+        </span>
+
+    )
+    const formatHelp = (textKey: string) => (
+        <span style={{ fontSize: 14 }}>
+            <FormattedMessage id={textKey} />
+        </span>
+    )
+
+
+    return (
+        <>
+
+            <div style={{ display: 'flex', justifyContent: 'center' }}>
+                <Form
+                    layout="vertical"
+                    form={form}
+                    name="explorer-tx-query"
+                    onFinish={onFinish}
+                    size="large"
+                    style={{ width: '90%', maxWidth: 800, fontSize: 14 }}
+                    colon={false}
+                    requiredMark={false}
+                    validateMessages={{ required: "'${label}' is required", }}
+                >
+
+                    <Form.Item shouldUpdate
+                        style={
+                            screens.md === false ? {
+                                display: 'block', width: '100%'
+                            } : {
+                                display: 'inline-block', width: '100%'
+                            }}
+                    >
+                        {() => (
+
+                            <Form.Item
+                                name="txId"
+                                label={formatLabel("explorer.txId")}
+                                help={formatHelp("explorer.txIdHelp")}
+                                rules={[{ required: true }]}
+                            >
+
+                                <Input
+                                    onChange={onTxId}
+                                    style={{ padding: "0 0 0 14px" }}
+                                    allowClear
+                                    suffix={
+                                        <Button
+                                            size="large"
+                                            type="primary"
+                                            style={{ width: 80 }}
+                                            icon={
+                                                <SearchOutlined style={{ fontSize: 16, color: 'black' }} />
+                                            }
+                                            htmlType="submit"
+                                            disabled={
+                                                // true if the value of any field is falsey, or
+                                                (Object.values({ ...form.getFieldsValue(formFields) }).some(v => !v)) ||
+                                                // true if the length of the errors array is true.
+                                                !!form.getFieldsError().filter(({ errors }) => errors.length).length
+                                            }
+                                        />
+                                    }
+                                />
+
+                            </Form.Item>
+                        )}
+                    </Form.Item>
+
+                </Form>
+            </div>
+            {txId ? (
+                <ExplorerQuery txId={txId} />
+            ) : null}
+        </ >
+    )
+};
+
+export default ExplorerTxForm

+ 2 - 0
explorer/src/components/App/ExplorerSearch/index.ts

@@ -0,0 +1,2 @@
+export { default as ExplorerSearchForm } from './ExplorerSearchForm'
+export { default as ExplorerTxForm } from './ExplorerTxForm'

+ 74 - 36
explorer/src/components/ExplorerQuery/ExplorerQuery.tsx

@@ -1,12 +1,15 @@
-import React, { useEffect, useState } from 'react';
+import React, { useContext, useEffect, useState } from 'react';
 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 { 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';
 
 export interface VAA {
     Version: number | string,
@@ -21,19 +24,23 @@ export interface VAA {
     Payload: string // base64 encoded byte array
 }
 export interface BigTableMessage {
-    InitiatingTxID: string
-    GuardianAddresses: string[],
+    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"
+    EmitterAddress: string
+    Sequence: string
 }
 
 interface ExplorerQuery {
-    emitterChain: number,
-    emitterAddress: string,
-    sequence: string
+    emitterChain?: number,
+    emitterAddress?: string,
+    sequence?: string,
+    txId?: string,
 }
 const ExplorerQuery = (props: ExplorerQuery) => {
+    const { activeNetwork } = useContext(NetworkContext)
     const [error, setError] = useState<string>();
     const [loading, setLoading] = useState<boolean>(true);
     const [message, setMessage] = useState<BigTableMessage>();
@@ -41,35 +48,65 @@ const ExplorerQuery = (props: ExplorerQuery) => {
     const [lastFetched, setLastFetched] = useState<number>()
     const [pollInterval, setPollInterval] = useState<NodeJS.Timeout>()
 
-    const fetchMessage = (
+    const fetchMessage = async (
         emitterChain: ExplorerQuery["emitterChain"],
         emitterAddress: ExplorerQuery["emitterAddress"],
-        sequence: ExplorerQuery["sequence"]) => {
-        let paddedAddress: string
-
-        if (emitterChain === 1) {
-            // TODO - zero pad Solana address, if needed.
-            paddedAddress = emitterAddress
-        } else if (emitterChain === 2 || emitterChain === 4) {
-            if (isHexString(emitterAddress)) {
+        sequence: ExplorerQuery["sequence"],
+        txId: ExplorerQuery["txId"]) => {
+        let paddedAddress: string = ""
+        let paddedSequence: string
+
+        let base = `${activeNetwork.endpoints.bigtableFunctionsBase}`
+        let url = ""
+
+        if (emitterChain && emitterAddress && sequence) {
+            if (emitterChain === chainIDs["solana"]) {
+                if (emitterAddress.length < 64) {
+                    try {
+                        paddedAddress = await getEmitterAddressSolana(emitterAddress)
+                    } catch (_) {
+                        // do nothing
+                    }
+                } else {
+                    paddedAddress = emitterAddress
+                }
+            } else if (emitterChain === chainIDs["ethereum"] || emitterChain === chainIDs["bsc"] || emitterChain === chainIDs["polygon"]) {
+                if (isHexString(emitterAddress)) {
 
-                let paddedAddressArray = zeroPad(arrayify(emitterAddress, { hexPad: "left" }), 32);
+                    let paddedAddressArray = zeroPad(arrayify(emitterAddress, { hexPad: "left" }), 32);
 
-                // TODO - properly encode the this to a hex string, Buffer is deprecated.
-                let maybeString = new Buffer(paddedAddressArray).toString('hex');
+                    let maybeString = Buffer.from(paddedAddressArray).toString('hex');
 
-                paddedAddress = maybeString
+                    paddedAddress = maybeString
+                } else {
+                    // must already be padded
+                    paddedAddress = emitterAddress
+                }
+            } else if (emitterChain === chainIDs["terra"]) {
+                if (emitterAddress.startsWith('terra')) {
+                    try {
+                        paddedAddress = toHex(zeroPad(Bech32.decode(emitterAddress).data, 32))
+                    } catch (_) {
+                        // do nothing
+                    }
+                } else {
+                    paddedAddress = emitterAddress
+                }
             } else {
-                // must already be padded
                 paddedAddress = emitterAddress
             }
-        } else {
-            // TODO - zero pad Terra address, if needed
-            paddedAddress = emitterAddress
-        }
 
-        const base = process.env.GATSBY_BIGTABLE_URL
-        const url = `${base}/?emitterChain=${emitterChain}&emitterAddress=${paddedAddress}&sequence=${sequence}`
+            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}`
+        }
 
         fetch(url)
             .then<BigTableMessage>(res => {
@@ -108,12 +145,12 @@ const ExplorerQuery = (props: ExplorerQuery) => {
     }
 
     const refreshCallback = () => {
-        fetchMessage(props.emitterChain, props.emitterAddress, props.sequence)
+        fetchMessage(props.emitterChain, props.emitterAddress, props.sequence, props.txId)
     }
 
     if (polling && !pollInterval) {
         let interval = setInterval(() => {
-            fetchMessage(props.emitterChain, props.emitterAddress, props.sequence)
+            fetchMessage(props.emitterChain, props.emitterAddress, props.sequence, props.txId)
         }, 3000)
         setPollInterval(interval)
     } else if (!polling && pollInterval) {
@@ -122,15 +159,16 @@ const ExplorerQuery = (props: ExplorerQuery) => {
     }
 
     useEffect(() => {
-        if (props.emitterChain && props.emitterAddress && props.sequence) {
-            setPolling(false)
+        setPolling(false)
+        setError(undefined)
+        setMessage(undefined)
+        setLastFetched(undefined)
+        if ((props.emitterChain && props.emitterAddress && props.sequence) || props.txId) {
             setLoading(true)
-            setError(undefined)
-            setMessage(undefined)
-            fetchMessage(props.emitterChain, props.emitterAddress, props.sequence)
+            fetchMessage(props.emitterChain, props.emitterAddress, props.sequence, props.txId)
         }
 
-    }, [props.emitterChain, props.emitterAddress, props.sequence])
+    }, [props.emitterChain, props.emitterAddress, props.sequence, props.txId, activeNetwork.endpoints.bigtableFunctionsBase])
 
     useEffect(() => {
         return function cleanup() {
@@ -138,7 +176,7 @@ const ExplorerQuery = (props: ExplorerQuery) => {
                 clearInterval(pollInterval)
             }
         };
-    }, [polling])
+    }, [polling, activeNetwork.endpoints.bigtableFunctionsBase])
 
 
     return (

+ 15 - 0
explorer/src/components/ExplorerStats/ChainOverviewCard.less

@@ -0,0 +1,15 @@
+// import theme to get antd less variables + overrides from AntdTheme.js
+@import "~antd/lib/style/themes/dark";
+
+
+.highlight-new-val {
+    animation: highlight 2000ms ease-out;
+}
+@keyframes highlight {
+  0% {
+    color: @primary-color;
+  }
+  100% {
+    color: @text-color;
+  }
+}

+ 106 - 0
explorer/src/components/ExplorerStats/ChainOverviewCard.tsx

@@ -0,0 +1,106 @@
+import React, { useState, useEffect } from 'react'
+
+import { Card, Statistic, Tooltip, Typography, } from 'antd'
+const { Text } = Typography
+
+import { useIntl, FormattedMessage } from 'gatsby-plugin-intl'
+import { navigate } from 'gatsby'
+import { Totals } from './ExplorerStats'
+import './ChainOverviewCard.less'
+
+interface ChainOverviewCardProps {
+    Icon: React.FC<React.SVGProps<SVGSVGElement>>
+    title: string
+    dataKey: "*" | "1" | "2" | "3" | "4" | "5"
+    totals?: Totals
+    iconStyle?: { [key: string]: string | number }
+    totalDays: number
+}
+
+const ChainOverviewCard: React.FC<ChainOverviewCardProps> = ({ Icon, iconStyle, title, dataKey, totals, totalDays }) => {
+    const intl = useIntl()
+    const [lastDayCount, setLastDayColunt] = useState<number>()
+    const [totalCount, setTotalColunt] = useState<number>()
+    const [loading, setLoading] = useState<boolean>(true)
+    const [animate, setAnimate] = useState<boolean>(false)
+
+    useEffect(() => {
+        if (!totals) {
+            setLoading(true)
+        }
+        // hold values from props in state, so that we can detect changes and add animation class
+        setLastDayColunt(totals?.LastDayCount[dataKey])
+        setTotalColunt(totals?.TotalCount[dataKey])
+
+        if (totals?.TotalCount && dataKey in totals?.TotalCount) {
+            setLoading(!totals?.TotalCount[dataKey] && !totals?.LastDayCount[dataKey])
+        }
+
+        let timeout: NodeJS.Timeout
+        if (totals?.LastDayCount[dataKey] && totalCount !== totals?.LastDayCount[dataKey]) {
+            setAnimate(true)
+            timeout = setTimeout(() => {
+                setAnimate(false)
+            }, 2000)
+        }
+        return function cleanup() {
+            if (timeout) {
+                clearTimeout(timeout)
+            }
+        }
+    }, [totals?.TotalCount[dataKey], totals?.LastDayCount[dataKey], dataKey, totalCount])
+
+    useEffect(() => {
+        // for chains that do not have a key in the bigtable result, no messages have been seen yet.
+        if (totals && "TotalCount" in totals && !(dataKey in totals?.TotalCount)) {
+            // if we have TotalCount, but the dataKey is not in it, no transactions for this chain
+            setLoading(false)
+        } else if (!totals) {
+            setLoading(true)
+        }
+    }, [totals?.TotalCount, dataKey])
+    return (
+        <Tooltip title={!!totalCount ?
+            intl.formatMessage({ id: "explorer.clickToView" }) :
+            loading ? "loading" : intl.formatMessage({ id: "explorer.comingSoon" })}>
+            <Card
+                style={{
+                    width: 200,
+                    paddingTop: 10
+                }}
+                className="hover-z-index"
+                cover={<Icon style={{ height: 140, ...iconStyle }} />}
+                hoverable={!!totalCount}
+                bordered={false}
+                onClick={() => !!totalCount && navigate(`/${intl.locale}/explorer?emitterChain=${dataKey}`)}
+                loading={loading}
+                bodyStyle={{
+                    display: 'flex',
+                    flexDirection: 'column',
+                    justifyContent: 'center',
+                    alignItems: 'center',
+
+                }}
+            >
+                <Card.Meta title={title} style={{ margin: '12px 0' }} />
+                {!!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 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 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 }} /> */}
+                        {/* <Statistic title={<span>last {totalDays} days</span>} value={totals?.TotalCount[dataKey]} style={{ display: 'flex', justifyContent: "center", alignItems: 'center', gap: 12, }} valueStyle={{ fontSize: 26 }} /> */}
+                    </>
+                ) : <Text type="secondary" style={{ height: 86, fontSize: 14 }}>{intl.formatMessage({ id: "explorer.comingSoon" })}</Text>}
+            </Card>
+
+        </Tooltip>
+    )
+}
+
+export default ChainOverviewCard

+ 120 - 0
explorer/src/components/ExplorerStats/DailyCountColumnChart.tsx

@@ -0,0 +1,120 @@
+import React, { useEffect, useState } from 'react';
+import { Totals } from './ExplorerStats';
+import { useIntl, FormattedMessage } from 'gatsby-plugin-intl'
+import { ResponsiveBar, BarDatum } from '@nivo/bar'
+import { makeDate, makeGroupName } from "./utils"
+
+
+interface DailyCountProps {
+    dailyCount: Totals["DailyTotals"]
+}
+
+const DailyCountColumnChart = (props: DailyCountProps) => {
+    const intl = useIntl()
+    const [data, setData] = useState<Array<BarDatum>>([])
+
+    useEffect(() => {
+        const datum = Object.keys(props.dailyCount).reduce<Array<BarDatum>>((accum, key) => {
+            const val = props.dailyCount[key]
+            return [...accum, Object.keys(val).reduce<BarDatum>((subAccum, subKey) => {
+
+                const group = makeGroupName(subKey)
+                return {
+                    ...subAccum,
+                    [group]: val[subKey],
+                }
+
+                // "SolanaColor": "hsl(259, 70%, 50%)", "EthereumColor": "hsl(43, 70%, 50%)", "BSCColor": "hsl(164, 70%, 50%)", "allColor": "hsl(345, 70%, 50%)"
+            }, { "date": makeDate(key) })]
+        }, [])
+        // console.log('bar datum: ', datum)
+
+        // TODO - create a dynamic list of keys
+        setData(datum)
+    }, [props.dailyCount])
+
+
+
+    return (
+        <div style={{ flexGrow: 1, width: '100%', height: 400, color: 'rgba(0, 0, 0, 0.85)' }}>
+            <h2>daily totals</h2>
+
+            <ResponsiveBar
+                theme={{ textColor: "rgba(255, 255, 255, 0.85)" }}
+                data={data}
+                keys={["All Messages", "Solana", "Ethereum", "BSC"]}
+                groupMode="grouped"
+                indexBy="date"
+                margin={{
+                    top: 50,
+                    right: 130,
+                    bottom: 50,
+                    left: 60
+                }}
+                padding={0.3}
+                valueScale={{ type: 'linear' }}
+                indexScale={{ type: 'band', round: true }}
+                colors={{ scheme: 'category10' }}
+                borderColor={{ from: 'color', modifiers: [['darker', 1.6]] }}
+                axisTop={null}
+                axisRight={null}
+                axisBottom={{
+                    tickSize: 5,
+                    tickPadding: 5,
+                    tickRotation: 0,
+                    legend: 'date',
+                    legendPosition: 'middle',
+                    legendOffset: 32
+                }}
+                axisLeft={{
+                    tickSize: 5,
+                    tickPadding: 5,
+                    tickRotation: 0,
+                    legend: 'messages',
+                    legendPosition: 'middle',
+                    legendOffset: -40
+                }}
+                labelSkipWidth={12}
+                labelSkipHeight={12}
+                labelTextColor={{ from: 'color', modifiers: [['darker', 1.6]] }}
+                legends={[
+                    {
+                        dataFrom: 'keys',
+                        anchor: 'bottom-right',
+                        direction: 'column',
+                        justify: false,
+                        translateX: 120,
+                        translateY: 0,
+                        itemsSpacing: 2,
+                        itemWidth: 100,
+                        itemHeight: 20,
+                        itemDirection: 'left-to-right',
+                        itemOpacity: 0.85,
+                        symbolSize: 20,
+                        effects: [
+                            {
+                                on: 'hover',
+                                style: {
+                                    itemOpacity: 1
+                                }
+                            }
+                        ]
+                    }
+                ]}
+            // tooltip={(props) => {
+            //     console.log(props)
+            //     // formattedValue: "21"
+            //     // height: 114
+            //     // hidden: false
+            //     // id: "Ethereum"
+            //     // index: 29
+            //     // indexValue: "09/21"
+            //     // label: "Ethereum - 09/21"
+            //     return <div>tooltip</div>
+            // }}
+            />
+        </div>
+    )
+}
+
+export default DailyCountColumnChart

+ 197 - 0
explorer/src/components/ExplorerStats/DailyCountLineChart.tsx

@@ -0,0 +1,197 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { Totals } from './ExplorerStats';
+import { FormattedMessage, useIntl } from 'gatsby-plugin-intl'
+import { Typography } from 'antd'
+const { Title } = Typography
+import ReactTimeAgo from 'react-time-ago'
+import { ResponsiveLine, Serie } from '@nivo/line'
+
+
+import { makeDate, makeGroupName, chainColors } from "./utils"
+import { titleStyles } from '~/styles';
+import { NetworkContext } from '../NetworkSelect';
+
+
+interface DailyCountProps {
+    dailyCount: Totals["DailyTotals"]
+    lastFetched?: number
+    title: string,
+    emitterChain?: number,
+    emitterAddress?: string
+}
+
+const DailyCountLineChart = (props: DailyCountProps) => {
+    const intl = useIntl()
+    const { activeNetwork } = useContext(NetworkContext)
+    const [data, setData] = useState<Array<Serie>>([])
+    const colors = [
+        "hsl(9, 100%, 61%)",
+        "hsl(30, 100%, 61%)",
+        "hsl(54, 100%, 61%)",
+        "hsl(82, 100%, 61%)",
+        "hsl(114, 100%, 61%)",
+        "hsl(176, 100%, 61%)",
+        "hsl(224, 100%, 61%)",
+        "hsl(270, 100%, 61%)",
+        "hsl(320, 100%, 61%)",
+        "hsl(360, 100%, 61%)",
+    ]
+
+    useEffect(() => {
+        const datum = Object.keys(props.dailyCount).reduce<{ [groupKey: string]: Serie }>((accum, key) => {
+            const vals = props.dailyCount[key]
+            const subKeyColors: { [key: string]: string } = {}
+
+            return Object.keys(vals).reduce<{ [groupKey: string]: Serie }>((subAccum, subKey) => {
+                if (props.emitterAddress && subKey === "*") {
+                    // if this chart is for a single emitterAddress, no need for "all messages" line.
+                    return subAccum
+                }
+                const group = makeGroupName(subKey, activeNetwork, props.emitterChain)
+
+                if (!(group in subAccum)) {
+                    // first time this group has been seen
+                    subAccum[group] = { id: group, data: [] }
+                    if (subKey in chainColors) {
+                        subAccum[group].color = chainColors[subKey]
+                    } else {
+
+                        if (!(subKey in subKeyColors)) {
+                            let len = Object.keys(subKeyColors).length
+                            subKeyColors[subKey] = colors[len]
+                        }
+                        subAccum[group].color = subKeyColors[subKey]
+                    }
+                }
+
+                subAccum[group].data.push({
+                    "y": vals[subKey],
+                    "x": makeDate(key)
+                })
+                return subAccum
+            }, accum)
+        }, {})
+
+        setData(Object.values(datum))
+    }, [props.dailyCount, props.lastFetched, props.emitterChain, props.emitterAddress, activeNetwork])
+
+    const dateLabel = [{
+        id: "label",
+        label: "Dates are UTC"
+    }]
+
+
+    return (
+        <div style={{ flexGrow: 1, height: 500, width: '100%' }}>
+            <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+                <Title level={3} style={{ ...titleStyles, marginLeft: 20 }}>{props.title}</Title>
+                {props.lastFetched ? (
+                    <div style={{ marginRight: 40 }}>
+                        <FormattedMessage id="explorer.lastUpdated" />:&nbsp;
+                        <ReactTimeAgo date={new Date(props.lastFetched)} locale={intl.locale} timeStyle="twitter" />
+                    </div>
+
+                ) : null}
+            </div>
+            <ResponsiveLine
+                theme={{ textColor: "rgba(255, 255, 255, 0.85)", fontSize: 16 }}
+                colors={({ color }) => color}
+                data={data}
+                curve={"monotoneX"}
+                margin={{ top: 10, right: 40, bottom: 160, left: 40 }}
+                xScale={{ type: 'point' }}
+                yScale={{ type: 'linear', min: 'auto', max: 'auto', stacked: false, reverse: false }}
+                enableGridX={false}
+                axisTop={null}
+                axisRight={null}
+                axisBottom={null}
+                axisLeft={{
+                    tickSize: 5,
+                    tickPadding: 5,
+                    tickRotation: 0,
+                    legend: 'count',
+                    legendOffset: -40,
+                    legendPosition: 'middle'
+                }}
+                pointSize={4}
+                pointColor={{ theme: 'background' }}
+                pointBorderWidth={2}
+                pointBorderColor={{ from: 'serieColor' }}
+                pointLabelYOffset={-12}
+                useMesh={true}
+                enableSlices={"x"}
+                isInteractive={true}
+                legends={[
+                    {
+                        anchor: 'bottom-right',
+                        direction: 'column',
+                        justify: false,
+                        translateX: -20,
+                        translateY: (30 + data.length * 20),
+                        itemsSpacing: 10,
+                        itemDirection: 'right-to-left',
+                        itemWidth: 400,
+                        itemHeight: 16,
+                        itemOpacity: 0.85,
+                        symbolSize: 12,
+                        symbolShape: 'circle',
+                        symbolBorderColor: 'rgba(0, 0, 0, .5)',
+                        effects: [
+                            {
+                                on: 'hover',
+                                style: {
+                                    itemBackground: 'rgba(0, 0, 0, .03)',
+                                    itemOpacity: 1
+                                }
+                            }
+                        ]
+                    },
+                    {
+                        anchor: 'bottom-left',
+                        direction: 'column',
+                        justify: false,
+                        translateX: 0,
+                        translateY: 40,
+                        itemsSpacing: 10,
+                        itemDirection: 'left-to-right',
+                        itemWidth: 60,
+                        itemHeight: 16,
+                        itemOpacity: 0.85,
+                        data: dateLabel,
+
+
+                    },
+                ]}
+                sliceTooltip={({ slice }) => {
+                    return (
+                        <div
+                            style={{
+                                background: '#010114',
+                                padding: '9px 12px',
+                                border: '1px solid rgba(255, 255, 255, 0.85)',
+                                color: "rgba(255, 255, 255, 0.85)",
+                                fontSize: 14
+                            }}
+                        >
+                            <Title level={4} style={{ color: 'rgba(255, 255, 255, 0.85)' }}>{slice.points[0].data.xFormatted}</Title>
+                            {slice.points.map(point => (
+                                <div
+                                    key={point.id}
+                                    style={{
+                                        display: 'flex',
+                                        padding: '3px 0',
+                                    }}
+                                >
+                                    <div style={{ background: point.serieColor, height: 16, width: 16, }} />&nbsp;
+                                    <span>{point.serieId}</span>&nbsp;-&nbsp;{point.data.yFormatted}
+                                </div>
+                            ))}
+                        </div>
+                    )
+                }}
+            />
+        </div>
+    )
+}
+
+export default DailyCountLineChart

+ 198 - 0
explorer/src/components/ExplorerStats/ExplorerStats.tsx

@@ -0,0 +1,198 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { Spin, } from 'antd'
+import { useIntl, } from 'gatsby-plugin-intl'
+import { BigTableMessage } from '~/components/ExplorerQuery/ExplorerQuery';
+
+import RecentMessages from './RecentMessages';
+import DailyCountLineChart from './DailyCountLineChart';
+import { NetworkContext } from '~/components/NetworkSelect';
+
+import { ReactComponent as BinanceChainIcon } from '~/icons/binancechain.svg';
+import { ReactComponent as EthereumIcon } from '~/icons/ethereum.svg';
+import { ReactComponent as SolanaIcon } from '~/icons/solana.svg';
+import { ReactComponent as TerraIcon } from '~/icons/terra.svg';
+import { ReactComponent as PolygonIcon } from '~/icons/polygon.svg';
+import ChainOverviewCard from './ChainOverviewCard';
+import { ChainID } from '~/utils/misc/constants';
+import { contractNameFormatter } from './utils';
+
+export interface Totals {
+    LastDayCount: { [groupByKey: string]: number }
+    TotalCount: { [groupByKey: string]: number }
+    DailyTotals: {
+        // "2021-08-22": { "*": 0 },
+        [date: string]: { [groupByKey: string]: number }
+    }
+}
+// type GroupByKey = "*" | "emitterChain" | "emitterChain:emitterAddress"
+export interface Recent {
+    [groupByKey: string]: Array<BigTableMessage>
+}
+type GroupBy = undefined | "chain" | "address"
+type ForChain = undefined | StatsProps["emitterChain"]
+type ForAddress = undefined | StatsProps["emitterAddress"]
+
+
+interface StatsProps {
+    emitterChain?: number,
+    emitterAddress?: string
+}
+
+const Stats: React.FC<StatsProps> = ({ emitterChain, emitterAddress }) => {
+    const intl = useIntl()
+    const { activeNetwork } = useContext(NetworkContext)
+
+    const [totals, setTotals] = useState<Totals>()
+    const [recent, setRecent] = useState<Recent>()
+    const [polling, setPolling] = useState(true);
+    const [lastFetched, setLastFetched] = useState<number>()
+    const [pollInterval, setPollInterval] = useState<NodeJS.Timeout>()
+    const [controller, setController] = useState<AbortController>(new AbortController())
+
+    const daysSinceDataStart = 30
+
+    const fetchTotals = (baseUrl: string, groupBy: GroupBy, forChain: ForChain, forAddress: ForAddress, signal: AbortSignal) => {
+        const totalsUrl = `${baseUrl}/totals`
+        let url = `${totalsUrl}?numDays=${daysSinceDataStart}`
+        if (groupBy) { url = `${url}&groupBy=${groupBy}` }
+        if (forChain) { url = `${url}&forChain=${forChain}` }
+        if (forAddress) { url = `${url}&forAddress=${forAddress}` }
+
+        return fetch(url, { signal })
+            .then<Totals>(res => {
+                if (res.ok) return res.json()
+                // throw an error with specific message, rather than letting the json decoding throw.
+                throw 'explorer.stats.failedFetchingTotals'
+            })
+            .then(result => {
+                setTotals(result)
+                setLastFetched(Date.now())
+
+            }, error => {
+                if (error.name !== "AbortError") {
+                    //  handle errors here instead of a catch(), so that we don't swallow exceptions from components
+                    console.error('failed fetching totals. error: ', error)
+                }
+            })
+    }
+    const fetchRecent = (baseUrl: string, groupBy: GroupBy, forChain: ForChain, forAddress: ForAddress, signal: AbortSignal) => {
+        const recentUrl = `${baseUrl}/recent`
+        let url = `${recentUrl}?numRows=24`
+        if (groupBy) { url = `${url}&groupBy=${groupBy}` }
+        if (forChain) { url = `${url}&forChain=${forChain}` }
+        if (forAddress) { url = `${url}&forAddress=${forAddress}` }
+
+        return fetch(url, { signal })
+            .then<Recent>(res => {
+                if (res.ok) return res.json()
+                // throw an error with specific message, rather than letting the json decoding throw.
+                throw 'explorer.stats.failedFetchingRecent'
+            })
+            .then(result => {
+                setRecent(result)
+                setLastFetched(Date.now())
+
+            }, error => {
+                if (error.name !== "AbortError") {
+                    //  handle errors here instead of a catch(), so that we don't swallow exceptions from components
+                    console.error('failed fetching recent. error: ', error)
+                }
+            })
+    }
+
+    const getData = (props: StatsProps, baseUrl: string, signal: AbortSignal) => {
+        let forChain: ForChain = undefined
+        let forAddress: ForAddress = undefined
+        let recentGroupBy: GroupBy = undefined
+        let totalsGroupBy: GroupBy = "chain"
+        if (props.emitterChain) {
+            forChain = props.emitterChain
+            totalsGroupBy = 'address'
+            recentGroupBy = 'address'
+        }
+        if (props.emitterChain && props.emitterAddress) {
+            forAddress = props.emitterAddress
+        }
+        return Promise.all([
+            fetchTotals(baseUrl, totalsGroupBy, forChain, forAddress, signal),
+            fetchRecent(baseUrl, recentGroupBy, forChain, forAddress, signal)
+        ])
+    }
+
+    const pollingController = (emitterChain: StatsProps["emitterChain"], emitterAddress: StatsProps["emitterAddress"], baseUrl: string) => {
+        // clear any ongoing intervals
+        if (pollInterval) {
+            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)
+            }, 4000)
+            setPollInterval(interval)
+        }
+    }
+
+    useEffect(() => {
+        controller.abort()
+        setTotals(undefined)
+        setRecent(undefined)
+        pollingController(emitterChain, emitterAddress, activeNetwork.endpoints.bigtableFunctionsBase)
+    }, [emitterChain, emitterAddress, activeNetwork.endpoints.bigtableFunctionsBase])
+
+    useEffect(() => {
+        return function cleanup() {
+            if (pollInterval) {
+                clearInterval(pollInterval)
+            }
+        };
+    }, [polling, pollInterval, activeNetwork.endpoints.bigtableFunctionsBase])
+
+    let title = "Recent messages"
+    let hideTableTitles = false
+    if (emitterChain) {
+        title = `Recent ${ChainID[Number(emitterChain)]} messages`
+    }
+    if (emitterChain && emitterAddress) {
+        title = `Recent ${contractNameFormatter(emitterAddress, emitterChain, activeNetwork)} messages`
+        hideTableTitles = true
+    }
+
+    return (
+        <>
+            {!emitterChain && !emitterAddress &&
+                <div style={{ display: 'flex', justifyContent: 'space-around', alignItems: 'flex-end', flexWrap: 'wrap', marginBottom: 40 }}>
+                    <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} />
+                    <ChainOverviewCard totalDays={daysSinceDataStart} totals={totals} dataKey="4" title={ChainID[4]} Icon={BinanceChainIcon} />
+                    <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 &&
+                    <DailyCountLineChart
+                        dailyCount={totals?.DailyTotals}
+                        lastFetched={lastFetched}
+                        title="messages/day"
+                        emitterChain={emitterChain}
+                        emitterAddress={emitterAddress}
+                    />}
+            </div>
+
+            {recent && <RecentMessages recent={recent} lastFetched={lastFetched} title={title} hideTableTitles={hideTableTitles} />}
+
+        </>
+    )
+}
+
+export default Stats

+ 17 - 0
explorer/src/components/ExplorerStats/RecentMessages.less

@@ -0,0 +1,17 @@
+// import theme to get antd less variables + overrides from AntdTheme.js
+@import "~antd/lib/style/themes/dark";
+
+
+.highlight-new-row {
+    animation: highlight-row 1500ms ease-out;
+}
+@keyframes highlight-row {
+  0% {
+    color: black;
+    background: darken(@primary-color, 5%);
+  }
+  100% {
+    color: @text-color;
+    background: @body-background;
+  }
+}

+ 135 - 0
explorer/src/components/ExplorerStats/RecentMessages.tsx

@@ -0,0 +1,135 @@
+import React from 'react';
+import { Recent } from './ExplorerStats';
+import { useIntl, FormattedMessage } from 'gatsby-plugin-intl'
+import { Grid, Table, Typography } from 'antd'
+const { useBreakpoint } = Grid
+const { Title } = Typography
+import ReactTimeAgo from 'react-time-ago'
+import { BigTableMessage } from '../ExplorerQuery/ExplorerQuery';
+import { chainIDs, ChainID } from '~/utils/misc/constants';
+import { Link } from 'gatsby';
+import { ColumnsType } from 'antd/es/table';
+import { titleStyles } from '~/styles';
+import { DecodePayload } from '../Payload';
+import { contractNameFormatter } from './utils';
+import './RecentMessages.less'
+
+import { ReactComponent as BinanceChainIcon } from '~/icons/binancechain.svg';
+import { ReactComponent as EthereumIcon } from '~/icons/ethereum.svg';
+import { ReactComponent as SolanaIcon } from '~/icons/solana.svg';
+import { ReactComponent as TerraIcon } from '~/icons/terra.svg';
+import { ReactComponent as PolygonIcon } from '~/icons/polygon.svg'
+
+interface RecentMessagesProps {
+    recent: Recent
+    lastFetched?: number
+    title: string
+    hideTableTitles?: boolean
+}
+
+const networkIcons = [
+    <></>,
+    <SolanaIcon key="1" style={{ height: 18, maxWidth: 18, margin: '0 4px' }} />,
+    <EthereumIcon key="2" style={{ height: 24, margin: '0 4px' }} />,
+    <TerraIcon key="3" style={{ height: 18, margin: '0 4px' }} />,
+    <BinanceChainIcon key="4" style={{ height: 18, margin: '0 4px' }} />,
+    <PolygonIcon key="5" style={{ height: 18, margin: '0 4px' }} />,
+]
+
+
+const RecentMessages = (props: RecentMessagesProps) => {
+    const intl = useIntl()
+    const screens = useBreakpoint()
+    const columns: ColumnsType<BigTableMessage> = [
+        { title: '', key: 'icon', render: (item: BigTableMessage) => networkIcons[chainIDs[item.EmitterChain]], responsive: ['sm'] },
+        {
+            title: "contract",
+            key: "contract",
+            render: (item: BigTableMessage) => {
+                const name = contractNameFormatter(item.EmitterAddress, chainIDs[item.EmitterChain])
+                return <div>{name}</div>
+            },
+            responsive: ['sm']
+        },
+        {
+            title: "message",
+            key: "payload",
+            render: (item: BigTableMessage) => <DecodePayload
+                base64VAA={item.SignedVAABytes}
+                emitterChainName={item.EmitterChain}
+                emitterAddress={item.EmitterAddress}
+                showType={true}
+                showSummary={true}
+            />
+        },
+        {
+            title: "sequence",
+            key: "sequence",
+            render: (item: BigTableMessage) => {
+                let sequence = item.Sequence.replace(/^0+/, "")
+                if (!sequence) sequence = "0"
+
+                return sequence
+            },
+            responsive: ['md']
+        },
+        {
+            title: "attested",
+            dataIndex: "QuorumTime",
+            key: "time",
+            render: QuorumTime => <ReactTimeAgo date={new Date(QuorumTime)} locale={intl.locale} timeStyle={!screens.md ? "twitter" : "round"} />
+
+        },
+        {
+            title: "",
+            key: "view",
+            render: (item: BigTableMessage) => <Link to={`/${intl.locale}/explorer/?emitterChain=${chainIDs[item.EmitterChain]}&emitterAddress=${item.EmitterAddress}&sequence=${item.Sequence}`}>View</Link>
+        },
+    ]
+
+
+    const formatKey = (key: string) => {
+        if (props.hideTableTitles) {
+            return null
+        }
+        if (key.includes(":")) {
+            const parts = key.split(":")
+            const link = `/${intl.locale}/explorer/?emitterChain=${parts[0]}&emitterAddress=${parts[1]}`
+            return <Title level={4} style={titleStyles}>From {ChainID[Number(parts[0])]} contract: <Link to={link}>{contractNameFormatter(parts[1], Number(parts[0]))}</Link></Title>
+        } else if (key === "*") {
+            return <Title level={4} style={titleStyles}>From all chains and addresses</Title>
+        } else {
+            return <Title level={4} style={titleStyles}>From {ChainID[Number(key)]}</Title>
+        }
+    }
+
+    return (
+        <>
+            <Title level={3} style={titleStyles} >{props.title}</Title>
+            {Object.keys(props.recent).map(key => (
+                <Table<BigTableMessage>
+                    key={key}
+                    rowKey={(item) => item.EmitterAddress + item.Sequence}
+                    style={{ marginBottom: 40 }}
+                    size={screens.lg ? "large" : "small"}
+                    columns={columns}
+                    dataSource={props.recent[key]}
+                    title={() => formatKey(key)}
+                    pagination={false}
+                    rowClassName="highlight-new-row"
+                    footer={() => {
+                        return props.lastFetched ? (
+                            <span>
+                                <FormattedMessage id="explorer.lastUpdated" />:&nbsp;
+                                <ReactTimeAgo date={new Date(props.lastFetched)} locale={intl.locale} timeStyle="twitter" />
+                            </span>
+
+                        ) : null
+                    }}
+                />
+            ))}
+        </>
+    )
+}
+
+export default RecentMessages

+ 1 - 0
explorer/src/components/ExplorerStats/index.ts

@@ -0,0 +1 @@
+export { default as ExplorerStats } from './ExplorerStats';

+ 116 - 0
explorer/src/components/ExplorerStats/utils.ts

@@ -0,0 +1,116 @@
+import { useContext } from 'react'
+import { Bech32, fromHex } from "@cosmjs/encoding"
+import { chainEnums, ChainID, chainIDs } from '~/utils/misc/constants';
+import { ActiveNetwork, NetworkContext } from "~/components/NetworkSelect";
+
+const makeDate = (date: string): string => {
+    const [_, month, day] = date.split("-")
+    if (!month || !day) {
+        throw Error("Invalid date supplied to makeDate. Expects YYYY-MM-DD.")
+    }
+    return `${month}/${day}`
+}
+const makeGroupName = (groupKey: string, activeNetwork: ActiveNetwork, emitterChain?: number): string => {
+    let ALL = "All Wormhole messages"
+    if (emitterChain) {
+        ALL = `All ${chainEnums[emitterChain]} messages`
+    }
+    let group = groupKey === "*" ? ALL : groupKey
+    if (group.includes(":")) {
+        // subKey is chainID:addresss
+        let parts = groupKey.split(":")
+        group = `${ChainID[Number(parts[0])]} ${contractNameFormatter(parts[1], Number(parts[0]), activeNetwork)}`
+    } else if (group != ALL) {
+        // subKey is a chainID
+        group = ChainID[Number(groupKey)]
+    }
+    return group
+}
+
+const getNativeAddress = (chainId: number, emitterAddress: string, activeNetwork?: ActiveNetwork): string => {
+    let nativeAddress = ""
+
+    if (chainId === chainIDs["ethereum"] || chainId === chainIDs["bsc"]) {
+        // remove zero-padding
+        let unpadded = emitterAddress.slice(-40)
+        nativeAddress = `0x${unpadded}`.toLowerCase()
+    } else if (chainId === chainIDs["terra"]) {
+        // remove zero-padding
+        let unpadded = emitterAddress.slice(-40)
+        nativeAddress = Bech32.encode("terra", fromHex(unpadded)).toLowerCase()
+    } else if (chainId === chainIDs["solana"]) {
+        if (!activeNetwork) {
+            activeNetwork = useContext(NetworkContext).activeNetwork
+        }
+        const chainName = chainEnums[chainId].toLowerCase()
+
+        // not sure how to do this programattically, so use the "chains" map
+        if (emitterAddress in activeNetwork.chains[chainName]) {
+            let desc = activeNetwork.chains[chainName][emitterAddress]
+            if (desc in activeNetwork.chains[chainName]) {
+                // lookup the contract address
+                nativeAddress = activeNetwork.chains[chainName][desc]
+            }
+        }
+    }
+    return nativeAddress
+}
+
+
+const truncateAddress = (address: string): string => {
+    return `${address.slice(0, 4)}...${address.slice(-4)}`
+}
+
+const contractNameFormatter = (address: string, chainId: number, activeNetwork?: ActiveNetwork): string => {
+    if (!activeNetwork) {
+        activeNetwork = useContext(NetworkContext).activeNetwork
+    }
+
+    const chainName = chainEnums[chainId].toLowerCase()
+    let nativeAddress = getNativeAddress(chainId, address, activeNetwork)
+
+    let truncated = truncateAddress(nativeAddress || address)
+    let formatted = truncated
+
+    if (nativeAddress in activeNetwork.chains[chainName]) {
+        // add the description of the contract, if we know it
+        let desc = activeNetwork.chains[chainName][nativeAddress]
+        formatted = `${desc} (${truncated})`
+    }
+    return formatted
+}
+
+
+const nativeExplorerUri = (chainId: number, address: string, activeNetwork?: ActiveNetwork): string => {
+    if (!activeNetwork) {
+        activeNetwork = useContext(NetworkContext).activeNetwork
+    }
+
+    const nativeAddress = getNativeAddress(chainId, address, activeNetwork)
+    if (nativeAddress) {
+        if (chainId === chainIDs["solana"]) {
+            let base = "https://explorer.solana.com/address/"
+            return `${base}${nativeAddress}`
+        } else if (chainId === chainIDs["ethereum"]) {
+            let base = "https://etherscan.io/address/"
+            return `${base}${nativeAddress}`
+        } else if (chainId === chainIDs["terra"]) {
+            let base = "https://finder.terra.money/columbus-5/address/"
+            return `${base}${nativeAddress}`
+        } else if (chainId === chainIDs["bsc"]) {
+            let base = "https://bscscan.com/address/"
+            return `${base}${nativeAddress}`
+        }
+    }
+    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%)"
+}
+
+export { makeDate, makeGroupName, chainColors, truncateAddress, contractNameFormatter, nativeExplorerUri }

+ 45 - 13
explorer/src/components/ExplorerSummary/ExplorerSummary.tsx

@@ -3,14 +3,19 @@ import { Button, Spin, Typography } from 'antd'
 const { Title } = Typography
 import { useIntl, FormattedMessage } from 'gatsby-plugin-intl'
 import { BigTableMessage } from '~/components/ExplorerQuery/ExplorerQuery';
-// import { WasmTest } from '~/components/wasm'
+import { DecodePayload } from '~/components/Payload'
 import ReactTimeAgo from 'react-time-ago'
-import { buttonStylesLg, titleStyles } from '~/styles';
+import { titleStyles } from '~/styles';
+import { CloseOutlined, ReloadOutlined } from '@ant-design/icons';
+import { Link } from 'gatsby';
+import { contractNameFormatter, nativeExplorerUri } from '../ExplorerStats/utils';
+import { OutboundLink } from 'gatsby-plugin-google-gtag';
 
 interface SummaryProps {
-    emitterChain: number,
-    emitterAddress: string,
-    sequence: string
+    emitterChain?: number,
+    emitterAddress?: string,
+    sequence?: string
+    txId?: string
     message: BigTableMessage
     polling?: boolean
     lastFetched?: number
@@ -20,10 +25,7 @@ interface SummaryProps {
 const Summary = (props: SummaryProps) => {
 
     const intl = useIntl()
-
-    useEffect(() => {
-        // TODO: decode the payload. if applicable lookup other relevant messages.
-    }, [props])
+    const { SignedVAA, ...message } = props.message
 
     return (
         <>
@@ -36,11 +38,42 @@ const Summary = (props: SummaryProps) => {
                         <Title level={2} style={titleStyles}><FormattedMessage id="explorer.listening" /></Title>
                     </>
                 ) : (
-                    <Button style={buttonStylesLg} onClick={props.refetch} size="large"><FormattedMessage id="explorer.refresh" /></Button>
+                    <div>
+                        <Button onClick={props.refetch} icon={<ReloadOutlined />} size="large" shape="round" >refresh</Button>
+                        <Link to={`/${intl.locale}/explorer`} style={{ marginLeft: 8 }}>
+                            <Button icon={<CloseOutlined />} size='large' shape="round">clear</Button>
+                        </Link>
+                    </div>
                 )}
             </div>
-            <pre>{JSON.stringify(props.message, undefined, 2)}</pre>
-            <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
+            <div className="styled-scrollbar">
+                <pre
+                    style={{ fontSize: 14, marginBottom: 20 }}
+                >{JSON.stringify(message, undefined, 2)}</pre>
+            </div>
+            <DecodePayload
+                base64VAA={props.message.SignedVAABytes}
+                emitterChainName={props.message.EmitterChain}
+                emitterAddress={props.message.EmitterAddress}
+                showPayload={true}
+            />
+            <div className="styled-scrollbar">
+                <Title level={3} style={titleStyles}>Signed VAA</Title>
+                <pre
+                    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 />}
 
                 {props.lastFetched ? (
                     <span>
@@ -50,7 +83,6 @@ const Summary = (props: SummaryProps) => {
 
                 ) : null}
             </div>
-            {/* <WasmTest base64VAA={props.message.SignedVAA} /> */}
         </>
     )
 }

+ 7 - 4
explorer/src/components/GuardiansTable/GuardiansTable.tsx

@@ -11,16 +11,18 @@ import { ReactComponent as BinanceChainIcon } from '~/icons/binancechain.svg';
 import { ReactComponent as EthereumIcon } from '~/icons/ethereum.svg';
 import { ReactComponent as SolanaIcon } from '~/icons/solana.svg';
 import { ReactComponent as TerraIcon } from '~/icons/terra.svg';
+import { ReactComponent as PolygonIcon } from '~/icons/polygon.svg'
 
 import './GuardiansTable.less'
+import { ChainID } from '~/utils/misc/constants';
 
-const networkEnums = ['', 'Solana', 'Ethereum', 'Terra', 'BSC']
 const networkIcons = [
   <></>,
   <SolanaIcon key="1" style={{ height: 18, maxWidth: 18, margin: '0 4px' }} />,
   <EthereumIcon key="2" style={{ height: 24, margin: '0 4px' }} />,
   <TerraIcon key="3" style={{ height: 18, margin: '0 4px' }} />,
   <BinanceChainIcon key="4" style={{ height: 18, margin: '0 4px' }} />,
+  <PolygonIcon key="5" style={{ height: 18, margin: '0 4px' }} />,
 ]
 
 const expandedRowRender = (intl: IntlShape) => (item: Heartbeat) => {
@@ -28,10 +30,11 @@ const expandedRowRender = (intl: IntlShape) => (item: Heartbeat) => {
     { title: '', dataIndex: 'id', key: 'icon', render: (id: number) => networkIcons[id] },
     {
       title: intl.formatMessage({ id: 'network.network' }), dataIndex: 'id', key: 'id', responsive: ['md'],
-      render: (id: number) => networkEnums[id]
+      render: (id: number) => ChainID[id]
     },
-    { title: intl.formatMessage({ id: 'network.address' }), dataIndex: 'bridgeAddress', key: 'bridgeAddress' },
-    { title: intl.formatMessage({ id: 'network.blockHeight' }), dataIndex: 'height', key: 'height', responsive: ['md'], }
+    { title: intl.formatMessage({ id: 'network.contractAddress' }), dataIndex: 'contractAddress', key: 'contractAddress' },
+    { title: intl.formatMessage({ id: 'network.blockHeight' }), dataIndex: 'height', key: 'height', responsive: ['md'], },
+    { title: intl.formatMessage({ id: 'network.errorCount' }), dataIndex: 'errorCount', key: 'errorCount', responsive: ['lg'], },
   ];
 
   return (

+ 28 - 0
explorer/src/components/NetworkSelect/NetworkSelect.tsx

@@ -0,0 +1,28 @@
+import React from 'react';
+import { Select } from 'antd'
+const { Option } = Select
+import { FormattedMessage } from 'gatsby-plugin-intl'
+import { NetworkContext } from "./network-context"
+
+const NetworkSelect = ({ style }: { style?: { [key: string]: string | number } }) => {
+    return (
+        <NetworkContext.Consumer>
+            {({ activeNetwork, setActiveNetwork }) => (
+                <Select
+                    defaultValue={activeNetwork.name}
+                    onSelect={setActiveNetwork}
+                    size="large"
+                    style={style}
+                >
+                    <Option value="devnet"><FormattedMessage id="networks.devnet" /></Option>
+                    <Option value="testnet"><FormattedMessage id="networks.testnet" /></Option>
+                    <Option value="mainnet"><FormattedMessage id="networks.mainnet" /></Option>
+                </Select>
+            )}
+        </NetworkContext.Consumer>
+
+    )
+
+}
+
+export default NetworkSelect

+ 85 - 0
explorer/src/components/NetworkSelect/WithNetwork.tsx

@@ -0,0 +1,85 @@
+import React from 'react';
+import { NetworkContext } from '~/components/NetworkSelect'
+import { NetworkContextI } from './network-context'
+import { endpoints, KnownContracts, knownContractsPromise, NetworkChains, networks } from '~/utils/misc/constants';
+
+// Check if window is defined (so if in the browser or in node.js).
+const isBrowser = typeof window !== "undefined"
+
+const defaultNetwork = process.env.GATSBY_DEFAULT_NETWORK || "mainnet"
+
+interface NetworkContextState extends NetworkContextI {
+    knownContracts: NetworkChains
+}
+const WithNetwork = (WrappedComponent: React.FC<any>) => {
+
+    return class extends React.Component<{}, NetworkContextState> {
+        constructor(props: any) {
+            super(props)
+
+            let network: string | undefined | null = ""
+            if (isBrowser) {
+                // isBrowser check for Gatsby develop's SSR
+                network = window.localStorage.getItem("networkName")
+            }
+            if (!network || !networks.includes(network)) {
+                network = defaultNetwork
+            }
+
+            this.state = {
+                // knownContracts are generated async and added to state
+                knownContracts: {
+                    "devnet": {},
+                    "testnet": {},
+                    "mainnet": {}
+                },
+                activeNetwork: {
+                    name: network,
+                    endpoints: endpoints[network],
+                    chains: {
+                        // chains are generated async and added to state
+                        "solana": {} as KnownContracts,
+                        "ethereum": {} as KnownContracts,
+                        "terra": {} as KnownContracts,
+                        "bsc": {} as KnownContracts
+                    }
+                },
+                setActiveNetwork: this.setActiveNetwork,
+            };
+            this.setActiveNetwork(network)
+        }
+
+        setActiveNetwork = async (network: string) => {
+            if (isBrowser) {
+                // isBrowser check for Gatsby develop's SSR
+                window.localStorage.setItem("networkName", network)
+            }
+
+            // generate knownContracts if needed
+            let contracts = this.state.knownContracts
+            if (!this.state.knownContracts.devent) {
+                contracts = await knownContractsPromise
+                this.setState(() => ({
+                    knownContracts: contracts
+                }))
+            }
+
+            this.setState(() => ({
+                activeNetwork: {
+                    name: network,
+                    endpoints: endpoints[network],
+                    chains: contracts[network],
+                }
+            }));
+        }
+        render() {
+            return (
+                <NetworkContext.Provider value={this.state}>
+                    <WrappedComponent {...this.props} />
+                </NetworkContext.Provider>
+            )
+        }
+    }
+}
+
+export default WithNetwork

+ 3 - 0
explorer/src/components/NetworkSelect/index.ts

@@ -0,0 +1,3 @@
+export { default as NetworkSelect } from './NetworkSelect';
+export { NetworkContext, ActiveNetwork } from "./network-context"
+export { default as WithNetwork } from "./WithNetwork"

+ 34 - 0
explorer/src/components/NetworkSelect/network-context.ts

@@ -0,0 +1,34 @@
+import { createContext } from "react"
+import { ChainContracts, endpoints, KnownContracts, NetworkConfig, } from '~/utils/misc/constants';
+
+let defaultNetwork = process.env.GATSBY_DEFAULT_NETWORK || "mainnet"
+
+// ensure the network value is valid
+if (!(defaultNetwork in endpoints)) {
+    defaultNetwork = defaultNetwork
+}
+export interface ActiveNetwork {
+    name: string
+    endpoints: NetworkConfig
+    chains: ChainContracts
+}
+interface NetworkContextI {
+    activeNetwork: ActiveNetwork,
+    setActiveNetwork: (network: string) => void
+}
+const NetworkContext = createContext<NetworkContextI>({
+    activeNetwork: {
+        name: defaultNetwork,
+        endpoints: endpoints[defaultNetwork],
+        chains: {
+            // initalize empty objects, will be replaced async by generated data
+            "solana": {} as KnownContracts,
+            "ethereum": {} as KnownContracts,
+            "terra": {} as KnownContracts,
+            "bsc": {} as KnownContracts
+        }
+    },
+    setActiveNetwork: (network: string) => { },
+})
+
+export { NetworkContext, NetworkContextI }

+ 296 - 0
explorer/src/components/Payload/DecodePayload.tsx

@@ -0,0 +1,296 @@
+
+import { BigNumber } from "ethers";
+import React, { useEffect, useState } from "react";
+import { chainEnums, ChainIDs, chainIDs, METADATA_REPLACE } from "~/utils/misc/constants";
+
+
+import { Statistic, Typography } from 'antd'
+import { FormattedMessage } from "gatsby-plugin-intl";
+import { titleStyles } from "~/styles";
+
+const { Title } = Typography
+
+const validChains = Object.values(chainIDs)
+
+// these types match/load the descriptions in src/locales
+type TokenTransfer = "tokenTransfer"
+type NFTTransfer = "nftTransfer"
+type AssetMeta = "assetMeta"
+type Governance = "governance"
+type Pyth = "pyth"
+type UnknownMessage = "unknownMessage"
+
+type PayloadType = TokenTransfer | NFTTransfer | AssetMeta | Governance | Pyth | UnknownMessage
+
+// the payloads this component can decode
+const knownPayloads = ["assetMeta", "tokenTransfer", "nftTransfer"]
+
+
+interface TokenTransferPayload {
+    payloadId: number
+    amount: string
+    originAddress: string
+    originChain: number
+    targetAddress: string
+    targetChain: number
+}
+interface NFTTransferPayload {
+    payloadId: number
+    name: string // "Not a PUNK🎸"
+    originAddress: string // "0101010101010101010101010101010101010101010101010101010101010101"
+    originChain: number // 1
+    symbol: string //  "PUNK🎸"
+    targetAddress: string // "00000000000000000000000090f8bf6a479f320ead074411a4b0e7944ea8c9c1"
+    targetChain: number // 2
+    tokenId: BigNumber | string // BigNumber { _hex: '0x9c006c48c8cbf33849cb07a3f936159cc523f9591cb1999abd45890ec5fee9b7', _isBigNumber: true }
+    uri: string // "https://wrappedpunks.com:3000/api/punks/metadata/39"
+}
+interface AssetMetaPayload {
+    payloadId: number
+    tokenAddress: string
+    tokenChain: number
+    decimals: number
+    symbol: string
+    name: string
+}
+// TODO - figure out how to decode these and what they contain
+interface GovernancePayload { }
+interface PythPayload { }
+interface UnknownPayload { }
+
+type VAAPayload =
+    | TokenTransferPayload
+    | NFTTransferPayload
+    | AssetMetaPayload
+    | GovernancePayload
+    | PythPayload
+    | UnknownPayload
+
+type TokenTransferBundle = { type: TokenTransfer, payload: TokenTransferPayload }
+type NFTTransferBundle = { type: NFTTransfer, payload: NFTTransferPayload }
+type AssetMetaBundle = { type: AssetMeta, payload: AssetMetaPayload }
+type UnknownMessageBundle = { type: UnknownMessage, payload: UnknownPayload }
+type PayloadBundle = TokenTransferBundle | NFTTransferBundle | AssetMetaBundle | UnknownMessageBundle
+
+
+const parseTokenPayload = (arr: Buffer): TokenTransferPayload => ({
+    payloadId: arr.readUInt8(0),
+    amount: BigNumber.from(arr.slice(1, 1 + 32)).toBigInt().toString(),
+    originAddress: arr.slice(33, 33 + 32).toString("hex"),
+    originChain: arr.readUInt16BE(65),
+    targetAddress: arr.slice(67, 67 + 32).toString("hex"),
+    targetChain: arr.readUInt16BE(99),
+});
+const parseNFTPayload = (arr: Buffer): NFTTransferPayload => {
+    const payloadId = arr.readUInt8(0)
+    const originAddress = arr.slice(1, 1 + 32).toString("hex");
+    const originChain = arr.readUInt16BE(33)
+    const symbol = Buffer.from(arr.slice(35, 35 + 32))
+        .toString("utf8")
+        .replace(METADATA_REPLACE, "");
+    const name = Buffer.from(arr.slice(67, 67 + 32))
+        .toString("utf8")
+        .replace(METADATA_REPLACE, "");
+    const tokenId = BigNumber.from(arr.slice(99, 99 + 32)).toString()
+    const uri_len = arr.readUInt8(131);
+    const uri = Buffer.from(arr.slice(132, 132 + uri_len))
+        .toString("utf8")
+        .replace(METADATA_REPLACE, "");
+    const target_offset = 132 + uri_len;
+    const targetAddress = arr
+        .slice(target_offset, target_offset + 32)
+        .toString("hex");
+    const targetChain = arr.readUInt16BE(target_offset + 32);
+    return {
+        payloadId,
+        originAddress,
+        originChain,
+        symbol,
+        name,
+        tokenId,
+        uri,
+        targetAddress,
+        targetChain,
+    };
+};
+const parseAssetMetaPayload = (arr: Buffer): AssetMetaPayload => {
+    let index = 0
+    const payloadId = arr.readUInt8(0)
+    index += 1
+
+    const tokenAddress = arr.slice(index, index + 32).toString("hex");
+    index += 32
+
+    const tokenChain = arr.readUInt16BE(index)
+    index += 1
+
+    const decimals = arr.readUInt8(index)
+    index += 1
+
+    const symbol = Buffer.from(arr.slice(index, index + 32))
+        .toString("utf8")
+        .replace(METADATA_REPLACE, "")
+        .replace(new RegExp("\u0012", "g"), "")
+        .replace(new RegExp("\u0002", "g"), "")
+    index += 32
+
+    const name = Buffer.from(arr.slice(index, index + 32))
+        .toString("utf8")
+        .replace(METADATA_REPLACE, "");
+    index += 32
+
+    return {
+        payloadId,
+        tokenAddress,
+        tokenChain,
+        decimals,
+        symbol,
+        name
+    }
+}
+
+function useBase64ToBuffer(base64VAA: string) {
+    const [buf, setBuf] = useState<Buffer>()
+
+    function convertbase64ToBinary(base64: string) {
+        var raw = window.atob(base64);
+        var rawLength = raw.length;
+        var array = new Uint8Array(new ArrayBuffer(rawLength));
+
+        for (let i = 0; i < rawLength; i++) {
+            array[i] = raw.charCodeAt(i);
+        }
+        return array;
+    }
+
+    useEffect(() => {
+        async function asyncWork(vaaString: string) {
+            const vaa = convertbase64ToBinary(vaaString)
+            const bridgeWasm = await import('bridge')
+
+            const parsedVaa = bridgeWasm.parse_vaa(vaa)
+
+            setBuf(Buffer.from(parsedVaa.payload))
+        }
+        asyncWork(base64VAA)
+    }, [base64VAA])
+    return buf
+}
+interface DecodePayloadProps {
+    base64VAA: string
+    emitterChainName: keyof ChainIDs
+    emitterAddress: string
+    showType?: boolean
+    showSummary?: boolean
+    showPayload?: boolean
+}
+
+const DecodePayload = (props: DecodePayloadProps) => {
+    const buf = useBase64ToBuffer(props.base64VAA)
+    const [payloadBundle, setPayloadBundle] = useState<PayloadBundle>()
+
+    const determineType = (payloadBuffer: Buffer) => {
+        let payload: PayloadBundle["payload"] = {}
+        let type: PayloadBundle["type"] = "unknownMessage"
+
+        let unknown: UnknownMessageBundle = { type: "unknownMessage", payload: {} }
+        let bundle: PayloadBundle = unknown
+
+        // try the types, do some logic on the results
+        let parsedTokenPayload: TokenTransferPayload | undefined
+        let parsedNftPayload: NFTTransferPayload | undefined
+        let parsedAssetMeta: AssetMetaPayload | undefined
+        try {
+            parsedTokenPayload = parseTokenPayload(payloadBuffer)
+            // console.log('parsedTokenPayload: ', parsedTokenPayload)
+        } catch (_) {
+            // do nothing
+        }
+
+        try {
+            parsedNftPayload = parseNFTPayload(payloadBuffer)
+            // console.log('parsedNftPayload ', parsedNftPayload)
+        } catch (_) {
+            // do nothing
+        }
+
+        try {
+            parsedAssetMeta = parseAssetMetaPayload(payloadBuffer)
+            // console.log('parsedAssetMeta ', parsedAssetMeta)
+        } catch (_) {
+            // do nothing
+        }
+
+        // determine which type of payload this is by asserting values
+        if (parsedNftPayload?.uri) {
+            try {
+                // test for valid url
+                new URL(parsedNftPayload.uri);
+                type = "nftTransfer"
+                payload = parsedNftPayload
+                bundle = { type: "nftTransfer", payload: parsedNftPayload }
+            } catch (_) {
+                // probably not an NFT transfer, continue
+            }
+        } else if (parsedTokenPayload && validChains.includes(parsedTokenPayload?.originChain) && validChains.includes(parsedTokenPayload?.targetChain)) {
+            type = "tokenTransfer"
+            payload = parsedTokenPayload
+            bundle = { type: "tokenTransfer", payload: parsedTokenPayload }
+        } else if (parsedAssetMeta && chainIDs[props.emitterChainName] === parsedAssetMeta.tokenChain) {
+            payload = parsedAssetMeta
+            type = "assetMeta"
+            bundle = { type: "assetMeta", payload: parsedAssetMeta }
+        }
+
+        setPayloadBundle(bundle)
+    }
+
+    useEffect(() => {
+        if (buf) {
+            determineType(buf)
+        }
+    }, [buf])
+
+
+
+    return (
+        <>
+            {props.showType && payloadBundle ?
+                <span>
+
+                    {props.showSummary && payloadBundle ? (
+                        payloadBundle.type === "assetMeta" ? (<>
+                            {chainEnums[payloadBundle.payload.tokenChain]}&nbsp; {payloadBundle.payload.symbol} {payloadBundle.payload.name}
+                        </>) :
+                            payloadBundle.type === "tokenTransfer" ? (<>
+                                {"native "}{chainEnums[payloadBundle.payload.originChain]}{' asset -> '}{chainEnums[payloadBundle.payload.targetChain]}
+                            </>) :
+                                payloadBundle.type === "nftTransfer" ? (<>
+                                    {payloadBundle.payload.symbol || "?"}&nbsp;{"-"}&nbsp;{chainEnums[payloadBundle.payload.originChain]}{' -> '}{chainEnums[payloadBundle.payload.targetChain]}
+                                </>) : null
+                    ) : null}
+                </span> : props.showPayload && payloadBundle ? (
+                    <>
+                        <div style={{ margin: "20px 0" }} className="styled-scrollbar">
+                            <Title level={3} style={titleStyles}><FormattedMessage id={`explorer.payloads.${payloadBundle.type}`} /> payload</Title>
+                            <pre style={{ fontSize: 14 }}>{JSON.stringify(payloadBundle.payload, undefined, 2)}</pre>
+                        </div>
+                        {/* TODO - prettier formatting of payload data. POC below. */}
+                        {/* {payloadBundle && payloadBundle.payload && knownPayloads.includes(payloadBundle.type) ? (
+                            Object.entries(payloadBundle.payload).map(([key, value]) => {
+                                return <Statistic title={key} key={key} value={value} />
+                            })
+                        ) : <span>Can't decode unknown payloads</span>} */}
+
+                    </>
+                ) : null}
+
+        </>
+    )
+
+
+
+}
+
+
+export { DecodePayload }

+ 1 - 0
explorer/src/components/Payload/index.tsx

@@ -0,0 +1 @@
+export { DecodePayload } from './DecodePayload';

+ 0 - 1
explorer/src/components/wasm/index.tsx

@@ -1 +0,0 @@
-export { default as WasmTest } from './wasm';

+ 0 - 52
explorer/src/components/wasm/wasm.tsx

@@ -1,52 +0,0 @@
-import React, { useEffect } from 'react';
-import { Typography } from 'antd'
-const { Title } = Typography
-
-export type IWASMModule = typeof import("bridge")
-
-function convertbase64ToBinary(base64: string) {
-    var raw = window.atob(base64);
-    var rawLength = raw.length;
-    var array = new Uint8Array(new ArrayBuffer(rawLength));
-
-    for (let i = 0; i < rawLength; i++) {
-        array[i] = raw.charCodeAt(i);
-    }
-    return array;
-}
-
-interface WasmProps {
-    base64VAA: string
-}
-
-const WasmTest = (props: WasmProps) => {
-
-
-    const loadWasm = async (base64VAA: string) => {
-        const vaa = convertbase64ToBinary(base64VAA)
-        try {
-            /*eslint no-useless-concat: "off"*/
-            const wasm = await import('bridge')
-            // debugger
-            const parsed = wasm.parse_vaa(vaa)
-            console.log('parsed vaa: ', parsed)
-            // let addr = 'Bridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o'
-            // let res = wasm.state_address(addr)
-            // console.log('res', res)
-            // alert('it worked.')
-            // debugger
-        } catch (err) {
-            debugger
-            console.error(`Unexpected error in loadWasm. [Message: ${err.message}]`)
-        }
-    }
-    useEffect(() => {
-        if (props.base64VAA) {
-            loadWasm(props.base64VAA)
-        }
-    }, [props])
-
-    return <Title level={3}>wasm test</Title>
-}
-
-export default WasmTest

+ 4 - 0
explorer/src/icons/polygon.svg

@@ -0,0 +1,4 @@
+<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
+<circle cx="512" cy="512" r="512" fill="#8247E5"/>
+<path d="M681.469 402.456C669.189 395.312 653.224 395.312 639.716 402.456L543.928 457.228L478.842 492.949L383.055 547.721C370.774 554.865 354.81 554.865 341.301 547.721L265.162 504.856C252.882 497.712 244.286 484.614 244.286 470.325V385.786C244.286 371.498 251.654 358.4 265.162 351.256L340.073 309.581C352.353 302.437 368.318 302.437 381.827 309.581L456.737 351.256C469.018 358.4 477.614 371.498 477.614 385.786V440.558L542.7 403.646V348.874C542.7 334.586 535.332 321.488 521.824 314.344L383.055 235.758C370.774 228.614 354.81 228.614 341.301 235.758L200.076 314.344C186.567 321.488 179.199 334.586 179.199 348.874V507.237C179.199 521.525 186.567 534.623 200.076 541.767L341.301 620.353C353.582 627.498 369.546 627.498 383.055 620.353L478.842 566.772L543.928 529.86L639.716 476.279C651.996 469.135 667.961 469.135 681.469 476.279L756.38 517.953C768.66 525.098 777.257 538.195 777.257 552.484V637.023C777.257 651.312 769.888 664.409 756.38 671.553L681.469 714.419C669.189 721.563 653.224 721.563 639.716 714.419L564.805 672.744C552.525 665.6 543.928 652.502 543.928 638.214V583.442L478.842 620.353V675.125C478.842 689.414 486.21 702.512 499.719 709.656L640.944 788.242C653.224 795.386 669.189 795.386 682.697 788.242L823.922 709.656C836.203 702.512 844.799 689.414 844.799 675.125V516.763C844.799 502.474 837.431 489.377 823.922 482.232L681.469 402.456Z" fill="white"/>
+</svg>

+ 31 - 2
explorer/src/locales/en.json

@@ -85,6 +85,8 @@
     "heartbeat": "Heartbeat #",
     "blockHeight": "Block height",
     "address": "Address",
+    "contractAddress": "Contract Address",
+    "errorCount": "errorCount",
     "network": "Network",
     "networks": "Networks",
     "version": "Version",
@@ -100,13 +102,34 @@
     "emitterAddress": "Emitter Address",
     "emitterAddressHelp": "The contract you interacted with",
     "sequence": "Sequence number",
-    "sequenceHelp": "The sequence number from your contract interaction",
+    "sequenceHelp": "The sequence number from the contract interaction",
+    "txId": "Transaction ID",
+    "txIdHelp": "The transaction identifier from the on-chain interaction",
     "messageSummary": "Message Summary",
     "failedFetching": "Something went wrong. Please check your data and try again.",
     "notFound": "Nothing found for that query. Please check your data and try again.",
     "listening": "Listening for Updates",
     "lastUpdated": "Last updated",
-    "refresh": "refresh"
+    "refresh": "refresh",
+    "clickToView": "View chain",
+    "comingSoon": "Coming soon",
+    "stats": {
+      "heading": "Recent activity",
+      "refresh": "refresh",
+      "failedFetchingRecent": "Something went wrong. Please refresh.",
+      "failedFetchingTotals": "Something went wrong. Please refresh."
+    },
+    "queryByMessageId": "search by message data",
+    "queryByTxId": "search by transaction ID",
+    "payloads": {
+      "tokenTransfer": "Token Transfer",
+      "nftTransfer": "NFT Transfer",
+      "assetMeta": "AssetMeta",
+      "governance": "Governance",
+      "pyth": "Pyth Data",
+      "unknown": "unknown",
+      "unknownMessage": "unknown"
+    }
   },
   "partners": {
     "title": "Wormhole Partners",
@@ -115,5 +138,11 @@
   "documentation": {
     "title": "Wormhole Documentation",
     "description": "Wormhole technical reference"
+  },
+  "networks": {
+    "network": "network",
+    "devnet": "devent",
+    "testnet": "testnet",
+    "mainnet": "mainnet"
   }
 }

+ 119 - 174
explorer/src/pages/explorer.tsx

@@ -1,117 +1,80 @@
-import React, { ChangeEventHandler, useEffect, useState } from 'react';
-import { PageProps } from "gatsby"
-import { Typography, Grid, Form, Input, Button, Radio } from 'antd';
+import React, { useEffect, useState } from 'react';
+import { Link, PageProps } from "gatsby"
+import { Typography, Grid, Button, } from 'antd';
 const { Title } = Typography;
-const { TextArea } = Input
 const { useBreakpoint } = Grid
-import { SearchOutlined } from '@ant-design/icons';
-import { injectIntl, WrappedComponentProps, FormattedMessage } from 'gatsby-plugin-intl';
+
+import { FormattedMessage, useIntl } from 'gatsby-plugin-intl';
 
 import { Layout } from '~/components/Layout';
 import { SEO } from '~/components/SEO';
-import { ExplorerQuery } from '~/components/ExplorerQuery'
+
+import { ExplorerStats } from '~/components/ExplorerStats'
+import { contractNameFormatter } from '~/components/ExplorerStats/utils';
 import { titleStyles } from '~/styles';
+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 { CloseOutlined } from '@ant-design/icons';
 
 
 // form props
-interface ExplorerFormValues {
+interface ExplorerQueryValues {
     emitterChain: number,
     emitterAddress: string,
     sequence: string
+    txId: string
 }
-const formFields = ['emitterChain', 'emitterAddress', 'sequence']
-const emitterChains = [
-    { label: 'Solana', value: 1 },
-    { label: 'Ethereum', value: 2 },
-    { label: 'Terra', value: 3 },
-    { label: 'Binance Smart Chain', value: 4 },
-
-]
-
-interface ExplorerProps extends PageProps, WrappedComponentProps<'intl'> { }
-const Explorer = ({ location, intl, navigate }: ExplorerProps) => {
 
+interface ExplorerProps extends PageProps { }
+const Explorer: React.FC<ExplorerProps> = ({ location, navigate }) => {
+    const intl = useIntl()
     const screens = useBreakpoint()
-    const [, forceUpdate] = useState({});
-    const [form] = Form.useForm<ExplorerFormValues>();
-    const [emitterChain, setEmitterChain] = useState<ExplorerFormValues["emitterChain"]>()
-    const [emitterAddress, setEmitterAddress] = useState<ExplorerFormValues["emitterAddress"]>()
-    const [sequence, setSequence] = useState<ExplorerFormValues["sequence"]>()
+    const [emitterChain, setEmitterChain] = useState<ExplorerQueryValues["emitterChain"]>()
+    const [emitterAddress, setEmitterAddress] = useState<ExplorerQueryValues["emitterAddress"]>()
+    const [sequence, setSequence] = useState<ExplorerQueryValues["sequence"]>()
+    const [txId, setTxId] = useState<ExplorerQueryValues["txId"]>()
+    const [showQueryForm, setShowQueryForm] = useState<boolean>(false)
 
     useEffect(() => {
-        // To disable submit button on first load.
-        forceUpdate({});
-    }, [])
-
-    useEffect(() => {
-
         if (location.search) {
             // take searchparams from the URL and set the values in the form
             const searchParams = new URLSearchParams(location.search);
 
             const chain = searchParams.get('emitterChain')
             const address = searchParams.get('emitterAddress')
-            const sequence = searchParams.get('sequence')
-
+            const seq = searchParams.get('sequence')
+            const tx = searchParams.get('txId')
 
-            // get the current values from the form fields
-            const { emitterChain, emitterAddress, sequence: seq } = form.getFieldsValue(true)
 
-            // if the search params are different form values, update the form.
-            if (chain) {
-                if (Number(chain) !== emitterChain) {
-                    form.setFieldsValue({ emitterChain: Number(chain) })
-                }
-                setEmitterChain(Number(chain))
+            // if the search params are different form values, update state
+            if (Number(chain) !== emitterChain) {
+                setEmitterChain(Number(chain) || undefined)
             }
-            if (address) {
-                if (address !== emitterAddress) {
-                    form.setFieldsValue({ emitterAddress: address })
-                }
-                setEmitterAddress(address)
+            if (address !== emitterAddress) {
+                setEmitterAddress(address || undefined)
             }
-            if (sequence) {
-                if (sequence !== seq) {
-                    form.setFieldsValue({ sequence: sequence })
-                }
-                setSequence(sequence)
+            if (seq !== sequence) {
+                setSequence(seq || undefined)
             }
+            if (tx !== txId) {
+                setTxId(tx || undefined)
+            }
+            if (!tx && (chain && address && seq)) {
+                setShowQueryForm(true)
+            }
+        } else {
+            // clear state
+            setEmitterChain(undefined)
+            setEmitterAddress(undefined)
+            setSequence(undefined)
+            setTxId(undefined)
+            setShowQueryForm(false)
         }
     }, [location.search])
 
-
-
-    const onFinish = ({ emitterChain, emitterAddress, sequence }: ExplorerFormValues) => {
-        // pushing to the history stack will cause the component to get new props, and useEffect will run.
-        navigate(`/${intl.locale}/explorer/?emitterChain=${emitterChain}&emitterAddress=${emitterAddress}&sequence=${sequence}`)
-    };
-
-    const onAddress: ChangeEventHandler<HTMLTextAreaElement> = (e) => {
-        if (e.currentTarget.value) {
-            // trim whitespace
-            form.setFieldsValue({ emitterAddress: e.currentTarget.value.replace(/\s/g, "") })
-        }
-
-    }
-    const onSequence: ChangeEventHandler<HTMLInputElement> = (e) => {
-        if (e.currentTarget.value) {
-            // remove everything except numbers
-            form.setFieldsValue({ sequence: e.currentTarget.value.replace(/\D/g, '') })
-        }
-    }
-    const formatLabel = (textKey: string) => (
-        <span style={{ fontSize: 16 }}>
-            <FormattedMessage id={textKey} />
-        </span>
-
-    )
-    const formatHelp = (textKey: string) => (
-        <span style={{ fontSize: 14 }}>
-            <FormattedMessage id={textKey} />
-        </span>
-    )
-
-
     return (
         <Layout>
             <SEO
@@ -123,106 +86,88 @@ const Explorer = ({ location, intl, navigate }: ExplorerProps) => {
                 style={{ paddingTop: screens.md === false ? 24 : 100 }}
             >
                 <div
-                    className="responsive-padding max-content-width"
+                    className="wider-responsive-padding max-content-width"
                     style={{ width: '100%' }}
                 >
+                    <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 40 }}>
+                        <Title level={1} style={titleStyles}>{intl.formatMessage({ id: 'explorer.title' })}</Title>
+                        <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'column', marginRight: !screens.md ? 0 : 80 }}>
+                            <div><FormattedMessage id="networks.network" /></div>
+                            <NetworkSelect />
+                        </div>
+                    </div>
+                    <div style={{ width: "100%", display: 'flex', justifyContent: 'flex-start', alignItems: 'center', flexDirection: 'column', marginBottom: 40 }}>
+                        <div style={{ width: '100%', maxWidth: 960, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
+                            <Title level={3} style={titleStyles}>{intl.formatMessage({ id: 'explorer.lookupPrompt' })}</Title>
+                            <div style={{ marginRight: !screens.md ? 0 : 80 }}>
+                                {showQueryForm && <a onClick={() => setShowQueryForm(false)}><FormattedMessage id="explorer.queryByTxId" /></a>}
+                                {!showQueryForm && <a onClick={() => setShowQueryForm(true)}><FormattedMessage id="explorer.queryByMessageId" /></a>}
+                            </div>
+                        </div>
+                        <div style={{ width: '100%', maxWidth: 900 }}>
+                            {showQueryForm ? (
+                                <ExplorerSearchForm location={location} navigate={navigate} />
+                            ) : (
+                                <ExplorerTxForm location={location} navigate={navigate} />
+                            )}
+                        </div>
 
-                    <Title level={1} style={titleStyles}>{intl.formatMessage({ id: 'explorer.title' })}</Title>
-
-                    <div>
-                        <Form
-                            layout="vertical"
-                            form={form}
-                            name="explorer-query"
-                            onFinish={onFinish}
-                            size="large"
-                            style={{ width: '90%', maxWidth: 800, marginBlockEnd: 60, fontSize: 14 }}
-                            colon={false}
-                            requiredMark={false}
-                            validateMessages={{ required: "'${label}' is required", }}
-                        >
-                            <Form.Item
-                                name="emitterAddress"
-                                label={formatLabel("explorer.emitterAddress")}
-                                help={formatHelp("explorer.emitterAddressHelp")}
-                                rules={[{ required: true }]}
-                            >
-                                <TextArea onChange={onAddress} allowClear autoSize />
-                            </Form.Item>
-
-                            <Form.Item
-                                name="emitterChain"
-                                label={formatLabel("explorer.emitterChain")}
-                                help={formatHelp("explorer.emitterChainHelp")}
-                                rules={[{ required: true }]}
-                                style={
-                                    screens.md === false ? {
-                                        display: 'block', width: '100%'
-                                    } : {
-                                        display: 'inline-block', width: '50%'
-                                    }}
-                            >
-                                <Radio.Group
-                                    optionType="button"
-                                    options={emitterChains}
-                                />
-                            </Form.Item>
-
-                            <Form.Item shouldUpdate
-                                style={
-                                    screens.md === false ? {
-                                        display: 'block', width: '100%'
-                                    } : {
-                                        display: 'inline-block', width: '50%'
-                                    }}
+                    </div>
+                    {!(emitterChain && emitterAddress && sequence) && !txId ? (
+                        <>
+                            <div
+                                style={{
+                                    width: '100%',
+                                    display: 'flex',
+                                    justifyContent: 'space-between',
+                                    marginBottom: 40
+                                }}
                             >
-                                {() => (
-
-                                    <Form.Item
-                                        name="sequence"
-                                        label={formatLabel("explorer.sequence")}
-                                        help={formatHelp("explorer.sequenceHelp")}
-                                        rules={[{ required: true }]}
-                                    >
-
-                                        <Input
-                                            onChange={onSequence}
-                                            style={{ padding: "0 0 0 14px" }}
-
-                                            allowClear
-                                            suffix={
-                                                <Button
-                                                    size="large"
-                                                    type="primary"
-                                                    style={{ width: 80 }}
-                                                    icon={
-                                                        <SearchOutlined style={{ fontSize: 16, color: 'black' }} />
-                                                    }
-                                                    htmlType="submit"
-                                                    disabled={
-                                                        // true if the value of any field is falsey, or
-                                                        (Object.values({ ...form.getFieldsValue(formFields) }).some(v => !v)) ||
-                                                        // true if the length of the errors array is true.
-                                                        !!form.getFieldsError().filter(({ errors }) => errors.length).length
-                                                    }
-                                                />
-                                            }
-                                        />
-
-                                    </Form.Item>
-                                )}
-                            </Form.Item>
+                                {emitterAddress && emitterChain ? (
+                                    // show heading with the context of the address
+                                    <Title level={3} style={{ ...titleStyles }}>
+                                        Recent messages from {ChainID[emitterChain]}&nbsp;
+                                        {nativeExplorerUri(emitterChain, emitterAddress) ?
+                                            <OutboundLink
+                                                href={nativeExplorerUri(emitterChain, emitterAddress)}
+                                                target="_blank"
+                                                rel="noopener noreferrer"
+                                            >
+                                                {contractNameFormatter(emitterAddress, emitterChain)}
+                                            </OutboundLink> : contractNameFormatter(emitterAddress, emitterChain)}
+                                        :
+                                    </Title>
+
+                                ) : emitterChain ? (
+                                    // show heading with the context of the chain
+                                    <Title level={3} style={{ ...titleStyles }}>
+                                        Recent {ChainID[emitterChain]} activity
+                                    </Title>
+                                ) : (
+                                    // show heading for root view, all chains
+                                    <Title level={3} style={{ ...titleStyles }}>
+                                        {intl.formatMessage({ id: 'explorer.stats.heading' })}
+                                    </Title>
 
-                        </Form>
-                    </div>
-                    {emitterChain && emitterAddress && sequence ? (
-                        <ExplorerQuery emitterChain={emitterChain} emitterAddress={emitterAddress} sequence={sequence} />
+                                )}
+                                {emitterAddress || emitterChain ?
+                                    <Link to={`/${intl.locale}/explorer`}>
+                                        <Button
+                                            shape="round"
+                                            icon={<CloseOutlined />}
+                                            size="large"
+                                            style={{ marginRight: !screens.md ? 0 : 40 }}
+
+                                        >clear</Button>
+                                    </Link> : null}
+                            </div>
+                            <ExplorerStats emitterChain={emitterChain} emitterAddress={emitterAddress} />
+                        </>
                     ) : null}
-
                 </div>
             </div>
         </Layout >
     )
 };
 
-export default injectIntl(Explorer)
+export default WithNetwork(Explorer)

+ 33 - 17
explorer/src/pages/network.tsx

@@ -1,42 +1,52 @@
-import React, { useEffect, useState } from 'react';
+import React, { useContext, useEffect, useState } from 'react';
 import { Typography, Grid } from 'antd';
 const { Title, Paragraph } = Typography;
 const { useBreakpoint } = Grid
-import { injectIntl, WrappedComponentProps } from 'gatsby-plugin-intl';
+import { FormattedMessage, injectIntl, WrappedComponentProps } from 'gatsby-plugin-intl';
 
 import { Layout } from '~/components/Layout';
 import { SEO } from '~/components/SEO';
 import { GuardiansTable } from '~/components/GuardiansTable'
+import { WithNetwork, NetworkSelect, NetworkContext } from '~/components/NetworkSelect'
 
 import { Heartbeat } from '~/proto/gossip/v1/gossip'
 import { GrpcWebImpl, PublicRPCServiceClientImpl } from '~/proto/publicrpc/v1/publicrpc'
 
-const rpc = new GrpcWebImpl(String(process.env.GATSBY_APP_RPC_URL), {});
-const publicRpc = new PublicRPCServiceClientImpl(rpc)
+const networks = { "devnet": {}, "testnet": {}, "mainnet": {} }
 
 const Network = ({ intl }: WrappedComponentProps) => {
-  const [heartbeats, setHeartbeats] = useState<{ [nodeName: string]: Heartbeat }>({})
+  const [heartbeats, setHeartbeats] = useState<{ [networkName: string]: { [nodeName: string]: Heartbeat } }>(networks)
   const screens = useBreakpoint()
+  const [pollInterval, setPollInterval] = useState<NodeJS.Timeout>()
+  const { activeNetwork } = useContext(NetworkContext)
 
-  const addHeartbeat = (hbObj: Heartbeat) => {
-    hbObj.networks.sort((a, b) => b.id - a.id)
+  const addHeartbeat = (networkName: string, hbObj: Heartbeat) => {
+    hbObj.networks.sort((a, b) => a.id - b.id)
     const { nodeName } = hbObj
-    heartbeats[nodeName] = hbObj
+    heartbeats[networkName][nodeName] = hbObj
     setHeartbeats({ ...heartbeats })
   }
 
   useEffect(() => {
+    if (pollInterval) {
+      // stop polling
+      clearInterval(pollInterval)
+      setHeartbeats({ ...heartbeats, [activeNetwork.name]: {} })
+    }
+    const rpc = new GrpcWebImpl(String(activeNetwork.endpoints.guardianRpcBase), {});
+    const publicRpc = new PublicRPCServiceClientImpl(rpc)
 
     const interval = setInterval(() => {
       publicRpc.GetLastHeartbeats({}).then(res => {
-        res.entries.map(entry => entry.rawHeartbeat ? addHeartbeat(entry.rawHeartbeat) : null)
+        res.entries.map(entry => entry.rawHeartbeat ? addHeartbeat(activeNetwork.name, entry.rawHeartbeat) : null)
       }, err => console.error('GetLastHearbeats err: ', err))
-    }, 2000)
+    }, 3000)
+    setPollInterval(interval)
 
     return function cleanup() {
       clearInterval(interval)
     }
-  }, [])
+  }, [activeNetwork.endpoints.guardianRpcBase])
 
   return (
     <Layout>
@@ -49,26 +59,32 @@ const Network = ({ intl }: WrappedComponentProps) => {
         style={{ paddingTop: screens.md === false ? 24 : 100 }}
       >
         <div
-          className="responsive-padding max-content-width"
+          className="wider-responsive-padding max-content-width"
           style={{ width: '100%' }}
         >
           <div style={{ padding: screens.md === false ? '100px 0 0 16px' : '' }} >
-            <Title level={1} style={{ fontWeight: 'normal' }}>{intl.formatMessage({ id: 'network.title' })}</Title>
+            <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 40 }}>
+              <Title level={1} style={{ fontWeight: 'normal' }}>{intl.formatMessage({ id: 'network.title' })}</Title>
+              <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'column', marginRight: !screens.md ? 0 : 80 }}>
+                <div><FormattedMessage id="networks.network" /></div>
+                <NetworkSelect />
+              </div>
+            </div>
             <Paragraph style={{ fontSize: 24, fontWeight: 400, lineHeight: '36px' }} type="secondary">
-              {Object.keys(heartbeats).length === 0 ? (
+              {Object.keys(heartbeats[activeNetwork.name]).length === 0 ? (
                 intl.formatMessage({ id: 'network.listening' })
               ) :
                 <>
-                  {Object.keys(heartbeats).length}&nbsp;
+                  {Object.keys(heartbeats[activeNetwork.name]).length}&nbsp;
                   {intl.formatMessage({ id: 'network.guardiansFound' })}
                 </>}
             </Paragraph>
           </div>
-          <GuardiansTable heartbeats={heartbeats} intl={intl} />
+          <GuardiansTable heartbeats={heartbeats[activeNetwork.name]} intl={intl} />
         </div>
       </div>
     </Layout>
   )
 };
 
-export default injectIntl(Network)
+export default WithNetwork(injectIntl(Network))

+ 174 - 20
explorer/src/utils/misc/constants.ts

@@ -1,28 +1,182 @@
+import { getEmitterAddressEth, getEmitterAddressSolana, getEmitterAddressTerra } from "@certusone/wormhole-sdk";
 
-const addresses = {
-    solana: {
-        token: ['Solana Token Bridge', process.env.SOL_TOKEN_BRIDGE],
-        bridge: ['Solana Core Bridge', process.env.SOL_CORE_BRIDGE],
-    },
-    ethereum: {
-        token: ['Ethereum Token Bridge', process.env.ETH_TOKEN_BRIDGE],
-        core: ['Ethereum Core Bridge', process.env.ETH_CORE_BRIDGE],
-    },
-    terra: {
-        token: ['Terra Token Bridge', process.env.LUN_TOKEN_BRIDGE],
-        core: ['Terra Core Bridge', process.env.LUN_CORE_BRIDGE],
-    },
-    bsc: {
-        token: ['BSC Token Bridge', process.env.BSC_TOKEN_BRIDGE],
-        core: ['BSC Core Bridge', process.env.BSC_CORE_BRIDGE],
-    },
+export const chainEnums = ['', 'Solana', 'Ethereum', 'Terra', 'BSC', 'Polygon']
+// type chainNames = "solana" | "ethereum" | "terra" | "bsc"
+export interface ChainIDs {
+    "solana": 1,
+    "ethereum": 2,
+    "terra": 3,
+    "bsc": 4,
+    "polygon": 5
+}
+export const chainIDs: ChainIDs = {
+    "solana": 1,
+    "ethereum": 2,
+    "terra": 3,
+    "bsc": 4,
+    "polygon": 5
 }
-enum ChainID {
+
+export enum ChainID {
+    "unknown",
     Solana,
     Ethereum,
     Terra,
-    'Binance Smart Chain'
+    'Binance Smart Chain',
+    Polygon,
 }
 
+export const METADATA_REPLACE = new RegExp("\u0000", "g");
 
-export { addresses, ChainID }
+// Gatsby only includes environment variables that are explictly referenced, it does the substitution at build time.
+// Created this map as a work around to access them dynamically (ie. process.env[someKeyName]).
+const envVarMap: { [name: string]: string | undefined } = {
+    GATSBY_DEVNET_SOLANA_CORE_BRIDGE: process.env.GATSBY_DEVNET_SOLANA_CORE_BRIDGE,
+    GATSBY_DEVNET_SOLANA_TOKEN_BRIDGE: process.env.GATSBY_DEVNET_SOLANA_TOKEN_BRIDGE,
+    GATSBY_DEVNET_SOLANA_NFT_BRIDGE: process.env.GATSBY_DEVNET_SOLANA_NFT_BRIDGE,
+    GATSBY_DEVNET_ETHEREUM_CORE_BRIDGE: process.env.GATSBY_DEVNET_ETHEREUM_CORE_BRIDGE,
+    GATSBY_DEVNET_ETHEREUM_TOKEN_BRIDGE: process.env.GATSBY_DEVNET_ETHEREUM_TOKEN_BRIDGE,
+    GATSBY_DEVNET_ETHEREUM_NFT_BRIDGE: process.env.GATSBY_DEVNET_ETHEREUM_NFT_BRIDGE,
+    GATSBY_DEVNET_TERRA_CORE_BRIDGE: process.env.GATSBY_DEVNET_TERRA_CORE_BRIDGE,
+    GATSBY_DEVNET_TERRA_TOKEN_BRIDGE: process.env.GATSBY_DEVNET_TERRA_TOKEN_BRIDGE,
+    GATSBY_DEVNET_TERRA_NFT_BRIDGE: process.env.GATSBY_DEVNET_TERRA_NFT_BRIDGE,
+    GATSBY_DEVNET_BSC_CORE_BRIDGE: process.env.GATSBY_DEVNET_BSC_CORE_BRIDGE,
+    GATSBY_DEVNET_BSC_TOKEN_BRIDGE: process.env.GATSBY_DEVNET_BSC_TOKEN_BRIDGE,
+    GATSBY_DEVNET_BSC_NFT_BRIDGE: process.env.GATSBY_DEVNET_BSC_NFT_BRIDGE,
+    GATSBY_DEVNET_POLYGON_CORE_BRIDGE: process.env.GATSBY_DEVNET_POLYGON_CORE_BRIDGE,
+    GATSBY_DEVNET_POLYGON_TOKEN_BRIDGE: process.env.GATSBY_DEVNET_POLYGON_TOKEN_BRIDGE,
+    GATSBY_DEVNET_POLYGON_NFT_BRIDGE: process.env.GATSBY_DEVNET_POLYGON_NFT_BRIDGE,
+
+    GATSBY_TESTNET_SOLANA_CORE_BRIDGE: process.env.GATSBY_TESTNET_SOLANA_CORE_BRIDGE,
+    GATSBY_TESTNET_SOLANA_TOKEN_BRIDGE: process.env.GATSBY_TESTNET_SOLANA_TOKEN_BRIDGE,
+    GATSBY_TESTNET_SOLANA_NFT_BRIDGE: process.env.GATSBY_TESTNET_SOLANA_NFT_BRIDGE,
+    GATSBY_TESTNET_ETHEREUM_CORE_BRIDGE: process.env.GATSBY_TESTNET_ETHEREUM_CORE_BRIDGE,
+    GATSBY_TESTNET_ETHEREUM_TOKEN_BRIDGE: process.env.GATSBY_TESTNET_ETHEREUM_TOKEN_BRIDGE,
+    GATSBY_TESTNET_ETHEREUM_NFT_BRIDGE: process.env.GATSBY_TESTNET_ETHEREUM_NFT_BRIDGE,
+    GATSBY_TESTNET_TERRA_CORE_BRIDGE: process.env.GATSBY_TESTNET_TERRA_CORE_BRIDGE,
+    GATSBY_TESTNET_TERRA_TOKEN_BRIDGE: process.env.GATSBY_TESTNET_TERRA_TOKEN_BRIDGE,
+    GATSBY_TESTNET_TERRA_NFT_BRIDGE: process.env.GATSBY_TESTNET_TERRA_NFT_BRIDGE,
+    GATSBY_TESTNET_BSC_CORE_BRIDGE: process.env.GATSBY_TESTNET_BSC_CORE_BRIDGE,
+    GATSBY_TESTNET_BSC_TOKEN_BRIDGE: process.env.GATSBY_TESTNET_BSC_TOKEN_BRIDGE,
+    GATSBY_TESTNET_BSC_NFT_BRIDGE: process.env.GATSBY_TESTNET_BSC_NFT_BRIDGE,
+    GATSBY_TESTNET_POLYGON_CORE_BRIDGE: process.env.GATSBY_TESTNET_POLYGON_CORE_BRIDGE,
+    GATSBY_TESTNET_POLYGON_TOKEN_BRIDGE: process.env.GATSBY_TESTNET_POLYGON_TOKEN_BRIDGE,
+    GATSBY_TESTNET_POLYGON_NFT_BRIDGE: process.env.GATSBY_TESTNET_POLYGON_NFT_BRIDGE,
+
+    GATSBY_MAINNET_SOLANA_CORE_BRIDGE: process.env.GATSBY_MAINNET_SOLANA_CORE_BRIDGE,
+    GATSBY_MAINNET_SOLANA_TOKEN_BRIDGE: process.env.GATSBY_MAINNET_SOLANA_TOKEN_BRIDGE,
+    GATSBY_MAINNET_SOLANA_NFT_BRIDGE: process.env.GATSBY_MAINNET_SOLANA_NFT_BRIDGE,
+    GATSBY_MAINNET_ETHEREUM_CORE_BRIDGE: process.env.GATSBY_MAINNET_ETHEREUM_CORE_BRIDGE,
+    GATSBY_MAINNET_ETHEREUM_TOKEN_BRIDGE: process.env.GATSBY_MAINNET_ETHEREUM_TOKEN_BRIDGE,
+    GATSBY_MAINNET_ETHEREUM_NFT_BRIDGE: process.env.GATSBY_MAINNET_ETHEREUM_NFT_BRIDGE,
+    GATSBY_MAINNET_TERRA_CORE_BRIDGE: process.env.GATSBY_MAINNET_TERRA_CORE_BRIDGE,
+    GATSBY_MAINNET_TERRA_TOKEN_BRIDGE: process.env.GATSBY_MAINNET_TERRA_TOKEN_BRIDGE,
+    GATSBY_MAINNET_TERRA_NFT_BRIDGE: process.env.GATSBY_MAINNET_TERRA_NFT_BRIDGE,
+    GATSBY_MAINNET_BSC_CORE_BRIDGE: process.env.GATSBY_MAINNET_BSC_CORE_BRIDGE,
+    GATSBY_MAINNET_BSC_TOKEN_BRIDGE: process.env.GATSBY_MAINNET_BSC_TOKEN_BRIDGE,
+    GATSBY_MAINNET_BSC_NFT_BRIDGE: process.env.GATSBY_MAINNET_BSC_NFT_BRIDGE,
+    GATSBY_MAINNET_POLYGON_CORE_BRIDGE: process.env.GATSBY_MAINNET_POLYGON_CORE_BRIDGE,
+    GATSBY_MAINNET_POLYGON_TOKEN_BRIDGE: process.env.GATSBY_MAINNET_POLYGON_TOKEN_BRIDGE,
+    GATSBY_MAINNET_POLYGON_NFT_BRIDGE: process.env.GATSBY_MAINNET_POLYGON_NFT_BRIDGE,
+}
+
+export interface KnownContracts {
+    "Token Bridge": string
+    "Core Bridge": string
+    "NFT Bridge": string
+    [address: string]: string
+}
+export interface ChainContracts {
+    [chainName: string]: KnownContracts
+}
+export interface NetworkChains {
+    [networkName: string]: ChainContracts
+}
+
+const getEmitterAddressEVM = (address: string) => Promise.resolve(getEmitterAddressEth(address))
+const getEmitterAddress: { [chainName: string]: (address: string) => Promise<string> } = {
+    "solana": getEmitterAddressSolana,
+    "ethereum": getEmitterAddressEVM,
+    "terra": getEmitterAddressTerra,
+    "bsc": getEmitterAddressEVM,
+    "polygon": getEmitterAddressEVM,
+}
+
+// the keys used for creating the map of contract addresses of each chain, on each network.
+export const networks = ["devnet", "testnet", "mainnet"]
+const contractTypes = ["Core", "Token", "NFT"]
+const chainNames = Object.keys(chainIDs)
+
+export const knownContractsPromise = networks.reduce<Promise<NetworkChains>>(async (promisedAccum, network) => {
+    // Create a data structure to access contract addresses by network, then chain,
+    // so that for the network picker.
+    // Index by address and name, so you can easily get at the data either way.
+    // {
+    //     devnet: {
+    //         solana: {
+    //             'Token Bridge': String(process.env.DEVNET_SOLANA_TOKEN_BRIDGE),
+    //             String(process.env.DEVNET_SOLANA_TOKEN_BRIDGE): 'Token Bridge'
+    //         },
+    //         ethereum: {
+    //             'Token Bridge': String(process.env.DEVNET_ETHEREUM_TOKEN_BRIDGE),
+    //              String(process.env.DEVNET_ETHEREUM_TOKEN_BRIDGE): 'Token Bridge'
+    //         },
+    //         terra: {
+    //             'Token Bridge': String(process.env.DEVNET_TERRA_TOKEN_BRIDGE),
+    //              String(process.env.DEVNET_TERRA_TOKEN_BRIDGE): 'Token Bridge'
+    //         },
+    //         bsc: {
+    //             'Token Bridge': String(process.env.DEVNET_BSC_TOKEN_BRIDGE),
+    //              String(process.env.DEVNET_BSC_TOKEN_BRIDGE): 'Token Bridge'
+    //         },
+    //     },
+    //     testnet: {...},
+    //     mainnet: {...}
+    // }
+    const accum = await promisedAccum
+    accum[network] = await chainNames.reduce<Promise<ChainContracts>>(async (promisedSubAccum, chainName) => {
+        const subAccum = await promisedSubAccum
+        subAccum[chainName] = await contractTypes.reduce<Promise<KnownContracts>>(async (promisedContractsOfChain, contractType) => {
+            const contractsOfChain = await promisedContractsOfChain
+            const envVarName = ['GATSBY', network.toUpperCase(), chainName.toUpperCase(), contractType.toUpperCase(), 'BRIDGE'].join("_")
+            let address = envVarMap[envVarName]
+            if (!address) throw `missing environment variable: ${envVarName}`
+            const desc = `${contractType} Bridge`
+            // index by: description, contract address, and emitter address
+            try {
+                const emitterAddress = await getEmitterAddress[chainName](address)
+                contractsOfChain[emitterAddress] = desc
+            } catch (_) {
+                console.log('failed getting emitterAddress for: ', address)
+            }
+            if (chainName != "solana") {
+                address = address.toLowerCase()
+            }
+            contractsOfChain[desc] = address
+            contractsOfChain[address] = desc
+            return contractsOfChain
+        }, Promise.resolve(Object()))
+        return subAccum
+    }, Promise.resolve(Object()))
+    return accum
+}, Promise.resolve(Object()))
+
+
+export interface NetworkConfig {
+    bigtableFunctionsBase: string
+    guardianRpcBase: string
+}
+export const endpoints: { [network: string]: NetworkConfig } = {
+    devnet: {
+        bigtableFunctionsBase: String(process.env.GATSBY_BIGTABLE_FUNCTIONS_DEVNET_BASE_URL),
+        guardianRpcBase: String(process.env.GATSBY_GUARDIAN_DEVNET_RPC_URL)
+    },
+    testnet: {
+        bigtableFunctionsBase: String(process.env.GATSBY_BIGTABLE_FUNCTIONS_TESTNET_BASE_URL),
+        guardianRpcBase: String(process.env.GATSBY_GUARDIAN_TESTNET_RPC_URL)
+    },
+    mainnet: {
+        bigtableFunctionsBase: String(process.env.GATSBY_BIGTABLE_FUNCTIONS_MAINNET_BASE_URL),
+        guardianRpcBase: String(process.env.GATSBY_GUARDIAN_MAINNET_RPC_URL)
+    }
+}

+ 4 - 3
explorer/tsconfig.json

@@ -1,8 +1,8 @@
 {
   "compileOnSave": false,
   "compilerOptions": {
-    "target": "es5",
-    "module": "es6",
+    "target": "es2020",
+    "module": "es2020",
     "types": [
       "node",
       "jest",
@@ -36,7 +36,8 @@
     "baseUrl": "./",
     "paths": {
       "~/*": [
-        "src/*"
+        "src/*",
+        "wasm/*"
       ],
     }
   },

+ 3 - 0
solana/Dockerfile.wasm

@@ -69,6 +69,9 @@ COPY --from=build /usr/src/bridge/modules/token_bridge/program/bundler sdk/js/sr
 COPY --from=build /usr/src/bridge/migration/bundler sdk/js/src/solana/migration
 COPY --from=build /usr/src/bridge/modules/nft_bridge/program/bundler sdk/js/src/solana/nft
 COPY --from=build /usr/src/bridge/pyth2wormhole/program/bundler third_party/pyth/p2w-sdk/src/solana/p2w-core
+COPY --from=build /usr/src/bridge/bridge/program/bundler explorer/wasm/core
+COPY --from=build /usr/src/bridge/modules/token_bridge/program/bundler explorer/wasm/token
+COPY --from=build /usr/src/bridge/modules/nft_bridge/program/bundler explorer/wasm/nft
 
 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

部分文件因为文件数量过多而无法显示