From b57fdda0c0a20906104e7ddb532e0601f086c500 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 06:28:43 +0000 Subject: [PATCH 01/45] Design the chain pages instead of printing their API responses The Dogecoin and Zcash routes rendered whatever envelope arrived as a grid of raw API key names, a table whose columns were the first eight keys of the first row, and the whole response as JSON. That renders, but it does not explain: a reader could not tell a koinu from a Dogecoin, an exact integer from a rounded one, or a fact the authority proved from a field it left null. multichain-view.ts is a pure presentation model with the rules in one place. Numbers keep their exact source string and are shifted by string arithmetic, never parsed into a double, because a Dogecoin balance can exceed the range where a double still counts single units. Absent and zero are different answers and the page says which. Shapes the model recognises get a purpose-built reading; shapes it does not are still rendered, as a humanised record that says plainly it is the response as received. The transaction page now reads the envelope the contract actually defines: lifecycle state as a sentence with an evidence tone, transparent value as two aligned columns with exact totals, shielded structure with its privacy notice and no inference about participants, protocol actions split into evaluated and candidate. Block, address and outpoint get their own readings. The chain overview states which questions the chain can answer at all, and separates "not offered" from "not stated" so an unreachable authority is never reported as one that declined. The narrowest thing here matters most: the coin-amount rule is an allowlist, not a pattern. A pattern that matched "supply" printed a DRC-20 supply of 100000000000 as 1,000 DOGE, because a token's units are its own and have nothing to do with koinu. The pages also had no visual coverage at all. The matrix knew thirteen Bitcoin routes and nothing else, so eleven chain routes reached production without one screenshot, contrast probe or unfinished-page check. They are in the route list and in GATED_ROUTES now, with fixtures for a chain at its tip, a chain behind its tip, an unreachable authority, and an object that simply is not there. Chain states are scoped to chain routes so the matrix does not pay for combinations that measure nothing. Fixed on the way: the scrollable tables took a mouse but no keyboard, which axe reports as a serious violation on three routes. Verified: 60 new unit tests, 252 frontend tests, lint, production build, and the visual matrix over 11 chain routes in both themes at 375 and 1440 with zero overflow, zero contrast failures, zero axe violations and zero unfinished pages. Co-Authored-By: Claude Opus 5 --- .../multichain-explorer.component.html | 603 ++++++-- .../multichain-explorer.component.scss | 666 ++++++++- .../multichain-explorer.component.ts | 273 ++-- .../multichain-view.spec.ts | 589 ++++++++ .../multichain-explorer/multichain-view.ts | 1235 +++++++++++++++++ scripts/universe/visual-qa/capture.mjs | 42 +- scripts/universe/visual-qa/chain-fixtures.mjs | 340 +++++ 7 files changed, 3480 insertions(+), 268 deletions(-) create mode 100644 frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts create mode 100644 frontend/src/app/universe/multichain-explorer/multichain-view.ts create mode 100644 scripts/universe/visual-qa/chain-fixtures.mjs diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html index 31dbf32d645..c29431b1133 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html @@ -1,145 +1,512 @@ -
-
-
-

{{ chainName }} mainnet / {{ ticker }}

-

{{ chainName }} {{ pageLabel() }}

-

{{ reference }}

+
+ + +
+ +

{{ chainName }} {{ pageLabel() }}

+ {{ reference }} + +
+
-
-

- Switched from {{ switchedFrom }}. Object identifiers were not carried - between chains. +

{{ pageLede() }}

+ +

+ You came from {{ switchedFrom }}. Object identifiers do not carry between chains, so this is the {{ chainName }} overview rather than the same object on {{ chainName }}.

-
- - {{ - stateLabel(capability) - }} - Tip {{ capability.tip?.heightAtomic || "not available" }} - Updated {{ freshness(capability) }} - Mempool {{ capability.mempool.state }}, - {{ capability.mempool.completeness }} - {{ - capability.mempool.snapshotId - ? "snapshot " + capability.mempool.snapshotId - : "no snapshot id" - }} - - status unavailable + +
+
+ {{ reading.label }} + {{ reading.value }} +
- - -
-

What is happening now

-
-
-
Chain tip
-
{{ capability.tip?.heightAtomic || "not available" }}
+ + + + + + + +
+

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.stateLabel }} + + {{ protocol.historyLabel }} + + + {{ protocol.lag.display }} blocks behind + + + + {{ protocol.reasons.join(', ') }} + +
  • +
+
+ +
+

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. +

+
+ + + +
+ + + + + + +
+

Status

+

+ {{ tx.statusLabel }} +

+
+
+
In block
+
+ {{ short(tx.block.hash) }} + {{ tx.block.height.display }} +
+
+
+
Confirmations
+
{{ tx.confirmations.display }}
+
+
+
First seen by our node
+
{{ tx.firstSeenAt }}
+
+
+
Size
+
{{ tx.size.display }} bytes
+
+
+
Virtual size
+
{{ tx.virtualSize.display }} vbytes
+
+
+
Fee paid
+
{{ tx.feeAmount.display }} {{ ticker }}
+
+
+
Fee rate
+
{{ tx.feeRate }} {{ tx.feeRateUnit }}
+
+
+
ZIP-317 logical actions
+
{{ tx.logicalActions.display }}
+
+
+
Evidence
+
{{ tx.completenessLabel }}
+
+
+
+ +
+

Why this transaction may never confirm

+

+ Replaced by + {{ short(tx.replacedByTxid) }} +

+ +

+ Expires at block + {{ tx.expiry.height.display }} + {{ tx.expiry.state }} +

+
+ +
+

Transparent value

+
+
+

+ Inputs + {{ tx.inputTotal.display }} {{ ticker }} +

+
    +
  1. + + {{ short(party.address, 12) }} + + Newly issued by the block + No standard address + + + {{ party.amount.display }} + not reported +
  2. +
+

+ No input total is shown because at least one input arrived without an amount. A partial sum would read as a whole one. +

+
+ + + +
+

+ Outputs + {{ tx.outputTotal.display }} {{ ticker }} +

+
    +
  1. + + {{ short(party.address, 12) }} + No standard address + Spent + + {{ party.amount.display }} + not reported +
  2. +
+
-
-
Sync
-
{{ capability.sync.state }}
+
+ +
+

Shielded structure

+

{{ shielded.notice }}

+
+
+
{{ component.label }}
+
+ {{ component.count.display }} + Not reported +
+
+
+

+ Net movement between the transparent and shielded pools: {{ shielded.valueBalance.display }} {{ ticker }}. This is the only shielded amount the chain itself makes public. +

+
+ +
+

Protocol actions

+ + +

Evaluated

+
    +
  • + {{ action.protocolLabel }} + {{ action.actionType }} + {{ action.stateLabel }} +
  • +
+
+ + +

Candidates

+

+ These look like protocol actions in the raw transaction. Whether the protocol accepts them is decided when the transaction confirms, so nothing here is a result yet. +

+
    +
  • + {{ action.protocolLabel }} + {{ action.actionType }} + {{ action.stateLabel }} +
  • +
+
+
+ +
+ + + + + + +
+

Block

+
+
+
Height
+
{{ blk.height.display }}
+
+
+
Hash
+
{{ blk.hash }}
+
+
+
Mined
+
{{ blk.time }}
+
+
+
Transactions
+
{{ blk.transactionCount.display }}
+
+
+
Size
+
{{ blk.sizeBytes.display }} bytes
+
+
+
Confirmations
+
{{ blk.confirmations.display }}
+
+
+
Difficulty
+
{{ blk.difficulty }}
+
+
+
Merkle root
+
{{ blk.merkleRoot }}
+
+
+ + +
+ +
+

Transactions in this block

+

+ Page {{ blk.page.display }} of {{ blk.totalPages.display }}. +

+ +
+ +
+ + + + + + +
+

Balance

+
+
+ Confirmed + {{ addr.balance.display }} {{ ticker }} +
+
+ Pending + {{ addr.unconfirmedBalance.display }} {{ ticker }} +
+
+ Received in total + {{ addr.totalReceived.display }} {{ ticker }} +
+
+ Sent in total + {{ addr.totalSent.display }} {{ ticker }} +
+
+ Transactions + {{ addr.transactionCount.display }} +
-
-
Pending coverage
-
{{ capability.mempool.completeness }}
+
+ +
+

Unspent outputs

+
+ + + + + + + + + + + + + + + +
OutpointAmountBlock
+ {{ short(utxo.txid) }}:{{ utxo.vout }} + + {{ utxo.amount.display }} + not reported + + {{ utxo.height.display }} + not in a block yet +
+
+ +
+

Transaction history

+ +
+ +
+ + + + +
+

Output

+
-
Confirmed history
-
{{ capability.coverage.confirmedHistory }}
+
Transaction
+
{{ out.txid }}
-
Address history
-
{{ capability.coverage.addressHistory }}
+
Output index
+
{{ out.vout }}
-
-
Release
-
{{ capability.release.sha }}
+
+
Amount
+
{{ out.amount.display }} {{ ticker }}
+
+
+
Address
+
{{ out.address }}
+
+
+
State
+
+ + Spent + Unspent + +
-

- Fee estimates are shown only when the first-party source supports them. - Dogecoin pages do not label fees as sat/vB. -

-

- Zcash fee guidance uses ZIP-317 logical actions when available. - Transparent-chain facts do not reveal shielded participants or shielded - amounts. -

-

- Projected blocks are not claimed for this chain. -

- Open mempool - Open protocols -
- - - -
-

Zcash privacy boundary

-

- This view can report transparent-chain evidence only. Shielded sender, - recipient, and amount details are not inferred. -

-
-

Verified summary

+ + + +
+

What the authority reported

+ +

Other reported fields

+
-
-
{{ item.key }}
-
{{ item.value }}
+
+
{{ fact.label }}
+
+ {{ fact.display }} + {{ fact.display }} + {{ fact.unit }} +
-
-

Results

-
+
+

{{ collection.title }}

+

+ Showing the first {{ collection.shownCount }} of {{ collection.totalCount }} rows the authority returned. +

+
+ + + + + - - + @@ -147,8 +514,18 @@

Results

+

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

+ +
- Full first-party response + The response this page was built from +

+ Exactly what our own {{ chainName }} authority returned, unedited. Every figure above is a rendering of a value in here. +

{{ vm.payload | json }}
+ 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 44f73faa9ab..0b5246db9b4 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss @@ -1,130 +1,666 @@ +// Dogecoin and Zcash explorer pages. +// +// These share their skeleton with the single-asset pages (an identity block, a +// status block, evidence), so they take the same mixins rather than a second +// set of literals. Everything below is either a token or derived from one; the +// colour gate refuses raw interface colour here. +// +// Two things are specific to a chain page and live only here: the status rail, +// which is the trust anchor a reader learns to look at first, and the value +// flow, which is the one place a transaction's shape is worth drawing. + +@use '../universe-tokens' as u; + :host { + @include u.universe-protocol-tokens; + @include u.universe-state-tokens; + display: block; } -.multichain-page { - padding-top: 2rem; - padding-bottom: 4rem; - color: var(--fg); +.chain-page { + padding: 1.25rem 0 3rem; + color: var(--u-text-primary); +} + +// --------------------------------------------------------------------------- +// Head +// --------------------------------------------------------------------------- +// +// The heading itself is the product's shared `.title-block`: the category as a +// small label, the identifier carrying the weight, and the page action pushed +// 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); + } } -.page-head { +.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); + border: 1px solid var(--u-border-strong); + border-radius: var(--u-radius-md); + color: var(--u-text-primary); + font-size: var(--u-text-sm); + font-weight: var(--u-weight-medium); + padding: 0.4rem 0.8rem; + + @include u.universe-motion(all, var(--u-duration-fast)); + + &:hover { + background: var(--u-surface-hover); + border-color: var(--u-brand-border); + } + + &.is-saved { + background: var(--u-brand-subtle); + border-color: var(--u-brand-border); + color: var(--u-brand); + } +} + +// --------------------------------------------------------------------------- +// 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; - justify-content: space-between; - gap: 1rem; - align-items: flex-start; + flex-direction: column; + gap: 0.3rem; + min-width: 0; } -.eyebrow { - color: var(--transparent-fg); - font-size: 0.8rem; - font-weight: 700; - letter-spacing: 0.08em; +.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; } -.lede { +.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); +} + +.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); +} + +// --------------------------------------------------------------------------- +// Chain overview +// --------------------------------------------------------------------------- + +.read-grid, +.coverage-list, +.action-list, +.conflict-list, +.identifier-list { + list-style: none; + margin: 0; + padding: 0; +} + +.read-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: var(--u-space-2) var(--u-space-4); + + li { + display: flex; + align-items: center; + gap: var(--u-space-2); + padding: var(--u-space-2) 0; + border-top: 1px solid var(--u-divider); + } + + .read-label { + flex: 1 1 auto; + min-width: 0; + font-size: var(--u-text-sm); + } + + li.off .read-label { + color: var(--u-text-muted); + } +} + +.coverage-list li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--u-space-2) var(--u-space-3); + padding: var(--u-space-3) 0; + border-top: 1px solid var(--u-divider); + + &:first-child { + border-top: 0; + padding-top: 0; + } +} + +.coverage-name { + font-weight: var(--u-weight-semibold); + min-width: 7rem; +} + +.coverage-detail { + color: var(--u-text-muted); + font-size: var(--u-text-sm); +} + +.coverage-reasons { + flex-basis: 100%; + color: var(--universe-state-partial); + font-size: var(--u-text-sm); +} + +.entry-actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: var(--u-space-3); + margin-bottom: var(--u-space-3); +} + +.entry { + @include u.universe-surface; + + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: var(--u-space-4); + color: var(--u-text-primary); + text-decoration: none; + + @include u.universe-motion(all, var(--u-duration-fast)); + + &:hover, + &:focus-visible { + border-color: var(--u-brand-accent); + background: var(--u-surface-hover); + text-decoration: none; + } + + .entry-title { + font-weight: var(--u-weight-semibold); + color: var(--u-brand); + } + + .entry-detail { + color: var(--u-text-muted); + font-size: var(--u-text-sm); + line-height: var(--u-leading-snug); + } +} + +// --------------------------------------------------------------------------- +// Transaction value flow +// --------------------------------------------------------------------------- + +.flow-grid { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: var(--u-space-4); + align-items: start; +} + +.flow-side { + min-width: 0; + + h3 { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--u-space-3); + margin: 0 0 var(--u-space-2); + padding-bottom: var(--u-space-2); + border-bottom: 1px solid var(--u-divider); + } +} + +.flow-total { + font-family: var(--u-font-data); + font-variant-numeric: tabular-nums; + font-weight: var(--u-weight-semibold); + color: var(--u-text-primary); +} + +// The direction of the transaction, drawn once. It carries no information the +// column headings do not, so it is hidden from assistive technology and simply +// disappears when the columns stack. +.flow-arrow { + align-self: center; + width: 1.5rem; + height: 1px; + background: var(--u-border-strong); + position: relative; + + &::after { + content: ''; + position: absolute; + right: 0; + top: -3px; + border-left: 6px solid var(--u-border-strong); + border-top: 3.5px solid transparent; + border-bottom: 3.5px solid transparent; + } +} + +.party-list { + list-style: none; + margin: 0; + padding: 0; + + li { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + align-items: baseline; + gap: var(--u-space-3); + padding: var(--u-space-2) 0; + border-bottom: 1px solid var(--u-divider); + } + + li:last-child { + border-bottom: 0; + } + +} + +.party-address { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--u-space-2); + min-width: 0; overflow-wrap: anywhere; } -.save, -.protocol-nav a { - border: 1px solid var(--primary); - border-radius: 0.45rem; - color: var(--fg); - padding: 0.55rem 0.75rem; - background: transparent; +.party-amount { + flex: none; + font-family: var(--u-font-data); + font-variant-numeric: tabular-nums; } -.status-panel, -.panel, -.switch-notice { - margin-top: 1rem; - padding: 1rem; - border: 1px solid var(--box-border); - border-radius: 0.6rem; - background: var(--box-bg); +.flow-note { + color: var(--u-text-muted); + font-size: var(--u-text-sm); + line-height: var(--u-leading-normal); + max-width: var(--u-width-prose); + margin: var(--u-space-3) 0 0; } -.status-panel { +.action-list li, +.conflict-list li { display: flex; flex-wrap: wrap; - gap: 0.6rem 1.2rem; + align-items: baseline; + gap: var(--u-space-2) var(--u-space-3); + padding: var(--u-space-2) 0; + border-top: 1px solid var(--u-divider); } -.state { - color: var(--u-state-proven); - font-weight: 700; +.action-protocol { + font-weight: var(--u-weight-semibold); + min-width: 6rem; } -.state.degraded, -.error { - color: var(--u-state-partial); +.action-type { + color: var(--u-text-secondary); + font-size: var(--u-text-sm); } -.facts { +.panel.warning { + border-left: 3px solid var(--universe-state-partial); +} + +// --------------------------------------------------------------------------- +// Figures +// --------------------------------------------------------------------------- + +.figure-row { display: grid; - grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); - gap: 0.75rem; + grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); + gap: var(--u-space-4); } -.facts div { +.figure { + display: flex; + flex-direction: column; + gap: 0.2rem; min-width: 0; } -.facts dt { - color: var(--transparent-fg); +.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; } -.facts dd { - margin: 0.15rem 0 0; +.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; } -.panel a + a { - margin-left: 1rem; +.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 { + color: var(--u-text-faint); + font-weight: var(--u-weight-normal); +} + +// --------------------------------------------------------------------------- +// Lists and tables +// --------------------------------------------------------------------------- + +.identifier-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr)); + gap: 0 var(--u-space-4); + + li { + padding: 0.3rem 0; + border-bottom: 1px solid var(--u-divider); + min-width: 0; + } +} + +.block-nav { + display: flex; + flex-wrap: wrap; + gap: var(--u-space-4); + margin-top: var(--u-space-4); + padding-top: var(--u-space-3); + border-top: 1px solid var(--u-divider); + font-size: var(--u-text-sm); } .protocol-nav { display: flex; flex-wrap: wrap; - gap: 0.6rem; - margin-top: 1rem; + gap: var(--u-space-2); + margin-bottom: var(--u-space-3); + + a { + border: 1px solid var(--u-border); + border-radius: var(--u-radius-pill); + background: var(--u-surface-raised); + color: var(--u-text-secondary); + font-size: var(--u-text-sm); + padding: 0.3rem 0.8rem; + text-decoration: none; + + @include u.universe-motion(all, var(--u-duration-fast)); + + &:hover { + border-color: var(--u-brand-border); + color: var(--u-brand); + } + + &.current { + background: var(--u-brand-subtle); + border-color: var(--u-brand-border); + color: var(--u-brand); + font-weight: var(--u-weight-semibold); + } + } } +// 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 { - min-width: 100%; + width: 100%; + border-collapse: collapse; + font-size: var(--u-text-sm); } -th, -td { - padding: 0.55rem; +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; } -pre { - max-height: 36rem; - overflow: auto; - white-space: pre-wrap; - overflow-wrap: anywhere; +tbody tr:last-child td { + border-bottom: 0; +} + +// --------------------------------------------------------------------------- +// Evidence +// --------------------------------------------------------------------------- + +.evidence { + summary { + cursor: pointer; + font-weight: var(--u-weight-semibold); + color: var(--u-text-secondary); + } + + &[open] summary { + margin-bottom: var(--u-space-3); + } + + pre { + @include u.universe-identifier; + + background: var(--u-surface-sunken); + border: 1px solid var(--u-border); + border-radius: var(--u-radius-sm); + color: var(--u-text-secondary); + margin: 0; + max-height: 32rem; + overflow: auto; + padding: var(--u-space-3); + white-space: pre-wrap; + } +} + +// --------------------------------------------------------------------------- +// Narrow +// --------------------------------------------------------------------------- + +@media (max-width: 767px) { + .flow-grid { + grid-template-columns: 1fr; + gap: var(--u-space-5); + } + + .flow-arrow { + display: none; + } } -@media (max-width: 32rem) { - .multichain-page { - padding-top: 1rem; +@media (max-width: 575px) { + .chain-page { + padding-top: var(--u-space-4); } - .page-head { - flex-direction: column; + .facts { + grid-template-columns: 1fr; } - .save { - width: 100%; + .coverage-name { + min-width: 0; + flex-basis: 100%; } } 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 81d7968856a..001e11f3871 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts @@ -23,6 +23,32 @@ import { ChainExplorerPayload, ExplorerChain, } from '@app/universe/universe.types'; +import { + AddressReading, + BlockReading, + ChainProfile, + ChainShape, + CollectionReading, + Fact, + OutpointReading, + ProtocolReading, + ReadCapability, + StatusReading, + TransactionReading, + chainProfile, + classifyPayload, + readAddress, + readBlock, + readCapabilities, + readCollection, + readIdentifierList, + readOutpoint, + readProtocolCoverage, + readRecordFacts, + readStatusRail, + readTransaction, + shortenIdentifier, +} from '@app/universe/multichain-explorer/multichain-view'; import { Observable, Subject, @@ -55,8 +81,18 @@ interface ExplorerViewModel { readonly capabilityError: string | null; readonly payload: ChainExplorerPayload | null; readonly payloadError: string | null; - readonly rows: readonly Record[]; - readonly summary: readonly { key: string; value: string }[]; + readonly rail: readonly StatusReading[]; + readonly reads: readonly ReadCapability[]; + readonly protocols: readonly ProtocolReading[]; + readonly shape: ChainShape; + readonly transaction: TransactionReading | null; + readonly block: BlockReading | null; + readonly address: AddressReading | null; + readonly outpoint: OutpointReading | null; + readonly collection: CollectionReading | null; + readonly facts: readonly Fact[]; + /** True when the page is showing a response it has no purpose-built reading for. */ + readonly generic: boolean; } interface RequestContext { @@ -73,6 +109,29 @@ const PAGE_KINDS: Partial> = { 'protocol-detail': 'protocol', }; +/** + * Fields a purpose-built reading already presents. They are skipped when the + * page lists the remaining scalars, so a value never appears twice: once in + * its designed place and again in the leftover record. + */ +const PRESENTED_FIELDS: Partial> = { + transaction: [ + 'schemaVersion', 'chain', 'network', 'txid', 'status', 'block', 'fee', + 'transparent', 'shielded', 'protocolActions', 'replacement', 'expiry', + 'confirmationsAtomic', 'sizeBytesAtomic', 'virtualSizeBytesAtomic', + 'firstSeenAt', 'completeness', + ], + block: ['chain', 'network', 'block', 'pagination'], + address: [ + 'chain', 'network', 'address', 'balanceAtomic', 'totalReceivedAtomic', + 'totalSentAtomic', 'unconfirmedBalanceAtomic', 'transactionCountAtomic', + 'unconfirmedTransactionsAtomic', + ], + outpoint: ['chain', 'network', 'outpoint', 'output', 'transaction'], + collection: ['chain', 'network'], + record: ['chain', 'network'], +}; + @Component({ selector: 'app-multichain-explorer', standalone: true, @@ -85,6 +144,7 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { private readonly destroyed$ = new Subject(); readonly chain: Exclude; + readonly profile: ChainProfile; readonly chainName: string; readonly ticker: string; readonly protocolIds: readonly string[]; @@ -103,12 +163,10 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { private readonly seo: SeoService ) { this.chain = this.chainFromUrl(router.url); - this.chainName = this.chain === 'dogecoin' ? 'Dogecoin' : 'Zcash'; - this.ticker = this.chain === 'dogecoin' ? 'DOGE' : 'ZEC'; - this.protocolIds = - this.chain === 'dogecoin' - ? ['doginals', 'drc20', 'doge-tap'] - : ['zerdinals', 'zrunes', 'zrc20']; + this.profile = chainProfile(this.chain); + this.chainName = this.profile.name; + this.ticker = this.profile.ticker; + this.protocolIds = this.profile.protocols.map((tab) => tab.id); } ngOnInit(): void { @@ -151,14 +209,9 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { ), ]) ), - map(([status, result]): ExplorerViewModel => ({ - capability: status.capability, - capabilityError: status.error, - payload: result.payload, - payloadError: result.error, - rows: this.rowsFrom(result.payload), - summary: this.summaryFrom(result.payload), - })) + map(([status, result]): ExplorerViewModel => + this.viewModel(status.capability, status.error, result.payload, result.error) + ) ); }), takeUntil(this.destroyed$) @@ -170,6 +223,44 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { this.destroyed$.complete(); } + private viewModel( + capability: ChainCapabilityEnvelope | null, + capabilityError: string | null, + payload: ChainExplorerPayload | null, + payloadError: string | null + ): ExplorerViewModel { + const shape = classifyPayload(payload); + const transaction = payload && shape === 'transaction' ? readTransaction(payload, this.profile) : null; + const block = payload && shape === 'block' ? readBlock(payload) : null; + const address = payload && shape === 'address' ? readAddress(payload, this.profile) : null; + const outpoint = payload && shape === 'outpoint' ? readOutpoint(payload, this.profile) : null; + const collection = + payload && (shape === 'collection' || shape === 'record') + ? readCollection(payload, this.profile) + : null; + const skip = [ + ...(PRESENTED_FIELDS[shape] ?? []), + ...(collection ? [collection.sourceKey] : []), + ]; + return { + capability, + capabilityError, + payload, + payloadError, + rail: readStatusRail(capability, this.profile, Date.now()), + reads: readCapabilities(capability), + protocols: readProtocolCoverage(capability, this.profile), + shape, + transaction, + block, + address, + outpoint, + collection, + facts: readRecordFacts(payload, this.profile, skip), + generic: shape === 'collection' || shape === 'record', + }; + } + toggleSaved(): void { const kind = PAGE_KINDS[this.page]; if (!kind || !this.reference) { @@ -185,44 +276,90 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { }); } - pageLabel(): string { - return this.page.replace('protocol-', '').replace('-', ' '); + /** True when this page addresses a single object a visitor can save. */ + savable(): boolean { + return !!PAGE_KINDS[this.page] && !!this.reference; } - stateLabel(capability: ChainCapabilityEnvelope): string { - return capability.ready ? 'ready' : 'degraded'; + 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': + return $localize`:@@universe.chain.page-block:block`; + case 'transaction': + return $localize`:@@universe.chain.page-transaction:transaction`; + case 'address': + return $localize`:@@universe.chain.page-address:address`; + case 'outpoint': + return $localize`:@@universe.chain.page-outpoint:outpoint`; + case 'protocols': + return $localize`:@@universe.chain.page-protocols:protocols`; + case 'protocol-list': + return $localize`:@@universe.chain.page-protocol-list:protocol assets`; + case 'protocol-detail': + return $localize`:@@universe.chain.page-protocol-detail:protocol asset`; + case 'protocol-holders': + return $localize`:@@universe.chain.page-protocol-holders:holders`; + case 'protocol-events': + return $localize`:@@universe.chain.page-protocol-events:events`; + default: + return ''; + } } - freshness(capability: ChainCapabilityEnvelope): string { - const observed = Date.parse(capability.updatedAt); - if (!Number.isFinite(observed)) { - return 'freshness unknown'; - } - const seconds = Math.max(0, Math.floor((Date.now() - observed) / 1000)); - if (seconds < 60) { - return `${seconds}s ago`; + /** 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': + return $localize`:@@universe.chain.lede-protocols:The asset protocols this explorer indexes on ${this.chainName}:CHAIN:, and the state of each indexer.`; + default: + return ''; } - return `${Math.floor(seconds / 60)}m ago`; } - value(value: unknown): string { - if (value === null || value === undefined) { - return 'not available'; - } - if (typeof value === 'object') { - return JSON.stringify(value); - } - return String(value); + isProtocolPage(): boolean { + return this.page.startsWith('protocol'); + } + + activeProtocolId(): string { + return this.route.snapshot.paramMap.get('protocol') ?? ''; } - keys(row: Record): string[] { - return Object.keys(row).slice(0, 8); + short(value: string, lead = 8): string { + return shortenIdentifier(value, lead); } - trackRow(index: number): number { + identifierList(values: unknown): readonly string[] { + return readIdentifierList(values); + } + + trackByIndex(index: number): number { return index; } + trackById(_index: number, item: { id: string }): string { + return item.id; + } + + trackByKey(_index: number, item: { key: string }): string { + return item.key; + } + + trackByProtocol(_index: number, item: { protocolId: string }): string { + return item.protocolId; + } + + trackByText(_index: number, value: string): string { + return value; + } + private chainFromUrl(url: string): Exclude { return url.split(/[?#]/, 1)[0].split('/').filter(Boolean)[0] === 'dogecoin' ? 'dogecoin' @@ -336,56 +473,20 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { return `${this.chainName} ${context.page.replace('-', ' ')}${suffix ? ` ${suffix}` : ''}`; } - private rowsFrom( - payload: ChainExplorerPayload | null - ): readonly Record[] { - if (!payload) { - return []; - } - for (const key of [ - 'transactions', - 'items', - 'data', - 'utxos', - 'holders', - 'events', - 'outputs', - 'inputs', - ]) { - const candidate = payload[key]; - if (Array.isArray(candidate)) { - return candidate - .slice(0, 100) - .map((item) => - typeof item === 'object' && item !== null - ? (item as Record) - : { value: item } - ); - } - } - return []; - } - - private summaryFrom( - payload: ChainExplorerPayload | null - ): readonly { key: string; value: string }[] { - if (!payload) { - return []; - } - return Object.entries(payload) - .filter( - ([, value]) => - !Array.isArray(value) && (typeof value !== 'object' || value === null) - ) - .slice(0, 24) - .map(([key, value]) => ({ key, value: this.value(value) })); - } - private errorMessage(error: unknown): string { const status = typeof error === 'object' && error !== null && 'status' in error ? String((error as { status: unknown }).status) : ''; - return status ? `Request failed (${status}).` : 'Request failed.'; + if (status === '404') { + return $localize`:@@universe.chain.error-not-found:The ${this.chainName}:CHAIN: authority has no record of this object. It may not exist, or it may be outside the range this indexer covers.`; + } + if (status === '503') { + return $localize`:@@universe.chain.error-unavailable:The ${this.chainName}:CHAIN: authority is not answering right now. Nothing here is stale data presented as current: the page shows no facts rather than old ones.`; + } + if (status === '400') { + return $localize`:@@universe.chain.error-malformed:That identifier is not a valid ${this.chainName}:CHAIN: reference, so no lookup was made.`; + } + return $localize`:@@universe.chain.error-generic:The request to the ${this.chainName}:CHAIN: authority did not complete.`; } } diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts new file mode 100644 index 00000000000..6303cf61fd3 --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts @@ -0,0 +1,589 @@ +import { describe, expect, it } from 'vitest'; +import { + ChainCapabilityEnvelope, + ChainExplorerPayload, +} from '@app/universe/universe.types'; +import { + chainProfile, + classifyPayload, + formatAtomicAmount, + formatElapsed, + formatExactInteger, + historyLabel, + humanizeFieldName, + readAddress, + readBlock, + readCapabilities, + readCollection, + readOutpoint, + readProtocolCoverage, + readRecordFacts, + readStatusRail, + readTransaction, + shortenIdentifier, +} from '@app/universe/multichain-explorer/multichain-view'; + +const DOGE = chainProfile('dogecoin'); +const ZEC = chainProfile('zcash'); +const TXID = 'a'.repeat(64); +const BLOCK_HASH = 'b'.repeat(64); + +function capability( + overrides: Partial = {} +): ChainCapabilityEnvelope { + return { + schemaVersion: 'universe-chain-capability-v1', + chain: 'dogecoin', + network: 'mainnet', + asset: { symbol: 'DOGE', name: 'Dogecoin', precision: 8, atomicUnit: 'koinu' }, + ready: true, + tip: { heightAtomic: '5123456', blockHash: BLOCK_HASH, observedAt: '2026-08-29T05:00:00.000Z' }, + sync: { state: 'ready', initialBlockDownload: false, progressDecimal: '1', updatedAt: '2026-08-29T05:00:00.000Z' }, + mempool: { supported: true, state: 'ready', completeness: 'complete', snapshotId: 's1', sequenceAtomic: '9', observedAt: '2026-08-29T05:00:00.000Z' }, + reads: { transaction: true, block: true, address: true, outpoint: true, feeEstimates: false, projectedBlocks: false }, + protocols: [], + coverage: { confirmedHistory: 'complete', addressHistory: 'complete', protocolHistory: 'partial' }, + updatedAt: '2026-08-29T05:00:00.000Z', + lagBlocksAtomic: '0', + degradedReasons: [], + release: { sha: 'abc1234' }, + ...overrides, + } as ChainCapabilityEnvelope; +} + +describe('formatExactInteger', () => { + it('groups digits and keeps the source string', () => { + expect(formatExactInteger('5123456')).toEqual({ display: '5,123,456', exact: '5123456' }); + }); + + it('keeps zero as zero rather than treating it as absent', () => { + expect(formatExactInteger('0')).toEqual({ display: '0', exact: '0' }); + }); + + it('groups a negative value on the digits only', () => { + expect(formatExactInteger('-1234')?.display).toBe('-1,234'); + }); + + it('rejects anything that is not an exact integer string', () => { + expect(formatExactInteger('1.5')).toBeNull(); + expect(formatExactInteger('007')).toBeNull(); + expect(formatExactInteger('')).toBeNull(); + expect(formatExactInteger(null)).toBeNull(); + expect(formatExactInteger(undefined)).toBeNull(); + }); +}); + +describe('formatAtomicAmount', () => { + it('shifts an atomic amount by the chain precision', () => { + expect(formatAtomicAmount('100000000', 8)?.display).toBe('1'); + }); + + it('drops trailing zeros but keeps significant ones', () => { + expect(formatAtomicAmount('123450000', 8)?.display).toBe('1.2345'); + expect(formatAtomicAmount('100000001', 8)?.display).toBe('1.00000001'); + }); + + it('pads an amount smaller than one whole unit', () => { + expect(formatAtomicAmount('1', 8)?.display).toBe('0.00000001'); + expect(formatAtomicAmount('0', 8)?.display).toBe('0'); + }); + + it('groups the whole part of a large balance', () => { + expect(formatAtomicAmount('123456789012345678', 8)?.display).toBe('1,234,567,890.12345678'); + }); + + it('stays exact past the range a double counts single units in', () => { + // 2^53 koinu plus one. Parsing this as a number would lose the last digit. + const amount = formatAtomicAmount('9007199254740993', 8); + expect(amount?.display).toBe('90,071,992.54740993'); + expect(amount?.exact).toBe('9007199254740993'); + }); + + it('keeps the sign on a negative value balance', () => { + expect(formatAtomicAmount('-50000000', 8)?.display).toBe('-0.5'); + }); +}); + +describe('formatElapsed', () => { + const now = Date.parse('2026-08-29T06:00:00.000Z'); + + it('reports coarse units', () => { + expect(formatElapsed('2026-08-29T05:59:30.000Z', now)).toContain('30'); + expect(formatElapsed('2026-08-29T05:30:00.000Z', now)).toContain('30'); + expect(formatElapsed('2026-08-29T01:00:00.000Z', now)).toContain('5'); + }); + + it('says one minute rather than 1 minutes', () => { + expect(formatElapsed('2026-08-29T05:59:00.000Z', now)).toBe('1 minute ago'); + expect(formatElapsed('2026-08-29T05:59:59.000Z', now)).toBe('1 second ago'); + expect(formatElapsed('2026-08-29T05:00:00.000Z', now)).toBe('1 hour ago'); + expect(formatElapsed('2026-08-26T06:00:00.000Z', now)).toBe('3 days ago'); + }); + + it('refuses to invent a freshness it cannot compute', () => { + expect(formatElapsed('not a date', now)).toBeNull(); + expect(formatElapsed(null, now)).toBeNull(); + // A timestamp from the future is a clock problem, not a fresh reading. + expect(formatElapsed('2026-08-29T07:00:00.000Z', now)).toBeNull(); + }); +}); + +describe('historyLabel', () => { + it('reads as a phrase rather than a bare adjective', () => { + expect(historyLabel('complete')).toBe('Complete history'); + expect(historyLabel('partial')).toBe('Partial history'); + expect(historyLabel(undefined)).toBe('History not stated'); + }); +}); + +describe('humanizeFieldName', () => { + it('drops the exact-transport suffix the contract uses', () => { + expect(humanizeFieldName('heightAtomic')).toBe('Height'); + expect(humanizeFieldName('progressDecimal')).toBe('Progress'); + }); + + it('splits camel case into a sentence', () => { + expect(humanizeFieldName('totalReceivedAtomic')).toBe('Total received'); + expect(humanizeFieldName('unconfirmedBalanceAtomic')).toBe('Unconfirmed balance'); + }); + + it('keeps the names a reader already knows', () => { + expect(humanizeFieldName('txid')).toBe('Transaction id'); + expect(humanizeFieldName('utxos')).toBe('Unspent outputs'); + expect(humanizeFieldName('drc20')).toBe('DRC-20'); + }); + + it('returns the key unchanged when there is nothing to humanise', () => { + expect(humanizeFieldName('')).toBe(''); + }); +}); + +describe('shortenIdentifier', () => { + it('keeps both ends so the value stays checkable', () => { + const short = shortenIdentifier(TXID); + expect(short.startsWith('aaaaaaaa')).toBe(true); + expect(short.endsWith('aaaaaa')).toBe(true); + }); + + it('leaves a short value alone', () => { + expect(shortenIdentifier('abc')).toBe('abc'); + }); +}); + +describe('readStatusRail', () => { + it('always returns the same five readings, in order', () => { + const ids = readStatusRail(capability(), DOGE, Date.now()).map((r) => r.id); + expect(ids).toEqual(['state', 'tip', 'lag', 'freshness', 'mempool']); + }); + + it('keeps all five readings when there is no status at all', () => { + const rail = readStatusRail(null, DOGE, Date.now()); + expect(rail).toHaveLength(5); + expect(rail[0].tone).toBe('unavailable'); + }); + + it('calls a chain at the tip proven and a lagging one partial', () => { + const now = Date.parse('2026-08-29T05:00:10.000Z'); + const atTip = readStatusRail(capability(), DOGE, now).find((r) => r.id === 'lag'); + expect(atTip?.tone).toBe('proven'); + + const behind = readStatusRail( + capability({ lagBlocksAtomic: '42' }), + DOGE, + now + ).find((r) => r.id === 'lag'); + expect(behind?.tone).toBe('partial'); + expect(behind?.exact).toBe('42'); + }); + + it('says a chain without a pending set is not offering one, not that it failed', () => { + const reading = readStatusRail( + capability({ mempool: { supported: false, state: 'unavailable', completeness: 'unavailable', snapshotId: null, sequenceAtomic: null, observedAt: null } }), + DOGE, + Date.now() + ).find((r) => r.id === 'mempool'); + expect(reading?.tone).toBe('neutral'); + }); +}); + +describe('readCapabilities', () => { + it('lists every read, marking the ones the chain does not offer', () => { + const reads = readCapabilities(capability()); + expect(reads.map((r) => r.id)).toEqual([ + 'transaction', 'block', 'address', 'outpoint', 'feeEstimates', 'projectedBlocks', + ]); + expect(reads.find((r) => r.id === 'projectedBlocks')?.state).toBe('not-offered'); + expect(reads.find((r) => r.id === 'transaction')?.state).toBe('offered'); + }); + + it('says nothing was stated when there is no envelope, rather than nothing offered', () => { + const reads = readCapabilities(null); + expect(reads.every((r) => r.state === 'unknown')).toBe(true); + expect(reads.every((r) => r.tone === 'neutral')).toBe(true); + }); +}); + +describe('readProtocolCoverage', () => { + it('shows every protocol the navigation offers, even one the overlay stopped reporting', () => { + const readings = readProtocolCoverage(capability({ protocols: [] }), DOGE); + expect(readings.map((r) => r.protocolId)).toEqual(['doginals', 'drc20', 'doge-tap']); + expect(readings[0].tone).toBe('neutral'); + }); + + it('carries the state, coverage and lag the overlay reported', () => { + const readings = readProtocolCoverage( + capability({ + protocols: [ + { protocolId: 'drc20', state: 'degraded', coverage: 'partial', updatedAt: null, lagBlocksAtomic: '17', degradedReasons: ['authority behind tip'] }, + ], + }), + DOGE + ); + const drc20 = readings.find((r) => r.protocolId === 'drc20'); + expect(drc20?.tone).toBe('partial'); + expect(drc20?.lag?.display).toBe('17'); + expect(drc20?.reasons).toEqual(['authority behind tip']); + expect(drc20?.historyLabel).toBe(historyLabel('partial')); + }); + + it('appends a protocol the overlay reports that the profile does not list', () => { + const readings = readProtocolCoverage( + capability({ + protocols: [ + { protocolId: 'newthing', state: 'ready', coverage: 'complete', updatedAt: null, lagBlocksAtomic: null, degradedReasons: [] }, + ], + }), + DOGE + ); + expect(readings.map((r) => r.protocolId)).toContain('newthing'); + }); +}); + +describe('classifyPayload', () => { + it('recognises each shape by the fields its contract guarantees', () => { + expect(classifyPayload(null)).toBe('empty'); + expect(classifyPayload({ schemaVersion: 'universe-transaction-v1', txid: TXID })).toBe('transaction'); + expect(classifyPayload({ block: { hash: BLOCK_HASH } })).toBe('block'); + expect(classifyPayload({ outpoint: { txid: TXID, voutAtomic: '0' } })).toBe('outpoint'); + expect(classifyPayload({ address: 'D7Zx' })).toBe('address'); + expect(classifyPayload({ items: [{ a: 1 }] })).toBe('collection'); + expect(classifyPayload({ state: 'unavailable' })).toBe('record'); + }); + + it('does not read a transaction envelope from a schema version alone', () => { + expect(classifyPayload({ schemaVersion: 'universe-transaction-v1' })).toBe('record'); + }); + + it('does not treat an empty array as a collection', () => { + expect(classifyPayload({ items: [] })).toBe('record'); + }); +}); + +describe('readTransaction', () => { + const envelope: ChainExplorerPayload = { + schemaVersion: 'universe-transaction-v1', + chain: 'zcash', + network: 'mainnet', + txid: TXID, + status: 'confirmed', + firstSeenAt: '2026-08-29T04:00:00.000Z', + confirmationsAtomic: '12', + sizeBytesAtomic: '1450', + block: { hash: BLOCK_HASH, heightAtomic: '2900001', time: '2026-08-29T04:10:00.000Z' }, + fee: { amountAtomic: '15000', rateDecimal: null, rateUnit: null, logicalActionsAtomic: '2' }, + conflicts: [], + replacement: null, + expiry: null, + transparent: { + inputs: [ + { indexAtomic: '0', previousOutpoint: `${TXID}:1`, address: 't1abc', valueAtomic: '200000000', coinbase: false }, + ], + outputs: [ + { indexAtomic: '0', address: 't1def', valueAtomic: '150000000', spent: false }, + { indexAtomic: '1', address: 't1ghi', valueAtomic: '49985000', spent: true }, + ], + }, + shielded: { + sproutJoinSplitsAtomic: '0', + saplingSpendsAtomic: '2', + saplingOutputsAtomic: '2', + orchardActionsAtomic: '0', + ironwoodActionsAtomic: '0', + valueBalanceAtomic: '-50000000', + privacyNotice: 'Shielded participants are not recorded on chain.', + }, + protocolActions: { + candidates: [{ eventId: 'e1', protocolId: 'zerdinals', state: 'candidate', actionType: 'inscribe', evidenceIds: [] }], + confirmed: [{ eventId: 'e2', protocolId: 'zrc20', state: 'confirmed-rejected', actionType: 'transfer', evidenceIds: [] }], + }, + evidenceIds: [], + completeness: 'complete', + }; + + it('reads the lifecycle state as a sentence with an evidence tone', () => { + const tx = readTransaction(envelope, ZEC); + expect(tx?.statusTone).toBe('proven'); + expect(tx?.statusLabel).toContain('block'); + }); + + it('totals the transparent sides exactly', () => { + const tx = readTransaction(envelope, ZEC); + expect(tx?.inputTotal?.display).toBe('2'); + expect(tx?.outputTotal?.display).toBe('1.99985'); + }); + + it('shows no total when an amount was not reported, rather than a short one', () => { + const partial = { + ...envelope, + transparent: { + inputs: [ + { indexAtomic: '0', address: 't1abc', valueAtomic: '200000000', coinbase: false }, + { indexAtomic: '1', address: null, valueAtomic: null, coinbase: false }, + ], + outputs: [], + }, + }; + expect(readTransaction(partial, ZEC)?.inputTotal).toBeNull(); + }); + + it('keeps the shielded structure without inferring participants', () => { + const tx = readTransaction(envelope, ZEC); + expect(tx?.shielded?.valueBalance?.display).toBe('-0.5'); + expect(tx?.shielded?.components).toHaveLength(5); + expect(tx?.shielded?.notice).toContain('not recorded'); + }); + + it('separates candidate actions from evaluated ones and tones them apart', () => { + const tx = readTransaction(envelope, ZEC); + expect(tx?.candidateActions[0].tone).toBe('pending'); + expect(tx?.confirmedActions[0].tone).toBe('unavailable'); + expect(tx?.confirmedActions[0].actionType).toBe('Transfer'); + }); + + it('names a protocol the way the navigation does, not by its wire id', () => { + const tx = readTransaction(envelope, ZEC); + expect(tx?.candidateActions[0].protocolLabel).toBe('Zerdinals'); + expect(tx?.confirmedActions[0].protocolLabel).toBe('ZRC-20'); + }); + + it('returns null for a payload that is not a transaction envelope', () => { + expect(readTransaction({ address: 'D7Zx' }, ZEC)).toBeNull(); + }); +}); + +describe('readBlock', () => { + const payload: ChainExplorerPayload = { + chain: 'dogecoin', + network: 'mainnet', + block: { + hash: BLOCK_HASH, + heightAtomic: '5123456', + confirmationsAtomic: '3', + sizeBytesAtomic: '28000', + time: '2026-08-29T04:00:00.000Z', + previousBlockHash: 'c'.repeat(64), + nextBlockHash: null, + merkleRoot: 'd'.repeat(64), + versionAtomic: '4', + nonce: '0', + bits: '1a', + difficulty: '12345.67', + transactionCountAtomic: '42', + }, + pagination: { pageAtomic: '1', totalPagesAtomic: '2', itemsOnPageAtomic: '50' }, + txids: [TXID], + }; + + it('reads the header facts and the paging', () => { + const block = readBlock(payload); + expect(block?.height?.display).toBe('5,123,456'); + expect(block?.transactionCount?.display).toBe('42'); + expect(block?.page?.display).toBe('1'); + expect(block?.totalPages?.display).toBe('2'); + expect(block?.txids).toEqual([TXID]); + }); + + it('accepts pagination that arrives as a JSON number', () => { + const block = readBlock({ ...payload, pagination: { page: 3, totalPages: 9 } }); + expect(block?.page?.display).toBe('3'); + expect(block?.totalPages?.display).toBe('9'); + }); + + it('leaves the next block absent rather than inventing one', () => { + expect(readBlock(payload)?.nextBlockHash).toBeNull(); + }); +}); + +describe('readAddress', () => { + const payload: ChainExplorerPayload = { + chain: 'dogecoin', + network: 'mainnet', + address: 'DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L', + balanceAtomic: '1234500000000', + totalReceivedAtomic: '5000000000000', + totalSentAtomic: '3765500000000', + unconfirmedBalanceAtomic: '0', + unconfirmedTransactionsAtomic: '0', + transactionCountAtomic: '87', + pagination: { pageAtomic: '1', totalPagesAtomic: '2', itemsOnPageAtomic: '50' }, + txids: [TXID], + utxos: [ + { txid: TXID, voutAtomic: '0', valueAtomic: '1234500000000', heightAtomic: '5123400', confirmationsAtomic: '56', address: 'DH5y', lockTimeAtomic: '0', coinbase: false }, + ], + }; + + it('shifts every balance into the ticker unit', () => { + const address = readAddress(payload, DOGE); + expect(address?.balance?.display).toBe('12,345'); + expect(address?.totalReceived?.display).toBe('50,000'); + expect(address?.transactionCount?.display).toBe('87'); + }); + + it('keeps a zero pending balance as zero rather than dropping it', () => { + expect(readAddress(payload, DOGE)?.unconfirmedBalance?.display).toBe('0'); + }); + + it('reads unspent outputs into routable parts', () => { + const [utxo] = readAddress(payload, DOGE)?.utxos ?? []; + expect(utxo.txid).toBe(TXID); + expect(utxo.vout).toBe('0'); + expect(utxo.amount?.display).toBe('12,345'); + }); +}); + +describe('readOutpoint', () => { + it('reads the output and its spent state', () => { + const out = readOutpoint( + { + chain: 'dogecoin', + network: 'mainnet', + outpoint: { txid: TXID, voutAtomic: '2' }, + output: { valueAtomic: '500000000', address: 'DH5y', spent: false }, + }, + DOGE + ); + expect(out?.vout).toBe('2'); + expect(out?.amount?.display).toBe('5'); + expect(out?.spent).toBe(false); + }); + + it('leaves the spent state unknown when the authority did not state it', () => { + const out = readOutpoint( + { chain: 'dogecoin', outpoint: { txid: TXID, voutAtomic: '0' }, output: { valueAtomic: '1' } }, + DOGE + ); + expect(out?.spent).toBeNull(); + }); +}); + +describe('readCollection', () => { + it('takes columns from the union of the rows, so a sparse row leaves a gap', () => { + const collection = readCollection( + { items: [{ tick: 'ABC', supplyAtomic: '100' }, { tick: 'DEF', holdersAtomic: '4' }] }, + DOGE + ); + expect(collection?.columns.map((c) => c.key)).toEqual(['tick', 'supplyAtomic', 'holdersAtomic']); + expect(collection?.rows[1][1].fact?.kind).toBe('absent'); + }); + + it('names the field the rows came from', () => { + const collection = readCollection({ holders: [{ address: 'DH5y' }] }, DOGE); + expect(collection?.sourceKey).toBe('holders'); + expect(collection?.title).toBe('Holders'); + }); + + it('picks the longest array when a payload carries several', () => { + const collection = readCollection({ txids: ['a'], events: [{ x: '1' }, { y: '2' }] }, DOGE); + expect(collection?.sourceKey).toBe('events'); + }); + + it('reports how many rows it is holding back', () => { + const rows = Array.from({ length: 120 }, (_, index) => ({ n: String(index) })); + const collection = readCollection({ items: rows }, DOGE); + expect(collection?.shownCount).toBe(100); + expect(collection?.totalCount).toBe(120); + }); + + it('wraps a list of plain values so it still has a column', () => { + const collection = readCollection({ items: ['one', 'two'] }, DOGE); + expect(collection?.columns.map((c) => c.key)).toEqual(['value']); + }); + + it('routes the identifiers the explorer has a page for', () => { + const collection = readCollection( + { transactions: [{ txid: TXID, address: 'DH5y', note: 'x' }] }, + DOGE + ); + const byKey = Object.fromEntries( + (collection?.rows[0] ?? []).map((cell) => [cell.key, cell.fact]) + ); + expect(byKey.txid?.link).toEqual(['/', 'dogecoin', 'tx', TXID]); + expect(byKey.address?.link).toEqual(['/', 'dogecoin', 'address', 'DH5y']); + expect(byKey.note?.link).toBeNull(); + }); + + it('names a coin amount column unit once, in the header', () => { + const collection = readCollection({ items: [{ feeAtomic: '1', sizeBytesAtomic: '2' }] }, DOGE); + const byKey = Object.fromEntries((collection?.columns ?? []).map((c) => [c.key, c])); + expect(byKey.feeAtomic.unit).toBe('DOGE'); + expect(byKey.sizeBytesAtomic.unit).toBeNull(); + }); + + it('returns nothing when there is no array to read', () => { + expect(readCollection({ state: 'unavailable' }, DOGE)).toBeNull(); + }); +}); + +describe('readRecordFacts', () => { + it('classifies amounts, counts, identifiers, flags and absences apart', () => { + const facts = readRecordFacts( + { + balanceAtomic: '100000000', + transactionCountAtomic: '12', + blockHash: BLOCK_HASH, + coinbase: true, + note: null, + }, + DOGE + ); + const byKey = Object.fromEntries(facts.map((fact) => [fact.key, fact])); + expect(byKey.balanceAtomic.kind).toBe('amount'); + expect(byKey.balanceAtomic.unit).toBe('DOGE'); + expect(byKey.transactionCountAtomic.kind).toBe('count'); + expect(byKey.blockHash.kind).toBe('identifier'); + expect(byKey.coinbase.kind).toBe('flag'); + expect(byKey.note.kind).toBe('absent'); + }); + + it('skips fields a purpose-built reading already presented', () => { + const keys = readRecordFacts({ chain: 'dogecoin', state: 'ready' }, DOGE, ['chain']).map((f) => f.key); + expect(keys).toEqual(['state']); + }); + + it('flattens one level of nesting so a nested scalar is not dropped', () => { + const facts = readRecordFacts({ status: { state: 'ready', lagBlocksAtomic: '3' } }, DOGE); + expect(facts.map((fact) => fact.label)).toEqual(['Status state', 'Status lag blocks']); + }); + + it('shifts only the fields that carry the chain own coin', () => { + // A DRC-20 supply is denominated in the token's units. Shifting it by the + // chain precision printed 100000000000 as 1,000 DOGE, which is not a + // formatting choice, it is a different number. + const facts = readRecordFacts( + { supplyAtomic: '100000000000', balanceAtomic: '100000000000' }, + DOGE + ); + const byKey = Object.fromEntries(facts.map((fact) => [fact.key, fact])); + expect(byKey.supplyAtomic.kind).toBe('count'); + expect(byKey.supplyAtomic.display).toBe('100,000,000,000'); + expect(byKey.supplyAtomic.unit).toBeNull(); + expect(byKey.balanceAtomic.kind).toBe('amount'); + expect(byKey.balanceAtomic.display).toBe('1,000'); + }); + + it('never drops a zero by treating it as absent', () => { + const [fact] = readRecordFacts({ balanceAtomic: '0' }, DOGE); + expect(fact.kind).toBe('amount'); + expect(fact.display).toBe('0'); + }); +}); diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.ts new file mode 100644 index 00000000000..59b5438e526 --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.ts @@ -0,0 +1,1235 @@ +/** + * Presentation model for the Dogecoin and Zcash explorer pages. + * + * Pure functions, no Angular and no I/O, so every rule below is testable on + * its own and the component stays a template. + * + * The problem this solves: the chain overlay answers with several different + * envelopes, and the page used to print whichever one arrived as a grid of + * raw API key names, a table whose columns were the first eight keys of the + * first row, and the whole response as JSON. That renders, but it does not + * explain. A reader could not tell a koinu from a Dogecoin, an exact integer + * from a rounded one, or a fact the authority proved from a field it left + * null. + * + * Three rules hold the model together: + * + * 1. Numbers cross the API boundary as exact decimal strings and are never + * parsed into a JavaScript number here. Grouping and the decimal shift are + * string operations, and every formatted value keeps the exact source + * string beside it so a reader can copy the value the authority sent. + * + * 2. Absent and zero are different answers, and the model says which. A null + * amount reads as not reported; a zero amount reads as zero. + * + * 3. Shapes the model recognises get a purpose-built reading. Shapes it does + * not are still rendered, as a humanised record that says plainly it is + * the response as received. The page never silently drops a field it did + * not expect. + */ + +import { + ChainCapabilityEnvelope, + ChainExplorerPayload, + ExplorerChain, +} from '@app/universe/universe.types'; + +/** + * The evidence tones the design system defines. They are the only tones any + * chain state may map to, so a chain page and a protocol page describe the + * same certainty with the same colour and the same word. + */ +export type EvidenceTone = + | 'proven' + | 'partial' + | 'pending' + | 'unavailable' + | 'neutral'; + +export interface ChainProtocolTab { + readonly id: string; + readonly label: string; +} + +export interface ChainProfile { + readonly chain: Exclude; + readonly name: string; + readonly ticker: string; + /** Name of one indivisible unit, as the chain's own documentation uses it. */ + readonly atomicUnit: string; + /** Decimal places between the atomic unit and the ticker unit. */ + readonly precision: number; + readonly protocols: readonly ChainProtocolTab[]; +} + +const DOGECOIN: ChainProfile = { + chain: 'dogecoin', + name: 'Dogecoin', + ticker: 'DOGE', + atomicUnit: 'koinu', + precision: 8, + protocols: [ + { id: 'doginals', label: 'Doginals' }, + { id: 'drc20', label: 'DRC-20' }, + { id: 'doge-tap', label: 'Doge TAP' }, + ], +}; + +const ZCASH: ChainProfile = { + chain: 'zcash', + name: 'Zcash', + ticker: 'ZEC', + atomicUnit: 'zatoshi', + precision: 8, + protocols: [ + { id: 'zerdinals', label: 'Zerdinals' }, + { id: 'zrunes', label: 'ZRunes' }, + { id: 'zrc20', label: 'ZRC-20' }, + ], +}; + +export function chainProfile( + chain: Exclude +): ChainProfile { + return chain === 'dogecoin' ? DOGECOIN : ZCASH; +} + +// --------------------------------------------------------------------------- +// Exact numeric presentation +// --------------------------------------------------------------------------- + +const INTEGER = /^-?(?:0|[1-9][0-9]*)$/; + +/** Digit grouping on the integer part only, applied to a string. */ +function groupDigits(digits: string): string { + return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +/** + * A number the page shows. `display` is grouped and shifted for reading; + * `exact` is the string the authority sent, so a reader who needs the precise + * value can copy it and a test can assert on it. + */ +export interface ExactNumber { + readonly display: string; + readonly exact: string; +} + +/** + * An exact integer, grouped for reading. Heights, counts, sizes and sequence + * numbers take this: they are whole things, not amounts, and shifting them by + * a precision would be wrong. + */ +export function formatExactInteger( + value: string | null | undefined +): ExactNumber | null { + if (typeof value !== 'string' || !INTEGER.test(value)) { + return null; + } + const negative = value.startsWith('-'); + const digits = negative ? value.slice(1) : value; + return { + display: (negative ? '-' : '') + groupDigits(digits), + exact: value, + }; +} + +/** + * An amount in the chain's atomic unit, shifted to the ticker unit by string + * arithmetic. 100000000 koinu becomes 1 DOGE with no floating point anywhere + * in the path, which matters because a Dogecoin balance can exceed the range + * where a double still counts single units. + * + * Trailing zeros in the fraction are dropped, because a balance of exactly one + * coin should read as 1 and not as 1.00000000, and a whole number keeps no + * decimal point at all. + */ +export function formatAtomicAmount( + value: string | null | undefined, + precision: number +): ExactNumber | null { + if (typeof value !== 'string' || !INTEGER.test(value)) { + return null; + } + const negative = value.startsWith('-'); + const digits = (negative ? value.slice(1) : value).padStart( + precision + 1, + '0' + ); + const whole = digits.slice(0, digits.length - precision); + const fraction = digits.slice(digits.length - precision).replace(/0+$/, ''); + const display = + (negative ? '-' : '') + + groupDigits(whole) + + (fraction ? `.${fraction}` : ''); + return { display, exact: value }; +} + +/** Shorten a hash for a dense column, keeping both ends so it stays checkable. */ +export function shortenIdentifier(value: string, lead = 8, tail = 6): string { + if (value.length <= lead + tail + 1) { + return value; + } + return `${value.slice(0, lead)}…${value.slice(-tail)}`; +} + +/** + * How long ago an observation was made, in the coarsest unit that is still + * honest. An unparseable or future timestamp returns null rather than a + * reassuring "0s ago"; the caller then says the freshness is unknown. + */ +export function formatElapsed( + isoTimestamp: string | null | undefined, + now: number +): string | null { + if (typeof isoTimestamp !== 'string') { + return null; + } + const observed = Date.parse(isoTimestamp); + if (!Number.isFinite(observed)) { + return null; + } + const seconds = Math.floor((now - observed) / 1000); + if (seconds < 0) { + return null; + } + if (seconds < 60) { + return seconds === 1 + ? $localize`:@@universe.chain.elapsed-second:1 second ago` + : $localize`:@@universe.chain.elapsed-seconds:${seconds}:SECONDS: seconds ago`; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return minutes === 1 + ? $localize`:@@universe.chain.elapsed-minute:1 minute ago` + : $localize`:@@universe.chain.elapsed-minutes:${minutes}:MINUTES: minutes ago`; + } + const hours = Math.floor(minutes / 60); + if (hours < 48) { + return hours === 1 + ? $localize`:@@universe.chain.elapsed-hour:1 hour ago` + : $localize`:@@universe.chain.elapsed-hours:${hours}:HOURS: hours ago`; + } + const days = Math.floor(hours / 24); + return days === 1 + ? $localize`:@@universe.chain.elapsed-day:1 day ago` + : $localize`:@@universe.chain.elapsed-days:${days}:DAYS: days ago`; +} + +// --------------------------------------------------------------------------- +// Field naming +// --------------------------------------------------------------------------- + +/** + * Words the generic sentence-case rule would get wrong. Everything else is + * derived, so a field the overlay adds later still reads as English without + * anyone having to add it here first. + */ +const KNOWN_WORDS: Record = { + txid: 'transaction id', + txids: 'transaction ids', + utxos: 'unspent outputs', + vout: 'output index', + vin: 'input index', + drc20: 'DRC-20', + zrc20: 'ZRC-20', + id: 'ID', + ids: 'IDs', + hash: 'hash', + ipfs: 'IPFS', + json: 'JSON', + url: 'URL', + uri: 'URI', + zip317: 'ZIP-317', +}; + +/** + * An API field name as a reader should see it. The `Atomic` suffix the + * contract uses to mark exact-string transport is a wire detail, not a label, + * so it is dropped here and the exactness is carried by the value instead. + */ +export function humanizeFieldName(key: string): string { + const withoutSuffix = key.replace(/Atomic$/, '').replace(/Decimal$/, ''); + const words = withoutSuffix + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .trim() + .toLowerCase() + .split(/\s+/) + .filter(Boolean) + .map((word) => KNOWN_WORDS[word] ?? word); + if (!words.length) { + return key; + } + const sentence = words.join(' '); + return sentence.charAt(0).toUpperCase() + sentence.slice(1); +} + +/** + * Fields that carry an amount of the chain's own coin, and may therefore be + * shifted by the chain's precision. + * + * This is an allowlist, not a pattern, and it is deliberately narrow. A + * pattern that matched "supply" printed a DRC-20 token supply of 100000000000 + * as 1,000 DOGE, because a token's units are its own and have nothing to do + * with koinu. Shifting an unknown quantity by eight places does not make it + * more readable, it makes it wrong, so anything not named here is shown as the + * exact integer the authority sent. + */ +const COIN_AMOUNT_FIELDS = new Set([ + 'valueAtomic', + 'valueBalanceAtomic', + 'saplingValueBalanceAtomic', + 'orchardValueBalanceAtomic', + 'ironwoodValueBalanceAtomic', + 'balanceAtomic', + 'unconfirmedBalanceAtomic', + 'totalReceivedAtomic', + 'totalSentAtomic', + 'amountAtomic', + 'feeAtomic', + 'rewardAtomic', +]); + +function isCoinAmount(key: string): boolean { + return COIN_AMOUNT_FIELDS.has(key.split(' ').pop() ?? key); +} +const IDENTIFIER_FIELD = /(hash|txid|^id$|root|address|outpoint|satpoint)/i; +const TIMESTAMP_FIELD = /(at|time|timestamp)$/i; + +export type FactKind = + | 'text' + | 'identifier' + | 'amount' + | 'count' + | 'timestamp' + | 'flag' + | 'absent'; + +/** One labelled value, classified so the template knows how to set it. */ +export interface Fact { + readonly key: string; + readonly label: string; + readonly kind: FactKind; + readonly display: string; + /** The exact source string, when the display form is a rendering of one. */ + readonly exact: string | null; + /** Unit shown beside the value, for amounts only. */ + readonly unit: string | null; + /** + * Router path to the page for this value, when the explorer has one. A list + * of transaction ids that cannot be clicked is a dead end, and that is what + * every generic table here used to be. + */ + readonly link: readonly string[] | null; +} + +const ABSENT = $localize`:@@universe.chain.not-reported:Not reported`; + +const TXID_FIELD = /(^|\s)(txid|txids|transactionid)$/i; +const ADDRESS_FIELD = /(^|\s)(address|owneraddress|holder)$/i; + +/** The page this value belongs to, when the explorer has one for it. */ +function routeFor( + key: string, + value: string, + profile: ChainProfile +): readonly string[] | null { + if (TXID_FIELD.test(key) && /^[0-9a-f]{64}$/.test(value)) { + return ['/', profile.chain, 'tx', value]; + } + if (ADDRESS_FIELD.test(key)) { + return ['/', profile.chain, 'address', value]; + } + return null; +} + +function factFrom( + key: string, + value: unknown, + profile: ChainProfile +): Fact | null { + const label = humanizeFieldName(key); + const base = { key, label, exact: null, unit: null, link: null } as const; + + if (value === null || value === undefined || value === '') { + return { ...base, kind: 'absent', display: ABSENT }; + } + if (typeof value === 'boolean') { + return { + ...base, + kind: 'flag', + display: value + ? $localize`:@@universe.chain.flag-yes:Yes` + : $localize`:@@universe.chain.flag-no:No`, + }; + } + if (typeof value === 'number') { + // The contract transports numbers as strings. A raw number means a field + // outside the contract, so it is shown as text rather than dressed up as + // an exact value it may not be. + return { ...base, kind: 'text', display: String(value) }; + } + if (typeof value !== 'string') { + return null; + } + + if (isCoinAmount(key) && INTEGER.test(value)) { + const amount = formatAtomicAmount(value, profile.precision); + return amount + ? { + key, + label, + kind: 'amount', + display: amount.display, + exact: amount.exact, + unit: profile.ticker, + link: null, + } + : { ...base, kind: 'text', display: value }; + } + if (INTEGER.test(value)) { + const count = formatExactInteger(value); + return count + ? { key, label, kind: 'count', display: count.display, exact: count.exact, unit: null, link: null } + : { ...base, kind: 'text', display: value }; + } + if (TIMESTAMP_FIELD.test(key) && Number.isFinite(Date.parse(value))) { + return { ...base, kind: 'timestamp', display: value }; + } + if (IDENTIFIER_FIELD.test(key) || /^[0-9a-f]{64}$/.test(value)) { + return { + key, + label, + kind: 'identifier', + display: value, + exact: value, + unit: null, + link: routeFor(key, value, profile), + }; + } + return { ...base, kind: 'text', display: value }; +} + +/** + * Every scalar field of a response, humanised and classified, in the order the + * authority sent them. Nested objects and arrays are handled by the shape + * readers below or, when the shape is unrecognised, listed separately so the + * page can say how many rows it is not detailing rather than hiding them. + */ +export function readRecordFacts( + payload: ChainExplorerPayload | null, + profile: ChainProfile, + skip: readonly string[] = [] +): readonly Fact[] { + if (!payload) { + return []; + } + const skipped = new Set(skip); + const facts: Fact[] = []; + for (const [key, value] of Object.entries(payload)) { + if (skipped.has(key) || Array.isArray(value)) { + continue; + } + if (value !== null && typeof value === 'object') { + for (const [childKey, childValue] of Object.entries( + value as Record + )) { + if (childValue !== null && typeof childValue === 'object') { + continue; + } + const fact = factFrom(`${key} ${childKey}`, childValue, profile); + if (fact) { + facts.push(fact); + } + } + continue; + } + const fact = factFrom(key, value, profile); + if (fact) { + facts.push(fact); + } + } + return facts; +} + +// --------------------------------------------------------------------------- +// Chain status +// --------------------------------------------------------------------------- + +export interface StatusReading { + readonly id: string; + readonly label: string; + readonly value: string; + readonly tone: EvidenceTone; + /** Present when the value is an exact figure worth copying. */ + readonly exact: string | null; +} + +function availabilityTone(state: string | null | undefined): EvidenceTone { + switch (state) { + case 'ready': + return 'proven'; + case 'degraded': + return 'partial'; + case 'unavailable': + return 'unavailable'; + default: + return 'neutral'; + } +} + +function completenessTone(state: string | null | undefined): EvidenceTone { + switch (state) { + case 'complete': + return 'proven'; + case 'partial': + return 'partial'; + case 'unavailable': + return 'unavailable'; + default: + return 'neutral'; + } +} + +export function completenessLabel(state: string | null | undefined): string { + switch (state) { + case 'complete': + return $localize`:@@universe.chain.coverage-complete:Complete`; + case 'partial': + return $localize`:@@universe.chain.coverage-partial:Partial`; + case 'unavailable': + return $localize`:@@universe.chain.coverage-unavailable:Unavailable`; + default: + return $localize`:@@universe.chain.coverage-unknown:Not stated`; + } +} + +/** + * Coverage as a phrase rather than a bare adjective. "History Complete" is not + * a sentence; the chip vocabulary and the prose vocabulary are different jobs. + */ +export function historyLabel(state: string | null | undefined): string { + switch (state) { + case 'complete': + return $localize`:@@universe.chain.history-complete:Complete history`; + case 'partial': + return $localize`:@@universe.chain.history-partial:Partial history`; + case 'unavailable': + return $localize`:@@universe.chain.history-unavailable:History unavailable`; + default: + return $localize`:@@universe.chain.history-unknown:History not stated`; + } +} + +export function availabilityLabel(state: string | null | undefined): string { + switch (state) { + case 'ready': + return $localize`:@@universe.chain.state-ready:Ready`; + case 'degraded': + return $localize`:@@universe.chain.state-degraded:Degraded`; + case 'unavailable': + return $localize`:@@universe.chain.state-unavailable:Unavailable`; + default: + return $localize`:@@universe.chain.state-unknown:Not stated`; + } +} + +/** + * The status rail. Five readings, always the same five in the same order, so a + * reader learns where to look once. A reading whose fact is missing says so in + * its own value rather than disappearing, because a rail with a hole in it + * reads as a page still loading. + */ +export function readStatusRail( + capability: ChainCapabilityEnvelope | null, + profile: ChainProfile, + now: number +): readonly StatusReading[] { + if (!capability) { + const unknown = $localize`:@@universe.chain.rail-unknown:Not available`; + return [ + { id: 'state', label: $localize`:@@universe.chain.rail-state:Chain`, value: $localize`:@@universe.chain.rail-no-status:Status unavailable`, tone: 'unavailable', exact: null }, + { id: 'tip', label: $localize`:@@universe.chain.rail-tip:Chain tip`, value: unknown, tone: 'neutral', exact: null }, + { id: 'lag', label: $localize`:@@universe.chain.rail-lag:Behind tip`, value: unknown, tone: 'neutral', exact: null }, + { id: 'freshness', label: $localize`:@@universe.chain.rail-observed:Last observed`, value: unknown, tone: 'neutral', exact: null }, + { id: 'mempool', label: $localize`:@@universe.chain.rail-pending:Pending coverage`, value: unknown, tone: 'neutral', exact: null }, + ]; + } + + const tip = formatExactInteger(capability.tip?.heightAtomic ?? null); + const lag = formatExactInteger(capability.lagBlocksAtomic); + const elapsed = formatElapsed(capability.updatedAt, now); + + return [ + { + id: 'state', + label: $localize`:@@universe.chain.rail-state:Chain`, + value: capability.ready + ? availabilityLabel('ready') + : availabilityLabel(capability.sync.state), + tone: capability.ready ? 'proven' : availabilityTone(capability.sync.state), + exact: null, + }, + { + id: 'tip', + label: $localize`:@@universe.chain.rail-tip:Chain tip`, + value: tip + ? $localize`:@@universe.chain.rail-tip-block:Block ${tip.display}:HEIGHT:` + : $localize`:@@universe.chain.rail-tip-none:No tip reported`, + tone: tip ? 'proven' : 'unavailable', + exact: tip?.exact ?? null, + }, + { + id: 'lag', + label: $localize`:@@universe.chain.rail-lag:Behind tip`, + value: lag + ? $localize`:@@universe.chain.rail-lag-blocks:${lag.display}:BLOCKS: blocks` + : $localize`:@@universe.chain.rail-lag-none:Not stated`, + // Any lag at all means the page is describing a past state of the chain, + // so it never reads as proven. Zero is the only reading that does. + tone: !lag ? 'neutral' : lag.exact === '0' ? 'proven' : 'partial', + exact: lag?.exact ?? null, + }, + { + id: 'freshness', + label: $localize`:@@universe.chain.rail-observed:Last observed`, + value: elapsed ?? $localize`:@@universe.chain.rail-observed-unknown:Time not stated`, + tone: elapsed ? 'neutral' : 'partial', + exact: null, + }, + { + id: 'mempool', + label: $localize`:@@universe.chain.rail-pending:Pending coverage`, + value: capability.mempool.supported + ? completenessLabel(capability.mempool.completeness) + : $localize`:@@universe.chain.rail-pending-unsupported:Not offered for ${profile.name}:CHAIN:`, + tone: capability.mempool.supported + ? completenessTone(capability.mempool.completeness) + : 'neutral', + exact: null, + }, + ]; +} + +export interface ReadCapability { + readonly id: string; + readonly label: string; + /** + * Three answers, not two. A chain that never answered has not declined a + * read, and printing "Not offered" for all six when the authority is simply + * unreachable states a fact nobody established. + */ + readonly state: 'offered' | 'not-offered' | 'unknown'; + readonly stateLabel: string; + readonly tone: EvidenceTone; +} + +const READ_LABELS: readonly { id: keyof ChainCapabilityEnvelope['reads']; label: string }[] = [ + { id: 'transaction', label: $localize`:@@universe.chain.read-transaction:Transaction lookup` }, + { id: 'block', label: $localize`:@@universe.chain.read-block:Block lookup` }, + { id: 'address', label: $localize`:@@universe.chain.read-address:Address history` }, + { id: 'outpoint', label: $localize`:@@universe.chain.read-outpoint:Outpoint lookup` }, + { id: 'feeEstimates', label: $localize`:@@universe.chain.read-fees:Fee estimates` }, + { id: 'projectedBlocks', label: $localize`:@@universe.chain.read-projected:Projected blocks` }, +]; + +/** + * What the chain can answer. This is the honest core of a chain overview: + * rather than showing empty sections for reads the overlay never claimed, the + * page states which questions it can answer at all, and says so separately + * from the case where it could not ask. + */ +export function readCapabilities( + capability: ChainCapabilityEnvelope | null +): readonly ReadCapability[] { + return READ_LABELS.map(({ id, label }) => { + if (!capability) { + return { + id, + label, + state: 'unknown' as const, + stateLabel: $localize`:@@universe.chain.read-unknown:Not stated`, + tone: 'neutral' as EvidenceTone, + }; + } + const offered = capability.reads?.[id] === true; + return { + id, + label, + state: offered ? ('offered' as const) : ('not-offered' as const), + stateLabel: offered + ? $localize`:@@universe.chain.read-offered:Offered` + : $localize`:@@universe.chain.read-not-offered:Not offered`, + tone: offered ? ('proven' as EvidenceTone) : ('unavailable' as EvidenceTone), + }; + }); +} + +export interface ProtocolReading { + readonly protocolId: string; + readonly label: string; + readonly stateLabel: string; + readonly tone: EvidenceTone; + readonly coverageLabel: string; + readonly historyLabel: string; + readonly lag: ExactNumber | null; + readonly reasons: readonly string[]; +} + +export function readProtocolCoverage( + capability: ChainCapabilityEnvelope | null, + profile: ChainProfile +): readonly ProtocolReading[] { + const declared = capability?.protocols ?? []; + const byId = new Map(declared.map((entry) => [entry.protocolId, entry])); + // The profile's tabs are the list the navigation offers, so a protocol the + // overlay stopped reporting still appears, marked as not stated, instead of + // vanishing from a page that links to it. + const ids = [ + ...profile.protocols.map((tab) => tab.id), + ...declared + .map((entry) => entry.protocolId) + .filter((id) => !profile.protocols.some((tab) => tab.id === id)), + ]; + return ids.map((protocolId) => { + const entry = byId.get(protocolId); + const tab = profile.protocols.find((candidate) => candidate.id === protocolId); + return { + protocolId, + label: tab?.label ?? humanizeFieldName(protocolId), + stateLabel: availabilityLabel(entry?.state), + tone: availabilityTone(entry?.state), + coverageLabel: completenessLabel(entry?.coverage), + historyLabel: historyLabel(entry?.coverage), + lag: formatExactInteger(entry?.lagBlocksAtomic ?? null), + reasons: entry?.degradedReasons ?? [], + }; + }); +} + +// --------------------------------------------------------------------------- +// Shape readers +// --------------------------------------------------------------------------- + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function text(value: unknown): string | null { + return typeof value === 'string' && value !== '' ? value : null; +} + +/** + * A whole number that may arrive as an exact string or, outside the contract, + * as a JSON number. Pagination is the one place the overlay passes a source + * field straight through, so both forms have to be readable. + */ +function integerText(value: unknown): string | null { + if (typeof value === 'number' && Number.isSafeInteger(value)) { + return String(value); + } + return text(value); +} + +export type ChainShape = + | 'transaction' + | 'block' + | 'address' + | 'outpoint' + | 'collection' + | 'record' + | 'empty'; + +/** + * Which reading applies to a payload. Recognition is by the fields the + * contract guarantees, not by which page requested it, so a response that + * arrives in an unexpected shape falls through to the record reading rather + * than being rendered against the wrong template. + */ +export function classifyPayload(payload: ChainExplorerPayload | null): ChainShape { + if (!payload || !isRecord(payload)) { + return 'empty'; + } + if (payload.schemaVersion === 'universe-transaction-v1' && text(payload.txid)) { + return 'transaction'; + } + if (isRecord(payload.block) && text(payload.block.hash)) { + return 'block'; + } + if (isRecord(payload.outpoint) && text(payload.outpoint.txid)) { + return 'outpoint'; + } + if (text(payload.address)) { + return 'address'; + } + if (Object.values(payload).some((value) => Array.isArray(value) && value.length)) { + return 'collection'; + } + return 'record'; +} + +export interface TransactionPartyRow { + readonly index: string; + readonly address: string | null; + readonly amount: ExactNumber | null; + readonly reference: string | null; + readonly coinbase: boolean; + readonly spent: boolean | null; +} + +export interface ShieldedReading { + readonly components: readonly { label: string; count: ExactNumber | null }[]; + readonly valueBalance: ExactNumber | null; + readonly notice: string; +} + +export interface ProtocolActionRow { + readonly eventId: string; + readonly protocolId: string; + readonly protocolLabel: string; + readonly actionType: string; + readonly stateLabel: string; + readonly tone: EvidenceTone; +} + +export interface TransactionReading { + readonly txid: string; + readonly statusLabel: string; + readonly statusTone: EvidenceTone; + readonly confirmations: ExactNumber | null; + readonly block: { hash: string; height: ExactNumber | null; time: string | null } | null; + readonly firstSeenAt: string | null; + readonly size: ExactNumber | null; + readonly virtualSize: ExactNumber | null; + readonly feeAmount: ExactNumber | null; + readonly feeRate: string | null; + readonly feeRateUnit: string | null; + readonly logicalActions: ExactNumber | null; + readonly inputs: readonly TransactionPartyRow[]; + readonly outputs: readonly TransactionPartyRow[]; + readonly inputTotal: ExactNumber | null; + readonly outputTotal: ExactNumber | null; + readonly shielded: ShieldedReading | null; + readonly candidateActions: readonly ProtocolActionRow[]; + readonly confirmedActions: readonly ProtocolActionRow[]; + readonly replacedByTxid: string | null; + readonly conflicts: readonly { txid: string; reason: string }[]; + readonly expiry: { height: ExactNumber | null; state: string } | null; + readonly completenessLabel: string; + readonly completenessTone: EvidenceTone; +} + +const LIFECYCLE_TONE: Record = { + confirmed: 'proven', + observed: 'pending', + pending: 'pending', + replaced: 'partial', + conflicted: 'partial', + evicted: 'unavailable', + expired: 'unavailable', + reorged: 'unavailable', + 'unknown-removal': 'unavailable', +}; + +const LIFECYCLE_LABEL: Record = { + confirmed: $localize`:@@universe.chain.tx-confirmed:Confirmed in a block`, + observed: $localize`:@@universe.chain.tx-observed:Seen in the pending set`, + pending: $localize`:@@universe.chain.tx-pending:Waiting for a block`, + replaced: $localize`:@@universe.chain.tx-replaced:Replaced by another transaction`, + conflicted: $localize`:@@universe.chain.tx-conflicted:Conflicts with a confirmed transaction`, + evicted: $localize`:@@universe.chain.tx-evicted:Dropped from the pending set`, + expired: $localize`:@@universe.chain.tx-expired:Expired before confirming`, + reorged: $localize`:@@universe.chain.tx-reorged:Removed by a chain reorganisation`, + 'unknown-removal': $localize`:@@universe.chain.tx-removed:Removed for a reason the node did not state`, +}; + +const PROTOCOL_STATE_TONE: Record = { + 'confirmed-accepted': 'proven', + 'confirmed-rejected': 'unavailable', + candidate: 'pending', + unevaluated: 'neutral', + 'outside-coverage': 'neutral', + evicted: 'unavailable', + expired: 'unavailable', + reorged: 'unavailable', +}; + +const PROTOCOL_STATE_LABEL: Record = { + 'confirmed-accepted': $localize`:@@universe.chain.action-accepted:Accepted`, + 'confirmed-rejected': $localize`:@@universe.chain.action-rejected:Rejected by the protocol rules`, + candidate: $localize`:@@universe.chain.action-candidate:Candidate, not yet evaluated`, + unevaluated: $localize`:@@universe.chain.action-unevaluated:Not yet evaluated`, + 'outside-coverage': $localize`:@@universe.chain.action-outside:Outside the indexer's coverage`, + evicted: $localize`:@@universe.chain.action-evicted:Dropped before evaluation`, + expired: $localize`:@@universe.chain.action-expired:Expired before evaluation`, + reorged: $localize`:@@universe.chain.action-reorged:Removed by a chain reorganisation`, +}; + +function sumAtomic( + rows: readonly TransactionPartyRow[], + precision: number +): ExactNumber | null { + let total = 0n; + let counted = false; + for (const row of rows) { + if (row.amount) { + total += BigInt(row.amount.exact); + counted = true; + } + } + // A total that omitted an unreported input would be a smaller number + // presented as a fact. Only sum when every row carried an amount. + if (!counted || rows.some((row) => !row.amount)) { + return null; + } + return formatAtomicAmount(total.toString(), precision); +} + +function partyRows( + values: unknown, + profile: ChainProfile, + kind: 'input' | 'output' +): readonly TransactionPartyRow[] { + if (!Array.isArray(values)) { + return []; + } + return values.filter(isRecord).map((entry) => ({ + index: text(entry.indexAtomic) ?? '', + address: text(entry.address), + amount: formatAtomicAmount(text(entry.valueAtomic), profile.precision), + reference: kind === 'input' ? text(entry.previousOutpoint) : null, + coinbase: entry.coinbase === true, + spent: typeof entry.spent === 'boolean' ? entry.spent : null, + })); +} + +function actionRows( + values: unknown, + profile: ChainProfile +): readonly ProtocolActionRow[] { + if (!Array.isArray(values)) { + return []; + } + return values.filter(isRecord).map((entry) => { + const state = text(entry.state) ?? ''; + const protocolId = text(entry.protocolId) ?? ''; + return { + eventId: text(entry.eventId) ?? '', + protocolId, + protocolLabel: + profile.protocols.find((tab) => tab.id === protocolId)?.label ?? + humanizeFieldName(protocolId), + actionType: humanizeFieldName(text(entry.actionType) ?? ''), + stateLabel: PROTOCOL_STATE_LABEL[state] ?? availabilityLabel(state), + tone: PROTOCOL_STATE_TONE[state] ?? 'neutral', + }; + }); +} + +function shieldedReading(value: unknown, profile: ChainProfile): ShieldedReading | null { + if (!isRecord(value)) { + return null; + } + const components = [ + { label: $localize`:@@universe.chain.shielded-sprout:Sprout joinsplits`, count: formatExactInteger(text(value.sproutJoinSplitsAtomic)) }, + { label: $localize`:@@universe.chain.shielded-sapling-spends:Sapling spends`, count: formatExactInteger(text(value.saplingSpendsAtomic)) }, + { label: $localize`:@@universe.chain.shielded-sapling-outputs:Sapling outputs`, count: formatExactInteger(text(value.saplingOutputsAtomic)) }, + { label: $localize`:@@universe.chain.shielded-orchard:Orchard actions`, count: formatExactInteger(text(value.orchardActionsAtomic)) }, + { label: $localize`:@@universe.chain.shielded-ironwood:Ironwood actions`, count: formatExactInteger(text(value.ironwoodActionsAtomic)) }, + ]; + return { + components, + valueBalance: formatAtomicAmount(text(value.valueBalanceAtomic), profile.precision), + notice: + text(value.privacyNotice) ?? + $localize`:@@universe.chain.shielded-default-notice:Shielded senders, recipients, and amounts are not recorded in a form this explorer can read.`, + }; +} + +export function readTransaction( + payload: ChainExplorerPayload, + profile: ChainProfile +): TransactionReading | null { + if (classifyPayload(payload) !== 'transaction') { + return null; + } + const status = text(payload.status) ?? ''; + const transparent = isRecord(payload.transparent) ? payload.transparent : {}; + const inputs = partyRows(transparent.inputs, profile, 'input'); + const outputs = partyRows(transparent.outputs, profile, 'output'); + const fee = isRecord(payload.fee) ? payload.fee : {}; + const block = isRecord(payload.block) ? payload.block : null; + const replacement = isRecord(payload.replacement) ? payload.replacement : null; + const expiry = isRecord(payload.expiry) ? payload.expiry : null; + const actions = isRecord(payload.protocolActions) ? payload.protocolActions : {}; + const completeness = text(payload.completeness); + + return { + txid: text(payload.txid) ?? '', + statusLabel: LIFECYCLE_LABEL[status] ?? availabilityLabel(status), + statusTone: LIFECYCLE_TONE[status] ?? 'neutral', + confirmations: formatExactInteger(text(payload.confirmationsAtomic)), + block: block + ? { + hash: text(block.hash) ?? '', + height: formatExactInteger(text(block.heightAtomic)), + time: text(block.time), + } + : null, + firstSeenAt: text(payload.firstSeenAt), + size: formatExactInteger(text(payload.sizeBytesAtomic)), + virtualSize: formatExactInteger(text(payload.virtualSizeBytesAtomic)), + feeAmount: formatAtomicAmount(text(fee.amountAtomic), profile.precision), + feeRate: text(fee.rateDecimal), + feeRateUnit: text(fee.rateUnit), + logicalActions: formatExactInteger(text(fee.logicalActionsAtomic)), + inputs, + outputs, + inputTotal: sumAtomic(inputs, profile.precision), + outputTotal: sumAtomic(outputs, profile.precision), + shielded: shieldedReading(payload.shielded, profile), + candidateActions: actionRows(actions.candidates, profile), + confirmedActions: actionRows(actions.confirmed, profile), + replacedByTxid: replacement ? text(replacement.replacedByTxid) : null, + conflicts: Array.isArray(payload.conflicts) + ? payload.conflicts.filter(isRecord).map((entry) => ({ + txid: text(entry.txid) ?? '', + reason: humanizeFieldName(text(entry.reason) ?? ''), + })) + : [], + expiry: expiry + ? { + height: formatExactInteger(text(expiry.heightAtomic)), + state: text(expiry.state) ?? '', + } + : null, + completenessLabel: completenessLabel(completeness), + completenessTone: completenessTone(completeness), + }; +} + +export interface BlockReading { + readonly hash: string; + readonly height: ExactNumber | null; + readonly time: string | null; + readonly transactionCount: ExactNumber | null; + readonly sizeBytes: ExactNumber | null; + readonly confirmations: ExactNumber | null; + readonly previousBlockHash: string | null; + readonly nextBlockHash: string | null; + readonly merkleRoot: string | null; + readonly difficulty: string | null; + readonly txids: readonly string[]; + readonly page: ExactNumber | null; + readonly totalPages: ExactNumber | null; +} + +export function readBlock(payload: ChainExplorerPayload): BlockReading | null { + if (classifyPayload(payload) !== 'block' || !isRecord(payload.block)) { + return null; + } + const block = payload.block; + const pagination = isRecord(payload.pagination) ? payload.pagination : {}; + return { + hash: text(block.hash) ?? '', + height: formatExactInteger(text(block.heightAtomic)), + time: text(block.time), + transactionCount: formatExactInteger(text(block.transactionCountAtomic)), + sizeBytes: formatExactInteger(text(block.sizeBytesAtomic)), + confirmations: formatExactInteger(text(block.confirmationsAtomic)), + previousBlockHash: text(block.previousBlockHash), + nextBlockHash: text(block.nextBlockHash), + merkleRoot: text(block.merkleRoot), + difficulty: text(block.difficulty), + txids: Array.isArray(payload.txids) ? payload.txids.filter((id): id is string => typeof id === 'string') : [], + page: formatExactInteger( + integerText(pagination.pageAtomic) ?? integerText(pagination.page) + ), + totalPages: formatExactInteger( + integerText(pagination.totalPagesAtomic) ?? + integerText(pagination.totalPages) + ), + }; +} + +export interface UtxoRow { + readonly txid: string; + readonly vout: string; + readonly amount: ExactNumber | null; + readonly height: ExactNumber | null; + readonly confirmations: ExactNumber | null; +} + +export interface AddressReading { + readonly address: string; + readonly balance: ExactNumber | null; + readonly totalReceived: ExactNumber | null; + readonly totalSent: ExactNumber | null; + readonly unconfirmedBalance: ExactNumber | null; + readonly transactionCount: ExactNumber | null; + readonly unconfirmedCount: ExactNumber | null; + readonly txids: readonly string[]; + readonly utxos: readonly UtxoRow[]; +} + +export function readAddress( + payload: ChainExplorerPayload, + profile: ChainProfile +): AddressReading | null { + if (classifyPayload(payload) !== 'address') { + return null; + } + const utxos = Array.isArray(payload.utxos) ? payload.utxos.filter(isRecord) : []; + return { + address: text(payload.address) ?? '', + balance: formatAtomicAmount(text(payload.balanceAtomic), profile.precision), + totalReceived: formatAtomicAmount(text(payload.totalReceivedAtomic), profile.precision), + totalSent: formatAtomicAmount(text(payload.totalSentAtomic), profile.precision), + unconfirmedBalance: formatAtomicAmount(text(payload.unconfirmedBalanceAtomic), profile.precision), + transactionCount: formatExactInteger(text(payload.transactionCountAtomic)), + unconfirmedCount: formatExactInteger(text(payload.unconfirmedTransactionsAtomic)), + txids: Array.isArray(payload.txids) ? payload.txids.filter((id): id is string => typeof id === 'string') : [], + utxos: utxos.map((entry) => ({ + txid: text(entry.txid) ?? '', + vout: text(entry.voutAtomic) ?? '', + amount: formatAtomicAmount(text(entry.valueAtomic), profile.precision), + height: formatExactInteger(text(entry.heightAtomic)), + confirmations: formatExactInteger(text(entry.confirmationsAtomic)), + })), + }; +} + +export interface OutpointReading { + readonly txid: string; + readonly vout: string; + readonly amount: ExactNumber | null; + readonly address: string | null; + readonly spent: boolean | null; +} + +export function readOutpoint( + payload: ChainExplorerPayload, + profile: ChainProfile +): OutpointReading | null { + if (classifyPayload(payload) !== 'outpoint' || !isRecord(payload.outpoint)) { + return null; + } + const output = isRecord(payload.output) ? payload.output : {}; + return { + txid: text(payload.outpoint.txid) ?? '', + vout: text(payload.outpoint.voutAtomic) ?? '', + amount: formatAtomicAmount(text(output.valueAtomic), profile.precision), + address: text(output.address), + spent: typeof output.spent === 'boolean' ? output.spent : null, + }; +} + +// --------------------------------------------------------------------------- +// Collections +// --------------------------------------------------------------------------- + +export interface CollectionColumn { + readonly key: string; + readonly label: string; + readonly numeric: boolean; + /** Named once in the header rather than repeated in every cell. */ + readonly unit: string | null; +} + +export interface CollectionCell { + readonly key: string; + readonly fact: Fact | null; +} + +export interface CollectionReading { + /** The field the rows came from, so the page can name what it is listing. */ + readonly sourceKey: string; + readonly title: string; + readonly columns: readonly CollectionColumn[]; + readonly rows: readonly (readonly CollectionCell[])[]; + readonly shownCount: number; + readonly totalCount: number; +} + +/** How many rows a page renders before it says how many it is holding back. */ +export const COLLECTION_ROW_LIMIT = 100; +/** How many columns stay legible on a phone before the table stops helping. */ +const COLLECTION_COLUMN_LIMIT = 7; + +/** + * The longest array in the payload, read as a table. + * + * Columns are the union of the keys across the rows shown, in first-seen + * order, so a row that omits a field leaves a blank cell rather than shifting + * every column after it. That was the failure of taking the first row's first + * eight keys: two responses with slightly different rows produced two tables + * whose columns did not line up with their headers. + */ +export function readCollection( + payload: ChainExplorerPayload, + profile: ChainProfile +): CollectionReading | null { + if (!isRecord(payload)) { + return null; + } + let sourceKey = ''; + let source: unknown[] = []; + for (const [key, value] of Object.entries(payload)) { + if (Array.isArray(value) && value.length > source.length) { + sourceKey = key; + source = value; + } + } + if (!source.length) { + return null; + } + + const shown = source.slice(0, COLLECTION_ROW_LIMIT); + const scalarRows = shown.map((entry) => + isRecord(entry) ? entry : { value: entry } + ); + + const order: string[] = []; + for (const row of scalarRows) { + for (const [key, value] of Object.entries(row)) { + if (value !== null && typeof value === 'object') { + continue; + } + if (!order.includes(key)) { + order.push(key); + } + } + } + const columns = order.slice(0, COLLECTION_COLUMN_LIMIT).map((key) => ({ + key, + label: humanizeFieldName(key), + numeric: isCoinAmount(key) || /Atomic$/.test(key), + unit: isCoinAmount(key) ? profile.ticker : null, + })); + + return { + sourceKey, + title: humanizeFieldName(sourceKey), + columns, + rows: scalarRows.map((row) => + columns.map((column) => ({ + key: column.key, + fact: factFrom(column.key, row[column.key], profile), + })) + ), + shownCount: shown.length, + totalCount: source.length, + }; +} + +/** + * A list of plain identifier strings, which is how block transaction lists and + * address history arrive. Kept apart from {@link readCollection} because a + * column of hashes is a navigation list, not a table. + */ +export function readIdentifierList(values: unknown): readonly string[] { + return Array.isArray(values) + ? values.filter((value): value is string => typeof value === 'string') + : []; +} diff --git a/scripts/universe/visual-qa/capture.mjs b/scripts/universe/visual-qa/capture.mjs index fe3598bb895..87d2f59440b 100644 --- a/scripts/universe/visual-qa/capture.mjs +++ b/scripts/universe/visual-qa/capture.mjs @@ -35,6 +35,7 @@ import { fileURLToPath } from 'node:url'; import AxeBuilder from '@axe-core/playwright'; import * as playwright from 'playwright'; import { addressFixtures, detailFixtures, fixtures, sampleIds, stateOverrides } from './fixtures.mjs'; +import { chainFixtures, chainSampleIds, chainStateOverrides, chainStateScope } from './chain-fixtures.mjs'; import { contrastProbe } from './contrast-probe.mjs'; import { progressProbe } from './progress-probe.mjs'; @@ -66,6 +67,21 @@ const ROUTES = [ { id: 'mining', path: '/mining', name: 'Mining dashboard' }, { id: 'docs', path: '/docs/api', name: 'API docs' }, { id: 'source', path: '/source', name: 'Source and licenses' }, + + // The chain-domain routes. They shipped with no coverage here at all, which + // is how eleven routes reached production without one screenshot, contrast + // probe or unfinished-page check ever looking at them. + { id: 'dogecoin', path: '/dogecoin', name: 'Dogecoin overview' }, + { id: 'dogecoin-mempool', path: '/dogecoin/mempool', name: 'Dogecoin pending' }, + { id: 'dogecoin-tx', path: `/dogecoin/tx/${chainSampleIds.DOGE_TXID}`, name: 'Dogecoin transaction' }, + { id: 'dogecoin-block', path: `/dogecoin/block/${chainSampleIds.DOGE_BLOCK}`, name: 'Dogecoin block' }, + { id: 'dogecoin-address', path: `/dogecoin/address/${chainSampleIds.DOGE_ADDRESS}`, name: 'Dogecoin address' }, + { id: 'dogecoin-protocols', path: '/dogecoin/protocols', name: 'Dogecoin protocols' }, + { id: 'dogecoin-drc20', path: '/dogecoin/protocols/drc20', name: 'DRC-20 assets' }, + { id: 'zcash', path: '/zcash', name: 'Zcash overview' }, + { id: 'zcash-mempool', path: '/zcash/mempool', name: 'Zcash pending' }, + { id: 'zcash-tx', path: `/zcash/tx/${chainSampleIds.ZEC_TXID}`, name: 'Zcash transaction' }, + { id: 'zcash-protocols', path: '/zcash/protocols', name: 'Zcash protocols' }, ]; const VIEWPORTS = [ @@ -95,7 +111,15 @@ const SETTLE_DEADLINE_MS = 15_000; */ const FAILURE_SETTLE_DEADLINE_MS = 4_000; -const STATES = ['populated', ...Object.keys(stateOverrides)]; +const ALL_OVERRIDES = { ...stateOverrides, ...chainStateOverrides }; + +const STATES = ['populated', ...Object.keys(ALL_OVERRIDES)]; + +/** True when a state is worth measuring on a route. Unscoped states run everywhere. */ +function stateApplies(state, routeId) { + const scope = chainStateScope[state]; + return !scope || scope.some((prefix) => routeId === prefix || routeId.startsWith(`${prefix}-`)); +} function pick(list, key, idKey = 'id') { if (!args[key]) return list; @@ -105,8 +129,8 @@ function pick(list, key, idKey = 'id') { /** Answer every API call from fixtures, applying the state's overrides. */ async function installFixtures(context, state) { - const overrides = stateOverrides[state] || {}; - const table = { ...fixtures, ...detailFixtures, ...addressFixtures }; + const overrides = ALL_OVERRIDES[state] || {}; + const table = { ...fixtures, ...detailFixtures, ...addressFixtures, ...chainFixtures }; await context.route('**/api/**', async (route) => { const url = new URL(route.request().url()); @@ -304,6 +328,7 @@ async function run() { }, theme); for (const route of routes) { + if (!stateApplies(state, route.id)) continue; const page = await context.newPage(); const consoleErrors = []; page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); }); @@ -474,7 +499,16 @@ async function run() { * transaction and address joined once their waiting and failure states said * something instead of holding a bare placeholder. */ -export const GATED_ROUTES = new Set(['graphs', 'mining', 'protocols', 'home', 'blocks', 'tx', 'address']); +export const GATED_ROUTES = new Set([ + 'graphs', 'mining', 'protocols', 'home', 'blocks', 'tx', 'address', + // The chain routes join once their status rail, their failure copy and their + // empty state all say something. Every one of them reaches a terminal state: + // the rail renders all five readings even with no status at all, and a + // failed lookup prints why rather than waiting. + 'dogecoin', 'dogecoin-mempool', 'dogecoin-tx', 'dogecoin-block', + 'dogecoin-address', 'dogecoin-protocols', 'dogecoin-drc20', + 'zcash', 'zcash-mempool', 'zcash-tx', 'zcash-protocols', +]); /** * Fixtures that hold a request open on purpose, to photograph a wait. diff --git a/scripts/universe/visual-qa/chain-fixtures.mjs b/scripts/universe/visual-qa/chain-fixtures.mjs new file mode 100644 index 00000000000..5d3e0ef12bc --- /dev/null +++ b/scripts/universe/visual-qa/chain-fixtures.mjs @@ -0,0 +1,340 @@ +// Deterministic fixtures for the Dogecoin and Zcash explorer pages. +// +// These pages shipped with no visual coverage at all: the matrix knew about +// thirteen Bitcoin routes and nothing else, so eleven chain routes reached +// production without a single screenshot, contrast probe or unfinished-page +// check ever looking at them. +// +// They live in their own file rather than in fixtures.mjs because they answer +// a different API surface (the chain-domain routes the overlay owns) and +// because the states worth pinning here are different: a chain behind its own +// tip, an authority that is degraded rather than down, and a Zcash transaction +// whose shielded side cannot be read at all. Those three are the states the +// interface has to be honest about, and none of them exist on the Bitcoin +// routes. + +const DOGE_TXID = '4c8f0b2e6a1d5937c0f4b8e2a6d0c4f8b2e6a0d4c8f2b6e0a4d8c2f6b0e4a8d2'; +const DOGE_BLOCK = 'e1b5f9d3a7c2064e8b1d5f9a3c7e0246b8d2f6a0c4e8b2d6f0a4c8e2b6d0f4a8'; +const DOGE_ADDRESS = 'DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L'; +const ZEC_TXID = '7a3e1c5f9b2d6048a2c6e0f4b8d2a6c0e4f8b2d6a0c4e8f2b6d0a4c8e2f6b0d4'; + +/** + * A chain capability envelope. Dogecoin is at its tip and complete; Zcash is + * behind and partial, which is the honest state a reader has to be able to + * tell apart from a chain that is simply down. + */ +function capability(chain, overrides = {}) { + const asset = chain === 'dogecoin' + ? { symbol: 'DOGE', name: 'Dogecoin', precision: 8, atomicUnit: 'koinu' } + : { symbol: 'ZEC', name: 'Zcash', precision: 8, atomicUnit: 'zatoshi' }; + const observedAt = new Date(Date.now() - 20_000).toISOString(); + return { + schemaVersion: 'universe-chain-capability-v1', + chain, + network: 'mainnet', + asset, + ready: true, + tip: { + heightAtomic: chain === 'dogecoin' ? '5623041' : '2884120', + blockHash: chain === 'dogecoin' ? DOGE_BLOCK : 'f'.repeat(64), + observedAt, + }, + sync: { state: 'ready', initialBlockDownload: false, progressDecimal: '1', updatedAt: observedAt }, + mempool: { + supported: true, + state: 'ready', + completeness: 'complete', + snapshotId: 'snap-4812', + sequenceAtomic: '148201', + observedAt, + }, + reads: { + transaction: true, + block: chain === 'dogecoin', + address: true, + outpoint: true, + feeEstimates: false, + projectedBlocks: false, + }, + protocols: chain === 'dogecoin' + ? [ + { protocolId: 'doginals', state: 'ready', coverage: 'complete', updatedAt: observedAt, lagBlocksAtomic: '0', degradedReasons: [] }, + { protocolId: 'drc20', state: 'ready', coverage: 'complete', updatedAt: observedAt, lagBlocksAtomic: '0', degradedReasons: [] }, + { protocolId: 'doge-tap', state: 'degraded', coverage: 'partial', updatedAt: observedAt, lagBlocksAtomic: '184', degradedReasons: ['authority behind chain tip'] }, + ] + : [ + { protocolId: 'zerdinals', state: 'ready', coverage: 'partial', updatedAt: observedAt, lagBlocksAtomic: '12', degradedReasons: [] }, + { protocolId: 'zrunes', state: 'ready', coverage: 'partial', updatedAt: observedAt, lagBlocksAtomic: '12', degradedReasons: [] }, + { protocolId: 'zrc20', state: 'degraded', coverage: 'partial', updatedAt: observedAt, lagBlocksAtomic: '12', degradedReasons: ['two rulesets disagree below this height'] }, + ], + coverage: { + confirmedHistory: chain === 'dogecoin' ? 'complete' : 'partial', + addressHistory: chain === 'dogecoin' ? 'complete' : 'partial', + protocolHistory: 'partial', + }, + updatedAt: observedAt, + lagBlocksAtomic: chain === 'dogecoin' ? '0' : '12', + degradedReasons: [], + release: { sha: 'a1b2c3d' }, + ...overrides, + }; +} + +/** A confirmed Dogecoin transaction: two inputs, a payment and a change output. */ +function dogecoinTransaction() { + return { + schemaVersion: 'universe-transaction-v1', + chain: 'dogecoin', + network: 'mainnet', + txid: DOGE_TXID, + status: 'confirmed', + firstSeenAt: '2026-08-29T03:58:12.000Z', + observedAt: '2026-08-29T04:02:00.000Z', + confirmedAt: '2026-08-29T04:02:00.000Z', + removedAt: null, + block: { hash: DOGE_BLOCK, heightAtomic: '5623038', time: '2026-08-29T04:02:00.000Z' }, + confirmationsAtomic: '3', + sizeBytesAtomic: '486', + fee: { amountAtomic: '113400000', rateDecimal: '2.33', rateUnit: 'DOGE/kB', model: 'per-kilobyte' }, + conflicts: [], + replacement: null, + expiry: null, + transparent: { + inputs: [ + { indexAtomic: '0', previousOutpoint: `${DOGE_TXID}:1`, address: DOGE_ADDRESS, valueAtomic: '480000000000', coinbase: false }, + { indexAtomic: '1', previousOutpoint: `${DOGE_TXID}:0`, address: 'DKuVPFPZUwMcNCU9hYzB4Mp3cRVJPuZLyq', valueAtomic: '120000000000', coinbase: false }, + ], + outputs: [ + { indexAtomic: '0', address: 'DPMFn6Yv9GsuTPZWCbTFmZ9UWjjjjcYfWr', valueAtomic: '525000000000', spent: false }, + { indexAtomic: '1', address: DOGE_ADDRESS, valueAtomic: '74886600000', spent: true }, + ], + }, + shielded: null, + protocolActions: { + candidates: [], + confirmed: [ + { eventId: 'doginals:1', protocolId: 'doginals', state: 'confirmed-accepted', actionType: 'inscribe', evidenceIds: ['ord-dogecoin:5623038'] }, + { eventId: 'drc20:1', protocolId: 'drc20', state: 'confirmed-rejected', actionType: 'mintOverCap', evidenceIds: ['ord-dogecoin:5623038'] }, + ], + }, + evidenceIds: ['dogecoin-blockbook:transaction'], + completeness: 'complete', + }; +} + +/** + * A Zcash transaction with both a transparent and a shielded side, still in + * the pending set. It is the one fixture that proves the page reports shielded + * structure without reporting shielded participants. + */ +function zcashTransaction() { + return { + schemaVersion: 'universe-transaction-v1', + chain: 'zcash', + network: 'mainnet', + txid: ZEC_TXID, + status: 'observed', + firstSeenAt: '2026-08-29T05:01:44.000Z', + observedAt: '2026-08-29T05:02:10.000Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '2104', + fee: { amountAtomic: '20000', rateDecimal: null, rateUnit: null, logicalActionsAtomic: '4', model: 'zip-317' }, + conflicts: [], + replacement: null, + expiry: { heightAtomic: '2884160', state: 'pending' }, + transparent: { + inputs: [ + { indexAtomic: '0', previousOutpoint: `${ZEC_TXID}:0`, address: 't1KrGGkMTgVTNaAmXcnCrMTgVTNaAmXcnCr', valueAtomic: '250000000', coinbase: false }, + ], + outputs: [ + { indexAtomic: '0', address: 't1SoMeTransparentOutputAddressXXXXX', valueAtomic: '99980000', spent: null }, + ], + }, + shielded: { + sproutJoinSplitsAtomic: '0', + saplingSpendsAtomic: '0', + saplingOutputsAtomic: '0', + orchardActionsAtomic: '4', + ironwoodActionsAtomic: '0', + valueBalanceAtomic: '-150000000', + privacyNotice: + 'Orchard actions hide their sender, recipient and amount. This explorer reports how many there are and nothing else about them.', + }, + protocolActions: { + candidates: [ + { eventId: 'zerdinals:c1', protocolId: 'zerdinals', state: 'candidate', actionType: 'inscribe', evidenceIds: [] }, + ], + confirmed: [], + }, + evidenceIds: ['zcash-indexer:mempool'], + completeness: 'partial', + }; +} + +export const chainFixtures = { + '/api/v1/chains': [capability('bitcoin'), capability('dogecoin'), capability('zcash')], + + '/api/v1/dogecoin/status': capability('dogecoin'), + '/api/v1/zcash/status': capability('zcash'), + + [`/api/v1/dogecoin/tx/${DOGE_TXID}`]: dogecoinTransaction(), + [`/api/v1/zcash/tx/${ZEC_TXID}`]: zcashTransaction(), + + [`/api/v1/dogecoin/block/${DOGE_BLOCK}`]: { + chain: 'dogecoin', + network: 'mainnet', + block: { + hash: DOGE_BLOCK, + heightAtomic: '5623038', + confirmationsAtomic: '3', + sizeBytesAtomic: '41882', + time: '2026-08-29T04:02:00.000Z', + previousBlockHash: '9c3e7a1b5d9f2064c8e2a6d0f4b8c2e6a0d4f8b2c6e0a4d8f2b6c0e4a8d2f6b0', + nextBlockHash: null, + merkleRoot: '2f6b0d4a8c2e6f0b4d8a2c6e0f4b8d2a6c0e4f8b2d6a0c4e8f2b6d0a4c8e2f6b', + versionAtomic: '6422788', + nonce: '0', + bits: '1a0f2b8c', + difficulty: '52418844.4218', + transactionCountAtomic: '112', + }, + pagination: { pageAtomic: '1', totalPagesAtomic: '3', itemsOnPageAtomic: '50' }, + txids: [DOGE_TXID], + }, + + [`/api/v1/dogecoin/address/${DOGE_ADDRESS}`]: { + chain: 'dogecoin', + network: 'mainnet', + address: DOGE_ADDRESS, + balanceAtomic: '1284460000000', + totalReceivedAtomic: '9930000000000', + totalSentAtomic: '8645540000000', + unconfirmedBalanceAtomic: '0', + unconfirmedTransactionsAtomic: '0', + transactionCountAtomic: '318', + historicalTransactionCountAtomic: '318', + pagination: { pageAtomic: '1', totalPagesAtomic: '7', itemsOnPageAtomic: '50' }, + txids: [DOGE_TXID], + utxos: [ + { txid: DOGE_TXID, voutAtomic: '1', valueAtomic: '74886600000', heightAtomic: '5623038', confirmationsAtomic: '3', address: DOGE_ADDRESS, lockTimeAtomic: '0', coinbase: false }, + { txid: DOGE_BLOCK, voutAtomic: '0', valueAtomic: '1209573400000', heightAtomic: '5620112', confirmationsAtomic: '2929', address: DOGE_ADDRESS, lockTimeAtomic: '0', coinbase: false }, + ], + }, + + '/api/v1/dogecoin/mempool': { + chain: 'dogecoin', + network: 'mainnet', + snapshotId: 'snap-4812', + sequenceAtomic: '148201', + observedAt: new Date(Date.now() - 9_000).toISOString(), + completeness: 'complete', + transactions: [ + { txid: DOGE_TXID, firstSeenAt: '2026-08-29T05:03:00.000Z', sizeBytesAtomic: '486', feeAtomic: '113400000', state: 'observed' }, + { txid: DOGE_BLOCK, firstSeenAt: '2026-08-29T05:03:41.000Z', sizeBytesAtomic: '225', feeAtomic: '50000000', state: 'observed' }, + ], + }, + + '/api/v1/zcash/mempool': { + chain: 'zcash', + network: 'mainnet', + snapshotId: 'snap-2210', + sequenceAtomic: '22104', + observedAt: new Date(Date.now() - 14_000).toISOString(), + completeness: 'partial', + transactions: [ + { txid: ZEC_TXID, firstSeenAt: '2026-08-29T05:01:44.000Z', sizeBytesAtomic: '2104', feeAtomic: '20000', state: 'observed' }, + ], + }, + + '/api/v1/dogecoin/protocols': { + chain: 'dogecoin', + network: 'mainnet', + items: [ + { id: 'doginals', displayName: 'Doginals', family: 'inscriptions', releaseStatus: 'stable' }, + { id: 'drc20', displayName: 'DRC-20', family: 'tokens', releaseStatus: 'stable' }, + { id: 'doge-tap', displayName: 'Doge TAP', family: 'tokens', releaseStatus: 'beta' }, + ], + confirmedHistory: { configured: true, state: 'ready', lastSuccessAt: '2026-08-29T05:03:00.000Z' }, + mempool: { configured: true, state: 'ready' }, + protocolAuthority: { configured: true, state: 'ready' }, + dogeTapAuthority: { configured: true, state: 'degraded' }, + }, + + '/api/v1/zcash/protocols': { + chain: 'zcash', + network: 'mainnet', + items: [ + { id: 'zerdinals', displayName: 'Zerdinals', family: 'inscriptions', releaseStatus: 'beta' }, + { id: 'zrunes', displayName: 'ZRunes', family: 'tokens', releaseStatus: 'beta' }, + { id: 'zrc20', displayName: 'ZRC-20', family: 'tokens', releaseStatus: 'beta' }, + ], + status: { configured: true, state: 'ready', lagBlocksAtomic: '12' }, + }, + + '/api/v1/dogecoin/protocols/drc20': { + chain: 'dogecoin', + network: 'mainnet', + items: [ + { tick: 'doge', supplyAtomic: '100000000000', mintedAtomic: '100000000000', holdersAtomic: '18422', deployHeightAtomic: '4610221' }, + { tick: 'wow', supplyAtomic: '21000000', mintedAtomic: '18400000', holdersAtomic: '2044', deployHeightAtomic: '4812009' }, + { tick: 'such', supplyAtomic: '5000000', mintedAtomic: '5000000', holdersAtomic: '891', deployHeightAtomic: '4901884' }, + ], + cursor: '3', + completeness: 'complete', + }, +}; + +export const chainStateOverrides = { + // Both chain authorities are unreachable. The page must say the status is + // unavailable and must not present an empty overview as a working one. + 'chain-authority-down': { + '/api/v1/dogecoin/status': { status: 503 }, + '/api/v1/zcash/status': { status: 503 }, + '/api/v1/dogecoin/mempool': { status: 503 }, + '/api/v1/zcash/mempool': { status: 503 }, + }, + + // The authority answers, and says it is behind. This is the state the whole + // status rail exists for, and it is not a failure: every figure on the page + // is true as of a block that is not the tip, and the page has to say so. + 'chain-behind': { + '/api/v1/dogecoin/status': { + body: capability('dogecoin', { + ready: false, + lagBlocksAtomic: '2841', + sync: { state: 'degraded', initialBlockDownload: false, progressDecimal: '0.9994', updatedAt: new Date(Date.now() - 20_000).toISOString() }, + coverage: { confirmedHistory: 'partial', addressHistory: 'partial', protocolHistory: 'partial' }, + degradedReasons: ['confirmed history authority is behind the chain tip'], + }), + }, + }, + + // The object simply is not there. A 404 from the authority is an answer, not + // an outage, and the page must not describe it as one. + 'chain-object-missing': { + [`/api/v1/dogecoin/tx/${DOGE_TXID}`]: { status: 404 }, + [`/api/v1/dogecoin/block/${DOGE_BLOCK}`]: { status: 404 }, + [`/api/v1/dogecoin/address/${DOGE_ADDRESS}`]: { status: 404 }, + }, +}; + +/** + * The chain failure states describe the chain-domain API, so running them + * against a Bitcoin route measures nothing and costs a page load in every + * theme and at every width. Each one names the route prefixes it applies to, + * and the matrix skips the rest. + * + * Only the states declared here are scoped. Every state without an entry + * still runs against every route, which is what the Bitcoin states have + * always done. + */ +export const chainStateScope = { + 'chain-authority-down': ['dogecoin', 'zcash'], + 'chain-behind': ['dogecoin'], + 'chain-object-missing': ['dogecoin'], +}; + +export const chainSampleIds = { DOGE_TXID, DOGE_BLOCK, DOGE_ADDRESS, ZEC_TXID }; From ded4ae8da53e730b227e83c921fa6df4a87508a4 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 06:34:32 +0000 Subject: [PATCH 02/45] Say in public that this is a three-chain explorer The README described a Bitcoin explorer. It has not been one since PR 11, and a reader arriving from GitHub had no way to learn that Dogecoin and Zcash are first-class here, what each chain can actually answer, or why the three are not interchangeable. The README now leads with all three chains and carries the capability table the product itself renders, including the three facts that follow from it: Dogecoin fees are quoted per kilobyte and never relabelled as sat/vB, Zcash fee guidance follows ZIP-317 logical actions, and only the transparent side of Zcash is public. It also records the gateway route table, which is what makes the chain surfaces reachable at all, and the design-system gates, which were running in CI without being mentioned anywhere a contributor would look. Two screenshots, taken by the review harness against its fixed review data and captioned as such, so nothing in them reads as a claim about live chain activity. Alt text describes what the image shows rather than naming it. The experience brief is amended rather than rewritten: the promise loses the word Bitcoin, and a new section states the five rules three chains imposed. Chains are not interchangeable. Not offered and not stated are different answers. Units are the chain's own. Privacy is a product boundary, not a gap to fill. Identifiers do not carry between chains. The design system gains the two surfaces the chain work introduced, the chain mark and the status rail, plus the numbers rule that decides when a figure may be shifted at all, and the sixth entry in the fixtures note: a whole surface that had no fixture and therefore no review. Co-Authored-By: Claude Opus 5 --- README.md | 136 ++++++++++++++++-- docs/product/DESIGN-SYSTEM.md | 69 +++++++++ docs/product/EXPERIENCE-BRIEF.md | 46 +++++- .../product/screenshots/dogecoin-overview.png | Bin 0 -> 28474 bytes .../screenshots/transaction-asset-flow.png | Bin 0 -> 49147 bytes 5 files changed, 231 insertions(+), 20 deletions(-) create mode 100644 docs/product/screenshots/dogecoin-overview.png create mode 100644 docs/product/screenshots/transaction-asset-flow.png diff --git a/README.md b/README.md index f50150b732f..bcd0ff913b0 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,93 @@ # Universe Explorer -The Bitcoin Universe mempool and block explorer. It shows what is happening on -Bitcoin right now, and what transactions actually do across Bitcoin Universe -protocols, with the evidence behind every claim. +A block and mempool explorer for Bitcoin, Dogecoin, and Zcash that also reads +the asset protocols carried on them, and states the evidence behind every claim +it makes. Live at [explorer.bitcoinuniverse.io](https://explorer.bitcoinuniverse.io). +![A Bitcoin transaction page. Above the fold: the projected and confirmed +blocks, the transaction identifier with its confirmation count, fee and fee +rate. Below: a Universe Asset Flow panel marked Complete evidence, showing a +Runes transfer from one input to one output with the exact quantities and a +link to the evidence behind each side.](docs/product/screenshots/transaction-asset-flow.png) + ## What makes it different -Most explorers answer one of two questions. A mempool explorer tells you what -is pending and what it will cost to confirm. A protocol explorer tells you what -assets exist. Universe Explorer answers both in one place, and it never guesses. +Most explorers answer one of two questions. A mempool explorer tells you what is +pending and what it will cost to confirm. A protocol explorer tells you what +assets exist. Universe Explorer answers both, on three chains, and it never +guesses. - **Exact asset flows.** A transaction page names the inputs and outputs that carry protocol assets, the action the authority reported, and the block that proves it. Nothing is inferred from transaction shape. - **Outputs are first class.** `/outpoint/:txid/:vout` is a real page. An output - is the unit that carries assets on Bitcoin, so it gets its own address. + is the unit that carries assets, so it gets its own address. - **States that mean something.** Proven, partly proven, outside coverage, pending, and unavailable are five different answers. A missing indexer never - becomes a false zero. + becomes a false zero, and an authority that did not answer is never reported + as one that answered nothing. +- **Every page says how current it is.** Chain pages carry a status rail with + the same five readings in the same order: chain state, chain tip, blocks + behind the tip, when the reading was taken, and how complete the pending set + is. A figure that is true as of a block that is not the tip says so. +- **Exact numbers, end to end.** Amounts cross the API as decimal strings and + are shifted by string arithmetic, never parsed into a floating point number. + Every rendered figure keeps the exact value it came from. - **Live protocol activity, measured.** The pulse page publishes its own denominator: how many arriving transactions were checked, and how many carried each protocol. Every number on it can be reproduced. - **No trackers, no accounts.** Search is matched in your browser. Saved pages and history live in local storage and never leave the device. - **First-party data only.** Every figure comes from Bitcoin Universe's own - node, Electrum index, and protocol authorities. + nodes, indexes, and protocol authorities. + +## Three chains, and what each can answer + +Chains are not interchangeable, and the interface does not pretend they are. The +chain selector switches the whole explorer, and each chain declares what it can +answer at `/api/v1//status`. The overview page renders that declaration +rather than offering a page for a lookup the indexer never claimed. + +| | Bitcoin | Dogecoin | Zcash | +| --- | --- | --- | --- | +| Transaction, block, address, outpoint | yes | yes | yes | +| Pending transactions | yes | yes | yes | +| Projected blocks | yes | no | no | +| Fee unit | sat/vB | per kilobyte | ZIP-317 logical actions | +| Protocol families | Ordinals, Rare Sats, Runes, and the rest of the registry | Doginals, DRC-20, Doge TAP | Zerdinals, ZRunes, ZRC-20 | + +Three things follow from that table, and they are visible on the pages: + +- Dogecoin fees are quoted per kilobyte. The explorer prints the unit the chain + actually uses and shows a fee rate only when the first-party source reports + one. Nothing is relabelled as sat/vB. +- Zcash fee guidance follows ZIP-317 logical actions where the node reports + them, because a transaction's cost there is not a function of its byte size. +- Only the transparent side of Zcash is public. A Zcash transaction page reports + the shape of the shielded side, how many Sprout joinsplits, Sapling spends and + outputs, and Orchard actions it contains, and the one amount the chain itself + makes public: the net movement between the transparent and shielded pools. It + never infers a shielded sender, recipient, or amount. + +Object identifiers do not carry between chains, so switching chains from an +object page lands on that chain's overview and says why, rather than looking up +a Bitcoin transaction id on Dogecoin. + +![The Dogecoin overview page. A status rail reads Ready, block 5,623,041, zero +blocks behind, observed 39 seconds ago, complete pending coverage. Below it a +panel titled Questions this chain can answer marks transaction, block, address +and outpoint lookup as offered and fee estimates and projected blocks as not +offered. A protocol indexers panel lists Doginals and DRC-20 as ready with +complete history, and Doge TAP as degraded, 184 blocks behind, because its +authority is behind the chain +tip.](docs/product/screenshots/dogecoin-overview.png) + +Both screenshots are taken by the review harness in +`scripts/universe/visual-qa`, against fixed review data rather than the live +chain, so they show the interface exactly as it renders and no figure in them is +a claim about real chain activity. ## Protocol coverage @@ -37,14 +98,14 @@ explorer states plainly which ones it can actually read. | --- | --- | | Live, read only | A first-party authority is running and its evidence is shown. | | Not yet available | No first-party authority for it is configured or answering here. The explorer makes no claim about it. | -| Different chain | The protocol lives on a chain this explorer does not serve. | Live today, backed by the first-party Ord 0.29 authority: **Ordinals**, **Rare Sats**, **Runes**. `docs/protocols/PROTOCOL-COVERAGE.md` is generated from the registry and lists -every entry with its authority. Run `node scripts/universe/generate-protocol-coverage.mjs --check` -to verify the table still matches. +every entry with its authority and its release status. Run +`node scripts/universe/generate-protocol-coverage.mjs --check` to verify the +table still matches the recorded manifest. ## Architecture @@ -53,9 +114,17 @@ Three processes behind one HTTPS origin: | Component | What it is | | --- | --- | | Explorer backend | `backend/` in this repository. Reads Bitcoin Core, Fulcrum, and MariaDB. | -| Protocol overlay | `backend-apis` standalone service. Serves `/api/v1/universe/*` and holds every indexer credential server side. | +| Protocol overlay | `backend-apis` standalone service. Serves `/api/v1/universe/*` and the chain-domain surfaces, and holds every indexer credential server side. | | Frontend | `frontend/`, an Angular application served as static files with SPA fallback. | +`scripts/universe/gateway.mjs` is the single public entry point. It serves the +built frontend and splits `/api` between the two backends: the overlay owns +`/api/v1/universe/*`, `/api/v1/chains`, and the `/api/v1/bitcoin`, +`/api/v1/dogecoin` and `/api/v1/zcash` route families, and everything else goes +to the explorer backend. The route table is in +`docs/operations/DEPLOYMENT.md` and is held by +`node --test scripts/universe/gateway.test.mjs`. + The browser never talks to an indexer, and no indexer origin or credential is ever exposed to it. See `docs/architecture/` for the overlay design and `docs/data/ASSET-EVIDENCE.md` for the evidence contract. @@ -72,6 +141,23 @@ appears in the source or in a production bundle. The production build also skips asset synchronization, so nothing is downloaded from a third party at build time either; mining pool logos fall back to the bundled default. +## Design system + +One set of tokens in `frontend/src/styles/_universe-tokens.scss` carries colour, +type, space, motion, and layer for the whole product, in a light theme, a dark +theme, and a forced-contrast theme. Three rules hold it together, and each one +is measured rather than trusted: + +- Pink is the brand and only the brand. Nothing pink ever means "it worked". +- Colour carries evidence state, never protocol identity on its own. A protocol + hue always appears beside the protocol's name; an evidence state always + carries its word, so neither depends on anyone noticing a hue. +- A fill and a label are different roles. Every strong fill declares the ink + that goes on it, and `check-fills.mjs` fails the build if one does not. + +`docs/product/DESIGN-SYSTEM.md` is the full account. The gates that hold it are +in the testing section below. + ## Local development Requires Node 24.19.0 and npm 11.17.0, both pinned in `.nvmrc` and @@ -92,15 +178,37 @@ instead, set `MEMPOOL_BACKEND` in `frontend/proxy.conf.json`. ```bash cd frontend && npm run build:universe # production build, no third-party fetches -cd frontend && npm run test # Universe unit suite +cd frontend && npm run test # Universe unit suite cd frontend && npm run lint cd backend && npm run test:ci && npm run lint node scripts/universe/generate-protocol-coverage.mjs --check node scripts/universe/check-text.mjs # no em dash anywhere +node scripts/universe/check-colors.mjs # no raw interface colour +node scripts/universe/check-palettes.mjs # measured contrast, both themes +node scripts/universe/check-fills.mjs # every fill declares its ink node scripts/universe/check-branding.mjs # no obsolete upstream marks node scripts/universe/check-origins.mjs # no third-party data origins +node --test scripts/universe/gateway.test.mjs # public route table ``` +The visual matrix drives the built application across every route, theme, +viewport, and data state, and fails the run when a page never finishes loading, +when a chart panel draws nothing, or when a failure state says nothing about +why. Those three faults passed every other check in the list above and shipped +once, which is why the matrix exists. + +```bash +cd frontend && npm run build:universe +UNIVERSE_GATEWAY_PORT=8099 UNIVERSE_GATEWAY_ROOT=frontend/dist/mempool/browser \ + node scripts/universe/gateway.mjs & +cd scripts/universe/visual-qa && npm ci +node capture.mjs --base=http://127.0.0.1:8099 --routes=dogecoin,dogecoin-tx +``` + +Run it with no `--routes` for the full matrix. Every API call is answered from +the fixtures in `fixtures.mjs` and `chain-fixtures.mjs`, so a difference between +two runs means the interface changed, not that the chain moved. + The same checks run in `.github/workflows/universe-ci.yml` on the self-hosted runner fleet. The branding and origin gates also accept a built bundle path, and the release workflow runs them against `frontend/dist` before anything ships. diff --git a/docs/product/DESIGN-SYSTEM.md b/docs/product/DESIGN-SYSTEM.md index 3dfd0e5b1db..ddf729f9677 100644 --- a/docs/product/DESIGN-SYSTEM.md +++ b/docs/product/DESIGN-SYSTEM.md @@ -226,6 +226,19 @@ anchor rather than a washed-out outline, and it means a fee colour reads identically whichever theme someone is using. Text printed on a block face takes block ink, never page ink. +### Chains carry a mark, not a meaning + +A chain gets a small round mark beside its name, drawn from the protocol scale +(`--universe-protocol-*`). That scale is already held apart from every evidence +state and from the brand, and it is measured rather than judged, so a chain mark +can never be mistaken for a verdict. The mark never appears without the chain's +name beside it, which is the same rule protocol hues follow. + +Chain identity is otherwise carried by words and by the chain selector, not by +repainting the product. Dogecoin does not turn the interface gold and Zcash does +not turn it yellow: the tokens are the same on all three chains, because the +evidence vocabulary has to mean the same thing everywhere. + ## Type A system text face and a system data face, both resolved from the platform. @@ -252,6 +265,49 @@ above it. A transaction page leads with the hash, not with the word Panels state what they are, where they lead, and one line on why it matters. The note is what turns a number into something a newcomer can act on. +## The status rail + +Every chain page opens with the same instrument: five readings, always the same +five, always in this order. + +| Reading | Answers | +|---|---| +| Chain | Is this chain answering at all | +| Chain tip | Which block is the present | +| Behind tip | How far back the figures on this page are true as of | +| Last observed | When the reading was taken | +| Pending coverage | How complete the unconfirmed set is | + +Three rules hold it. + +- **The rail never loses a reading.** A reading whose fact is missing says so in + its own place. A rail with a hole in it reads as a page still loading, which + is exactly the wrong thing to communicate when the answer is that a fact is + unavailable. +- **Any lag at all is `partial`, never `proven`.** Zero blocks behind is the + only reading that earns the proven treatment, because a page describing a past + state of the chain is a partly proven page whatever its other figures say. +- **Not offered and not stated take different treatments.** A capability the + chain declined is `unavailable`; a capability nobody could ask about is + `neutral`. Collapsing them would report an unreachable authority as one that + answered. + +## Numbers + +Amounts cross the API as exact decimal strings and are never parsed into a +JavaScript number anywhere in the presentation path. Grouping and the decimal +shift are string operations, and every rendered figure keeps the exact source +string on its `title`, so the value a reader copies is the value the authority +sent. + +The rule that decides whether a figure may be shifted at all is an allowlist of +the fields that carry the chain's own coin, not a pattern over field names. A +pattern that matched `supply` once printed a DRC-20 token supply of +`100000000000` as `1,000 DOGE`, because a token's units are its own and have +nothing to do with koinu. Anything not on the allowlist is shown as the exact +integer it arrived as. Shifting an unknown quantity by eight places does not make +it more readable, it makes it wrong. + ## Writing Precise, direct, calm under uncertainty. Say what is true and no more. @@ -281,6 +337,8 @@ WCAG 2.2 AA is the floor, checked automatically rather than remembered. colour so a brand-coloured control still shows one. - Colour is never the only signal. - No critical route overflows horizontally at 320px. +- A region that scrolls with a mouse takes focus and shows it, so a wide table + is reachable from a keyboard. Three chain routes shipped without that. - Touch targets clear the minimum on the mobile navigation bar. `scripts/universe/visual-qa/capture.mjs` drives the whole route matrix across @@ -296,6 +354,8 @@ changed rather than the chain moving. | `frontend/src/styles/_universe-bootstrap-bridge.scss` | the inherited Bootstrap layer, repainted | | `frontend/src/theme-dark.scss`, `theme-contrast.scss` | the alternate themes | | `frontend/src/app/universe/_universe-tokens.scss` | Universe surface mixins, delegating to the tokens above | +| `frontend/src/app/universe/multichain-explorer/multichain-view.ts` | the chain presentation model: exact numbers, evidence tones, shape readings | +| `scripts/universe/visual-qa/chain-fixtures.mjs` | the chain states the matrix holds: at tip, behind tip, authority unreachable, object missing | | `scripts/universe/check-text.mjs` | the em dash gate, over source and over the built output | | `scripts/universe/check-colors.mjs` | no raw interface colour, including in style and attribute bindings | | `scripts/universe/check-palettes.mjs` | every dynamic palette, role separation, theme parity, retired values | @@ -319,3 +379,12 @@ the numbers were four orders of magnitude too small. A route that renders nothing passes every automated check. When a fixture is missing or wrong, the review it feeds is not weaker; it is absent, and it reports success. + +The sixth case was a whole surface rather than a defect. The Dogecoin and Zcash +routes shipped with no fixture and no route entry, so eleven pages reached +production without one screenshot, contrast probe or unfinished-page check ever +looking at them. They are in the matrix now, and the states they hold are the +ones the interface has to be honest about rather than the ones that are easy to +produce: a chain at its tip, a chain behind its tip, an authority that cannot be +reached, and an object that simply does not exist. Chain states are scoped to +chain routes, so the matrix does not pay for combinations that measure nothing. diff --git a/docs/product/EXPERIENCE-BRIEF.md b/docs/product/EXPERIENCE-BRIEF.md index 5bd8de7a13d..616ba9b4a1c 100644 --- a/docs/product/EXPERIENCE-BRIEF.md +++ b/docs/product/EXPERIENCE-BRIEF.md @@ -1,11 +1,12 @@ # Universe Explorer experience brief -Written 26 August 2026 to direct implementation. Every statement here is meant -to settle a build decision, not to describe an aspiration. +Written 26 August 2026 to direct implementation. Amended 29 August 2026, when +the product grew from one chain to three. Every statement here is meant to +settle a build decision, not to describe an aspiration. ## The promise -**See what a Bitcoin transaction actually does, with the proof attached.** +**See what a transaction actually does, with the proof attached.** Everything else follows from that sentence. If a screen cannot show what it proves, it does not ship. @@ -20,6 +21,39 @@ proves, it does not ship. | Analysts and researchers | "What is happening across protocols right now" | The pulse page, with a published denominator so the number can be checked. | | Operators self-hosting | "Can I run this and trust it" | One documented stack, first-party data only, source published on the site. | +## What three chains changed + +Bitcoin, Dogecoin, and Zcash are now all first-class in the same product. That +is one promise on three chains, not three products sharing a header, and five +rules follow from it. + +1. **Chains are not interchangeable, and the interface never pretends they + are.** Each chain declares what it can answer at `/api/v1//status`, + and the overview page renders that declaration. Where a chain does not offer + a lookup, the explorer offers no page for it rather than an empty one. +2. **Not offered and not stated are different answers.** An authority that + declined a read and an authority that never answered are not the same fact, + and a status rail that cannot reach its chain says so rather than reporting + six capabilities as absent. +3. **Units are the chain's own.** Dogecoin fees are quoted per kilobyte and are + never relabelled as sat/vB. Zcash fee guidance follows ZIP-317 logical + actions. A koinu is not a satoshi and a page that shifted one by the other's + precision would be printing a different number, not a friendlier one. +4. **Privacy is a product boundary, not a gap to fill.** Only the transparent + side of Zcash is public. A Zcash page reports the shape of the shielded side + and the single amount the chain itself makes public, the net movement between + the pools. It never infers a shielded sender, recipient, or amount, and it + never presents that limit as a temporary one. +5. **Identifiers do not carry between chains.** Switching chains from an object + page lands on that chain's overview and says why. Looking up a Bitcoin + transaction id on Dogecoin would produce a confident wrong answer. + +The status rail is the surface all five of these meet on. Five readings, always +the same five in the same order: chain state, chain tip, blocks behind the tip, +when the reading was taken, and how complete the pending set is. A reading whose +fact is missing says so in its own place rather than disappearing, because a +rail with a hole in it reads as a page still loading. + ## The emotional outcomes to design for 1. **Oriented in seconds.** Live data above the fold, one dominant search field, @@ -102,6 +136,6 @@ sighting, an unusual verified event. ## The one-sentence position -Universe Explorer is the only self-hostable, mempool-grade Bitcoin explorer that -shows exactly which outputs carry which protocol assets, with first-party -evidence and a stated block for every claim. +Universe Explorer is the only self-hostable, mempool-grade explorer for Bitcoin, +Dogecoin, and Zcash that shows exactly which outputs carry which protocol +assets, with first-party evidence and a stated block for every claim. diff --git a/docs/product/screenshots/dogecoin-overview.png b/docs/product/screenshots/dogecoin-overview.png new file mode 100644 index 0000000000000000000000000000000000000000..5d35a764deb561522fb5b6928615aa38994e2f64 GIT binary patch literal 28474 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 literal 0 HcmV?d00001 diff --git a/docs/product/screenshots/transaction-asset-flow.png b/docs/product/screenshots/transaction-asset-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..277d1a4712a7383a40464368640c4794323a2b75 GIT binary patch literal 49147 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 z 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 G^S=O89#*da literal 0 HcmV?d00001 From fd490f6e5c1bff2c5646161240fea2380031ace9 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 06:41:29 +0000 Subject: [PATCH 03/45] Give the paged lists a way to reach page two, and print times people can read Two dead ends the redesign made visible rather than fixed. A block page stated "Page 1 of 3" and offered no way to reach the other two. The address page did the same with a transaction history. The overlay has always accepted a page, so the fix is a real address: the requested page travels as a `page` query parameter, both lists share one pager, and a link to page three of a long history can be shared. readPaging refuses paging it cannot trust rather than linking nowhere. Timestamps were printed as the ISO-8601 the authority sends. That is precise and unreadable at speed: a reader has to parse punctuation to find the hour. They now read as "29 Aug 2026, 04:02 UTC" with the exact string on the title, built by hand rather than through Intl and always in UTC, because a block timestamp is a fact about the chain and two people describing the same block should not disagree about when it happened. Verified: 67 unit tests, lint, production build, and a scripted click through the pager confirming it produces /dogecoin/block/?page=2 and navigates there. Co-Authored-By: Claude Opus 5 --- .../multichain-explorer.component.html | 24 +++- .../multichain-explorer.component.scss | 15 +++ .../multichain-explorer.component.ts | 32 ++++- .../multichain-view.spec.ts | 52 +++++++- .../multichain-explorer/multichain-view.ts | 114 +++++++++++++++--- 5 files changed, 209 insertions(+), 28 deletions(-) diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html index c29431b1133..b31de7b5250 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html @@ -143,7 +143,7 @@

Status

First seen by our node
-
{{ tx.firstSeenAt }}
+
{{ tx.firstSeenAt.display }}
Size
@@ -309,7 +309,7 @@

Block

Mined
-
{{ blk.time }}
+
{{ blk.time.display }}
Transactions
@@ -343,14 +343,12 @@

Block

Transactions in this block

-

- Page {{ blk.page.display }} of {{ blk.totalPages.display }}. -

+
@@ -423,6 +421,7 @@

Transaction {{ txid }} + @@ -519,6 +518,21 @@

{{ collection.title }}

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

+ + + + +
The response this page was built from 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 0b5246db9b4..2f2dc422d3d 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss @@ -519,6 +519,21 @@ } } +.pager { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--u-space-4); + margin-top: var(--u-space-4); + padding-top: var(--u-space-3); + border-top: 1px solid var(--u-divider); + font-size: var(--u-text-sm); +} + +.pager-position { + color: var(--u-text-muted); +} + .block-nav { display: flex; flex-wrap: wrap; 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 001e11f3871..2d5ac9e7389 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts @@ -99,6 +99,18 @@ interface RequestContext { readonly page: MultichainPage; readonly params: ParamMap; readonly ruleset?: string; + /** 1-based list page, from the `page` query parameter. */ + readonly listPage: number; +} + +/** Rows a paged list requests at a time. Also the offset step. */ +const LIST_PAGE_SIZE = 100; + +function listPageFrom(value: string | null): number { + if (!value || !/^[1-9][0-9]{0,5}$/.test(value)) { + return 1; + } + return Number(value); } const PAGE_KINDS: Partial> = { @@ -179,6 +191,7 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { page: data.page as MultichainPage, params, ruleset: queryParams.get('ruleset') ?? undefined, + listPage: listPageFrom(queryParams.get('page')), })) ); @@ -324,6 +337,11 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { } } + /** Query parameters for a link to another page of the current list. */ + pageLink(page: number): Record { + return { page: String(page) }; + } + isProtocolPage(): boolean { return this.page.startsWith('protocol'); } @@ -380,9 +398,19 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { case 'transaction': return this.api.getChainTransaction$(this.chain, txid); case 'block': - return this.api.getChainBlock$(this.chain, reference); + return this.api.getChainBlock$( + this.chain, + reference, + LIST_PAGE_SIZE, + (context.listPage - 1) * LIST_PAGE_SIZE + ); case 'address': - return this.api.getChainAddress$(this.chain, reference); + return this.api.getChainAddress$( + this.chain, + reference, + LIST_PAGE_SIZE, + (context.listPage - 1) * LIST_PAGE_SIZE + ); case 'outpoint': return this.api.getChainOutpoint$( this.chain, diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts index 6303cf61fd3..b85a6caaeaa 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts @@ -9,6 +9,7 @@ import { formatAtomicAmount, formatElapsed, formatExactInteger, + formatTimestamp, historyLabel, humanizeFieldName, readAddress, @@ -16,6 +17,7 @@ import { readCapabilities, readCollection, readOutpoint, + readPaging, readProtocolCoverage, readRecordFacts, readStatusRail, @@ -136,6 +138,42 @@ describe('historyLabel', () => { }); }); +describe('formatTimestamp', () => { + it('reads at a glance and keeps the exact string', () => { + const moment = formatTimestamp('2026-08-29T04:02:00.000Z'); + expect(moment?.display).toBe('29 Aug 2026, 04:02 UTC'); + expect(moment?.exact).toBe('2026-08-29T04:02:00.000Z'); + }); + + it('always reports UTC, so two readers describe the same block the same way', () => { + expect(formatTimestamp('2026-01-05T23:07:09Z')?.display).toBe('5 Jan 2026, 23:07 UTC'); + }); + + it('returns nothing rather than an invented date', () => { + expect(formatTimestamp('not a date')).toBeNull(); + expect(formatTimestamp(null)).toBeNull(); + }); +}); + +describe('readPaging', () => { + it('offers the neighbouring pages that exist', () => { + expect(readPaging({ pageAtomic: '2', totalPagesAtomic: '3' })).toEqual({ + page: 2, totalPages: 3, previousPage: 1, nextPage: 3, + }); + }); + + it('offers no previous page on the first and no next on the last', () => { + expect(readPaging({ pageAtomic: '1', totalPagesAtomic: '3' })?.previousPage).toBeNull(); + expect(readPaging({ pageAtomic: '3', totalPagesAtomic: '3' })?.nextPage).toBeNull(); + }); + + it('rejects paging it cannot trust rather than linking nowhere', () => { + expect(readPaging({ pageAtomic: '0', totalPagesAtomic: '3' })).toBeNull(); + expect(readPaging({ pageAtomic: '2' })).toBeNull(); + expect(readPaging(null)).toBeNull(); + }); +}); + describe('humanizeFieldName', () => { it('drops the exact-transport suffix the contract uses', () => { expect(humanizeFieldName('heightAtomic')).toBe('Height'); @@ -398,15 +436,15 @@ describe('readBlock', () => { const block = readBlock(payload); expect(block?.height?.display).toBe('5,123,456'); expect(block?.transactionCount?.display).toBe('42'); - expect(block?.page?.display).toBe('1'); - expect(block?.totalPages?.display).toBe('2'); + expect(block?.time?.display).toBe('29 Aug 2026, 04:00 UTC'); + expect(block?.paging).toEqual({ page: 1, totalPages: 2, previousPage: null, nextPage: 2 }); expect(block?.txids).toEqual([TXID]); }); it('accepts pagination that arrives as a JSON number', () => { const block = readBlock({ ...payload, pagination: { page: 3, totalPages: 9 } }); - expect(block?.page?.display).toBe('3'); - expect(block?.totalPages?.display).toBe('9'); + expect(block?.paging?.page).toBe(3); + expect(block?.paging?.nextPage).toBe(4); }); it('leaves the next block absent rather than inventing one', () => { @@ -443,6 +481,12 @@ describe('readAddress', () => { expect(readAddress(payload, DOGE)?.unconfirmedBalance?.display).toBe('0'); }); + it('carries the paging so the rest of a long history is reachable', () => { + expect(readAddress(payload, DOGE)?.paging).toEqual({ + page: 1, totalPages: 2, previousPage: null, nextPage: 2, + }); + }); + it('reads unspent outputs into routable parts', () => { const [utxo] = readAddress(payload, DOGE)?.utxos ?? []; expect(utxo.txid).toBe(TXID); diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.ts index 59b5438e526..5d30560e18e 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.ts @@ -216,6 +216,40 @@ export function formatElapsed( : $localize`:@@universe.chain.elapsed-days:${days}:DAYS: days ago`; } +const MONTHS = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', +]; + +/** + * A timestamp a reader can take in at a glance, with the exact string beside + * it. The authority sends ISO-8601, which is precise and unreadable at speed: + * "2026-08-29T04:02:00.000Z" makes a reader parse punctuation to find the hour. + * + * The format is built by hand rather than through Intl, and it is always UTC. + * A block timestamp is a fact about the chain, not about where the reader is + * sitting, and a locale-dependent rendering would make two people describing + * the same block disagree about when it happened. + */ +export function formatTimestamp( + isoTimestamp: string | null | undefined +): ExactNumber | null { + if (typeof isoTimestamp !== 'string') { + return null; + } + const parsed = new Date(isoTimestamp); + const time = parsed.getTime(); + if (!Number.isFinite(time)) { + return null; + } + const pad = (value: number): string => String(value).padStart(2, '0'); + const display = + `${parsed.getUTCDate()} ${MONTHS[parsed.getUTCMonth()]} ` + + `${parsed.getUTCFullYear()}, ${pad(parsed.getUTCHours())}:` + + `${pad(parsed.getUTCMinutes())} UTC`; + return { display, exact: isoTimestamp }; +} + // --------------------------------------------------------------------------- // Field naming // --------------------------------------------------------------------------- @@ -394,8 +428,19 @@ function factFrom( ? { key, label, kind: 'count', display: count.display, exact: count.exact, unit: null, link: null } : { ...base, kind: 'text', display: value }; } - if (TIMESTAMP_FIELD.test(key) && Number.isFinite(Date.parse(value))) { - return { ...base, kind: 'timestamp', display: value }; + if (TIMESTAMP_FIELD.test(key)) { + const moment = formatTimestamp(value); + if (moment) { + return { + key, + label, + kind: 'timestamp', + display: moment.display, + exact: moment.exact, + unit: null, + link: null, + }; + } } if (IDENTIFIER_FIELD.test(key) || /^[0-9a-f]{64}$/.test(value)) { return { @@ -798,8 +843,8 @@ export interface TransactionReading { readonly statusLabel: string; readonly statusTone: EvidenceTone; readonly confirmations: ExactNumber | null; - readonly block: { hash: string; height: ExactNumber | null; time: string | null } | null; - readonly firstSeenAt: string | null; + readonly block: { hash: string; height: ExactNumber | null; time: ExactNumber | null } | null; + readonly firstSeenAt: ExactNumber | null; readonly size: ExactNumber | null; readonly virtualSize: ExactNumber | null; readonly feeAmount: ExactNumber | null; @@ -974,10 +1019,10 @@ export function readTransaction( ? { hash: text(block.hash) ?? '', height: formatExactInteger(text(block.heightAtomic)), - time: text(block.time), + time: formatTimestamp(text(block.time)), } : null, - firstSeenAt: text(payload.firstSeenAt), + firstSeenAt: formatTimestamp(text(payload.firstSeenAt)), size: formatExactInteger(text(payload.sizeBytesAtomic)), virtualSize: formatExactInteger(text(payload.virtualSizeBytesAtomic)), feeAmount: formatAtomicAmount(text(fee.amountAtomic), profile.precision), @@ -1012,7 +1057,7 @@ export function readTransaction( export interface BlockReading { readonly hash: string; readonly height: ExactNumber | null; - readonly time: string | null; + readonly time: ExactNumber | null; readonly transactionCount: ExactNumber | null; readonly sizeBytes: ExactNumber | null; readonly confirmations: ExactNumber | null; @@ -1021,8 +1066,47 @@ export interface BlockReading { readonly merkleRoot: string | null; readonly difficulty: string | null; readonly txids: readonly string[]; - readonly page: ExactNumber | null; - readonly totalPages: ExactNumber | null; + readonly paging: Paging | null; +} + +/** + * Where a list sits in a longer one, and where the next and previous parts of + * it are. A page that states "page 1 of 3" and offers no way to reach the other + * two is a dead end, and both the block and the address page were one. + */ +export interface Paging { + readonly page: number; + readonly totalPages: number; + readonly previousPage: number | null; + readonly nextPage: number | null; +} + +const MAX_PAGE = 100_000; + +export function readPaging(value: unknown): Paging | null { + if (!isRecord(value)) { + return null; + } + const page = Number(integerText(value.pageAtomic) ?? integerText(value.page)); + const totalPages = Number( + integerText(value.totalPagesAtomic) ?? integerText(value.totalPages) + ); + if ( + !Number.isSafeInteger(page) || + !Number.isSafeInteger(totalPages) || + page < 1 || + totalPages < 1 || + page > MAX_PAGE || + totalPages > MAX_PAGE + ) { + return null; + } + return { + page, + totalPages, + previousPage: page > 1 ? page - 1 : null, + nextPage: page < totalPages ? page + 1 : null, + }; } export function readBlock(payload: ChainExplorerPayload): BlockReading | null { @@ -1034,7 +1118,7 @@ export function readBlock(payload: ChainExplorerPayload): BlockReading | null { return { hash: text(block.hash) ?? '', height: formatExactInteger(text(block.heightAtomic)), - time: text(block.time), + time: formatTimestamp(text(block.time)), transactionCount: formatExactInteger(text(block.transactionCountAtomic)), sizeBytes: formatExactInteger(text(block.sizeBytesAtomic)), confirmations: formatExactInteger(text(block.confirmationsAtomic)), @@ -1043,13 +1127,7 @@ export function readBlock(payload: ChainExplorerPayload): BlockReading | null { merkleRoot: text(block.merkleRoot), difficulty: text(block.difficulty), txids: Array.isArray(payload.txids) ? payload.txids.filter((id): id is string => typeof id === 'string') : [], - page: formatExactInteger( - integerText(pagination.pageAtomic) ?? integerText(pagination.page) - ), - totalPages: formatExactInteger( - integerText(pagination.totalPagesAtomic) ?? - integerText(pagination.totalPages) - ), + paging: readPaging(pagination), }; } @@ -1071,6 +1149,7 @@ export interface AddressReading { readonly unconfirmedCount: ExactNumber | null; readonly txids: readonly string[]; readonly utxos: readonly UtxoRow[]; + readonly paging: Paging | null; } export function readAddress( @@ -1090,6 +1169,7 @@ export function readAddress( transactionCount: formatExactInteger(text(payload.transactionCountAtomic)), unconfirmedCount: formatExactInteger(text(payload.unconfirmedTransactionsAtomic)), txids: Array.isArray(payload.txids) ? payload.txids.filter((id): id is string => typeof id === 'string') : [], + paging: readPaging(payload.pagination), utxos: utxos.map((entry) => ({ txid: text(entry.txid) ?? '', vout: text(entry.voutAtomic) ?? '', From 81e7820167aff5be5bd1e82014930f48a4017a7c Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 06:57:20 +0000 Subject: [PATCH 04/45] Photograph the five Universe pages nobody had photographed, and fix what showed The visual matrix covered thirteen inherited routes. Five Universe-authored ones were not among them: the output page, the three asset lookups, and the page that holds everything this browser remembers. Two defects had been shipping in plain sight because a page nobody photographs is a page nobody reviews. The rune mint progress bar had never rendered, on any release. The inherited Bootstrap layer owns `.progress` and makes it a 16px flex row, which collapsed the track to zero width and printed the label on top of an empty grey rectangle. It draws its 87 percent now, and it uses names Bootstrap does not own. The one timestamp on the saved page was printed in `--secondary`, which is a surface token: it resolves to `--u-surface-inset` and measured 1.3:1 on white. Nothing caught it because no fixture ever put an entry on that page, so the element had never existed in a screenshot. The saved page also spoke in wire identifiers. Its filters offered "all", "bitcoin", "dogecoin"; its entries read "dogecoin / mainnet"; its pinned protocols read "runes". They now read "Every chain", "Dogecoin, mainnet", "Runes", four headings that had no i18n now have it, and the two select controls take the product's palette instead of the browser's. Chain display names moved into universe-chain-routing.ts so a chain is spelled the same way in a heading, a saved list, and a filter. The saved page is seeded through localStorage rather than a request, because that is where its state actually lives, and at a fixed instant so the screenshot is the same every run. Verified: 259 frontend tests, lint, production build, every documentation gate, and 45 screenshots across three themes at 320, 375 and 1440 with zero overflow, zero contrast failures, zero axe violations and zero unfinished pages. Co-Authored-By: Claude Opus 5 --- .../multichain-explorer/multichain-view.ts | 5 +- .../src/app/universe/rune/rune.component.html | 6 +- .../src/app/universe/rune/rune.component.scss | 11 +- .../app/universe/saved/saved.component.html | 26 +- .../app/universe/saved/saved.component.scss | 26 +- .../src/app/universe/saved/saved.component.ts | 29 +++ .../app/universe/universe-chain-routing.ts | 15 ++ scripts/universe/visual-qa/asset-fixtures.mjs | 242 ++++++++++++++++++ scripts/universe/visual-qa/capture.mjs | 23 +- 9 files changed, 357 insertions(+), 26 deletions(-) create mode 100644 scripts/universe/visual-qa/asset-fixtures.mjs diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.ts index 5d30560e18e..01784fe858a 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.ts @@ -33,6 +33,7 @@ import { ChainExplorerPayload, ExplorerChain, } from '@app/universe/universe.types'; +import { explorerChainName } from '@app/universe/universe-chain-routing'; /** * The evidence tones the design system defines. They are the only tones any @@ -64,7 +65,7 @@ export interface ChainProfile { const DOGECOIN: ChainProfile = { chain: 'dogecoin', - name: 'Dogecoin', + name: explorerChainName('dogecoin'), ticker: 'DOGE', atomicUnit: 'koinu', precision: 8, @@ -77,7 +78,7 @@ const DOGECOIN: ChainProfile = { const ZCASH: ChainProfile = { chain: 'zcash', - name: 'Zcash', + name: explorerChainName('zcash'), ticker: 'ZEC', atomicUnit: 'zatoshi', precision: 8, diff --git a/frontend/src/app/universe/rune/rune.component.html b/frontend/src/app/universe/rune/rune.component.html index 37769290784..a13f07f48a5 100644 --- a/frontend/src/app/universe/rune/rune.component.html +++ b/frontend/src/app/universe/rune/rune.component.html @@ -103,12 +103,12 @@

Minting

{{ mintExplanation(state.result.value) }}

-
-
{{ position.asset?.assetKind }} - {{ position.state }} + {{ position.state }}
  • diff --git a/frontend/src/app/universe/outpoint/outpoint.component.scss b/frontend/src/app/universe/outpoint/outpoint.component.scss index cfeebed3979..72fbeac6ad3 100644 --- a/frontend/src/app/universe/outpoint/outpoint.component.scss +++ b/frontend/src/app/universe/outpoint/outpoint.component.scss @@ -138,6 +138,7 @@ .asset-meta { display: flex; + align-items: center; gap: 0.75rem; font-size: 0.8rem; color: var(--u-text-muted); diff --git a/frontend/src/app/universe/outpoint/outpoint.component.ts b/frontend/src/app/universe/outpoint/outpoint.component.ts index 439a0d2ca75..a3b45ba9b14 100644 --- a/frontend/src/app/universe/outpoint/outpoint.component.ts +++ b/frontend/src/app/universe/outpoint/outpoint.component.ts @@ -13,6 +13,7 @@ import { OutpointEnrichment, } from '@app/universe/universe.types'; import { + EvidenceTone, EvidenceView, ProtocolGroup, formatAtomicAmount, @@ -143,6 +144,29 @@ export class OutpointComponent implements OnInit, OnDestroy { return group.protocolId; } + /** + * The evidence tone for one attached asset. The verdict at the top of the + * page is for the whole output; a position can differ from it, and the word + * on its own carried no treatment at all. + */ + positionTone(state: string): EvidenceTone { + switch (state) { + case 'proven': + return 'proven'; + case 'partial': + case 'partially-proven': + return 'partial'; + case 'pending': + case 'mempool-candidate': + return 'pending'; + case 'unavailable': + case 'out-of-coverage': + return 'unavailable'; + default: + return 'neutral'; + } + } + trackByPosition(index: number, position: ExplorerOutpointPosition): string { return `${position.outpoint}:${position.asset?.assetId ?? index}`; } diff --git a/frontend/src/app/universe/universe-evidence.spec.ts b/frontend/src/app/universe/universe-evidence.spec.ts index c8c186adbe8..c7b96352781 100644 --- a/frontend/src/app/universe/universe-evidence.spec.ts +++ b/frontend/src/app/universe/universe-evidence.spec.ts @@ -106,13 +106,21 @@ describe('formatAtomicAmount', () => { expect(formatAtomicAmount(huge).replace(/,/g, '')).toBe(huge); }); - it('refuses anything that is not a non-negative integer string', () => { - expect(formatAtomicAmount('-1')).toBe(''); + it('refuses anything that is not an exact integer string', () => { expect(formatAtomicAmount('1.5')).toBe(''); expect(formatAtomicAmount('007')).toBe(''); + expect(formatAtomicAmount('')).toBe(''); expect(formatAtomicAmount(undefined as never)).toBe(''); }); + it('keeps the sign on the one amount in the contract that is signed', () => { + // A Zcash transaction's value balance is the net movement between the + // transparent and shielded pools, and it is negative whenever value moves + // into the shielded side. Refusing it printed nothing at all. + expect(formatAtomicAmount('-50000000', 8)).toBe('-0.5'); + expect(formatAtomicAmount('-1234567')).toBe('-1,234,567'); + }); + it('ignores an out-of-range divisibility rather than guessing', () => { expect(formatAtomicAmount('1000', 99)).toBe('1,000'); expect(formatAtomicAmount('1000', -1)).toBe('1,000'); diff --git a/frontend/src/app/universe/universe-evidence.ts b/frontend/src/app/universe/universe-evidence.ts index 0496693c034..837dc15e451 100644 --- a/frontend/src/app/universe/universe-evidence.ts +++ b/frontend/src/app/universe/universe-evidence.ts @@ -12,7 +12,17 @@ import { OutpointEnrichmentStatus, } from '@app/universe/universe.types'; -export type EvidenceTone = 'proven' | 'partial' | 'pending' | 'unavailable'; +/** + * The five tones the design system defines. `neutral` is the one for a fact + * that carries no evidence claim at all, which is different from a claim that + * could not be established: a chain that never answered has not answered "no". + */ +export type EvidenceTone = + | 'proven' + | 'partial' + | 'pending' + | 'unavailable' + | 'neutral'; export interface EvidenceView { readonly tone: EvidenceTone; @@ -104,14 +114,22 @@ export function groupPositionsByProtocol( * pipeline keeps them as decimal strings and so does this. */ export function formatAtomicAmount(atomic: string, decimals = 0): string { - if (typeof atomic !== 'string' || !/^(0|[1-9][0-9]*)$/.test(atomic)) {return '';} + // Negatives are accepted because one real amount in the contract is signed: + // a Zcash transaction's value balance, the net movement between the + // transparent and shielded pools. Rejecting it printed nothing at all. + if (typeof atomic !== 'string' || !/^-?(0|[1-9][0-9]*)$/.test(atomic)) {return '';} + const negative = atomic.startsWith('-'); + const digits = negative ? atomic.slice(1) : atomic; + const sign = negative ? '-' : ''; if (!Number.isInteger(decimals) || decimals <= 0 || decimals > 38) { - return groupDigits(atomic); + return sign + groupDigits(digits); } - const padded = atomic.padStart(decimals + 1, '0'); + const padded = digits.padStart(decimals + 1, '0'); const whole = padded.slice(0, padded.length - decimals); const fraction = padded.slice(padded.length - decimals).replace(/0+$/, ''); - return fraction ? `${groupDigits(whole)}.${fraction}` : groupDigits(whole); + return fraction + ? `${sign}${groupDigits(whole)}.${fraction}` + : sign + groupDigits(whole); } function groupDigits(value: string): string { From be8efc37edc5c67991d70058ec2d3518053bb205 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 07:07:57 +0000 Subject: [PATCH 06/45] Draw the trust boundary, and stop sending contributors to the wrong project The architecture section described three processes and a route split in prose, which is the one thing here a picture is better at: the whole first-party data policy is a statement about which side of the gateway a thing sits on. The diagram makes that visible in one look, and it is a mermaid fence so GitHub renders it with nothing fetched from anywhere. CONTRIBUTING.md was the upstream document, unmodified, ending in a stray literal EOF from however it was written. It opened by welcoming contributors to the Mempool Open Source Project and instructed them to sign Mempool Space K.K.'s contributor licence agreement and add themselves to `contributors/`. Someone opening a pull request against this fork is not contributing to that project and should not be told to do any of it. It now leads with what applies here: where work happens, where Universe changes live and why that boundary is what keeps upstream security fixes easy to take, the checks CI will run, the four rules that are enforced rather than remembered, and the requirement to add a fixture for any page you add. The upstream agreement is preserved verbatim below it, under a sentence saying it governs work submitted to that project rather than to this one. Nothing was deleted, and the trademark audit's allowlist entry for this file still holds. Co-Authored-By: Claude Opus 5 --- CONTRIBUTING.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++-- README.md | 38 +++++++++++++++++++----- 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e43bda9443..1da9285324e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,79 @@ +# Contributing to Universe Explorer + +This repository is Bitcoin Universe's fork of the Mempool Open Source Project, +released under the AGPL. Contributions to it are contributions to this fork. +The upstream project's own contribution terms are preserved further down, and +they govern work you submit to that project rather than to this one. + +## Where work happens + +`develop` is the working branch. Open a pull request against it. `main` is the +released branch and is protected: it only ever moves through a promotion from +`develop`. + +## Scope conventions + +Universe changes live in `frontend/src/app/universe/`, `scripts/universe/`, and +`docs/`, and reach the inherited application through a small number of +documented integration points. Keeping them there is what makes upstream +security fixes easy to take. `UPSTREAM.md` records every subsystem this fork +modifies and the known conflict points, and +`docs/operations/UPSTREAM-SYNC.md` is the synchronization procedure. + +## What the checks hold you to + +Run these before opening a pull request. They are the same ones +`.github/workflows/universe-ci.yml` runs. + +```bash +cd frontend && npm ci && npm run lint && npm run test && npm run build:universe +cd backend && npm ci && npm run lint && npm run test:ci +node scripts/universe/generate-protocol-coverage.mjs --check +node scripts/universe/check-text.mjs +node scripts/universe/check-colors.mjs +node scripts/universe/check-palettes.mjs +node scripts/universe/check-fills.mjs +node scripts/universe/check-branding.mjs +node scripts/universe/check-origins.mjs +node --test scripts/universe/gateway.test.mjs +``` + +Four of them are easy to trip by accident, so they are enforced rather than +remembered: + +- **No em dash.** Use a colon, a comma, or two sentences. The word "canonical" + is also out of the vocabulary here. +- **No raw interface colour.** Everything comes from the tokens in + `frontend/src/styles/_universe-tokens.scss`, including in style bindings. + Every strong fill declares the ink that goes on it. +- **No third-party data origin.** Not in the source, not in the built bundle, + not as a fallback. `docs/data/ASSET-EVIDENCE.md` explains why. +- **No obsolete upstream product mark** outside the allowlist recorded in + `docs/legal/TRADEMARK-AUDIT.md`. + +## If you add or change a page + +Add it to the visual matrix in `scripts/universe/visual-qa/`. A route with no +fixture is a route no screenshot, contrast probe or unfinished-page check ever +looks at, and every defect this product has shipped visually was on a surface +in exactly that position. `docs/product/DESIGN-SYSTEM.md` has the account of +what that has cost, at the end. + +## Writing + +`docs/product/DESIGN-SYSTEM.md` has the rules the copy is held to. The short +version: say what is true and no more, distinguish "there is none" from "we +could not tell", keep exact figures exact, and explain a term at the moment it +matters rather than in a glossary. + +## Reporting a vulnerability + +Privately, to the Bitcoin Universe security contact, rather than in a public +pull request. `docs/security/THREAT-MODEL.md` records the trust boundaries this +deployment assumes. + +--- + # Contributing to The Mempool Open Source Project Thank you for contributing to The Mempool Open Source Project managed by Mempool Space K.K. (“Mempool”). @@ -47,5 +123,3 @@ Should You wish to submit work that is not Your original creation, You may submi ### 8. Notifications You agree to notify Mempool of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect. - -EOF diff --git a/README.md b/README.md index bcd0ff913b0..50b6d28a4a8 100644 --- a/README.md +++ b/README.md @@ -118,16 +118,38 @@ Three processes behind one HTTPS origin: | Frontend | `frontend/`, an Angular application served as static files with SPA fallback. | `scripts/universe/gateway.mjs` is the single public entry point. It serves the -built frontend and splits `/api` between the two backends: the overlay owns -`/api/v1/universe/*`, `/api/v1/chains`, and the `/api/v1/bitcoin`, -`/api/v1/dogecoin` and `/api/v1/zcash` route families, and everything else goes -to the explorer backend. The route table is in -`docs/operations/DEPLOYMENT.md` and is held by +built frontend and splits `/api` between the two backends by path prefix. + +```mermaid +flowchart LR + browser["Browser"] + gateway["Gateway
    scripts/universe/gateway.mjs"] + backend["Explorer backend
    backend/"] + overlay["Protocol overlay
    backend-apis"] + core[("Bitcoin Core
    Fulcrum, MariaDB")] + doge[("Dogecoin node
    and confirmed history")] + zec[("Zcash node")] + auth[("Protocol indexers
    ord, ord-dogecoin,
    doge-tap, zcash-metaprotocols")] + + browser -->|"everything else"| gateway + gateway --> backend + gateway -->|"/api/v1/universe/*
    /api/v1/chains
    /api/v1/bitcoin/*, /dogecoin/*, /zcash/*"| overlay + backend --> core + overlay --> core + overlay --> doge + overlay --> zec + overlay --> auth +``` + +Everything the browser can reach is on the left of the gateway. Every node, +index and credential is on the right of the overlay, and no indexer origin or +bearer token is ever exposed to a page. Lookalike paths such as +`/api/v1/chainstats` stay on the explorer backend. The route table is recorded +in `docs/operations/DEPLOYMENT.md` and held by `node --test scripts/universe/gateway.test.mjs`. -The browser never talks to an indexer, and no indexer origin or credential is -ever exposed to it. See `docs/architecture/` for the overlay design and -`docs/data/ASSET-EVIDENCE.md` for the evidence contract. +`docs/architecture/` has the overlay design and `docs/data/ASSET-EVIDENCE.md` +the evidence contract. ## First-party data policy From a170df2a3aded21c1b0d3c105f85f5add5343165 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 07:13:54 +0000 Subject: [PATCH 07/45] Correct the search privacy claim, which stopped being true The privacy page said the explorer "only asks its own backend for the specific transaction, block, address, output, or asset you opened". That was written when search was a local matcher. It is no longer what happens. Searching on Dogecoin or Zcash, or widening a search to every chain, calls /api/v1/universe/search with the text you typed, and completing a partly typed address has always sent that prefix to the backend. In those cases the query itself reaches the server before you open anything, which is exactly the fact the old sentence denied. The page now separates the two halves honestly. Classification is local: working out whether what you typed looks like a transaction, a block, an address, an output, or an asset happens on your device and asks nobody. Then it names the three searches that do reach the server, and states the part that is still absolutely true and is the one that matters: none of it reaches anyone else, and no query is tied to a visitor because there is nothing here to tie it to. The README carried the same claim and now carries the same correction. A privacy page that overstates is worse than one that admits a server call, and this one had been overstating since the multichain search shipped. Co-Authored-By: Claude Opus 5 --- README.md | 6 ++++-- .../privacy-policy.component.html | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 50b6d28a4a8..bfb117d0faa 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,10 @@ guesses. - **Live protocol activity, measured.** The pulse page publishes its own denominator: how many arriving transactions were checked, and how many carried each protocol. Every number on it can be reproduced. -- **No trackers, no accounts.** Search is matched in your browser. Saved pages - and history live in local storage and never leave the device. +- **No trackers, no accounts.** What you type is classified on your device. + The searches that do need the server, completing a partly typed address and + searching Dogecoin, Zcash or every chain at once, go to our own backend and + to nobody else's. Saved pages and history stay in local storage. - **First-party data only.** Every figure comes from Bitcoin Universe's own nodes, indexes, and protocol authorities. diff --git a/frontend/src/app/components/privacy-policy/privacy-policy.component.html b/frontend/src/app/components/privacy-policy/privacy-policy.component.html index 8ec42bee577..73e84a17d11 100644 --- a/frontend/src/app/components/privacy-policy/privacy-policy.component.html +++ b/frontend/src/app/components/privacy-policy/privacy-policy.component.html @@ -11,10 +11,20 @@

    What this explorer collects

    Searches and browsing

    - Whatever you type into the search box is matched in your browser first. The - explorer only asks its own backend for the specific transaction, block, - address, output, or asset you opened, and it never sends your query - anywhere else. + What you type is classified in your browser. Working out whether it looks + like a transaction, a block, an address, an output, or an asset happens on + your device and asks nobody. +

    +

    + Some searches then need the server, and in those the text you typed does + reach us. Completing a partly typed address, searching on Dogecoin or + Zcash, and widening a search to every chain are all answered by our own + backend. Opening a result asks our backend for that one object. +

    +

    + None of it reaches anyone else. There is no third-party search, + autocomplete, suggestion, or analytics call at any point, and no query is + tied to a visitor, because there is nothing here to tie it to.

    What your browser stores

    From 1487ed52b8ab64b87cae5cb8be7721faca40914d Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 07:22:14 +0000 Subject: [PATCH 08/45] Stop calling Dogecoin pages Bitcoin, in the tab and in the shared link Every page title fell back to "Bitcoin Explorer" on mainnet, because that is what an empty network meant when there was one chain. Eleven routes have been telling browsers, tab strips, and search engines that a Dogecoin page is a Bitcoin one. The chain is in the URL, so the suffix now says which chain it actually is, and Bitcoin and the test networks keep exactly the title format they had. The chain pages then said it twice: "Dogecoin dashboard - Universe Explorer - Dogecoin", and said "dashboard" where the heading on the page said "overview". The title takes the same label the heading does and lets the suffix carry the chain once. The description in index.html, which is also the og and twitter description on every shared link and the social card's alt text, described a Bitcoin explorer. So did the fallback in SeoService. Both name three chains now. Also here: the chain pages open their own socket and nothing in the matrix answered it, so every chain screenshot carried a failed-handshake console error. That is noise which would hide a real one, and it meant the live transport was never exercised in review. The harness answers the subscribe with one envelope per channel, at fixed sequence numbers so a rerun produces the same screenshot. Verified: 259 frontend tests, lint, production build, every gate against source and against frontend/dist, page titles checked by driving the built application across six routes, and the matrix now reporting zero console errors on the chain routes. Co-Authored-By: Claude Opus 5 --- frontend/src/app/services/seo.service.ts | 16 ++++++++- .../multichain-explorer.component.ts | 11 ++++-- scripts/universe/visual-qa/capture.mjs | 36 +++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/services/seo.service.ts b/frontend/src/app/services/seo.service.ts index 30f56b09564..8be39248ddb 100644 --- a/frontend/src/app/services/seo.service.ts +++ b/frontend/src/app/services/seo.service.ts @@ -3,6 +3,10 @@ import { Title, Meta } from '@angular/platform-browser'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { filter, map, switchMap } from 'rxjs'; import { StateService } from '@app/services/state.service'; +import { + explorerChainFromUrl, + explorerChainName, +} from '@app/universe/universe-chain-routing'; @Injectable({ providedIn: 'root' @@ -10,7 +14,7 @@ import { StateService } from '@app/services/state.service'; export class SeoService { network = ''; baseTitle = 'Universe Explorer'; - baseDescription = 'Universe Explorer is the live protocol-aware Bitcoin explorer for the Bitcoin Universe ecosystem.'; + baseDescription = 'Universe Explorer is the live protocol-aware explorer for Bitcoin, Dogecoin, and Zcash in the Bitcoin Universe ecosystem.'; // Overwritten below from the canonical link in index.html; this is only the fallback. baseDomain = 'explorer.bitcoinuniverse.io'; @@ -98,6 +102,16 @@ export class SeoService { {return this.baseTitle + ' - Liquid Network';} if (this.network === 'liquidtestnet') {return this.baseTitle + ' - Liquid Testnet';} + // On mainnet the network is empty, which used to mean every page fell back + // to "Bitcoin Explorer". That put the wrong chain in the tab title, the + // shared link title, and the search result of every Dogecoin and Zcash + // page. The chain is in the URL, so the title can simply say which one. + if (!this.network) { + const chain = explorerChainFromUrl(this.router.url); + if (chain !== 'bitcoin') { + return this.baseTitle + ' - ' + explorerChainName(chain); + } + } return this.baseTitle + ' - ' + (this.network ? this.ucfirst(this.network) : 'Bitcoin') + ' Explorer'; } 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 2d5ac9e7389..f9efdfdcdc9 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts @@ -496,9 +496,16 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { ); } + /** + * The tab title. The chain is already the suffix the SEO service appends, so + * naming it here too produced "Dogecoin dashboard - Universe Explorer - + * Dogecoin". It also said "dashboard" where the heading said "overview". + */ private pageTitle(context: RequestContext): string { - const suffix = this.referenceFrom(context); - return `${this.chainName} ${context.page.replace('-', ' ')}${suffix ? ` ${suffix}` : ''}`; + const label = this.pageLabel(); + const reference = this.referenceFrom(context); + const subject = label.charAt(0).toUpperCase() + label.slice(1); + return reference ? `${subject} ${reference}` : subject; } private errorMessage(error: unknown): string { diff --git a/scripts/universe/visual-qa/capture.mjs b/scripts/universe/visual-qa/capture.mjs index 4e5893716ac..00c7ed05020 100644 --- a/scripts/universe/visual-qa/capture.mjs +++ b/scripts/universe/visual-qa/capture.mjs @@ -203,6 +203,42 @@ async function installFixtures(context, state) { }); push(); }); + + // The chain pages open their own socket, and nothing answered it: every + // chain screenshot carried a failed-handshake console error, which is noise + // that would hide a real one, and the live path itself went unexercised. + // + // The client subscribes to three channels at once and expects one envelope + // per channel. Sequence numbers stay fixed so a rerun produces the same + // screenshot; the client only uses them to resume after a drop. + await context.routeWebSocket('**/api/v1/universe/ws', (ws) => { + if (down) return; // connected but silent, which is the reconnecting state + ws.onMessage((raw) => { + let message; + try { message = JSON.parse(String(raw)); } catch { return; } + if (message?.type !== 'subscribe' || !Array.isArray(message.subscriptions)) { + return; + } + for (const subscription of message.subscriptions) { + const chain = subscription?.chain; + const channel = subscription?.channel; + if (!chain || !channel) continue; + ws.send(JSON.stringify({ + schemaVersion: 'universe-websocket-v1', + chain, + network: 'mainnet', + channel, + snapshotId: 'snap-4812', + sequenceAtomic: '148201', + observedAt: '2026-08-29T05:03:00.000Z', + tip: chainFixtures[`/api/v1/${chain}/status`]?.tip ?? null, + reorg: null, + completeness: 'complete', + data: {}, + })); + } + }); + }); } /** From 747613f6785dca45075599b07bcb745f711aeee4 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 07:23:06 +0000 Subject: [PATCH 09/45] Put the metadata fix in the file that is not thrown away frontend/src/index.html is generated: generate-config.js copies it from index.mempool.html on every build, and it is gitignored. Editing it produced a correct local build and a change that the next CI run would have silently discarded, which is the worst shape a fix can have, because the local proof looks exactly like a real one. The description, the og and twitter descriptions, and the social card alt text are corrected in index.mempool.html, which is the file the build reads. Regenerating confirms the output carries them. Co-Authored-By: Claude Opus 5 --- frontend/src/index.mempool.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/index.mempool.html b/frontend/src/index.mempool.html index 2584e3c7218..1f7a3856740 100644 --- a/frontend/src/index.mempool.html +++ b/frontend/src/index.mempool.html @@ -18,7 +18,7 @@ - + @@ -28,11 +28,11 @@ - - + + - + From f1b369f61754be10e25e1735e3571a6c1ea74f60 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 07:40:36 +0000 Subject: [PATCH 10/45] Readable is not the same as current, and the README should not blur them The README said "Live today, backed by the first-party Ord 0.29 authority" and named three protocols. The production smoke check reports that same authority as stale by 519,670 blocks while it rebuilds its index, and reports it as ok, because staleness is a fact to publish rather than a failure to hide. Both statements are true and they read as contradicting each other, which is the problem. The product itself has never conflated the two: a page using an authority states how far behind the chain tip it is. The README was the one surface that said only the first half. It now says readable, and then says plainly that readable is not current, that an authority rebuilding its index is still answering, and where the freshness figure is published. That stays accurate whether the rebuild is running or finished, so it needs no revisiting when ord catches up. Co-Authored-By: Claude Opus 5 --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bfb117d0faa..7942c8f8592 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,16 @@ explorer states plainly which ones it can actually read. | Live, read only | A first-party authority is running and its evidence is shown. | | Not yet available | No first-party authority for it is configured or answering here. The explorer makes no claim about it. | -Live today, backed by the first-party Ord 0.29 authority: **Ordinals**, +Readable today, backed by the first-party Ord 0.29 authority: **Ordinals**, **Rare Sats**, **Runes**. +Readable is not the same as current, and the product never conflates them. An +authority that is rebuilding its index is still answering, and every page that +uses it states how far behind the chain tip it is rather than presenting its +answers as the present. `/api/v1/universe/sources` publishes the same figure, +and the production smoke check in +`.github/workflows/universe-production-smoke.yml` reads it on a schedule. + `docs/protocols/PROTOCOL-COVERAGE.md` is generated from the registry and lists every entry with its authority and its release status. Run `node scripts/universe/generate-protocol-coverage.mjs --check` to verify the From 683dd1274ed775dcdef4ba4afdb3e40603916848 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 07:43:14 +0000 Subject: [PATCH 11/45] Say how many columns the table left out The generic table caps at seven columns so it stays legible on a phone. It said how many rows it was holding back and nothing about the fields, so a response with twelve fields per row rendered as a complete answer with five of them silently gone. It now states the count and points at the full response, which is the same rule the row cap already followed. Co-Authored-By: Claude Opus 5 --- .../multichain-explorer.component.html | 4 ++++ .../multichain-explorer/multichain-view.spec.ts | 11 +++++++++++ .../universe/multichain-explorer/multichain-view.ts | 8 ++++++++ 3 files changed, 23 insertions(+) diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html index b31de7b5250..cef11d7ac59 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html @@ -489,6 +489,10 @@

    {{ collection.title }}

    i18n="universe.chain.collection-truncated"> Showing the first {{ collection.shownCount }} of {{ collection.totalCount }} rows the authority returned.

    +

    + {{ collection.hiddenColumnCount }} further fields per row are not in this table. Every one of them is in the full response below. +

{{ column.label }} {{ column.unit }}
- {{ key }}
{{ value(row[key]) }} +
+ {{ cell.fact.display }} + {{ cell.fact ? cell.fact.display : '' }}
diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts index b85a6caaeaa..22f4a57019f 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts @@ -573,6 +573,17 @@ describe('readCollection', () => { expect(byKey.sizeBytesAtomic.unit).toBeNull(); }); + it('says how many fields per row the table left out', () => { + const wide = { a: '1', b: '2', c: '3', d: '4', e: '5', f: '6', g: '7', h: '8', i: '9' }; + const collection = readCollection({ items: [wide] }, DOGE); + expect(collection?.columns).toHaveLength(7); + expect(collection?.hiddenColumnCount).toBe(2); + }); + + it('holds nothing back when every field fits', () => { + expect(readCollection({ items: [{ a: '1', b: '2' }] }, DOGE)?.hiddenColumnCount).toBe(0); + }); + it('returns nothing when there is no array to read', () => { expect(readCollection({ state: 'unavailable' }, DOGE)).toBeNull(); }); diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.ts index 232f6903312..68cc197aafe 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.ts @@ -1214,6 +1214,12 @@ export interface CollectionReading { readonly rows: readonly (readonly CollectionCell[])[]; readonly shownCount: number; readonly totalCount: number; + /** + * Fields present in the rows that the table did not have room for. A table + * that quietly drops a column reads as a complete answer, so the page says + * how many it is holding back and the full response stays one click away. + */ + readonly hiddenColumnCount: number; } /** How many rows a page renders before it says how many it is holding back. */ @@ -1265,6 +1271,7 @@ export function readCollection( } } } + const hiddenColumnCount = Math.max(0, order.length - COLLECTION_COLUMN_LIMIT); const columns = order.slice(0, COLLECTION_COLUMN_LIMIT).map((key) => ({ key, label: humanizeFieldName(key), @@ -1284,6 +1291,7 @@ export function readCollection( ), shownCount: shown.length, totalCount: source.length, + hiddenColumnCount, }; } From ea023b66726b25b2ea2a63b13c83b07065849296 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 07:44:07 +0000 Subject: [PATCH 12/45] Stop pointing people at an issue tracker that is switched off Both the README and CONTRIBUTING told readers to report vulnerabilities privately "rather than opening a public issue", which implies an issue tracker. This repository has none: `has_issues` is false. Someone arriving with a bug to report had nowhere the documentation acknowledged. Both now say plainly that the tracker is off and that a pull request is the durable place to raise anything, including a report the reporter cannot fix, with an empty diff. The security route stays private and now says not in a commit message either, which is the other place a vulnerability leaks by accident. Co-Authored-By: Claude Opus 5 --- CONTRIBUTING.md | 11 ++++++++--- README.md | 14 ++++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1da9285324e..cb3c0a149c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,11 @@ they govern work you submit to that project rather than to this one. released branch and is protected: it only ever moves through a promotion from `develop`. +The issue tracker is turned off, so a pull request is the only durable place to +raise something. That is deliberate: a change, its reasoning, its review, and +its checks stay in one record. Open one even for a report you cannot fix +yourself, with an empty diff if you have nothing to change yet. + ## Scope conventions Universe changes live in `frontend/src/app/universe/`, `scripts/universe/`, and @@ -68,9 +73,9 @@ matters rather than in a glossary. ## Reporting a vulnerability -Privately, to the Bitcoin Universe security contact, rather than in a public -pull request. `docs/security/THREAT-MODEL.md` records the trust boundaries this -deployment assumes. +Privately, to the Bitcoin Universe security contact. Not in a pull request, and +not in a commit message. `docs/security/THREAT-MODEL.md` records the trust +boundaries this deployment assumes. --- diff --git a/README.md b/README.md index 7942c8f8592..70b13309f85 100644 --- a/README.md +++ b/README.md @@ -290,12 +290,18 @@ upstream security fixes stay easy to take. ## Security Report a suspected vulnerability privately to the Bitcoin Universe security -contact rather than opening a public issue. `docs/security/THREAT-MODEL.md` -records the trust boundaries this deployment assumes. +contact. Do not put it in a pull request. +`docs/security/THREAT-MODEL.md` records the trust boundaries this deployment +assumes. ## Contributing Work happens on `develop`. Open a pull request against it, keep Universe changes inside `frontend/src/app/universe/` and the documented integration points where -possible, and make sure the checks above pass. [CONTRIBUTING.md](CONTRIBUTING.md) -covers the details inherited from upstream. +possible, and make sure the checks above pass. + +This repository has its issue tracker turned off, so a pull request is how +anything gets raised, discussed, and recorded. Open one even for a report you +cannot fix yourself: describe what you saw and where, and leave the diff empty +if you have nothing to change yet. [CONTRIBUTING.md](CONTRIBUTING.md) has the +rest. From 8ce3db88b349789a04b5cf9420dc59143cd41c78 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 07:59:21 +0000 Subject: [PATCH 13/45] Refuse a matrix run whose build moved underneath it I corrupted my own sweep and nearly reported the result. An Angular production build empties its output directory before writing it, so a rebuild during a matrix leaves a window where index.html and the lazy chunks are simply absent. The gateway answers 503 for the missing index, the pages served in that window have no title and no lang attribute, and axe reports both as serious violations. Eleven findings across nine route and viewport combinations, every one of them about the build being replaced rather than about the interface, and nothing in the output distinguished them from real ones. The run already proves it is measuring the right thing before it starts, after a port collision once put another product on the address and the matrix passed thirty-nine blank screenshots. This is the same argument extended over time: the hashed bundle names are recorded at the start and compared at the end, and a run whose identity moved throws with both identities printed rather than reporting findings it did not measure. A server that stopped answering part-way fails the same way, because incomplete results that look complete are the specific danger here. Proven rather than assumed: driven against a sandboxed copy of the output whose bundle name was swapped six seconds into a run, it exits 1 and names both builds. Co-Authored-By: Claude Opus 5 --- scripts/universe/visual-qa/capture.mjs | 49 ++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/scripts/universe/visual-qa/capture.mjs b/scripts/universe/visual-qa/capture.mjs index 00c7ed05020..d4fdb244848 100644 --- a/scripts/universe/visual-qa/capture.mjs +++ b/scripts/universe/visual-qa/capture.mjs @@ -302,7 +302,16 @@ function socketState(state) { }; } +/** The hashed bundle names an index.html points at, as one comparable string. */ +function bundleIdentity(html) { + return [...html.matchAll(/(?:runtime|main|polyfills)\.[a-f0-9]{8,}\.js/g)] + .map((match) => match[0]) + .sort() + .join(' '); +} + async function run() { + let bundleAtStart = ''; const routes = pick(ROUTES, 'routes'); const viewports = pick(VIEWPORTS, 'viewports'); const themes = pick(THEMES.map((id) => ({ id })), 'themes').map((t) => t.id); @@ -330,9 +339,11 @@ async function run() { if (!/(runtime|main)\.[a-f0-9]{8,}\.js/.test(html)) { throw new Error(`${BASE} served no hashed application bundle; the build output looks incomplete.`); } + bundleAtStart = bundleIdentity(html); } mkdirSync(OUT, { recursive: true }); + // The Lens is drawn with WebGL. Headless Chromium has no GPU, so without a // software rasteriser the product's signature view is a grey rectangle in // every screenshot and the one thing worth reviewing goes unreviewed. @@ -522,6 +533,44 @@ async function run() { await browser.close(); + // The build must not have been rewritten underneath the run. + // + // An Angular production build empties its output directory before writing + // it, so a rebuild during a matrix leaves a window where index.html and the + // lazy chunks are simply absent. The pages served in that window have no + // title and no lang attribute, which axe reports as two serious violations, + // and their chunks 404. Every one of those findings is about the build being + // replaced rather than about the interface, and nothing in the output said + // so: eleven of them were indistinguishable from real ones until the cause + // was found by hand. + // + // This is the same reasoning as the server-identity check at the start. That + // one proves the right thing was being measured; this one proves it stayed + // the right thing for the whole run. + { + const after = await fetch(BASE, { redirect: 'follow' }) + .then((response) => (response.ok ? response.text() : '')) + .catch(() => ''); + const bundleAtEnd = bundleIdentity(after); + if (bundleAtStart && bundleAtEnd && bundleAtEnd !== bundleAtStart) { + throw new Error( + [ + 'the build changed while the matrix was running, so these results', + 'measure two different builds and some of them measure neither.', + ` at the start: ${bundleAtStart}`, + ` at the end: ${bundleAtEnd}`, + 'Rebuild, then run the matrix with nothing else writing to the output.', + ].join('\n'), + ); + } + if (bundleAtStart && !bundleAtEnd) { + throw new Error( + 'the server stopped answering before the matrix finished, so the ' + + 'results are incomplete. Nothing was measured after that point.', + ); + } + } + const report = { browser: BROWSER, base: BASE, screenshots: shots, findings }; writeFileSync(join(OUT, `report-${BROWSER}.json`), JSON.stringify(report, null, 2)); const stuck = summarise(report); From 15f60ba3df550d59c318351ec7b80a6d197b1f85 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 08:52:20 +0000 Subject: [PATCH 14/45] Read the real API, and fix the three things the fixtures could not show The release went live, so for the first time these pages could be tested against what the chain API actually sends rather than against what I expected it to send. Three faults fell out immediately, and none of them were reachable from the hand-written fixtures. **A protocol listed twice, once falsely.** The capability envelope names TAP on Doge `tap_doge`; the API serves it at `/protocols/doge-tap`. Matching on the route id alone rendered the tab as "not stated" and then appended the envelope entry beside it, so the overview showed the same protocol twice and neither row was the whole truth. A tab now carries every registry id that means it. **A link into a 404.** Production reports a fourth Dogecoin protocol, `dunes`, that this explorer has no page for. It was appended with a link to `/dogecoin/protocols/dunes`, which answers 404, and which the frontend's own allowlist throws on before it even asks. It is still listed, because the chain says it is there and hiding it would be a claim the chain did not make, and it is no longer a link. **A column that would have been empty on every Zcash row.** The pending list carries whole transaction envelopes, and Zcash reports no fee amount on any of them, because a shielded transaction's fee cannot be read from its transparent side. It reports ZIP-317 logical actions instead. A fixed Fee column spent the most valuable slot on the page on nothing; the cost column now takes whichever figure the rows can actually fill, and says why when it is the second one. Those envelopes also broke the generic table: twenty-one fields per row meant schemaVersion, chain and network took three of the seven columns and every cell in them was identical. Constant columns are dropped and counted, and a list of transaction envelopes now gets read as transactions, with the two figures a pending list is for. Smaller, same source: a nested snapshotId read as "Snapshot snapshot ID", and a nested chain and network repeated the page heading. The payloads are committed as live-payloads.ts, captured from the public origin at 2026-08-29T08:26Z against release mempool-8f6005ae0, trimmed only in row count. live-model-check.spec.ts drives the model with them. Every assertion is an invariant rather than a value, because the chain moves: the first version asserted a one-block lag and failed on the next run against a two-block one. The visual fixtures carried the wrong mempool shape, so the matrix had never rendered a pending list the way production sends one. They match now. Verified: 283 frontend tests, lint, production build, all seven documentation gates against source and against frontend/dist. Co-Authored-By: Claude Opus 5 --- .../live-model-check.spec.ts | 188 ++ .../multichain-explorer/live-payloads.ts | 1599 +++++++++++++++++ .../multichain-explorer.component.html | 70 +- .../multichain-explorer.component.ts | 18 +- .../multichain-view.spec.ts | 57 +- .../multichain-explorer/multichain-view.ts | 198 +- scripts/universe/visual-qa/chain-fixtures.mjs | 74 +- 7 files changed, 2160 insertions(+), 44 deletions(-) create mode 100644 frontend/src/app/universe/multichain-explorer/live-model-check.spec.ts create mode 100644 frontend/src/app/universe/multichain-explorer/live-payloads.ts diff --git a/frontend/src/app/universe/multichain-explorer/live-model-check.spec.ts b/frontend/src/app/universe/multichain-explorer/live-model-check.spec.ts new file mode 100644 index 00000000000..8eb011b0447 --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/live-model-check.spec.ts @@ -0,0 +1,188 @@ +// Drives the chain presentation model with payloads captured from the live +// public origin rather than written by hand. +// +// The hand-written fixtures encode what the API was expected to send. These +// encode what it actually sent, and the difference between the two is where +// three real design faults were hiding. Every assertion here is about an +// invariant the model must hold for whatever the chain returns, never about a +// value the chain will move: a lag of one block becomes two while you read it. +import { describe, expect, it } from 'vitest'; +import { LIVE_PAYLOADS } from '@app/universe/multichain-explorer/live-payloads'; +import { + chainProfile, + classifyPayload, + readCollection, + readProtocolCoverage, + readRecordFacts, + readStatusRail, + readTransaction, + readTransactionList, +} from '@app/universe/multichain-explorer/multichain-view'; + +const PAYLOADS: Record = { + dogecoin_status: LIVE_PAYLOADS.dogecoinStatus, + zcash_status: LIVE_PAYLOADS.zcashStatus, + dogecoin_mempool: LIVE_PAYLOADS.dogecoinMempool, + zcash_mempool: LIVE_PAYLOADS.zcashMempool, + dogecoin_protocols: LIVE_PAYLOADS.dogecoinProtocols, + zcash_protocols: LIVE_PAYLOADS.zcashProtocols, +}; +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- captured payloads are read as the API sends them +const load = (name: string): any => structuredClone(PAYLOADS[name]); + +const DOGE = chainProfile('dogecoin'); +const ZEC = chainProfile('zcash'); +const NOW = Date.parse('2026-08-29T08:30:00.000Z'); + +describe('live dogecoin status', () => { + const status = load('dogecoin_status'); + + it('fills all five rail readings from the real envelope', () => { + const rail = readStatusRail(status, DOGE, NOW); + expect(rail.map((r) => r.id)).toEqual(['state', 'tip', 'lag', 'freshness', 'mempool']); + expect(rail.every((r) => r.value && r.value.length > 0)).toBe(true); + }); + + it('reports a null lag as not stated rather than as zero', () => { + // Production sends lagBlocksAtomic: null on this chain right now. Reading + // that as zero would claim the page is describing the tip. + expect(status.lagBlocksAtomic).toBeNull(); + const lag = readStatusRail(status, DOGE, NOW).find((r) => r.id === 'lag'); + expect(lag?.tone).toBe('neutral'); + expect(lag?.exact).toBeNull(); + }); + + it('lists each protocol once, and offers no route to one it cannot open', () => { + const readings = readProtocolCoverage(status, DOGE); + const labels = readings.map((r) => r.label); + expect(new Set(labels).size).toBe(labels.length); + + // tap_doge in the envelope, doge-tap on the route. + const tap = readings.filter((r) => r.label === 'TAP on Doge'); + expect(tap).toHaveLength(1); + expect(tap[0].routeId).toBe('doge-tap'); + + // dunes is reported by the chain and has no page here. + const dunes = readings.find((r) => r.protocolId === 'dunes'); + expect(dunes).toBeDefined(); + expect(dunes?.routeId).toBeNull(); + }); +}); + +describe('live zcash status', () => { + it('reads any non-zero lag as partly proven, not as proven', () => { + const status = load('zcash_status'); + const lag = readStatusRail(status, ZEC, NOW).find((r) => r.id === 'lag'); + expect(lag?.exact).toBe(status.lagBlocksAtomic); + expect(lag?.tone).toBe(status.lagBlocksAtomic === '0' ? 'proven' : 'partial'); + }); +}); + +describe('live mempool payloads', () => { + for (const [name, profile] of [ + ['dogecoin_mempool', DOGE], + ['zcash_mempool', ZEC], + ] as const) { + it(`${name} reads as transactions, not as a table of field names`, () => { + const payload = load(name); + const list = readTransactionList(payload, profile); + expect(list).not.toBeNull(); + expect(list!.rows.length).toBeGreaterThan(0); + + for (const row of list!.rows) { + expect(row.txid).toMatch(/^[0-9a-f]{64}$/); + expect(row.statusLabel.length).toBeGreaterThan(0); + expect(['proven', 'partial', 'pending', 'unavailable', 'neutral']).toContain(row.statusTone); + } + }); + + it(`${name} keeps every reported cost exact`, () => { + const payload = load(name); + const list = readTransactionList(payload, profile)!; + for (const row of list.rows) { + if (row.fee) { + expect(row.fee.exact).toMatch(/^-?(0|[1-9][0-9]*)$/); + } + if (row.logicalActions) { + expect(row.logicalActions.exact).toMatch(/^(0|[1-9][0-9]*)$/); + } + } + }); + + it(`${name} picks a cost column its rows can actually fill`, () => { + const payload = load(name); + const list = readTransactionList(payload, profile)!; + if (list.costColumn === 'amount') { + expect(list.rows.some((row) => row.fee)).toBe(true); + } else if (list.costColumn === 'logical-actions') { + // No row reports an amount, so an amount column would have been empty. + expect(list.rows.every((row) => !row.fee)).toBe(true); + expect(list.rows.some((row) => row.logicalActions)).toBe(true); + } + }); + } + + it('does not fall through to the generic table for a transaction list', () => { + const payload = load('dogecoin_mempool'); + expect(classifyPayload(payload)).toBe('collection'); + expect(readTransactionList(payload, DOGE)).not.toBeNull(); + }); +}); + +describe('live transaction envelopes', () => { + it('every pending dogecoin transaction reads without loss', () => { + const payload = load('dogecoin_mempool'); + for (const raw of payload.transactions) { + const tx = readTransaction(raw, DOGE); + expect(tx).not.toBeNull(); + expect(tx!.txid).toBe(raw.txid); + // A total is only shown when every side carried an amount. + if (tx!.inputTotal) { + expect(tx!.inputs.every((input) => input.amount)).toBe(true); + } + } + }); + + it('a real zcash transaction reports shielded structure without participants', () => { + const payload = load('zcash_mempool'); + const shielded = payload.transactions.find( + (t: { shielded?: unknown }) => t.shielded + ); + if (!shielded) { + return; // none pending in this capture, which is itself a valid state + } + const tx = readTransaction(shielded, ZEC); + expect(tx!.shielded).not.toBeNull(); + expect(tx!.shielded!.notice.length).toBeGreaterThan(0); + expect(tx!.shielded!.components).toHaveLength(5); + }); +}); + +describe('live protocol manifests', () => { + for (const [name, profile] of [ + ['dogecoin_protocols', DOGE], + ['zcash_protocols', ZEC], + ] as const) { + it(`${name} renders as a table with no constant column wasting a slot`, () => { + const payload = load(name); + const collection = readCollection(payload, profile); + expect(collection).not.toBeNull(); + const keys = collection!.columns.map((c) => c.key); + // chain and schemaVersion are identical on every registry row. + expect(keys).not.toContain('schemaVersion'); + expect(keys).not.toContain('chain'); + }); + + it(`${name} leaves no scalar unreported`, () => { + const payload = load(name); + const collection = readCollection(payload, profile); + const facts = readRecordFacts(payload, profile, [ + 'chain', + 'network', + collection!.sourceKey, + ]); + expect(facts.length).toBeGreaterThan(0); + expect(facts.every((f) => f.label.length > 0)).toBe(true); + }); + } +}); diff --git a/frontend/src/app/universe/multichain-explorer/live-payloads.ts b/frontend/src/app/universe/multichain-explorer/live-payloads.ts new file mode 100644 index 00000000000..81f059a122d --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/live-payloads.ts @@ -0,0 +1,1599 @@ +/** + * Payloads captured from the live public origin, not written by hand. + * + * https://explorer.bitcoinuniverse.io at 2026-08-29T08:26Z, release + * mempool-8f6005ae0, overlay backend-apis-d1b34dbd. Only the pending + * transaction lists are trimmed, to four and five entries; nothing else is + * edited, reordered or tidied. + * + * They are here because the hand-written fixtures encoded what the API was + * expected to send and the real thing differs in ways that changed the + * design. Three of them, each found by these payloads and not by the + * fixtures: + * + * - the pending list carries whole transaction envelopes, not summary rows, + * so a generic table spent three of its seven columns on schemaVersion, + * chain and network, identical in every row; + * - Zcash reports no fee amount on a pending transaction at all, because a + * shielded transaction's fee cannot be read from its transparent side, and + * reports ZIP-317 logical actions instead; + * - the capability envelope names TAP on Doge `tap_doge` while the API + * serves it at `/protocols/doge-tap`, and reports `dunes`, which this + * explorer has no page for. + * + * Refresh them by re-capturing from the same endpoints. They are a record of + * a shape, so a refresh that changes one is a finding rather than a chore. + */ + +export const LIVE_PAYLOADS = { + dogecoinStatus: { + schemaVersion: 'universe-chain-capability-v1', + chain: 'dogecoin', + network: 'mainnet', + asset: { + symbol: 'DOGE', + name: 'Dogecoin', + precision: 8, + atomicUnit: 'koinu' + }, + ready: false, + tip: { + heightAtomic: '6351906', + blockHash: '648c241e0a6602ebae1b6e589a228cd5b3e60d6988423343f6dae74216779576', + observedAt: '2026-08-29T08:27:13.782Z' + }, + sync: { + state: 'degraded', + initialBlockDownload: false, + progressDecimal: null, + updatedAt: '2026-08-29T08:27:13.782Z' + }, + mempool: { + supported: true, + state: 'ready', + completeness: 'complete', + snapshotId: 'dogecoin-mainnet-125515f9916eb54b416b2ace409554db', + sequenceAtomic: '1554', + observedAt: '2026-08-29T08:27:13.782Z' + }, + reads: { + transaction: true, + block: true, + address: true, + outpoint: true, + feeEstimates: true, + projectedBlocks: false + }, + protocols: [ + { + protocolId: 'doginals', + state: 'unavailable', + coverage: 'unavailable', + updatedAt: null, + lagBlocksAtomic: null, + degradedReasons: [ + 'authority-capability-disabled' + ] + }, + { + protocolId: 'drc20', + state: 'unavailable', + coverage: 'unavailable', + updatedAt: null, + lagBlocksAtomic: null, + degradedReasons: [ + 'authority-capability-disabled' + ] + }, + { + protocolId: 'tap_doge', + state: 'degraded', + coverage: 'unavailable', + updatedAt: null, + lagBlocksAtomic: null, + degradedReasons: [ + 'protocol-authority-unavailable' + ] + }, + { + protocolId: 'dunes', + state: 'unavailable', + coverage: 'unavailable', + updatedAt: null, + lagBlocksAtomic: null, + degradedReasons: [ + 'authority-capability-disabled' + ] + } + ], + coverage: { + confirmedHistory: 'unavailable', + addressHistory: 'unavailable', + protocolHistory: 'unavailable' + }, + updatedAt: '2026-08-29T08:27:13.782Z', + lagBlocksAtomic: null, + degradedReasons: [ + 'base-chain-authority-unavailable', + 'confirmed-history-authority-unavailable', + 'protocol-history-unavailable' + ], + release: { + sha: 'development' + } + }, + zcashStatus: { + schemaVersion: 'universe-chain-capability-v1', + chain: 'zcash', + network: 'mainnet', + asset: { + symbol: 'ZEC', + name: 'Zcash', + precision: 8, + atomicUnit: 'zatoshi' + }, + ready: false, + tip: { + heightAtomic: '3464548', + blockHash: '000000000094e3f346dd672170a92ff16ad6e74694c9ffd43d72296e4bda42ab', + observedAt: '2026-08-29T08:27:13.196Z' + }, + sync: { + state: 'degraded', + initialBlockDownload: true, + progressDecimal: null, + updatedAt: '2026-08-29T08:27:13.790Z' + }, + mempool: { + supported: true, + state: 'ready', + completeness: 'complete', + snapshotId: 'zcash-fcdd0365c79832b5d61484b5d0cc834e143ac771', + sequenceAtomic: '57', + observedAt: '2026-08-29T08:27:13.196Z' + }, + reads: { + transaction: true, + block: true, + address: true, + outpoint: true, + feeEstimates: true, + projectedBlocks: false + }, + protocols: [ + { + protocolId: 'zerdinals', + state: 'ready', + coverage: 'complete', + updatedAt: '2026-08-29T08:27:13.790Z', + lagBlocksAtomic: '2', + degradedReasons: [] + }, + { + protocolId: 'zrunes', + state: 'ready', + coverage: 'complete', + updatedAt: '2026-08-29T08:27:13.790Z', + lagBlocksAtomic: '2', + degradedReasons: [] + }, + { + protocolId: 'zrc20', + state: 'ready', + coverage: 'complete', + updatedAt: '2026-08-29T08:27:13.790Z', + lagBlocksAtomic: '2', + degradedReasons: [] + } + ], + coverage: { + confirmedHistory: 'complete', + addressHistory: 'complete', + protocolHistory: 'complete' + }, + updatedAt: '2026-08-29T08:27:13.196Z', + lagBlocksAtomic: '2', + degradedReasons: [], + release: { + sha: 'development' + } + }, + dogecoinMempool: { + snapshot: { + chain: 'dogecoin', + network: 'mainnet', + snapshotId: 'dogecoin-mainnet-125515f9916eb54b416b2ace409554db', + sequenceAtomic: '1554', + tip: { + heightAtomic: '6351906', + blockHash: '648c241e0a6602ebae1b6e589a228cd5b3e60d6988423343f6dae74216779576', + observedAt: '2026-08-29T08:27:13.782Z' + }, + initialBlockDownload: false, + observedAt: '2026-08-29T08:27:13.782Z', + completeness: 'complete' + }, + transactions: [ + { + schemaVersion: 'universe-transaction-v1', + chain: 'dogecoin', + network: 'mainnet', + txid: '00d2039119ea4ecb7d026f4c89a84a908de826a3d68adc036f905dc574e0a4a7', + status: 'pending', + firstSeenAt: '2026-08-29T08:26:53.779Z', + observedAt: '2026-08-29T08:27:13.782Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '2751', + fee: { + amountAtomic: '10884900', + rateDecimal: null, + rateUnit: null + }, + conflicts: [], + replacement: null, + expiry: null, + transparent: { + inputs: [ + { + indexAtomic: '0', + previousOutpoint: '1b6675e3f71d8bf44e8c3309c6f86d50d5bab1759d6647777959ee02d5887313:80', + address: null, + valueAtomic: '122645319284', + scriptSigHex: '473044022053f94d02b596cf107085a79f4a6dd505110a7f7cd5c02260ee45c3d7d45bfc39022028de83441de3e8f2927cbec735f704e6cf9c875e7c1caf19663746cd2d7baa1e0121038981275966cee875fa3dc0c403aad7cd77f6fedd2e6d4488ab8f45a5e51c04cc', + coinbase: false + } + ], + outputs: [ + { + indexAtomic: '0', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914f351ce53d9e6e808b183d40e1d30a679aa3b1ba087', + spent: null + }, + { + indexAtomic: '1', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9149c877b266645cdbb0b22a86740b82b19465c40bd87', + spent: null + }, + { + indexAtomic: '2', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9144b13a115b62c2908b474d6fb1f72b993f75a2ebc87', + spent: null + }, + { + indexAtomic: '3', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914e353e26d9aa368283b5e330fc8fd4addafe0d17987', + spent: null + }, + { + indexAtomic: '4', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914519433b6d2bff4cdb965b4b4758dfc73c3ff1bf587', + spent: null + }, + { + indexAtomic: '5', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9140bb0296f0e744fc0ff5e24bd037d0112a0b3ff5387', + spent: null + }, + { + indexAtomic: '6', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914e4b311be790f5a84088158b574f041eb05a5113687', + spent: null + }, + { + indexAtomic: '7', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9146e69f43c5d40e84b213eab57e01022105e4e02f887', + spent: null + }, + { + indexAtomic: '8', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91416066f7c799f43cafa85550899356a32b11d687a87', + spent: null + }, + { + indexAtomic: '9', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9144ee9af04979865296f117189fdd28d105d92daf787', + spent: null + }, + { + indexAtomic: '10', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914d314c510ba7d93c1e36c8df3259059c3101833cf87', + spent: null + }, + { + indexAtomic: '11', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914ad3a7df5d5e66031961d49b0df2bfa590a1c336987', + spent: null + }, + { + indexAtomic: '12', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9144fad30104fa12d596fa2ee80f90c9a91a3627b9887', + spent: null + }, + { + indexAtomic: '13', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914be3bc741874795b1f25e530d335d9ca6189d22ad87', + spent: null + }, + { + indexAtomic: '14', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9144f930025f9540c42ee6370ba27da98f86ffc42f987', + spent: null + }, + { + indexAtomic: '15', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914ae0786d14929a5da804df259b2c62bfb28128d6b87', + spent: null + }, + { + indexAtomic: '16', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9147ba26de4403ee1e298658afd64c3d8a76165cb7b87', + spent: null + }, + { + indexAtomic: '17', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914fe7d364d6193fc1d3e1ced723f2cb7202b11838987', + spent: null + }, + { + indexAtomic: '18', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91434616c7fd40a4256607b586fcbe644183e2bac9787', + spent: null + }, + { + indexAtomic: '19', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9140111e815a3f7a62f3fe324739e0ea1470c27883a87', + spent: null + }, + { + indexAtomic: '20', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91432e3de02856a9814aee166cb21bdacfa1ab5d7e787', + spent: null + }, + { + indexAtomic: '21', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914f9b8a0e13eaa5511c47743fecc13828cfd7df34887', + spent: null + }, + { + indexAtomic: '22', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914aec8308026a6cd3d546ff5251aec750c621b8a8287', + spent: null + }, + { + indexAtomic: '23', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914491b6304333bcc45bf6224a9e2a9ac1739fb650087', + spent: null + }, + { + indexAtomic: '24', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914c15d1d600998b738f7de342e409f1115b5d8cfbf87', + spent: null + }, + { + indexAtomic: '25', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9141767fc89c583518ba816d8b4ba00f1107a8e5b3e87', + spent: null + }, + { + indexAtomic: '26', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9148efaffaee1d88bb5f3ca2664ec536b9057cd5fd687', + spent: null + }, + { + indexAtomic: '27', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91477d8fc2319ad8e75f8a264b01bfd9581ac4c50f487', + spent: null + }, + { + indexAtomic: '28', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91484e68cc0fd8785617d91d5dbe53e615b8ec7362787', + spent: null + }, + { + indexAtomic: '29', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914121e246489354aa999dc99f0d7959989081499b487', + spent: null + }, + { + indexAtomic: '30', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914b9bd3e9313405c5b53851a65cdd8b0adaa4c8a8a87', + spent: null + }, + { + indexAtomic: '31', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914913577f7ed3e1c6c43add217fc17388c519c3f5587', + spent: null + }, + { + indexAtomic: '32', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9142353d0b44a112962cb750b82c9178d049fad917687', + spent: null + }, + { + indexAtomic: '33', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9144108dad0d0bbe6a314e0ec5c33ae9d5f06ae0a6c87', + spent: null + }, + { + indexAtomic: '34', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9147b17c2cd7ab0150badd8baeaf1648f525864428287', + spent: null + }, + { + indexAtomic: '35', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914f6edd5a06996e0081cf1fada0ce527999853035d87', + spent: null + }, + { + indexAtomic: '36', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91459443886670fdeebb8f1c1b0d27dccc181d969e987', + spent: null + }, + { + indexAtomic: '37', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9142f20a56df6997a1c6862f595229b7206a66cda8187', + spent: null + }, + { + indexAtomic: '38', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914373c4d1aa25e2caa539a2fafaf1ba4a29892166287', + spent: null + }, + { + indexAtomic: '39', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91473e5121c30cc33bfbcf2a60a1a18169af3b3464e87', + spent: null + }, + { + indexAtomic: '40', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914cbcc6bdd735ffe3b45596d473f1f797b5d79d2fe87', + spent: null + }, + { + indexAtomic: '41', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9145a10270165f4d4c1692877cc689d1a2585eb194d87', + spent: null + }, + { + indexAtomic: '42', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9142f8b5d8f9e3cf756c35724bedb8fd39f5f1d964a87', + spent: null + }, + { + indexAtomic: '43', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914a6c471d5cf899e818005462794a34bdf1f4d634587', + spent: null + }, + { + indexAtomic: '44', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9140b7fe409b308ffb4d1a548e64992a24b09b641be87', + spent: null + }, + { + indexAtomic: '45', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914fb96a3ac34d85535b9ba6bca00ad8c8a9cd85f6f87', + spent: null + }, + { + indexAtomic: '46', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91467967d690e4ca4bb5476429017e53ee4b3a234e087', + spent: null + }, + { + indexAtomic: '47', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914a9380fe298761b20467e3ae75473e3d399fc25e587', + spent: null + }, + { + indexAtomic: '48', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914768793cc89063d66208d30993add92e487af246287', + spent: null + }, + { + indexAtomic: '49', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914f7e6e7f09e20c493e4d56cf0cd4364892aae87ae87', + spent: null + }, + { + indexAtomic: '50', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9149346a048c2d8d71d1dfc24d194b9e80e2b09909b87', + spent: null + }, + { + indexAtomic: '51', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914805e457185f06cc8cca6aa171fe0d59874ec55d987', + spent: null + }, + { + indexAtomic: '52', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914cf9861f0b1fcbf81f922cfbe4cd8764ff555b0bc87', + spent: null + }, + { + indexAtomic: '53', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914083585a6a05ced615539513a7e8087a0499bec0087', + spent: null + }, + { + indexAtomic: '54', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914b64094cb985e8128b1e110805c01094f2216d50287', + spent: null + }, + { + indexAtomic: '55', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9142e3b0fe57ebe2f3e09089eca65e9008eed88021687', + spent: null + }, + { + indexAtomic: '56', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914d89a9a832f0ef58e4c2ba7f6eb164279ce2abbd087', + spent: null + }, + { + indexAtomic: '57', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914c6c2ea6fca6f947cdf12438a87a1411f891e7ad287', + spent: null + }, + { + indexAtomic: '58', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9144b12b11f20ef9f7fc8f1ee4c48680aa85e70ea9b87', + spent: null + }, + { + indexAtomic: '59', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914892830d7e23803c3429e50839c11c2b1606a784d87', + spent: null + }, + { + indexAtomic: '60', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91468f90a79ebc33f4142f25233537461d9a98efe5187', + spent: null + }, + { + indexAtomic: '61', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9145bac018119ec73b4bde99e9961928f8140fca8a787', + spent: null + }, + { + indexAtomic: '62', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9148a552cb7c5c673fae953f74c8e6cfe0805562d4c87', + spent: null + }, + { + indexAtomic: '63', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914f9232fdd70c160e3af4057db284b9fadf1b529ad87', + spent: null + }, + { + indexAtomic: '64', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91485033b8b775e06d8b3c31c694db747b9165dbddb87', + spent: null + }, + { + indexAtomic: '65', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914ed087b0a907b01336b15510bfb42bc80cd4d468687', + spent: null + }, + { + indexAtomic: '66', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a91443af873d267fa175ef33c8150b3eeba3f299569c87', + spent: null + }, + { + indexAtomic: '67', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914c3f1a64fc7a05ca0175e3de5c36eaf365da394e187', + spent: null + }, + { + indexAtomic: '68', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914db47501d3714cba1cdb72d28062a05cc716b9c4187', + spent: null + }, + { + indexAtomic: '69', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9144c27422c3f26060a46145d1b05231ef7f723b3d887', + spent: null + }, + { + indexAtomic: '70', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9148495e72695c4bb7d04ac90d1899b3d30c241a3cf87', + spent: null + }, + { + indexAtomic: '71', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9144697ee495b0e6ad435d8b614cd3e602099cdf83d87', + spent: null + }, + { + indexAtomic: '72', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9141e69a1f1c3d9e40b9fb7525ba548347c05c3ed2087', + spent: null + }, + { + indexAtomic: '73', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9146536e84f2c09e8153627aba4fb99405710ce147a87', + spent: null + }, + { + indexAtomic: '74', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914aef5a15b76db13493fcb93e3d31d4847d72bf97c87', + spent: null + }, + { + indexAtomic: '75', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914b934cea9a3230d6b1bd3626f9f4eff74416ff67587', + spent: null + }, + { + indexAtomic: '76', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914f5e0186152ed7b60867f8e8a702348cc76371ec087', + spent: null + }, + { + indexAtomic: '77', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914f832cbb66f65a4ee594d6bdb9c712a75e031e1cf87', + spent: null + }, + { + indexAtomic: '78', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a914744e18b8e20eb54a7182ba92ac3361e5e7985d1687', + spent: null + }, + { + indexAtomic: '79', + address: null, + valueAtomic: '2000000', + scriptPubKeyHex: 'a9147fcf6405df275d29b272b99c7395d5c697a5ea5687', + spent: null + }, + { + indexAtomic: '80', + address: null, + valueAtomic: '122474434384', + scriptPubKeyHex: '76a9141df6354ebf997bb69cad25ce0b3927ef36d629dd88ac', + spent: null + } + ] + }, + shielded: null, + protocolActions: { + candidates: [], + confirmed: [] + }, + evidenceIds: [ + 'dogecoin-core:raw:00d2039119ea4ecb7d026f4c89a84a908de826a3d68adc036f905dc574e0a4a7', + 'dogecoin-core:mempool:00d2039119ea4ecb7d026f4c89a84a908de826a3d68adc036f905dc574e0a4a7' + ], + completeness: 'complete' + }, + { + schemaVersion: 'universe-transaction-v1', + chain: 'dogecoin', + network: 'mainnet', + txid: '0291b7485ba29475fc8612e5184d4aace1593a77be7a1350f535bb4cf52b7da7', + status: 'pending', + firstSeenAt: '2026-08-29T07:06:18.954Z', + observedAt: '2026-08-29T08:27:13.782Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '223', + fee: { + amountAtomic: '153600', + rateDecimal: null, + rateUnit: null + }, + conflicts: [], + replacement: null, + expiry: null, + transparent: { + inputs: [ + { + indexAtomic: '0', + previousOutpoint: '3a6ddcdeaa8e0c3a7474b1e9a1699c31e43d9a674ca8a04d8a37d9d64326ca87:0', + address: null, + valueAtomic: '20000000', + scriptSigHex: '473044022052d9eff8cba7e5fd10960358e447c59660783c7f90236851f0439dfab7e402cb02206d2e2d5f4117649fdad9bff1a9a8ffd30a3ea21a619aa9fe4f1fdd142f73a37d014104d75c6b53f27ea174087acb315e49f9d23ca4d13faf3d4b83c0277caa3e138df5a271f356bbd59eaeb03fa06bca244066655b1ab7bd26f179aa4240fd6d2d5eff', + coinbase: false + } + ], + outputs: [ + { + indexAtomic: '0', + address: null, + valueAtomic: '19846400', + scriptPubKeyHex: '76a914f251dc7a7fa93ae5e87aacebeb0da73837ffe91388ac', + spent: null + } + ] + }, + shielded: null, + protocolActions: { + candidates: [], + confirmed: [] + }, + evidenceIds: [ + 'dogecoin-core:raw:0291b7485ba29475fc8612e5184d4aace1593a77be7a1350f535bb4cf52b7da7', + 'dogecoin-core:mempool:0291b7485ba29475fc8612e5184d4aace1593a77be7a1350f535bb4cf52b7da7' + ], + completeness: 'complete' + }, + { + schemaVersion: 'universe-transaction-v1', + chain: 'dogecoin', + network: 'mainnet', + txid: '02e488a61a6ec16185328eb615f61c07132bfc43678a4643d7448373a521c6c7', + status: 'pending', + firstSeenAt: '2026-08-29T06:26:45.080Z', + observedAt: '2026-08-29T08:27:13.782Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '223', + fee: { + amountAtomic: '153600', + rateDecimal: null, + rateUnit: null + }, + conflicts: [], + replacement: null, + expiry: null, + transparent: { + inputs: [ + { + indexAtomic: '0', + previousOutpoint: 'ba1412b2e9c440c90565dfee4351a6a277801c16c2283f151bd3da1adec43b45:1', + address: null, + valueAtomic: '20000000', + scriptSigHex: '47304402203c586eef205018df76b2fd60cdf2acd655bf71cc382c3421a4f3e71d2af41ff3022015507f0b84a24cf7bce31440e9f3d95d42a85ffa6a3f75189c6ab15ccf76d9bc014104dc5dbe584383396cba30667fb93b492a1e44c75a2b0ae751c1ef2235f92894602dbfaed953a5dcdd52fe42b00744aa4005194913b21f2797ffbf5ea258bdec4d', + coinbase: false + } + ], + outputs: [ + { + indexAtomic: '0', + address: null, + valueAtomic: '19846400', + scriptPubKeyHex: '76a914f251dc7a7fa93ae5e87aacebeb0da73837ffe91388ac', + spent: null + } + ] + }, + shielded: null, + protocolActions: { + candidates: [], + confirmed: [] + }, + evidenceIds: [ + 'dogecoin-core:raw:02e488a61a6ec16185328eb615f61c07132bfc43678a4643d7448373a521c6c7', + 'dogecoin-core:mempool:02e488a61a6ec16185328eb615f61c07132bfc43678a4643d7448373a521c6c7' + ], + completeness: 'complete' + }, + { + schemaVersion: 'universe-transaction-v1', + chain: 'dogecoin', + network: 'mainnet', + txid: '0341487a72eddc478ee804849750eb60b88263ee422b08397eae991b66946d0b', + status: 'pending', + firstSeenAt: '2026-08-29T07:06:48.961Z', + observedAt: '2026-08-29T08:27:13.782Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '223', + fee: { + amountAtomic: '153600', + rateDecimal: null, + rateUnit: null + }, + conflicts: [], + replacement: null, + expiry: null, + transparent: { + inputs: [ + { + indexAtomic: '0', + previousOutpoint: '39581e38a19abcc87781a7b0840ff9026c0d592709d60db30ad371815ec4a0ae:2', + address: null, + valueAtomic: '20000000', + scriptSigHex: '47304402203224a4789a3722558165b00452b34cde1b837755553f879b6323f74b2c25574b02205bb4a3e70da5a7a7d9c1617a9f567f8affab782fff5b0688b7b2ab64348c37ce014104d9d8fed4b18ee5bfd043c7fd2f961c229193cc05640dd3a54b1ea722192d12e4676fdf047f686e6be5ff0e40f96f81561e86be6a27d582b4a0a8d6305d049b11', + coinbase: false + } + ], + outputs: [ + { + indexAtomic: '0', + address: null, + valueAtomic: '19846400', + scriptPubKeyHex: '76a914f251dc7a7fa93ae5e87aacebeb0da73837ffe91388ac', + spent: null + } + ] + }, + shielded: null, + protocolActions: { + candidates: [], + confirmed: [] + }, + evidenceIds: [ + 'dogecoin-core:raw:0341487a72eddc478ee804849750eb60b88263ee422b08397eae991b66946d0b', + 'dogecoin-core:mempool:0341487a72eddc478ee804849750eb60b88263ee422b08397eae991b66946d0b' + ], + completeness: 'complete' + } + ] + }, + zcashMempool: { + snapshot: { + network: 'mainnet', + snapshotId: 'zcash-fcdd0365c79832b5d61484b5d0cc834e143ac771', + sequenceAtomic: '57', + tip: { + heightAtomic: '3464548', + blockHash: '000000000094e3f346dd672170a92ff16ad6e74694c9ffd43d72296e4bda42ab', + observedAt: '2026-08-29T08:27:13.196Z' + }, + initialBlockDownload: true, + completeness: 'complete', + transactionCountAtomic: '5', + hydratedCountAtomic: '0', + observedAt: '2026-08-29T08:27:13.196Z' + }, + transactions: [ + { + schemaVersion: 'universe-transaction-v1', + chain: 'zcash', + network: 'mainnet', + txid: '4bda6a72c537e3ab42f81495e625d92dc29a2d544405212634176cc720c9d338', + status: 'pending', + firstSeenAt: '2026-08-29T08:26:23.192Z', + observedAt: '2026-08-29T08:27:13.196Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '244', + fee: { + amountAtomic: null, + rateDecimal: null, + rateUnit: 'zatoshi/logical-action', + logicalActionsAtomic: '2', + model: 'ZIP-317-revision-1' + }, + conflicts: [], + replacement: null, + expiry: { + heightAtomic: '3464648', + state: 'pending' + }, + transparent: { + inputs: [ + { + indexAtomic: '0', + previousOutpoint: 'c697245abda6bebf4bb96d9c6230bcb74848d0ee845331ee3cb9784cb03e06ef:1', + address: null, + valueAtomic: null, + scriptSigHex: '473044022070bf646632f52d917f4a19987d2949b763219d432b43cc25a457a4f6239833180220287f4f10c191eab86a0c78b374715c03d454e5314a6c749479e9b2531edd44170121033e842058be42e762effd7daf3b9e6cc44321f904cc8736fa50b90d261fe7b1e9', + coinbase: false + } + ], + outputs: [ + { + indexAtomic: '0', + address: 't1Uo52SqpupL6Z1TPVHWhGSxrsjVgcyMKsM', + valueAtomic: '100000000', + scriptPubKeyHex: '76a91477cddbb738117e9116e0527c66d36cf472b8965688ac', + spent: null + }, + { + indexAtomic: '1', + address: 't1YhRyphDWJ8h6oUwrMJ4NaH7QYJgRsFS9i', + valueAtomic: '529578076', + scriptPubKeyHex: '76a914a29d5b9834b8cb63c8516a4094c6a7f946a56dfa88ac', + spent: null + } + ] + }, + shielded: { + sproutJoinSplitsAtomic: '0', + saplingSpendsAtomic: '0', + saplingOutputsAtomic: '0', + orchardActionsAtomic: '0', + ironwoodActionsAtomic: '0', + valueBalanceAtomic: null, + saplingValueBalanceAtomic: '0', + orchardValueBalanceAtomic: '0', + ironwoodValueBalanceAtomic: null, + privacyNotice: 'Shielded sender, recipient, address, amount, ownership, and direction are not observable from public transaction data.' + }, + protocolActions: { + candidates: [], + confirmed: [] + }, + evidenceIds: [ + 'zebra:getrawmempool:4bda6a72c537e3ab42f81495e625d92dc29a2d544405212634176cc720c9d338', + 'zebra:getrawtransaction:4bda6a72c537e3ab42f81495e625d92dc29a2d544405212634176cc720c9d338' + ], + completeness: 'complete' + }, + { + schemaVersion: 'universe-transaction-v1', + chain: 'zcash', + network: 'mainnet', + txid: '58f5e4e5bc127396c05f27f724c493727a5f64d1e2b1644ab19ca1d87f76a079', + status: 'pending', + firstSeenAt: '2026-08-29T08:25:18.187Z', + observedAt: '2026-08-29T08:27:13.196Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '245', + fee: { + amountAtomic: null, + rateDecimal: null, + rateUnit: 'zatoshi/logical-action', + logicalActionsAtomic: '2', + model: 'ZIP-317-revision-1' + }, + conflicts: [], + replacement: null, + expiry: { + heightAtomic: '0', + state: 'pending' + }, + transparent: { + inputs: [ + { + indexAtomic: '0', + previousOutpoint: '9543e886776f8d053482697b62d7e0c7e1ae8579a2f7d834dfaf83b4a93001ff:0', + address: null, + valueAtomic: null, + scriptSigHex: '483045022100aa573afdbb53a283c5ccabe017ccc9e792d47ce7461b76309425251e87077ddd022041c8cdc81c0b4627c671eeb2356d5174105799379f85407e8f3d6a3182a6462701210214e3b384c0c97fefa02268d20c4d1dc1916aabce7b7947523bed78889244cdc5', + coinbase: false + } + ], + outputs: [ + { + indexAtomic: '0', + address: 't1NLjGNiwrw4dDQkM9immE5aRh6ut1eRnbS', + valueAtomic: '505000000', + scriptPubKeyHex: '76a9143101cb3ae364ac29b91f3a2a837b3f6a8f67e79d88ac', + spent: null + }, + { + indexAtomic: '1', + address: 't1PZswZWuE9cnQMKY7TQFiK9hWhvnjNghh3', + valueAtomic: '121628907', + scriptPubKeyHex: '76a9143e7691b7045db5e10b64cea1cec5d0eb98e3ce8b88ac', + spent: null + } + ] + }, + shielded: { + sproutJoinSplitsAtomic: '0', + saplingSpendsAtomic: '0', + saplingOutputsAtomic: '0', + orchardActionsAtomic: '0', + ironwoodActionsAtomic: '0', + valueBalanceAtomic: null, + saplingValueBalanceAtomic: '0', + orchardValueBalanceAtomic: '0', + ironwoodValueBalanceAtomic: null, + privacyNotice: 'Shielded sender, recipient, address, amount, ownership, and direction are not observable from public transaction data.' + }, + protocolActions: { + candidates: [], + confirmed: [] + }, + evidenceIds: [ + 'zebra:getrawmempool:58f5e4e5bc127396c05f27f724c493727a5f64d1e2b1644ab19ca1d87f76a079', + 'zebra:getrawtransaction:58f5e4e5bc127396c05f27f724c493727a5f64d1e2b1644ab19ca1d87f76a079' + ], + completeness: 'complete' + }, + { + schemaVersion: 'universe-transaction-v1', + chain: 'zcash', + network: 'mainnet', + txid: '982b98c61601fcec1315ff345a3be904017a122a9397ec0deba18dc4aa32450f', + status: 'pending', + firstSeenAt: '2026-08-29T08:25:48.189Z', + observedAt: '2026-08-29T08:27:13.196Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '244', + fee: { + amountAtomic: null, + rateDecimal: null, + rateUnit: 'zatoshi/logical-action', + logicalActionsAtomic: '2', + model: 'ZIP-317-revision-1' + }, + conflicts: [], + replacement: null, + expiry: { + heightAtomic: '0', + state: 'pending' + }, + transparent: { + inputs: [ + { + indexAtomic: '0', + previousOutpoint: '4d77c31aa6c0466263880a634b36bed79f51051c5af9acffad28ceac904dc08f:1', + address: null, + valueAtomic: null, + scriptSigHex: '473044022035c6595c9bae8c49db73d337c25fe01a2e12b0f076a2ee3d6fe292ffafc8184a02205eca36cdd18982060e31cd5d1c32687981bf4ce9d793ba0f4d1192a854339cdf012102106a2dcaaac2ae3b24358a03f4264e05db420c5b090399bc23885fa02fef7716', + coinbase: false + } + ], + outputs: [ + { + indexAtomic: '0', + address: 't1VN3eH8nxqCQFcJ2i5Zv54LdD7XLnKXRDN', + valueAtomic: '196131846', + scriptPubKeyHex: '76a9147e0a6e63687b23f0fabbd8127a5df01a7b31ae1a88ac', + spent: null + }, + { + indexAtomic: '1', + address: 't1Ku2KLyndDPsR32jwnrTMd3yvi9tfFP8ML', + valueAtomic: '3872726483', + scriptPubKeyHex: '76a9141634f5ff0b8f6603a17570436d6c12a91f4b1fed88ac', + spent: null + } + ] + }, + shielded: { + sproutJoinSplitsAtomic: '0', + saplingSpendsAtomic: '0', + saplingOutputsAtomic: '0', + orchardActionsAtomic: '0', + ironwoodActionsAtomic: '0', + valueBalanceAtomic: null, + saplingValueBalanceAtomic: '0', + orchardValueBalanceAtomic: '0', + ironwoodValueBalanceAtomic: null, + privacyNotice: 'Shielded sender, recipient, address, amount, ownership, and direction are not observable from public transaction data.' + }, + protocolActions: { + candidates: [], + confirmed: [] + }, + evidenceIds: [ + 'zebra:getrawmempool:982b98c61601fcec1315ff345a3be904017a122a9397ec0deba18dc4aa32450f', + 'zebra:getrawtransaction:982b98c61601fcec1315ff345a3be904017a122a9397ec0deba18dc4aa32450f' + ], + completeness: 'complete' + }, + { + schemaVersion: 'universe-transaction-v1', + chain: 'zcash', + network: 'mainnet', + txid: 'c0f08743c4e645b1bb7409c64fb376adaeeca8bc9f5bdb7c8f60ef69e85cbb8c', + status: 'pending', + firstSeenAt: '2026-08-29T08:26:13.191Z', + observedAt: '2026-08-29T08:27:13.196Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '15512', + fee: { + amountAtomic: null, + rateDecimal: null, + rateUnit: 'zatoshi/logical-action', + logicalActionsAtomic: '5', + model: 'ZIP-317-revision-1' + }, + conflicts: [], + replacement: null, + expiry: { + heightAtomic: '3464589', + state: 'pending' + }, + transparent: { + inputs: [], + outputs: [ + { + indexAtomic: '0', + address: 't1WZUWvWzzL7bnTuDkfxnXoeWV5trVTqLqw', + valueAtomic: '100800000', + scriptPubKeyHex: '76a9148b2be54997a7cc4885bd0ef844a956b4449dbf8588ac', + spent: null + } + ] + }, + shielded: { + sproutJoinSplitsAtomic: '0', + saplingSpendsAtomic: '0', + saplingOutputsAtomic: '0', + orchardActionsAtomic: '0', + ironwoodActionsAtomic: '4', + valueBalanceAtomic: null, + saplingValueBalanceAtomic: '0', + orchardValueBalanceAtomic: '0', + ironwoodValueBalanceAtomic: '100825000', + privacyNotice: 'Shielded sender, recipient, address, amount, ownership, and direction are not observable from public transaction data.' + }, + protocolActions: { + candidates: [], + confirmed: [] + }, + evidenceIds: [ + 'zebra:getrawmempool:c0f08743c4e645b1bb7409c64fb376adaeeca8bc9f5bdb7c8f60ef69e85cbb8c', + 'zebra:getrawtransaction:c0f08743c4e645b1bb7409c64fb376adaeeca8bc9f5bdb7c8f60ef69e85cbb8c' + ], + completeness: 'complete' + }, + { + schemaVersion: 'universe-transaction-v1', + chain: 'zcash', + network: 'mainnet', + txid: 'dada060cafb1f3e4acf1f7f7c3fb7feba0454b9392eb29cb2c28ef8992581c5f', + status: 'pending', + firstSeenAt: '2026-08-29T08:26:58.194Z', + observedAt: '2026-08-29T08:27:13.196Z', + confirmedAt: null, + removedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: '18340', + fee: { + amountAtomic: null, + rateDecimal: null, + rateUnit: 'zatoshi/logical-action', + logicalActionsAtomic: '5', + model: 'ZIP-317-revision-1' + }, + conflicts: [], + replacement: null, + expiry: { + heightAtomic: '3464588', + state: 'pending' + }, + transparent: { + inputs: [], + outputs: [ + { + indexAtomic: '0', + address: 't1PLVRnDwxoeMxxTmPDhwXzEny21oJZNmCu', + valueAtomic: '148000000', + scriptPubKeyHex: '76a9143bee5e4f9f397189af886895d4940be34e0455a388ac', + spent: null + } + ] + }, + shielded: { + sproutJoinSplitsAtomic: '0', + saplingSpendsAtomic: '0', + saplingOutputsAtomic: '0', + orchardActionsAtomic: '2', + ironwoodActionsAtomic: '2', + valueBalanceAtomic: null, + saplingValueBalanceAtomic: '0', + orchardValueBalanceAtomic: '45945', + ironwoodValueBalanceAtomic: '147979055', + privacyNotice: 'Shielded sender, recipient, address, amount, ownership, and direction are not observable from public transaction data.' + }, + protocolActions: { + candidates: [], + confirmed: [] + }, + evidenceIds: [ + 'zebra:getrawmempool:dada060cafb1f3e4acf1f7f7c3fb7feba0454b9392eb29cb2c28ef8992581c5f', + 'zebra:getrawtransaction:dada060cafb1f3e4acf1f7f7c3fb7feba0454b9392eb29cb2c28ef8992581c5f' + ], + completeness: 'complete' + } + ] + }, + dogecoinProtocols: { + chain: 'dogecoin', + network: 'mainnet', + items: [ + { + schemaVersion: 'universe-explorer-protocol-v1', + id: 'doginals', + aliases: [ + 'doginal' + ], + displayName: 'Doginals', + shortName: 'Doginals', + family: 'OTHER', + chain: 'dogecoin', + networks: [ + 'mainnet' + ], + icon: 'protocol-doginals', + visualToken: 'protocol-doginals', + implementedReadOperations: [], + authorizedReadOperations: [], + releaseStatus: 'BLOCKED', + indexerAuthority: 'ord-dogecoin', + coverage: 'unknown' + }, + { + schemaVersion: 'universe-explorer-protocol-v1', + id: 'drc20', + aliases: [ + 'drc-20' + ], + displayName: 'DRC-20', + shortName: 'DRC-20', + family: 'OTHER', + chain: 'dogecoin', + networks: [ + 'mainnet' + ], + icon: 'protocol-drc20', + visualToken: 'protocol-drc20', + implementedReadOperations: [], + authorizedReadOperations: [], + releaseStatus: 'BLOCKED', + indexerAuthority: 'ord-dogecoin', + coverage: 'unknown' + }, + { + schemaVersion: 'universe-explorer-protocol-v1', + id: 'tap_doge', + aliases: [ + 'tap-on-doge', + 'doge-tap', + 'tap-doge' + ], + displayName: 'TAP on Doge', + shortName: 'Doge TAP', + family: 'OTHER', + chain: 'dogecoin', + networks: [ + 'mainnet' + ], + icon: 'protocol-tap-doge', + visualToken: 'protocol-tap-doge', + implementedReadOperations: [], + authorizedReadOperations: [], + releaseStatus: 'BLOCKED', + indexerAuthority: 'index-doge-tap', + coverage: 'unknown' + }, + { + schemaVersion: 'universe-explorer-protocol-v1', + id: 'dunes', + aliases: [], + displayName: 'Dunes', + shortName: 'Dunes', + family: 'OTHER', + chain: 'dogecoin', + networks: [ + 'mainnet' + ], + icon: 'protocol-dunes', + visualToken: 'protocol-dunes', + implementedReadOperations: [], + authorizedReadOperations: [], + releaseStatus: 'BLOCKED', + indexerAuthority: 'ord-dogecoin', + coverage: 'unknown' + } + ], + confirmedHistory: { + configured: false, + configurationFailure: 'blockbook-unconfigured', + state: 'unavailable', + lastAttemptAt: null, + lastSuccessAt: null, + lastFailureKind: null, + tip: null, + initialSync: null, + mempoolInSync: null, + release: null + }, + mempool: { + configured: true, + configurationFailure: null, + running: false, + lastAttemptAt: '2026-08-29T08:27:13.782Z', + lastSuccessAt: '2026-08-29T08:27:13.782Z', + lastFailureKind: null, + snapshot: { + chain: 'dogecoin', + network: 'mainnet', + snapshotId: 'dogecoin-mainnet-125515f9916eb54b416b2ace409554db', + sequenceAtomic: '1554', + tip: { + heightAtomic: '6351906', + blockHash: '648c241e0a6602ebae1b6e589a228cd5b3e60d6988423343f6dae74216779576', + observedAt: '2026-08-29T08:27:13.782Z' + }, + initialBlockDownload: false, + observedAt: '2026-08-29T08:27:13.782Z', + completeness: 'complete' + } + }, + protocolAuthority: { + configured: true, + state: 'unavailable', + lastAttemptAt: '2026-08-29T08:27:12.791Z', + lastSuccessAt: null, + lastFailureKind: 'transport', + checkpoint: null, + capabilities: null, + authorityId: 'ord-dogecoin' + }, + dogeTapAuthority: { + configured: true, + state: 'unavailable', + lastAttemptAt: '2026-08-29T08:27:12.792Z', + lastSuccessAt: null, + lastFailureKind: 'transport', + authorityId: 'index-doge-tap', + sourceId: null, + coverage: null, + pendingCoverage: null, + reorgCoverage: null, + sourceSequenceAtomic: null, + initialScanComplete: null, + quarantinedRecordCountAtomic: null, + release: null + } + }, + zcashProtocols: { + chain: 'zcash', + network: 'mainnet', + items: [ + { + schemaVersion: 'universe-explorer-protocol-v1', + id: 'zerdinals', + aliases: [ + 'universe-zerdinals' + ], + displayName: 'Zerdinals', + shortName: 'Zerdinals', + family: 'OTHER', + chain: 'zcash', + networks: [ + 'mainnet' + ], + icon: 'protocol-zerdinals', + visualToken: 'protocol-zerdinals', + implementedReadOperations: [], + authorizedReadOperations: [], + releaseStatus: 'BLOCKED', + indexerAuthority: 'index-zcash-metaprotocols', + coverage: 'unknown' + }, + { + schemaVersion: 'universe-explorer-protocol-v1', + id: 'zrunes', + aliases: [], + displayName: 'ZRunes', + shortName: 'zRunes', + family: 'OTHER', + chain: 'zcash', + networks: [ + 'mainnet' + ], + icon: 'protocol-zrunes', + visualToken: 'protocol-zrunes', + implementedReadOperations: [], + authorizedReadOperations: [], + releaseStatus: 'BLOCKED', + indexerAuthority: 'index-zcash-metaprotocols', + coverage: 'unknown' + }, + { + schemaVersion: 'universe-explorer-protocol-v1', + id: 'zrc20', + aliases: [], + displayName: 'ZRC-20', + shortName: 'ZRC-20', + family: 'OTHER', + chain: 'zcash', + networks: [ + 'mainnet' + ], + icon: 'protocol-zrc20', + visualToken: 'protocol-zrc20', + implementedReadOperations: [], + authorizedReadOperations: [], + releaseStatus: 'BLOCKED', + indexerAuthority: 'index-zcash-metaprotocols', + coverage: 'unknown' + } + ], + status: { + configured: true, + configurationFailure: null, + state: 'ready', + running: false, + lastAttemptAt: '2026-08-29T08:27:13.783Z', + lastSuccessAt: '2026-08-29T08:27:15.103Z', + lastFailureKind: null, + snapshot: { + network: 'mainnet', + snapshotId: 'zcash-fcdd0365c79832b5d61484b5d0cc834e143ac771', + sequenceAtomic: '57', + tip: { + heightAtomic: '3464548', + blockHash: '000000000094e3f346dd672170a92ff16ad6e74694c9ffd43d72296e4bda42ab', + observedAt: '2026-08-29T08:27:13.196Z' + }, + initialBlockDownload: true, + completeness: 'complete', + transactionCountAtomic: '5', + hydratedCountAtomic: '0', + observedAt: '2026-08-29T08:27:13.196Z' + }, + confirmedCoverage: 'complete', + confirmedLagBlocksAtomic: '2' + } + } +} as const; diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html index cef11d7ac59..4efdac34d26 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html @@ -74,7 +74,11 @@

Questions this ch

Protocol indexers

  • - {{ protocol.label }} + {{ protocol.label }} + + {{ protocol.label }} + {{ protocol.stateLabel }} {{ protocol.historyLabel }} @@ -86,6 +90,10 @@

    Protocol in {{ protocol.reasons.join(', ') }} + + This chain reports it. This explorer has no page for it yet, so there is nothing to open. +

@@ -483,6 +491,62 @@

Other reported + + + +
+

Transactions

+

+ Showing the first {{ list.shownCount }} of {{ list.totalCount }} the authority returned. +

+

+ These transactions report no fee amount. A shielded transaction's fee cannot be read from its transparent side, so the chain reports what it costs under ZIP-317 instead. +

+
+

+ + + + + + + + + + + + + + + + + + + + +
TransactionStateFirst seenSize + Fee {{ ticker }} + ZIP-317 actions
{{ short(row.txid) }}{{ row.statusLabel }} + {{ row.firstSeenAt.display }} + not reported + + {{ row.size.display }} + not reported + + {{ row.fee.display }} + not reported + + {{ row.logicalActions.display }} + not reported +
+
+
+

{{ collection.title }}

{{ collection.title }} i18n="universe.chain.collection-columns-hidden"> {{ collection.hiddenColumnCount }} further fields per row are not in this table. Every one of them is in the full response below.

+

+ {{ collection.constantColumnCount }} fields held the same value on every row, so they are not columns here. +

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 f9efdfdcdc9..7d5ea488fd5 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts @@ -34,6 +34,7 @@ import { ProtocolReading, ReadCapability, StatusReading, + TransactionListReading, TransactionReading, chainProfile, classifyPayload, @@ -47,6 +48,7 @@ import { readRecordFacts, readStatusRail, readTransaction, + readTransactionList, shortenIdentifier, } from '@app/universe/multichain-explorer/multichain-view'; import { @@ -90,6 +92,7 @@ interface ExplorerViewModel { readonly address: AddressReading | null; readonly outpoint: OutpointReading | null; readonly collection: CollectionReading | null; + readonly transactionList: TransactionListReading | null; readonly facts: readonly Fact[]; /** True when the page is showing a response it has no purpose-built reading for. */ readonly generic: boolean; @@ -247,13 +250,21 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { const block = payload && shape === 'block' ? readBlock(payload) : null; const address = payload && shape === 'address' ? readAddress(payload, this.profile) : null; const outpoint = payload && shape === 'outpoint' ? readOutpoint(payload, this.profile) : null; + // A list of whole transaction envelopes gets read as transactions. Only + // what is left over falls through to the generic table. + const transactionList = + payload && shape === 'collection' + ? readTransactionList(payload, this.profile) + : null; const collection = - payload && (shape === 'collection' || shape === 'record') + payload && !transactionList && (shape === 'collection' || shape === 'record') ? readCollection(payload, this.profile) : null; const skip = [ ...(PRESENTED_FIELDS[shape] ?? []), ...(collection ? [collection.sourceKey] : []), + // The transaction list owns the array it read, whichever field it was in. + ...(transactionList ? ['transactions', 'items'] : []), ]; return { capability, @@ -269,6 +280,7 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { address, outpoint, collection, + transactionList, facts: readRecordFacts(payload, this.profile, skip), generic: shape === 'collection' || shape === 'record', }; @@ -378,6 +390,10 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { return value; } + trackByTxid(_index: number, item: { txid: string }): string { + return item.txid; + } + private chainFromUrl(url: string): Exclude { return url.split(/[?#]/, 1)[0].split('/').filter(Boolean)[0] === 'dogecoin' ? 'dogecoin' diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts index 22f4a57019f..3251e3c03b1 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts @@ -264,10 +264,46 @@ describe('readCapabilities', () => { describe('readProtocolCoverage', () => { it('shows every protocol the navigation offers, even one the overlay stopped reporting', () => { const readings = readProtocolCoverage(capability({ protocols: [] }), DOGE); - expect(readings.map((r) => r.protocolId)).toEqual(['doginals', 'drc20', 'doge-tap']); + expect(readings.map((r) => r.routeId)).toEqual(['doginals', 'drc20', 'doge-tap']); expect(readings[0].tone).toBe('neutral'); }); + it('matches a tab to the registry id the envelope actually uses', () => { + // Production reports tap_doge; the route serves it at doge-tap. Matching on + // the route id alone listed the protocol twice, once falsely as not stated. + const readings = readProtocolCoverage( + capability({ + protocols: [ + { protocolId: 'tap_doge', state: 'degraded', coverage: 'unavailable', updatedAt: null, lagBlocksAtomic: null, degradedReasons: [] }, + ], + }), + DOGE + ); + expect(readings).toHaveLength(3); + const tap = readings.find((r) => r.routeId === 'doge-tap'); + expect(tap?.tone).toBe('partial'); + expect(tap?.label).toBe('TAP on Doge'); + expect(readings.filter((r) => r.label === 'TAP on Doge')).toHaveLength(1); + }); + + it('lists a protocol it has no page for, and offers no route to it', () => { + // /dogecoin/protocols/dunes answers 404 and the API service throws before + // it asks, so a link there is a dead end. Hiding it would be a claim the + // chain did not make. + const readings = readProtocolCoverage( + capability({ + protocols: [ + { protocolId: 'dunes', state: 'unavailable', coverage: 'unavailable', updatedAt: null, lagBlocksAtomic: null, degradedReasons: [] }, + ], + }), + DOGE + ); + const dunes = readings.find((r) => r.protocolId === 'dunes'); + expect(dunes).toBeDefined(); + expect(dunes?.routeId).toBeNull(); + expect(dunes?.label).toBe('Dunes'); + }); + it('carries the state, coverage and lag the overlay reported', () => { const readings = readProtocolCoverage( capability({ @@ -294,6 +330,7 @@ describe('readProtocolCoverage', () => { DOGE ); expect(readings.map((r) => r.protocolId)).toContain('newthing'); + expect(readings.find((r) => r.protocolId === 'newthing')?.routeId).toBeNull(); }); }); @@ -620,6 +657,24 @@ describe('readRecordFacts', () => { expect(facts.map((fact) => fact.label)).toEqual(['Status state', 'Status lag blocks']); }); + it('does not repeat a parent the child already names', () => { + // The live pending response nests snapshotId inside snapshot, which read + // as "Snapshot snapshot ID". + const facts = readRecordFacts( + { snapshot: { snapshotId: 'snap-4812', sequenceAtomic: '148201' } }, + DOGE + ); + expect(facts.map((fact) => fact.label)).toEqual(['Snapshot ID', 'Snapshot sequence']); + }); + + it('drops a nested chain and network, which only repeat the heading', () => { + const facts = readRecordFacts( + { snapshot: { chain: 'dogecoin', network: 'mainnet', completeness: 'complete' } }, + DOGE + ); + expect(facts.map((fact) => fact.key)).toEqual(['snapshot completeness']); + }); + it('shifts only the fields that carry the chain own coin', () => { // A DRC-20 supply is denominated in the token's units. Shifting it by the // chain precision printed 100000000000 as 1,000 DOGE, which is not a diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.ts index 68cc197aafe..7ae9d9513fb 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.ts @@ -41,8 +41,20 @@ import { } from '@app/universe/universe-evidence'; export interface ChainProtocolTab { + /** The path segment the chain API uses, and the route this explorer serves. */ readonly id: string; readonly label: string; + /** + * Every id the capability envelope might use for this same protocol. + * + * The route segment and the registry id are different namespaces and they do + * not always agree: the API serves TAP on Doge at `/protocols/doge-tap` + * while the registry calls it `tap_doge`. Matching on the route id alone + * listed the protocol twice on the overview, once from the tab reporting + * "not stated" and once appended from the envelope, and neither row was the + * whole truth. + */ + readonly registryIds: readonly string[]; } export interface ChainProfile { @@ -63,9 +75,9 @@ const DOGECOIN: ChainProfile = { atomicUnit: 'koinu', precision: 8, protocols: [ - { id: 'doginals', label: 'Doginals' }, - { id: 'drc20', label: 'DRC-20' }, - { id: 'doge-tap', label: 'Doge TAP' }, + { id: 'doginals', label: 'Doginals', registryIds: ['doginals'] }, + { id: 'drc20', label: 'DRC-20', registryIds: ['drc20'] }, + { id: 'doge-tap', label: 'TAP on Doge', registryIds: ['tap_doge', 'doge-tap', 'tap-on-doge', 'tap-doge'] }, ], }; @@ -76,9 +88,9 @@ const ZCASH: ChainProfile = { atomicUnit: 'zatoshi', precision: 8, protocols: [ - { id: 'zerdinals', label: 'Zerdinals' }, - { id: 'zrunes', label: 'ZRunes' }, - { id: 'zrc20', label: 'ZRC-20' }, + { id: 'zerdinals', label: 'Zerdinals', registryIds: ['zerdinals', 'universe-zerdinals'] }, + { id: 'zrunes', label: 'ZRunes', registryIds: ['zrunes'] }, + { id: 'zrc20', label: 'ZRC-20', registryIds: ['zrc20'] }, ], }; @@ -467,7 +479,19 @@ export function readRecordFacts( if (childValue !== null && typeof childValue === 'object') { continue; } - const fact = factFrom(`${key} ${childKey}`, childValue, profile); + // A nested chain or network repeats the page heading, the same way the + // top-level ones do, and the skip list only reaches the top level. + if (childKey === 'chain' || childKey === 'network') { + continue; + } + // "snapshot" plus "snapshotId" reads as "Snapshot snapshot ID". Where + // the child already names its parent, the parent is not repeated. + const prefixed = childKey.toLowerCase().startsWith(key.toLowerCase()); + const fact = factFrom( + prefixed ? childKey : `${key} ${childKey}`, + childValue, + profile + ); if (fact) { facts.push(fact); } @@ -698,6 +722,14 @@ export function readCapabilities( export interface ProtocolReading { readonly protocolId: string; readonly label: string; + /** + * The route this protocol has, or null when the explorer serves none for it. + * A protocol the chain reports but this explorer cannot open is still listed, + * because hiding it would be a claim the chain did not make, but it is not + * linked: `/dogecoin/protocols/dunes` answers 404 and the frontend throws + * before it even asks. + */ + readonly routeId: string | null; readonly stateLabel: string; readonly tone: EvidenceTone; readonly coverageLabel: string; @@ -712,21 +744,22 @@ export function readProtocolCoverage( ): readonly ProtocolReading[] { const declared = capability?.protocols ?? []; const byId = new Map(declared.map((entry) => [entry.protocolId, entry])); - // The profile's tabs are the list the navigation offers, so a protocol the - // overlay stopped reporting still appears, marked as not stated, instead of - // vanishing from a page that links to it. - const ids = [ - ...profile.protocols.map((tab) => tab.id), - ...declared - .map((entry) => entry.protocolId) - .filter((id) => !profile.protocols.some((tab) => tab.id === id)), - ]; - return ids.map((protocolId) => { - const entry = byId.get(protocolId); - const tab = profile.protocols.find((candidate) => candidate.id === protocolId); + const claimed = new Set(); + + // The tabs first, in the order the navigation offers them, each matched to + // whichever id the envelope used for it. A tab the overlay stopped reporting + // still appears, marked as not stated, rather than vanishing from a page + // that links to it. + const readings: ProtocolReading[] = profile.protocols.map((tab) => { + const matchedId = tab.registryIds.find((id) => byId.has(id)); + if (matchedId) { + claimed.add(matchedId); + } + const entry = matchedId ? byId.get(matchedId) : undefined; return { - protocolId, - label: tab?.label ?? humanizeFieldName(protocolId), + protocolId: matchedId ?? tab.id, + label: tab.label, + routeId: tab.id, stateLabel: availabilityLabel(entry?.state), tone: availabilityTone(entry?.state), coverageLabel: completenessLabel(entry?.coverage), @@ -735,6 +768,27 @@ export function readProtocolCoverage( reasons: entry?.degradedReasons ?? [], }; }); + + // Then anything the chain reports that this explorer has no page for. It is + // listed, because the chain said it is there, and it is not linked, because + // the route would throw before it asked. + for (const entry of declared) { + if (claimed.has(entry.protocolId)) { + continue; + } + readings.push({ + protocolId: entry.protocolId, + label: humanizeFieldName(entry.protocolId), + routeId: null, + stateLabel: availabilityLabel(entry.state), + tone: availabilityTone(entry.state), + coverageLabel: completenessLabel(entry.coverage), + historyLabel: historyLabel(entry.coverage), + lag: formatExactInteger(entry.lagBlocksAtomic ?? null), + reasons: entry.degradedReasons ?? [], + }); + } + return readings; } // --------------------------------------------------------------------------- @@ -1220,6 +1274,8 @@ export interface CollectionReading { * how many it is holding back and the full response stays one click away. */ readonly hiddenColumnCount: number; + /** Fields that were identical on every row, and so became no column at all. */ + readonly constantColumnCount: number; } /** How many rows a page renders before it says how many it is holding back. */ @@ -1271,8 +1327,23 @@ export function readCollection( } } } - const hiddenColumnCount = Math.max(0, order.length - COLLECTION_COLUMN_LIMIT); - const columns = order.slice(0, COLLECTION_COLUMN_LIMIT).map((key) => ({ + + // A column whose value is the same on every row tells a reader nothing, and + // it costs one of the few slots a table has on a phone. The live mempool + // response is the case that showed it: its rows are whole transaction + // envelopes, so schemaVersion, chain and network were three of the seven + // columns and every cell in them was identical. They are still reported, + // once each, by the record reading beside the table. + const varying = + scalarRows.length > 1 + ? order.filter((key) => { + const first = scalarRows[0][key]; + return scalarRows.some((row) => row[key] !== first); + }) + : order; + const constantColumnCount = order.length - varying.length; + const hiddenColumnCount = Math.max(0, varying.length - COLLECTION_COLUMN_LIMIT); + const columns = varying.slice(0, COLLECTION_COLUMN_LIMIT).map((key) => ({ key, label: humanizeFieldName(key), numeric: isCoinAmount(key) || /Atomic$/.test(key), @@ -1292,9 +1363,90 @@ export function readCollection( shownCount: shown.length, totalCount: source.length, hiddenColumnCount, + constantColumnCount, }; } +export interface TransactionListRow { + readonly txid: string; + readonly statusLabel: string; + readonly statusTone: EvidenceTone; + readonly firstSeenAt: ExactNumber | null; + readonly size: ExactNumber | null; + readonly fee: ExactNumber | null; + readonly logicalActions: ExactNumber | null; +} + +/** + * What the cost column can honestly be, decided by what the rows carry. + * + * Dogecoin reports a fee amount and no rate. Zcash reports no amount at all on + * a pending transaction, because a shielded transaction's fee is not derivable + * from its transparent side, and reports ZIP-317 logical actions instead. A + * fixed "Fee" column would have been empty on every row of the Zcash pending + * list, which is the page's most valuable column spent on nothing. + */ +export type TransactionCostColumn = 'amount' | 'logical-actions' | 'none'; + +export interface TransactionListReading { + readonly rows: readonly TransactionListRow[]; + readonly shownCount: number; + readonly totalCount: number; + readonly costColumn: TransactionCostColumn; +} + +/** + * A pending set, read as transactions rather than as a table of field names. + * + * The live mempool response carries whole transaction envelopes, one per row, + * not the summary rows the fixtures assumed. Rendered generically that produced + * seven columns of which three were the same on every row, and the two figures + * a reader actually wants from a pending list, what it pays and how big it is, + * were not among them. + */ +export function readTransactionList( + payload: ChainExplorerPayload | null, + profile: ChainProfile +): TransactionListReading | null { + if (!payload || !isRecord(payload)) { + return null; + } + const source = Object.values(payload).find( + (value): value is unknown[] => + Array.isArray(value) && + value.length > 0 && + value.every((entry) => classifyPayload(entry as ChainExplorerPayload) === 'transaction') + ); + if (!source) { + return null; + } + const shown = source.slice(0, COLLECTION_ROW_LIMIT).filter(isRecord); + const rows = shown.map((entry) => { + const status = text(entry.status) ?? ''; + const fee = isRecord(entry.fee) ? entry.fee : {}; + return { + txid: text(entry.txid) ?? '', + statusLabel: LIFECYCLE_LABEL[status] ?? availabilityLabel(status), + statusTone: LIFECYCLE_TONE[status] ?? 'neutral', + firstSeenAt: formatTimestamp(text(entry.firstSeenAt)), + size: formatExactInteger(text(entry.sizeBytesAtomic)), + fee: formatAtomicAmount(text(fee.amountAtomic), profile.precision), + logicalActions: formatExactInteger(text(fee.logicalActionsAtomic)), + }; + }); + + // The amount wins where any row has one, because it is the figure a reader + // is looking for. Logical actions are the fallback only when no row reports + // an amount at all, which is the shielded case rather than a gap. + const costColumn: TransactionCostColumn = rows.some((row) => row.fee) + ? 'amount' + : rows.some((row) => row.logicalActions) + ? 'logical-actions' + : 'none'; + + return { rows, shownCount: shown.length, totalCount: source.length, costColumn }; +} + /** * A list of plain identifier strings, which is how block transaction lists and * address history arrive. Kept apart from {@link readCollection} because a diff --git a/scripts/universe/visual-qa/chain-fixtures.mjs b/scripts/universe/visual-qa/chain-fixtures.mjs index 5d3e0ef12bc..2f8c4dc00e2 100644 --- a/scripts/universe/visual-qa/chain-fixtures.mjs +++ b/scripts/universe/visual-qa/chain-fixtures.mjs @@ -141,7 +141,16 @@ function zcashTransaction() { block: null, confirmationsAtomic: '0', sizeBytesAtomic: '2104', - fee: { amountAtomic: '20000', rateDecimal: null, rateUnit: null, logicalActionsAtomic: '4', model: 'zip-317' }, + // Exactly what production sends for a pending shielded transaction: no fee + // amount at all, because it cannot be read from the transparent side, and + // the ZIP-317 cost instead. + fee: { + amountAtomic: null, + rateDecimal: null, + rateUnit: 'zatoshi/logical-action', + logicalActionsAtomic: '4', + model: 'ZIP-317-revision-1', + }, conflicts: [], replacement: null, expiry: { heightAtomic: '2884160', state: 'pending' }, @@ -174,6 +183,24 @@ function zcashTransaction() { }; } +/** One pending Dogecoin transaction, in the envelope shape the live API sends. */ +function pendingDogecoinTransaction(txid, feeAtomic, sizeBytes, firstSeenAt) { + const confirmed = dogecoinTransaction(); + return { + ...confirmed, + txid, + status: 'pending', + firstSeenAt, + confirmedAt: null, + block: null, + confirmationsAtomic: '0', + sizeBytesAtomic: sizeBytes, + fee: { amountAtomic: feeAtomic, rateDecimal: null, rateUnit: null }, + protocolActions: { candidates: [], confirmed: [] }, + completeness: 'partial', + }; +} + export const chainFixtures = { '/api/v1/chains': [capability('bitcoin'), capability('dogecoin'), capability('zcash')], @@ -224,29 +251,40 @@ export const chainFixtures = { ], }, + // The pending lists carry whole transaction envelopes, one per entry, which + // is what the live API returns. They were summary rows here until the real + // payloads were read, and the difference matters: a list of envelopes is + // rendered as transactions, a list of summary rows falls through to the + // generic table, and only one of those is what production does. '/api/v1/dogecoin/mempool': { - chain: 'dogecoin', - network: 'mainnet', - snapshotId: 'snap-4812', - sequenceAtomic: '148201', - observedAt: new Date(Date.now() - 9_000).toISOString(), - completeness: 'complete', + snapshot: { + chain: 'dogecoin', + network: 'mainnet', + snapshotId: 'snap-4812', + sequenceAtomic: '148201', + observedAt: new Date(Date.now() - 9_000).toISOString(), + completeness: 'complete', + }, transactions: [ - { txid: DOGE_TXID, firstSeenAt: '2026-08-29T05:03:00.000Z', sizeBytesAtomic: '486', feeAtomic: '113400000', state: 'observed' }, - { txid: DOGE_BLOCK, firstSeenAt: '2026-08-29T05:03:41.000Z', sizeBytesAtomic: '225', feeAtomic: '50000000', state: 'observed' }, + pendingDogecoinTransaction(DOGE_TXID, '113400000', '486', '2026-08-29T05:03:00.000Z'), + pendingDogecoinTransaction(DOGE_BLOCK, '50000000', '225', '2026-08-29T05:03:41.000Z'), ], }, + // Zcash reports no fee amount on a pending transaction, because a shielded + // transaction's fee cannot be read from its transparent side. It reports + // ZIP-317 logical actions instead, and the page has to show that rather than + // a column of "not reported". '/api/v1/zcash/mempool': { - chain: 'zcash', - network: 'mainnet', - snapshotId: 'snap-2210', - sequenceAtomic: '22104', - observedAt: new Date(Date.now() - 14_000).toISOString(), - completeness: 'partial', - transactions: [ - { txid: ZEC_TXID, firstSeenAt: '2026-08-29T05:01:44.000Z', sizeBytesAtomic: '2104', feeAtomic: '20000', state: 'observed' }, - ], + snapshot: { + chain: 'zcash', + network: 'mainnet', + snapshotId: 'snap-2210', + sequenceAtomic: '22104', + observedAt: new Date(Date.now() - 14_000).toISOString(), + completeness: 'partial', + }, + transactions: [zcashTransaction()], }, '/api/v1/dogecoin/protocols': { From a7e5aefc1124af4ae800915b72f2018fdca1cf13 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 08:59:11 +0000 Subject: [PATCH 15/45] Say none, rather than showing nothing The live Zcash pending set was empty when I looked at it, which is a real and ordinary state: no unconfirmed transactions, and the node reporting its view as complete. The page showed a status rail, a record of the snapshot, and nothing at all where the list would be. A reader could not tell "there are none" from "the list did not load", and telling those apart is the product's whole claim. An empty list the authority actually returned now says which one it is. Where the authority also called its view complete, that is a proven none and takes the proven treatment: our node has none right now, and this is none rather than an answer that failed to arrive. Where it did not, the list is empty and the emptiness is not established, which is a different fact and takes the partial treatment. Pinned as a fixture in both forms, from the shape production actually sent at 08:54Z, so neither can quietly regress into the other. Also here, a correction to the previous commit. The claim that a run whose server goes away is now refused was not mine to make: capture.mjs has caught a refused connection mid-run since 69c7437d2, and it names the exact route it stopped at, which is better than anything checked at the end could. My second clause restated it and could never fire, because that guard exits first. Only the bundle-identity check is new, and the comment now says which case it is for: a server that keeps answering while the files under it are replaced. That guard proved itself twice over on the way here. It refused a run I broke by rebuilding underneath it, and the older one refused a run I broke by killing its gateway while it was still going. Co-Authored-By: Claude Opus 5 --- .../multichain-explorer.component.html | 20 ++++++++ .../multichain-explorer.component.ts | 4 ++ .../multichain-view.spec.ts | 31 +++++++++++++ .../multichain-explorer/multichain-view.ts | 46 +++++++++++++++++++ scripts/universe/visual-qa/capture.mjs | 11 ++--- scripts/universe/visual-qa/chain-fixtures.mjs | 35 ++++++++++++++ 6 files changed, 141 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html index 4efdac34d26..600c1b66935 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html @@ -585,6 +585,26 @@

{{ collection.title }}

+ +
+

{{ empty.label }}

+

+ + None + Not established + +

+

+ Our own {{ chainName }} node has none right now, and it reported its view as complete. This is none, not an answer that failed to arrive. +

+ +

+ The {{ chainName }} authority returned an empty list and did not say its view is complete, so this is not the same as none. +

+
+
+

The {{ chainName }} authority answered, and had nothing to report for this request. 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 7d5ea488fd5..dd938b2a761 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts @@ -29,6 +29,7 @@ import { ChainProfile, ChainShape, CollectionReading, + EmptyListReading, Fact, OutpointReading, ProtocolReading, @@ -42,6 +43,7 @@ import { readBlock, readCapabilities, readCollection, + readEmptyList, readIdentifierList, readOutpoint, readProtocolCoverage, @@ -93,6 +95,7 @@ interface ExplorerViewModel { readonly outpoint: OutpointReading | null; readonly collection: CollectionReading | null; readonly transactionList: TransactionListReading | null; + readonly emptyList: EmptyListReading | null; readonly facts: readonly Fact[]; /** True when the page is showing a response it has no purpose-built reading for. */ readonly generic: boolean; @@ -281,6 +284,7 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { outpoint, collection, transactionList, + emptyList: transactionList || collection ? null : readEmptyList(payload), facts: readRecordFacts(payload, this.profile, skip), generic: shape === 'collection' || shape === 'record', }; diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts index 3251e3c03b1..2d53a5d5c7f 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts @@ -16,6 +16,7 @@ import { readBlock, readCapabilities, readCollection, + readEmptyList, readOutpoint, readPaging, readProtocolCoverage, @@ -626,6 +627,36 @@ describe('readCollection', () => { }); }); +describe('readEmptyList', () => { + it('calls a complete empty list none, and an incomplete one not established', () => { + // The live Zcash pending set is empty and complete, which is a proven none. + expect( + readEmptyList({ snapshot: { completeness: 'complete' }, transactions: [] }) + ).toEqual({ field: 'transactions', label: 'Transactions', proven: true }); + + expect( + readEmptyList({ snapshot: { completeness: 'partial' }, transactions: [] })?.proven + ).toBe(false); + + // No completeness stated at all is not a proven none either. + expect(readEmptyList({ transactions: [] })?.proven).toBe(false); + }); + + it('reads a top-level completeness as well as a nested one', () => { + expect(readEmptyList({ completeness: 'complete', items: [] })?.proven).toBe(true); + }); + + it('says nothing when there is a list to show instead', () => { + expect(readEmptyList({ transactions: [{ txid: 'a' }] })).toBeNull(); + expect(readEmptyList({ txids: [], transactions: [{ txid: 'a' }] })).toBeNull(); + }); + + it('says nothing when the payload has no list at all', () => { + expect(readEmptyList({ state: 'unavailable' })).toBeNull(); + expect(readEmptyList(null)).toBeNull(); + }); +}); + describe('readRecordFacts', () => { it('classifies amounts, counts, identifiers, flags and absences apart', () => { const facts = readRecordFacts( diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.ts index 7ae9d9513fb..4aa78820d1b 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.ts @@ -1447,6 +1447,52 @@ export function readTransactionList( return { rows, shownCount: shown.length, totalCount: source.length, costColumn }; } +export interface EmptyListReading { + /** The field the list would have come from. */ + readonly field: string; + readonly label: string; + /** + * True when the authority also said its view is complete, which turns an + * empty list into a proven none rather than an absence of knowledge. + * + * The distinction is the product's whole claim, and this page was missing it: + * with no pending Zcash transactions, which is a real and common state, the + * page showed a status rail, a record of the snapshot, and nothing at all + * where the list would be. A reader could not tell "there are none" from + * "the list did not load". + */ + readonly proven: boolean; +} + +/** + * An empty list the authority actually returned. + * + * Only when there is no non-empty array to read instead, so a payload carrying + * both a full list and an empty one is described by the full one. + */ +export function readEmptyList( + payload: ChainExplorerPayload | null +): EmptyListReading | null { + if (!payload || !isRecord(payload)) { + return null; + } + const entries = Object.entries(payload).filter(([, value]) => + Array.isArray(value) + ); + if (!entries.length || entries.some(([, value]) => (value as unknown[]).length)) { + return null; + } + const [field] = entries[0]; + const snapshot = isRecord(payload.snapshot) ? payload.snapshot : {}; + const completeness = + text(payload.completeness) ?? text(snapshot.completeness); + return { + field, + label: humanizeFieldName(field), + proven: completeness === 'complete', + }; +} + /** * A list of plain identifier strings, which is how block transaction lists and * address history arrive. Kept apart from {@link readCollection} because a diff --git a/scripts/universe/visual-qa/capture.mjs b/scripts/universe/visual-qa/capture.mjs index d4fdb244848..38fe471b75f 100644 --- a/scripts/universe/visual-qa/capture.mjs +++ b/scripts/universe/visual-qa/capture.mjs @@ -535,6 +535,11 @@ async function run() { // The build must not have been rewritten underneath the run. // + // A server that goes away entirely is already caught mid-run, above, where + // a refused connection ends the run rather than logging a hundred identical + // page failures. This is the case that guard cannot see: a server that keeps + // answering while the files under it are replaced. + // // An Angular production build empties its output directory before writing // it, so a rebuild during a matrix leaves a window where index.html and the // lazy chunks are simply absent. The pages served in that window have no @@ -563,12 +568,6 @@ async function run() { ].join('\n'), ); } - if (bundleAtStart && !bundleAtEnd) { - throw new Error( - 'the server stopped answering before the matrix finished, so the ' + - 'results are incomplete. Nothing was measured after that point.', - ); - } } const report = { browser: BROWSER, base: BASE, screenshots: shots, findings }; diff --git a/scripts/universe/visual-qa/chain-fixtures.mjs b/scripts/universe/visual-qa/chain-fixtures.mjs index 2f8c4dc00e2..fd1bb4d384d 100644 --- a/scripts/universe/visual-qa/chain-fixtures.mjs +++ b/scripts/universe/visual-qa/chain-fixtures.mjs @@ -326,6 +326,40 @@ export const chainFixtures = { }; export const chainStateOverrides = { + // A pending set that is genuinely empty and says so. + // + // This is a real and common production state: at 2026-08-29T08:54Z the live + // Zcash pending set held nothing and reported its view complete. It has to + // read as a proven none rather than as a list that failed to arrive, and + // without a fixture the page said neither. + 'chain-empty-pending': { + '/api/v1/zcash/mempool': { + body: { + snapshot: { + network: 'mainnet', + snapshotId: 'zcash-ba6a8834d37e822b13a88c323255e2b3d2c6e68d', + sequenceAtomic: '384', + completeness: 'complete', + transactionCountAtomic: '0', + observedAt: new Date(Date.now() - 11_000).toISOString(), + }, + transactions: [], + }, + }, + '/api/v1/dogecoin/mempool': { + body: { + snapshot: { + network: 'mainnet', + snapshotId: 'dogecoin-partial-view', + sequenceAtomic: '12', + completeness: 'partial', + observedAt: new Date(Date.now() - 11_000).toISOString(), + }, + transactions: [], + }, + }, + }, + // Both chain authorities are unreachable. The page must say the status is // unavailable and must not present an empty overview as a working one. 'chain-authority-down': { @@ -370,6 +404,7 @@ export const chainStateOverrides = { * always done. */ export const chainStateScope = { + 'chain-empty-pending': ['dogecoin-mempool', 'zcash-mempool'], 'chain-authority-down': ['dogecoin', 'zcash'], 'chain-behind': ['dogecoin'], 'chain-object-missing': ['dogecoin'], From b615b4986dd091f76c13f886690db6d3a6b9f860 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 09:04:28 +0000 Subject: [PATCH 16/45] Stop telling a Dogecoin page it is about Bitcoin Two places on a chain route still said Bitcoin, found by mempool-41 looking at the deployed site. The footer tagline read "Bitcoin activity while it is still forming, and what it proves" on every page, including the chain-prefixed ones. It names three chains now, which is correct everywhere rather than correct on one route family. Chain pages set a title but never a description, so every one of them fell back to the site description. A Dogecoin transaction and the Zcash protocol list previewed identically wherever a link was pasted. Each page kind now says what it actually is. Verified by driving the built application: the title, the description and the tagline read correctly on the homepage, a chain overview, a chain transaction and a chain protocol list. Checked while I was there, and no change needed: a socket that cannot connect cannot freeze these pages. UniverseWebsocketService never errors its observable, it reconnects with backoff or completes, so the 15 second poll continues and "Last observed" keeps telling the truth. The refused handshake mempool-41 found belongs to the cutover gate they shipped it in; this matrix answers the socket from a fixture and could never have caught it. Co-Authored-By: Claude Opus 5 --- .../global-footer.component.html | 2 +- .../multichain-explorer.component.ts | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/shared/components/global-footer/global-footer.component.html b/frontend/src/app/shared/components/global-footer/global-footer.component.html index 37b8a54fc50..39a14a1da31 100644 --- a/frontend/src/app/shared/components/global-footer/global-footer.component.html +++ b/frontend/src/app/shared/components/global-footer/global-footer.component.html @@ -11,7 +11,7 @@ @if (!enterpriseInfo?.footer_img) {

} 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 dd938b2a761..093808d84cf 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts @@ -209,6 +209,7 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { this.route.snapshot.queryParamMap.get('switchedFrom'); this.saved = this.isSaved(context); this.seo.setTitle(this.pageTitle(context)); + this.seo.setDescription(this.pageDescription(context)); this.recordVisit(context); return merge(timer(0, 15_000), this.live.stream$(this.chain)).pipe( auditTime(100), @@ -516,6 +517,35 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { ); } + /** + * What a shared link to this page says it is. + * + * Without this every chain page fell back to the site description, so a + * Dogecoin transaction and the Zcash protocol list previewed identically + * wherever a link was pasted. + */ + 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': + return $localize`:@@universe.chain.meta-protocols:The asset protocols Universe Explorer indexes on ${this.chainName}:CHAIN:, and the state and coverage of each indexer.`; + case 'transaction': + return $localize`:@@universe.chain.meta-transaction:${this.chainName}:CHAIN: transaction ${reference}:REFERENCE:: its state, transparent value, and the protocol actions the authority reported, with the evidence behind each.`; + case 'block': + return $localize`:@@universe.chain.meta-block:${this.chainName}:CHAIN: block ${reference}:REFERENCE:: its header facts and the transactions it contains, from first-party data.`; + case 'address': + return $localize`:@@universe.chain.meta-address:${this.chainName}:CHAIN: address ${reference}:REFERENCE:: balance, history, and unspent outputs, with the block each figure is true as of.`; + case 'outpoint': + return $localize`:@@universe.chain.meta-outpoint:${this.chainName}:CHAIN: output ${reference}:REFERENCE:: what it carries and whether it has been spent.`; + default: + return $localize`:@@universe.chain.meta-generic:${this.chainName}:CHAIN: data from Bitcoin Universe's own node and protocol indexers, with the evidence behind every claim.`; + } + } + /** * The tab title. The chain is already the suffix the SEO service appends, so * naming it here too produced "Dogecoin dashboard - Universe Explorer - From 7d845f7e0ef1d6cc3bd22ac2a03192b4859f777a Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 09:09:39 +0000 Subject: [PATCH 17/45] Make the fixture addresses look like addresses One Zcash output in the fixtures read t1SoMeTransparentOutputAddressXXXXX. It is the one thing a fixture must not do: a value that announces itself as filler teaches a reviewer to skim past the column it sits in, and the whole point of these files is that somebody looks at what renders from them. Both transparent addresses are now well formed for the chain: t1, thirty-five characters, base58 alphabet. They are still not real addresses and nothing here claims they are. Co-Authored-By: Claude Opus 5 --- scripts/universe/visual-qa/chain-fixtures.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/universe/visual-qa/chain-fixtures.mjs b/scripts/universe/visual-qa/chain-fixtures.mjs index fd1bb4d384d..2b202f7e6c4 100644 --- a/scripts/universe/visual-qa/chain-fixtures.mjs +++ b/scripts/universe/visual-qa/chain-fixtures.mjs @@ -156,10 +156,10 @@ function zcashTransaction() { expiry: { heightAtomic: '2884160', state: 'pending' }, transparent: { inputs: [ - { indexAtomic: '0', previousOutpoint: `${ZEC_TXID}:0`, address: 't1KrGGkMTgVTNaAmXcnCrMTgVTNaAmXcnCr', valueAtomic: '250000000', coinbase: false }, + { indexAtomic: '0', previousOutpoint: `${ZEC_TXID}:0`, address: 't1WhKz4uNbLBmYMsJvUeAqDPxKfR2nGdTca', valueAtomic: '250000000', coinbase: false }, ], outputs: [ - { indexAtomic: '0', address: 't1SoMeTransparentOutputAddressXXXXX', valueAtomic: '99980000', spent: null }, + { indexAtomic: '0', address: 't1QjR7bYpNvA3kHsWmEzXuF9dLcT6gVbKrn', valueAtomic: '99980000', spent: null }, ], }, shielded: { From 2af6b848d36fde46046bf9bb6362824c6a6759fb Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 09:11:40 +0000 Subject: [PATCH 18/45] Write down the shape that cost this release four defects The design system defines five evidence states and holds them apart with measured colour, a word, a border and a tint. It says nothing about the code that feeds them, and this release showed why that gap matters: an interface can only show a difference the data path preserved. Four defects shipped in one day, across three repositories, with one shape. Somewhere in each, "I do not know" and "I know, and the answer is no" were the same value. - an indexer publishing block_count where the reader expected blockCount, read as publishing no checkpoint; - a WebSocket handshake with no Origin header, read as permitted, so every probe passed and every browser was refused; - a field-name pattern matching "supply", reading a token quantity as a coin amount and printing 100000000000 as 1,000 DOGE; - a Bitcoin node answering "no such block", read as a failure, so a healthy chain was reported unavailable on every search. Three of those were found by mempool-41 during the release, one by me. The generalisation is theirs and it is better than the one I started with: this is not only about tolerant readers accepting bad input, it is about a definitive negative and an absence of knowledge collapsing into one value, in either direction. The evidence-states section now says the distinction has to survive the trip, and what to ask of anything reading a payload, a header, a field name or a status this product does not fully control. Co-Authored-By: Claude Opus 5 --- docs/product/DESIGN-SYSTEM.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/product/DESIGN-SYSTEM.md b/docs/product/DESIGN-SYSTEM.md index ddf729f9677..14ca0c93f0a 100644 --- a/docs/product/DESIGN-SYSTEM.md +++ b/docs/product/DESIGN-SYSTEM.md @@ -202,6 +202,26 @@ Every state token comes with a `-surface` and a `-border` so a status treatment is a tinted, bordered, worded object rather than coloured text. That keeps it legible in greyscale, in print, and under forced colours. +**The distinction has to survive the trip to get here.** These five states are +the product's whole claim, and an interface can only show a difference the code +feeding it preserved. On 29 August 2026 four defects shipped in one release, +across three repositories, with a single shape: somewhere, "I do not know" and +"I know, and the answer is no" were the same value. + +An indexer publishing `block_count` where the reader expected `blockCount` was +read as publishing no checkpoint. A WebSocket handshake sent with no `Origin` +header was read as permitted, so every probe passed and every browser was +refused. A field-name pattern matching `supply` read a token quantity as a coin +amount. A Bitcoin node answering "no such block" was read as a failure, so a +healthy chain was reported unavailable on every search. + +So the rule is not only about rendering. Anywhere this product reads something +it does not fully control, a payload, a header, a field name, a status, it must +be able to say which of the five it has. If an unrecognised input and a +definitive negative produce the same value, the interface downstream cannot +recover the difference, and it will state one of them as the other with the +full confidence of a measured colour and a word. + ### Themes **Light is the primary experience.** It is defined on `:root` in `styles.scss`, From 71ed48622e544a5aad831f65c0d8b39536239cb5 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 09:48:41 +0000 Subject: [PATCH 19/45] A contrast probe that threw and one that found nothing both printed zero mempool-41 found the day's pattern living in their own release tooling: a promotion waiter that merged on GitHub's mergeStateStatus, which reports CLEAN whether a run passed, failed, or never started. It would have promoted an unvalidated commit to main. That prompted the obvious question about this harness, and the answer was the same. The contrast probe's failure path records an error and an empty result. An empty result is also what a page with nothing wrong produces. Both printed "contrast failures: 0" and exited 0, and the error field, though written into the JSON report, was never read by the summary or shown to anyone. So a run where the probe threw on every page was indistinguishable from a clean one. That is the worst place for this to hide. Measured contrast is the one check here an accessibility engine cannot make: text on a fee gradient, on a block face, over the Lens canvas. Its silence is exactly what nobody would question. The summary now separates three states that were one. Failures found. Probe threw, so nothing was measured and the zero is not a result: reported, listed with the error, and the run fails, because it cannot claim what it did not measure. Probe ran and found no text to measure: reported and not failed, since a genuinely empty page is a legitimate reason. Proven rather than assumed, the same way the chain checks were: with the probe made to throw, the run names both affected pages and exits 1, where before it printed zero and exited 0. Restored, a clean run over twelve screenshots still passes. Co-Authored-By: Claude Opus 5 --- scripts/universe/visual-qa/capture.mjs | 46 +++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/scripts/universe/visual-qa/capture.mjs b/scripts/universe/visual-qa/capture.mjs index 38fe471b75f..3d06f614791 100644 --- a/scripts/universe/visual-qa/capture.mjs +++ b/scripts/universe/visual-qa/capture.mjs @@ -572,11 +572,17 @@ async function run() { const report = { browser: BROWSER, base: BASE, screenshots: shots, findings }; writeFileSync(join(OUT, `report-${BROWSER}.json`), JSON.stringify(report, null, 2)); - const stuck = summarise(report); + const { blocking: stuck, contrastNotMeasured } = summarise(report); if (stuck.length > 0) { console.error(`${stuck.length} page(s) never finished loading. This is the failure that shipped last time, so it fails the run.`); process.exitCode = 1; } + if (contrastNotMeasured.length > 0) { + console.error( + `${contrastNotMeasured.length} page(s) had no contrast measurement taken, so this run cannot claim their contrast is correct.`, + ); + process.exitCode = 1; + } } /** @@ -684,12 +690,30 @@ function summarise(report) { const contrastFailures = []; const blankCanvases = []; + // Pages where the contrast probe did not run at all. + // + // Its failure path records an error and an empty result, and an empty result + // is what a clean page produces too, so both printed "contrast failures: 0". + // A gate that measures nothing and a gate that measures everything and finds + // nothing wrong are not the same statement, and this is the one check here + // that an accessibility engine cannot make, so its silence is expensive. + const contrastNotMeasured = []; + // Pages where it ran and found nothing to measure. Legitimate on a page that + // is genuinely empty, so this is reported rather than failed, but it is not + // evidence of contrast being correct either. + const contrastNoSamples = []; for (const f of report.findings) { + const where = { route: f.route, state: f.state, theme: f.theme, viewport: f.viewport }; + if (f.contrast?.error) { + contrastNotMeasured.push({ ...where, error: f.contrast.error }); + } else if (f.contrast && (f.contrast.sampled ?? 0) === 0 && !f.error) { + contrastNoSamples.push(where); + } for (const row of f.contrast?.text ?? []) { - contrastFailures.push({ ...row, route: f.route, state: f.state, theme: f.theme, viewport: f.viewport }); + contrastFailures.push({ ...row, ...where }); } for (const c of f.contrast?.canvas ?? []) { - if (c.blank) blankCanvases.push({ ...c, route: f.route, state: f.state, theme: f.theme, viewport: f.viewport }); + if (c.blank) blankCanvases.push({ ...c, ...where }); } } contrastFailures.sort((a, b) => a.ratio - b.ratio); @@ -712,6 +736,12 @@ function summarise(report) { console.log(`a11y rules violated : ${byRule.size}\n`); console.log(`contrast failures : ${contrastFailures.length}`); + if (contrastNotMeasured.length) { + console.log(`contrast NOT MEASURED: ${contrastNotMeasured.length} (the probe threw; the zero above is not a result)`); + } + if (contrastNoSamples.length) { + console.log(`contrast no samples : ${contrastNoSamples.length} (ran, found no text to measure)`); + } console.log(`blank canvases : ${blankCanvases.length}`); if (contrastFailures.length) { @@ -731,6 +761,14 @@ function summarise(report) { } } + if (contrastNotMeasured.length) { + console.log(); + console.log('-- pages where the contrast probe did not run --'); + for (const f of contrastNotMeasured.slice(0, 20)) { + console.log(` ${f.route}/${f.state}/${f.theme}@${f.viewport}: ${f.error}`); + } + } + if (blankCanvases.length) { console.log(); console.log('-- canvases that drew nothing --'); @@ -787,7 +825,7 @@ function summarise(report) { if (known.length > 40) console.log(` ... and ${known.length - 40} more, see the report`); } console.log(''); - return blocking; + return { blocking, contrastNotMeasured }; } // Only drive browsers when this file is the program. Importing it, as the From 0b4bc094b2894e52cb306aea8fb10f58df72ca69 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 10:10:06 +0000 Subject: [PATCH 20/45] Correct the count, and name the two that were in the gates The section said four defects across three repositories. Two more were found after it was written, and both were in the machinery meant to protect the distinction rather than in the product: GitHub reporting a pull request as CLEAN whether its checks passed, failed or never ran, and this repository's own contrast probe recording an empty result when it threw, which is what a clean page also produces. Those two belong in this section more than the first four do. In the product a collapsed distinction produces a wrong page, which somebody eventually sees. In a gate it produces silence, which is what everybody is hoping for. Six, not four. I had also written seven elsewhere while describing the same list, which was simply wrong. Co-Authored-By: Claude Opus 5 --- docs/product/DESIGN-SYSTEM.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/product/DESIGN-SYSTEM.md b/docs/product/DESIGN-SYSTEM.md index 14ca0c93f0a..ec1d7aebb90 100644 --- a/docs/product/DESIGN-SYSTEM.md +++ b/docs/product/DESIGN-SYSTEM.md @@ -204,9 +204,10 @@ legible in greyscale, in print, and under forced colours. **The distinction has to survive the trip to get here.** These five states are the product's whole claim, and an interface can only show a difference the code -feeding it preserved. On 29 August 2026 four defects shipped in one release, -across three repositories, with a single shape: somewhere, "I do not know" and -"I know, and the answer is no" were the same value. +feeding it preserved. On 29 August 2026 six defects were found in one release, +across the product, the infrastructure, the release tooling and these gates, +with a single shape: somewhere, "I do not know" and "I know, and the answer is +no" were the same value. An indexer publishing `block_count` where the reader expected `blockCount` was read as publishing no checkpoint. A WebSocket handshake sent with no `Origin` @@ -215,6 +216,14 @@ refused. A field-name pattern matching `supply` read a token quantity as a coin amount. A Bitcoin node answering "no such block" was read as a failure, so a healthy chain was reported unavailable on every search. +The last two were in the machinery meant to protect all of this, which is where +the shape is most expensive because its failure mode is silence. GitHub reports +a pull request as `CLEAN` whether its checks passed, failed, or never ran, and a +promotion waiter reading that would have pushed an unvalidated commit to `main`. +The contrast probe in `scripts/universe/visual-qa` recorded an empty result when +it threw, which is what a clean page also produces, so a run that measured +nothing printed the same zero as a run that measured everything. + So the rule is not only about rendering. Anywhere this product reads something it does not fully control, a payload, a header, a field name, a status, it must be able to say which of the five it has. If an unrecognised input and a From ecb4d026d9d2c280a75e6f7f1ed4903d038bc81a Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 11:36:15 +0000 Subject: [PATCH 21/45] Say which part is missing, not only that something is A chain publishes `ready` and, when that is false, the codes saying why. The rail read `sync.state` for its chain reading, which is the node's view of its own blocks and not the same question. A chain whose node is caught up while a protocol indexer is not answering publishes sync ready and ready false, so the rail printed Ready, in the proven tone, on the reading a visitor trusts most, for a chain that had just said no. Dogecoin publishes exactly that today. The reasons were carried in the same document and rendered nowhere. Where a reason did reach a page, in the protocol coverage list, it arrived as `protocol-authority-stale`. So: the verdict wins over the sync state, and the reasons are read into sentences beneath the rail. `chain-reasons.ts` holds the fourteen codes the overlay derives, as an allowlist rather than a pattern, because a pattern would match a code it has never seen and assert a meaning for it. Three distinctions the raw list does not make on its own: - A code on an authority that is ready is not a fault. `tap_doge` reports `pending-protocol-coverage-unavailable` and `reorg-evidence-tail-only` while ready and complete; they are the stated edges of what it covers. They now sit under "What it does not cover" rather than in the colour that means broken. - `authority-capability-disabled` says the index was built without the protocol switched on, so the wording says it cannot serve it until it is rebuilt, rather than implying it will catch up. - A code with no sentence here keeps its own words and is marked as one this build has no description for. A chain that withholds readiness and states no reason says so, because an empty space where the explanation belongs reads as a chain that is fine. The fixtures said `doge-tap` where the capability envelope says `tap_doge`, and carried three prose-shaped reasons the API never emits, so nothing here had ever been read against a code production actually sends. They now use the production spellings, and two states photograph what the live overlay published this morning: a chain not ready with three reasons, and one not ready with none. --- README.md | 7 + .../multichain-explorer/chain-reasons.spec.ts | 42 ++++++ .../multichain-explorer/chain-reasons.ts | 133 ++++++++++++++++++ .../multichain-explorer.component.html | 26 +++- .../multichain-explorer.component.scss | 58 ++++++++ .../multichain-explorer.component.ts | 5 + .../multichain-view.spec.ts | 75 +++++++++- .../multichain-explorer/multichain-view.ts | 61 +++++++- scripts/universe/visual-qa/chain-fixtures.mjs | 71 +++++++++- 9 files changed, 465 insertions(+), 13 deletions(-) create mode 100644 frontend/src/app/universe/multichain-explorer/chain-reasons.spec.ts create mode 100644 frontend/src/app/universe/multichain-explorer/chain-reasons.ts diff --git a/README.md b/README.md index 70b13309f85..2dacf3529a5 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,13 @@ guesses. the same five readings in the same order: chain state, chain tip, blocks behind the tip, when the reading was taken, and how complete the pending set is. A figure that is true as of a block that is not the tip says so. +- **A verdict never travels without its evidence.** When a chain says it is not + ready, the page says which part is missing, in English rather than in the + API's codes: whose node did not answer, which indexer is behind, which index + was built without a protocol switched on. A working authority that states the + edges of its coverage is shown as exactly that, not as something broken. A + chain that withholds readiness and gives no reason is reported as having + given none. - **Exact numbers, end to end.** Amounts cross the API as decimal strings and are shifted by string arithmetic, never parsed into a floating point number. Every rendered figure keeps the exact value it came from. diff --git a/frontend/src/app/universe/multichain-explorer/chain-reasons.spec.ts b/frontend/src/app/universe/multichain-explorer/chain-reasons.spec.ts new file mode 100644 index 00000000000..80ae31784ce --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/chain-reasons.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { + describeChainReason, + describeChainReasons, +} from '@app/universe/multichain-explorer/chain-reasons'; + +describe('describeChainReason', () => { + it('reads a fault as a sentence rather than a code', () => { + const reading = describeChainReason('base-chain-authority-unavailable'); + expect(reading.kind).toBe('fault'); + expect(reading.text).toContain('did not answer'); + expect(reading.text).not.toContain('-'); + }); + + it('separates a working authority stating its edges from one that is broken', () => { + // tap_doge publishes both of these while ready and complete. Read as + // faults they describe an outage that is not happening. + expect(describeChainReason('pending-protocol-coverage-unavailable').kind).toBe('limit'); + expect(describeChainReason('reorg-evidence-tail-only').kind).toBe('limit'); + expect(describeChainReason('protocol-authority-unavailable').kind).toBe('fault'); + }); + + it('keeps an unrecognised code, and does not claim to know what it means', () => { + const reading = describeChainReason('some-future-authority-problem'); + expect(reading.kind).toBe('unstated'); + expect(reading.text).toBe('Some future authority problem'); + expect(reading.code).toBe('some-future-authority-problem'); + }); + + it('says the index cannot serve a protocol at all, not that it is merely behind', () => { + // The flag is fixed when the index is created, so this is not something + // that heals by waiting, and the wording has to say so. + expect(describeChainReason('authority-capability-disabled').text).toContain('rebuilt'); + expect(describeChainReason('protocol-authority-stale').text).toContain('behind the chain tip'); + }); + + it('reads a list in the order it was given', () => { + expect( + describeChainReasons(['protocol-history-unavailable', 'reorg-evidence-tail-only']).map((r) => r.kind) + ).toEqual(['fault', 'limit']); + }); +}); diff --git a/frontend/src/app/universe/multichain-explorer/chain-reasons.ts b/frontend/src/app/universe/multichain-explorer/chain-reasons.ts new file mode 100644 index 00000000000..5e72939c17d --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/chain-reasons.ts @@ -0,0 +1,133 @@ +/** + * The words behind a chain's own verdict on itself. + * + * `/api/v1//status` states `ready` and, when that is false, a list of + * codes saying why. The codes are a machine contract, so they arrive as + * `protocol-history-unavailable` and the like. Printing them is a field dump, + * and dropping them leaves a verdict with no evidence behind it, which is the + * one thing this product must never do. + * + * Two further distinctions the raw list does not make on its own: + * + * - A code on a source that is *ready* is not a fault. `tap_doge` reports + * `pending-protocol-coverage-unavailable` and `reorg-evidence-tail-only` + * while ready and complete: they are the stated edges of what that authority + * covers, not something broken. Shown under a failure heading they read as + * an outage that is not happening. + * - A code this build has no sentence for is not the same as no code. It is + * shown, humanised, and marked as stated but not explained here, rather than + * silently dropped or given a meaning it may not have. + */ + +export type ChainReasonKind = + /** Something the explorer depends on is not answering, or is not current. */ + | 'fault' + /** A stated edge of what a working source covers. */ + | 'limit' + /** A code this build carries no sentence for. */ + | 'unstated'; + +export interface ChainReasonReading { + readonly code: string; + readonly text: string; + readonly kind: ChainReasonKind; +} + +interface ReasonCopy { + readonly text: string; + readonly kind: Exclude; +} + +/** + * An allowlist, deliberately. A pattern over these codes would match a new one + * it has never seen and assert a meaning for it, and the whole point of this + * table is that an unrecognised code stays unrecognised. + * + * Each sentence says what did not happen, not what it implies. The overlay + * derives these in `chain-capabilities.service.ts`; the wording here follows + * that derivation rather than paraphrasing the code name. + */ +const REASON_COPY: Record = { + 'base-chain-authority-unavailable': { + text: $localize`:@@universe.reason.base-chain-authority-unavailable:The node that serves this chain's own blocks and transactions did not answer.`, + kind: 'fault', + }, + 'confirmed-history-authority-unavailable': { + text: $localize`:@@universe.reason.confirmed-history-authority-unavailable:The source of confirmed history did not answer, so history older than the pending set cannot be read.`, + kind: 'fault', + }, + 'mempool-collector-unavailable': { + text: $localize`:@@universe.reason.mempool-collector-unavailable:The collector that watches pending transactions did not answer.`, + kind: 'fault', + }, + 'mempool-collector-degraded': { + text: $localize`:@@universe.reason.mempool-collector-degraded:The pending-transaction collector answered, but reports itself as not healthy.`, + kind: 'fault', + }, + 'mempool-collector-recent-failure': { + text: $localize`:@@universe.reason.mempool-collector-recent-failure:The pending-transaction collector's most recent attempt failed, so the set below may have missed arrivals.`, + kind: 'fault', + }, + 'mempool-coverage-partial': { + text: $localize`:@@universe.reason.mempool-coverage-partial:The pending set is a partial view of the node's mempool rather than all of it.`, + kind: 'fault', + }, + 'node-initial-block-download': { + text: $localize`:@@universe.reason.node-initial-block-download:The node is still downloading the chain for the first time, so it does not yet hold every block.`, + kind: 'fault', + }, + 'protocol-history-unavailable': { + text: $localize`:@@universe.reason.protocol-history-unavailable:At least one protocol indexer this chain needs is not answering, so protocol history cannot be read. Blocks, transactions and addresses are unaffected.`, + kind: 'fault', + }, + 'protocol-history-partial': { + text: $localize`:@@universe.reason.protocol-history-partial:Protocol history is readable but does not yet cover the whole chain.`, + kind: 'fault', + }, + 'protocol-authority-unavailable': { + text: $localize`:@@universe.reason.protocol-authority-unavailable:This protocol's indexer did not answer.`, + kind: 'fault', + }, + 'protocol-authority-stale': { + text: $localize`:@@universe.reason.protocol-authority-stale:This protocol's indexer answered, but its last checkpoint is behind the chain tip.`, + kind: 'fault', + }, + 'authority-capability-disabled': { + text: $localize`:@@universe.reason.authority-capability-disabled:The running index was built without this protocol switched on, so it cannot serve it at all until it is rebuilt.`, + kind: 'fault', + }, + 'pending-protocol-coverage-unavailable': { + text: $localize`:@@universe.reason.pending-protocol-coverage-unavailable:This authority reads confirmed blocks only. It says nothing about pending transactions.`, + kind: 'limit', + }, + 'reorg-evidence-tail-only': { + text: $localize`:@@universe.reason.reorg-evidence-tail-only:This authority keeps reorganisation evidence for recent blocks only, not for the whole chain.`, + kind: 'limit', + }, +}; + +/** + * A code with no sentence here, made readable without being interpreted. The + * words are the code's own: `protocol-authority-stale` becomes "Protocol + * authority stale" and nothing is added to it. + */ +function readableCode(code: string): string { + const words = code.replace(/[_-]+/g, ' ').trim(); + if (!words) { + return code; + } + return words.charAt(0).toUpperCase() + words.slice(1); +} + +/** One code, read. An unknown code keeps its own words rather than borrowing any. */ +export function describeChainReason(code: string): ChainReasonReading { + const copy = REASON_COPY[code]; + if (copy) { + return { code, text: copy.text, kind: copy.kind }; + } + return { code, text: readableCode(code), kind: 'unstated' }; +} + +export function describeChainReasons(codes: readonly string[]): readonly ChainReasonReading[] { + return codes.map(describeChainReason); +} diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html index 600c1b66935..014caaed596 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html @@ -35,6 +35,20 @@

{{ chainName }} {{ pageLabel() }}

+ +
+

Why {{ chainName }} is not ready

+
    +
  • + {{ reason.text }} + Reported as this code, which this build has no description for. +
  • +
+
+ @@ -87,9 +101,15 @@

Protocol in {{ protocol.lag.display }} blocks behind - - {{ protocol.reasons.join(', ') }} - + + + 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. 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 2f2dc422d3d..5b1d9e1988d 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss @@ -179,6 +179,44 @@ color: var(--u-text-muted); } +// --------------------------------------------------------------------------- +// Why a chain is not ready +// --------------------------------------------------------------------------- + +// Sits directly beneath the rail, because it is the evidence for the verdict +// the rail just gave. Bordered like a notice rather than raised like a panel: +// it is a qualification of what is above it, not a section of its own. +.not-ready { + @include u.universe-surface; + + border-left: 3px solid var(--universe-state-partial); + padding: var(--u-space-3) var(--u-space-4); + margin-bottom: var(--u-space-3); + max-width: var(--u-width-prose); + + h2 { + font-size: var(--u-text-base); + font-weight: var(--u-weight-semibold); + margin: 0 0 var(--u-space-2); + } + + ul { + display: grid; + gap: var(--u-space-2); + font-size: var(--u-text-sm); + line-height: var(--u-leading-normal); + list-style: none; + margin: 0; + padding: 0; + } +} + +.reason-raw { + color: var(--u-text-muted); + display: block; + font-size: var(--u-text-xs); +} + .identifier { @include u.universe-identifier; } @@ -292,10 +330,30 @@ font-size: var(--u-text-sm); } +.coverage-reasons-label { + flex-basis: 100%; + 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; +} + .coverage-reasons { flex-basis: 100%; color: var(--universe-state-partial); font-size: var(--u-text-sm); + line-height: var(--u-leading-normal); + list-style: none; + margin: 0; + padding: 0; +} + +// An authority that is working still states the edges of its coverage. Those +// sentences sit under their own label and must not wear the partial colour, +// which on this page means something is wrong. +.coverage-reasons-label + .coverage-reasons { + color: var(--u-text-secondary); } .entry-actions { 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 093808d84cf..cba880be9d6 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts @@ -45,6 +45,7 @@ import { readCollection, readEmptyList, readIdentifierList, + readNotReadyReasons, readOutpoint, readProtocolCoverage, readRecordFacts, @@ -53,6 +54,7 @@ import { readTransactionList, shortenIdentifier, } from '@app/universe/multichain-explorer/multichain-view'; +import { ChainReasonReading } from '@app/universe/multichain-explorer/chain-reasons'; import { Observable, Subject, @@ -86,6 +88,8 @@ interface ExplorerViewModel { readonly payload: ChainExplorerPayload | null; readonly payloadError: string | null; readonly rail: readonly StatusReading[]; + /** Why the chain withholds readiness. Null when it does not. */ + readonly notReady: readonly ChainReasonReading[] | null; readonly reads: readonly ReadCapability[]; readonly protocols: readonly ProtocolReading[]; readonly shape: ChainShape; @@ -276,6 +280,7 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { payload, payloadError, rail: readStatusRail(capability, this.profile, Date.now()), + notReady: readNotReadyReasons(capability), reads: readCapabilities(capability), protocols: readProtocolCoverage(capability, this.profile), shape, diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts index 2d53a5d5c7f..81de41bb0c0 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts @@ -17,6 +17,7 @@ import { readCapabilities, readCollection, readEmptyList, + readNotReadyReasons, readOutpoint, readPaging, readProtocolCoverage, @@ -309,7 +310,7 @@ describe('readProtocolCoverage', () => { const readings = readProtocolCoverage( capability({ protocols: [ - { protocolId: 'drc20', state: 'degraded', coverage: 'partial', updatedAt: null, lagBlocksAtomic: '17', degradedReasons: ['authority behind tip'] }, + { protocolId: 'drc20', state: 'degraded', coverage: 'partial', updatedAt: null, lagBlocksAtomic: '17', degradedReasons: ['protocol-authority-stale'] }, ], }), DOGE @@ -317,7 +318,7 @@ describe('readProtocolCoverage', () => { const drc20 = readings.find((r) => r.protocolId === 'drc20'); expect(drc20?.tone).toBe('partial'); expect(drc20?.lag?.display).toBe('17'); - expect(drc20?.reasons).toEqual(['authority behind tip']); + expect(drc20?.reasons.map((reason) => reason.code)).toEqual(['protocol-authority-stale']); expect(drc20?.historyLabel).toBe(historyLabel('partial')); }); @@ -728,3 +729,73 @@ describe('readRecordFacts', () => { expect(fact.display).toBe('0'); }); }); + +describe('the chain reading on the status rail', () => { + it('does not report a chain as ready when the chain says it is not', () => { + // The node can be caught up while a protocol indexer this chain needs is + // not answering. That publishes sync.state ready and ready false, and the + // rail used to read the sync state alone: Ready, in the proven tone, on + // the reading a visitor trusts most, for a chain that had just said no. + const rail = readStatusRail( + capability({ ready: false, degradedReasons: ['protocol-history-unavailable'] }), + DOGE, + Date.parse('2026-08-29T05:00:10.000Z') + ); + const state = rail.find((reading) => reading.id === 'state'); + expect(state?.value).toBe('Degraded'); + expect(state?.tone).toBe('partial'); + }); + + it('keeps the worse reading when the node itself is the problem', () => { + const rail = readStatusRail( + capability({ ready: false, sync: { state: 'unavailable', initialBlockDownload: false, progressDecimal: null, updatedAt: null } }), + DOGE, + Date.parse('2026-08-29T05:00:10.000Z') + ); + expect(rail.find((reading) => reading.id === 'state')?.value).toBe('Unavailable'); + }); + + it('still reads ready when the chain says so', () => { + const rail = readStatusRail(capability(), DOGE, Date.parse('2026-08-29T05:00:10.000Z')); + const state = rail.find((reading) => reading.id === 'state'); + expect(state?.value).toBe('Ready'); + expect(state?.tone).toBe('proven'); + }); +}); + +describe('readNotReadyReasons', () => { + it('says nothing about a ready chain', () => { + expect(readNotReadyReasons(capability())).toBeNull(); + expect(readNotReadyReasons(null)).toBeNull(); + }); + + it('reads the reasons the chain gave', () => { + const reasons = readNotReadyReasons( + capability({ ready: false, degradedReasons: ['base-chain-authority-unavailable', 'protocol-history-unavailable'] }) + ); + expect(reasons).toHaveLength(2); + expect(reasons?.[0].text).toContain('did not answer'); + }); + + it('says a chain withheld readiness without explaining, rather than showing nothing', () => { + const reasons = readNotReadyReasons(capability({ ready: false, degradedReasons: [] })); + expect(reasons).toHaveLength(1); + expect(reasons?.[0].text).toContain('states no reason'); + }); +}); + +describe('readProtocolCoverage reasons', () => { + it('hands the page sentences rather than wire codes', () => { + const readings = readProtocolCoverage( + capability({ + protocols: [ + { protocolId: 'drc20', state: 'unavailable', coverage: 'unavailable', updatedAt: null, lagBlocksAtomic: null, degradedReasons: ['authority-capability-disabled'] }, + ], + }), + DOGE + ); + const drc20 = readings.find((reading) => reading.protocolId === 'drc20'); + expect(drc20?.reasons.map((reason) => reason.code)).toEqual(['authority-capability-disabled']); + expect(drc20?.reasons[0].text).not.toContain('-disabled'); + }); +}); diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.ts index 4aa78820d1b..0792b3be0f8 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.ts @@ -34,6 +34,10 @@ import { ExplorerChain, } from '@app/universe/universe.types'; import { explorerChainName } from '@app/universe/universe-chain-routing'; +import { + ChainReasonReading, + describeChainReasons, +} from '@app/universe/multichain-explorer/chain-reasons'; import { EvidenceTone, formatAtomicAmount as formatAtomicDigits, @@ -618,10 +622,24 @@ export function readStatusRail( { id: 'state', label: $localize`:@@universe.chain.rail-state:Chain`, + // `sync` is the node's own view of its blocks. `ready` is the chain's + // verdict on everything the explorer needs, and it is the stricter of + // the two: a chain whose node is caught up while a protocol indexer is + // not answering publishes sync.state ready and ready false. Reading the + // sync state alone therefore printed Ready, in the proven tone, on the + // one reading a visitor trusts most, for a chain that had just said it + // was not. When the two disagree the verdict wins, and the reasons + // beneath the rail say which part is missing. value: capability.ready ? availabilityLabel('ready') - : availabilityLabel(capability.sync.state), - tone: capability.ready ? 'proven' : availabilityTone(capability.sync.state), + : capability.sync.state === 'ready' + ? availabilityLabel('degraded') + : availabilityLabel(capability.sync.state), + tone: capability.ready + ? 'proven' + : capability.sync.state === 'ready' + ? availabilityTone('degraded') + : availabilityTone(capability.sync.state), exact: null, }, { @@ -665,6 +683,34 @@ export function readStatusRail( ]; } +/** + * Why a chain says it is not ready, in words, or null when it says it is. + * + * The capability document is the record of this and the reasons were never + * rendered anywhere, so the rail published a verdict and dropped every word of + * the evidence behind it. A chain that withholds readiness without stating a + * reason is itself worth saying out loud rather than showing an empty space + * where the explanation should be. + */ +export function readNotReadyReasons( + capability: ChainCapabilityEnvelope | null +): readonly ChainReasonReading[] | null { + if (!capability || capability.ready) { + return null; + } + const reasons = describeChainReasons(capability.degradedReasons ?? []); + if (reasons.length) { + return reasons; + } + return [ + { + code: '', + text: $localize`:@@universe.chain.not-ready-no-reason:This chain reports that it is not ready and states no reason for it.`, + kind: 'unstated', + }, + ]; +} + export interface ReadCapability { readonly id: string; readonly label: string; @@ -735,7 +781,12 @@ export interface ProtocolReading { readonly coverageLabel: string; readonly historyLabel: string; readonly lag: ExactNumber | null; - readonly reasons: readonly string[]; + /** + * Why this reading is what it is, in words. On a ready authority these are + * the stated edges of its coverage rather than faults, which is why each one + * carries its own kind. + */ + readonly reasons: readonly ChainReasonReading[]; } export function readProtocolCoverage( @@ -765,7 +816,7 @@ export function readProtocolCoverage( coverageLabel: completenessLabel(entry?.coverage), historyLabel: historyLabel(entry?.coverage), lag: formatExactInteger(entry?.lagBlocksAtomic ?? null), - reasons: entry?.degradedReasons ?? [], + reasons: describeChainReasons(entry?.degradedReasons ?? []), }; }); @@ -785,7 +836,7 @@ export function readProtocolCoverage( coverageLabel: completenessLabel(entry.coverage), historyLabel: historyLabel(entry.coverage), lag: formatExactInteger(entry.lagBlocksAtomic ?? null), - reasons: entry.degradedReasons ?? [], + reasons: describeChainReasons(entry.degradedReasons ?? []), }); } return readings; diff --git a/scripts/universe/visual-qa/chain-fixtures.mjs b/scripts/universe/visual-qa/chain-fixtures.mjs index 2b202f7e6c4..29f515e458a 100644 --- a/scripts/universe/visual-qa/chain-fixtures.mjs +++ b/scripts/universe/visual-qa/chain-fixtures.mjs @@ -60,12 +60,15 @@ function capability(chain, overrides = {}) { ? [ { protocolId: 'doginals', state: 'ready', coverage: 'complete', updatedAt: observedAt, lagBlocksAtomic: '0', degradedReasons: [] }, { protocolId: 'drc20', state: 'ready', coverage: 'complete', updatedAt: observedAt, lagBlocksAtomic: '0', degradedReasons: [] }, - { protocolId: 'doge-tap', state: 'degraded', coverage: 'partial', updatedAt: observedAt, lagBlocksAtomic: '184', degradedReasons: ['authority behind chain tip'] }, + // `tap_doge`, not `doge-tap`: the capability envelope and the route + // are different namespaces, and a fixture written in the route's + // spelling cannot see a reader that only knows one of them. + { protocolId: 'tap_doge', state: 'degraded', coverage: 'partial', updatedAt: observedAt, lagBlocksAtomic: '184', degradedReasons: ['protocol-authority-stale'] }, ] : [ { protocolId: 'zerdinals', state: 'ready', coverage: 'partial', updatedAt: observedAt, lagBlocksAtomic: '12', degradedReasons: [] }, { protocolId: 'zrunes', state: 'ready', coverage: 'partial', updatedAt: observedAt, lagBlocksAtomic: '12', degradedReasons: [] }, - { protocolId: 'zrc20', state: 'degraded', coverage: 'partial', updatedAt: observedAt, lagBlocksAtomic: '12', degradedReasons: ['two rulesets disagree below this height'] }, + { protocolId: 'zrc20', state: 'degraded', coverage: 'partial', updatedAt: observedAt, lagBlocksAtomic: '12', degradedReasons: ['protocol-history-partial'] }, ], coverage: { confirmedHistory: chain === 'dogecoin' ? 'complete' : 'partial', @@ -325,6 +328,20 @@ export const chainFixtures = { }, }; +/** + * Dogecoin as it stood on 2026-08-29: caught up on blocks, not ready overall, + * because the protocol side is not answering and the DRC-20 index was created + * without the flag that would let it serve at all. + */ +const DOGECOIN_NOT_READY = { + ready: false, + degradedReasons: [ + 'base-chain-authority-unavailable', + 'confirmed-history-authority-unavailable', + 'protocol-history-unavailable', + ], +}; + export const chainStateOverrides = { // A pending set that is genuinely empty and says so. // @@ -379,11 +396,57 @@ export const chainStateOverrides = { lagBlocksAtomic: '2841', sync: { state: 'degraded', initialBlockDownload: false, progressDecimal: '0.9994', updatedAt: new Date(Date.now() - 20_000).toISOString() }, coverage: { confirmedHistory: 'partial', addressHistory: 'partial', protocolHistory: 'partial' }, - degradedReasons: ['confirmed history authority is behind the chain tip'], + degradedReasons: ['protocol-history-partial'], + }), + }, + }, + + // A chain whose node is caught up while the protocol side is not, which is + // what Dogecoin published on 2026-08-29: ready false, sync ready, three + // reasons, and a DRC-20 index that was created without the flag and so + // cannot serve it at all. Reading the sync state alone printed Ready in the + // proven tone on a chain that had just said no, and the reasons were carried + // in the same document and rendered nowhere. + 'chain-not-ready-protocols': { + // The header switcher reads the chain list and the page reads the single + // status, so a fixture that overrides one and not the other photographs a + // disagreement production cannot produce, both documents come from the + // same overlay. + '/api/v1/chains': { + body: [ + capability('dogecoin', DOGECOIN_NOT_READY), + capability('zcash'), + ], + }, + '/api/v1/dogecoin/status': { + body: capability('dogecoin', { + ...DOGECOIN_NOT_READY, + protocols: [ + { protocolId: 'doginals', state: 'degraded', coverage: 'unavailable', updatedAt: null, lagBlocksAtomic: null, degradedReasons: ['protocol-authority-unavailable'] }, + { protocolId: 'drc20', state: 'unavailable', coverage: 'unavailable', updatedAt: null, lagBlocksAtomic: null, degradedReasons: ['authority-capability-disabled'] }, + // Ready, and still reporting two edges of its coverage. Those are + // not faults and must not be printed as though they were. + { protocolId: 'tap_doge', state: 'ready', coverage: 'complete', updatedAt: new Date(Date.now() - 20_000).toISOString(), lagBlocksAtomic: '0', degradedReasons: ['pending-protocol-coverage-unavailable', 'reorg-evidence-tail-only'] }, + ], }), }, }, + // A chain that withholds readiness and states no reason for it. The page has + // to say that out loud rather than leave the space where the explanation + // belongs empty, which reads as a chain that is simply fine. + 'chain-not-ready-unexplained': { + '/api/v1/chains': { + body: [ + capability('dogecoin'), + capability('zcash', { ready: false, degradedReasons: [] }), + ], + }, + '/api/v1/zcash/status': { + body: capability('zcash', { ready: false, degradedReasons: [] }), + }, + }, + // The object simply is not there. A 404 from the authority is an answer, not // an outage, and the page must not describe it as one. 'chain-object-missing': { @@ -407,6 +470,8 @@ export const chainStateScope = { 'chain-empty-pending': ['dogecoin-mempool', 'zcash-mempool'], 'chain-authority-down': ['dogecoin', 'zcash'], 'chain-behind': ['dogecoin'], + 'chain-not-ready-protocols': ['dogecoin'], + 'chain-not-ready-unexplained': ['zcash'], 'chain-object-missing': ['dogecoin'], }; From 84ed833bada1066a9be1bfaa1eb4a8e0cc6bf2a0 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 11:36:29 +0000 Subject: [PATCH 22/45] Open the chain switcher, which nothing had ever opened Every row in the chain menu was one line: the chain's name, its state and its detail sentence run together and off the end. The header's own `.dropdown-item` rule sets `display: flex`, which is right for the network rows it was written for, an icon beside a label. A component style carries an attribute selector as well as its class, so it outranked the switcher's two-column grid in the global sheet, and the grid never applied at any width. It survived because the matrix had never opened a menu. Routes may now name a control to open before anything is measured, and the switcher is a route. A route that declares one and cannot find it fails, rather than quietly measuring the closed page instead. Opening it found two more things. The block height printed as a bare run of digits, `Block 2884120`, while the status rail groups the same fact as `2,884,120`; it is grouped in both places now. And the detail line read `Tip 2884120; mempool ready, complete` whether the chain said it was ready or not, so a chain marked degraded sat beside a sentence in which nothing was wrong, in the wire's words. A degraded chain leads with its reason. axe reads a control under an open menu as a target too small to hit, and at 320 and 375 it reads the search field behind this one that way. That is a real measurement of a state the rule is not about: the field is dismissible from under a menu, and the same field at the same widths passes on `home`, which is this path with the menu closed and is measured in full in every run. Obscuring findings on a route that declares it opens an overlay are printed on their own line, with the element, viewport and theme, and not counted. Every other rule still fails the run, and a target-size failure that is not about obscuring still fails too. --- .../master-page/master-page.component.scss | 13 ++-- .../master-page/master-page.component.ts | 41 ++++++++-- frontend/src/styles.scss | 10 ++- scripts/universe/visual-qa/capture.mjs | 77 ++++++++++++++++++- 4 files changed, 125 insertions(+), 16 deletions(-) diff --git a/frontend/src/app/components/master-page/master-page.component.scss b/frontend/src/app/components/master-page/master-page.component.scss index c99eaa6ea63..42f726fbcfa 100644 --- a/frontend/src/app/components/master-page/master-page.component.scss +++ b/frontend/src/app/components/master-page/master-page.component.scss @@ -81,12 +81,6 @@ } } -// Dim the mark while the socket is down. Paired with the worded status beside -// it, never as the only signal. -.brand-dimmed { - opacity: 0.45; -} - .subdomain_logo { align-self: center; margin: auto; @@ -171,7 +165,12 @@ width: 22px; } -.dropdown-item { +// Scoped to the network picker on purpose. Its rows are an icon beside a +// label, which is what wants a flex row. Unscoped, this beat the chain +// switcher's own two-column grid, because a component style carries an +// attribute selector as well as the class, and collapsed every chain row +// into one line with its detail line running off the end. +.network-picker .dropdown-item { align-items: center; display: flex; } diff --git a/frontend/src/app/components/master-page/master-page.component.ts b/frontend/src/app/components/master-page/master-page.component.ts index 7636f8f252f..d90ded74b65 100644 --- a/frontend/src/app/components/master-page/master-page.component.ts +++ b/frontend/src/app/components/master-page/master-page.component.ts @@ -9,6 +9,12 @@ import { StorageService } from '@app/services/storage.service'; import { UniverseApiService } from '@app/universe/universe-api.service'; import { UniverseLocalService } from '@app/universe/universe-local.service'; import { ChainCapabilityEnvelope, ExplorerChain } from '@app/universe/universe.types'; +import { describeChainReasons } from '@app/universe/multichain-explorer/chain-reasons'; +import { + availabilityLabel, + completenessLabel, + formatExactInteger, +} from '@app/universe/multichain-explorer/multichain-view'; import { explorerChainFromUrl, explorerSectionRoute, @@ -171,14 +177,39 @@ export class MasterPageComponent implements OnInit, OnDestroy { } chainState(capability: ChainCapabilityEnvelope | undefined): string { - if (!capability) {return 'status unavailable';} - return capability.ready ? 'ready' : 'degraded'; + if (!capability) {return availabilityLabel(null);} + return capability.ready ? availabilityLabel('ready') : availabilityLabel('degraded'); } + /** + * The line under a chain's name in the switcher. + * + * It used to read "Tip 964557; mempool ready, complete" whether the chain + * said it was ready or not, so a chain marked degraded sat beside a sentence + * in which nothing was wrong, in the wire's words rather than in English. + * The reason is in the same document the verdict came from, so a degraded + * chain leads with it and a ready one keeps the tip and pending reading. + */ chainDetail(capability: ChainCapabilityEnvelope | undefined): string { - if (!capability) {return 'Tip and mempool unavailable';} - const tip = capability.tip?.heightAtomic ?? 'unknown tip'; - return `Tip ${tip}; mempool ${capability.mempool.state}, ${capability.mempool.completeness}`; + if (!capability) { + return $localize`:@@master-page.chain-status-unavailable:This chain did not report its status.`; + } + // Grouped the way the status rail groups it. A block height printed as a + // bare run of digits in one place and as 2,884,120 in another is the same + // fact in two voices, and the long one is where it is hardest to read. + const tip = formatExactInteger(capability.tip?.heightAtomic ?? null); + const height = tip + ? $localize`:@@master-page.chain-tip:Block ${tip.display}:HEIGHT:` + : $localize`:@@master-page.chain-tip-none:No tip reported`; + if (!capability.ready) { + const [first] = describeChainReasons(capability.degradedReasons ?? []); + return first ? `${height}. ${first.text}` : height; + } + if (!capability.mempool.supported) { + return height; + } + const coverage = completenessLabel(capability.mempool.completeness); + return $localize`:@@master-page.chain-ready-detail:${height}:BLOCK:. Pending coverage ${coverage}:COVERAGE:.`; } chainRoute(kind: 'dashboard' | 'mempool' | 'protocols'): string { diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 33e0f666fd4..2d8226d2532 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -1620,18 +1620,24 @@ h2.anchor { } .chain-option { - align-items: start; + align-items: center; + column-gap: var(--u-space-3); display: grid; - grid-template-columns: 1fr auto; + grid-template-columns: minmax(0, 1fr) auto; + row-gap: 2px; white-space: normal; } .chain-option-title { font-weight: var(--u-weight-semibold); + // The chain name and its ticker are one label and must not be broken across + // two lines. The detail line beneath is the part allowed to wrap. + white-space: nowrap; } .chain-option-detail { font-size: var(--u-text-xs); + line-height: 1.3; } @media (min-width: 720px) { diff --git a/scripts/universe/visual-qa/capture.mjs b/scripts/universe/visual-qa/capture.mjs index 3d06f614791..dcaaf6b6479 100644 --- a/scripts/universe/visual-qa/capture.mjs +++ b/scripts/universe/visual-qa/capture.mjs @@ -91,6 +91,31 @@ const ROUTES = [ { id: 'rune', path: `/rune/${assetSampleIds.RUNE_NAME}`, name: 'Rune' }, { id: 'sat', path: `/sat/${assetSampleIds.SAT_NUMBER}`, name: 'Sat' }, { id: 'saved', path: '/saved', name: 'Saved in this browser' }, + + // The chain switcher, open. Nothing here had ever opened a menu, so the one + // surface that decides which chain a visitor is looking at was measured only + // while closed. It was collapsed: the header's own `.dropdown-item` rule, + // written for the network rows, outranked the switcher's two-column grid and + // ran each chain's name, state and detail into a single line. + { + id: 'chain-menu', + path: '/', + name: 'Chain switcher, open', + open: '.chain-toggle', + // An open menu deliberately covers what is under it. axe reads a covered + // control as a target too small to hit, and at 320 and 375 it reads the + // search field behind this menu that way. That is a real measurement of a + // state that is not the one the rule is about: the field is not a target + // while a dismissible menu is over it, and the same field at the same + // widths passes on the `home` route, which is this path with the menu + // closed and is measured in full in every run. + // + // So obscuring findings on this route are reported on their own line + // rather than counted, and every other rule still fails the run. The line + // prints what was obscured, because a rule that is quietly not counted is + // a rule nobody sees again. + overlayObscures: true, + }, ]; const VIEWPORTS = [ @@ -401,6 +426,22 @@ async function run() { await page.goto(BASE + route.path, { waitUntil: 'domcontentloaded', timeout: 45_000 }); await page.waitForTimeout(state === 'loading' ? 1_200 : 2_600); + // A route may name a control to open before anything is measured. + // A menu that never opens is a menu nothing checks. When the + // control is genuinely absent in this state, that is recorded as a + // navigation failure rather than passed over, because a route that + // measured the closed page instead is not the route that was asked + // for. + if (route.open) { + const control = page.locator(route.open).first(); + if (await control.count()) { + await control.click(); + await page.waitForTimeout(400); + } else if (state === 'populated') { + throw new Error(`${route.open} is not on the page, so this route measured the closed page instead of the open one`); + } + } + const overflow = await page.evaluate(() => { const d = document.documentElement; return { scrollWidth: d.scrollWidth, clientWidth: d.clientWidth }; @@ -414,6 +455,7 @@ async function run() { ); let violations = []; + const obscured = []; if (!args.skipAxe) { try { const axe = await new AxeBuilder({ page }) @@ -432,6 +474,22 @@ async function run() { detail: (n.any?.[0]?.message ?? n.failureSummary ?? '').slice(0, 200), })), })); + + // Split off what an open overlay is expected to obscure. Only + // on a route that declares it opens one, and only for nodes + // axe itself says are obscured; a target that is simply too + // small still fails here as it does everywhere else. + if (route.overlayObscures) { + for (const violation of violations) { + if (violation.id !== 'target-size') continue; + const covered = violation.nodes.filter((n) => /obscured/i.test(n.detail)); + if (!covered.length) continue; + obscured.push({ id: violation.id, nodes: covered }); + violation.nodes = violation.nodes.filter((n) => !/obscured/i.test(n.detail)); + violation.count -= covered.length; + } + violations = violations.filter((violation) => violation.nodes.length > 0); + } } catch (e) { violations = [{ id: 'axe-failed', impact: 'unknown', count: 0, help: String(e).slice(0, 200), sample: '' }]; } @@ -501,7 +559,7 @@ async function run() { findings.push({ route: route.id, routeName: route.name, state, theme, viewport: viewport.id, - overflowBy, consoleErrors, brokenImages, violations, contrast, progress, settledAfterMs, + overflowBy, consoleErrors, brokenImages, violations, obscured, contrast, progress, settledAfterMs, }); } catch (error) { // A server that has gone away is not a page that failed. Reporting @@ -733,7 +791,22 @@ function summarise(report) { console.log(`console errors : ${errors.length}`); console.log(`broken images : ${images.length}`); console.log(`navigation failures : ${failed.length}`); - console.log(`a11y rules violated : ${byRule.size}\n`); + console.log(`a11y rules violated : ${byRule.size}`); + + // Reported, never counted, and never silent. A route that opens a menu is + // measured with the menu open, so the controls beneath it read as obscured. + // Printing each one is what keeps this from becoming a rule nobody sees. + const covered = report.findings.flatMap((f) => + (f.obscured ?? []).flatMap((v) => + v.nodes.map((n) => `${f.route}@${f.viewport}/${f.theme} ${n.target}`), + ), + ); + if (covered.length) { + console.log(`obscured by an open overlay : ${covered.length} (expected, not counted)`); + for (const line of covered.slice(0, 12)) console.log(` ${line}`); + if (covered.length > 12) console.log(` ... and ${covered.length - 12} more`); + } + console.log(''); console.log(`contrast failures : ${contrastFailures.length}`); if (contrastNotMeasured.length) { From 4fbda578bcb4a39fa15a142cd339968389f2ddbb Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 11:36:43 +0000 Subject: [PATCH 23/45] A gate that read nothing said what a gate that found nothing says The text and branding gates run twice in CI, once over the source and once over `frontend/dist`, and the release artifact workflow runs the pair over `dist` before it packages anything. Given a directory that exists and holds no files, both walked it, found no problems, and printed the same pass a clean output prints. That directory is empty for the whole of an Angular build, which deletes its output before rewriting it, and it stays empty after the silent build failure this repository has hit before. Either would have been read as a clean release artifact by the two gates whose job is to read the artifact. Both now refuse a run that read no files, and say that there was nothing to check rather than nothing to find. Passing a path that does not exist already failed; this is the case where the path is there and the content is not. --- scripts/universe/check-branding.mjs | 18 +++++++++++++++++- scripts/universe/check-text.mjs | 19 ++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/scripts/universe/check-branding.mjs b/scripts/universe/check-branding.mjs index af3ef0ea972..7d9628a88b8 100644 --- a/scripts/universe/check-branding.mjs +++ b/scripts/universe/check-branding.mjs @@ -167,6 +167,7 @@ function targets(argv) { } const problems = []; +let scanned = 0; for (const target of targets(explicitTargets)) { let stats; try { @@ -176,12 +177,27 @@ for (const target of targets(explicitTargets)) { process.exit(1); } if (stats.isDirectory()) { - for (const file of walk(target, explicitTargets.length === 0)) problems.push(...findings(file)); + for (const file of walk(target, explicitTargets.length === 0)) { + scanned += 1; + problems.push(...findings(file)); + } } else { + scanned += 1; problems.push(...findings(target)); } } +// The target existing is not the same as the target holding anything. An +// Angular build empties its output directory before rewriting it, so a run +// against `frontend/dist` at the wrong moment, or after a build that failed +// without saying so, reads zero files and reports the same pass as a clean +// output. This gate guards the release artifact too. +if (scanned === 0) { + console.error('Branding gate read no files. There was nothing to check, which is not the same as nothing to find.'); + console.error(`Targets: ${targets(explicitTargets).join(', ')}`); + process.exit(1); +} + if (problems.length > 0) { console.error(`Obsolete upstream product marks found in ${problems.length} place(s):`); for (const problem of problems.slice(0, 50)) { diff --git a/scripts/universe/check-text.mjs b/scripts/universe/check-text.mjs index 0e6736e9bba..ba66eadc620 100644 --- a/scripts/universe/check-text.mjs +++ b/scripts/universe/check-text.mjs @@ -124,15 +124,32 @@ function targets(argv) { } const problems = []; +let scanned = 0; for (const target of targets(process.argv.slice(2))) { const stats = statSync(target); if (stats.isDirectory()) { - for (const file of walk(target)) problems.push(...findings(file)); + for (const file of walk(target)) { + scanned += 1; + problems.push(...findings(file)); + } } else { + scanned += 1; problems.push(...findings(target)); } } +// A run that read nothing finds nothing, and used to say so in the same words +// as a clean one. `frontend/dist` exists as an empty directory for the whole +// of an Angular build, which empties its output before rewriting it, and the +// silent build failure this repository has already hit twice leaves it that +// way. This gate also guards the release artifact, so a pass it did not earn +// is the expensive kind. +if (scanned === 0) { + console.error('Text gate read no files. There was nothing to check, which is not the same as nothing to find.'); + console.error(`Targets: ${targets(process.argv.slice(2)).join(', ')}`); + process.exit(1); +} + if (problems.length > 0) { console.error(`Text gate found ${problems.length} banned item(s):`); for (const problem of problems.slice(0, 50)) { From 494481733867c5caa97047f03f4bb208f092444f Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 11:36:43 +0000 Subject: [PATCH 24/45] The brand mark is not a status light, and a filled badge needs ink Two small things carried over from the working tree on the build host. The wordmark faded to 45% opacity whenever the socket was not connected. The connection status sits three elements along in the same bar, says what has happened in words, and announces itself politely; the mark does not need to repeat it, and a product whose logo dims is a product that looks broken rather than one whose socket is reconnecting. The `ng-container` that existed only to read the connection state goes with it. The `.accelerate` badge is a filled chip with no foreground of its own, so it inherited the row's text colour over solid lavender, and over the brand colour while its pulse animation ran. It declares the paired ink for both fills. --- .../master-page/master-page.component.html | 23 ++++++++----------- .../transactions-list.component.scss | 7 +++++- 2 files changed, 16 insertions(+), 14 deletions(-) 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 b0491913b47..c7653fc7f00 100644 --- a/frontend/src/app/components/master-page/master-page.component.html +++ b/frontend/src/app/components/master-page/master-page.component.html @@ -6,20 +6,17 @@ - - @if (enterpriseInfo) { -
- -
- @if (enterpriseInfo.dualLogo) { -
- } + @if (enterpriseInfo) { +
+ +
+ @if (enterpriseInfo.dualLogo) { +
} - @if (!enterpriseInfo || enterpriseInfo.dualLogo) { - - } -
+ } + @if (!enterpriseInfo || enterpriseInfo.dualLogo) { + + }
diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.scss b/frontend/src/app/components/transactions-list/transactions-list.component.scss index d604a980a3a..4b933ced150 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.scss +++ b/frontend/src/app/components/transactions-list/transactions-list.component.scss @@ -404,6 +404,11 @@ h2 { .accelerate { background-color: var(--tertiary); + // The badge had no foreground of its own, so it inherited the surrounding + // row's text colour over a filled purple chip. This is the paired + // foreground for both fills it wears: the lavender at rest and the brand + // colour the pulse animation paints. + color: var(--u-brand-contrast); margin-left: 5px; &.blink { @@ -416,4 +421,4 @@ h2 { 0% { background-color: var(--u-brand); box-shadow: var(--u-brand-hover) 0px 0px 12px -2px; } 50% { background-color: var(--u-brand); box-shadow: var(--u-brand-hover) 0px 0px 18px -2px;} 100% { background-color: var(--u-brand); box-shadow: var(--u-brand-hover) 0px 0px 12px -2px; } -} \ No newline at end of file +} From 381a92f782c5c70decdb1714a54a8621044790f3 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 11:53:01 +0000 Subject: [PATCH 25/45] Read a Zcash block, which production printed field by field Live, right now, `/zcash/block/` renders as a two-column table of raw API fields: `schemaVersion`, `zcash-metaprotocols-api-v1`, `prev_hash`, `tx_count`, and a block time as `1788003830`. None of its four transactions is listed. That is the whole page. Zcash does not answer a block the way Dogecoin does. It puts the block's fields at the top level rather than under a `block` key, in snake_case, with the time in seconds and the transaction ids inside an envelope that counts from an offset instead of a page number. `classifyPayload` only recognised the Dogecoin shape, so a real Zcash block fell through to the generic reader, which is the fallback for a response this product has no reading for and is not supposed to be a page anyone reaches. It is read now, in the same words the rest of the product uses: height grouped, the time as a UTC timestamp with the seconds kept exactly on the title, the transaction list linked and paged from the offset. Nothing is invented from the Dogecoin shape: size, difficulty, the merkle root, the confirmation count and the next block are not in this payload and stay absent rather than being filled in. `formatUnixTimestamp` refuses milliseconds, because a time read in the wrong unit lands in the year 58000 and still renders. The generic fact reader drops every array without a word, so a block carrying Zerdinals inscriptions or ZRune events would have shown nothing about them and said nothing about showing nothing. Arrays this page has no reading for are named and counted instead. `checkpoint` and `coverage` are the source's statement about its own coverage of the chain, not facts about this block, and the status rail already states every one of them from the capability document. They are not repeated as a nested field table. Three fixture corrections, all read from the live capability document rather than chosen: - Zcash was marked as unable to answer a block lookup while production answers one, so the overview said the page was not offered and nothing ever asked for the page that exists. Both chains declare fee estimates too. - The Zcash chain tip in the fixture sat below the height of the Zcash block the same run renders, which is a page contradicting itself. - The block fixture is the response production actually returned, with the protocol arrays emptied and nothing else changed. The spec is written against the same response, because a fixture in the Dogecoin spelling is exactly how this page shipped as a field dump. --- .../multichain-explorer.component.html | 6 + .../multichain-explorer.component.ts | 21 ++- .../multichain-explorer/multichain-view.ts | 141 +++++++++++++++++- .../multichain-explorer/zcash-block.spec.ts | 140 +++++++++++++++++ scripts/universe/visual-qa/capture.mjs | 3 + scripts/universe/visual-qa/chain-fixtures.mjs | 63 +++++++- 6 files changed, 368 insertions(+), 6 deletions(-) create mode 100644 frontend/src/app/universe/multichain-explorer/zcash-block.spec.ts diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html index 014caaed596..20ca264cc2d 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html @@ -379,6 +379,12 @@

Transactions in + +

+ This block also carries {{ unlistedSummary(blk.unlisted) }}. This page has no reading for them yet, so they are counted here rather than left out silently. +

+ 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 cba880be9d6..fdde8422925 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts @@ -143,7 +143,16 @@ const PRESENTED_FIELDS: Partial> = { 'confirmationsAtomic', 'sizeBytesAtomic', 'virtualSizeBytesAtomic', 'firstSeenAt', 'completeness', ], - block: ['chain', 'network', 'block', 'pagination'], + block: [ + 'chain', 'network', 'block', 'pagination', + // The Zcash spellings, read by the block reading rather than dumped. + 'schemaVersion', 'hash', 'height', 'prev_hash', 'time', 'tx_count', + 'transactions', + // What the source says about its own coverage of the chain. The status + // rail already states every one of these from the capability document, + // and a second copy as a nested field table is not a second fact. + 'checkpoint', 'coverage', + ], address: [ 'chain', 'network', 'address', 'balanceAtomic', 'totalReceivedAtomic', 'totalSentAtomic', 'unconfirmedBalanceAtomic', 'transactionCountAtomic', @@ -384,6 +393,16 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { return index; } + /** "3 inscriptions and 2 ZRune events", in the reader's language. */ + unlistedSummary(entries: readonly { label: string; count: number }[]): string { + const parts = entries.map((entry) => `${entry.count} ${entry.label.toLowerCase()}`); + if (parts.length < 2) { + return parts.join(''); + } + const last = parts[parts.length - 1]; + return $localize`:@@universe.chain.list-join:${parts.slice(0, -1).join(', ')}:ITEMS: and ${last}:LAST:`; + } + trackById(_index: number, item: { id: string }): string { return item.id; } diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.ts index 0792b3be0f8..52409d5c3d6 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.ts @@ -250,6 +250,31 @@ export function formatTimestamp( return { display, exact: isoTimestamp }; } +/** + * A time stated as whole seconds since the epoch, which is how the Zcash + * source states a block time. Rendered in the same words and the same zone as + * every other time here, with the number it came from kept exactly. + * + * Seconds, never milliseconds: a value read in the wrong unit lands in 1970 or + * in the year 58000 and still renders, so the range is checked rather than + * assumed. + */ +export function formatUnixTimestamp( + seconds: string | null | undefined +): ExactNumber | null { + const digits = integerText(seconds); + if (digits === null) { + return null; + } + const value = Number(digits); + if (!Number.isSafeInteger(value) || value <= 0 || value > 4_102_444_800) { + return null; + } + const iso = new Date(value * 1000).toISOString(); + const formatted = formatTimestamp(iso); + return formatted ? { display: formatted.display, exact: digits } : null; +} + // --------------------------------------------------------------------------- // Field naming // --------------------------------------------------------------------------- @@ -891,6 +916,16 @@ export function classifyPayload(payload: ChainExplorerPayload | null): ChainShap if (isRecord(payload.block) && text(payload.block.hash)) { return 'block'; } + // Zcash answers a block with its fields at the top level rather than under a + // `block` key, and with `prev_hash` and `tx_count` rather than the Dogecoin + // spellings. Nothing recognised it, so a real Zcash block page fell through + // to the generic field table and printed `schemaVersion`, a snake_case field + // list and a unix timestamp to a visitor, with none of its four + // transactions. `txid` is checked first so a transaction, which also carries + // a hash of its own, is never read as the block it is in. + if (!text(payload.txid) && text(payload.hash) && text(payload.height)) { + return 'block'; + } if (isRecord(payload.outpoint) && text(payload.outpoint.txid)) { return 'outpoint'; } @@ -1156,6 +1191,14 @@ export interface BlockReading { readonly difficulty: string | null; readonly txids: readonly string[]; readonly paging: Paging | null; + /** + * Lists this payload carries that this page has no reading for yet, with + * how many entries each holds. The generic fact reader drops every array + * without a word, so a Zcash block carrying Zerdinals inscriptions or ZRune + * events showed nothing about them, which is the false zero this product + * exists to avoid. + */ + readonly unlisted: readonly { label: string; count: number }[]; } /** @@ -1199,9 +1242,12 @@ export function readPaging(value: unknown): Paging | null { } export function readBlock(payload: ChainExplorerPayload): BlockReading | null { - if (classifyPayload(payload) !== 'block' || !isRecord(payload.block)) { + if (classifyPayload(payload) !== 'block') { return null; } + if (!isRecord(payload.block)) { + return readFlatBlock(payload); + } const block = payload.block; const pagination = isRecord(payload.pagination) ? payload.pagination : {}; return { @@ -1217,6 +1263,99 @@ export function readBlock(payload: ChainExplorerPayload): BlockReading | null { difficulty: text(block.difficulty), txids: Array.isArray(payload.txids) ? payload.txids.filter((id): id is string => typeof id === 'string') : [], paging: readPaging(pagination), + unlisted: unlistedArrays(payload, ['txids']), + }; +} + +/** + * Arrays the payload carries that nothing on this page reads. Named and + * counted rather than dropped, because an array silently skipped and an array + * that was empty look identical to a reader, and on a protocol payload those + * are opposite answers. + */ +function unlistedArrays( + payload: ChainExplorerPayload, + read: readonly string[] +): readonly { label: string; count: number }[] { + const known = new Set(read); + const found: { label: string; count: number }[] = []; + for (const [key, value] of Object.entries(payload)) { + if (known.has(key) || !Array.isArray(value) || value.length === 0) { + continue; + } + found.push({ label: humanizeFieldName(key), count: value.length }); + } + return found; +} + +/** + * The Zcash shape: block fields at the top level, snake_case, with the + * transaction ids inside a `transactions` envelope that counts from an offset + * rather than a page number. + * + * Only the facts this payload actually carries are read. Size, difficulty, + * merkle root, confirmations and the next block are not in it, and they stay + * null rather than being invented or borrowed from elsewhere. + */ +function readFlatBlock(payload: ChainExplorerPayload): BlockReading | null { + const hash = text(payload.hash); + if (!hash) { + return null; + } + const list = isRecord(payload.transactions) ? payload.transactions : {}; + return { + hash, + height: formatExactInteger(text(payload.height)), + time: formatUnixTimestamp(text(payload.time)), + transactionCount: formatExactInteger(text(payload.tx_count)), + sizeBytes: null, + confirmations: null, + previousBlockHash: text(payload.prev_hash), + nextBlockHash: null, + merkleRoot: null, + difficulty: null, + txids: Array.isArray(list.items) + ? list.items.filter((id): id is string => typeof id === 'string') + : [], + paging: readOffsetPaging(list), + unlisted: unlistedArrays(payload, []), + }; +} + +/** + * Paging stated as an offset and a limit rather than as a page number. The + * arithmetic is done on safe integers only, and anything that does not divide + * into whole pages is reported as no paging rather than as a page number that + * would be wrong. + */ +export function readOffsetPaging(value: unknown): Paging | null { + if (!isRecord(value)) { + return null; + } + const total = Number(integerText(value.total)); + const offset = Number(integerText(value.offset)); + const limit = Number(integerText(value.limit)); + if ( + !Number.isSafeInteger(total) || + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(limit) || + total < 0 || + offset < 0 || + limit < 1 || + offset % limit !== 0 + ) { + return null; + } + const totalPages = Math.max(1, Math.ceil(total / limit)); + const page = offset / limit + 1; + if (page > totalPages || totalPages > MAX_PAGE) { + return null; + } + return { + page, + totalPages, + previousPage: page > 1 ? page - 1 : null, + nextPage: page < totalPages ? page + 1 : null, }; } diff --git a/frontend/src/app/universe/multichain-explorer/zcash-block.spec.ts b/frontend/src/app/universe/multichain-explorer/zcash-block.spec.ts new file mode 100644 index 00000000000..fdb3601ba74 --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/zcash-block.spec.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; +import { ChainExplorerPayload } from '@app/universe/universe.types'; +import { + classifyPayload, + readBlock, + readOffsetPaging, + formatUnixTimestamp, +} from '@app/universe/multichain-explorer/multichain-view'; + +/** + * The response `/api/v1/zcash/block/` actually returned from production + * on 2026-08-29, with the transaction list trimmed to two entries and nothing + * else changed. Written down rather than paraphrased, because every field + * spelling here is one this reader has to get right, and a fixture in the + * Dogecoin spelling is exactly how this page shipped as a field dump. + */ +const ZCASH_BLOCK = { + "schemaVersion": "zcash-metaprotocols-api-v1", + "network": "mainnet", + "checkpoint": { + "height": "3464717", + "hash": "00000000001a8027b33ea8f1972569b135eea78be68a381875307549ddf178e2" + }, + "coverage": { + "scannedHeight": "3464717", + "networkHeight": "3464717", + "blocksBehindNetwork": "0", + "nodeSynced": true, + "verificationProgress": 1, + "chainComplete": true + }, + "height": "3464717", + "hash": "00000000001a8027b33ea8f1972569b135eea78be68a381875307549ddf178e2", + "prev_hash": "00000000006c247a8c511e915a0370a4bdbae53d4cf4b10931b2df553d186277", + "time": "1788003830", + "tx_count": "4", + "transactions": { + "total": "4", + "offset": "0", + "limit": "50", + "items": [ + "e743ed36e0a10285fbfd4de57844f0f78bc929ad58b1d391fa294a540f9232bd", + "9ff76d00ba46f460a3d2cc536c5e9e82fdabf1ad0eea45fb32a554bbab1d4d59" + ], + "has_more": false + }, + "inscription_count": "0", + "inscriptions": [], + "envelopes": [], + "events": [], + "zrune_events": [], + "chain": "zcash" + } as unknown as ChainExplorerPayload; + +describe('a Zcash block, in the shape Zcash actually sends', () => { + it('is a block, not a record to be printed field by field', () => { + // It was 'record' before, so the page rendered `schemaVersion`, + // `prev_hash` and a unix timestamp in a generic two-column table. + expect(classifyPayload(ZCASH_BLOCK)).toBe('block'); + }); + + it('reads the facts the payload carries, in the words the rest of the product uses', () => { + const block = readBlock(ZCASH_BLOCK); + expect(block?.hash).toBe('00000000001a8027b33ea8f1972569b135eea78be68a381875307549ddf178e2'); + expect(block?.height).toEqual({ display: '3,464,717', exact: '3464717' }); + expect(block?.transactionCount).toEqual({ display: '4', exact: '4' }); + expect(block?.previousBlockHash).toBe('00000000006c247a8c511e915a0370a4bdbae53d4cf4b10931b2df553d186277'); + expect(block?.txids).toHaveLength(2); + }); + + it('reads a block time given in seconds, and keeps the seconds', () => { + const block = readBlock(ZCASH_BLOCK); + expect(block?.time?.display).toBe('29 Aug 2026, 11:43 UTC'); + expect(block?.time?.exact).toBe('1788003830'); + }); + + it('states nothing it was not told', () => { + // Size, difficulty, the merkle root, the confirmation count and the next + // block are not in this payload. None of them is invented. + const block = readBlock(ZCASH_BLOCK); + expect(block?.sizeBytes).toBeNull(); + expect(block?.difficulty).toBeNull(); + expect(block?.merkleRoot).toBeNull(); + expect(block?.confirmations).toBeNull(); + expect(block?.nextBlockHash).toBeNull(); + }); + + it('does not drop a protocol list without saying so', () => { + const withInscriptions = { + ...ZCASH_BLOCK, + inscriptions: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + zrune_events: [{ id: 'd' }], + } as unknown as ChainExplorerPayload; + expect(readBlock(withInscriptions)?.unlisted).toEqual([ + { label: 'Inscriptions', count: 3 }, + { label: 'Zrune events', count: 1 }, + ]); + // Empty arrays are a proven none and say nothing. + expect(readBlock(ZCASH_BLOCK)?.unlisted).toEqual([]); + }); + + it('does not read a transaction as the block it names', () => { + const transaction = { + schemaVersion: 'universe-transaction-v1', + txid: 'a'.repeat(64), + hash: 'b'.repeat(64), + height: '3464717', + } as unknown as ChainExplorerPayload; + expect(classifyPayload(transaction)).toBe('transaction'); + }); +}); + +describe('readOffsetPaging', () => { + it('turns an offset and a limit into a page number', () => { + expect(readOffsetPaging({ total: '120', offset: '50', limit: '50' })).toEqual({ + page: 2, totalPages: 3, previousPage: 1, nextPage: 3, + }); + }); + + it('reports no paging rather than a page number it had to round', () => { + expect(readOffsetPaging({ total: '120', offset: '30', limit: '50' })).toBeNull(); + expect(readOffsetPaging({ total: '120', offset: '0', limit: '0' })).toBeNull(); + expect(readOffsetPaging(null)).toBeNull(); + }); + + it('gives an empty list one page rather than none', () => { + expect(readOffsetPaging({ total: '0', offset: '0', limit: '50' })?.totalPages).toBe(1); + }); +}); + +describe('formatUnixTimestamp', () => { + it('refuses a value that is not a time in seconds', () => { + // Milliseconds pass every "is it a number" check and land in the year + // 58000, which renders perfectly well and is wrong. + expect(formatUnixTimestamp('1788003830000')).toBeNull(); + expect(formatUnixTimestamp('0')).toBeNull(); + expect(formatUnixTimestamp('not a time')).toBeNull(); + expect(formatUnixTimestamp(null)).toBeNull(); + }); +}); diff --git a/scripts/universe/visual-qa/capture.mjs b/scripts/universe/visual-qa/capture.mjs index dcaaf6b6479..ad39d820fef 100644 --- a/scripts/universe/visual-qa/capture.mjs +++ b/scripts/universe/visual-qa/capture.mjs @@ -82,6 +82,9 @@ const ROUTES = [ { id: 'zcash', path: '/zcash', name: 'Zcash overview' }, { id: 'zcash-mempool', path: '/zcash/mempool', name: 'Zcash pending' }, { id: 'zcash-tx', path: `/zcash/tx/${chainSampleIds.ZEC_TXID}`, name: 'Zcash transaction' }, + // Zcash's block response is not Dogecoin's, and nothing here had ever asked + // for one. It reached production as a generic field table. + { id: 'zcash-block', path: `/zcash/block/${chainSampleIds.ZEC_BLOCK}`, name: 'Zcash block' }, { id: 'zcash-protocols', path: '/zcash/protocols', name: 'Zcash protocols' }, // Universe-authored routes that had no coverage either. The saved page is diff --git a/scripts/universe/visual-qa/chain-fixtures.mjs b/scripts/universe/visual-qa/chain-fixtures.mjs index 29f515e458a..3769d3c2c7e 100644 --- a/scripts/universe/visual-qa/chain-fixtures.mjs +++ b/scripts/universe/visual-qa/chain-fixtures.mjs @@ -17,6 +17,7 @@ const DOGE_TXID = '4c8f0b2e6a1d5937c0f4b8e2a6d0c4f8b2e6a0d4c8f2b6e0a4d8c2f6b0e4a const DOGE_BLOCK = 'e1b5f9d3a7c2064e8b1d5f9a3c7e0246b8d2f6a0c4e8b2d6f0a4c8e2b6d0f4a8'; const DOGE_ADDRESS = 'DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L'; const ZEC_TXID = '7a3e1c5f9b2d6048a2c6e0f4b8d2a6c0e4f8b2d6a0c4e8f2b6d0a4c8e2f6b0d4'; +const ZEC_BLOCK = '00000000001a8027b33ea8f1972569b135eea78be68a381875307549ddf178e2'; /** * A chain capability envelope. Dogecoin is at its tip and complete; Zcash is @@ -35,7 +36,11 @@ function capability(chain, overrides = {}) { asset, ready: true, tip: { - heightAtomic: chain === 'dogecoin' ? '5623041' : '2884120', + // Twelve above the Zcash block fixture, which is a real block at + // 3,464,717. A chain tip below the height of a block the same run + // renders is a page that contradicts itself, and it read that way for + // as long as the two numbers were chosen separately. + heightAtomic: chain === 'dogecoin' ? '5623041' : '3464729', blockHash: chain === 'dogecoin' ? DOGE_BLOCK : 'f'.repeat(64), observedAt, }, @@ -48,12 +53,16 @@ function capability(chain, overrides = {}) { sequenceAtomic: '148201', observedAt, }, + // What each chain declared on 2026-08-29, read from the live capability + // document rather than assumed. Zcash was marked here as unable to answer + // a block lookup while production answered one, so the overview said the + // page was not offered and nothing ever rendered the page that was. reads: { transaction: true, - block: chain === 'dogecoin', + block: true, address: true, outpoint: true, - feeEstimates: false, + feeEstimates: true, projectedBlocks: false, }, protocols: chain === 'dogecoin' @@ -254,6 +263,52 @@ export const chainFixtures = { ], }, + // The Zcash block response as production returned it on 2026-08-29, with the + // protocol arrays emptied and nothing else changed. Zcash puts the block's + // fields at the top level, in snake_case, with a unix time and an + // offset-counted transaction list, and none of that is the Dogecoin shape + // above. Written from a real response, because the page shipped as a field + // dump on exactly this difference. + [`/api/v1/zcash/block/${ZEC_BLOCK}`]: { + "schemaVersion": "zcash-metaprotocols-api-v1", + "network": "mainnet", + "checkpoint": { + "height": "3464717", + "hash": "00000000001a8027b33ea8f1972569b135eea78be68a381875307549ddf178e2" + }, + "coverage": { + "scannedHeight": "3464717", + "networkHeight": "3464717", + "blocksBehindNetwork": "0", + "nodeSynced": true, + "verificationProgress": 1, + "chainComplete": true + }, + "height": "3464717", + "hash": "00000000001a8027b33ea8f1972569b135eea78be68a381875307549ddf178e2", + "prev_hash": "00000000006c247a8c511e915a0370a4bdbae53d4cf4b10931b2df553d186277", + "time": "1788003830", + "tx_count": "4", + "transactions": { + "total": "4", + "offset": "0", + "limit": "50", + "items": [ + "e743ed36e0a10285fbfd4de57844f0f78bc929ad58b1d391fa294a540f9232bd", + "9ff76d00ba46f460a3d2cc536c5e9e82fdabf1ad0eea45fb32a554bbab1d4d59", + "10ac3514e6e21b25d35cd5941e887b1936036c5c8a6a556796309765ef3cf30f", + "87bee021247fb56e3436447fd7f827266f71f4257ca6050f473ad03fdc09de1d" + ], + "has_more": false + }, + "inscription_count": "0", + "inscriptions": [], + "envelopes": [], + "events": [], + "zrune_events": [], + "chain": "zcash" + }, + // The pending lists carry whole transaction envelopes, one per entry, which // is what the live API returns. They were summary rows here until the real // payloads were read, and the difference matters: a list of envelopes is @@ -475,4 +530,4 @@ export const chainStateScope = { 'chain-object-missing': ['dogecoin'], }; -export const chainSampleIds = { DOGE_TXID, DOGE_BLOCK, DOGE_ADDRESS, ZEC_TXID }; +export const chainSampleIds = { DOGE_TXID, DOGE_BLOCK, DOGE_ADDRESS, ZEC_TXID, ZEC_BLOCK }; From ae8e7f1d86aa0936006a3f5e1c0bee70f5a0fc8f Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 11:54:38 +0000 Subject: [PATCH 26/45] Stop a superseded commit from holding the runner its replacement waits for `cancel-in-progress: false` applies to every ref, so pushing three times to a branch under review queues three full matrix runs and only the last one is worth waiting for. The heavy job holds one of the six ultra runners for up to an hour and a half while the commit that replaced it sits in the queue behind it, which is what happened on this branch this morning. Cancelled only for pull requests. Pushes to develop and main keep running to completion, because promotion reads per-commit check results and the release artifact is built from a main commit, so each of those has to be validated on its own rather than inheriting a later one's run. --- .github/workflows/universe-ci.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml index a088a9cc629..bee1b38821f 100644 --- a/.github/workflows/universe-ci.yml +++ b/.github/workflows/universe-ci.yml @@ -14,7 +14,16 @@ permissions: concurrency: group: universe-ci-${{ github.ref }} - cancel-in-progress: false + # A superseded commit on a pull request proves nothing about the commit that + # replaced it, and while its run continues it holds one of the six ultra + # runners the replacement is queued for. Pushing three times to a branch + # under review used to mean three full matrix runs in a queue, and only the + # last one was worth waiting for. + # + # Pushes to develop and main are never cancelled. Every commit on those is + # validated on its own, because promotion reads per-commit check results and + # the release artifact is built from main. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: docs: From 0f6b2eb1f7de59db4a7acde240db40eb2c5c4e28 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 12:01:13 +0000 Subject: [PATCH 27/45] Name the security channel, which the documentation never did Both the README and CONTRIBUTING.md said to report a vulnerability "privately to the Bitcoin Universe security contact" and never said who that is or how to reach them. With the issue tracker off, a reader following that instruction had nowhere to go, and the only thing the page actually told them was where not to report. The channel exists and is already switched on: GitHub private vulnerability reporting is enabled on this repository, and `/security/advisories/new` answers. Both documents now link it, and SECURITY.md says what to include, what is in scope, where the trust boundaries are written down, and that an inherited upstream finding is worth sending upstream as well so the fix reaches everyone running it. No response time is promised, because none is committed to. Also records, in the two documents that describe the rail, that its chain reading is the chain's own verdict rather than its node's, and that a verdict there is now published with the evidence behind it. --- CONTRIBUTING.md | 7 +++-- README.md | 11 +++++--- SECURITY.md | 45 ++++++++++++++++++++++++++++++++ docs/product/DESIGN-SYSTEM.md | 25 ++++++++++++++++++ docs/product/EXPERIENCE-BRIEF.md | 7 +++++ 5 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 SECURITY.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb3c0a149c3..27c99b202a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,8 +73,11 @@ matters rather than in a glossary. ## Reporting a vulnerability -Privately, to the Bitcoin Universe security contact. Not in a pull request, and -not in a commit message. `docs/security/THREAT-MODEL.md` records the trust +Through GitHub's private vulnerability reporting for this repository: +[open a report](https://github.com/bitcoinuniverseio/mempool/security/advisories/new). +Not in a pull request, and not in a commit message, because both are public as +soon as they are written. [SECURITY.md](SECURITY.md) has what to include and +what is in scope, and `docs/security/THREAT-MODEL.md` records the trust boundaries this deployment assumes. --- diff --git a/README.md b/README.md index 2dacf3529a5..f1db1541230 100644 --- a/README.md +++ b/README.md @@ -296,10 +296,13 @@ upstream security fixes stay easy to take. ## Security -Report a suspected vulnerability privately to the Bitcoin Universe security -contact. Do not put it in a pull request. -`docs/security/THREAT-MODEL.md` records the trust boundaries this deployment -assumes. +Report a suspected vulnerability through GitHub's private vulnerability +reporting for this repository: +[open a report](https://github.com/bitcoinuniverseio/mempool/security/advisories/new). +It stays private to the maintainers. Do not put one in a pull request, which is +public the moment it is written. [SECURITY.md](SECURITY.md) has what to include +and what is in scope, and `docs/security/THREAT-MODEL.md` records the trust +boundaries this deployment assumes. ## Contributing diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000000..d5ddfe33bfb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,45 @@ +# Reporting a vulnerability + +Use GitHub's private vulnerability reporting for this repository: + +**https://github.com/bitcoinuniverseio/mempool/security/advisories/new** + +It is enabled here, it is private to the maintainers until an advisory is +published, and it is the only reporting channel this project has. This +repository's issue tracker is turned off, so there is no public place to file +one by accident. + +Please do not put a suspected vulnerability in a pull request, a commit +message, or a pull request comment. All three are public the moment they are +written. + +## What to include + +Enough to reproduce it, and nothing more than you are comfortable sending: + +- what you did, against which URL or endpoint, and what happened; +- what you expected instead, and why the difference matters; +- the commit or the deployed build if you know it. The build the public site is + serving is in `GIT_COMMIT_HASH` at + [/resources/config.js](https://explorer.bitcoinuniverse.io/resources/config.js). + +No proof of exploitation is needed. A clear description of the flaw is enough, +and we would rather you stop at the point where you are confident than go +further to demonstrate impact. + +## What is in scope + +This repository is the explorer: the Angular frontend, the backend it is forked +from, the gateway in `scripts/universe/`, and the deployment at +[explorer.bitcoinuniverse.io](https://explorer.bitcoinuniverse.io). + +`docs/security/THREAT-MODEL.md` records the trust boundaries this deployment +assumes, and is the right thing to read first if you are unsure whether +something is a finding or a stated boundary. The explorer reads from +first-party nodes and indexes only, and a report about one of those services +belongs here too, because this is where the boundary is documented. + +Findings in the upstream project that this fork inherits unmodified are better +sent to [mempool/mempool](https://github.com/mempool/mempool) as well, so the +fix reaches everyone running it. `UPSTREAM.md` records which subsystems this +fork changes and which it takes as they are. diff --git a/docs/product/DESIGN-SYSTEM.md b/docs/product/DESIGN-SYSTEM.md index ec1d7aebb90..c3461516acf 100644 --- a/docs/product/DESIGN-SYSTEM.md +++ b/docs/product/DESIGN-SYSTEM.md @@ -320,6 +320,31 @@ Three rules hold it. chain declined is `unavailable`; a capability nobody could ask about is `neutral`. Collapsing them would report an unreachable authority as one that answered. +- **The Chain reading is the chain's own verdict, not its node's.** A chain + publishes `ready` for everything the explorer needs and `sync.state` for the + node's view of its own blocks, and they disagree: a chain whose node is + caught up while a protocol indexer is silent publishes `sync.state: ready` + and `ready: false`. The reading takes the stricter of the two, so the rail + can never say Ready about a chain that has just said it is not. + +### Saying why + +The rail states a verdict in one word. Directly beneath it, and only when the +chain says it is not ready, the page states the evidence for that verdict: the +codes from `degradedReasons`, read into sentences by +`multichain-explorer/chain-reasons.ts`. + +- The table there is an **allowlist**, not a pattern. A pattern would match a + code it has never seen and assert a meaning for it. +- A code carried by a source that is **ready** is a stated edge of its + coverage, not a fault, and appears under its own heading rather than in the + colour that means something is broken. `tap_doge` publishes two while ready + and complete. +- A code with **no sentence in this build** keeps its own words and is marked + as one this build has no description for. +- A chain that withholds readiness and **states no reason** is reported as + having stated none. An empty space where the explanation belongs reads as a + chain that is fine. ## Numbers diff --git a/docs/product/EXPERIENCE-BRIEF.md b/docs/product/EXPERIENCE-BRIEF.md index 616ba9b4a1c..7d12de2d1f3 100644 --- a/docs/product/EXPERIENCE-BRIEF.md +++ b/docs/product/EXPERIENCE-BRIEF.md @@ -54,6 +54,13 @@ when the reading was taken, and how complete the pending set is. A reading whose fact is missing says so in its own place rather than disappearing, because a rail with a hole in it reads as a page still loading. +A verdict there never travels alone. When a chain says it is not ready, the +page says which part is missing, in English rather than in the codes the API +uses, and separates a source that is failing from one that is working and +stating the edge of what it covers. A one-word verdict with the evidence +withheld is the shape of every other explorer, and it is the thing this one +exists not to be. + ## The emotional outcomes to design for 1. **Oriented in seconds.** Live data above the fold, one dominant search field, From 8e19d7f7d5605571631d899cdb3afa761914431d Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 12:04:01 +0000 Subject: [PATCH 28/45] Retake the overview screenshot the fixture corrections made stale Correcting the Dogecoin and Zcash fixtures to what the live capability document declares changed two things this image shows: fee estimates are offered, and TAP on Doge states its condition as a sentence rather than as a wire code. The alt text says what the image now says, including the chain switcher reading, which is the part of the header this branch changed. --- README.md | 16 +++++++++------- .../product/screenshots/dogecoin-overview.png | Bin 28474 -> 29117 bytes .../screenshots/transaction-asset-flow.png | Bin 49147 -> 49468 bytes 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f1db1541230..6fc4dd7e9ae 100644 --- a/README.md +++ b/README.md @@ -84,13 +84,15 @@ Object identifiers do not carry between chains, so switching chains from an object page lands on that chain's overview and says why, rather than looking up a Bitcoin transaction id on Dogecoin. -![The Dogecoin overview page. A status rail reads Ready, block 5,623,041, zero -blocks behind, observed 39 seconds ago, complete pending coverage. Below it a -panel titled Questions this chain can answer marks transaction, block, address -and outpoint lookup as offered and fee estimates and projected blocks as not -offered. A protocol indexers panel lists Doginals and DRC-20 as ready with -complete history, and Doge TAP as degraded, 184 blocks behind, because its -authority is behind the chain +![The Dogecoin overview page. The chain switcher in the header reads DOGE +Dogecoin, Ready. A status rail reads Ready, block 5,623,041, zero blocks behind, +observed 25 seconds ago, complete pending coverage. Below it a panel titled +Questions this chain can answer marks transaction, block, address and outpoint +lookup and fee estimates as offered, and projected blocks as not offered. A +protocol indexers panel lists Doginals and DRC-20 as ready with complete +history, and TAP on Doge as degraded with partial history, 184 blocks behind, +followed by the sentence: this protocol's indexer answered, but its last +checkpoint is behind the chain tip.](docs/product/screenshots/dogecoin-overview.png) Both screenshots are taken by the review harness in diff --git a/docs/product/screenshots/dogecoin-overview.png b/docs/product/screenshots/dogecoin-overview.png index 5d35a764deb561522fb5b6928615aa38994e2f64..df589401b8f7eb59eecd0705d41ec3a1fa8faab6 100644 GIT binary patch literal 29117 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 diff --git a/docs/product/screenshots/transaction-asset-flow.png b/docs/product/screenshots/transaction-asset-flow.png index 277d1a4712a7383a40464368640c4794323a2b75..f609ce7f9636710291d6acee29ec0b6c1a61aaf3 100644 GIT binary patch literal 49468 zcmcG#2T)U8_da?61qB3Aiim)M^eRQ9qcjBpLFrw(ROy5gPyy-EJ3+d1sZxU!k=_KP zh8`k>8VC@Q+~E6rzx(~~+SO5aL$Bt_F8+bXRZCLwND~mXsTW#zfBGRz_n*j zm2?2$GU1Zs!xdt}>8%gND*(6-JX3o7$~S9!;c5p`DR2*;4zKAex}r~?N-RiM|GKc< zh)%hz!Ntq3f~_tPIoCW{$U9r+RKw@|#i%~A9p89dVSr?7h@>dF8^y;R$^VW#vWEQ% z!_MWMzzs;^vyJB>ebxzyYgt(V@-k~1&pAaUMI{GzpQi{U=@4|V`0(nxiQyA(2X%8u z0ZLFn>!6Yh;AX!XOaye?`Z6e=w;7*Y*&6!m6@7b3Pgl+6g9pOEFI9jN08}`IQM$=r zzm}GCGUTg`K6db+te6N3Jf_xa!v2*QMX^WV1vt3SQiG`|n+kPV!*^n`I)5+aLAml9>(y)ogJz zr>c4~PH=w8_YomPxirKU03b}&WN9pMCjPmK37vB73R(88ZQ1B1i=Pq{8xJWqx z(qQ0H2Vt(4`t!_vmh=rzDQ?vAoboC;0C(K$JUHi*l_R7IH=QhBTL4!SfM}ikS&4!* zV-kSB%EaF24wgQ&J(Rtxz3X$=w{Tg8SX8rRwGiosf;~{u5DL_|K#zv!T$@%auc4!P zIivHi6^%3Qy%?89Y+wVOsIlp~4+mgy-{EI-4V!wEy$#!<$tF|alleBD#MfB%gSgVl zck4~b+UH;S8fV#Z9yDs}^s$LEHeBy#+|Vt0u6#lPB;O)Z`7tz&1$CXZG&D`?8N0L< z-b(l1X|`JpYiXQu*)sKpHrlUcKwzFnaaAqr8*}<=kpRG-qg&rRF)S_Pwrh&^9Tn75 z$czeF{c=Mh=+Blu1FS&cBk-suP1;Pv1nbh@YO8-PQYA1fxXQtI6GuC)5Ba-=C*S6jF zOAvQ?_=FNqvdPRF>>1Lf-7?iq&K;uF8gzT>%*q0t_6AU(`SC^A3nSp8WCeXapGW@< znIf&EG4|V)7K=MhWNDZXRDuH4-|`wav!`}W=Csa-j9r?M1hbf2$NrdLl0Q8%)`Z$e zVRrc&7C>I&xA|X`jmeiKg7Z#?2!Fkr)!;{+C$5wIkOJ3Oe3GDrmwVCR#xuz>2g$?` zn0<4pHLCF~hS_EI@Uukr+qZR$P1m9%bl0@B>2>wt#6ZE7;0IpQVg4$n(#y~chE!6X3-?&E;X9eq6N?A@jW)m`wu1jN(>g5<0{bJcyW*&ajoCjaTAroJuU zBPEoW_7HcoY~u0aq;7VJQi9-wdtQV|HszHqBhLJ`Mq~|U5`1X5^=aFLavpVN3<$ei z>UgRI={31(=r~H%I3Iw~bo99n1c!Xvon>}CokS)|a-GF&?4FO+ub3ITK2&V}I+O_Y z+?HVZZ7SXcEdv=mijT%tYW4Sm#oQfeEyBceTgKR_LA*YSPAilCRwl=x|foo#E3dvV#@fD{S95G)4cUW2A<2 zve!AR@r-{pFDy^=nUIl!;%<7A##i3J3-#1leZ}lMhD}^J!>*p)@fZmpun0)ax z9A}x;onhkXq1Ko!u;3|bqSb##8Z2_DqvU(6|50n^iX0_lm;k@sac*igBd}z}s_!)N z(U<#oKgVEonOVyG=jlsd&R&`GWPP8W9As?ad@{9?7?i&Wy3wxkwu<&lD^339YxoYO zlOUO?r=10$AW3XdC&WN05&OhTQYvU_!X(-`)%>A?b4o_urH}`}T}4Co4z;-7nwo&7 zD##`PQPw9LS!C5|dyTF*>ioCsojT_H$Amug`TD}upTg|rIsaWLk ze!QFDQbb?d6GN`EA4GO{NR8h&rhi=+VkUbO=vz7LS<2^5=qY zlb;tRt7hETc2^1r72tcA(s#v8#+rZXxh6@@+B2#Q&DuZe-dBCDKk}#b?7Q*+P;it6 zV%dyoggY=(__RwMW3czrk;h8foBZ*1%gvna;-M|tb?N3)-=;TRE+v081Ul|mhk=n4 z{92WJRn)Ji210+Dgh}@0e`xn`b;mDSzY(0v;NxnCJ7ui&1hz2FIFvdPv zn)I~L|0tF=MF3PfpFBMF-G&%pjjDcyCaR29M&2_)mmrMXdu6&(q^~qZW$riIWF!Uc zU?~6t@{Npu=YfA| z&a9!IsJ8OmtcjB!5^gxS#U1@z~u>#rzR)exxkK>iqFMMNC0}ty6nM_U=hFv+m zR(0lxS;oj8NaQhTD=*Ns-);7d%@MKt;-ZjA9vvlm?8r(}F)n>;-F-$sB=+?J?0gFr zw=s2dy8OLwQ+_l5Nwbn`diT$Z`e? zxBzv_VWJehWQ6vpHmU^{gppmos>SVZ1Uui5(2_m+HQl!3wUY;D*&DY9%^Koui#Nk< zf3V-z@_h(w&gd``JNv%2kXE@Ctv^un_E_ll=$I>Q`lkGY>rJ_V?b|W;e!O@7?EQ-J zeW3l-^1RBvbUh5!!wV{ZztWN zd4+MlbGtpM$@H1Yf*SY1yxF{k#hI(9p|_ycXZ>xE@dY2)%@`~w5B9VU^@FJmU9f#? zOenbL*Tj|T**XxCtlbmd1AUSCP>Tf&w@XCmLTRO)oDZC^xu#RN{lwj)(S?5UvG-qX z^sR<%Zkp9*eV@a7HB?(~r^TFH12+0iX8b^VwQb!j2yqFFZeN;{$inF}>IhuZeio!a z0#RR?#$h9N<@~2tV$-DcwvUa-8lTO9-H(vs{PsTk>KX#a8dgbRW4`QCGG!qI@I!Kl zmnNlm&rU7z4X48`B3~Hr{He~FveO`93(KC4jn7<}C4Dc;nf>A9tdo)*n;DGI8j{sc zBCWvSGDa;b!v&A>a^v>Q@4wddOq)WJGuLLqOg1`W_SdTB(?mj62XjRD7(!|JN^cjq zkMXyt`9_86BP++9MEh%W8w5TT*L*Kf)8S-qb9l!swL5Kc8I;PUMZB~%(%zXhFMZV~ z-`=3IJdzt|7g1}4B_68@X47FM4C(2rMVf-ABI~9z7Ly%fvT`6!u#M@3r5%J~QK-kS zy6lyWKIPfaUQ-IvNFr2aG2lB)*0}t8PB*|S#BCC)0C6p~CLuPnex&*65o|HL# znh(1ycw;?qO=UL3(z@20J0LbBQ`;aP3uQf-`(W7Hn82OMJdmpNP-Ugm?aw#%~G>(mb` zN)Zo6%8q*=6ZyG`6iWELl8Kz4jZ7A=ZjVaLpY>_Tk(D@OPfV|S^88US*{=WZwd1GL zIe~>nlM<9EplyBD*>X?A5mr+}m~G-X!{C4}@1c0MOK9 z;0{)qXiuw#fu*(L?6<9jle?-@%C)nI1P>eI2mYsC>cZc_%dK}C5bcj;$S8j6G8}6f zpTqC#yO6SXYzDit+Kpg_7DyaGFHlEzra@z45PXG?9Guav9YLlFUT&_!o}AT!o41Il z7mmJA&!9irMfjfh+yDkpq)**B(o+$EaVt?)UrtsG)QSh_xBz-l762*#h!If9d#{*l zj(J63Dr@*-a0xK_bQ*SKCj@PIUy>TlJP^9JZNAL7l*d#b8_T((9G>JadOz@9>019_ zpR9im%4kS0a+F8@(Vs(Y$~&^YC}{|`ysCX!>tP$*&>W}Ej8*aW?_V6jjhlZ=7JpJn z!*|9z>c~l*f7%DTom|A1EvTQM+}XUA-0$LSj(Su9-`g;GNTXuMc}zMAbr~iSyb{dH zHoFI<4yr#DyeB$GaQt3@LCTxh_RPg<*f zzd_*1RWHaZLGaJzmU|jVNoX^Frrm(Y1Gn{?D6ywYY+C7Fm@;ATd)wZRE1duK0;qW& zy3xflC^%;Ajk6q?#^6z=2`Eq9l1Xdr1mEQ`^G8_3E*|&5JX$5V$NCqVYpi3JAqbz3 zuR~f+=Xk8UV0MqTNAT%tq`q9>vbSsH#eIrxPYYfiatvJ?W8#T^=#g5@JCKi`muWif zbP7F1W&xwcIhDiP4u_)9bP9%K3utw|mwnF}~_cFDQX+QYDfIlMB0rZGpZ6wuA%5DKKVaZz~ID{A6q%an9 zutu-?TI2(h`Lf?S#mH%~9T*4RZO7+~NFu@6$LDcd>{+Y#%mW|QHHo@q2wIE0FnL`j z&}iZh#RMK^!U8&P=DbMZGHUil8W90M6G~HC`WCz(oLg8UEL;jMNf_f(5@yFv25L533LMg>RU_sW_>dZ;k}24KmcGSV>`W zo%4V!bRAYzmI{H-=Cu5dtuB$SUps71+nbljO|f(qxW2M26tHUu-kVF@GSIrnbj^W8z*rsEPvAn&4vF_567K*2%Stjge6;7 zI0QKcLCc^A-4O6yxjRNh;J$Nl2{Z5?2T0rg52kXi`$P9{C_{xn%TdSQ@GQiD1>@qC zYDRPM$F|cC>Wi+bG`Q4J0v(;Ty+IY<7k#CeuW2gISkwCSFpg=! zwfu2Bae=)rIkzgU$GJ}P)AXMAekfKSKz`kFslpuUN1RN3-#2e{-k&Tk&YU?Ecu!<; z1wQSn*b$Ie++8{8?NT)nMx#UR9p~9d|+_Aza5Qc0(>lvYbda z4E%NNd0pm@=(3r^ceVNI95!Z z6+O|L%@JX;dwc+3;3n1j3h}DoeaSi;%Lyknwt}9$u(PAppJne#{P}RL)qLy48%Hbh zC!T-;36`0-tv3_WHVtELty!3|Vca?SKGHagl*?1w>9D$46-M3G+|_xUT-Mp^HK1p> z^VniJtD7nI(=DL7#CnHqpuY)Lm7ONLAQ9_Ood50G4mxwAFU6!T9tciHdD8Inw;M5h zzoE?Dma}D%PGKW(vR)-7`0g@bCERpns&+`Zx+t|%$H)2X$D-@``UZ3PfVl?yGIyUz zsag+v_7P;LyF+d+LSLb`<8E3dm=FPyP)yb2)h`ufqrCI!9?KF6C z*HatUYHsMcxLdd8!0q_N1mRQ*zX#-AArjuW>jS?J02@3WC$)99MFdOoOzCDs4P%B( zx|)7qgOKMNZ^k#+47~067V2s!tZA;3C=d}_3=(}70xYzZ#)<-^3_!GB{pswt!rC@k z&C_LfeKkEK{$Lr;{>OLH5x1gS6VaB<33?0vj1l zneKA1SE50iJ98#MbJ?#4Y|)tPoE;`_sJY`SNx5r}%=YCP&6%84Zl4v+3Di!^FOQ`2 zSr7r_K>d z<)AYMB^67@21#J3ZH$Xw9F@|<_c_86rQIjlMp&WrS5BXu^`l-b`l@h5=_Nc~+8+1p zNw&TzbatSzcW3{lJ4_K&@ zD2MVzt~AUyOA(jL(1YBppiC0;ZA0G1yET|LW%lXw^P)0vz=D7Ttes7=fq>z$QlkY# zJE6H*g+ac>V>B!0g(Z6>+DWQqHSAo@M_lnMZ4l^T?{r@>ZgFz*&!0AynmK}nv+D7O zU|MD924_cSLqV^u#td~b4Vg0JjJ;;4=((UgIgkovzY~XpQyPpe$!!)D<^O?I|9FgZ z$5hur<_qWJFEu~nM6IArol$QXX5Lz0Hf_)f_US*CCrS*GV)tSZHOzo8#osPH=SHrL zWT4Eox5etJtDI!Iol;LPk3El5@#=C$51CqV_9!+>9*W_cIiy`|;B!$Rm;6Gw z!E1-|Ov?SD)jFN$UDM|@W47Ckxdrzr={uVbJX(WrItDXz%0?~OuVcX}!vV`ZmDa~% zOEs?}B8YPD{C!@BqZr_N-!cB7++m(i*H*!o!)KDOY;Y~okQaQr8tfRb+QBj1Ekcc~ z4MKJmtCtc3v}^z=0I)An5p#7x-9b84?yr~T|MZnq={~VtYUJJFzg0VAMr%YH_Fc2t zR4?d+0a#MJ*c3@I9{XKRcMP(!NWL_9Ha>bxS6z25`>FJy55aRY^n45cJ(X_R5V?q)-}D>IlQ4WGLt^fdpfQZ;}IMxdOM54g~GN! z-@HZaF+n%EzXE3_V?)&G(^IBJ0X~)LvmM4n#At|J_+}59elxBcX6? zoz57!Z~O8<_v1Jd5kqg~O#JfmTiY=)$ofH&Cl)}-lM5^T`0PT-Pp*a59sUBnJZb&R z{2dc%RDv`qMV>j3M)wcid_GLYgm}!%KQ;J9Q^~8w&nAlhc%E-E&!@D^kx$Xj&1gw_ zk=+T(+MifiEQfv1JGZyfHEUN)Dd_KX5`m`hN2OmL69RtyBe6h)lzt{krbXZPVnfQG zu56HKe6g!IB8VuL^DjI_N-ZkcC~i*g>DX5YAeMov(u#12Qar_5j$(BF$w z98sEpfd<*ZTMyzs+ENAeuwM1tXd-JBd-LXj13Yz;(ZbC4rosqHuo&HVeK%-V+7Du) z8Oq=+h%qWCeZAn8^GUbATKCq;<>0UlMnFI4vg9TmxLaZ=wiTc}nRlN^WQF~6sXULO4QVxoaEygFe}($)5DC$> zI_tfa59A#y7#O)p8r3jza_wH*mk;fL@Oy!78QLKHsJUDIGKK%6r*^BEstlfG8L=NT zOdHR+9xYZq6|<(rDs)u-PO-O}z?fOuTk8BF)fvFV;^tSm!Kta~yN-%O0z0?O&M|E*;j=!CLyx(Xh z%{#&H^DzVJdto&CEM4{!cb+ntF%;~#X{G3U)1H6U0pj1GKEzb+O)k%0l*i0}*8LXm ztA9t_keiNEDZ^1u1JO37>7DuXje# z?d3YvLdF;E?S8(@T%D-uSujl5ZViG4@aKhvCZx-zdS#{CG$j7 zS_ehdk*V-g*fxo)I(hrpWP zEZdDhq_DZ%pig~gyu@;V4bPxSy16wpfMMg8vCTOAgrjC#<*qjd%=QR428%XGch&`S zpB8P7STW4aNvO+)(O-L#bMO_xuwoedvx4&ed@*Ehh}TD$raDzm@Xt>kBRgsbFmr%C775Y`8)L?y@QxK6*W#P6}A?w{*3%?v-bRBmc7 zYorYYXEKw72Z%PX$JEf~*HH8y41R5*Y~%aG{=@6l{L-0a@=&sWk;|{;z88){M?yv- z$sWgkm_d`UJ;+C@e!<;@H`2?cx)YbB#^IK8nOl6U@Kk2mMvUVzP`1Qo*8H4Q3| zhM!qrvn*luK^j$uyHC-iKHC^Irs-}m+%X(mC~#l4;M{1dp@$XwX5*lBSps*JQjMuD zA#>5rBmLv_c8L5(p~V#S@&TAhsMe?`ZlfrC*8$u3=esglB;4pAHxFX^roI918`5EC zno=RGls=qvHMCVqh9O(?{ z=u28B--<8QN{m&)gT(-GpIX6qoQj1?k5Hr?Irx!%&m-swi%HPcuX|-H0`3!DsIXO< zgB!;t#HC*i7fCR#g*#$Fc=MtQn~tHFflOo2Y($gjD0x22h+!JJ0zZUHrT#i79?`L7 zAbj_JTrr!XCtO8BezVT2O*~clsf$tH{3q{}RY<*Hj`I7qx{>T^Mp=<TRU+YArc;{9&hp<`LJRms8nRR919hb!hTJYM3?5@s+ayl+r2@%~+vveSmg zwBf!r6@Fv6GLK@U5TX}!l{>S|Wr;T?Ss5yzLR&03UF)C^8KR5HoKcKDKKZ?!YCRz{ z=`N|B*P#`%~W$i9q5M{){LZ62DJJZWRurg<|PM(hqdWomIP1g<{#K;UP8+IJ* z-fq5DiJz~xjA2jT?5yR>klFE?eev`Bm#z;S75+|L14s{% z5VDUyrgP=}HeF@V8Vd~6 zvKLiW6;VX(yTn3laKH6xuH9G;easeVPDB^Y34tglo{|v=4dFmpDE~bR6JHP~0^icU zd%+0-Ae#7sp%CyxdqF=4N4~-ZsUeW$%NI-n06MrYI6vV~3Ay0jgf~>kjZOUe#ECDi zgKfAZ%3<6WmwV`Q63QJHYm`mN3g236*l*X!9wkIF#4NwJrB^Y3}hckhw)f3Pca6Q-j8O7)w<}^77X80*&hW)x3k1C$k90GHwr|HnkKA@*E&&}n7i2$rmLTE{>}o^{8xc_5 z{piwdK={ptJTuT})OJ!T^Eu&rAccnDdkasNqs_sEI5Y3PC=JSh!4~TE#itiFR+A-G zg)q6FCIMrGO$z#~=g$e;tm6jzzqkok_`ks7|GQ_L?hz*0VOoua%uu6^@mk4I?uea8 z?}koKiLdT7!~##lXKludffSMpjCQ3^IOK(MrtS0h_lSlrCl)t1M_qS$EcJ^j0vQQd zj9yGK_{+h#NX<&_I^A8k6bT;-C-b)8Mc=uU+v zH^9%KLNNWU2JW!0aj)xF?~Jg?Q`zRQOVI+&T~`QV-`n*n@6goRMD@D~AI=LHg}W0A z2Nzz{ARX0B3@RejvQgWUk|N%p`HjMFV2KSl31$e^p0jPK$VdE;-npTe(qg7Pv*73+ zMUJ3sa{8bkYy637-2eav!Y|_K`DHVitBPaa6ba;=pW~u+*0HoM7)|@_>GgTR(#wb! z$JYpV(pUgMmIPSKVN{pR$p+|k=OLN%g9wmI6AtC;%Shq*b;>=Xq} z^6}u;g_#}e3!S#fODT8;Q)Nf(NTF7DvC~^%P`&~*=Xj-9<~X?bBh~0Z2CIEC+fwH!48ez0dq47Dk`FQVbuy%-n3CdqC?Kx>Ah&3cAa6gG4pz2s9vwj-T%#@QR8mHs?+H4`N}(F&Tv}|gLJjTHdc?BL{;RUuKkF>9 zq+<{NGxE_P1pmb4%cZlUvP@UWEtF<^F2Q<_!v)iKq&y!icur`b=XV7|f)?J+W%uGw zBmPxazWspf&tm4~Gp`A83Gi~#gz|;I42q~Y0GIC^sV44_`a6P?jb&A5J$`mU>KERk zkn-#!Of(9y1v@LNcc*4AoOA!qxvMTW31w)aZrcvi1JMptW%L)vD=idgT$k z)*RmnCA5ehhYu_JFoTX8hi8rm8^~u~%S{Y{m;r`=z;cIK4Vcex4a68bo`x;7xI%os z$ALWy|H}RsBJz;4uWc;KKYulX{qbNk-(l*%5Z$a)gq|M>?KY#am(IL~!4RLB%knpv za~0vQuSz8W-!x#*6EbP!AkE7_%q%0^6zt43tyQcDe@A61vf#D(`s}v@t8q)OATbsM zyT`h{NnZFrcsN;}_F1&z`X4;B9T@$^L)aq063(-3y#F8o&u)%#OqhlXmcN7^IfN`S;|?kX$u$V`S7iqEo=I1pO(xGFJIEeaJ#;m~cJ^vP zR~w|2<_O-q1q#0bG;U{@o7EaFvX54o!$5O}=2H6q?L3FWKO32i2bnbDdY|hmj~SaZKkH8Y3$$A<1XX=j{9zcD z)Hwt!POxdE1$Nf{zc`Fx_`H*ZwKkj(ih(~)@ke7L5f^YFsG5Vx_Si*VAKuxSqlWtR zNkD)1F&|tQ!2V*?^-LIU3hNE``rSy;?zg<2ic|VulwHMp^)JTFsOOqPPB-oHOP>9! zE1x;O-^E1nn-4q?XUqV`==|fc-vo~_H!pSWF8Uw?FP|ONPmHu3r%p%xpV;+$fSmwi zVVK>aYD<`$=Wo@j_7#SIBh3!HyIoJc)H~W9xW8(5zS0Y&{fjQQ3(S6Q*=@%BaSUo- zIG($T+A0^yuEiZ63q4m2AEqGW9H~E}p+^y-T84)4#8|0=B`b-A+JDp~B!|kDo#vIW z0iWLv{sfTW%-cQZ1RA3n#y%g4loP^uXUc2Z%x8Mz8ZkDq-57G(Q~THQ|C5rdgFqu6 zsm{&rEG*oL_$S%?F*gb9rALC_hQpAJ<@j@X@IRs~y|{_o;6bST0iRYO#pi)7I~hz; z%}Db9O1~pP`?UhB-DEDXO8n`yUH>ile^C{LUt!%oxhr?&?05iTc6jQtlTXN}J;72* zjk|IIyPdncHB7~~kCBlBLGCz-b}lJ}|0Kj7)VfFeK?kfIssI&~!C>$>oYr5XUm(B- zH$5%)p@js69N#tZUUYz6|7%q43!^3y62Y8f)0)$6I~vJ)zPZBpzbr;_xb!%Aln)Er zXw?XQAaxk)#D&f+QiSKVP+|W}L!0N`H-)*u+D{92O;9mW|G-s=04y|Injyf>PEc~L zrIwH~2!T-XUwG|WXxq=()IzP`&*7@*dAI~2CF7YfBl1m~#s7gq4tN90(jE6XP#S9v zJvbbwqW`P){zZ(*XG%D?AN=9-X+={JoP9_EpZ9jeq$(0JQ79qTFnig5n;a?|gKQ4S zgZHiGoLXWOps|?4n}m%M&A&h;fJFoC)Qnj80B+!h#A!SLKrW>b@BdO!2Cx+f+1z@R+#1^XV4Tw3wXm)Ss6;YM_Vrzn1^DWm?s!+Bd2+bXU%)l=gfO2*<$ueh?hRRmOtyuLdA}Qg9gN>P5h89)U#eT8UtOA6R8%~wbPv~+A*gDLc|0I2g0z&beq$3 z5@v$ktpBWV)dXP6uxg_)TU&PWgX4#x;%hCQ22`%5i6{oTR6tg6vAZwzyj#hfo^x_1*E>oSfEb$H+YMQoaS zV)W+)bS>hQ?VEK!ZCuwVi zNO{?B)>to&TK3Rs!33;xP3uJ2al@-``~hBKJNcx1{o~V5U5a^kK9X{fm2-VKtmKg1 z8SSBMe8e=2nKG#3uC&yPnToXNEmq0xX)P!ctJt5m+SX#WOTPsggc72 zOr11EJBi2bCYFy>#xETwIuDCb1dmisrDf_0Su8T-DtT!fG6`g7y5!tzSr`65e=kj#>d zI3QD%jb-7sAjW4$QzaO~uc|@DlF0n*GT8MwLu82O=DBLHGQzPS)FasL_cM1m5a)T_ z&YfeffU(dkG}JnW5ERNjNgf%cyeAf%Z3~`2Nt8!0nW}B^#w#gpF#)rt?a#ILM(T#9 z?qPAPj47%h{Ui=YLp-MqKd!d`B@oXMLu2-|t=`&gID=yj4n);d#0}L_=pO8FXp}#1{CwW)g!%9cfuC%d z5nAb=`*{!XonyqEs(Nub_3)0xjd!KKj@v4?loS2iU#Rp_o`K240Z; zb(fZd247@ap`ZgN(UyLZn1Qxl725>(_NA~Wwz=UNUOQuPZ~fRoxlfjkHX_uY73XBg z3_d=DRP.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 LI%?H-ZJz!wi>3v$ literal 49147 zcmb@tcT`i|*EMdxHtchk#P22XRf*CnS1S>w@-D{sID?y1pt6bLtR-P z07waMNnTwhCj9Y)P#6NhbwES;k)eO)*4dSXZRL=?^P%8f(hApVD~Z(VVp_|mBNH$5 zYG1H*nAaKe{YE;wx$(WIGpguqD;+j2r^VL7&katwQO+*o#v@mGFTbRS;jZq!$KC7q z==#oz^};+DFP)Yooz!C|(xc&}qcbnmw!Hd;Gs%wA5o1?f7MrI|h=cO!G6SDxw~pHy zGU>zomky123;?Kb5D5bSLmu~Gf(J}wlpTDRmNv3dWxvV?#z{sNwsAR(2xxD=^tw&Y z*_9=k|2uuY=1?hz3Kn^Xk}Qnr@3-%k^Fg6&8H(D?Q#m>!k`A4<_icqA2m^*n03`sB z<%?nC4)olYqu)i!UK7L`-KWqm6m=B5nUcNp?3oQv90o8DVvA73)+r&llfK3Jvzs*C zg%{bavf08#7Hvg~L@6&KG5YDQVN00#@~Ma(v2+F|B)J)MDt^=jiypg&2>&dNM75>h*QqmmZeiMUP;?k!+AzkF&mWmP4Y zAfnmkx01q_bmNSn&`FMm$=Biy_v8Tcx(NGs5WuZMEiAIrv_#K5Az!5 zAjI`nU)9UG&v&!F30Yxfjs0*o2tDPPIO=N0c$W|Xf+z#q#qglXt`PXM--}F*=krH- zd5yc0@z5L$j%&M-Vnhh&uRLK7>>8g@JPd#b@6MqS$4NmfBVZBB({sTl^lYiIf`jAN zBjdh4Psc~KEax?HST0sXs~py(P;I?2{Uc6*8Pm0%kpx$#t&(fjcQ`E0T&c~}>jKMJ z#@pcy4C4 z&J&1ADhU73K?RHJ7co$De3T&&$IdQny8y~Pb^yw!2zQSA_Ok)6c@kGWGYQG3-5;5k2D zVO6JFGcNEV`F@tLEC4|L)G2L%qmk6Hon9kgIs5`Om_A|pMa|BrwkzvdVF+BKYo~e# zZ)H69{rOTGVnqW{DQ+^2ZVAW)b#?aTn+ACwc>R!=0lzU}Z+Vu);P1t}5zHkFsU+)*@$6&PmQcRWt~us+`4to~&OgBxb|(%_tHT4J3RJD_n! zfN2=%cRvRhLXWumDeq<7$AF?Wdh~sd1rr2ktfKP@olrkq#ROF)4J?`Ff?cH3f3_zj z_h(<;o|OZ_C=wn$<`(_fx)p>NGIm=O372ypZ|rTv_ZQMv zjN&Y8RWyFd>_Dw_zE7;|=SPb`8Z<{E17GPSvZhhQKHpoB8qS(!;S)4bE#Y_e+g6<- z=eaNBJ6&wU!`XCi=Moj5ayd*doDp5JAFPSDSC=fS$y|@H{_X&TB?MT1mA;o)VZ)v} zJXi5z`s4~sM%^eCtNDI2*lN)7Ybm?k{iZd^_zJZ@E-YxxPxx2(vAgw0b~YihPLoOD zL_o*oun{>vhKk#fC0>TOve(A3ZYiK&kIIWS@-Brx0Pe7h1ctGXKbLI=q_)?Sd&((q{71Vmc%*#gI?m55Ags?S;} z-Mz6D=J3j$OJsn5-E{y_Yn&N0;Iy`1eWn)r5TI=|zw6TW!;Im5)!_%{QiF5`20*x} z(L}j3vQ48s?bSC`q}yk(Xy|&3y2!S98p+VCdq}T)Yaj5(xqFJUYO*K% zy8m(I&GI!1RagEl`F4%euci;0Dg8e<29Nrr8_iW~Ez5@2I6k6BT_*uPd?s0ts-`8n z-MWiGaRc$^$5sOAx70q3DQ6D`_y(6!45^I-2J0x|#?ekM>>F1`7EM&VwaxP>)!4HaWE6suLLS3wz5jTY1dRvBI zO-8#VG$YKuv}AJ0w=W^d65PpPa_j4~Nsd?5mSV!eEr;<#*@qyP-DbYIo2RX7 zRq@Ix?;&|opuNh8$O9j5oVvDybd)r~6MWQw6OG`jpi+m|`VMINUsEP*5 zWaG>)B2OU`52cpdYey=en^ZE6%E2LEFGbJNpO4d*w}rJp@YF81lM5bTvS`|q>7}g&dt;$loT?He$L}4YA~i~C-f(n zqam3tgWV{d;ap{1IQo}|cVe5ghORMBucGeypD4Dsr%?G3Fn@NlUpYGMg8)00R5(Xw zgD74?0D*^qpnDrRsu>aq)*$TC$X-g$+wJ<%fcik3U?~E%nGm?RqY_y>_&cxVfyLCT zNimZ>zt9{3eg;tob|^_$+9<>?I}_)s^B^ej=m#|4GiS@c@$fL``gzT9UdCt?iV4!6 zD6(@2+8dlo7mT5$_X ziqabh3B{AAoJSjM7E{LCb+{~f-xA%Kr@U=YK84OZ@|R2-YrW;UHoV=&&}UMTRb&B`s!cv${uTSY1fp# znlwGhJFNL-5trv$=u7cC&sCT)&OlWj=KRvb3SGKO4C|{*AYPCaYJCKnmAwjCaxxVZ zh>-%?q;+PGFhL36EZTF)bUOMfvF7lJL@{K#T(Wayre%(+!m1?v`?f*RlbBhvF$YbB z1X^d=p%ywH8j>|jHKM)GIh8f}Ajf);oN0R3VeNb#jZ2B5ceiEueIQ`)qxOTba=Aet zzJwanEQSPs!K(tiHBMQFQ#cnVL70>H<9a=RzNfFo6X3j=E0-Mj+^*HMk6tdXeOyLM zk;xG~!_DSQ{`&pV#u!crM@d&zFd+~ zB4)&0N;dj%t{c-hnUjNe1Zt{3P68x{PtKm8qqvRnqo~nh5su4OOlZy~Oq;os#%(x5 zf3;6_G3b9T%6!)$&TxiJWf0MLwar^cY8+&}J$|QGh3{Sn(Vo<+IaK$-s+t5?6EDHF zv>~LOhWo-gWt9!zC46|FCqn{k&N@)-jB3CJ1a%qgHtwnIpLiu!2YA;87}T;*OBVgm zUKiFtd{~~U4VY?rHYWT-XO7PzLewu#i;q`0Cp5i~q+Kk1hiD#hdx)M?SCjRT@6l%gHwNNgf<@t9%@-nf>`z<2UguW^s471lH6IanHuQ#H5h{ zQ>_^9KswY3*@&VPK9fG@yrPKpFj}_F?<|XcmLV?CGpTJ>eaCOFjhsb1P;PXaex603 zl$=a!99^p87OyJ*?OA|xaH!K97t5Dy+aRk#7wuAKo-MH!KN3g12f}CVfXu{WfaSb{ zXis~JnR*@sbr%&Dn_$dP$%5uBm`|xb0YS%a`t0qp(EpU!HhF`zBk~>@oN$glPhTD# z&n0INcymx$GkIn{^e{b>CIm@33G)3;pW`>nXYGY=X$$?Hx^se!gIJ&B&s~Eqx(fw= zC+cc_!Z8QW2TVUxKTOnG@=Q}eb2mvFe;}AJ{dF%MdLV{JH|#hklM)~q`OY&84*BFi zg+E3-(xm}jIG;A)Q7a9Qn@2AnJa2lJIp=LnBsi_NzU6e!x|X|Fky2ys&5K~i)qT;R zj%Dis)##@@2t|C1CnXEqAL}U;IyE`Jf106ytFC0J$V2?z5&Df>0pXv2t$o~0`%Ghw zG_YgMUQPjPaiehky=JP`lE(MO6{8PUGZn>9^P%+tV~HE8_MFFL!!W-JBEL)Ry4SV+ z4{bwB&V8b#$J3@XDfy=i)b;Rr{Zc63Q0{la5ji;f_CZu0?sMLzp-bhA+=r*W`DM7f zMZ2#J>yX%uJlAaThGz?-R47CB1rFUbqnW-mxN2S0ZeD`Jzr6q&W+@6JMec*9z0s{B zD;@kVgT!ARX3Bj_8cRRd^F39O+=UL$ z3!N{rsV-fcA3=EClrH^qQkc#$`U(F&g?hxrQ#E5kpe*{J7#_^(GMhG#hUmWnSZ%Bc z4pU(j9nZj<_ADM7pG0fC6MOn|@0p6id0Tz7acM;5YlBZPvd`bT`sguTH%GpIeVPCK zc?WgPm)h5Z$*9zY9#FGLUPZeXZYr8{z5aMsj=$>hcdnIG>*o4D;%52NG4&7Z#G@r1 z`J<_Fc|(RA-(Ne_1tt#%OL_#h({o0sw|@{-^pfj{3f`2OL9_*-K~`S9y2px{GR z^6jH&Z~UY*{D{Z*wfE>ssZP{{E}70)BnyOw{K&&as-7IKoewTls9vu}LIs}2uCVgY4*l$R7)FZQc#;ES-(NQVgo_C z;!7KZdat7wv(MH;^_AZHZIpQ0qV_jv-fy3XBgYHhHu>yMpQ5M1346G{z7Z3J!z}t% z93wI8Al?k|#DA6VsRn$4?y~2f)V9;=^ZovjvvSqYzIAmPc~rL~Is}=O(J&~FRaaGI zCrVW#+{g;`0&m(6dzc^G@Biy_s{7o+`0ymqY}E;@(81!Y)^6YUVY8 zG5G$8o69b4OF9SCaxC~fqJZ`1;u>1K5;~BbFl9jKmYi)8O`Mg=bi2x~^7G2UIq&5* zG)?xeO)~YU)f+eR0l?a=n%&8@E$!Dy^9y1hZlf0kR|0*;_PwE-Wsb0O()@r_k@b9> z=Mz58knc%fI*w=E&<$rJId7InzV(2r-FxPWxJsw?*&~Q@sb#K)NW z%?qI>wD~%W@3BLb*ZNx-q5~l@`Uf=@bKUKf1FecRpxY%r=g+>aJ4wbdrq)c7I_?tR zzJJ$K>?$Zp%WtejGGRASoVC~IqeCN3i9X>JIz|!FWiSrQC75o(!b3sri2=DoCO|kc zo_N7Xclvp%q{8k_Xwz?RDenx&t-8M5vvemz^*b*j%VOptXYTO5tka1j19I66{TT}h zdCa2&p=H}7ey^Px70Dn2JqxEq<)U4cDo(|93MGs?Z{(2xFV=Qw*5uC@SAx#RZM9}x zjXv0(p+l=zc4Xg(OuX%qv8LJ$9Dq<{Kq{{8P70*kf0Ye)xx+yQ^vgTX*-7`x?@m7H zx8BM4S({`%%QSSVeQ2lUSIz<0@ZO%gL~$sz$7#B%CBE21ozkXf3~~=G^tiHt?X630sGug*?j6gLOa*Mw|nzn8=!c_OF362<8O_!&y&7d_CrX8{x^ z01O`|MiVXLQ_4Y4fiUTYRu*k{K;URQm%XVY+9|*+R@bTm*9w|zvG@}JUs{udfoiY4 zRx7>xYqTpAlkq6%yX`%Dp!+V+`SHUii1KNa==nYy9)Etz2-lNywx82_B#_7TVwTaJ z&<{JsOq2V~+RG9FYY{=a&7oac^|Z$%?>glx2)>lHv;uzq=&K40d8Q+XrV5&`94XDT zB+Kk?{6W&tRr>yQ+j?N%*09W(v^P0F5cVSx(6^9(5u?T$SRiJ>(%VT?etDvDO|l}q zkyTkGA?W)~gJq~JTpGv~a&P)`+_hb|>BK)-$dn+rV9Q{62L)=bEvsxr`LjjNDB--=l0=)2cq&!P!IO&9tW4tDL$v5K1WX9<2b3H@@oOZ&zLYHs{c1a>~T^H7`7iDv>RZf;pI*W^ymNWWY!1G&bRDfK4z&x+!?HvGUSMUvwUWEIbV`hJnN=s zYu&%r^lPptu(lOf%(aDYgutF1?c$>Ou;bK3bWIE&2@U1m8^Dj__7G|Xk6mn(gxtL- zpPZhPxT^6IfqH~(_ANAyru5Y0IzSReebF{IRlZBGoLJg4;onywF?pcdz-ErV z-YT2Y@w}l!R5=%!-|L_GH~z?;oBh265>5sHG2N{V-gca;?q~dCPmMd?Y4sR{tw=fJ zyfjE*UI&&7+yS_`f`>2~vqpubjxV8cmnSGA%0)AMXZcVeRX(|VqNL^&Tv$?op4bKe zs)`FvGwss)uJ?#7YIhmUmOi(k-4|3#O1oDBHq+60bzBz1Sf+VcZ?wuYFjbSqHN-s_ zujQh|LO}v7-vCH}Fxz+fb``i@U*gh6PabI=^DbDQ9+R$Wey*4ZHbFE5Zv3aBFveA} zD&W`)uk4i8`6sE^l;(Kv+IszMK$z+x1Yr(rfxUEq{1s2G$<5D8T2tQH?Ci5~qfi9AZ9H@^5eZ`S(EIxHC-5}(@AbtmwZgix+V!K# zoM-v+pG55?`cCrY_wV1XxpyjJb3T274X4<7TL?-wIZH3nrRTp2EGYlolemMpBDaH2 zPKpW4x1@9(P}n&V*X;JerF3M)E6i+Ze~J{P94ySRpPGXM0NY=ne_MvssQ%uJ3(xm# zFLS4In(MzaQ zEciC!w;5tXTK3t%vs6xAOA>xB*f(R3O$MHL=)mYD0t-(F+ycT>Or*&KKNdJPKowNt zzm~n^^A#-;qkq--X{561xhwGSTG2zof8ABr1*J4`k1NodOJ!5?m-W3K3plzmn)JkW zGp~!29)*pt{Syx^6=VWl$FW`{7ntDb-GSQJqR>oBX+K?~VVCtz?$|)K(zh80UwxbZ z{X0_&e&-Tdn8>Ae0I*>w>1_WV&;fg!9n-m}d@;P!vG&-`l>+6Bn*>%sGUSm6JRj;|ZlN?Y}ZQKUVY>N|bx=FFl3d zKQH5;^h3VYr29jmEuYoQd;boKRv8XF9oQmSD6MG4mgxqY$Nl_KB)`O?8`O#4@3sxy zYauR|US62&G7?Zd!pVpe_g`frUi&z`@(WM57{MkTKh)E)-0JK-GL%=$KS71OVpd3* z5g88xik(x4Jl1vJDV;AsFr>4IXxQeZn$<5a`d^(Z-MJ<$_0XWlEawDGn3gks;!8 z88XF$I~3|nr6Qz+lGsKREANUz%N&VZYc`zqbvh`WjP3nJ&0^hg9cJ_L^}=J zcJ2#N`?_CdBd+U^-M&i>w+?ORb@a4(K)Y+RFR6o1(Cv7Xr1H%p$kxf7$4%%u@VaCZ zIN{Hy*3R0-o$*zEJy;HU)x3VLmpWvW_3PW{4Bey~?$+M0YhBK`CD^h1^MJ=8U(ap@ zg>ywZdy~u=kNozf>;Ua_$zV-)X^B8k>6phNY8|l}5`YXWRwBVDUb}A7KVm!Mb6)x3 zw#pO`W@bfF#sTl&0x|GX_MQKZI}KGhxq;aC--LXW@xEF3YgaBL>F%5&Tf!^L-I9{I z5;w>=R(RCr)sPvzrP)Hfm3btzGzf5RVm{f*hVBRP3H9@Ocew8nIu6i#y%(Em*K)cy;0cu}XjM&SYG zk=y`dQ(?F=^ss!c%!m9IQIPX`K}zOIb6rcFq|9c$eq@HxpU}-T9%FDT+^eiauv}xv zQA>;1g@5^JDs5jfH05OWI>z`TNa@~|+uHdcVH18VV}NJ4gjoPFP&yc@ z&<9o6!y9CWy};u=;$5;S9bP#^PGC@@j}H!`uSv6gFO!|^XOLP>{mSS}Q|MHv{q%$5 z>+!-bR(e?vD)JVY(A0e8f-YKzdQn_Tg|J_W&fh+`*U^@~sqX#q!Hi~i`*5LTy}}jJ zW~-z?lI7dh9+b-sWxiH$697JvTOlrKsZeZG6gW`Qv_xe5+Y9(iEWFuMyP?CeVJ|7U zjKXaRht@_GM124`&woD22dN;kC2`*r!yh@vnfDe!%&cS<$AcEoNf`}hZ;ddeeNExn z_=e!XlkAC*a@JKDz1fl;C95ba-tqeL;PCcx!QNF$J%{Sh!3fP6+2!HErl{*X#K7=Z zv-wMXcBD4xJA@U5hQ?*bD%-4^Q)OnpdMRGnn6eyY`)CVgw~jfN-6{K{!S8)?8J^*x z{qkpwr;=VJcFw*7+xW3;|Ijg6N0v478(x8oewe8C*=*VA4e~*jk_Kw(@|4WtO8{U# z?hBOdtiHH!4wJ{9J}0dfJg>P)i_|C>leM!3(=N5L$0IrBIXD>?f0&d<`MyOlgzwP8 zc^N%frJQ~Whn}z+2UC3AYgrud9*|zG`Q9jz{d$Cn$we#wzIQvgSv4F4Eor8^qJMA8n)2K6oU zP%}2lo;o{FiGhvk=0&JzP2b2CTLwPGHM*#Ikf$-pJGUKH>!VNp%k$N!t7F10)0}B& zi(~0PZlOLbd1ASYx9QQJ-4@N=r<*%Zn=g%2bCiW-Ia2=`;*l!rZA0RiZg#X7D1;FC z(E`9~8sW@`PsJLeoQ|A@Zy<&(KcsTp>?m!`s-g2NXJ(=j%5mGu-%llrD>O$!YF)|7 z2%;0p-DvX*X}hjd2!Trn*AVQ&w1f98{!RBGR%LkHo6?c8?d*vSxy)yQrus=^Ip?vB z#N+N?j{6%k=E_Ew%iifqdQrDN8gb+{l-Ko%|#=Dz_uxyaDwk>O-?jCSYc0;HKOD-q(Z~1K{u#HIWbh{MaINuAR?Q ziiC~eibPH?j3NMpUE{xU@fRTbd)$QJY%X6o7Qz>g?f?W=PUxu+UziR8jjmsuA0fOV z9$pw0!r7Qh7d`?2+V5OA4#JDfc4c64mh<8*VCv)bqdATsFocm*x_E~FzIpUNA2R%( zF#o?q_5afl!pbHfUpHsMQ6^JizIcTLC&KxkcPyuE2It#bL!IopAy}x)emNMg#YZ^e z#Q!mwnBXek=7YwkN}nxAvK*TeuzMYDkV^!R98@}nbjpoO$A?QFG6G?L2t8#$n7>jH zB~F;ZiUqB(;(RK<;;c(Y0A2V3bdo$Bf8(=uu+)~gCnY}taT|}TVi$=1Kjfr>hzy{d z`?@P@6vrVM*|tNlZX!df#DLA+3;#{Fy-`{rp7ZLb!F$4e*klq@0SvW8a&GWRHY}6q z3RM(L^&(*xivfnrA_e(FRQ^Tmw@GqNzOe^L5R^Jb)7`W|kH8aQLa^nuYb8lJ1W!pA z>tEU|&^TR$_P<#1Kga(S0XzUTyE23SggI#UJvWr;(Ht6ba`66)yrso;cQ{k5*o@Qd zs?CE7;thk1)o;OneT#KWq#cs_!8Np4qVu>>+WDy{hXnm40M>ayK2$binyDUPGrs8h zeYQiIBQ`A8r;-4t;XP8KhY7Er-k;HR39p)! zIb<%SEqC?wiz-JDKMYzUV4REYZ{!Z`bR8+Um)S4peK?V82~gVD{u2>~nSDJqTVr)A zlTp@WwfqqQA^w|$YH80cpLQtqZ}dD<${Q*>rKOdJb*3*Dh?q`Y4wsSOqUt4_qM`e{ zg8q29HQ**T&oAQU8L_GQ>?H~yY`(@lmPShB`s`pS;qH#9fS9O@r3h9#i+oc~YeMvj zw+oc%*VU@20DvE8vYk^U5cH`)r+7O6+_`kI$|1bS{^!Z~->bZkK)iO=`I9$<0e@VG z0YmM|Kp!DxlqCP}(@1_J26E#m;HWszX&myZk9&-O1?K2+#Gs}Y)m1>}uWHl-YZATJ{wizL2^5L9z z8Lq(}pU?RonuojJ#%)AoW4=4Sp&QN>{pwHEd$$4G<~;?09<_s-P6eU1kc}C7tNLxf znXS6rC6zI!;k4XG^iP7QWOCX9k4*5BeE5Zw(op{4Pr6xnp`fk0Q~XdswG$H|3;~I? z1~`^^2p`u1ecb))3xxt?q+Zq?{j+G7FI_ca@aLj9ca(I|K?Ixi+1l-T`n8G4p_b*H zmYo{{xsM!Ql359Fi?0P^kwVbL5!spy-)&Sw$O)mbbL-;kJQx`a6_vWvxEW78CxhOq zob}zb$|Dt9d%|xTaTCzBKWjd6*==#@6KJ4W8~&`u+O*fICE@O`E*3|Yb%n|2TkT}7 z_hw>_CI4{K^K0^tY9q+9-lj02@Z>viw>jMg^Sb1RQLNClKSJkyCK6?cJAWg7Db7u_ zSL+TtD)Q=6&$U3~acvYsE-U0${{!_o2;!`}g5>H6@+bC`O>QMYfy4Y^#GrBzRp63< zq)Ng(|8RK=7>?|73{j~KBh0Gu;wkY0nWgu3UBZ0iSYfl@WbHJMM^?0Z<(5WGD`|4%N3sWgGvSiZKPj=`2)j^T2b z%4Ym&DoEhsvP)FWHUt_tT-4c#?vw4B^Rp>f_K_CxT=UvtYVdjzGWvoKBi0etsH_;j zImfz(4Q-Hpqg3e8)xR8~%YSqwjQKE23trRbE=wTbs8&VeI@Us&;y(z2$JFHfKK=6Q ziPdRC!dX``eF*694IeY1N)gx%_n=+Q;c{^BpG7unPuO4PQVi!_YS+f%#SsWwuEDOg z=}4qi@Z@&+tYX%GXYFwP7BcwRQFhB^pUwPT{7KgK?57U|7zv|hC^o*j^|BegQUmuo z6@dK3VFXQfA8;RiO)fdxc6@APQF(K1_~77R67IDhAu#(f{=did-mF33V;>^pq0cQO zBR>-;R!C+Ahn@+b=UR{IEhv1P4p#@xLnZ zmA#8!=-p<6XFY6ydi^Lz;^CG5WD;>PYzL&W)r($GeQow=a{gp)x7KThk{}5G#wwI` zUf3o7TsPc31lih$KV*a7`Ilq+_Vl~WLM9N%_68VqxGfZ__BWH+65K={7HuKx5aZoJ z0ml}CHzayYw>E*q?;m>p`nMji<6x*8WasW|lNSDTX3{b+ z>n{mHTnY6+XSEO8-Kp&(RCn`UYs_xrw$A?z*HG-x`QgWqHHoe1G_|v(a!H)wzud~> z6PjLm;KS^<-RGEt9?Nk5M=&54^^h0E20fXW0k3rW%(X1^AxzN^4<$e6KXQyD$c=#b z+N{het|ld=;9*0&{8oW>g6lu&DkPgb8SZ4>ZML1c-56Z!a(?#3dl!x-8WCgiF~*J) zNW0y<6fes<-5bUTZ^;Aa9#IGq zwESM7ZZ_XvsNqZmO8HKLLeKnU-PniA2|fAEO{DeTTK})2#h{i5hh<~B4kKskWJA3s z)ezpV|KdhaOLPSWf1I!sL*T_+u!Yvp$R`4g)u?*AAoLZud9>eroEBA!-x77G52m4l z=0EiX+p7W!nH6Ot%*iK&n}>&PiT+hnf=tqb@B`zg9@|cn6);@0RvSv-Zziy#gxkK2 z8MIH``7Yon3t1^Nu?iiC`+L=JF2!dUS(a`NcD5+{WQ&VXsxdjZ14)I@xBrGZ*>+Sn zhyKI*-RktXDHXq9G5Jrq#t?|QGiZ;1xfYG1M0~gNDxrtNiq^V@{ewLOkr9S{0O==csf~$U&dOfTu9T$^S{HdYG;6( zj#jtp5yrU|$aAy`H7*Y`b9Xqm=5d7J#}p>9*@M-U6J4K`ob%ILrS;bLhs%@B*OaIg z_HzHmPskv9hJ@8=jSDO6kf6Lrgaig5=HS=02~@HlywC*$m&tk9&L{E|+jxXrY-Lj_MI= z*jFKF_Rmbo;@PTrwXk14%jrwX0_1XMVZP{VYm6VgpLOG;cK_`Kwf!mavxQeAJ?|YdnQdi zygmP1KjA1dz}JG zK#HRbWI832FO2x4gXcFkm#mGUK}Y_xxj_~2_2vytFtU*EmAHj^i~5s}Y1%QklD)f= z_RV95-qF#|Jx?>GQMd9NFM*niW|Os^B}g#)C|F@(S~<0c2EMH{jLB?lB3!USRL*SB zjGH8)?Q{uZVLr0Q=#fu0xAG;;*QWJs|Kf>}8hRM!c>B(9IrGH)ouA;pV)>wfAQf`V zo~Y9p*`mteA!=h?j+Jh%?@&n-te!=_n4W2Ff7?5IjOdi4P|VCutY}4Wnp0mEVdTfP zpEa$-Yp$EYPIyH>@b|%sI;a9Sv-ZI`v_rSt-sU5Mc3l^IzC?rh7&B`e$k>aXP$!wa z6|HhM!_}^kWf^$U6s?;id5+FJL3r=VE4%cw+H|%sg6kd;@pBL_OuadUs*v1!5A7-f zkz&={XilD+A}P}D=5T4Inn!rQB`y7wDw^)Tdc|~mzZFtm-jM!i2P7NOFe;Y3D=<{C zyoZT!d{BAi{oq-N*h;+wv)Illt#&cK{$SvS_~fj{`NxlKBN;dAW^)gneLM~I(fbJ) z?z3fw)*}K^h9P&6DK6JEz;d^Nl0{C_ng<;L$)ilYBS<*SlA?=#fSHJedv|q==AIn| z|3ET#P2bmsz7~91fTS+Os~Yr6`HxY|yK%=6(dL_W-Gl6WG@FC>x%v`S`mSm?A63A% zEi)E+p2I@trf|t1XUBI&Ojb`|Ki>`IT?7A+6G|L%A5Xr|xa-;~FLI{a+r!7B0hYcE z@aB;>LhiYI+$J)%vk8r#iZq3l_6mMWDle`L3mDionw4*_h)h51Rj&JYRT3j&ogEkG#uL?f9+xe0X@wOC~e^u!x zAS`-~XeXtl8tbvYPH8uT~9*K7<11*bb)Ch;9c-bwRMIitGEVECZR}Mq!Lj4k^r3Q zpF}j%!U3pAMDk|hRMJfyGA{F;RZjAPTOS)+}Tw8&Zrl}C5=lg*ci&Idua>ygM@KPFDsrrby4-ckReH|zZLx=X=f!cvq57%< zD1bcrWs6MQit<(9|3L=TalhrrrE<36fMDD{+m1tmdNx}lX zuUrcnlIP<;?Q{dfe3m4$vV;Qav2Vv{tk`QKNcc&rNmEueKTNIso>{Rv?{BTa+Ot(` zF!eSw8`UMk4#~7dynA}8uV#Fk?--s7uaZ}) z7&p5{*k20_3lg}Q z)K^1zNa5AP&n?DM z14im{ASt(sHm^!K!A<<&usm^%sU^~aKJCu&QQ0T(y=kG?jR|#K114_8dELdl%=Kno z&b|$spo`tVh54^amT74p7*n7E-7ojw;j){E;ql*B>mCyawG`}qNJ)d>;T!|U6hok( zM^l$T_k)mB>2|A$M13WxB2ff6srI>MX01+Itw|7)VY^l9T8G1e{m(f^C)_1(m&RW` zu=btz#By}J2R)&qQM2Dap{#GX9lvWgY0)x((x7=!tFwI|)VxZOmU4TSxl*j;fkBR6 z--MNW-9HjR5Wcky`b2Cx*E#0VYAe+-5QV-(*6-q8`Q1B=>uugdWmw7oxq7ZcoHYJO z`8skcd54r#SSY}YUhUV;`lnh-#~((V9`Bve`@SsL?|9@o#m@YwgHq;$ySz@)wdB$5`^W~@%fAZ&?#aB@CKN#KXUHn_WAKwcX$B!C&Va>hFsYX!T}z*OMKyI_FVW;b z$xrY>nG0_Rx*#`HRtUfxbGjX9g~+DpPUzPh*OO-fieNl5dX%=4V8IE_eQPV4I2U z3=M&sYyd}-Y9{_V2S3*xqFGJW;cbY!h{oeg|FvGQ+fw5vp=MQ~wRioK#%eP&<`E?V zgF>ZFx2K;Zl)14rsBprk^uIenAbru6eLB5tpv0h&x~7!vX9UtR5lH(0K2+e5Srf1=zn@6>UD6`@*!?}-4QOz{ESuw9-U32<`nv;m-F@&2Xz~8Du3ok_63zoD{S@f z2@KkMH%z?!c`;xe#Z954g%WOT-D*g}jf~qjXp+qc;yhX8@K=7M{g&Yp@j`1?O1>{m z*Aot_l4fPQ<(_!6zl9X2_ZruCLK+%Ro%Pq^XHaRW3)FBwAp)Lkrmu#fT?w+~bT^($BeoaE~In68)i2)LkBW6ZT6_4%2v z-o}^L_i9)iUnk6E*20oy{ndV^oE#{Q2L5#dod}vjn692*D=LPgYDl7h`@JB{)!I9V z7D8R+@@cgd)HOL3zy;#x#LM+fNQ5%Xqtm%oB& zC#AVnKlB4cgjG!nj$c;#xprxiBUrPu{HUjLra)BqCcRZrAgS7YhOFrKMr|L1tCgEX z*q_hk4c5!w+}?Td-qX{YgL5b#zQ9RNz5qIqJujn%^KPV6kjbe-_BFrbZsN?m_iehlj-5ZB;JsLkPTubYglwCuBj7yGAwk zyAhnB8-+}7qu2%pPT#xss#!+%U7ZU|D^FU%93DnngGjbGRI;?Od+Uy&Qf~2Z=`+`( zqFw`bjb1ZjUo+VI(Bu#^=a6L%uXKM^_7mL^M8zu*Le8A8jD2N4$1n*kb z!8X*D@k}V6jDyxTU5;OL}NmU$DCn(K+g{O%*+1`&? zUbdj6?AszOHXvktUT?Fo|1M*STHRx>qdI<>wcKdlX2KQ0rB`$yN25qBEAO8fqcP!X zdS9f^9o78eqH6IE~eph5a@`7S9E> zz}D#XX4-=Hi9?F+6zuY2Ss4Yg7RDPQaUlzse3!S5dep$v8$=$v$`ixmRNQ0u%zoMs z&!44Ap!cu81~8tE=J-kk+`O-U47ME1b-9U**##VjvEsy_5@DM8!DjO=^b`RO=R2vA6+kz{N0}`ND{9$&qx>H zN#zmYQ)r5t1Y!zqnGO^RV#WJno>##&o_&H-293JLxr7~6>sykzuI>-~3Ak33UZ5Lp zOg~Ai^!?vnz*#z@Pjv6|c{&rJ5lg>r@pdZn)&|EndVI=>wp~gi*>5DWzC8sVs{FH` zz|yoTr8kLPBhyO?!Htc5HN)23DYf%k9pPEst|WLLyQg{VA0ZX>KJ*e_a!zuRbj}O7 zCA1^l-?-Bl9Jz_=vq1t?m4-o#9%2Z~WdKH>OM8}gop5@|eO4?$@-R1C_yLNuj z{Cp*yhDkE#W+|{5O(XN1_Mv;BgjQ*YUr=JP%kJ%*U%nv5AL_wJ%|dTdn0$)3s9N0} zMQ|H+ZDb*bw%{ASxA)3OF3F$=A@DiTWU}onjO$Z^oN!gY-thN3Mz5T>+^rs)v+wbl z&6!G5OSK_5BbxiwT0+^Av(_DRBwOai(%?RFe8zQCume=jGvy_zG%rR-4fa#S##+xa zgz#8T>`eCw6p-)&G; z-b$M{_Dxp>S6xY;PYY5Nna4;0HdO>_3fwHA{`58?Pp@2VgeD4QSF)KdzOgWHfJle|Od)V`eR&46znhF+|*^L=wxV_^fqv|5%w)mW}wK zcqZ|j>;$dw>RmTyD!wCnqxBNrG`>CVpx>=Y0^?&@r2=!2+N)XT&(9rBBaxM`T-TSC z|F!JcXR$G1HIs?r7b+k&D;%NFbQ19vGOr;`{1f|n`;$BU7jYtf&I`w!5K{7WD>cke z(M_vzO%xeN+@^65V5vpT%j-Xn)0^-2Q)xSrL~aj=w+~M9Y0}iSotF|;mj4^<|13?u zEy|PoFE~8~)<}sLteGRV{tOY(bzqc#Q=2iq`)6e*NB%qM_piqrRwT2({trt!R=9ML ztr*FV!Pf%yMFp=Ng)fDbT)?Y|XQLY14eLcv$bh;jF>-p%znC)rS!j>=i0Zb#NI<$B zL57|@`FFLg=ucN8Uf>PCs*x5tjxcVMX2R8_`W#2EGsAdd(8rV_8xL5F@M1rPve7*z za1^>6HI#)8J$n4>SFol!Ca{9=S?C6hyn982-pP5U*BMDev0vQV369(CWA)B9R3_!# zS)fU4g~IpKgO#C6r%&831LK4yLp&CrP3fH_p--&^r?dO!uMQou3^8pc@X-&NHkdMxKMjt!*vJj_98SCmlS=-p3|L z+;u&p=$c`7Dc6L&pe?PN;6c7Xg7}T& zjwE=>67#D9N-K0n)%5Gj-etX*&k((VtFNAi@3_My{fkJpY1Fk z!GLRf>Z7!l0dhO(r`xG`Xj`V#J*L8<0OgzFaRzz+7jJJF7iHge3xg;qh+ImyigZdh zq5{&ALx*%rHzrGFoN7rGhVXIrHWzpqkS3UD? zJx=v1omK<$3#*2rz zj^5ARb6iI%s4?1YMi!4kHCrBcK|Iqu+Yi z%ngd8^&+VlPpVJwbo!?&h2a$>XX;sQNk9&6De0mP0E{6 zq@|HeTGt;CU!+bdts~ZZzirKYE9urX*D;B8ta?y&OddRQWx~-17Ep?u?$!xns?!ro z6mpnpk#20Y3h~1FU=2(n76>)I2*2ea0h&me2r#ON#+N)b=5}Z7#69BEP8Cg*MQ7Xj za^q7<$9Ul%@t$B#`b{}O=F1(LAl!p`=bGHciXQhBQGbmh>EMa|oEgWU-``~8C_y#i zL+^bk?$J8@Qjv;~C*Y;B2`d;nO*(j!mvtC#3vQs0x#RN~KX%DTkS1|Z0jB1)C^(@j zy|dU=B1Bh)%KG$R?WFVDF0*uPc9;n+iSPY(VY1i zRJ`t8!mqnIc8plDZW)LG5W+Y)n2;ke0Tm$QrAM)(tEfTNMhyNU_{C2 zoL6e`?}ITui>>qv>lq*Bm^a(Md8Gm^%pJ72X~=DgB7J1e&A$7MCmn^jab8-#G`&x( zzY{%tSu8S* zg5F_-nFU&GaLAyqBueAna_JWHE`f85a#Tc|gD2QLo0pzr;2Xp`k%v=SvG4hWgaicY z5I#{LFif%TlpS!)EGN*%Tx6`A*0o%um>s|^g^ad=S*TZBH?hGn;`{-o zJ=CRMxSm&Mp=U6B4s}F$AJ$*V^z%4qU}(DF+C#+gbX@On&hfQD>J%U@5nj$~Q2z&D ziCDGHhYnN35@2Qbj^Y=fjt1KMBDvZ!?KR3DY0L#8COO9!>V=)P(vw)6n4hc`Xsi1X zJDO4Skb#7*;jHOS_K9*J`ml+C?!s|JC}kwvQv+$)>7qS&T&N8y?ctQ~wcbp2dUQ^4 zGU3^R^08S{GyQR8QkpL}!ETnIR?WsUYb#>W7`kCq_ue!^;{PWhb6Ok5+xPb%Ks zR{cF9)1?#D@!PRZb_Q4WlD14jU=B2sUHr+InyffeCw)XMLmt11ka6=uRbrjT!c9{S zY|B))~cZTfvjc6?rMJfq9C z)qc9~*3CA|!sYAa_C`K z(dLl%!M>R(eR~_zjeRT?G31eDlwaZcR@w_wrB%s$g>I(RGW-b~@iQDw`v|sEH68m}KhhU5=S-=_w!hyF zZ)KaZ8tM}B+WR=MqwvM>lz$_Iz&o@6egn*YcW=4affVvu?)6)IIwZ@Pi=NC>@Z*r5 z$T`rm(mn_B^1@Qsrbmfu;eP)hR~(YquQja@1SFzUbt5f0;7n5dDYmMuohO?+xphHa5Gi2{^$zBtW( zdQHYKI*K!JRF%w$=2-PKytWj(eQ$(VskR>AR(AsVm= z34v$t&T3bdFd5~8PuCP~x0Z4ftHvqDX|&v(qph64h_onm!GE7STV74$H4F|99V`6) z^z^&EUs?2=hnEeva&Aq6yqEXpO-iLaygfI7wq%4Xf#}Jts)oa{uPvyf-VaUxE4DRO zP=*GcSxuGUQqGz_To*av|KtL6dlpjpc*umPadgekXf_X0oGB_F!!F18M^Y3Wf=ru^ z90BN2Edw1Gfx1$&XC(I)o(5Qa)nl&l*M9vYczj%H^_=>MW6pS~U@@Gf1VEEnIf^T+ z&<}LWp_oZE3Mqnr- z{m>EUcG2Mgv4<(YtwC{m?;HoSXs#I$fG&4vOjMkZ+_aozY`-3N729dpvFh5}{b{*` z!6$uqg)e|+3wQ+X7o8s0n`>e-e+i&o6=GvMPiq)H+>yeV5!wATJ?Vg1=r<*8Hvd2c ztb6z29iRA>qa=EdO5%7DE{7{F2lK|K^T?E=lQoJ3xpS#fS`6d(ejq_|*H71sC5I`6 zso3XXlLQboiFS))lQqi#j8;y7v5$tueCaGN8QXt8{TuiYzr~7&%Qnb5s>m^ro(@6) zYCZW@EUooTNEkiqwiJCI?&Nc?;Zz_(A2AGk0;4ow1&7ZW5%MdfOcRUCHPBAkc0T~Uu8LU(vlstTFqo|dUavKw3egpBFoZ8}))b3&$@cs*R zOw62=OW-NV#~RbOLuOHE&4r2QIyh2Q>?`d<23&!171n6ww+i)+;~(kG9ket@Iq^njs= z26lj_KYOp_`tx0JUKVG%C&>eG6qrdC2Ah4X#|(+9P$+A=|0MK@cGT|;24YR8+;d88 z&F+X{0C+#EWhrbQ8EJ>VHg8z6xTdl4ve?Ce-9C9MH=?WL0BmfzY@M0@L~H;k389km zx_CG%)xG`ZjX96h*yWWY$2TKBgi|MukD07rS5v5ak_Wi=G{FMobcXs-l~nqJug#oh zo=U}efSM1TxBD^~N~kDY5jI%Tz5V-Xb0A{;b(Rq|nS<+}HJY0B*Q!t(WQ6-@@LFZ5 z$VSUM^j{D=1Z%sOPJTUVHBkL=F|U{8Jw@|fDI0$1v38#Lc)mjG_LuTP5Y%reRP21f zR#PZ(RuF3DX=Qk15X^k{Zc;ht0vN?{&}=9WBu7km!Cn z;HfKqIRCi_P6`u4P;2(6{BF{iU~CQrYKoqSz?uXso}Ss6p;;VqtP)L1SruRYGLyd# zoiVZsr3(qmBa4(#%THPCwQPFa8b83+mFlute;afz1z&b5G>8poc&kSJO>^{^PKQWn z!noI^Q8M(1QYds}^rfDiuXb4}GwqFo{*d7)bI^{8d%+ zdyVnNil1CYcYqXf7WH#`FYgk$GCiMo$aRbvemPFPs6(^6rK3_Yjus=Dd2zp5MCQ1$ zkD-gR@xVvJZ3de6E>e(wXEJ4_bsIZvN6K1skSelkRNX&~0<-ckVV#wmVyDBA zX@Y|8%9NLSz~s2}`Abt_*W=2Z@99bM&-6lYyAEF{WN%vkjj_peW-5Tg|z;H*8K@Csc_$KERyIsr5dSv+A29j+-q*^@= z@iq@k4x*<$`%K!ge88n2l{Vka5M~=oqn9?%WQ-c=1w;|dy~O>+BO?%Fy~QrhGv7-n zmUo!7cGWlAXH_FmlX*P~ip9hBc+Pqg3Vyzao98lL=rR2;h$No)qLFu6#A#AIK}l8Z2uJsNygQ?rm3f`6OxCMN2N_i5 zdDZ^yE->9st!&@kYdZ4J`8_F-nX&+NE;U&jtGO)G;}_fH3`?-<+?O;!e3oi3-KELnZr}-qu4L1 zd6nf!TqU_|c0Qq&Ur=LRqlkIaH@mjh!b5;S_vcr334^~vrKJ(9m5cUQxp#N7m~nmC zY2D01XYM7J@|3HG;aXI$4fOM+ABuf2Cq$JN3#pq|wU_n^7;fQCgr21+thLM45(Ty- zjLuGGF&SxxPGN1^7WxFq5yPofhI1d<~FclqmSh}^hvhIK)Z3M4IiLV6QUh8Z1?D01cx*FwO(71wf zg#24rxhZ3aIr^Rp37S1fW<4L)lP_HBPW!dE>p?oFs99<>?uG)55%~Nn_|~~DlSAFg zy^Eu{TKwX)d2&vl`f>|FK#U-p;F&R?@|Al%HsqnRD=6k`wq7?9ejI#ZAXuFq9*)6BC)_u9 zJAHMYf)NgiVvqzXGi2xCdDZ4P^4GVaD#-iyqN%BA(fSGgmpiWSR|$GX$347f`qDVW zbe(kMGtXgfrM_FGm>*Kw+1X3gqI|skypQ@^OJ$FaN1bAU>KmX-M7ZL=A9nswWn#Q4 zoO~H2X1Rs7ss&A8_s zoY1pZz-C~c{IZCuq16}6^?N(qkSD@qM;co7bWU2_pq+>9*<|}$Kz=pNyZr~K;6by? z*6L>8%KhKbeX+K}VuoNA$yF$4N9zM_Ku+bJwc;2!X_y2tgG zmQG;%ZYS6yGY%DDr(_$(0a34+nr~}Om81Yxj1(rAfkf7G&?L~Wh8OjDj(AYH6L+)? zVh^m#B^Utr=F^pQv8M33+N$McNadyLNhioN9~2p)jHZip-XY=l8b*Fy5UZ`G`7#+y zXclSC&+Ri#XyDNiZb&Nqn>;^9jj_*HlY)kF^1=9O!J{VlrlG%aLMmr#{q3$W_Ayo6 ztb@m`Y}z8T)2f53MWoRx5r0Ljc^GTyDig)OZPGi1L>W$7ZRIabFkpoJy@^dmne$z5 zCBzfTG;Kj+Wu7&+AeBu;K3~emmHk?3;+|a@L7jVcycEaz-734+n`TV&7Lwht^g#BK zF+}+X{>*ufyS6l>7l1wMU%uYFI4y7fvEz*k<#+$e^NYIGCMJ`tqs;>59m)Iqb|oYJ z6tAnI#m`r>KUh1dy|{fx%@Quy=%%YW)11yzlOKg;`d`{~n5Vx5`gR=Pt!RHQd?=M3jIq6QZPZZ?-<1L&-TjG+^NG6rL#w(E`2PLBCE{p8* z-yc9{P6OAPMp77CuhzUsR+Igj@S`1fiVYIvlDWmo2#X2SCo<#9rj-&+VEK%#Y+AI< zF2z)f`u4+Kz06xbk{@7bpqEi@5nh5J@Xu(TkqbaWp^FoiuB3A6f7STGetLux+}2{a z{wV-&_mLs`PitgRh26T|*AlrEP2zdoHf2RlCQnnaB_2*#G)7UdSTUuzk7zFG*Ku%_Qtbqyef@19GBa z#~H=#qi^=&3!2QClWHrpJR4poBYZ>sr30wHe<-&FjU)FD_vz*eAfI!v^w*m+j$g%G z4c3pw(*(cbJfc`|C+bqtoT2oiEg?uWgjiGWP?rXH<}R(guQZwM_w)v!`?PJ1T(V;U?#2MHLch=~7{vBm zkGSGjh|YWs5LJH3o$j~dd23%J0Gi`#@jKRI=`s5@$B?jRr7J8Q%N7bF1(sk>N7v+s z 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 G^S=O89#*da From 37e3ec8a7883d45348874bf339af76fb0f539fab Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 12:05:40 +0000 Subject: [PATCH 29/45] Stop the failure fixtures photographing a disagreement production cannot have The chain switcher reads `/api/v1/chains` and a chain page reads that chain's own `/status`. Both come from the same overlay, so they agree in production and an outage that reaches one reaches both. Two failure states overrode only the single status, so their screenshots showed a header calling the chain Ready above a page saying its authority could not be reached, and the reviewer's first question was about the contradiction rather than about the state. `chain-authority-down` now takes the list down too, and the header reads Not stated beside a rail whose five readings all say Not available. `chain-behind` publishes the same not-ready verdict in both documents. --- scripts/universe/visual-qa/chain-fixtures.mjs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/universe/visual-qa/chain-fixtures.mjs b/scripts/universe/visual-qa/chain-fixtures.mjs index 3769d3c2c7e..bb3024bee39 100644 --- a/scripts/universe/visual-qa/chain-fixtures.mjs +++ b/scripts/universe/visual-qa/chain-fixtures.mjs @@ -397,6 +397,12 @@ const DOGECOIN_NOT_READY = { ], }; +/** Answering, and behind. Not a failure: every figure is true as of an older block. */ +const DOGECOIN_BEHIND = { + ready: false, + degradedReasons: ['protocol-history-partial'], +}; + export const chainStateOverrides = { // A pending set that is genuinely empty and says so. // @@ -435,6 +441,11 @@ export const chainStateOverrides = { // Both chain authorities are unreachable. The page must say the status is // unavailable and must not present an empty overview as a working one. 'chain-authority-down': { + // The list too. It is the same overlay, so an outage that reaches one + // reaches both, and overriding only the single status photographed a + // header claiming the chain is ready above a page saying it cannot be + // reached, which production cannot produce. + '/api/v1/chains': { status: 503 }, '/api/v1/dogecoin/status': { status: 503 }, '/api/v1/zcash/status': { status: 503 }, '/api/v1/dogecoin/mempool': { status: 503 }, @@ -445,13 +456,15 @@ export const chainStateOverrides = { // status rail exists for, and it is not a failure: every figure on the page // is true as of a block that is not the tip, and the page has to say so. 'chain-behind': { + '/api/v1/chains': { + body: [capability('dogecoin', DOGECOIN_BEHIND), capability('zcash')], + }, '/api/v1/dogecoin/status': { body: capability('dogecoin', { - ready: false, + ...DOGECOIN_BEHIND, lagBlocksAtomic: '2841', sync: { state: 'degraded', initialBlockDownload: false, progressDecimal: '0.9994', updatedAt: new Date(Date.now() - 20_000).toISOString() }, coverage: { confirmedHistory: 'partial', addressHistory: 'partial', protocolHistory: 'partial' }, - degradedReasons: ['protocol-history-partial'], }), }, }, From bb75e74b361de21612e6fe6294fc8566e576050b Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 12:11:44 +0000 Subject: [PATCH 30/45] Read a Zcash address, which had a balance and showed none Same shape as the block, and worse in effect. `/api/v1/zcash/address/` publishes `balance.confirmed_zatoshis` and `balance.received_zatoshis`, its history in an offset-counted envelope, and unspent outputs whose index is a number and whose amount is called `valueZatoshis`. The reader knew only the Dogecoin names, so a real Zcash address rendered its identifier, no balance at all, and a table of unspent outputs with every amount and index blank, while all of it was in the response. It is 12.513454 ZEC confirmed, 9,203.00177805 received, and 8,945 transactions, and the page said none of it. Read now, with the history paged from the offset (page 1 of 179 rather than a list that stops at fifty), and with what the source does not publish left absent: nothing about what has been sent and nothing about anything unconfirmed. Received minus confirmed is not the total sent, and it stops being close the moment an output is immature. Fixing this exposed a second thing that was already wrong for both chains. The unspent output table printed "not in a block yet" whenever no height was stated, so a source that does not publish heights at all had every one of its confirmed outputs labelled pending. A row now says that only where the source does state a height for its confirmed outputs and did not state one here, which is what a mempool output looks like there. Where the source never states one, the column says the height was not reported and makes no claim about confirmation. --- .../multichain-explorer.component.html | 9 +- .../multichain-explorer.component.ts | 4 + .../multichain-explorer/multichain-view.ts | 67 +++++++++ .../multichain-explorer/zcash-address.spec.ts | 129 ++++++++++++++++++ scripts/universe/visual-qa/capture.mjs | 1 + scripts/universe/visual-qa/chain-fixtures.mjs | 66 ++++++++- 6 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/universe/multichain-explorer/zcash-address.spec.ts diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html index 20ca264cc2d..357fa50da82 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html @@ -439,8 +439,13 @@

Unspent o not reported

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 fdde8422925..9721cf4cb70 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts @@ -157,6 +157,10 @@ const PRESENTED_FIELDS: Partial> = { 'chain', 'network', 'address', 'balanceAtomic', 'totalReceivedAtomic', 'totalSentAtomic', 'unconfirmedBalanceAtomic', 'transactionCountAtomic', 'unconfirmedTransactionsAtomic', + // The Zcash spellings, read by the address reading. `address_type` and + // `publicly_observable` are not repeated here because they are facts about + // this address that nothing else on the page states. + 'schemaVersion', 'balance', 'utxos', 'transactions', 'checkpoint', 'coverage', ], outpoint: ['chain', 'network', 'outpoint', 'output', 'transaction'], collection: ['chain', 'network'], diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.ts index 52409d5c3d6..202065d10e7 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.ts @@ -1359,12 +1359,67 @@ export function readOffsetPaging(value: unknown): Paging | null { }; } +/** + * The Zcash shape. Only what the payload states is read: it publishes the + * confirmed and the received balance and nothing about what has been sent or + * about anything unconfirmed, and those stay absent. Subtracting one published + * figure from the other would produce a total sent that is wrong the moment an + * output is immature or unconfirmed, and a wrong figure is worse here than a + * missing one. + */ +function readFlatAddress( + payload: ChainExplorerPayload, + profile: ChainProfile +): AddressReading | null { + const address = text(payload.address); + if (!address) { + return null; + } + const balance = isRecord(payload.balance) ? payload.balance : {}; + const history = isRecord(payload.transactions) ? payload.transactions : {}; + const utxos = Array.isArray(payload.utxos) ? payload.utxos.filter(isRecord) : []; + return { + address, + balance: formatAtomicAmount(text(balance.confirmed_zatoshis), profile.precision), + totalReceived: formatAtomicAmount(text(balance.received_zatoshis), profile.precision), + totalSent: null, + unconfirmedBalance: null, + transactionCount: formatExactInteger(integerText(history.total)), + unconfirmedCount: null, + txids: Array.isArray(history.items) + ? history.items.filter((id): id is string => typeof id === 'string') + : [], + paging: readOffsetPaging(history), + utxos: utxos.map((entry) => ({ + txid: text(entry.txid) ?? '', + vout: integerText(entry.vout) ?? '', + amount: formatAtomicAmount(text(entry.valueZatoshis), profile.precision), + // Not stated for any unspent output by this source, so nothing here says + // whether one is confirmed. + height: null, + confirmations: null, + pending: false, + })), + }; +} + export interface UtxoRow { readonly txid: string; readonly vout: string; readonly amount: ExactNumber | null; readonly height: ExactNumber | null; readonly confirmations: ExactNumber | null; + /** + * True when this source states a confirming block for its unspent outputs + * and did not state one here, which is what an output still in the mempool + * looks like. False when the source never states one, so the absence says + * nothing about whether the output is confirmed. + * + * Without the distinction a missing height read as "not in a block yet", and + * every unspent output on a Zcash address, all of them confirmed, was + * labelled pending. + */ + readonly pending: boolean; } export interface AddressReading { @@ -1387,6 +1442,15 @@ export function readAddress( if (classifyPayload(payload) !== 'address') { return null; } + // Zcash states the same facts under different names: the balance nested and + // in snake_case, the history in an offset-counted envelope, an unspent + // output's index as a number rather than a decimal string. Read against the + // Dogecoin names alone, a real Zcash address rendered its identifier, no + // balance at all, and a table of unspent outputs with every amount blank, + // while every one of those figures was in the response. + if (isRecord(payload.balance) && !text(payload.balanceAtomic)) { + return readFlatAddress(payload, profile); + } const utxos = Array.isArray(payload.utxos) ? payload.utxos.filter(isRecord) : []; return { address: text(payload.address) ?? '', @@ -1404,6 +1468,9 @@ export function readAddress( amount: formatAtomicAmount(text(entry.valueAtomic), profile.precision), height: formatExactInteger(text(entry.heightAtomic)), confirmations: formatExactInteger(text(entry.confirmationsAtomic)), + // This source states a height for a confirmed output, so the absence of + // one is a statement that the output is still pending. + pending: !text(entry.heightAtomic), })), }; } diff --git a/frontend/src/app/universe/multichain-explorer/zcash-address.spec.ts b/frontend/src/app/universe/multichain-explorer/zcash-address.spec.ts new file mode 100644 index 00000000000..7146ed814c0 --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/zcash-address.spec.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import { ChainExplorerPayload } from '@app/universe/universe.types'; +import { + chainProfile, + classifyPayload, + readAddress, +} from '@app/universe/multichain-explorer/multichain-view'; + +/** + * `/api/v1/zcash/address/` as production returned it on + * 2026-08-29, with the unspent output list and the history trimmed to two + * entries each and nothing else changed. + */ +const ZCASH_ADDRESS = { + "schemaVersion": "zcash-metaprotocols-api-v1", + "network": "mainnet", + "checkpoint": { + "height": "3464734", + "hash": "0000000000645e18fd55f18a5b2681f0becdc180d14d35a730875c4dc66add1a" + }, + "coverage": { + "scannedHeight": "3464734", + "networkHeight": "3464734", + "blocksBehindNetwork": "0", + "nodeSynced": true, + "verificationProgress": 0.9999997113775545, + "chainComplete": true + }, + "address": "t1SEgZvXCu3ceE42qrq5pCeSq7HbLjX8NJv", + "address_type": "transparent", + "publicly_observable": true, + "balance": { + "confirmed_zatoshis": "1251345400", + "received_zatoshis": "920300177805" + }, + "utxos": [ + { + "txid": "e642bab049488871a307aff5a46be789b374858dfa7163e33ddff27c76e80438", + "vout": 0, + "valueZatoshis": "125522000", + "scriptPubKey": "76a9145bbd8cad40c669b9ef2a5b7b2fbedbe9f74d7bea88ac" + }, + { + "txid": "44b60d006a4197f81385da1718eeddb5be23fdfdaa45553f0cae97766f2e150e", + "vout": 0, + "valueZatoshis": "125015000", + "scriptPubKey": "76a9145bbd8cad40c669b9ef2a5b7b2fbedbe9f74d7bea88ac" + } + ], + "transactions": { + "total": "8945", + "offset": "0", + "limit": "50", + "items": [ + "73c47124313064d13e53b921857de4d57ac45e3e712b97233a2b38092f39ffbf", + "8dc93dfbc11a54d0a71488e6ba5193216018ef289a3e2f4ea8f115bab03011f6" + ], + "has_more": true + }, + "privacy_notice": "This page covers only publicly observable transparent activity and does not reveal shielded or Unified Address history.", + "chain": "zcash" + } as unknown as ChainExplorerPayload; + +const ZEC = chainProfile('zcash'); + +describe('a Zcash address, in the shape Zcash actually sends', () => { + it('shows the balance, which was in the response and on no page', () => { + // `balance.confirmed_zatoshis`, not `balanceAtomic`. Read against the + // Dogecoin name the page rendered an address and no figures at all. + const reading = readAddress(ZCASH_ADDRESS, ZEC); + expect(reading?.balance).toEqual({ display: '12.513454', exact: '1251345400' }); + expect(reading?.totalReceived).toEqual({ display: '9,203.00177805', exact: '920300177805' }); + }); + + it('does not derive a figure the chain did not state', () => { + // Received minus confirmed is not what has been sent: it is wrong the + // moment an output is unconfirmed or immature, and this source publishes + // neither figure. + const reading = readAddress(ZCASH_ADDRESS, ZEC); + expect(reading?.totalSent).toBeNull(); + expect(reading?.unconfirmedBalance).toBeNull(); + expect(reading?.unconfirmedCount).toBeNull(); + }); + + it('reads the history and its paging out of the envelope it arrives in', () => { + const reading = readAddress(ZCASH_ADDRESS, ZEC); + expect(reading?.transactionCount?.exact).toBe('8945'); + expect(reading?.txids).toHaveLength(2); + expect(reading?.paging?.page).toBe(1); + expect(reading?.paging?.nextPage).toBe(2); + }); + + it('reads an unspent output whose index is a number and whose amount is not called value', () => { + const utxo = readAddress(ZCASH_ADDRESS, ZEC)?.utxos[0]; + expect(utxo?.vout).toBe('0'); + expect(utxo?.amount).toEqual({ display: '1.25522', exact: '125522000' }); + // Neither height nor confirmation count is in this payload. + expect(utxo?.height).toBeNull(); + expect(utxo?.confirmations).toBeNull(); + // And that says nothing about whether the output is confirmed. Every + // unspent output on this address is confirmed, and a missing height read + // as "not in a block yet" labelled all of them pending. + expect(utxo?.pending).toBe(false); + }); + + it('still reads the Dogecoin shape', () => { + const dogecoin = { + chain: 'dogecoin', + address: 'DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L', + balanceAtomic: '1284460000000', + totalReceivedAtomic: '9930000000000', + totalSentAtomic: '8645540000000', + transactionCountAtomic: '318', + utxos: [{ txid: 'a'.repeat(64), voutAtomic: '1', valueAtomic: '74886600000', heightAtomic: '5623038', confirmationsAtomic: '3' }], + } as unknown as ChainExplorerPayload; + expect(classifyPayload(dogecoin)).toBe('address'); + const reading = readAddress(dogecoin, chainProfile('dogecoin')); + expect(reading?.balance?.exact).toBe('1284460000000'); + expect(reading?.totalSent?.exact).toBe('8645540000000'); + expect(reading?.utxos[0].vout).toBe('1'); + // This source does state a height, so an output without one is pending. + expect(reading?.utxos[0].pending).toBe(false); + const pendingUtxo = readAddress( + { ...dogecoin, utxos: [{ txid: 'b'.repeat(64), voutAtomic: '0', valueAtomic: '5' }] } as unknown as ChainExplorerPayload, + chainProfile('dogecoin') + ); + expect(pendingUtxo?.utxos[0].pending).toBe(true); + }); +}); diff --git a/scripts/universe/visual-qa/capture.mjs b/scripts/universe/visual-qa/capture.mjs index ad39d820fef..6646a3cb8dc 100644 --- a/scripts/universe/visual-qa/capture.mjs +++ b/scripts/universe/visual-qa/capture.mjs @@ -85,6 +85,7 @@ const ROUTES = [ // Zcash's block response is not Dogecoin's, and nothing here had ever asked // for one. It reached production as a generic field table. { id: 'zcash-block', path: `/zcash/block/${chainSampleIds.ZEC_BLOCK}`, name: 'Zcash block' }, + { id: 'zcash-address', path: `/zcash/address/${chainSampleIds.ZEC_ADDRESS}`, name: 'Zcash address' }, { id: 'zcash-protocols', path: '/zcash/protocols', name: 'Zcash protocols' }, // Universe-authored routes that had no coverage either. The saved page is diff --git a/scripts/universe/visual-qa/chain-fixtures.mjs b/scripts/universe/visual-qa/chain-fixtures.mjs index bb3024bee39..53c5e063025 100644 --- a/scripts/universe/visual-qa/chain-fixtures.mjs +++ b/scripts/universe/visual-qa/chain-fixtures.mjs @@ -17,6 +17,7 @@ const DOGE_TXID = '4c8f0b2e6a1d5937c0f4b8e2a6d0c4f8b2e6a0d4c8f2b6e0a4d8c2f6b0e4a const DOGE_BLOCK = 'e1b5f9d3a7c2064e8b1d5f9a3c7e0246b8d2f6a0c4e8b2d6f0a4c8e2b6d0f4a8'; const DOGE_ADDRESS = 'DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L'; const ZEC_TXID = '7a3e1c5f9b2d6048a2c6e0f4b8d2a6c0e4f8b2d6a0c4e8f2b6d0a4c8e2f6b0d4'; +const ZEC_ADDRESS = 't1SEgZvXCu3ceE42qrq5pCeSq7HbLjX8NJv'; const ZEC_BLOCK = '00000000001a8027b33ea8f1972569b135eea78be68a381875307549ddf178e2'; /** @@ -309,6 +310,69 @@ export const chainFixtures = { "chain": "zcash" }, + // A Zcash address, as production returned it, with the unspent output list + // and the history trimmed. The balance is nested and in snake_case, the + // history counts from an offset, and an unspent output states its index as a + // number. Read against the Dogecoin names this page showed an identifier, no + // balance at all, and a table of unspent outputs with every amount blank. + [`/api/v1/zcash/address/${ZEC_ADDRESS}`]: { + "schemaVersion": "zcash-metaprotocols-api-v1", + "network": "mainnet", + "checkpoint": { + "height": "3464734", + "hash": "0000000000645e18fd55f18a5b2681f0becdc180d14d35a730875c4dc66add1a" + }, + "coverage": { + "scannedHeight": "3464734", + "networkHeight": "3464734", + "blocksBehindNetwork": "0", + "nodeSynced": true, + "verificationProgress": 0.9999997113775545, + "chainComplete": true + }, + "address": "t1SEgZvXCu3ceE42qrq5pCeSq7HbLjX8NJv", + "address_type": "transparent", + "publicly_observable": true, + "balance": { + "confirmed_zatoshis": "1251345400", + "received_zatoshis": "920300177805" + }, + "utxos": [ + { + "txid": "e642bab049488871a307aff5a46be789b374858dfa7163e33ddff27c76e80438", + "vout": 0, + "valueZatoshis": "125522000", + "scriptPubKey": "76a9145bbd8cad40c669b9ef2a5b7b2fbedbe9f74d7bea88ac" + }, + { + "txid": "44b60d006a4197f81385da1718eeddb5be23fdfdaa45553f0cae97766f2e150e", + "vout": 0, + "valueZatoshis": "125015000", + "scriptPubKey": "76a9145bbd8cad40c669b9ef2a5b7b2fbedbe9f74d7bea88ac" + }, + { + "txid": "69fb0df1609d51da8f8526bf28fd7d92213461609811e3e119cc88623e93e5a5", + "vout": 0, + "valueZatoshis": "125176000", + "scriptPubKey": "76a9145bbd8cad40c669b9ef2a5b7b2fbedbe9f74d7bea88ac" + } + ], + "transactions": { + "total": "8945", + "offset": "0", + "limit": "50", + "items": [ + "73c47124313064d13e53b921857de4d57ac45e3e712b97233a2b38092f39ffbf", + "8dc93dfbc11a54d0a71488e6ba5193216018ef289a3e2f4ea8f115bab03011f6", + "983ac6acd606a66b0c948ce14455b0d35c5465100bba275a78c171dbf9a91ccc", + "33ca5b4e93fde681a9b263b588398a2623b3198a8a89cf7815d773f235a80ffe" + ], + "has_more": true + }, + "privacy_notice": "This page covers only publicly observable transparent activity and does not reveal shielded or Unified Address history.", + "chain": "zcash" + }, + // The pending lists carry whole transaction envelopes, one per entry, which // is what the live API returns. They were summary rows here until the real // payloads were read, and the difference matters: a list of envelopes is @@ -543,4 +607,4 @@ export const chainStateScope = { 'chain-object-missing': ['dogecoin'], }; -export const chainSampleIds = { DOGE_TXID, DOGE_BLOCK, DOGE_ADDRESS, ZEC_TXID, ZEC_BLOCK }; +export const chainSampleIds = { DOGE_TXID, DOGE_BLOCK, DOGE_ADDRESS, ZEC_TXID, ZEC_BLOCK, ZEC_ADDRESS }; From a9b8b7760df774dc28de7c6f86dbe09f9eba0f63 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 12:20:15 +0000 Subject: [PATCH 31/45] Photograph a Zcash block that carries something The block fixture was the chain tip, whose protocol lists are all empty, so the path that names lists this page cannot read yet was never rendered by anything. It is a real block carrying a Zerdinal now, and rendering it found two things. "1 inscriptions" and "1 envelopes". The names come from the response's own field names, which are plural, so putting the count in front produces that for every list holding one entry. The count sits beside the name instead: inscriptions (1), envelopes (1), events (1). The transaction list was trimmed to four ids while the same response counts seventeen and states a page size of fifty, so the page read "page 1 of 1" over four of seventeen. A false claim of completeness is not a thing to put in a review fixture, and the list is kept whole. --- .../multichain-explorer.component.html | 2 +- .../multichain-explorer.component.ts | 18 +-- scripts/universe/visual-qa/chain-fixtures.mjs | 112 +++++++++++++----- 3 files changed, 97 insertions(+), 35 deletions(-) diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html index 357fa50da82..2a244f0de3c 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html @@ -382,7 +382,7 @@

Transactions in

- This block also carries {{ unlistedSummary(blk.unlisted) }}. This page has no reading for them yet, so they are counted here rather than left out silently. + This block's response also carries {{ unlistedSummary(blk.unlisted) }}. This page has no reading for those yet, so they are counted here rather than left out silently.

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 9721cf4cb70..3c7d67266f7 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts @@ -397,14 +397,18 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { return index; } - /** "3 inscriptions and 2 ZRune events", in the reader's language. */ + /** + * "inscriptions (3), zrune events (1)". + * + * The count sits beside the name rather than in front of it. The names come + * from the response's own field names, which are already plural, so "1 + * inscriptions" is what putting the number first produces, for every list + * with one entry in it. + */ unlistedSummary(entries: readonly { label: string; count: number }[]): string { - const parts = entries.map((entry) => `${entry.count} ${entry.label.toLowerCase()}`); - if (parts.length < 2) { - return parts.join(''); - } - const last = parts[parts.length - 1]; - return $localize`:@@universe.chain.list-join:${parts.slice(0, -1).join(', ')}:ITEMS: and ${last}:LAST:`; + return entries + .map((entry) => `${entry.label.toLowerCase()} (${entry.count})`) + .join(', '); } trackById(_index: number, item: { id: string }): string { diff --git a/scripts/universe/visual-qa/chain-fixtures.mjs b/scripts/universe/visual-qa/chain-fixtures.mjs index 53c5e063025..765cd0a54d8 100644 --- a/scripts/universe/visual-qa/chain-fixtures.mjs +++ b/scripts/universe/visual-qa/chain-fixtures.mjs @@ -18,7 +18,7 @@ const DOGE_BLOCK = 'e1b5f9d3a7c2064e8b1d5f9a3c7e0246b8d2f6a0c4e8b2d6f0a4c8e2b6d0 const DOGE_ADDRESS = 'DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L'; const ZEC_TXID = '7a3e1c5f9b2d6048a2c6e0f4b8d2a6c0e4f8b2d6a0c4e8f2b6d0a4c8e2f6b0d4'; const ZEC_ADDRESS = 't1SEgZvXCu3ceE42qrq5pCeSq7HbLjX8NJv'; -const ZEC_BLOCK = '00000000001a8027b33ea8f1972569b135eea78be68a381875307549ddf178e2'; +const ZEC_BLOCK = '00000000004db04aba335b14021f8dd4fa02a0a954edad7da547c3a4fd917141'; /** * A chain capability envelope. Dogecoin is at its tip and complete; Zcash is @@ -37,10 +37,11 @@ function capability(chain, overrides = {}) { asset, ready: true, tip: { - // Twelve above the Zcash block fixture, which is a real block at - // 3,464,717. A chain tip below the height of a block the same run - // renders is a page that contradicts itself, and it read that way for - // as long as the two numbers were chosen separately. + // The real Zcash tip on 2026-08-29, twelve blocks above the height the + // status fixture reports as behind. It used to sit below the height of + // the Zcash block the same run renders, which is a page contradicting + // itself, and it read that way for as long as the two numbers were + // chosen separately. heightAtomic: chain === 'dogecoin' ? '5623041' : '3464729', blockHash: chain === 'dogecoin' ? DOGE_BLOCK : 'f'.repeat(64), observedAt, @@ -264,48 +265,105 @@ export const chainFixtures = { ], }, - // The Zcash block response as production returned it on 2026-08-29, with the - // protocol arrays emptied and nothing else changed. Zcash puts the block's + // A real Zcash block, exactly as production returned it. The transaction + // list is kept whole: trimming it left the page saying page 1 of 1 over + // four of the seventeen ids the same response counts, which is a false + // claim of completeness in a screenshot. Zcash puts the block's // fields at the top level, in snake_case, with a unix time and an // offset-counted transaction list, and none of that is the Dogecoin shape // above. Written from a real response, because the page shipped as a field // dump on exactly this difference. + // + // This block carries one Zerdinal, so it also exercises the path that names + // the lists the page has no reading for. A block with all of them empty + // would have left that path unrendered by anything. [`/api/v1/zcash/block/${ZEC_BLOCK}`]: { "schemaVersion": "zcash-metaprotocols-api-v1", "network": "mainnet", "checkpoint": { - "height": "3464717", - "hash": "00000000001a8027b33ea8f1972569b135eea78be68a381875307549ddf178e2" + "height": "3464740", + "hash": "000000000027615ceabc9b72fe71165b65a726b7c8b6cdc49552bedeefe70033" }, "coverage": { - "scannedHeight": "3464717", - "networkHeight": "3464717", - "blocksBehindNetwork": "0", + "scannedHeight": "3464740", + "networkHeight": "3464742", + "blocksBehindNetwork": "2", "nodeSynced": true, - "verificationProgress": 1, + "verificationProgress": 0.9999994227564419, "chainComplete": true }, - "height": "3464717", - "hash": "00000000001a8027b33ea8f1972569b135eea78be68a381875307549ddf178e2", - "prev_hash": "00000000006c247a8c511e915a0370a4bdbae53d4cf4b10931b2df553d186277", - "time": "1788003830", - "tx_count": "4", + "height": "3132356", + "hash": "00000000004db04aba335b14021f8dd4fa02a0a954edad7da547c3a4fd917141", + "prev_hash": "0000000000404690d08f1b4d51cbd29b629dcf69575477aebb0b8be1e684622c", + "time": "1762955823", + "tx_count": "17", "transactions": { - "total": "4", + "total": "17", "offset": "0", "limit": "50", "items": [ - "e743ed36e0a10285fbfd4de57844f0f78bc929ad58b1d391fa294a540f9232bd", - "9ff76d00ba46f460a3d2cc536c5e9e82fdabf1ad0eea45fb32a554bbab1d4d59", - "10ac3514e6e21b25d35cd5941e887b1936036c5c8a6a556796309765ef3cf30f", - "87bee021247fb56e3436447fd7f827266f71f4257ca6050f473ad03fdc09de1d" + "c7b1b83c597564f17d3a1e85569f15766e9ec41e8bcb98b80c16cabe8d906efa", + "7af7c97b40057e4bd66a060b590a2974364757bac236aa954a311f0565c844ad", + "c8629f9c0929e99d16e8848f62ec05f2fc6a8c0c6db8f90db242e89979600b05", + "1c85153ef3707e601ff095167d54c4b7dd396c240647828524adbc1518a0fa2a", + "385f7a1bfd45b9fc4a79ef110a3144086a51ec0755c92acb473184af2ea38fe7", + "219cadf573630d346484043ab12c1199a78730036852870872eeee682f3496d7", + "5380b950889eb9b9635cd4ae317b9222e78a773b7e96951df436f5a0163fa5e0", + "056c95bdf6858cd66f7cad716695342a21df4c359b75606b3ba70216a9d7b82e", + "ecee360011f2866c552e4c3fdb2d183d85fd68b05332d76959531a1791af2b6d", + "220f8d2ddb34705808df739a4e08b2d88c0edcc9d012178ba7acb9ff62b49972", + "8cf2bd26da6b319647e31ea2a2ac686ad2541d1af1716842cfacb8d9c61f3803", + "e3215cff08b36d0dcfc118ff41b8cf18b51ba9b16554a164866e6f4ab1a0c53e", + "a88f237bbc8a8035d94c0ebd559cd5b079eddc9f844f241e8c074290826d6f01", + "a27482dcbda2bdb279fcea415bc5ba0cf6e776de5411e58fed32a4c3703dfbfc", + "a73ad0cf234164acd70a2fa681dbb0efd2379f2d9ab305e292ff649a25515065", + "1e8ecb2cd0a20057f838877706d8524ab488b9ac0dd5949ad8e0cc8c6b3444cf", + "bd1b547d9c803caff668b8b2801714f0894560156a6923048c71be650416816a" ], "has_more": false }, - "inscription_count": "0", - "inscriptions": [], - "envelopes": [], - "events": [], + "inscription_count": "1", + "inscriptions": [ + { + "id": "219cadf573630d346484043ab12c1199a78730036852870872eeee682f3496d7i0", + "sequence": "1", + "content_type": "text/plain", + "content_length": "15", + "content_hash": "cc24bb0eb02674e155c2fe8a9e5ee33291e3f3b244cc4f33851c3294acd9d814", + "genesis_height": "3132356", + "genesis_txid": "219cadf573630d346484043ab12c1199a78730036852870872eeee682f3496d7", + "completion_txid": "219cadf573630d346484043ab12c1199a78730036852870872eeee682f3496d7", + "completion_height": "3132356", + "owner_address": "t1Yidqtcf4FeDzbZi4EBZkn84HyS9sypqWU", + "outpoint": "219cadf573630d346484043ab12c1199a78730036852870872eeee682f3496d7:0", + "family": "zordinals-legacy", + "state": "confirmed", + "total_pieces": "1", + "pieces_found": "1", + "complete": true, + "terminal_txid": null, + "collection_slug": null + } + ], + "envelopes": [ + { + "txid": "219cadf573630d346484043ab12c1199a78730036852870872eeee682f3496d7", + "vin_index": "0", + "family": "zordinals-legacy", + "total_pieces": "1", + "piece_count": "1", + "malformed_reason": null + } + ], + "events": [ + { + "inscription_id": "219cadf573630d346484043ab12c1199a78730036852870872eeee682f3496d7i0", + "event": "inscribed", + "txid": "219cadf573630d346484043ab12c1199a78730036852870872eeee682f3496d7", + "to_address": "t1Yidqtcf4FeDzbZi4EBZkn84HyS9sypqWU", + "outpoint": "219cadf573630d346484043ab12c1199a78730036852870872eeee682f3496d7:0" + } + ], "zrune_events": [], "chain": "zcash" }, From 9aeae8411ea99915ed7ba24f36cf8cf97fa93a80 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 12:22:57 +0000 Subject: [PATCH 32/45] Stop a three minute job holding a runner the fifty minute one waits for Six runners in the fleet carry `linux-ultra` and they are the only ones that can run the visual matrix. Eight more carry `universe-ci` and were idle while this repository's runs queued behind each other for over an hour. The documentation gates are a checkout, a Node install and a set of Node scripts. No browser, no Rust, no Docker, and a ten minute ceiling it does not come close to. It runs anywhere now. The backend job stays on `linux-ultra` because it builds rust-gbt and no other runner advertises a Rust toolchain, and the frontend job stays because the matrix needs Chromium. --- .github/workflows/universe-ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml index bee1b38821f..cf832442eaa 100644 --- a/.github/workflows/universe-ci.yml +++ b/.github/workflows/universe-ci.yml @@ -28,7 +28,12 @@ concurrency: jobs: docs: name: Documentation gates - runs-on: [self-hosted, linux-ultra] + # Any runner in the fleet. This job is a checkout, a Node install and a set + # of Node scripts: no browser, no Rust, no Docker. Pinned to `linux-ultra` + # it competed for the six runners that are the only ones able to run the + # visual matrix, while eight runners carrying `universe-ci` sat idle, and a + # three minute job held a slot the fifty minute one was queued for. + runs-on: [self-hosted, universe-ci] timeout-minutes: 10 steps: - name: Checkout From 25f139285a1e43f8c58ef687be43ad0386970bbf Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 12:29:34 +0000 Subject: [PATCH 33/45] Run the five chain failure states, which nothing had ever run They are scoped to the chain routes, so the deep pass over the Bitcoin routes never reaches them, and they were reviewed by hand instead. Those five are the states the whole status vocabulary exists for: a chain that cannot be reached, one that is behind, one whose node is caught up while its protocol side is not, one that withholds readiness without saying why, and an object that is simply not there. They could not have run here before this change. A state that tells a request to fail gets exactly that, and Chromium writes its own line about the refusal, which the harness counted as an application console error. The five states whose entire purpose is a failing request therefore failed the gate by succeeding. A refused request on a state that declared it is now printed and not counted, matched narrowly: only on a state carrying a `status` override, only for the browser's own resource line, and every one of them listed in the summary. Anything the application throws still counts, and so does a resource failure on a state that asked for none. Measured on the current build: thirty-two refusals asked for, zero console errors, zero overflow, zero axe violations, zero contrast failures, zero unfinished pages. --- .github/workflows/universe-ci.yml | 20 ++++++++++++++ scripts/universe/visual-qa/capture.mjs | 36 ++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml index cf832442eaa..15c25fa7f53 100644 --- a/.github/workflows/universe-ci.yml +++ b/.github/workflows/universe-ci.yml @@ -220,6 +220,25 @@ jobs: node capture.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts --routes=graphs,mining,protocols,home,blocks,tx,address --viewports=320,768,1440 + # The chain failure states have never run here. They are scoped to the + # chain routes, so the pass above never reaches them, and they were + # reviewed by hand instead: a chain that cannot be reached, one that is + # behind, one whose node is caught up while its protocol side is not, one + # that withholds readiness without saying why, and an object that simply + # is not there. Those five are the states the whole status vocabulary + # exists for, and the one set nothing measured. + # + # Two themes and two widths rather than three and three, because the cost + # has to fit beside the pass above and this is the part of the matrix + # that catches wording and state, not layout. + - name: A chain that cannot answer says so, in every failure state + working-directory: scripts/universe/visual-qa + run: >- + node capture.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts-chain-states + --routes=dogecoin,dogecoin-drc20,zcash,zcash-block + --states=chain-authority-down,chain-behind,chain-not-ready-protocols,chain-not-ready-unexplained,chain-object-missing + --themes=default,dark --viewports=375,1440 + - name: Forced colours and 200 percent zoom working-directory: scripts/universe/visual-qa run: node modes-check.mjs --base=http://127.0.0.1:$GATEWAY_PORT @@ -232,4 +251,5 @@ jobs: path: | scripts/universe/visual-qa/artifacts scripts/universe/visual-qa/artifacts-broad + scripts/universe/visual-qa/artifacts-chain-states retention-days: 30 diff --git a/scripts/universe/visual-qa/capture.mjs b/scripts/universe/visual-qa/capture.mjs index 6646a3cb8dc..463b38b2d42 100644 --- a/scripts/universe/visual-qa/capture.mjs +++ b/scripts/universe/visual-qa/capture.mjs @@ -422,7 +422,28 @@ async function run() { if (!stateApplies(state, route.id)) continue; const page = await context.newPage(); const consoleErrors = []; - page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); }); + const expectedFetchErrors = []; + // A state that tells a request to fail gets exactly that, and the + // browser writes its own line about it. That line is the fixture + // speaking, not the application: the point of `chain-authority-down` + // is a 503, and the page handling it correctly still produces one + // console entry per refused request. Counted as a failure, the five + // states the status vocabulary exists for could never run here at + // all, which is why they never have. + // + // Narrow on purpose. Only on a state that declares a `status` + // override, only for the browser's own resource line, and every one + // is printed. Anything the application throws still counts, and so + // does a resource failure on a state that asked for none. + const stateFailsRequests = Object.values(ALL_OVERRIDES[state] ?? {}) + .some((override) => override && override.status); + const isRefusedFetch = (textLine) => + stateFailsRequests + && /Failed to load resource: the server responded with a status of \d+/.test(textLine); + page.on('console', (m) => { + if (m.type() !== 'error') return; + (isRefusedFetch(m.text()) ? expectedFetchErrors : consoleErrors).push(m.text()); + }); page.on('pageerror', (e) => consoleErrors.push(String(e))); const label = `${route.id}__${state}__${theme}__${viewport.id}`; @@ -563,7 +584,7 @@ async function run() { findings.push({ route: route.id, routeName: route.name, state, theme, viewport: viewport.id, - overflowBy, consoleErrors, brokenImages, violations, obscured, contrast, progress, settledAfterMs, + overflowBy, consoleErrors, expectedFetchErrors, brokenImages, violations, obscured, contrast, progress, settledAfterMs, }); } catch (error) { // A server that has gone away is not a page that failed. Reporting @@ -793,6 +814,17 @@ function summarise(report) { console.log(`\n=== ${report.browser} : ${report.screenshots} screenshots -> ${OUT}\n`); console.log(`horizontal overflow : ${overflow.length}`); console.log(`console errors : ${errors.length}`); + + // Printed, never counted. A state that asked a request to fail got one line + // per refusal, and a rule that is quietly not counted is a rule nobody sees. + const refused = report.findings.flatMap((f) => + (f.expectedFetchErrors ?? []).map((line) => `${f.route}/${f.state} ${line}`), + ); + if (refused.length) { + console.log(`refused by a failure fixture : ${refused.length} (asked for, not counted)`); + for (const line of refused.slice(0, 8)) console.log(` ${line}`); + if (refused.length > 8) console.log(` ... and ${refused.length - 8} more`); + } console.log(`broken images : ${images.length}`); console.log(`navigation failures : ${failed.length}`); console.log(`a11y rules violated : ${byRule.size}`); From d685ab14006147737286dc5a0a10d6b5247c4f03 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 12:56:46 +0000 Subject: [PATCH 34/45] Stop counting, and name the two gates that read the build I have now written four, then six, then corrected seven to six, and mempool-d3 has since found two more. The number was wrong every time within the hour, and a count that keeps rising is a worse fact about this than the count itself. It is gone. The two new ones are the most expensive yet, because they are the gates that read what we ship. check-text.mjs and check-branding.mjs, pointed at a frontend/dist that exists and is empty, walked it, found no files, and printed the sentence a clean output prints. An Angular build empties that directory and leaves it empty for the whole of the build, and both gates run against it in the release artifact workflow. Verified on this branch rather than taken on trust: pointed at an empty directory both now exit 1 and say there was nothing to check, which is not the same as nothing to find. Pointed at a missing path they exit 1. Pointed at the real output they still pass. That makes three gates in this repository with the same defect, found by two sessions in one afternoon, all of them failing by staying quiet. A gate is allowed to find nothing. It is not allowed to look at nothing and report it the same way. Co-Authored-By: Claude Opus 5 --- docs/product/DESIGN-SYSTEM.md | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/docs/product/DESIGN-SYSTEM.md b/docs/product/DESIGN-SYSTEM.md index c3461516acf..90e21d8211a 100644 --- a/docs/product/DESIGN-SYSTEM.md +++ b/docs/product/DESIGN-SYSTEM.md @@ -204,10 +204,12 @@ legible in greyscale, in print, and under forced colours. **The distinction has to survive the trip to get here.** These five states are the product's whole claim, and an interface can only show a difference the code -feeding it preserved. On 29 August 2026 six defects were found in one release, -across the product, the infrastructure, the release tooling and these gates, -with a single shape: somewhere, "I do not know" and "I know, and the answer is -no" were the same value. +feeding it preserved. On 29 August 2026 this shape was found again and again +through a single release, across the product, the infrastructure, the release +tooling and these gates: somewhere, "I do not know" and "I know, and the answer +is no" were the same value. The count is deliberately not given, because it +kept rising while this was being written, which is the more useful fact about +it. An indexer publishing `block_count` where the reader expected `blockCount` was read as publishing no checkpoint. A WebSocket handshake sent with no `Origin` @@ -216,13 +218,21 @@ refused. A field-name pattern matching `supply` read a token quantity as a coin amount. A Bitcoin node answering "no such block" was read as a failure, so a healthy chain was reported unavailable on every search. -The last two were in the machinery meant to protect all of this, which is where -the shape is most expensive because its failure mode is silence. GitHub reports -a pull request as `CLEAN` whether its checks passed, failed, or never ran, and a -promotion waiter reading that would have pushed an unvalidated commit to `main`. -The contrast probe in `scripts/universe/visual-qa` recorded an empty result when -it threw, which is what a clean page also produces, so a run that measured -nothing printed the same zero as a run that measured everything. +The worst of them were in the machinery meant to protect all of this, which is +where the shape is most expensive because its failure mode is silence. GitHub +reports a pull request as `CLEAN` whether its checks passed, failed, or never +ran, and a promotion waiter reading that would have pushed an unvalidated commit +to `main`. The contrast probe in `scripts/universe/visual-qa` recorded an empty +result when it threw, which is what a clean page also produces, so a run that +measured nothing printed the same zero as a run that measured everything. And +`check-text.mjs` and `check-branding.mjs`, pointed at a `frontend/dist` that +exists and is empty, which is its state for the whole of a build, read no files +and printed the sentence a clean output prints. Those two guard the release +artifact. + +All three now say when they measured nothing, and fail rather than pass. A gate +is allowed to find nothing. It is not allowed to look at nothing and report it +the same way. So the rule is not only about rendering. Anywhere this product reads something it does not fully control, a payload, a header, a field name, a status, it must From 628c029df3c881954458ad94e0737a8fe4733c81 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 13:02:10 +0000 Subject: [PATCH 35/45] My own table claimed it held nothing back while dropping the ledger mempool-d3 found the live ZRC-20 list printing its entire ruleset ledger as a JSON string in a table cell. That is the deployed build, and this branch already removes it. What this branch does instead turns out to be worse in one specific way, and it is mine. Driven against the payload production returns right now, the table renders six columns, silently drops rulesets and divergence, and reports hiddenColumnCount zero. So the page states it held nothing back at the moment it is holding back the most: the whole ledger for every token under two rulesets that disagree, the list of fields they disagree on, and the authority's own prose about a rule neither ruleset evaluates because no activation height for it exists. A JSON blob is ugly and present. This was readable, incomplete, and accompanied by a claim of completeness, which is the shape I have spent the day removing from other people's code and had just introduced into mine. A field a table cannot hold is now named rather than counted: the page says which structures each row carries, that this explorer has no reading for them yet, and that they are in the full response and nowhere else. This is deliberately not a reading for those structures. That is mempool-d3's next piece of work and it needs its own review: shifting by the stated decimals, showing the lens ruleset, putting each ruleset's figure beside the other rather than picking one. Splitting it also means the page stops lying about completeness now rather than when that lands. Co-Authored-By: Claude Opus 5 --- .../multichain-explorer.component.html | 4 ++++ .../multichain-view.spec.ts | 21 +++++++++++++++++++ .../multichain-explorer/multichain-view.ts | 17 +++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html index 2a244f0de3c..44bef95f1c8 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html @@ -592,6 +592,10 @@

{{ collection.title }}

i18n="universe.chain.collection-columns-constant"> {{ collection.constantColumnCount }} fields held the same value on every row, so they are not columns here.

+

+ Each row also carries {{ collection.structuredFields.join(', ') }}, which a table cannot hold. This explorer has no reading for them yet, so they are in the full response below and nowhere else on this page. +

- {{ utxo.height.display }} - not in a block yet + {{ utxo.height.display }} + + not in a block yet + not reported +
diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts index 81de41bb0c0..8cc8f6cde11 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts @@ -612,6 +612,27 @@ describe('readCollection', () => { expect(byKey.sizeBytesAtomic.unit).toBeNull(); }); + it('names the structured fields a table cannot hold, rather than dropping them', () => { + // The live ZRC-20 list carries rulesets and divergence on every row. They + // were skipped in silence while hiddenColumnCount reported zero, so the + // page claimed it had held nothing back while holding back the ledger. + const collection = readCollection( + { + items: [ + { tick: 'ZERO', decimals: '18', rulesets: { zord: { holders: '2330' } }, divergence: { diverges: true } }, + { tick: 'ONE', decimals: '18', rulesets: { zord: { holders: '11' } }, divergence: { diverges: false } }, + ], + }, + DOGE + ); + expect(collection?.structuredFields).toEqual(['rulesets', 'divergence']); + expect(collection?.columns.map((c) => c.key)).toEqual(['tick']); + }); + + it('reports no structured fields when every value is a scalar', () => { + expect(readCollection({ items: [{ a: '1' }, { a: '2' }] }, DOGE)?.structuredFields).toEqual([]); + }); + it('says how many fields per row the table left out', () => { const wide = { a: '1', b: '2', c: '3', d: '4', e: '5', f: '6', g: '7', h: '8', i: '9' }; const collection = readCollection({ items: [wide] }, DOGE); diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.ts index 202065d10e7..91a19c66aa7 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.ts @@ -1533,6 +1533,18 @@ export interface CollectionReading { readonly hiddenColumnCount: number; /** Fields that were identical on every row, and so became no column at all. */ readonly constantColumnCount: number; + /** + * Fields present in every row that a table cannot hold at all, because their + * value is a structure rather than a value. Named, not just counted. + * + * These were dropped in silence while `hiddenColumnCount` reported zero, so + * the page stated it had held nothing back at the moment it was holding back + * the most. On the live ZRC-20 list that is `rulesets`, the whole ledger for + * every token under two disagreeing rulesets, and `divergence`, which names + * the fields they disagree on and carries the authority's own prose about + * rules neither ruleset evaluates. + */ + readonly structuredFields: readonly string[]; } /** How many rows a page renders before it says how many it is holding back. */ @@ -1574,9 +1586,13 @@ export function readCollection( ); const order: string[] = []; + const structured: string[] = []; for (const row of scalarRows) { for (const [key, value] of Object.entries(row)) { if (value !== null && typeof value === 'object') { + if (!structured.includes(key)) { + structured.push(key); + } continue; } if (!order.includes(key)) { @@ -1621,6 +1637,7 @@ export function readCollection( totalCount: source.length, hiddenColumnCount, constantColumnCount, + structuredFields: structured, }; } From c13f8e587b4e14436998e98774dc98aef8c56e29 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 13:09:56 +0000 Subject: [PATCH 36/45] The restart test asserted an empty port it never owned Documentation gates failed on my head commit, on a test my branch does not touch: "an upstream that is genuinely gone is still reported, and promptly" gave up after 106191ms against a 12000ms bound. It takes 6.5 seconds locally and it passed on universe-runner-01 and universe-runner-03 earlier today. reservePorts counted from a fixed base of 8794. The test then asserts that nothing is listening on the upstream port, which is only a true premise if the test owns it. Where something else holds that port the connection is accepted and never answered, so instead of the instant ECONNREFUSED the gateway retries on, the request simply waits. That is the 106 seconds. This repository already learned this. The visual matrix step twelve lines away in the same workflow says a fixed port once had it measuring another job's build, and takes an operating system port now. This file never did, and while the docs job ran alone on the ultra pool it did not have to. Moving that job to the shared pool, which was the right change and made the fleet faster, turned a rare collision into a likely one. It found an old fault rather than causing a new one. Ports now come from the operating system. The "genuinely gone" test also checks its own premise first and refuses to run against a port something answers on, because a test that times a stranger's service reports on the stranger. Proven rather than assumed: with a listener deliberately placed on the reserved port, the guard fires and names it; on a free port it passes. All three restart tests and all fourteen gateway routing tests still pass. Co-Authored-By: Claude Opus 5 --- scripts/universe/gateway-restart.test.mjs | 71 +++++++++++++++++++---- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/scripts/universe/gateway-restart.test.mjs b/scripts/universe/gateway-restart.test.mjs index e7eb8e9b7fa..2e82c1719f1 100644 --- a/scripts/universe/gateway-restart.test.mjs +++ b/scripts/universe/gateway-restart.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; +import { createServer, connect } from 'node:net'; import { spawn } from 'node:child_process'; import { setTimeout as sleep } from 'node:timers/promises'; import { dirname, join } from 'node:path'; @@ -21,17 +22,64 @@ const HERE = dirname(fileURLToPath(import.meta.url)); const GATEWAY = join(HERE, 'gateway.mjs'); const ROOT = join(HERE, '..', '..', 'frontend', 'dist', 'mempool', 'browser'); -// Each test takes its own pair of ports. Sharing them let a gateway from a -// finished test still be listening when the next one started, and the next -// test then measured the wrong process. -let nextPort = 8794; -function reservePorts() { - const gatewayPort = nextPort; - const upstreamPort = nextPort + 1; - nextPort += 2; +// Each test takes its own pair of ports, and asks the operating system for +// them rather than counting from a fixed base. +// +// Sharing ports let a gateway from a finished test still be listening when the +// next one started, and the next test then measured the wrong process. Counting +// from 8794 has the same fault against anything outside this file: the test +// asserts that nothing is listening on the upstream port, on a port it never +// owned. On a runner where something else holds 8795 the connection is accepted +// and then never answered, so instead of the instant ECONNREFUSED the gateway +// retries on, the request waits. That is what happened on universe-runner-07: +// the same assertion that takes six seconds locally gave up after 106 seconds. +// +// The visual matrix in this repository learned this already, and its workflow +// step says so: a fixed port once had it measuring another job's build. Moving +// this job onto the shared runner pool turned a rare collision into a likely +// one, which is a good change finding an old fault rather than causing one. +function freePort() { + const server = createServer(); + server.listen(0, '127.0.0.1'); + return new Promise((resolve, reject) => { + server.once('listening', () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + server.once('error', reject); + }); +} + +async function reservePorts() { + const [gatewayPort, upstreamPort] = await Promise.all([freePort(), freePort()]); return { gatewayPort, upstreamPort }; } +/** + * Refuse to measure a port this test does not own. + * + * "An upstream that is genuinely gone" is only a true premise if nothing is + * listening. If something is, the test is timing a stranger's service and + * whatever it concludes is about them. + */ +async function assertNothingIsListening(port) { + const refused = await new Promise((resolve) => { + const socket = connect({ host: '127.0.0.1', port }); + const done = (answer) => { + socket.destroy(); + resolve(answer); + }; + socket.setTimeout(2_000); + socket.once('connect', () => done(false)); + socket.once('timeout', () => done(false)); + socket.once('error', (error) => done(error.code === 'ECONNREFUSED')); + }); + assert.ok( + refused, + `port ${port} is not free: something answered or hung, so this test would be measuring it rather than the gateway`, + ); +} + function startGateway({ gatewayPort, upstreamPort }) { return spawn(process.execPath, [GATEWAY], { env: { @@ -59,7 +107,7 @@ function ask(gatewayPort, path = '/api/v1/backend-info') { } test('a request waits for an upstream that is restarting, rather than failing', async (t) => { - const ports = reservePorts(); + const ports = await reservePorts(); const gateway = startGateway(ports); t.after(() => gateway.kill()); await sleep(1200); @@ -80,7 +128,8 @@ test('a request waits for an upstream that is restarting, rather than failing', }); test('an upstream that is genuinely gone is still reported, and promptly', async (t) => { - const ports = reservePorts(); + const ports = await reservePorts(); + await assertNothingIsListening(ports.upstreamPort); const gateway = startGateway(ports); t.after(() => gateway.kill()); await sleep(1200); @@ -96,7 +145,7 @@ test('a reader who leaves mid-retry does not take the gateway down', async (t) = // This crashed the gateway in CI. With no upstream listening, a request // enters the retry loop; the client then goes away, and the write that // follows throws from a timer callback where there is no request to fail. - const ports = reservePorts(); + const ports = await reservePorts(); const gateway = startGateway(ports); t.after(() => gateway.kill()); await sleep(1200); From dcbf20843e6cd73d0ba1f2a502607f1c60f887c4 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 14:13:50 +0000 Subject: [PATCH 37/45] Production smoke had no idea the chain domain existed The defect PR 12 fixed was every chain route answering 404 in production while the site itself loaded. That is precisely what a production smoke check exists to catch, and this one would have sailed through it: it probes capabilities, statistics, mining, universe protocols and sources, and backend-info, and nothing else. The defect was found by hand, and until now the check still could not see it. Four things are asserted, each drawn from something that actually broke. The directory serves all three chains by name. Three envelopes of which one is a chain this product does not serve passes a length check and is wrong. Each chain's routes reach the service that owns them. An invalid network query must be rejected with 400 by the chain API; the Bitcoin backend, where these requests went when the gateway was wrong, has no such route and answers 404. So 400 proves the overlay saw the request and 404 proves it did not. The status route is used because it is the one route every chain has: an earlier version sent a malformed txid, which works on Dogecoin and Zcash and is wrong on Bitcoin, whose transactions the explorer backend serves. Each capability document is readable against the contract rather than merely non-empty, because a 200 carrying the wrong body is what a misrouted family looks like when something else happens to answer. Reads a chain declares are served. This is the assertion that had to be built carefully, because `reads` states what lookup the explorer offers and not whether it will succeed today. A 503 with a named reason passes and is recorded: that is Dogecoin telling the truth while its index backfills, and a check that cries wolf for a day gets ignored on the day it is right. A 404 is read from the body rather than the status, because "no such route" and "no such object" both answer 404 and collapsing them makes this either blind to a misrouted family or noisy about every identifier that does not exist. Also a live socket handshake carrying a browser Origin, asserted at 101. The cutover gate runs that against the loopback gateway; this runs hourly against the public origin, which is the hop that gate cannot see, and which is exactly where the CORS defect lived. Written against node:http because fetch will not perform an upgrade, and rejects in a way that reads as an unreachable server. Verified twice. Against production as it stands, with Dogecoin degraded: every chain assertion passes, and the only failure is the pre-existing Doge TAP checkpoint, which is honest. And against a deliberately broken origin, one fault at a time: the whole domain misrouted, only the per-chain families misrouted, a directory naming a chain we do not serve, a socket refusing a browser Origin, and a declared read answering 500. All five caught, with a no-fault control passing every chain check. Co-Authored-By: Claude Opus 5 --- scripts/universe/synthetic-check.mjs | 297 +++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) diff --git a/scripts/universe/synthetic-check.mjs b/scripts/universe/synthetic-check.mjs index 0ebf6789fa5..a56b6d13cb4 100644 --- a/scripts/universe/synthetic-check.mjs +++ b/scripts/universe/synthetic-check.mjs @@ -223,6 +223,291 @@ async function checkReleaseAgreement() { } } + +/** + * The chain domain: three chains, their capability documents, and the routes + * those documents claim. + * + * This is the check that was missing when it was most needed. Every chain + * route answered 404 in production while the site itself loaded, because the + * gateway sent everything outside /api/v1/universe to the Bitcoin backend, and + * nothing here looked. It was found by hand. + * + * The distinction this has to keep is between a chain that cannot answer and a + * chain that will not. A Dogecoin authority in an index recovery is a chain + * truthfully reporting unavailable, and it must pass. A chain whose document + * says it can answer while the route does not is a deployment fault, and it + * must fail. Those two look identical if you only count HTTP statuses, which + * is why the document is read first and the routes are checked against what it + * claimed. + */ +const EXPLORER_CHAINS = ['bitcoin', 'dogecoin', 'zcash']; + +/** + * Reads the capability document declares, and the route each one implies. + * + * Only for the chains whose object reads the chain domain actually serves. + * Bitcoin is not one of them: it declares the same reads and they are served + * by the explorer backend at /api/tx and /api/block, which the rest of this + * suite already exercises. Its chain-domain surface is status and mempool, and + * those are checked directly. + * + * Address is absent because every chain has its own address alphabet and a + * wrong one is rejected before the route is reached, which would prove + * nothing. Fee estimates and projected blocks are not routes of their own. + */ +const CHAIN_OBJECT_READ_CHAINS = ['dogecoin', 'zcash']; +const CHAIN_READ_ROUTES = { + transaction: (chain) => `/api/v1/${chain}/tx/${'0'.repeat(64)}`, + block: (chain) => `/api/v1/${chain}/block/1`, + outpoint: (chain) => `/api/v1/${chain}/outpoint/${'0'.repeat(64)}/0`, +}; + +/** + * Telling a missing route from an absent object, both of which answer 404. + * + * This is the same distinction the whole product turns on, applied to the + * check itself: "there is no such route" and "there is no such object" are + * different answers, and collapsing them makes this either blind to a + * misrouted family or noisy about every identifier that does not exist. + * + * The framework answers an unmounted path with "Cannot GET /some/path". A + * service answering about an object names its own reason instead, such as + * zcash-transaction-not-found. So the body decides, not the status. + */ +function isRouteMissing(body) { + return typeof body?.message === 'string' && body.message.startsWith('Cannot GET'); +} + +function namedReason(body) { + if (typeof body?.error === 'string' && body.error.includes('-')) { + return body.error; + } + if (typeof body?.message === 'string') { + return body.message; + } + return 'no reason given'; +} + +function chainEnvelopeProblems(chain, envelope) { + if (!envelope || typeof envelope !== 'object') { + return ['no envelope']; + } + const problems = []; + if (envelope.schemaVersion !== 'universe-chain-capability-v1') { + problems.push(`schemaVersion is ${JSON.stringify(envelope.schemaVersion)}`); + } + if (envelope.chain !== chain) { + problems.push(`chain is ${JSON.stringify(envelope.chain)}`); + } + if (envelope.network !== 'mainnet') { + problems.push(`network is ${JSON.stringify(envelope.network)}`); + } + if (!envelope.reads || typeof envelope.reads !== 'object') { + problems.push('no reads block'); + } + if (!envelope.mempool || typeof envelope.mempool.state !== 'string') { + problems.push('no mempool state'); + } + // A tip may legitimately be null on a chain that cannot reach its node. What + // it may not be is a shape nobody can read. + if (envelope.tip !== null && typeof envelope.tip?.heightAtomic !== 'string') { + problems.push('tip is neither null nor a checkpoint'); + } + return problems; +} + +/** + * Every chain the product serves must be present by name. Three envelopes of + * which one is a chain we do not serve passes a length check and is wrong. + */ +async function checkChainDirectory() { + const { status, body } = await get('/api/v1/chains'); + if (status !== 200 || !Array.isArray(body)) { + fail('chains', `/api/v1/chains answered HTTP ${status}`); + return null; + } + const served = new Set( + body.filter((entry) => entry && typeof entry.chain === 'string').map((entry) => entry.chain), + ); + const missing = EXPLORER_CHAINS.filter((chain) => !served.has(chain)); + const unexpected = [...served].filter((chain) => !EXPLORER_CHAINS.includes(chain)); + for (const chain of missing) { + fail('chains', `/api/v1/chains does not include ${chain}`); + } + if (unexpected.length) { + fail('chains', `/api/v1/chains includes ${unexpected.join(', ')}, which this product does not serve`); + } + if (!missing.length && !unexpected.length) { + pass('chains', `/api/v1/chains serves ${EXPLORER_CHAINS.join(', ')}`); + } + return served; +} + +/** + * A chain's own status route must answer, and its document must be readable + * against the contract. A 200 carrying the wrong body is what a misrouted + * route family looks like when something else happens to answer. + */ +async function checkChainStatus(chain) { + const { status, body } = await get(`/api/v1/${chain}/status`); + if (status !== 200) { + fail(`chain:${chain}`, `/api/v1/${chain}/status answered HTTP ${status}`); + return null; + } + const problems = chainEnvelopeProblems(chain, body); + if (problems.length) { + fail(`chain:${chain}`, `capability document is not readable: ${problems.join('; ')}`); + return null; + } + const offered = Object.entries(body.reads || {}) + .filter(([, available]) => available === true) + .map(([name]) => name); + pass( + `chain:${chain}`, + `declares ${offered.length ? offered.join(', ') : 'no reads'}, confirmed history ${body.coverage?.confirmedHistory}`, + ); + return body; +} + +/** + * Proof that a chain route family reaches the service that owns it. + * + * An invalid network query must be rejected by the chain API with a 400. The + * Bitcoin backend, which is where these requests went when the gateway was + * wrong, has no such route and answers 404. So a 400 proves the overlay saw + * the request and a 404 proves it did not, which is exactly the failure that + * shipped and that nothing here could see. + * + * The status route is used because it is the one route every chain has. An + * earlier version sent a malformed txid, which works for Dogecoin and Zcash + * and is wrong for Bitcoin: the chain domain gives Bitcoin only status and + * mempool, and its transactions are served by the explorer backend at + * /api/tx/:txid, so a 404 there was a correct answer being read as a fault. + * + * This runs whatever the chain's own health is: a chain in an index recovery + * still has its routes mounted, and the point of this check is the wiring + * rather than the data behind it. + */ +async function checkChainRoutingReachesTheOverlay(chain) { + const path = `/api/v1/${chain}/status?network=not-a-network`; + const { status } = await get(path); + if (status === 400) { + pass(`chain:${chain}`, 'chain routes reach the service that owns them'); + } else if (status === 404) { + fail(`chain:${chain}`, `${path} answered 404, so this route family is not reaching the overlay`); + } else { + fail(`chain:${chain}`, `${path} answered HTTP ${status}, expected 400 for an invalid network`); + } +} + +/** + * The reads a chain claims must actually be served. + * + * `reads` is a static statement of what kind of lookup this explorer offers + * for a chain. It is not a promise that the lookup will succeed right now: + * that lives in `coverage` and `degradedReasons`. Conflating the two is the + * first mistake this check made, and it failed Dogecoin for telling the truth. + * + * So a 503 passes and is recorded. It is the authority saying it cannot answer, + * with a named reason, which is exactly what the pages render. A 404 is the + * route family missing, and a 500 or 502 is a fault; both fail. + * + * The identifiers are deliberately ones no chain has, an all-zero txid and + * block one, so a 404 is expected. Whether that 404 means the route is missing + * or the object is is read from the body rather than guessed from the status, + * which is what isRouteMissing is for. The route family is also proved + * independently by the invalid-network request above. + */ +async function checkChainReadsAreServed(chain, envelope) { + if (!CHAIN_OBJECT_READ_CHAINS.includes(chain)) { + return; + } + for (const [read, route] of Object.entries(CHAIN_READ_ROUTES)) { + if (envelope.reads?.[read] !== true) { + continue; + } + const path = route(chain); + const { status, body } = await get(path); + if (status === 503) { + pass(`chain:${chain}`, `${read} is offered and currently unavailable: ${namedReason(body)}`); + } else if (status === 404 && isRouteMissing(body)) { + fail(`chain:${chain}`, `declares ${read} but ${path} is not served by this deployment`); + } else if (status === 404) { + pass(`chain:${chain}`, `${read} route answers, and has no such object: ${namedReason(body)}`); + } else if (status >= 500) { + fail(`chain:${chain}`, `declares ${read} but ${path} answered HTTP ${status}`); + } else { + pass(`chain:${chain}`, `${read} route answers HTTP ${status}`); + } + } +} + +/** + * The live socket must accept the handshake a browser sends. + * + * The overlay refuses an upgrade whose Origin is not allowlisted, and allows + * one sent with no Origin at all, which is exactly what curl sends. So every + * probe reported the socket healthy while every real browser was refused and + * the frontend fell back to its fifteen second poll. A handshake without an + * Origin header proves nothing; this sends one. + * + * The cutover gate runs the same assertion against the loopback gateway. This + * one runs against the public origin, which is the hop that gate cannot see, + * and which is where the defect actually lived. + * + * Written against node:http rather than fetch, because fetch will not perform + * an upgrade: it rejects the request outright, which reads as an unreachable + * server rather than a refused handshake. + */ +async function checkLiveSocketAcceptsABrowser() { + const url = new URL(`${ORIGIN}/api/v1/universe/ws`); + const secure = url.protocol === 'https:'; + const transport = secure ? await import('node:https') : await import('node:http'); + const target = `${secure ? 'wss:' : 'ws:'}//${url.host}${url.pathname}`; + + const status = await new Promise((resolve) => { + const request = transport.request( + { + host: url.hostname, + port: url.port || (secure ? 443 : 80), + path: url.pathname, + headers: { + connection: 'Upgrade', + upgrade: 'websocket', + 'sec-websocket-version': '13', + 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==', + origin: ORIGIN, + }, + timeout: REQUEST_TIMEOUT_MS, + }, + (response) => { + response.destroy(); + resolve(response.statusCode); + }, + ); + // A successful upgrade never emits 'response'; it emits 'upgrade'. + request.on('upgrade', (response, socket) => { + socket.destroy(); + resolve(response.statusCode); + }); + request.on('timeout', () => { + request.destroy(); + resolve('timeout'); + }); + request.on('error', (error) => resolve(`error ${error.message}`)); + request.end(); + }); + + if (status === 101) { + pass('live-socket', 'accepts a handshake carrying a browser Origin'); + } else if (status === 403) { + fail('live-socket', `${target} refused a browser Origin with 403; every real browser falls back to polling`); + } else { + fail('live-socket', `${target} answered ${status}, expected 101`); + } +} + async function main() { console.log(`Synthetic check against ${ORIGIN}`); @@ -230,6 +515,18 @@ async function main() { await checkReleaseAgreement(); await checkProtocolsAreTruthful(); + const chains = await checkChainDirectory(); + if (chains) { + for (const chain of EXPLORER_CHAINS) { + await checkChainRoutingReachesTheOverlay(chain); + const envelope = await checkChainStatus(chain); + if (envelope) { + await checkChainReadsAreServed(chain, envelope); + } + } + } + await checkLiveSocketAcceptsABrowser(); + if (capabilities?.features?.statistics?.enabled) { await checkRoutesExist('charts', [ '/api/v1/statistics/2h', From 62b5075d774f104a5c577587f99e22f8a2083013 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 14:15:07 +0000 Subject: [PATCH 38/45] Write down what the matrix actually costs, not what it cost once The comment beside the visual passes said they were measured at about two and a half minutes and sixteen. The deep pass took forty-seven minutes and 504 screenshots on the run that merged PR 14, so the recorded figure is now off by a factor of three, and it is the figure the next person will plan against. Nothing is failing and nothing needs doing today. The job finished in 59m57s of its 100 minute cap. The reason to write the real numbers down is that the deep pass is routes times states times themes times widths, one more route costs about seven minutes, and whoever adds one is currently working from sixteen. They would meet the ceiling as a timeout forty-seven minutes into a run rather than as a number they could have added up beforehand. So the comment carries the measurement, the run it came from, the runner it ran on, and the per-pass breakdown, plus what to do if it needs to grow: split it again, the way it was split from the broad pass in the first place, rather than raising the cap. A gate that cannot finish protects nothing, which is the reason that split exists. Found by mempool-d3 reading the timings of their own new step and noticing the number beside it did not match the one in the file. Co-Authored-By: Claude Opus 5 --- .github/workflows/universe-ci.yml | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml index 15c25fa7f53..27bdf38f0b3 100644 --- a/.github/workflows/universe-ci.yml +++ b/.github/workflows/universe-ci.yml @@ -206,8 +206,30 @@ jobs: # finishes show up. The deep pass takes the routes this suite holds to a # terminal state through every failure state, every theme and the widths # that change the layout, which is where a page that lies about why it is - # empty shows up. Measured at about two and a half minutes and sixteen - # minutes respectively. + # empty shows up. + # + # Measured on run 33254326169, 2026-08-29, on universe-linux-ultra-02: + # + # broad pass 64 screenshots 5m50s + # deep pass 504 screenshots 47m10s + # chain failures 48 screenshots 3m27s + # forced colours and zoom 30s + # whole job 59m57s of a 100 minute cap + # + # This comment said two and a half minutes and sixteen. The deep pass has + # since grown to roughly three times that, it is now 78 percent of the + # job, and the job sits at 60 of its 100 minutes. Nothing is failing. The + # point of writing the real numbers down is that the next person to add a + # route, a state or a theme to the deep pass is the one who meets the + # cap, and without this they meet it as a timeout forty-seven minutes + # into a run rather than as a number they could have added up first. + # + # The deep pass is routes multiplied by states multiplied by themes + # multiplied by widths. Adding one route to it currently costs about + # seven minutes. If it needs to grow again, split it the way it was split + # from the broad pass in the first place, rather than raising the cap: + # a gate that cannot finish protects nothing, which is the reason the + # split exists. - name: Every route renders and finishes, at a phone and a desktop width working-directory: scripts/universe/visual-qa run: >- From c0531b936415502474b75c8638e974161c34dc2b Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 22:09:53 +0000 Subject: [PATCH 39/45] The overview said address history was offered and could not be read The chain overview rendered `reads` and dropped `coverage`. They answer different questions: `reads` is the static statement of which lookups this explorer offers for a chain, and `coverage` is the live one, how much of the chain each of them can currently answer over. Dogecoin declares both, and on a day its confirmed history source was down the page said "Address history: Offered" while the same document beside it said address history was unavailable. Both statements were in the envelope; only one was on the page. So the three coverage dimensions are stated, always all three, beside the explanation of why the chain is not ready. One says what is not answering and the other says what that costs a reader, and at a desktop width they now share a row that the fault panel used to leave three quarters empty. The technical identifiers go back on the page as well, at the level they belong at. The snapshot identifier, the schema version, the exact observation time and the overlay's own commit are how an operator checks a rendering against the document it came from, and the redesign that took the snapshot identifier out of the status line took all of them off the page entirely. They are filed in a disclosure that is closed by default, the snapshot identifier is shortened in the middle where it is shown, and the whole value is carried for a screen reader and for a copy control, because a shortened identifier nobody can recover is evidence destroyed rather than evidence filed. The rail's freshness reading gains the instant behind it. "3 seconds ago" is the one figure there that changes while nobody is looking, and it carried no way to recover the time it was measured from. --- .../multichain-explorer.component.html | 78 +++++-- .../multichain-explorer.component.scss | 201 +++++++++++++++++- .../multichain-explorer.component.ts | 51 ++++- .../multichain-view.spec.ts | 98 +++++++++ .../multichain-explorer/multichain-view.ts | 167 ++++++++++++++- 5 files changed, 579 insertions(+), 16 deletions(-) diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html index 44bef95f1c8..53e94781606 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html @@ -35,19 +35,37 @@

{{ chainName }} {{ pageLabel() }}

- -
-

Why {{ chainName }} is not ready

-
    -
  • - {{ reason.text }} - Reported as this code, which this build has no description for. -
  • -
-
+ +
+
+

Why {{ chainName }} is not ready

+
    +
  • + {{ reason.text }} + Reported as this code, which this build has no description for. +
  • +
+
+ +
+

How much history is readable now

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

What this chain + +
+ 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 }} +
+
+
+
+ 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 5b1d9e1988d..39e4ca08d64 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss @@ -217,6 +217,97 @@ font-size: var(--u-text-xs); } +// --------------------------------------------------------------------------- +// Readiness band +// --------------------------------------------------------------------------- + +// What is wrong, and what it costs, read together. +// +// Both were prose columns the width of the container, which at a desktop width +// left the fault panel a narrow card with an empty half page beside it. Side by +// side they fill the row for a reason, and each keeps a readable measure. +.readiness-band { + display: grid; + gap: var(--u-space-3); + margin-bottom: var(--u-space-3); + + .not-ready, + .history-coverage { + margin-bottom: 0; + max-width: none; + } +} + +@media (min-width: 992px) { + .readiness-band.fault { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: start; + } + + // A ready chain has no fault panel beside this one, so stacking the three + // dimensions left a full width card with content in its first third. Read + // across instead: three short columns fill the row they are given. + .readiness-band:not(.fault) .history-coverage ul { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--u-space-4); + } + + .readiness-band:not(.fault) .history-coverage li { + grid-template-columns: minmax(0, 1fr); + align-content: start; + justify-items: start; + gap: var(--u-space-1); + } +} + +// The three history dimensions the capability document reports. Bordered like +// the fault panel beside it rather than raised, because it qualifies the rail +// above rather than opening a new section. +.history-coverage { + @include u.universe-surface; + + border-left: 3px solid var(--universe-state-neutral); + padding: var(--u-space-3) var(--u-space-4); + + h2 { + font-size: var(--u-text-base); + font-weight: var(--u-weight-semibold); + margin: 0 0 var(--u-space-2); + } + + ul { + display: grid; + gap: var(--u-space-2); + list-style: none; + margin: 0; + padding: 0; + } + + // One column for the names and one for the states, shared by all three + // rows, so the states line up and can be read down. Sized per row they + // stepped sideways with the length of each name. + li { + display: grid; + grid-template-columns: minmax(0, 10rem) minmax(0, max-content); + align-items: center; + justify-content: start; + gap: var(--u-space-2); + } + + .coverage-label { + font-size: var(--u-text-sm); + font-weight: var(--u-weight-semibold); + } + + .coverage-detail { + grid-column: 1 / -1; + color: var(--u-text-muted); + font-size: var(--u-text-xs); + line-height: var(--u-leading-snug); + max-width: var(--u-width-prose); + } +} + .identifier { @include u.universe-identifier; } @@ -284,7 +375,10 @@ .read-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + // Capped, not stretched. At a desktop width four equal fractions put each + // label and its state at opposite ends of a wide cell, with the whitespace + // between them doing the binding. + grid-template-columns: repeat(auto-fit, minmax(15rem, 20rem)); gap: var(--u-space-2) var(--u-space-4); li { @@ -708,6 +802,102 @@ tbody tr:last-child td { } } +// --------------------------------------------------------------------------- +// Technical source details +// --------------------------------------------------------------------------- + +// The identifiers the page was rendered from. Closed by default: the snapshot +// identifier used to be the widest thing in the primary status line and said +// nothing to almost everyone reading it, and it is still needed exactly by the +// few people checking a figure against its document. +.source-details { + summary { + cursor: pointer; + font-weight: var(--u-weight-semibold); + color: var(--u-text-secondary); + } + + &[open] summary { + margin-bottom: var(--u-space-3); + } +} + +.source-list { + display: grid; + grid-template-columns: minmax(0, 12rem) minmax(0, 1fr); + gap: var(--u-space-2) var(--u-space-4); + margin: 0; + + dt { + color: var(--u-text-muted); + font-size: var(--u-text-sm); + font-weight: var(--u-weight-semibold); + min-width: 0; + } + + dd { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--u-space-2); + margin: 0; + min-width: 0; + } +} + +.source-value { + @include u.universe-identifier; + + // A shortened identifier still has to survive a narrow column and a doubled + // font size without pushing the page sideways. + overflow-wrap: anywhere; + min-width: 0; +} + +// The whole identifier, for a screen reader and for anything reading the page +// rather than looking at it. Clipped rather than `display: none`, which would +// take it away from both. +.source-exact { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.source-note { + flex-basis: 100%; + color: var(--u-text-muted); + font-size: var(--u-text-xs); + line-height: var(--u-leading-snug); + max-width: var(--u-width-prose); +} + +.source-copy { + background: none; + border: 1px solid var(--u-border); + border-radius: var(--u-radius-sm); + color: var(--u-text-secondary); + cursor: pointer; + font-size: var(--u-text-2xs); + font-weight: var(--u-weight-semibold); + letter-spacing: var(--u-eyebrow-tracking); + padding: 0.15rem var(--u-space-2); + text-transform: uppercase; + + @include u.universe-motion(all, var(--u-duration-fast)); + + &:hover, + &:focus-visible { + border-color: var(--u-brand-accent); + color: var(--u-brand); + } +} + // --------------------------------------------------------------------------- // Narrow // --------------------------------------------------------------------------- @@ -736,4 +926,13 @@ tbody tr:last-child td { min-width: 0; flex-basis: 100%; } + + .source-list { + grid-template-columns: 1fr; + gap: var(--u-space-1) 0; + } + + .source-list dt { + margin-top: var(--u-space-2); + } } 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 3c7d67266f7..6951ac0bd48 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts @@ -1,6 +1,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, + ChangeDetectorRef, Component, OnDestroy, OnInit, @@ -29,11 +30,13 @@ import { ChainProfile, ChainShape, CollectionReading, + CoverageReading, EmptyListReading, Fact, OutpointReading, ProtocolReading, ReadCapability, + SourceDetail, StatusReading, TransactionListReading, TransactionReading, @@ -44,11 +47,13 @@ import { readCapabilities, readCollection, readEmptyList, + readHistoryCoverage, readIdentifierList, readNotReadyReasons, readOutpoint, readProtocolCoverage, readRecordFacts, + readSourceDetails, readStatusRail, readTransaction, readTransactionList, @@ -91,7 +96,9 @@ interface ExplorerViewModel { /** Why the chain withholds readiness. Null when it does not. */ readonly notReady: readonly ChainReasonReading[] | null; readonly reads: readonly ReadCapability[]; + readonly coverage: readonly CoverageReading[]; readonly protocols: readonly ProtocolReading[]; + readonly sourceDetails: readonly SourceDetail[]; readonly shape: ChainShape; readonly transaction: TransactionReading | null; readonly block: BlockReading | null; @@ -195,7 +202,8 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { private readonly api: UniverseApiService, private readonly live: UniverseWebsocketService, private readonly local: UniverseLocalService, - private readonly seo: SeoService + private readonly seo: SeoService, + private readonly changes: ChangeDetectorRef ) { this.chain = this.chainFromUrl(router.url); this.profile = chainProfile(this.chain); @@ -295,7 +303,9 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { rail: readStatusRail(capability, this.profile, Date.now()), notReady: readNotReadyReasons(capability), reads: readCapabilities(capability), + coverage: readHistoryCoverage(capability), protocols: readProtocolCoverage(capability, this.profile), + sourceDetails: readSourceDetails(capability), shape, transaction, block, @@ -411,6 +421,45 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { .join(', '); } + /** + * The identifier a technical detail was last copied from. + * + * Kept so the confirmation names what was copied rather than appearing next + * to every row at once, and cleared on the next copy rather than on a timer, + * because a message that vanishes on its own is one a screen reader can miss. + */ + copiedDetailId: string | null = null; + + /** + * What the copy control is called, naming the value it copies. + * + * A row of buttons all reading "Copy" is unusable without the table around + * them, which a screen reader is under no obligation to convey, and the + * confirmation has to reach the same place the action did. + */ + copyActionLabel(detail: { id: string; label: string }): string { + return this.copiedDetailId === detail.id + ? $localize`:@@universe.chain.source-copied-label:Copied ${detail.label}:LABEL:` + : $localize`:@@universe.chain.source-copy-label:Copy ${detail.label}:LABEL:`; + } + + async copyDetail(detail: { id: string; exact: string | null }): Promise { + if (!detail.exact) { + return; + } + try { + await navigator.clipboard.writeText(detail.exact); + this.copiedDetailId = detail.id; + this.changes.markForCheck(); + } catch { + // A browser that refuses the clipboard has still shown the exact value, + // which is selectable. Saying "copied" when nothing was would be worse + // than saying nothing. + this.copiedDetailId = null; + this.changes.markForCheck(); + } + } + trackById(_index: number, item: { id: string }): string { return item.id; } diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts index 8cc8f6cde11..6c4827a359e 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts @@ -17,11 +17,13 @@ import { readCapabilities, readCollection, readEmptyList, + readHistoryCoverage, readNotReadyReasons, readOutpoint, readPaging, readProtocolCoverage, readRecordFacts, + readSourceDetails, readStatusRail, readTransaction, shortenIdentifier, @@ -246,6 +248,102 @@ describe('readStatusRail', () => { }); }); +describe('readStatusRail freshness', () => { + // "3 seconds ago" is the reading and the instant behind it was dropped, so + // the one figure on the rail that changes on its own could not be checked + // against anything. + it('carries the exact observation time behind the relative one', () => { + const reading = readStatusRail(capability(), DOGE, Date.now()).find( + (r) => r.id === 'freshness' + ); + expect(reading?.exact).toBe('2026-08-29T05:00:00.000Z'); + }); +}); + +describe('readHistoryCoverage', () => { + it('reports all three dimensions, in order', () => { + const readings = readHistoryCoverage(capability()); + expect(readings.map((r) => r.id)).toEqual([ + 'confirmedHistory', + 'addressHistory', + 'protocolHistory', + ]); + }); + + // The overview rendered `reads` and dropped `coverage`, so Dogecoin said + // address history was offered while the same document said it could not be + // read. Both statements are in the envelope and both have to be on the page. + it('says an unavailable history is unavailable while the read is still offered', () => { + const envelope = capability({ + ready: false, + coverage: { + confirmedHistory: 'unavailable', + addressHistory: 'unavailable', + protocolHistory: 'unavailable', + }, + }); + expect(readCapabilities(envelope).find((r) => r.id === 'address')?.state).toBe( + 'offered' + ); + const address = readHistoryCoverage(envelope).find( + (r) => r.id === 'addressHistory' + ); + expect(address?.tone).toBe('unavailable'); + expect(address?.stateLabel).not.toBe('Complete'); + }); + + it('states the dimensions rather than dropping them when nothing answered', () => { + const readings = readHistoryCoverage(null); + expect(readings).toHaveLength(3); + expect(readings.every((r) => r.tone === 'neutral')).toBe(true); + }); +}); + +describe('readSourceDetails', () => { + it('keeps the whole snapshot identifier for copying and shortens what is shown', () => { + const long = 'dogecoin-mainnet-9304b4b3a53e788898da63a1b52cd509'; + const detail = readSourceDetails( + capability({ + mempool: { + supported: true, + state: 'ready', + completeness: 'complete', + snapshotId: long, + sequenceAtomic: '10916', + observedAt: '2026-08-29T05:00:00.000Z', + }, + }) + ).find((entry) => entry.id === 'snapshot'); + expect(detail?.exact).toBe(long); + expect(detail?.display).not.toBe(long); + expect(detail?.display.length).toBeLessThan(long.length); + expect(detail?.truncated).toBe(true); + }); + + it('names which component the release identifier belongs to', () => { + const detail = readSourceDetails(capability()).find( + (entry) => entry.id === 'release' + ); + expect(detail?.exact).toBe('abc1234'); + expect(detail?.note).toContain('overlay'); + expect(detail?.truncated).toBe(false); + }); + + it('carries the exact observation time and the schema the document declares', () => { + const details = readSourceDetails(capability()); + expect(details.find((entry) => entry.id === 'observed')?.exact).toBe( + '2026-08-29T05:00:00.000Z' + ); + expect(details.find((entry) => entry.id === 'schema')?.display).toBe( + 'universe-chain-capability-v1' + ); + }); + + it('has nothing to file when no document was read', () => { + expect(readSourceDetails(null)).toEqual([]); + }); +}); + describe('readCapabilities', () => { it('lists every read, marking the ones the chain does not offer', () => { const reads = readCapabilities(capability()); diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.ts index 91a19c66aa7..d39a6999a11 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.ts @@ -692,7 +692,11 @@ export function readStatusRail( label: $localize`:@@universe.chain.rail-observed:Last observed`, value: elapsed ?? $localize`:@@universe.chain.rail-observed-unknown:Time not stated`, tone: elapsed ? 'neutral' : 'partial', - exact: null, + // "3 seconds ago" is the reading, and it is the one that changes while + // nobody is looking. The instant it was measured from is the fact behind + // it, and it was dropped: the rail carried a relative time with no way to + // recover the absolute one it was derived from. + exact: capability.updatedAt ?? null, }, { id: 'mempool', @@ -736,6 +740,167 @@ export function readNotReadyReasons( ]; } +/** + * How much history is readable right now, which is a different question from + * which lookups this explorer offers. + * + * `reads` is a static statement of the kinds of lookup that exist for a chain. + * `coverage` is the live one: how much of the chain each of them can currently + * answer over. The overview rendered the first and dropped the second, so + * Dogecoin read "Address history: Offered" while the same document said its + * address history was unavailable. Both statements were true and only one was + * on the page, which is the shape of turning unavailable into nothing at all. + * + * Three dimensions, always all three, in the order a reader needs them: the + * confirmed chain first, then addresses over it, then the protocols on top. + */ +export interface CoverageReading { + readonly id: string; + readonly label: string; + readonly stateLabel: string; + readonly tone: EvidenceTone; + /** What this dimension covers, in words, so the state is not read alone. */ + readonly detail: string; +} + +const COVERAGE_LABELS: readonly { + id: keyof ChainCapabilityEnvelope['coverage']; + label: string; + detail: string; +}[] = [ + { + id: 'confirmedHistory', + label: $localize`:@@universe.chain.coverage-confirmed:Confirmed history`, + detail: $localize`:@@universe.chain.coverage-confirmed-detail:Blocks and transactions the chain has already confirmed.`, + }, + { + id: 'addressHistory', + label: $localize`:@@universe.chain.coverage-address:Address history`, + detail: $localize`:@@universe.chain.coverage-address-detail:What an address received and spent over that confirmed history.`, + }, + { + id: 'protocolHistory', + label: $localize`:@@universe.chain.coverage-protocol:Protocol history`, + detail: $localize`:@@universe.chain.coverage-protocol-detail:Assets carried on this chain, as the protocol indexers below read them.`, + }, +]; + +export function readHistoryCoverage( + capability: ChainCapabilityEnvelope | null +): readonly CoverageReading[] { + return COVERAGE_LABELS.map(({ id, label, detail }) => { + const state = capability?.coverage?.[id]; + return { + id, + label, + stateLabel: capability + ? completenessLabel(state) + : $localize`:@@universe.chain.coverage-unknown:Not stated`, + tone: capability ? completenessTone(state) : ('neutral' as EvidenceTone), + detail, + }; + }); +} + +/** + * The evidence behind the page, at the level it belongs at. + * + * The snapshot identifier, the schema version and the overlay's own commit are + * how an operator checks a rendering against the document it came from. They + * are not what a visitor came to read, and the old overview put the whole + * opaque snapshot identifier in the primary status line, where it was the + * widest thing on the page and said nothing to almost everyone looking at it. + * + * So they are kept, exactly, in a disclosure that is closed by default. Values + * long enough to overflow are shortened for display and carry their exact form + * for copying, because a shortened identifier that cannot be recovered is + * evidence destroyed rather than evidence filed. + */ +export interface SourceDetail { + readonly id: string; + readonly label: string; + /** What is shown. Shortened when the exact value is too long to print. */ + readonly display: string; + /** The exact value, when there is one worth copying. */ + readonly exact: string | null; + /** + * True when the display is a shortening of the exact value. The page then + * carries the exact form for a screen reader as well, because a reader who + * cannot see the middle of an identifier has not been given it. + */ + readonly truncated: boolean; + /** Which component this identifies, when the value alone would not say. */ + readonly note: string | null; +} + +export function readSourceDetails( + capability: ChainCapabilityEnvelope | null +): readonly SourceDetail[] { + if (!capability) { + return []; + } + const details: SourceDetail[] = [ + { + id: 'observed', + label: $localize`:@@universe.chain.source-observed:Observed at`, + display: capability.updatedAt, + exact: capability.updatedAt, + truncated: false, + note: null, + }, + ]; + + const snapshotId = capability.mempool?.snapshotId ?? null; + if (snapshotId) { + details.push({ + id: 'snapshot', + label: $localize`:@@universe.chain.source-snapshot:Pending snapshot`, + display: shortenIdentifier(snapshotId, 10), + exact: snapshotId, + truncated: shortenIdentifier(snapshotId, 10) !== snapshotId, + note: $localize`:@@universe.chain.source-snapshot-note:Which reading of the pending set the figures above came from.`, + }); + } + + const sequence = formatExactInteger(capability.mempool?.sequenceAtomic ?? null); + if (sequence) { + details.push({ + id: 'sequence', + label: $localize`:@@universe.chain.source-sequence:Snapshot sequence`, + display: sequence.display, + exact: sequence.exact, + truncated: false, + note: null, + }); + } + + details.push({ + id: 'schema', + label: $localize`:@@universe.chain.source-schema:Document schema`, + display: capability.schemaVersion, + exact: capability.schemaVersion, + truncated: false, + note: null, + }); + + const releaseSha = capability.release?.sha ?? null; + if (releaseSha) { + details.push({ + id: 'release', + label: $localize`:@@universe.chain.source-release:Overlay release`, + display: releaseSha, + exact: releaseSha, + truncated: false, + // Three components carry their own commit, and this is one of them. An + // unqualified "Release" here reads as the version of the site, which it + // has never been. + note: $localize`:@@universe.chain.source-release-note:The commit of the protocol overlay that produced this document. The site and the explorer backend publish their own on the source page.`, + }); + } + + return details; +} + export interface ReadCapability { readonly id: string; readonly label: string; From 0210d5c90332c4f734b64e9abc0ad5546b0fb8ee Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 22:10:08 +0000 Subject: [PATCH 40/45] Nothing had ever looked at what the origin renders Production served a chain dashboard whose status line ran six facts together behind a raw snapshot identifier, headed static capability metadata "What is happening now", never said why Dogecoin was degraded, and published "Release development" to the public. Every check here passed for the whole of it. That is not a coincidence: the visual matrix renders this repository's build against fixtures, the synthetic check reads the API without a browser, and the origin was serving neither. It was found by looking at a screenshot. So a smoke that opens the two chain pages in a browser, against the public origin, with nothing mocked, and holds each one to the capability document the same run just read. Nothing in it hard-codes a tip, a readiness verdict or a Dogecoin outage: a chain that truthfully reports it cannot answer passes, and a page that fails to say so does not. The judgements live apart from the browser run so they can be tested, and they are. Twenty-two cases, each a state that reached production or one a change could plausibly reintroduce: the dashboard this one replaced, a snapshot identifier back in the primary interface, a release the overlay could not name, a chain not ready with nothing but a raw code where the explanation should be, a ready chain still showing a fault panel, the wrong chain under the right route, a rail that lost its labels, and a scan that matched nothing, which must fail rather than read as a clean page. Two more assert the healthy states pass, because a check that fails on good output gets switched off. The synthetic check gains the release field it never read. A placeholder identity is a legitimate answer from a workstation and never from the public origin, and the three chains are served by one overlay process, so two different identifiers on one origin means two processes are answering. --- .github/workflows/universe-ci.yml | 8 + .../workflows/universe-production-smoke.yml | 34 +- scripts/universe/synthetic-check.mjs | 54 +++ .../universe/visual-qa/chain-page-audit.mjs | 313 ++++++++++++++++++ .../visual-qa/chain-page-audit.test.mjs | 288 ++++++++++++++++ .../universe/visual-qa/chain-page-smoke.mjs | 226 +++++++++++++ 6 files changed, 922 insertions(+), 1 deletion(-) create mode 100644 scripts/universe/visual-qa/chain-page-audit.mjs create mode 100644 scripts/universe/visual-qa/chain-page-audit.test.mjs create mode 100644 scripts/universe/visual-qa/chain-page-smoke.mjs diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml index 27bdf38f0b3..dbe9ae31b09 100644 --- a/.github/workflows/universe-ci.yml +++ b/.github/workflows/universe-ci.yml @@ -174,6 +174,14 @@ jobs: working-directory: scripts/universe/visual-qa run: node --test progress-gate.test.mjs + # The public chain page gate, checked against the states it exists for. + # It runs against the live origin rather than this build, so nothing in + # this job exercises it, and a gate that has only ever passed has not + # shown it can fail. Every case it covers shipped past a green suite. + - name: The public chain page gate fires on the states that shipped + working-directory: scripts/universe/visual-qa + run: node --test chain-page-audit.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/.github/workflows/universe-production-smoke.yml b/.github/workflows/universe-production-smoke.yml index a889acc7225..9667131ec59 100644 --- a/.github/workflows/universe-production-smoke.yml +++ b/.github/workflows/universe-production-smoke.yml @@ -30,7 +30,10 @@ jobs: smoke: name: Live origin answers with real content runs-on: [self-hosted, linux-ultra] - timeout-minutes: 10 + # Ten was for a job that only read the API. It now installs the browser + # harness and loads two pages in a real browser, which is the only way to + # see what the origin renders. + timeout-minutes: 25 steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -42,3 +45,32 @@ jobs: - name: Every advertised feature has routes, data, and a matching release run: node scripts/universe/synthetic-check.mjs "${{ github.event.inputs.origin || 'https://explorer.bitcoinuniverse.io' }}" + + # The step above reads the API. It cannot see what the origin renders, and + # for as long as it could not, production served a chain dashboard whose + # status line ran six facts together behind a raw snapshot identifier, + # never said why Dogecoin was degraded, and published "Release + # development" to the public, while every check here passed. The API was + # answering correctly the whole time; the origin was serving an older + # build than anything measured it. + - name: Install the visual QA harness + working-directory: scripts/universe/visual-qa + run: npm ci + + - name: The gate can still tell a bad page from a good one + working-directory: scripts/universe/visual-qa + run: node --test chain-page-audit.test.mjs + + - name: The public chain pages say what the chain documents say + working-directory: scripts/universe/visual-qa + run: >- + node chain-page-smoke.mjs + --origin="${{ github.event.inputs.origin || 'https://explorer.bitcoinuniverse.io' }}" + + - name: Keep what the chain pages looked like + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: universe-chain-page-smoke + path: scripts/universe/visual-qa/artifacts-chain-smoke + retention-days: 30 diff --git a/scripts/universe/synthetic-check.mjs b/scripts/universe/synthetic-check.mjs index a56b6d13cb4..d0f306362e3 100644 --- a/scripts/universe/synthetic-check.mjs +++ b/scripts/universe/synthetic-check.mjs @@ -443,6 +443,57 @@ async function checkChainReadsAreServed(chain, envelope) { } } +/** + * Every chain document must name the build that produced it. + * + * The overlay resolves its release identifier and, when nothing supplied one, + * used to fall back to the literal string `development`. Nothing set it: the + * unit reads an environment file that never mentioned the variable, and no + * example documented it. So production published `Release development` to the + * public for as long as the field existed, and every check here passed while + * it did, because none of them read the field. + * + * A placeholder is a legitimate answer from a workstation and never from the + * public origin, which is the distinction this makes. The three chains are + * served by one overlay process, so they must also agree: two different + * release identifiers on one origin means two processes are answering, which + * is a deployment fault of its own. + */ +const COMMIT_SHA = /^[0-9a-f]{7,64}$/; + +function checkChainReleaseIdentity(envelopes) { + const named = new Map(); + for (const [chain, envelope] of envelopes) { + const sha = envelope?.release?.sha; + if (typeof sha !== 'string' || sha === '') { + fail(`chain:${chain}`, 'the capability document names no release'); + continue; + } + if (sha === 'development') { + fail( + `chain:${chain}`, + 'the capability document reports its release as "development", which is the placeholder for a build that could not name itself. Set UNIVERSE_EXPLORER_RELEASE_SHA, or install a RELEASE-SHA file in the overlay release directory', + ); + continue; + } + if (!COMMIT_SHA.test(sha)) { + fail(`chain:${chain}`, `the release identifier ${JSON.stringify(sha)} is not a commit`); + continue; + } + named.set(chain, sha); + } + if (named.size === 0) { + return; + } + const distinct = new Set(named.values()); + if (distinct.size > 1) { + const listed = [...named].map(([chain, sha]) => `${chain} ${sha}`).join(', '); + fail('release', `the chain documents name different overlay releases: ${listed}`); + return; + } + pass('release', `every chain document names overlay release ${[...distinct][0]}`); +} + /** * The live socket must accept the handshake a browser sends. * @@ -517,13 +568,16 @@ async function main() { const chains = await checkChainDirectory(); if (chains) { + const envelopes = []; for (const chain of EXPLORER_CHAINS) { await checkChainRoutingReachesTheOverlay(chain); const envelope = await checkChainStatus(chain); if (envelope) { + envelopes.push([chain, envelope]); await checkChainReadsAreServed(chain, envelope); } } + checkChainReleaseIdentity(envelopes); } await checkLiveSocketAcceptsABrowser(); diff --git a/scripts/universe/visual-qa/chain-page-audit.mjs b/scripts/universe/visual-qa/chain-page-audit.mjs new file mode 100644 index 00000000000..de5e75d5445 --- /dev/null +++ b/scripts/universe/visual-qa/chain-page-audit.mjs @@ -0,0 +1,313 @@ +/** + * What a public chain page has to be true about, given the document behind it. + * + * Kept apart from the browser run in `chain-page-smoke.mjs` so it can be + * tested. A gate that has only ever passed has not shown that it can fail, and + * every defect this exists for shipped past a suite that was green: the pages + * were rendered from fixtures, the API was read without a browser, and the + * origin served an older build than either of them measured. + * + * Nothing here hard-codes a tip height, a readiness verdict or a Dogecoin + * outage. Everything is asserted against the capability document the same run + * just read, so a chain that truthfully reports it cannot answer passes, and a + * page that fails to say so does not. + */ + +const COMMIT_SHA = /^[0-9a-f]{7,64}$/; + +/** The placeholder the overlay published to the public when nothing named it. */ +export const PLACEHOLDER_RELEASE = 'development'; + +/** + * Phrases the dashboard that shipped used, and this one must not. + * + * Each is a specific defect rather than a style preference. "What is happening + * now" headed a block of static capability metadata describing nothing + * happening. "Verified summary" and a bare key table were the generic record + * renderer standing in for a designed page, and a reader cannot tell a page + * that was designed from one that fell through to a fallback. So the words + * those fallbacks print are what this looks for. + */ +export const OBSOLETE_DASHBOARD_PHRASES = [ + 'What is happening now', + 'Verified summary', + 'Release development', +]; + +export const CHAIN_NAMES = { dogecoin: 'Dogecoin', zcash: 'Zcash' }; + +/** What each chain has to say about itself that the other one must not. */ +export const CHAIN_TRUTHS = { + dogecoin: [{ id: 'fee-unit', needle: 'per kilobyte' }], + zcash: [ + { id: 'zip-317', needle: 'ZIP-317' }, + { id: 'shielded', needle: 'shielded' }, + ], +}; + +/** + * The shape of this chain's snapshot identifier, taken from the one the + * document just reported rather than assumed. + * + * Dogecoin's read `dogecoin-mainnet-<32 hex>` and Zcash's `zcash-<40 hex>`, and + * neither is a contract. Keeping the literal prefix and the length of the + * hexadecimal run after it survives a change to either, and still tells a whole + * identifier apart from the shortened form the page shows. + */ +export function snapshotIdentifierShape(snapshotId) { + const match = String(snapshotId).match(/^(.*?)([0-9a-f]{16,})$/); + return match ? { prefix: match[1], hexLength: match[2].length } : null; +} + +/** Whether any identifier of that shape, whole, appears in this text. */ +export function carriesWholeSnapshotIdentifier(text, shape) { + if (!shape) { + return false; + } + for (let from = 0; ; ) { + const at = text.indexOf(shape.prefix, from); + if (at < 0) { + return false; + } + const tail = text.slice(at + shape.prefix.length); + let run = 0; + while (run < tail.length && /[0-9a-f]/.test(tail[run])) { + run += 1; + } + if (run >= shape.hexLength) { + return true; + } + from = at + 1; + } +} + +/** + * A raw code printed at a reader is not an explanation. + * + * The document states reasons as `confirmed-history-authority-unavailable`. + * The page has an allowlist that turns each into a sentence, and shows an + * unrecognised one as its own words rather than inventing a meaning. Both are + * acceptable; printing the code itself as the whole explanation is not. + */ +export function looksLikeAnExplanation(text) { + return text.length > 30 && text.split(/\s+/).length >= 6; +} + +/** + * The commit the origin serves against the one this run was pointed at. + * + * They are compared by prefix in both directions, because the site publishes an + * abbreviated commit and a release is named by a full one. + */ +export function auditRelease(commit, expected) { + if (!commit) { + return { failures: ['release: the origin publishes no frontend commit'], notes: [] }; + } + if (expected && !expected.startsWith(commit) && !commit.startsWith(expected)) { + return { + failures: [`release: the origin serves frontend ${commit}, and this run expected ${expected}`], + notes: [], + }; + } + return { failures: [], notes: [`release: the origin serves frontend ${commit}`] }; +} + +/** + * Everything the rendered page has to answer for. + * + * `seen` is what the browser observed, in the shape `readPageText` returns, or + * null when the page carried none of this dashboard's structure at all. That + * last case is not an error to be thrown: it is the exact state that reached + * production, an origin serving the dashboard this one replaced, and it has to + * be reported as the finding it is. + */ +export function auditChainPage({ chain, envelope, seen, observations = {} }) { + const scope = `page:${chain}`; + const failures = []; + const notes = []; + const fail = (message) => failures.push(`${scope}: ${message}`); + const pass = (message) => notes.push(`${scope}: ${message}`); + const name = CHAIN_NAMES[chain] ?? chain; + + if (!envelope) { + fail('the capability document could not be read, so there is nothing to hold the page to'); + return { failures, notes }; + } + if (!seen) { + fail( + 'the page has no labelled status rail, so this origin is not serving the chain dashboard this release ships', + ); + return { failures, notes }; + } + + // Identity. A page that renders the wrong chain is the failure that put + // Bitcoin's name on a Dogecoin tab, and it renders perfectly. Compared + // without case, because the heading is set in capitals by the stylesheet and + // innerText reports what is rendered. + const heading = (seen.headings?.[0] ?? '').toLowerCase(); + if (!heading.includes(name.toLowerCase())) { + fail( + `the first heading is ${JSON.stringify(seen.headings?.[0] ?? '')}, which does not name ${name}`, + ); + } else { + pass(`identifies itself as ${name}`); + } + const other = chain === 'dogecoin' ? 'Zcash' : 'Dogecoin'; + if (seen.primary.toLowerCase().includes(other.toLowerCase())) { + fail(`the ${name} page mentions ${other} in its own content`); + } + + // The status rail: labelled readings, not a run-on line of facts. + const rail = seen.rail ?? []; + if (rail.length < 5) { + fail(`the status rail has ${rail.length} readings, expected at least five`); + } else if (rail.some((reading) => !reading.label || !reading.value)) { + const bare = rail.filter((reading) => !reading.label || !reading.value).length; + fail(`${bare} status reading(s) carry a value with no label, or a label with no value`); + } else { + pass(`status rail reads ${rail.map((reading) => reading.label).join(', ')}`); + } + + // Readiness, from the document rather than from an assumption about which + // chain is currently healthy. + const reasons = seen.notReadyReasons ?? []; + if (envelope.ready === false) { + const explained = reasons.filter(looksLikeAnExplanation); + if (!explained.length) { + fail(`${name} reports itself not ready and the page gives no explanation a reader could act on`); + } else { + pass(`not ready, and says why in ${explained.length} sentence(s)`); + } + } else if (reasons.length) { + fail(`${name} reports itself ready while the page still shows a fault explanation`); + } else { + pass('ready, and shows no fault explanation'); + } + + // Coverage: the live answer to how much history is readable, which is a + // different question from which lookups exist, and was dropped once while + // the page went on saying address history was offered. + const coverage = seen.coverage ?? []; + const declared = envelope.coverage ?? {}; + if (coverage.length !== 3) { + fail(`the page states ${coverage.length} history coverage dimensions, expected three`); + } else if (coverage.some((reading) => !reading.label || !reading.state)) { + fail('a history coverage reading has no label or no state'); + } else if ( + declared.addressHistory === 'unavailable' && + !coverage.some( + (reading) => /address/i.test(reading.label) && /unavailable/i.test(reading.state), + ) + ) { + fail('the document says address history is unavailable and the page does not say so'); + } else { + pass( + `history coverage reads ${coverage.map((reading) => `${reading.label} ${reading.state}`).join(', ')}`, + ); + } + + // The identifier the page rendered is not always the one the run read a + // moment earlier: a new pending snapshot is taken every few seconds and each + // has its own identifier. So the exact value is used for the assertion that + // can only be made about it, that it is not in the primary interface, and the + // shape is used for the assertion about the page, that an identifier of that + // shape is filed, whole, under the technical details. + const snapshotId = envelope.mempool?.snapshotId ?? null; + if (snapshotId) { + const shape = snapshotIdentifierShape(snapshotId); + if (seen.primary.includes(snapshotId) || carriesWholeSnapshotIdentifier(seen.primary, shape)) { + fail( + `a whole snapshot identifier is in the primary interface, where ${snapshotId} used to be the widest thing on the page`, + ); + } else if (!carriesWholeSnapshotIdentifier(seen.disclosure ?? '', shape)) { + fail( + 'no snapshot identifier appears in the technical details, so the evidence was dropped rather than filed', + ); + } else { + pass('the snapshot identifier is filed under the technical details, whole'); + } + } else { + pass('this chain reports no pending snapshot identifier'); + } + + // Release identity. The document is checked because that is where the value + // comes from, and the rendered page because that is what a reader saw. + const releaseSha = envelope.release?.sha; + if (releaseSha === PLACEHOLDER_RELEASE || !COMMIT_SHA.test(String(releaseSha ?? ''))) { + fail( + `the capability document names its release as ${JSON.stringify(releaseSha ?? null)}, which is not a commit`, + ); + } else if (seen.primary.includes(releaseSha)) { + fail('a component release identifier is in the primary interface rather than the technical details'); + } else { + pass(`overlay release ${releaseSha}`); + } + + for (const phrase of OBSOLETE_DASHBOARD_PHRASES) { + if (seen.primary.includes(phrase) || (seen.disclosure ?? '').includes(phrase)) { + fail(`the page still says ${JSON.stringify(phrase)}, which belongs to the dashboard this replaced`); + } + } + + // What makes this chain itself, rather than the other one with a name + // swapped in. + for (const truth of CHAIN_TRUTHS[chain] ?? []) { + if (!seen.primary.includes(truth.needle)) { + fail(`the page does not state ${truth.id}, which is part of what ${name} does differently`); + } else { + pass(`states ${truth.id}`); + } + } + if (envelope.reads?.projectedBlocks !== true && !/projected block/i.test(seen.primary)) { + fail('this chain offers no projected blocks and the page never says so'); + } + + // Next actions: real destinations, described, and reachable. Reachability is + // measured by the runner and passed in, because it needs the network. + const entries = seen.entries ?? []; + if (entries.length < 2) { + fail(`the page offers ${entries.length} next action(s), expected at least two`); + } + for (const entry of entries) { + if (!entry.title || !entry.detail) { + fail(`a next action has no title or no description: ${JSON.stringify(entry)}`); + continue; + } + if (!entry.href?.startsWith(`/${chain}/`)) { + fail( + `the next action ${JSON.stringify(entry.title)} points at ${JSON.stringify(entry.href ?? null)}, which is not a ${name} route`, + ); + continue; + } + const status = observations.entryStatus?.[entry.href]; + if (status === undefined) { + continue; + } + if (status >= 400) { + fail(`the next action ${entry.href} answered HTTP ${status}`); + } else { + pass(`next action ${entry.href} resolves`); + } + } + + if (seen.horizontalOverflow > 0) { + fail(`the page scrolls sideways by ${seen.horizontalOverflow} pixels at 1440`); + } + + const consoleErrors = observations.consoleErrors ?? []; + const failedRequests = observations.failedRequests ?? []; + const foreignRequests = observations.foreignRequests ?? []; + if (consoleErrors.length) { + fail(`${consoleErrors.length} console error(s): ${consoleErrors.slice(0, 3).join(' | ')}`); + } + if (failedRequests.length) { + fail(`${failedRequests.length} failed request(s): ${failedRequests.slice(0, 3).join(' | ')}`); + } + if (foreignRequests.length) { + fail( + `the page requested ${foreignRequests.length} third-party address(es): ${foreignRequests.slice(0, 3).join(' | ')}`, + ); + } + + return { failures, notes }; +} diff --git a/scripts/universe/visual-qa/chain-page-audit.test.mjs b/scripts/universe/visual-qa/chain-page-audit.test.mjs new file mode 100644 index 00000000000..3997de4e65a --- /dev/null +++ b/scripts/universe/visual-qa/chain-page-audit.test.mjs @@ -0,0 +1,288 @@ +/** + * Proof that the public chain page gate can fail. + * + * Every defect it exists for shipped past a suite that was green, so a gate + * that has only ever passed proves nothing about the next one. Each case here + * reconstructs a state that actually reached production, or one a change could + * plausibly reintroduce, and asserts that this gate names it. + * + * The passing case is here for the same reason in reverse: a check that fails + * on healthy output gets switched off, and the honest degraded state of a chain + * in an index recovery must go through. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { auditChainPage, auditRelease } from './chain-page-audit.mjs'; + +const SNAPSHOT = 'dogecoin-mainnet-9304b4b3a53e788898da63a1b52cd509'; + +/** Dogecoin as production reported it on 2026-08-29, degraded and truthful. */ +function envelope(overrides = {}) { + return { + schemaVersion: 'universe-chain-capability-v1', + chain: 'dogecoin', + network: 'mainnet', + ready: false, + mempool: { supported: true, state: 'ready', completeness: 'complete', snapshotId: SNAPSHOT }, + reads: { + transaction: true, + block: true, + address: true, + outpoint: true, + feeEstimates: true, + projectedBlocks: false, + }, + coverage: { + confirmedHistory: 'unavailable', + addressHistory: 'unavailable', + protocolHistory: 'unavailable', + }, + degradedReasons: [ + 'base-chain-authority-unavailable', + 'confirmed-history-authority-unavailable', + 'protocol-history-unavailable', + ], + release: { sha: 'bea93c1ec' }, + ...overrides, + }; +} + +/** The page as this release renders it for that document. */ +function seenPage(overrides = {}) { + return { + primary: [ + 'DOGECOIN OVERVIEW', + 'CHAIN Degraded CHAIN TIP Block 6,352,650 BEHIND TIP Not stated', + 'Why Dogecoin is not ready', + 'Questions this chain can answer', + 'How much history is readable now', + 'Dogecoin fees are quoted per kilobyte, not in sat/vB.', + 'No projected blocks are shown for this chain.', + ].join('\n'), + disclosure: `Where these readings came from Pending snapshot ${SNAPSHOT} Overlay release bea93c1ec`, + rail: [ + { label: 'CHAIN', value: 'Degraded' }, + { label: 'CHAIN TIP', value: 'Block 6,352,650' }, + { label: 'BEHIND TIP', value: 'Not stated' }, + { label: 'LAST OBSERVED', value: '3 seconds ago' }, + { label: 'PENDING COVERAGE', value: 'Complete' }, + ], + headings: ['DOGECOIN OVERVIEW', 'Why Dogecoin is not ready'], + notReadyReasons: [ + "The node that serves this chain's own blocks and transactions did not answer.", + 'The source of confirmed history did not answer, so history older than the pending set cannot be read.', + 'At least one protocol indexer this chain needs is not answering, so protocol history cannot be read.', + ], + coverage: [ + { label: 'Confirmed history', state: 'Unavailable' }, + { label: 'Address history', state: 'Unavailable' }, + { label: 'Protocol history', state: 'Unavailable' }, + ], + entries: [ + { href: '/dogecoin/mempool', title: 'Pending transactions', detail: 'What our node has seen' }, + { href: '/dogecoin/protocols', title: 'Protocols', detail: 'Assets carried on this chain' }, + ], + horizontalOverflow: 0, + ...overrides, + }; +} + +function audit(envelopeOverrides = {}, seenOverrides = {}, observations = {}) { + return auditChainPage({ + chain: 'dogecoin', + envelope: envelope(envelopeOverrides), + seen: seenOverrides === null ? null : seenPage(seenOverrides), + observations: { + entryStatus: { '/dogecoin/mempool': 200, '/dogecoin/protocols': 200 }, + ...observations, + }, + }); +} + +function saidSomethingAbout(result, needle) { + return result.failures.some((failure) => failure.includes(needle)); +} + +test('a truthfully degraded chain passes', () => { + const result = audit(); + assert.deepEqual(result.failures, []); + assert.ok(result.notes.length > 5); +}); + +test('a ready chain with no fault panel passes', () => { + const result = audit( + { + ready: true, + coverage: { + confirmedHistory: 'complete', + addressHistory: 'complete', + protocolHistory: 'complete', + }, + degradedReasons: [], + }, + { + notReadyReasons: [], + coverage: [ + { label: 'Confirmed history', state: 'Complete' }, + { label: 'Address history', state: 'Complete' }, + { label: 'Protocol history', state: 'Complete' }, + ], + }, + ); + assert.deepEqual(result.failures, []); +}); + +test('the dashboard this one replaced is detected', () => { + // An origin serving the old build renders a page with none of this + // structure. It loads, it answers 200, and every other check passes. + const result = auditChainPage({ chain: 'dogecoin', envelope: envelope(), seen: null }); + assert.ok(saidSomethingAbout(result, 'not serving the chain dashboard this release ships')); +}); + +test('a raw snapshot identifier restored to the primary interface is detected', () => { + const result = audit({}, { primary: `${seenPage().primary}\nsnapshot ${SNAPSHOT}` }); + assert.ok(saidSomethingAbout(result, 'whole snapshot identifier is in the primary interface')); +}); + +test('a snapshot identifier that is nowhere at all is detected', () => { + const result = audit({}, { disclosure: 'Where these readings came from' }); + assert.ok(saidSomethingAbout(result, 'dropped rather than filed')); +}); + +test('a release the overlay could not name is detected', () => { + assert.ok( + saidSomethingAbout(audit({ release: { sha: 'development' } }), 'which is not a commit'), + ); +}); + +test('a missing release identifier is detected', () => { + assert.ok(saidSomethingAbout(audit({ release: {} }), 'which is not a commit')); + assert.ok(saidSomethingAbout(audit({ release: undefined }), 'which is not a commit')); +}); + +test('a malformed release identifier is detected', () => { + assert.ok(saidSomethingAbout(audit({ release: { sha: 'HEAD' } }), 'which is not a commit')); +}); + +test('a chain that is not ready and does not say why is detected', () => { + assert.ok(saidSomethingAbout(audit({}, { notReadyReasons: [] }), 'gives no explanation')); +}); + +test('a raw reason code printed instead of an explanation is detected', () => { + const result = audit({}, { notReadyReasons: ['base-chain-authority-unavailable'] }); + assert.ok(saidSomethingAbout(result, 'gives no explanation')); +}); + +test('a ready chain still showing a fault panel is detected', () => { + const result = audit({ ready: true }, {}); + assert.ok(saidSomethingAbout(result, 'ready while the page still shows a fault explanation')); +}); + +test('an unavailable history reported as nothing at all is detected', () => { + const result = audit( + {}, + { + coverage: [ + { label: 'Confirmed history', state: 'Complete' }, + { label: 'Address history', state: 'Complete' }, + { label: 'Protocol history', state: 'Complete' }, + ], + }, + ); + assert.ok(saidSomethingAbout(result, 'address history is unavailable and the page does not say so')); +}); + +test('the wrong chain rendered under the right route is detected', () => { + const result = audit( + {}, + { + headings: ['ZCASH OVERVIEW'], + primary: seenPage().primary.replace('DOGECOIN', 'ZCASH') + '\nZcash', + }, + ); + assert.ok(saidSomethingAbout(result, 'does not name Dogecoin')); + assert.ok(saidSomethingAbout(result, 'mentions Zcash in its own content')); +}); + +test('a status rail that lost its labels is detected', () => { + const result = audit( + {}, + { rail: seenPage().rail.map((reading) => ({ label: '', value: reading.value })) }, + ); + assert.ok(saidSomethingAbout(result, 'value with no label')); +}); + +test('an empty scan is a failure rather than a pass', () => { + // A selector change that matches nothing must not read as a clean page. + const result = audit({}, { rail: [], coverage: [], entries: [], headings: [] }); + assert.ok(saidSomethingAbout(result, 'status rail has 0 readings')); + assert.ok(saidSomethingAbout(result, 'history coverage dimensions')); + assert.ok(saidSomethingAbout(result, 'next action(s)')); +}); + +test('the phrases of the dashboard this replaced are detected', () => { + const result = audit({}, { primary: `${seenPage().primary}\nWhat is happening now` }); + assert.ok(saidSomethingAbout(result, 'What is happening now')); +}); + +test('losing what makes this chain itself is detected', () => { + const result = audit({}, { primary: seenPage().primary.replace('per kilobyte', 'per byte') }); + assert.ok(saidSomethingAbout(result, 'fee-unit')); +}); + +test('a next action pointing at another chain is detected', () => { + const result = audit( + {}, + { + entries: [ + { href: '/zcash/mempool', title: 'Pending transactions', detail: 'Wrong chain' }, + { href: '/dogecoin/protocols', title: 'Protocols', detail: 'Assets on this chain' }, + ], + }, + ); + assert.ok(saidSomethingAbout(result, 'is not a Dogecoin route')); +}); + +test('a next action whose destination does not answer is detected', () => { + const result = audit({}, {}, { entryStatus: { '/dogecoin/mempool': 404, '/dogecoin/protocols': 200 } }); + assert.ok(saidSomethingAbout(result, '/dogecoin/mempool answered HTTP 404')); +}); + +test('a bare link with no description is detected', () => { + const result = audit( + {}, + { + entries: [ + { href: '/dogecoin/mempool', title: 'Open mempool', detail: '' }, + { href: '/dogecoin/protocols', title: 'Open protocols', detail: '' }, + ], + }, + ); + assert.ok(saidSomethingAbout(result, 'no title or no description')); +}); + +test('sideways scroll, console errors and third-party requests are detected', () => { + const result = audit( + {}, + { horizontalOverflow: 42 }, + { + consoleErrors: ['TypeError: undefined'], + failedRequests: ['/api/v1/dogecoin/status HTTP 502'], + foreignRequests: ['https://example.invalid/pixel.gif'], + }, + ); + assert.ok(saidSomethingAbout(result, 'scrolls sideways by 42')); + assert.ok(saidSomethingAbout(result, 'console error(s)')); + assert.ok(saidSomethingAbout(result, 'failed request(s)')); + assert.ok(saidSomethingAbout(result, 'third-party address(es)')); +}); + +test('an origin serving a different build than the release names is detected', () => { + assert.deepEqual(auditRelease('bea93c1ec', 'bea93c1ec7f608313').failures, []); + assert.deepEqual(auditRelease('bea93c1ec', null).failures, []); + assert.ok( + auditRelease('521a091', 'bea93c1ec7f608313').failures[0].includes('this run expected'), + ); + assert.ok(auditRelease(null, null).failures[0].includes('publishes no frontend commit')); +}); diff --git a/scripts/universe/visual-qa/chain-page-smoke.mjs b/scripts/universe/visual-qa/chain-page-smoke.mjs new file mode 100644 index 00000000000..a55d5d1dd11 --- /dev/null +++ b/scripts/universe/visual-qa/chain-page-smoke.mjs @@ -0,0 +1,226 @@ +#!/usr/bin/env node +/** + * What a reader actually sees on the public chain pages, in a browser, against + * the public origin, with nothing mocked. + * + * This is the gate that was missing. Every other check in this repository + * either reads the API without a browser or renders the frontend against + * fixtures, and all of them stayed green for as long as production served a + * chain dashboard that ran its status facts together in one line, put a whole + * opaque snapshot identifier in the primary interface, headed static + * capability metadata "What is happening now", said Dogecoin was degraded + * without a word about why, and published "Release development" to the public. + * Nothing was broken in a way any of them could see: the API answered, the + * fixtures rendered the newer build, and the older build was the one on the + * origin. + * + * The judgements live in `chain-page-audit.mjs`, which is tested. This file + * collects what the browser saw and hands it over. + * + * Usage: + * node chain-page-smoke.mjs [--origin=URL] [--release=SHA] [--out=DIR] + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import playwright from 'playwright'; + +import { CHAIN_NAMES, auditChainPage, auditRelease } from './chain-page-audit.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const args = Object.fromEntries( + process.argv.slice(2).map((a) => { + const [k, v] = a.replace(/^--/, '').split('='); + return [k, v ?? true]; + }), +); + +const ORIGIN = String(args.origin || 'https://explorer.bitcoinuniverse.io').replace(/\/$/, ''); +const EXPECTED_RELEASE = typeof args.release === 'string' ? args.release : null; +const OUT = resolve(args.out || join(HERE, 'artifacts-chain-smoke')); +const REQUEST_TIMEOUT_MS = 20_000; + +const failures = []; +const notes = []; + +function record(result) { + failures.push(...result.failures); + notes.push(...result.notes); +} + +async function readJson(path) { + const response = await fetch(`${ORIGIN}${path}`, { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + return { status: response.status, body: await response.json().catch(() => null) }; +} + +/** + * The commit the site publishes, which is the frontend's and not the overlay's. + * They are different components and are allowed to differ; the point of reading + * it here is to prove the origin serves the release this run was pointed at. + */ +async function frontendCommit() { + const response = await fetch(`${ORIGIN}/resources/config.js`, { + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + const body = await response.text(); + return body.match(/GIT_COMMIT_HASH\s*=\s*'([^']+)'/)?.[1] ?? null; +} + +/** + * Everything a reader sees before opening anything, and everything filed behind + * the technical disclosure, kept apart. + * + * A closed disclosure renders only its summary, so its contents are read from + * the markup and the primary text is read with the disclosures hidden. Reading + * either one the other way makes the snapshot check meaningless, since the + * whole assertion is that a value is in the second and not the first. + * + * Runs inside the page, so it may use no import from this file. + */ +function readPageText() { + const root = document.querySelector('.chain-page'); + if (!root) { + return null; + } + const disclosures = [...root.querySelectorAll('details')]; + const disclosure = disclosures.map((node) => node.textContent).join('\n'); + const restore = disclosures.map((node) => node.style.display); + disclosures.forEach((node) => { + node.style.display = 'none'; + }); + const primary = root.innerText; + disclosures.forEach((node, index) => { + node.style.display = restore[index]; + }); + const notReady = root.querySelector('.not-ready'); + return { + primary, + disclosure, + rail: [...root.querySelectorAll('.status-rail .reading')].map((node) => ({ + label: node.querySelector('.reading-label')?.innerText?.trim() ?? '', + value: node.querySelector('.universe-chip')?.innerText?.trim() ?? '', + })), + headings: [...root.querySelectorAll('h1, h2')].map((node) => node.innerText.trim()), + notReadyReasons: notReady + ? [...notReady.querySelectorAll('li')].map((node) => node.innerText.trim()) + : [], + coverage: [...root.querySelectorAll('.history-coverage li')].map((node) => ({ + label: node.querySelector('.coverage-label')?.innerText?.trim() ?? '', + state: node.querySelector('.universe-chip')?.innerText?.trim() ?? '', + })), + entries: [...root.querySelectorAll('.entry-actions a.entry')].map((node) => ({ + href: node.getAttribute('href'), + title: node.querySelector('.entry-title')?.innerText?.trim() ?? '', + detail: node.querySelector('.entry-detail')?.innerText?.trim() ?? '', + })), + horizontalOverflow: + document.documentElement.scrollWidth - document.documentElement.clientWidth, + }; +} + +async function checkChain(browser, chain) { + const { status, body: envelope } = await readJson(`/api/v1/${chain}/status`); + if (status !== 200 || !envelope) { + failures.push( + `page:${chain}: /api/v1/${chain}/status answered HTTP ${status}, so there is nothing to hold the page to`, + ); + return; + } + + const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await context.newPage(); + const consoleErrors = []; + const failedRequests = []; + const foreignRequests = []; + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + page.on('requestfailed', (request) => { + failedRequests.push(`${request.url()} ${request.failure()?.errorText ?? 'failed'}`); + }); + page.on('request', (request) => { + const url = new URL(request.url()); + if (url.origin !== new URL(ORIGIN).origin && url.protocol !== 'data:') { + foreignRequests.push(request.url()); + } + }); + page.on('response', (response) => { + if (response.status() >= 400) { + failedRequests.push(`${response.url()} HTTP ${response.status()}`); + } + }); + + try { + await page.goto(`${ORIGIN}/${chain}`, { waitUntil: 'networkidle', timeout: 45_000 }); + // The labelled status rail is this dashboard's own structure. An origin + // still serving the dashboard it replaced loads perfectly and has no such + // element, which is exactly the state that had to be caught by hand, so a + // missing rail becomes a named finding rather than a thrown timeout. + const rendered = await page + .waitForSelector('.chain-page .status-rail', { timeout: 30_000 }) + .then(() => true) + .catch(() => false); + mkdirSync(OUT, { recursive: true }); + const seen = rendered ? await page.evaluate(readPageText) : null; + await page.screenshot({ + path: join(OUT, rendered ? `${chain}.png` : `${chain}-unrecognised.png`), + fullPage: true, + }); + if (seen) { + writeFileSync(join(OUT, `${chain}.txt`), seen.primary, 'utf8'); + } + + const entryStatus = {}; + for (const entry of seen?.entries ?? []) { + if (!entry.href?.startsWith(`/${chain}/`)) { + continue; + } + const response = await fetch(`${ORIGIN}${entry.href}`, { + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }).catch(() => null); + entryStatus[entry.href] = response ? response.status : 599; + } + + record( + auditChainPage({ + chain, + envelope, + seen, + observations: { consoleErrors, failedRequests, foreignRequests, entryStatus }, + }), + ); + } finally { + await context.close(); + } +} + +async function main() { + console.log(`Chain page smoke against ${ORIGIN}`); + record(auditRelease(await frontendCommit(), EXPECTED_RELEASE)); + + const browser = await playwright.chromium.launch(); + try { + for (const chain of Object.keys(CHAIN_NAMES)) { + await checkChain(browser, chain); + } + } finally { + await browser.close(); + } + + for (const note of notes) console.log(` ok ${note}`); + for (const failure of failures) console.error(` FAIL ${failure}`); + if (failures.length) { + console.error(`\n${failures.length} chain page check(s) failed against ${ORIGIN}`); + process.exit(1); + } + console.log(`\nAll chain page checks passed against ${ORIGIN}`); +} + +main().catch((error) => { + console.error(`Chain page smoke could not run: ${error instanceof Error ? error.stack : error}`); + process.exit(1); +}); From 2b81f362eb80aafb9599a9ed0639337ccd029ce2 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 22:10:15 +0000 Subject: [PATCH 41/45] Three components publish a commit and the document named one The release identity section described the frontend and the explorer backend and said nothing about the overlay, which publishes its own on every chain status document and had been publishing the word "development" to the public. Nothing set the variable it reads, no example file mentioned it, and every release directory had been naming its own commit in a file nobody read. Written down: which component publishes where, why the overlay fell back to a placeholder, what installing an overlay release now requires, and the two public checks that refuse a release which cannot name what is answering. --- docs/operations/DEPLOYMENT.md | 59 ++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/docs/operations/DEPLOYMENT.md b/docs/operations/DEPLOYMENT.md index 981d5c9c519..c223ca3a17b 100644 --- a/docs/operations/DEPLOYMENT.md +++ b/docs/operations/DEPLOYMENT.md @@ -220,9 +220,19 @@ suite can see that. 6. Run `node scripts/universe/synthetic-check.mjs` against the public origin. It asks the live endpoints with nothing mocked and fails on an empty range, a protocol advertised as readable whose authority cannot answer, a - configured authority with no checkpoint, or a frontend and backend on - different builds. -7. Keep the previous release directory until the stability window closes. + configured authority with no checkpoint, a frontend and backend on different + builds, or a chain document that cannot name the overlay release behind it. +7. Run `node scripts/universe/visual-qa/chain-page-smoke.mjs --release=` + against the public origin. Step 6 reads the API and cannot see what the + origin renders, which is how a release that was never promoted went on + serving an obsolete chain dashboard while every check passed. This one opens + `/dogecoin` and `/zcash` in a browser and holds each page to its own + capability document: a labelled status rail, a readable explanation whenever + the chain says it is not ready and none when it says it is, the three history + coverage dimensions, no whole snapshot identifier in the primary interface + and the whole one filed under the technical details, next actions that + resolve, and the frontend commit this release expected. +8. Keep the previous release directory until the stability window closes. Rolling back to a release from before the socket handover needs the port back, because such a gateway opens 8099 itself and dies on bind while systemd holds @@ -313,10 +323,45 @@ on the same host is a different product and is not part of this stack. ## Release identity -Every deployment publishes what it is running: the backend reports `gitCommit` -on `/api/v1/backend-info`, and the public `/source` page renders the release -SHA, the pinned upstream base, the licence, and the source repository link. A -build whose SHA is not published must not ship. +Three components run here and each publishes its own commit. They are allowed +to differ, and reading one as another is how the last identity defect went +unnoticed for as long as it did. + +| Component | Where it publishes | Release directory | +| --- | --- | --- | +| frontend | `GIT_COMMIT_HASH` in `/resources/config.js`, and the `/source` page | `releases/mempool-` | +| explorer backend | `gitCommit` on `/api/v1/backend-info` | the same directory | +| protocol overlay | `release.sha` on every `/api/v1//status` | `releases/backend-apis-` | + +A build whose SHA is not published must not ship. + +The overlay's identifier shipped as the literal string `development` and served +that to the public. Two faults produced it, and both are worth knowing because +neither was visible from anything that was being checked: + +- Nothing supplied the value. `universe-explorer-overlay.service` reads + `/etc/universe-explorer/overlay.env`, no example file mentioned + `UNIVERSE_EXPLORER_RELEASE_SHA`, and no install step wrote it. Meanwhile every + release directory already carried a `RELEASE-SHA` file naming its commit. +- The absence was survivable. A missing identity fell back to a placeholder and + the service started, so the only signal was a word on a public page. + +The overlay now reads its own release directory's `RELEASE-SHA` when the +variable is absent, which is an identity that cannot drift from the artifact, +and with `NODE_ENV=production` it refuses to start when neither is a commit. +Installing an overlay release therefore has one requirement beyond unpacking it: + +```bash +printf '%s +' "" > /opt/universe-explorer/releases/backend-apis-/RELEASE-SHA +ln -sfn /opt/universe-explorer/releases/backend-apis- /opt/universe-explorer/current-overlay.new +mv -Tf /opt/universe-explorer/current-overlay.new /opt/universe-explorer/current-overlay +systemctl restart universe-explorer-overlay +``` + +`synthetic-check.mjs` fails the release when any chain document reports +`development`, reports something that is not a commit, or when the three chains +disagree about which overlay answered. ## The edge From 9218ae6e6843b8eae67030ba18576f14e7df6eb9 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 22:17:16 +0000 Subject: [PATCH 42/45] A label stretched to the far side of its cell is not bound to its value Three fixes to the same fault, that the space between a reading and its label was doing the work of relating them. In the capability grid the label carried `flex: 1 1 auto`, so it grew to the end of a 320 pixel cell and every state chip sat against the right edge with a hand's width of nothing between it and the words it answers. Measured at 1437 and at 1920: eight pixels between the label box and the chip, and roughly a hundred and sixty between the text and the chip. The label no longer stretches. In the coverage block the two columns were sized per row, so the three states stepped sideways with the length of each name and could not be read down. And the two disclosure controls on this page are the only way to reach the evidence behind it, at about eighteen pixels tall. On a coarse pointer they are targets a finger can hit. --- .../multichain-explorer.component.scss | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) 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 39e4ca08d64..f8b279bf7c3 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss @@ -389,8 +389,11 @@ border-top: 1px solid var(--u-divider); } + // The label does not stretch to the end of its track. It used to, which put + // every state chip against the right edge of a 320 pixel cell with a hand's + // width of nothing binding it to the words it answers. .read-label { - flex: 1 1 auto; + flex: 0 1 auto; min-width: 0; font-size: var(--u-text-sm); } @@ -815,6 +818,15 @@ tbody tr:last-child td { cursor: pointer; font-weight: var(--u-weight-semibold); color: var(--u-text-secondary); + + // The only control that opens this, and a disclosure triangle beside a + // word is about 18 pixels tall. 44 is the smallest target a finger can be + // expected to hit. + @media (pointer: coarse), (max-width: 991.98px) { + align-items: center; + display: flex; + min-height: 44px; + } } &[open] summary { @@ -896,6 +908,11 @@ tbody tr:last-child td { border-color: var(--u-brand-accent); color: var(--u-brand); } + + @media (pointer: coarse) { + min-height: 44px; + padding-inline: var(--u-space-3); + } } // --------------------------------------------------------------------------- From 834bc4d03c4881964e9a75ffc29594b455b62cab Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 22:25:27 +0000 Subject: [PATCH 43/45] The chain pages were absent from the forced colours and zoom pass Five routes were checked under a replaced palette and at 200 percent, and the two this suite most recently redesigned were not among them. Both carry a status rail, a coverage block and a disclosure, which is exactly the kind of structure a halved viewport has to reflow rather than clip. Both hold: no overflow, no text painted on itself, in either mode. --- scripts/universe/visual-qa/modes-check.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/universe/visual-qa/modes-check.mjs b/scripts/universe/visual-qa/modes-check.mjs index f13e630f68a..53a8268f620 100644 --- a/scripts/universe/visual-qa/modes-check.mjs +++ b/scripts/universe/visual-qa/modes-check.mjs @@ -43,6 +43,7 @@ import { fileURLToPath } from 'node:url'; import playwright from 'playwright'; import { addressFixtures, detailFixtures, fixtures, sampleIds } from './fixtures.mjs'; +import { chainFixtures } from './chain-fixtures.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const args = Object.fromEntries( @@ -61,11 +62,18 @@ const ROUTES = [ ['blocks', '/blocks'], ['protocols', '/protocols'], ['graphs', '/graphs/mempool'], + + // The two chain overviews. They were absent, so the pages this suite most + // recently redesigned were the ones nothing measured under a replaced + // palette or at 200 percent, and both carry a status rail, a coverage block + // and a disclosure that a halved viewport has to reflow rather than clip. + ['dogecoin', '/dogecoin'], + ['zcash', '/zcash'], ]; /** Answer every API call from the same table the matrix uses. */ async function installFixtures(context) { - const table = { ...fixtures, ...detailFixtures, ...addressFixtures }; + const table = { ...fixtures, ...detailFixtures, ...addressFixtures, ...chainFixtures }; await context.route('**/api/**', async (route) => { const path = new URL(route.request().url()).pathname; const body = table[path] ?? Object.entries(table).find(([k]) => path.startsWith(k))?.[1]; From 57392dd79f4432b2d4ab8a6db3692479c0ba4bf5 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 22:28:32 +0000 Subject: [PATCH 44/45] Retake the overview screenshot the coverage panel made stale The README shows the Dogecoin overview as the harness renders it, and the page now states how much history is readable beneath the rail. Retaken at the same width, from the same review data, and the alt text describes what is there, including the five rail readings it summarised as one sentence before. --- README.md | 19 +++++++++--------- .../product/screenshots/dogecoin-overview.png | Bin 29117 -> 29334 bytes 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6fc4dd7e9ae..2cee2d30e28 100644 --- a/README.md +++ b/README.md @@ -85,15 +85,16 @@ object page lands on that chain's overview and says why, rather than looking up a Bitcoin transaction id on Dogecoin. ![The Dogecoin overview page. The chain switcher in the header reads DOGE -Dogecoin, Ready. A status rail reads Ready, block 5,623,041, zero blocks behind, -observed 25 seconds ago, complete pending coverage. Below it a panel titled -Questions this chain can answer marks transaction, block, address and outpoint -lookup and fee estimates as offered, and projected blocks as not offered. A -protocol indexers panel lists Doginals and DRC-20 as ready with complete -history, and TAP on Doge as degraded with partial history, 184 blocks behind, -followed by the sentence: this protocol's indexer answered, but its last -checkpoint is behind the chain -tip.](docs/product/screenshots/dogecoin-overview.png) +Dogecoin, Ready. A status rail of five labelled readings gives Chain: Ready, +Chain tip: block 5,623,041, Behind tip: zero blocks, Last observed: 20 seconds +ago, Pending coverage: Complete. Below it a panel titled How much history is +readable now reads confirmed history complete, address history complete, +protocol history partial. A panel titled Questions this chain can answer marks +transaction, block, address and outpoint lookup and fee estimates as offered, +and projected blocks as not offered. A protocol indexers panel lists Doginals +and DRC-20 as ready with complete history, and TAP on Doge as degraded with +partial history, 184 blocks +behind.](docs/product/screenshots/dogecoin-overview.png) Both screenshots are taken by the review harness in `scripts/universe/visual-qa`, against fixed review data rather than the live diff --git a/docs/product/screenshots/dogecoin-overview.png b/docs/product/screenshots/dogecoin-overview.png index df589401b8f7eb59eecd0705d41ec3a1fa8faab6..40e1a4ef51dbc7927b5ca5f2602a0aecdbccc7e2 100644 GIT binary patch literal 29334 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 literal 29117 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 Date: Sat, 29 Aug 2026 22:40:11 +0000 Subject: [PATCH 45/45] Nothing wrote down what a release was supposed to be Three components sit behind this origin and each publishes its own commit: the frontend in the config script, the explorer backend on backend-info, and the overlay in the release field of every chain capability document. Nothing recorded what a release expected those three to be, so nothing could tell a coherent deployment from an origin serving a frontend from one release, a backend from another, and an overlay that could not name itself at all. All three have happened. The frontend and the backend were checked against each other, which catches the pair drifting and says nothing about either being the release that was meant to be live; that is how this origin served a frontend forty-three commits behind for a day with every check green. So the build writes RELEASE-MANIFEST.json from the commit it is building, the artifact carries it, install refuses a manifest naming a different commit from the directory it is going into, and the cutover holds the live origin to it before the switch is allowed to stand. The overlay commit is deliberately not pinned. It comes from another repository on its own release train, and pinning it would either be wrong every time that train moved or would couple two releases that are not coupled. What is required of it is the contract this frontend reads and an identity it can state; the commit it reports is recorded. Eleven tests, each a way these three have drifted apart or could: a frontend from another release, a backend from another release, a component publishing nothing, an overlay naming a placeholder, two overlay processes answering one origin, an overlay on a contract this frontend does not read, and a manifest that is missing or foreign, which must fail rather than be assumed good. Run against production as it stands, it passes the frontend, the backend and the contract, and fails the overlay for the reason that is on the public page. --- .github/workflows/universe-ci.yml | 3 + .../workflows/universe-release-artifact.yml | 13 +- docs/operations/DEPLOYMENT.md | 24 +- scripts/universe/release-manifest.mjs | 220 ++++++++++++++++++ scripts/universe/release-manifest.test.mjs | 107 +++++++++ scripts/universe/release.sh | 53 ++++- 6 files changed, 415 insertions(+), 5 deletions(-) create mode 100644 scripts/universe/release-manifest.mjs create mode 100644 scripts/universe/release-manifest.test.mjs diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml index dbe9ae31b09..b5053ad1822 100644 --- a/.github/workflows/universe-ci.yml +++ b/.github/workflows/universe-ci.yml @@ -71,6 +71,9 @@ jobs: - name: Gateway routing run: node --test scripts/universe/gateway.test.mjs + - name: The component identity check can still fail + run: node --test scripts/universe/release-manifest.test.mjs + - name: The gateway bridges an upstream restart rather than failing run: node --test scripts/universe/gateway-restart.test.mjs diff --git a/.github/workflows/universe-release-artifact.yml b/.github/workflows/universe-release-artifact.yml index 068a6af965f..ed6d894b1f9 100644 --- a/.github/workflows/universe-release-artifact.yml +++ b/.github/workflows/universe-release-artifact.yml @@ -74,6 +74,9 @@ jobs: - name: No em dash in what the build actually ships run: node scripts/universe/check-text.mjs frontend/dist + - name: The component identity check can still fail + run: node --test scripts/universe/release-manifest.test.mjs + # release.sh reads the frontend from frontend/build, the backend from # backend/dist, and the gateway from scripts/universe. node_modules and # the compiled gbt module are not packed: install hard-links them from the @@ -102,11 +105,19 @@ jobs: mkdir -p "$stage/production" cp -a production/linux "$stage/production/linux" test -f "$stage/production/linux/universe-explorer-gateway.socket" + # What this release is, written down once and carried with it. The + # frontend, the explorer backend and the gateway in this artifact are + # one commit, and stating so is what makes an origin serving two of + # them from different releases a detectable fault rather than an open + # question. release.sh refuses an install whose manifest names a + # different commit, and the cutover holds the live origin to it. + node scripts/universe/release-manifest.mjs emit --commit='${{ steps.sha.outputs.sha }}' --out="$stage/RELEASE-MANIFEST.json" + test -f "$stage/RELEASE-MANIFEST.json" test -f "$stage/backend/dist/index.js" test -f "$stage/frontend/build/index.html" test -f "$stage/scripts/universe/gateway.mjs" out="$PWD/mempool-$sha.tar.gz" - tar -czf "$out" -C "$stage" backend frontend scripts production + tar -czf "$out" -C "$stage" backend frontend scripts production RELEASE-MANIFEST.json sha256sum "$out" | tee "$out.sha256" printf 'name=mempool-%s\n' "$sha" >> "$GITHUB_OUTPUT" diff --git a/docs/operations/DEPLOYMENT.md b/docs/operations/DEPLOYMENT.md index c223ca3a17b..6da300c690e 100644 --- a/docs/operations/DEPLOYMENT.md +++ b/docs/operations/DEPLOYMENT.md @@ -166,7 +166,8 @@ suite can see that. hard-links the dependency tree from the release in use. A release directory is never overwritten in place. 4. `universe-explorer-release preflight ` runs the gates and changes - nothing. It refuses a release whose build is incomplete, whose + nothing. It refuses a release whose build is incomplete, whose manifest is + missing or names a different commit from the one being installed, whose configuration would advertise a feature it cannot serve, whose database does not answer, whose source registry does not parse or names a token variable that is missing, or where a protocol the registry calls readable has no @@ -216,7 +217,10 @@ suite can see that. An upstream that is genuinely gone is still reported as a gateway failure within a few seconds, so a real outage is never hidden behind a long wait. After the swap it reads `/api/v1/capabilities` and fails the release if any feature is enabled with no routes registered, which - is exactly the state that shipped. A failed verification rolls back. + is exactly the state that shipped. It then holds the three component + identities to `RELEASE-MANIFEST.json`, which is where a frontend and a + backend from different releases, or an overlay that cannot name itself, stop + the cutover. A failed verification rolls back. 6. Run `node scripts/universe/synthetic-check.mjs` against the public origin. It asks the live endpoints with nothing mocked and fails on an empty range, a protocol advertised as readable whose authority cannot answer, a @@ -335,6 +339,22 @@ unnoticed for as long as it did. A build whose SHA is not published must not ship. +Every artifact carries `RELEASE-MANIFEST.json` at its root, generated from the +commit being built by `scripts/universe/release-manifest.mjs`. It names the one +commit the frontend, the explorer backend and the gateway in that artifact all +come from, and the contract versions this frontend reads. It deliberately does +not pin the overlay commit: the overlay is built from another repository on its +own release train, so what the manifest requires of it is the contract and an +identity it can state, and the commit it reports is recorded rather than +required. + +```bash +node scripts/universe/release-manifest.mjs verify --manifest=/opt/universe-explorer/current/RELEASE-MANIFEST.json --origin=https://explorer.bitcoinuniverse.io +``` + +`release.sh` runs the same check against the loopback gateway before the +cutover is allowed to stand. + The overlay's identifier shipped as the literal string `development` and served that to the public. Two faults produced it, and both are worth knowing because neither was visible from anything that was being checked: diff --git a/scripts/universe/release-manifest.mjs b/scripts/universe/release-manifest.mjs new file mode 100644 index 00000000000..83dffc11caa --- /dev/null +++ b/scripts/universe/release-manifest.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node +/** + * What a release is, written down once, and checked against what is serving. + * + * A deployment here is three components behind one origin, and each publishes + * its own commit: the frontend in `/resources/config.js`, the explorer backend + * on `/api/v1/backend-info`, and the protocol overlay in the `release.sha` of + * every chain capability document. Nothing recorded what a release expected + * those three to be, so nothing could tell a coherent deployment from an + * origin serving a frontend from one release, a backend from another, and an + * overlay that could not name itself at all. Every one of those has happened. + * + * The manifest is generated from the commit being built and travels inside the + * artifact. `verify` reads it and holds a live origin to it. + * + * Deliberately, the overlay commit is not pinned. The overlay is built from a + * different repository on its own release train, and a manifest that pinned it + * would either be wrong every time that train moved or would couple two + * releases that are not coupled. What this pins is what the overlay has to + * satisfy: the contract versions this frontend reads, and that it can name + * itself at all. The commit it reports is recorded rather than required. + * + * Usage: + * release-manifest.mjs emit --commit= --out= [--built-at=] + * release-manifest.mjs verify --manifest= --origin= + */ +import { readFileSync, writeFileSync } from 'node:fs'; + +export const RELEASE_MANIFEST_SCHEMA = 'universe-release-manifest-v1'; + +/** + * The contracts this frontend reads, by the version string each document + * declares. A deployment whose overlay answers a different one renders pages + * against a shape nobody tested, which is a fault that looks like missing data + * rather than like a version mismatch. + */ +export const REQUIRED_CONTRACTS = { + chainCapability: 'universe-chain-capability-v1', +}; + +const COMMIT_SHA = /^[0-9a-f]{7,64}$/; + +/** + * The three components this artifact carries, all built from one commit. + * + * Stating that they are the same commit is the point. It is what makes a + * frontend and a backend reporting different builds a detectable fault rather + * than an open question. + */ +export function buildManifest({ commit, builtAt }) { + if (!COMMIT_SHA.test(String(commit ?? ''))) { + throw new Error(`release manifest needs a commit, got ${JSON.stringify(commit ?? null)}`); + } + return { + schemaVersion: RELEASE_MANIFEST_SCHEMA, + commit, + shortCommit: commit.slice(0, 9), + builtAt, + components: { + frontend: { commit, publishedAt: '/resources/config.js' }, + explorerBackend: { commit, publishedAt: '/api/v1/backend-info' }, + gateway: { commit, publishedAt: null }, + }, + // Not pinned, for the reason at the top of this file. Recorded at cutover. + overlay: { commit: null, publishedAt: '/api/v1/chains' }, + requires: { ...REQUIRED_CONTRACTS }, + }; +} + +/** + * Holds observed component identities to a manifest. + * + * Pure, so the cases it exists for can be tested without an origin. The caller + * reads the three published values; this decides whether they are a release. + */ +export function verifyManifest(manifest, observed) { + const failures = []; + const notes = []; + + if (!manifest || manifest.schemaVersion !== RELEASE_MANIFEST_SCHEMA) { + failures.push( + `the release manifest is missing or declares ${JSON.stringify(manifest?.schemaVersion ?? null)}, not ${RELEASE_MANIFEST_SCHEMA}`, + ); + return { failures, notes }; + } + const expected = manifest.shortCommit; + if (!COMMIT_SHA.test(String(expected ?? ''))) { + failures.push(`the release manifest names no commit`); + return { failures, notes }; + } + + const frontend = observed.frontendCommit; + if (!frontend) { + failures.push('the origin publishes no frontend commit'); + } else if (!expected.startsWith(frontend) && !frontend.startsWith(expected)) { + failures.push(`the origin serves frontend ${frontend}, and this release is ${expected}`); + } else { + notes.push(`frontend ${frontend}`); + } + + const backend = observed.backendCommit; + if (!backend) { + failures.push('the origin publishes no explorer backend commit'); + } else if (!expected.startsWith(backend) && !backend.startsWith(expected)) { + failures.push(`the origin runs explorer backend ${backend}, and this release is ${expected}`); + } else { + notes.push(`explorer backend ${backend}`); + } + + // The overlay is not pinned, so what is checked is that it can name itself + // and that it answers the contract this frontend reads. + const overlay = observed.overlayRelease; + if (!overlay || overlay === 'development' || !COMMIT_SHA.test(String(overlay))) { + failures.push( + `the overlay names its release as ${JSON.stringify(overlay ?? null)}, which is not a commit`, + ); + } else { + notes.push(`overlay ${overlay}, which this release does not pin`); + } + + const contract = observed.chainCapabilitySchema; + if (contract !== manifest.requires?.chainCapability) { + failures.push( + `the chain documents declare ${JSON.stringify(contract ?? null)} and this release reads ${JSON.stringify(manifest.requires?.chainCapability ?? null)}`, + ); + } else { + notes.push(`chain capability contract ${contract}`); + } + + if (observed.overlayReleases && new Set(observed.overlayReleases).size > 1) { + failures.push( + `the chain documents name ${new Set(observed.overlayReleases).size} different overlay releases, so more than one process is answering`, + ); + } + + return { failures, notes }; +} + +async function readObserved(origin) { + const base = origin.replace(/\/$/, ''); + const config = await fetch(`${base}/resources/config.js`, { + signal: AbortSignal.timeout(20_000), + }).then((response) => response.text()); + const info = await fetch(`${base}/api/v1/backend-info`, { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(20_000), + }) + .then((response) => response.json()) + .catch(() => null); + const chains = await fetch(`${base}/api/v1/chains`, { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(20_000), + }) + .then((response) => response.json()) + .catch(() => null); + + const releases = Array.isArray(chains) + ? chains.map((entry) => entry?.release?.sha).filter((sha) => typeof sha === 'string') + : []; + return { + frontendCommit: config.match(/GIT_COMMIT_HASH\s*=\s*'([^']+)'/)?.[1] ?? null, + backendCommit: info?.gitCommit ?? null, + overlayRelease: releases[0] ?? null, + overlayReleases: releases, + chainCapabilitySchema: Array.isArray(chains) ? (chains[0]?.schemaVersion ?? null) : null, + }; +} + +function argument(name) { + const found = process.argv.slice(2).find((value) => value.startsWith(`--${name}=`)); + return found ? found.slice(name.length + 3) : null; +} + +async function main() { + const command = process.argv[2]; + if (command === 'emit') { + const manifest = buildManifest({ + commit: argument('commit'), + builtAt: argument('built-at') ?? new Date().toISOString(), + }); + const out = argument('out'); + const rendered = `${JSON.stringify(manifest, null, 2)}\n`; + if (out) { + writeFileSync(out, rendered, 'utf8'); + console.log(`Release manifest for ${manifest.shortCommit} written to ${out}`); + } else { + process.stdout.write(rendered); + } + return; + } + + if (command === 'verify') { + const path = argument('manifest'); + const origin = argument('origin'); + if (!path || !origin) { + throw new Error('verify needs --manifest= and --origin='); + } + const manifest = JSON.parse(readFileSync(path, 'utf8')); + const observed = await readObserved(origin); + const { failures, notes } = verifyManifest(manifest, observed); + console.log(`Release manifest ${manifest.shortCommit ?? 'unknown'} against ${origin}`); + for (const note of notes) console.log(` ok ${note}`); + for (const failure of failures) console.error(` FAIL ${failure}`); + if (failures.length) { + console.error(`\n${failures.length} component identity check(s) failed`); + process.exit(1); + } + console.log('\nEvery component identity matches the release manifest'); + return; + } + + throw new Error('usage: release-manifest.mjs {emit|verify} ...'); +} + +if (process.argv[1]?.endsWith('release-manifest.mjs')) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/scripts/universe/release-manifest.test.mjs b/scripts/universe/release-manifest.test.mjs new file mode 100644 index 00000000000..30639e21400 --- /dev/null +++ b/scripts/universe/release-manifest.test.mjs @@ -0,0 +1,107 @@ +/** + * Proof that the component identity check can fail. + * + * Each case is a way the three components behind this origin have drifted + * apart, or could. They are worth writing down because none of them looks like + * an outage: every one of these deployments serves pages, answers the API, and + * is wrong about what it is. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildManifest, verifyManifest } from './release-manifest.mjs'; + +const COMMIT = 'bea93c1ec7f608313e6ac1d0b0ba9a29e2a7c2f1'; +const MANIFEST = buildManifest({ commit: COMMIT, builtAt: '2026-08-29T22:00:00.000Z' }); + +function observed(overrides = {}) { + return { + frontendCommit: 'bea93c1ec', + backendCommit: 'bea93c1ec', + overlayRelease: 'a06f8ed1b', + overlayReleases: ['a06f8ed1b', 'a06f8ed1b', 'a06f8ed1b'], + chainCapabilitySchema: 'universe-chain-capability-v1', + ...overrides, + }; +} + +function said(result, needle) { + return result.failures.some((failure) => failure.includes(needle)); +} + +test('a coherent deployment passes', () => { + const result = verifyManifest(MANIFEST, observed()); + assert.deepEqual(result.failures, []); + assert.equal(result.notes.length, 4); +}); + +test('the manifest states one commit for all three components it carries', () => { + assert.equal(MANIFEST.components.frontend.commit, COMMIT); + assert.equal(MANIFEST.components.explorerBackend.commit, COMMIT); + assert.equal(MANIFEST.components.gateway.commit, COMMIT); + assert.equal(MANIFEST.shortCommit, 'bea93c1ec'); +}); + +test('the overlay commit is recorded and not pinned', () => { + // Pinning it would couple two release trains that are not coupled. What the + // release requires of the overlay is the contract and an identity. + assert.equal(MANIFEST.overlay.commit, null); + assert.deepEqual(verifyManifest(MANIFEST, observed({ overlayRelease: 'ffffff1', overlayReleases: ['ffffff1'] })).failures, []); +}); + +test('an origin serving a frontend from another release is detected', () => { + // The exact fault this exists for: 43 commits of work on develop, an origin + // serving the release before it, and every check green. + assert.ok(said(verifyManifest(MANIFEST, observed({ frontendCommit: '521a091' })), 'serves frontend 521a091')); +}); + +test('a backend from another release is detected', () => { + assert.ok( + said(verifyManifest(MANIFEST, observed({ backendCommit: '521a091' })), 'explorer backend 521a091'), + ); +}); + +test('a component that publishes nothing is detected', () => { + assert.ok(said(verifyManifest(MANIFEST, observed({ frontendCommit: null })), 'no frontend commit')); + assert.ok(said(verifyManifest(MANIFEST, observed({ backendCommit: null })), 'no explorer backend commit')); +}); + +test('an overlay that cannot name itself is detected', () => { + assert.ok( + said(verifyManifest(MANIFEST, observed({ overlayRelease: 'development' })), 'which is not a commit'), + ); + assert.ok(said(verifyManifest(MANIFEST, observed({ overlayRelease: null })), 'which is not a commit')); + assert.ok(said(verifyManifest(MANIFEST, observed({ overlayRelease: 'HEAD' })), 'which is not a commit')); +}); + +test('two overlay processes answering one origin are detected', () => { + const result = verifyManifest( + MANIFEST, + observed({ overlayReleases: ['a06f8ed1b', 'a06f8ed1b', 'c0e28dc6f'] }), + ); + assert.ok(said(result, 'more than one process is answering')); +}); + +test('an overlay answering a different contract than this release reads is detected', () => { + const result = verifyManifest( + MANIFEST, + observed({ chainCapabilitySchema: 'universe-chain-capability-v2' }), + ); + assert.ok(said(result, 'universe-chain-capability-v2')); +}); + +test('a missing or foreign manifest is detected rather than assumed good', () => { + assert.ok(said(verifyManifest(null, observed()), 'missing or declares')); + assert.ok(said(verifyManifest({ schemaVersion: 'something-else' }, observed()), 'missing or declares')); + assert.ok( + said( + verifyManifest({ schemaVersion: 'universe-release-manifest-v1' }, observed()), + 'names no commit', + ), + ); +}); + +test('a manifest cannot be built without a commit', () => { + assert.throws(() => buildManifest({ commit: 'development' }), /needs a commit/); + assert.throws(() => buildManifest({ commit: '' }), /needs a commit/); +}); diff --git a/scripts/universe/release.sh b/scripts/universe/release.sh index 45540562756..3d71cb013b3 100644 --- a/scripts/universe/release.sh +++ b/scripts/universe/release.sh @@ -96,6 +96,32 @@ gate_release_present() { [ -f "$dir/scripts/universe/gateway.mjs" ] || fail "release has no gateway" [ -f "$dir/backend/dist/tasks/pools/pools-v2.json" ] \ || fail "release has no bundled mining pool metadata, so enabling the database would abort at startup" + [ -f "$dir/RELEASE-MANIFEST.json" ] \ + || fail "release carries no manifest, so nothing states what its components are supposed to be" +} + +# The manifest and the directory must name the same commit. +# +# A release directory is named by whoever ran install, and the manifest is +# written by the build. When they disagree, one of them is wrong about what is +# in the tree, and every check downstream reads the wrong answer. +gate_manifest_matches() { + local dir=$1 sha=$2 + local named + named=$(python3 -c " +import json,sys +print(json.load(open(sys.argv[1]))['shortCommit']) +" "$dir/RELEASE-MANIFEST.json") || fail "the release manifest is not readable" + case "$sha" in + "$named"*) ;; + *) + case "$named" in + "$sha"*) ;; + *) fail "the release manifest names $named and this is being installed as $sha" ;; + esac + ;; + esac + log "manifest names $named" } # The same combinations the backend refuses at startup, checked before cutover @@ -206,6 +232,7 @@ cmd_preflight() { local dir; dir=$(release_dir "$sha") [ -d "$dir" ] || fail "no release at $dir" gate_release_present "$dir" + gate_manifest_matches "$dir" "$sha" gate_configuration gate_database gate_address_backend @@ -232,6 +259,7 @@ wait_for() { # Returns non-zero rather than exiting, so a failed check can be rolled back # instead of leaving the new release in place with nothing serving. verify_live() { + local dir=$1 wait_for "$GATEWAY/__gateway/health" gateway || { log "gateway did not come back"; return 1; } wait_for "$BACKEND/api/v1/backend-info" backend || { log "backend did not come back"; return 1; } wait_for "$OVERLAY/api/v1/universe/status" overlay || { log "overlay did not come back"; return 1; } @@ -263,6 +291,27 @@ for name, feature in report['features'].items(): PY gate_live_socket || return 1 + gate_component_identity "$dir" || return 1 +} + +# The three components behind this origin, held to what the release says they +# should be. +# +# The checks above prove the backend came back and that its enabled features +# have routes. They say nothing about whether the frontend beside it comes from +# the same release, which is how this origin went on serving a frontend +# forty-three commits behind for a day with every check green, and nothing +# about whether the overlay can name itself, which is how "Release development" +# reached the public. +# +# The overlay is not pinned by the manifest. It is built from another +# repository on its own release train, so what is required of it is the +# contract this frontend reads and an identity it can state. +gate_component_identity() { + local dir=$1 + [ -f "$dir/RELEASE-MANIFEST.json" ] || { log "release carries no manifest"; return 1; } + node "$dir/scripts/universe/release-manifest.mjs" verify \ + --manifest="$dir/RELEASE-MANIFEST.json" --origin="$GATEWAY" } # The live socket, asked for the way a browser asks for it. @@ -348,7 +397,7 @@ cmd_cutover() { else log "the gateway is unchanged, leaving it up so the origin sees no gap" fi - if ! verify_live; then + if ! verify_live "$dir"; then log "verification failed, rolling back" [ -n "$previous" ] && cmd_rollback "$(basename "$previous" | sed 's/^mempool-//')" fail "cutover verification failed" @@ -376,7 +425,7 @@ cmd_rollback() { mv -Tf "$CURRENT.new" "$CURRENT" # shellcheck disable=SC2086 systemctl restart $UNITS - verify_live || fail "rollback target did not come back either" + verify_live "$dir" || fail "rollback target did not come back either" log "rolled back to $sha" }