From 02360288a8cce599c80cd51646143e606e44211b Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sun, 30 Aug 2026 07:39:49 +0000 Subject: [PATCH 01/15] Write down how a source entry reaches an authority that speaks TLS The overlay published index-doge-tap as unreachable from the day it was configured, and the hourly production smoke failed on it for a day, for two reasons this file did not warn about. The entry named http://127.0.0.1:3013 against a service that has served TLS since its certificate was issued. Every probe got an empty reply, which the overlay can only report as unreachable, and the service was running and ready the whole time. The entry now names the certificate's own SAN host over loopback, and the certificate is trusted rather than verification being switched off. The entry also carried the reader's token. The marketplace endpoints check a per-authority token instead, so every checkpoint request was a 401 while the unauthenticated readiness probe passed. A green readiness probe says nothing about the request that actually matters. Both are properties of the deployment rather than of the repository, which is why they were invisible here. The configuration table also gains the certificate file the overlay now reads. Co-Authored-By: Claude Opus 5 --- docs/operations/DEPLOYMENT.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/operations/DEPLOYMENT.md b/docs/operations/DEPLOYMENT.md index 16b7d2613c..a6efe7eaae 100644 --- a/docs/operations/DEPLOYMENT.md +++ b/docs/operations/DEPLOYMENT.md @@ -100,6 +100,28 @@ The overlay reads only Universe protocol authorities, configured through bearer tokens supplied by separate named variables. Credentials never appear in the JSON, in responses, or in logs. +Two things about a source entry are easy to get wrong, and both have already +produced an authority the overlay reported as unreachable while it was running +and ready. + +The scheme has to be the one the authority actually speaks. Most authorities +here are plain HTTP on loopback; `index-doge-tap` is an HTTPS listener, so its +entry names `https://doge-tap.internal.bitcoinuniverse.io:3013`. That host is in +the certificate's subject alternative names and resolves to `127.0.0.1` through +`/etc/hosts`, so verification passes and the traffic still stays on the loopback +interface rather than going out to the public address the certificate also +names. The certificate is trusted through `NODE_EXTRA_CA_CERTS`, never by +turning verification off. Point an entry at `http://` when the authority serves +TLS and every probe gets an empty reply, which the overlay can only report as +unreachable. + +The token has to be the one the endpoint being called checks. A service can +carry more than one: `index-doge-tap` authenticates its reader API with +`TOKEN_EXPLORER_BEARER_TOKEN` and its marketplace authority endpoints per +authority, against `DOGE_MARKETPLACE__AUTHORITY_BEARER_TOKEN`. The +readiness probe passing proves nothing about the checkpoint request, because +readiness is unauthenticated. + ## Configuration `/etc/universe-explorer/`, owned by root, group `universe-explorer`, mode 0640: @@ -111,6 +133,7 @@ the JSON, in responses, or in logs. | `gateway.env` | gateway ports and the static root | | `mysql.env` | explorer database name, user, and passwords | | `fulcrum.conf` | the Electrum index this deployment reads | +| `doge-tap-ca.pem` | the certificate `index-doge-tap` presents, trusted by the overlay | ## Build From 5606499da3fa08b7c0da124abc372c897a71eaea Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sun, 30 Aug 2026 13:25:17 +0000 Subject: [PATCH 02/15] Give Dogecoin and Zcash the full explorer surface Bitcoin has The two chains rendered a capability report where Bitcoin renders a dashboard, and their navigation hid Mining, Charts, and chain Docs behind an activeChain gate. This builds the missing product: - The route registry gains mining, graphs, and docs categories, chain switching preserves the section, and a chart child survives a switch when the destination chain has an equivalent (a registry maps block-sizes-weights to block-sizes and block-health to block-interval). - The chain dashboard: a full-width block timeline with one cube per upcoming target slot and one per confirmed block, heights above every cube, the direction toggle shared with the Bitcoin strip, fee guidance in chain-native units, a Universe Lens over the real pending set (the Bitcoin treemap renderer fed chain-read items, with a table fallback), mining facts, recent blocks with evidence-based miner attribution, and the old capability report filed as a status drawer. - Zcash slots state eligibility, never order: tier chips stay on the cubes and the strip says selection is randomized under ZIP-317. - A chain mining page: network facts, rewards, pool shares with an honest Unknown bucket and named evidence, recent blocks, and a how-this-chain-mines explainer covering AuxPoW and Equihash. - Chain charts: an echarts shell with ten chart routes per chain, ranges disabled until stored history covers them, table fallback, and a pool ranking page. - Chain docs at /dogecoin/docs and /zcash/docs: fourteen sections from dashboard field definitions to the API list, privacy limits on Zcash, and the candidate-versus-projected distinction. - The mempool page keeps its bucket row; the shared cube grammar and page skeleton move to partials so the surfaces cannot drift apart. - Gates: parity-regressions.test.mjs pins the structure in CI, the production smoke checks the new data families with a backfilling-aware honesty rule, the browser smoke audits the rendered timeline, and the visual matrix covers the eight new routes in both themes with fixtures for every new endpoint. Local verification: production build green, 407 universe unit tests, 40-screenshot visual run with zero contrast, a11y, overflow, and console findings. Co-Authored-By: Claude Fable 5 --- .github/workflows/universe-ci.yml | 7 + docs/operations/DEPLOYMENT.md | 18 + .../master-page/master-page.component.html | 13 +- .../master-page/master-page.component.ts | 4 +- frontend/src/app/universe/_chain-cubes.scss | 108 ++++ frontend/src/app/universe/_chain-page.scss | 253 +++++++++ .../chain-dashboard/chain-dashboard-format.ts | 104 ++++ .../chain-dashboard.component.html | 334 +++++++++++ .../chain-dashboard.component.scss | 233 ++++++++ .../chain-dashboard.component.ts | 328 +++++++++++ .../chain-dashboard.service.ts | 122 ++++ .../chain-dashboard/chain-lens.component.html | 77 +++ .../chain-dashboard/chain-lens.component.scss | 84 +++ .../chain-dashboard/chain-lens.component.ts | 207 +++++++ .../chain-dashboard/chain-lens.spec.ts | 102 ++++ .../universe/chain-dashboard/chain-lens.ts | 153 +++++ .../chain-mining.component.html | 213 +++++++ .../chain-mining.component.scss | 147 +++++ .../chain-dashboard/chain-mining.component.ts | 216 +++++++ .../chain-timeline.component.html | 101 ++++ .../chain-timeline.component.scss | 175 ++++++ .../chain-timeline.component.ts | 81 +++ .../chain-dashboard/chain-timeline.spec.ts | 175 ++++++ .../chain-dashboard/chain-timeline.ts | 240 ++++++++ .../universe/chain-docs/chain-docs-content.ts | 98 ++++ .../chain-docs/chain-docs.component.html | 532 ++++++++++++++++++ .../chain-docs/chain-docs.component.scss | 177 ++++++ .../chain-docs/chain-docs.component.spec.ts | 102 ++++ .../chain-docs/chain-docs.component.ts | 102 ++++ .../universe/chain-docs/chain-docs.module.ts | 19 + .../chain-graphs/chain-chart-config.spec.ts | 122 ++++ .../chain-graphs/chain-chart-config.ts | 323 +++++++++++ .../chain-graphs/chain-chart-format.spec.ts | 104 ++++ .../chain-graphs/chain-chart-format.ts | 186 ++++++ .../chain-graphs/chain-chart-options.ts | 222 ++++++++ .../chain-graphs/chain-chart.component.html | 70 +++ .../chain-graphs/chain-chart.component.scss | 148 +++++ .../chain-graphs/chain-chart.component.ts | 399 +++++++++++++ .../chain-graphs/chain-graphs.component.html | 27 + .../chain-graphs/chain-graphs.component.scss | 84 +++ .../chain-graphs/chain-graphs.component.ts | 105 ++++ .../chain-graphs/chain-graphs.module.ts | 48 ++ .../chain-pools-ranking.component.html | 76 +++ .../chain-pools-ranking.component.scss | 147 +++++ .../chain-pools-ranking.component.ts | 163 ++++++ .../multichain-explorer.component.html | 132 +---- .../multichain-explorer.component.scss | 354 +----------- .../multichain-explorer.component.ts | 11 +- .../multichain-explorer.module.ts | 30 +- .../src/app/universe/universe-api.service.ts | 44 ++ .../universe/universe-chain-routing.spec.ts | 72 +++ .../app/universe/universe-chain-routing.ts | 62 +- .../app/universe/universe-chart-registry.ts | 94 ++++ frontend/src/app/universe/universe.types.ts | 190 +++++++ scripts/universe/parity-regressions.test.mjs | 128 +++++ scripts/universe/synthetic-check.mjs | 96 ++++ scripts/universe/visual-qa/capture.mjs | 16 +- scripts/universe/visual-qa/chain-fixtures.mjs | 315 ++++++++++- .../universe/visual-qa/chain-page-audit.mjs | 160 ++++++ .../visual-qa/chain-page-audit.test.mjs | 177 +++++- .../universe/visual-qa/chain-page-smoke.mjs | 130 ++++- scripts/universe/visual-qa/mobile-check.mjs | 3 + 62 files changed, 8250 insertions(+), 513 deletions(-) create mode 100644 frontend/src/app/universe/_chain-cubes.scss create mode 100644 frontend/src/app/universe/_chain-page.scss create mode 100644 frontend/src/app/universe/chain-dashboard/chain-dashboard-format.ts create mode 100644 frontend/src/app/universe/chain-dashboard/chain-dashboard.component.html create mode 100644 frontend/src/app/universe/chain-dashboard/chain-dashboard.component.scss create mode 100644 frontend/src/app/universe/chain-dashboard/chain-dashboard.component.ts create mode 100644 frontend/src/app/universe/chain-dashboard/chain-dashboard.service.ts create mode 100644 frontend/src/app/universe/chain-dashboard/chain-lens.component.html create mode 100644 frontend/src/app/universe/chain-dashboard/chain-lens.component.scss create mode 100644 frontend/src/app/universe/chain-dashboard/chain-lens.component.ts create mode 100644 frontend/src/app/universe/chain-dashboard/chain-lens.spec.ts create mode 100644 frontend/src/app/universe/chain-dashboard/chain-lens.ts create mode 100644 frontend/src/app/universe/chain-dashboard/chain-mining.component.html create mode 100644 frontend/src/app/universe/chain-dashboard/chain-mining.component.scss create mode 100644 frontend/src/app/universe/chain-dashboard/chain-mining.component.ts create mode 100644 frontend/src/app/universe/chain-dashboard/chain-timeline.component.html create mode 100644 frontend/src/app/universe/chain-dashboard/chain-timeline.component.scss create mode 100644 frontend/src/app/universe/chain-dashboard/chain-timeline.component.ts create mode 100644 frontend/src/app/universe/chain-dashboard/chain-timeline.spec.ts create mode 100644 frontend/src/app/universe/chain-dashboard/chain-timeline.ts create mode 100644 frontend/src/app/universe/chain-docs/chain-docs-content.ts create mode 100644 frontend/src/app/universe/chain-docs/chain-docs.component.html create mode 100644 frontend/src/app/universe/chain-docs/chain-docs.component.scss create mode 100644 frontend/src/app/universe/chain-docs/chain-docs.component.spec.ts create mode 100644 frontend/src/app/universe/chain-docs/chain-docs.component.ts create mode 100644 frontend/src/app/universe/chain-docs/chain-docs.module.ts create mode 100644 frontend/src/app/universe/chain-graphs/chain-chart-config.spec.ts create mode 100644 frontend/src/app/universe/chain-graphs/chain-chart-config.ts create mode 100644 frontend/src/app/universe/chain-graphs/chain-chart-format.spec.ts create mode 100644 frontend/src/app/universe/chain-graphs/chain-chart-format.ts create mode 100644 frontend/src/app/universe/chain-graphs/chain-chart-options.ts create mode 100644 frontend/src/app/universe/chain-graphs/chain-chart.component.html create mode 100644 frontend/src/app/universe/chain-graphs/chain-chart.component.scss create mode 100644 frontend/src/app/universe/chain-graphs/chain-chart.component.ts create mode 100644 frontend/src/app/universe/chain-graphs/chain-graphs.component.html create mode 100644 frontend/src/app/universe/chain-graphs/chain-graphs.component.scss create mode 100644 frontend/src/app/universe/chain-graphs/chain-graphs.component.ts create mode 100644 frontend/src/app/universe/chain-graphs/chain-graphs.module.ts create mode 100644 frontend/src/app/universe/chain-graphs/chain-pools-ranking.component.html create mode 100644 frontend/src/app/universe/chain-graphs/chain-pools-ranking.component.scss create mode 100644 frontend/src/app/universe/chain-graphs/chain-pools-ranking.component.ts create mode 100644 frontend/src/app/universe/universe-chart-registry.ts create mode 100644 scripts/universe/parity-regressions.test.mjs diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml index b04727686f..a71293aa03 100644 --- a/.github/workflows/universe-ci.yml +++ b/.github/workflows/universe-ci.yml @@ -185,6 +185,13 @@ jobs: working-directory: scripts/universe/visual-qa run: node --test chain-page-audit.test.mjs + # Structural facts of the multichain parity release: navigation not + # gated back to Bitcoin, the route registry whole, the dashboard + # requesting real data, cubes carrying heights, chain-native units. + - name: The parity regressions gate holds + working-directory: scripts/universe + run: node --test parity-regressions.test.mjs + # A fixed port here once measured another job's build: two runners share # a host, the second gateway crashed on the taken port behind "&", the # probe happily reached the first job's server, and the run died halfway diff --git a/docs/operations/DEPLOYMENT.md b/docs/operations/DEPLOYMENT.md index a6efe7eaae..f970fb48b9 100644 --- a/docs/operations/DEPLOYMENT.md +++ b/docs/operations/DEPLOYMENT.md @@ -122,6 +122,24 @@ authority, against `DOGE_MARKETPLACE__AUTHORITY_BEARER_TOKEN`. The readiness probe passing proves nothing about the checkpoint request, because readiness is unauthenticated. +The dashboard, mining, and chart families for Dogecoin and Zcash read two +node RPCs and one durable history store, all configured in the same +`overlay.env`: + +- `UNIVERSE_DOGECOIN_RPC_URL` with `UNIVERSE_DOGECOIN_RPC_USER` and + `UNIVERSE_DOGECOIN_RPC_PASSWORD` (already present for the mempool + collector) also feed the block collector, `estimatesmartfee`, and + `getmininginfo`. +- `UNIVERSE_ZCASH_RPC_URL` points at the Zebra JSON-RPC listener, plain + HTTP on loopback (`http://127.0.0.1:8232/`). Zebra runs without RPC + auth; set `UNIVERSE_ZCASH_RPC_AUTHORIZATION` only if that changes. +- `UNIVERSE_EXPLORER_HISTORY_PATH` names the SQLite file holding collected + block history and mempool samples + (`/var/lib/universe-explorer/chain-history.sqlite`). It is separate from + `UNIVERSE_EXPLORER_STATE_PATH` so history writes never contend with the + mempool collector's commits. Unset, the history features report + themselves unavailable and every other surface keeps working. + ## Configuration `/etc/universe-explorer/`, owned by root, group `universe-explorer`, mode 0640: diff --git a/frontend/src/app/components/master-page/master-page.component.html b/frontend/src/app/components/master-page/master-page.component.html index 8051b04635..8ca085e4ea 100644 --- a/frontend/src/app/components/master-page/master-page.component.html +++ b/frontend/src/app/components/master-page/master-page.component.html @@ -85,8 +85,8 @@ Dashboard - - + - -
-

How much history is readable now

-
    -
  • - {{ reading.label }} - {{ reading.stateLabel }} - {{ reading.detail }} -
  • -
-

Waiting for a block

- - - - - -
-

Questions this chain can answer

-

- Each entry is a claim the indexer makes about itself. Where it says no, the explorer offers no page for that lookup rather than an empty one. -

-
    -
  • - {{ read.label }} - {{ read.stateLabel }} -
  • -
-
- -
-

Protocol indexers

-
    -
  • - {{ protocol.label }} - - {{ protocol.label }} - - {{ protocol.stateLabel }} - - {{ protocol.historyLabel }} - - - {{ protocol.lag.display }} blocks behind - - - - - What it does not cover -
      -
    • {{ reason.text }}
    • -
    -
    - - This chain reports it. This explorer has no page for it yet, so there is nothing to open. - -
  • -
-
- -
-

What this chain does differently

-

- Dogecoin fees are quoted per kilobyte, not in sat/vB. This explorer prints the unit the chain actually uses and shows a fee rate only when the first-party source reports one. -

-

- Zcash fee guidance follows ZIP-317 logical actions where the node reports them. A transaction's cost is not a simple function of its byte size on this chain. -

-

- Only the transparent side of Zcash is public. This explorer reports the shape of a shielded transaction, never who was involved or how much moved. -

-

- No projected blocks are shown for this chain. Projecting the next block needs a fee market this explorer does not model here, and a guess presented as a forecast would be worse than nothing. -

-
- - - - -
- Where these readings came from -

- The identifiers behind this page, for checking a reading against the document it was rendered from. -

-
- -
{{ detail.label }}
-
- {{ detail.display }} - - {{ detail.exact }} - - {{ detail.note }} -
-
-
-
- -
- @@ -1057,7 +929,7 @@

{{ empty.label }}

-

The {{ chainName }} authority answered, and had nothing to report for this request.

diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss index 6c6c7e9f37..045c22d131 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss @@ -11,6 +11,11 @@ @use '../universe-tokens' as u; +@use '../chain-page' as page; +// The cube grammar shared with the dashboard timeline, so the bucket row +// and the timeline cannot drift apart visually. +@use '../chain-cubes'; + :host { @include u.universe-protocol-tokens; @include u.universe-state-tokens; @@ -18,10 +23,10 @@ display: block; } -.chain-page { - padding: 1.25rem 0 3rem; - color: var(--u-text-primary); -} +// The generic chain-page grammar (head, rail, panels, facts, tables) is the +// shared mixin, so this page and the dashboard, mining, charts, and docs +// pages stay one design. +@include page.universe-chain-page; // --------------------------------------------------------------------------- // Head @@ -32,32 +37,6 @@ // to the end of the rule. Only the chain mark and the save control are added // here, so a Dogecoin transaction page and a Bitcoin one open the same way. -// A chain gets a mark, not a colour that means something. The hue comes from -// the protocol scale, which is held away from every evidence state, and it -// never appears without the chain's name beside it. -.chain-mark { - width: 0.5rem; - height: 0.5rem; - border-radius: var(--u-radius-pill); - flex: none; - align-self: center; - - &.chain-mark-dogecoin { - background: var(--universe-protocol-ordinals); - } - - &.chain-mark-zcash { - background: var(--universe-protocol-alkanes); - } -} - -.lede { - margin: 0 0 var(--u-space-4); - max-width: var(--u-width-prose); - color: var(--u-text-secondary); - line-height: var(--u-leading-normal); -} - .save { flex: none; background: var(--u-surface-raised); @@ -86,99 +65,6 @@ // Status rail // --------------------------------------------------------------------------- -.status-rail { - @include u.universe-surface; - - display: grid; - grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); - gap: var(--u-space-3) var(--u-space-5); - padding: var(--u-space-3) var(--u-space-4); - margin-bottom: var(--u-space-3); -} - -.reading { - display: flex; - flex-direction: column; - gap: 0.3rem; - min-width: 0; -} - -.reading-label { - color: var(--u-text-muted); - font-size: var(--u-text-2xs); - font-weight: var(--u-weight-semibold); - letter-spacing: var(--u-eyebrow-tracking); - text-transform: uppercase; -} - -.universe-chip { - @include u.universe-chip; -} - -// --------------------------------------------------------------------------- -// Panels -// --------------------------------------------------------------------------- - -.panel { - @include u.universe-surface; - - padding: var(--u-space-4); - margin-bottom: var(--u-space-3); - - h2 { - font-size: var(--u-text-lg); - font-weight: var(--u-weight-semibold); - margin: 0 0 var(--u-space-3); - } - - h3 { - font-size: var(--u-text-base); - font-weight: var(--u-weight-semibold); - color: var(--u-text-secondary); - margin: var(--u-space-4) 0 var(--u-space-2); - } - - h3:first-of-type { - margin-top: 0; - } -} - -.panel-lede { - color: var(--u-text-secondary); - font-size: var(--u-text-sm); - line-height: var(--u-leading-normal); - max-width: var(--u-width-prose); - margin: 0 0 var(--u-space-3); -} - -.notice { - @include u.universe-surface; - - padding: var(--u-space-3) var(--u-space-4); - margin-bottom: var(--u-space-3); - font-size: var(--u-text-sm); - line-height: var(--u-leading-normal); - max-width: var(--u-width-prose); - - &.switched { - border-left: 3px solid var(--universe-state-pending); - } - - &.failure { - border-left: 3px solid var(--universe-state-unavailable); - color: var(--universe-state-unavailable); - } - - &.empty { - border-left: 3px solid var(--universe-state-neutral); - color: var(--u-text-secondary); - } -} - -.secondary { - color: var(--u-text-muted); -} - // --------------------------------------------------------------------------- // Why a chain is not ready // --------------------------------------------------------------------------- @@ -308,53 +194,6 @@ } } -.identifier { - @include u.universe-identifier; -} - -.numeric { - text-align: right; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -.dot { - display: inline-block; - width: 3px; - height: 3px; - margin: 0 0.45rem; - border-radius: var(--u-radius-pill); - background: var(--u-text-faint); - vertical-align: middle; -} - -// --------------------------------------------------------------------------- -// Facts -// --------------------------------------------------------------------------- - -.facts { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); - gap: var(--u-space-3) var(--u-space-5); - margin: 0; - - > div { - min-width: 0; - } - - dt { - color: var(--u-text-muted); - font-size: var(--u-text-xs); - font-weight: var(--u-weight-medium); - } - - dd { - margin: 0.2rem 0 0; - min-width: 0; - overflow-wrap: anywhere; - } -} - .verdict { margin: 0 0 var(--u-space-3); } @@ -615,42 +454,6 @@ // Figures // --------------------------------------------------------------------------- -.figure-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); - gap: var(--u-space-4); -} - -.figure { - display: flex; - flex-direction: column; - gap: 0.2rem; - min-width: 0; -} - -.figure-label { - color: var(--u-text-muted); - font-size: var(--u-text-2xs); - font-weight: var(--u-weight-semibold); - letter-spacing: var(--u-eyebrow-tracking); - text-transform: uppercase; -} - -.figure-value { - font-family: var(--u-font-data); - font-size: var(--u-text-xl); - font-variant-numeric: tabular-nums; - font-weight: var(--u-weight-semibold); - line-height: var(--u-leading-tight); - overflow-wrap: anywhere; -} - -.figure-unit { - color: var(--u-text-muted); - font-size: var(--u-text-sm); - font-weight: var(--u-weight-normal); -} - // The unit belongs to the column, so it is stated once in the header rather // than repeated down every row of a dense table. .column-unit { @@ -730,149 +533,10 @@ } } -// A table wide enough to scroll is a scrollable region, and a region a mouse -// can pan has to be reachable from a keyboard too. It takes focus, so it also -// has to show that it has it. -.table-wrap { - overflow-x: auto; - - &:focus-visible { - outline: 2px solid var(--u-focus-ring); - outline-offset: 2px; - } -} - -table { - width: 100%; - border-collapse: collapse; - font-size: var(--u-text-sm); -} - -thead th { - color: var(--u-text-muted); - font-size: var(--u-text-2xs); - font-weight: var(--u-weight-semibold); - letter-spacing: var(--u-eyebrow-tracking); - text-transform: uppercase; - text-align: left; - padding: 0 var(--u-space-3) var(--u-space-2) 0; - border-bottom: 1px solid var(--u-border); - white-space: nowrap; - - &.numeric { - text-align: right; - } -} - -tbody td { - padding: var(--u-space-2) var(--u-space-3) var(--u-space-2) 0; - border-bottom: 1px solid var(--u-divider); - vertical-align: top; - overflow-wrap: anywhere; -} - -tbody tr:last-child td { - border-bottom: 0; -} - // --------------------------------------------------------------------------- // Candidate buckets // --------------------------------------------------------------------------- -// The same cube grammar the Bitcoin projection uses: a face carrying the -// figures, a skewed top and side for depth, the projected-block palette. -// What differs is the claim, which the copy above the row states. -.cube-row { - display: flex; - gap: calc(var(--u-space-6) + 20px); - overflow-x: auto; - padding: calc(0.24 * var(--cube-size)) var(--u-space-2) var(--u-space-2) - calc(0.16 * var(--cube-size) + var(--u-space-2)); - --cube-size: 125px; - - &:focus-visible { - outline: 2px solid var(--u-focus-ring); - outline-offset: 2px; - } -} - -.candidate-cube { - flex: 0 0 auto; - width: var(--cube-size); -} - -.cube-block { - position: relative; - width: var(--cube-size); - height: var(--cube-size); - background: var(--u-block-projected-empty); -} - -.cube-block::after { - content: ''; - width: var(--cube-size); - height: calc(0.192 * var(--cube-size)); - position: absolute; - top: calc(-0.192 * var(--cube-size)); - left: calc(-0.16 * var(--cube-size)); - background-color: var(--u-block-projected-top); - transform: skew(40deg); - transform-origin: top; -} - -.cube-block::before { - content: ''; - width: calc(0.16 * var(--cube-size)); - height: var(--cube-size); - position: absolute; - top: calc(-0.096 * var(--cube-size)); - left: calc(-0.16 * var(--cube-size)); - background-color: var(--u-block-projected-side); - transform: skewY(50deg); - transform-origin: top; -} - -.cube-body { - position: relative; - height: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: var(--u-space-1); - text-align: center; - padding: var(--u-space-2); - color: var(--u-block-ink); -} - -.cube-median { - font-size: var(--u-text-md); - font-weight: var(--u-weight-bold); - overflow-wrap: anywhere; -} - -.cube-unit { - font-size: var(--u-text-2xs); - font-weight: var(--u-weight-regular); -} - -.cube-span, -.cube-count, -.cube-size { - font-size: var(--u-text-2xs); - color: var(--u-block-ink-muted); - overflow-wrap: anywhere; -} - -.cube-caption { - margin-top: var(--u-space-2); - display: flex; - flex-direction: column; - align-items: flex-start; - gap: var(--u-space-1); - font-size: var(--u-text-2xs); -} - .bucket-facts { margin-top: var(--u-space-3); } diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts index 0fc63cc67a..c6fd6e069e 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts @@ -98,7 +98,6 @@ import { } from 'rxjs'; type MultichainPage = - | 'dashboard' | 'mempool' | 'block' | 'transaction' @@ -225,7 +224,7 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { readonly chainName: string; readonly ticker: string; readonly protocolIds: readonly string[]; - page: MultichainPage = 'dashboard'; + page: MultichainPage = 'mempool'; reference = ''; saved = false; switchedFrom: string | null = null; @@ -436,8 +435,6 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { pageLabel(): string { switch (this.page) { - case 'dashboard': - return $localize`:@@universe.chain.page-dashboard:overview`; case 'mempool': return $localize`:@@universe.chain.page-mempool:pending transactions`; case 'block': @@ -466,8 +463,6 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { /** One sentence saying what the reader is looking at, under the heading. */ pageLede(): string { switch (this.page) { - case 'dashboard': - return $localize`:@@universe.chain.lede-dashboard:What this explorer can answer about ${this.chainName}:CHAIN: right now, and how far behind the chain tip each answer is.`; case 'mempool': return $localize`:@@universe.chain.lede-mempool:Transactions seen by our own ${this.chainName}:CHAIN: node and not yet in a block.`; case 'protocols': @@ -629,8 +624,6 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { const reference = context.params.get('reference') ?? ''; const protocol = context.params.get('protocol') ?? ''; switch (context.page) { - case 'dashboard': - return of(null); case 'mempool': return this.api.getChainMempool$(this.chain); case 'transaction': @@ -744,8 +737,6 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { private pageDescription(context: RequestContext): string { const reference = this.referenceFrom(context); switch (context.page) { - case 'dashboard': - return $localize`:@@universe.chain.meta-dashboard:What Universe Explorer can answer about ${this.chainName}:CHAIN: right now, how far behind the chain tip each answer is, and which protocol indexers are running.`; case 'mempool': return $localize`:@@universe.chain.meta-mempool:${this.chainName}:CHAIN: transactions seen by Bitcoin Universe's own node and not yet in a block, read from first-party data.`; case 'protocols': diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.module.ts b/frontend/src/app/universe/multichain-explorer/multichain-explorer.module.ts index 4c0fb0a89a..275d9dedd4 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.module.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.module.ts @@ -1,13 +1,34 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { MultichainExplorerComponent } from '@app/universe/multichain-explorer/multichain-explorer.component'; +import { ChainDashboardComponent } from '@app/universe/chain-dashboard/chain-dashboard.component'; +import { ChainMiningComponent } from '@app/universe/chain-dashboard/chain-mining.component'; const routes: Routes = [ { path: '', - component: MultichainExplorerComponent, + component: ChainDashboardComponent, data: { page: 'dashboard' }, }, + { + path: 'mining', + component: ChainMiningComponent, + data: { page: 'mining' }, + }, + { + path: 'graphs', + loadChildren: () => + import('@app/universe/chain-graphs/chain-graphs.module').then( + (m) => m.ChainGraphsModule + ), + }, + { + path: 'docs', + loadChildren: () => + import('@app/universe/chain-docs/chain-docs.module').then( + (m) => m.ChainDocsModule + ), + }, { path: 'mempool', component: MultichainExplorerComponent, @@ -61,6 +82,11 @@ const routes: Routes = [ ]; @NgModule({ - imports: [MultichainExplorerComponent, RouterModule.forChild(routes)], + imports: [ + MultichainExplorerComponent, + ChainDashboardComponent, + ChainMiningComponent, + RouterModule.forChild(routes), + ], }) export class MultichainExplorerModule {} diff --git a/frontend/src/app/universe/universe-api.service.ts b/frontend/src/app/universe/universe-api.service.ts index 5f1c16540b..40282a55b7 100644 --- a/frontend/src/app/universe/universe-api.service.ts +++ b/frontend/src/app/universe/universe-api.service.ts @@ -17,8 +17,14 @@ import { OrdRuneView, OrdSatView, ChainCapabilityEnvelope, + ChainDashboardView, ChainExplorerPayload, + ChartSeriesView, ExplorerChain, + FeeRecommendationsView, + MiningPoolsView, + MiningSummaryView, + RecentBlocksView, UniverseSearchResponse, } from '@app/universe/universe.types'; @@ -170,6 +176,44 @@ export class UniverseApiService { ); } + /** The one-call dashboard aggregate: blocks, buckets, fees, mempool, mining. */ + getChainDashboard$(chain: Exclude): Observable { + return this.httpClient.get( + this.apiBaseUrl + '/api/v1/' + chain + '/dashboard?network=mainnet' + ); + } + + getChainRecentBlocks$(chain: Exclude, limit = 15): Observable { + return this.httpClient.get( + this.apiBaseUrl + '/api/v1/' + chain + '/blocks/recent?network=mainnet&limit=' + limit + ); + } + + getChainFees$(chain: Exclude): Observable { + return this.httpClient.get( + this.apiBaseUrl + '/api/v1/' + chain + '/fees?network=mainnet' + ); + } + + getChainMining$(chain: Exclude): Observable { + return this.httpClient.get( + this.apiBaseUrl + '/api/v1/' + chain + '/mining?network=mainnet' + ); + } + + getChainMiningPools$(chain: Exclude, window = '1w'): Observable { + return this.httpClient.get( + this.apiBaseUrl + '/api/v1/' + chain + '/mining/pools?network=mainnet&window=' + encodeURIComponent(window) + ); + } + + getChainChartSeries$(chain: Exclude, seriesId: string, range = '1w'): Observable { + return this.httpClient.get( + this.apiBaseUrl + '/api/v1/' + chain + '/charts/' + encodeURIComponent(seriesId) + + '?network=mainnet&range=' + encodeURIComponent(range) + ); + } + getChainTransaction$(chain: Exclude, txid: string): Observable { return this.httpClient.get( this.apiBaseUrl + '/api/v1/' + chain + '/tx/' + encodeURIComponent(txid) + '?network=mainnet' diff --git a/frontend/src/app/universe/universe-chain-routing.spec.ts b/frontend/src/app/universe/universe-chain-routing.spec.ts index dcff36fe66..8f4903f167 100644 --- a/frontend/src/app/universe/universe-chain-routing.spec.ts +++ b/frontend/src/app/universe/universe-chain-routing.spec.ts @@ -1,4 +1,5 @@ import { + ExplorerRouteCategory, explorerChainFromUrl, explorerRouteCategory, explorerSectionRoute, @@ -33,4 +34,75 @@ describe('Universe chain routing', () => { droppedObject: true, }); }); + + // The route category type is the registry every chain's navigation is built + // from. Losing a section here silently removes it from every chain's menu, + // so the full set is pinned. + it('keeps every product section in the category registry', () => { + const sections: ExplorerRouteCategory[] = [ + 'dashboard', 'mining', 'mempool', 'protocols', 'graphs', 'docs', + ]; + for (const section of sections) { + expect(explorerSectionRoute('bitcoin', section as Exclude)).toBeTruthy(); + expect(explorerSectionRoute('dogecoin', section as Exclude)).toContain('/dogecoin'); + expect(explorerSectionRoute('zcash', section as Exclude)).toContain('/zcash'); + } + }); + + it('classifies mining, graphs, and docs URLs as sections, not objects', () => { + expect(explorerRouteCategory('/mining')).toBe('mining'); + expect(explorerRouteCategory('/dogecoin/mining')).toBe('mining'); + expect(explorerRouteCategory('/zcash/graphs')).toBe('graphs'); + expect(explorerRouteCategory('/graphs/mining/pools')).toBe('graphs'); + expect(explorerRouteCategory('/dogecoin/docs')).toBe('docs'); + expect(explorerRouteCategory('/docs/api')).toBe('docs'); + // A pool page names one pool, which another chain does not have. + expect(explorerRouteCategory('/mining/pool/foundry')).toBe('object'); + }); + + it('preserves the section when switching chains', () => { + expect(explorerSwitchTarget('/mining', 'dogecoin')).toEqual({ + path: '/dogecoin/mining', + droppedObject: false, + }); + expect(explorerSwitchTarget('/dogecoin/mining', 'zcash')).toEqual({ + path: '/zcash/mining', + droppedObject: false, + }); + expect(explorerSwitchTarget('/zcash/docs', 'bitcoin')).toEqual({ + path: '/docs', + droppedObject: false, + }); + }); + + it('preserves a chart child route across chains when an equivalent exists', () => { + expect(explorerSwitchTarget('/graphs/mining/pools', 'dogecoin')).toEqual({ + path: '/dogecoin/graphs/mining/pools', + droppedObject: false, + }); + expect( + explorerSwitchTarget('/dogecoin/graphs/mining/hashrate-difficulty', 'zcash') + ).toEqual({ + path: '/zcash/graphs/mining/hashrate-difficulty', + droppedObject: false, + }); + // Sizes and weights exist together only on Bitcoin; the others chart sizes. + expect( + explorerSwitchTarget('/graphs/mining/block-sizes-weights', 'zcash') + ).toEqual({ + path: '/zcash/graphs/mining/block-sizes', + droppedObject: false, + }); + expect( + explorerSwitchTarget('/dogecoin/graphs/mining/block-sizes', 'bitcoin') + ).toEqual({ + path: '/graphs/mining/block-sizes-weights', + droppedObject: false, + }); + // The fiat price chart has no self-hosted equivalent on the other chains. + expect(explorerSwitchTarget('/graphs/price', 'dogecoin')).toEqual({ + path: '/dogecoin/graphs', + droppedObject: false, + }); + }); }); diff --git a/frontend/src/app/universe/universe-chain-routing.ts b/frontend/src/app/universe/universe-chain-routing.ts index 4d5ae2d485..df13dfec41 100644 --- a/frontend/src/app/universe/universe-chain-routing.ts +++ b/frontend/src/app/universe/universe-chain-routing.ts @@ -1,7 +1,8 @@ import { ExplorerChain } from '@app/universe/universe.types'; +import { equivalentGraphChild } from '@app/universe/universe-chart-registry'; export type ExplorerRouteCategory = - 'dashboard' | 'mempool' | 'protocols' | 'object'; + 'dashboard' | 'mining' | 'mempool' | 'protocols' | 'graphs' | 'docs' | 'object'; /** * How each chain is written when a person reads it. The wire uses lowercase @@ -40,6 +41,19 @@ export function explorerRouteCategory(url: string): ExplorerRouteCategory { if (segments.length === 1 && segments[0] === 'protocols') { return 'protocols'; } + // The mining dashboard is a section; a pool page under it addresses one + // pool, which does not exist on another chain, so it stays an object. + if (segments.length === 1 && segments[0] === 'mining') { + return 'mining'; + } + // Every chart child stays inside the graphs section: the switcher decides + // per chart whether the destination chain has an equivalent. + if (segments[0] === 'graphs') { + return 'graphs'; + } + if (segments[0] === 'docs') { + return 'docs'; + } return 'object'; } @@ -51,26 +65,46 @@ export function explorerSectionRoute( if (category === 'mempool') { return '/pulse'; } - if (category === 'protocols') { - return '/protocols'; - } - return '/'; + return category === 'dashboard' ? '/' : `/${category}`; } return category === 'dashboard' ? `/${chain}` : `/${chain}/${category}`; } +/** The chart child path after `graphs/`, or null when the URL is not one. */ +function graphChildFrom(url: string): string | null { + const segments = url.split(/[?#]/, 1)[0].split('/').filter(Boolean); + if (segments[0] === 'dogecoin' || segments[0] === 'zcash') { + segments.shift(); + } + if (segments[0] !== 'graphs' || segments.length < 2) { + return null; + } + return segments.slice(1).join('/'); +} + export function explorerSwitchTarget( currentUrl: string, targetChain: ExplorerChain ): { readonly path: string; readonly droppedObject: boolean } { const category = explorerRouteCategory(currentUrl); - return category === 'object' - ? { - path: explorerSectionRoute(targetChain, 'dashboard'), - droppedObject: true, - } - : { - path: explorerSectionRoute(targetChain, category), - droppedObject: false, - }; + if (category === 'object') { + return { + path: explorerSectionRoute(targetChain, 'dashboard'), + droppedObject: true, + }; + } + if (category === 'graphs') { + const child = graphChildFrom(currentUrl); + const equivalent = child ? equivalentGraphChild(child, targetChain) : null; + return { + path: equivalent + ? `${explorerSectionRoute(targetChain, 'graphs')}/${equivalent}` + : explorerSectionRoute(targetChain, 'graphs'), + droppedObject: false, + }; + } + return { + path: explorerSectionRoute(targetChain, category), + droppedObject: false, + }; } diff --git a/frontend/src/app/universe/universe-chart-registry.ts b/frontend/src/app/universe/universe-chart-registry.ts new file mode 100644 index 0000000000..a3089639b0 --- /dev/null +++ b/frontend/src/app/universe/universe-chart-registry.ts @@ -0,0 +1,94 @@ +import { ExplorerChain } from '@app/universe/universe.types'; + +/** + * The chart child routes each chain offers under its graphs section. + * + * One table, used by the chain switcher to decide whether a chart survives a + * chain switch and by the chain graphs page to build its navigation. A chart + * missing from a chain's list is not hidden by accident: it either has a + * different spelling there (see {@link GRAPH_ROUTE_EQUIVALENTS}) or no honest + * equivalent exists on that chain. + */ +export const CHAIN_GRAPH_CHILDREN: Record = { + bitcoin: [ + 'mempool', + 'mining/hashrate-difficulty', + 'mining/pools-dominance', + 'mining/pools', + 'mining/block-fees', + 'mining/block-fees-subsidy', + 'mining/block-rewards', + 'mining/block-fee-rates', + 'mining/block-sizes-weights', + 'mining/block-health', + 'price', + ], + dogecoin: [ + 'mempool', + 'mining/hashrate-difficulty', + 'mining/pools-dominance', + 'mining/pools', + 'mining/block-fees', + 'mining/block-fees-subsidy', + 'mining/block-rewards', + 'mining/block-fee-rates', + 'mining/block-sizes', + 'mining/block-interval', + ], + zcash: [ + 'mempool', + 'mining/hashrate-difficulty', + 'mining/pools-dominance', + 'mining/pools', + 'mining/block-fees', + 'mining/block-fees-subsidy', + 'mining/block-rewards', + 'mining/block-fee-rates', + 'mining/block-sizes', + 'mining/block-interval', + ], +}; + +/** + * Where a chart keeps its meaning across chains under a different spelling. + * + * Bitcoin plots sizes and weights together because segwit gave it two + * capacity measures; the other chains have one, so their chart is sizes + * alone. Block health grades Bitcoin blocks against this explorer's own + * projection, which the other chains honestly cannot have yet, so their + * nearest reliability reading is the observed against target interval. + */ +const GRAPH_ROUTE_EQUIVALENTS: Record> = { + 'mining/block-sizes-weights': { + dogecoin: 'mining/block-sizes', + zcash: 'mining/block-sizes', + }, + 'mining/block-sizes': { + bitcoin: 'mining/block-sizes-weights', + }, + 'mining/block-health': { + dogecoin: 'mining/block-interval', + zcash: 'mining/block-interval', + }, + 'mining/block-interval': { + bitcoin: 'mining/block-health', + }, +}; + +/** + * The chart child a switch to `targetChain` should land on, or null when the + * target chain has no equivalent and the switch should land on its graphs + * landing page instead. + */ +export function equivalentGraphChild( + child: string, + targetChain: ExplorerChain +): string | null { + if (CHAIN_GRAPH_CHILDREN[targetChain].includes(child)) { + return child; + } + const mapped = GRAPH_ROUTE_EQUIVALENTS[child]?.[targetChain]; + return mapped && CHAIN_GRAPH_CHILDREN[targetChain].includes(mapped) + ? mapped + : null; +} diff --git a/frontend/src/app/universe/universe.types.ts b/frontend/src/app/universe/universe.types.ts index 56e77e6378..cc3403f12a 100644 --- a/frontend/src/app/universe/universe.types.ts +++ b/frontend/src/app/universe/universe.types.ts @@ -365,6 +365,196 @@ export interface ChainCapabilityEnvelope { export type ChainExplorerPayload = Record; +// --- Chain dashboard, mining, fees, and chart views --- +// Mirrors backend-apis src/universe-explorer/contracts/chain-dashboard.ts. +// Quantities are exact decimal strings; unknown facts are null, never zero. + +export type PoolAttributionEvidence = + | 'coinbase-tag' + | 'payout-address' + | 'auxpow-parent-tag'; + +export interface BlockMinerView { + poolId: string | null; + name: string | null; + evidence: PoolAttributionEvidence | null; +} + +export interface ChainBlockSummary { + heightAtomic: string; + hash: string; + time: string; + txCountAtomic: string; + sizeBytesAtomic: string | null; + feesAtomic: string | null; + subsidyAtomic: string | null; + rewardAtomic: string | null; + medianFeeRateDecimal: string | null; + difficultyDecimal: string | null; + intervalSecondsAtomic: string | null; + miner: BlockMinerView; +} + +export interface RecentBlocksView { + schemaVersion: string; + chain: ExplorerChain; + network: ExplorerNetwork; + tip: SourceCheckpoint | null; + blocks: ChainBlockSummary[]; + coverage: { + fromHeightAtomic: string | null; + toHeightAtomic: string | null; + complete: boolean; + }; + observedAt: string; +} + +export type FeeRecommendationBasis = + | 'node-estimate' + | 'mempool-quantile' + | 'recent-blocks' + | 'relay-floor' + | 'zip317-conventional'; + +export interface FeeRecommendationLevel { + id: string; + amountDecimal: string; + basis: FeeRecommendationBasis; +} + +export type FeeRecommendationsView = + | { + schemaVersion: string; + chain: ExplorerChain; + network: ExplorerNetwork; + kind: 'fee-per-kilobyte'; + unit: 'koinu/kB'; + levels: FeeRecommendationLevel[]; + minRelayFeeAtomicPerKb: string; + tip: SourceCheckpoint | null; + observedAt: string; + } + | { + schemaVersion: string; + chain: ExplorerChain; + network: ExplorerNetwork; + kind: 'zip-317'; + unit: 'zatoshi'; + marginalFeeAtomic: string; + graceActionsAtomic: string; + typicalConventionalFeeAtomic: string; + paidShareDecimal: string | null; + basis: FeeRecommendationBasis; + tip: SourceCheckpoint | null; + observedAt: string; + }; + +export type HashrateUnit = 'hashes-per-second' | 'solutions-per-second'; + +export interface MiningSummaryView { + schemaVersion: string; + chain: ExplorerChain; + network: ExplorerNetwork; + tip: SourceCheckpoint | null; + difficultyDecimal: string | null; + networkRateDecimal: string | null; + hashrateUnit: HashrateUnit; + algorithm: string; + targetBlockSecondsAtomic: string; + observedIntervalSecondsDecimal: string | null; + windowBlocksAtomic: string | null; + subsidyAtomic: string | null; + meanRewardAtomic: string | null; + meanFeesAtomic: string | null; + mergedMining: { + supported: boolean; + noticeId: string | null; + }; + observedAt: string; +} + +export interface MiningPoolShare { + poolId: string; + name: string; + blocksAtomic: string; + shareDecimal: string; + evidence: PoolAttributionEvidence[]; +} + +export interface MiningPoolsView { + schemaVersion: string; + chain: ExplorerChain; + network: ExplorerNetwork; + windowId: string; + windowBlocksAtomic: string; + pools: MiningPoolShare[]; + attributionDatasetVersion: string; + coverageComplete: boolean; + observedAt: string; +} + +export interface ChartSeriesLine { + key: string; + unit: string; + points: [string, string | null][]; +} + +export interface ChartSeriesView { + schemaVersion: string; + chain: ExplorerChain; + network: ExplorerNetwork; + seriesId: string; + rangeId: string; + lines: ChartSeriesLine[]; + aggregation: string; + bucketSecondsAtomic: string | null; + coverage: { + fromAtomic: string | null; + toAtomic: string | null; + complete: boolean; + earliestAtomic: string | null; + }; + sourceHeightAtomic: string | null; + observedAt: string; +} + +export interface ChainMempoolSummary { + txCountAtomic: string | null; + totalSizeBytesAtomic: string | null; + totalFeesAtomic: string | null; + arrivalRatePerSecondDecimal: string | null; + observedAt: string | null; +} + +export type ChainSubsystemId = + | 'core-node' + | 'confirmed-history' + | 'address-history' + | 'mempool' + | 'mining-analytics' + | 'historical-statistics' + | 'protocol-indexers'; + +export interface ChainSubsystemHealth { + id: ChainSubsystemId; + state: 'ready' | 'degraded' | 'unavailable'; + reasonIds: string[]; +} + +export interface ChainDashboardView { + schemaVersion: string; + chain: ExplorerChain; + network: ExplorerNetwork; + tip: SourceCheckpoint | null; + recentBlocks: RecentBlocksView | null; + buckets: ChainExplorerPayload | null; + fees: FeeRecommendationsView | null; + mempool: ChainMempoolSummary | null; + mining: MiningSummaryView | null; + subsystems: ChainSubsystemHealth[]; + observedAt: string; +} + export interface UniverseSearchResult { chain: ExplorerChain; network: 'mainnet'; diff --git a/scripts/universe/parity-regressions.test.mjs b/scripts/universe/parity-regressions.test.mjs new file mode 100644 index 0000000000..508e5c7ba7 --- /dev/null +++ b/scripts/universe/parity-regressions.test.mjs @@ -0,0 +1,128 @@ +/** + * Regression gate for the multichain parity release. + * + * Every case here pins a structural fact that once failed in production or + * that a cleanup could plausibly reintroduce: a navigation item gated back + * to Bitcoin, a section dropped from the route registry, a dashboard whose + * data request went back to answering nothing, cubes losing their heights, + * or Bitcoin units wearing another chain's name. These read the source + * rather than a build, so they run in seconds and fail with the file named. + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +function read(path) { + return readFileSync(join(root, path), 'utf8'); +} + +test('Mining and Charts navigation are not gated to Bitcoin', () => { + const nav = read('frontend/src/app/components/master-page/master-page.component.html'); + const miningItem = nav.match(/