From e46b3db926c4026d3783d5fcf66011cf0c93760d Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sat, 29 Aug 2026 14:20:33 +0000 Subject: [PATCH 1/6] Read a ledger that is reported under two readings of the rules Live at /zcash/protocols/zrc20 right now, in a table cell: rulesets {"zord":{"max_supply":"21000000000000000000000000","mint_limit":"10000000 00000000000000","minted":"21000000000000000000000000","burned":"0",... The authority indexes every ZRC-20 token twice, once under `zord` and once under `zecscriptions`, publishes both ledgers, names the fields the two disagree on, and carries prose about rules that neither reading evaluates. None of it reaches a page: `rulesets` is an object of objects, the fact reader stops one level above the figures, and a table cannot hold a structure. The whole ledger for all 159 tokens is a field the page declines to show. This is the reading. Three rules hold it. Quantities are shifted by the token's own `decimals`, and the kinds are an allowlist rather than a pattern. A pattern is what printed a DRC-20 supply of 100000000000 as `1,000 DOGE` by matching a field that was not an amount, and the opposite mistake is available here: `max_supply` of 21000000000000000000000000 is twenty-one million with eighteen decimals applied and twenty-one septillion without. `holders` is a number of things and is never shifted at all. A token whose `decimals` cannot be read as a plain non-negative integer shows no amount rather than an unshifted one, because a wrong quantity is worse than a missing one and there is no partial credit for being close. A ruleset field this build has no kind for is named, not dropped. Reproducing the defect this reading exists to fix, one level further down, would be worse for being deliberate. `mint_progress` is skipped by name instead, because it restates two figures already shown and is not something the page cannot read. Where the readings disagree, both figures are kept, lens first. For ZERO they agree on every quantity and disagree only on how many mints count and therefore on how many holders there are: zord 21,120 and 2,330, zecscriptions 21,000 and 2,539. Choosing one would be the explorer inventing a consensus the chain does not have. Divergence is observed from the figures and kept apart from what the authority says diverges, so a summary drifting from the data behind it stays visible rather than being taken as the data. The spec is `/api/v1/zcash/protocols/zrc20/ZERO` as it answered on 2026-08-29, carrying only the fields this reading looks at, with the spellings, nesting and digits unchanged. A fixture written in the shape we expected is how this became a field the page declined to show. This commit is the reading and its spec only. The pages that render it follow. --- .../ruleset-assets.spec.ts | 205 +++++++++++++++ .../multichain-explorer/ruleset-assets.ts | 238 ++++++++++++++++++ 2 files changed, 443 insertions(+) create mode 100644 frontend/src/app/universe/multichain-explorer/ruleset-assets.spec.ts create mode 100644 frontend/src/app/universe/multichain-explorer/ruleset-assets.ts diff --git a/frontend/src/app/universe/multichain-explorer/ruleset-assets.spec.ts b/frontend/src/app/universe/multichain-explorer/ruleset-assets.spec.ts new file mode 100644 index 00000000000..596754831b5 --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/ruleset-assets.spec.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from 'vitest'; +import { + readDecimals, + readRulesetAsset, +} from '@app/universe/multichain-explorer/ruleset-assets'; + +/** + * ZERO, as `/api/v1/zcash/protocols/zrc20/ZERO` returned it on 2026-08-29, + * carrying only the fields this reading looks at. Nothing is paraphrased: the + * spellings, the nesting and the digits are the response's own, because a + * fixture written in the shape we expected is how the whole ledger came to be + * a field the page declined to show. + */ +const ZERO = { + "lens": "zord", + "rulesets": { + "zord": { + "max_supply": "21000000000000000000000000", + "mint_limit": "1000000000000000000000", + "minted": "21000000000000000000000000", + "burned": "0", + "shielded": "6000000000000000000000", + "circulating": "20994000000000000000000000", + "mint_count": "21120", + "holders": "2330", + "mint_progress": { + "minted": "21000000000000000000000000", + "max_supply": "21000000000000000000000000" + }, + "status": "minted" + }, + "zecscriptions": { + "max_supply": "21000000000000000000000000", + "mint_limit": "1000000000000000000000", + "minted": "21000000000000000000000000", + "burned": "0", + "shielded": "6000000000000000000000", + "circulating": "20994000000000000000000000", + "mint_count": "21000", + "holders": "2539", + "mint_progress": { + "minted": "21000000000000000000000000", + "max_supply": "21000000000000000000000000" + }, + "status": "minted" + } + }, + "tick": "ZERO", + "tick_key": "7a65726f", + "decimals": "18", + "deploy_inscription_id": "6d3a32c5a6847b14d9e361ad4384b0e0e1bf82b5b2a2ce5dc204ddf34e34ceeei0", + "deploy_txid": "6d3a32c5a6847b14d9e361ad4384b0e0e1bf82b5b2a2ce5dc204ddf34e34ceee", + "deploy_height": "3133112", + "deployer_address": "t1HzoKn3UXD8vjsS2GzvjGxVUSTKa5NpeH9", + "divergence": { + "diverges": true, + "fields": [ + "mint_count", + "holders" + ], + "absent_from": [], + "unevaluated": [ + { + "id": "zecscriptions-protocol-v2-reveal-outputs", + "summary": "zecscriptions protocol version 2 requires three reveal outputs: the minter at vout 0, a deployer share of 19200 zatoshis at vout 1, and a platform share of 172800 zatoshis at vout 2.", + "reason": "No activation height for protocol version 2 is recorded in the compatibility matrix, so applying the rule would require inventing one. Neither ruleset evaluates it." + }, + { + "id": "shielded-settlement-accounting", + "summary": "A settlement spend into a fully shielded transaction is a permanent burn in zord accounting.", + "reason": "Reported as its own bucket rather than as a ruleset switch. Shielded and burned totals are published separately so either accounting can be derived exactly." + } + ] + } + }; + +describe('an asset reported under two readings of the rules', () => { + it('shifts a quantity by the token decimals and never shifts a count', () => { + const reading = readRulesetAsset(ZERO, 'zord'); + const figure = (key) => reading?.figures.find((f) => f.key === key); + + // 21000000000000000000000000 with 18 decimals is twenty-one million. + // Unshifted it reads as twenty-one septillion, which is what the page + // showed before this, inside a JSON string. + expect(figure('max_supply')?.readings[0].amount).toEqual({ + display: '21,000,000', + exact: '21000000000000000000000000', + }); + expect(figure('mint_limit')?.readings[0].amount?.display).toBe('1,000'); + expect(figure('circulating')?.readings[0].amount?.display).toBe('20,994,000'); + expect(figure('shielded')?.readings[0].amount?.display).toBe('6,000'); + + // Counts are things, not quantities. 2330 holders is 2330 holders. + expect(figure('holders')?.readings[0].amount).toEqual({ display: '2,330', exact: '2330' }); + expect(figure('mint_count')?.readings[0].amount?.display).toBe('21,120'); + }); + + it('keeps the exact source digits beside every rendered figure', () => { + const reading = readRulesetAsset(ZERO, 'zord'); + for (const figure of reading?.figures ?? []) { + for (const value of figure.readings) { + if (value.amount) { + expect(value.amount.exact).toMatch(/^(0|[1-9][0-9]*)$/); + } + } + } + }); + + it('states the decimals once rather than per figure', () => { + const reading = readRulesetAsset(ZERO, 'zord'); + expect(reading?.decimals).toBe(18); + expect(reading?.decimalsExact).toBe('18'); + }); + + it('puts the lens reading first and keeps the other beside it', () => { + expect(readRulesetAsset(ZERO, 'zord')?.rulesets).toEqual(['zord', 'zecscriptions']); + // The order follows the lens, not the object key order. + expect(readRulesetAsset(ZERO, 'zecscriptions')?.rulesets).toEqual([ + 'zecscriptions', + 'zord', + ]); + }); + + it('shows both figures where the readings disagree, and does not pick one', () => { + const reading = readRulesetAsset(ZERO, 'zord'); + const holders = reading?.figures.find((f) => f.key === 'holders'); + expect(holders?.diverges).toBe(true); + expect(holders?.readings.map((r) => [r.ruleset, r.amount?.display])).toEqual([ + ['zord', '2,330'], + ['zecscriptions', '2,539'], + ]); + }); + + it('observes divergence from the figures rather than trusting the summary', () => { + const reading = readRulesetAsset(ZERO, 'zord'); + // What the figures actually show, and what the authority says they show. + // Kept apart on purpose: a summary that drifts from the data behind it is + // the thing this product exists not to repeat. + expect(reading?.divergingFields).toEqual(['mint_count', 'holders']); + expect(reading?.statedDivergingFields).toEqual(['mint_count', 'holders']); + }); + + it('agrees on every quantity, which is the story worth telling', () => { + const reading = readRulesetAsset(ZERO, 'zord'); + const amounts = reading?.figures.filter((f) => f.kind === 'amount') ?? []; + expect(amounts.length).toBeGreaterThan(0); + expect(amounts.every((f) => !f.diverges)).toBe(true); + }); + + it('carries the prose about rules neither reading evaluates', () => { + const reading = readRulesetAsset(ZERO, 'zord'); + expect(reading?.unevaluated).toHaveLength(2); + expect(reading?.unevaluated[0].reason).toContain('would require inventing one'); + expect(reading?.unevaluated[1].summary).toContain('permanent burn'); + }); + + it('names a ruleset field it has no kind for rather than dropping it', () => { + const withNewField = { + ...ZERO, + rulesets: { + zord: { ...ZERO.rulesets.zord, reorg_depth: '7' }, + zecscriptions: ZERO.rulesets.zecscriptions, + }, + }; + const reading = readRulesetAsset(withNewField, 'zord'); + expect(reading?.unreadFields).toEqual(['reorg_depth']); + expect(reading?.figures.some((f) => f.key === 'reorg_depth')).toBe(false); + }); + + it('does not report a restated field as one it cannot read', () => { + // mint_progress repeats minted and max_supply, both already figures. + const reading = readRulesetAsset(ZERO, 'zord'); + expect(reading?.unreadFields).toEqual([]); + expect(reading?.figures.some((f) => f.key === 'mint_progress')).toBe(false); + }); + + it('shows no amount at all rather than an unshifted one when decimals are unreadable', () => { + const noDecimals = { ...ZERO, decimals: 'eighteen' }; + const reading = readRulesetAsset(noDecimals, 'zord'); + expect(reading?.decimals).toBeNull(); + const supply = reading?.figures.find((f) => f.key === 'max_supply'); + expect(supply?.readings[0].amount).toBeNull(); + // A count needs no decimals and is unaffected. + expect(reading?.figures.find((f) => f.key === 'holders')?.readings[0].amount?.display).toBe('2,330'); + }); + + it('is not this reading at all when there are no rulesets', () => { + expect(readRulesetAsset({ tick: 'ZERO' }, 'zord')).toBeNull(); + expect(readRulesetAsset({ tick: 'ZERO', rulesets: {} }, 'zord')).toBeNull(); + expect(readRulesetAsset(null, 'zord')).toBeNull(); + }); +}); + +describe('readDecimals', () => { + it('refuses anything that is not a plain non-negative integer', () => { + expect(readDecimals('18')).toBe(18); + expect(readDecimals('0')).toBe(0); + expect(readDecimals('018')).toBeNull(); + expect(readDecimals('-1')).toBeNull(); + expect(readDecimals('18.0')).toBeNull(); + expect(readDecimals(18)).toBeNull(); + expect(readDecimals('99')).toBeNull(); + expect(readDecimals(null)).toBeNull(); + }); +}); diff --git a/frontend/src/app/universe/multichain-explorer/ruleset-assets.ts b/frontend/src/app/universe/multichain-explorer/ruleset-assets.ts new file mode 100644 index 00000000000..e3a52c43eeb --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/ruleset-assets.ts @@ -0,0 +1,238 @@ +/** + * Assets whose ledger is reported under more than one reading of the rules. + * + * ZRC-20 is the case this exists for. The authority indexes every token twice, + * once under `zord` and once under `zecscriptions`, publishes both ledgers, and + * names the fields the two disagree on. It also publishes prose about rules + * that neither reading evaluates, and why. + * + * None of it reached a page. `rulesets` is an object of objects, so the generic + * fact reader stopped one level above the figures and the table could not hold + * a structure at all. The whole ledger for all 159 tokens was a field the page + * declined to show. + * + * Three rules hold this reading: + * + * - **Quantities are shifted by the token's own `decimals`, and the kinds are + * an allowlist.** A pattern over field names is what printed a DRC-20 supply + * of `100000000000` as `1,000 DOGE`, by matching a field that was not an + * amount. Here the opposite mistake is available: `max_supply` of + * `21000000000000000000000000` is twenty-one million with eighteen decimals + * applied and twenty-one septillion without, and `holders` must never be + * shifted at all. + * - **A field this build has no kind for is named, not dropped.** That is the + * defect this reading exists to fix, and reproducing it one level down would + * be worse for being deliberate. + * - **Where the readings disagree, both figures are shown.** Choosing one and + * presenting it as the answer would be the explorer inventing a consensus + * the chain does not have. + */ + +import { ExactNumber, formatAtomicAmount, humanizeFieldName } from './multichain-view'; + +/** What a ruleset field is, so it is never shifted by the wrong rule. */ +export type FigureKind = + /** A quantity of the token, stated in its smallest unit. */ + | 'amount' + /** A number of things. Never shifted by decimals. */ + | 'count' + /** A word the authority chose. */ + | 'word'; + +/** + * An allowlist, because a pattern is how the wrong field gets matched. Every + * entry here was read from a live `/api/v1/zcash/protocols/zrc20` response. + */ +const FIGURE_KIND: Record = { + max_supply: 'amount', + mint_limit: 'amount', + minted: 'amount', + burned: 'amount', + shielded: 'amount', + circulating: 'amount', + mint_count: 'count', + holders: 'count', + status: 'word', +}; + +/** + * `mint_progress` restates `minted` and `max_supply`, both of which are already + * figures of their own. Skipped by name rather than left to fall through the + * unknown-field path, so it is not reported as something this build cannot read + * when there is nothing in it the page does not already show. + */ +const RESTATED_FIELDS = new Set(['mint_progress']); + +/** One figure, as each reading of the rules reports it. */ +export interface RulesetFigure { + readonly key: string; + readonly label: string; + readonly kind: FigureKind; + /** Lens ruleset first. Every entry is present even when they agree. */ + readonly readings: readonly { + ruleset: string; + amount: ExactNumber | null; + word: string | null; + }[]; + /** True when the readings do not report the same value. */ + readonly diverges: boolean; +} + +/** A rule the authority states that no reading of the rules evaluates. */ +export interface UnevaluatedRule { + readonly id: string; + readonly summary: string; + readonly reason: string; +} + +export interface RulesetAssetReading { + readonly tick: string; + /** + * Stated once and prominently rather than per row. It is the field that makes + * every other number mean anything, and being identical on every row is + * exactly why a table drops it. + */ + readonly decimals: number | null; + readonly decimalsExact: string | null; + /** The reading the authority serves by default. */ + readonly lens: string | null; + readonly rulesets: readonly string[]; + readonly figures: readonly RulesetFigure[]; + /** Fields whose readings differ, observed from the figures themselves. */ + readonly divergingFields: readonly string[]; + /** Fields the authority says diverge. Its claim, checked against the above. */ + readonly statedDivergingFields: readonly string[]; + /** Rulesets the authority says do not carry this asset at all. */ + readonly absentFrom: readonly string[]; + readonly unevaluated: readonly UnevaluatedRule[]; + /** + * Ruleset fields this build carries no kind for. Named, so a figure that is + * in the response and not on the page is visible as an omission rather than + * as an absence. + */ + readonly unreadFields: readonly string[]; +} + +function text(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * `decimals` as a number, or null. + * + * Anything that is not a plain non-negative integer within the shift's + * supported range is refused rather than coerced, because a wrong shift is a + * wrong quantity and there is no partial credit for being close. + */ +export function readDecimals(value: unknown): number | null { + const digits = text(value); + if (digits === null || !/^(0|[1-9][0-9]*)$/.test(digits)) { + return null; + } + const parsed = Number(digits); + return Number.isSafeInteger(parsed) && parsed <= 38 ? parsed : null; +} + +/** + * One asset, read from an item carrying a `rulesets` object. + * + * Returns null for anything without one, so a payload that is not this shape + * falls through to the reading it already had. + */ +export function readRulesetAsset( + item: unknown, + lens: string | null +): RulesetAssetReading | null { + if (!isRecord(item) || !isRecord(item.rulesets)) { + return null; + } + const table = item.rulesets as Record; + const names = Object.keys(table).filter((name) => isRecord(table[name])); + if (!names.length) { + return null; + } + + // Lens first, so the reading the authority serves by default is the one a + // visitor reads first and the others sit beside it rather than replacing it. + const ordered = + lens && names.includes(lens) ? [lens, ...names.filter((name) => name !== lens)] : names; + + const decimalsExact = text(item.decimals); + const decimals = readDecimals(item.decimals); + + const keys: string[] = []; + const unread: string[] = []; + for (const name of ordered) { + for (const key of Object.keys(table[name] as Record)) { + if (RESTATED_FIELDS.has(key)) { + continue; + } + if (!FIGURE_KIND[key]) { + if (!unread.includes(key)) { + unread.push(key); + } + continue; + } + if (!keys.includes(key)) { + keys.push(key); + } + } + } + + const figures: RulesetFigure[] = keys.map((key) => { + const kind = FIGURE_KIND[key]; + const readings = ordered.map((ruleset) => { + const source = text((table[ruleset] as Record)[key]); + if (kind === 'word') { + return { ruleset, amount: null, word: source }; + } + // A count is a number of things and is never shifted. An amount is a + // quantity of the token and is always shifted by that token's own + // decimals, which is why a token whose decimals could not be read shows + // no amount at all rather than an unshifted one. + const precision = kind === 'amount' ? decimals : 0; + return { + ruleset, + amount: precision === null ? null : formatAtomicAmount(source, precision), + word: null, + }; + }); + const first = readings[0]; + const diverges = readings.some( + (reading) => + reading.word !== first.word || + (reading.amount?.exact ?? null) !== (first.amount?.exact ?? null) + ); + return { key, label: humanizeFieldName(key), kind, readings, diverges }; + }); + + const divergence = isRecord(item.divergence) ? item.divergence : {}; + + return { + tick: text(item.tick) ?? '', + decimals, + decimalsExact, + lens: lens && names.includes(lens) ? lens : null, + rulesets: ordered, + figures, + divergingFields: figures.filter((figure) => figure.diverges).map((figure) => figure.key), + statedDivergingFields: Array.isArray(divergence.fields) + ? divergence.fields.filter((field): field is string => typeof field === 'string') + : [], + absentFrom: Array.isArray(divergence.absent_from) + ? divergence.absent_from.filter((name): name is string => typeof name === 'string') + : [], + unevaluated: Array.isArray(divergence.unevaluated) + ? divergence.unevaluated.filter(isRecord).map((rule) => ({ + id: text(rule.id) ?? '', + summary: text(rule.summary) ?? '', + reason: text(rule.reason) ?? '', + })) + : [], + unreadFields: unread, + }; +} From b5cd75f9949d8a105fe6641b983bd607a796525c Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sun, 30 Aug 2026 06:06:48 +0000 Subject: [PATCH 2/6] The tracker panel moved the page when the transaction arrived The transaction route was the one route over the layout budget, at 0.103 on develop and 0.108 with the mobile shell, and it was over for a single shift. The panel under the tracker bar painted at 46 pixels with its rows empty. At about three seconds, when the transaction's own data arrived, two rows appeared in it and it grew to 193. The bottom panel is flex-grow inside a viewport-height column, so it took that as a move and a shrink at once, 148 pixels of each, and that one shift was the whole figure. Reserving the room needs one height, and the note recorded with the exception said the states did not have one. Measured at 390 by 844 they nearly do. A pending transaction settles at two 48px rows, first seen and an ETA. A confirmed one settles at 72 and 68, because below 992px the confirmed timestamp puts its relative time on a second line and the confirmation count is a button rather than a line of text. The replaced shape the note also listed is not on this path at all: this.replaced is only ever set from the txReplaced socket event, so a cold load never starts there. So the row gets the reservation rather than the block. Every row in the panel is two lines of value tall, because the tallest of them is: 4.5em a row, 9em for the pair, held by the block itself so the box survives the waiting state that has no rows in it, and filled by skeleton rows while the transaction is being read. Rows taller than their content centre it, because baseline alignment pins the label to the value's first line and left "Confirmed at" sitting above the relative time instead of beside the pair of them. Measured after, on the same throttle: loading, pending and confirmed all settle at panel 197.3, data 144, rows 72 and 72, bottom panel top 440.7. Nothing moves. The route reads 0.001, twice, against 0.108. The pending panel is 48 pixels taller than it was, which is the cost the old note asked to have measured, and the live pending to confirmed transition no longer jumps 44 pixels at the moment the tracker exists to show. The recorded exception goes with it. The debt table keeps its mechanism and its reasoning and is now empty. The design system records the rule rather than the exception, and the two paragraphs there that still described the performance gate as it was before the runner fix now describe what it does. Co-Authored-By: Claude Opus 5 --- docs/product/DESIGN-SYSTEM.md | 55 +++++++++++++------ .../components/tracker/tracker.component.html | 13 ++++- .../components/tracker/tracker.component.scss | 51 +++++++++++++++++ scripts/universe/visual-qa/mobile-perf.mjs | 33 ++++------- 4 files changed, 112 insertions(+), 40 deletions(-) diff --git a/docs/product/DESIGN-SYSTEM.md b/docs/product/DESIGN-SYSTEM.md index bc6d93230e1..189dc7123e7 100644 --- a/docs/product/DESIGN-SYSTEM.md +++ b/docs/product/DESIGN-SYSTEM.md @@ -443,6 +443,29 @@ matrix, and both fixed layers eat into it. The header drops to 48px, the bottom bar puts each icon beside its label instead of above it, and the scroll padding comes down to match. No destination and no control is removed to buy the space. +### Reserving room for data that has not arrived + +In a column with a viewport height, anything that grows moves everything below +it, and a panel that grows by a third of the screen is the whole layout-shift +budget on its own. So a block that will be filled from the network reserves its +room before the answer arrives, and the loading state fills that same box. + +The reservation needs one height, and states that carry different content do +not have one by default. The transaction tracker is the worked example. Its +rows under the tracker bar settle at two sizes: a pending transaction shows a +first-seen time and an ETA, one line each; a confirmed one shows a timestamp +whose relative time goes to a second line below 992px, and a confirmation count +that is a button rather than a line of text. Reserving the smaller trades one +shift for another. + +The rule that follows: give the row the reservation, not the block. Every row +in that panel is two lines of value tall, because the tallest of them is, so +both settled states are 4.5em a row, the block holds 9em with nothing in it, +and skeleton rows fill it while the transaction is being read. Nothing moves +when the answer arrives, and nothing moves again if a watched transaction +confirms. Rows taller than their content centre it, because a reservation that +shows as a gap under the text reads as a mistake. + ### The safe area `viewport-fit=cover` is set, so the page runs under the notch and the home @@ -619,7 +642,7 @@ rather than of the machine: | Budget | Value | Why it is gateable | |---|---|---| | eager payload, compressed | 480kB | the same commit gives the same number anywhere | -| cumulative layout shift | 0.1 | a property of the stylesheet, not the clock | +| cumulative layout shift | 0.1 | mostly the stylesheet, and the load can only add to it | The eager payload is runtime, polyfills, main and styles: what a browser must have before the shell exists. Measured at `bea93c1ec`, develop was 463kB @@ -627,26 +650,26 @@ compressed and 1655kB raw; with the mobile work it is 464kB and 1659kB. The ceiling is set where a real regression trips it and ordinary drift does not. Raise it deliberately, and say what bought the bytes. +Layout shift is less deterministic than a stylesheet property sounds. The same +tree measured 0.042, 0.046 and 0.069 on the blocks route across three machines, +because a busier machine delivers content later and a shift that lands after +the first paint counts while the same shift before it does not. Load can only +add shifts, never remove one, so a route over its ceiling is measured again, up +to twice, and judged on the smallest reading. The table says when a route was +measured more than once, because a gate that quietly retries until it passes is +worse than one that fails. + Largest contentful paint is printed and not gated. The 2.5 second target is a field number for a real device on a real network, and this is a throttled headless browser on a runner that is also building and serving. The same commit measured twelve seconds and twenty on two routes whose shells are identical. A number that swings by eight seconds between runs of the same code cannot gate anything; used as one it fails honest changes and passes slow ones depending on -what else the machine is doing. What is still failed is a paint that never -happens, which is a shell that did not render rather than one that rendered -slowly. - -One route is over the layout budget for a reason that is written down rather -than hidden. On the transaction route, at about three seconds, `div.panel` -grows from 46 to 193 pixels as the tracker bar and the confirmations render -into it, pushing `div.bottom-panel` down 148 pixels. That is 0.103 of shift, -measured on develop before this work and 0.108 after. It is recorded with its -figure and printed every run, and the route is held to that figure, so drifting -further fails even while it is over. Reserving the room means knowing the -panel's height before the transaction is read, and it is not one height: an -alert, a tracker bar and a confirmation count are three different sizes. That -is work on the tracker's loading design, not on the shell. +what else the machine is doing. The paint is waited for rather than read once, +and when it still has not arrived the page is asked what is on it: a screenful +of text with no paint entry is a measurement problem and is reported as one, +while a page with nothing on it is the fault the check exists for. That +distinction does not move with the load. ### Real devices @@ -674,7 +697,7 @@ rotation, back navigation, and a live update arriving. | `scripts/universe/visual-qa/capture.mjs` | the route matrix: themes, widths, and data states | | `scripts/universe/visual-qa/modes-check.mjs` | forced colours and 200 percent zoom | | `scripts/universe/visual-qa/mobile-check.mjs` | the mobile gate: windows, insets, targets, fixed layers, rotation | -| `scripts/universe/visual-qa/mobile-perf.mjs` | the payload, the layout shift, and the recorded exception to it | +| `scripts/universe/visual-qa/mobile-perf.mjs` | the payload, the layout shift, and the table for recording a route that is over budget | | `frontend/src/app/universe/universe-viewport.service.ts` | the visual viewport, published as CSS, for the software keyboard | | `scripts/universe/visual-qa/fixtures.mjs` | the data every reviewed route renders from | diff --git a/frontend/src/app/components/tracker/tracker.component.html b/frontend/src/app/components/tracker/tracker.component.html index 76d4d47ee84..29c4aa1f48e 100644 --- a/frontend/src/app/components/tracker/tracker.component.html +++ b/frontend/src/app/components/tracker/tracker.component.html @@ -52,7 +52,7 @@ }
@if (tx && !tx.status?.confirmed) { -
+
First seen
@if (transactionTime > 0) { @@ -82,7 +82,7 @@
} } @else if (tx && tx.status?.confirmed) { -
+
Confirmed at
@@ -106,6 +106,15 @@
+ } @else if (isLoading) { + + }
diff --git a/frontend/src/app/components/tracker/tracker.component.scss b/frontend/src/app/components/tracker/tracker.component.scss index 683acef7d27..d68721eaa7b 100644 --- a/frontend/src/app/components/tracker/tracker.component.scss +++ b/frontend/src/app/components/tracker/tracker.component.scss @@ -147,6 +147,57 @@ padding: 1em 0.5em 0; } +/** + * The two rows under the tracker bar, held at one size. + * + * They are what used to move the page. The panel painted at 45px with this + * block empty, and at about three seconds, when the transaction arrived, two + * rows appeared in it and pushed everything below down by 148 pixels. The + * bottom panel is flex-grow inside a viewport-height column, so it took the + * push as a move and a shrink at once, and that single shift was the route's + * whole 0.103 of layout shift. + * + * Reserving the room needs one height, and the states did not have one. + * Measured at 390 by 844: a pending transaction settles at two 48px rows, + * first seen and an ETA; a confirmed one at 72 and 68, because below 992px + * the confirmed timestamp puts its relative time on a second line and the + * confirmation count is a button rather than a line of text. Reserving the + * smaller would have traded one shift for another. + * + * So the row is what gets the reservation, not the block: every row here is + * two lines of value tall, because the tallest of them is. Both settled + * states are then 4.5em a row and 9em together, the block holds that height + * with nothing in it, and skeleton rows fill it while the transaction is being + * read. Nothing moves when + * the transaction arrives, and nothing moves again if it confirms while it is + * being watched, which used to be a second 44px jump at the moment the + * tracker exists to show. + */ +.data { + min-height: 9em; + // Carried here from the first row, which used to hold it, so that the space + // above the rows is there in the states that have no rows to hold it. + margin-top: 0.5rem; + + .field.narrower { + // The row is taller than most of its content now, so the content sits in + // the middle of it rather than against the top with the reservation + // showing as a gap underneath. Baseline alignment cannot do this: it pins + // the label to the value's first line, which on the confirmed row is the + // date and leaves the label above the relative time rather than beside + // the pair of them. + align-items: center; + + .value { + min-height: 3em; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-end; + } + } +} + .accelerate { display: flex !important; align-self: auto; diff --git a/scripts/universe/visual-qa/mobile-perf.mjs b/scripts/universe/visual-qa/mobile-perf.mjs index b7d0e28e8ae..1158c8abfa6 100644 --- a/scripts/universe/visual-qa/mobile-perf.mjs +++ b/scripts/universe/visual-qa/mobile-perf.mjs @@ -96,30 +96,19 @@ const BUDGETS = { /** * Routes that are over the layout budget for a reason that predates this gate. * - * One entry, and it is here rather than quietly excluded because a budget - * nobody can see is not a budget. The number is what develop measured at - * bea93c1ec under the same throttle, so a route drifting further gets caught - * even while it is over. + * Empty, and kept rather than deleted, because the mechanism is the point: a + * route that is over budget for a reason somebody wrote down is recorded here + * with the figure it measured, printed on every run, and held to that figure, + * so it cannot drift further while it is over. A budget nobody can see is not + * a budget, and an exclusion nobody can see is not an exception. * - * The transaction route: at about three seconds, when the transaction's own - * data arrives, `div.panel` grows from 46 to 193 pixels as the tracker bar and - * the confirmations render into it. That pushes `div.bottom-panel` down by 148 - * and shrinks it by the same, which is the whole 0.103. - * - * Reserving that room means knowing how tall the panel will be before the - * transaction is read, and it is not one height: a replaced transaction shows - * an alert, a pending one a tracker bar, a confirmed one a confirmation count. - * A single min-height would trade a measured shift for unmeasured dead space - * on the states that are shorter. That is a piece of work on the tracker's - * loading design rather than on the shell, so it is written down here with its - * measurement instead of being fixed badly or hidden. + * The one entry this held was the transaction route, whose panel grew from 46 + * to 193 pixels when the transaction arrived and pushed the bottom panel down + * 148. That is fixed rather than recorded now: the panel's rows are reserved + * at one height, so the box is the same before and after the transaction is + * read. See the note beside `.data` in `tracker.component.scss`. */ -const KNOWN_LAYOUT_DEBT = { - tx: { - cls: 0.103, - note: 'div.panel grows 46 to 193px when the transaction arrives, pushing div.bottom-panel down 148px', - }, -}; +const KNOWN_LAYOUT_DEBT = {}; /** How much worse than the recorded figure counts as a regression rather than * noise. Two runs of the same commit moved this by 0.005. */ From 578e221311c9e377d23e6a5afa490afc565bc9d1 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sun, 30 Aug 2026 06:18:34 +0000 Subject: [PATCH 3/6] The ledger reached a page, under both readings of the rules The reading model existed and nothing called it. The list page fell through to the generic table, which named rulesets and divergence as structures it was holding back, and the token page dumped the same ledger as a JSON string in a cell. Now the token page states the decimals once, shows every figure under each ruleset side by side, marks the rows where the readings disagree, cross-checks the authority's own divergence summary against the figures, and carries the authority's prose about rules neither reading evaluates. The list page shows four chosen figures per token, names the five it holds back, stacks both readings in any cell where they disagree, and links each token to its page, which nothing did before. The fixtures are captured from the live origin, trimmed and otherwise unedited, and the model checks assert invariants rather than values: an amount is shifted only by the token's own decimals, a count is never shifted, and divergence is observed from the figures rather than trusted from the summary. The visual matrix gains both pages as gated routes, with a fixture token whose readings disagree, one that agrees, and one with zero decimals. Co-Authored-By: Claude Fable 5 --- .github/workflows/universe-ci.yml | 2 +- .../live-model-check.spec.ts | 61 ++++ .../multichain-explorer/live-payloads.ts | 303 ++++++++++++++++++ .../multichain-explorer.component.html | 148 +++++++++ .../multichain-explorer.component.scss | 64 ++++ .../multichain-explorer.component.ts | 60 +++- .../ruleset-assets.spec.ts | 162 +++++++--- .../multichain-explorer/ruleset-assets.ts | 125 ++++++++ scripts/universe/visual-qa/capture.mjs | 5 + scripts/universe/visual-qa/chain-fixtures.mjs | 101 +++++- 10 files changed, 978 insertions(+), 53 deletions(-) diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml index 8ca0e214e18..2e5e3601de5 100644 --- a/.github/workflows/universe-ci.yml +++ b/.github/workflows/universe-ci.yml @@ -268,7 +268,7 @@ jobs: working-directory: scripts/universe/visual-qa run: >- node capture.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts-chain-states - --routes=dogecoin,dogecoin-drc20,zcash,zcash-block + --routes=dogecoin,dogecoin-drc20,zcash,zcash-block,zcash-zrc20,zcash-zrc20-token --states=chain-authority-down,chain-behind,chain-not-ready-protocols,chain-not-ready-unexplained,chain-object-missing --themes=default,dark --viewports=375,1440 diff --git a/frontend/src/app/universe/multichain-explorer/live-model-check.spec.ts b/frontend/src/app/universe/multichain-explorer/live-model-check.spec.ts index 8eb011b0447..64a13e20df0 100644 --- a/frontend/src/app/universe/multichain-explorer/live-model-check.spec.ts +++ b/frontend/src/app/universe/multichain-explorer/live-model-check.spec.ts @@ -18,6 +18,10 @@ import { readTransaction, readTransactionList, } from '@app/universe/multichain-explorer/multichain-view'; +import { + readRulesetAsset, + readRulesetAssetList, +} from '@app/universe/multichain-explorer/ruleset-assets'; const PAYLOADS: Record = { dogecoin_status: LIVE_PAYLOADS.dogecoinStatus, @@ -26,6 +30,8 @@ const PAYLOADS: Record = { zcash_mempool: LIVE_PAYLOADS.zcashMempool, dogecoin_protocols: LIVE_PAYLOADS.dogecoinProtocols, zcash_protocols: LIVE_PAYLOADS.zcashProtocols, + zrc20_list: LIVE_PAYLOADS.zrc20List, + zrc20_detail: LIVE_PAYLOADS.zrc20Detail, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- captured payloads are read as the API sends them const load = (name: string): any => structuredClone(PAYLOADS[name]); @@ -186,3 +192,58 @@ describe('live protocol manifests', () => { }); } }); + +describe('live zrc20 ledgers', () => { + it('reads the whole list page, with nothing silently dropped', () => { + const payload = load('zrc20_list'); + const list = readRulesetAssetList(payload); + expect(list).not.toBeNull(); + expect(list!.shownCount).toBe(payload.items.length); + expect(list!.unreadRowCount).toBe(0); + expect(list!.lens).toBe(payload.lens); + // Every figure the ledger carries is either a column or named as held + // back. The union of the two is the ledger. + const shown = list!.columns.map((column) => column.key); + for (const row of list!.rows) { + for (const figure of row.reading.figures) { + expect(shown.includes(figure.key) || list!.hiddenFigureFields.includes(figure.key)).toBe(true); + } + expect(row.reading.unreadFields).toEqual([]); + } + }); + + it('reads the detail payload as one asset under every ruleset it is in', () => { + const payload = load('zrc20_detail'); + const asset = readRulesetAsset(payload, payload.lens); + expect(asset).not.toBeNull(); + expect(asset!.tick).toBe(payload.tick); + expect(asset!.rulesets[0]).toBe(payload.lens); + expect(asset!.rulesets.length).toBeGreaterThan(1); + expect(asset!.unreadFields).toEqual([]); + // Whatever the values are when recaptured, exactness is the invariant: + // every amount carries its source digits, shifted only by the token's own + // decimals, never by the chain's. + for (const figure of asset!.figures) { + for (const reading of figure.readings) { + if (reading.amount) { + expect(reading.amount.exact).toMatch(/^(0|[1-9][0-9]*)$/); + } + } + } + }); + + it('observes divergence for itself rather than trusting the summary', () => { + const payload = load('zrc20_detail'); + const asset = readRulesetAsset(payload, payload.lens); + // The capture disagrees on holders and mint_count. If a recapture stops + // disagreeing, this assertion is about the cross-check, not the values: + // observed and stated must be derived independently and then compared. + expect(asset!.divergingFields).toEqual(asset!.statedDivergingFields); + }); + + it('does not fall through to the generic table for a ruleset list', () => { + const payload = load('zrc20_list'); + expect(classifyPayload(payload)).toBe('collection'); + expect(readRulesetAssetList(payload)).not.toBeNull(); + }); +}); diff --git a/frontend/src/app/universe/multichain-explorer/live-payloads.ts b/frontend/src/app/universe/multichain-explorer/live-payloads.ts index 81f059a122d..8a3e37bd131 100644 --- a/frontend/src/app/universe/multichain-explorer/live-payloads.ts +++ b/frontend/src/app/universe/multichain-explorer/live-payloads.ts @@ -1595,5 +1595,308 @@ export const LIVE_PAYLOADS = { confirmedCoverage: 'complete', confirmedLagBlocksAtomic: '2' } + }, + + /** + * The ZRC-20 list, captured 2026-08-30T06:07Z from the same origin, + * release mempool-e21a26d5c, overlay backend-apis-711b6c25. Trimmed from + * fifty items to three; nothing else is edited. Each item carries the + * whole ledger twice, under `rulesets`, and `divergence` naming where + * the readings disagree, which is what the ruleset reading exists for. + */ + zrc20List: { + 'schemaVersion': 'zcash-metaprotocols-api-v1', + 'network': 'mainnet', + 'checkpoint': { + 'height': '3465591', + 'hash': '00000000003153f1b17b3dc0d059dcfa6e093adf9c7467fcfad8debe25bfa123' + }, + 'coverage': { + 'scannedHeight': '3465591', + 'networkHeight': '3465591', + 'blocksBehindNetwork': '0', + 'nodeSynced': true, + 'verificationProgress': 1, + 'chainComplete': true + }, + 'lens': 'zord', + 'rulesets': [ + 'zord', + 'zecscriptions' + ], + 'total': '159', + 'limit': '50', + 'offset': '0', + 'items': [ + { + 'tick': 'ZERO', + 'tick_key': '7a65726f', + 'decimals': '18', + 'deploy_inscription_id': '6d3a32c5a6847b14d9e361ad4384b0e0e1bf82b5b2a2ce5dc204ddf34e34ceeei0', + 'deploy_txid': '6d3a32c5a6847b14d9e361ad4384b0e0e1bf82b5b2a2ce5dc204ddf34e34ceee', + 'deploy_height': '3133112', + 'deployer_address': 't1HzoKn3UXD8vjsS2GzvjGxVUSTKa5NpeH9', + 'rulesets': { + 'zord': { + 'max_supply': '21000000000000000000000000', + 'mint_limit': '1000000000000000000000', + 'minted': '21000000000000000000000000', + 'burned': '0', + 'shielded': '6000000000000000000000', + 'circulating': '20994000000000000000000000', + 'mint_count': '21120', + 'holders': '2330', + 'mint_progress': { + 'minted': '21000000000000000000000000', + 'max_supply': '21000000000000000000000000' + }, + 'status': 'minted' + }, + 'zecscriptions': { + 'max_supply': '21000000000000000000000000', + 'mint_limit': '1000000000000000000000', + 'minted': '21000000000000000000000000', + 'burned': '0', + 'shielded': '6000000000000000000000', + 'circulating': '20994000000000000000000000', + 'mint_count': '21000', + 'holders': '2539', + 'mint_progress': { + 'minted': '21000000000000000000000000', + 'max_supply': '21000000000000000000000000' + }, + 'status': 'minted' + } + }, + 'divergence': { + 'diverges': true, + 'fields': [ + 'mint_count', + 'holders' + ], + 'absent_from': [], + 'unevaluated': [ + { + 'id': 'zecscriptions-protocol-v2-reveal-outputs', + 'summary': 'zecscriptions protocol version 2 requires three reveal outputs: the minter at vout 0, a deployer share of 19200 zatoshis at vout 1, and a platform share of 172800 zatoshis at vout 2.', + 'reason': 'No activation height for protocol version 2 is recorded in the compatibility matrix, so applying the rule would require inventing one. Neither ruleset evaluates it.' + }, + { + 'id': 'shielded-settlement-accounting', + 'summary': 'A settlement spend into a fully shielded transaction is a permanent burn in zord accounting.', + 'reason': 'Reported as its own bucket rather than as a ruleset switch. Shielded and burned totals are published separately so either accounting can be derived exactly.' + } + ] + } + }, + { + 'tick': 'ZATS', + 'tick_key': '7a617473', + 'decimals': '18', + 'deploy_inscription_id': 'e1dea1d897c24b19496aa246d86275bb24c0090203226e572bd5c623f5f0aaa2i0', + 'deploy_txid': 'e1dea1d897c24b19496aa246d86275bb24c0090203226e572bd5c623f5f0aaa2', + 'deploy_height': '3133597', + 'deployer_address': 't1KiqdVSBkCe9BcBYNsG3EVD7nvBmPofHVc', + 'rulesets': { + 'zord': { + 'max_supply': '2100000000000000000000000000000000', + 'mint_limit': '500000000000000000000000000000', + 'minted': '2099941120287959000000000000000000', + 'burned': '0', + 'shielded': '0', + 'circulating': '2099941120287959000000000000000000', + 'mint_count': '4306', + 'holders': '420', + 'mint_progress': { + 'minted': '2099941120287959000000000000000000', + 'max_supply': '2100000000000000000000000000000000' + }, + 'status': 'minting' + }, + 'zecscriptions': { + 'max_supply': '2100000000000000000000000000000000', + 'mint_limit': '500000000000000000000000000000', + 'minted': '2100000000000000000000000000000000', + 'burned': '0', + 'shielded': '0', + 'circulating': '2100000000000000000000000000000000', + 'mint_count': '4200', + 'holders': '413', + 'mint_progress': { + 'minted': '2100000000000000000000000000000000', + 'max_supply': '2100000000000000000000000000000000' + }, + 'status': 'minted' + } + }, + 'divergence': { + 'diverges': true, + 'fields': [ + 'minted', + 'circulating', + 'mint_count', + 'holders' + ], + 'absent_from': [], + 'unevaluated': [ + { + 'id': 'zecscriptions-protocol-v2-reveal-outputs', + 'summary': 'zecscriptions protocol version 2 requires three reveal outputs: the minter at vout 0, a deployer share of 19200 zatoshis at vout 1, and a platform share of 172800 zatoshis at vout 2.', + 'reason': 'No activation height for protocol version 2 is recorded in the compatibility matrix, so applying the rule would require inventing one. Neither ruleset evaluates it.' + }, + { + 'id': 'shielded-settlement-accounting', + 'summary': 'A settlement spend into a fully shielded transaction is a permanent burn in zord accounting.', + 'reason': 'Reported as its own bucket rather than as a ruleset switch. Shielded and burned totals are published separately so either accounting can be derived exactly.' + } + ] + } + }, + { + 'tick': 'ZILL', + 'tick_key': '7a696c6c', + 'decimals': '18', + 'deploy_inscription_id': 'e9ff942cefa0fe09b243170cf80047c1b2e7252f34ce04297cb965b8f4ead318i0', + 'deploy_txid': 'e9ff942cefa0fe09b243170cf80047c1b2e7252f34ce04297cb965b8f4ead318', + 'deploy_height': '3133600', + 'deployer_address': 't1XkJq7Z7dQ1s1ksFUx7qnk9L8A5rTjxaPU', + 'rulesets': { + 'zord': { + 'max_supply': '21000000000000000000000000', + 'mint_limit': '1000000000000000000000', + 'minted': '83100000000000000000000', + 'burned': '0', + 'shielded': '0', + 'circulating': '83100000000000000000000', + 'mint_count': '84', + 'holders': '24', + 'mint_progress': { + 'minted': '83100000000000000000000', + 'max_supply': '21000000000000000000000000' + }, + 'status': 'minting' + }, + 'zecscriptions': { + 'max_supply': '21000000000000000000000000', + 'mint_limit': '1000000000000000000000', + 'minted': '83000000000000000000000', + 'burned': '0', + 'shielded': '0', + 'circulating': '83000000000000000000000', + 'mint_count': '83', + 'holders': '24', + 'mint_progress': { + 'minted': '83000000000000000000000', + 'max_supply': '21000000000000000000000000' + }, + 'status': 'minting' + } + }, + 'divergence': { + 'diverges': true, + 'fields': [ + 'minted', + 'circulating', + 'mint_count' + ], + 'absent_from': [], + 'unevaluated': [ + { + 'id': 'zecscriptions-protocol-v2-reveal-outputs', + 'summary': 'zecscriptions protocol version 2 requires three reveal outputs: the minter at vout 0, a deployer share of 19200 zatoshis at vout 1, and a platform share of 172800 zatoshis at vout 2.', + 'reason': 'No activation height for protocol version 2 is recorded in the compatibility matrix, so applying the rule would require inventing one. Neither ruleset evaluates it.' + }, + { + 'id': 'shielded-settlement-accounting', + 'summary': 'A settlement spend into a fully shielded transaction is a permanent burn in zord accounting.', + 'reason': 'Reported as its own bucket rather than as a ruleset switch. Shielded and burned totals are published separately so either accounting can be derived exactly.' + } + ] + } + } + ], + 'chain': 'zcash' + }, + + /** + * One token, captured 2026-08-30T06:08Z from + * /api/v1/zcash/protocols/zrc20/ZERO on the same release. Unedited. + */ + zrc20Detail: { + 'schemaVersion': 'zcash-metaprotocols-api-v1', + 'network': 'mainnet', + 'checkpoint': { + 'height': '3465591', + 'hash': '00000000003153f1b17b3dc0d059dcfa6e093adf9c7467fcfad8debe25bfa123' + }, + 'coverage': { + 'scannedHeight': '3465591', + 'networkHeight': '3465592', + 'blocksBehindNetwork': '1', + 'nodeSynced': true, + 'verificationProgress': 0.9999997114490108, + 'chainComplete': true + }, + 'lens': 'zord', + 'rulesets': { + 'zord': { + 'max_supply': '21000000000000000000000000', + 'mint_limit': '1000000000000000000000', + 'minted': '21000000000000000000000000', + 'burned': '0', + 'shielded': '6000000000000000000000', + 'circulating': '20994000000000000000000000', + 'mint_count': '21120', + 'holders': '2330', + 'mint_progress': { + 'minted': '21000000000000000000000000', + 'max_supply': '21000000000000000000000000' + }, + 'status': 'minted' + }, + 'zecscriptions': { + 'max_supply': '21000000000000000000000000', + 'mint_limit': '1000000000000000000000', + 'minted': '21000000000000000000000000', + 'burned': '0', + 'shielded': '6000000000000000000000', + 'circulating': '20994000000000000000000000', + 'mint_count': '21000', + 'holders': '2539', + 'mint_progress': { + 'minted': '21000000000000000000000000', + 'max_supply': '21000000000000000000000000' + }, + 'status': 'minted' + } + }, + 'tick': 'ZERO', + 'tick_key': '7a65726f', + 'decimals': '18', + 'deploy_inscription_id': '6d3a32c5a6847b14d9e361ad4384b0e0e1bf82b5b2a2ce5dc204ddf34e34ceeei0', + 'deploy_txid': '6d3a32c5a6847b14d9e361ad4384b0e0e1bf82b5b2a2ce5dc204ddf34e34ceee', + 'deploy_height': '3133112', + 'deployer_address': 't1HzoKn3UXD8vjsS2GzvjGxVUSTKa5NpeH9', + 'divergence': { + 'diverges': true, + 'fields': [ + 'mint_count', + 'holders' + ], + 'absent_from': [], + 'unevaluated': [ + { + 'id': 'zecscriptions-protocol-v2-reveal-outputs', + 'summary': 'zecscriptions protocol version 2 requires three reveal outputs: the minter at vout 0, a deployer share of 19200 zatoshis at vout 1, and a platform share of 172800 zatoshis at vout 2.', + 'reason': 'No activation height for protocol version 2 is recorded in the compatibility matrix, so applying the rule would require inventing one. Neither ruleset evaluates it.' + }, + { + 'id': 'shielded-settlement-accounting', + 'summary': 'A settlement spend into a fully shielded transaction is a permanent burn in zord accounting.', + 'reason': 'Reported as its own bucket rather than as a ruleset switch. Shielded and burned totals are published separately so either accounting can be derived exactly.' + } + ] + }, + 'chain': 'zcash' } } as const; diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html index 53e94781606..907d285c860 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html @@ -551,6 +551,154 @@

Output

+ + + + + +
+

Ledger under each ruleset

+

+ This token is indexed once under each of these rules: {{ rulesetNames(asset.rulesets) }}. Where the readings disagree, both figures are shown. The explorer does not choose between them. +

+ +
+
+
Decimals
+
+ {{ asset.decimalsExact }} + Every quantity below is shifted by this. +
+
+
+
Default reading
+
{{ asset.lens }}
+
+
+ +

+ This token's decimals could not be read, so no shifted quantity is shown. An unshifted figure presented as one would be wrong by a factor the reader cannot see. The exact digits are in the full response below. +

+ +
+ + + + + + + + + + + + + +
Figure{{ name }}
+ {{ figure.label }} + Readings differ + + {{ value.amount.display }} + {{ value.word }} + not shown +
+
+ +

+ The authority reports this token as absent from: {{ rulesetNames(asset.absentFrom) }}. +

+

+ The authority's own summary names {{ asset.statedDivergingFields.join(', ') || 'no fields' }} as where the readings disagree, while the figures above show {{ asset.divergingFields.join(', ') || 'none' }}. Both statements are kept; the difference is a fact about the source. +

+

+ The ledger also carries {{ asset.unreadFields.join(', ') }}, which this build has no reading for. They are named here rather than shifted by a rule that may not apply, and appear in the full response below. +

+
+ +
+

Rules neither reading evaluates

+

+ The authority states these rules exist and explains why no reading applies them. They are part of this token's story, so they are here in the authority's own words. +

+
    +
  • +

    {{ rule.summary }}

    +

    {{ rule.reason }}

    +
  • +
+
+ +
+ + + + +
+

Tokens

+

+ Every token here is indexed once under each of these rules: {{ rulesetNames(list.rulesets) }}. Figures are the {{ list.lens }} reading; a cell whose readings disagree shows every reading, and each token's page shows its full ledger under both. +

+

+ Showing the first {{ list.shownCount }} of {{ list.totalExact }} tokens the authority reports. +

+

+ Each ledger also carries {{ list.hiddenFigureFields.join(', ') }}, which are not columns here. Every one of them is on the token's own page. +

+
+ + + + + + + + + + + + + + + +
Token{{ column.label }}Readings
+ {{ row.tick }} + + + + {{ figure.readings[0].amount?.display ?? figure.readings[0].word ?? '' }} + + + + {{ value.amount?.display ?? value.word ?? '' }} + {{ value.ruleset }} + + + + not reported + + + Differ + Agree + +
+
+

+ {{ list.unreadRowCount }} entries in the response carry no ruleset ledger and are only in the full response below. +

+
+ diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss index a375a416caf..07b32aa2de3 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss @@ -775,6 +775,70 @@ tbody tr:last-child td { border-bottom: 0; } +// --------------------------------------------------------------------------- +// Ruleset ledgers +// --------------------------------------------------------------------------- + +// The figure names are row headers, not cells: the ledger reads figure by +// figure, and each ruleset's value sits beside the others under its own name. +.ruleset-ledger, +.ruleset-list { + tbody th { + color: var(--u-text-secondary); + font-weight: var(--u-weight-semibold); + font-size: var(--u-text-sm); + text-align: left; + padding: var(--u-space-2) var(--u-space-3) var(--u-space-2) 0; + border-bottom: 1px solid var(--u-divider); + vertical-align: top; + white-space: nowrap; + + .universe-chip { + margin-left: var(--u-space-2); + } + } + + tbody tr:last-child th { + border-bottom: 0; + } + + // A row whose readings disagree is the row a visitor came to check. The + // mark is a border rather than a fill, so it survives forced colours. + tr.diverges th, + tr.diverges td { + border-bottom-color: var(--u-border); + } +} + +// Both figures of a diverging cell, stacked, each naming its ruleset. On one +// line they read as a single number with a word in it. +.reading-stack { + display: block; + white-space: nowrap; +} + +// The authority's prose about rules neither reading evaluates. +.rule-list { + list-style: none; + margin: 0; + padding: 0; + + li + li { + margin-top: var(--u-space-3); + padding-top: var(--u-space-3); + border-top: 1px solid var(--u-divider); + } + + p { + margin: 0; + } + + .rule-reason { + margin-top: var(--u-space-1); + font-size: var(--u-text-sm); + } +} + // --------------------------------------------------------------------------- // Evidence // --------------------------------------------------------------------------- diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts index 6951ac0bd48..32b2fd5f7cb 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts @@ -60,6 +60,12 @@ import { shortenIdentifier, } from '@app/universe/multichain-explorer/multichain-view'; import { ChainReasonReading } from '@app/universe/multichain-explorer/chain-reasons'; +import { + RulesetAssetListReading, + RulesetAssetReading, + readRulesetAsset, + readRulesetAssetList, +} from '@app/universe/multichain-explorer/ruleset-assets'; import { Observable, Subject, @@ -106,6 +112,10 @@ interface ExplorerViewModel { readonly outpoint: OutpointReading | null; readonly collection: CollectionReading | null; readonly transactionList: TransactionListReading | null; + /** One asset whose ledger is reported under more than one ruleset. */ + readonly rulesetAsset: RulesetAssetReading | null; + /** A page of such assets, each row carrying its own ledger. */ + readonly rulesetList: RulesetAssetListReading | null; readonly emptyList: EmptyListReading | null; readonly facts: readonly Fact[]; /** True when the page is showing a response it has no purpose-built reading for. */ @@ -285,8 +295,14 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { payload && shape === 'collection' ? readTransactionList(payload, this.profile) : null; + // Both readings are shape-driven: a payload without a rulesets ledger, or + // without items carrying one, is null here and keeps the reading it had. + const rulesetAsset = payload + ? readRulesetAsset(payload, typeof payload.lens === 'string' ? payload.lens : null) + : null; + const rulesetList = payload && !transactionList ? readRulesetAssetList(payload) : null; const collection = - payload && !transactionList && (shape === 'collection' || shape === 'record') + payload && !transactionList && !rulesetList && (shape === 'collection' || shape === 'record') ? readCollection(payload, this.profile) : null; const skip = [ @@ -294,6 +310,12 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { ...(collection ? [collection.sourceKey] : []), // The transaction list owns the array it read, whichever field it was in. ...(transactionList ? ['transactions', 'items'] : []), + // The ruleset reading owns the ledger, the divergence prose, the lens, + // and the decimals it states prominently. `tick` repeats the heading. + ...(rulesetAsset ? ['rulesets', 'divergence', 'lens', 'decimals', 'tick'] : []), + // The list owns its rows, and its lede states the lens, the ruleset + // names and the authority's total. + ...(rulesetList ? ['items', 'rulesets', 'lens', 'total'] : []), ]; return { capability, @@ -313,9 +335,13 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { outpoint, collection, transactionList, - emptyList: transactionList || collection ? null : readEmptyList(payload), + rulesetAsset, + rulesetList, + emptyList: + transactionList || collection || rulesetList ? null : readEmptyList(payload), facts: readRecordFacts(payload, this.profile, skip), - generic: shape === 'collection' || shape === 'record', + generic: + (shape === 'collection' || shape === 'record') && !rulesetAsset && !rulesetList, }; } @@ -421,6 +447,34 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { .join(', '); } + /** + * True when the authority's own divergence summary and the divergence the + * figures actually show do not name the same fields. The two are kept apart + * on purpose, and a drift between them is a finding the page states rather + * than a difference it smooths over. + */ + rulesetClaimDiffers(asset: RulesetAssetReading): boolean { + const observed = [...asset.divergingFields].sort().join(','); + const stated = [...asset.statedDivergingFields].sort().join(','); + return observed !== stated; + } + + /** True when the authority reports more tokens than this page shows. */ + rulesetListTruncated(list: RulesetAssetListReading): boolean { + if (!list.totalExact || !/^(0|[1-9][0-9]*)$/.test(list.totalExact)) { + return false; + } + return list.shownCount < Number(list.totalExact); + } + + /** "zord and zecscriptions", or the one name when there is one. */ + rulesetNames(names: readonly string[]): string { + if (names.length < 2) { + return names.join(''); + } + return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`; + } + /** * The identifier a technical detail was last copied from. * diff --git a/frontend/src/app/universe/multichain-explorer/ruleset-assets.spec.ts b/frontend/src/app/universe/multichain-explorer/ruleset-assets.spec.ts index 596754831b5..092adf8dace 100644 --- a/frontend/src/app/universe/multichain-explorer/ruleset-assets.spec.ts +++ b/frontend/src/app/universe/multichain-explorer/ruleset-assets.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { readDecimals, readRulesetAsset, + readRulesetAssetList, } from '@app/universe/multichain-explorer/ruleset-assets'; /** @@ -12,63 +13,63 @@ import { * a field the page declined to show. */ const ZERO = { - "lens": "zord", - "rulesets": { - "zord": { - "max_supply": "21000000000000000000000000", - "mint_limit": "1000000000000000000000", - "minted": "21000000000000000000000000", - "burned": "0", - "shielded": "6000000000000000000000", - "circulating": "20994000000000000000000000", - "mint_count": "21120", - "holders": "2330", - "mint_progress": { - "minted": "21000000000000000000000000", - "max_supply": "21000000000000000000000000" + 'lens': 'zord', + 'rulesets': { + 'zord': { + 'max_supply': '21000000000000000000000000', + 'mint_limit': '1000000000000000000000', + 'minted': '21000000000000000000000000', + 'burned': '0', + 'shielded': '6000000000000000000000', + 'circulating': '20994000000000000000000000', + 'mint_count': '21120', + 'holders': '2330', + 'mint_progress': { + 'minted': '21000000000000000000000000', + 'max_supply': '21000000000000000000000000' }, - "status": "minted" + 'status': 'minted' }, - "zecscriptions": { - "max_supply": "21000000000000000000000000", - "mint_limit": "1000000000000000000000", - "minted": "21000000000000000000000000", - "burned": "0", - "shielded": "6000000000000000000000", - "circulating": "20994000000000000000000000", - "mint_count": "21000", - "holders": "2539", - "mint_progress": { - "minted": "21000000000000000000000000", - "max_supply": "21000000000000000000000000" + 'zecscriptions': { + 'max_supply': '21000000000000000000000000', + 'mint_limit': '1000000000000000000000', + 'minted': '21000000000000000000000000', + 'burned': '0', + 'shielded': '6000000000000000000000', + 'circulating': '20994000000000000000000000', + 'mint_count': '21000', + 'holders': '2539', + 'mint_progress': { + 'minted': '21000000000000000000000000', + 'max_supply': '21000000000000000000000000' }, - "status": "minted" + 'status': 'minted' } }, - "tick": "ZERO", - "tick_key": "7a65726f", - "decimals": "18", - "deploy_inscription_id": "6d3a32c5a6847b14d9e361ad4384b0e0e1bf82b5b2a2ce5dc204ddf34e34ceeei0", - "deploy_txid": "6d3a32c5a6847b14d9e361ad4384b0e0e1bf82b5b2a2ce5dc204ddf34e34ceee", - "deploy_height": "3133112", - "deployer_address": "t1HzoKn3UXD8vjsS2GzvjGxVUSTKa5NpeH9", - "divergence": { - "diverges": true, - "fields": [ - "mint_count", - "holders" + 'tick': 'ZERO', + 'tick_key': '7a65726f', + 'decimals': '18', + 'deploy_inscription_id': '6d3a32c5a6847b14d9e361ad4384b0e0e1bf82b5b2a2ce5dc204ddf34e34ceeei0', + 'deploy_txid': '6d3a32c5a6847b14d9e361ad4384b0e0e1bf82b5b2a2ce5dc204ddf34e34ceee', + 'deploy_height': '3133112', + 'deployer_address': 't1HzoKn3UXD8vjsS2GzvjGxVUSTKa5NpeH9', + 'divergence': { + 'diverges': true, + 'fields': [ + 'mint_count', + 'holders' ], - "absent_from": [], - "unevaluated": [ + 'absent_from': [], + 'unevaluated': [ { - "id": "zecscriptions-protocol-v2-reveal-outputs", - "summary": "zecscriptions protocol version 2 requires three reveal outputs: the minter at vout 0, a deployer share of 19200 zatoshis at vout 1, and a platform share of 172800 zatoshis at vout 2.", - "reason": "No activation height for protocol version 2 is recorded in the compatibility matrix, so applying the rule would require inventing one. Neither ruleset evaluates it." + 'id': 'zecscriptions-protocol-v2-reveal-outputs', + 'summary': 'zecscriptions protocol version 2 requires three reveal outputs: the minter at vout 0, a deployer share of 19200 zatoshis at vout 1, and a platform share of 172800 zatoshis at vout 2.', + 'reason': 'No activation height for protocol version 2 is recorded in the compatibility matrix, so applying the rule would require inventing one. Neither ruleset evaluates it.' }, { - "id": "shielded-settlement-accounting", - "summary": "A settlement spend into a fully shielded transaction is a permanent burn in zord accounting.", - "reason": "Reported as its own bucket rather than as a ruleset switch. Shielded and burned totals are published separately so either accounting can be derived exactly." + 'id': 'shielded-settlement-accounting', + 'summary': 'A settlement spend into a fully shielded transaction is a permanent burn in zord accounting.', + 'reason': 'Reported as its own bucket rather than as a ruleset switch. Shielded and burned totals are published separately so either accounting can be derived exactly.' } ] } @@ -203,3 +204,68 @@ describe('readDecimals', () => { expect(readDecimals(null)).toBeNull(); }); }); + +describe('a page of assets each carrying its own ledger', () => { + const page = { + lens: 'zord', + rulesets: ['zord', 'zecscriptions'], + total: '159', + limit: '50', + offset: '0', + items: [ZERO, { ...ZERO, tick: 'CALM', divergence: { diverges: false, fields: [], absent_from: [], unevaluated: [] } }], + }; + + it('reads every item and keeps the authority total exactly', () => { + const list = readRulesetAssetList(page); + expect(list?.shownCount).toBe(2); + expect(list?.totalExact).toBe('159'); + expect(list?.lens).toBe('zord'); + expect(list?.rulesets).toEqual(['zord', 'zecscriptions']); + }); + + it('shows the chosen columns and names every figure it is holding back', () => { + const list = readRulesetAssetList(page); + expect(list?.columns.map((column) => column.key)).toEqual([ + 'status', 'minted', 'max_supply', 'holders', + ]); + // Nine figures in the ledger, four columns on the page. The other five + // are named, because a table that quietly shows four of nine reads as + // the whole ledger. + expect(list?.hiddenFigureFields).toEqual([ + 'max_supply', 'mint_limit', 'minted', 'burned', 'shielded', + 'circulating', 'mint_count', 'holders', 'status', + ].filter((key) => !['status', 'minted', 'max_supply', 'holders'].includes(key))); + }); + + it('never shifts a count column and always shifts an amount column', () => { + const list = readRulesetAssetList(page); + const row = list?.rows[0]; + const cell = (key) => row?.cells.find((entry) => entry.key === key); + expect(cell('minted')?.figure?.readings[0].amount?.display).toBe('21,000,000'); + expect(cell('holders')?.figure?.readings[0].amount?.display).toBe('2,330'); + expect(cell('status')?.figure?.readings[0].word).toBe('minted'); + }); + + it('marks the row whose readings disagree, from the figures themselves', () => { + const list = readRulesetAssetList(page); + expect(list?.rows[0].diverges).toBe(true); + // The second row restates ZERO's figures, which still disagree on + // holders and mint_count. Divergence is observed from the figures, not + // from the summary object, so a summary claiming agreement cannot hide + // a ledger that does not agree. + expect(list?.rows[1].diverges).toBe(true); + }); + + it('counts an item it cannot read rather than dropping it in silence', () => { + const list = readRulesetAssetList({ ...page, items: [ZERO, { tick: 'BARE' }] }); + expect(list?.shownCount).toBe(1); + expect(list?.unreadRowCount).toBe(1); + }); + + it('is not this reading at all for a page without ruleset items', () => { + expect(readRulesetAssetList({ items: [{ tick: 'BARE' }] })).toBeNull(); + expect(readRulesetAssetList({ items: [] })).toBeNull(); + expect(readRulesetAssetList({ transactions: [ZERO] })).toBeNull(); + expect(readRulesetAssetList(null)).toBeNull(); + }); +}); diff --git a/frontend/src/app/universe/multichain-explorer/ruleset-assets.ts b/frontend/src/app/universe/multichain-explorer/ruleset-assets.ts index e3a52c43eeb..ad9c977ae46 100644 --- a/frontend/src/app/universe/multichain-explorer/ruleset-assets.ts +++ b/frontend/src/app/universe/multichain-explorer/ruleset-assets.ts @@ -236,3 +236,128 @@ export function readRulesetAsset( unreadFields: unread, }; } + +// --------------------------------------------------------------------------- +// The list page +// --------------------------------------------------------------------------- + +/** + * The figure columns the list table shows, in this order, when a row carries + * them. Chosen for what a visitor scanning 159 tokens is deciding between: + * whether a token is still mintable, how much of it exists, and how widely it + * is held. Every other figure is on the token's own page, and the columns the + * table is holding back are named beside it rather than dropped. + */ +const LIST_FIGURE_COLUMNS = ['status', 'minted', 'max_supply', 'holders']; + +export interface RulesetListColumn { + readonly key: string; + readonly label: string; + readonly kind: FigureKind; +} + +export interface RulesetAssetListRow { + readonly tick: string; + /** The full reading, so the row's cells never re-derive what it holds. */ + readonly reading: RulesetAssetReading; + /** One cell per column. Null where this token has no such figure. */ + readonly cells: readonly { key: string; figure: RulesetFigure | null }[]; + /** True when any figure of this token differs between the readings. */ + readonly diverges: boolean; +} + +export interface RulesetAssetListReading { + readonly lens: string | null; + /** Every ruleset seen across the rows, lens first. */ + readonly rulesets: readonly string[]; + readonly columns: readonly RulesetListColumn[]; + readonly rows: readonly RulesetAssetListRow[]; + readonly shownCount: number; + /** The authority's own total, exactly as sent. Null when it sent none. */ + readonly totalExact: string | null; + /** + * Figure fields present in the rows that are not columns here. Named, so a + * table that shows four of nine figures says so instead of reading as the + * whole ledger. + */ + readonly hiddenFigureFields: readonly string[]; + /** Items in the response this reading could not read as ruleset assets. */ + readonly unreadRowCount: number; +} + +/** + * A page of assets whose items each carry their own `rulesets` ledger. + * + * Returns null for a payload without such items, so everything else keeps the + * reading it already had. The list shows each figure under the lens reading; + * a cell whose readings disagree shows every reading, because the table + * quietly picking one is the same invented consensus the detail reading + * refuses to print. + */ +export function readRulesetAssetList(payload: unknown): RulesetAssetListReading | null { + if (!isRecord(payload) || !Array.isArray(payload.items) || !payload.items.length) { + return null; + } + const lens = text(payload.lens); + const rows: RulesetAssetListRow[] = []; + let unreadRowCount = 0; + for (const item of payload.items) { + const reading = readRulesetAsset(item, lens); + if (!reading) { + unreadRowCount += 1; + continue; + } + rows.push({ + tick: reading.tick, + reading, + cells: [], + diverges: reading.divergingFields.length > 0, + }); + } + if (!rows.length) { + return null; + } + + const present = new Set(); + for (const row of rows) { + for (const figure of row.reading.figures) { + present.add(figure.key); + } + } + const columns: RulesetListColumn[] = LIST_FIGURE_COLUMNS.filter((key) => + present.has(key) + ).map((key) => ({ + key, + label: humanizeFieldName(key), + kind: FIGURE_KIND[key], + })); + const hiddenFigureFields = [...present].filter( + (key) => !LIST_FIGURE_COLUMNS.includes(key) + ); + + const rulesets: string[] = []; + for (const row of rows) { + for (const name of row.reading.rulesets) { + if (!rulesets.includes(name)) { + rulesets.push(name); + } + } + } + + return { + lens, + rulesets, + columns, + rows: rows.map((row) => ({ + ...row, + cells: columns.map((column) => ({ + key: column.key, + figure: row.reading.figures.find((figure) => figure.key === column.key) ?? null, + })), + })), + shownCount: rows.length, + totalExact: text(payload.total), + hiddenFigureFields, + unreadRowCount, + }; +} diff --git a/scripts/universe/visual-qa/capture.mjs b/scripts/universe/visual-qa/capture.mjs index b458ea32083..ab9bf11d1d3 100644 --- a/scripts/universe/visual-qa/capture.mjs +++ b/scripts/universe/visual-qa/capture.mjs @@ -93,6 +93,10 @@ export const ROUTES = [ { id: 'zcash-block', path: `/zcash/block/${chainSampleIds.ZEC_BLOCK}`, name: 'Zcash block' }, { id: 'zcash-address', path: `/zcash/address/${chainSampleIds.ZEC_ADDRESS}`, name: 'Zcash address' }, { id: 'zcash-protocols', path: '/zcash/protocols', name: 'Zcash protocols' }, + // The ZRC-20 pages carry a ledger reported under two rulesets that are + // allowed to disagree. They reached production as a JSON string in a cell. + { id: 'zcash-zrc20', path: '/zcash/protocols/zrc20', name: 'ZRC-20 tokens' }, + { id: 'zcash-zrc20-token', path: `/zcash/protocols/zrc20/${chainSampleIds.ZEC_ZRC20}`, name: 'ZRC-20 token' }, // Universe-authored routes that had no coverage either. The saved page is // seeded through localStorage below, because its state was never a request. @@ -702,6 +706,7 @@ export const GATED_ROUTES = new Set([ 'dogecoin', 'dogecoin-mempool', 'dogecoin-tx', 'dogecoin-block', 'dogecoin-address', 'dogecoin-protocols', 'dogecoin-drc20', 'zcash', 'zcash-mempool', 'zcash-tx', 'zcash-protocols', + 'zcash-zrc20', 'zcash-zrc20-token', // The single-asset pages and the local-state page, for the same reason. 'outpoint', 'inscription', 'rune', 'sat', 'saved', ]); diff --git a/scripts/universe/visual-qa/chain-fixtures.mjs b/scripts/universe/visual-qa/chain-fixtures.mjs index 765cd0a54d8..995d537734c 100644 --- a/scripts/universe/visual-qa/chain-fixtures.mjs +++ b/scripts/universe/visual-qa/chain-fixtures.mjs @@ -19,6 +19,7 @@ const DOGE_ADDRESS = 'DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L'; const ZEC_TXID = '7a3e1c5f9b2d6048a2c6e0f4b8d2a6c0e4f8b2d6a0c4e8f2b6d0a4c8e2f6b0d4'; const ZEC_ADDRESS = 't1SEgZvXCu3ceE42qrq5pCeSq7HbLjX8NJv'; const ZEC_BLOCK = '00000000004db04aba335b14021f8dd4fa02a0a954edad7da547c3a4fd917141'; +const ZEC_ZRC20 = 'ZERO'; /** * A chain capability envelope. Dogecoin is at its tip and complete; Zcash is @@ -215,6 +216,90 @@ function pendingDogecoinTransaction(txid, feeAtomic, sizeBytes, firstSeenAt) { }; } +/** + * A ZRC-20 token as the live authority reports one: the whole ledger twice, + * once under each ruleset, and a divergence summary naming where the two + * disagree. The shape follows /api/v1/zcash/protocols/zrc20/ZERO as captured + * on 2026-08-30; the figures are fixture values. + */ +function zrc20Token(tick, overrides = {}) { + const ledger = { + max_supply: '21000000000000000000000000', + mint_limit: '1000000000000000000000', + minted: '21000000000000000000000000', + burned: '0', + shielded: '6000000000000000000000', + circulating: '20994000000000000000000000', + mint_count: '21120', + holders: '2330', + mint_progress: { minted: '21000000000000000000000000', max_supply: '21000000000000000000000000' }, + status: 'minted', + }; + return { + tick, + tick_key: Buffer.from(tick.toLowerCase()).toString('hex'), + decimals: '18', + deploy_inscription_id: `${ZEC_TXID}i0`, + deploy_txid: ZEC_TXID, + deploy_height: '3133112', + deployer_address: ZEC_ADDRESS, + rulesets: { + zord: { ...ledger }, + zecscriptions: { ...ledger }, + }, + divergence: { diverges: false, fields: [], absent_from: [], unevaluated: [] }, + ...overrides, + }; +} + +/** The token whose readings disagree, which is what the pages exist to show. */ +function zrc20DivergingToken() { + const token = zrc20Token(ZEC_ZRC20); + token.rulesets.zecscriptions = { + ...token.rulesets.zecscriptions, + mint_count: '21000', + holders: '2539', + }; + token.divergence = { + diverges: true, + fields: ['mint_count', 'holders'], + absent_from: [], + unevaluated: [ + { + id: 'zecscriptions-protocol-v2-reveal-outputs', + summary: 'zecscriptions protocol version 2 requires three reveal outputs: the minter at vout 0, a deployer share of 19200 zatoshis at vout 1, and a platform share of 172800 zatoshis at vout 2.', + reason: 'No activation height for protocol version 2 is recorded in the compatibility matrix, so applying the rule would require inventing one. Neither ruleset evaluates it.', + }, + { + id: 'shielded-settlement-accounting', + summary: 'A settlement spend into a fully shielded transaction is a permanent burn in zord accounting.', + reason: 'Reported as its own bucket rather than as a ruleset switch. Shielded and burned totals are published separately so either accounting can be derived exactly.', + }, + ], + }; + return token; +} + +function zrc20Envelope(body) { + return { + schemaVersion: 'zcash-metaprotocols-api-v1', + chain: 'zcash', + network: 'mainnet', + checkpoint: { height: '3465589', hash: ZEC_BLOCK }, + coverage: { + scannedHeight: 3465589, + networkHeight: 3465590, + blocksBehindNetwork: 1, + nodeSynced: true, + verificationProgress: 1, + chainComplete: true, + }, + lens: 'zord', + rulesets: ['zord', 'zecscriptions'], + ...body, + }; +} + export const chainFixtures = { '/api/v1/chains': [capability('bitcoin'), capability('dogecoin'), capability('zcash')], @@ -503,6 +588,20 @@ export const chainFixtures = { cursor: '3', completeness: 'complete', }, + + // The ZRC-20 list and one token. Each item carries its ledger twice, under + // two rulesets that are allowed to disagree, and the divergence summary. + '/api/v1/zcash/protocols/zrc20': zrc20Envelope({ + total: '159', + limit: '50', + offset: '0', + items: [ + zrc20DivergingToken(), + zrc20Token('ZATS'), + zrc20Token('ZILL', { decimals: '0' }), + ], + }), + [`/api/v1/zcash/protocols/zrc20/${ZEC_ZRC20}`]: zrc20Envelope(zrc20DivergingToken()), }; /** @@ -665,4 +764,4 @@ export const chainStateScope = { 'chain-object-missing': ['dogecoin'], }; -export const chainSampleIds = { DOGE_TXID, DOGE_BLOCK, DOGE_ADDRESS, ZEC_TXID, ZEC_BLOCK, ZEC_ADDRESS }; +export const chainSampleIds = { DOGE_TXID, DOGE_BLOCK, DOGE_ADDRESS, ZEC_TXID, ZEC_BLOCK, ZEC_ADDRESS, ZEC_ZRC20 }; From 1adfb7cfa30f616117773426341fc20a8fc1f3f9 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sun, 30 Aug 2026 06:55:15 +0000 Subject: [PATCH 4/6] Dunes reach the Dogecoin pages, shifted by their own divisibility The registry lists dunes, the capability document reports it, and the chain navigation omitted it: the coverage panel could only say there was no page to open. Now there is one, and it does not run the ledger through the generic table, because a dune's arithmetic is 128-bit and each dune carries its own divisibility. The generic path would have shown supplies unshifted or, worse, shifted by Dogecoin's eight decimals, which is the DRC-20 supply mistake with a different name. The catalog page shows supply, mints and burned per dune, names the figures it holds back, and links each dune to its page. The dune page states the divisibility once, shifts every quantity by it and never by the chain's decimals, keeps counts unshifted, renders the etching as a link and the mint window as a verdict, and names any field this build has no kind for. A dune whose divisibility cannot be read shows no shifted quantity at all. The visual matrix gains both pages as gated routes, with an open mint, a closed one with no symbol, and a zero-divisibility dune whose unshifted figure is on the page deliberately. Co-Authored-By: Claude Fable 5 --- .github/workflows/universe-ci.yml | 2 +- .../multichain-explorer/dune-assets.spec.ts | 126 +++++++++ .../multichain-explorer/dune-assets.ts | 253 ++++++++++++++++++ .../live-model-check.spec.ts | 4 +- .../multichain-explorer.component.html | 108 ++++++++ .../multichain-explorer.component.scss | 12 + .../multichain-explorer.component.ts | 36 ++- .../multichain-view.spec.ts | 28 +- .../multichain-explorer/multichain-view.ts | 1 + .../src/app/universe/universe-api.service.ts | 2 +- scripts/universe/visual-qa/capture.mjs | 4 + scripts/universe/visual-qa/chain-fixtures.mjs | 52 +++- 12 files changed, 613 insertions(+), 15 deletions(-) create mode 100644 frontend/src/app/universe/multichain-explorer/dune-assets.spec.ts create mode 100644 frontend/src/app/universe/multichain-explorer/dune-assets.ts diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml index 2e5e3601de5..b04727686f3 100644 --- a/.github/workflows/universe-ci.yml +++ b/.github/workflows/universe-ci.yml @@ -268,7 +268,7 @@ jobs: working-directory: scripts/universe/visual-qa run: >- node capture.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts-chain-states - --routes=dogecoin,dogecoin-drc20,zcash,zcash-block,zcash-zrc20,zcash-zrc20-token + --routes=dogecoin,dogecoin-drc20,dogecoin-dunes,dogecoin-dune,zcash,zcash-block,zcash-zrc20,zcash-zrc20-token --states=chain-authority-down,chain-behind,chain-not-ready-protocols,chain-not-ready-unexplained,chain-object-missing --themes=default,dark --viewports=375,1440 diff --git a/frontend/src/app/universe/multichain-explorer/dune-assets.spec.ts b/frontend/src/app/universe/multichain-explorer/dune-assets.spec.ts new file mode 100644 index 00000000000..4d1ddc11d4e --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/dune-assets.spec.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; +import { + readDuneAsset, + readDuneAssetDetail, + readDuneAssetList, +} from '@app/universe/multichain-explorer/dune-assets'; + +/** + * One dune, in the shape `/dogecoin/protocols/dunes` items carry per the + * authority contract. The digits are chosen so a wrong shift is visible: + * with divisibility 8 the supply is one hundred million, and unshifted it + * reads as one hundred quadrillion. + */ +const DUNE = { + dune: 'SUCH•WOW•DUNE', + duneId: '5084000:1', + numberAtomic: '1', + symbol: 'D', + divisibilityAtomic: '8', + etchingTxid: 'a'.repeat(64), + supplyAtomic: '10000000000000000', + premineAtomic: '100000000', + mintsAtomic: '21000', + burnedAtomic: '0', + etchedHeightAtomic: '5084000', + etchedTimestampAtomic: '1700000000', + mintable: true, +}; + +describe('a dune, read under its own divisibility', () => { + it('shifts a quantity by the dune divisibility and never shifts a count', () => { + const reading = readDuneAsset(DUNE); + const figure = (key: string) => reading?.figures.find((f) => f.key === key); + expect(figure('supplyAtomic')?.value).toEqual({ + display: '100,000,000', + exact: '10000000000000000', + }); + expect(figure('premineAtomic')?.value?.display).toBe('1'); + // Mints is a number of mint events. 21000 mints is 21000 mints. + expect(figure('mintsAtomic')?.value).toEqual({ display: '21,000', exact: '21000' }); + expect(figure('etchedHeightAtomic')?.value?.display).toBe('5,084,000'); + }); + + it('states the divisibility once and keeps the glyph as sent', () => { + const reading = readDuneAsset(DUNE); + expect(reading?.divisibility).toBe(8); + expect(reading?.divisibilityExact).toBe('8'); + expect(reading?.symbol).toBe('D'); + expect(reading?.mintable).toBe(true); + expect(reading?.etchedAt?.exact).toBe('1700000000'); + }); + + it('shows no shifted quantity at all when the divisibility is unreadable', () => { + const reading = readDuneAsset({ ...DUNE, divisibilityAtomic: 'eight' }); + expect(reading?.divisibility).toBeNull(); + const supply = reading?.figures.find((f) => f.key === 'supplyAtomic'); + expect(supply?.value).toBeNull(); + // A count needs no divisibility and is unaffected. + expect(reading?.figures.find((f) => f.key === 'mintsAtomic')?.value?.display).toBe('21,000'); + }); + + it('names a field it has no kind for rather than dropping it', () => { + const reading = readDuneAsset({ ...DUNE, turbo_atomic: '1' }); + expect(reading?.unreadFields).toEqual(['turbo_atomic']); + expect(reading?.figures.some((f) => f.key === 'turbo_atomic')).toBe(false); + }); + + it('is not this reading at all without a name and a divisibility', () => { + expect(readDuneAsset({ tick: 'DOGI' })).toBeNull(); + expect(readDuneAsset({ dune: 'BARE' })).toBeNull(); + expect(readDuneAsset(null)).toBeNull(); + }); +}); + +describe('a page of dunes', () => { + const page = { + chain: 'dogecoin', + network: 'mainnet', + blockCountAtomic: '5900001', + blockHash: 'd'.repeat(64), + inventoryComplete: true, + totalCountAtomic: '3', + nextCursor: null, + dunes: [DUNE, { ...DUNE, dune: 'BARE', duneId: '5084001:0', symbol: null, mintable: false }], + }; + + it('reads every row and keeps the authority total exactly', () => { + const list = readDuneAssetList(page); + expect(list?.shownCount).toBe(2); + expect(list?.totalExact).toBe('3'); + expect(list?.unreadRowCount).toBe(0); + expect(list?.columns.map((column) => column.key)).toEqual([ + 'supplyAtomic', 'mintsAtomic', 'burnedAtomic', + ]); + // What the table holds back is named, in the words the page uses. + expect(list?.hiddenFigureFields).toEqual(['Number', 'Premine', 'Etched height']); + }); + + it('counts a row it cannot read rather than dropping it in silence', () => { + const list = readDuneAssetList({ ...page, dunes: [DUNE, { tick: 'BARE' }] }); + expect(list?.shownCount).toBe(1); + expect(list?.unreadRowCount).toBe(1); + }); + + it('is not this reading at all for other payloads', () => { + expect(readDuneAssetList({ items: [DUNE] })).toBeNull(); + expect(readDuneAssetList({ dunes: [] })).toBeNull(); + expect(readDuneAssetList(null)).toBeNull(); + }); +}); + +describe('one dune as the detail payload carries it', () => { + it('reads the record under the dune key and nothing else', () => { + const detail = { + chain: 'dogecoin', + network: 'mainnet', + blockCountAtomic: '5900001', + blockHash: 'd'.repeat(64), + inventoryComplete: true, + dune: DUNE, + }; + expect(readDuneAssetDetail(detail)?.dune).toBe('SUCH•WOW•DUNE'); + expect(readDuneAssetDetail({ dunes: [DUNE] })).toBeNull(); + expect(readDuneAssetDetail(DUNE)).toBeNull(); + }); +}); diff --git a/frontend/src/app/universe/multichain-explorer/dune-assets.ts b/frontend/src/app/universe/multichain-explorer/dune-assets.ts new file mode 100644 index 00000000000..43f6cb6af4d --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/dune-assets.ts @@ -0,0 +1,253 @@ +/** + * Dunes, read from the ord-dogecoin authority's catalog. + * + * A dune's arithmetic is 128-bit and each dune carries its own + * `divisibility`, so nothing here shares a rule with Dogecoin's eight + * decimals. The same three rules that hold the ruleset reading hold this + * one: + * + * - **Quantities are shifted by the dune's own divisibility, and the kinds + * are an allowlist.** A supply of `100000000000000000` is a hundred + * million with divisibility eight and a hundred quadrillion with zero, + * and `mints` is a number of mint events that must never be shifted at + * all. + * - **A field this build has no kind for is named, not dropped.** + * - **A dune whose divisibility cannot be read shows no shifted quantity at + * all**, because an unshifted figure presented as one is wrong by a + * factor the reader cannot see. + */ + +import { + ExactNumber, + formatAtomicAmount, + formatUnixTimestamp, + humanizeFieldName, +} from './multichain-view'; +import { readDecimals } from './ruleset-assets'; + +/** What a dune figure is, so it is never shifted by the wrong rule. */ +export type DuneFigureKind = + /** A quantity of the dune, stated in its smallest unit. */ + | 'amount' + /** A number of things. Never shifted by divisibility. */ + | 'count'; + +/** + * An allowlist, because a pattern is how the wrong field gets matched. + * Every entry here was read from the authority contract for + * `/dogecoin/protocols/dunes`. + */ +const DUNE_FIGURE_KIND: Record = { + supplyAtomic: 'amount', + premineAtomic: 'amount', + burnedAtomic: 'amount', + mintsAtomic: 'count', + numberAtomic: 'count', + etchedHeightAtomic: 'count', +}; + +/** + * Fields the reading presents in its own designed places rather than as + * figures: the name, the identifier, the glyph, the shift itself, the + * etching transaction, the timestamp, and the mintability verdict. + */ +const PRESENTED_DUNE_FIELDS = new Set([ + 'dune', + 'duneId', + 'symbol', + 'divisibilityAtomic', + 'etchingTxid', + 'etchedTimestampAtomic', + 'mintable', +]); + +export interface DuneFigure { + readonly key: string; + readonly label: string; + readonly kind: DuneFigureKind; + readonly value: ExactNumber | null; +} + +export interface DuneAssetReading { + readonly dune: string; + readonly duneId: string; + /** The dune's display glyph, or null when none was etched. */ + readonly symbol: string | null; + /** Stated once and prominently: it is what every quantity was shifted by. */ + readonly divisibility: number | null; + readonly divisibilityExact: string | null; + readonly etchingTxid: string | null; + readonly etchedAt: ExactNumber | null; + /** Whether the terms allow a mint in the next block. Null when unstated. */ + readonly mintable: boolean | null; + readonly figures: readonly DuneFigure[]; + /** + * Fields this build carries no kind for. Named, so a figure that is in + * the response and not on the page is visible as an omission rather than + * as an absence. + */ + readonly unreadFields: readonly string[]; +} + +function text(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * One dune, read from an item carrying a spaced name and a divisibility. + * + * Returns null for anything without both, so a payload that is not this + * shape falls through to the reading it already had. + */ +export function readDuneAsset(item: unknown): DuneAssetReading | null { + if (!isRecord(item)) { + return null; + } + const dune = text(item.dune); + const divisibilityExact = text(item.divisibilityAtomic); + if (dune === null || divisibilityExact === null) { + return null; + } + const divisibility = readDecimals(item.divisibilityAtomic); + + const figures: DuneFigure[] = []; + const unread: string[] = []; + for (const [key, value] of Object.entries(item)) { + if (PRESENTED_DUNE_FIELDS.has(key)) { + continue; + } + const kind = DUNE_FIGURE_KIND[key]; + if (!kind) { + unread.push(key); + continue; + } + const precision = kind === 'amount' ? divisibility : 0; + figures.push({ + key, + label: humanizeFieldName(key.replace(/Atomic$/, '')), + kind, + value: precision === null ? null : formatAtomicAmount(text(value), precision), + }); + } + + return { + dune, + duneId: text(item.duneId) ?? '', + symbol: text(item.symbol), + divisibility, + divisibilityExact, + etchingTxid: text(item.etchingTxid), + etchedAt: formatUnixTimestamp(text(item.etchedTimestampAtomic)), + mintable: typeof item.mintable === 'boolean' ? item.mintable : null, + figures, + unreadFields: unread, + }; +} + +// --------------------------------------------------------------------------- +// The list page +// --------------------------------------------------------------------------- + +/** + * The figure columns the list table shows, in this order, when a row + * carries them. Every other figure is on the dune's own page, and the + * columns the table is holding back are named beside it. + */ +const DUNE_LIST_COLUMNS = ['supplyAtomic', 'mintsAtomic', 'burnedAtomic']; + +export interface DuneListColumn { + readonly key: string; + readonly label: string; +} + +export interface DuneAssetListRow { + readonly reading: DuneAssetReading; + /** One cell per column. Null where this dune has no such figure. */ + readonly cells: readonly { key: string; figure: DuneFigure | null }[]; +} + +export interface DuneAssetListReading { + readonly columns: readonly DuneListColumn[]; + readonly rows: readonly DuneAssetListRow[]; + readonly shownCount: number; + /** The authority's own total, exactly as sent. Null when it sent none. */ + readonly totalExact: string | null; + /** Figure fields present in the rows that are not columns here. Named. */ + readonly hiddenFigureFields: readonly string[]; + /** Items in the response this reading could not read as dunes. */ + readonly unreadRowCount: number; +} + +/** + * A page of dunes, each carrying its own divisibility. + * + * Returns null for a payload without a `dunes` array of such items, so + * everything else keeps the reading it already had. + */ +export function readDuneAssetList(payload: unknown): DuneAssetListReading | null { + if (!isRecord(payload) || !Array.isArray(payload.dunes) || !payload.dunes.length) { + return null; + } + const rows: DuneAssetReading[] = []; + let unreadRowCount = 0; + for (const item of payload.dunes) { + const reading = readDuneAsset(item); + if (reading) { + rows.push(reading); + } else { + unreadRowCount += 1; + } + } + if (!rows.length) { + return null; + } + + const present = new Set(); + for (const reading of rows) { + for (const figure of reading.figures) { + present.add(figure.key); + } + } + const columns = DUNE_LIST_COLUMNS.filter((key) => present.has(key)).map( + (key) => ({ + key, + label: humanizeFieldName(key.replace(/Atomic$/, '')), + }) + ); + const hiddenFigureFields = [...present] + .filter((key) => !DUNE_LIST_COLUMNS.includes(key)) + .map((key) => humanizeFieldName(key.replace(/Atomic$/, ''))); + + return { + columns, + rows: rows.map((reading) => ({ + reading, + cells: columns.map((column) => ({ + key: column.key, + figure: + reading.figures.find((figure) => figure.key === column.key) ?? null, + })), + })), + shownCount: rows.length, + totalExact: text(payload.totalCountAtomic), + hiddenFigureFields, + unreadRowCount, + }; +} + +/** + * The detail payload: one dune under a `dune` key beside the checkpoint. + * The list payload's `dunes` array does not match this, and an item alone + * does not carry the key, so the two readings never claim each other's + * payloads. + */ +export function readDuneAssetDetail(payload: unknown): DuneAssetReading | null { + if (!isRecord(payload) || !isRecord(payload.dune)) { + return null; + } + return readDuneAsset(payload.dune); +} diff --git a/frontend/src/app/universe/multichain-explorer/live-model-check.spec.ts b/frontend/src/app/universe/multichain-explorer/live-model-check.spec.ts index 64a13e20df0..03fc43ce6ef 100644 --- a/frontend/src/app/universe/multichain-explorer/live-model-check.spec.ts +++ b/frontend/src/app/universe/multichain-explorer/live-model-check.spec.ts @@ -68,10 +68,10 @@ describe('live dogecoin status', () => { expect(tap).toHaveLength(1); expect(tap[0].routeId).toBe('doge-tap'); - // dunes is reported by the chain and has no page here. + // dunes is reported by the chain and now has a page of its own. const dunes = readings.find((r) => r.protocolId === 'dunes'); expect(dunes).toBeDefined(); - expect(dunes?.routeId).toBeNull(); + expect(dunes?.routeId).toBe('dunes'); }); }); diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html index 907d285c860..eedc8903a3b 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html @@ -699,6 +699,114 @@

Tokens + + + +
+

+ {{ dune.dune }} + {{ dune.symbol }} +

+
+
+
Dune
+
{{ dune.duneId }}
+
+
+
Divisibility
+
+ {{ dune.divisibilityExact }} + Every quantity below is shifted by this, never by the chain's own decimals. +
+
+ +
+
Etched
+
{{ dune.etchedAt.display }}
+
+
+
Minting
+
+ + Open in the next block + Not open + +
+
+
+ +

+ This dune's divisibility could not be read, so no shifted quantity is shown. An unshifted figure presented as one would be wrong by a factor the reader cannot see. The exact digits are in the full response below. +

+ +
+
+
{{ figure.label }}
+
+ {{ figure.value.display }} + not shown +
+
+
+ +

+ The record also carries {{ dune.unreadFields.join(', ') }}, which this build has no reading for. They are named here rather than shifted by a rule that may not apply, and appear in the full response below. +

+
+ + + + +
+

Dunes

+

+ Each dune's quantities are shifted by its own divisibility, stated on its page. Nothing here shares a unit with DOGE. +

+

+ Showing the first {{ list.shownCount }} of {{ list.totalExact }} dunes the authority reports. +

+

+ Each record also carries {{ list.hiddenFigureFields.join(', ') }}, which are not columns here. Every one of them is on the dune's own page. +

+
+ + + + + + + + + + + + + + + +
Dune{{ column.label }}Minting
+ {{ row.reading.dune }} + {{ row.reading.symbol }} + + {{ value.display }} + not shown + + + Open + Closed + +
+
+

+ {{ list.unreadRowCount }} entries in the response could not be read as dunes and are only in the full response below. +

+
+ diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss index 07b32aa2de3..4d8e7715b33 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss @@ -817,6 +817,18 @@ tbody tr:last-child td { white-space: nowrap; } +// The dune's own glyph, beside its name. It is the authority's value, so it +// sits in a quiet chip rather than decorating the heading. +.dune-symbol { + display: inline-block; + margin-left: var(--u-space-2); + padding: 0 var(--u-space-2); + border: 1px solid var(--u-border); + border-radius: var(--u-radius-sm); + color: var(--u-text-secondary); + font-size: var(--u-text-sm); +} + // The authority's prose about rules neither reading evaluates. .rule-list { list-style: none; diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts index 32b2fd5f7cb..5c77799c957 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts @@ -66,6 +66,12 @@ import { readRulesetAsset, readRulesetAssetList, } from '@app/universe/multichain-explorer/ruleset-assets'; +import { + DuneAssetListReading, + DuneAssetReading, + readDuneAssetDetail, + readDuneAssetList, +} from '@app/universe/multichain-explorer/dune-assets'; import { Observable, Subject, @@ -116,6 +122,10 @@ interface ExplorerViewModel { readonly rulesetAsset: RulesetAssetReading | null; /** A page of such assets, each row carrying its own ledger. */ readonly rulesetList: RulesetAssetListReading | null; + /** One dune, whose quantities shift by its own divisibility. */ + readonly duneAsset: DuneAssetReading | null; + /** A page of dunes, each row carrying its own divisibility. */ + readonly duneList: DuneAssetListReading | null; readonly emptyList: EmptyListReading | null; readonly facts: readonly Fact[]; /** True when the page is showing a response it has no purpose-built reading for. */ @@ -301,8 +311,11 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { ? readRulesetAsset(payload, typeof payload.lens === 'string' ? payload.lens : null) : null; const rulesetList = payload && !transactionList ? readRulesetAssetList(payload) : null; + const duneAsset = payload ? readDuneAssetDetail(payload) : null; + const duneList = payload && !transactionList ? readDuneAssetList(payload) : null; const collection = - payload && !transactionList && !rulesetList && (shape === 'collection' || shape === 'record') + payload && !transactionList && !rulesetList && !duneList && + (shape === 'collection' || shape === 'record') ? readCollection(payload, this.profile) : null; const skip = [ @@ -316,6 +329,10 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { // The list owns its rows, and its lede states the lens, the ruleset // names and the authority's total. ...(rulesetList ? ['items', 'rulesets', 'lens', 'total'] : []), + // The dune readings own their records; the checkpoint fields beside + // them stay, because nothing else on the page states them. + ...(duneAsset ? ['dune'] : []), + ...(duneList ? ['dunes', 'totalCountAtomic'] : []), ]; return { capability, @@ -337,11 +354,16 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { transactionList, rulesetAsset, rulesetList, + duneAsset, + duneList, emptyList: - transactionList || collection || rulesetList ? null : readEmptyList(payload), + transactionList || collection || rulesetList || duneList + ? null + : readEmptyList(payload), facts: readRecordFacts(payload, this.profile, skip), generic: - (shape === 'collection' || shape === 'record') && !rulesetAsset && !rulesetList, + (shape === 'collection' || shape === 'record') && + !rulesetAsset && !rulesetList && !duneAsset && !duneList, }; } @@ -467,6 +489,14 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { return list.shownCount < Number(list.totalExact); } + /** True when the authority reports more dunes than this page shows. */ + duneListTruncated(list: DuneAssetListReading): boolean { + if (!list.totalExact || !/^(0|[1-9][0-9]*)$/.test(list.totalExact)) { + return false; + } + return list.shownCount < Number(list.totalExact); + } + /** "zord and zecscriptions", or the one name when there is one. */ rulesetNames(names: readonly string[]): string { if (names.length < 2) { diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts index 6c4827a359e..33e31797779 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts @@ -364,7 +364,7 @@ describe('readCapabilities', () => { describe('readProtocolCoverage', () => { it('shows every protocol the navigation offers, even one the overlay stopped reporting', () => { const readings = readProtocolCoverage(capability({ protocols: [] }), DOGE); - expect(readings.map((r) => r.routeId)).toEqual(['doginals', 'drc20', 'doge-tap']); + expect(readings.map((r) => r.routeId)).toEqual(['doginals', 'drc20', 'doge-tap', 'dunes']); expect(readings[0].tone).toBe('neutral'); }); @@ -379,7 +379,7 @@ describe('readProtocolCoverage', () => { }), DOGE ); - expect(readings).toHaveLength(3); + expect(readings).toHaveLength(4); const tap = readings.find((r) => r.routeId === 'doge-tap'); expect(tap?.tone).toBe('partial'); expect(tap?.label).toBe('TAP on Doge'); @@ -387,9 +387,24 @@ describe('readProtocolCoverage', () => { }); it('lists a protocol it has no page for, and offers no route to it', () => { - // /dogecoin/protocols/dunes answers 404 and the API service throws before - // it asks, so a link there is a dead end. Hiding it would be a claim the - // chain did not make. + // A protocol this build has no tab for gets no link: the API service + // throws before it asks, so a link there is a dead end. Hiding it would + // be a claim the chain did not make. Dunes was the live example until it + // got a page of its own, so the case now uses an id no tab claims. + const readings = readProtocolCoverage( + capability({ + protocols: [ + { protocolId: 'moon_bones', state: 'unavailable', coverage: 'unavailable', updatedAt: null, lagBlocksAtomic: null, degradedReasons: [] }, + ], + }), + DOGE + ); + const unclaimed = readings.find((r) => r.protocolId === 'moon_bones'); + expect(unclaimed).toBeDefined(); + expect(unclaimed?.routeId).toBeNull(); + }); + + it('routes dunes to its own page', () => { const readings = readProtocolCoverage( capability({ protocols: [ @@ -399,8 +414,7 @@ describe('readProtocolCoverage', () => { DOGE ); const dunes = readings.find((r) => r.protocolId === 'dunes'); - expect(dunes).toBeDefined(); - expect(dunes?.routeId).toBeNull(); + expect(dunes?.routeId).toBe('dunes'); expect(dunes?.label).toBe('Dunes'); }); diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.ts index d39a6999a11..754aafdea89 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.ts @@ -82,6 +82,7 @@ const DOGECOIN: ChainProfile = { { id: 'doginals', label: 'Doginals', registryIds: ['doginals'] }, { id: 'drc20', label: 'DRC-20', registryIds: ['drc20'] }, { id: 'doge-tap', label: 'TAP on Doge', registryIds: ['tap_doge', 'doge-tap', 'tap-on-doge', 'tap-doge'] }, + { id: 'dunes', label: 'Dunes', registryIds: ['dunes'] }, ], }; diff --git a/frontend/src/app/universe/universe-api.service.ts b/frontend/src/app/universe/universe-api.service.ts index bb3ab132f12..db377d88f3d 100644 --- a/frontend/src/app/universe/universe-api.service.ts +++ b/frontend/src/app/universe/universe-api.service.ts @@ -235,7 +235,7 @@ export class UniverseApiService { private protocolPath(chain: Exclude, protocol: string): string { const allowed = chain === 'dogecoin' - ? ['doginals', 'drc20', 'doge-tap'] + ? ['doginals', 'drc20', 'doge-tap', 'dunes'] : ['zerdinals', 'zrunes', 'zrc20']; if (!allowed.includes(protocol)) {throw new Error('unsupported-chain-protocol');} return protocol; diff --git a/scripts/universe/visual-qa/capture.mjs b/scripts/universe/visual-qa/capture.mjs index ab9bf11d1d3..1d6079f890b 100644 --- a/scripts/universe/visual-qa/capture.mjs +++ b/scripts/universe/visual-qa/capture.mjs @@ -85,6 +85,9 @@ export const ROUTES = [ { id: 'dogecoin-address', path: `/dogecoin/address/${chainSampleIds.DOGE_ADDRESS}`, name: 'Dogecoin address' }, { id: 'dogecoin-protocols', path: '/dogecoin/protocols', name: 'Dogecoin protocols' }, { id: 'dogecoin-drc20', path: '/dogecoin/protocols/drc20', name: 'DRC-20 assets' }, + // Dunes carry their own divisibility, so the pages exist to shift by it. + { id: 'dogecoin-dunes', path: '/dogecoin/protocols/dunes', name: 'Dune catalog' }, + { id: 'dogecoin-dune', path: `/dogecoin/protocols/dunes/${chainSampleIds.DOGE_DUNE_ID}`, name: 'Dune' }, { id: 'zcash', path: '/zcash', name: 'Zcash overview' }, { id: 'zcash-mempool', path: '/zcash/mempool', name: 'Zcash pending' }, { id: 'zcash-tx', path: `/zcash/tx/${chainSampleIds.ZEC_TXID}`, name: 'Zcash transaction' }, @@ -705,6 +708,7 @@ export const GATED_ROUTES = new Set([ // failed lookup prints why rather than waiting. 'dogecoin', 'dogecoin-mempool', 'dogecoin-tx', 'dogecoin-block', 'dogecoin-address', 'dogecoin-protocols', 'dogecoin-drc20', + 'dogecoin-dunes', 'dogecoin-dune', 'zcash', 'zcash-mempool', 'zcash-tx', 'zcash-protocols', 'zcash-zrc20', 'zcash-zrc20-token', // The single-asset pages and the local-state page, for the same reason. diff --git a/scripts/universe/visual-qa/chain-fixtures.mjs b/scripts/universe/visual-qa/chain-fixtures.mjs index 995d537734c..614da6fb390 100644 --- a/scripts/universe/visual-qa/chain-fixtures.mjs +++ b/scripts/universe/visual-qa/chain-fixtures.mjs @@ -20,6 +20,7 @@ const ZEC_TXID = '7a3e1c5f9b2d6048a2c6e0f4b8d2a6c0e4f8b2d6a0c4e8f2b6d0a4c8e2f6b0 const ZEC_ADDRESS = 't1SEgZvXCu3ceE42qrq5pCeSq7HbLjX8NJv'; const ZEC_BLOCK = '00000000004db04aba335b14021f8dd4fa02a0a954edad7da547c3a4fd917141'; const ZEC_ZRC20 = 'ZERO'; +const DOGE_DUNE_ID = '5084000:1'; /** * A chain capability envelope. Dogecoin is at its tip and complete; Zcash is @@ -300,6 +301,40 @@ function zrc20Envelope(body) { }; } +/** + * One dune as the authority contract reports it. The digits are chosen so a + * wrong shift is visible: divisibility 8, supply one hundred million. + */ +function dogeDune(dune, duneId, overrides = {}) { + return { + dune, + duneId, + numberAtomic: '1', + symbol: 'D', + divisibilityAtomic: '8', + etchingTxid: DOGE_TXID, + supplyAtomic: '10000000000000000', + premineAtomic: '100000000', + mintsAtomic: '21000', + burnedAtomic: '0', + etchedHeightAtomic: '5084000', + etchedTimestampAtomic: '1700000000', + mintable: true, + ...overrides, + }; +} + +function dogeDuneEnvelope(body) { + return { + chain: 'dogecoin', + network: 'mainnet', + blockCountAtomic: '5900001', + blockHash: DOGE_BLOCK, + inventoryComplete: true, + ...body, + }; +} + export const chainFixtures = { '/api/v1/chains': [capability('bitcoin'), capability('dogecoin'), capability('zcash')], @@ -602,6 +637,21 @@ export const chainFixtures = { ], }), [`/api/v1/zcash/protocols/zrc20/${ZEC_ZRC20}`]: zrc20Envelope(zrc20DivergingToken()), + + // The dune catalog: one open mint, one closed with no symbol, and one with + // zero divisibility so an unshifted quantity is on the page deliberately. + '/api/v1/dogecoin/protocols/dunes': dogeDuneEnvelope({ + totalCountAtomic: '3', + nextCursor: null, + dunes: [ + dogeDune('SUCH•WOW•DUNE', DOGE_DUNE_ID), + dogeDune('BARE•DUNE', '5084001:0', { symbol: null, mintable: false }), + dogeDune('WHOLE•THINGS', '5084002:0', { divisibilityAtomic: '0', supplyAtomic: '21000' }), + ], + }), + [`/api/v1/dogecoin/protocols/dunes/${DOGE_DUNE_ID}`]: dogeDuneEnvelope({ + dune: dogeDune('SUCH•WOW•DUNE', DOGE_DUNE_ID), + }), }; /** @@ -764,4 +814,4 @@ export const chainStateScope = { 'chain-object-missing': ['dogecoin'], }; -export const chainSampleIds = { DOGE_TXID, DOGE_BLOCK, DOGE_ADDRESS, ZEC_TXID, ZEC_BLOCK, ZEC_ADDRESS, ZEC_ZRC20 }; +export const chainSampleIds = { DOGE_TXID, DOGE_BLOCK, DOGE_ADDRESS, DOGE_DUNE_ID, ZEC_TXID, ZEC_BLOCK, ZEC_ADDRESS, ZEC_ZRC20 }; From f703ac6031423b102c0d64b3cd9d233b9dbf90a6 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sun, 30 Aug 2026 07:21:53 +0000 Subject: [PATCH 5/6] The pending set gets its cubes, without borrowing Bitcoin's claim The Dogecoin and Zcash mempool pages were a table beside a Bitcoin page that draws projected blocks. Copying that projection would be false in the units and in the claim, so the cubes here draw the weaker statement the overlay actually publishes: candidate buckets, grouped under each chain's own fee rules, with the semantics stated above the row. Dogecoin's cubes are the pending set ordered by fee per kilobyte, and the copy says they are not a forecast. Zcash's cubes are ZIP-317 eligibility tiers, labeled as verdicts each transaction carries on its own, because its block producers select randomly and drawing a queue would invent a consensus. The cadence line names the chain's target spacing and never a countdown; colors map each bucket's own quantiles onto the theme ramp by position, because absolute thresholds in koinu/kB or zatoshi-per-action would be numbers this explorer made up; a disclosure id this build has no copy for renders as its id rather than vanishing. The cube grammar itself is the Bitcoin one: the same face, skewed top and side, and projected-block palette, so the product reads as one thing across chains while each chain keeps its own truth. The bucket request failing renders no cubes and the list stands alone; the fixture states keep the cubes and the list in agreement, including an empty pending set and an authority outage. Co-Authored-By: Claude Fable 5 --- .../candidate-buckets.spec.ts | 146 +++++++++++ .../multichain-explorer/candidate-buckets.ts | 246 ++++++++++++++++++ .../multichain-explorer.component.html | 50 ++++ .../multichain-explorer.component.scss | 113 ++++++++ .../multichain-explorer.component.ts | 43 ++- .../multichain-view.spec.ts | 2 +- .../multichain-explorer/multichain-view.ts | 1 + .../src/app/universe/universe-api.service.ts | 6 + .../universe/universe-websocket.service.ts | 4 +- frontend/src/app/universe/universe.types.ts | 5 + scripts/universe/visual-qa/chain-fixtures.mjs | 106 ++++++++ 11 files changed, 717 insertions(+), 5 deletions(-) create mode 100644 frontend/src/app/universe/multichain-explorer/candidate-buckets.spec.ts create mode 100644 frontend/src/app/universe/multichain-explorer/candidate-buckets.ts diff --git a/frontend/src/app/universe/multichain-explorer/candidate-buckets.spec.ts b/frontend/src/app/universe/multichain-explorer/candidate-buckets.spec.ts new file mode 100644 index 00000000000..3cfa8d56ac8 --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/candidate-buckets.spec.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import { + cubeGradient, + readCandidateBuckets, +} from '@app/universe/multichain-explorer/candidate-buckets'; + +/** A Dogecoin view, in the exact overlay contract shape. */ +const DOGE_VIEW = { + schemaVersion: 'universe-candidate-buckets-v1', + chain: 'dogecoin', + network: 'mainnet', + snapshotId: 'snap-1', + sequenceAtomic: '7', + observedAt: '2026-08-30T07:00:00.000Z', + tip: { heightAtomic: '5900001', blockHash: 'd'.repeat(64), observedAt: '2026-08-30T07:00:00.000Z' }, + completeness: 'complete', + semantics: 'ordered-by-fee-policy', + feeModel: { kind: 'fee-per-kilobyte', unit: 'koinu/kB', minRelayFeeAtomicPerKb: '100000' }, + capacity: { + maxBlockSizeBytesAtomic: '1000000', + minerSoftCapBytesAtomic: null, + targetBlockSecondsAtomic: '60', + }, + buckets: [ + { + indexAtomic: '0', + txCountAtomic: '2', + totalSizeBytesAtomic: '1200', + totalFeesAtomic: '6600000', + feeQuantilesDecimal: ['1000000', null, null, null, null, null, null, '10000000'], + medianFeeDecimal: '1000000', + fillDecimal: '0.001', + overflow: false, + unknownFeeCountAtomic: '0', + }, + { + indexAtomic: '1', + txCountAtomic: '3', + totalSizeBytesAtomic: '1800', + totalFeesAtomic: null, + feeQuantilesDecimal: [null, null, null, null, null, null, null, null], + medianFeeDecimal: null, + fillDecimal: '0.001', + overflow: true, + unknownFeeCountAtomic: '2', + }, + ], + disclosures: ['ordered-not-a-forecast', 'unknown-fees-not-placed', 'some-future-id'], +}; + +describe('readCandidateBuckets', () => { + it('reads the view without strengthening any claim', () => { + const reading = readCandidateBuckets(DOGE_VIEW, 8, 'DOGE'); + expect(reading).not.toBeNull(); + expect(reading!.unit).toBe('koinu/kB'); + // The cadence line names a target, never an arrival time. + expect(reading!.cadenceNotice).toContain('60'); + expect(reading!.cadenceNotice).not.toMatch(/in ~|minutes away/i); + expect(reading!.semanticsNotice).toContain('Not a forecast'); + expect(reading!.cubes).toHaveLength(2); + const first = reading!.cubes[0]; + expect(first.txCount?.display).toBe('2'); + // Total fees shift by the chain precision: 6600000 koinu is 0.066 DOGE. + expect(first.totalFees?.display).toBe('0.066'); + expect(first.feeSpan).toEqual({ low: '1000000', high: '10000000' }); + }); + + it('keeps the overflow bucket honest about what it holds', () => { + const reading = readCandidateBuckets(DOGE_VIEW, 8, 'DOGE'); + const overflow = reading!.cubes[1]; + expect(overflow.overflow).toBe(true); + expect(overflow.totalFees).toBeNull(); + expect(overflow.unknownFeeCount?.display).toBe('2'); + expect(overflow.medianFee).toBeNull(); + }); + + it('shows a disclosure id it has no copy for rather than dropping it', () => { + const reading = readCandidateBuckets(DOGE_VIEW, 8, 'DOGE'); + expect(reading!.disclosures).toHaveLength(3); + expect(reading!.disclosures[2]).toBe('some-future-id'); + }); + + it('labels ZIP-317 tiers and never implies an order', () => { + const reading = readCandidateBuckets( + { + ...DOGE_VIEW, + semantics: 'zip317-eligibility-tiers', + feeModel: { + kind: 'zip-317', + unit: 'zatoshi/logical-action', + revisionAtomic: '1', + marginalFeeAtomic: '5000', + graceActionsAtomic: '2', + unpaidActionLimitAtomic: null, + }, + buckets: [ + { ...DOGE_VIEW.buckets[0], tier: 'fee-unknown' }, + ], + disclosures: ['zip317-random-selection'], + }, + 8, + 'ZEC' + ); + expect(reading!.semanticsNotice).toContain('No order is implied'); + expect(reading!.cubes[0].tierLabel).toBe('Fee not readable'); + expect(reading!.cubes[0].tierTone).toBe('neutral'); + }); + + it('is not this reading at all for other payloads', () => { + expect(readCandidateBuckets({ transactions: [] }, 8, 'DOGE')).toBeNull(); + expect(readCandidateBuckets(null, 8, 'DOGE')).toBeNull(); + }); +}); + +describe('cubeGradient', () => { + const cube = { + index: '0', + overflow: false, + tierLabel: null, + tierTone: null, + txCount: null, + totalSize: null, + totalFees: null, + medianFee: null, + feeSpan: null, + fillPercent: 50, + quantiles: ['1', '2', '3', '4'], + unknownFeeCount: null, + }; + + it('leaves the empty share empty and bands the filled share', () => { + const gradient = cubeGradient(cube, ['aaaaaa', 'bbbbbb', 'cccccc']); + expect(gradient).toContain('var(--u-block-projected-empty) 50%'); + expect(gradient).toContain('#aaaaaa'); + expect(gradient).toContain('#cccccc'); + }); + + it('renders an empty cube with no bands at all', () => { + expect(cubeGradient({ ...cube, fillPercent: 0 }, ['aaaaaa'])).toBe( + 'var(--u-block-projected-empty)' + ); + expect(cubeGradient({ ...cube, quantiles: [] }, ['aaaaaa'])).toBe( + 'var(--u-block-projected-empty)' + ); + }); +}); diff --git a/frontend/src/app/universe/multichain-explorer/candidate-buckets.ts b/frontend/src/app/universe/multichain-explorer/candidate-buckets.ts new file mode 100644 index 00000000000..4e2e2492b85 --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/candidate-buckets.ts @@ -0,0 +1,246 @@ +/** + * Candidate buckets, read for the cube row. + * + * The overlay groups each chain's pending set under that chain's own fee + * rules and says exactly what the grouping means: an ordering under the + * relay policy for Dogecoin, eligibility tiers under ZIP-317 for Zcash. + * This reading turns that payload into cubes without ever strengthening + * the claim: no ETA is derived from a target cadence, no ordering is + * implied for tiers, and a disclosure id this build has no copy for is + * still shown, as its id, rather than dropped. + * + * Colors are relative: a bucket's gradient maps its own fee quantiles onto + * the theme ramp by position, because absolute thresholds in koinu/kB or + * zatoshi-per-action would be numbers this explorer invented. The exact + * figures are beside every cube. + */ + +import { + ExactNumber, + formatAtomicAmount, + formatExactInteger, +} from './multichain-view'; + +export interface CandidateCubeReading { + readonly index: string; + readonly overflow: boolean; + /** The tier's short label, or null for an ordered bucket. */ + readonly tierLabel: string | null; + readonly tierTone: 'proven' | 'partial' | 'neutral' | 'unavailable' | null; + readonly txCount: ExactNumber | null; + readonly totalSize: ExactNumber | null; + /** Total fees shifted to the chain's ticker unit. Null when unknown. */ + readonly totalFees: ExactNumber | null; + readonly medianFee: string | null; + /** Lowest and highest known quantile, for the "a to b" span line. */ + readonly feeSpan: { readonly low: string; readonly high: string } | null; + /** Fill against capacity, clamped to 0..100 for the gradient. */ + readonly fillPercent: number; + /** Known fee quantiles, lowest first, for the relative gradient. */ + readonly quantiles: readonly string[]; + readonly unknownFeeCount: ExactNumber | null; +} + +export interface CandidateBucketsReading { + /** What the buckets are, in one honest sentence. */ + readonly semanticsNotice: string; + /** The fee unit, exactly as the model names it. */ + readonly unit: string; + /** The target cadence line. A target, never an ETA. */ + readonly cadenceNotice: string; + readonly facts: readonly { label: string; value: string }[]; + readonly disclosures: readonly string[]; + readonly cubes: readonly CandidateCubeReading[]; + readonly completeness: string; + readonly partial: boolean; +} + +function text(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const DECIMAL = /^(0|[1-9][0-9]*)(\.[0-9]+)?$/; + +function decimal(value: unknown): string | null { + return typeof value === 'string' && DECIMAL.test(value) ? value : null; +} + +const TIER_LABELS: Record = { + paid: $localize`:@@universe.buckets.tier-paid:Pays its conventional fee`, + unpaid: $localize`:@@universe.buckets.tier-unpaid:Pays less than convention`, + 'fee-unknown': $localize`:@@universe.buckets.tier-fee-unknown:Fee not readable`, + expiring: $localize`:@@universe.buckets.tier-expiring:Expiring`, +}; + +const TIER_TONES: Record = { + paid: 'proven', + unpaid: 'partial', + 'fee-unknown': 'neutral', + expiring: 'unavailable', +}; + +/** + * The honest copy per disclosure id. An id this build has no words for is + * shown as itself: an unreadable sentence beats a silently dropped claim. + */ +const DISCLOSURE_COPY: Record = { + 'ordered-not-a-forecast': $localize`:@@universe.buckets.d-ordered:Ordered by the fee policy nodes relay by. This is not a forecast of the next mined block.`, + 'packs-consensus-limit': $localize`:@@universe.buckets.d-consensus:Buckets pack against the consensus block size limit. Miners commonly build against a smaller soft cap this explorer does not claim to know.`, + 'unknown-fees-not-placed': $localize`:@@universe.buckets.d-unknown-fees:Transactions whose fee could not be read are placed in no bucket. Not knowing a fee must neither promote nor demote a transaction.`, + 'zip317-random-selection': $localize`:@@universe.buckets.d-zip317:Zcash block producers select transactions by weighted random sampling under ZIP-317. These are eligibility tiers, not a queue, and no order is implied.`, + 'shielded-fee-unknown': $localize`:@@universe.buckets.d-shielded:A shielded transaction's fee cannot be read from its transparent side. Those transactions are in the fee-not-readable tier, never assumed paid or unpaid.`, + 'expiry-drops-candidates': $localize`:@@universe.buckets.d-expiry:An expiring transaction may never confirm at all. Zcash transactions carry an expiry height; past it they leave the pending set without a block.`, + 'unpaid-admission-not-modeled': $localize`:@@universe.buckets.d-unpaid:Whether an underpaying transaction is admitted depends on each block producer's unpaid budget, which this explorer does not model. The tier states eligibility only.`, +}; + +function readCube(entry: unknown): CandidateCubeReading | null { + if (!isRecord(entry)) { + return null; + } + const quantiles = Array.isArray(entry.feeQuantilesDecimal) + ? entry.feeQuantilesDecimal + .map((value) => decimal(value)) + .filter((value): value is string => value !== null) + : []; + const fill = Number(decimal(entry.fillDecimal) ?? '0'); + const tier = text(entry.tier); + const unknownRaw = text(entry.unknownFeeCountAtomic); + return { + index: text(entry.indexAtomic) ?? '', + overflow: entry.overflow === true, + tierLabel: tier ? TIER_LABELS[tier] ?? tier : null, + tierTone: tier ? TIER_TONES[tier] ?? 'neutral' : null, + txCount: formatExactInteger(text(entry.txCountAtomic)), + totalSize: formatExactInteger(text(entry.totalSizeBytesAtomic)), + totalFees: null, + medianFee: decimal(entry.medianFeeDecimal), + feeSpan: + quantiles.length >= 2 + ? { low: quantiles[0], high: quantiles[quantiles.length - 1] } + : null, + fillPercent: Math.max(0, Math.min(100, Math.round(fill * 100))), + quantiles, + unknownFeeCount: + unknownRaw && unknownRaw !== '0' ? formatExactInteger(unknownRaw) : null, + }; +} + +/** + * The bucket view, read for the page. Returns null for anything that is not + * a candidate-buckets payload, so everything else keeps its reading. + */ +export function readCandidateBuckets( + payload: unknown, + precision: number, + ticker: string +): CandidateBucketsReading | null { + if ( + !isRecord(payload) || + payload.schemaVersion !== 'universe-candidate-buckets-v1' || + !isRecord(payload.feeModel) || + !Array.isArray(payload.buckets) + ) { + return null; + } + const model = payload.feeModel; + const unit = text(model.unit) ?? ''; + const semantics = text(payload.semantics); + const capacity = isRecord(payload.capacity) ? payload.capacity : {}; + const targetSeconds = text(capacity.targetBlockSecondsAtomic); + + const cubes: CandidateCubeReading[] = []; + for (const entry of payload.buckets) { + const cube = readCube(entry); + if (!cube) { + continue; + } + const totalFees = formatAtomicAmount( + isRecord(entry) ? text(entry.totalFeesAtomic) : null, + precision + ); + cubes.push(totalFees ? { ...cube, totalFees } : cube); + } + + const facts: { label: string; value: string }[] = []; + const maxSize = text(capacity.maxBlockSizeBytesAtomic); + if (maxSize) { + const size = formatExactInteger(maxSize); + if (size) { + facts.push({ + label: $localize`:@@universe.buckets.fact-capacity:Block size limit`, + value: $localize`:@@universe.buckets.fact-capacity-bytes:${size.display}:SIZE: bytes`, + }); + } + } + if (model.kind === 'fee-per-kilobyte') { + const floor = formatAtomicAmount(text(model.minRelayFeeAtomicPerKb), precision); + if (floor) { + facts.push({ + label: $localize`:@@universe.buckets.fact-relay-floor:Relay floor`, + value: `${floor.display} ${ticker}/kB`, + }); + } + } + if (model.kind === 'zip-317') { + const marginal = text(model.marginalFeeAtomic); + if (marginal) { + facts.push({ + label: $localize`:@@universe.buckets.fact-marginal:ZIP-317 marginal fee`, + value: $localize`:@@universe.buckets.fact-marginal-value:${marginal}:FEE: zatoshis per logical action`, + }); + } + } + + const completeness = text(payload.completeness) ?? 'unavailable'; + return { + semanticsNotice: + semantics === 'zip317-eligibility-tiers' + ? $localize`:@@universe.buckets.semantics-tiers:What is waiting, in the tiers ZIP-317 defines. No order is implied between or within them.` + : $localize`:@@universe.buckets.semantics-ordered:What is waiting, ordered as the fee policy prices it. Not a forecast of the next mined block.`, + unit, + cadenceNotice: targetSeconds + ? $localize`:@@universe.buckets.cadence:This chain targets a block every ${targetSeconds}:SECONDS: seconds. A target is not an arrival time, so no countdown is shown.` + : '', + facts, + disclosures: Array.isArray(payload.disclosures) + ? payload.disclosures + .map((id) => (typeof id === 'string' ? DISCLOSURE_COPY[id] ?? id : '')) + .filter((line) => line.length > 0) + : [], + cubes, + completeness, + partial: completeness !== 'complete', + }; +} + +/** + * The cube's background: the empty share on the left, then the known fee + * quantiles as bands across the filled share, colored by their position on + * the theme ramp. Pure, so it can be tested without a browser: the colors + * arrive as an argument and the return value is a CSS background. + */ +export function cubeGradient( + cube: CandidateCubeReading, + colors: readonly string[] +): string { + const empty = 100 - cube.fillPercent; + if (!cube.quantiles.length || !colors.length || cube.fillPercent === 0) { + return 'var(--u-block-projected-empty)'; + } + const stops = [ + `var(--u-block-projected-empty), var(--u-block-projected-empty) ${empty}%`, + ]; + const bands = cube.quantiles.length; + for (let index = 0; index < bands; index += 1) { + const color = + colors[Math.round((index / Math.max(1, bands - 1)) * (colors.length - 1))]; + const from = empty + (index / bands) * cube.fillPercent; + const to = empty + ((index + 1) / bands) * cube.fillPercent; + stops.push(`#${color} ${from}%, #${color} ${to}%`); + } + return `repeating-linear-gradient(to right, ${stops.join(', ')})`; +} diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html index eedc8903a3b..cb7207496fc 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html @@ -84,6 +84,56 @@

How much hist [class.current]="activeProtocolId() === protocol.id">{{ protocol.label }} + + + +
+

Waiting for a block

+

{{ buckets.semanticsNotice }}

+

{{ buckets.cadenceNotice }}

+

+ This grouping is built from a partial view of the pending set, so a bucket may be missing members. The list below carries the same limitation. +

+ +
+
+
+
+ + ~{{ cube.medianFee }} + {{ buckets.unit }} + + {{ cube.feeSpan.low }} to {{ cube.feeSpan.high }} + {{ cube.txCount.display }} transactions + {{ cube.totalSize.display }} bytes +
+
+
+ {{ cube.tierLabel }} + Everything after these + {{ cube.unknownFeeCount.display }} without a readable fee +
+
+
+

+ Nothing is waiting right now, as far as our own node can see. +

+ +
+
+
{{ fact.label }}
+
{{ fact.value }}
+
+
+
    +
  • {{ line }}
  • +
+
+ diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss index 4d8e7715b33..109028ccb7d 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss @@ -775,6 +775,119 @@ tbody tr:last-child td { border-bottom: 0; } +// --------------------------------------------------------------------------- +// Candidate buckets +// --------------------------------------------------------------------------- + +// The same cube grammar the Bitcoin projection uses: a face carrying the +// figures, a skewed top and side for depth, the projected-block palette. +// What differs is the claim, which the copy above the row states. +.cube-row { + display: flex; + gap: calc(var(--u-space-6) + 20px); + overflow-x: auto; + padding: calc(0.24 * var(--cube-size)) var(--u-space-2) var(--u-space-2) + calc(0.16 * var(--cube-size) + var(--u-space-2)); + --cube-size: 125px; + + &:focus-visible { + outline: 2px solid var(--u-focus-ring); + outline-offset: 2px; + } +} + +.candidate-cube { + flex: 0 0 auto; + width: var(--cube-size); +} + +.cube-block { + position: relative; + width: var(--cube-size); + height: var(--cube-size); + background: var(--u-block-projected-empty); +} + +.cube-block::after { + content: ''; + width: var(--cube-size); + height: calc(0.192 * var(--cube-size)); + position: absolute; + top: calc(-0.192 * var(--cube-size)); + left: calc(-0.16 * var(--cube-size)); + background-color: var(--u-block-projected-top); + transform: skew(40deg); + transform-origin: top; +} + +.cube-block::before { + content: ''; + width: calc(0.16 * var(--cube-size)); + height: var(--cube-size); + position: absolute; + top: calc(-0.096 * var(--cube-size)); + left: calc(-0.16 * var(--cube-size)); + background-color: var(--u-block-projected-side); + transform: skewY(50deg); + transform-origin: top; +} + +.cube-body { + position: relative; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--u-space-1); + text-align: center; + padding: var(--u-space-2); + color: var(--u-block-ink); +} + +.cube-median { + font-size: var(--u-text-md); + font-weight: var(--u-weight-bold); + overflow-wrap: anywhere; +} + +.cube-unit { + font-size: var(--u-text-2xs); + font-weight: var(--u-weight-regular); +} + +.cube-span, +.cube-count, +.cube-size { + font-size: var(--u-text-2xs); + color: var(--u-block-ink-muted); + overflow-wrap: anywhere; +} + +.cube-caption { + margin-top: var(--u-space-2); + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--u-space-1); + font-size: var(--u-text-2xs); +} + +.bucket-facts { + margin-top: var(--u-space-3); +} + +.bucket-disclosures { + list-style: none; + margin: var(--u-space-3) 0 0; + padding: 0; + font-size: var(--u-text-sm); + + li + li { + margin-top: var(--u-space-1); + } +} + // --------------------------------------------------------------------------- // Ruleset ledgers // --------------------------------------------------------------------------- diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts index 5c77799c957..70913ac9920 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts @@ -72,6 +72,13 @@ import { readDuneAssetDetail, readDuneAssetList, } from '@app/universe/multichain-explorer/dune-assets'; +import { + CandidateBucketsReading, + CandidateCubeReading, + cubeGradient, + readCandidateBuckets, +} from '@app/universe/multichain-explorer/candidate-buckets'; +import { ThemeService } from '@app/services/theme.service'; import { Observable, Subject, @@ -126,6 +133,8 @@ interface ExplorerViewModel { readonly duneAsset: DuneAssetReading | null; /** A page of dunes, each row carrying its own divisibility. */ readonly duneList: DuneAssetListReading | null; + /** The pending set grouped under this chain's own fee rules. */ + readonly buckets: CandidateBucketsReading | null; readonly emptyList: EmptyListReading | null; readonly facts: readonly Fact[]; /** True when the page is showing a response it has no purpose-built reading for. */ @@ -223,6 +232,7 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { private readonly live: UniverseWebsocketService, private readonly local: UniverseLocalService, private readonly seo: SeoService, + private readonly theme: ThemeService, private readonly changes: ChangeDetectorRef ) { this.chain = this.chainFromUrl(router.url); @@ -272,10 +282,24 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { of({ payload: null, error: this.errorMessage(error) }) ) ), + // The bucket view beside the pending list. Its failure is not + // the page's failure: the list stands on its own, so a bucket + // request that cannot answer simply renders no cubes. + context.page === 'mempool' + ? this.api.getChainCandidateBuckets$(this.chain).pipe( + catchError(() => of(null)) + ) + : of(null), ]) ), - map(([status, result]): ExplorerViewModel => - this.viewModel(status.capability, status.error, result.payload, result.error) + map(([status, result, bucketsPayload]): ExplorerViewModel => + this.viewModel( + status.capability, + status.error, + result.payload, + result.error, + bucketsPayload + ) ) ); }), @@ -292,7 +316,8 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { capability: ChainCapabilityEnvelope | null, capabilityError: string | null, payload: ChainExplorerPayload | null, - payloadError: string | null + payloadError: string | null, + bucketsPayload: ChainExplorerPayload | null = null ): ExplorerViewModel { const shape = classifyPayload(payload); const transaction = payload && shape === 'transaction' ? readTransaction(payload, this.profile) : null; @@ -356,6 +381,13 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { rulesetList, duneAsset, duneList, + buckets: bucketsPayload + ? readCandidateBuckets( + bucketsPayload, + this.profile.precision, + this.ticker + ) + : null, emptyList: transactionList || collection || rulesetList || duneList ? null @@ -489,6 +521,11 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { return list.shownCount < Number(list.totalExact); } + /** The cube's background, from its own quantiles and the theme ramp. */ + cubeBackground(cube: CandidateCubeReading): string { + return cubeGradient(cube, this.theme.mempoolFeeColors); + } + /** True when the authority reports more dunes than this page shows. */ duneListTruncated(list: DuneAssetListReading): boolean { if (!list.totalExact || !/^(0|[1-9][0-9]*)$/.test(list.totalExact)) { diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts index 33e31797779..33530a4f62d 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts @@ -348,7 +348,7 @@ describe('readCapabilities', () => { it('lists every read, marking the ones the chain does not offer', () => { const reads = readCapabilities(capability()); expect(reads.map((r) => r.id)).toEqual([ - 'transaction', 'block', 'address', 'outpoint', 'feeEstimates', 'projectedBlocks', + 'transaction', 'block', 'address', 'outpoint', 'feeEstimates', 'projectedBlocks', 'candidateBuckets', ]); expect(reads.find((r) => r.id === 'projectedBlocks')?.state).toBe('not-offered'); expect(reads.find((r) => r.id === 'transaction')?.state).toBe('offered'); diff --git a/frontend/src/app/universe/multichain-explorer/multichain-view.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.ts index 754aafdea89..e740137edf4 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-view.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-view.ts @@ -922,6 +922,7 @@ const READ_LABELS: readonly { id: keyof ChainCapabilityEnvelope['reads']; label: { id: 'outpoint', label: $localize`:@@universe.chain.read-outpoint:Outpoint lookup` }, { id: 'feeEstimates', label: $localize`:@@universe.chain.read-fees:Fee estimates` }, { id: 'projectedBlocks', label: $localize`:@@universe.chain.read-projected:Projected blocks` }, + { id: 'candidateBuckets', label: $localize`:@@universe.chain.read-buckets:Pending set, grouped by fee policy` }, ]; /** diff --git a/frontend/src/app/universe/universe-api.service.ts b/frontend/src/app/universe/universe-api.service.ts index db377d88f3d..5f1c16540b5 100644 --- a/frontend/src/app/universe/universe-api.service.ts +++ b/frontend/src/app/universe/universe-api.service.ts @@ -164,6 +164,12 @@ export class UniverseApiService { ); } + getChainCandidateBuckets$(chain: Exclude): Observable { + return this.httpClient.get( + this.apiBaseUrl + '/api/v1/' + chain + '/candidate-buckets?network=mainnet' + ); + } + getChainTransaction$(chain: Exclude, txid: string): Observable { return this.httpClient.get( this.apiBaseUrl + '/api/v1/' + chain + '/tx/' + encodeURIComponent(txid) + '?network=mainnet' diff --git a/frontend/src/app/universe/universe-websocket.service.ts b/frontend/src/app/universe/universe-websocket.service.ts index add97acc674..b1b73416c1d 100644 --- a/frontend/src/app/universe/universe-websocket.service.ts +++ b/frontend/src/app/universe/universe-websocket.service.ts @@ -8,7 +8,8 @@ export interface UniverseLiveEnvelope { readonly chain: ExplorerChain; readonly network: 'mainnet'; readonly channel: - 'chain-status' | 'mempool-snapshot' | 'confirmed-protocol-activity'; + 'chain-status' | 'mempool-snapshot' | 'candidate-buckets' + | 'confirmed-protocol-activity'; readonly snapshotId: string; readonly sequenceAtomic: string; readonly observedAt: string; @@ -24,6 +25,7 @@ interface ResumeCursor { const CHANNELS = [ 'chain-status', 'mempool-snapshot', + 'candidate-buckets', 'confirmed-protocol-activity', ] as const; const DECIMAL = /^(0|[1-9][0-9]*)$/; diff --git a/frontend/src/app/universe/universe.types.ts b/frontend/src/app/universe/universe.types.ts index 327b032a6d2..56e77e63782 100644 --- a/frontend/src/app/universe/universe.types.ts +++ b/frontend/src/app/universe/universe.types.ts @@ -345,6 +345,11 @@ export interface ChainCapabilityEnvelope { outpoint: boolean; feeEstimates: boolean; projectedBlocks: boolean; + /** + * A weaker claim than projected blocks: the pending set grouped under + * the chain's own fee policy, never a forecast of the next block. + */ + candidateBuckets?: boolean; }; protocols: ChainCapabilityProtocol[]; coverage: { diff --git a/scripts/universe/visual-qa/chain-fixtures.mjs b/scripts/universe/visual-qa/chain-fixtures.mjs index 614da6fb390..b1e72582e2b 100644 --- a/scripts/universe/visual-qa/chain-fixtures.mjs +++ b/scripts/universe/visual-qa/chain-fixtures.mjs @@ -652,6 +652,81 @@ export const chainFixtures = { [`/api/v1/dogecoin/protocols/dunes/${DOGE_DUNE_ID}`]: dogeDuneEnvelope({ dune: dogeDune('SUCH•WOW•DUNE', DOGE_DUNE_ID), }), + + // The pending set grouped under each chain's own fee rules. Dogecoin's is + // an ordering per kilobyte with an overflow bucket holding what could not + // be priced; Zcash's are ZIP-317 tiers with no ordering claimed. + '/api/v1/dogecoin/candidate-buckets': { + schemaVersion: 'universe-candidate-buckets-v1', + chain: 'dogecoin', + network: 'mainnet', + snapshotId: 'dogecoin-mainnet-buckets', + sequenceAtomic: '19', + observedAt: new Date(Date.now() - 9_000).toISOString(), + tip: { heightAtomic: '5900001', blockHash: DOGE_BLOCK, observedAt: new Date(Date.now() - 9_000).toISOString() }, + completeness: 'partial', + semantics: 'ordered-by-fee-policy', + feeModel: { kind: 'fee-per-kilobyte', unit: 'koinu/kB', minRelayFeeAtomicPerKb: '100000' }, + capacity: { maxBlockSizeBytesAtomic: '1000000', minerSoftCapBytesAtomic: null, targetBlockSecondsAtomic: '60' }, + buckets: [ + { + indexAtomic: '0', txCountAtomic: '412', totalSizeBytesAtomic: '981220', + totalFeesAtomic: '10981220000', medianFeeDecimal: '11190476.19', + feeQuantilesDecimal: ['1000000', '2600000', '5200000', '8400000', '11100000', '16400000', '24800000', '104000000'], + fillDecimal: '0.981', overflow: false, unknownFeeCountAtomic: '0', + }, + { + indexAtomic: '1', txCountAtomic: '188', totalSizeBytesAtomic: '412008', + totalFeesAtomic: '482008000', medianFeeDecimal: '1160000', + feeQuantilesDecimal: ['100000', '220000', '460000', '780000', '1160000', '1520000', '2100000', '2600000'], + fillDecimal: '0.412', overflow: false, unknownFeeCountAtomic: '0', + }, + { + indexAtomic: '2', txCountAtomic: '44', totalSizeBytesAtomic: '96110', + totalFeesAtomic: null, medianFeeDecimal: '100000', + feeQuantilesDecimal: ['100000', '100000', '100000', '100000', '100000', '100000', '100000', '100000'], + fillDecimal: '0.096', overflow: true, unknownFeeCountAtomic: '9', + }, + ], + disclosures: ['ordered-not-a-forecast', 'packs-consensus-limit', 'unknown-fees-not-placed'], + }, + '/api/v1/zcash/candidate-buckets': { + schemaVersion: 'universe-candidate-buckets-v1', + chain: 'zcash', + network: 'mainnet', + snapshotId: 'zcash-buckets', + sequenceAtomic: '22105', + observedAt: new Date(Date.now() - 9_000).toISOString(), + tip: { heightAtomic: '3465589', blockHash: ZEC_BLOCK, observedAt: new Date(Date.now() - 9_000).toISOString() }, + completeness: 'complete', + semantics: 'zip317-eligibility-tiers', + feeModel: { kind: 'zip-317', unit: 'zatoshi/logical-action', revisionAtomic: '1', marginalFeeAtomic: '5000', graceActionsAtomic: '2', unpaidActionLimitAtomic: null }, + capacity: { maxBlockSizeBytesAtomic: '2000000', minerSoftCapBytesAtomic: null, targetBlockSecondsAtomic: '75' }, + buckets: [ + { + indexAtomic: '0', txCountAtomic: '9', totalSizeBytesAtomic: '18420', + totalFeesAtomic: '191500', medianFeeDecimal: '5000', + feeQuantilesDecimal: ['5000', '5000', '5000', '5000', '5000', '5250', '6100', '7500'], + fillDecimal: '0.009', overflow: false, unknownFeeCountAtomic: '0', + tier: 'paid', logicalActionsAtomic: '37', + }, + { + indexAtomic: '1', txCountAtomic: '2', totalSizeBytesAtomic: '3100', + totalFeesAtomic: '4000', medianFeeDecimal: '666.666', + feeQuantilesDecimal: ['500', '666.666', null, null, null, null, null, null], + fillDecimal: '0.001', overflow: false, unknownFeeCountAtomic: '0', + tier: 'unpaid', logicalActionsAtomic: '6', + }, + { + indexAtomic: '2', txCountAtomic: '1', totalSizeBytesAtomic: '2100', + totalFeesAtomic: null, medianFeeDecimal: null, + feeQuantilesDecimal: [null, null, null, null, null, null, null, null], + fillDecimal: '0.001', overflow: false, unknownFeeCountAtomic: '1', + tier: 'fee-unknown', logicalActionsAtomic: '4', + }, + ], + disclosures: ['zip317-random-selection', 'shielded-fee-unknown', 'unpaid-admission-not-modeled'], + }, }; /** @@ -707,6 +782,35 @@ export const chainStateOverrides = { transactions: [], }, }, + // The cubes beside an empty list must also be empty: a populated bucket + // view above an empty pending list is a disagreement production cannot + // produce, both come from the same snapshot. + '/api/v1/dogecoin/candidate-buckets': { + body: { + schemaVersion: 'universe-candidate-buckets-v1', + chain: 'dogecoin', network: 'mainnet', + snapshotId: 'dogecoin-partial-view', sequenceAtomic: '12', + observedAt: new Date(Date.now() - 11_000).toISOString(), + tip: null, completeness: 'partial', + semantics: 'ordered-by-fee-policy', + feeModel: { kind: 'fee-per-kilobyte', unit: 'koinu/kB', minRelayFeeAtomicPerKb: '100000' }, + capacity: { maxBlockSizeBytesAtomic: '1000000', minerSoftCapBytesAtomic: null, targetBlockSecondsAtomic: '60' }, + buckets: [], disclosures: ['ordered-not-a-forecast'], + }, + }, + '/api/v1/zcash/candidate-buckets': { + body: { + schemaVersion: 'universe-candidate-buckets-v1', + chain: 'zcash', network: 'mainnet', + snapshotId: 'zcash-ba6a8834d37e822b13a88c323255e2b3d2c6e68d', sequenceAtomic: '384', + observedAt: new Date(Date.now() - 11_000).toISOString(), + tip: null, completeness: 'complete', + semantics: 'zip317-eligibility-tiers', + feeModel: { kind: 'zip-317', unit: 'zatoshi/logical-action', revisionAtomic: '1', marginalFeeAtomic: '5000', graceActionsAtomic: '2', unpaidActionLimitAtomic: null }, + capacity: { maxBlockSizeBytesAtomic: '2000000', minerSoftCapBytesAtomic: null, targetBlockSecondsAtomic: '75' }, + buckets: [], disclosures: ['zip317-random-selection'], + }, + }, }, // Both chain authorities are unreachable. The page must say the status is @@ -721,6 +825,8 @@ export const chainStateOverrides = { '/api/v1/zcash/status': { status: 503 }, '/api/v1/dogecoin/mempool': { status: 503 }, '/api/v1/zcash/mempool': { status: 503 }, + '/api/v1/dogecoin/candidate-buckets': { status: 503 }, + '/api/v1/zcash/candidate-buckets': { status: 503 }, }, // The authority answers, and says it is behind. This is the state the whole From 95558218ba6e02d0c1f179b5844a2d7b05e53781 Mon Sep 17 00:00:00 2001 From: Bitcoin Universe Date: Sun, 30 Aug 2026 07:35:20 +0000 Subject: [PATCH 6/6] The protocols index stops being a dump of the authority's fields The chain protocols page fell through to the generic path: the registry manifest as a seven-column table, and each authority's operational record flattened into facts like "Confirmed history configured: No" beside "blockbook-unconfigured", which left a reader to reassemble the sentence themselves. Every one of those facts is kept and given its place. The manifest is a designed table: each protocol with its page, its release verdict in the registry's own vocabulary translated without being softened, the authority that serves it, and its coverage. Below it, each authority reports about itself verdict first: what it is, whether it answers, the block it stands at, when it last answered, and any failure under the exact code the source reported, because a paraphrase cannot be checked against the service and the code can. Co-Authored-By: Claude Fable 5 --- .../multichain-explorer.component.html | 59 +++++ .../multichain-explorer.component.scss | 37 ++++ .../multichain-explorer.component.ts | 21 +- .../protocol-index.spec.ts | 77 +++++++ .../multichain-explorer/protocol-index.ts | 201 ++++++++++++++++++ 5 files changed, 392 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/universe/multichain-explorer/protocol-index.spec.ts create mode 100644 frontend/src/app/universe/multichain-explorer/protocol-index.ts diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html index cb7207496fc..2d88d60c752 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.html @@ -749,6 +749,65 @@

Tokens + + + + + +
+

Protocols on this chain

+

+ What the registry lists for this chain, and how far each entry has come. A protocol is only called verified after its integration has been proven end to end; a healthy indexer alone does not earn it. +

+
+ + + + + + + + + + + + + + + + + +
ProtocolReleaseIndexerCoverage
+ {{ row.displayName }} + {{ row.displayName }} + {{ row.releaseLabel }}{{ row.authorityId }}{{ row.coverageLabel }}
+
+
+ +
+

The authorities behind them

+

+ What each first-party source reports about itself right now. A failure is shown under the code the source reported, so it can be checked against the service rather than a paraphrase of it. +

+
    +
  • + {{ authority.label }} + {{ authority.stateLabel }} + + {{ authority.authorityId }} + at block {{ authority.checkpointHeight.display }} + last answered {{ authority.lastSuccess }} + + Reported as {{ authority.failureCode }}, in the source's own words. +
  • +
+
+ +
+ diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss index 109028ccb7d..6c6c7e9f372 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss @@ -930,6 +930,43 @@ tbody tr:last-child td { white-space: nowrap; } +// One authority per row: what it is, its verdict, then the evidence. +.authority-list { + list-style: none; + margin: 0; + padding: 0; + + li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--u-space-2) var(--u-space-3); + padding: var(--u-space-2) 0; + + + li { + border-top: 1px solid var(--u-divider); + } + } +} + +.authority-label { + font-weight: var(--u-weight-semibold); + min-width: 11rem; +} + +.authority-detail { + display: flex; + flex-wrap: wrap; + gap: var(--u-space-3); + color: var(--u-text-secondary); + font-size: var(--u-text-sm); +} + +.authority-failure { + flex-basis: 100%; + font-size: var(--u-text-sm); +} + // The dune's own glyph, beside its name. It is the authority's value, so it // sits in a quiet chip rather than decorating the heading. .dune-symbol { diff --git a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts index 70913ac9920..0fc63cc67a5 100644 --- a/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts +++ b/frontend/src/app/universe/multichain-explorer/multichain-explorer.component.ts @@ -79,6 +79,10 @@ import { readCandidateBuckets, } from '@app/universe/multichain-explorer/candidate-buckets'; import { ThemeService } from '@app/services/theme.service'; +import { + ProtocolIndexReading, + readProtocolIndex, +} from '@app/universe/multichain-explorer/protocol-index'; import { Observable, Subject, @@ -135,6 +139,8 @@ interface ExplorerViewModel { readonly duneList: DuneAssetListReading | null; /** The pending set grouped under this chain's own fee rules. */ readonly buckets: CandidateBucketsReading | null; + /** The registry manifest and the authorities behind it, designed. */ + readonly protocolIndex: ProtocolIndexReading | null; readonly emptyList: EmptyListReading | null; readonly facts: readonly Fact[]; /** True when the page is showing a response it has no purpose-built reading for. */ @@ -338,8 +344,12 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { const rulesetList = payload && !transactionList ? readRulesetAssetList(payload) : null; const duneAsset = payload ? readDuneAssetDetail(payload) : null; const duneList = payload && !transactionList ? readDuneAssetList(payload) : null; + const protocolIndex = + payload && !transactionList + ? readProtocolIndex(payload, this.profile, Date.now()) + : null; const collection = - payload && !transactionList && !rulesetList && !duneList && + payload && !transactionList && !rulesetList && !duneList && !protocolIndex && (shape === 'collection' || shape === 'record') ? readCollection(payload, this.profile) : null; @@ -358,6 +368,10 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { // them stay, because nothing else on the page states them. ...(duneAsset ? ['dune'] : []), ...(duneList ? ['dunes', 'totalCountAtomic'] : []), + // The protocol index owns the manifest rows and every authority record. + ...(protocolIndex + ? ['items', ...protocolIndex.authorities.map((authority) => authority.key)] + : []), ]; return { capability, @@ -381,6 +395,7 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { rulesetList, duneAsset, duneList, + protocolIndex, buckets: bucketsPayload ? readCandidateBuckets( bucketsPayload, @@ -389,13 +404,13 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { ) : null, emptyList: - transactionList || collection || rulesetList || duneList + transactionList || collection || rulesetList || duneList || protocolIndex ? null : readEmptyList(payload), facts: readRecordFacts(payload, this.profile, skip), generic: (shape === 'collection' || shape === 'record') && - !rulesetAsset && !rulesetList && !duneAsset && !duneList, + !rulesetAsset && !rulesetList && !duneAsset && !duneList && !protocolIndex, }; } diff --git a/frontend/src/app/universe/multichain-explorer/protocol-index.spec.ts b/frontend/src/app/universe/multichain-explorer/protocol-index.spec.ts new file mode 100644 index 00000000000..288717d76d3 --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/protocol-index.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { LIVE_PAYLOADS } from '@app/universe/multichain-explorer/live-payloads'; +import { chainProfile } from '@app/universe/multichain-explorer/multichain-view'; +import { readProtocolIndex } from '@app/universe/multichain-explorer/protocol-index'; + +const DOGE = chainProfile('dogecoin'); +const ZEC = chainProfile('zcash'); +const NOW = Date.parse('2026-08-29T08:30:00.000Z'); + +describe('the chain protocols index, read as a designed page', () => { + it('lists every registry entry with its release verdict and a route', () => { + const reading = readProtocolIndex( + structuredClone(LIVE_PAYLOADS.dogecoinProtocols), + DOGE, + NOW + ); + expect(reading).not.toBeNull(); + expect(reading!.rows.map((row) => row.id)).toEqual([ + 'doginals', 'drc20', 'tap_doge', 'dunes', + ]); + // tap_doge routes through its alias; every entry has a page now. + expect(reading!.rows.map((row) => row.routeId)).toEqual([ + 'doginals', 'drc20', 'doge-tap', 'dunes', + ]); + const blocked = reading!.rows[0]; + expect(blocked.releaseLabel).toBe('Not yet verified'); + expect(blocked.releaseTone).toBe('unavailable'); + expect(blocked.authorityId).toBe('ord-dogecoin'); + expect(blocked.coverageLabel).toBe('Coverage not established'); + }); + + it('turns each authority record into a verdict with its evidence beside it', () => { + const reading = readProtocolIndex( + structuredClone(LIVE_PAYLOADS.dogecoinProtocols), + DOGE, + NOW + ); + const byKey = new Map( + reading!.authorities.map((authority) => [authority.key, authority]) + ); + // The exact dump from the screenshot, now a verdict with the code kept. + const confirmed = byKey.get('confirmedHistory'); + expect(confirmed?.stateLabel).toBe('Not configured'); + expect(confirmed?.tone).toBe('unavailable'); + expect(confirmed?.failureCode).toBe('blockbook-unconfigured'); + + const mempool = byKey.get('mempool'); + expect(mempool?.stateLabel).toBe('Configured, not polling'); + expect(mempool?.checkpointHeight?.exact).toBe('6351906'); + + const protocol = byKey.get('protocolAuthority'); + expect(protocol?.stateLabel).toBe('Not answering'); + expect(protocol?.failureCode).toBe('transport'); + expect(protocol?.authorityId).toBe('ord-dogecoin'); + }); + + it('reads the zcash manifest, whose status names no failure at all', () => { + const reading = readProtocolIndex( + structuredClone(LIVE_PAYLOADS.zcashProtocols), + ZEC, + NOW + ); + expect(reading!.rows.map((row) => row.routeId)).toEqual([ + 'zerdinals', 'zrunes', 'zrc20', + ]); + const status = reading!.authorities.find((authority) => authority.key === 'status'); + expect(status?.stateLabel).toBe('Answering'); + expect(status?.tone).toBe('proven'); + expect(status?.failureCode).toBeNull(); + }); + + it('is not this reading at all for other payloads', () => { + expect(readProtocolIndex({ items: [{ tick: 'ZERO' }] }, ZEC, NOW)).toBeNull(); + expect(readProtocolIndex({ transactions: [] }, DOGE, NOW)).toBeNull(); + expect(readProtocolIndex(null, DOGE, NOW)).toBeNull(); + }); +}); diff --git a/frontend/src/app/universe/multichain-explorer/protocol-index.ts b/frontend/src/app/universe/multichain-explorer/protocol-index.ts new file mode 100644 index 00000000000..661add421e7 --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/protocol-index.ts @@ -0,0 +1,201 @@ +/** + * The chain protocols index, read as a designed page. + * + * The payload is the registry manifest for one chain beside the operational + * status of every authority serving it. The generic path dumped both: the + * registry rows as a seven-column table and each authority as a run of + * "Confirmed history configured: No" facts, which is a reader's job to + * reassemble. This reading keeps every one of those facts and gives each + * its place: what each protocol is and where its page is, then what each + * authority reports about itself, verdict first and evidence beside it. + * + * Nothing is softened on the way through: a BLOCKED release status renders + * as not yet verified, a configuration failure is shown under the code the + * authority reported, and a status this build has no words for is shown as + * itself. + */ + +import type { ChainProfile } from './multichain-view'; +import { + ExactNumber, + formatElapsed, + formatExactInteger, + humanizeFieldName, +} from './multichain-view'; + +export type IndexTone = 'proven' | 'partial' | 'neutral' | 'unavailable'; + +export interface ProtocolIndexRow { + readonly id: string; + readonly displayName: string; + /** The explorer page for it, or null when this build has none. */ + readonly routeId: string | null; + readonly releaseLabel: string; + readonly releaseTone: IndexTone; + readonly authorityId: string | null; + readonly coverageLabel: string | null; +} + +export interface AuthorityStatusReading { + /** The payload field this came from, for the skip list. */ + readonly key: string; + readonly label: string; + readonly authorityId: string | null; + readonly stateLabel: string; + readonly tone: IndexTone; + /** The failure code the authority reported, exactly. */ + readonly failureCode: string | null; + readonly lastSuccess: string | null; + readonly checkpointHeight: ExactNumber | null; +} + +export interface ProtocolIndexReading { + readonly rows: readonly ProtocolIndexRow[]; + readonly authorities: readonly AuthorityStatusReading[]; +} + +function text(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * The registry's own vocabulary, translated without being softened. + * BLOCKED is a policy veto: the integration has not been verified, so the + * registry refuses to call the protocol readable no matter how healthy the + * authority looks. + */ +function releaseReading(status: string | null): { label: string; tone: IndexTone } { + const normalized = (status ?? '').toUpperCase().replace(/[_-]+/g, ' ').trim(); + if (normalized === 'VERIFIED READ ONLY') { + return { + label: $localize`:@@universe.protoindex.release-verified:Verified, read only`, + tone: 'proven', + }; + } + if (normalized === 'BLOCKED') { + return { + label: $localize`:@@universe.protoindex.release-blocked:Not yet verified`, + tone: 'unavailable', + }; + } + if (normalized === 'INTENTIONALLY DISABLED') { + return { + label: $localize`:@@universe.protoindex.release-disabled:Disabled on purpose`, + tone: 'neutral', + }; + } + return { label: status ?? '', tone: 'neutral' }; +} + +function stateReading(record: Record): { + label: string; + tone: IndexTone; +} { + if (record.configured === false) { + return { + label: $localize`:@@universe.protoindex.state-unconfigured:Not configured`, + tone: 'unavailable', + }; + } + const state = text(record.state); + switch (state) { + case 'ready': + return { label: $localize`:@@universe.protoindex.state-ready:Answering`, tone: 'proven' }; + case 'degraded': + return { label: $localize`:@@universe.protoindex.state-degraded:Degraded`, tone: 'partial' }; + case 'unavailable': + return { label: $localize`:@@universe.protoindex.state-unavailable:Not answering`, tone: 'unavailable' }; + } + if (state !== null) { + // A state this build has no words for is shown as itself. + return { label: state, tone: 'neutral' }; + } + if (record.running === false) { + return { label: $localize`:@@universe.protoindex.state-idle:Configured, not polling`, tone: 'partial' }; + } + return { label: $localize`:@@universe.protoindex.state-configured:Configured`, tone: 'neutral' }; +} + +function checkpointHeightFrom(record: Record): ExactNumber | null { + const checkpoint = isRecord(record.checkpoint) ? record.checkpoint : null; + const snapshot = isRecord(record.snapshot) ? record.snapshot : null; + const tip = + (checkpoint && text(checkpoint.heightAtomic)) ?? + (snapshot && isRecord(snapshot.tip) ? text(snapshot.tip.heightAtomic) : null); + return formatExactInteger(tip); +} + +/** + * The chain protocols index. Returns null for anything that is not a + * registry manifest page, so everything else keeps the reading it had. + */ +export function readProtocolIndex( + payload: unknown, + profile: ChainProfile, + now: number +): ProtocolIndexReading | null { + if (!isRecord(payload) || !Array.isArray(payload.items)) { + return null; + } + const items = payload.items.filter(isRecord); + if ( + !items.length || + !items.every( + (item) => item.schemaVersion === 'universe-explorer-protocol-v1' + ) + ) { + return null; + } + + const rows: ProtocolIndexRow[] = items.map((item) => { + const id = text(item.id) ?? ''; + const aliases = Array.isArray(item.aliases) + ? item.aliases.filter((alias): alias is string => typeof alias === 'string') + : []; + const tab = profile.protocols.find( + (candidate) => + candidate.registryIds.includes(id) || + aliases.some((alias) => candidate.registryIds.includes(alias)) || + candidate.id === id + ); + const release = releaseReading(text(item.releaseStatus)); + const coverage = text(item.coverage); + return { + id, + displayName: text(item.displayName) ?? id, + routeId: tab?.id ?? null, + releaseLabel: release.label, + releaseTone: release.tone, + authorityId: text(item.indexerAuthority), + coverageLabel: + coverage === 'unknown' + ? $localize`:@@universe.protoindex.coverage-unknown:Coverage not established` + : coverage, + }; + }); + + const authorities: AuthorityStatusReading[] = []; + for (const [key, value] of Object.entries(payload)) { + if (!isRecord(value) || typeof value.configured !== 'boolean') { + continue; + } + const state = stateReading(value); + authorities.push({ + key, + label: humanizeFieldName(key), + authorityId: text(value.authorityId), + stateLabel: state.label, + tone: state.tone, + failureCode: + text(value.configurationFailure) ?? text(value.lastFailureKind), + lastSuccess: formatElapsed(text(value.lastSuccessAt), now), + checkpointHeight: checkpointHeightFrom(value), + }); + } + + return { rows, authorities }; +}