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 new file mode 100644 index 0000000000..7787a91c92 --- /dev/null +++ b/frontend/src/app/universe/asset-summary/IMPLEMENTATION-HANDOFF.md @@ -0,0 +1,59 @@ +# 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.fixture-parity.spec.ts b/frontend/src/app/universe/asset-summary/asset-summary.fixture-parity.spec.ts new file mode 100644 index 0000000000..07c24882f0 --- /dev/null +++ b/frontend/src/app/universe/asset-summary/asset-summary.fixture-parity.spec.ts @@ -0,0 +1,128 @@ +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 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( + 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(); + }); +}); + +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/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.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..f6005cb1fc --- /dev/null +++ b/frontend/src/app/universe/asset-summary/asset-summary.test-render.ts @@ -0,0 +1,34 @@ +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, + 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); + } + const text = readFileSync(resolve(base, url), 'utf8'); + return Promise.resolve({ text: () => Promise.resolve(text) } 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": [] +} 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/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(), 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/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..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'], @@ -362,10 +371,114 @@ 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 }], '/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: {