Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
1713437
feat(scaling): build the scorecard from benchmark pipeline results
0x00101010 Oct 27, 2026
c6f418a
perf(scaling): load replay and DA files only for the runs the page shows
0x00101010 Oct 27, 2026
1bbb9d5
feat(scaling): show only the latest proven-capacity change instead of…
0x00101010 Oct 27, 2026
d2646f7
feat(scaling): lay out the scorecard as headline, ladder, bottlenecks…
0x00101010 Oct 27, 2026
276580b
feat(scaling): judge proof-node budgets and label usage dates instead…
0x00101010 Oct 27, 2026
71fcbd7
test(scaling): strip tags until none remain in the scorecard test helper
0x00101010 Oct 27, 2026
60617d3
feat(scaling): shade the ladder in two tones and retitle the page
0x00101010 Oct 27, 2026
cab4429
feat(scaling): lead with transactions per second and proven payments …
0x00101010 Oct 27, 2026
6127ed2
feat(scaling): head with proven Mgas/s beside payments and swaps, and…
0x00101010 Oct 27, 2026
99ae98a
feat(scaling): rank bottlenecks, card workloads and compress guardrai…
0x00101010 Oct 27, 2026
b9eda2e
fix(scaling): scroll the ranked limits table on narrow screens instea…
0x00101010 Oct 27, 2026
023d898
feat(scaling): count successful tx/s, show measured swaps DA and disk…
0x00101010 Oct 27, 2026
7566a98
fix(scaling): keep run details when storage growth fails, and flag an…
0x00101010 Oct 27, 2026
09401cd
feat(scaling): head with workload tiles in transactions and move subs…
0x00101010 Oct 27, 2026
3f9c6f4
feat(scaling): rank full-node and archive disk ceilings from runway p…
0x00101010 Oct 27, 2026
abe1116
feat(scaling): show headroom to the first limit for today's traffic o…
0x00101010 Oct 27, 2026
a3e5163
feat(scaling): add mainnet state growth to the archive ceiling and sh…
0x00101010 Oct 27, 2026
74e36c6
feat(scaling): drop the header callout and disk note, and move the la…
0x00101010 Oct 27, 2026
d743b0f
feat(scaling): mark every ranked breaking point on the ladder with la…
0x00101010 Oct 27, 2026
e23ccc6
feat(scaling): state ceilings with units and plain evidence, move det…
0x00101010 Oct 27, 2026
1f7e61e
test(scaling): find the swaps row by label now that DA rows carry help
0x00101010 Oct 27, 2026
956e69f
feat(scaling): show subsystems by workload as one small table per cat…
0x00101010 Oct 27, 2026
59e5823
feat(scaling): take the mainnet archive size, proofs included, from c…
0x00101010 Oct 27, 2026
8f4210c
feat(scaling): rank the mix by the bench mix run's DA, like payments …
0x00101010 Oct 27, 2026
c9752ef
feat(scaling): leave guardrails off the first screen for now, keeping…
0x00101010 Oct 27, 2026
9e4a309
feat(scaling): label the mix DA tick DA · mix (bench), matching the r…
0x00101010 Oct 27, 2026
f41314a
feat(scaling): show tx/s on every ceiling, tagging only estimates
0x00101010 Oct 27, 2026
e8c7c0b
fix(scaling): say when there is no gas limit to compare DA against, a…
0x00101010 Oct 27, 2026
c2b5ed7
feat(scaling): replace work by limit with a work log of in-progress a…
0x00101010 Oct 28, 2026
1e1d13a
feat(scaling): show live Linear status in the work log when LINEAR_AP…
0x00101010 Oct 28, 2026
a2d1d4c
fix(scaling): show 0% progress on live Linear rows too
0x00101010 Oct 28, 2026
4c406c3
revert(scaling): drop live Linear status from the work log, keeping t…
0x00101010 Oct 28, 2026
b86499d
fix(scaling): take disk growth from the workload with the heaviest tr…
0x00101010 Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/scaling/[area]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ export default async function ScalingAreaPage({ params }: { params: Promise<{ ar
const ceilings = withDaWorkloads(snapshot.ceilings);
return (
<div className="flex flex-col gap-10 pb-8">
<Header loaded={loaded} title={`${entry[1]} · ${snapshot.network}`} crumb={entry[1]} />
<Header title={`${entry[1]} · ${snapshot.network}`} crumb={entry[1]} />
<section className="flex flex-col gap-3">
<Text as="h2" variant="headline">Ceiling <span className="text-bds-gray-60">· Mgas/s</span></Text>
<RunBackedCeilingCard area={entry[0]} ceilings={ceilings} />
<RunBackedCeilingCard area={entry[0]} ceilings={ceilings} benchUrl={process.env.SCALING_BENCH_BASE_URL} />
</section>
{area === 'da' ? <DaWorkloads observedP50={snapshot.ladder.observed.p50.value} /> : null}
{area === 'da' ? <DaWorkloads observedP50={snapshot.ladder.observed.p50.value} benchUrl={process.env.SCALING_BENCH_BASE_URL} /> : null}
{section ? (
<SectionTable section={section} windows={snapshot.windows} />
) : (
Expand Down
66 changes: 66 additions & 0 deletions app/scaling/archiveSize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { readFileSync } from 'node:fs';

import { afterEach, expect, it, vi } from 'vitest';

import { ARCHIVE_SNAPSHOTS_URL, fetchArchiveSize, loadArchiveSize, parseArchiveSize, withArchiveSize } from './archiveSize';
import type { Snapshot } from './snapshot.mjs';

// Synthetic sizes in the chain.base.org shape, with extra fields the parser must ignore.
const ENTRIES = [
{ network: 'sepolia', profile: 'archive', block: 9, date: '2026-01-01', size: 1e12, components: [] },
{
chainId: '8453', network: 'mainnet', profile: 'archive', block: 1_234_567, timestamp: '1767225600', date: '2026-01-01', size: 6e12, isModular: true,
components: [{ name: 'state', displayName: 'State', size: 1e12 }, { name: 'proofs', size: 1.5e12 }, { name: 'receipts', size: 2e12, fullSize: 1e11 }],
},
];
const ARCHIVE = { size: 6e12, sizeWithoutProofs: 4.5e12, block: 1_234_567, date: '2026-01-01', source: 'chain.base.org' };
const fixture = (network: string): Snapshot => ({ ...JSON.parse(readFileSync(new URL('./fixtures/synthetic.v2.json', import.meta.url), 'utf8')), network });

afterEach(() => vi.restoreAllMocks());

it('reads the mainnet archive entry, total including proofs and without them', () => {
expect(parseArchiveSize(ENTRIES)).toEqual(ARCHIVE);
});

it('rejects a response that is not the expected shape', () => {
const mainnet = ENTRIES[1];
for (const bad of [
{},
[ENTRIES[0]],
[{ ...mainnet, size: '6e12' }],
[{ ...mainnet, size: 0 }],
[{ ...mainnet, block: 1.5 }],
[{ ...mainnet, date: 'yesterday' }],
[{ ...mainnet, date: '2026-02-30' }],
[{ ...mainnet, components: null }],
[{ ...mainnet, components: [{ name: 'proofs', size: -1 }] }],
[{ ...mainnet, components: [{ name: 'proofs', size: 7e12 }] }],
]) expect(parseArchiveSize(bad)).toBeNull();
});

it('fetches with a timeout and throws on a failed or malformed response', async () => {
const fetchImpl = vi.fn(async () => Response.json(ENTRIES));
expect(await fetchArchiveSize(fetchImpl as unknown as typeof fetch)).toEqual(ARCHIVE);
expect(fetchImpl).toHaveBeenCalledWith(ARCHIVE_SNAPSHOTS_URL, { signal: expect.any(AbortSignal) });
await expect(fetchArchiveSize((async () => new Response('down', { status: 502 })) as typeof fetch)).rejects.toThrow('502');
await expect(fetchArchiveSize((async () => Response.json([{ network: 'mainnet' }])) as typeof fetch)).rejects.toThrow('unexpected response shape');
});

it('falls back to null on a timeout, logging why', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const timeout = () => Promise.reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'));
expect(await loadArchiveSize(timeout)).toBeNull();
expect(warn).toHaveBeenCalledOnce();
expect(await loadArchiveSize(async () => ARCHIVE as never)).toEqual(ARCHIVE);
});

it('replaces a mainnet snapshot’s archive size and leaves others or a failed fetch alone', () => {
const mainnet = fixture('Base mainnet');
expect(withArchiveSize(mainnet, ARCHIVE as never).production!.archiveBytes).toEqual({
value: 6e12, asOf: '2026-01-01', source: 'chain.base.org snapshot, block 1,234,567', note: 'incl. proofs',
});
expect(withArchiveSize(mainnet, ARCHIVE as never).production!.stateBytesPerGas).toEqual(mainnet.production!.stateBytesPerGas);
expect(withArchiveSize(mainnet, null)).toBe(mainnet);
const synthetic = fixture('synthetic-testnet');
expect(withArchiveSize(synthetic, ARCHIVE as never)).toBe(synthetic);
});
63 changes: 63 additions & 0 deletions app/scaling/archiveSize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { unstable_cache } from 'next/cache';

import type { Snapshot } from './snapshot.mjs';

// chain.base.org lists the latest node snapshots publicly. It sends no CORS header, so the server fetches it.
export const ARCHIVE_SNAPSHOTS_URL = 'https://chain.base.org/api/snapshots';
const TIMEOUT_MS = 5_000;
const REVALIDATE_SECONDS = 3_600;

// `size` is the whole archive snapshot, proofs included: production nodes run archive with proofs.
export type ArchiveSize = { size: number; sizeWithoutProofs: number; block: number; date: string; source: 'chain.base.org' };

const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
const isDay = (date: string) => !Number.isNaN(Date.parse(date)) && new Date(date).toISOString().slice(0, 10) === date;
const isSize = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0;

// The mainnet archive entry, checked field by field; extra fields are ignored. Null when the shape is off.
export function parseArchiveSize(json: unknown): ArchiveSize | null {
if (!Array.isArray(json)) return null;
const entry = json.find((e) => isObj(e) && e.network === 'mainnet' && e.profile === 'archive');
if (!isObj(entry)) return null;
const { size, block, date, components } = entry;
if (!isSize(size) || size === 0 || !Number.isSafeInteger(block) || (block as number) <= 0) return null;
// A real calendar day: 2026-02-30 matches the pattern but is not one.
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date) || !isDay(date) || !Array.isArray(components)) return null;
if (!components.every((c) => isObj(c) && typeof c.name === 'string' && isSize(c.size))) return null;
const proofs = (components as { name: string; size: number }[]).find((c) => c.name === 'proofs')?.size ?? 0;
if (proofs > size) return null;
return { size, sizeWithoutProofs: size - proofs, block: block as number, date, source: 'chain.base.org' };
}

// Throws on any failure so a failed fetch is not cached.
export async function fetchArchiveSize(fetchImpl: typeof fetch = fetch): Promise<ArchiveSize> {
const res = await fetchImpl(ARCHIVE_SNAPSHOTS_URL, { signal: AbortSignal.timeout(TIMEOUT_MS) });
if (!res.ok) throw new Error(`${ARCHIVE_SNAPSHOTS_URL}: ${res.status}`);
const archive = parseArchiveSize(await res.json());
if (!archive) throw new Error(`${ARCHIVE_SNAPSHOTS_URL}: unexpected response shape`);
return archive;
}

const fetchArchiveSizeCached = unstable_cache(() => fetchArchiveSize(), ['scaling-archive-size'], { revalidate: REVALIDATE_SECONDS });

// Optional context: on failure the page keeps the snapshot's archive size.
export async function loadArchiveSize(load: () => Promise<ArchiveSize> = fetchArchiveSizeCached): Promise<ArchiveSize | null> {
try {
return await load();
} catch (error) {
console.warn('[scaling] archive size from chain.base.org unavailable, using the snapshot value:', error);
return null;
}
}

// Only mainnet snapshots take the live mainnet archive size; testnet and synthetic ones keep their own.
export function withArchiveSize(snapshot: Snapshot, archive: ArchiveSize | null): Snapshot {
if (!archive || !/\bmainnet\b/i.test(snapshot.network)) return snapshot;
return {
...snapshot,
production: {
...snapshot.production,
archiveBytes: { value: archive.size, asOf: archive.date, source: `chain.base.org snapshot, block ${archive.block.toLocaleString('en-US')}`, note: 'incl. proofs' },
},
};
}
Loading
Loading