Преглед изворни кода

bridge_ui: stats page overhaul, added custody-addresses page

Kevin Peters пре 3 година
родитељ
комит
d4a4f8aab5
28 измењених фајлова са 2774 додато и 357 уклоњено
  1. 520 0
      bridge_ui/package-lock.json
  2. 3 0
      bridge_ui/package.json
  3. 87 91
      bridge_ui/src/App.js
  4. 10 0
      bridge_ui/src/components/Footer.tsx
  5. 9 5
      bridge_ui/src/components/HeaderText.tsx
  6. 46 0
      bridge_ui/src/components/Stats/Charts/CustomTooltip.tsx
  7. 123 0
      bridge_ui/src/components/Stats/Charts/MultiChainTooltip.tsx
  8. 63 0
      bridge_ui/src/components/Stats/Charts/TVLAreaChart.tsx
  9. 116 0
      bridge_ui/src/components/Stats/Charts/TVLBarChart.tsx
  10. 86 0
      bridge_ui/src/components/Stats/Charts/TVLLineChart.tsx
  11. 141 0
      bridge_ui/src/components/Stats/Charts/TVLTable.tsx
  12. 34 0
      bridge_ui/src/components/Stats/Charts/TimeFrame.ts
  13. 68 0
      bridge_ui/src/components/Stats/Charts/TransactionsAreaChart.tsx
  14. 85 0
      bridge_ui/src/components/Stats/Charts/TransactionsLineChart.tsx
  15. 63 0
      bridge_ui/src/components/Stats/Charts/VolumeAreaChart.tsx
  16. 76 0
      bridge_ui/src/components/Stats/Charts/VolumeLineChart.tsx
  17. 196 0
      bridge_ui/src/components/Stats/Charts/VolumeStackedBarChart.tsx
  18. 212 0
      bridge_ui/src/components/Stats/Charts/utils.tsx
  19. 14 3
      bridge_ui/src/components/Stats/CustodyAddresses.tsx
  20. 298 0
      bridge_ui/src/components/Stats/TVLStats.tsx
  21. 325 0
      bridge_ui/src/components/Stats/VolumeStats.tsx
  22. 7 250
      bridge_ui/src/components/Stats/index.tsx
  23. 63 0
      bridge_ui/src/hooks/useCumulativeTVL.ts
  24. 50 0
      bridge_ui/src/hooks/useNotionalTransferred.ts
  25. 6 6
      bridge_ui/src/hooks/useTVL.ts
  26. 5 2
      bridge_ui/src/hooks/useTotalTransactedAmount.ts
  27. 45 0
      bridge_ui/src/hooks/useTransactionTotals.ts
  28. 23 0
      bridge_ui/src/utils/consts.ts

+ 520 - 0
bridge_ui/package-lock.json

@@ -31,6 +31,7 @@
         "clsx": "^1.1.1",
         "ethers": "^5.4.1",
         "js-base64": "^3.6.1",
+        "luxon": "^2.3.1",
         "notistack": "^1.0.10",
         "numeral": "^2.0.6",
         "react": "^17.0.2",
@@ -39,11 +40,13 @@
         "react-router-dom": "^5.2.0",
         "react-scripts": "5.0.0",
         "react-table": "^7.7.0",
+        "recharts": "^2.1.9",
         "redux": "^3.7.2",
         "use-debounce": "^7.0.0"
       },
       "devDependencies": {
         "@truffle/hdwallet-provider": "^1.4.1",
+        "@types/luxon": "^2.3.1",
         "@types/node": "^16.6.1",
         "@types/numeral": "^2.0.2",
         "@types/react-router-dom": "^5.1.8",
@@ -8474,6 +8477,45 @@
       "dev": true,
       "optional": true
     },
+    "node_modules/@types/d3-color": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.3.tgz",
+      "integrity": "sha512-+0EtEjBfKEDtH9Rk3u3kLOUXM5F+iZK+WvASPb0MhIZl8J8NUvGeZRwKCXl+P3HkYx5TdU4YtcibpqHkSR9n7w=="
+    },
+    "node_modules/@types/d3-interpolate": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.2.tgz",
+      "integrity": "sha512-lElyqlUfIPyWG/cD475vl6msPL4aMU7eJvx1//Q177L8mdXoVPFl1djIESF2FKnc0NyaHvQlJpWwKJYwAhUoCw==",
+      "dependencies": {
+        "@types/d3-color": "^2"
+      }
+    },
+    "node_modules/@types/d3-path": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-2.0.2.tgz",
+      "integrity": "sha512-3YHpvDw9LzONaJzejXLOwZ3LqwwkoXb9LI2YN7Hbd6pkGo5nIlJ09ul4bQhBN4hQZJKmUpX8HkVqbzgUKY48cg=="
+    },
+    "node_modules/@types/d3-scale": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.2.tgz",
+      "integrity": "sha512-gGqr7x1ost9px3FvIfUMi5XA/F/yAf4UkUDtdQhpH92XCT0Oa7zkkRzY61gPVJq+DxpHn/btouw5ohWkbBsCzQ==",
+      "dependencies": {
+        "@types/d3-time": "^2"
+      }
+    },
+    "node_modules/@types/d3-shape": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-2.1.3.tgz",
+      "integrity": "sha512-HAhCel3wP93kh4/rq+7atLdybcESZ5bRHDEZUojClyZWsRuEMo3A52NGYJSh48SxfxEU6RZIVbZL2YFZ2OAlzQ==",
+      "dependencies": {
+        "@types/d3-path": "^2"
+      }
+    },
+    "node_modules/@types/d3-time": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.1.tgz",
+      "integrity": "sha512-9MVYlmIgmRR31C5b4FVSWtuMmBHh2mOWQYfl7XAYOa8dsnb7iEmUmRSWSFgXFtkjxO65d7hTUHQC+RhR/9IWFg=="
+    },
     "node_modules/@types/ed2curve": {
       "version": "0.2.2",
       "resolved": "https://registry.npmjs.org/@types/ed2curve/-/ed2curve-0.2.2.tgz",
@@ -8670,6 +8712,12 @@
       "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz",
       "integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w=="
     },
+    "node_modules/@types/luxon": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.3.1.tgz",
+      "integrity": "sha512-nAPUltOT28fal2eDZz8yyzNhBjHw1NEymFBP7Q9iCShqpflWPybxHbD7pw/46jQmT+HXOy1QN5hNTms8MOTlOQ==",
+      "dev": true
+    },
     "node_modules/@types/mime": {
       "version": "1.3.2",
       "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
@@ -8793,6 +8841,11 @@
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz",
       "integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw=="
     },
+    "node_modules/@types/resize-observer-browser": {
+      "version": "0.1.7",
+      "resolved": "https://registry.npmjs.org/@types/resize-observer-browser/-/resize-observer-browser-0.1.7.tgz",
+      "integrity": "sha512-G9eN0Sn0ii9PWQ3Vl72jDPgeJwRWhv2Qk/nQkJuWmRmOB4HX3/BhD5SE1dZs/hzPZL/WKnvF0RHdTSG54QJFyg=="
+    },
     "node_modules/@types/resolve": {
       "version": "1.17.1",
       "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
@@ -13120,6 +13173,11 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/classnames": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz",
+      "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA=="
+    },
     "node_modules/clean-css": {
       "version": "5.2.4",
       "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.2.4.tgz",
@@ -14376,6 +14434,11 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/css-unit-converter": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.2.tgz",
+      "integrity": "sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA=="
+    },
     "node_modules/css-vendor": {
       "version": "2.0.8",
       "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz",
@@ -14574,6 +14637,73 @@
         "type": "^1.0.1"
       }
     },
+    "node_modules/d3-array": {
+      "version": "2.12.1",
+      "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
+      "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
+      "dependencies": {
+        "internmap": "^1.0.0"
+      }
+    },
+    "node_modules/d3-color": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz",
+      "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ=="
+    },
+    "node_modules/d3-format": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-2.0.0.tgz",
+      "integrity": "sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA=="
+    },
+    "node_modules/d3-interpolate": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz",
+      "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==",
+      "dependencies": {
+        "d3-color": "1 - 2"
+      }
+    },
+    "node_modules/d3-path": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-2.0.0.tgz",
+      "integrity": "sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA=="
+    },
+    "node_modules/d3-scale": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz",
+      "integrity": "sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==",
+      "dependencies": {
+        "d3-array": "^2.3.0",
+        "d3-format": "1 - 2",
+        "d3-interpolate": "1.2.0 - 2",
+        "d3-time": "^2.1.1",
+        "d3-time-format": "2 - 3"
+      }
+    },
+    "node_modules/d3-shape": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-2.1.0.tgz",
+      "integrity": "sha512-PnjUqfM2PpskbSLTJvAzp2Wv4CZsnAgTfcVRTwW03QR3MkXF8Uo7B1y/lWkAsmbKwuecto++4NlsYcvYpXpTHA==",
+      "dependencies": {
+        "d3-path": "1 - 2"
+      }
+    },
+    "node_modules/d3-time": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz",
+      "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==",
+      "dependencies": {
+        "d3-array": "2"
+      }
+    },
+    "node_modules/d3-time-format": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz",
+      "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==",
+      "dependencies": {
+        "d3-time": "1 - 2"
+      }
+    },
     "node_modules/damerau-levenshtein": {
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -14679,6 +14809,11 @@
       "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.3.1.tgz",
       "integrity": "sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ=="
     },
+    "node_modules/decimal.js-light": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+      "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
+    },
     "node_modules/decode-uri-component": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
@@ -18588,6 +18723,11 @@
       "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
       "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
     },
+    "node_modules/fast-equals": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-2.0.4.tgz",
+      "integrity": "sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w=="
+    },
     "node_modules/fast-fifo": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.0.0.tgz",
@@ -22627,6 +22767,11 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/internmap": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
+      "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="
+    },
     "node_modules/interpret": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz",
@@ -28256,6 +28401,14 @@
       "integrity": "sha1-81ypHEk/e3PaDgdJUwTxezH4fuU=",
       "dev": true
     },
+    "node_modules/luxon": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.3.1.tgz",
+      "integrity": "sha512-I8vnjOmhXsMSlNMZlMkSOvgrxKJl0uOsEzdGgGNZuZPaS9KlefpE9KV95QFftlJSC+1UyCC9/I69R02cz/zcCA==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/magic-string": {
       "version": "0.25.7",
       "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz",
@@ -34818,6 +34971,11 @@
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
       "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
     },
+    "node_modules/react-lifecycles-compat": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
+      "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
+    },
     "node_modules/react-redux": {
       "version": "7.2.5",
       "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.5.tgz",
@@ -34855,6 +35013,20 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/react-resize-detector": {
+      "version": "6.7.8",
+      "resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-6.7.8.tgz",
+      "integrity": "sha512-0FaEcUBAbn+pq3PT5a9hHRebUfuS1SRLGLpIw8LydU7zX429I6XJgKerKAMPsJH0qWAl6o5bVKNqFJqr6tGPYw==",
+      "dependencies": {
+        "@types/resize-observer-browser": "^0.1.6",
+        "lodash": "^4.17.21",
+        "resize-observer-polyfill": "^1.5.1"
+      },
+      "peerDependencies": {
+        "react": "^16.0.0 || ^17.0.0",
+        "react-dom": "^16.0.0 || ^17.0.0"
+      }
+    },
     "node_modules/react-router": {
       "version": "5.2.1",
       "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.1.tgz",
@@ -36133,6 +36305,44 @@
         "node": ">=10"
       }
     },
+    "node_modules/react-smooth": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-2.0.0.tgz",
+      "integrity": "sha512-wK4dBBR6P21otowgMT9toZk+GngMplGS1O5gk+2WSiHEXIrQgDvhR5IIlT74Vtu//qpTcipkgo21dD7a7AUNxw==",
+      "dependencies": {
+        "fast-equals": "^2.0.0",
+        "raf": "^3.4.0",
+        "react-transition-group": "2.9.0"
+      },
+      "peerDependencies": {
+        "prop-types": "^15.6.0",
+        "react": "^15.0.0 || ^16.0.0 || ^17.0.0",
+        "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0"
+      }
+    },
+    "node_modules/react-smooth/node_modules/dom-helpers": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
+      "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
+      "dependencies": {
+        "@babel/runtime": "^7.1.2"
+      }
+    },
+    "node_modules/react-smooth/node_modules/react-transition-group": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz",
+      "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==",
+      "dependencies": {
+        "dom-helpers": "^3.4.0",
+        "loose-envify": "^1.4.0",
+        "prop-types": "^15.6.2",
+        "react-lifecycles-compat": "^3.0.4"
+      },
+      "peerDependencies": {
+        "react": ">=15.0.0",
+        "react-dom": ">=15.0.0"
+      }
+    },
     "node_modules/react-table": {
       "version": "7.7.0",
       "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.7.0.tgz",
@@ -36194,6 +36404,47 @@
         "ms": "^2.1.1"
       }
     },
+    "node_modules/recharts": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.1.9.tgz",
+      "integrity": "sha512-VozH5uznUvGqD7n224FGj7cmMAenlS0HPCs+7r2HeeHiQK6un6z0CTZfWVAB860xbcr4m+BN/EGMPZmYWd34Rg==",
+      "dependencies": {
+        "@types/d3-interpolate": "^2.0.0",
+        "@types/d3-scale": "^3.0.0",
+        "@types/d3-shape": "^2.0.0",
+        "classnames": "^2.2.5",
+        "d3-interpolate": "^2.0.0",
+        "d3-scale": "^3.0.0",
+        "d3-shape": "^2.0.0",
+        "eventemitter3": "^4.0.1",
+        "lodash": "^4.17.19",
+        "react-is": "^16.10.2",
+        "react-resize-detector": "^6.6.3",
+        "react-smooth": "^2.0.0",
+        "recharts-scale": "^0.4.4",
+        "reduce-css-calc": "^2.1.8"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "react": "^16.0.0 || ^17.0.0",
+        "react-dom": "^16.0.0 || ^17.0.0"
+      }
+    },
+    "node_modules/recharts-scale": {
+      "version": "0.4.5",
+      "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+      "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+      "dependencies": {
+        "decimal.js-light": "^2.4.1"
+      }
+    },
+    "node_modules/recharts/node_modules/react-is": {
+      "version": "16.13.1",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+    },
     "node_modules/recursive-readdir": {
       "version": "2.2.2",
       "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz",
@@ -36205,6 +36456,20 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/reduce-css-calc": {
+      "version": "2.1.8",
+      "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-2.1.8.tgz",
+      "integrity": "sha512-8liAVezDmUcH+tdzoEGrhfbGcP7nOV4NkGE3a74+qqvE7nt9i4sKLGBuZNOnpI4WiGksiNPklZxva80061QiPg==",
+      "dependencies": {
+        "css-unit-converter": "^1.1.1",
+        "postcss-value-parser": "^3.3.0"
+      }
+    },
+    "node_modules/reduce-css-calc/node_modules/postcss-value-parser": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz",
+      "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ=="
+    },
     "node_modules/redux": {
       "version": "3.7.2",
       "resolved": "https://registry.npmjs.org/redux/-/redux-3.7.2.tgz",
@@ -36782,6 +37047,11 @@
         "node": ">= 0.8.0"
       }
     },
+    "node_modules/resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
+    },
     "node_modules/resolve": {
       "version": "1.22.0",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz",
@@ -51036,6 +51306,45 @@
       "dev": true,
       "optional": true
     },
+    "@types/d3-color": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.3.tgz",
+      "integrity": "sha512-+0EtEjBfKEDtH9Rk3u3kLOUXM5F+iZK+WvASPb0MhIZl8J8NUvGeZRwKCXl+P3HkYx5TdU4YtcibpqHkSR9n7w=="
+    },
+    "@types/d3-interpolate": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.2.tgz",
+      "integrity": "sha512-lElyqlUfIPyWG/cD475vl6msPL4aMU7eJvx1//Q177L8mdXoVPFl1djIESF2FKnc0NyaHvQlJpWwKJYwAhUoCw==",
+      "requires": {
+        "@types/d3-color": "^2"
+      }
+    },
+    "@types/d3-path": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-2.0.2.tgz",
+      "integrity": "sha512-3YHpvDw9LzONaJzejXLOwZ3LqwwkoXb9LI2YN7Hbd6pkGo5nIlJ09ul4bQhBN4hQZJKmUpX8HkVqbzgUKY48cg=="
+    },
+    "@types/d3-scale": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.2.tgz",
+      "integrity": "sha512-gGqr7x1ost9px3FvIfUMi5XA/F/yAf4UkUDtdQhpH92XCT0Oa7zkkRzY61gPVJq+DxpHn/btouw5ohWkbBsCzQ==",
+      "requires": {
+        "@types/d3-time": "^2"
+      }
+    },
+    "@types/d3-shape": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-2.1.3.tgz",
+      "integrity": "sha512-HAhCel3wP93kh4/rq+7atLdybcESZ5bRHDEZUojClyZWsRuEMo3A52NGYJSh48SxfxEU6RZIVbZL2YFZ2OAlzQ==",
+      "requires": {
+        "@types/d3-path": "^2"
+      }
+    },
+    "@types/d3-time": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.1.tgz",
+      "integrity": "sha512-9MVYlmIgmRR31C5b4FVSWtuMmBHh2mOWQYfl7XAYOa8dsnb7iEmUmRSWSFgXFtkjxO65d7hTUHQC+RhR/9IWFg=="
+    },
     "@types/ed2curve": {
       "version": "0.2.2",
       "resolved": "https://registry.npmjs.org/@types/ed2curve/-/ed2curve-0.2.2.tgz",
@@ -51232,6 +51541,12 @@
       "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz",
       "integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w=="
     },
+    "@types/luxon": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.3.1.tgz",
+      "integrity": "sha512-nAPUltOT28fal2eDZz8yyzNhBjHw1NEymFBP7Q9iCShqpflWPybxHbD7pw/46jQmT+HXOy1QN5hNTms8MOTlOQ==",
+      "dev": true
+    },
     "@types/mime": {
       "version": "1.3.2",
       "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
@@ -51359,6 +51674,11 @@
         "@types/react": "*"
       }
     },
+    "@types/resize-observer-browser": {
+      "version": "0.1.7",
+      "resolved": "https://registry.npmjs.org/@types/resize-observer-browser/-/resize-observer-browser-0.1.7.tgz",
+      "integrity": "sha512-G9eN0Sn0ii9PWQ3Vl72jDPgeJwRWhv2Qk/nQkJuWmRmOB4HX3/BhD5SE1dZs/hzPZL/WKnvF0RHdTSG54QJFyg=="
+    },
     "@types/resolve": {
       "version": "1.17.1",
       "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
@@ -54895,6 +55215,11 @@
         }
       }
     },
+    "classnames": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz",
+      "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA=="
+    },
     "clean-css": {
       "version": "5.2.4",
       "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.2.4.tgz",
@@ -55890,6 +56215,11 @@
         }
       }
     },
+    "css-unit-converter": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.2.tgz",
+      "integrity": "sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA=="
+    },
     "css-vendor": {
       "version": "2.0.8",
       "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz",
@@ -56037,6 +56367,73 @@
         "type": "^1.0.1"
       }
     },
+    "d3-array": {
+      "version": "2.12.1",
+      "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
+      "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
+      "requires": {
+        "internmap": "^1.0.0"
+      }
+    },
+    "d3-color": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz",
+      "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ=="
+    },
+    "d3-format": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-2.0.0.tgz",
+      "integrity": "sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA=="
+    },
+    "d3-interpolate": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz",
+      "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==",
+      "requires": {
+        "d3-color": "1 - 2"
+      }
+    },
+    "d3-path": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-2.0.0.tgz",
+      "integrity": "sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA=="
+    },
+    "d3-scale": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz",
+      "integrity": "sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==",
+      "requires": {
+        "d3-array": "^2.3.0",
+        "d3-format": "1 - 2",
+        "d3-interpolate": "1.2.0 - 2",
+        "d3-time": "^2.1.1",
+        "d3-time-format": "2 - 3"
+      }
+    },
+    "d3-shape": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-2.1.0.tgz",
+      "integrity": "sha512-PnjUqfM2PpskbSLTJvAzp2Wv4CZsnAgTfcVRTwW03QR3MkXF8Uo7B1y/lWkAsmbKwuecto++4NlsYcvYpXpTHA==",
+      "requires": {
+        "d3-path": "1 - 2"
+      }
+    },
+    "d3-time": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz",
+      "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==",
+      "requires": {
+        "d3-array": "2"
+      }
+    },
+    "d3-time-format": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz",
+      "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==",
+      "requires": {
+        "d3-time": "1 - 2"
+      }
+    },
     "damerau-levenshtein": {
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -56124,6 +56521,11 @@
       "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.3.1.tgz",
       "integrity": "sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ=="
     },
+    "decimal.js-light": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+      "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
+    },
     "decode-uri-component": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
@@ -59371,6 +59773,11 @@
       "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
       "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
     },
+    "fast-equals": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-2.0.4.tgz",
+      "integrity": "sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w=="
+    },
     "fast-fifo": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.0.0.tgz",
@@ -62587,6 +62994,11 @@
         "side-channel": "^1.0.4"
       }
     },
+    "internmap": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
+      "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="
+    },
     "interpret": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz",
@@ -67046,6 +67458,11 @@
       "integrity": "sha1-81ypHEk/e3PaDgdJUwTxezH4fuU=",
       "dev": true
     },
+    "luxon": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.3.1.tgz",
+      "integrity": "sha512-I8vnjOmhXsMSlNMZlMkSOvgrxKJl0uOsEzdGgGNZuZPaS9KlefpE9KV95QFftlJSC+1UyCC9/I69R02cz/zcCA=="
+    },
     "magic-string": {
       "version": "0.25.7",
       "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz",
@@ -72241,6 +72658,11 @@
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
       "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
     },
+    "react-lifecycles-compat": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
+      "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
+    },
     "react-redux": {
       "version": "7.2.5",
       "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.5.tgz",
@@ -72266,6 +72688,16 @@
       "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
       "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A=="
     },
+    "react-resize-detector": {
+      "version": "6.7.8",
+      "resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-6.7.8.tgz",
+      "integrity": "sha512-0FaEcUBAbn+pq3PT5a9hHRebUfuS1SRLGLpIw8LydU7zX429I6XJgKerKAMPsJH0qWAl6o5bVKNqFJqr6tGPYw==",
+      "requires": {
+        "@types/resize-observer-browser": "^0.1.6",
+        "lodash": "^4.17.21",
+        "resize-observer-polyfill": "^1.5.1"
+      }
+    },
     "react-router": {
       "version": "5.2.1",
       "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.1.tgz",
@@ -73235,6 +73667,37 @@
         }
       }
     },
+    "react-smooth": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-2.0.0.tgz",
+      "integrity": "sha512-wK4dBBR6P21otowgMT9toZk+GngMplGS1O5gk+2WSiHEXIrQgDvhR5IIlT74Vtu//qpTcipkgo21dD7a7AUNxw==",
+      "requires": {
+        "fast-equals": "^2.0.0",
+        "raf": "^3.4.0",
+        "react-transition-group": "2.9.0"
+      },
+      "dependencies": {
+        "dom-helpers": {
+          "version": "3.4.0",
+          "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
+          "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
+          "requires": {
+            "@babel/runtime": "^7.1.2"
+          }
+        },
+        "react-transition-group": {
+          "version": "2.9.0",
+          "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz",
+          "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==",
+          "requires": {
+            "dom-helpers": "^3.4.0",
+            "loose-envify": "^1.4.0",
+            "prop-types": "^15.6.2",
+            "react-lifecycles-compat": "^3.0.4"
+          }
+        }
+      }
+    },
     "react-table": {
       "version": "7.7.0",
       "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.7.0.tgz",
@@ -73280,6 +73743,42 @@
         "ms": "^2.1.1"
       }
     },
+    "recharts": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.1.9.tgz",
+      "integrity": "sha512-VozH5uznUvGqD7n224FGj7cmMAenlS0HPCs+7r2HeeHiQK6un6z0CTZfWVAB860xbcr4m+BN/EGMPZmYWd34Rg==",
+      "requires": {
+        "@types/d3-interpolate": "^2.0.0",
+        "@types/d3-scale": "^3.0.0",
+        "@types/d3-shape": "^2.0.0",
+        "classnames": "^2.2.5",
+        "d3-interpolate": "^2.0.0",
+        "d3-scale": "^3.0.0",
+        "d3-shape": "^2.0.0",
+        "eventemitter3": "^4.0.1",
+        "lodash": "^4.17.19",
+        "react-is": "^16.10.2",
+        "react-resize-detector": "^6.6.3",
+        "react-smooth": "^2.0.0",
+        "recharts-scale": "^0.4.4",
+        "reduce-css-calc": "^2.1.8"
+      },
+      "dependencies": {
+        "react-is": {
+          "version": "16.13.1",
+          "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+          "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+        }
+      }
+    },
+    "recharts-scale": {
+      "version": "0.4.5",
+      "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+      "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+      "requires": {
+        "decimal.js-light": "^2.4.1"
+      }
+    },
     "recursive-readdir": {
       "version": "2.2.2",
       "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz",
@@ -73288,6 +73787,22 @@
         "minimatch": "3.0.4"
       }
     },
+    "reduce-css-calc": {
+      "version": "2.1.8",
+      "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-2.1.8.tgz",
+      "integrity": "sha512-8liAVezDmUcH+tdzoEGrhfbGcP7nOV4NkGE3a74+qqvE7nt9i4sKLGBuZNOnpI4WiGksiNPklZxva80061QiPg==",
+      "requires": {
+        "css-unit-converter": "^1.1.1",
+        "postcss-value-parser": "^3.3.0"
+      },
+      "dependencies": {
+        "postcss-value-parser": {
+          "version": "3.3.1",
+          "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz",
+          "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ=="
+        }
+      }
+    },
     "redux": {
       "version": "3.7.2",
       "resolved": "https://registry.npmjs.org/redux/-/redux-3.7.2.tgz",
@@ -73772,6 +74287,11 @@
       "dev": true,
       "optional": true
     },
+    "resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
+    },
     "resolve": {
       "version": "1.22.0",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz",

+ 3 - 0
bridge_ui/package.json

@@ -26,6 +26,7 @@
     "clsx": "^1.1.1",
     "ethers": "^5.4.1",
     "js-base64": "^3.6.1",
+    "luxon": "^2.3.1",
     "notistack": "^1.0.10",
     "numeral": "^2.0.6",
     "react": "^17.0.2",
@@ -34,6 +35,7 @@
     "react-router-dom": "^5.2.0",
     "react-scripts": "5.0.0",
     "react-table": "^7.7.0",
+    "recharts": "^2.1.9",
     "redux": "^3.7.2",
     "use-debounce": "^7.0.0"
   },
@@ -63,6 +65,7 @@
   },
   "devDependencies": {
     "@truffle/hdwallet-provider": "^1.4.1",
+    "@types/luxon": "^2.3.1",
     "@types/node": "^16.6.1",
     "@types/numeral": "^2.0.2",
     "@types/react-router-dom": "^5.1.8",

+ 87 - 91
bridge_ui/src/App.js

@@ -36,6 +36,7 @@ import NFT from "./components/NFT";
 import NFTOriginVerifier from "./components/NFTOriginVerifier";
 import Recovery from "./components/Recovery";
 import Stats from "./components/Stats";
+import CustodyAddresses from "./components/Stats/CustodyAddresses";
 import TokenOriginVerifier from "./components/TokenOriginVerifier";
 import Transfer from "./components/Transfer";
 import UnwrapNative from "./components/UnwrapNative";
@@ -85,19 +86,13 @@ const useStyles = makeStyles((theme) => ({
     position: "relative",
     overflow: "hidden",
   },
-  content: {
-    margin: theme.spacing(2, 0),
-    [theme.breakpoints.up("md")]: {
-      margin: theme.spacing(4, 0),
-    },
-  },
   headerImage: {
     position: "absolute",
     zIndex: -1,
     top: 0,
     background: `url(${Header})`,
     backgroundRepeat: "no-repeat",
-    backgroundPosition: "top -500px center",
+    backgroundPosition: "top -750px center",
     backgroundSize: "2070px 1155px",
     width: "100%",
     height: 1155,
@@ -291,90 +286,91 @@ function App() {
           </Typography>
         </AppBar>
       ) : null}
-      <div className={classes.content}>
-        <div className={classes.headerImage} />
-        {["/transfer", "/nft", "/redeem"].includes(pathname) ? (
-          <Container maxWidth="md" style={{ paddingBottom: 24 }}>
-            <HeaderText
-              white
-              subtitle={
-                <>
-                  <Typography>
-                    Portal is a bridge that offers unlimited transfers across
-                    chains for tokens and NFTs wrapped by Wormhole.
-                  </Typography>
-                  <Typography>
-                    Unlike many other bridges, you avoid double wrapping and
-                    never have to retrace your steps.
-                  </Typography>
-                </>
-              }
-            >
-              Token Bridge
-            </HeaderText>
-            <Tabs
-              value={pathname}
-              variant="fullWidth"
-              onChange={handleTabChange}
-              indicatorColor="primary"
-            >
-              <Tab label="Tokens" value="/transfer" />
-              <Tab label="NFTs" value="/nft" />
-              <Tab label="Redeem" value="/redeem" to="/redeem" />
-            </Tabs>
-          </Container>
-        ) : null}
-        <Switch>
-          <Route exact path="/transfer">
-            <Transfer />
-          </Route>
-          <Route exact path="/nft">
-            <NFT />
-          </Route>
-          <Route exact path="/redeem">
-            <Recovery />
-          </Route>
-          <Route exact path="/nft-origin-verifier">
-            <NFTOriginVerifier />
-          </Route>
-          <Route exact path="/token-origin-verifier">
-            <TokenOriginVerifier />
-          </Route>
-          <Route exact path="/register">
-            <Attest />
-          </Route>
-          <Route exact path="/migrate/Solana/:legacyAsset/:fromTokenAccount">
-            <Migration chainId={CHAIN_ID_SOLANA} />
-          </Route>
-          <Route exact path="/migrate/Ethereum/:legacyAsset/">
-            <Migration chainId={CHAIN_ID_ETH} />
-          </Route>
-          <Route exact path="/migrate/BinanceSmartChain/:legacyAsset/">
-            <Migration chainId={CHAIN_ID_BSC} />
-          </Route>
-          <Route exact path="/migrate/Ethereum/">
-            <EvmQuickMigrate chainId={CHAIN_ID_ETH} />
-          </Route>
-          <Route exact path="/migrate/BinanceSmartChain/">
-            <EvmQuickMigrate chainId={CHAIN_ID_BSC} />
-          </Route>
-          <Route exact path="/migrate/Solana/">
-            <SolanaQuickMigrate />
-          </Route>
-          <Route exact path="/stats">
-            <Stats />
-          </Route>
-          <Route exact path="/withdraw-tokens-terra">
-            <WithdrawTokensTerra />
-          </Route>
-          <Route exact path="/unwrap-native">
-            <UnwrapNative />
-          </Route>
-          <Route>
-            <Redirect to="/transfer" />
-          </Route>
-        </Switch>
-      </div>
+      <div className={classes.headerImage} />
+      {["/transfer", "/nft", "/redeem"].includes(pathname) ? (
+        <Container maxWidth="md" style={{ paddingBottom: 24 }}>
+          <HeaderText
+            white
+            subtitle={
+              <>
+                <Typography>
+                  Portal is a bridge that offers unlimited transfers across
+                  chains for tokens and NFTs wrapped by Wormhole.
+                </Typography>
+                <Typography>
+                  Unlike many other bridges, you avoid double wrapping and never
+                  have to retrace your steps.
+                </Typography>
+              </>
+            }
+          >
+            Token Bridge
+          </HeaderText>
+          <Tabs
+            value={pathname}
+            variant="fullWidth"
+            onChange={handleTabChange}
+            indicatorColor="primary"
+          >
+            <Tab label="Tokens" value="/transfer" />
+            <Tab label="NFTs" value="/nft" />
+            <Tab label="Redeem" value="/redeem" to="/redeem" />
+          </Tabs>
+        </Container>
+      ) : null}
+      <Switch>
+        <Route exact path="/transfer">
+          <Transfer />
+        </Route>
+        <Route exact path="/nft">
+          <NFT />
+        </Route>
+        <Route exact path="/redeem">
+          <Recovery />
+        </Route>
+        <Route exact path="/nft-origin-verifier">
+          <NFTOriginVerifier />
+        </Route>
+        <Route exact path="/token-origin-verifier">
+          <TokenOriginVerifier />
+        </Route>
+        <Route exact path="/register">
+          <Attest />
+        </Route>
+        <Route exact path="/migrate/Solana/:legacyAsset/:fromTokenAccount">
+          <Migration chainId={CHAIN_ID_SOLANA} />
+        </Route>
+        <Route exact path="/migrate/Ethereum/:legacyAsset/">
+          <Migration chainId={CHAIN_ID_ETH} />
+        </Route>
+        <Route exact path="/migrate/BinanceSmartChain/:legacyAsset/">
+          <Migration chainId={CHAIN_ID_BSC} />
+        </Route>
+        <Route exact path="/migrate/Ethereum/">
+          <EvmQuickMigrate chainId={CHAIN_ID_ETH} />
+        </Route>
+        <Route exact path="/migrate/BinanceSmartChain/">
+          <EvmQuickMigrate chainId={CHAIN_ID_BSC} />
+        </Route>
+        <Route exact path="/migrate/Solana/">
+          <SolanaQuickMigrate />
+        </Route>
+        <Route exact path="/stats">
+          <Stats />
+        </Route>
+        <Route exact path="/withdraw-tokens-terra">
+          <WithdrawTokensTerra />
+        </Route>
+        <Route exact path="/unwrap-native">
+          <UnwrapNative />
+        </Route>
+        <Route exact path="/custody-addresses">
+          <CustodyAddresses />
+        </Route>
+        <Route>
+          <Redirect to="/transfer" />
+        </Route>
+      </Switch>
       <div className={classes.spacer} />
       <div className={classes.gradientRight}></div>
       <div className={classes.gradientRight2}></div>

+ 10 - 0
bridge_ui/src/components/Footer.tsx

@@ -189,6 +189,16 @@ export default function Footer() {
               >
                 Wormhole
               </Link>
+              <Link
+                component={NavLink}
+                to={"/custody-addresses"}
+                color="inherit"
+                underline="hover"
+                className={classes.linkStyle}
+                activeClassName={classes.linkActiveStyle}
+              >
+                Custody
+              </Link>
             </div>
           </div>
           <div className={classes.spacer} />

+ 9 - 5
bridge_ui/src/components/HeaderText.tsx

@@ -5,9 +5,7 @@ import { COLORS } from "../muiTheme";
 
 const useStyles = makeStyles((theme) => ({
   centeredContainer: {
-    marginTop: theme.spacing(14),
-    marginBottom: theme.spacing(26),
-    minHeight: 208,
+    marginBottom: theme.spacing(24),
     textAlign: "center",
     width: "100%",
   },
@@ -19,6 +17,9 @@ const useStyles = makeStyles((theme) => ({
     MozBackgroundClip: "text",
     MozTextFillColor: "transparent",
   },
+  subtitle: {
+    marginTop: theme.spacing(2),
+  },
 }));
 
 export default function HeaderText({
@@ -39,11 +40,14 @@ export default function HeaderText({
         variant={small ? "h2" : "h1"}
         component="h1"
         className={clsx({ [classes.linearGradient]: !white })}
-        gutterBottom={!!subtitle}
       >
         {children}
       </Typography>
-      {subtitle ? <Typography component="div">{subtitle}</Typography> : null}
+      {subtitle ? (
+        <Typography component="div" className={classes.subtitle}>
+          {subtitle}
+        </Typography>
+      ) : null}
     </div>
   );
 }

+ 46 - 0
bridge_ui/src/components/Stats/Charts/CustomTooltip.tsx

@@ -0,0 +1,46 @@
+import { makeStyles, Typography } from "@material-ui/core";
+import { formatDate } from "./utils";
+
+const useStyles = makeStyles(() => ({
+  container: {
+    padding: "16px",
+    minWidth: "214px",
+    background: "rgba(255, 255, 255, 0.95)",
+    borderRadius: "4px",
+  },
+  titleText: {
+    color: "#21227E",
+    fontSize: "24px",
+    fontWeight: 500,
+  },
+  ruler: {
+    height: "3px",
+    backgroundImage: "linear-gradient(90deg, #F44B1B 0%, #EEB430 100%)",
+  },
+  valueText: {
+    color: "#404040",
+    fontSize: "18px",
+    fontWeight: 500,
+  },
+}));
+
+const CustomTooltip = ({ active, payload, title, valueFormatter }: any) => {
+  const classes = useStyles();
+  if (active && payload && payload.length) {
+    return (
+      <div className={classes.container}>
+        <Typography className={classes.titleText}>{title}</Typography>
+        <hr className={classes.ruler}></hr>
+        <Typography className={classes.valueText}>
+          {valueFormatter(payload[0].value)}
+        </Typography>
+        <Typography className={classes.valueText}>
+          {formatDate(payload[0].payload.date)}
+        </Typography>
+      </div>
+    );
+  }
+  return null;
+};
+
+export default CustomTooltip;

+ 123 - 0
bridge_ui/src/components/Stats/Charts/MultiChainTooltip.tsx

@@ -0,0 +1,123 @@
+import { ChainId } from "@certusone/wormhole-sdk";
+import { makeStyles, Grid, Typography } from "@material-ui/core";
+import {
+  getChainShortName,
+  CHAINS_BY_ID,
+  COLOR_BY_CHAIN_ID,
+} from "../../../utils/consts";
+import { formatDate } from "./utils";
+
+const useStyles = makeStyles(() => ({
+  container: {
+    padding: "16px",
+    minWidth: "214px",
+    background: "rgba(255, 255, 255, 0.95)",
+    borderRadius: "4px",
+  },
+  titleText: {
+    color: "#21227E",
+    fontSize: "24px",
+    fontWeight: 500,
+  },
+  row: {
+    display: "flex",
+    alignItems: "center",
+    marginBottom: "8px",
+  },
+  ruler: {
+    height: "3px",
+    backgroundColor: "#374B92",
+  },
+  valueText: {
+    color: "#404040",
+    fontSize: "18px",
+    fontWeight: 500,
+  },
+  icon: {
+    width: "24px",
+    height: "24px",
+  },
+}));
+
+const MultiChainTooltip = ({ active, payload, title, valueFormatter }: any) => {
+  const classes = useStyles();
+  if (active && payload && payload.length) {
+    if (payload.length === 1) {
+      const chainId = +payload[0].dataKey.split(".")[1] as ChainId;
+      const chainShortName = getChainShortName(chainId);
+      const data = payload.find((data: any) => data.name === chainShortName);
+      if (data) {
+        return (
+          <div className={classes.container}>
+            <Grid container alignItems="center">
+              <img
+                className={classes.icon}
+                src={CHAINS_BY_ID[chainId]?.logo}
+                alt={chainShortName}
+              />
+              <Typography
+                display="inline"
+                className={classes.titleText}
+                style={{ marginLeft: "8px" }}
+              >
+                {chainShortName}
+              </Typography>
+            </Grid>
+            <hr
+              className={classes.ruler}
+              style={{ backgroundColor: COLOR_BY_CHAIN_ID[chainId] }}
+            ></hr>
+            <Typography className={classes.valueText}>
+              {valueFormatter(data.value)}
+            </Typography>
+            <Typography className={classes.valueText}>
+              {formatDate(data.payload.date)}
+            </Typography>
+          </div>
+        );
+      }
+    } else {
+      return (
+        <div className={classes.container}>
+          <Typography noWrap className={classes.titleText}>
+            {title}
+          </Typography>
+          <Typography className={classes.valueText}>
+            {formatDate(payload[0].payload.date)}
+          </Typography>
+          <hr className={classes.ruler}></hr>
+          {payload.map((data: any) => {
+            return (
+              <div key={data.name} className={classes.row}>
+                <div
+                  style={{
+                    width: "24px",
+                    height: "24px",
+                    backgroundColor: data.stroke,
+                  }}
+                />
+                <Typography
+                  display="inline"
+                  className={classes.valueText}
+                  style={{ marginLeft: "8px", marginRight: "8px" }}
+                >
+                  {data.name}
+                </Typography>
+                <Typography
+                  display="inline"
+                  className={classes.valueText}
+                  style={{ marginLeft: "auto" }}
+                >
+                  {valueFormatter(data.value)}
+                </Typography>
+              </div>
+            );
+          })}
+        </div>
+      );
+    }
+  }
+  return null;
+};
+
+export default MultiChainTooltip;

+ 63 - 0
bridge_ui/src/components/Stats/Charts/TVLAreaChart.tsx

@@ -0,0 +1,63 @@
+import {
+  AreaChart,
+  Area,
+  ResponsiveContainer,
+  Tooltip,
+  XAxis,
+  YAxis,
+} from "recharts";
+import { formatTVL, createCumulativeTVLChartData } from "./utils";
+import { NotionalTVLCumulative } from "../../../hooks/useCumulativeTVL";
+import { useMemo } from "react";
+import { TimeFrame } from "./TimeFrame";
+import CustomTooltip from "./CustomTooltip";
+import { useTheme, useMediaQuery } from "@material-ui/core";
+
+const TVLAreaChart = ({
+  cumulativeTVL,
+  timeFrame,
+}: {
+  cumulativeTVL: NotionalTVLCumulative;
+  timeFrame: TimeFrame;
+}) => {
+  const data = useMemo(() => {
+    return createCumulativeTVLChartData(cumulativeTVL, timeFrame);
+  }, [cumulativeTVL, timeFrame]);
+
+  const theme = useTheme();
+  const isXSmall = useMediaQuery(theme.breakpoints.down("xs"));
+
+  return (
+    <ResponsiveContainer height={768}>
+      <AreaChart data={data}>
+        <XAxis
+          dataKey="date"
+          tickFormatter={timeFrame.tickFormatter}
+          tick={{ fill: "white" }}
+          interval={!isXSmall ? timeFrame.interval : undefined}
+          axisLine={false}
+          tickLine={false}
+          dy={16}
+        />
+        <YAxis
+          tickFormatter={formatTVL}
+          tick={{ fill: "white" }}
+          axisLine={false}
+          tickLine={false}
+        />
+        <Tooltip
+          content={<CustomTooltip title="TVL" valueFormatter={formatTVL} />}
+        />
+        <defs>
+          <linearGradient id="gradient" gradientTransform="rotate(100)">
+            <stop offset="0%" stopColor="#FF2B57" />
+            <stop offset="100%" stopColor="#5EA1EC" />
+          </linearGradient>
+        </defs>
+        <Area dataKey="totalTVL" fill="url(#gradient)" stroke="#405BBC" />
+      </AreaChart>
+    </ResponsiveContainer>
+  );
+};
+
+export default TVLAreaChart;

+ 116 - 0
bridge_ui/src/components/Stats/Charts/TVLBarChart.tsx

@@ -0,0 +1,116 @@
+import { ChainId, CHAIN_ID_ETH } from "@certusone/wormhole-sdk";
+import {
+  Button,
+  makeStyles,
+  Typography,
+  useMediaQuery,
+  useTheme,
+} from "@material-ui/core";
+import { ArrowForward } from "@material-ui/icons";
+import { useCallback, useMemo, useState } from "react";
+import { NotionalTVL } from "../../../hooks/useTVL";
+import { ChainInfo, getChainShortName } from "../../../utils/consts";
+import { createChainTVLChartData, formatTVL } from "./utils";
+
+const useStyles = makeStyles(() => ({
+  table: {
+    borderSpacing: "16px",
+    overflowX: "auto",
+    display: "block",
+  },
+  button: {
+    height: "30px",
+    textTransform: "none",
+    width: "150px",
+    fontSize: "12px",
+  },
+}));
+
+const TVLBarChart = ({
+  tvl,
+  onChainSelected,
+}: {
+  tvl: NotionalTVL;
+  onChainSelected: (chainInfo: ChainInfo) => void;
+}) => {
+  const classes = useStyles();
+
+  const [mouseOverChainId, setMouseOverChainId] =
+    useState<ChainId>(CHAIN_ID_ETH);
+
+  const chainTVLs = useMemo(() => {
+    return createChainTVLChartData(tvl);
+  }, [tvl]);
+
+  const handleClick = useCallback(
+    (chainInfo: ChainInfo) => {
+      onChainSelected(chainInfo);
+    },
+    [onChainSelected]
+  );
+
+  const handleMouseOver = useCallback((chainId: ChainId) => {
+    setMouseOverChainId(chainId);
+  }, []);
+
+  const theme = useTheme();
+  const isSmall = useMediaQuery(theme.breakpoints.down("sm"));
+
+  return (
+    <table className={classes.table}>
+      <tbody>
+        {chainTVLs.map((chainTVL) => (
+          <tr
+            key={chainTVL.chainInfo.id}
+            onMouseOver={() => handleMouseOver(chainTVL.chainInfo.id)}
+          >
+            <td style={{ textAlign: "right" }}>
+              <Typography noWrap display="inline">
+                {getChainShortName(chainTVL.chainInfo.id)}
+              </Typography>
+            </td>
+            <td>
+              <img
+                src={chainTVL.chainInfo.logo}
+                alt={""}
+                width={24}
+                height={24}
+              />
+            </td>
+            <td width="100%">
+              <div
+                style={{
+                  height: 30,
+                  width: `${chainTVL.tvlRatio}%`,
+                  backgroundImage:
+                    "linear-gradient(90deg, #F44B1B 0%, #EEB430 100%)",
+                }}
+              ></div>
+            </td>
+            <td>
+              <Typography noWrap display="inline">
+                {formatTVL(chainTVL.tvl)}
+              </Typography>
+            </td>
+            <td>
+              {isSmall || mouseOverChainId === chainTVL.chainInfo.id ? (
+                <Button
+                  variant="outlined"
+                  endIcon={<ArrowForward />}
+                  onClick={() => handleClick(chainTVL.chainInfo)}
+                  className={classes.button}
+                >
+                  View assets
+                </Button>
+              ) : (
+                <div style={{ width: 150 }} />
+              )}
+            </td>
+          </tr>
+        ))}
+      </tbody>
+    </table>
+  );
+};
+
+export default TVLBarChart;

+ 86 - 0
bridge_ui/src/components/Stats/Charts/TVLLineChart.tsx

@@ -0,0 +1,86 @@
+import { ChainId } from "@certusone/wormhole-sdk";
+import { useTheme, useMediaQuery } from "@material-ui/core";
+import { useMemo } from "react";
+import {
+  Legend,
+  Line,
+  LineChart,
+  ResponsiveContainer,
+  Tooltip,
+  XAxis,
+  YAxis,
+} from "recharts";
+import { NotionalTVLCumulative } from "../../../hooks/useCumulativeTVL";
+import { COLOR_BY_CHAIN_ID, getChainShortName } from "../../../utils/consts";
+import MultiChainTooltip from "./MultiChainTooltip";
+import { TimeFrame } from "./TimeFrame";
+import {
+  formatTVL,
+  createCumulativeTVLChartData,
+  renderLegendText,
+} from "./utils";
+
+const TVLLineChart = ({
+  cumulativeTVL,
+  timeFrame,
+  selectedChains,
+}: {
+  cumulativeTVL: NotionalTVLCumulative;
+  timeFrame: TimeFrame;
+  selectedChains: ChainId[];
+}) => {
+  const data = useMemo(() => {
+    return createCumulativeTVLChartData(cumulativeTVL, timeFrame);
+  }, [cumulativeTVL, timeFrame]);
+
+  const theme = useTheme();
+  const isXSmall = useMediaQuery(theme.breakpoints.down("xs"));
+
+  return (
+    <ResponsiveContainer height={768}>
+      <LineChart data={data}>
+        <XAxis
+          dataKey="date"
+          tickFormatter={timeFrame.tickFormatter}
+          tick={{ fill: "white" }}
+          interval={!isXSmall ? timeFrame.interval : undefined}
+          axisLine={false}
+          tickLine={false}
+          dy={16}
+        />
+        <YAxis
+          tickFormatter={formatTVL}
+          tick={{ fill: "white" }}
+          axisLine={false}
+          tickLine={false}
+        />
+        <Tooltip
+          content={
+            <MultiChainTooltip
+              title="Multiple Chains"
+              valueFormatter={formatTVL}
+            />
+          }
+        />
+        {selectedChains.map((chainId) => (
+          <Line
+            dataKey={`tvlByChain.${chainId}`}
+            name={getChainShortName(chainId)}
+            stroke={COLOR_BY_CHAIN_ID[chainId]}
+            strokeWidth="4"
+            dot={false}
+            key={chainId}
+          />
+        ))}
+        <Legend
+          iconType="square"
+          iconSize={32}
+          formatter={renderLegendText}
+          wrapperStyle={{ paddingTop: 24 }}
+        />
+      </LineChart>
+    </ResponsiveContainer>
+  );
+};
+
+export default TVLLineChart;

+ 141 - 0
bridge_ui/src/components/Stats/Charts/TVLTable.tsx

@@ -0,0 +1,141 @@
+import { makeStyles } from "@material-ui/core";
+import numeral from "numeral";
+import { useMemo } from "react";
+import { createTVLArray, NotionalTVL } from "../../../hooks/useTVL";
+import { ChainInfo } from "../../../utils/consts";
+import SmartAddress from "../../SmartAddress";
+import MuiReactTable from "../tableComponents/MuiReactTable";
+import { formatTVL } from "./utils";
+
+const useStyles = makeStyles((theme) => ({
+  logoPositioner: {
+    height: "30px",
+    width: "30px",
+    maxWidth: "30px",
+    marginRight: theme.spacing(1),
+    display: "flex",
+    alignItems: "center",
+  },
+  logo: {
+    maxHeight: "100%",
+    maxWidth: "100%",
+  },
+  tokenContainer: {
+    display: "flex",
+    justifyContent: "flex-start",
+    alignItems: "center",
+  },
+}));
+
+const TVLTable = ({
+  chainInfo,
+  tvl,
+}: {
+  chainInfo: ChainInfo;
+  tvl: NotionalTVL;
+}) => {
+  const classes = useStyles();
+  const chainTVL = useMemo(() => {
+    return createTVLArray(tvl).filter((x) => x.originChainId === chainInfo.id);
+  }, [chainInfo, tvl]);
+
+  const sortTokens = useMemo(() => {
+    return (rowA: any, rowB: any) => {
+      if (rowA.isGrouped && rowB.isGrouped) {
+        return rowA.values.assetAddress > rowB.values.assetAddress ? 1 : -1;
+      } else if (rowA.isGrouped && !rowB.isGrouped) {
+        return 1;
+      } else if (!rowA.isGrouped && rowB.isGrouped) {
+        return -1;
+      } else if (rowA.original.symbol && !rowB.original.symbol) {
+        return 1;
+      } else if (rowB.original.symbol && !rowA.original.symbol) {
+        return -1;
+      } else if (rowA.original.symbol && rowB.original.symbol) {
+        return rowA.original.symbol > rowB.original.symbol ? 1 : -1;
+      } else {
+        return rowA.original.assetAddress > rowB.original.assetAddress ? 1 : -1;
+      }
+    };
+  }, []);
+  const tvlColumns = useMemo(() => {
+    return [
+      {
+        Header: "Token",
+        id: "assetAddress",
+        sortType: sortTokens,
+        disableGroupBy: true,
+        accessor: (value: any) => ({
+          chainId: value.originChainId,
+          symbol: value.symbol,
+          name: value.name,
+          logo: value.logo,
+          assetAddress: value.assetAddress,
+        }),
+        Cell: (value: any) => (
+          <div className={classes.tokenContainer}>
+            <div className={classes.logoPositioner}>
+              {value.row?.original?.logo ? (
+                <img
+                  src={value.row?.original?.logo}
+                  alt=""
+                  className={classes.logo}
+                />
+              ) : null}
+            </div>
+            <SmartAddress
+              chainId={value.row?.original?.originChainId}
+              address={value.row?.original?.assetAddress}
+              symbol={value.row?.original?.symbol}
+              tokenName={value.row?.original?.name}
+            />
+          </div>
+        ),
+      },
+      {
+        Header: "Quantity",
+        accessor: "amount",
+        disableGroupBy: true,
+        Cell: (value: any) =>
+          value.row?.original?.amount !== undefined
+            ? numeral(value.row?.original?.amount).format("0,0.00")
+            : "",
+      },
+      {
+        Header: "Unit Price",
+        accessor: "quotePrice",
+        disableGroupBy: true,
+        Cell: (value: any) =>
+          value.row?.original?.quotePrice !== undefined
+            ? numeral(value.row?.original?.quotePrice).format("0,0.00")
+            : "",
+      },
+      {
+        Header: "Value (USD)",
+        id: "totalValue",
+        accessor: "totalValue",
+        disableGroupBy: true,
+        Cell: (value: any) =>
+          value.row?.original?.totalValue !== undefined
+            ? formatTVL(value.row?.original?.totalValue)
+            : "",
+      },
+    ];
+  }, [
+    classes.logo,
+    classes.tokenContainer,
+    classes.logoPositioner,
+    sortTokens,
+  ]);
+
+  return (
+    <MuiReactTable
+      columns={tvlColumns}
+      data={chainTVL || []}
+      skipPageReset={false}
+      initialState={{ sortBy: [{ id: "totalValue", desc: true }] }}
+    />
+  );
+};
+
+export default TVLTable;

+ 34 - 0
bridge_ui/src/components/Stats/Charts/TimeFrame.ts

@@ -0,0 +1,34 @@
+import { DurationLike } from "luxon";
+import { formatTickDay, formatTickMonth } from "./utils";
+
+export interface TimeFrame {
+  interval?: number;
+  duration?: DurationLike;
+  tickFormatter: (value: any, index: number) => string;
+}
+
+export const TIME_FRAMES: { [key: string]: TimeFrame } = {
+  "7 days": {
+    duration: { days: 7 },
+    tickFormatter: formatTickDay,
+  },
+  "30 days": {
+    duration: { days: 30 },
+    tickFormatter: formatTickDay,
+  },
+  "3 months": {
+    duration: { months: 3 },
+    tickFormatter: formatTickDay,
+  },
+  "6 months": {
+    duration: { months: 6 },
+    interval: 30,
+    tickFormatter: formatTickMonth,
+  },
+  "1 year": {
+    duration: { years: 1 },
+    interval: 30,
+    tickFormatter: formatTickMonth,
+  },
+  "All time": { interval: 30, tickFormatter: formatTickMonth },
+};

+ 68 - 0
bridge_ui/src/components/Stats/Charts/TransactionsAreaChart.tsx

@@ -0,0 +1,68 @@
+import { useTheme, useMediaQuery } from "@material-ui/core";
+import { useCallback } from "react";
+import {
+  Area,
+  AreaChart,
+  ResponsiveContainer,
+  Tooltip,
+  XAxis,
+  YAxis,
+} from "recharts";
+import CustomTooltip from "./CustomTooltip";
+import { TimeFrame } from "./TimeFrame";
+import { formatTransactionCount, TransactionData } from "./utils";
+
+const TransactionsAreaChart = ({
+  transactionData,
+  timeFrame,
+}: {
+  transactionData: TransactionData[];
+  timeFrame: TimeFrame;
+}) => {
+  const formatValue = useCallback((value: number) => {
+    return `${formatTransactionCount(value)} transactions`;
+  }, []);
+
+  const theme = useTheme();
+  const isXSmall = useMediaQuery(theme.breakpoints.down("xs"));
+
+  return (
+    <ResponsiveContainer height={768}>
+      <AreaChart data={transactionData}>
+        <XAxis
+          dataKey="date"
+          tickFormatter={timeFrame.tickFormatter}
+          tick={{ fill: "white" }}
+          interval={!isXSmall ? timeFrame.interval : undefined}
+          axisLine={false}
+          tickLine={false}
+          dy={16}
+        />
+        <YAxis
+          tickFormatter={formatTransactionCount}
+          tick={{ fill: "white" }}
+          axisLine={false}
+          tickLine={false}
+        />
+        <Tooltip
+          content={
+            <CustomTooltip title="All chains" valueFormatter={formatValue} />
+          }
+        />
+        <defs>
+          <linearGradient id="gradient" gradientTransform="rotate(100)">
+            <stop offset="0%" stopColor="#FF2B57" />
+            <stop offset="100%" stopColor="#5EA1EC" />
+          </linearGradient>
+        </defs>
+        <Area
+          dataKey="totalTransactions"
+          stroke="#405BBC"
+          fill="url(#gradient)"
+        />
+      </AreaChart>
+    </ResponsiveContainer>
+  );
+};
+
+export default TransactionsAreaChart;

+ 85 - 0
bridge_ui/src/components/Stats/Charts/TransactionsLineChart.tsx

@@ -0,0 +1,85 @@
+import { ChainId } from "@certusone/wormhole-sdk";
+import { useTheme, useMediaQuery } from "@material-ui/core";
+import { useCallback } from "react";
+import {
+  ResponsiveContainer,
+  LineChart,
+  XAxis,
+  YAxis,
+  Line,
+  Legend,
+  Tooltip,
+} from "recharts";
+import { COLOR_BY_CHAIN_ID, getChainShortName } from "../../../utils/consts";
+import MultiChainTooltip from "./MultiChainTooltip";
+import { TimeFrame } from "./TimeFrame";
+import {
+  formatTransactionCount,
+  renderLegendText,
+  TransactionData,
+} from "./utils";
+
+const TransactionsLineChart = ({
+  transactionData,
+  timeFrame,
+  chains,
+}: {
+  transactionData: TransactionData[];
+  timeFrame: TimeFrame;
+  chains: ChainId[];
+}) => {
+  const formatValue = useCallback((value: number) => {
+    return `${formatTransactionCount(value)} transactions`;
+  }, []);
+
+  const theme = useTheme();
+  const isXSmall = useMediaQuery(theme.breakpoints.down("xs"));
+
+  return (
+    <ResponsiveContainer height={768}>
+      <LineChart data={transactionData}>
+        <XAxis
+          dataKey="date"
+          tickFormatter={timeFrame.tickFormatter}
+          tick={{ fill: "white" }}
+          interval={!isXSmall ? timeFrame.interval : undefined}
+          axisLine={false}
+          tickLine={false}
+          dy={16}
+        />
+        <YAxis
+          tickFormatter={formatTransactionCount}
+          tick={{ fill: "white" }}
+          axisLine={false}
+          tickLine={false}
+        />
+        <Tooltip
+          content={
+            <MultiChainTooltip
+              title="Multiple Chains"
+              valueFormatter={formatValue}
+            />
+          }
+        />
+        {chains.map((chainId) => (
+          <Line
+            dataKey={`transactionsByChain.${chainId}`}
+            name={getChainShortName(chainId)}
+            stroke={COLOR_BY_CHAIN_ID[chainId]}
+            strokeWidth="4"
+            dot={false}
+            key={chainId}
+          />
+        ))}
+        <Legend
+          iconType="square"
+          iconSize={32}
+          formatter={renderLegendText}
+          wrapperStyle={{ paddingTop: 24 }}
+        />
+      </LineChart>
+    </ResponsiveContainer>
+  );
+};
+
+export default TransactionsLineChart;

+ 63 - 0
bridge_ui/src/components/Stats/Charts/VolumeAreaChart.tsx

@@ -0,0 +1,63 @@
+import { useTheme, useMediaQuery } from "@material-ui/core";
+import {
+  Area,
+  AreaChart,
+  ResponsiveContainer,
+  Tooltip,
+  XAxis,
+  YAxis,
+} from "recharts";
+import CustomTooltip from "./CustomTooltip";
+import { TimeFrame } from "./TimeFrame";
+import { TransferChartData, formatTVL } from "./utils";
+
+const VolumeAreaChart = ({
+  transferData,
+  timeFrame,
+}: {
+  transferData: TransferChartData[];
+  timeFrame: TimeFrame;
+}) => {
+  const theme = useTheme();
+  const isXSmall = useMediaQuery(theme.breakpoints.down("xs"));
+
+  return (
+    <ResponsiveContainer height={768}>
+      <AreaChart data={transferData}>
+        <XAxis
+          dataKey="date"
+          tickFormatter={timeFrame.tickFormatter}
+          tick={{ fill: "white" }}
+          interval={!isXSmall ? timeFrame.interval : undefined}
+          axisLine={false}
+          tickLine={false}
+          dy={16}
+        />
+        <YAxis
+          tickFormatter={formatTVL}
+          tick={{ fill: "white" }}
+          axisLine={false}
+          tickLine={false}
+        />
+        <Tooltip
+          content={
+            <CustomTooltip title="All chains" valueFormatter={formatTVL} />
+          }
+        />
+        <defs>
+          <linearGradient id="gradient" gradientTransform="rotate(100)">
+            <stop offset="0%" stopColor="#FF2B57" />
+            <stop offset="100%" stopColor="#5EA1EC" />
+          </linearGradient>
+        </defs>
+        <Area
+          dataKey="totalTransferred"
+          stroke="#405BBC"
+          fill="url(#gradient)"
+        />
+      </AreaChart>
+    </ResponsiveContainer>
+  );
+};
+
+export default VolumeAreaChart;

+ 76 - 0
bridge_ui/src/components/Stats/Charts/VolumeLineChart.tsx

@@ -0,0 +1,76 @@
+import { ChainId } from "@certusone/wormhole-sdk";
+import { useTheme, useMediaQuery } from "@material-ui/core";
+import {
+  Legend,
+  ResponsiveContainer,
+  LineChart,
+  XAxis,
+  YAxis,
+  Line,
+  Tooltip,
+} from "recharts";
+import { COLOR_BY_CHAIN_ID, getChainShortName } from "../../../utils/consts";
+import MultiChainTooltip from "./MultiChainTooltip";
+import { TimeFrame } from "./TimeFrame";
+import { formatTVL, renderLegendText, TransferChartData } from "./utils";
+
+const VolumeLineChart = ({
+  transferData,
+  timeFrame,
+  chains,
+}: {
+  transferData: TransferChartData[];
+  timeFrame: TimeFrame;
+  chains: ChainId[];
+}) => {
+  const theme = useTheme();
+  const isXSmall = useMediaQuery(theme.breakpoints.down("xs"));
+
+  return (
+    <ResponsiveContainer height={768}>
+      <LineChart data={transferData}>
+        <XAxis
+          dataKey="date"
+          tickFormatter={timeFrame.tickFormatter}
+          tick={{ fill: "white" }}
+          interval={!isXSmall ? timeFrame.interval : undefined}
+          axisLine={false}
+          tickLine={false}
+          dy={16}
+        />
+        <YAxis
+          tickFormatter={formatTVL}
+          tick={{ fill: "white" }}
+          axisLine={false}
+          tickLine={false}
+        />
+        <Tooltip
+          content={
+            <MultiChainTooltip
+              title="Multiple Chains"
+              valueFormatter={formatTVL}
+            />
+          }
+        />
+        {chains.map((chainId) => (
+          <Line
+            dataKey={`transferredByChain.${chainId}`}
+            name={getChainShortName(chainId)}
+            stroke={COLOR_BY_CHAIN_ID[chainId]}
+            strokeWidth="4"
+            dot={false}
+            key={chainId}
+          />
+        ))}
+        <Legend
+          iconType="square"
+          iconSize={32}
+          formatter={renderLegendText}
+          wrapperStyle={{ paddingTop: 24 }}
+        />
+      </LineChart>
+    </ResponsiveContainer>
+  );
+};
+
+export default VolumeLineChart;

+ 196 - 0
bridge_ui/src/components/Stats/Charts/VolumeStackedBarChart.tsx

@@ -0,0 +1,196 @@
+import { ChainId } from "@certusone/wormhole-sdk";
+import {
+  Typography,
+  makeStyles,
+  Grid,
+  useMediaQuery,
+  useTheme,
+} from "@material-ui/core";
+import { useMemo, useState } from "react";
+import {
+  ResponsiveContainer,
+  BarChart,
+  XAxis,
+  YAxis,
+  Tooltip,
+  Bar,
+  Legend,
+} from "recharts";
+import {
+  CHAINS_BY_ID,
+  COLOR_BY_CHAIN_ID,
+  getChainShortName,
+} from "../../../utils/consts";
+import { TimeFrame } from "./TimeFrame";
+import { formatDate, TransferChartData, formatTVL, renderLegendText } from "./utils";
+
+const useStyles = makeStyles(() => ({
+  tooltipContainer: {
+    padding: "16px",
+    minWidth: "214px",
+    background: "rgba(255, 255, 255, 0.95)",
+    borderRadius: "4px",
+  },
+  tooltipTitleText: {
+    color: "#21227E",
+    fontSize: "24px",
+    fontWeight: 500,
+    marginLeft: "8px",
+  },
+  tooltipRuler: {
+    height: "3px",
+  },
+  tooltipValueText: {
+    color: "#404040",
+    fontSize: "18px",
+    fontWeight: 500,
+  },
+  tooltipIcon: {
+    width: "24px",
+    height: "24px",
+  },
+}));
+
+interface BarData {
+  date: Date;
+  volume: {
+    [chainId: string]: number;
+  };
+  volumePercent: {
+    [chainId: string]: number;
+  };
+}
+
+const createBarData = (
+  transferData: TransferChartData[],
+  selectedChains: ChainId[]
+) => {
+  return transferData.reduce<BarData[]>((barData, transfer) => {
+    const data: BarData = {
+      date: transfer.date,
+      volume: {},
+      volumePercent: {},
+    };
+    const totalVolume = Object.entries(transfer.transferredByChain).reduce(
+      (totalVolume, [chainId, volume]) => {
+        if (selectedChains.indexOf(+chainId as ChainId) > -1) {
+          data.volume[chainId] = volume;
+          return totalVolume + volume;
+        }
+        return totalVolume;
+      },
+      0
+    );
+    if (totalVolume > 0) {
+      Object.keys(data.volume).forEach((chainId) => {
+        data.volumePercent[chainId] =
+          (data.volume[chainId] / totalVolume) * 100;
+      });
+    }
+    barData.push(data);
+    return barData;
+  }, []);
+};
+
+const CustomTooltip = ({ active, payload, chainId }: any) => {
+  const classes = useStyles();
+  if (active && payload && payload.length && chainId) {
+    const chainShortName = getChainShortName(chainId);
+    const data = payload.find((data: any) => data.name === chainShortName);
+    if (data) {
+      return (
+        <div className={classes.tooltipContainer}>
+          <Grid container alignItems="center">
+            <img
+              className={classes.tooltipIcon}
+              src={CHAINS_BY_ID[chainId as ChainId]?.logo}
+              alt={chainShortName}
+            />
+            <Typography display="inline" className={classes.tooltipTitleText}>
+              {chainShortName}
+            </Typography>
+          </Grid>
+          <hr
+            className={classes.tooltipRuler}
+            style={{ backgroundColor: COLOR_BY_CHAIN_ID[chainId as ChainId] }}
+          ></hr>
+          <Typography
+            className={classes.tooltipValueText}
+          >{`${data.value.toFixed(1)}%`}</Typography>
+          <Typography className={classes.tooltipValueText}>
+            {formatTVL(data.payload.volume[chainId])}
+          </Typography>
+          <Typography className={classes.tooltipValueText}>
+            {formatDate(data.payload.date)}
+          </Typography>
+        </div>
+      );
+    }
+  }
+  return null;
+};
+
+const VolumeStackedBarChart = ({
+  transferData,
+  timeFrame,
+  selectedChains,
+}: {
+  transferData: TransferChartData[];
+  timeFrame: TimeFrame;
+  selectedChains: ChainId[];
+}) => {
+  const [hoverChainId, setHoverChainId] = useState<ChainId | null>(null);
+
+  const barData = useMemo(() => {
+    return createBarData(transferData, selectedChains);
+  }, [transferData, selectedChains]);
+
+  const theme = useTheme();
+  const isXSmall = useMediaQuery(theme.breakpoints.down("xs"));
+
+  return (
+    <ResponsiveContainer height={768}>
+      <BarChart data={barData}>
+        <XAxis
+          dataKey="date"
+          tickFormatter={timeFrame.tickFormatter}
+          tick={{ fill: "white" }}
+          interval={!isXSmall ? timeFrame.interval : undefined}
+          axisLine={false}
+          tickLine={false}
+          dy={16}
+        />
+        <YAxis
+          tickFormatter={(tick) => `${tick}%`}
+          ticks={[0, 25, 50, 75, 100]}
+          domain={[0, 100]}
+          tick={{ fill: "white" }}
+          axisLine={false}
+          tickLine={false}
+        />
+        <Tooltip
+          content={<CustomTooltip chainId={hoverChainId} barData={barData} />}
+          cursor={{ fill: "transparent" }}
+        />
+        {selectedChains.map((chainId) => (
+          <Bar
+            dataKey={`volumePercent.${chainId}`}
+            name={getChainShortName(chainId)}
+            fill={COLOR_BY_CHAIN_ID[chainId]}
+            key={chainId}
+            stackId="a"
+            onMouseOver={() => setHoverChainId(chainId)}
+          />
+        ))}
+        <Legend
+          iconType="square"
+          iconSize={32}
+          formatter={renderLegendText}
+          wrapperStyle={{ paddingTop: 24 }}
+        />
+      </BarChart>
+    </ResponsiveContainer>
+  );
+};
+
+export default VolumeStackedBarChart;

+ 212 - 0
bridge_ui/src/components/Stats/Charts/utils.tsx

@@ -0,0 +1,212 @@
+import { NotionalTVLCumulative } from "../../../hooks/useCumulativeTVL";
+import { NotionalTransferredFrom } from "../../../hooks/useNotionalTransferred";
+import { TimeFrame } from "./TimeFrame";
+import { DateTime } from "luxon";
+import { Totals } from "../../../hooks/useTransactionTotals";
+import {
+  ChainInfo,
+  CHAINS_BY_ID,
+  VAA_EMITTER_ADDRESSES,
+} from "../../../utils/consts";
+import { NotionalTVL } from "../../../hooks/useTVL";
+import { ChainId } from "@certusone/wormhole-sdk";
+
+export const formatTVL = (tvl: number) => {
+  const [divisor, unit, fractionDigits] =
+    tvl < 1e3
+      ? [1, "", 0]
+      : tvl < 1e6
+      ? [1e3, "K", 0]
+      : tvl < 1e9
+      ? [1e6, "M", 0]
+      : [1e9, "B", 2];
+  return `$${(tvl / divisor).toFixed(fractionDigits)} ${unit}`;
+};
+
+export const formatDate = (date: Date) => {
+  return date.toLocaleString("en-US", {
+    day: "numeric",
+    month: "long",
+    year: "numeric",
+    timeZone: "UTC",
+  });
+};
+
+export const formatTickDay = (date: Date) => {
+  return date.toLocaleString("en-US", {
+    day: "numeric",
+    month: "short",
+    year: "numeric",
+    timeZone: "UTC",
+  });
+};
+
+export const formatTickMonth = (date: Date) => {
+  return date.toLocaleString("en-US", {
+    month: "short",
+    year: "numeric",
+    timeZone: "UTC",
+  });
+};
+
+export const formatTransactionCount = (transactionCount: number) => {
+  return transactionCount.toLocaleString("en-US");
+};
+
+export const renderLegendText = (value: any) => {
+  return <span style={{ color: "white", margin: "8px" }}>{value}</span>;
+};
+
+export const getStartDate = (timeFrame: TimeFrame) => {
+  return timeFrame.duration
+    ? DateTime.now().toUTC().minus(timeFrame.duration).toJSDate()
+    : undefined;
+};
+
+export interface CumulativeTVLChartData {
+  date: Date;
+  totalTVL: number;
+  tvlByChain: {
+    [chainId: string]: number;
+  };
+}
+
+export const createCumulativeTVLChartData = (
+  cumulativeTVL: NotionalTVLCumulative,
+  timeFrame: TimeFrame
+) => {
+  const startDate = getStartDate(timeFrame);
+  return Object.entries(cumulativeTVL.DailyLocked)
+    .reduce<CumulativeTVLChartData[]>(
+      (chartData, [dateString, chainsAssets]) => {
+        const date = new Date(dateString);
+        if (!startDate || date >= startDate) {
+          const data: CumulativeTVLChartData = {
+            date: date,
+            totalTVL: 0,
+            tvlByChain: {},
+          };
+          Object.entries(chainsAssets).forEach(([chainId, lockedAssets]) => {
+            const notional = lockedAssets["*"].Notional;
+            if (chainId === "*") {
+              data.totalTVL = notional;
+            } else {
+              data.tvlByChain[chainId] = notional;
+            }
+          });
+          chartData.push(data);
+        }
+        return chartData;
+      },
+      []
+    )
+    .sort((a, z) => a.date.getTime() - z.date.getTime());
+};
+
+export interface TransferChartData {
+  date: Date;
+  totalTransferred: number;
+  transferredByChain: {
+    [chainId: string]: number;
+  };
+}
+
+export const createCumulativeTransferChartData = (
+  notionalTransferredFrom: NotionalTransferredFrom,
+  timeFrame: TimeFrame
+) => {
+  const startDate = getStartDate(timeFrame);
+  return Object.keys(notionalTransferredFrom.Daily)
+    .sort()
+    .reduce<TransferChartData[]>((chartData, dateString) => {
+      const transferFromData = notionalTransferredFrom.Daily[dateString];
+      const data: TransferChartData = {
+        date: new Date(dateString),
+        totalTransferred: 0,
+        transferredByChain: {},
+      };
+      Object.entries(transferFromData).forEach(([chainId, amount]) => {
+        if (chainId === "*") {
+          data.totalTransferred =
+            (chartData[chartData.length - 1]?.totalTransferred || 0) + amount;
+        } else {
+          data.transferredByChain[chainId] =
+            (chartData[chartData.length - 1]?.transferredByChain[chainId] ||
+              0) + amount;
+        }
+      });
+      chartData.push(data);
+      return chartData;
+    }, [])
+    .filter((value) => !startDate || startDate <= value.date);
+};
+
+export interface TransactionData {
+  date: Date;
+  totalTransactions: number;
+  transactionsByChain: {
+    [chainId: string]: number;
+  };
+}
+
+export const createCumulativeTransactionData = (
+  totals: Totals,
+  timeFrame: TimeFrame
+) => {
+  const startDate = getStartDate(timeFrame);
+  return Object.keys(totals.DailyTotals)
+    .sort()
+    .reduce<TransactionData[]>((chartData, dateString) => {
+      const groupByKeys = totals.DailyTotals[dateString];
+      const prevData = chartData[chartData.length - 1];
+      const data: TransactionData = {
+        date: new Date(dateString),
+        totalTransactions: prevData?.totalTransactions || 0,
+        transactionsByChain: {},
+      };
+      VAA_EMITTER_ADDRESSES.forEach((address) => {
+        const count = groupByKeys[address] || 0;
+        data.totalTransactions += count;
+        const chainId = address.slice(0, address.indexOf(":"));
+        if (data.transactionsByChain[chainId] === undefined) {
+          data.transactionsByChain[chainId] =
+            prevData?.transactionsByChain[chainId] || 0;
+        }
+        data.transactionsByChain[chainId] += count;
+      });
+      chartData.push(data);
+      return chartData;
+    }, [])
+    .filter((value) => !startDate || startDate <= value.date);
+};
+
+export interface ChainTVLChartData {
+  chainInfo: ChainInfo;
+  tvl: number;
+  tvlRatio: number;
+}
+
+export const createChainTVLChartData = (tvl: NotionalTVL) => {
+  let maxTVL = 0;
+  const chainTVLs = Object.entries(tvl.AllTime)
+    .reduce<ChainTVLChartData[]>((chartData, [chainId, assets]) => {
+      const chainInfo = CHAINS_BY_ID[+chainId as ChainId];
+      if (chainInfo !== undefined) {
+        const tvl = assets["*"].Notional;
+        chartData.push({
+          chainInfo: chainInfo,
+          tvl: tvl,
+          tvlRatio: 0,
+        });
+        maxTVL = Math.max(maxTVL, tvl);
+      }
+      return chartData;
+    }, [])
+    .sort((a, z) => z.tvl - a.tvl);
+  if (maxTVL > 0) {
+    chainTVLs.forEach((chainTVL) => {
+      chainTVL.tvlRatio = (chainTVL.tvl / maxTVL) * 100;
+    });
+  }
+  return chainTVLs;
+};

+ 14 - 3
bridge_ui/src/components/Stats/CustodyAddresses.tsx

@@ -1,4 +1,5 @@
 import {
+  CHAIN_ID_AURORA,
   CHAIN_ID_AVAX,
   CHAIN_ID_BSC,
   CHAIN_ID_ETH,
@@ -8,7 +9,7 @@ import {
   CHAIN_ID_SOLANA,
   CHAIN_ID_TERRA,
 } from "@certusone/wormhole-sdk";
-import { makeStyles, Paper, Typography } from "@material-ui/core";
+import { Container, makeStyles, Paper, Typography } from "@material-ui/core";
 import { useMemo } from "react";
 import { COLORS } from "../../muiTheme";
 import {
@@ -17,6 +18,7 @@ import {
   SOL_CUSTODY_ADDRESS,
   SOL_NFT_CUSTODY_ADDRESS,
 } from "../../utils/consts";
+import HeaderText from "../HeaderText";
 import SmartAddress from "../SmartAddress";
 import MuiReactTable from "./tableComponents/MuiReactTable";
 
@@ -97,6 +99,12 @@ const CustodyAddresses: React.FC<any> = () => {
         tokenAddress: getTokenBridgeAddressForChain(CHAIN_ID_FANTOM),
         nftAddress: getNFTBridgeAddressForChain(CHAIN_ID_FANTOM),
       },
+      {
+        chainName: "Aurora",
+        chainId: CHAIN_ID_AURORA,
+        tokenAddress: getTokenBridgeAddressForChain(CHAIN_ID_AURORA),
+        nftAddress: getNFTBridgeAddressForChain(CHAIN_ID_AURORA),
+      },
     ];
   }, []);
 
@@ -159,10 +167,13 @@ const CustodyAddresses: React.FC<any> = () => {
   );
 
   return (
-    <>
+    <Container maxWidth="lg">
+      <Container maxWidth="md">
+        <HeaderText white>Custody</HeaderText>
+      </Container>
       {header}
       <Paper className={classes.mainPaper}>{table}</Paper>
-    </>
+    </Container>
   );
 };
 

+ 298 - 0
bridge_ui/src/components/Stats/TVLStats.tsx

@@ -0,0 +1,298 @@
+import {
+  Button,
+  Checkbox,
+  CircularProgress,
+  FormControl,
+  ListItemText,
+  makeStyles,
+  MenuItem,
+  Paper,
+  Select,
+  TextField,
+  Tooltip,
+  Typography,
+  withStyles,
+} from "@material-ui/core";
+import { ToggleButton, ToggleButtonGroup } from "@material-ui/lab";
+import { useCallback, useMemo, useState } from "react";
+import TVLAreaChart from "./Charts/TVLAreaChart";
+import useCumulativeTVL from "../../hooks/useCumulativeTVL";
+import { TIME_FRAMES } from "./Charts/TimeFrame";
+import TVLLineChart from "./Charts/TVLLineChart";
+import { ChainInfo, CHAINS_BY_ID } from "../../utils/consts";
+import { ChainId } from "@certusone/wormhole-sdk";
+import { COLORS } from "../../muiTheme";
+import TVLBarChart from "./Charts/TVLBarChart";
+import TVLTable from "./Charts/TVLTable";
+import useTVL from "../../hooks/useTVL";
+import { ArrowBack, InfoOutlined } from "@material-ui/icons";
+
+const useStyles = makeStyles((theme) => ({
+  description: {
+    display: "flex",
+    alignItems: "center",
+    justifyContent: "space-between",
+    marginBottom: "16px",
+    [theme.breakpoints.down("xs")]: {
+      flexDirection: "column",
+    },
+  },
+  displayBy: {
+    display: "flex",
+    alignItems: "center",
+    justifyContent: "space-between",
+    flexWrap: "wrap",
+    marginBottom: "16px",
+    [theme.breakpoints.down("xs")]: {
+      justifyContent: "center",
+      columnGap: 8,
+      rowGap: 8,
+    },
+  },
+  mainPaper: {
+    display: "flex",
+    flexDirection: "column",
+    backgroundColor: COLORS.whiteWithTransparency,
+    padding: "2rem",
+    marginBottom: theme.spacing(8),
+    borderRadius: 8,
+  },
+  toggleButton: {
+    textTransform: "none",
+  },
+  tooltip: {
+    margin: 8,
+  },
+  alignCenter: {
+    margin: "0 auto",
+    display: "block",
+  },
+}));
+
+const tooltipStyles = {
+  tooltip: {
+    minWidth: "max-content",
+    borderRadius: "4px",
+    backgroundColor: "#5EA1EC",
+    color: "#0F0C48",
+    fontSize: "14px",
+  },
+};
+
+const StyledTooltip = withStyles(tooltipStyles)(Tooltip);
+
+const DISPLAY_BY_VALUES = ["Time", "Chain"];
+
+const TVLStats = () => {
+  const classes = useStyles();
+
+  const [displayBy, setDisplayBy] = useState(DISPLAY_BY_VALUES[0]);
+  const [timeFrame, setTimeFrame] = useState("All time");
+
+  const [selectedChains, setSelectedChains] = useState<ChainId[]>([]);
+
+  const [selectedChainDetail, setSelectedChainDetail] =
+    useState<ChainInfo | null>(null);
+
+  const cumulativeTVL = useCumulativeTVL();
+  const tvl = useTVL();
+
+  const tvlAllTime = useMemo(() => {
+    return tvl.data
+      ? new Intl.NumberFormat("en-US", {
+          style: "currency",
+          currency: "USD",
+          maximumFractionDigits: 0,
+        }).format(
+          tvl.data.AllTime[selectedChainDetail?.id || "*"]["*"].Notional || 0
+        )
+      : "";
+  }, [selectedChainDetail, tvl]);
+
+  const availableChains = useMemo(() => {
+    const chainIds = cumulativeTVL.data
+      ? Object.keys(
+          Object.values(cumulativeTVL.data.DailyLocked)[0] || {}
+        ).reduce<ChainId[]>((chainIds, key) => {
+          if (key !== "*") {
+            const chainId = parseInt(key) as ChainId;
+            if (CHAINS_BY_ID[chainId]) {
+              chainIds.push(chainId);
+            }
+          }
+          return chainIds;
+        }, [])
+      : [];
+    setSelectedChains(chainIds);
+    return chainIds;
+  }, [cumulativeTVL]);
+
+  const handleDisplayByChange = useCallback((event, nextValue) => {
+    if (nextValue) {
+      setDisplayBy(nextValue);
+    }
+  }, []);
+
+  const handleTimeFrameChange = useCallback(
+    (event) => setTimeFrame(event.target.value),
+    []
+  );
+
+  const handleSelectedChainsChange = useCallback(
+    (event) => {
+      const value = event.target.value;
+      if (value[value.length - 1] === "all") {
+        setSelectedChains((prevValue) =>
+          prevValue.length === availableChains.length ? [] : availableChains
+        );
+      } else {
+        setSelectedChains(value);
+      }
+    },
+    [availableChains]
+  );
+
+  const handleChainDetailSelected = useCallback((chainInfo: ChainInfo) => {
+    setSelectedChainDetail(chainInfo);
+  }, []);
+
+  const allChainsSelected = selectedChains.length === availableChains.length;
+  const tvlText =
+    "Total Value Locked" +
+    (selectedChainDetail ? ` on ${selectedChainDetail?.name}` : "");
+  const tooltipText = selectedChainDetail
+    ? `Total Value Locked on ${selectedChainDetail?.name}`
+    : "USD equivalent value of all assets locked in Portal";
+
+  return (
+    <>
+      <div className={classes.description}>
+        <Typography variant="h3">
+          {tvlText}
+          <StyledTooltip title={tooltipText} className={classes.tooltip}>
+            <InfoOutlined />
+          </StyledTooltip>
+        </Typography>
+        <Typography variant="h3">{tvlAllTime}</Typography>
+      </div>
+      <div className={classes.displayBy}>
+        {!selectedChainDetail ? (
+          <div>
+            <Typography display="inline" style={{ marginRight: "8px" }}>
+              Display by
+            </Typography>
+            <ToggleButtonGroup
+              value={displayBy}
+              exclusive
+              onChange={handleDisplayByChange}
+            >
+              {DISPLAY_BY_VALUES.map((value) => (
+                <ToggleButton
+                  key={value}
+                  value={value}
+                  className={classes.toggleButton}
+                >
+                  {value}
+                </ToggleButton>
+              ))}
+            </ToggleButtonGroup>
+          </div>
+        ) : null}
+        {displayBy === "Time" && !selectedChainDetail ? (
+          <div>
+            <FormControl>
+              <Select
+                multiple
+                variant="outlined"
+                value={selectedChains}
+                onChange={handleSelectedChainsChange}
+                renderValue={(selected: any) =>
+                  selected.length === availableChains.length
+                    ? "All chains"
+                    : selected.length > 1
+                    ? `${selected.length} chains`
+                    : //@ts-ignore
+                      CHAINS_BY_ID[selected[0]]?.name
+                }
+                MenuProps={{ getContentAnchorEl: null }} // hack to prevent popup menu from moving
+                style={{ minWidth: 128 }}
+              >
+                <MenuItem value="all">
+                  <Checkbox
+                    checked={availableChains.length > 0 && allChainsSelected}
+                    indeterminate={
+                      selectedChains.length > 0 &&
+                      selectedChains.length < availableChains.length
+                    }
+                  />
+                  <ListItemText primary="All chains" />
+                </MenuItem>
+                {availableChains.map((option) => (
+                  <MenuItem key={option} value={option}>
+                    <Checkbox checked={selectedChains.indexOf(option) > -1} />
+                    <ListItemText primary={CHAINS_BY_ID[option]?.name} />
+                  </MenuItem>
+                ))}
+              </Select>
+            </FormControl>
+            <TextField
+              select
+              variant="outlined"
+              value={timeFrame}
+              onChange={handleTimeFrameChange}
+              style={{ marginLeft: 8 }}
+            >
+              {Object.keys(TIME_FRAMES).map((timeFrame) => (
+                <MenuItem key={timeFrame} value={timeFrame}>
+                  {timeFrame}
+                </MenuItem>
+              ))}
+            </TextField>
+          </div>
+        ) : selectedChainDetail ? (
+          <Button
+            startIcon={<ArrowBack />}
+            onClick={() => {
+              setSelectedChainDetail(null);
+            }}
+          >
+            Back to all chains
+          </Button>
+        ) : null}
+      </div>
+      <Paper className={classes.mainPaper}>
+        {displayBy === "Time" ? (
+          cumulativeTVL.data ? (
+            allChainsSelected ? (
+              <TVLAreaChart
+                cumulativeTVL={cumulativeTVL.data}
+                timeFrame={TIME_FRAMES[timeFrame]}
+              />
+            ) : (
+              <TVLLineChart
+                cumulativeTVL={cumulativeTVL.data}
+                timeFrame={TIME_FRAMES[timeFrame]}
+                selectedChains={selectedChains}
+              />
+            )
+          ) : (
+            <CircularProgress className={classes.alignCenter} />
+          )
+        ) : tvl.data ? (
+          selectedChainDetail ? (
+            <TVLTable chainInfo={selectedChainDetail} tvl={tvl.data} />
+          ) : (
+            <TVLBarChart
+              tvl={tvl.data}
+              onChainSelected={handleChainDetailSelected}
+            />
+          )
+        ) : (
+          <CircularProgress className={classes.alignCenter} />
+        )}
+      </Paper>
+    </>
+  );
+};
+
+export default TVLStats;

+ 325 - 0
bridge_ui/src/components/Stats/VolumeStats.tsx

@@ -0,0 +1,325 @@
+import { ChainId } from "@certusone/wormhole-sdk";
+import {
+  Checkbox,
+  CircularProgress,
+  FormControl,
+  ListItemText,
+  makeStyles,
+  MenuItem,
+  Paper,
+  Select,
+  TextField,
+  Tooltip,
+  Typography,
+  withStyles,
+} from "@material-ui/core";
+import { InfoOutlined } from "@material-ui/icons";
+import { ToggleButton, ToggleButtonGroup } from "@material-ui/lab";
+import { useCallback, useMemo, useState } from "react";
+import useNotionalTransferred from "../../hooks/useNotionalTransferred";
+import { COLORS } from "../../muiTheme";
+import { CHAINS_BY_ID } from "../../utils/consts";
+import { TIME_FRAMES } from "./Charts/TimeFrame";
+import {
+  createCumulativeTransferChartData,
+  createCumulativeTransactionData,
+  formatTransactionCount,
+} from "./Charts/utils";
+import VolumeAreaChart from "./Charts/VolumeAreaChart";
+import VolumeStackedBarChart from "./Charts/VolumeStackedBarChart";
+import VolumeLineChart from "./Charts/VolumeLineChart";
+import TransactionsAreaChart from "./Charts/TransactionsAreaChart";
+import TransactionsLineChart from "./Charts/TransactionsLineChart";
+import useTransactionTotals from "../../hooks/useTransactionTotals";
+
+const DISPLAY_BY_VALUES = ["Dollar", "Percent", "Transactions"];
+
+const useStyles = makeStyles((theme) => ({
+  description: {
+    display: "flex",
+    alignItems: "center",
+    justifyContent: "space-between",
+    marginBottom: "16px",
+    [theme.breakpoints.down("xs")]: {
+      flexDirection: "column",
+    },
+  },
+  displayBy: {
+    display: "flex",
+    alignItems: "center",
+    justifyContent: "space-between",
+    flexWrap: "wrap",
+    marginBottom: "16px",
+    [theme.breakpoints.down("xs")]: {
+      justifyContent: "center",
+      columnGap: 8,
+      rowGap: 8,
+    },
+  },
+  mainPaper: {
+    display: "flex",
+    flexDirection: "column",
+    backgroundColor: COLORS.whiteWithTransparency,
+    padding: "2rem",
+    marginBottom: theme.spacing(8),
+    borderRadius: 8,
+  },
+  toggleButton: {
+    textTransform: "none",
+  },
+  tooltip: {
+    margin: 8,
+  },
+  alignCenter: {
+    margin: "0 auto",
+    display: "block",
+  },
+}));
+
+const tooltipStyles = {
+  tooltip: {
+    minWidth: "max-content",
+    borderRadius: "4px",
+    backgroundColor: "#5EA1EC",
+    color: "#0F0C48",
+    fontSize: "14px",
+  },
+};
+
+const StyledTooltip = withStyles(tooltipStyles)(Tooltip);
+
+const VolumeStats = () => {
+  const classes = useStyles();
+
+  const [displayBy, setDisplayBy] = useState(DISPLAY_BY_VALUES[0]);
+  const [timeFrame, setTimeFrame] = useState("All time");
+
+  const [selectedChains, setSelectedChains] = useState<ChainId[]>([]);
+
+  const notionalTransferred = useNotionalTransferred();
+
+  const transferData = useMemo(() => {
+    return notionalTransferred.data
+      ? createCumulativeTransferChartData(
+          notionalTransferred.data,
+          TIME_FRAMES[timeFrame]
+        )
+      : [];
+  }, [notionalTransferred, timeFrame]);
+
+  const transferredAllTime = useMemo(() => {
+    return notionalTransferred.data
+      ? new Intl.NumberFormat("en-US", {
+          style: "currency",
+          currency: "USD",
+          maximumFractionDigits: 0,
+        }).format(notionalTransferred.data.Total || 0)
+      : "";
+  }, [notionalTransferred]);
+
+  const transactionTotals = useTransactionTotals();
+
+  const transactionData = useMemo(() => {
+    return transactionTotals.data
+      ? createCumulativeTransactionData(
+          transactionTotals.data,
+          TIME_FRAMES[timeFrame]
+        )
+      : [];
+  }, [transactionTotals, timeFrame]);
+
+  const transactionsAllTime = useMemo(() => {
+    const totalTransactions =
+      transactionData[transactionData.length - 1]?.totalTransactions;
+    return totalTransactions !== undefined
+      ? formatTransactionCount(totalTransactions)
+      : "";
+  }, [transactionData]);
+
+  const availableChains = useMemo(() => {
+    const chainIds = notionalTransferred.data
+      ? Object.keys(
+          Object.values(notionalTransferred.data.Daily)[0] || {}
+        ).reduce<ChainId[]>((chainIds, key) => {
+          if (key !== "*") {
+            const chainId = parseInt(key) as ChainId;
+            if (CHAINS_BY_ID[chainId] !== undefined) {
+              chainIds.push(chainId);
+            }
+          }
+          return chainIds;
+        }, [])
+      : [];
+    setSelectedChains(chainIds);
+    return chainIds;
+  }, [notionalTransferred]);
+
+  const handleDisplayByChange = useCallback((event, nextValue) => {
+    if (nextValue !== null) {
+      setDisplayBy(nextValue);
+    }
+  }, []);
+
+  const handleTimeFrameChange = useCallback(
+    (event) => setTimeFrame(event.target.value),
+    []
+  );
+
+  const handleSelectedChainsChange = useCallback(
+    (event) => {
+      const value = event.target.value;
+      if (value[value.length - 1] === "all") {
+        setSelectedChains((prevValue) =>
+          prevValue.length === availableChains.length ? [] : availableChains
+        );
+      } else {
+        setSelectedChains(value);
+      }
+    },
+    [availableChains]
+  );
+
+  const allChainsSelected = selectedChains.length === availableChains.length;
+
+  return (
+    <>
+      <div className={classes.description}>
+        <Typography variant="h3">
+          {displayBy === "Transactions"
+            ? "Transaction Count"
+            : "Outbound Volume"}
+          <StyledTooltip
+            title={
+              displayBy === "Transactions"
+                ? "Total number of transactions the Token Bridge has processed"
+                : "Amount of assets bridged through Portal in the outbound direction"
+            }
+            className={classes.tooltip}
+          >
+            <InfoOutlined />
+          </StyledTooltip>
+        </Typography>
+        <Typography variant="h3">
+          {displayBy === "Transactions"
+            ? transactionsAllTime
+            : transferredAllTime}
+        </Typography>
+      </div>
+      <div className={classes.displayBy}>
+        <div>
+          <Typography display="inline" style={{ marginRight: "8px" }}>
+            Display by
+          </Typography>
+          <ToggleButtonGroup
+            value={displayBy}
+            exclusive
+            onChange={handleDisplayByChange}
+          >
+            {DISPLAY_BY_VALUES.map((value) => (
+              <ToggleButton
+                key={value}
+                value={value}
+                className={classes.toggleButton}
+              >
+                {value}
+              </ToggleButton>
+            ))}
+          </ToggleButtonGroup>
+        </div>
+        <div>
+          <FormControl>
+            <Select
+              multiple
+              variant="outlined"
+              value={selectedChains}
+              onChange={handleSelectedChainsChange}
+              renderValue={(selected: any) =>
+                selected.length === availableChains.length
+                  ? "All chains"
+                  : selected.length > 1
+                  ? `${selected.length} chains`
+                  : //@ts-ignore
+                    CHAINS_BY_ID[selected[0]]?.name
+              }
+              MenuProps={{ getContentAnchorEl: null }} // hack to prevent popup menu from moving
+              style={{ minWidth: 128 }}
+            >
+              <MenuItem value="all">
+                <Checkbox
+                  checked={availableChains.length > 0 && allChainsSelected}
+                  indeterminate={
+                    selectedChains.length > 0 &&
+                    selectedChains.length < availableChains.length
+                  }
+                />
+                <ListItemText primary="All chains" />
+              </MenuItem>
+              {availableChains.map((option) => (
+                <MenuItem key={option} value={option}>
+                  <Checkbox checked={selectedChains.indexOf(option) > -1} />
+                  <ListItemText primary={CHAINS_BY_ID[option]?.name} />
+                </MenuItem>
+              ))}
+            </Select>
+          </FormControl>
+          <TextField
+            select
+            variant="outlined"
+            value={timeFrame}
+            onChange={handleTimeFrameChange}
+            style={{ marginLeft: 8 }}
+          >
+            {Object.keys(TIME_FRAMES).map((timeFrame) => (
+              <MenuItem key={timeFrame} value={timeFrame}>
+                {timeFrame}
+              </MenuItem>
+            ))}
+          </TextField>
+        </div>
+      </div>
+      <Paper className={classes.mainPaper}>
+        {displayBy === "Dollar" ? (
+          notionalTransferred.data ? (
+            allChainsSelected ? (
+              <VolumeAreaChart
+                transferData={transferData}
+                timeFrame={TIME_FRAMES[timeFrame]}
+              />
+            ) : (
+              <VolumeLineChart
+                transferData={transferData}
+                timeFrame={TIME_FRAMES[timeFrame]}
+                chains={selectedChains}
+              />
+            )
+          ) : (
+            <CircularProgress className={classes.alignCenter} />
+          )
+        ) : displayBy === "Percent" ? (
+          <VolumeStackedBarChart
+            transferData={transferData}
+            timeFrame={TIME_FRAMES[timeFrame]}
+            selectedChains={selectedChains}
+          />
+        ) : transactionTotals.data ? (
+          allChainsSelected ? (
+            <TransactionsAreaChart
+              transactionData={transactionData}
+              timeFrame={TIME_FRAMES[timeFrame]}
+            />
+          ) : (
+            <TransactionsLineChart
+              transactionData={transactionData}
+              timeFrame={TIME_FRAMES[timeFrame]}
+              chains={selectedChains}
+            />
+          )
+        ) : (
+          <CircularProgress className={classes.alignCenter} />
+        )}
+      </Paper>
+    </>
+  );
+};
+
+export default VolumeStats;

+ 7 - 250
bridge_ui/src/components/Stats/index.tsx

@@ -1,259 +1,16 @@
-import { BigNumber } from "@ethersproject/bignumber";
-import { formatUnits, parseUnits } from "@ethersproject/units";
-import {
-  CircularProgress,
-  Container,
-  makeStyles,
-  Paper,
-  Typography,
-} from "@material-ui/core";
-import clsx from "clsx";
-import numeral from "numeral";
-import { useMemo } from "react";
-import useTVL from "../../hooks/useTVL";
-import { COLORS } from "../../muiTheme";
+import { Container } from "@material-ui/core";
 import HeaderText from "../HeaderText";
-import SmartAddress from "../SmartAddress";
-import { balancePretty } from "../TokenSelectors/TokenPicker";
-import CustodyAddresses from "./CustodyAddresses";
-import NFTStats from "./NFTStats";
-import MuiReactTable from "./tableComponents/MuiReactTable";
-import TransactionMetrics from "./TransactionMetrics";
-
-const useStyles = makeStyles((theme) => ({
-  logoPositioner: {
-    height: "30px",
-    width: "30px",
-    maxWidth: "30px",
-    marginRight: theme.spacing(1),
-    display: "flex",
-    alignItems: "center",
-  },
-  logo: {
-    maxHeight: "100%",
-    maxWidth: "100%",
-  },
-  tokenContainer: {
-    display: "flex",
-    justifyContent: "flex-start",
-    alignItems: "center",
-  },
-  mainPaper: {
-    backgroundColor: COLORS.whiteWithTransparency,
-    padding: "2rem",
-    "& > h, & > p ": {
-      margin: ".5rem",
-    },
-    marginBottom: theme.spacing(8),
-  },
-  flexBox: {
-    display: "flex",
-    alignItems: "flex-end",
-    marginBottom: theme.spacing(4),
-    textAlign: "left",
-    [theme.breakpoints.down("sm")]: {
-      flexDirection: "column",
-      alignItems: "unset",
-    },
-  },
-  grower: {
-    flexGrow: 1,
-  },
-  explainerContainer: {},
-  totalContainer: {
-    display: "flex",
-    alignItems: "flex-end",
-    paddingBottom: 1, // line up with left text bottom
-    [theme.breakpoints.down("sm")]: {
-      marginTop: theme.spacing(1),
-    },
-  },
-  totalValue: {
-    marginLeft: theme.spacing(0.5),
-    marginBottom: "-.125em", // line up number with label
-  },
-  alignCenter: {
-    margin: "0 auto",
-    display: "block",
-  },
-}));
-
-const StatsRoot: React.FC<any> = () => {
-  const classes = useStyles();
-  const tvl = useTVL();
-
-  const sortTokens = useMemo(() => {
-    return (rowA: any, rowB: any) => {
-      if (rowA.isGrouped && rowB.isGrouped) {
-        return rowA.values.assetAddress > rowB.values.assetAddress ? 1 : -1;
-      } else if (rowA.isGrouped && !rowB.isGrouped) {
-        return 1;
-      } else if (!rowA.isGrouped && rowB.isGrouped) {
-        return -1;
-      } else if (rowA.original.symbol && !rowB.original.symbol) {
-        return 1;
-      } else if (rowB.original.symbol && !rowA.original.symbol) {
-        return -1;
-      } else if (rowA.original.symbol && rowB.original.symbol) {
-        return rowA.original.symbol > rowB.original.symbol ? 1 : -1;
-      } else {
-        return rowA.original.assetAddress > rowB.original.assetAddress ? 1 : -1;
-      }
-    };
-  }, []);
-  const tvlColumns = useMemo(() => {
-    return [
-      {
-        Header: "Token",
-        id: "assetAddress",
-        sortType: sortTokens,
-        disableGroupBy: true,
-        accessor: (value: any) => ({
-          chainId: value.originChainId,
-          symbol: value.symbol,
-          name: value.name,
-          logo: value.logo,
-          assetAddress: value.assetAddress,
-        }),
-        aggregate: (leafValues: any) => leafValues.length,
-        Aggregated: ({ value }: { value: any }) =>
-          `${value} Token${value === 1 ? "" : "s"}`,
-        Cell: (value: any) => (
-          <div className={classes.tokenContainer}>
-            <div className={classes.logoPositioner}>
-              {value.row?.original?.logo ? (
-                <img
-                  src={value.row?.original?.logo}
-                  alt=""
-                  className={classes.logo}
-                />
-              ) : null}
-            </div>
-            <SmartAddress
-              chainId={value.row?.original?.originChainId}
-              address={value.row?.original?.assetAddress}
-              symbol={value.row?.original?.symbol}
-              tokenName={value.row?.original?.name}
-            />
-          </div>
-        ),
-      },
-      { Header: "Chain", accessor: "originChain" },
-      {
-        Header: "Amount",
-        accessor: "amount",
-        align: "right",
-        disableGroupBy: true,
-        Cell: (value: any) =>
-          value.row?.original?.amount !== undefined
-            ? numeral(value.row?.original?.amount).format("0,0.00")
-            : "",
-      },
-      {
-        Header: "Total Value (USD)",
-        id: "totalValue",
-        accessor: "totalValue",
-        align: "right",
-        disableGroupBy: true,
-        aggregate: (leafValues: any) =>
-          balancePretty(
-            formatUnits(
-              leafValues.reduce(
-                (p: BigNumber, v: number | null | undefined) =>
-                  v ? p.add(parseUnits(v.toFixed(18).toString(), 18)) : p,
-                BigNumber.from(0)
-              ),
-              18
-            )
-          ),
-        Aggregated: ({ value }: { value: any }) => value,
-        Cell: (value: any) =>
-          value.row?.original?.totalValue !== undefined
-            ? numeral(value.row?.original?.totalValue).format("0.0 a")
-            : "",
-      },
-      {
-        Header: "Unit Price (USD)",
-        accessor: "quotePrice",
-        align: "right",
-        disableGroupBy: true,
-        Cell: (value: any) =>
-          value.row?.original?.quotePrice !== undefined
-            ? numeral(value.row?.original?.quotePrice).format("0,0.00")
-            : "",
-      },
-    ];
-  }, [
-    classes.logo,
-    classes.tokenContainer,
-    classes.logoPositioner,
-    sortTokens,
-  ]);
-  const tvlString = useMemo(() => {
-    if (!tvl.data) {
-      return "";
-    } else {
-      let sum = 0;
-      tvl.data.forEach((val) => {
-        if (val.totalValue) sum += val.totalValue;
-      });
-      return numeral(sum)
-        .format(sum >= 1000000000 ? "0.000 a" : "0 a")
-        .toUpperCase();
-    }
-  }, [tvl.data]);
+import TVLStats from "./TVLStats";
+import VolumeStats from "./VolumeStats";
 
+const StatsRoot = () => {
   return (
     <Container maxWidth="lg">
       <Container maxWidth="md">
-        <HeaderText white>Rock Hard Stats</HeaderText>
+        <HeaderText white>Stats</HeaderText>
       </Container>
-      <div className={classes.flexBox}>
-        <div className={classes.explainerContainer}>
-          <Typography variant="h4">Total Value Locked</Typography>
-          <Typography variant="subtitle1" color="textSecondary">
-            These assets are currently locked by the Token Bridge contracts.
-          </Typography>
-        </div>
-        <div className={classes.grower} />
-        {!tvl.isFetching ? (
-          <div
-            className={clsx(classes.explainerContainer, classes.totalContainer)}
-          >
-            <Typography
-              variant="body2"
-              color="textSecondary"
-              component="div"
-              noWrap
-            >
-              {"Total (USD)"}
-            </Typography>
-            <Typography
-              variant="h3"
-              component="div"
-              noWrap
-              className={classes.totalValue}
-            >
-              {tvlString}
-            </Typography>
-          </div>
-        ) : null}
-      </div>
-      <Paper className={classes.mainPaper}>
-        {!tvl.isFetching ? (
-          <MuiReactTable
-            columns={tvlColumns}
-            data={tvl.data || []}
-            skipPageReset={false}
-            initialState={{ sortBy: [{ id: "totalValue", desc: true }] }}
-          />
-        ) : (
-          <CircularProgress className={classes.alignCenter} />
-        )}
-      </Paper>
-      <TransactionMetrics />
-      <CustodyAddresses />
-      <NFTStats />
+      <TVLStats />
+      <VolumeStats />
     </Container>
   );
 };

+ 63 - 0
bridge_ui/src/hooks/useCumulativeTVL.ts

@@ -0,0 +1,63 @@
+import { useEffect, useState } from "react";
+import axios from "axios";
+import {
+  DataWrapper,
+  errorDataWrapper,
+  fetchDataWrapper,
+  receiveDataWrapper,
+} from "../store/helpers";
+import { TVL_CUMULATIVE_URL } from "../utils/consts";
+
+export interface LockedAsset {
+  Symbol: string;
+  Name: string;
+  Address: string;
+  CoinGeckoId: string;
+  Amount: number;
+  Notional: number;
+  TokenPrice: number;
+}
+
+export interface LockedAssets {
+  [tokenAddress: string]: LockedAsset;
+}
+
+export interface ChainsAssets {
+  [chainId: string]: LockedAssets;
+}
+
+export interface NotionalTVLCumulative {
+  DailyLocked: {
+    [date: string]: ChainsAssets;
+  };
+}
+
+const useCumulativeTVL = () => {
+  const [cumulativeTVL, setCumulativeTVL] = useState<
+    DataWrapper<NotionalTVLCumulative>
+  >(fetchDataWrapper());
+
+  useEffect(() => {
+    let cancelled = false;
+    axios
+      .get<NotionalTVLCumulative>(TVL_CUMULATIVE_URL)
+      .then((response) => {
+        if (!cancelled) {
+          setCumulativeTVL(receiveDataWrapper(response.data));
+        }
+      })
+      .catch((error) => {
+        if (!cancelled) {
+          setCumulativeTVL(errorDataWrapper(error));
+        }
+        console.log(error);
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, []);
+
+  return cumulativeTVL;
+};
+
+export default useCumulativeTVL;

+ 50 - 0
bridge_ui/src/hooks/useNotionalTransferred.ts

@@ -0,0 +1,50 @@
+import axios from "axios";
+import { useEffect, useState } from "react";
+import {
+  DataWrapper,
+  errorDataWrapper,
+  fetchDataWrapper,
+  receiveDataWrapper,
+} from "../store/helpers";
+import { NOTIONAL_TRANSFERRED_URL } from "../utils/consts";
+
+export interface TransferFromData {
+  [leavingChainId: string]: number;
+}
+
+export interface NotionalTransferredFrom {
+  Total: number;
+  Daily: {
+    [date: string]: TransferFromData;
+  };
+}
+
+const useNotionalTransferred = () => {
+  const [notionalTransferred, setNotionalTransferred] = useState<
+    DataWrapper<NotionalTransferredFrom>
+  >(fetchDataWrapper());
+
+  useEffect(() => {
+    let cancelled = false;
+    axios
+      .get<NotionalTransferredFrom>(NOTIONAL_TRANSFERRED_URL)
+      .then((response) => {
+        if (!cancelled) {
+          setNotionalTransferred(receiveDataWrapper(response.data));
+        }
+      })
+      .catch((error) => {
+        if (!cancelled) {
+          setNotionalTransferred(errorDataWrapper(error));
+          console.error(error);
+        }
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, []);
+
+  return notionalTransferred;
+};
+
+export default useNotionalTransferred;

+ 6 - 6
bridge_ui/src/hooks/useTVL.ts

@@ -41,12 +41,12 @@ interface ChainsAssets {
   [chainId: string]: LockedAssets;
 }
 
-interface NotionalTvl {
+export interface NotionalTVL {
   Last24HoursChange: ChainsAssets;
   AllTime: ChainsAssets;
 }
 
-const createTVLArray = (notionalTvl: NotionalTvl) => {
+export const createTVLArray = (notionalTvl: NotionalTVL) => {
   const tvl: TVL[] = [];
   for (const [chainId, chainAssets] of Object.entries(notionalTvl.AllTime)) {
     if (chainId === "*") continue;
@@ -71,16 +71,16 @@ const createTVLArray = (notionalTvl: NotionalTvl) => {
   return tvl;
 };
 
-const useTVL = () => {
-  const [tvl, setTvl] = useState<DataWrapper<TVL[]>>(fetchDataWrapper());
+export const useTVL = () => {
+  const [tvl, setTvl] = useState<DataWrapper<NotionalTVL>>(fetchDataWrapper());
 
   useEffect(() => {
     let cancelled = false;
     axios
-      .get<NotionalTvl>(TVL_URL)
+      .get<NotionalTVL>(TVL_URL)
       .then((response) => {
         if (!cancelled) {
-          setTvl(receiveDataWrapper(createTVLArray(response.data)));
+          setTvl(receiveDataWrapper(response.data));
         }
       })
       .catch((error) => {

+ 5 - 2
bridge_ui/src/hooks/useTotalTransactedAmount.ts

@@ -6,7 +6,7 @@ import { formatUnits } from "@ethersproject/units";
 import axios from "axios";
 import { useEffect, useMemo, useState } from "react";
 import { DataWrapper } from "../store/helpers";
-import useTVL from "./useTVL";
+import { createTVLArray, useTVL } from "./useTVL";
 
 function convertbase64ToBinary(base64: string) {
   var raw = window.atob(base64);
@@ -24,6 +24,9 @@ function convertbase64ToBinary(base64: string) {
 //Don't actually mount this hook, it's way to expensive for the prod site.
 const useTotalTransactedAmount = (): DataWrapper<number> => {
   const tvl = useTVL();
+  const tvlArray = useMemo(() => {
+    return tvl.data ? createTVLArray(tvl.data) : [];
+  }, [tvl]);
   const [everyVaaPayloadInHistory, setEveryVaaPayloadInHistory] = useState<
     { EmitterChain: string; EmitterAddress: string; Payload: string }[] | null
   >(null);
@@ -84,7 +87,7 @@ const useTotalTransactedAmount = (): DataWrapper<number> => {
       const assetAddress =
         hexToNativeString(payload.originAddress, payload.originChain) || "";
 
-      const tvlItem = tvl.data?.find((item) => {
+      const tvlItem = tvlArray.find((item) => {
         return (
           assetAddress &&
           item.assetAddress.toLowerCase() === assetAddress.toLowerCase()

+ 45 - 0
bridge_ui/src/hooks/useTransactionTotals.ts

@@ -0,0 +1,45 @@
+import { useEffect, useState } from "react";
+import axios from "axios";
+import {
+  DataWrapper,
+  errorDataWrapper,
+  fetchDataWrapper,
+  receiveDataWrapper,
+} from "../store/helpers";
+import { TOTAL_TRANSACTIONS_WORMHOLE } from "../utils/consts";
+
+export interface Totals {
+  TotalCount: { [chainId: string]: number };
+  DailyTotals: {
+    // "2021-08-22": { "*": 0 },
+    [date: string]: { [groupByKey: string]: number };
+  };
+}
+
+const useTransactionTotals = () => {
+  const [totals, setTotals] = useState<DataWrapper<Totals>>(fetchDataWrapper());
+
+  useEffect(() => {
+    let cancelled = false;
+    axios
+      .get<Totals>(TOTAL_TRANSACTIONS_WORMHOLE)
+      .then((response) => {
+        if (!cancelled) {
+          setTotals(receiveDataWrapper(response.data));
+        }
+      })
+      .catch((error) => {
+        if (!cancelled) {
+          setTotals(errorDataWrapper(error));
+          console.log(error);
+        }
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, []);
+
+  return totals;
+};
+
+export default useTransactionTotals;

+ 23 - 0
bridge_ui/src/utils/consts.ts

@@ -705,6 +705,8 @@ export const COVALENT_GET_TOKENS_URL = (
 };
 export const TVL_URL =
   "https://europe-west3-wormhole-315720.cloudfunctions.net/mainnet-notionaltvl";
+export const TVL_CUMULATIVE_URL =
+  "https://europe-west3-wormhole-315720.cloudfunctions.net/mainnet-notionaltvlcumulative?totalsOnly=true";
 export const TERRA_SWAPRATE_URL =
   "https://fcd.terra.dev/v1/market/swaprate/uusd";
 
@@ -1043,6 +1045,9 @@ export const TOTAL_TRANSACTIONS_WORMHOLE = `https://europe-west3-wormhole-315720
 
 export const RECENT_TRANSACTIONS_WORMHOLE = `https://europe-west3-wormhole-315720.cloudfunctions.net/mainnet-recent?groupBy=address&numRows=2`;
 
+export const NOTIONAL_TRANSFERRED_URL =
+  "https://europe-west3-wormhole-315720.cloudfunctions.net/mainnet-notionaltransferredfrom";
+
 export const VAA_EMITTER_ADDRESSES = [
   `${CHAIN_ID_SOLANA}:ec7372995d5cc8732397fb0ad35c0121e0eaa90d26f828a534cab54391b3a4f5`, //SOLANA TOKEN
   `${CHAIN_ID_SOLANA}:0def15a24423e1edd1a5ab16f557b9060303ddbab8c803d2ee48f4b78a1cfd6b`, //SOLAN NFT
@@ -1193,3 +1198,21 @@ export const ACALA_RELAYER_URL =
 
 export const ACALA_RELAY_URL = `${ACALA_RELAYER_URL}/relay`;
 export const ACALA_SHOULD_RELAY_URL = `${ACALA_RELAYER_URL}/shouldRelay`;
+
+export const getChainShortName = (chainId: ChainId) => {
+  return chainId === CHAIN_ID_BSC ? "BSC" : CHAINS_BY_ID[chainId]?.name;
+};
+
+export const COLOR_BY_CHAIN_ID: { [key in ChainId]?: string } = {
+  [CHAIN_ID_SOLANA]: "#31D7BB",
+  [CHAIN_ID_ETH]: "#8A92B2",
+  [CHAIN_ID_TERRA]: "#5493F7",
+  [CHAIN_ID_BSC]: "#F0B90B",
+  [CHAIN_ID_POLYGON]: "#8247E5",
+  [CHAIN_ID_AVAX]: "#E84142",
+  [CHAIN_ID_OASIS]: "#0092F6",
+  [CHAIN_ID_AURORA]: "#23685A",
+  [CHAIN_ID_FANTOM]: "#1969FF",
+  [CHAIN_ID_KARURA]: "#FF4B3B",
+  [CHAIN_ID_ACALA]: "#E00F51",
+};