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