From 3d59e61b423b0b16ec214bda23d6fbe103129698 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Admin Date: Sat, 19 Sep 2026 05:29:16 +0000 Subject: [PATCH 1/5] docs(explorer): pin asset UI handoff and reconcile concurrent corrections --- .../asset-summary/IMPLEMENTATION-HANDOFF.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 frontend/src/app/universe/asset-summary/IMPLEMENTATION-HANDOFF.md diff --git a/frontend/src/app/universe/asset-summary/IMPLEMENTATION-HANDOFF.md b/frontend/src/app/universe/asset-summary/IMPLEMENTATION-HANDOFF.md new file mode 100644 index 0000000000..df7e68a7b2 --- /dev/null +++ b/frontend/src/app/universe/asset-summary/IMPLEMENTATION-HANDOFF.md @@ -0,0 +1,28 @@ +# Asset summary UI preparation, 2026-09-19 + +Scope: a major, consistent redesign of transaction summaries and address holdings. This is preparation, not an implemented redesign or a release. Do not add wallets, signing, trading, indexers or unrelated protocol products. + +## Exact source baseline +Mempool source: `d0b49e8d136476658685a6cc9ec1528679db4af7`. This commit contains the eight UI source-comment blocks originally appended in the isolated local feature worktree. That worktree was subsequently removed by concurrent work. Resume this preparation branch, not the removed path. +Backend preparation branch: `bitcoinuniverseio/backend-apis`, `prep/asset-summary-ui-20260919`, commit `91d0c6984db34b659d1663270e90c661432fa3bb`, based on `44b615adfa07118919d3130fe10add35a85e38eb`. +Server text handoff target: `D:\universe\mempool\audits\implementation-prep-20260919-ui\mempool_UI_HANDOFF_2026-09-19`. Verify its actual files; a server ZIP has NOT been created. Remote process execution was blocked, and text-file writing cannot reliably transfer an archive. + +## Annotation index and order +UI-WP07: backend `contracts/transaction-asset-summary.ts`, contract parity and fixtures. +UI-WP08: backend `transactions/transaction-asset-summary.service.ts`, CACHE/COVERAGE/DIVISIBILITY/MEDIA/CHECKPOINT markers. Reuse `TransactionFlowService.enricherRegistry()`; coordinate its existing flow cache. +UI-WP01: `transaction-assets/transaction-assets.types.ts`, decoder and exact quantities. +UI-WP02: `transaction-assets/transaction-assets.component.ts`, stable view model, metadata-independent loading and truthful states. +UI-WP03: `transaction-assets/transaction-assets.component.html` and `.scss`, hierarchy, accessible details, bounded numeric columns and container-driven mobile cards. +UI-WP04: `address-assets/address-assets.component.ts`, exact units, full identity, coverage denominator and retry. +UI-WP05: `address-assets/address-assets.component.html` and `.scss`, shared holdings layout and accessible outpoint details. +UI-WP06: `transaction-assets/transaction-assets.component.spec.ts`, regression and real read-only acceptance. +Order: WP07 + WP08, then WP01, WP02, WP03, WP04, WP05, WP06. Do not treat deleting a marker as completion. + +## Baseline drift corrections +The original WP01/WP02 prose describes the earlier local source. At d0b49e8, `logo()` already requires `verified === true`, and the effects template already says `(not accepted)`. Preserve both concurrent corrections and verify them; they are NOT unimplemented defects now. Their application tests were not run in this preparation. Counts/coverage validation, missing effect evidence and the redesign remain required. + +## Design and verification rules +Use the existing Universe tokens, a single coverage badge, aligned Inputs/Outputs or Held/Positions, explicit approximation only for compact headlines, full exact-value copy in row details, labelled protocol fallbacks, and a <=720px container breakpoint. Do not wrap primary amounts, guess decimals, sum unlike assets, convert atomic strings to floating point, hide gaps, or require hover to read full data. All controls must work with keyboard and touch. +The supplied standalone HTML design has 120 passing viewport/theme/state checks. It is fixture-only evidence, NOT Angular, API, Signet or production acceptance. The backend service's annotation emits identical comment-free JavaScript; repository builds remain untested. Read the complete bundled execution prompt and coverage matrix. +The pinned registry defaults Bitcoin protocols to mainnet-only. A missing Signet advertisement does not prove a protocol cannot support Signet. Resolve actual test-source capabilities without changing production defaults; use a justified supported Testnet only when needed. Do not invent test transactions for this read-only feature. +Full functional acceptance on real supported test-network paths, dependent-flow regression, and completed public Mainnet deployment are the final GO gate. No merge or deployment was performed by this preparation agent. From bfbc45e1c28438bb411d7369e7b1a12cd39b0b34 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 19 Sep 2026 08:55:33 +0000 Subject: [PATCH 2/5] feat(explorer): rebuild the transaction and address asset panels WP01 to WP06 of the asset summary UI plan, whose eight source markers are resolved and removed. The backend repairs these depend on land alongside. A shared asset-summary directory holds what the two panels must not render two different ways: the quantity presenter, the layout mixins, and the helper that makes their real templates renderable under the unit runner. The presenter never constructs a Number from a quantity, keeps an exact decimal, true digits of an unknown scale and an unknown quantity as three separate cases, and gives a long value an explicitly marked short form while copy always yields the exact string. A very small nonzero amount becomes an exponent rather than zero. The decoder validates the coverage enum instead of casting it, rejects duplicate protocol coverage and coverage from another context, requires the asset and coverage arrays rather than reading an absent one as an empty inventory, and refuses a stated total that conclusive coverage does not support or that disagrees with the identities listed. Effect evidence and the reading's checkpoint are preserved, so "not accepted" can be told from "not yet ruled on" and an absent checkpoint stays absent. The transaction component builds one display model per response instead of mapping inside template bindings, seeds the optional protocol registry so combineLatest cannot hold a proven amount behind a display name, states each coverage gap with its own state and reason instead of counting things being waited on, offers a retry only where one could change the answer, honours the payload's cooldown, and keys a failed logo by its artwork revision. Both templates keep a real table with a row header and put flex inside the header cell rather than on it, bound their numeric columns through an explicit colgroup, and stack by the panel's own width through a container query rather than by the viewport. Each row has a 44px disclosure carrying the full identity, the exact values and its own protocol effects, and every copy control reports what actually happened. The address panel keys holdings by the whole identity, carries divisibility and ruleset, resolves a divisibility conflict to unknown, separates a known quantity from the scope it was read over, states a checked and a total denominator, distinguishes a proven empty address from one that could not be fully checked, and routes its outpoint links through the active network. A bare /outpoint route resolves against the root network, so on signet those links pointed at mainnet. Two visual-QA fixtures were missing rather than wrong. The router falls back to a prefix match, so the summary endpoint was served the detailed flow payload and every transaction and address screenshot reviewed the panel's error state instead of the panel; the outpoint batch answered every request with an empty list. Frontend: 2491 unit tests pass, including twelve rendered-template cases for the transaction panel and twelve for the address panel. Lint reports no errors, the production build compiles both templates, and the text gate passes. Co-Authored-By: Claude Opus 5 --- .../address-assets.component.html | 317 ++++++-- .../address-assets.component.scss | 298 ++++--- .../address-assets.component.spec.ts | 477 +++++++++++- .../address-assets.component.ts | 402 +++++++--- .../asset-summary/IMPLEMENTATION-HANDOFF.md | 87 ++- .../asset-summary/_asset-summary.scss | 472 +++++++++++ .../asset-summary.presentation.spec.ts | 175 +++++ .../asset-summary.presentation.ts | 248 ++++++ .../asset-summary.test-render.ts | 29 + .../transaction-assets.component.html | 347 ++++++--- .../transaction-assets.component.scss | 363 +++------ .../transaction-assets.component.spec.ts | 732 ++++++++++++++---- .../transaction-assets.component.ts | 493 ++++++++---- .../transaction-assets.types.spec.ts | 269 +++++++ .../transaction-assets.types.ts | 262 +++++-- frontend/src/app/universe/universe.types.ts | 18 + scripts/universe/visual-qa/fixture-router.mjs | 50 +- scripts/universe/visual-qa/fixtures.mjs | 95 +++ 18 files changed, 4105 insertions(+), 1029 deletions(-) create mode 100644 frontend/src/app/universe/asset-summary/_asset-summary.scss create mode 100644 frontend/src/app/universe/asset-summary/asset-summary.presentation.spec.ts create mode 100644 frontend/src/app/universe/asset-summary/asset-summary.presentation.ts create mode 100644 frontend/src/app/universe/asset-summary/asset-summary.test-render.ts diff --git a/frontend/src/app/universe/address-assets/address-assets.component.html b/frontend/src/app/universe/address-assets/address-assets.component.html index b293092eb7..dadd3555b7 100644 --- a/frontend/src/app/universe/address-assets/address-assets.component.html +++ b/frontend/src/app/universe/address-assets/address-assets.component.html @@ -1,89 +1,266 @@ +
+
-

Universe assets

{{ state.reason }}

+
+

Asset holdings

+
+

{{ state.reason }}

-

Universe assets

-

+
+

Asset holdings

+ Checking +
+ +
-

Universe assets

-

- The asset authority could not be reached, so this address's protocol assets - are not shown. Its bitcoin balance above is unaffected. -

+
+

Asset holdings

+
+
+ + The asset authority could not be reached, so this address's protocol assets + are not shown. Its bitcoin balance above is unaffected. + + +
-

Universe assets

- -

- - Checked {{ state.resolved }} of this address's unspent outputs. - - - {{ state.notResolved }} more were not checked on this page. - -

- -

- At least one output could not be fully accounted for, so this list may be - incomplete. -

- -

- No supported protocol asset was found on the outputs checked. -

- - +
+

Asset holdings

+ + {{ state.holdings.length }} held + None held + + + Partial + Complete + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AssetHeldPositionsDetails
+ + + + {{ holding.displayName }} + + {{ holding.protocolId }} + {{ + holding.shortAssetId }} + + + + + + + + {{ holding.held.headline }} + {{ holding.ticker }} + + + {{ holding.held.headline }} + smallest units + + Not published + + approximate + + + + {{ holding.outpoints.length }} + + + +
+
+
Identity
+
+ {{ holding.chain || 'unknown chain' }} / + {{ holding.network || 'unknown network' }} / {{ holding.protocolId }} +
-

- Observed checkpoint: block {{ state.checkpointHeight }}. -

+
Asset id
+
+ {{ holding.assetId }} + + + Copied + Could not copy. + Select the value to copy it yourself. + +
+ +
Ruleset
+
{{ holding.ruleset }}
+ +
Divisibility
+
+ {{ holding.decimals }} + Not published, so amounts are shown in the + smallest units +
+ +
Exact amount held
+
+ Not published + + {{ holding.held.exact }} + smallest units + + + Copied + Could not copy. + Select the value to copy it yourself. + + +
+ +
Outputs
+
+ +
+
+
+ +

Every unspent output this address holds was checked, and none carries a + supported protocol asset.

+ +

No supported protocol asset was found on the outputs checked, but not + every output could be checked, so this is not proof that the address holds none.

+ + +
+ + + Partial + Complete + + Checked {{ state.resolved }} of {{ state.total }} unspent + outputs + +
    +
  • + Not checked on this page + {{ state.notResolved }} +
  • +
  • + Scope + At least one output could not be fully + accounted for, so this list may be incomplete. +
  • +
  • + Read at block + {{ state.checkpointHeight }} +
  • +
  • + Read at block + Not published, or the outputs were read + at different blocks. +
  • +
+ +
- - diff --git a/frontend/src/app/universe/address-assets/address-assets.component.scss b/frontend/src/app/universe/address-assets/address-assets.component.scss index 2b057efba0..d61cb118a8 100644 --- a/frontend/src/app/universe/address-assets/address-assets.component.scss +++ b/frontend/src/app/universe/address-assets/address-assets.component.scss @@ -1,4 +1,24 @@ @use '../universe-tokens' as u; +@use '../asset-summary/asset-summary' as summary; + +/** + * The address asset holdings panel. + * + * Geometry comes from the shared asset-summary mixins, the same ones the + * transaction summary uses, so the two panels cannot drift into rendering the + * same fact two different ways. What is local here is the outpoint list and the + * panel's own notices. + * + * Two defects this replaces are worth naming. The quantity column was + * `max-content` at every width, so one arbitrary precision integer set the + * row's intrinsic width and pushed the panel past the page. And every output + * identifier was expanded by default, so an address with many outputs produced + * an unbounded wall of links above everything else on the page. The links are + * all still here, and none is clamped away: they moved into each row's own + * disclosure, which is where a reader opens the one holding they are checking. + * + * The global identifier mixin is used, never modified: other panels depend on it. + */ :host { @include u.universe-protocol-tokens; @@ -9,136 +29,198 @@ } .panel { - @include u.universe-surface; + @include summary.asset-summary-panel; + @include summary.asset-summary-identity; + @include summary.asset-summary-quantity; + @include summary.asset-summary-controls; + @include summary.asset-summary-details; + @include summary.asset-summary-visually-hidden; - padding: 1rem; + margin-top: 0; - h2 { - font-size: 1rem; - margin: 0 0 0.6rem; + .universe-chip { + @include u.universe-chip; } - p { - margin: 0 0 0.5rem; - max-width: 72ch; + &.unavailable { + border-left: 3px solid var(--universe-state-unavailable); } - p:last-child { - margin-bottom: 0; + .summary-header { + @include summary.asset-summary-header; } - &.unavailable { - border-left: 3px solid var(--universe-state-unavailable); + .summary-badge { + margin-left: auto; } -} -.identifier { - @include u.universe-identifier; -} + .summary-count { + font-size: var(--u-text-sm); + color: var(--u-text-secondary); + } -.coverage, -.asof { - color: var(--u-text-muted); - font-size: 0.85rem; -} + // Reserved geometry, so the answer arriving does not move the page. + .summary-skeleton { + display: flex; + flex-direction: column; + gap: var(--u-space-2); + min-height: 3.5rem; + + .skeleton-bar { + display: block; + height: 1.5rem; + border-radius: var(--u-radius-sm); + background: var(--u-surface-sunken); + } + } -.partial { - color: var(--universe-state-partial); - font-size: 0.85rem; -} + .summary-notice { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--u-space-2) var(--u-space-3); + margin: 0; + max-width: 72ch; + font-size: var(--u-text-sm); + color: var(--u-text-secondary); + } -.empty { - color: var(--u-text-muted); -} + .summary-retry { + background: var(--u-surface-raised); + border: 1px solid var(--u-border-strong); + border-radius: var(--u-radius-sm); + color: var(--u-text-primary); + cursor: pointer; + font: inherit; + font-weight: var(--u-weight-semibold); + padding: 0.25rem 0.85rem; + + @include u.universe-touch-box; + + &:hover { + background: var(--u-surface-hover); + } + + &:focus-visible { + outline: 2px solid var(--u-focus-ring); + outline-offset: 2px; + } + } -.holding-list { - list-style: none; - margin: 0 0 0.6rem; - padding: 0; - - li { - display: grid; - grid-template-columns: minmax(6rem, max-content) minmax(8rem, 1fr) max-content; - grid-template-areas: - 'protocol asset quantity' - 'outputs outputs outputs'; - align-items: baseline; - gap: 0.25rem 0.75rem; - padding: 0.45rem 0; - border-top: 1px solid var(--u-divider); + .summary-table { + @include summary.asset-summary-table; } - li:first-child { - border-top: 0; + .cell-label { + display: none; } -} -.protocol { - grid-area: protocol; - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.06em; -} + .disclose-cell { + text-align: right; + } -.asset { - grid-area: asset; - font-weight: 600; - overflow-wrap: anywhere; -} + .protocol-link { + font-size: var(--u-text-xs); + text-transform: uppercase; + letter-spacing: 0.06em; + min-width: 0; -.quantity { - grid-area: quantity; - font-variant-numeric: tabular-nums; -} + @include u.universe-touch-line; + } -.outputs { - grid-area: outputs; - display: flex; - flex-wrap: wrap; - gap: 0.35rem 0.6rem; -} + // Every outpoint, readable and wrappable, each link at the touch target + // floor. Nothing is clamped or omitted to shorten the panel. + .outpoint-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.6rem; + min-width: 0; + + li { + min-width: 0; + } + + .identifier { + @include u.universe-identifier; + @include u.universe-touch-line; + + overflow-wrap: anywhere; + min-width: 0; + } + } -.skeleton-line { - height: 1rem; - width: 60%; - border-radius: 0.2rem; - background: var(--u-divider); -} + .summary-coverage { + margin-top: var(--u-space-3); + font-size: var(--u-text-sm); + color: var(--u-text-secondary); + + summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--u-space-2); + cursor: pointer; + + @include u.universe-touch-line; + + &:focus-visible { + outline: 2px solid var(--u-focus-ring); + outline-offset: 2px; + } + } + + .coverage-list { + list-style: none; + margin: var(--u-space-2) 0; + padding: 0; + display: grid; + gap: 0.3rem; + + li { + display: grid; + grid-template-columns: minmax(0, 12rem) minmax(0, 1fr); + gap: 0.2rem var(--u-space-3); + align-items: baseline; + } + } + + .coverage-name { + color: var(--u-text-primary); + min-width: 0; + } + + .coverage-state { + color: var(--u-text-secondary); + font-variant-numeric: tabular-nums; + min-width: 0; + } + + .coverage-reason { + color: var(--u-text-muted); + font-size: var(--u-text-xs); + min-width: 0; + } + } + + // Driven by the panel's own width: the address page's container is narrower + // than the transaction page's, so a viewport query would stack one and not + // the other at the same reading width. + @container (max-width: 720px) { + @include summary.asset-summary-stacked; + + padding: var(--u-space-3); -@media (max-width: 575px) { - .holding-list li { - grid-template-columns: 1fr max-content; - grid-template-areas: - 'protocol quantity' - 'asset asset' - 'outputs outputs'; + .summary-badge { + margin-left: 0; + } + + .summary-coverage .coverage-list li { + grid-template-columns: minmax(0, 1fr); + } } -} -/* IMPLEMENTATION-HANDOFF [UI-WP05:ADDRESS-LAYOUT] 2026-09-19 - * Coverage C22-C32/C33-C44; defects F02/F04. Preparation only. - * Verified: the quantity column is max-content on desktop AND below 575px; - * an arbitrary-precision integer can therefore set the row's intrinsic width. - * All output identifiers are expanded by default, creating unbounded height. - * Sources R01 WCAG 2.2 reflow; existing global u-tokens; UI-WP03 shared layout. - * 1. Import the proposed shared asset-summary mixin after UI-WP03. Use bounded - * Held/Positions columns and a minmax(0,1fr) identity area, min-width:0 on - * every flexible child. Eliminate the unconstrained max-content amount. - * 2. Use container-driven cards at <=720px, with labelled Held/Positions boxes - * below identity; verify the panel inside the real address page container. - * 3. Keep full quantity text in the explicit exact-value detail. Primary values - * use the same labelled compaction as transactions and never wrap digits. - * 4. Keep output references in an expanded detail list with readable mono text - * and safe line wrapping; every link remains at least the u-touch-target - * floor. Do not clamp or omit actual outpoint links to reduce panel height. - * 5. Reserve fixed logo and skeleton boxes, use existing state token pairs, - * and restore keyboard focus rings in both themes and forced-colors mode. - * No new global palette, external font, arbitrary animation or decorative - * chart is required. Keep this work limited to the requested asset panels. - * Tests: C22-C32 crossed with the address route; 39 and 1000 digit stress - * values, 320px width, 200/400 percent zoom, both themes, details and links. - * Check document width and control bounds, not screenshots alone. Browser - * application tests were NOT RUN; the separate HTML reference is not a pass. - * Dependencies UI-WP03/04/05 markup. Revert paired local styles on rollback; - * do not modify the global universe-identifier mixin to repair this panel. - */ + @include summary.asset-summary-environment; +} diff --git a/frontend/src/app/universe/address-assets/address-assets.component.spec.ts b/frontend/src/app/universe/address-assets/address-assets.component.spec.ts index 9e465fcb9b..1ce91ad853 100644 --- a/frontend/src/app/universe/address-assets/address-assets.component.spec.ts +++ b/frontend/src/app/universe/address-assets/address-assets.component.spec.ts @@ -1,4 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; +// @vitest-environment jsdom +import 'zone.js'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { firstValueFrom, of } from 'rxjs'; import { AddressAssetsComponent } from './address-assets.component'; import { summarise } from '@app/universe/address-assets/address-assets.component'; @@ -177,3 +179,476 @@ describe('summarise', () => { expect(summary.partial).toBe(true); }); }); + + +// --------------------------------------------------------------------------- +// WP04 and WP05: identity, units, scope and the rendered panel. +// --------------------------------------------------------------------------- + +import { TestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; +import { provideRouter } from '@angular/router'; +import { StateService } from '@app/services/state.service'; +import { resolveTemplates } from '@app/universe/asset-summary/asset-summary.test-render'; +import { UniverseApiService } from '@app/universe/universe-api.service'; +import type { AddressAssetsState } from './address-assets.component'; + +const CHECKPOINT = { + chain: 'bitcoin', + network: 'signet', + heightAtomic: '240000', + blockHash: 'c'.repeat(64), + reorgEpoch: '0', +}; + +/** A result carrying a checkpoint, so identities have a chain and a network. */ +function scoped(patch: Partial = {}): OutpointEnrichment { + return result({ checkpoint: CHECKPOINT as never, ...patch }); +} + +function positionAt( + outpoint: string, + asset: Record, + quantityAtomic?: string, +): unknown { + return { + outpoint, + vout: Number(outpoint.slice(65)), + valueSatsAtomic: '546', + asset, + quantityAtomic, + state: 'unspent', + evidence: { authorityId: 'ord', coverage: 'complete' }, + }; +} + +const OUT_A = `${'a'.repeat(64)}:0`; +const OUT_B = `${'b'.repeat(64)}:1`; + +describe('address holdings keep the whole identity', () => { + it('never merges two rulesets over one ledger', () => { + const holdings = summarise([ + scoped({ + positions: [ + positionAt(OUT_A, { protocolId: 'runes', assetId: 'A', assetKind: 'fungible', ruleset: 'strict' }, '5'), + positionAt(OUT_A, { protocolId: 'runes', assetId: 'A', assetKind: 'fungible', ruleset: 'lenient' }, '7'), + ] as never, + }), + ]).holdings; + // Two rulesets are two assets. A merged total is one no authority states. + expect(holdings).toHaveLength(2); + expect(holdings.map((holding) => holding.quantityAtomic).sort()).toEqual(['5', '7']); + }); + + it('carries the chain, network, kind and ruleset through to the row', () => { + const [holding] = summarise([ + scoped({ + positions: [ + positionAt(OUT_A, { + protocolId: 'runes', + assetId: 'UNCOMMON.GOODS', + assetKind: 'fungible', + ticker: 'UNCOMMON', + displayName: 'Uncommon Goods', + ruleset: 'strict', + decimals: 2, + }, '1234'), + ] as never, + }), + ]).holdings; + expect(holding.chain).toBe('bitcoin'); + expect(holding.network).toBe('signet'); + expect(holding.assetKind).toBe('fungible'); + expect(holding.ruleset).toBe('strict'); + expect(holding.ticker).toBe('UNCOMMON'); + expect(holding.decimals).toBe(2); + }); + + it('keeps the stated divisibility and leaves a conflict unknown', () => { + const one = summarise([ + scoped({ + positions: [ + positionAt(OUT_A, { protocolId: 'runes', assetId: 'A', assetKind: 'fungible', decimals: 8 }, '100000000'), + ] as never, + }), + ]).holdings[0]; + expect(one.decimals).toBe(8); + + // Two outputs, because one output cannot hold one identity twice: that is + // the duplicate guard, and it is a different rule from this one. + const conflicted = summarise([ + scoped({ + positions: [ + positionAt(OUT_A, { protocolId: 'runes', assetId: 'A', assetKind: 'fungible', decimals: 2 }, '1'), + ] as never, + }), + scoped({ + outpoint: OUT_B, + positions: [ + positionAt(OUT_B, { protocolId: 'runes', assetId: 'A', assetKind: 'fungible', decimals: 8 }, '1'), + ] as never, + }), + ]).holdings[0]; + // Unknown, not the last one stated: the scale would be off by six orders. + expect(conflicted.decimals).toBeNull(); + // The atomic sum is still exact. + expect(conflicted.quantityAtomic).toBe('2'); + }); + + it('refuses an out-of-range divisibility without disturbing a valid one', () => { + const holding = summarise([ + scoped({ + positions: [ + positionAt(OUT_A, { protocolId: 'runes', assetId: 'A', assetKind: 'fungible', decimals: 39 }, '1'), + ] as never, + }), + scoped({ + outpoint: OUT_B, + positions: [ + positionAt(OUT_B, { protocolId: 'runes', assetId: 'A', assetKind: 'fungible', decimals: 8 }, '1'), + ] as never, + }), + ]).holdings[0]; + expect(holding.decimals).toBe(8); + }); + + it('counts an inscription as one item and an absent fungible amount as unknown', () => { + const inscription = summarise([ + scoped({ + positions: [ + positionAt(OUT_A, { protocolId: 'ordinals', assetId: 'i0', assetKind: 'inscription' }), + ] as never, + }), + ]).holdings[0]; + // One item by the protocol's own definition, which is the contract. + expect(inscription.quantityAtomic).toBe('1'); + expect(inscription.quantityKnown).toBe(true); + + const fungible = summarise([ + scoped({ + positions: [ + positionAt(OUT_A, { protocolId: 'runes', assetId: 'A', assetKind: 'fungible' }), + ] as never, + }), + ]).holdings[0]; + expect(fungible.quantityAtomic).toBeNull(); + expect(fungible.quantityKnown).toBe(false); + }); + + it('tracks a known quantity separately from the coverage of the scope', () => { + const summary = summarise([ + scoped({ positions: [positionAt(OUT_A, { protocolId: 'runes', assetId: 'A', assetKind: 'fungible' }, '5')] as never }), + scoped({ outpoint: OUT_B, status: 'unavailable' as never }), + ]); + // The sum of what was read is exact; the set it was read from is not whole. + expect(summary.holdings[0].quantityKnown).toBe(true); + expect(summary.holdings[0].quantityAtomic).toBe('5'); + expect(summary.partial).toBe(true); + }); + + it('sums beyond the safe integer range exactly', () => { + const big = '90071992547409910'; + const holding = summarise([ + scoped({ + positions: [ + positionAt(OUT_A, { protocolId: 'runes', assetId: 'A', assetKind: 'fungible', decimals: 0 }, big), + ] as never, + }), + scoped({ + outpoint: OUT_B, + positions: [ + positionAt(OUT_B, { protocolId: 'runes', assetId: 'A', assetKind: 'fungible', decimals: 0 }, big), + ] as never, + }), + ]).holdings[0]; + expect(holding.quantityAtomic).toBe('180143985094819820'); + }); +}); + +describe('address holdings scope and empty states', () => { + function component( + results: OutpointEnrichment[], + utxos: { txid: string; vout: number }[], + calls?: { count: () => void }, + ): AddressAssetsComponent { + const api = { + getOutpoints$: () => { + calls?.count(); + return of({ results }); + }, + }; + const built = new AddressAssetsComponent(api as never); + built.utxos = utxos as never; + built.sourceState = 'complete'; + return built; + } + + function states(built: AddressAssetsComponent): AddressAssetsState[] { + built.ngOnChanges({ utxos: {} as never }); + const collected: AddressAssetsState[] = []; + const subscription = built.state$.subscribe((state) => collected.push(state)); + subscription.unsubscribe(); + return collected; + } + + it('states a checked and a total denominator', () => { + const built = component( + [scoped({ positions: [positionAt(OUT_A, { protocolId: 'runes', assetId: 'A', assetKind: 'fungible' }, '5')] as never })], + [{ txid: 'a'.repeat(64), vout: 0 }, { txid: 'b'.repeat(64), vout: 1 }], + ); + const last = states(built).pop() as AddressAssetsState; + expect(last.resolved).toBe(1); + expect(last.total).toBe(2); + expect(last.notResolved).toBe(1); + // Outputs this page did not ask about leave the scope partial even though + // every output it did ask about answered. + expect(last.partial).toBe(true); + expect(last.provenEmpty).toBe(false); + }); + + it('separates an authoritative empty output list from no input at all', async () => { + const empty = new AddressAssetsComponent({ getOutpoints$: vi.fn() } as never); + empty.utxos = [] as never; + empty.sourceState = 'complete'; + empty.ngOnChanges({ utxos: {} as never }); + const answered = await firstValueFrom(empty.state$); + // A real answer: there is nothing for an asset to sit on. + expect(answered).toMatchObject({ kind: 'ready', provenEmpty: true, total: 0 }); + + const absent = new AddressAssetsComponent({ getOutpoints$: vi.fn() } as never); + absent.utxos = null; + absent.sourceState = 'complete'; + absent.ngOnChanges({ utxos: {} as never }); + // No input is not an answer about this address. + expect(await firstValueFrom(absent.state$)).toMatchObject({ kind: 'skipped' }); + }); + + it('claims proven empty only when every output was checked and held nothing', () => { + const complete = component([scoped()], [{ txid: 'a'.repeat(64), vout: 0 }]); + expect((states(complete).pop() as AddressAssetsState).provenEmpty).toBe(true); + + const incomplete = component( + [scoped({ status: 'unavailable' as never })], + [{ txid: 'a'.repeat(64), vout: 0 }], + ); + const last = incomplete.ngOnChanges({ utxos: {} as never }); + void last; + const collected: AddressAssetsState[] = []; + const subscription = incomplete.state$.subscribe((state) => collected.push(state)); + subscription.unsubscribe(); + expect((collected.pop() as AddressAssetsState).provenEmpty).toBe(false); + }); + + it('retries the read once per press, without polling', () => { + let calls = 0; + const built = component( + [scoped()], + [{ txid: 'a'.repeat(64), vout: 0 }], + { count: () => { calls += 1; } }, + ); + built.ngOnChanges({ utxos: {} as never }); + const subscription = built.state$.subscribe(); + expect(calls).toBe(1); + built.retry(); + expect(calls).toBe(2); + subscription.unsubscribe(); + }); + + it('clears the open rows when the address input changes', () => { + const built = component( + [scoped({ positions: [positionAt(OUT_A, { protocolId: 'runes', assetId: 'A', assetKind: 'fungible' }, '5')] as never })], + [{ txid: 'a'.repeat(64), vout: 0 }], + ); + const last = states(built).pop() as AddressAssetsState; + const holding = (last.holdings as never as { assetKey: string }[])[0]; + built.toggle(holding as never); + expect(built.expanded.size).toBe(1); + built.ngOnChanges({ utxos: {} as never }); + expect(built.expanded.size).toBe(0); + }); + + it('splits an outpoint only when it really is one', () => { + const built = new AddressAssetsComponent({ getOutpoints$: vi.fn() } as never); + expect(built.outpointParts(OUT_A)).toEqual({ txid: 'a'.repeat(64), vout: '0' }); + expect(built.outpointParts('not-an-outpoint')).toBeNull(); + expect(built.outpointParts(`${'a'.repeat(63)}:0`)).toBeNull(); + }); + + it('reports a clipboard denial rather than claiming a copy succeeded', async () => { + const built = new AddressAssetsComponent({ getOutpoints$: vi.fn() } as never); + const original = (globalThis as { navigator?: unknown }).navigator; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { clipboard: { writeText: () => Promise.reject(new Error('denied')) } }, + }); + try { + built.copy('k', '5'); + await Promise.resolve(); + await Promise.resolve(); + expect(built.copyResult('k')).toBe('failed'); + } finally { + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: original }); + } + }); +}); + +/** + * Rendered template tests. + * + * The panel's claims are as much about wording and routing as about numbers, and + * neither a lost network prefix nor a dropped outpoint link is visible from a + * class-level test. + */ +describe('address holdings rendered panel', () => { + beforeAll(async () => { + Object.defineProperty(AddressAssetsComponent, 'ctorParameters', { + configurable: true, + value: () => [{ type: UniverseApiService }], + }); + await resolveTemplates(import.meta.url); + TestBed.initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); + }); + afterEach(() => TestBed.resetTestingModule()); + + function render(results: OutpointEnrichment[], utxos: { txid: string; vout: number }[]) { + TestBed.configureTestingModule({ + imports: [AddressAssetsComponent], + providers: [ + provideRouter([]), + { provide: UniverseApiService, useValue: { getOutpoints$: () => of({ results }) } }, + // The network the links have to carry. Without it the pipe returns the + // bare path and the assertion below could not tell the two apart. + { provide: StateService, useValue: { network: 'signet', env: { ROOT_NETWORK: 'mainnet', BASE_MODULE: 'mempool' } } }, + ], + errorOnUnknownElements: false, + errorOnUnknownProperties: false, + }); + const view = TestBed.createComponent(AddressAssetsComponent); + view.componentInstance.utxos = utxos as never; + view.componentInstance.sourceState = 'complete'; + view.componentInstance.ngOnChanges({ utxos: {} as never }); + view.detectChanges(); + return view; + } + + const HOLDING = [ + scoped({ + positions: [ + positionAt(OUT_A, { + protocolId: 'runes', + assetId: 'UNCOMMON.GOODS', + assetKind: 'fungible', + displayName: 'Uncommon Goods', + ticker: 'UNCOMMON', + decimals: 2, + }, '1234'), + ] as never, + }), + ]; + const ONE_UTXO = [{ txid: 'a'.repeat(64), vout: 0 }]; + + it('labels the quantity Held and never implies a whole wallet', () => { + const view = render(HOLDING, ONE_UTXO); + const text = view.nativeElement.textContent as string; + expect(text).toContain('Held'); + expect(text).toContain('Positions'); + expect(text).toContain('12.34'); + // Not a wallet valuation and not a fiat total. + expect(text).not.toContain('Received'); + expect(text).not.toContain('Balance'); + expect(text).not.toContain('$'); + }); + + it('keeps a real table with a row header', () => { + const element = render(HOLDING, ONE_UTXO).nativeElement as HTMLElement; + expect(element.querySelector('table.summary-table')).not.toBeNull(); + const rowHeader = element.querySelector('tbody th[scope="row"]'); + expect(rowHeader).not.toBeNull(); + expect(rowHeader?.querySelector('.asset-identity-box')).not.toBeNull(); + }); + + it('states the checked and total scope with a denominator', () => { + const view = render(HOLDING, [...ONE_UTXO, { txid: 'b'.repeat(64), vout: 1 }]); + expect(view.nativeElement.textContent).toContain('Checked 1 of 2 unspent outputs'); + }); + + it('moves every outpoint link into the row details, network-correct', () => { + const view = render(HOLDING, ONE_UTXO); + const element = view.nativeElement as HTMLElement; + // Not above everything else by default: the panel is not a wall of links. + expect(element.querySelector('.outpoint-list')).toBeNull(); + (element.querySelector('button.asset-disclose') as HTMLButtonElement).click(); + view.detectChanges(); + const links = element.querySelectorAll('.outpoint-list a'); + expect(links).toHaveLength(1); + // The defect this closes: a bare /outpoint route resolves against the root + // network, so on signet the link pointed at the mainnet outpoint. + expect(links[0].getAttribute('href')).toBe(`/signet/outpoint/${'a'.repeat(64)}/0`); + }); + + it('shows the full id and the exact amount only in the details', () => { + const view = render(HOLDING, ONE_UTXO); + const element = view.nativeElement as HTMLElement; + (element.querySelector('button.asset-disclose') as HTMLButtonElement).click(); + view.detectChanges(); + const details = element.querySelector('.asset-details-row'); + expect(details?.textContent).toContain('UNCOMMON.GOODS'); + expect(details?.textContent).toContain('bitcoin / signet / runes'); + expect(details?.textContent).toContain('strict'.slice(0, 0) + '12.34'); + }); + + it('names the disclosure and says what it controls', () => { + const element = render(HOLDING, ONE_UTXO).nativeElement as HTMLElement; + const button = element.querySelector('button.asset-disclose'); + expect(button?.getAttribute('aria-expanded')).toBe('false'); + expect(button?.getAttribute('aria-controls')).toBeTruthy(); + expect(button?.getAttribute('aria-label')).toContain('Uncommon Goods'); + }); + + it('says smallest units rather than scaling an unknown divisibility', () => { + const view = render( + [ + scoped({ + positions: [ + positionAt(OUT_A, { protocolId: 'runes', assetId: 'A', assetKind: 'fungible', displayName: 'A' }, '1234'), + ] as never, + }), + ], + ONE_UTXO, + ); + expect(view.nativeElement.textContent).toContain('smallest units'); + }); + + it('says Not published rather than hiding an unknown amount', () => { + const view = render( + [ + scoped({ + positions: [ + positionAt(OUT_A, { protocolId: 'runes', assetId: 'A', assetKind: 'fungible', displayName: 'A' }), + ] as never, + }), + ], + ONE_UTXO, + ); + expect(view.nativeElement.textContent).toContain('Not published'); + }); + + it('words a proven empty address differently from a partial one', () => { + const proven = render([scoped()], ONE_UTXO); + expect(proven.nativeElement.textContent).toContain('Every unspent output this address holds was checked'); + + TestBed.resetTestingModule(); + const partial = render([scoped({ status: 'unavailable' as never })], ONE_UTXO); + const text = partial.nativeElement.textContent as string; + expect(text).toContain('not every output could be checked'); + expect(text).toContain('not proof'); + }); + + it('offers a retry while the scope is partial', () => { + const view = render([scoped({ status: 'unavailable' as never })], ONE_UTXO); + expect(view.nativeElement.querySelector('.summary-coverage .summary-retry')).not.toBeNull(); + }); +}); diff --git a/frontend/src/app/universe/address-assets/address-assets.component.ts b/frontend/src/app/universe/address-assets/address-assets.component.ts index 24e3d3e71c..bbfd246f16 100644 --- a/frontend/src/app/universe/address-assets/address-assets.component.ts +++ b/frontend/src/app/universe/address-assets/address-assets.component.ts @@ -7,7 +7,16 @@ import { } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterModule } from '@angular/router'; -import { Observable, catchError, forkJoin, map, of, startWith } from 'rxjs'; +import { + BehaviorSubject, + Observable, + catchError, + forkJoin, + map, + of, + startWith, + switchMap, +} from 'rxjs'; import { Utxo } from '@interfaces/electrs.interface'; import { UniverseApiService, @@ -18,9 +27,11 @@ import { OutpointEnrichment, } from '@app/universe/universe.types'; import { - formatAtomicAmount, - shortenIdentifier, -} from '@app/universe/universe-evidence'; + PresentedQuantity, + initialsFor, + presentQuantity, + shortenAssetId, +} from '@app/universe/asset-summary/asset-summary.presentation'; import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe'; /** @@ -30,27 +41,75 @@ import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pip */ const MAXIMUM_RESOLVED_OUTPUTS = UNIVERSE_OUTPOINT_BATCH_LIMIT * 2; -interface ProtocolHolding { - readonly protocolId: string; +/** + * One asset this address holds, keyed by its full identity. + * + * The identity is the whole tuple, not the protocol and id alone. Two rulesets + * over one ledger are two assets and two networks are two assets; merging any + * of them produces a total no authority would agree with. The unit metadata + * travels with it for the same reason: a quantity whose divisibility was + * dropped on the way here can only be rendered as a guess. + */ +export interface ProtocolHolding { readonly assetKey: string; + readonly chain: string; + readonly network: string; + readonly protocolId: string; + readonly assetId: string; + readonly ruleset: string | null; + readonly assetKind: string; readonly displayName: string; - /** Sum across outputs, kept exact as a decimal string. */ + readonly ticker: string | null; + /** The authority's divisibility, or null when unknown or conflicted. */ + readonly decimals: number | null; + /** Sum across outputs, exact, as an unsigned integer string. Null if unknown. */ readonly quantityAtomic: string | null; + /** + * False when a contributing quantity was missing or malformed. + * + * Tracked separately from coverage: the set of outputs checked and the + * arithmetic over what they held are two different things, and a reader needs + * to know which one is incomplete. + */ + readonly quantityKnown: boolean; readonly outpoints: readonly string[]; + /** One or two letters standing in for a missing logo. */ + readonly initials: string; + /** The shortened id for the row; the full one stays in `assetId`. */ + readonly shortAssetId: string; + /** The quantity as the view renders it. */ + readonly held: PresentedQuantity; + /** The id of this row's disclosure region, for aria-controls. */ + readonly detailsId: string; } -interface AddressAssetsState { +export interface AddressAssetsState { readonly kind: - 'loading' | 'ready' | 'unavailable' | 'skipped' | 'source-unavailable'; + | 'loading' + | 'ready' + | 'unavailable' + | 'skipped' + | 'source-unavailable'; readonly reason?: string; readonly holdings?: readonly ProtocolHolding[]; /** Outputs the authority answered for. The denominator for everything shown. */ readonly resolved?: number; /** Outputs this address holds that were not asked about. */ readonly notResolved?: number; + /** Every unspent output this address has, answered for or not. */ + readonly total?: number; /** True when at least one answered output could not be fully accounted for. */ readonly partial?: boolean; readonly checkpointHeight?: string | null; + /** + * True only when every output this address holds was answered for and none of + * them carried a supported asset. + * + * Distinct from having nothing to show: an address with no unspent outputs at + * all, one still loading, and one whose authority failed are three different + * facts and only this one may be stated as an answer. + */ + readonly provenEmpty?: boolean; } /** @@ -59,7 +118,12 @@ interface AddressAssetsState { * Built from the address's own unspent outputs, resolved through the asset * authority one bounded batch at a time. The panel always states how many * outputs it covered, because a portfolio that hides its own coverage is - * indistinguishable from a wrong one. + * indistinguishable from a wrong one, and it never presents itself as a + * complete wallet valuation: it is what the outputs it checked carried. + * + * The layout is shared with the transaction summary through the asset-summary + * mixins, and the quantities go through the same presenter, so the two panels + * cannot drift into rendering the same fact two different ways. */ @Component({ selector: 'app-universe-address-assets', @@ -75,8 +139,14 @@ export class AddressAssetsComponent implements OnChanges { 'idle' | 'loading' | 'complete' | 'limit' | 'unavailable' = 'idle'; state$: Observable; - readonly shorten = shortenIdentifier; - readonly amount = formatAtomicAmount; + readonly shorten = shortenAssetId; + + /** Which rows a person has opened, keyed by full asset identity. */ + readonly expanded = new Set(); + /** What the last copy attempt did, keyed by what was copied. */ + readonly copyResults = new Map(); + + private readonly retry$ = new BehaviorSubject(0); constructor(private api: UniverseApiService) {} @@ -84,6 +154,11 @@ export class AddressAssetsComponent implements OnChanges { if (!changes.utxos && !changes.sourceState) { return; } + // A different address or a fresh lookup clears what the previous one proved, + // including which row was open: a row left open would show another + // address's outputs in place. + this.expanded.clear(); + this.copyResults.clear(); if (this.sourceState === 'limit' || this.sourceState === 'unavailable') { this.state$ = of({ kind: 'source-unavailable', @@ -99,10 +174,27 @@ export class AddressAssetsComponent implements OnChanges { return; } const utxos = this.utxos; - if (!Array.isArray(utxos) || utxos.length === 0) { + if (!Array.isArray(utxos)) { + // No input yet. Not an answer about this address. this.state$ = of({ kind: 'skipped' }); return; } + if (utxos.length === 0) { + // An authoritative empty list. The address holds no unspent outputs, so + // there is nothing for an asset to sit on, and that is a real answer + // rather than something to hide. + this.state$ = of({ + kind: 'ready', + holdings: [], + resolved: 0, + notResolved: 0, + total: 0, + partial: false, + checkpointHeight: null, + provenEmpty: true, + }); + return; + } const references = utxos .filter( @@ -122,39 +214,139 @@ export class AddressAssetsComponent implements OnChanges { batches.push(covered.slice(index, index + UNIVERSE_OUTPOINT_BATCH_LIMIT)); } - this.state$ = forkJoin( - batches.map((batch) => this.api.getOutpoints$(batch)) - ).pipe( - map((responses): AddressAssetsState => { - const results = responses.flatMap( - (response) => response?.results ?? [] - ); - if (results.some((result) => !covered.includes(result?.outpoint))) - throw Error('Unrelated output evidence.'); - const summary = summarise(results); - return { - ...summary, - partial: summary.partial || summary.resolved !== covered.length, - notResolved: notResolved + covered.length - summary.resolved, - kind: 'ready', - }; - }), - catchError(() => of({ kind: 'unavailable' })), - startWith({ kind: 'loading' }) + this.state$ = this.retry$.pipe( + switchMap(() => + forkJoin(batches.map((batch) => this.api.getOutpoints$(batch))).pipe( + map((responses): AddressAssetsState => { + const results = responses.flatMap( + (response) => response?.results ?? [] + ); + if (results.some((result) => !covered.includes(result?.outpoint))) + throw Error('Unrelated output evidence.'); + const summary = summarise(results); + const unchecked = notResolved + covered.length - summary.resolved; + return { + ...summary, + holdings: summary.holdings?.map((holding) => + present(holding) + ), + // Outputs this page did not ask about leave the scope partial even + // when every output it did ask about answered. The rows are true; + // the set they were drawn from is not the whole set. + partial: summary.partial || unchecked > 0, + notResolved: unchecked, + total: references.length, + provenEmpty: + !summary.partial && + unchecked === 0 && + (summary.holdings?.length ?? 0) === 0, + kind: 'ready', + }; + }), + catchError(() => of({ kind: 'unavailable' })), + startWith({ kind: 'loading' }) + ) + ) ); } + /** Explicit, bounded retry. One press, one fresh read; nothing polls. */ + retry(): void { + this.retry$.next(this.retry$.value + 1); + } + trackByHolding(index: number, holding: ProtocolHolding): string { return holding.assetKey; } - outpointRoute(outpoint: string): string[] | null { + /** + * The txid and vout of an outpoint, or null when it is not one. + * + * The network prefix is applied in the template through the relativeUrl pipe, + * which is how every other outpoint link on the site is built. A bare + * `/outpoint` route resolves against the root network, so on signet it sent a + * reader to the mainnet outpoint of the same txid. + */ + outpointParts(outpoint: string): { txid: string; vout: string } | null { const separator = outpoint.lastIndexOf(':'); if (separator !== 64) { return null; } - return ['/outpoint', outpoint.slice(0, 64), outpoint.slice(65)]; + return { txid: outpoint.slice(0, 64), vout: outpoint.slice(65) }; } + + toggle(holding: ProtocolHolding): void { + if (this.expanded.has(holding.assetKey)) {this.expanded.delete(holding.assetKey);} + else {this.expanded.add(holding.assetKey);} + } + + isExpanded(holding: ProtocolHolding): boolean { + return this.expanded.has(holding.assetKey); + } + + /** + * Copies the exact value and reports what actually happened. A button that + * says "Copied" when nothing was copied loses the value a person was keeping. + */ + copy(what: string, value: string): void { + const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard; + if (!clipboard || typeof clipboard.writeText !== 'function') { + this.copyResults.set(what, 'failed'); + return; + } + clipboard.writeText(value).then( + () => this.copyResults.set(what, 'copied'), + () => this.copyResults.set(what, 'failed'), + ); + } + + copyResult(what: string): 'copied' | 'failed' | null { + return this.copyResults.get(what) ?? null; + } +} + +/** The identity key. The whole tuple, never the protocol and id alone. */ +export function holdingKey(entry: { + chain: string; + network: string; + protocolId: string; + assetId: string; + ruleset: string | null; +}): string { + return JSON.stringify([ + entry.chain, + entry.network, + entry.protocolId, + entry.assetId, + entry.ruleset, + ]); +} + +interface HoldingAccumulator { + chain: string; + network: string; + protocolId: string; + assetId: string; + ruleset: string | null; + assetKind: string; + displayName: string; + ticker: string | null; + /** Every distinct divisibility any authority stated for this identity. */ + statedDecimals: Set; + quantity: bigint | null; + quantityKnown: boolean; + outpoints: string[]; +} + +/** Everything the view needs for one holding, derived from the accumulator. */ +function present(holding: ProtocolHolding): ProtocolHolding { + return { + ...holding, + initials: initialsFor(holding.ticker ?? '', holding.displayName, holding.protocolId), + shortAssetId: shortenAssetId(holding.assetId, 8), + held: presentQuantity(holding.quantityAtomic, holding.decimals, holding.quantityKnown), + detailsId: 'holding-details-' + hashKey(holding.assetKey), + }; } /** @@ -166,16 +358,8 @@ export class AddressAssetsComponent implements OnChanges { */ export function summarise( results: readonly OutpointEnrichment[] -): Omit { - const holdings = new Map< - string, - { - protocolId: string; - displayName: string; - quantity: bigint | null; - outpoints: string[]; - } - >(); +): Omit { + const holdings = new Map(); let resolved = 0; let partial = false; let checkpointHeight: string | null = null; @@ -219,18 +403,33 @@ export function summarise( partial = true; continue; } - if (!addPosition(holdings, position)) partial = true; + if (!addPosition(holdings, position, result)) partial = true; } } const list: ProtocolHolding[] = [...holdings.entries()].map( ([assetKey, entry]) => ({ assetKey, + chain: entry.chain, + network: entry.network, protocolId: entry.protocolId, + assetId: entry.assetId, + ruleset: entry.ruleset, + assetKind: entry.assetKind, displayName: entry.displayName, - quantityAtomic: - entry.quantity === null ? null : entry.quantity.toString(), + ticker: entry.ticker, + // One stated value is the divisibility. None is unknown and more than one + // is a conflict, and both render as smallest units rather than as a scale + // that would be applied to an exact sum. + decimals: entry.statedDecimals.size === 1 ? [...entry.statedDecimals][0] : null, + quantityAtomic: entry.quantity === null ? null : entry.quantity.toString(), + quantityKnown: entry.quantityKnown && entry.quantity !== null, outpoints: entry.outpoints, + // Filled in by present(); declared here so the shape is complete. + initials: '', + shortAssetId: '', + held: presentQuantity(null, null), + detailsId: '', }) ); @@ -250,74 +449,95 @@ export function summarise( } function addPosition( - holdings: Map< - string, - { - protocolId: string; - displayName: string; - quantity: bigint | null; - outpoints: string[]; - } - >, - position: ExplorerOutpointPosition + holdings: Map, + position: ExplorerOutpointPosition, + result: OutpointEnrichment ): boolean { const asset = position?.asset; if (!asset?.protocolId) { return false; } - const assetKey = `${asset.protocolId}:${asset.assetId ?? ''}`; - if (!holdings.has(assetKey)) { - holdings.set(assetKey, { + const chain = result.checkpoint?.chain ?? ''; + const network = result.checkpoint?.network ?? ''; + const ruleset = rulesetOf(asset); + const assetId = asset.assetId ?? ''; + const assetKey = holdingKey({ + chain, + network, + protocolId: asset.protocolId, + assetId, + ruleset, + }); + let entry = holdings.get(assetKey); + if (!entry) { + entry = { + chain, + network, protocolId: asset.protocolId, + assetId, + ruleset, + assetKind: typeof asset.assetKind === 'string' ? asset.assetKind : 'unknown', displayName: - asset.displayName || asset.ticker || asset.assetId || asset.protocolId, + asset.displayName || asset.ticker || assetId || asset.protocolId, + ticker: typeof asset.ticker === 'string' && asset.ticker ? asset.ticker : null, + statedDecimals: new Set(), quantity: 0n, + quantityKnown: true, outpoints: [], - }); + }; + holdings.set(assetKey, entry); } - const entry = holdings.get(assetKey); if (entry.outpoints.includes(position.outpoint)) { + // The same output counted twice would double a balance, so the sum is + // abandoned rather than adjusted. entry.quantity = null; + entry.quantityKnown = false; return false; } entry.outpoints.push(position.outpoint); + const stated = statedDecimalsOf(asset); + if (stated !== null) entry.statedDecimals.add(stated); if ( position.quantityAtomic && /^(0|[1-9][0-9]{0,999})$/.test(position.quantityAtomic) ) { if (entry.quantity !== null) entry.quantity += BigInt(position.quantityAtomic); + } else if ( + position.quantityAtomic === undefined || + position.quantityAtomic === null + ) { + // An inscription is one item by its protocol's own definition; anything + // else with no stated quantity has an unknown one, which is not zero. + if (asset.assetKind === 'inscription') { + if (entry.quantity !== null) entry.quantity += 1n; + } else { + entry.quantity = null; + entry.quantityKnown = false; + } } else { entry.quantity = null; + entry.quantityKnown = false; } return true; } -/* IMPLEMENTATION-HANDOFF [UI-WP04:ADDRESS-DATA] 2026-09-19 - * Coverage C33-C44; defects F04/F09/F10/F11. Preparation only. - * Verified: ProtocolHolding drops unit metadata, addPosition keys only by - * protocol/id, and missing quantity becomes null without flagging partial. - * The template calls formatAtomicAmount with its default zero decimals. - * Sources R05 identity/unknown-unit invariants; R01 truthful status feedback. - * 1. Preserve assetId, assetKind, identity context and supported ruleset in the - * holding view model. Coordinate ExplorerAssetRef with UI-WP07; never invent - * absent rulesets/decimals or merge distinguishable ledgers. Reject ambiguous - * mixed-context evidence instead of displaying an authoritative total. - * 2. Keep BigInt sums. Track quantityKnown independently from coverage, and - * mark unknown/malformed contributing quantity visibly. Missing units render - * smallest units via UI-WP02 presenter, not implied whole-token quantities. - * Do not change formatAtomicAmount's shared default for unrelated consumers. - * 3. Retain the two-batch limit and explicit checked/unchecked denominator. - * notResolved>0 must produce partial scope even when queried rows succeeded. - * Preserve duplicate, foreign-outpoint and mixed-checkpoint safeguards. - * 4. Separate complete zero-UTXO state from loading/absent input. Render a - * truthful empty holdings panel after an authoritative complete empty list. - * 5. Add explicit bounded retry for enrichment failure without refetching on - * render; preserve cancellation and route/network clearing on input change. - * 6. Verify outpointRoute retains the active network prefix; route behavior - * is unresolved, not a proven routing bug. Never link Signet to mainnet. - * Tests: extend address-assets.component.spec.ts; from frontend run npm test -- - * src/app/universe/address-assets (NOT RUN). Cover null/zero/huge quantities, - * >two batches, conflicting identity, duplicate/mixed checkpoints, network - * navigation and empty/error/retry. No wallet, signing or migration is added. - */ +function rulesetOf(asset: { readonly ruleset?: string }): string | null { + return typeof asset.ruleset === 'string' && asset.ruleset ? asset.ruleset : null; +} + +function statedDecimalsOf(asset: { readonly decimals?: unknown }): number | null { + const value = asset.decimals; + if (typeof value !== 'number' || !Number.isInteger(value)) return null; + if (value < 0 || value > 38) return null; + return value; +} + +/** A short, stable, DOM-safe id from an identity key. */ +function hashKey(key: string): string { + let hash = 0; + for (let index = 0; index < key.length; index += 1) { + hash = (hash * 31 + key.charCodeAt(index)) | 0; + } + return (hash >>> 0).toString(36); +} diff --git a/frontend/src/app/universe/asset-summary/IMPLEMENTATION-HANDOFF.md b/frontend/src/app/universe/asset-summary/IMPLEMENTATION-HANDOFF.md index df7e68a7b2..7787a91c92 100644 --- a/frontend/src/app/universe/asset-summary/IMPLEMENTATION-HANDOFF.md +++ b/frontend/src/app/universe/asset-summary/IMPLEMENTATION-HANDOFF.md @@ -1,28 +1,59 @@ -# Asset summary UI preparation, 2026-09-19 - -Scope: a major, consistent redesign of transaction summaries and address holdings. This is preparation, not an implemented redesign or a release. Do not add wallets, signing, trading, indexers or unrelated protocol products. - -## Exact source baseline -Mempool source: `d0b49e8d136476658685a6cc9ec1528679db4af7`. This commit contains the eight UI source-comment blocks originally appended in the isolated local feature worktree. That worktree was subsequently removed by concurrent work. Resume this preparation branch, not the removed path. -Backend preparation branch: `bitcoinuniverseio/backend-apis`, `prep/asset-summary-ui-20260919`, commit `91d0c6984db34b659d1663270e90c661432fa3bb`, based on `44b615adfa07118919d3130fe10add35a85e38eb`. -Server text handoff target: `D:\universe\mempool\audits\implementation-prep-20260919-ui\mempool_UI_HANDOFF_2026-09-19`. Verify its actual files; a server ZIP has NOT been created. Remote process execution was blocked, and text-file writing cannot reliably transfer an archive. - -## Annotation index and order -UI-WP07: backend `contracts/transaction-asset-summary.ts`, contract parity and fixtures. -UI-WP08: backend `transactions/transaction-asset-summary.service.ts`, CACHE/COVERAGE/DIVISIBILITY/MEDIA/CHECKPOINT markers. Reuse `TransactionFlowService.enricherRegistry()`; coordinate its existing flow cache. -UI-WP01: `transaction-assets/transaction-assets.types.ts`, decoder and exact quantities. -UI-WP02: `transaction-assets/transaction-assets.component.ts`, stable view model, metadata-independent loading and truthful states. -UI-WP03: `transaction-assets/transaction-assets.component.html` and `.scss`, hierarchy, accessible details, bounded numeric columns and container-driven mobile cards. -UI-WP04: `address-assets/address-assets.component.ts`, exact units, full identity, coverage denominator and retry. -UI-WP05: `address-assets/address-assets.component.html` and `.scss`, shared holdings layout and accessible outpoint details. -UI-WP06: `transaction-assets/transaction-assets.component.spec.ts`, regression and real read-only acceptance. -Order: WP07 + WP08, then WP01, WP02, WP03, WP04, WP05, WP06. Do not treat deleting a marker as completion. - -## Baseline drift corrections -The original WP01/WP02 prose describes the earlier local source. At d0b49e8, `logo()` already requires `verified === true`, and the effects template already says `(not accepted)`. Preserve both concurrent corrections and verify them; they are NOT unimplemented defects now. Their application tests were not run in this preparation. Counts/coverage validation, missing effect evidence and the redesign remain required. - -## Design and verification rules -Use the existing Universe tokens, a single coverage badge, aligned Inputs/Outputs or Held/Positions, explicit approximation only for compact headlines, full exact-value copy in row details, labelled protocol fallbacks, and a <=720px container breakpoint. Do not wrap primary amounts, guess decimals, sum unlike assets, convert atomic strings to floating point, hide gaps, or require hover to read full data. All controls must work with keyboard and touch. -The supplied standalone HTML design has 120 passing viewport/theme/state checks. It is fixture-only evidence, NOT Angular, API, Signet or production acceptance. The backend service's annotation emits identical comment-free JavaScript; repository builds remain untested. Read the complete bundled execution prompt and coverage matrix. -The pinned registry defaults Bitcoin protocols to mainnet-only. A missing Signet advertisement does not prove a protocol cannot support Signet. Resolve actual test-source capabilities without changing production defaults; use a justified supported Testnet only when needed. Do not invent test transactions for this read-only feature. -Full functional acceptance on real supported test-network paths, dependent-flow regression, and completed public Mainnet deployment are the final GO gate. No merge or deployment was performed by this preparation agent. +# Asset summary panels: what this directory is, and what is still open + +This directory holds what the transaction asset summary and the address asset +holdings panel share: the quantity presenter, the layout mixins, and the test +helper that makes their real templates renderable under the unit runner. + +The two panels state different facts and are deliberately not merged. What is +shared is everything that would otherwise drift: how a quantity is scaled and +shortened, how an identity block is laid out, how wide a bounded numeric column +is, and at what panel width the table stacks. + +## Why the shared presenter exists + +Each panel had its own quantity formatting, and they disagreed. One scaled by +divisibility and one did not; one wrapped long digits and one let them set the +column width. A reader moving between a transaction page and an address page saw +the same authority fact rendered two ways, and in one of those ways a balance +could be split across two lines, where a wrapped number is indistinguishable +from two numbers. + +`asset-summary.presentation.ts` is therefore the only place a quantity becomes +text. It never constructs a `Number` from a quantity: an asset amount is an +unsigned integer of arbitrary size and a rune supply exceeds what a double can +hold exactly, so every operation is string surgery. Three cases are kept +separate rather than collapsed into a falsy check: an exact decimal, true digits +whose scale is unknown, and an unknown quantity. Zero is none of those. + +## Implemented on 2026-09-19 + +Both panels were rebuilt against the plan pinned at +`bitcoinuniverseio/mempool` `prep/asset-summary-ui-20260919`, whose eight source +markers are resolved and removed. The backend repairs the plan depended on +landed alongside, on `bitcoinuniverseio/backend-apis` +`impl/asset-summary-ui-20260919`. + +- The decoder validates the coverage enum, rejects duplicate protocol coverage + and coverage from another context, requires the asset and coverage arrays + rather than treating an absent one as empty, and refuses a stated total that + conclusive coverage does not support or that disagrees with the identities + listed. Effect evidence and the reading's checkpoint are preserved. +- The transaction panel builds one display model per response instead of mapping + in template bindings, seeds the optional protocol registry so an optional + display name cannot gate a proven amount, states each coverage gap with its + own state and reason, and offers a retry only where one could change the + answer. +- Both panels keep a real table with a row header on desktop, put flex inside + the header cell rather than on it, bound their numeric columns, and stack by + the panel's own width through a container query. +- The address panel keys holdings by the whole identity, carries divisibility + and ruleset, separates a known quantity from the scope it was read over, + states a checked and a total denominator, distinguishes a proven empty address + from one that could not be fully checked, and routes its outpoint links + through the active network. + +## Still open + +Live functional acceptance against a first-party test network, and the public +mainnet release, are not covered by anything in this repository. They are +operational steps with their own evidence, recorded outside the source tree. diff --git a/frontend/src/app/universe/asset-summary/_asset-summary.scss b/frontend/src/app/universe/asset-summary/_asset-summary.scss new file mode 100644 index 0000000000..4f700ab2ed --- /dev/null +++ b/frontend/src/app/universe/asset-summary/_asset-summary.scss @@ -0,0 +1,472 @@ +@use '../universe-tokens' as u; + +/** + * Layout shared by the transaction asset summary and the address holdings + * panel. + * + * The two panels state different facts and must not be merged, but they are + * read one after the other on the same site and their geometry has to match: + * the same identity block, the same bounded numeric columns, the same + * disclosure control, the same stacking width. + * + * Two rules here exist because of specific defects. + * + * The numeric columns are bounded and their values never wrap. An asset + * quantity is an arbitrary precision integer, so with an automatic table + * layout the longest value in the column sets the column's intrinsic width, + * squeezes the identity column to nothing, and then `overflow-wrap: anywhere` + * splits the digits of a balance across two lines. A reader cannot tell a + * wrapped number from two numbers. + * + * Stacking is driven by the panel's own width through a container query, not by + * the viewport. These panels sit inside page containers of different widths, so + * a viewport breakpoint stacks a panel that had room and leaves a narrow one + * unstacked. + */ + +/** The panel shell: surface, tokens, and its own containment context. */ +@mixin asset-summary-panel { + @include u.universe-surface; + @include u.universe-state-tokens; + + // What the stacking breakpoint measures. Without this the query below has + // nothing to resolve against and never matches. + container-type: inline-size; + margin: var(--u-space-4) 0 0; + padding: var(--u-space-4); +} + +@mixin asset-summary-header { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--u-space-2) var(--u-space-3); + margin-bottom: var(--u-space-3); + + h2 { + font-size: var(--u-text-lg); + font-weight: var(--u-weight-semibold); + margin: 0; + color: var(--u-text-primary); + } +} + +/** + * The table shell. + * + * A fixed layout with an explicit colgroup, so the columns are sized by the + * design rather than by whichever asset happens to have the longest balance. + */ +@mixin asset-summary-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + + th, + td { + text-align: left; + vertical-align: top; + padding: var(--u-space-2); + border-bottom: 1px solid var(--u-divider); + font-weight: 400; + color: var(--u-text-primary); + } + + th:first-child, + td:first-child { + padding-left: 0; + } + + td:last-child { + padding-right: 0; + } + + thead th { + font-size: var(--u-text-xs); + font-weight: var(--u-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--u-text-secondary); + border-bottom-color: var(--u-border); + } + + th.numeric, + td.numeric, + .quantity-cell { + text-align: right; + } + + // The identity column takes whatever the bounded columns leave. Every other + // column is fixed, so a long value cannot take space from the name. + .col-asset { + width: auto; + } + + .col-quantity { + width: 9.5rem; + } + + .col-disclose { + width: 3rem; + } + + tbody tr:last-child th, + tbody tr:last-child td { + border-bottom: 0; + } +} + +/** + * The identity block. + * + * Flex sits on this child of the row header, never on the `th` itself: a `th` + * whose display is changed to flex stops being a table cell, and the row loses + * the accessible name and the column association a screen reader reads from it. + */ +@mixin asset-summary-identity { + .asset-identity-box { + display: flex; + gap: var(--u-space-2); + align-items: flex-start; + min-width: 0; + } + + .asset-logo { + // A fixed box in every state, so a late, missing or broken image cannot + // shift the row it sits in. + flex: 0 0 40px; + width: 40px; + height: 40px; + + img, + .asset-logo-fallback { + width: 40px; + height: 40px; + display: block; + border-radius: var(--u-radius-sm); + } + + img { + object-fit: contain; + background: var(--u-surface-sunken); + } + + // The stand-in reads as deliberate: a bordered box with the protocol's + // initials. Never a bare grey square, which reads as an image that failed, + // and never mistakable for a published token logo. + .asset-logo-fallback { + display: flex; + align-items: center; + justify-content: center; + background: var(--u-surface-sunken); + border: 1px solid var(--u-border-strong); + color: var(--u-text-secondary); + font-size: 0.75rem; + font-weight: var(--u-weight-bold); + letter-spacing: 0.02em; + } + } + + .asset-identity { + display: flex; + flex-direction: column; + gap: 0.15rem; + // Without this a flex child refuses to shrink below its content, which is + // how a long asset name pushes a bounded column off the panel. + min-width: 0; + } + + .asset-name { + font-weight: var(--u-weight-semibold); + overflow-wrap: anywhere; + } + + .asset-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.3rem; + min-width: 0; + } + + .asset-kind { + font-size: var(--u-text-xs); + color: var(--u-text-secondary); + } + + .asset-id { + @include u.universe-identifier; + + color: var(--u-text-secondary); + min-width: 0; + } +} + +/** + * A primary quantity. + * + * Tabular numerals so columns of digits line up, and `nowrap` so a balance is + * never split across lines. Length is handled by the shared presenter, which + * produces a labelled short form; it is never handled by breaking digits or by + * shrinking the type below what a person can read. + */ +@mixin asset-summary-quantity { + .quantity-cell { + font-variant-numeric: tabular-nums; + min-width: 0; + } + + .quantity-value { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.1rem; + min-width: 0; + } + + .quantity-number { + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + + .quantity-ticker, + .quantity-note, + .quantity-none, + .quantity-approx { + font-size: var(--u-text-xs); + color: var(--u-text-secondary); + } + + .quantity-approx { + display: block; + font-style: italic; + } + + .quantity-partial { + margin-top: 0.25rem; + } + + // The exact value may wrap: it is the disclosure's full-width content, where + // completeness matters more than a single line. + .quantity-exact { + overflow-wrap: anywhere; + font-variant-numeric: tabular-nums; + user-select: text; + } +} + +/** The 44px disclosure and copy controls. */ +@mixin asset-summary-controls { + .asset-disclose { + @include u.universe-touch-box; + + display: flex; + align-items: center; + justify-content: center; + min-width: 44px; + min-height: 44px; + margin-left: auto; + background: transparent; + border: 1px solid var(--u-border); + border-radius: var(--u-radius-sm); + color: var(--u-text-primary); + cursor: pointer; + font: inherit; + font-size: var(--u-text-md); + line-height: 1; + + &:hover { + background: var(--u-surface-hover); + } + + &:focus-visible { + outline: 2px solid var(--u-focus-ring); + outline-offset: 2px; + } + } + + .copy-button { + @include u.universe-touch-line; + + background: var(--u-surface-raised); + border: 1px solid var(--u-border-strong); + border-radius: var(--u-radius-sm); + color: var(--u-text-primary); + cursor: pointer; + font: inherit; + font-size: var(--u-text-xs); + padding: 0.15rem 0.6rem; + + &:hover { + background: var(--u-surface-hover); + } + + &:focus-visible { + outline: 2px solid var(--u-focus-ring); + outline-offset: 2px; + } + } + + .copy-result { + font-size: var(--u-text-xs); + color: var(--u-text-secondary); + } +} + +/** The disclosure region's definition list. */ +@mixin asset-summary-details { + .asset-details-row > td { + padding-top: 0; + background: var(--u-surface-sunken); + } + + .asset-details { + display: grid; + grid-template-columns: minmax(0, 10rem) minmax(0, 1fr); + gap: 0.35rem var(--u-space-3); + margin: 0; + font-size: var(--u-text-sm); + + dt { + color: var(--u-text-secondary); + font-weight: var(--u-weight-semibold); + min-width: 0; + } + + dd { + margin: 0; + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + } + } + + .detail-value { + overflow-wrap: anywhere; + user-select: text; + min-width: 0; + } + + .identifier { + @include u.universe-identifier; + } +} + +/** A screen-reader-only label that still carries an accessible name. */ +@mixin asset-summary-visually-hidden { + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } +} + +/** + * Stacked cards, driven by the panel's own width. + * + * The table's semantics are kept: only presentation changes, and the column + * meaning moves into each cell's own visible label so nothing is lost when the + * header row is no longer laid out as a row. + */ +@mixin asset-summary-stacked { + .summary-table, + .summary-table tbody, + .summary-table tr, + .summary-table td, + .summary-table th { + display: block; + width: auto; + } + + .summary-table colgroup { + display: none; + } + + .summary-table thead { + // Visually hidden, never removed: the header cells stay in the + // accessibility tree, and each stacked cell also carries its own label. + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + .summary-table tr.asset-row { + padding: var(--u-space-2) 0; + border-bottom: 1px solid var(--u-divider); + position: relative; + } + + .summary-table th, + .summary-table td { + border-bottom: 0; + padding: 0.15rem 0; + text-align: left; + } + + .summary-table td.quantity-cell { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.4rem; + } + + .quantity-value { + align-items: flex-start; + } + + .cell-label { + display: inline-block; + font-size: var(--u-text-xs); + font-weight: var(--u-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--u-text-secondary); + } + + .disclose-cell { + position: absolute; + top: var(--u-space-2); + right: 0; + } + + .asset-details { + grid-template-columns: minmax(0, 1fr); + gap: 0.1rem; + + dt { + margin-top: 0.4rem; + } + } +} + +/** Forced colours and reduced motion, applied by both panels. */ +@mixin asset-summary-environment { + @media (prefers-reduced-motion: reduce) { + .skeleton-bar, + .skeleton-line { + animation: none; + } + } + + @media (forced-colors: active) { + .asset-logo-fallback, + .asset-disclose, + .copy-button { + border: 1px solid ButtonBorder; + } + + .asset-disclose:focus-visible, + .copy-button:focus-visible { + outline: 2px solid Highlight; + } + } +} diff --git a/frontend/src/app/universe/asset-summary/asset-summary.presentation.spec.ts b/frontend/src/app/universe/asset-summary/asset-summary.presentation.spec.ts new file mode 100644 index 0000000000..b4bac4d76e --- /dev/null +++ b/frontend/src/app/universe/asset-summary/asset-summary.presentation.spec.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest'; +import { + COMPACT_THRESHOLD, + compactDecimal, + exactDecimal, + groupIntegerDigits, + initialsFor, + presentQuantity, + shortenAssetId, +} from './asset-summary.presentation'; + +describe('exact decimal scaling', () => { + it('scales by string surgery, never by arithmetic', () => { + expect(exactDecimal('1234', 2)).toBe('12.34'); + expect(exactDecimal('1', 8)).toBe('0.00000001'); + expect(exactDecimal('100000000', 8)).toBe('1'); + }); + + it('keeps a quantity larger than a safe integer exact', () => { + const huge = '123456789012345678901234567890'; + expect(exactDecimal(huge, 0)).toBe(huge); + expect(exactDecimal(huge, 10)).toBe('12345678901234567890.123456789'); + }); + + it('treats zero divisibility as a whole count, not as unknown', () => { + expect(exactDecimal('5', 0)).toBe('5'); + }); + + it('refuses to scale when divisibility is unknown or out of range', () => { + expect(exactDecimal('1234', null)).toBeNull(); + expect(exactDecimal('1234', 39)).toBeNull(); + expect(exactDecimal('1234', -1)).toBeNull(); + expect(exactDecimal('1234', 1.5)).toBeNull(); + }); + + it('refuses a quantity that is not an unsigned integer string', () => { + expect(exactDecimal('-1', 0)).toBeNull(); + expect(exactDecimal('1.5', 0)).toBeNull(); + expect(exactDecimal('01', 0)).toBeNull(); + expect(exactDecimal('1e3', 0)).toBeNull(); + }); + + it('accepts the contract boundary of thirty eight places', () => { + expect(exactDecimal('1', 38)).toBe('0.' + '0'.repeat(37) + '1'); + }); +}); + +describe('digit grouping', () => { + it('groups the integer part and leaves the fraction alone', () => { + expect(groupIntegerDigits('1234567')).toBe('1,234,567'); + expect(groupIntegerDigits('1234567.891011')).toBe('1,234,567.891011'); + expect(groupIntegerDigits('0.000001')).toBe('0.000001'); + }); +}); + +describe('explicit approximation', () => { + it('leaves a short value alone so nothing implies rounding', () => { + expect(compactDecimal('12.34')).toBeNull(); + expect(compactDecimal('1,234,567')).toBeNull(); + }); + + it('shortens a long integer with a magnitude suffix', () => { + expect(compactDecimal(groupIntegerDigits('1234567890'))).toBe('1.23B'); + expect(compactDecimal(groupIntegerDigits('21000000000000'))).toBe('21T'); + }); + + it('never rounds a small nonzero value to zero', () => { + const tiny = exactDecimal('1', 38); + expect(tiny).not.toBeNull(); + const short = compactDecimal(tiny as string); + // The smallest representable amount at the contract's divisibility limit. + // Zero would be a false statement about a balance somebody holds. + expect(short).not.toBe('0'); + expect(short).toBe('1e-38'); + }); + + it('keeps a small fraction positional while that fits', () => { + const value = exactDecimal('123', 10); + expect(value).toBe('0.0000000123'); + // Twelve characters exactly: it fits, so it is not shortened at all. + expect(compactDecimal(value as string)).toBeNull(); + }); + + it('falls back to an exponent rather than to zero or to a weak bound', () => { + const value = exactDecimal('123', 20); + expect(compactDecimal(value as string)).toBe('1.23e-18'); + }); + + it('shortens a value beyond the largest magnitude suffix', () => { + const value = '1' + '0'.repeat(39); + expect(compactDecimal(groupIntegerDigits(value))).toBe('1e39'); + }); + + it('never produces a headline longer than the column allows', () => { + const values = [ + '1234567890123456789012345678901234567890', + '0.' + '0'.repeat(20) + '123', + '999999999999999.999999', + '1000.' + '1'.repeat(30), + ]; + for (const value of values) { + const grouped = groupIntegerDigits(value); + const short = compactDecimal(grouped) ?? grouped; + expect(short.length).toBeLessThanOrEqual(COMPACT_THRESHOLD + 4); + } + }); +}); + +describe('presented quantity', () => { + it('states an exact value when divisibility is known', () => { + const presented = presentQuantity('1234', 2); + expect(presented.kind).toBe('exact'); + expect(presented.headline).toBe('12.34'); + expect(presented.exact).toBe('12.34'); + expect(presented.approximate).toBe(false); + }); + + it('shows true digits labelled raw when divisibility is unknown', () => { + const presented = presentQuantity('1234', null); + expect(presented.kind).toBe('raw'); + // The digits are exactly what the authority said; only the scale is unknown. + expect(presented.exact).toBe('1234'); + expect(presented.headline).toBe('1,234'); + }); + + it('keeps zero as zero and unknown as unknown', () => { + const zero = presentQuantity('0', 8); + expect(zero.kind).toBe('exact'); + expect(zero.headline).toBe('0'); + + const unknown = presentQuantity(null, 8); + expect(unknown.kind).toBe('unknown'); + expect(unknown.headline).toBe(''); + expect(unknown.exact).toBe(''); + }); + + it('marks a long value approximate and keeps the exact string for copying', () => { + const presented = presentQuantity('123456789012345678', 0); + expect(presented.approximate).toBe(true); + expect(presented.exact).toBe('123456789012345678'); + expect(presented.headline).not.toBe(presented.exact); + expect(presented.headline.length).toBeLessThan(presented.exact.length); + }); + + it('carries the partial flag without changing the value', () => { + const presented = presentQuantity('100', 0, false); + expect(presented.partial).toBe(true); + expect(presented.exact).toBe('100'); + }); + + it('reports a malformed quantity as unknown rather than as zero', () => { + expect(presentQuantity('not-a-number', 0).kind).toBe('unknown'); + expect(presentQuantity('-5', 0).kind).toBe('unknown'); + }); +}); + +describe('identity presentation', () => { + it('builds initials from the first useful candidate', () => { + expect(initialsFor('', 'Uncommon Goods')).toBe('UG'); + expect(initialsFor('runes')).toBe('RU'); + expect(initialsFor('', '', '')).toBe('?'); + }); + + it('keeps both ends of an identifier it shortens', () => { + const id = 'a'.repeat(6) + 'b'.repeat(20) + 'c'.repeat(6); + const short = shortenAssetId(id, 6); + expect(short.startsWith('aaaaaa')).toBe(true); + expect(short.endsWith('cccccc')).toBe(true); + expect(short.length).toBeLessThan(id.length); + }); + + it('leaves a short identifier untouched', () => { + expect(shortenAssetId('SHORT', 6)).toBe('SHORT'); + }); +}); diff --git a/frontend/src/app/universe/asset-summary/asset-summary.presentation.ts b/frontend/src/app/universe/asset-summary/asset-summary.presentation.ts new file mode 100644 index 0000000000..e1d4a3756f --- /dev/null +++ b/frontend/src/app/universe/asset-summary/asset-summary.presentation.ts @@ -0,0 +1,248 @@ +/** + * How an asset quantity is turned into something a person can read, shared by + * the transaction summary and the address holdings panel. + * + * Every function here is pure and works on strings. That is not a style + * preference: an asset quantity is an unsigned integer of arbitrary size in the + * asset's smallest unit, and a rune supply routinely exceeds what a JavaScript + * number can hold. Converting one to a number to divide it by its divisibility + * loses digits silently, and the digit it loses is a digit of somebody's + * balance. Nothing in this file constructs a Number from a quantity. + * + * Three facts have to survive into the view, and each has its own case rather + * than a falsy check: + * + * - A known quantity with known divisibility is an exact decimal. + * - A known quantity with unknown divisibility is still exactly true as digits, + * and is labelled as smallest units. Scaling it by a guessed divisibility + * would understate it by up to thirty eight orders of magnitude. + * - An unknown quantity is unknown. It is not zero, and zero is not unknown. + * + * A long exact value also gets an explicitly approximate short form, so a + * column stays readable without a person ever being shown a rounded number they + * might mistake for the real one. The approximation is marked, the exact value + * is always available, and what gets copied is always the exact value. + */ + +/** The largest divisibility the contract allows. */ +const MAX_DECIMALS = 38; +const UNSIGNED_INTEGER = /^(0|[1-9][0-9]*)$/; + +/** + * How many characters a primary quantity may occupy before it is shortened. + * + * Chosen from the layout rather than from the value: a bounded numeric column + * at the narrowest supported width fits about this many tabular digits, and a + * primary quantity must never wrap. + */ +export const COMPACT_THRESHOLD = 12; + +/** The short scale suffixes, paired with the power of ten each stands for. */ +const MAGNITUDES: readonly { readonly power: number; readonly suffix: string }[] = + [ + { power: 12, suffix: 'T' }, + { power: 9, suffix: 'B' }, + { power: 6, suffix: 'M' }, + { power: 3, suffix: 'K' }, + ]; + +export type QuantityKind = 'exact' | 'raw' | 'unknown'; + +/** + * One quantity, ready to render. + * + * `headline` is what the column shows and `exact` is what a disclosure shows + * and what a copy action puts on the clipboard. When `approximate` is true the + * two differ and the view must say so; when it is false they are the same + * string and there is nothing to disclose. + */ +export interface PresentedQuantity { + readonly kind: QuantityKind; + /** The short form for the column. Empty only when the kind is unknown. */ + readonly headline: string; + /** The complete value, never shortened. Empty only when kind is unknown. */ + readonly exact: string; + /** True when `headline` is a rounded stand-in for `exact`. */ + readonly approximate: boolean; + /** + * True when the side this came from was only partly proven, so the value is + * a floor rather than a total. + */ + readonly partial: boolean; +} + +export const UNKNOWN_QUANTITY: PresentedQuantity = { + kind: 'unknown', + headline: '', + exact: '', + approximate: false, + partial: false, +}; + +/** + * The exact decimal form of an atomic quantity, or null when divisibility is + * not known. + * + * Null is the instruction to label the digits as smallest units. It never means + * zero and never licenses treating the digits as whole units. + */ +export function exactDecimal( + quantityAtomic: string | null, + decimals: number | null, +): string | null { + if (quantityAtomic === null || decimals === null) return null; + if (!UNSIGNED_INTEGER.test(quantityAtomic)) return null; + if (!Number.isInteger(decimals) || decimals < 0 || decimals > MAX_DECIMALS) { + return null; + } + if (decimals === 0) return quantityAtomic; + const padded = quantityAtomic.padStart(decimals + 1, '0'); + const whole = padded.slice(0, padded.length - decimals); + const fraction = padded.slice(padded.length - decimals).replace(/0+$/, ''); + return fraction.length === 0 ? whole : whole + '.' + fraction; +} + +/** Thousands separators, by string surgery on the integer part only. */ +export function groupIntegerDigits(value: string): string { + const point = value.indexOf('.'); + const whole = point === -1 ? value : value.slice(0, point); + const rest = point === -1 ? '' : value.slice(point); + return whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + rest; +} + +/** + * A short, explicitly approximate form of a decimal string, or null when the + * value already fits. + * + * Accepts the grouped display string, because the column's width is what + * decides whether shortening is needed, but computes on the digits: a comma is + * not a digit and treating it as one mis-measures every magnitude. + * + * Returning null is meaningful. It says the value printed in full, so nothing + * in the view should suggest the reader is looking at a rounded number. + * + * A very small nonzero value never becomes zero. Rounding 0.00000001 down to + * "0" would state that an address holds none of an asset it holds some of, so + * a value too small to place positionally is given in exponent form instead, + * which stays short, stays nonzero, and cannot be misread as the exact value. + */ +export function compactDecimal(exact: string): string | null { + if (exact.length <= COMPACT_THRESHOLD) return null; + const value = exact.replace(/,/g, ''); + const point = value.indexOf('.'); + const whole = point === -1 ? value : value.slice(0, point); + const fraction = point === -1 ? '' : value.slice(point + 1); + + if (whole !== '0' && whole !== '') { + for (const magnitude of MAGNITUDES) { + if (whole.length <= magnitude.power) continue; + const cut = whole.length - magnitude.power; + // More than three digits ahead of the largest suffix means the suffix + // cannot shorten it enough, and an exponent is the honest short form. + if (cut > 3) break; + const head = whole.slice(0, cut); + const tail = whole.slice(cut, cut + 2).replace(/0+$/, ''); + return head + (tail.length > 0 ? '.' + tail : '') + magnitude.suffix; + } + if (whole.length > MAGNITUDES[0].power) return exponentForm(whole, 0); + // A short whole part with a long fraction: the fraction is the only part + // that can be cut, and the whole part is never touched. + const room = COMPACT_THRESHOLD - whole.length - 1; + const kept = room > 0 ? fraction.slice(0, room) : ''; + return kept.length > 0 ? whole + '.' + kept : whole; + } + + // A pure fraction. Show it positionally while that fits, so a small holding + // stays legible, and fall back to an exponent rather than to zero. + const leadingZeros = fraction.length - fraction.replace(/^0+/, '').length; + const significant = fraction.slice(leadingZeros, leadingZeros + 3); + if (significant.length === 0) return '0'; + if (leadingZeros + significant.length <= COMPACT_THRESHOLD - 2) { + return '0.' + '0'.repeat(leadingZeros) + significant; + } + return exponentForm(significant, -(leadingZeros + 1)); +} + +/** + * Three significant digits and a power of ten, for a value no positional form + * can show in the space available. + */ +function exponentForm(digits: string, exponentOfFirstDigit: number): string { + const significant = digits.replace(/^0+/, ''); + const exponent = + exponentOfFirstDigit === 0 ? digits.length - 1 : exponentOfFirstDigit; + const lead = significant.slice(0, 1) || '0'; + const rest = significant.slice(1, 3).replace(/0+$/, ''); + return lead + (rest.length > 0 ? '.' + rest : '') + 'e' + String(exponent); +} + +/** + * The presented form of one side's quantity. + * + * `complete` is the authority's statement that it accounted for every position + * on that side. A false value does not make the quantity wrong, it makes it a + * floor, which the view has to say. + */ +export function presentQuantity( + quantityAtomic: string | null, + decimals: number | null, + complete = true, +): PresentedQuantity { + if (quantityAtomic === null || !UNSIGNED_INTEGER.test(quantityAtomic)) { + return { ...UNKNOWN_QUANTITY, partial: !complete }; + } + const decimal = exactDecimal(quantityAtomic, decimals); + if (decimal === null) { + // Divisibility unknown or conflicted. The digits are exactly true; only + // their scale is unknown, so they are shown and labelled, never scaled. + const grouped = groupIntegerDigits(quantityAtomic); + const short = compactDecimal(grouped); + return { + kind: 'raw', + headline: short ?? grouped, + exact: quantityAtomic, + approximate: short !== null, + partial: !complete, + }; + } + const grouped = groupIntegerDigits(decimal); + const short = compactDecimal(grouped); + return { + kind: 'exact', + headline: short ?? grouped, + exact: decimal, + approximate: short !== null, + partial: !complete, + }; +} + +/** + * One or two letters standing in for a missing logo. + * + * A labelled placeholder in the protocol's own hue reads as a deliberate + * stand-in. An empty grey square reads as an image that failed to load, and + * neither may be mistaken for a verified token logo. + */ +export function initialsFor(...candidates: readonly string[]): string { + const source = candidates.find((candidate) => candidate && candidate.trim()); + const trimmed = (source ?? '?').trim(); + const words = trimmed.split(/[\s._-]+/).filter(Boolean); + const letters = + words.length > 1 + ? words[0].charAt(0) + words[1].charAt(0) + : trimmed.slice(0, 2); + return letters.toUpperCase(); +} + +/** + * A shortened identifier for a column, with the ends kept. + * + * The ends are what a person compares against another identifier, so the + * middle is what goes. The full value always stays available elsewhere; this is + * presentation only and is never what gets copied. + */ +export function shortenAssetId(value: string, keep = 6): string { + if (typeof value !== 'string') return ''; + if (value.length <= keep * 2 + 1) return value; + return value.slice(0, keep) + '…' + value.slice(value.length - keep); +} diff --git a/frontend/src/app/universe/asset-summary/asset-summary.test-render.ts b/frontend/src/app/universe/asset-summary/asset-summary.test-render.ts new file mode 100644 index 0000000000..545e2f9849 --- /dev/null +++ b/frontend/src/app/universe/asset-summary/asset-summary.test-render.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ɵresolveComponentResources as resolveComponentResources } from '@angular/core'; + +/** + * Makes `templateUrl` components renderable under the unit-test runner. + * + * The suite runs outside the Angular CLI, so nothing has inlined the external + * template and stylesheet references a component declares, and the TestBed + * refuses to create such a component. Without this, template correctness would + * only ever be checked by the production build, and a lost table semantic or a + * missing aria attribute would ship until somebody opened the page. + * + * The real template file is read from disk, so these tests assert against the + * template that ships rather than against a copy kept in a test. Stylesheets + * resolve to nothing: they are Sass, which is the build's job to compile, and no + * assertion here depends on a computed style. + */ +export async function resolveTemplates(specUrl: string): Promise { + const base = dirname(fileURLToPath(specUrl)); + await resolveComponentResources((url: string) => { + if (url.endsWith('.scss') || url.endsWith('.css')) { + return Promise.resolve({ text: () => Promise.resolve('') } as Response); + } + const text = readFileSync(resolve(base, url), 'utf8'); + return Promise.resolve({ text: () => Promise.resolve(text) } as Response); + }); +} diff --git a/frontend/src/app/universe/transaction-assets/transaction-assets.component.html b/frontend/src/app/universe/transaction-assets/transaction-assets.component.html index 2a21f18b23..8223e179b9 100644 --- a/frontend/src/app/universe/transaction-assets/transaction-assets.component.html +++ b/frontend/src/app/universe/transaction-assets/transaction-assets.component.html @@ -1,10 +1,28 @@ +

Assets

- - {{ summaryChip(summary).label }} - {{ countLabel(summary) }} + + {{ model.countLabel }} + {{ model.contextLabel }} + {{ model.chip.label }} Assets
+
- + + + Not published + + {{ quantity.exact }} + smallest units + + + Copied + Could not copy. Select + the value to copy it yourself. + + + + diff --git a/frontend/src/app/universe/transaction-assets/transaction-assets.component.scss b/frontend/src/app/universe/transaction-assets/transaction-assets.component.scss index 27c72739d1..ce22f6d9ec 100644 --- a/frontend/src/app/universe/transaction-assets/transaction-assets.component.scss +++ b/frontend/src/app/universe/transaction-assets/transaction-assets.component.scss @@ -1,43 +1,41 @@ @use '../universe-tokens' as u; +@use '../asset-summary/asset-summary' as summary; /** * The transaction asset summary. * + * Geometry and the bounded numeric columns come from the shared asset-summary + * mixins, which the address holdings panel also uses, so the two panels read as + * one system. What is local here is what is genuinely specific to a + * transaction: the coverage disclosure, the effects list and the panel's own + * notices. + * * Every colour comes from the theme tokens, never from a literal: the same * markup is read on a white page and a near-black one, and a grey chosen - * against one of them falls through the contrast floor on the other. - * - * At narrow widths the table stacks into cards rather than scrolling - * sideways, and each stacked amount carries its own In/Out label, because a - * bare number in a stacked row says nothing about which side it is on. Exact - * amounts always print in full and stay selectable; nothing meaningful is - * hidden behind a hover. + * against one of them falls through the contrast floor on the other. The global + * identifier mixin is used, never modified: other panels depend on it. */ .universe-transaction-assets { - @include u.universe-surface; - @include u.universe-state-tokens; - - margin: var(--u-space-4) 0 0; - padding: var(--u-space-4); + @include summary.asset-summary-panel; + @include summary.asset-summary-identity; + @include summary.asset-summary-quantity; + @include summary.asset-summary-controls; + @include summary.asset-summary-details; + @include summary.asset-summary-visually-hidden; .universe-chip { @include u.universe-chip; } .summary-header { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--u-space-2) var(--u-space-3); - margin-bottom: var(--u-space-3); + @include summary.asset-summary-header; + } - h2 { - font-size: var(--u-text-lg); - font-weight: var(--u-weight-semibold); - margin: 0; - color: var(--u-text-primary); - } + // The one overall coverage badge, pushed to the end of the header so the + // count and the context read first. + .summary-badge { + margin-left: auto; } .summary-count { @@ -45,10 +43,18 @@ color: var(--u-text-secondary); } + .summary-context { + font-size: var(--u-text-xs); + color: var(--u-text-muted); + } + + // Reserved geometry, so the answer arriving does not move the page under a + // reader's cursor. .summary-skeleton { display: flex; flex-direction: column; gap: var(--u-space-2); + min-height: 3.5rem; .skeleton-bar { display: block; @@ -68,6 +74,12 @@ color: var(--u-text-secondary); } + .summary-asof { + margin: var(--u-space-3) 0 0; + font-size: var(--u-text-xs); + color: var(--u-text-muted); + } + .summary-retry { background: var(--u-surface-raised); border: 1px solid var(--u-border-strong); @@ -80,10 +92,15 @@ @include u.universe-touch-box; - &:hover { + &:hover:not(:disabled) { background: var(--u-surface-hover); } + &:disabled { + cursor: default; + opacity: 0.6; + } + &:focus-visible { outline: 2px solid var(--u-focus-ring); outline-offset: 2px; @@ -91,175 +108,34 @@ } .summary-table { - width: 100%; - border-collapse: collapse; - table-layout: auto; - - th, - td { - text-align: left; - vertical-align: top; - padding: var(--u-space-2) var(--u-space-2); - border-bottom: 1px solid var(--u-divider); - font-weight: 400; - color: var(--u-text-primary); - } - - th:first-child, - td:first-child { - padding-left: 0; - } - - td:last-child { - padding-right: 0; - } - - thead th { - font-size: var(--u-text-xs); - font-weight: var(--u-weight-semibold); - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--u-text-secondary); - border-bottom-color: var(--u-border); - } - - tbody tr:last-child th, - tbody tr:last-child td { - border-bottom: 0; - } + @include summary.asset-summary-table; } - // The In/Out label each stacked amount carries on a narrow screen. On a wide + // The In/Out label each stacked amount carries on a narrow panel. On a wide // one the column header already says it, so it is not repeated. .cell-label { display: none; - font-size: var(--u-text-xs); - font-weight: var(--u-weight-semibold); - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--u-text-secondary); } - .asset-cell { - display: flex; - gap: var(--u-space-2); - align-items: flex-start; - } - - .asset-logo { - // A fixed box in both states, so a late, missing or broken image cannot - // shift the row it sits in. - flex: 0 0 28px; - width: 28px; - height: 28px; - - img, - .asset-logo-fallback { - width: 28px; - height: 28px; - display: block; - border-radius: var(--u-radius-sm); - } - - img { - object-fit: contain; - background: var(--u-surface-sunken); - } - - // The stand-in reads as deliberate: the protocol's own hue as a border and - // its initials as the mark. Never a bare grey square, which reads as an - // image that failed, and never mistakable for a published token logo. - .asset-logo-fallback { - display: flex; - align-items: center; - justify-content: center; - background: var(--u-surface-sunken); - border: 1px solid var(--u-border-strong); - color: var(--u-text-secondary); - font-size: 0.7rem; - font-weight: var(--u-weight-bold); - letter-spacing: 0.02em; - } - } - - .asset-identity { - display: flex; - flex-direction: column; - gap: 0.15rem; - min-width: 0; + .disclose-cell { + text-align: right; } - .asset-name { - font-weight: var(--u-weight-semibold); - overflow-wrap: anywhere; - } - - .asset-meta { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.3rem; - } - - .asset-kind { - font-size: var(--u-text-xs); - color: var(--u-text-secondary); - } - - .asset-id { - @include u.universe-identifier; - - color: var(--u-text-secondary); - } - - .quantity-cell { - font-variant-numeric: tabular-nums; - } - - .quantity-value { - // The full digits stay visible and selectable. Nothing meaningful is put - // behind a hover or an ellipsis. - overflow-wrap: anywhere; - user-select: text; - } - - .quantity-ticker { - color: var(--u-text-secondary); - } - - .quantity-note { - display: block; - font-size: var(--u-text-xs); - color: var(--u-text-secondary); - } - - .quantity-none { - font-size: var(--u-text-xs); - color: var(--u-text-secondary); - } - - .quantity-partial { - margin-top: 0.25rem; - } - - .summary-effects { + .effect-list { list-style: none; - margin: var(--u-space-3) 0 0; + margin: 0; padding: 0; display: flex; - flex-wrap: wrap; - gap: var(--u-space-2); - - &:empty { - display: none; - } + flex-direction: column; + gap: 0.3rem; + min-width: 0; li { - display: inline-flex; + display: flex; + flex-wrap: wrap; align-items: center; gap: 0.4rem; - font-size: var(--u-text-sm); - color: var(--u-text-secondary); + min-width: 0; } .universe-chip { @@ -267,7 +143,18 @@ } } - .summary-gaps { + .effect-quantity { + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; + min-width: 0; + } + + .effect-authority { + font-size: var(--u-text-xs); + color: var(--u-text-muted); + } + + .summary-coverage { margin-top: var(--u-space-3); font-size: var(--u-text-sm); color: var(--u-text-secondary); @@ -287,110 +174,54 @@ } } - .gap-intro { - margin: var(--u-space-2) 0 0.25rem; - } - - .gap-list { - margin: 0; - padding-left: 1.1rem; - columns: 2; + .coverage-list { + list-style: none; + margin: var(--u-space-2) 0 var(--u-space-2); + padding: 0; + display: grid; + gap: 0.3rem; li { - break-inside: avoid; + display: grid; + grid-template-columns: minmax(0, 12rem) minmax(0, 12rem) minmax(0, 1fr); + gap: 0.2rem var(--u-space-3); + align-items: baseline; } } - } -} - -// Stacked cards instead of a table that scrolls sideways on a phone. -@media (max-width: 575.98px) { - .universe-transaction-assets { - padding: var(--u-space-3); - .summary-table, - .summary-table tbody, - .summary-table tr, - .summary-table td, - .summary-table th { - display: block; - width: auto; - } - - .summary-table thead { - // Visually hidden, not removed: the column meaning moves into each - // stacked cell's own label so nothing is lost. - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; + .coverage-name { + color: var(--u-text-primary); + min-width: 0; } - .summary-table tr { - padding: var(--u-space-2) 0; - border-bottom: 1px solid var(--u-divider); + .coverage-state { + color: var(--u-text-secondary); + min-width: 0; } - .summary-table tr:last-child { - border-bottom: 0; + .coverage-reason { + color: var(--u-text-muted); + font-size: var(--u-text-xs); + min-width: 0; } + } - .summary-table th, - .summary-table td { - border-bottom: 0; - padding: 0.15rem 0; - } + // Driven by the panel's own width, not the viewport: this panel sits inside + // page containers of several widths, and a viewport query stacks a panel that + // had room while leaving a narrow one unstacked. + @container (max-width: 720px) { + @include summary.asset-summary-stacked; - .cell-label { - display: inline-block; - min-width: 2.5rem; - } + padding: var(--u-space-3); - .summary-table td.quantity-cell { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 0.4rem; + .summary-badge { + margin-left: 0; } - .summary-gaps .gap-list { - columns: 1; + .summary-coverage .coverage-list li { + grid-template-columns: minmax(0, 1fr); } } -} -@media (prefers-reduced-motion: reduce) { - .universe-transaction-assets .skeleton-bar { - animation: none; - } + @include summary.asset-summary-environment; } - -/* IMPLEMENTATION-HANDOFF [UI-WP03:TX-LAYOUT] 2026-09-19 - * Coverage C22-C32; defects F01/F02. Preparation only; do not add active CSS yet. - * Verified: table-layout:auto, flex on th.asset-cell, and overflow-wrap:anywhere - * on .quantity-value allow content to dominate column sizing and split digits. - * The only stacking breakpoint is a 575.98px viewport, not the panel width. - * Sources R01 WCAG 2.2 reflow; R06 CSS table model; existing global u-tokens. - * 1. With TX-MARKUP, restore the desktop th to table-cell and put flex inside - * its identity child. Set an explicit colgroup/fixed layout or measured - * bounded column sizing; the identity column is the flexible remainder. - * 2. Add container-type:inline-size on the panel. At <=720px container width, - * use stacked asset rows with labelled two-column Inputs/Outputs. Confirm - * semantic table accessibility; never use CSS to remove accessible labels. - * 3. Give every flexible child min-width:0. Primary quantities use tabular nums, - * white-space:nowrap and the shared compact presenter, not word-break or - * a smaller unreadable font. Exact disclosure may wrap at its full width. - * 4. Use existing u-space 4/8/12/16/24 and radius 5/8 tokens, 40px fixed logo - * boxes, 44px controls, restrained separators and state foreground/surface - * pairs. Keep brand accents separate from evidence colors; no palette fork. - * 5. Extract common styles to PROPOSED NEW ../asset-summary/_asset-summary.scss - * and reuse in address-assets. Do not alter the global identifier mixin. - * 6. Reserve loading geometry; retain focus rings, forced-colors borders and - * reduced-motion rules. No nested page-wide horizontal scrolling. - * Tests: actual Angular views at all C22-C32 widths/themes; assert amount - * line boxes, no document overflow, correct hit targets and focus order. - * Run root npm run checkscreenshots:changed and frontend type/template checks - * after implementation (NOT RUN here). Rollback local styles with markup. - */ diff --git a/frontend/src/app/universe/transaction-assets/transaction-assets.component.spec.ts b/frontend/src/app/universe/transaction-assets/transaction-assets.component.spec.ts index 8f36ca23f9..997593f628 100644 --- a/frontend/src/app/universe/transaction-assets/transaction-assets.component.spec.ts +++ b/frontend/src/app/universe/transaction-assets/transaction-assets.component.spec.ts @@ -1,12 +1,47 @@ -import { describe, expect, it } from 'vitest'; +// @vitest-environment jsdom +import 'zone.js'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; import { SimpleChange, SimpleChanges } from '@angular/core'; import { HttpErrorResponse } from '@angular/common/http'; +import { CommonModule } from '@angular/common'; +import { TestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; import { Observable, TimeoutError, of, throwError } from 'rxjs'; -import { TransactionAssetsComponent, SummaryViewState } from './transaction-assets.component'; -import { TransactionAssetSummary } from './transaction-assets.types'; +import { + SummaryViewModel, + SummaryViewState, + TransactionAssetsComponent, +} from './transaction-assets.component'; +import { SummaryAsset, TransactionAssetSummary } from './transaction-assets.types'; import { ExplorerProtocolDefinition } from '@app/universe/universe.types'; +import { UniverseApiService } from '@app/universe/universe-api.service'; +import { resolveTemplates } from '@app/universe/asset-summary/asset-summary.test-render'; const TXID = 'a'.repeat(64); +const BLOCK_HASH = 'c'.repeat(64); + +function runesAsset(overrides: Partial = {}): SummaryAsset { + return { + chain: 'bitcoin', + network: 'signet', + protocolId: 'runes', + assetId: 'UNCOMMON.GOODS', + ruleset: null, + assetKind: 'fungible', + displayName: 'Uncommon Goods', + ticker: 'UNCOMMON', + decimals: 2, + logo: null, + inputs: { quantityAtomic: null, positionCountAtomic: '0', complete: false }, + outputs: { quantityAtomic: '1234', positionCountAtomic: '1', complete: true }, + effects: [], + evidence: [], + ...overrides, + }; +} function summary(overrides: Partial = {}): TransactionAssetSummary { return { @@ -15,25 +50,9 @@ function summary(overrides: Partial = {}): TransactionA network: 'signet', txid: TXID, status: 'confirmed', - assets: [ - { - chain: 'bitcoin', - network: 'signet', - protocolId: 'runes', - assetId: 'UNCOMMON.GOODS', - ruleset: null, - assetKind: 'fungible', - displayName: 'Uncommon Goods', - ticker: 'UNCOMMON', - decimals: 2, - logo: null, - inputs: { quantityAtomic: null, positionCountAtomic: '0', complete: false }, - outputs: { quantityAtomic: '1234', positionCountAtomic: '1', complete: true }, - effects: [], - }, - ], + assets: [runesAsset()], perProtocolCoverage: [ - { protocolId: 'runes', chain: 'bitcoin', network: 'signet', state: 'complete' }, + { protocolId: 'runes', chain: 'bitcoin', network: 'signet', state: 'complete', reason: 'reader-answered' }, { protocolId: 'brc20', chain: 'bitcoin', network: 'signet', state: 'unconfigured', reason: 'no-transaction-reader' }, ], counts: { @@ -45,22 +64,40 @@ function summary(overrides: Partial = {}): TransactionA totalCount: null, }, retryAfterSeconds: 5, + checkpoint: { + chain: 'bitcoin', + network: 'signet', + heightAtomic: '240000', + blockHash: BLOCK_HASH, + reorgEpoch: '0', + observedAt: '2026-09-19T00:00:00.000Z', + }, ...overrides, }; } -function componentWith( +const PROTOCOLS: ExplorerProtocolDefinition[] = [ + { id: 'runes', shortName: 'RUNES', displayName: 'Runes', visualToken: 'runes' } as ExplorerProtocolDefinition, + { id: 'brc20', shortName: 'BRC-20', displayName: 'BRC-20', visualToken: 'brc20' } as ExplorerProtocolDefinition, +]; + +function apiFor( source: () => Observable, - protocols: ExplorerProtocolDefinition[] = [ - { id: 'runes', shortName: 'RUNES', displayName: 'Runes', visualToken: 'runes' } as ExplorerProtocolDefinition, - { id: 'brc20', shortName: 'BRC-20', displayName: 'BRC-20', visualToken: 'brc20' } as ExplorerProtocolDefinition, - ], -): TransactionAssetsComponent { - const api = { + protocols: ExplorerProtocolDefinition[] = PROTOCOLS, + protocolSource?: () => Observable, +): never { + return { getTransactionAssets$: () => source(), - getProtocols$: () => of({ protocols } as never), + getProtocols$: () => protocolSource ? protocolSource() : of({ protocols } as never), } as never; - const component = new TransactionAssetsComponent(api); +} + +function componentWith( + source: () => Observable, + protocols: ExplorerProtocolDefinition[] = PROTOCOLS, + protocolSource?: () => Observable, +): TransactionAssetsComponent { + const component = new TransactionAssetsComponent(apiFor(source, protocols, protocolSource)); component.txid = TXID; component.chain = 'bitcoin'; return component; @@ -82,11 +119,46 @@ function txidChange(): SimpleChanges { return { txid: new SimpleChange(undefined, TXID, true) }; } +/** The model of the last rendered summary, with registry names resolved. */ +function model( + overrides: Partial = {}, + protocols: ExplorerProtocolDefinition[] = PROTOCOLS, +): SummaryViewModel { + const component = componentWith(() => of(summary(overrides)), protocols); + component.ngOnChanges(txidChange()); + const states = collect(component); + const rendered = states[states.length - 1]; + if (rendered.kind !== 'summary' || !rendered.model) { + throw new Error('expected a rendered summary'); + } + return rendered.model; +} + describe('TransactionAssetsComponent request lifecycle', () => { it('starts loading and then renders the summary', () => { const component = componentWith(() => of(summary())); component.ngOnChanges(txidChange()); - expect(collect(component).map((state) => state.kind)).toEqual(['loading', 'summary']); + const kinds = collect(component).map((state) => state.kind); + expect(kinds[0]).toBe('loading'); + expect(kinds[kinds.length - 1]).toBe('summary'); + }); + + it('renders proven amounts before the optional registry has answered', () => { + // The registry stream is seeded, so combineLatest does not hold a proven + // quantity behind an optional display name. Never emitting a name at all is + // the strongest form of "slow". + const component = componentWith( + () => of(summary()), + [], + () => new Observable(), + ); + component.ngOnChanges(txidChange()); + const states = collect(component); + const rendered = states[states.length - 1]; + expect(rendered.kind).toBe('summary'); + expect(rendered.model?.rows[0].outputs.headline).toBe('12.34'); + // The id stands in for the name that never arrived, rather than blocking. + expect(rendered.model?.rows[0].protocol.displayName).toBe('runes'); }); it('does not request again for an unrelated change', () => { @@ -101,11 +173,15 @@ describe('TransactionAssetsComponent request lifecycle', () => { expect(calls).toBe(0); }); - it('separates timeout, not found, unconfigured and other failures', () => { + it('separates timeout, not found, unconfigured, outage and other failures', () => { const cases: [unknown, string][] = [ [new TimeoutError(), 'timeout'], [new HttpErrorResponse({ status: 404 }), 'not-found'], [new HttpErrorResponse({ status: 503 }), 'unconfigured'], + // An upstream outage is a different fact from "not set up": telling a + // visitor the deployment lacks a source sends them to fix the wrong thing. + [new HttpErrorResponse({ status: 502 }), 'unavailable'], + [new HttpErrorResponse({ status: 504 }), 'unavailable'], [new HttpErrorResponse({ status: 500 }), 'error'], [new Error('decode failed'), 'error'], ]; @@ -124,9 +200,10 @@ describe('TransactionAssetsComponent request lifecycle', () => { const last = states[states.length - 1]; expect(last.kind).toBe('error'); expect(last.summary).toBeUndefined(); + expect(last.model).toBeUndefined(); }); - it('re-requests exactly once per explicit retry', async () => { + it('re-requests exactly once per explicit retry', () => { let calls = 0; const component = componentWith(() => { calls += 1; @@ -139,174 +216,527 @@ describe('TransactionAssetsComponent request lifecycle', () => { expect(calls).toBe(2); subscription.unsubscribe(); }); -}); -describe('TransactionAssetsComponent presentation', () => { - const component = componentWith(() => of(summary())); - - /** The view state a rendered summary produces, with registry names resolved. */ - function view(overrides: Partial = {}): SummaryViewState { - const rendering = componentWith(() => of(summary(overrides))); - rendering.ngOnChanges(txidChange()); - const states = collect(rendering); - const rendered = states[states.length - 1]; - if (rendered.kind !== 'summary') {throw new Error('expected a rendered summary');} - return rendered; - } + it('honours a retry cooldown instead of allowing a burst', () => { + let calls = 0; + const component = componentWith(() => { + calls += 1; + return of(summary()); + }); + component.ngOnChanges(txidChange()); + const subscription = component.state$.subscribe(); + component.retry(5); + expect(calls).toBe(2); + expect(component.retryCooldown).toBe(true); + component.retry(5); + component.retry(5); + // Nothing during the cooldown the payload itself asked for, and no polling. + expect(calls).toBe(2); + subscription.unsubscribe(); + component.ngOnDestroy(); + }); +}); +describe('TransactionAssetsComponent presentation model', () => { it('renders the exact decimal amount from the stated divisibility', () => { - const [row] = component.rows(view()); - expect(row.outputs).toEqual({ kind: 'exact', value: '12.34', partial: false }); + const [row] = model().rows; + expect(row.outputs.kind).toBe('exact'); + expect(row.outputs.headline).toBe('12.34'); + expect(row.outputs.exact).toBe('12.34'); + expect(row.outputs.approximate).toBe(false); }); it('labels digits as smallest units when divisibility is unknown', () => { - const state = view(); - state.summary.assets[0].decimals = null; - const [row] = component.rows(state); - expect(row.outputs).toEqual({ kind: 'raw', value: '1234', partial: false }); + const [row] = model({ assets: [runesAsset({ decimals: null })] }).rows; + expect(row.outputs.kind).toBe('raw'); + expect(row.outputs.exact).toBe('1234'); }); it('shows an unstated amount as not published rather than zero', () => { - const [row] = component.rows(view()); - expect(row.inputs.kind).toBe('none'); - expect(row.inputs.value).toBe(''); + const [row] = model().rows; + expect(row.inputs.kind).toBe('unknown'); + expect(row.inputs.headline).toBe(''); expect(row.inputs.partial).toBe(true); }); + it('keeps a very long amount on one line and the exact value for copying', () => { + const [row] = model({ + assets: [ + runesAsset({ + decimals: 0, + outputs: { quantityAtomic: '340282366920938463463374607431768211455', positionCountAtomic: '1', complete: true }, + }), + ], + }).rows; + expect(row.outputs.approximate).toBe(true); + expect(row.outputs.exact).toBe('340282366920938463463374607431768211455'); + expect(row.outputs.headline).not.toContain(' '); + }); + it('keys rows by full identity so two protocols do not merge', () => { - const state = view(); - state.summary.assets = [ - state.summary.assets[0], - { ...state.summary.assets[0], protocolId: 'alkanes' }, - ]; - const keys = component.rows(state).map((row) => row.key); + const keys = model({ + assets: [runesAsset(), runesAsset({ protocolId: 'alkanes' })], + counts: { ...summary().counts, knownCountAtomic: '2' }, + }).rows.map((row) => row.key); expect(new Set(keys).size).toBe(2); }); it('keeps two rulesets of one ledger as separate rows', () => { - const state = view(); - state.summary.assets = [ - { ...state.summary.assets[0], ruleset: 'strict' }, - { ...state.summary.assets[0], ruleset: 'lenient' }, - ]; - expect(new Set(component.rows(state).map((row) => row.key)).size).toBe(2); + const keys = model({ + assets: [runesAsset({ ruleset: 'strict' }), runesAsset({ ruleset: 'lenient' })], + counts: { ...summary().counts, knownCountAtomic: '2' }, + }).rows.map((row) => row.key); + expect(new Set(keys).size).toBe(2); }); - it('names the sources that left the inventory incomplete, officially', () => { - expect(component.incomplete(summary())).toBe(true); - expect(component.gapCount(summary())).toBe(1); + it('states each gap with its own state and reason, not as waiting', () => { + const built = model(); + expect(built.incomplete).toBe(true); + expect(built.gaps).toHaveLength(1); + const [gap] = built.gaps; // The registry's published name, not the internal id. - expect(component.gapNames(view())).toEqual(['BRC-20']); + expect(gap.name).toBe('BRC-20'); + expect(gap.stateLabel).toBe('No source set up'); + expect(gap.reasonLabel).toContain('No source reads this protocol'); + // A protocol nobody reads will not start answering because of a button. + expect(gap.retryable).toBe(false); + expect(built.retryable).toBe(false); + }); + + it('offers a retry only for a gap that could actually clear', () => { + const built = model({ + perProtocolCoverage: [ + { protocolId: 'runes', chain: 'bitcoin', network: 'signet', state: 'unavailable', reason: 'authority-unavailable' }, + ], + }); + expect(built.gaps[0].retryable).toBe(true); + expect(built.retryable).toBe(true); + expect(built.gaps[0].stateLabel).toBe('Source not reachable'); + }); + + it('keeps a permanent limit distinct from an outage', () => { + const states = model({ + perProtocolCoverage: [ + { protocolId: 'runes', chain: 'bitcoin', network: 'signet', state: 'unsupported-network', reason: 'unsupported-network' }, + { protocolId: 'brc20', chain: 'bitcoin', network: 'signet', state: 'not-publicly-observable', reason: 'public-proof-not-observable' }, + ], + }).gaps; + expect(states.map((entry) => entry.retryable)).toEqual([false, false]); + expect(states[0].stateLabel).toBe('Not available on this network'); + expect(states[1].stateLabel).toBe('Not publicly verifiable'); }); it('falls back to a readable id when the registry is unavailable', () => { - const bare = componentWith(() => of(summary()), []); - bare.ngOnChanges(txidChange()); - const states = collect(bare); - const rendered = states[states.length - 1]; - if (rendered.kind !== 'summary') {throw new Error('expected a rendered summary');} - expect(bare.gapNames(rendered)).toEqual(['brc20']); + const built = model({}, []); + expect(built.gaps[0].name).toBe('brc20'); // Amounts are untouched by missing presentation metadata. - expect(bare.rows(rendered)[0].outputs.value).toBe('12.34'); + expect(built.rows[0].outputs.headline).toBe('12.34'); }); - it('states the evidence chip and the count in plain words', () => { - expect(component.summaryChip(summary())).toEqual({ state: 'state-partial', label: 'Partial' }); - expect(component.countLabel(summary())).toBe('1 asset found so far'); + it('states one overall badge and the count in plain words', () => { + const partial = model(); + expect(partial.chip).toEqual({ state: 'state-partial', label: 'Partial' }); + expect(partial.countLabel).toBe('1 asset found so far'); - const complete = summary(); - complete.counts.totalCount = 1; - expect(component.summaryChip(complete)).toEqual({ state: 'state-proven', label: 'Complete' }); - expect(component.countLabel(complete)).toBe('1 asset'); + const complete = model({ + perProtocolCoverage: [ + { protocolId: 'runes', chain: 'bitcoin', network: 'signet', state: 'complete', reason: 'reader-answered' }, + ], + counts: { ...summary().counts, totalCount: 1 }, + }); + expect(complete.chip).toEqual({ state: 'state-proven', label: 'Complete' }); + expect(complete.countLabel).toBe('1 asset'); + expect(complete.gaps).toHaveLength(0); - const empty = summary({ assets: [] }); - empty.counts.knownCountAtomic = '0'; - empty.counts.totalCount = 0; - expect(component.summaryChip(empty)).toEqual({ state: 'state-proven', label: 'None found' }); - expect(component.countLabel(empty)).toBe('No supported assets'); + const empty = model({ + assets: [], + perProtocolCoverage: [ + { protocolId: 'runes', chain: 'bitcoin', network: 'signet', state: 'proven-empty', reason: 'reader-answered' }, + ], + counts: { ...summary().counts, knownCountAtomic: '0', totalCount: 0 }, + }); + expect(empty.chip).toEqual({ state: 'state-proven', label: 'None found' }); + expect(empty.countLabel).toBe('No supported assets'); + expect(empty.provenEmpty).toBe(true); + }); + + it('claims none found only for a stated total of zero with no rows', () => { + expect(model().provenEmpty).toBe(false); + const unknownTotal = model({ + assets: [], + counts: { ...summary().counts, knownCountAtomic: '0', totalCount: null }, + }); + // Nothing found and nothing proven is not the same as proven empty. + expect(unknownTotal.provenEmpty).toBe(false); }); it('gives every row a labelled stand-in when no logo is published', () => { - const [row] = component.rows(view()); + const [row] = model().rows; expect(row.asset.logo).toBeNull(); - // A visible mark in the protocol's own hue, never an empty grey square. expect(row.initials).toBe('RU'); expect(row.protocol.displayName).toBe('Runes'); }); - it('does not repeat the asset id when it is already the title', () => { - const [named] = component.rows(view()); - expect(named.subtitle).toBe('UNCOMMON.GOODS'); + it('shortens the id in the row and keeps the full one for the details', () => { + const [named] = model().rows; + expect(named.assetId).toBe('UNCOMMON.GOODS'); - const state = view(); - state.summary.assets[0] = { ...state.summary.assets[0], displayName: null, ticker: null }; - expect(component.rows(state)[0].subtitle).toBe(''); + const long = 'z'.repeat(60); + const [row] = model({ assets: [runesAsset({ assetId: long })] }).rows; + expect(row.subtitle.length).toBeLessThan(long.length); + expect(row.assetId).toBe(long); + }); + + it('does not repeat the asset id when it is already the title', () => { + const [row] = model({ + assets: [runesAsset({ displayName: null, ticker: null })], + }).rows; + expect(row.subtitle).toBe(''); }); it('translates the authority classification into a plain word', () => { - expect(component.rows(view())[0].kindLabel).toBe('Token'); - const state = view(); - state.summary.assets[0] = { ...state.summary.assets[0], assetKind: 'inscription' }; - expect(component.rows(state)[0].kindLabel).toBe('Collectible'); - const odd = view(); - odd.summary.assets[0] = { ...odd.summary.assets[0], assetKind: 'something-new' }; - expect(component.rows(odd)[0].kindLabel).toBe(''); + expect(model().rows[0].kindLabel).toBe('Token'); + expect(model({ assets: [runesAsset({ assetKind: 'inscription' })] }).rows[0].kindLabel) + .toBe('Collectible'); + expect(model({ assets: [runesAsset({ assetKind: 'something-new' })] }).rows[0].kindLabel) + .toBe(''); }); - it('claims none found only for a stated total of zero with no rows', () => { - expect(component.provenEmpty(summary())).toBe(false); - const empty = summary({ assets: [] }); - empty.counts.knownCountAtomic = '0'; - expect(component.provenEmpty(empty)).toBe(false); - empty.counts.totalCount = 0; - expect(component.provenEmpty(empty)).toBe(true); + it('says not accepted for an effect no authority accepted', () => { + const [row] = model({ + assets: [ + runesAsset({ + effects: [ + { eventId: 'e1', actionType: 'transfer', quantityAtomic: '1234', accepted: false, evidence: null }, + { eventId: 'e2', actionType: 'mint', quantityAtomic: '1', accepted: true, evidence: null }, + ], + }), + ], + }).rows; + expect(row.effects[0].label).toBe('transfer (not accepted)'); + expect(row.effects[0].accepted).toBe(false); + // Never "unconfirmed": base chain confirmation is not protocol acceptance. + expect(row.effects[0].label).not.toContain('unconfirmed'); + expect(row.effects[1].label).toBe('mint'); + }); + + it('keeps the effect authority from the preserved evidence', () => { + const [row] = model({ + assets: [ + runesAsset({ + effects: [ + { + eventId: 'e1', + actionType: 'transfer', + quantityAtomic: '1234', + accepted: true, + evidence: { + authorityId: 'ord', + protocolId: 'runes', + coverage: 'complete', + checkedAt: '2026-09-19T00:00:00.000Z', + checkpoint: null, + }, + }, + ], + }), + ], + }).rows; + expect(row.effects[0].authorityId).toBe('ord'); + }); + + it('carries the checkpoint through, and its absence as absence', () => { + expect(model().checkpoint?.heightAtomic).toBe('240000'); + expect(model({ checkpoint: null }).checkpoint).toBeNull(); }); - it('falls back once per identity on a logo error, without a loop', () => { - const state = view(); - const [row] = component.rows(state); + it('falls back once per artwork revision on a logo error, without a loop', () => { + const component = componentWith(() => of(summary({ + assets: [ + runesAsset({ + logo: { + objectPath: '/universe-media/v1/objects/' + 'd'.repeat(64), + contentHash: 'd'.repeat(64), + mediaType: 'image/png', + metadataRevision: 'rev-1', + verified: true, + }, + }), + ], + }))); + component.ngOnChanges(txidChange()); + const first = collect(component); + const row = (first[first.length - 1].model as SummaryViewModel).rows[0]; expect(row.logoFailed).toBe(false); - component.onLogoError(row.key); - expect(component.rows(state)[0].logoFailed).toBe(true); - component.onLogoError(row.key); - expect(component.rows(state)[0].logoFailed).toBe(true); + component.onLogoError(row); + expect(row.logoFailed).toBe(true); + const again = collect(component); + expect((again[again.length - 1].model as SummaryViewModel).rows[0].logoFailed).toBe(true); + }); + + it('gives replaced artwork its own attempt rather than the old failure', () => { + const component = componentWith(() => of(summary({ + assets: [ + runesAsset({ + logo: { + objectPath: '/universe-media/v1/objects/' + 'd'.repeat(64), + contentHash: 'd'.repeat(64), + mediaType: 'image/png', + metadataRevision: 'rev-1', + verified: true, + }, + }), + ], + }))); + component.ngOnChanges(txidChange()); + const states = collect(component); + const row = (states[states.length - 1].model as SummaryViewModel).rows[0]; + component.onLogoError(row); + + const replaced = componentWith(() => of(summary({ + assets: [ + runesAsset({ + logo: { + objectPath: '/universe-media/v1/objects/' + 'e'.repeat(64), + contentHash: 'e'.repeat(64), + mediaType: 'image/png', + metadataRevision: 'rev-2', + verified: true, + }, + }), + ], + }))); + replaced.ngOnChanges(txidChange()); + const next = collect(replaced); + expect((next[next.length - 1].model as SummaryViewModel).rows[0].logoFailed).toBe(false); }); - it('clears logo failures when the transaction changes', () => { - const state = view(); - const [row] = component.rows(state); - component.onLogoError(row.key); + it('clears the open rows and logo failures when the transaction changes', () => { + const component = componentWith(() => of(summary())); + component.ngOnChanges(txidChange()); + const states = collect(component); + const row = (states[states.length - 1].model as SummaryViewModel).rows[0]; + component.toggle(row); + expect(component.isExpanded(row)).toBe(true); + component.onLogoError(row); + component.ngOnChanges({ txid: new SimpleChange(TXID, 'b'.repeat(64), false) }); + // A row left open would show another transaction's details in place. + expect(component.expanded.size).toBe(0); component.txid = TXID; - expect(component.rows(state)[0].logoFailed).toBe(false); + const after = collect(component); + expect((after[after.length - 1].model as SummaryViewModel).rows[0].logoFailed).toBe(false); + }); + + it('reports a clipboard denial instead of claiming a copy succeeded', async () => { + const component = componentWith(() => of(summary())); + const original = (globalThis as { navigator?: unknown }).navigator; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { clipboard: { writeText: () => Promise.reject(new Error('denied')) } }, + }); + try { + component.copy('k', '12.34'); + await Promise.resolve(); + await Promise.resolve(); + expect(component.copyResult('k')).toBe('failed'); + } finally { + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: original }); + } + }); + + it('reports a copy that actually happened', async () => { + const component = componentWith(() => of(summary())); + const original = (globalThis as { navigator?: unknown }).navigator; + let written = ''; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + clipboard: { + writeText: (value: string) => { + written = value; + return Promise.resolve(); + }, + }, + }, + }); + try { + component.copy('k', '340282366920938463463374607431768211455'); + await Promise.resolve(); + await Promise.resolve(); + expect(component.copyResult('k')).toBe('copied'); + // The exact value, never the shortened headline. + expect(written).toBe('340282366920938463463374607431768211455'); + } finally { + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: original }); + } }); }); -/* IMPLEMENTATION-HANDOFF [UI-WP06:ACCEPTANCE] 2026-09-19 - * Coverage C01-C52. Preparation only: existing tests were inspected, NOT RUN. - * Verified: these are class-level tests, not rendered Angular/browser E2E. - * Some fixtures set complete totals while retaining unconfigured coverage; - * they must be made contract-consistent, not used to weaken UI-WP01 validation. - * 1. Keep every existing valid assertion and add the UI-WP01/02 adversarial - * decoder/lifecycle cases, using authoritative contract fixtures (UI-WP07). - * 2. Add rendered-template tests for exact/raw/null, full identity/copy, - * accepted versus not-accepted effects, counts/filters and coverage reasons. - * Test Angular template compilation; a standalone HTML mock is insufficient. - * 3. Add a shared browser matrix to PROPOSED NEW scripts/universe/visual-qa/ - * asset-summary.spec.mjs after checking the existing harness API. Cover - * transaction, tracker, address and any confirmed multichain embed, both - * themes, 320/390/768/1024/1440, 200/400 percent zoom and keyboard focus. - * 4. Use real Signet read-only API/indexer-to-UI journeys where supported; - * record justified Testnet substitutes per source. Do not spend funds or - * manufacture a transaction requirement for a read-only feature. Faults - * may use labelled isolated integration fixtures, never acceptance mocks. - * 5. Retest base BTC values, details/links, network isolation, back/refresh, - * cancellation, unavailable/partial/proven-empty, logos and bounded retries. - * 6. Record revision, network, requests, source/checkpoint readback, screenshots - * and assertions per row. Keep BLOCKED/NOT TESTED separate from PASS. - * 7. Only after every required row passes, integrate legitimate feature work - * and use the established release process; verify public revision/health - * and preserve rollback. No deployment is authorized by a fixture pass. - * Commands: frontend npm test -- src/app/universe/transaction-assets; root - * npm run checkscreenshots:changed (NOT RUN here). Read the full handoff prompt. +/** + * Rendered template tests. + * + * The class-level tests above prove the model. These prove the template + * compiles and puts the model on the page, which a class-level test cannot: a + * binding to a property that does not exist, a lost table semantic or a missing + * aria attribute are all invisible until something renders. */ +describe('TransactionAssetsComponent rendered template', () => { + beforeAll(async () => { + Object.defineProperty(TransactionAssetsComponent, 'ctorParameters', { + configurable: true, + value: () => [{ type: UniverseApiService }], + }); + // The real template file, read from disk, so these assertions are about the + // template that ships rather than about a copy kept here. + await resolveTemplates(import.meta.url); + TestBed.initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); + }); + afterEach(() => TestBed.resetTestingModule()); + + function render(overrides: Partial = {}) { + TestBed.configureTestingModule({ + imports: [CommonModule], + declarations: [TransactionAssetsComponent], + providers: [ + { provide: UniverseApiService, useValue: apiFor(() => of(summary(overrides))) }, + ], + // The protocol badge is another component's concern and has its own tests. + errorOnUnknownElements: false, + errorOnUnknownProperties: false, + }); + const view = TestBed.createComponent(TransactionAssetsComponent); + view.componentInstance.txid = TXID; + view.componentInstance.chain = 'bitcoin'; + view.componentInstance.ngOnChanges(txidChange()); + view.detectChanges(); + return view; + } + + it('renders the amounts and the single coverage badge', () => { + const view = render(); + const text = view.nativeElement.textContent as string; + expect(text).toContain('12.34'); + expect(text).toContain('1 asset found so far'); + expect(text).toContain('Partial'); + expect(text).toContain('Not published'); + }); + + it('keeps a real table with a row header, not a grid of divs', () => { + const view = render(); + const element = view.nativeElement as HTMLElement; + expect(element.querySelector('table.summary-table')).not.toBeNull(); + const rowHeader = element.querySelector('tbody th[scope="row"]'); + expect(rowHeader).not.toBeNull(); + // Flex belongs on a child of the cell. A th displayed as flex stops being a + // table cell and the row loses its accessible name. + expect(rowHeader?.querySelector('.asset-identity-box')).not.toBeNull(); + expect(element.querySelectorAll('thead th[scope="col"]').length).toBe(4); + }); + + it('gives each row a named disclosure that says what it controls', () => { + const view = render(); + const element = view.nativeElement as HTMLElement; + const button = element.querySelector('button.asset-disclose'); + expect(button).not.toBeNull(); + expect(button?.getAttribute('aria-expanded')).toBe('false'); + const controls = button?.getAttribute('aria-controls'); + expect(controls).toBeTruthy(); + expect(button?.getAttribute('aria-label')).toContain('Uncommon Goods'); + // Nothing is rendered for the region until it is opened. + expect(element.querySelector('#' + controls)).toBeNull(); + }); + + it('reveals the full identity and exact values when opened', () => { + const view = render({ + assets: [ + runesAsset({ + decimals: 0, + assetId: 'z'.repeat(60), + outputs: { quantityAtomic: '340282366920938463463374607431768211455', positionCountAtomic: '1', complete: true }, + }), + ], + }); + const element = view.nativeElement as HTMLElement; + const button = element.querySelector('button.asset-disclose') as HTMLButtonElement; + button.click(); + view.detectChanges(); + expect(button.getAttribute('aria-expanded')).toBe('true'); + const text = element.textContent as string; + // The complete id and the complete value, not the shortened stand-ins. + expect(text).toContain('z'.repeat(60)); + expect(text).toContain('340282366920938463463374607431768211455'); + expect(element.querySelectorAll('button.copy-button').length).toBeGreaterThan(0); + }); + + it('puts an effect beside its own asset and labels acceptance truthfully', () => { + const view = render({ + assets: [ + runesAsset({ + effects: [ + { eventId: 'e1', actionType: 'transfer', quantityAtomic: '1234', accepted: false, evidence: null }, + ], + }), + ], + }); + const element = view.nativeElement as HTMLElement; + (element.querySelector('button.asset-disclose') as HTMLButtonElement).click(); + view.detectChanges(); + const details = element.querySelector('.asset-details-row'); + expect(details).not.toBeNull(); + expect(details?.textContent).toContain('transfer (not accepted)'); + expect(element.textContent).not.toContain('unconfirmed'); + }); + + it('states coverage per source rather than a count of things being waited on', () => { + const view = render(); + const text = view.nativeElement.textContent as string; + expect(text).toContain('Coverage details'); + expect(text).toContain('BRC-20'); + expect(text).toContain('No source set up'); + // "Waiting on" promises an answer that will never come for a missing reader. + expect(text).not.toContain('Waiting on'); + }); + + it('says a proven empty transaction is proven, not merely unknown', () => { + const view = render({ + assets: [], + perProtocolCoverage: [ + { protocolId: 'runes', chain: 'bitcoin', network: 'signet', state: 'proven-empty', reason: 'reader-answered' }, + ], + counts: { ...summary().counts, knownCountAtomic: '0', totalCount: 0 }, + }); + const text = view.nativeElement.textContent as string; + expect(text).toContain('No supported assets'); + expect(text).toContain('reports no supported assets'); + }); + + it('says so when no source published the block it was read at', () => { + const view = render({ checkpoint: null }); + expect(view.nativeElement.textContent).toContain('No source published the block'); + }); + + it('renders a stand-in mark rather than a broken image box', () => { + const view = render(); + const element = view.nativeElement as HTMLElement; + expect(element.querySelector('img')).toBeNull(); + const fallback = element.querySelector('.asset-logo-fallback'); + expect(fallback?.textContent?.trim()).toBe('RU'); + // Decorative: the accessible name comes from the row header's text. + expect(fallback?.getAttribute('aria-hidden')).toBe('true'); + }); + + it('offers a retry on an outage and not on a proven absence', () => { + const outage = render({ + perProtocolCoverage: [ + { protocolId: 'runes', chain: 'bitcoin', network: 'signet', state: 'unavailable', reason: 'authority-unavailable' }, + ], + }); + expect(outage.nativeElement.querySelector('.summary-coverage .summary-retry')).not.toBeNull(); + + // One testing module per rendered component; the second case needs its own. + TestBed.resetTestingModule(); + const permanent = render(); + expect(permanent.nativeElement.querySelector('.summary-coverage .summary-retry')).toBeNull(); + }); +}); diff --git a/frontend/src/app/universe/transaction-assets/transaction-assets.component.ts b/frontend/src/app/universe/transaction-assets/transaction-assets.component.ts index 7b4dfec14d..4a036ee35f 100644 --- a/frontend/src/app/universe/transaction-assets/transaction-assets.component.ts +++ b/frontend/src/app/universe/transaction-assets/transaction-assets.component.ts @@ -1,13 +1,22 @@ -import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; import { BehaviorSubject, Observable, TimeoutError, catchError, combineLatest, map, of, startWith, switchMap } from 'rxjs'; import { HttpErrorResponse } from '@angular/common/http'; import { UniverseApiService } from '@app/universe/universe-api.service'; import { ExplorerProtocolDefinition } from '@app/universe/universe.types'; import { + PresentedQuantity, + initialsFor, + presentQuantity, + shortenAssetId, +} from '@app/universe/asset-summary/asset-summary.presentation'; +import { + INCONCLUSIVE_COVERAGE, SummaryAsset, + SummaryCheckpoint, SummaryCoverageState, + SummaryEffect, + SummaryProtocolCoverage, TransactionAssetSummary, - exactQuantity, summaryAssetKey, } from './transaction-assets.types'; @@ -26,6 +35,12 @@ import { * and a transaction every applicable source proves carries no supported * assets. Only the last of those may say none. * + * The display model is built once per response rather than recomputed by the + * template. A template that calls a mapping function in a binding runs it on + * every change detection pass, and two such bindings over one list produce two + * different object identities for the same row, which defeats trackBy and + * resets anything the row was holding, including which row a person had open. + * * Wording is plain on purpose. A visitor reading a transaction page is not * assumed to know what divisibility, coverage or an atomic unit is, so the * view says "smallest units", "Partial" and "Not published" instead, and the @@ -37,25 +52,18 @@ export type SummaryStateKind = | 'summary' | 'not-found' | 'unconfigured' + | 'unavailable' | 'timeout' | 'error'; export interface SummaryViewState { kind: SummaryStateKind; summary?: TransactionAssetSummary; - /** Registry definitions by protocol id, for official names and colours. */ - protocols?: Map; -} - -/** One amount as the view renders it: exact where possible, labelled otherwise. */ -export interface DisplayQuantity { - /** 'exact' carries a decimal value; 'raw' carries smallest-unit digits. */ - kind: 'exact' | 'raw' | 'none'; - value: string; - /** True when the side the value came from was only partly proven. */ - partial: boolean; + /** Everything the template renders, computed once when the response arrives. */ + model?: SummaryViewModel; } +/** One asset row, with its identity, amounts and disclosure content. */ export interface DisplayAsset { key: string; asset: SummaryAsset; @@ -63,26 +71,66 @@ export interface DisplayAsset { protocol: ExplorerProtocolDefinition; /** The heading shown for the asset: its name, ticker, or id. */ title: string; - /** The id, only when it is not already the title. Empty otherwise. */ + /** The shortened id, only when it is not already the title. Empty otherwise. */ subtitle: string; + /** The complete id, for the disclosure and for copying. */ + assetId: string; /** Plain-language asset kind, e.g. "Token". Empty when it adds nothing. */ kindLabel: string; /** One or two letters standing in for a missing logo. */ initials: string; logoFailed: boolean; - inputs: DisplayQuantity; - outputs: DisplayQuantity; + inputs: PresentedQuantity; + outputs: PresentedQuantity; + effects: DisplayEffect[]; + /** True when anything in the disclosure is worth opening it for. */ + hasDetails: boolean; + /** The id of the row's disclosure region, for aria-controls. */ + detailsId: string; } -/** Coverage states that leave the inventory inconclusive. */ -const INCONCLUSIVE: ReadonlySet = new Set([ - 'partial', - 'candidate-only', - 'unsupported-network', - 'unconfigured', - 'unavailable', - 'not-publicly-observable', -]); +/** One protocol effect, with the acceptance wording already decided. */ +export interface DisplayEffect { + effect: SummaryEffect; + /** The plain label: the action, and whether the authority accepted it. */ + label: string; + accepted: boolean; + quantity: PresentedQuantity; + /** The authority that stated it, or an empty string when none did. */ + authorityId: string; +} + +/** One protocol's coverage, as the details list renders it. */ +export interface CoverageRow { + protocolId: string; + name: string; + state: SummaryCoverageState; + /** Plain-language state, e.g. "Answered" or "Source not reachable". */ + stateLabel: string; + /** Plain-language reason, or an empty string when the payload gave none. */ + reasonLabel: string; + /** True when the gap is temporary, so retrying could change it. */ + retryable: boolean; + /** True when the gap leaves the inventory inconclusive. */ + inconclusive: boolean; +} + +export interface SummaryViewModel { + rows: DisplayAsset[]; + coverage: CoverageRow[]; + /** The single overall badge: proven, partial, or none found. */ + chip: { state: string; label: string }; + countLabel: string; + /** The short context line under the heading. */ + contextLabel: string; + incomplete: boolean; + provenEmpty: boolean; + /** Coverage rows that leave the inventory inconclusive. */ + gaps: CoverageRow[]; + /** True when at least one gap could clear on a retry. */ + retryable: boolean; + checkpoint: SummaryCheckpoint | null; +} /** * Plain words for the authority's asset classification. @@ -101,6 +149,55 @@ const KIND_LABELS: Readonly> = { 'protocol-event': 'Event', }; +/** + * Plain words for each coverage state. + * + * A permanent limit and a temporary outage read differently on purpose. Saying + * "Waiting on" for a protocol nobody has a reader for promises an answer that + * will never arrive, and a visitor who waits for it is being misled. + */ +const COVERAGE_STATE_LABELS: Readonly> = { + complete: 'Answered', + 'proven-empty': 'Answered, none held', + partial: 'Answered in part', + 'candidate-only': 'Unconfirmed only', + 'unsupported-network': 'Not available on this network', + unconfigured: 'No source set up', + unavailable: 'Source not reachable', + 'not-publicly-observable': 'Not publicly verifiable', +}; + +/** + * Plain words for the machine-readable reasons the backend contract defines. + * + * An unknown reason maps to an empty string. Printing a raw token a visitor + * cannot read is worse than printing nothing, and the payload still carries it + * for anyone reading the API. + */ +const COVERAGE_REASON_LABELS: Readonly> = { + 'no-transaction-reader': 'No source reads this protocol per transaction.', + 'authority-unconfigured': 'This source is not set up on this deployment.', + 'authority-unavailable': 'This source did not answer.', + 'unsupported-network': 'This protocol does not run on this network.', + 'outpoint-inventory-not-retained': + 'The source no longer keeps the records for some of these outputs.', + 'public-proof-not-observable': + 'This protocol keeps its records private, so holdings cannot be verified publicly.', + 'reader-answered': '', +}; + +/** + * The states a retry could plausibly change. + * + * A missing reader and an unsupported network will not change because somebody + * pressed a button, so offering a retry for them is a false promise. + */ +const RETRYABLE_STATES: ReadonlySet = new Set([ + 'unavailable', + 'partial', + 'candidate-only', +]); + @Component({ selector: 'app-universe-transaction-assets', templateUrl: './transaction-assets.component.html', @@ -108,7 +205,7 @@ const KIND_LABELS: Readonly> = { changeDetection: ChangeDetectionStrategy.OnPush, standalone: false, }) -export class TransactionAssetsComponent implements OnChanges { +export class TransactionAssetsComponent implements OnChanges, OnDestroy { @Input() txid: string; @Input() chain = 'bitcoin'; /** @@ -120,8 +217,16 @@ export class TransactionAssetsComponent implements OnChanges { state$: Observable; + /** Which rows a person has opened, keyed by full asset identity. */ + readonly expanded = new Set(); + /** What the last copy attempt did, keyed by what was copied. */ + readonly copyResults = new Map(); + /** True while an explicit retry is on cooldown, per the payload's request. */ + retryCooldown = false; + private readonly retry$ = new BehaviorSubject(0); private readonly logoFailures = new Set(); + private cooldownTimer: ReturnType | null = null; constructor(private universeApiService: UniverseApiService) {} @@ -132,8 +237,11 @@ export class TransactionAssetsComponent implements OnChanges { if (changes.txid || changes.chain) { // A new transaction or chain clears everything the previous one proved, // immediately, before the next answer arrives. Stale rows from another - // transaction are worse than no rows. + // transaction are worse than no rows, and a row left open from the + // previous transaction would show another transaction's details. this.logoFailures.clear(); + this.expanded.clear(); + this.copyResults.clear(); } if (!this.txid) { this.state$ = of({ kind: 'loading' as const }); @@ -144,30 +252,58 @@ export class TransactionAssetsComponent implements OnChanges { this.state$ = this.retry$.pipe( switchMap(() => combineLatest([ this.universeApiService.getTransactionAssets$(txid, chain), - // Registry names and colours are presentation only. If the registry is - // unavailable the summary still renders, with ids standing in for the - // official names; metadata never gates a proven amount. + // Registry names and colours are presentation only, so the stream is + // seeded with an empty map. combineLatest emits nothing until every + // source has emitted once, which without the seed makes an optional + // name lookup a gate on every proven amount on the page. this.universeApiService.getProtocols$().pipe( map((response) => new Map(response.protocols.map((entry) => [entry.id, entry]))), catchError(() => of(new Map())), + startWith(new Map()), ), ]).pipe( - map(([summary, protocols]): SummaryViewState => ({ kind: 'summary', summary, protocols })), + map(([summary, protocols]): SummaryViewState => ({ + kind: 'summary', + summary, + model: this.buildModel(summary, protocols), + })), catchError((error: unknown) => of({ kind: this.classify(error) })), startWith({ kind: 'loading' }), )), ); } - /** Retry is explicit and bounded: one press, one fresh request. */ - retry(): void { + /** + * Retry is explicit and bounded: one press, one fresh request, and a cooldown + * the payload asked for. Nothing here polls, and no render triggers a refetch. + */ + retry(retryAfterSeconds: number | null = null): void { + if (this.retryCooldown) {return;} this.retry$.next(this.retry$.value + 1); + const seconds = typeof retryAfterSeconds === 'number' && retryAfterSeconds > 0 + ? Math.min(retryAfterSeconds, 60) + : 0; + if (seconds === 0) {return;} + this.retryCooldown = true; + if (this.cooldownTimer !== null) {clearTimeout(this.cooldownTimer);} + this.cooldownTimer = setTimeout(() => { + this.retryCooldown = false; + this.cooldownTimer = null; + }, seconds * 1000); + } + + ngOnDestroy(): void { + if (this.cooldownTimer !== null) {clearTimeout(this.cooldownTimer);} } /** - * Distinguishes the failure kinds a visitor needs told apart. A timeout, an - * unconfigured authority and a proven-absent transaction are three different - * facts, and none of them is an empty asset list. + * Distinguishes the failure kinds a visitor needs told apart. + * + * The statuses come from what the API actually returns: the summary route + * answers 404 only when the base authority proved the transaction absent, and + * 503 when the deployment has no source configured. A 502 or 504 is an + * upstream outage, which is a different fact from "not set up", and anything + * else stays generic rather than being guessed into a specific cause. */ private classify(error: unknown): SummaryStateKind { if (error instanceof TimeoutError) {return 'timeout';} @@ -175,42 +311,114 @@ export class TransactionAssetsComponent implements OnChanges { if (response && typeof response.status === 'number') { if (response.status === 404) {return 'not-found';} if (response.status === 503) {return 'unconfigured';} + if (response.status === 502 || response.status === 504) {return 'unavailable';} } return 'error'; } /** - * The rows to render, keyed by full identity. + * Everything the template needs, built once from one response. * - * Two protocols may legitimately use the same asset id and two rulesets may - * read the same ledger; keying by assetId alone would merge them into one - * row and silently drop a proven asset. + * Keyed by full identity throughout. Two protocols may legitimately use the + * same asset id and two rulesets may read the same ledger; keying by assetId + * alone would merge them into one row and silently drop a proven asset. */ - rows(state: SummaryViewState): DisplayAsset[] { - const summary = state.summary; - if (!summary) {return [];} - return summary.assets.map((asset) => { - const key = summaryAssetKey(asset); - const protocol = this.protocolFor(state, asset.protocolId); - const title = asset.displayName || asset.ticker || asset.assetId; - return { - key, - asset, - protocol, - title, - subtitle: title === asset.assetId ? '' : asset.assetId, - kindLabel: KIND_LABELS[asset.assetKind] ?? '', - initials: this.initialsFor(protocol, title), - logoFailed: this.logoFailures.has(key), - inputs: this.quantity(asset, asset.inputs.quantityAtomic, asset.inputs.complete), - outputs: this.quantity(asset, asset.outputs.quantityAtomic, asset.outputs.complete), - }; - }); + private buildModel( + summary: TransactionAssetSummary, + protocols: Map, + ): SummaryViewModel { + const rows = summary.assets.map((asset) => this.buildRow(asset, protocols)); + const coverage = summary.perProtocolCoverage.map((entry) => + this.buildCoverage(entry, protocols), + ); + const gaps = coverage.filter((entry) => entry.inconclusive); + const provenEmpty = summary.counts.totalCount === 0 && summary.assets.length === 0; + const incomplete = summary.counts.totalCount === null; + return { + rows, + coverage, + gaps, + retryable: gaps.some((entry) => entry.retryable), + provenEmpty, + incomplete, + chip: provenEmpty + ? { state: 'state-proven', label: 'None found' } + : incomplete + ? { state: 'state-partial', label: 'Partial' } + : { state: 'state-proven', label: 'Complete' }, + countLabel: this.countLabel(summary, provenEmpty, incomplete), + contextLabel: this.contextLabel(summary, rows), + checkpoint: summary.checkpoint, + }; + } + + private buildRow( + asset: SummaryAsset, + protocols: Map, + ): DisplayAsset { + const key = summaryAssetKey(asset); + const protocol = this.protocolFor(protocols, asset.protocolId); + const title = asset.displayName || asset.ticker || asset.assetId; + const inputs = presentQuantity(asset.inputs.quantityAtomic, asset.decimals, asset.inputs.complete); + const outputs = presentQuantity(asset.outputs.quantityAtomic, asset.decimals, asset.outputs.complete); + const effects = asset.effects.map((effect) => this.buildEffect(effect, asset)); + return { + key, + asset, + protocol, + title, + subtitle: title === asset.assetId ? '' : shortenAssetId(asset.assetId, 8), + assetId: asset.assetId, + kindLabel: KIND_LABELS[asset.assetKind] ?? '', + initials: initialsFor(protocol.shortName, title), + // The failure is keyed by the artwork, not only by the asset: a replaced + // logo is a different image and deserves its own attempt rather than + // inheriting the previous revision's failure forever. + logoFailed: this.logoFailures.has(this.logoKey(key, asset)), + inputs, + outputs, + effects, + hasDetails: true, + detailsId: 'asset-details-' + hashKey(key), + }; + } + + private buildEffect(effect: SummaryEffect, asset: SummaryAsset): DisplayEffect { + const action = effect.actionType.replace(/[_-]+/g, ' '); + return { + effect, + accepted: effect.accepted, + // "Not accepted" covers a rejected record and one no authority has ruled + // on. Narrowing it to "unconfirmed" would tell a reader that time alone + // will settle it, which for a rejected transfer is false. + label: effect.accepted ? action : action + ' (not accepted)', + quantity: presentQuantity(effect.quantityAtomic, asset.decimals), + authorityId: effect.evidence?.authorityId ?? '', + }; + } + + private buildCoverage( + entry: SummaryProtocolCoverage, + protocols: Map, + ): CoverageRow { + const reason = entry.reason ?? ''; + return { + protocolId: entry.protocolId, + name: this.protocolFor(protocols, entry.protocolId).displayName, + state: entry.state, + stateLabel: COVERAGE_STATE_LABELS[entry.state] ?? entry.state, + reasonLabel: COVERAGE_REASON_LABELS[reason] ?? '', + retryable: RETRYABLE_STATES.has(entry.state), + inconclusive: INCONCLUSIVE_COVERAGE.has(entry.state), + }; } /** The registry entry, or a placeholder so the badge still names something. */ - private protocolFor(state: SummaryViewState, protocolId: string): ExplorerProtocolDefinition { - const known = state.protocols?.get(protocolId); + private protocolFor( + protocols: Map, + protocolId: string, + ): ExplorerProtocolDefinition { + const known = protocols.get(protocolId); if (known) {return known;} const readable = protocolId.replace(/[_-]+/g, ' '); return { @@ -221,123 +429,98 @@ export class TransactionAssetsComponent implements OnChanges { } as ExplorerProtocolDefinition; } - /** - * One or two letters standing in for a missing logo. - * - * A labelled placeholder in the protocol's own hue reads as a deliberate - * stand-in. An empty grey square reads as an image that failed to load, and - * neither may be mistaken for a verified token logo. - */ - private initialsFor(protocol: ExplorerProtocolDefinition, title: string): string { - const source = (protocol.shortName || title || '?').trim(); - const words = source.split(/[\s._-]+/).filter(Boolean); - const letters = words.length > 1 - ? words[0].charAt(0) + words[1].charAt(0) - : source.slice(0, 2); - return letters.toUpperCase(); + /** The count sentence beside the heading. Plural forms are spelled out. */ + private countLabel( + summary: TransactionAssetSummary, + provenEmpty: boolean, + incomplete: boolean, + ): string { + if (provenEmpty) { + return 'No supported assets'; + } + const found = summary.assets.length; + const noun = found === 1 ? 'asset' : 'assets'; + return incomplete ? `${found} ${noun} found so far` : `${found} ${noun}`; } /** - * One displayable amount. + * The short context line: how many protocols the found assets span. * - * An exact decimal needs the authority's divisibility. Without it the digits - * are still true, so they are shown labelled as smallest units rather than - * as whole tokens, which would understate the amount by up to thirty eight - * orders of magnitude. + * It describes what was found, never a total. A count of protocols on the + * roster would read as an inventory of the transaction, which it is not. */ - private quantity(asset: SummaryAsset, quantityAtomic: string | null, complete: boolean): DisplayQuantity { - if (quantityAtomic === null) { - return { kind: 'none', value: '', partial: !complete }; - } - const exact = exactQuantity(quantityAtomic, asset.decimals); - return exact === null - ? { kind: 'raw', value: quantityAtomic, partial: !complete } - : { kind: 'exact', value: exact, partial: !complete }; + private contextLabel(summary: TransactionAssetSummary, rows: DisplayAsset[]): string { + if (rows.length === 0) {return '';} + const protocols = new Set(rows.map((row) => row.asset.protocolId)).size; + return protocols === 1 ? '1 protocol' : `${protocols} protocols`; } - /** One deterministic fallback per identity. Never a request loop. */ - onLogoError(key: string): void { - this.logoFailures.add(key); + private logoKey(key: string, asset: SummaryAsset): string { + const revision = asset.logo ? asset.logo.contentHash + '/' + asset.logo.metadataRevision : ''; + return key + '' + revision; } - trackRow(_index: number, row: DisplayAsset): string { - return row.key; + /** One deterministic fallback per artwork revision. Never a request loop. */ + onLogoError(row: DisplayAsset): void { + this.logoFailures.add(this.logoKey(row.key, row.asset)); + row.logoFailed = true; } - /** How many sources could not answer. Drives the partial notice. */ - gapCount(summary: TransactionAssetSummary): number { - return summary.perProtocolCoverage.filter((entry) => INCONCLUSIVE.has(entry.state)).length; + toggle(row: DisplayAsset): void { + if (this.expanded.has(row.key)) {this.expanded.delete(row.key);} + else {this.expanded.add(row.key);} } - /** The official names of those sources, for the expandable detail. */ - gapNames(state: SummaryViewState): string[] { - const summary = state.summary; - if (!summary) {return [];} - return summary.perProtocolCoverage - .filter((entry) => INCONCLUSIVE.has(entry.state)) - .map((entry) => this.protocolFor(state, entry.protocolId).displayName) - .sort((a, b) => a.localeCompare(b)); + isExpanded(row: DisplayAsset): boolean { + return this.expanded.has(row.key); } /** - * True only when every applicable source proved absence. Any other state - * means the transaction is not known to be empty, merely not known. + * Copies the exact value and reports what actually happened. + * + * Clipboard access can be denied, and a button that shows "Copied" when + * nothing was copied is worse than one that shows nothing at all: a person + * pastes and loses the value they were trying to keep. */ - provenEmpty(summary: TransactionAssetSummary): boolean { - return summary.counts.totalCount === 0 && summary.assets.length === 0; + copy(what: string, value: string): void { + const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard; + if (!clipboard || typeof clipboard.writeText !== 'function') { + this.copyResults.set(what, 'failed'); + return; + } + clipboard.writeText(value).then( + () => this.copyResults.set(what, 'copied'), + () => this.copyResults.set(what, 'failed'), + ); } - incomplete(summary: TransactionAssetSummary): boolean { - return summary.counts.totalCount === null; + copyResult(what: string): 'copied' | 'failed' | null { + return this.copyResults.get(what) ?? null; } - /** The evidence chip for the whole summary: proven, partial, or none found. */ - summaryChip(summary: TransactionAssetSummary): { state: string; label: string } { - if (this.provenEmpty(summary)) { - return { state: 'state-proven', label: 'None found' }; - } - return this.incomplete(summary) - ? { state: 'state-partial', label: 'Partial' } - : { state: 'state-proven', label: 'Complete' }; + trackRow(_index: number, row: DisplayAsset): string { + return row.key; } - /** The count sentence beside the heading. Plural forms are spelled out. */ - countLabel(summary: TransactionAssetSummary): string { - if (this.provenEmpty(summary)) { - return 'No supported assets'; - } - const found = summary.assets.length; - const noun = found === 1 ? 'asset' : 'assets'; - return this.incomplete(summary) - ? `${found} ${noun} found so far` - : `${found} ${noun}`; + trackCoverage(_index: number, row: CoverageRow): string { + return row.protocolId; + } + + trackEffect(_index: number, row: DisplayEffect): string { + return row.effect.eventId; } } -/* IMPLEMENTATION-HANDOFF [UI-WP02:TX-VIEW] 2026-09-19 - * Coverage C09-C21; defects F06/F07/F08. Preparation only. - * Verified: ngOnChanges combineLatest waits for the registry's first value; - * rows() is mapped twice by the template; gapNames discards state/reason; - * classify equates every 503 with configuration and every 404 with absence. - * Sources: R03 RxJS combineLatest; R05 summary-v1; R02 Angular 20 accessibility. - * 1. After UI-WP01, produce a stable display model once per response. Seed the - * registry stream with an empty Map so optional names never delay amounts. - * Keep switchMap cancellation and context validation across route/network. - * 2. Add a pure shared quantity presenter (PROPOSED NEW asset-summary/ - * asset-summary.presentation.ts): exact decimal strings plus raw-unit state, - * explicit approximation for long headlines, full exact strings for copy. - * Never copy the abbreviation, round with floats, or sum unlike assets. - * 3. Build coverage rows with protocol, state, reason, retry eligibility. Show - * unsupported/unconfigured/private-history limits as limits, not Waiting. - * 4. Replace false=>unconfirmed with Not accepted unless evidence proves a - * narrower status. Preserve accepted effects separately from input/output. - * 5. Distinguish proven base-tx absence from a missing API route, and temporary - * outage from configuration using observed API error codes; keep unknown - * errors generic. Honor retryAfterSeconds; one explicit retry, no polling. - * 6. Localize derived labels; key row expansion by full identity and clear on - * context change. Key failed images by identity plus contentHash/revision. - * Tests: transaction-assets.component.spec.ts; npm test -- - * src/app/universe/transaction-assets (NOT RUN). Test delayed registry, stale - * responses, retry cooldown, accepted:false, all eight coverage states, logo - * recovery, null/zero and focus after refresh. Dependencies UI-WP01/UI-WP07. +/** + * A short, stable, DOM-safe id from an identity key. + * + * An identity key is a JSON tuple and contains quotes and brackets, which a DOM + * id and an aria-controls reference cannot carry. */ +function hashKey(key: string): string { + let hash = 0; + for (let index = 0; index < key.length; index += 1) { + hash = (hash * 31 + key.charCodeAt(index)) | 0; + } + return (hash >>> 0).toString(36); +} diff --git a/frontend/src/app/universe/transaction-assets/transaction-assets.types.spec.ts b/frontend/src/app/universe/transaction-assets/transaction-assets.types.spec.ts index 1a613b9491..13f092b339 100644 --- a/frontend/src/app/universe/transaction-assets/transaction-assets.types.spec.ts +++ b/frontend/src/app/universe/transaction-assets/transaction-assets.types.spec.ts @@ -225,3 +225,272 @@ describe('exactQuantity', () => { expect(exactQuantity(null, 8)).toBeNull(); }); }); + +describe('decoder: coverage and totals must agree', () => { + it('refuses a coverage state the contract does not define', () => { + // A cast would render whichever template branch a value falls through to, + // and the fall-through branch is the reassuring one. + expect(() => decodeTransactionAssetSummary( + payload({ + perProtocolCoverage: [ + { protocolId: 'runes', chain: 'bitcoin', network: 'signet', state: 'probably-fine' }, + ], + }), + CONTEXT, + )).toThrow(SummaryDecodeError); + }); + + it('refuses two coverage rows for one protocol', () => { + expect(() => decodeTransactionAssetSummary( + payload({ + perProtocolCoverage: [ + { protocolId: 'runes', chain: 'bitcoin', network: 'signet', state: 'complete' }, + { protocolId: 'runes', chain: 'bitcoin', network: 'signet', state: 'unavailable' }, + ], + }), + CONTEXT, + )).toThrow(SummaryDecodeError); + }); + + it('refuses a coverage row from another context', () => { + expect(() => decodeTransactionAssetSummary( + payload({ + perProtocolCoverage: [ + { protocolId: 'runes', chain: 'bitcoin', network: 'mainnet', state: 'complete' }, + ], + }), + CONTEXT, + )).toThrow(SummaryDecodeError); + }); + + it('refuses a stated total while a source is inconclusive', () => { + // The defect this closes: a total claims nothing is missing, so it cannot + // stand beside a protocol nobody read. + expect(() => decodeTransactionAssetSummary( + payload({ + perProtocolCoverage: [ + { protocolId: 'runes', chain: 'bitcoin', network: 'signet', state: 'complete' }, + { protocolId: 'brc20', chain: 'bitcoin', network: 'signet', state: 'unconfigured' }, + ], + counts: { ...(payload().counts as Record), totalCount: 1 }, + }), + CONTEXT, + )).toThrow(SummaryDecodeError); + }); + + it('refuses a stated total that disagrees with the identities listed', () => { + expect(() => decodeTransactionAssetSummary( + payload({ + counts: { ...(payload().counts as Record), totalCount: 7 }, + }), + CONTEXT, + )).toThrow(SummaryDecodeError); + }); + + it('refuses a stated total with no coverage at all', () => { + expect(() => decodeTransactionAssetSummary( + payload({ + perProtocolCoverage: [], + counts: { ...(payload().counts as Record), totalCount: 1 }, + }), + CONTEXT, + )).toThrow(SummaryDecodeError); + }); + + it('accepts a stated total that every source accounts for', () => { + const decoded = decodeTransactionAssetSummary( + payload({ + counts: { ...(payload().counts as Record), totalCount: 1 }, + }), + CONTEXT, + ); + expect(decoded.counts.totalCount).toBe(1); + expect(coverageIncomplete(decoded)).toBe(false); + }); + + it('refuses a missing assets array rather than rendering an empty inventory', () => { + // The most misleading thing this component can do is say "no supported + // assets" because a field was absent. + const broken = payload(); + delete broken.assets; + expect(() => decodeTransactionAssetSummary(broken, CONTEXT)).toThrow(SummaryDecodeError); + }); + + it('refuses a missing coverage array', () => { + const broken = payload(); + delete broken.perProtocolCoverage; + expect(() => decodeTransactionAssetSummary(broken, CONTEXT)).toThrow(SummaryDecodeError); + }); + + it('refuses a non-array effects list on an asset', () => { + expect(() => decodeTransactionAssetSummary( + payload({ assets: [assetRow({ effects: 'none' })] }), + CONTEXT, + )).toThrow(SummaryDecodeError); + }); +}); + +describe('decoder: evidence and checkpoints survive', () => { + const evidence = { + authorityId: 'ord', + protocolId: 'runes', + coverage: 'complete', + checkedAt: '2026-09-19T00:00:00.000Z', + checkpoint: { + chain: 'bitcoin', + network: 'signet', + heightAtomic: '240000', + blockHash: HASH, + reorgEpoch: '0', + observedAt: '2026-09-19T00:00:00.000Z', + }, + }; + + it('keeps an effect\u2019s evidence rather than dropping it', () => { + const decoded = decodeTransactionAssetSummary( + payload({ + assets: [ + assetRow({ + effects: [ + { eventId: 'e1', actionType: 'transfer', quantityAtomic: '1234', accepted: false, evidence }, + ], + }), + ], + }), + CONTEXT, + ); + const [effect] = decoded.assets[0].effects; + // Without this, "not accepted" cannot be told apart from "not yet ruled on". + expect(effect.accepted).toBe(false); + expect(effect.evidence?.authorityId).toBe('ord'); + expect(effect.evidence?.checkpoint?.heightAtomic).toBe('240000'); + }); + + it('keeps the asset\u2019s own evidence list', () => { + const decoded = decodeTransactionAssetSummary( + payload({ assets: [assetRow({ evidence: [evidence] })] }), + CONTEXT, + ); + expect(decoded.assets[0].evidence).toHaveLength(1); + }); + + it('drops malformed evidence without discarding the proven quantity', () => { + const decoded = decodeTransactionAssetSummary( + payload({ assets: [assetRow({ evidence: [{ nonsense: true }] })] }), + CONTEXT, + ); + expect(decoded.assets[0].evidence).toHaveLength(0); + expect(decoded.assets[0].outputs.quantityAtomic).toBe('1234'); + }); + + it('keeps a complete checkpoint and refuses a partial one', () => { + const complete = decodeTransactionAssetSummary( + payload({ checkpoint: evidence.checkpoint }), + CONTEXT, + ); + expect(complete.checkpoint?.heightAtomic).toBe('240000'); + + // Absent is null. A zero height beside a real block hash would say the + // reading was taken at genesis, which is false for all but one block. + const partial = decodeTransactionAssetSummary( + payload({ checkpoint: { blockHash: HASH } }), + CONTEXT, + ); + expect(partial.checkpoint).toBeNull(); + + const absent = decodeTransactionAssetSummary(payload(), CONTEXT); + expect(absent.checkpoint).toBeNull(); + }); + + it('keeps an accepted effect and a rejected one distinguishable', () => { + const decoded = decodeTransactionAssetSummary( + payload({ + assets: [ + assetRow({ + effects: [ + { eventId: 'a', actionType: 'transfer', quantityAtomic: '1', accepted: true }, + { eventId: 'b', actionType: 'transfer', quantityAtomic: '1', accepted: 'yes' }, + { eventId: 'c', actionType: 'transfer', quantityAtomic: '1' }, + ], + }), + ], + }), + CONTEXT, + ); + // Only an explicit true is acceptance: a truthy string is not the authority + // saying yes. + expect(decoded.assets[0].effects.map((effect) => effect.accepted)) + .toEqual([true, false, false]); + }); +}); + +describe('decoder: quantity boundaries', () => { + it('keeps a zero quantity and an unstated one apart', () => { + const decoded = decodeTransactionAssetSummary( + payload({ + assets: [ + assetRow({ + inputs: { quantityAtomic: '0', positionCountAtomic: '0', complete: true }, + outputs: { quantityAtomic: null, positionCountAtomic: '1', complete: false }, + }), + ], + }), + CONTEXT, + ); + expect(decoded.assets[0].inputs.quantityAtomic).toBe('0'); + expect(decoded.assets[0].outputs.quantityAtomic).toBeNull(); + }); + + it('accepts the divisibility boundaries and refuses what is past them', () => { + for (const decimals of [0, 38]) { + const decoded = decodeTransactionAssetSummary( + payload({ assets: [assetRow({ decimals })] }), + CONTEXT, + ); + expect(decoded.assets[0].decimals).toBe(decimals); + } + for (const decimals of [-1, 39, 1.5]) { + expect(() => decodeTransactionAssetSummary( + payload({ assets: [assetRow({ decimals })] }), + CONTEXT, + )).toThrow(SummaryDecodeError); + } + }); + + it('keeps a u128 maximum exact', () => { + const max = '340282366920938463463374607431768211455'; + const decoded = decodeTransactionAssetSummary( + payload({ + assets: [ + assetRow({ + outputs: { quantityAtomic: max, positionCountAtomic: '1', complete: true }, + }), + ], + }), + CONTEXT, + ); + expect(decoded.assets[0].outputs.quantityAtomic).toBe(max); + }); + + it('refuses a logo the payload has not marked verified', () => { + const decoded = decodeTransactionAssetSummary( + payload({ + assets: [ + assetRow({ + logo: { + objectPath: '/universe-media/v1/objects/' + HASH, + contentHash: HASH, + mediaType: 'image/png', + metadataRevision: 'rev-1', + verified: false, + }, + }), + ], + }), + CONTEXT, + ); + // A labelled fallback, and the amount is untouched. + expect(decoded.assets[0].logo).toBeNull(); + expect(decoded.assets[0].outputs.quantityAtomic).toBe('1234'); + }); +}); diff --git a/frontend/src/app/universe/transaction-assets/transaction-assets.types.ts b/frontend/src/app/universe/transaction-assets/transaction-assets.types.ts index c8ab2863f8..b4566be8e9 100644 --- a/frontend/src/app/universe/transaction-assets/transaction-assets.types.ts +++ b/frontend/src/app/universe/transaction-assets/transaction-assets.types.ts @@ -13,18 +13,42 @@ * but malformed required data is an error rather than something to render. */ +import { exactDecimal } from '@app/universe/asset-summary/asset-summary.presentation'; + export const EXPLORER_TX_ASSET_SUMMARY_SCHEMA_VERSION = 'universe-transaction-asset-summary-v1'; -export type SummaryCoverageState = - | 'complete' - | 'partial' - | 'proven-empty' - | 'candidate-only' - | 'unsupported-network' - | 'unconfigured' - | 'unavailable' - | 'not-publicly-observable'; +/** + * The coverage states, as a value rather than only as a type. + * + * A type annotation on an untrusted payload is a claim, not a check. The + * decoder validates against this set, because an unrecognised state cast to the + * union renders as whichever branch the template falls through to, and the + * fall-through branch is usually the reassuring one. + */ +export const SUMMARY_COVERAGE_STATES = [ + 'complete', + 'partial', + 'proven-empty', + 'candidate-only', + 'unsupported-network', + 'unconfigured', + 'unavailable', + 'not-publicly-observable', +] as const; + +export type SummaryCoverageState = (typeof SUMMARY_COVERAGE_STATES)[number]; + +/** Coverage states under which the inventory is not conclusive. */ +export const INCONCLUSIVE_COVERAGE: ReadonlySet = + new Set([ + 'partial', + 'candidate-only', + 'unsupported-network', + 'unconfigured', + 'unavailable', + 'not-publicly-observable', + ]); export interface SummaryProtocolCoverage { protocolId: string; @@ -40,11 +64,45 @@ export interface SummaryQuantitySide { complete: boolean; } +/** + * What one authority states it observed, and when. + * + * Preserved rather than dropped because it is the difference between "this + * authority rejected the transfer" and "no authority has ruled on it yet". A + * view that has only `accepted: false` cannot tell a reader which it is. + */ +export interface SummaryEvidence { + authorityId: string; + protocolId: string | null; + coverage: string; + checkedAt: string | null; + checkpoint: SummaryCheckpoint | null; +} + export interface SummaryEffect { eventId: string; actionType: string; quantityAtomic: string | null; accepted: boolean; + /** The authority's own record for this effect, or null when it sent none. */ + evidence: SummaryEvidence | null; +} + +/** + * The block a reading was taken at. + * + * Every field is required because a partial checkpoint is not a checkpoint: a + * height of zero beside a real block hash is a statement that the reading was + * taken at genesis. Absent is represented by a null checkpoint, never by a + * checkpoint full of zeroes. + */ +export interface SummaryCheckpoint { + chain: string; + network: string; + heightAtomic: string; + blockHash: string; + reorgEpoch: string; + observedAt: string | null; } export interface SummaryLogo { @@ -71,6 +129,8 @@ export interface SummaryAsset { inputs: SummaryQuantitySide; outputs: SummaryQuantitySide; effects: SummaryEffect[]; + /** Every authority that contributed a position for this identity. */ + evidence: SummaryEvidence[]; } export interface SummaryCounts { @@ -92,6 +152,8 @@ export interface TransactionAssetSummary { perProtocolCoverage: SummaryProtocolCoverage[]; counts: SummaryCounts; retryAfterSeconds: number | null; + /** The reading's checkpoint, or null when no authority stated a complete one. */ + checkpoint: SummaryCheckpoint | null; } const ATOMIC_INTEGER = /^(0|[1-9][0-9]*)$/; @@ -197,10 +259,77 @@ function logo(value: unknown): SummaryLogo | null { }; } +/** + * A complete checkpoint, or null. + * + * Partial is null. Accepting a checkpoint with a missing height would let the + * view print "block " followed by nothing, and accepting a zero height beside a + * real block hash would state that the reading was taken at genesis. + */ +function checkpoint(value: unknown): SummaryCheckpoint | null { + if (value === null || value === undefined) {return null;} + let row: Record; + try { + row = record(value, 'checkpoint'); + } catch { + return null; + } + const heightAtomic = row.heightAtomic; + const blockHash = row.blockHash; + const reorgEpoch = row.reorgEpoch; + if (typeof heightAtomic !== 'string' || !ATOMIC_INTEGER.test(heightAtomic)) {return null;} + if (typeof blockHash !== 'string' || !CONTENT_HASH.test(blockHash)) {return null;} + if (typeof reorgEpoch !== 'string' || !ATOMIC_INTEGER.test(reorgEpoch)) {return null;} + if (typeof row.chain !== 'string' || !row.chain) {return null;} + if (typeof row.network !== 'string' || !row.network) {return null;} + return { + chain: row.chain, + network: row.network, + heightAtomic, + blockHash, + reorgEpoch, + observedAt: typeof row.observedAt === 'string' ? row.observedAt : null, + }; +} + +/** + * One authority's record, or null when the payload carries none. + * + * Malformed evidence becomes null rather than an error: evidence enriches the + * explanation of a state the payload already states elsewhere, so losing it must + * not discard a proven quantity. + */ +function evidence(value: unknown): SummaryEvidence | null { + if (value === null || value === undefined) {return null;} + let row: Record; + try { + row = record(value, 'evidence'); + } catch { + return null; + } + if (typeof row.authorityId !== 'string' || !row.authorityId) {return null;} + return { + authorityId: row.authorityId, + protocolId: typeof row.protocolId === 'string' ? row.protocolId : null, + coverage: typeof row.coverage === 'string' ? row.coverage : 'unknown', + checkedAt: typeof row.checkedAt === 'string' ? row.checkedAt : null, + checkpoint: checkpoint(row.checkpoint), + }; +} + function asset(value: unknown, index: number): SummaryAsset { const what = 'assets[' + index + ']'; const row = record(value, what); + // An absent effects list and an empty one mean the same thing, but a present + // non-array is a malformed payload rather than an empty one. + if (row.effects !== undefined && row.effects !== null && !Array.isArray(row.effects)) { + throw new SummaryDecodeError(what + '.effects is not an array'); + } const effects = Array.isArray(row.effects) ? row.effects : []; + if (row.evidence !== undefined && row.evidence !== null && !Array.isArray(row.evidence)) { + throw new SummaryDecodeError(what + '.evidence is not an array'); + } + const assetEvidence = Array.isArray(row.evidence) ? row.evidence : []; return { chain: text(row.chain, what + '.chain'), network: text(row.network, what + '.network'), @@ -220,13 +349,20 @@ function asset(value: unknown, index: number): SummaryAsset { eventId: text(effect.eventId, what + '.effects.eventId'), actionType: text(effect.actionType, what + '.effects.actionType'), quantityAtomic: atomic(effect.quantityAtomic, what + '.effects.quantityAtomic'), + // Only an explicit true is acceptance. Anything else covers both a + // rejected record and one no authority has ruled on, which the view + // must not narrow to "unconfirmed" without evidence that says so. accepted: effect.accepted === true, + evidence: evidence(effect.evidence), }; }), + evidence: assetEvidence + .map(evidence) + .filter((entry): entry is SummaryEvidence => entry !== null), }; } -/** The canonical identity key. Never a ticker, a name or a txid. */ +/** The identity key in its one agreed form. Never a ticker, a name or a txid. */ export function summaryAssetKey(entry: { chain: string; network: string; protocolId: string; assetId: string; ruleset: string | null; }): string { @@ -253,7 +389,16 @@ export function decodeTransactionAssetSummary( if (txid !== expected.txid || row.chain !== expected.chain || row.network !== expected.network) { throw new SummaryDecodeError('summary context mismatch'); } - const assets = Array.isArray(row.assets) ? row.assets.map(asset) : []; + // A missing assets array is not an empty inventory. Rendering one as "no + // supported assets" is the single most misleading thing this component can + // do, so the array is required and its absence is an error. + if (!Array.isArray(row.assets)) { + throw new SummaryDecodeError('summary.assets is not an array'); + } + if (!Array.isArray(row.perProtocolCoverage)) { + throw new SummaryDecodeError('summary.perProtocolCoverage is not an array'); + } + const assets = row.assets.map(asset); const identities = new Set(assets.map(summaryAssetKey)); if (identities.size !== assets.length) { throw new SummaryDecodeError('summary lists one asset identity twice'); @@ -272,7 +417,50 @@ export function decodeTransactionAssetSummary( if (totalCount !== null && (typeof totalCount !== 'number' || !Number.isSafeInteger(totalCount) || totalCount < 0)) { throw new SummaryDecodeError('counts.totalCount is neither null nor a count'); } - const coverage = Array.isArray(row.perProtocolCoverage) ? row.perProtocolCoverage : []; + const coverage = row.perProtocolCoverage.map( + (entry, index): SummaryProtocolCoverage => { + const item = record(entry, 'perProtocolCoverage[' + index + ']'); + const state = text(item.state, 'coverage.state'); + if (!(SUMMARY_COVERAGE_STATES as readonly string[]).includes(state)) { + throw new SummaryDecodeError('coverage state is not a known state'); + } + const entryChain = text(item.chain, 'coverage.chain'); + const entryNetwork = text(item.network, 'coverage.network'); + if (entryChain !== expected.chain || entryNetwork !== expected.network) { + throw new SummaryDecodeError('coverage row is from another context'); + } + return { + protocolId: text(item.protocolId, 'coverage.protocolId'), + chain: entryChain, + network: entryNetwork, + state: state as SummaryCoverageState, + ...(typeof item.reason === 'string' ? { reason: item.reason } : {}), + }; + }, + ); + // One decision per protocol. Two rows for one protocol would let the view + // pick whichever it read last, and the roster's whole purpose is that each + // protocol is accounted for exactly once. + const coveredProtocols = new Set(coverage.map((entry) => entry.protocolId)); + if (coveredProtocols.size !== coverage.length) { + throw new SummaryDecodeError('coverage lists one protocol twice'); + } + const conclusive = coverage.filter( + (entry) => !INCONCLUSIVE_COVERAGE.has(entry.state), + ); + if (totalCount !== null) { + // A stated total is a claim that nothing is missing. It needs conclusive + // coverage to stand on, and it has to equal the identities actually listed; + // otherwise the count in the heading contradicts the rows beneath it. + if (coverage.length === 0 || conclusive.length !== coverage.length) { + throw new SummaryDecodeError( + 'a stated total needs conclusive coverage for every protocol', + ); + } + if (totalCount !== identities.size) { + throw new SummaryDecodeError('the stated total disagrees with the asset identities'); + } + } const retryAfterSeconds = row.retryAfterSeconds; return { schemaVersion: EXPLORER_TX_ASSET_SUMMARY_SCHEMA_VERSION, @@ -281,16 +469,7 @@ export function decodeTransactionAssetSummary( txid, status: text(row.status, 'status'), assets, - perProtocolCoverage: coverage.map((entry, index) => { - const item = record(entry, 'perProtocolCoverage[' + index + ']'); - return { - protocolId: text(item.protocolId, 'coverage.protocolId'), - chain: text(item.chain, 'coverage.chain'), - network: text(item.network, 'coverage.network'), - state: text(item.state, 'coverage.state') as SummaryCoverageState, - ...(typeof item.reason === 'string' ? { reason: item.reason } : {}), - }; - }), + perProtocolCoverage: coverage, counts: { fungibleTypeCountAtomic: atomicRequired(counts.fungibleTypeCountAtomic, 'counts.fungible'), collectibleItemCountAtomic: atomicRequired(counts.collectibleItemCountAtomic, 'counts.collectible'), @@ -303,6 +482,7 @@ export function decodeTransactionAssetSummary( && Number.isSafeInteger(retryAfterSeconds) && retryAfterSeconds >= 0 ? retryAfterSeconds : null, + checkpoint: checkpoint(row.checkpoint), }; } @@ -311,15 +491,13 @@ export function decodeTransactionAssetSummary( * * String arithmetic only. A null return means the caller must label the atomic * digits as atomic units; it never means zero and never means whole tokens. + * + * Kept as the decoder's own export and delegated to the shared presenter, so + * the transaction panel, the address panel and this module cannot scale one + * quantity three slightly different ways. */ export function exactQuantity(quantityAtomic: string | null, assetDecimals: number | null): string | null { - if (quantityAtomic === null || assetDecimals === null) {return null;} - if (!ATOMIC_INTEGER.test(quantityAtomic)) {return null;} - if (assetDecimals === 0) {return quantityAtomic;} - const padded = quantityAtomic.padStart(assetDecimals + 1, '0'); - const whole = padded.slice(0, padded.length - assetDecimals); - const fraction = padded.slice(padded.length - assetDecimals).replace(/0+$/, ''); - return fraction.length === 0 ? whole : whole + '.' + fraction; + return exactDecimal(quantityAtomic, assetDecimals); } /** True when any consulted source left the inventory inconclusive. */ @@ -327,29 +505,3 @@ export function coverageIncomplete(summary: TransactionAssetSummary): boolean { return summary.counts.totalCount === null; } -/* IMPLEMENTATION-HANDOFF [UI-WP01:TX-CONTRACT] 2026-09-19 - * Coverage C01-C08; defects F03/F05/F06. Preparation only; no behavior changed. - * Verified: decodeTransactionAssetSummary accepts unequal non-null totals, - * casts unknown coverage states, and logo() promotes any matching path to - * verified:true. The backend contract distinguishes rejected effects from - * candidates through evidence; this decoder drops that evidence. - * Source R05: backend-apis contracts/transaction-asset-summary.ts at local - * df0b12a5d1f15b03c2d0969f88bbba14a23cb369; R01 WCAG 2.2 status clarity. - * 1. Require asset/coverage arrays, validate the coverage enum and each - * chain/network, reject duplicate protocol coverage, and validate totals - * against unique identities and classification counts using integer strings. - * 2. A non-null total requires nonempty conclusive coverage and equals the - * known count. Fail closed on contradictions; never render false emptiness. - * 3. Keep the exact same-origin/hash logo constraint; require verified===true. - * Invalid artwork becomes a labelled fallback without discarding amounts. - * 4. Preserve validated effect evidence/checkpoint needed by UI-WP02. Until - * reason is proven, accepted:false means Not accepted, not Unconfirmed. - * 5. Retain null decimals and null quantities. Never use Number/parseFloat for - * amounts. Coordinate schema compatibility with UI-WP07 before deployment. - * Tests: extend transaction-assets.types.spec.ts beside this file; run from - * frontend: npm test -- src/app/universe/transaction-assets (NOT RUN here). - * Assert mismatched totals/context/enums fail; false logo is fallback; u128, - * zero, null, decimals 0/38, duplicate identities and empty partial stay exact. - * Dependencies: UI-WP07 contract review before UI-WP02/03. No migration; roll - * back consumer and producer compatibly, never weaken validation for rollout. - */ diff --git a/frontend/src/app/universe/universe.types.ts b/frontend/src/app/universe/universe.types.ts index 53a0e41bed..057464f4f2 100644 --- a/frontend/src/app/universe/universe.types.ts +++ b/frontend/src/app/universe/universe.types.ts @@ -140,6 +140,24 @@ export interface ExplorerAssetRef { displayName?: string; ticker?: string; assetKind: string; + /** + * The ruleset this asset's ledger is read under, for the protocols that + * define more than one reading of one ledger. + * + * Part of the identity, not decoration: two rulesets over one ledger are two + * assets, and a consumer that keys without it sums quantities no authority + * would agree belong together. Absent for protocols that define a single + * reading, which is what the producer sends. + */ + ruleset?: string; + /** + * The divisibility the authority stated, when it stated one. + * + * Absent means unknown, which a consumer renders as smallest units. Treating + * absence as zero understates a quantity by up to thirty eight orders of + * magnitude, so it is never defaulted here. + */ + decimals?: number; } export interface ExplorerEvidenceCheckpoint { diff --git a/scripts/universe/visual-qa/fixture-router.mjs b/scripts/universe/visual-qa/fixture-router.mjs index ec010424b9..e599e6b052 100644 --- a/scripts/universe/visual-qa/fixture-router.mjs +++ b/scripts/universe/visual-qa/fixture-router.mjs @@ -3,6 +3,11 @@ import { chainFixtures, chainStateOverrides } from './chain-fixtures.mjs'; import { assetFixtures } from './asset-fixtures.mjs'; import { intelligenceFixtures } from './intelligence-fixtures.mjs'; +/** The transaction and block the address and detail fixtures share. */ +const SAMPLE_TXID = '9f4a1c7e5b2d8036a1f4c9e7b3d5081a2c6e4f9b7d3a1c58e26f0b4d9a7c3e15'; +const SAMPLE_BLOCK_HASH = + '00000000000000000002a7c4c1e8b7d3f9a5c2e6b0d4f8a1c5e9b3d7f1a5c9e3'; + /** * Fixture Schema Version. */ @@ -121,10 +126,53 @@ export class FixtureRouter { path: '/api/v1/universe/transactions/batch', response: { results: [] }, }); + // The outpoint batch the address holdings panel reads. + // + // It used to answer every request with an empty result list, so the panel + // was only ever reviewed in the state where the authority answered for + // nothing. One of the two outputs the address fixture holds now carries a + // real position and the other is left unanswered, which is the state the + // panel has to get right: exact holdings for what was read, and an explicit + // partial scope for what was not. this.register({ method: 'POST', path: '/api/v1/universe/outpoints/batch', - response: { results: [] }, + response: { + results: [ + { + outpoint: `${SAMPLE_TXID}:0`, + status: 'ok', + coveredProtocolIds: ['runes'], + unknownAttachments: false, + checkpoint: { + chain: 'bitcoin', + network: 'mainnet', + heightAtomic: '887412', + blockHash: SAMPLE_BLOCK_HASH, + reorgEpoch: '0', + observedAt: '2026-09-19T00:00:00.000Z', + }, + positions: [ + { + outpoint: `${SAMPLE_TXID}:0`, + vout: 0, + valueSatsAtomic: '1500000', + state: 'active', + asset: { + protocolId: 'runes', + assetId: 'UNIVERSE.RUNE', + displayName: 'UNIVERSE', + ticker: 'UNIVERSE', + assetKind: 'fungible', + decimals: 8, + }, + quantityAtomic: '100000000000', + evidence: { authorityId: 'ord 0.29', protocolId: 'runes', coverage: 'complete' }, + }, + ], + }, + ], + }, }); } diff --git a/scripts/universe/visual-qa/fixtures.mjs b/scripts/universe/visual-qa/fixtures.mjs index 1b983b4d5d..a84d7146f0 100644 --- a/scripts/universe/visual-qa/fixtures.mjs +++ b/scripts/universe/visual-qa/fixtures.mjs @@ -362,6 +362,101 @@ export const detailFixtures = { actions: [{ protocolId: 'runes', actionType: 'transfer', asset: { protocolId: 'runes', assetId: 'UNIVERSE.RUNE', name: 'UNIVERSE' }, quantityAtomic: '100000000000' }], sourceEvidence: [{ authorityId: 'ord 0.29', coverage: 'complete', checkpoint: { heightAtomic: '887412' } }], }, + // The asset summary for the same transaction. + // + // This fixture was missing, and the router falls back to a prefix match, so + // the summary endpoint was being served the detailed flow payload above. The + // panel correctly rejected it as the wrong schema and rendered its error + // state, which meant every transaction and address screenshot reviewed the + // panel's failure rather than the panel. An exact path wins over the prefix + // fallback, so registering it here is what actually covers the panel. + // + // It is deliberately not conclusive: one protocol answered and one has no + // reader, so `totalCount` is null. A fixture that stated a total while a + // roster protocol went unread would be internally inconsistent, and the + // decoder rejects exactly that. + [`/api/v1/universe/transactions/${TXID_A}/assets`]: { + schemaVersion: 'universe-transaction-asset-summary-v1', + chain: 'bitcoin', + network: 'mainnet', + txid: TXID_A, + status: 'confirmed', + assets: [ + { + chain: 'bitcoin', + network: 'mainnet', + protocolId: 'runes', + assetId: 'UNIVERSE.RUNE', + ruleset: null, + assetKind: 'fungible', + displayName: 'UNIVERSE', + ticker: 'UNIVERSE', + decimals: 8, + logo: null, + inputs: { quantityAtomic: '125000000000', positionCountAtomic: '1', complete: true }, + outputs: { quantityAtomic: '125000000000', positionCountAtomic: '2', complete: true }, + effects: [ + { + eventId: `${TXID_A}:runes:0`, + actionType: 'transfer', + quantityAtomic: '100000000000', + accepted: true, + evidence: { + authorityId: 'ord 0.29', + protocolId: 'runes', + coverage: 'complete', + checkedAt: '2026-09-19T00:00:00.000Z', + checkpoint: { + chain: 'bitcoin', + network: 'mainnet', + heightAtomic: '887412', + blockHash: BLOCK_HASH, + reorgEpoch: '0', + observedAt: '2026-09-19T00:00:00.000Z', + }, + }, + }, + ], + evidence: [ + { + authorityId: 'ord 0.29', + protocolId: 'runes', + coverage: 'complete', + checkedAt: '2026-09-19T00:00:00.000Z', + checkpoint: { + chain: 'bitcoin', + network: 'mainnet', + heightAtomic: '887412', + blockHash: BLOCK_HASH, + reorgEpoch: '0', + observedAt: '2026-09-19T00:00:00.000Z', + }, + }, + ], + }, + ], + perProtocolCoverage: [ + { protocolId: 'runes', chain: 'bitcoin', network: 'mainnet', state: 'complete', reason: 'reader-answered' }, + { protocolId: 'anima', chain: 'bitcoin', network: 'mainnet', state: 'unconfigured', reason: 'no-transaction-reader' }, + ], + counts: { + fungibleTypeCountAtomic: '1', + collectibleItemCountAtomic: '0', + otherAssetCountAtomic: '0', + protocolCountAtomic: '1', + knownCountAtomic: '1', + totalCount: null, + }, + retryAfterSeconds: 5, + checkpoint: { + chain: 'bitcoin', + network: 'mainnet', + heightAtomic: '887412', + blockHash: BLOCK_HASH, + reorgEpoch: '0', + observedAt: '2026-09-19T00:00:00.000Z', + }, + }, '/api/v1/mining/hashrate/3d': { hashrates: [{ timestamp: 1_772_000_000, avgHashrate: 8.1e20 }], difficulty: [{ timestamp: 1_772_000_000, difficulty: 1.1e14, height: 887_000 }], currentHashrate: 8.12e20, currentDifficulty: 1.105e14 }, '/api/v1/mining/reward-stats/144': { startBlock: 887_268, endBlock: 887_412, totalReward: '46_800_000_000'.replace(/_/g, ''), totalFee: '1_400_000_000'.replace(/_/g, ''), totalTx: '412_004'.replace(/_/g, '') }, '/api/v1/mining/blocks/fees/1w': [{ avgHeight: 887_000, timestamp: 1_772_000_000, avgFees: 12_884_901 }], From 39ff78851c78412705a59ccf3247de1767d0f099 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 19 Sep 2026 09:08:37 +0000 Subject: [PATCH 3/5] fix(visual-qa): stop the service worker from bypassing every declared fixture The screenshot gate reported fourteen console errors on the transaction and address routes, and the errors said only "ERROR yt": a production build's minified constructor name, logged because msg.text() abbreviates a logged object rather than printing it. The gate now resolves the argument handles in the page, so a recorded error carries its real message. With that visible, the cause was not the pages. The application registers a service worker, and a request a service worker answers never reaches context.route, so the worker returned its cached app shell for API requests and the page parsed index.html as JSON. Every fixture this run declares was being bypassed for any request the worker answered, which means these screenshots were not driven by the fixtures they claim to assert. capture.mjs has always passed serviceWorkers: 'block'; this run did not. Three fixtures were also missing outright and fell through to the static server: the asset summary endpoint, which the router's prefix fallback answered with the detailed flow payload, so every transaction and address screenshot reviewed the asset panel's error state rather than the panel; the mining pools windows other than 1w; and the historical price the address page reads. The outpoint batch answered every request with an empty list, so the address holdings panel was only ever reviewed with nothing to show. The gate now reports GO: 24 screenshots, no automatic failures, no accessibility failures, no console errors, no unmatched fixtures. Co-Authored-By: Claude Opus 5 --- .../asset-summary.fixture-parity.spec.ts | 73 +++++++++++++++++++ .../universe/visual-qa/checkscreenshots.mjs | 47 +++++++++++- scripts/universe/visual-qa/fixtures.mjs | 18 +++++ 3 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/universe/asset-summary/asset-summary.fixture-parity.spec.ts diff --git a/frontend/src/app/universe/asset-summary/asset-summary.fixture-parity.spec.ts b/frontend/src/app/universe/asset-summary/asset-summary.fixture-parity.spec.ts new file mode 100644 index 0000000000..c95c04d4e1 --- /dev/null +++ b/frontend/src/app/universe/asset-summary/asset-summary.fixture-parity.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { + decodeTransactionAssetSummary, +} from '@app/universe/transaction-assets/transaction-assets.types'; + +/** + * The visual-QA fixtures have to satisfy the same decoder the application uses. + * + * They did not, and nothing noticed. The fixture router falls back to a prefix + * match, so a missing summary fixture was answered with the detailed flow + * payload; the panel rejected it as the wrong schema and every transaction and + * address screenshot reviewed the panel's error state rather than the panel. + * A screenshot of a failure looks like a screenshot, which is why this is + * asserted here rather than left to a reviewer's eye. + * + * The fixtures are ESM in the repository's script tree, outside the frontend's + * module graph, so they are loaded by path at test time rather than imported. + */ +async function loadDetailFixtures(): Promise> { + const here = dirname(fileURLToPath(import.meta.url)); + const fixturesPath = resolve( + here, + '../../../../../scripts/universe/visual-qa/fixtures.mjs', + ); + const module = (await import(/* @vite-ignore */ fixturesPath)) as { + detailFixtures: Record; + }; + return module.detailFixtures; +} + +describe('visual-QA fixtures match the summary contract', () => { + it('serves a summary fixture the decoder accepts', async () => { + const fixtures = await loadDetailFixtures(); + const paths = Object.keys(fixtures).filter((path) => path.endsWith('/assets')); + expect(paths.length).toBeGreaterThan(0); + + for (const path of paths) { + const txid = path.split('/').slice(-2)[0]; + const decoded = decodeTransactionAssetSummary(fixtures[path], { + chain: 'bitcoin', + network: 'mainnet', + txid, + }); + expect(decoded.txid).toBe(txid); + // Not conclusive on purpose: one roster protocol has no reader, so a + // stated total would contradict its own coverage. + expect(decoded.counts.totalCount).toBeNull(); + expect(decoded.assets.length).toBeGreaterThan(0); + expect(decoded.assets[0].outputs.quantityAtomic).not.toBeNull(); + // The evidence the panel needs to word acceptance truthfully. + expect(decoded.assets[0].effects[0].evidence?.authorityId).toBeTruthy(); + expect(decoded.checkpoint).not.toBeNull(); + } + }); + + it('does not let the flow payload pass as a summary', async () => { + const fixtures = await loadDetailFixtures(); + const flowPath = Object.keys(fixtures).find( + (path) => path.includes('/universe/transactions/') && !path.endsWith('/assets'), + ); + expect(flowPath).toBeTruthy(); + // This is what the summary endpoint was being served, and why the panel + // rendered an error. The decoder must keep refusing it. + expect(() => decodeTransactionAssetSummary(fixtures[flowPath as string], { + chain: 'bitcoin', + network: 'mainnet', + txid: (flowPath as string).split('/').pop() as string, + })).toThrow(); + }); +}); diff --git a/scripts/universe/visual-qa/checkscreenshots.mjs b/scripts/universe/visual-qa/checkscreenshots.mjs index 1096ae3e49..5256ade621 100644 --- a/scripts/universe/visual-qa/checkscreenshots.mjs +++ b/scripts/universe/visual-qa/checkscreenshots.mjs @@ -175,6 +175,14 @@ async function main() { const context = await browser.newContext({ viewport: vp, deviceScaleFactor: 1, + // The app registers a service worker, and a request a service + // worker answers never reaches context.route. Without this, the + // worker served its cached app shell for API requests, so the page + // parsed index.html as JSON and every transaction and address + // screenshot carried uncaught parse failures, while the fixtures + // this run declares were quietly bypassed. capture.mjs has always + // blocked it; this run did not. + serviceWorkers: 'block', }); await installFixtures(context, 'populated'); @@ -202,10 +210,43 @@ async function main() { }); const pageConsoleErrors = []; + const consoleDetail = []; let pageError = null; page.on('console', (msg) => { - if (msg.type() === 'error') pageConsoleErrors.push(msg.text()); + if (msg.type() !== 'error') return; + // msg.text() abbreviates a logged object to its constructor name, + // which in a production build is one or two minified letters and + // tells a reader nothing about what failed. The argument handles + // still carry the real error, so they are resolved in the page and + // the recorded line is replaced with something actionable. + const index = pageConsoleErrors.push(msg.text()) - 1; + consoleDetail.push( + Promise.all( + msg.args().map((arg) => + arg + .evaluate((value) => { + if (value instanceof Error) { + return value.stack || `${value.name}: ${value.message}`; + } + if (value && typeof value === 'object') { + try { + return JSON.stringify(value); + } catch { + return Object.prototype.toString.call(value); + } + } + return String(value); + }) + .catch(() => undefined), + ), + ) + .then((values) => { + const detail = values.filter(Boolean).join(' '); + if (detail) pageConsoleErrors[index] = detail.slice(0, 600); + }) + .catch(() => undefined), + ); }); page.on('pageerror', (err) => { pageError = String(err); @@ -243,6 +284,10 @@ async function main() { pixelThreshold: 0.1, }); + // The console detail resolves asynchronously; the recorded lines + // have to be final before they are counted and reported. + await Promise.all(consoleDetail); + screenshotsCompared++; const unmatched = [...(context._unmatchedFixtureErrors || [])]; if (unmatched.length > 0) unmatchedFixturesCount += unmatched.length; diff --git a/scripts/universe/visual-qa/fixtures.mjs b/scripts/universe/visual-qa/fixtures.mjs index a84d7146f0..15eb09022c 100644 --- a/scripts/universe/visual-qa/fixtures.mjs +++ b/scripts/universe/visual-qa/fixtures.mjs @@ -337,6 +337,15 @@ export const detailFixtures = { '/api/v1/mining/pools/1m': fixtures['/api/v1/mining/pools/1w'], '/api/v1/mining/pools/3m': fixtures['/api/v1/mining/pools/1w'], '/api/v1/mining/pools/6m': fixtures['/api/v1/mining/pools/1w'], + // Every window the pools panel can ask for. 1m was missing, so the transaction + // page's request fell through to the static server, was answered with + // index.html, and failed JSON parsing: an uncaught HttpErrorResponse on every + // transaction screenshot, in both themes and both viewports. + '/api/v1/mining/pools/24h': fixtures['/api/v1/mining/pools/1w'], + '/api/v1/mining/pools/3d': fixtures['/api/v1/mining/pools/1w'], + '/api/v1/mining/pools/1m': fixtures['/api/v1/mining/pools/1w'], + '/api/v1/mining/pools/3m': fixtures['/api/v1/mining/pools/1w'], + '/api/v1/mining/pools/6m': fixtures['/api/v1/mining/pools/1w'], '/api/v1/mining/pools/1y': fixtures['/api/v1/mining/pools/1w'], '/api/v1/mining/pools/2y': fixtures['/api/v1/mining/pools/1w'], '/api/v1/mining/pools/3y': fixtures['/api/v1/mining/pools/1w'], @@ -461,6 +470,15 @@ export const detailFixtures = { '/api/v1/mining/reward-stats/144': { startBlock: 887_268, endBlock: 887_412, totalReward: '46_800_000_000'.replace(/_/g, ''), totalFee: '1_400_000_000'.replace(/_/g, ''), totalTx: '412_004'.replace(/_/g, '') }, '/api/v1/mining/blocks/fees/1w': [{ avgHeight: 887_000, timestamp: 1_772_000_000, avgFees: 12_884_901 }], '/api/v1/difficulty-adjustments/1m': [[1_772_000_000, 887_000, 1.1e14, 3.18]], + // The historical price the address and transaction pages ask for when they + // show a fiat figure beside a base-coin amount. Missing, so it was answered + // with index.html and raised an uncaught parse failure on every address + // screenshot. The asset panels state no fiat value of their own; this is the + // surrounding page's request. + '/api/v1/historical-price': { + prices: [{ time: 1_772_100_000, USD: 84_000, EUR: 78_000, GBP: 66_000, CAD: 114_000, CHF: 74_000, AUD: 126_000, JPY: 12_600_000 }], + exchangeRates: { USDEUR: 0.93, USDGBP: 0.79, USDCAD: 1.36, USDCHF: 0.88, USDAUD: 1.5, USDJPY: 150 }, + }, '/api/v1/capabilities': { schemaVersion: 'capabilities-v1', features: { From 89d307d4d947ddb1a15a4ae8fc9fa6a2c7434986 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 19 Sep 2026 09:58:48 +0000 Subject: [PATCH 4/5] test(explorer): assert the panel against a payload the live authority returned `live-evidence/mainnet-summary-20260919.json` is the verbatim body of GET /api/v1/universe/transactions/{txid}/assets from the production explorer on 2026-09-19, for a confirmed mainnet transaction. It is read-only public data, pinned because a fixture written by the same hand as the decoder proves less than the real thing. It pins two things. The deployed producer does not send the per-asset evidence or effect evidence the rebuilt decoder reads, so the decoder has to accept their absence or the first deploy breaks the live site; that is now asserted. And the deployed producer states a checkpoint height of zero beside the real tip block hash, and reports all thirty one roster protocols as having no reader at all including the six that two configured adapters do read. Both are the defects the backend branch repairs, and both are asserted here so that shipping the repaired producer fails this test loudly instead of passing on stale expectations. The panel rendering that payload says Partial and "0 assets found so far", never that there are none, states every unread protocol as a limit rather than as something being waited on, and offers no retry for a gap no retry could clear. Also adds a fixture-parity spec: the visual-QA summary fixture must decode under the same decoder the application uses, and the detailed flow payload must keep failing to, since that is what the router's prefix fallback was serving. Co-Authored-By: Claude Opus 5 --- .../asset-summary.live-payload.spec.ts | 159 ++++++++++++ .../asset-summary.test-render.ts | 9 +- .../mainnet-summary-20260919.json | 245 ++++++++++++++++++ 3 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 frontend/src/app/universe/asset-summary/asset-summary.live-payload.spec.ts create mode 100644 frontend/src/app/universe/asset-summary/live-evidence/mainnet-summary-20260919.json diff --git a/frontend/src/app/universe/asset-summary/asset-summary.live-payload.spec.ts b/frontend/src/app/universe/asset-summary/asset-summary.live-payload.spec.ts new file mode 100644 index 0000000000..503642a22a --- /dev/null +++ b/frontend/src/app/universe/asset-summary/asset-summary.live-payload.spec.ts @@ -0,0 +1,159 @@ +// @vitest-environment jsdom +import 'zone.js'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { SimpleChange } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; +import { of } from 'rxjs'; +import { TransactionAssetsComponent } from '@app/universe/transaction-assets/transaction-assets.component'; +import { + decodeTransactionAssetSummary, +} from '@app/universe/transaction-assets/transaction-assets.types'; +import { UniverseApiService } from '@app/universe/universe-api.service'; +import { resolveTemplates } from './asset-summary.test-render'; + +/** + * The rebuilt panel against a payload the live authority actually returned. + * + * `live-evidence/mainnet-summary-20260919.json` is the verbatim body of + * `GET /api/v1/universe/transactions/{txid}/assets` from + * `https://explorer.bitcoinuniverse.io` on 2026-09-19, for a confirmed mainnet + * transaction in block 0000000000000000000048c5b08853cc2a5c2a8941830089fa5dcdcb4847fb9e. + * It is read-only public data and is pinned here because a fixture written by + * the same hand that wrote the decoder proves less than the real thing. + * + * It matters in two directions. + * + * It is the producer as deployed today, which does not send the per-asset + * evidence or effect evidence the rebuilt decoder reads. A consumer that + * required them would break the live site on the first deploy, so the decoder + * has to accept their absence, and this asserts that it does. + * + * It also records what the deployed producer gets wrong, which is what the + * backend repairs on this branch fix. Its checkpoint states height zero beside + * the real tip block hash, and all thirty one roster protocols report + * `no-transaction-reader` even though two adapters are configured there. Both + * are asserted below, so when the repaired producer ships this test fails and + * says so rather than quietly passing on stale expectations. + */ +function livePayload(): unknown { + const here = dirname(fileURLToPath(import.meta.url)); + const path = resolve(here, 'live-evidence/mainnet-summary-20260919.json'); + return JSON.parse(readFileSync(path, 'utf8')); +} + +const CONTEXT = { + chain: 'bitcoin', + network: 'mainnet', + txid: '5b63ff3a594995bb9890f7cc96449f9d95a4f35b3f98bf2a753831ee7906a347', +}; + +describe('the live production payload', () => { + it('decodes under the rebuilt decoder without its optional evidence', () => { + const decoded = decodeTransactionAssetSummary(livePayload(), CONTEXT); + expect(decoded.txid).toBe(CONTEXT.txid); + expect(decoded.assets).toHaveLength(0); + expect(decoded.perProtocolCoverage).toHaveLength(31); + // Nothing conclusive, so no total. This is the state the live site is in. + expect(decoded.counts.totalCount).toBeNull(); + expect(decoded.retryAfterSeconds).toBe(5); + }); + + it('records the two producer defects this branch repairs', () => { + const payload = livePayload() as { + checkpoint: { heightAtomic: string; blockHash: string }; + perProtocolCoverage: { reason: string }[]; + }; + // A real tip block hash beside height zero: the transaction was not read at + // genesis, and a consumer comparing reorg epochs would trust this over every + // real reading. The repaired producer returns null instead. + expect(payload.checkpoint.heightAtomic).toBe('0'); + expect(payload.checkpoint.blockHash).not.toBe('0'.repeat(64)); + // Every protocol reads as having no reader at all, including the six that + // two configured adapters do read. The repaired producer separates a + // missing reader from a configured one that did not answer. + const reasons = new Set(payload.perProtocolCoverage.map((entry) => entry.reason)); + expect([...reasons]).toEqual(['no-transaction-reader']); + }); + + it('never states a coverage state the contract does not define', () => { + const decoded = decodeTransactionAssetSummary(livePayload(), CONTEXT); + for (const entry of decoded.perProtocolCoverage) { + expect(entry.state).toBe('unconfigured'); + expect(entry.chain).toBe('bitcoin'); + expect(entry.network).toBe('mainnet'); + } + }); +}); + +describe('the panel rendering the live production payload', () => { + beforeAll(async () => { + Object.defineProperty(TransactionAssetsComponent, 'ctorParameters', { + configurable: true, + value: () => [{ type: UniverseApiService }], + }); + await resolveTemplates(import.meta.url, '../transaction-assets'); + TestBed.initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); + }); + afterEach(() => TestBed.resetTestingModule()); + + function render() { + const decoded = decodeTransactionAssetSummary(livePayload(), CONTEXT); + TestBed.configureTestingModule({ + imports: [CommonModule], + declarations: [TransactionAssetsComponent], + providers: [ + { + provide: UniverseApiService, + useValue: { + getTransactionAssets$: () => of(decoded), + getProtocols$: () => of({ protocols: [] }), + }, + }, + ], + errorOnUnknownElements: false, + errorOnUnknownProperties: false, + }); + const view = TestBed.createComponent(TransactionAssetsComponent); + view.componentInstance.txid = CONTEXT.txid; + view.componentInstance.chain = 'bitcoin'; + view.componentInstance.ngOnChanges({ + txid: new SimpleChange(undefined, CONTEXT.txid, true), + }); + view.detectChanges(); + return view; + } + + it('says partial and nothing found so far, never that there are none', () => { + const text = render().nativeElement.textContent as string; + expect(text).toContain('Partial'); + expect(text).toContain('0 assets found so far'); + // The distinction the whole feature rests on: unknown is not empty. + expect(text).not.toContain('No supported assets'); + expect(text).not.toContain('reports no supported assets'); + }); + + it('states every unread protocol as a limit rather than as waiting', () => { + const element = render().nativeElement as HTMLElement; + const text = element.textContent as string; + expect(text).toContain('Coverage details for 31 of 31 sources'); + expect(text).toContain('No source set up'); + expect(text).toContain('No source reads this protocol per transaction'); + expect(text).not.toContain('Waiting on'); + // Nothing here could clear on a retry, so no retry is offered. + expect(element.querySelector('.summary-coverage .summary-retry')).toBeNull(); + }); + + it('renders no asset table and no fabricated row', () => { + const element = render().nativeElement as HTMLElement; + expect(element.querySelector('table.summary-table')).toBeNull(); + expect(element.querySelectorAll('.asset-row')).toHaveLength(0); + }); +}); diff --git a/frontend/src/app/universe/asset-summary/asset-summary.test-render.ts b/frontend/src/app/universe/asset-summary/asset-summary.test-render.ts index 545e2f9849..f6005cb1fc 100644 --- a/frontend/src/app/universe/asset-summary/asset-summary.test-render.ts +++ b/frontend/src/app/universe/asset-summary/asset-summary.test-render.ts @@ -17,8 +17,13 @@ import { ɵresolveComponentResources as resolveComponentResources } from '@angul * resolve to nothing: they are Sass, which is the build's job to compile, and no * assertion here depends on a computed style. */ -export async function resolveTemplates(specUrl: string): Promise { - const base = dirname(fileURLToPath(specUrl)); +export async function resolveTemplates( + specUrl: string, + relativeBase = '.', +): Promise { + // A component's templateUrl is relative to the component, not to whichever + // spec renders it, so a spec in another directory says where to look. + const base = resolve(dirname(fileURLToPath(specUrl)), relativeBase); await resolveComponentResources((url: string) => { if (url.endsWith('.scss') || url.endsWith('.css')) { return Promise.resolve({ text: () => Promise.resolve('') } as Response); diff --git a/frontend/src/app/universe/asset-summary/live-evidence/mainnet-summary-20260919.json b/frontend/src/app/universe/asset-summary/live-evidence/mainnet-summary-20260919.json new file mode 100644 index 0000000000..8b15c287f1 --- /dev/null +++ b/frontend/src/app/universe/asset-summary/live-evidence/mainnet-summary-20260919.json @@ -0,0 +1,245 @@ +{ + "schemaVersion": "universe-transaction-asset-summary-v1", + "chain": "bitcoin", + "network": "mainnet", + "txid": "5b63ff3a594995bb9890f7cc96449f9d95a4f35b3f98bf2a753831ee7906a347", + "status": "confirmed", + "checkpoint": { + "chain": "bitcoin", + "network": "mainnet", + "heightAtomic": "0", + "blockHash": "0000000000000000000048c5b08853cc2a5c2a8941830089fa5dcdcb4847fb9e", + "reorgEpoch": "0", + "observedAt": "2026-09-19T09:37:25.703Z" + }, + "assets": [], + "perProtocolCoverage": [ + { + "protocolId": "ordinals", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "rare_sats", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "names", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "bitmap", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "unat", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "runes", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "alkanes", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "mezcal", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "stamps", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "src20", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "src101", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "atomicals_nft", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "realms", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "subrealms", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "arc20", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "op_return", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "op_names", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "op_inscriptions", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "op_drop", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "drops", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "brc20", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "tap", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "dmt", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "dust20", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "block20", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "chainbloom", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "patina", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "witness_circles", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "tandem", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "ordex", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + }, + { + "protocolId": "anima", + "chain": "bitcoin", + "network": "mainnet", + "state": "unconfigured", + "reason": "no-transaction-reader" + } + ], + "counts": { + "fungibleTypeCountAtomic": "0", + "collectibleItemCountAtomic": "0", + "otherAssetCountAtomic": "0", + "protocolCountAtomic": "0", + "knownCountAtomic": "0", + "totalCount": null + }, + "retryAfterSeconds": 5, + "sourceEvidence": [] +} From 82edf95d3586a0dfc2e31c52e8341b449105bf07 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 19 Sep 2026 11:11:38 +0000 Subject: [PATCH 5/5] fix(visual-qa): add the chain transaction asset summary fixtures The Dogecoin and Zcash transaction routes read the asset summary and had no fixture for it, so the request failed closed. It went unnoticed because other missing fixtures on the same pages raised JSON parse failures first and the pages never rendered far enough to ask. Repairing those, in the previous commit, is what surfaced this. Both are deliberately inconclusive: one protocol answered and the rest of the chain's roster has no transaction reader, so a total would contradict its own coverage and the decoder rejects that. The Zcash one states no divisibility, so the panel renders the true digits labelled as smallest units rather than a scaled figure, which is the case that was previously impossible to review. The fixture-parity spec now covers them, and asserts that neither carries the fabricated zero-height checkpoint the deployed producer sends. Co-Authored-By: Claude Opus 5 --- .../asset-summary.fixture-parity.spec.ts | 55 ++++++++ scripts/universe/visual-qa/chain-fixtures.mjs | 129 ++++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/frontend/src/app/universe/asset-summary/asset-summary.fixture-parity.spec.ts b/frontend/src/app/universe/asset-summary/asset-summary.fixture-parity.spec.ts index c95c04d4e1..07c24882f0 100644 --- a/frontend/src/app/universe/asset-summary/asset-summary.fixture-parity.spec.ts +++ b/frontend/src/app/universe/asset-summary/asset-summary.fixture-parity.spec.ts @@ -19,6 +19,18 @@ import { * The fixtures are ESM in the repository's script tree, outside the frontend's * module graph, so they are loaded by path at test time rather than imported. */ +async function loadChainFixtures(): Promise> { + const here = dirname(fileURLToPath(import.meta.url)); + const fixturesPath = resolve( + here, + '../../../../../scripts/universe/visual-qa/chain-fixtures.mjs', + ); + const module = (await import(/* @vite-ignore */ fixturesPath)) as { + chainFixtures: Record; + }; + return module.chainFixtures; +} + async function loadDetailFixtures(): Promise> { const here = dirname(fileURLToPath(import.meta.url)); const fixturesPath = resolve( @@ -71,3 +83,46 @@ describe('visual-QA fixtures match the summary contract', () => { })).toThrow(); }); }); + +describe('the chain transaction fixtures match the summary contract', () => { + it('serves a summary the decoder accepts for every chain route', async () => { + const fixtures = await loadChainFixtures(); + const paths = Object.keys(fixtures).filter((path) => path.endsWith('/assets')); + // Dogecoin and Zcash. Their absence is what made the panel's request fail + // closed on those routes once the pages rendered far enough to make it. + expect(paths).toHaveLength(2); + for (const path of paths) { + const txid = path.split('/').slice(-2)[0]; + const payload = fixtures[path] as { chain: string }; + const decoded = decodeTransactionAssetSummary(payload, { + chain: payload.chain, + network: 'mainnet', + txid, + }); + expect(decoded.assets).toHaveLength(1); + expect(decoded.assets[0].outputs.quantityAtomic).not.toBeNull(); + expect(decoded.checkpoint?.heightAtomic).not.toBe('0'); + // One reader answered and the rest of the roster has none, so no total. + if (decoded.perProtocolCoverage.length > 1) { + expect(decoded.counts.totalCount).toBeNull(); + } + } + }); + + it('keeps a divisibility the authority did not state unknown', async () => { + const fixtures = await loadChainFixtures(); + const zcash = Object.entries(fixtures).find( + ([path, value]) => + path.endsWith('/assets') && (value as { chain: string }).chain === 'zcash', + ); + expect(zcash).toBeTruthy(); + const [path, payload] = zcash as [string, { chain: string }]; + const decoded = decodeTransactionAssetSummary(payload, { + chain: 'zcash', + network: 'mainnet', + txid: path.split('/').slice(-2)[0], + }); + // Null, so the panel shows the true digits labelled as smallest units. + expect(decoded.assets[0].decimals).toBeNull(); + }); +}); diff --git a/scripts/universe/visual-qa/chain-fixtures.mjs b/scripts/universe/visual-qa/chain-fixtures.mjs index 3a20d76758..52a4a6bd05 100644 --- a/scripts/universe/visual-qa/chain-fixtures.mjs +++ b/scripts/universe/visual-qa/chain-fixtures.mjs @@ -402,12 +402,141 @@ function dogeDuneEnvelope(body) { }; } +/** + * One asset summary for a chain transaction page. + * + * Deliberately inconclusive: `totalCount` is null whenever any roster protocol + * has no transaction reader, which is the state every deployment is actually in + * and the state the panel has to render honestly. + */ +function chainAssetSummary(input) { + const checkpoint = { + chain: input.chain, + network: 'mainnet', + heightAtomic: input.heightAtomic, + blockHash: input.blockHash, + reorgEpoch: '0', + observedAt: '2026-08-29T04:05:00.000Z', + }; + const evidence = { + authorityId: input.authorityId, + protocolId: input.protocolId, + coverage: 'complete', + checkedAt: '2026-08-29T04:05:00.000Z', + checkpoint, + }; + return { + schemaVersion: 'universe-transaction-asset-summary-v1', + chain: input.chain, + network: 'mainnet', + txid: input.txid, + status: 'confirmed', + assets: [ + { + chain: input.chain, + network: 'mainnet', + protocolId: input.protocolId, + assetId: input.assetId, + ruleset: null, + assetKind: input.assetKind, + displayName: input.displayName, + ticker: input.ticker, + decimals: input.decimals, + logo: null, + inputs: { quantityAtomic: '0', positionCountAtomic: '0', complete: true }, + outputs: { + quantityAtomic: input.quantityAtomic, + positionCountAtomic: '1', + complete: true, + }, + effects: [ + { + eventId: `${input.txid}:${input.protocolId}:0`, + actionType: 'transfer', + quantityAtomic: input.quantityAtomic, + accepted: true, + evidence, + }, + ], + evidence: [evidence], + }, + ], + perProtocolCoverage: [ + { + protocolId: input.protocolId, + chain: input.chain, + network: 'mainnet', + state: 'complete', + reason: 'reader-answered', + }, + ...input.unreadProtocols.map((protocolId) => ({ + protocolId, + chain: input.chain, + network: 'mainnet', + state: 'unconfigured', + reason: 'no-transaction-reader', + })), + ], + counts: { + fungibleTypeCountAtomic: input.assetKind === 'fungible' ? '1' : '0', + collectibleItemCountAtomic: input.assetKind === 'inscription' ? '1' : '0', + otherAssetCountAtomic: '0', + protocolCountAtomic: '1', + knownCountAtomic: '1', + totalCount: input.unreadProtocols.length === 0 ? 1 : null, + }, + retryAfterSeconds: input.unreadProtocols.length === 0 ? null : 5, + checkpoint, + }; +} + export const chainFixtures = { '/api/v1/chains': [capability('bitcoin'), capability('dogecoin'), capability('zcash')], '/api/v1/dogecoin/status': capability('dogecoin'), '/api/v1/zcash/status': capability('zcash'), + // The asset summary each chain transaction page reads. + // + // These were missing, and the panel's request failed closed. It went unnoticed + // because other missing fixtures on the same pages raised parse failures first + // and the pages never rendered far enough to ask. + // + // Neither states a total: one protocol answered and the rest of the chain's + // roster has no transaction reader, so a total would contradict its own + // coverage and the decoder rejects that. + [`/api/v1/universe/transactions/${DOGE_TXID}/assets`]: chainAssetSummary({ + chain: 'dogecoin', + txid: DOGE_TXID, + blockHash: DOGE_BLOCK, + heightAtomic: '5623041', + protocolId: 'doginals', + assetId: `${DOGE_TXID}i0`, + assetKind: 'inscription', + displayName: 'Doginal #1', + ticker: null, + decimals: 0, + quantityAtomic: '1', + authorityId: 'ord-dogecoin', + unreadProtocols: ['dunes', 'drc20'], + }), + [`/api/v1/universe/transactions/${ZEC_TXID}/assets`]: chainAssetSummary({ + chain: 'zcash', + txid: ZEC_TXID, + blockHash: ZEC_BLOCK, + heightAtomic: '2891044', + protocolId: 'zrunes', + assetId: 'ZRUNE.UNIVERSE', + assetKind: 'fungible', + displayName: 'ZRUNE UNIVERSE', + ticker: 'ZRUNE', + // Divisibility this authority does not state, so the panel shows the true + // digits labelled as smallest units rather than a scaled figure. + decimals: null, + quantityAtomic: '2500000000', + authorityId: 'index-zrunes', + unreadProtocols: [], + }), [`/api/v1/dogecoin/tx/${DOGE_TXID}`]: dogecoinTransaction(), [`/api/v1/zcash/tx/${ZEC_TXID}`]: zcashTransaction(),