+ );
+}
+
+// A ≤ or ≥ ceiling bounds its tx/s the same way; other ceilings convert approximately.
+const bound = (ceiling: string) => /^[≤≥]/.exec(ceiling)?.[0];
+const ceilingHelp = (gasPerTx: number | null) =>
+ `tx/s for payments and swaps uses that workload's transactions; ${gasPerTx ? `every other row uses today's mainnet average of ~${Math.round(gasPerTx / 1e3)}k gas per transaction` : 'the snapshot has no transactions per second, so every other row stays in Mgas/s'}. Reverted transactions still use gas and blob space.`;
+
+export function Bottlenecks({ limits, max, gasLimit, proven, history, mainnetGasPerTx = null }: { limits: Limit[]; max: number; gasLimit: number | null; proven: Proven | null; history: ProvenCapacity['history']; mainnetGasPerTx?: number | null }) {
+ const pct = (v: number) => `${Math.min(100, (v / max) * 100)}%`;
+ const ranked = limits.filter((l) => l.value !== null).length;
+ const fixed = [{ label: 'TEE proving, RPC under sync', evidence: 'Not measured yet.' }];
+ return (
+
+ What breaks first · Mgas/s
+
+
+
+ {proven ? : null}
+
+ );
+}
diff --git a/app/scaling/components/CeilingCards.tsx b/app/scaling/components/CeilingCards.tsx
index 96bbc21..fa34cfc 100644
--- a/app/scaling/components/CeilingCards.tsx
+++ b/app/scaling/components/CeilingCards.tsx
@@ -2,11 +2,11 @@ import Link from 'next/link';
import { Card } from '../../components/ui/Card';
import { Text } from '../../components/ui/Text';
+import type { DaWorkload } from '../da/workloads';
import type { UnifiedRun } from '../runs/lib/unified';
import { deriveBinding, type Ceiling, type CeilingInput } from '../snapshot.mjs';
import { DaWorkloadRows } from './DaWorkloads';
import { Info } from './Info';
-import { RunBackedCeilingCard } from './RunBackedCeilingCard';
import { Tag } from './Tag';
// Networking stays a work area but has no ceiling page: nothing measures its limit.
@@ -17,18 +17,7 @@ function input({ name, value, unit, status, asOf }: CeilingInput): string {
return [name, shown, value === null ? null : status, asOf && new Date(asOf).toISOString().slice(0, 10)].filter(Boolean).join(' · ');
}
-export function CeilingCards({ ceilings }: { ceilings: Ceiling[] }) {
- return (
-
- Ceilings by bottleneck · Mgas/s
-
- {AREAS.map(([area]) => )}
-
-
- );
-}
-
-export function CeilingCard({ area, ceilings, run }: { area: Ceiling['area']; ceilings: Ceiling[]; run?: UnifiedRun }) {
+export function CeilingCard({ area, ceilings, workloads, run }: { area: Ceiling['area']; ceilings: Ceiling[]; workloads?: DaWorkload[]; run?: UnifiedRun }) {
const name = AREAS.find(([a]) => a === area)![1];
const c = ceilings.find((x) => x.area === area)!;
return (
@@ -45,7 +34,7 @@ export function CeilingCard({ area, ceilings, run }: { area: Ceiling['area']; ce
{area === 'da' ? (
<>
{c.reason}
-
+
>
) : (
<>
diff --git a/app/scaling/components/DaWorkloads.tsx b/app/scaling/components/DaWorkloads.tsx
index f14cd36..54cab58 100644
--- a/app/scaling/components/DaWorkloads.tsx
+++ b/app/scaling/components/DaWorkloads.tsx
@@ -1,20 +1,23 @@
+'use client';
+
import { cn } from '../../components/ui/cn';
import { Card } from '../../components/ui/Card';
import { Text } from '../../components/ui/Text';
-import { BLOB_MAX, BLOB_TARGET, bindingWorkload, daCapacity, DA_WORKLOADS, fmt } from '../da/workloads';
+import { BLOB_MAX, BLOB_TARGET, bindingWorkload, daCapacity, daWorkloads, DA_WORKLOADS, fmt, type DaWorkload } from '../da/workloads';
+import { useProvenCapacity } from './ProvenCapacity';
import { Tag } from './Tag';
const TH = 'px-4 py-2 text-right text-[12px] font-[500] text-bds-gray-60 first:text-left';
const TD = 'whitespace-nowrap px-4 py-3 text-right font-mono first:text-left first:font-sans';
// The DA ceiling card's body: one aligned row per workload instead of the generic input list.
-export function DaWorkloadRows() {
- const binding = bindingWorkload();
+export function DaWorkloadRows({ workloads = DA_WORKLOADS }: { workloads?: DaWorkload[] }) {
+ const binding = bindingWorkload(workloads);
const row = 'grid grid-cols-[1fr_2.75rem_3rem] items-baseline gap-x-2 py-1 text-right';
return (
+
+ {usageAsOf(collectedAt)}; bench rows show their run. Breached SLOs are not attributed to saturation until triaged.
);
}
diff --git a/app/scaling/components/Headline.tsx b/app/scaling/components/Headline.tsx
new file mode 100644
index 0000000..8229e66
--- /dev/null
+++ b/app/scaling/components/Headline.tsx
@@ -0,0 +1,207 @@
+import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
+
+import { cn } from '../../components/ui/cn';
+import { Text } from '../../components/ui/Text';
+import { BLOB_TARGET, fmt } from '../da/workloads';
+import { successfulTps, type ProvenCapacity, type Workload } from '../proven/capacity';
+import type { Snapshot } from '../snapshot.mjs';
+import { times, type Limit } from './Bottlenecks';
+import { Info } from './Info';
+import type { ProvenMark } from './Ladder';
+import { day } from './ProvenCapacity';
+
+export type BenchState = { configured: boolean; data?: ProvenCapacity; error?: unknown };
+
+const TRAFFIC: Record = { payment: 'payments', swap: 'swaps', mix: 'tx' };
+const n = (v: number) => v.toLocaleString('en-US', { maximumFractionDigits: 1 });
+
+type Props = Pick & { bench: BenchState; proven: ProvenMark; limits: Limit[] };
+
+// Only states other than a loaded bench result need a note.
+function benchNote({ configured, data, error }: BenchState, proven: ProvenMark, qualified: Snapshot['ladder']['qualified']): ReactNode {
+ if (!configured) {
+ if (!proven) return null;
+ return <>Proven capacity from the snapshot{qualified.evidence?.map((l) => · {l.label})}>;
+ }
+ if (error) return 'Could not load bench results. The bench host is reachable only on VPN and must allow this origin via CORS.';
+ if (!data) return 'Loading bench results…';
+ // A run whose files failed to load is skipped, so an older result at its rate may count instead.
+ const partial = data.unknown.length ? `${data.unknown.length} run(s) could not be loaded, so this may be wrong.` : '';
+ return data.current.mix ? partial || null : `No main run passed in the last 14 days. ${partial}`.trim();
+}
+
+// "≥ 21×" when proven is the top rate tested, a floor.
+export const headroomLabel = (proven: NonNullable, today: number) => `${proven.atLeast ? '≥ ' : ''}${times(proven.value / today)}`;
+
+// Today's mainnet gas per transaction: median gas rate ÷ transactions per second. Null without either.
+export const mainnetGasPerTx = ({ ladder, usage }: Pick) => {
+ const p50 = ladder.observed.p50.value;
+ const tps = usage?.transactionsPerSecond.value ?? null;
+ return p50 !== null && tps ? (p50 * 1e6) / tps : null;
+};
+
+export type Placement = { side: 'center' | 'left' | 'right'; x: number };
+
+// Centers a label on x, or pins it to the nearer edge when centering would overflow. `estimate` is an
+// approximate label width; a centered label renders with translateX(-50%), so it stays exact.
+export function place(x: number, estimate: number, width: number): Placement {
+ if (x - estimate / 2 < 0) return { side: 'left', x: 0 };
+ if (x + estimate / 2 > width) return { side: 'right', x: width };
+ return { side: 'center', x };
+}
+
+const placed = (p: Placement): CSSProperties =>
+ p.side === 'left' ? { left: 0 } : p.side === 'right' ? { right: 0 } : { left: p.x, transform: 'translateX(-50%)' };
+
+function Capacity({ ladder, usage, proven }: Pick) {
+ const ref = useRef(null);
+ const [width, setWidth] = useState(240);
+ useLayoutEffect(() => {
+ if (!ref.current) return;
+ const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
+ observer.observe(ref.current);
+ return () => observer.disconnect();
+ }, []);
+ const today = ladder.observed.p50.value;
+ const tps = usage?.transactionsPerSecond;
+ const span = proven && today !== null && today > 0 ? proven : null;
+ const todayX = span ? Math.min(width, (today! / span.value) * width) : 0;
+ const headroom = span ? headroomLabel(span, today!) : '';
+ const todayText = `today ${today === null ? '' : n(today)}${tps ? ` · ${fmt(tps.value)} tx/s` : ''}`;
+ return (
+
+
+ Proven capacity
+ Execution, validators and proof node all pass at this rate on the mainnet snapshot. Workload tiles count successful transactions per second.
+
+
+ {proven ? `${proven.atLeast ? '≥ ' : ''}${n(proven.value)}` : '—'}
+ Mgas/s
+
+ {span ? (
+
+
+ {headroom}
+ Proven capacity ÷ today's median gas rate. What breaks first lists the limits that apply before proven capacity.
+
+
+
+
+
+
+ today {n(today!)}
+ {tps ? · {fmt(tps.value)} tx/s : null}
+ {tps ? {tps.help} : null}
+
+
+ proven {span.atLeast ? '≥ ' : ''}{n(span.value)}
+
+
+ ) : null}
+
+ );
+}
+
+type Cap = 'DA' | 'gas limit' | 'execution';
+type Translation = {
+ label: ReactNode;
+ gasPerTx: number | null;
+ proven: { mgas: number; atLeast: boolean } | null;
+ da: number;
+ reverted?: number;
+};
+
+// The lowest of the three ceilings; ties go to DA, then the gas limit.
+export function sustainable(proven: number, da: number, gasLimit: number | null): { mgas: number; cap: Cap } {
+ const caps: [Cap, number][] = [['DA', da], ['gas limit', gasLimit ?? Infinity], ['execution', proven]];
+ const [cap, mgas] = caps.reduce((low, c) => (c[1] < low[1] ? c : low));
+ return { mgas, cap };
+}
+
+// The headline column and the tiles share these slots so their label and value rows line up.
+const SLOTS = 'flex flex-col gap-1.5 border py-3';
+const LABEL = 'flex h-4 items-center gap-1 overflow-hidden whitespace-nowrap text-[11px] font-[600] uppercase leading-4 tracking-wide text-bds-gray-60';
+const VALUE = 'whitespace-nowrap leading-[44px] tabular-nums';
+const CAP_TONE: Record = { DA: 'text-bds-red-60', 'gas limit': 'text-bds-orange-60', execution: '' };
+const compact = (v: number) => new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(v).toLowerCase();
+
+function Tile({ row: r, gasLimit }: { row: Translation; gasLimit: number | null }) {
+ const s = r.proven && sustainable(r.proven.mgas, r.da, gasLimit);
+ const tx = (mgas: number) => (mgas * 1e6) / r.gasPerTx!;
+ return (
+
+ {r.label}
+
+ {!r.proven ? 'pending' : r.gasPerTx === null ? '—' : <>{r.proven.atLeast ? '≥ ' : ''}{compact(tx(r.proven.mgas))}/s>}
+
+
+ sustainable today {!s ? 'pending' : r.gasPerTx === null ? '—' : `${fmt(tx(s.mgas))}/s`}
+
+ Highest rate we can run continuously now: below the L1 blob target and within the current gas limit{gasLimit === null ? '' : ` (${n(gasLimit)} Mgas/s)`}. Capped by DA or the gas limit, not execution.
+
+
+ {s ? `capped by ${s.cap}` : ''}
+ {/* Reserved on every tile so a note on one does not shift the others. */}
+
+ {r.reverted !== undefined && r.reverted > 0.01 ? (
+ <>
+ {Math.round(r.reverted * 100)}% reverted
+ Reverted transactions still use gas and count toward Mgas/s; tx/s counts successful ones only.
+ >
+ ) : null}
+
+
+ );
+}
+
+const MIX_DA = 'Its DA cap comes from the bench mix run, which is lighter per transaction and compresses worse than mainnet, so the cap is conservative.';
+
+// Bench workloads convert with their proven run's gas per successful transaction; today's traffic mix
+// with mainnet's own gas per transaction, since the bench mix only approximates mainnet.
+function translations({ ladder, usage, bench, proven, limits }: Pick): Translation[] {
+ const daOf = (w: Workload) => limits.find((l) => l.id === `da-${w}`)!.value!;
+ const benchRow = (w: Workload, label: string): Translation => {
+ const p = bench.data?.current[w] ?? null;
+ return {
+ label,
+ gasPerTx: p ? p.run.gps / successfulTps(p.run) : null,
+ proven: p && { mgas: p.mgas, atLeast: p.atTop },
+ da: daOf(w),
+ reverted: p?.run.reverted,
+ };
+ };
+ const p50 = ladder.observed.p50.value;
+ const gasPerTx = mainnetGasPerTx({ ladder, usage });
+ const tps = usage?.transactionsPerSecond.value ?? null;
+ const mixHelp = gasPerTx !== null
+ ? `At today's mainnet average of ~${Math.round(gasPerTx / 1e3)}k gas per transaction (${n(p50!)} Mgas/s ÷ ${fmt(tps!)} tx/s), not the bench mix. ${MIX_DA}`
+ : `The snapshot has no transactions per second, so this row stays in gas: today ${p50 === null ? 'not measured' : `${n(p50)} Mgas/s`}. ${MIX_DA}`;
+ return [
+ benchRow('payment', 'Payments (B20)'),
+ benchRow('swap', 'Swaps (Uniswap v3)'),
+ {
+ label: Today's traffic mix {mixHelp},
+ gasPerTx,
+ proven: proven && { mgas: proven.value, atLeast: proven.atLeast },
+ da: daOf('mix'),
+ },
+ ];
+}
+
+export function Headline({ ladder, usage, bench, proven, limits }: Props) {
+ const note = benchNote(bench, proven, ladder.qualified);
+ const rows = translations({ ladder, usage, bench, proven, limits });
+ return (
+
+
+
+ {rows.map((row, i) => )}
+
+ {note ? {note} : null}
+
+ );
+}
diff --git a/app/scaling/components/Ladder.tsx b/app/scaling/components/Ladder.tsx
index c69eeed..548f017 100644
--- a/app/scaling/components/Ladder.tsx
+++ b/app/scaling/components/Ladder.tsx
@@ -4,121 +4,184 @@ import { useLayoutEffect, useRef, useState } from 'react';
import { cn } from '../../components/ui/cn';
import { Text } from '../../components/ui/Text';
-import type { Proven } from '../proven/capacity';
-import { deriveBinding, type Rung, type Snapshot } from '../snapshot.mjs';
+import type { Rung, Snapshot } from '../snapshot.mjs';
+import type { Limit } from './Bottlenecks';
import { Info } from './Info';
-import { day, ProvenCapacity, useProvenCapacity } from './ProvenCapacity';
-import { Tag } from './Tag';
-import { layoutRungs, type Anchor } from './ladderLayout';
-const LABEL_PX = 150;
-const ROW = 44;
-const AXIS = 12;
-const AREA_NAME = { da: 'DA', execution: 'execution', state: 'state', networking: 'networking' };
-const ALIGN: Record = { start: 'items-start text-left', middle: 'items-center text-center', end: 'items-end text-right' };
-const SHIFT: Record = { start: 'none', middle: 'translateX(-50%)', end: 'translateX(-100%)' };
+const BAR_H = 24;
+// Each label is two 12px lines; rows sit GAP_PX from the bar and touch each other, so a second row stays
+// within 30px of the bar.
+const ROW_PX = 24;
+const GAP_PX = 6;
+const n = (v: number) => v.toLocaleString('en-US', { maximumFractionDigits: 1 });
-type Props = Pick & { benchUrl?: string; dashboardUrl?: string };
-type Item = { id: string; label: string; value: number | null; tag: string; help: string; limit?: boolean; atLeast?: boolean; source?: string };
+export type ProvenMark = { value: number; atLeast: boolean; help: string } | null;
-function benchItem({ mgas, atTop, run }: Proven) {
- const top = atTop ? ` ≥ because ${mgas} Mgas/s is the highest rate the bench runs.` : '';
- const help = `Bench-proven: the highest rate whose latest main run in the last 14 days was comparable and delivered ≥ 95% of target. Run ${run.outputDir}, commit ${run.commit.slice(0, 7)}, ${day(run.createdAt)}.${top} DA and proof guardrails are not checked.`;
- return { value: mgas, tag: 'measured' as const, help, atLeast: atTop, source: 'bench' };
-}
+type Tone = 'today' | 'p99' | 'fee' | 'da' | 'disk' | 'gas' | 'proven';
+export type Point = { id: string; value: number; valueText: string; name: string; tone: Tone; help: string };
+export type PlacedPoint = Point & { x: number; offScale: boolean; side: 'above' | 'below'; tier: number; left: number; width: number; compact: boolean };
-function items({ ladder, ceilings, windows }: Props, proven: Proven | null | undefined): Item[] {
- const rung = (id: string, label: string, r: Rung): Item =>
- ({ id, label, value: r.value, tag: r.tag, help: r.window ? `${r.help} Window: ${windows[r.window].label}.` : r.help });
- const bench = proven ? benchItem(proven) : null;
- const binding = deriveBinding(bench ? ceilings.map((c) => (c.area === 'execution' ? { ...c, value: bench.value, tag: bench.tag } : c)) : ceilings);
- const ceiling: Item = binding
- ? { id: 'ceiling', label: `Ceiling · ${AREA_NAME[binding.area]}`, value: binding.value, tag: binding.tag, help: binding.help, ...(bench && binding.area === 'execution' ? bench : {}) }
- : { id: 'ceiling', label: 'Ceiling', value: null, tag: 'not-modeled', help: `No area ceiling is measured or modeled yet: ${ceilings.map((c) => `${AREA_NAME[c.area]} ${c.tag}`).join(', ')}.` };
- return [
- rung('p50', 'Observed p50', ladder.observed.p50),
- rung('p99', 'Observed p99', ladder.observed.p99),
- rung('operating', 'Operating point', ladder.operatingPoint),
- bench ? { id: 'qualified', label: 'Qualified sustained', ...bench } : rung('qualified', 'Qualified sustained', ladder.qualified),
- ceiling,
- { ...rung('limit', 'Configured limit', ladder.configuredLimit), limit: true },
- ];
-}
+const TEXT: Record = {
+ today: 'text-bds-blue-90 dark:text-white',
+ p99: 'text-bds-blue-50',
+ fee: 'text-bds-gray-60',
+ da: 'text-bds-red-60',
+ disk: 'text-bds-purple-60',
+ gas: 'text-bds-orange-60',
+ proven: 'text-base-blue',
+};
+const TICK: Record = {
+ today: 'border-bds-blue-90 dark:border-white',
+ p99: 'border-bds-blue-50',
+ fee: 'border-dotted border-bds-gray-60',
+ da: 'border-bds-red-60',
+ disk: 'border-bds-purple-60',
+ gas: 'border-dashed border-bds-orange-60',
+ proven: 'border-base-blue',
+};
+const SHADES = {
+ median: 'fill-base-blue bg-base-blue',
+ p99: 'fill-bds-blue-30 bg-bds-blue-30',
+ allowed: 'fill-bds-blue-10 bg-bds-blue-10 dark:fill-bds-blue-80 dark:bg-bds-blue-80',
+ proven: 'fill-bds-gray-10 bg-bds-gray-10 dark:fill-white/10 dark:bg-white/10',
+};
+const SHORT: Record = { 'da-payment': 'DA · payments', 'da-swap': 'DA · swaps', 'da-mix': 'DA · mix (bench)', 'disk-archive': 'archive disk', 'disk-full': 'full-node disk' };
-function translation({ gasPerPayment, gasPerSwap }: Snapshot['translations']): string {
- if (!gasPerPayment && !gasPerSwap) return 'Translation to payments/s and swaps/s: constants pending';
- const per = (c: typeof gasPerPayment, noun: string) => (c ? `${Math.round(1e6 / c.value)} ${noun}/s` : `${noun}/s pending`);
- return `1 Mgas/s ≈ ${per(gasPerPayment, 'payments')} ≈ ${per(gasPerSwap, 'swaps')}`;
+// Approximate label widths: a bold 12px value with its help icon over an 11px name.
+const widthOf = (p: Point, offScale: boolean, compact: boolean) =>
+ Math.max((p.valueText.length + (offScale ? 2 : 0)) * 7.6 + 24, compact ? 0 : p.name.length * 6) + 2;
+
+// Labels alternate above and below in value order, in one row hugging the bar on each side. A label
+// that collides first tries the other side, then a nudge that keeps it over its tick, then drops its
+// name (listed under the ladder); only then does it take a second row. Off-scale points sit at the
+// right edge without a tick.
+export function layoutPoints(points: Point[], max: number, width: number): PlacedPoint[] {
+ const ends: Record<'above' | 'below', number[]> = { above: [], below: [] };
+ return points.toSorted((a, b) => a.value - b.value).map((p, i) => {
+ const offScale = p.value > max;
+ const x = offScale ? width : (Math.max(0, p.value) / max) * width;
+ const preferred = i % 2 === 0 ? 'above' : 'below';
+ const sides = [preferred, preferred === 'above' ? 'below' : 'above'] as const;
+ for (let tier = 0; ; tier++) {
+ for (const compact of [false, true]) {
+ const w = Math.min(width, widthOf(p, offScale, compact));
+ const centered = Math.min(Math.max(x - w / 2, 0), width - w);
+ for (const nudge of [false, true]) {
+ for (const side of sides) {
+ const end = ends[side][tier] ?? -Infinity;
+ const left = nudge ? Math.max(centered, end + GAP_PX) : centered;
+ const clear = left >= end + GAP_PX && left + w <= width && Math.abs(left + w / 2 - x) <= w;
+ // A new, empty row always fits, so the loop ends.
+ if (clear || end === -Infinity) {
+ ends[side][tier] = left + w;
+ return { ...p, x, offScale, side, tier, left, width: w, compact };
+ }
+ }
+ }
+ }
+ }
+ });
}
-export function Ladder(props: Props) {
- const axisRef = useRef(null);
+type Props = Pick & { proven: ProvenMark; max: number; limits: Limit[] };
+
+export function Ladder({ ladder, windows, proven, max, limits }: Props) {
+ const ref = useRef(null);
const [width, setWidth] = useState(960);
useLayoutEffect(() => {
const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
- observer.observe(axisRef.current!);
+ observer.observe(ref.current!);
return () => observer.disconnect();
}, []);
- const bench = useProvenCapacity(props.benchUrl);
- const proven = bench.error ? null : bench.data?.current;
- const all = items(props, proven);
- const valued = all.flatMap((i) => (i.value === null ? [] : [{ id: i.id, value: i.value }]));
- const max = props.ladder.configuredLimit.value ?? Math.max(0, ...valued.map((v) => v.value));
- const placed = new Map(max > 0 ? layoutRungs(valued, max, LABEL_PX / width).map((p) => [p.id, p]) : []);
- const depth = (side: string) => 1 + Math.max(-1, ...[...placed.values()].filter((p) => p.side === side).map((p) => p.tier));
- const above = depth('above');
- const axisY = above * ROW + AXIS / 2;
+ const help = (r: Rung) => (r.window ? `${r.help} Window: ${windows[r.window].label}.` : r.help);
+ const pct = (v: number) => `${Math.min(1, v / max) * 100}%`;
+ const { p50, p99 } = ladder.observed;
+ const limit = ladder.configuredLimit.value;
+ const median = p50.value;
+ const tail = median === null ? null : p99.value;
+ const rung = (id: string, r: Rung, name: string, tone: Tone): Point[] => (r.value === null ? [] : [{ id, value: r.value, valueText: n(r.value), name, tone, help: help(r) }]);
+ const points: Point[] = [
+ ...rung('today', p50, 'today', 'today'),
+ ...(tail === null ? [] : rung('p99', p99, 'p99', 'p99')),
+ ...rung('fee', ladder.operatingPoint, 'fee target', 'fee'),
+ ...rung('gas', ladder.configuredLimit, 'gas limit', 'gas'),
+ ...(proven ? [{ id: 'proven', value: proven.value, valueText: `${proven.atLeast ? '≥ ' : ''}${n(proven.value)}`, name: 'proven', tone: 'proven' as const, help: proven.help }] : []),
+ // The ranked breaking points from What breaks first; unranked rows have no ceiling to mark.
+ ...limits.filter((l) => l.value !== null && SHORT[l.id]).map((l): Point => ({
+ id: l.id,
+ value: l.value!,
+ valueText: l.ceiling,
+ name: SHORT[l.id],
+ tone: l.id.startsWith('disk-') ? 'disk' : 'da',
+ help: l.help ?? l.evidence,
+ })),
+ ];
+ const placed = layoutPoints(points, max, width);
+ const tiers = (side: 'above' | 'below') => placed.filter((p) => p.side === side).reduce((h, p) => Math.max(h, (p.tier + 1) * ROW_PX), 0);
+ const barTop = tiers('above') + GAP_PX;
+ const height = barTop + BAR_H + GAP_PX + tiers('below') + 16;
+ const nearEdge = (p: PlacedPoint) => (p.side === 'above' ? barTop - GAP_PX - p.tier * ROW_PX : barTop + BAR_H + GAP_PX + p.tier * ROW_PX);
+ const legend = [
+ median !== null && ['up to median', SHADES.median],
+ tail !== null && ['median to p99', SHADES.p99],
+ limit !== null && ['allowed by policy', SHADES.allowed],
+ proven && limit !== null && proven.value > limit && ['proven, not yet allowed', SHADES.proven],
+ ].filter((l) => !!l);
+ const compact = placed.filter((p) => p.compact);
return (
- Capacity ladder · Mgas/s
-
-
- {all.map((item) => {
- const p = placed.get(item.id);
- if (!p) return null;
- const top = p.side === 'above' ? (above - 1 - p.tier) * ROW : above * ROW + AXIS + p.tier * ROW;
- const left = `${p.x * 100}%`;
+ How close are we, and to what? · Mgas/s
+
')[0]).startsWith(title))!;
+ const withStorage = () => {
+ const data = bench(400, true);
+ data.current.mix = { mgas: 400, atTop: true, run: { ...run(400), storage: storage() } };
+ return data;
+ };
+
+ it('orders cards by their category’s place in What breaks first, unranked ones last in the default order', () => {
+ swr.result = { data: bench(400, true) };
+ // DA ~57 first; the merged core row ties proof node, validators and sequencer; no storage leaves both disks unranked.
+ expect(titles(render().subsystems).map((t) => t.split(' · ')[0])).toEqual(['DA', 'Proof node', 'Validators', 'Sequencer', 'Disk', 'Disk']);
+ swr.result = { data: withStorage() };
+ expect(titles(render().subsystems)).toEqual([
+ 'DA · not measured yet',
+ 'Disk · archive · 1-year runway',
+ 'Proof node · within budget (lag ≤ 10, ≤ 1 s)',
+ 'Validators · all keep up',
+ 'Sequencer · 400 delivered',
+ 'Disk · full node · 2-year runway',
+ ]);
+ });
+
+ it('stacks one card per row below 760 px and labels columns with full names', () => {
+ swr.result = { data: bench(400, true) };
+ const html = render().subsystems;
+ expect(html).toContain('grid grid-cols-1 gap-3 min-[760px]:grid-cols-2');
+ expect(html).toContain('Pay');
+ expect(html).toContain('title="Mainnet mix (bench run)"');
+ });
+
+ it('rescales DA blobs to the load rate and names workloads whose ceiling is under the gas limit', () => {
+ // Payments: 20 blobs over a 160 Mgas/s window at a 200 Mgas/s load → 25, ceiling ~76. Swaps: ~232, above the 200 gas limit.
+ swr.result = { data: bench(400, true, { da: { mix: null, payment: run(200, 'payment', daResult(8e7, 20, false)), swap: run(400, 'swap', daResult(2.6158e7, 22.4, false)) } }) };
+ const da = card(render().subsystems, 'DA');
+ expect(text(da.split('')[0])).toBe('DA · binding for payments');
+ expect(da.split('')[0]).toContain('text-bds-red-60');
+ expect(rows(da)).toEqual(['Blobs per L1 slot at each run’s rate 2556— (not in this run)', 'Ceiling (Mgas/s) 76232— (not in this run)']);
+ swr.result = { data: bench(400, true, { da: { mix: null, payment: null, swap: run(400, 'swap', daResult(2.6158e7, 22.4, false)) } }) };
+ expect(text(card(render().subsystems, 'DA').split('')[0])).toBe('DA · below target at the 200 Mgas/s gas limit');
+ const noLimit = fixture();
+ noLimit.ladder.configuredLimit = { ...noLimit.ladder.configuredLimit, value: null };
+ expect(text(card(render({ benchUrl: BENCH }, noLimit).subsystems, 'DA').split('')[0])).toBe('DA · no gas limit to compare against');
+ });
+
+ it('projects disk per workload run, saying which runs lack a storage measurement', () => {
+ const data = withStorage();
+ data.current.payment = { mgas: 200, atTop: true, run: run(200, 'payment') };
+ swr.result = { data };
+ const html = render().subsystems;
+ // (0.0086 bench + 0.0008 mainnet state) B/gas × 400 Mgas/s × 1 year; (0.0086 × 31 d + 0.0045 × 15 d) × 400 Mgas/s.
+ expect(rows(card(html, 'Disk · archive'))).toEqual([
+ 'Growth at 400 — (needs a run with storage measurement)— (no proven run for this workload)≥ 118.7 TB/yr',
+ 'Ceiling (1-year runway) — (needs a run with storage measurement)— (no proven run for this workload)≤ 138',
+ ]);
+ expect(rows(card(html, 'Disk · full node'))[0]).toBe('Size at 400 — (needs a run with storage measurement)— (no proven run for this workload)11.5 TB');
+ expect(html).toContain('title="needs a run with storage measurement"');
+ });
+
+ it('flags proof-node budgets and validators that fall behind', () => {
+ const data = bench(400, true);
+ const slow = run(400, 'mix', null, 11);
+ slow.proofNode!.proofs!.methods.debug_executePayload.latency.p99Ms = 1200;
+ slow.replay = { ...slow.replay!, blocksPerSecond: 4 };
+ data.current.mix = { mgas: 400, atTop: true, run: slow };
+ data.current.swap = { mgas: 400, atTop: true, run: run(400, 'swap') };
+ swr.result = { data };
+ const html = render().subsystems;
+ expect(text(card(html, 'Proof node').split('')[0])).toBe('Proof node · lag over 10 for mix; executePayload over 1 s for mix');
+ expect(rows(card(html, 'Proof node'))).toEqual([
+ 'Lag p99 (max) — (no proven run for this workload)4 (20)11 (20)',
+ 'executePayload p99 — (no proven run for this workload)733 ms1200 ms',
+ 'Witness p50 — (no proven run for this workload)— (not in this run)— (not in this run)',
+ ]);
+ expect(text(card(html, 'Validators').split('')[0])).toBe('Validators · mix does not keep up');
+ expect(rows(card(html, 'Validators'))[0]).toBe('Replay speed — (no proven run for this workload)7.3×0.8×');
+ expect(card(html, 'Validators').split('')[0]).toContain('text-bds-red-60');
+ });
+
+ it('shows delivered rate, successful tx/s with reverts over 1%, and block latency', () => {
+ const data = bench(400, true);
+ data.current.swap = { mgas: 400, atTop: true, run: { ...run(400, 'swap'), reverted: 0.155, blockLatency: { p50Ms: 205.4, p99Ms: 306.3 } } };
+ data.current.payment = { mgas: 200, atTop: true, run: { ...run(200, 'payment'), reverted: 0.005 } };
+ swr.result = { data };
+ const seq = card(render().subsystems, 'Sequencer');
+ expect(text(seq.split('')[0])).toBe('Sequencer · 200–400 delivered');
+ // 4,200 submitted tx/s × (1 − 15.5% reverted) = 3,549.
+ expect(rows(seq)).toEqual([
+ 'Delivered (Mgas/s) 195.02,090 tx/s395.03,549 tx/s16% reverted395.04,200 tx/s',
+ 'Block latency p50 / p99 — (not in this run)205 / 306 ms— (not in this run)',
+ ]);
+ });
+});
+
+describe('ladder', () => {
+ it('shades the used band in two tones and labels today, p99, fee target, gas limit and proven', () => {
+ swr.result = { data: bench(400, true) };
+ const snapshot = fixture();
+ snapshot.ladder.observed.p99 = { ...snapshot.ladder.observed.p99, value: 42.24, tag: 'measured' };
+ const { ladder } = render({ benchUrl: BENCH }, snapshot);
+ expect(ladder).toMatch(/data-band="p50" width="3.125%"/);
+ expect(ladder).toMatch(/data-band="p99" width="10.56%"/);
+ const label = (id: string) => text(ladder.split(`data-label="${id}"`)[1]?.split('data-label=')[0] ?? '').replace(/^[^>]*>/, '');
+ expect(label('today')).toMatch(/^12\.5.*today/);
+ expect(label('p99')).toMatch(/^42\.2.*p99/);
+ expect(label('fee')).toMatch(/^40.*fee target/);
+ expect(label('gas')).toMatch(/^200.*gas limit/);
+ expect(label('proven')).toMatch(/^≥ 400.*proven/);
+ expect(ladder).toMatch(/data-tick="gas" class="[^"]*border-dashed border-bds-orange-60/);
+ expect(text(ladder)).toContain('up to medianmedian to p99allowed by policyproven, not yet allowed');
+ expect(text(ladder)).not.toMatch(/42\.24|Usage data/);
+ });
+
+ it('ticks every ranked breaking point in its colour at its value, and none for unranked rows', () => {
+ swr.result = { data: bench(400, true) };
+ const { ladder } = render();
+ // On the 960 px server-render width: DA payments ~57, swaps ~115, mix ~190 Mgas/s of 400.
+ expect(ladder).toMatch(/data-tick="da-payment" class="[^"]*border-bds-red-60" style="left:137\.43\d*px/);
+ expect(ladder).toMatch(/data-tick="da-swap" class="[^"]*border-bds-red-60" style="left:275\.94\d*px/);
+ expect(ladder).toMatch(/data-tick="da-mix" class="[^"]*border-bds-red-60" style="left:455\.09\d*px/);
+ // Without a storage run both disk rows are unranked, so neither gets a tick.
+ expect(ladder).not.toMatch(/data-(tick|label)="disk-/);
+ });
+
+ it('pins a breaking point beyond the scale to the right edge without a tick', () => {
+ const data = bench(400, true);
+ data.current.mix = { mgas: 400, atTop: true, run: { ...run(400), storage: storage() } };
+ swr.result = { data };
+ const { ladder } = render();
+ expect(ladder).toMatch(/data-tick="disk-archive" class="[^"]*border-bds-purple-60"/);
+ expect(ladder).not.toContain('data-tick="disk-full"');
+ expect(text(ladder.split('data-label="disk-full"')[1].split('data-label=')[0])).toMatch(/≤ 553 →.*full-node disk/);
+ });
+
+ it('keeps labels apart in a crowded set, dropping names to a legend before stacking tiers', () => {
+ const point = (id: string, value: number): Point => ({ id, value, valueText: String(value), name: `limit ${id}`, tone: 'da', help: '' });
+ const crowded = [10, 12, 14, 16, 18, 20, 22, 24, 26, 600].map((v, i) => point(`p${i}`, v));
+ for (const width of [960, 600, 320]) {
+ const placed = layoutPoints(crowded, 400, width);
+ const top = (p: PlacedPoint) => (p.side === 'above' ? -1 : 1) * (p.tier + 1);
+ for (const a of placed) {
+ expect(a.left).toBeGreaterThanOrEqual(0);
+ expect(a.left + a.width).toBeLessThanOrEqual(width + 0.001);
+ for (const b of placed) {
+ if (a !== b && top(a) === top(b)) expect(a.left + a.width <= b.left || b.left + b.width <= a.left).toBe(true);
+ }
+ }
+ expect(placed.at(-1)).toMatchObject({ id: 'p9', offScale: true, x: width });
+ }
+ // Long names on a 320 px track: the third label fits beside the first only without its name.
+ const long = (id: string, value: number): Point => ({ ...point(id, value), name: 'a breaking point with a long name' });
+ expect(layoutPoints([long('a', 100), long('b', 180), long('c', 270)], 320, 320).map((p) => [p.side, p.tier, p.compact])).toEqual([
+ ['above', 0, false],
+ ['below', 0, false],
+ ['above', 0, true],
+ ]);
+ });
+
+ it('shades only to p50 without a p99, and draws no used band without either', () => {
+ const { ladder } = render({});
+ expect(ladder).toMatch(/data-band="p50" width="6.25%"/);
+ expect(ladder).not.toContain('data-band="p99"');
+ const snapshot = fixture();
+ snapshot.ladder.observed.p50 = { ...snapshot.ladder.observed.p50, value: null, tag: 'not-measured' };
+ expect(render({}, snapshot).ladder).not.toContain('data-band');
+ });
+});
+
+describe('what breaks first', () => {
+ it('ranks DA estimates first without bench data, with units, and leaves unproven limits unranked', () => {
+ expect(rows(render({}).ranked)).toEqual([
+ '1DA · Payments (B20)next~57 Mgas/s≈ 1,517 tx/sEstimatedEstimated: no bench DA run yet, so this uses the formula. Target is 14, shared by all rollups.',
+ '2DA · Swaps~115 Mgas/s≈ 799 tx/sEstimatedEstimated: no bench DA run yet, so this uses the formula. Target is 14.',
+ '3DA · Mix (bench)~190 Mgas/s≈ 974 tx/sEstimatedEstimated: no bench DA run yet, so this uses the formula. Target is 14.',
+ '—Execution (sequencer)—no proven run',
+ '—Validators—pending',
+ '—Proof node—pending',
+ '—Disk · full node—needs a bench storage-growth run',
+ '—Disk · archive node—needs a bench storage-growth run',
+ '—TEE proving, RPC under sync—Not measured yet.',
+ ]);
+ });
+
+ it('merges execution, validators and proof node when they share the proven ceiling, with the run in help', () => {
+ swr.result = { data: bench(400, true) };
+ const { ranked } = render();
+ expect(rows(ranked)[3]).toBe('4Execution, validators, proof node≥ 400 Mgas/s≥ 2,054 tx/sMeasured in the bench: all passed at 400 Mgas/s, the highest rate tested.');
+ expect(ranked).toContain('Run synthetic-400. Validator replay: 36.4 blocks/s, 7.3× the 5 needed. Proof node: lag p99 4 blocks, executePayload p99 733 ms.');
+ expect(ranked.match(/>next {
+ const over = bench(400, true);
+ over.current.mix = { mgas: 400, atTop: true, run: run(400, 'mix', null, 11) };
+ swr.result = { data: over };
+ expect(rows(render().ranked).slice(3, 6)).toEqual([
+ '4Execution (sequencer)≥ 400 Mgas/s≥ 2,054 tx/stop rate tested',
+ '5Validators≥ 400 Mgas/s≥ 2,054 tx/s7.3× replay headroom',
+ '—Proof node—over budget at 400 · lag p99 11 blocks',
+ ]);
+ });
+
+ it('ranks the mix by the bench mix run’s DA everywhere on the page, with the bench evidence', () => {
+ // 4.3e7 frame bytes over 4e10 gas put the 14-blob ceiling at ~141 Mgas/s; 16 blobs over a 160 Mgas/s window at 400 → 40.
+ swr.result = { data: bench(400, true, { da: { mix: run(400, 'mix', daResult(4.3e7, 16, false)), payment: null, swap: null } }) };
+ const { head, ladder, ranked, subsystems } = render();
+ // The mix converts at today's mainnet gas per transaction, so it equals the traffic tile's sustainable today.
+ expect(rows(ranked)[2]).toMatch(/^3DA · Mix \(bench\)141 Mgas\/s≈ 725 tx\/sMeasured in the bench: at 400 Mgas\/s, needs 40 blobs per L1 slot\. Target is 14\.$/);
+ // 12.5 Mgas/s ÷ 64.2 tx/s today ≈ 195k gas per transaction; the bench mix run used 40k.
+ expect(ranked).toContain('tx/s at today's mainnet average of ~195k gas per transaction (the bench mix is lighter: ~40k).');
+ expect(ranked).not.toContain('>Measured<');
+ expect(text(ladder)).toMatch(/141.*DA · mix \(bench\)/);
+ expect(tile(head, 'traffic mix')).toContain('capped by DA');
+ expect(head).toContain('Its DA cap comes from the bench mix run, which is lighter per transaction and compresses worse than mainnet, so the cap is conservative.');
+ expect(rows(subsystems.split('
{
+ const data = bench(400, true, { da: { mix: run(400, 'mix', daResult(4.3e7, 16, false)), payment: run(200, 'payment', daResult(8e7, 20, false)), swap: null } });
+ data.current.mix = { mgas: 400, atTop: true, run: { ...run(400), storage: storage() } };
+ swr.result = { data };
+ const { head, ranked } = render();
+ const row = (label: string) => rows(ranked).find((r) => r.includes(label))!;
+ // Payments: its DA run's 40k gas per successful transaction. Every other row: 12.5 Mgas/s ÷ 64.2 tx/s ≈ 195k, a bound kept on both numbers.
+ expect(row('Payments')).toContain('76 Mgas/s≈ 1,896 tx/s');
+ expect(row('Mix (bench)')).toContain('141 Mgas/s≈ 725 tx/s');
+ expect(row('archive node')).toContain('≤ 138 Mgas/s≤ 710 tx/s');
+ expect(row('Execution, validators')).toContain('≥ 400 Mgas/s≥ 2,054 tx/s');
+ expect(row('full node')).toContain('≤ 553 Mgas/s≤ 2,841 tx/s');
+ expect(row('TEE proving')).not.toContain('tx/s');
+ // Same function and rounding as the traffic tile's sustainable today.
+ expect(tile(head, 'traffic mix')).toContain(`sustainable today ${/≈ ([\d,]+) tx\/s/.exec(row('Mix (bench)'))![1]}/s`);
+ expect(ranked).toContain('tx/s for payments and swaps uses that workload's transactions; every other row uses today's mainnet average of ~195k gas per transaction.');
+ });
+
+ it('keeps shared rows in Mgas/s without today’s transactions per second', () => {
+ const data = bench(400, true, { da: { mix: run(400, 'mix', daResult(4.3e7, 16, false)), payment: run(200, 'payment', daResult(8e7, 20, false)), swap: null } });
+ data.current.mix = { mgas: 400, atTop: true, run: { ...run(400), storage: storage() } };
+ swr.result = { data };
+ const snapshot = fixture();
+ delete snapshot.usage;
+ const { ranked } = render({ benchUrl: BENCH }, snapshot);
+ const row = (label: string) => rows(ranked).find((r) => r.includes(label))!;
+ expect(row('Payments')).toContain('76 Mgas/s≈ 1,896 tx/s');
+ for (const label of ['Mix (bench)', 'archive node', 'Execution, validators', 'full node']) expect(row(label)).not.toContain('tx/s');
+ expect(ranked).toContain('the snapshot has no transactions per second, so every other row stays in Mgas/s.');
+ });
+
+ it('says the next rate did not pass when the core limit is lowest below the top rate', () => {
+ swr.result = { data: bench(40, false) };
+ expect(rows(render().ranked)[0]).toBe('1Execution, validators, proof nodenext40 Mgas/s≈ 205 tx/sMeasured in the bench: all passed at 40 Mgas/s; the next rate tested did not pass.');
+ });
+
+ it('rescales a bench DA run’s posting-window blobs to its load rate, with the details in help', () => {
+ // 20 blobs per slot over a window at 160 Mgas/s, rescaled to the 200 Mgas/s load: 25.
+ swr.result = { data: bench(400, true, { da: { mix: null, payment: run(200, 'payment', daResult(8e7, 20, false)), swap: null } }) };
+ const { ranked } = render();
+ expect(rows(ranked)[0]).toBe('1DA · Payments (B20)next76 Mgas/s≈ 1,896 tx/sMeasured in the bench: at 200 Mgas/s, needs 25 blobs per L1 slot. Target is 14, shared by all rollups.');
+ expect(ranked).toContain('Run synthetic-200: 0.00200 compressed bytes per gas (compression 4.5×). Over the batch posting window it averaged 20.0 blobs per L1 slot at 160.0 Mgas/s; scaling that window to the 200 Mgas/s load gives 25.');
+ expect(ranked).toContain('14 × 130,044 bytes per blob ÷ 12 s per slot ÷ bytes per gas.');
+ });
+
+ it('notes swaps reverts above 1% only', () => {
+ const swapDa = (reverted: number) => ({ ...run(400, 'swap', daResult(2.6158e7, 22.4, false)), reverted });
+ swr.result = { data: bench(400, true, { da: { mix: null, payment: null, swap: swapDa(0.155) } }) };
+ expect(rows(render().ranked).find((r) => r.includes('DA · Swaps'))).toMatch(/needs 56 blobs per L1 slot\. Target is 14\. 16% of swaps reverted\.$/);
+ swr.result = { data: bench(400, true, { da: { mix: null, payment: null, swap: swapDa(0.005) } }) };
+ expect(rows(render().ranked).find((r) => r.includes('DA · Swaps'))).toMatch(/Target is 14\.$/);
+ });
+
+ it('ranks a measured swaps DA ceiling by its value, past the gas limit', () => {
+ // 4e10 gas and 2.6158e7 frame bytes put the 14-blob ceiling at ~232 Mgas/s.
+ swr.result = { data: bench(400, true, { da: { mix: null, payment: null, swap: run(400, 'swap', daResult(2.6158e7, 22.4, false)) } }) };
+ const ranked = render().ranked;
+ expect(rows(ranked).slice(0, 4).map((r) => r.slice(0, 22))).toEqual(['1DA · Payments (B20)ne', '2DA · Mix (bench)~190 ', '3DA · Swaps232 Mgas/s≈', '4Execution, validators']);
+ const swapsRow = ranked.split('
r.includes('>DA · Swaps'))!;
+ expect(swapsRow).toMatch(/style="width:58\.\d+%"/);
+ expect(swapsRow).toMatch(/border-dashed[^"]*" style="left:50%"/);
+ });
+
+ it('lays out the proven run as a definition list, with the change only when there is one', () => {
+ swr.result = { data: bench(400, true) };
+ const block = text(render().ranked.split('
{
+ const withStorage = (more: Partial = {}) => {
+ const data = bench(400, true, more);
+ data.current.mix = { mgas: 400, atTop: true, run: { ...run(400), storage: storage() } };
+ return data;
+ };
+ const disk = (ranked: string, label: string) => rows(ranked).find((r) => r.includes(label))!;
+
+ it('ranks both disk rows by their upper-bound ceilings, with plain-language evidence', () => {
+ swr.result = { data: withStorage() };
+ const { ranked } = render();
+ expect(rows(ranked).map((r) => r.slice(0, 26))).toEqual([
+ '1DA · Payments (B20)next~5',
+ '2DA · Swaps~115 Mgas/s≈ 79',
+ '3Disk · archive node≤ 138 ',
+ '4DA · Mix (bench)~190 Mgas',
+ '5Execution, validators, pr',
+ '6Disk · full node≤ 553 Mga',
+ '—TEE proving, RPC under sy',
+ ]);
+ // (45 TB − 4 TB) ÷ (1e6 × (0.0086 bench + 0.0008 mainnet state) B/gas × 1 year); (45 TB − 1.1 TB state) ÷ (1e6 × (history 31 d + proofs 15 d + 2 years of 0.0008 B/gas state)).
+ expect(disk(ranked, 'archive node')).toBe('3Disk · archive node≤ 138 Mgas/s≤ 710 tx/sProjected from bench growth: one year fills the 45.0 TB disk (4.0 TB used today). Upper bound.');
+ expect(disk(ranked, 'full node')).toBe('6Disk · full node≤ 553 Mgas/s≤ 2,841 tx/sProjected from bench growth: 31 days of history, 15 days of proofs and 2 years of state growth fit in 45.0 TB. Upper bound.');
+ });
+
+ it('pluralizes the runway', () => {
+ swr.result = { data: withStorage() };
+ const snapshot = fixture();
+ snapshot.policy!.runwayYears = { fullNode: 1, archive: 2 };
+ const { ranked } = render({ benchUrl: BENCH }, snapshot);
+ expect(disk(ranked, 'archive node')).toContain('Projected from bench growth: 2 years fill the 45.0 TB disk');
+ expect(disk(ranked, 'full node')).toContain('and one year of state growth fit in 45.0 TB');
+ });
+
+ it('lists every input and why the ceilings are upper bounds in help', () => {
+ swr.result = { data: withStorage() };
+ const help = render().ranked;
+ expect(help).toContain("Inputs: 45.0 TB disk budget (a bench config assumption); today's archive 4.0 TB from Synthetic archive snapshot on 2026-01-01 (invented size); 0.00860 bytes per gas from the mainnet mix run plus 0.0008 bytes per gas of mainnet state growth from Synthetic dashboard, since the bench run writes almost no new state; runway policy: full node 2 years, archive one year (Invented runway policy.).");
+ expect(help).toContain("Inputs: 45.0 TB disk budget (a bench config assumption); today's state 1.1 TB, measured at the mainnet mix run's snapshot; 0.00860 bytes per gas of history and 0.00450 of proofs from the mainnet mix run; 0.0008 bytes per gas of production state growth from Synthetic dashboard;");
+ expect(help).toContain('Upper bound: bench bytes per gas is a lower bound, because the sequencer prunes history.');
+ expect(help).toContain("may write more bytes per gas than mainnet traffic does (Base's whole archive is 4.0 TB)");
+ });
+
+ it('counts only the bench run’s state in the archive ceiling without production state growth', () => {
+ swr.result = { data: withStorage() };
+ const snapshot = fixture();
+ delete snapshot.production!.stateBytesPerGas;
+ // (45 TB − 4 TB) ÷ (1e6 × 0.0086 B/gas × 1 year).
+ const { ranked } = render({ benchUrl: BENCH }, snapshot);
+ expect(disk(ranked, 'archive node')).toMatch(/≤ 151 Mgas\/s≤ 776 tx\/sProjected from bench growth/);
+ expect(ranked).toContain('0.00860 bytes per gas from the mainnet mix run (bench state only)');
+ });
+
+ it('names the missing input and leaves the row unranked', () => {
+ swr.result = { data: withStorage() };
+ const noPolicy = fixture();
+ delete noPolicy.policy;
+ expect(disk(render({ benchUrl: BENCH }, noPolicy).ranked, 'archive node')).toBe('—Disk · archive node—needs a runway policy');
+ expect(disk(render({ benchUrl: BENCH }, noPolicy).ranked, 'full node')).toBe('—Disk · full node—needs a runway policy');
+ const noProduction = fixture();
+ delete noProduction.production;
+ expect(disk(render({ benchUrl: BENCH }, noProduction).ranked, 'archive node')).toBe('—Disk · archive node—needs the production archive size');
+ expect(disk(render({ benchUrl: BENCH }, noProduction).ranked, 'full node')).toBe('—Disk · full node—needs production state growth');
+ const data = withStorage();
+ data.current.mix!.run.storage = { ...storage(), bytesPerGas: { history: 0.0086, proofs: null, archive: 0.0086 } };
+ swr.result = { data };
+ expect(disk(render().ranked, 'full node')).toBe('—Disk · full node—needs proof storage growth');
+ });
+
+ it('ranks an archive already over budget first', () => {
+ swr.result = { data: withStorage() };
+ const snapshot = fixture();
+ snapshot.production!.archiveBytes!.value = 50e12;
+ const { ranked } = render({ benchUrl: BENCH }, snapshot);
+ expect(rows(ranked)[0]).toBe('1Disk · archive nodenextover budgetProjected from bench growth: today\'s archive (50.0 TB) already fills the 45.0 TB disk.');
+ });
+
+ it('takes storage growth from the run with the most gas per successful transaction, saying why', () => {
+ const data = withStorage();
+ // Mix: 395 Mgas/s ÷ 4,200 tx/s ≈ 94k gas per tx. Swaps: 395 Mgas/s ÷ 2,000 tx/s ≈ 198k, the heaviest.
+ data.current.swap = { mgas: 400, atTop: true, run: { ...run(400, 'swap'), tps: 2000, storage: { ...storage(), bytesPerGas: { history: 0.0043, proofs: 0.0045, archive: 0.0043 } } } };
+ swr.result = { data };
+ const { ranked } = render();
+ // (45 TB − 4 TB) ÷ (1e6 × (0.0043 swaps + 0.0008 mainnet state) B/gas × 1 year).
+ expect(disk(ranked, 'archive node')).toMatch(/^\d+Disk · archive node≤ 255 Mgas\/s/);
+ expect(ranked).toContain('0.00430 bytes per gas from the swaps run');
+ expect(ranked).not.toContain('from the mainnet mix run');
+ expect(ranked.match(/Uses the workload with the heaviest transactions, because bench workloads with lighter transactions than mainnet overstate history bytes per gas\. A production measurement will replace this\./g)).toHaveLength(2);
+ });
+
+ it('falls back to the only run with storage growth, even a light one', () => {
+ const data = bench(400, true, { da: { mix: null, payment: { ...run(200, 'payment'), storage: storage() }, swap: null } });
+ swr.result = { data };
+ expect(render().ranked).toContain('0.00860 bytes per gas from the payments run');
+ });
+});
+
+describe('guardrails summary and work', () => {
+ // The summary is off the first screen for now; the component stays for when it returns.
+ const guardrails = (benchUrl?: string) => renderToStaticMarkup();
+
+ it('compresses guardrails to breached, met and not-measured counts with a link', () => {
+ const summary = guardrails();
+ expect(text(summary)).toContain('Breached 1 — RPC availability 99.5 %Met 2Not measured 3');
+ expect(text(summary)).toContain('RPC p99 latencyPropagation p99State growth');
+ expect(summary).toContain('href="/scaling/guardrails"');
+ swr.result = { data: bench(400, true) };
+ expect(text(guardrails(BENCH))).toContain('Met 5');
+ });
+
+ it('logs work in progress, then planned, then done, hiding other statuses', () => {
+ const work = render({}).work;
+ expect(text(work.split('')[0])).toContain('StatusWorkImpact categoryEffectLinks');
+ // The legacy "Research-derived proposal" entry is not shown; the Linear project and evidence of an older entry become links.
+ expect(rows(work)).toEqual([
+ 'In ProgressSynthetic unified binaryExecutionabout 20% faster block processing (synthetic demo)Linear · Synthetic run',
+ 'PlannedSynthetic blob batching changeDAnot measured yetSynthetic doc',
+ ]);
+ expect(work).toContain('Linear');
+ expect(text(work)).not.toMatch(/legacy proposal|Expected effects are not summed/);
+ });
+
+ it('falls back to an older entry’s expected effect and area, and to an empty state', () => {
+ const snapshot = fixture();
+ const [first, second] = snapshot.work;
+ snapshot.work = [
+ { ...second, status: 'done', category: undefined, links: [{ label: 'Doc', url: 'https://example.com/doc' }, { label: 'Linear', url: 'https://example.com/linear' }] },
+ { ...first, effect: undefined, expected: 'about 10% faster' },
+ ];
+ expect(rows(render({}, snapshot).work)).toEqual([
+ 'In ProgressSynthetic unified binaryExecutionabout 10% fasterLinear · Synthetic run',
+ 'CompletedSynthetic blob batching changeDAnot measured yetDoc · Linear',
+ ]);
+ snapshot.work = [{ ...first, status: 'Proposed' }];
+ expect(text(render({}, snapshot).work)).toContain('No work in progress or planned for this snapshot.');
+ });
+});
diff --git a/app/scaling/da/workloads.test.ts b/app/scaling/da/workloads.test.ts
index a6a3c6b..3d83a2a 100644
--- a/app/scaling/da/workloads.test.ts
+++ b/app/scaling/da/workloads.test.ts
@@ -1,13 +1,14 @@
import { expect, it } from 'vitest';
import type { Ceiling } from '../snapshot.mjs';
-import { BLOB_MAX, BLOB_TARGET, daCapacity, withDaWorkloads, type DaWorkload } from './workloads';
+import type { Da, Run } from '../proven/capacity';
+import { BLOB_MAX, BLOB_TARGET, daCapacity, daWorkloads, DA_WORKLOADS, withDaWorkloads, type DaWorkload } from './workloads';
const ceiling = (area: Ceiling['area'], value: number): Ceiling => ({
area, value, unit: 'Mgas/s', tag: 'modeled', formula: 'snapshot', inputs: [], help: 'snapshot',
});
const workload = (label: string, bytesPerTx: number, gasPerTx: number, tag: DaWorkload['tag']): DaWorkload => ({
- label, benchWorkload: label, bytesPerTx, gasPerTx, tag, asOf: null,
+ label, payload: 'mix', benchWorkload: label, bytesPerTx, gasPerTx, tag, asOf: null,
});
it('turns bytes per transaction into tx/s and gas/s at a blob count', () => {
@@ -32,3 +33,43 @@ it('is modeled once every workload is measured', () => {
const [da] = withDaWorkloads([ceiling('da', 820)], [workload('mix', 155, 193_000, 'measured')]);
expect(da.tag).toBe('modeled');
});
+
+const run = (workload: Run['workload'], da: Run['da']): Run => ({
+ id: `synthetic-${workload}`, outputDir: `synthetic-${workload}`, workload, mgas: 200, gps: 2e8, tps: 4000, reverted: 0, storage: null, blockLatency: null, passed: true,
+ commit: 'c0ffee', createdAt: Date.parse('2026-01-05T00:00:00Z'), steps: [], replay: null, proofNode: null, da,
+});
+const da = (frame_bytes: number, gas_used: number, transactions: number): Da => ({
+ result: { seconds: 600, gas_used, transactions, transaction_bytes: 0, frame_bytes, gas_per_second: 0, bytes_per_gas: frame_bytes / gas_used, compression_ratio: 0, blobs_per_l1_slot: 15.5, blob_fill: 1, blob_target_gas_per_second: 0 },
+ target: 14,
+ withinTarget: false,
+});
+
+it('measures bytes/tx and gas/tx from a bench DA run, keeping the mainnet mix and estimates without one', () => {
+ const payment = run('payment', da(8e7, 4e10, 1e6));
+ const [payments, swaps, mix] = daWorkloads({ payment, swap: run('swap', null), mix: run('mix', da(1, 1, 1)) });
+ expect(payments).toMatchObject({
+ bytesPerTx: 80, gasPerTx: 40_000, tag: 'measured', asOf: '2026-01-05T00:00:00.000Z',
+ source: { label: 'bench 200 Mgas/s run', url: '/scaling/runs/bench/synthetic-payment' }, bench: { run: payment },
+ });
+ // bytes/tx ÷ gas/tx is the run's frame bytes per gas, so the 14-blob formula matches the run's own.
+ expect(daCapacity(payments, BLOB_TARGET).mgasPerSecond).toBeCloseTo((14 * 130_044) / 12 / payment.da!.result.bytes_per_gas / 1e6);
+ expect(swaps).toBe(DA_WORKLOADS[1]);
+ expect(mix).toMatchObject({ bytesPerTx: DA_WORKLOADS[2].bytesPerTx, tag: 'measured', bench: { da: { withinTarget: false } } });
+ expect(daWorkloads(undefined)).toEqual(DA_WORKLOADS);
+});
+
+it('counts only successful transactions per second, keeping gas per second', () => {
+ const swap = { ...run('swap', da(1.58e8, 2.4e11, 2e6)), reverted: 0.155 };
+ const [, swaps] = daWorkloads({ payment: null, swap, mix: null });
+ // 2,000,000 × 0.845 = 1,690,000 successful transactions carry all the bytes and gas.
+ expect(swaps.gasPerTx).toBeCloseTo(2.4e11 / 1.69e6);
+ expect(swaps.bytesPerTx).toBeCloseTo(1.58e8 / 1.69e6);
+ const allTx = daWorkloads({ payment: null, swap: { ...swap, reverted: 0 }, mix: null })[1];
+ expect(daCapacity(swaps, BLOB_TARGET).txPerSecond).toBeCloseTo(0.845 * daCapacity(allTx, BLOB_TARGET).txPerSecond);
+ expect(daCapacity(swaps, BLOB_TARGET).mgasPerSecond).toBeCloseTo(daCapacity(allTx, BLOB_TARGET).mgasPerSecond);
+});
+
+it('is modeled once payments and swaps are measured', () => {
+ const [da2] = withDaWorkloads([ceiling('da', 820)], daWorkloads({ payment: run('payment', da(8e7, 4e10, 1e6)), swap: run('swap', da(2e8, 1.4e11, 1e6)), mix: null }));
+ expect(da2.tag).toBe('modeled');
+});
diff --git a/app/scaling/da/workloads.ts b/app/scaling/da/workloads.ts
index ba621c6..318c784 100644
--- a/app/scaling/da/workloads.ts
+++ b/app/scaling/da/workloads.ts
@@ -1,28 +1,34 @@
+import { runHref, type Da, type ProvenCapacity, type Run, type Workload } from '../proven/capacity';
import type { Ceiling, Link } from '../snapshot.mjs';
// Ethereum's blob target and max per L1 block since BPO2 (2026-01-07), and the data one blob carries.
export const BLOB_TARGET = 14;
export const BLOB_MAX = 21;
-const BLOB_DATA_BYTES = 130_044;
-const L1_SLOT_SECONDS = 12;
+export const BLOB_DATA_BYTES = 130_044;
+export const L1_SLOT_SECONDS = 12;
export type DaWorkload = {
label: string;
+ payload: Workload;
benchWorkload: string;
gasPerTx: number;
bytesPerTx: number;
tag: 'measured' | 'estimated';
asOf: string | null;
source?: Link;
+ // The latest bench run's DA step, shown beside the row whether or not it set bytes/tx.
+ bench?: { run: Run; da: Da };
};
-// Bytes per transaction come from `base-bench da`; gas per transaction for bench workloads from the
-// load-test calibration (etc/scripts/local/mainnet-mix.py in base/base).
+// Bytes per transaction are compressed batch frame bytes per non-deposit transaction from
+// `base-bench da`, the bytes that fill a blob; gas per transaction for bench workloads comes from
+// the load-test calibration (etc/scripts/local/mainnet-mix.py in base/base).
export const DA_WORKLOADS: DaWorkload[] = [
- { label: 'Payments (B20)', benchWorkload: 'b20-transfer', gasPerTx: 37_743, bytesPerTx: 100, tag: 'estimated', asOf: null },
- { label: 'Swaps', benchWorkload: 'uniswap-swap', gasPerTx: 143_987, bytesPerTx: 190, tag: 'estimated', asOf: null },
+ { label: 'Payments (B20)', payload: 'payment', benchWorkload: 'b20-transfer', gasPerTx: 37_743, bytesPerTx: 100, tag: 'estimated', asOf: null },
+ { label: 'Swaps', payload: 'swap', benchWorkload: 'uniswap-swap', gasPerTx: 143_987, bytesPerTx: 190, tag: 'estimated', asOf: null },
{
label: 'Mainnet mix',
+ payload: 'mix',
benchWorkload: 'mainnet blocks 53,091,727–53,095,326',
gasPerTx: 193_349,
bytesPerTx: 154.7,
@@ -32,6 +38,30 @@ export const DA_WORKLOADS: DaWorkload[] = [
},
];
+// A bench DA run replaces an estimate with frame_bytes and gas_used per successful transaction (reverted
+// ones still use gas and blob space), so bytes/tx ÷ gas/tx is the run's bytes per gas and tx/s counts
+// successful transactions only. The mainnet mix keeps its measurement from real
+// mainnet blocks, which the synthetic bench mix only approximates.
+export function daWorkloads(da: ProvenCapacity['da'] | undefined, workloads: DaWorkload[] = DA_WORKLOADS): DaWorkload[] {
+ return workloads.map((w) => {
+ const run = da?.[w.payload];
+ if (!run?.da) return w;
+ const bench = { run, da: run.da };
+ if (w.tag === 'measured') return { ...w, bench };
+ const { frame_bytes, gas_used } = run.da.result;
+ const transactions = run.da.result.transactions * (1 - run.reverted);
+ return {
+ ...w,
+ gasPerTx: gas_used / transactions,
+ bytesPerTx: frame_bytes / transactions,
+ tag: 'measured',
+ asOf: new Date(run.createdAt).toISOString(),
+ source: { label: `bench ${run.mgas} Mgas/s run`, url: runHref(run) },
+ bench,
+ };
+ });
+}
+
export function daCapacity(w: DaWorkload, blobs: number): { txPerSecond: number; mgasPerSecond: number } {
const txPerSecond = (blobs * BLOB_DATA_BYTES) / L1_SLOT_SECONDS / w.bytesPerTx;
return { txPerSecond, mgasPerSecond: (txPerSecond * w.gasPerTx) / 1e6 };
diff --git a/app/scaling/fixtures/synthetic.v2.json b/app/scaling/fixtures/synthetic.v2.json
index 4ac44f3..49dfca5 100644
--- a/app/scaling/fixtures/synthetic.v2.json
+++ b/app/scaling/fixtures/synthetic.v2.json
@@ -5,7 +5,8 @@
"context": "Synthetic fixture: every number is invented to exercise the schema. Not a measurement of any network.",
"windows": {
"day": { "label": "24 h", "start": "2026-01-01T00:00:00Z", "end": "2026-01-02T00:00:00Z" },
- "week": { "label": "7 d", "start": "2025-12-26T00:00:00Z", "end": "2026-01-02T00:00:00Z" }
+ "week": { "label": "7 d", "start": "2025-12-26T00:00:00Z", "end": "2026-01-02T00:00:00Z" },
+ "month": { "label": "the last 30 days", "start": "2025-12-03T00:00:00Z", "end": "2026-01-02T00:00:00Z" }
},
"ladder": {
"observed": {
@@ -195,6 +196,21 @@
},
"gasPerSwap": null
},
+ "policy": {
+ "runwayYears": { "fullNode": 2, "archive": 1, "help": "Invented runway policy." }
+ },
+ "production": {
+ "archiveBytes": { "value": 4000000000000, "asOf": "2026-01-01", "source": "Synthetic archive snapshot", "note": "invented size" },
+ "stateBytesPerGas": { "value": 0.0008, "window": "week", "source": "Synthetic dashboard" }
+ },
+ "usage": {
+ "transactionsPerSecond": {
+ "value": 64.2,
+ "window": "month",
+ "help": "Invented: synthetic transactions per second averaged over the window.",
+ "source": { "label": "Synthetic dashboard", "url": "https://example.com/synthetic/tps" }
+ }
+ },
"sections": [
{
"id": "da",
@@ -246,7 +262,9 @@
{
"area": "execution",
"title": "Synthetic unified binary",
- "status": "In progress",
+ "status": "in-progress",
+ "category": "Execution",
+ "effect": "about 20% faster block processing (synthetic demo)",
"owner": "Synthetic owner",
"hypothesis": "Fewer process hops per block speed up block processing.",
"change": "Run the consensus and execution clients in one process.",
@@ -268,7 +286,8 @@
{
"area": "da",
"title": "Synthetic blob batching change",
- "status": "Proposed",
+ "status": "planned",
+ "category": "DA",
"owner": null,
"hypothesis": "Denser batches post fewer bytes per gas.",
"change": "Not yet specified.",
@@ -277,6 +296,19 @@
"evidence": null,
"linear": null,
"links": [{ "label": "Synthetic doc", "url": "https://example.com/synthetic/doc" }]
+ },
+ {
+ "area": "networking",
+ "title": "Synthetic legacy proposal",
+ "status": "Research-derived proposal",
+ "owner": null,
+ "hypothesis": "An entry from before the work log, which the page does not show.",
+ "change": "Not yet specified.",
+ "expected": null,
+ "realized": null,
+ "evidence": null,
+ "linear": null,
+ "links": []
}
]
}
diff --git a/app/scaling/guardrails/page.tsx b/app/scaling/guardrails/page.tsx
index 79e8154..8d8731d 100644
--- a/app/scaling/guardrails/page.tsx
+++ b/app/scaling/guardrails/page.tsx
@@ -12,8 +12,8 @@ export default async function ScalingGuardrailsPage() {
const outcomes = snapshot.sections.find((s) => s.id === 'outcomes');
return (
-
-
+
+
{outcomes ? : null}
);
diff --git a/app/scaling/loadSnapshot.tsx b/app/scaling/loadSnapshot.tsx
index c645c85..dc06375 100644
--- a/app/scaling/loadSnapshot.tsx
+++ b/app/scaling/loadSnapshot.tsx
@@ -2,19 +2,15 @@ import { readFile } from 'node:fs/promises';
import Link from 'next/link';
import type { ReactNode } from 'react';
-import { Banner } from '../components/ui/Banner';
import { EmptyState } from '../components/ui/EmptyState';
import { Text } from '../components/ui/Text';
-import { isStale, parseSnapshot, type Snapshot } from './snapshot.mjs';
-
-const STALE_AFTER_DAYS = 10;
-const DAY_MS = 86_400_000;
+import { parseSnapshot, type Snapshot } from './snapshot.mjs';
export type Loaded =
| { kind: 'unconfigured' }
| { kind: 'invalid'; errors: string[] }
- | { kind: 'ok'; snapshot: Snapshot; ageDays: number; stale: boolean };
+ | { kind: 'ok'; snapshot: Snapshot };
export async function loadSnapshot(): Promise {
const path = process.env.SCALING_SNAPSHOT_PATH;
@@ -22,9 +18,7 @@ export async function loadSnapshot(): Promise {
try {
const result = parseSnapshot(JSON.parse(await readFile(path, 'utf8')));
if (!result.ok) return { kind: 'invalid', errors: result.errors };
- const { collectedAt } = result.snapshot;
- const ageDays = Math.floor((Date.now() - Date.parse(collectedAt)) / DAY_MS);
- return { kind: 'ok', snapshot: result.snapshot, ageDays, stale: isStale(collectedAt, new Date(), STALE_AFTER_DAYS) };
+ return { kind: 'ok', snapshot: result.snapshot };
} catch (error) {
return { kind: 'invalid', errors: [error instanceof Error ? error.message : String(error)] };
}
@@ -47,22 +41,13 @@ export function Unavailable({ loaded }: { loaded: Exclude; title: ReactNode; crumb?: string; action?: ReactNode }) {
- const { snapshot, ageDays, stale } = loaded;
+export function Header({ title, crumb }: { title: ReactNode; crumb?: string }) {
return (
{crumb ? (
Scaling / {crumb}
) : null}
{title}
- {action}
-
- As of {formatTime(snapshot.collectedAt)} ({ageDays === 0 ? 'today' : `${ageDays}d old`}) · manually refreshed
- snapshot, not live data. {snapshot.context}
-
- {stale ? (
- Stale: this snapshot is older than {STALE_AFTER_DAYS} days. Refresh it before using these numbers.
- ) : null}
);
}
diff --git a/app/scaling/page.test.tsx b/app/scaling/page.test.tsx
index 725ee05..159770f 100644
--- a/app/scaling/page.test.tsx
+++ b/app/scaling/page.test.tsx
@@ -11,17 +11,37 @@ import fixture from './fixtures/synthetic.v2.json';
import ScalingGuardrailsPage from './guardrails/page';
import ScalingPage from './page';
+// No network in tests: the live archive size is unavailable, so the page keeps the snapshot's.
+vi.mock('./archiveSize', async (original) => ({ ...(await original()), loadArchiveSize: async () => null }));
+
beforeEach(() => vi.stubEnv('SCALING_SNAPSHOT_PATH', fileURLToPath(new URL('./fixtures/synthetic.v2.json', import.meta.url))));
afterEach(() => vi.unstubAllEnvs());
const area = async (id: string) => renderToStaticMarkup(await ScalingAreaPage({ params: Promise.resolve({ area: id }) }));
-it('links the first screen to four detail pages instead of listing metric rows', async () => {
+it('ends the first screen with the DA link, and no guardrails or benchmark links', async () => {
const html = renderToStaticMarkup(await ScalingPage());
- for (const page of ['da', 'execution', 'state', 'guardrails']) expect(html).toContain(`href="/scaling/${page}"`);
- expect(html).not.toContain('href="/scaling/networking"');
+ expect(html).toContain('DA by workload');
+ // The guardrails page stays, unlinked, while the first screen leaves guardrails out.
+ expect(html).not.toMatch(/href="\/scaling\/guardrails"|Guardrails<\/h2>|SLOs/);
+ expect(html).not.toMatch(/href="\/benchmark|href="\/scaling\/networking"/);
expect(html).not.toContain('Blob utilization');
- expect(html).toContain('href="/scaling/runs"');
+ expect(html).not.toContain('href="/scaling/runs"');
+ expect(html).toMatch(/
]*>synthetic-testnet capacity<\/h1>/);
+ expect(html).not.toMatch(/As of|manually refreshed|Synthetic fixture: every number/);
+});
+
+it('orders the first screen: freshness, headline, ladder, ranked limits, subsystems by workload, work, links', async () => {
+ const html = renderToStaticMarkup(await ScalingPage());
+ const order = ['Usage to 2026-01-02', 'Proven capacity', 'How close are we, and to what?', 'What breaks first', 'Subsystems by workload', 'Work log', 'DA by workload'].map((h) => html.indexOf(h));
+ expect(order.every((i, k) => i > (order[k - 1] ?? -1))).toBe(true);
+ expect(html).not.toMatch(/Subsystem detail|>Progress<|Ceilings by bottleneck|Usage data as of/);
+});
+
+it('dates snapshot data without a stale banner', async () => {
+ // The fixture is far older than any staleness window.
+ expect(renderToStaticMarkup(await ScalingGuardrailsPage())).toContain('Usage data as of 2026-01-02');
+ for (const html of [renderToStaticMarkup(await ScalingPage()), renderToStaticMarkup(await ScalingGuardrailsPage()), await area('da')]) expect(html).not.toContain('Stale');
});
it('shows every metric row on its detail page', async () => {
@@ -31,10 +51,10 @@ it('shows every metric row on its detail page', async () => {
}
});
-it('renders an area with breadcrumb, as-of header, ceiling, metric details, gaps and a runs slot', async () => {
+it('renders an area with breadcrumb, no as-of line, ceiling, metric details, gaps and a runs slot', async () => {
const html = await area('da');
expect(html).toContain('href="/scaling">Scaling / DA');
- expect(html).toContain('As of 2026-01-02 00:00 UTC');
+ expect(html).not.toContain('As of 2026-01-02');
expect(html).toMatch(/>Ceiling ]*>· Mgas\/s<\/span>/);
expect(html).toContain('Lowest: Payments (B20) · 14-blob target, no margin');
expect(html).toContain('>1,517<');
@@ -57,7 +77,7 @@ it('404s an unknown area and networking, which has no ceiling', async () => {
for (const id of ['bogus', 'networking']) await expect(area(id)).rejects.toMatchObject({ digest: 'NEXT_HTTP_ERROR_FALLBACK;404' });
});
-it('passes the runtime bench and dashboard URLs to the ladder', async () => {
+it('passes the runtime bench and dashboard URLs to the scorecard and footer', async () => {
expect(renderToStaticMarkup(await ScalingPage())).not.toContain('Loading bench results');
vi.stubEnv('SCALING_BENCH_BASE_URL', 'https://bench.example.com');
vi.stubEnv('SCALING_DATADOG_DASHBOARD_URL', 'https://dashboards.example.com/scaling');
@@ -66,15 +86,20 @@ it('passes the runtime bench and dashboard URLs to the ladder', async () => {
expect(html).toContain('href="https://dashboards.example.com/scaling"');
});
-it('links the first-screen guardrail strip to the guardrails page', async () => {
- expect(renderToStaticMarkup(await ScalingPage())).toContain('Guardrails →');
+it('adds bench guardrail rows when a bench host is set, unknown until its results load', async () => {
+ vi.stubEnv('SCALING_BENCH_BASE_URL', 'https://bench.example.com');
+ const html = renderToStaticMarkup(await ScalingGuardrailsPage());
+ for (const label of ['Proof-node head lag p99', 'debug_executePayload p99', 'Validator replay headroom']) expect(html).toContain(label);
+ expect(html).toContain('No qualifying mainnet-mix bench run is loaded.');
});
it('shows guardrail help inline and the outcomes table on the guardrails page', async () => {
const html = renderToStaticMarkup(await ScalingGuardrailsPage());
expect(html).toContain('href="/scaling">Scaling / Guardrails');
- expect(html).toContain('As of 2026-01-02 00:00 UTC');
- for (const g of fixture.guardrails) expect(html).toContain(g.help);
+ expect(html).not.toContain('As of 2026-01-02');
+ for (const g of fixture.guardrails) if (g.id !== 'validator-keep-up') expect(html).toContain(g.help);
+ expect(html).toContain('They must stay within 3 blocks.');
+ expect(html).not.toContain('Proof-node head lag p99');
expect(html).not.toContain('Guardrails →');
expect(html).toContain('Inclusion latency p50');
expect(html).toContain('synthetic.inclusion_latency');
diff --git a/app/scaling/page.tsx b/app/scaling/page.tsx
index b9d928a..0fec28a 100644
--- a/app/scaling/page.tsx
+++ b/app/scaling/page.tsx
@@ -2,11 +2,8 @@ import Link from 'next/link';
import { Text } from '../components/ui/Text';
-import { CeilingCards } from './components/CeilingCards';
-import { Guardrails } from './components/Guardrails';
-import { Ladder } from './components/Ladder';
-import { Progress } from './components/Progress';
-import { withDaWorkloads } from './da/workloads';
+import { Scorecard } from './components/Scorecard';
+import { loadArchiveSize, withArchiveSize } from './archiveSize';
import { Header, loadSnapshot, Unavailable } from './loadSnapshot';
// The snapshot is a runtime file, so render per request rather than at build.
@@ -16,21 +13,17 @@ export default async function ScalingPage() {
const loaded = await loadSnapshot();
if (loaded.kind !== 'ok') return ;
- const { snapshot } = loaded;
- const ceilings = withDaWorkloads(snapshot.ceilings);
+ const snapshot = withArchiveSize(loaded.snapshot, await loadArchiveSize());
+ const dashboardUrl = process.env.SCALING_DATADOG_DASHBOARD_URL;
return (
- Runs →} />
-
-
-
-
-
-
-
+
+
- Experiment results: Benchmarks ·{' '}
- Load tests
+
+ DA by workload
+ {dashboardUrl ? Mainnet load, gas limit, DA ceiling : null}
+
);
diff --git a/app/scaling/proven/capacity.test.ts b/app/scaling/proven/capacity.test.ts
index 6fa8279..c4c0a6a 100644
--- a/app/scaling/proven/capacity.test.ts
+++ b/app/scaling/proven/capacity.test.ts
@@ -1,20 +1,49 @@
import { expect, it } from 'vitest';
-import { loadProvenCapacity } from './capacity';
+import { loadProvenCapacity, PAYLOADS, type Workload } from './capacity';
const NOW = Date.parse('2026-02-01T00:00:00Z');
const DAY = 86_400_000;
+const HTML = '';
type Spec = {
dir: string;
mgas: number;
daysAgo: number;
+ workload?: Workload;
ref?: string | null;
comparable?: boolean;
delivered?: number;
scenario?: string;
benchmarkRun?: string;
fail?: string;
+ // false: written before the step pipeline, so no pipeline-result.json.
+ pipeline?: boolean;
+ missing?: string[];
+ // Older load-test-result.json files have no block_latency.
+ noLatency?: boolean;
+ withinTarget?: boolean | null;
+};
+
+const LATENCY = { meanMs: 3, p50Ms: 2, p99Ms: 9, maxMs: 12 };
+const STEPS = (s: Spec) => [
+ { name: 'sequencer', enabled: true, status: 'passed', reason: null, durationSeconds: 900, outputs: {} },
+ { name: 'validator_replay', enabled: true, status: s.comparable === false ? 'failed' : 'passed', reason: s.comparable === false ? 'replay mismatch' : null, durationSeconds: 130, outputs: {} },
+ { name: 'da', enabled: true, status: 'passed', reason: null, durationSeconds: 20, outputs: { target: 14, blobsPerL1Slot: 15.5, blobTargetGasPerSecond: 1.9e8, withinTarget: s.withinTarget === undefined ? false : s.withinTarget } },
+ { name: 'tee_proof', enabled: false, status: 'not-implemented', reason: 'needs a synthetic L1' },
+];
+const REPLAY = { blocksPerSecond: 36.4, newPayload: LATENCY, forkchoice: LATENCY, match: true, blocks: [{ number: 1 }] };
+const PROOFS = {
+ headLagBlocks: { samples: 3000, p50: 1, p99: 4, max: 9 },
+ failedProbes: 0,
+ methods: { debug_executePayload: { errors: 0, latency: LATENCY } },
+ witnessBytes: { samples: 3000, p50: 4096, p99: 8192, max: 9000 },
+};
+const DA = { seconds: 600, gas_used: 6e10, transactions: 1e6, transaction_bytes: 1.5e8, frame_bytes: 6e7, gas_per_second: 1e8, bytes_per_gas: 0.001, compression_ratio: 2.5, blobs_per_l1_slot: 15.5, blob_fill: 0.98, blob_target_gas_per_second: 1.5e8 };
+const STORAGE = {
+ diskBudgetBytes: 45e12,
+ archive: { growthBytesPerYear: 1e14, growthBound: 'lower', currentBytes: null, runwayYears: null, runwayBound: 'upper' },
+ fullNode: { stateGrowthBytesPerYear: 1e10, historyFootprintBytes: 9e12, historyFootprintBound: 'lower', proofFootprintBytes: 2e12 },
};
// Run outputs are cached by outputDir across calls, so every test uses its own dirs.
@@ -24,10 +53,10 @@ function load(specs: Spec[]) {
outputDir: s.dir,
createdAt: new Date(NOW - s.daysAgo * DAY).toISOString(),
testConfig: {
- BenchmarkRun: s.benchmarkRun ?? 'mainnet-snapshot',
+ BenchmarkRun: s.benchmarkRun ?? `run-${s.dir}`,
ChainId: 8453,
Scenario: s.scenario ?? `sustained-${s.mgas}mgas`,
- TransactionPayload: 'mainnet-mix',
+ TransactionPayload: PAYLOADS[s.workload ?? 'mix'],
},
result: { success: true },
})),
@@ -35,31 +64,102 @@ function load(specs: Spec[]) {
const fetchImpl = async (url: string) => {
const path = url.replace('https://bench.example/output/', '');
if (path === 'metadata.json') return Response.json(metadata);
- const [dir, file] = path.split('/');
+ const [dir, ...rest] = path.split('/');
+ const file = rest.join('/');
const s = specs.find((x) => x.dir === dir)!;
if (s.fail === file) return new Response('error', { status: 500 });
- if (file === 'validation.json') return Response.json({ comparable: s.comparable ?? true, reasons: [] });
- if (file === 'load-test-result.json') return Response.json({ throughput: { gps: (s.delivered ?? 1) * s.mgas * 1e6 } });
- if (s.ref === null) return new Response('');
+ if (s.missing?.includes(file) || (file === 'pipeline-result.json' && s.pipeline === false)) return new Response(HTML);
+ const delivered = (s.delivered ?? 1) * s.mgas;
+ switch (file) {
+ case 'validation.json': return Response.json({ comparable: s.comparable ?? true, reasons: [] });
+ case 'load-test-result.json': return Response.json({
+ throughput: { gps: delivered * 1e6, tps: delivered * 10, total_submitted: 1000, total_reverted: 155 },
+ ...(s.noLatency ? {} : { block_latency: { p50: { secs: 0, nanos: 205_382_593 }, p99: { secs: 1, nanos: 6_265_374 } } }),
+ });
+ case 'pipeline-result.json': return Response.json({ suite: 'synthetic', comparable: s.comparable ?? true, steps: STEPS(s) });
+ case 'replay-result.json': return Response.json(REPLAY);
+ case 'proof-node/replay-result.json': return Response.json({ ...REPLAY, blocksPerSecond: 20, proofs: PROOFS });
+ case 'da-result.json': return Response.json(DA);
+ case 'storage-growth.json': return Response.json(STORAGE);
+ }
+ if (s.ref === null) return new Response(HTML);
return Response.json({ source: { repository: 'base/base', ref: s.ref ?? 'main', revision: `rev-${dir}` } });
};
return loadProvenCapacity('https://bench.example', fetchImpl as typeof fetch, NOW);
}
-it('counts only runs built from main', async () => {
+it('counts only pipeline runs built from main, with their steps, replays and DA result', async () => {
const { current, unknown } = await load([
{ dir: 'main-100', mgas: 100, daysAgo: 3 },
{ dir: 'branch-html-200', mgas: 200, daysAgo: 2, ref: null },
{ dir: 'branch-ref-300', mgas: 300, daysAgo: 1, ref: 'feature' },
+ { dir: 'pre-pipeline-400', mgas: 400, daysAgo: 1, pipeline: false },
]);
- expect(current).toEqual({
+ expect(current.mix).toEqual({
mgas: 100,
atTop: true,
- run: { outputDir: 'main-100', mgas: 100, gps: 100e6, passed: true, commit: 'rev-main-100', createdAt: NOW - 3 * DAY },
+ run: {
+ id: 'run-main-100',
+ outputDir: 'main-100',
+ workload: 'mix',
+ mgas: 100,
+ gps: 100e6,
+ tps: 1000,
+ reverted: 0.155,
+ blockLatency: { p50Ms: 205.382593, p99Ms: 1006.265374 },
+ passed: true,
+ commit: 'rev-main-100',
+ createdAt: NOW - 3 * DAY,
+ steps: STEPS({ dir: '', mgas: 0, daysAgo: 0 }),
+ replay: { blocksPerSecond: 36.4, newPayload: LATENCY, match: true },
+ proofNode: { blocksPerSecond: 20, newPayload: LATENCY, match: true, proofs: PROOFS },
+ da: { result: DA, target: 14, withinTarget: false },
+ storage: STORAGE,
+ },
});
expect(unknown).toEqual([]);
});
+it('keeps each payload separate, with the latest DA run per payload', async () => {
+ const { current, da, history, latestAt } = await load([
+ { dir: 'pay-mix-400', mgas: 400, daysAgo: 3 },
+ { dir: 'pay-b20-200', mgas: 200, daysAgo: 3, workload: 'payment' },
+ { dir: 'pay-b20-300', mgas: 300, daysAgo: 2, workload: 'payment', delivered: 0.5 },
+ { dir: 'pay-b20-old', mgas: 100, daysAgo: 20, workload: 'payment' },
+ { dir: 'pay-swap-100', mgas: 100, daysAgo: 1, workload: 'swap', missing: ['da-result.json'] },
+ ]);
+ expect(current.mix).toMatchObject({ mgas: 400, run: { outputDir: 'pay-mix-400' } });
+ expect(current.payment).toMatchObject({ mgas: 200, atTop: false, run: { tps: 2000 } });
+ expect(current.swap).toMatchObject({ mgas: 100, run: { da: null } });
+ expect(da.mix?.outputDir).toBe('pay-mix-400');
+ expect(da.payment?.outputDir).toBe('pay-b20-300');
+ expect(da.swap).toBeNull();
+ expect(history.map((s) => s.mgas)).toEqual([400]);
+ // The newest counting run, here one without a DA result.
+ expect(latestAt).toBe(NOW - DAY);
+});
+
+it('fails a run whose pipeline step failed', async () => {
+ const { current } = await load([
+ { dir: 'step-100', mgas: 100, daysAgo: 3 },
+ { dir: 'step-200', mgas: 200, daysAgo: 2, comparable: false },
+ ]);
+ expect(current.mix).toMatchObject({ mgas: 100, atTop: false });
+});
+
+it('keeps a run and its other details when only storage-growth.json fails', async () => {
+ const { current, unknown } = await load([{ dir: 'storage-500', mgas: 100, daysAgo: 1, fail: 'storage-growth.json' }]);
+ expect(current.mix?.run).toMatchObject({ storage: null, da: { target: 14 }, replay: { match: true } });
+ expect(unknown).toEqual([]);
+});
+
+it('reads missing replay, proof-node and storage files as null, and an unjudged blob target as null', async () => {
+ const { current } = await load([
+ { dir: 'partial-100', mgas: 100, daysAgo: 1, missing: ['replay-result.json', 'proof-node/replay-result.json', 'storage-growth.json'], withinTarget: null },
+ ]);
+ expect(current.mix?.run).toMatchObject({ replay: null, proofNode: null, storage: null, da: { target: 14, withinTarget: null } });
+});
+
it('ignores alias copies and non-sustained scenarios', async () => {
const { current, unknown } = await load([
{ dir: 'cand-100', mgas: 100, daysAgo: 3 },
@@ -67,7 +167,7 @@ it('ignores alias copies and non-sustained scenarios', async () => {
{ dir: 'cand-glam-200', mgas: 200, daysAgo: 2, scenario: 'glamsterdam-sustained-200mgas' },
{ dir: 'cand-scrape-400', mgas: 400, daysAgo: 2, scenario: 'sustained-400mgas-scrape200ms' },
]);
- expect(current).toMatchObject({ mgas: 100, atTop: true });
+ expect(current.mix).toMatchObject({ mgas: 100, atTop: true });
expect(unknown).toEqual([]);
});
@@ -78,7 +178,7 @@ it('fails runs that are not comparable or deliver under 95% of target', async ()
{ dir: 'fail-150', mgas: 150, daysAgo: 3, delivered: 0.94 },
{ dir: 'fail-200', mgas: 200, daysAgo: 3, comparable: false },
]);
- expect(current).toMatchObject({ mgas: 120, atTop: false, run: { gps: 114e6 } });
+ expect(current.mix).toMatchObject({ mgas: 120, atTop: false, run: { gps: 114e6 } });
});
it('ignores runs older than 14 days', async () => {
@@ -86,7 +186,7 @@ it('ignores runs older than 14 days', async () => {
{ dir: 'window-100', mgas: 100, daysAgo: 13 },
{ dir: 'window-200', mgas: 200, daysAgo: 14 },
]);
- expect(current).toMatchObject({ mgas: 100, atTop: true });
+ expect(current.mix).toMatchObject({ mgas: 100, atTop: true });
});
it('uses the latest run at each rate over an older pass', async () => {
@@ -95,7 +195,7 @@ it('uses the latest run at each rate over an older pass', async () => {
{ dir: 'latest-200-old', mgas: 200, daysAgo: 4 },
{ dir: 'latest-200-new', mgas: 200, daysAgo: 1, comparable: false },
]);
- expect(current).toMatchObject({ mgas: 100, atTop: false });
+ expect(current.mix).toMatchObject({ mgas: 100, atTop: false });
});
it('steps history when proven capacity changes, with compare links', async () => {
@@ -134,13 +234,44 @@ it('dates a drop when the higher pass ages out of the window', async () => {
]);
});
-it('marks a run unknown when one of its files fails, keeping the rest', async () => {
+it('marks a run unknown when one of its files fails or its results are missing, keeping the rest', async () => {
const { current, unknown } = await load([
{ dir: 'iso-100', mgas: 100, daysAgo: 3 },
{ dir: 'iso-200', mgas: 200, daysAgo: 2, fail: 'load-test-result.json' },
+ { dir: 'iso-300', mgas: 300, daysAgo: 2, fail: 'da-result.json' },
+ { dir: 'iso-400', mgas: 400, daysAgo: 2, missing: ['validation.json'] },
]);
- expect(current).toMatchObject({ mgas: 100, atTop: true });
- expect(unknown).toEqual(['iso-200']);
+ // A failed detail file leaves the run counted, without its details.
+ expect(current.mix).toMatchObject({ mgas: 300, atTop: true, run: { replay: null, da: null } });
+ expect(unknown.toSorted()).toEqual(['iso-200', 'iso-300', 'iso-400']);
+});
+
+it('fetches replay, proof-node and DA files only for proven and latest-DA runs', async () => {
+ const fetched: string[] = [];
+ const metadata = { runs: ['sel-100', 'sel-200', 'sel-300'].map((dir, i) => ({
+ outputDir: dir, createdAt: new Date(NOW - (3 - i) * DAY).toISOString(),
+ testConfig: { BenchmarkRun: dir, ChainId: 8453, Scenario: `sustained-${(i + 1) * 100}mgas`, TransactionPayload: 'mainnet-mix' },
+ })) };
+ const fetchImpl = async (url: string) => {
+ const path = url.replace('https://bench.example/output/', '');
+ if (path === 'metadata.json') return Response.json(metadata);
+ fetched.push(path);
+ const [dir, ...rest] = path.split('/');
+ const s: Spec = { dir, mgas: Number(dir.slice(4)), daysAgo: 0, comparable: dir !== 'sel-300' };
+ const file = rest.join('/');
+ if (file === 'validation.json') return Response.json({ comparable: s.comparable });
+ if (file === 'load-test-result.json') return Response.json({ throughput: { gps: s.mgas * 1e6, tps: 1 } });
+ if (file === 'pipeline-result.json') return Response.json({ steps: STEPS(s) });
+ if (file === 'provenance.json') return Response.json({ source: { ref: 'main', revision: 'r' } });
+ return Response.json(file === 'da-result.json' ? DA : REPLAY);
+ };
+ const { current, da } = await loadProvenCapacity('https://bench.example', fetchImpl as typeof fetch, NOW);
+ expect(current.mix?.run.outputDir).toBe('sel-200');
+ // Without submitted and reverted totals, nothing counts as reverted.
+ expect(current.mix?.run.reverted).toBe(0);
+ expect(da.mix?.outputDir).toBe('sel-300');
+ const details = fetched.filter((p) => /replay-result|da-result/.test(p)).map((p) => p.split('/')[0]);
+ expect([...new Set(details)].toSorted()).toEqual(['sel-200', 'sel-300']);
});
it('revalidates the run index instead of trusting a cached copy', async () => {
@@ -152,3 +283,12 @@ it('revalidates the run index instead of trusting a cached copy', async () => {
await loadProvenCapacity('https://bench.example', fetchImpl as typeof fetch, NOW);
expect(inits.get('https://bench.example/output/metadata.json')?.cache).toBe('no-cache');
});
+
+it('reads block latency from load-test-result.json as milliseconds, and its absence as null', async () => {
+ const { current } = await load([
+ { dir: 'latency-100', mgas: 100, daysAgo: 1 },
+ { dir: 'old-200', mgas: 200, daysAgo: 1, noLatency: true, workload: 'swap' },
+ ]);
+ expect(current.mix?.run.blockLatency).toEqual({ p50Ms: 205.382593, p99Ms: 1006.265374 });
+ expect(current.swap?.run.blockLatency).toBeNull();
+});
diff --git a/app/scaling/proven/capacity.ts b/app/scaling/proven/capacity.ts
index 7c20db1..30faab2 100644
--- a/app/scaling/proven/capacity.ts
+++ b/app/scaling/proven/capacity.ts
@@ -1,14 +1,83 @@
import type { BenchmarkRun, BenchmarkRuns } from '../runs/lib/types';
+export const PAYLOADS = { mix: 'mainnet-mix', payment: 'b20-transfer-existing', swap: 'uniswap-v3-swap' } as const;
+export type Workload = keyof typeof PAYLOADS;
+
+export type PipelineStep = {
+ name: string;
+ enabled: boolean;
+ status: 'passed' | 'failed' | 'skipped' | 'not-implemented';
+ reason?: string | null;
+ durationSeconds?: number;
+ outputs?: Record;
+};
+type Latency = { p50Ms: number; p99Ms: number; maxMs: number };
+type Counts = { samples: number; p50: number; p99: number; max: number };
+export type Replay = { blocksPerSecond: number; newPayload: Latency; match: boolean };
+export type Proofs = {
+ headLagBlocks: Counts;
+ failedProbes: number;
+ methods: Record;
+ witnessBytes: Counts;
+};
+// `base-bench da` writes snake_case.
+export type DaResult = {
+ seconds: number;
+ gas_used: number;
+ transactions: number;
+ transaction_bytes: number;
+ frame_bytes: number;
+ gas_per_second: number;
+ bytes_per_gas: number;
+ compression_ratio: number;
+ blobs_per_l1_slot: number;
+ blob_fill: number;
+ blob_target_gas_per_second: number;
+};
+// `target` and `withinTarget` come from the pipeline's DA step; the blob target is recorded, not gating.
+export type Da = { result: DaResult; target: number | null; withinTarget: boolean | null };
+
+// `storage-growth.json`: disk growth measured over the run, projected to a year at its gas rate.
+export type Storage = {
+ diskBudgetBytes: number;
+ // Proofs are null when the proofs disk usage could not be measured.
+ bytesPerGas: { history: number; proofs: number | null; archive: number };
+ current: { state: number };
+ archive: { growthBytesPerYear: number; growthBound: string; currentBytes: number | null; runwayYears: number | null; runwayBound: string };
+ fullNode: {
+ stateGrowthBytesPerYear: number;
+ historyWindowSeconds: number;
+ historyFootprintBytes: number;
+ historyFootprintBound: string;
+ proofWindowSeconds: number;
+ proofFootprintBytes: number;
+ };
+};
+
export type Run = {
+ id: string;
outputDir: string;
+ workload: Workload;
mgas: number;
gps: number;
+ tps: number;
+ // Share of submitted transactions that reverted; they still use gas.
+ reverted: number;
passed: boolean;
commit: string;
createdAt: number;
+ steps: PipelineStep[];
+ replay: Replay | null;
+ proofNode: (Replay & { proofs: Proofs | null }) | null;
+ da: Da | null;
+ storage: Storage | null;
+ // load-test-result.json's block_latency: transaction send to inclusion.
+ blockLatency: { p50Ms: number; p99Ms: number } | null;
};
+// Reverted transactions count toward gas/s but not toward transactions per second.
+export const successfulTps = (run: Run) => run.tps * (1 - run.reverted);
+
export type Proven = { mgas: number; atTop: boolean; run: Run };
export type Step = {
@@ -19,15 +88,29 @@ export type Step = {
compareUrl: string | null;
};
-export type ProvenCapacity = { current: Proven | null; history: Step[]; unknown: string[] };
+// `history` is the mainnet mix's; `da` is the latest counting run per workload in the window whose DA
+// step passed; `latestAt` is when the newest counting run was created. Replay, proof-node and DA results are loaded only for the runs in `current` and `da`:
+// replay files list every block, and every other run would download them for nothing.
+export type ProvenCapacity = {
+ current: Record;
+ da: Record;
+ history: Step[];
+ latestAt: number | null;
+ unknown: string[];
+};
const WINDOW_MS = 14 * 24 * 60 * 60 * 1000;
const PASS_RATIO = 0.95;
const CONCURRENCY = 6;
const SCENARIO = /^sustained-(\d+)mgas$/;
+const WORKLOADS = Object.keys(PAYLOADS) as Workload[];
-// Run outputs never change once written. `null` marks a run not built from main.
+// Run outputs never change once written. `null` marks a run that does not count: not built from
+// main, or written before the step pipeline.
const runCache = new Map();
+const detailCache = new Map>();
+
+export const runHref = (run: Run) => `/scaling/runs/bench/${encodeURIComponent(run.id)}`;
export async function loadProvenCapacity(
baseUrl: string,
@@ -49,16 +132,40 @@ export async function loadProvenCapacity(
unknown.push(meta.outputDir);
}
});
- return { current: provenAt(runs, now), history: history(runs, now), unknown };
+ const inWindow = (r: Run) => r.createdAt > now - WINDOW_MS && r.createdAt <= now;
+ const daPassed = (r: Run) => r.steps.some((s) => s.name === 'da' && s.status === 'passed');
+ const latestDa = (rs: Run[]) => rs.filter((r) => inWindow(r) && daPassed(r)).sort((a, b) => b.createdAt - a.createdAt)[0];
+ const of = (rs: Run[], w: Workload) => rs.filter((r) => r.workload === w);
+ const selected = new Set(WORKLOADS.flatMap((w) => [provenAt(of(runs, w), now)?.run, latestDa(of(runs, w))]).filter((r) => r !== undefined));
+ await forEachLimit([...selected], CONCURRENCY, async (run) => {
+ try {
+ if (!detailCache.has(run.outputDir)) detailCache.set(run.outputDir, await loadDetails(root, run, fetchImpl));
+ } catch {
+ unknown.push(run.outputDir);
+ }
+ });
+ const full = runs.map((r) => ({ ...r, ...detailCache.get(r.outputDir) }));
+ const daRun = (w: Workload) => {
+ const run = latestDa(of(full, w));
+ return run?.da ? run : null;
+ };
+ return {
+ current: Object.fromEntries(WORKLOADS.map((w) => [w, provenAt(of(full, w), now)])) as ProvenCapacity['current'],
+ da: Object.fromEntries(WORKLOADS.map((w) => [w, daRun(w)])) as ProvenCapacity['da'],
+ history: history(of(full, 'mix'), now),
+ latestAt: runs.length ? Math.max(...runs.map((r) => r.createdAt)) : null,
+ unknown,
+ };
}
function candidates({ runs }: BenchmarkRuns): BenchmarkRun[] {
+ const payloads: string[] = Object.values(PAYLOADS);
const byDir = new Map();
for (const run of runs) {
const c = run.testConfig;
if (
c.ChainId === 8453 &&
- c.TransactionPayload === 'mainnet-mix' &&
+ payloads.includes(String(c.TransactionPayload)) &&
SCENARIO.test(String(c.Scenario)) &&
!String(c.BenchmarkRun).endsWith('--latest')
) {
@@ -68,31 +175,65 @@ function candidates({ runs }: BenchmarkRuns): BenchmarkRun[] {
return [...byDir.values()];
}
+// A missing file comes back as the host's HTML fallback with a 200, so non-JSON means missing.
+async function getJson(fetchImpl: typeof fetch, url: string) {
+ const res = await fetchImpl(url);
+ if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
+ return res.json().catch(() => null);
+}
+
async function loadRun(root: string, meta: BenchmarkRun, fetchImpl: typeof fetch): Promise {
- const get = async (file: string) => {
- const res = await fetchImpl(`${root}${meta.outputDir}/${file}`);
- if (!res.ok) throw new Error(`Failed to fetch ${file}: ${res.status}`);
- return res;
- };
- const [validation, load, provenance] = await Promise.all([
- get('validation.json').then((r) => r.json()),
- get('load-test-result.json').then((r) => r.json()),
- // Only main-tracking runs write provenance.json; for others the host serves its HTML fallback with a 200.
- get('provenance.json').then((r) => r.json().catch(() => null)),
- ]);
- if (provenance?.source?.ref !== 'main') return null;
+ const get = (file: string) => getJson(fetchImpl, `${root}${meta.outputDir}/${file}`);
+ const [pipeline, provenance] = await Promise.all([get('pipeline-result.json'), get('provenance.json')]);
+ if (!pipeline || provenance?.source?.ref !== 'main') return null;
+ const [validation, load] = await Promise.all([get('validation.json'), get('load-test-result.json')]);
+ if (!validation || !load) throw new Error(`Missing results for ${meta.outputDir}`);
const mgas = Number(SCENARIO.exec(String(meta.testConfig.Scenario))![1]);
const gps: number = load.throughput.gps;
return {
+ id: String(meta.testConfig.BenchmarkRun),
outputDir: meta.outputDir,
+ workload: WORKLOADS.find((w) => PAYLOADS[w] === meta.testConfig.TransactionPayload)!,
mgas,
gps,
+ tps: load.throughput.tps,
+ reverted: load.throughput.total_submitted ? (load.throughput.total_reverted ?? 0) / load.throughput.total_submitted : 0,
+ blockLatency: load.block_latency?.p50 && load.block_latency?.p99 ? { p50Ms: durationMs(load.block_latency.p50), p99Ms: durationMs(load.block_latency.p99) } : null,
passed: validation.comparable === true && gps >= PASS_RATIO * mgas * 1e6,
commit: provenance.source.revision,
createdAt: Date.parse(meta.createdAt),
+ steps: pipeline.steps,
+ replay: null,
+ proofNode: null,
+ da: null,
+ storage: null,
};
}
+async function loadDetails(root: string, run: Run, fetchImpl: typeof fetch): Promise> {
+ const get = (file: string) => getJson(fetchImpl, `${root}${run.outputDir}/${file}`);
+ const [replay, proofNode, da, storage] = await Promise.all([
+ get('replay-result.json'),
+ get('proof-node/replay-result.json'),
+ get('da-result.json'),
+ // Newer than the other files and only shown as context, so its failure must not drop them.
+ get('storage-growth.json').catch(() => null),
+ ]);
+ const daOutputs = run.steps.find((s) => s.name === 'da')?.outputs;
+ return {
+ replay: replay && pickReplay(replay),
+ proofNode: proofNode && { ...pickReplay(proofNode), proofs: proofNode.proofs ?? null },
+ da: da && { result: da, target: (daOutputs?.target as number) ?? null, withinTarget: (daOutputs?.withinTarget as boolean) ?? null },
+ storage,
+ };
+}
+
+// Rust Durations serialize as { secs, nanos }.
+const durationMs = (d: { secs: number; nanos: number }) => d.secs * 1e3 + d.nanos / 1e6;
+
+// replay-result.json also lists every block's timings; keep only the summary.
+const pickReplay = ({ blocksPerSecond, newPayload, match }: Replay): Replay => ({ blocksPerSecond, newPayload, match });
+
function provenAt(runs: Run[], t: number): Proven | null {
const latest = new Map();
for (const run of runs) {
diff --git a/app/scaling/snapshot.mjs b/app/scaling/snapshot.mjs
index 531f036..a1ecefe 100644
--- a/app/scaling/snapshot.mjs
+++ b/app/scaling/snapshot.mjs
@@ -36,27 +36,37 @@
* projectId: string, issueId?: string, state: string, progress: number, scopeDone: number,
* scopeTotal: number, targetDate?: string, updatedAt: string, url: string
* }} LinearStatus
+ * @typedef {'DA' | 'Disk' | 'Execution' | 'Proving' | 'Latency'} WorkCategory
* @typedef {{
- * area: Area, title: string, status: string, owner: string | null, hypothesis: string, change: string,
- * expected: string | null, realized: string | null, evidence: Link | null, linear: LinearStatus | null,
- * links: Link[]
+ * title: string, status: string, category?: WorkCategory, effect?: string | null, links?: Link[],
+ * owner?: string | null, area?: Area, hypothesis?: string, change?: string,
+ * expected?: string | null, realized?: string | null, evidence?: Link | null, linear?: LinearStatus | null
* }} WorkItem
* @typedef {{
* schemaVersion: 2, network: string, collectedAt: string, context: string, windows: Record,
* ladder: Ladder, ceilings: Ceiling[], guardrails: Guardrail[],
* translations: { gasPerPayment: Constant | null, gasPerSwap: Constant | null },
+ * usage?: { transactionsPerSecond: Usage },
+ * policy?: { runwayYears?: { fullNode: number, archive: number, help?: string, source?: Link } },
+ * production?: {
+ * archiveBytes?: { value: number, asOf: string, source: string, note?: string },
+ * stateBytesPerGas?: { value: number, window?: string, source?: string }
+ * },
* sections: Section[], work: WorkItem[]
* }} Snapshot
+ * @typedef {{ value: number, window: string, help: string, source?: Link }} Usage
*/
const STATUSES = ['observed', 'partial', 'unavailable', 'not-measured'];
const TAGS = ['measured', 'modeled', 'estimated', 'not-measured', 'not-modeled', 'not-established'];
const VALUED_TAGS = ['measured', 'modeled', 'estimated'];
const AREAS = ['da', 'execution', 'state', 'networking'];
+// Work log statuses follow Linear's project states; impact categories are the page's limit names.
+export const WORK_STATUSES = ['in-progress', 'planned', 'done'];
+export const WORK_CATEGORIES = ['DA', 'Disk', 'Execution', 'Proving', 'Latency'];
// Each section renders on its area page, or on the guardrails page for outcomes.
const SECTION_IDS = ['da', 'execution', 'state', 'outcomes'];
const INPUT_STATUSES = ['observed', 'configured', 'provisional', 'pending'];
-const DAY_MS = 86_400_000;
// Captures a bench run id, or a load test's network and timestamp.
export const RUN_PAGE = /^https:\/\/[^/]+\/scaling\/runs\/(?:bench\/([^/?#]+)|load\/([^/?#]+)\/([^/?#]+))\/?(?:[?#].*)?$/;
const V1_MESSAGE =
@@ -213,6 +223,47 @@ export function parseSnapshot(data) {
windowRef(c, 'window', path);
}
+ // Optional: without it the page heads with gas per second instead of transactions per second.
+ if (data.usage !== undefined) {
+ const tps = obj(obj(data, 'usage', 'snapshot'), 'transactionsPerSecond', 'usage');
+ const path = 'usage.transactionsPerSecond';
+ if (!(Number.isFinite(tps.value) && tps.value >= 0)) fail(`${path}.value`, 'expected a non-negative number');
+ if (!Object.hasOwn(windows, tps.window)) fail(`${path}.window`, 'expected a key of windows');
+ str(tps, 'help', path);
+ if (tps.source !== undefined) link(tps.source, `${path}.source`);
+ }
+
+ // Optional disk inputs: without them the disk rows say which input they need instead of a ceiling.
+ const positive = (o, key, path) => {
+ if (!(Number.isFinite(o[key]) && o[key] > 0)) fail(`${path}.${key}`, 'expected a positive number');
+ };
+ if (data.policy !== undefined) {
+ const policy = obj(data, 'policy', 'snapshot');
+ if (policy.runwayYears !== undefined) {
+ const runway = obj(policy, 'runwayYears', 'policy');
+ positive(runway, 'fullNode', 'policy.runwayYears');
+ positive(runway, 'archive', 'policy.runwayYears');
+ if (runway.help !== undefined) str(runway, 'help', 'policy.runwayYears');
+ if (runway.source !== undefined) link(runway.source, 'policy.runwayYears.source');
+ }
+ }
+ if (data.production !== undefined) {
+ const production = obj(data, 'production', 'snapshot');
+ if (production.archiveBytes !== undefined) {
+ const archive = obj(production, 'archiveBytes', 'production');
+ positive(archive, 'value', 'production.archiveBytes');
+ date(archive, 'asOf', 'production.archiveBytes');
+ str(archive, 'source', 'production.archiveBytes');
+ if (archive.note !== undefined) str(archive, 'note', 'production.archiveBytes');
+ }
+ if (production.stateBytesPerGas !== undefined) {
+ const state = obj(production, 'stateBytesPerGas', 'production');
+ positive(state, 'value', 'production.stateBytesPerGas');
+ if (state.window !== undefined && !Object.hasOwn(windows, state.window)) fail('production.stateBytesPerGas.window', 'expected a key of windows');
+ if (state.source !== undefined) str(state, 'source', 'production.stateBytesPerGas');
+ }
+ }
+
const metric = (m, path) => {
if (!isObj(m)) return fail(path, 'expected object');
str(m, 'label', path);
@@ -250,16 +301,21 @@ export function parseSnapshot(data) {
date(l, 'updatedAt', path);
if (typeof l.url !== 'string' || !l.url.startsWith('https://')) fail(`${path}.url`, 'expected https URL');
};
+ // Work log entries need a title and a status; the page shows only WORK_STATUSES. Older entries keep their
+ // area, hypothesis, change, expected, realized, evidence and linear fields, now optional but still checked.
list(data, 'work', 'snapshot').forEach((w, i) => {
const path = `work[${i}]`;
if (!isObj(w)) return fail(path, 'expected object');
- oneOf(w, 'area', path, AREAS);
- for (const key of ['title', 'status', 'hypothesis', 'change']) str(w, key, path);
- for (const key of ['owner', 'expected', 'realized']) nullableStr(w, key, path);
- if (w.evidence !== null) link(w.evidence, `${path}.evidence`);
- else if (w.realized !== null) fail(`${path}.evidence`, 'required when realized is set');
- if (w.linear !== null) linear(w.linear, `${path}.linear`);
- list(w, 'links', path).forEach((l, j) => link(l, `${path}.links[${j}]`));
+ for (const key of ['title', 'status']) str(w, key, path);
+ if (w.area !== undefined) oneOf(w, 'area', path, AREAS);
+ if (w.category !== undefined) oneOf(w, 'category', path, WORK_CATEGORIES);
+ if (w.area === undefined && w.category === undefined) fail(`${path}.category`, 'required without area');
+ for (const key of ['hypothesis', 'change']) if (w[key] !== undefined) str(w, key, path);
+ for (const key of ['owner', 'expected', 'realized', 'effect']) if (w[key] !== undefined) nullableStr(w, key, path);
+ if (w.evidence != null) link(w.evidence, `${path}.evidence`);
+ else if (w.realized != null) fail(`${path}.evidence`, 'required when realized is set');
+ if (w.linear != null) linear(w.linear, `${path}.linear`);
+ if (w.links !== undefined) list(w, 'links', path).forEach((l, j) => link(l, `${path}.links[${j}]`));
});
return errors.length ? { ok: false, errors } : { ok: true, snapshot: data };
@@ -278,12 +334,3 @@ export function deriveBinding(ceilings) {
}
return binding;
}
-
-/**
- * @param {string} collectedAt
- * @param {Date} now
- * @param {number} [days]
- */
-export function isStale(collectedAt, now, days = 10) {
- return now.getTime() - Date.parse(collectedAt) > days * DAY_MS;
-}
diff --git a/app/scaling/snapshot.test.ts b/app/scaling/snapshot.test.ts
index f446d15..5d8bff2 100644
--- a/app/scaling/snapshot.test.ts
+++ b/app/scaling/snapshot.test.ts
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
-import { deriveBinding, isStale, parseSnapshot } from './snapshot.mjs';
+import { deriveBinding, parseSnapshot } from './snapshot.mjs';
// Synthetic data only: real snapshots are internal and never committed.
function fixture() {
@@ -24,6 +24,36 @@ describe('parseSnapshot', () => {
expect(result.snapshot.work[1].linear).toBeNull();
});
+ it('treats usage as optional, and checks its value, window and help when present', () => {
+ const { usage, ...withoutUsage } = fixture();
+ expect(errorsOf(withoutUsage)).toEqual([]);
+ const bad = { ...withoutUsage, usage: { transactionsPerSecond: { ...usage.transactionsPerSecond, value: -1, window: 'year', help: '' } } };
+ expect(errorsOf(bad)).toEqual([
+ 'usage.transactionsPerSecond.value: expected a non-negative number',
+ 'usage.transactionsPerSecond.window: expected a key of windows',
+ 'usage.transactionsPerSecond.help: expected non-empty string',
+ ]);
+ });
+
+ it('treats the disk policy and production inputs as optional, and checks them when present', () => {
+ const { policy, production, ...without } = fixture();
+ expect(errorsOf(without)).toEqual([]);
+ const bad = {
+ ...without,
+ policy: { runwayYears: { ...policy.runwayYears, fullNode: 0 } },
+ production: {
+ archiveBytes: { ...production.archiveBytes, asOf: 'soon', source: '' },
+ stateBytesPerGas: { ...production.stateBytesPerGas, window: 'decade' },
+ },
+ };
+ expect(errorsOf(bad)).toEqual([
+ 'policy.runwayYears.fullNode: expected a positive number',
+ 'production.archiveBytes.asOf: expected ISO timestamp',
+ 'production.archiveBytes.source: expected non-empty string',
+ 'production.stateBytesPerGas.window: expected a key of windows',
+ ]);
+ });
+
it('rejects schema version 1 and names the migration', () => {
const errors = errorsOf({ ...fixture(), schemaVersion: 1 });
expect(errors).toHaveLength(1);
@@ -60,6 +90,23 @@ describe('parseSnapshot', () => {
]);
});
+ it('accepts a work log entry with only title, status, category, effect, links and owner, and checks each', () => {
+ const data = fixture();
+ data.work.push({ title: 'Synthetic planned work', status: 'planned', category: 'Execution', effect: null, links: [{ label: 'Linear', url: 'https://example.com/linear' }], owner: 'Synthetic owner' });
+ expect(errorsOf(data)).toEqual([]);
+ data.work.push(
+ { title: 'Bad category', status: 'planned', category: 'Gas' },
+ { title: 'No category', status: 'planned' },
+ { title: 'Bad link', status: 'planned', category: 'DA', effect: 3, links: [{ label: 'Doc', url: 'http://example.com' }] },
+ );
+ expect(errorsOf(data)).toEqual([
+ 'work[4].category: expected one of DA, Disk, Execution, Proving, Latency',
+ 'work[5].category: required without area',
+ 'work[6].effect: expected string or null',
+ 'work[6].links[0].url: expected https URL',
+ ]);
+ });
+
it('rejects a missing ceiling area and a duplicate guardrail id', () => {
const data = fixture();
data.ceilings.pop();
@@ -144,13 +191,3 @@ describe('deriveBinding', () => {
expect(deriveBinding(ceilings)).toBeNull();
});
});
-
-describe('isStale', () => {
- const collectedAt = fixture().collectedAt;
-
- it('flags snapshots older than the threshold', () => {
- expect(isStale(collectedAt, new Date('2026-01-13T00:00:00Z'))).toBe(true);
- expect(isStale(collectedAt, new Date('2026-01-11T00:00:00Z'))).toBe(false);
- expect(isStale(collectedAt, new Date('2026-01-13T00:00:00Z'), 20)).toBe(false);
- });
-});