diff --git a/.github/workflows/universe-ci.yml b/.github/workflows/universe-ci.yml index 8ca0e214e18..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 + --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/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/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/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 8eb011b0447..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 @@ -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]); @@ -62,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'); }); }); @@ -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..2d88d60c752 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 }}
  • +
+
+ @@ -551,6 +601,321 @@

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

+
+ + + + + + +
+

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

+ {{ 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 a375a416caf..6c6c7e9f372 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,232 @@ 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 +// --------------------------------------------------------------------------- + +// 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; +} + +// 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 { + 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; + 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..0fc63cc67a5 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,29 @@ 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 { + DuneAssetListReading, + DuneAssetReading, + 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 { + ProtocolIndexReading, + readProtocolIndex, +} from '@app/universe/multichain-explorer/protocol-index'; import { Observable, Subject, @@ -106,6 +129,18 @@ 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; + /** 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; + /** 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. */ @@ -203,6 +238,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); @@ -252,10 +288,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 + ) ) ); }), @@ -272,7 +322,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; @@ -285,8 +336,21 @@ 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 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 && (shape === 'collection' || shape === 'record') + payload && !transactionList && !rulesetList && !duneList && !protocolIndex && + (shape === 'collection' || shape === 'record') ? readCollection(payload, this.profile) : null; const skip = [ @@ -294,6 +358,20 @@ 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'] : []), + // 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'] : []), + // The protocol index owns the manifest rows and every authority record. + ...(protocolIndex + ? ['items', ...protocolIndex.authorities.map((authority) => authority.key)] + : []), ]; return { capability, @@ -313,9 +391,26 @@ export class MultichainExplorerComponent implements OnInit, OnDestroy { outpoint, collection, transactionList, - emptyList: transactionList || collection ? null : readEmptyList(payload), + rulesetAsset, + rulesetList, + duneAsset, + duneList, + protocolIndex, + buckets: bucketsPayload + ? readCandidateBuckets( + bucketsPayload, + this.profile.precision, + this.ticker + ) + : null, + emptyList: + transactionList || collection || rulesetList || duneList || protocolIndex + ? null + : readEmptyList(payload), facts: readRecordFacts(payload, this.profile, skip), - generic: shape === 'collection' || shape === 'record', + generic: + (shape === 'collection' || shape === 'record') && + !rulesetAsset && !rulesetList && !duneAsset && !duneList && !protocolIndex, }; } @@ -421,6 +516,47 @@ 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); + } + + /** 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)) { + 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/multichain-view.spec.ts b/frontend/src/app/universe/multichain-explorer/multichain-view.spec.ts index 6c4827a359e..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'); @@ -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..e740137edf4 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'] }, ], }; @@ -921,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/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 }; +} 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..092adf8dace --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/ruleset-assets.spec.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from 'vitest'; +import { + readDecimals, + readRulesetAsset, + readRulesetAssetList, +} 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(); + }); +}); + +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 new file mode 100644 index 00000000000..ad9c977ae46 --- /dev/null +++ b/frontend/src/app/universe/multichain-explorer/ruleset-assets.ts @@ -0,0 +1,363 @@ +/** + * 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, + }; +} + +// --------------------------------------------------------------------------- +// 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/frontend/src/app/universe/universe-api.service.ts b/frontend/src/app/universe/universe-api.service.ts index bb3ab132f12..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' @@ -235,7 +241,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/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/capture.mjs b/scripts/universe/visual-qa/capture.mjs index b458ea32083..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' }, @@ -93,6 +96,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. @@ -701,7 +708,9 @@ 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. '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..b1e72582e2b 100644 --- a/scripts/universe/visual-qa/chain-fixtures.mjs +++ b/scripts/universe/visual-qa/chain-fixtures.mjs @@ -19,6 +19,8 @@ const DOGE_ADDRESS = 'DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L'; const ZEC_TXID = '7a3e1c5f9b2d6048a2c6e0f4b8d2a6c0e4f8b2d6a0c4e8f2b6d0a4c8e2f6b0d4'; 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 @@ -215,6 +217,124 @@ 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, + }; +} + +/** + * 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')], @@ -503,6 +623,110 @@ 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()), + + // 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), + }), + + // 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'], + }, }; /** @@ -558,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 @@ -572,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 @@ -665,4 +920,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, DOGE_DUNE_ID, ZEC_TXID, ZEC_BLOCK, ZEC_ADDRESS, ZEC_ZRC20 }; 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. */