+
First seen
@if (transactionTime > 0) {
@@ -82,7 +82,7 @@
}
} @else if (tx && tx.status?.confirmed) {
-
+
+ } @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 }}
+
+
+
+
+
@@ -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.
+
+
+
+
+
+ | Protocol |
+ Release |
+ Indexer |
+ Coverage |
+
+
+
+
+ |
+ {{ 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. */