diff --git a/README.md b/README.md index 1811ec3..67d82c5 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,10 @@ to run the **internal** build locally with those sections visible. See - **typecheck** — `tsc --noEmit` - **lint** — eslint -- **test** — vitest +- **test** — vitest. Includes `app/snapshots/networks.contract.test.ts`, which + asserts every expected network stays served by `/api/snapshots`. Nodes sync + from those buckets, so a network must never be dropped just to take it off the + page — hide it with `hiddenFromUi` instead. - **docs (generated agent index)** — fails if the committed `public/llms.txt`, `llms-full.txt`, or `AGENTS.md` are stale relative to the route tree. Fix with `npm run llms && npm run agents`. diff --git a/app/snapshots/data.ts b/app/snapshots/data.ts index 67e29a1..54e6302 100644 --- a/app/snapshots/data.ts +++ b/app/snapshots/data.ts @@ -11,7 +11,7 @@ export type SnapshotComponent = { export type Snapshot = { chainId: string; chainName: string; - network: string; // "mainnet" | "sepolia" + network: string; // "mainnet" | "sepolia" | "zeronet" block: number; timestamp: string; date: string; @@ -74,6 +74,7 @@ export const PRESETS: Preset[] = [ export const CHAIN_NAME_BY_NETWORK: Record = { mainnet: 'base', sepolia: 'base-sepolia', + zeronet: 'base-zeronet', }; export function formatBytes(bytes: number): string { @@ -175,4 +176,16 @@ export const SAMPLE_SNAPSHOTS: Snapshot[] = [ storage_changesets: 90, rocksdb_indices: 35, }), + // Hidden from the page (see isNetworkVisibleInUi) but still served by the API, + // so the dev fallback mirrors what /api/snapshots returns. + sampleSnapshot('zeronet', 'Base Zeronet', '84530', 512000, { + state: 12, + headers: 1, + transactions: 3, + transaction_senders: 1, + receipts: 4, + account_changesets: 2, + storage_changesets: 5, + rocksdb_indices: 2, + }), ]; diff --git a/app/snapshots/networks.contract.test.ts b/app/snapshots/networks.contract.test.ts new file mode 100644 index 0000000..42b4ff0 --- /dev/null +++ b/app/snapshots/networks.contract.test.ts @@ -0,0 +1,82 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { isNetworkVisibleInUi, NETWORK_CONFIGS, NETWORK_IDS } from './r2'; + +/** + * Contract for the snapshots API surface. + * + * The regression this exists to prevent: #14 "Cobalt and fixes" wanted zeronet + * off the snapshots *page* and deleted its chain descriptor to get there. That + * descriptor is also what /api/snapshots enumerates, so ?network=zeronet started + * answering 400 and zeronet nodes could no longer sync from a snapshot. The page + * looked right, nothing failed, and it went unnoticed. + * + * Every network listed here must keep being served. Hiding one from the UI is a + * `hiddenFromUi` flag, never a deletion. + * + * Adding a network? Add it to EXPECTED_NETWORKS in the same change. Removing one + * is intentional and rare: delete it here too, and make sure no node still syncs + * from that bucket first. + */ +const EXPECTED_NETWORKS = ['mainnet', 'sepolia', 'zeronet'] as const; + +/** Networks deliberately absent from the snapshots page but still served. */ +const EXPECTED_HIDDEN_FROM_UI = ['zeronet'] as const; + +describe('snapshots API network contract', () => { + it.each(EXPECTED_NETWORKS)('serves %s from the API', (id) => { + expect(NETWORK_IDS).toContain(id); + }); + + it('serves exactly the expected networks — no silent additions or removals', () => { + expect([...NETWORK_IDS].sort()).toEqual([...EXPECTED_NETWORKS].sort()); + }); + + it.each(EXPECTED_HIDDEN_FROM_UI)('hides %s from the page but keeps serving it', (id) => { + expect(NETWORK_IDS).toContain(id); + expect(isNetworkVisibleInUi(id)).toBe(false); + }); + + it('leaves every other network visible', () => { + const hidden = new Set(EXPECTED_HIDDEN_FROM_UI); + for (const id of NETWORK_IDS) { + if (!hidden.has(id)) expect(isNetworkVisibleInUi(id)).toBe(true); + } + }); + + it('gives every network the config the loader needs', () => { + for (const network of NETWORK_CONFIGS) { + expect(network.chainName, `${network.id} chainName`).toBeTruthy(); + expect(network.bucket, `${network.id} bucket`).toBeTruthy(); + expect(network.envPrefix, `${network.id} envPrefix`).toBeTruthy(); + expect(network.publicBaseUrl, `${network.id} publicBaseUrl`).toMatch(/^https:\/\//); + } + }); + + it('keeps ids, buckets, and env prefixes unique', () => { + for (const key of ['id', 'bucket', 'envPrefix'] as const) { + const values = NETWORK_CONFIGS.map((n) => n[key]); + expect(new Set(values).size, `duplicate ${key}`).toBe(values.length); + } + }); + + // loadSnapshots throws if ANY network fails, so a network whose R2 credentials + // were never provisioned 502s the whole endpoint — every network with it. This + // catches the config half of that at PR time; the credentials themselves live + // in Vercel and cannot be checked from here. + it('documents every network\'s R2 credentials in .env.example', () => { + const envExample = fs.readFileSync( + path.join(process.cwd(), '.env.example'), + 'utf8', + ); + for (const network of NETWORK_CONFIGS) { + expect( + envExample.includes(`${network.envPrefix}_`), + `${network.id}: .env.example never mentions ${network.envPrefix}_*, so whoever ` + + `deploys this has no signal that its R2 credentials must be set. Without them ` + + `/api/snapshots returns 502 for every network, not just this one.`, + ).toBe(true); + } + }); +}); diff --git a/app/snapshots/page.tsx b/app/snapshots/page.tsx index d44ac60..4f8c5b8 100644 --- a/app/snapshots/page.tsx +++ b/app/snapshots/page.tsx @@ -1,7 +1,7 @@ import { EmptyState } from '../components/ui/EmptyState'; import { SAMPLE_SNAPSHOTS, Snapshot } from './data'; -import { getSnapshots } from './r2'; +import { getSnapshots, isNetworkVisibleInUi } from './r2'; import { SnapshotsClient } from './SnapshotsClient'; // Statically rendered and revalidated, matching app/upgrades/page.tsx. This keeps the @@ -13,7 +13,11 @@ import { SnapshotsClient } from './SnapshotsClient'; export const revalidate = 300; export default async function SnapshotsPage() { - const snapshots = await loadSnapshots(); + // Filtered at the render boundary, not in the data layer: /api/snapshots keeps + // serving every network so nodes can sync from buckets we don't advertise here. + const snapshots = (await loadSnapshots()).filter((snapshot) => + isNetworkVisibleInUi(snapshot.network), + ); if (snapshots.length === 0) { return ( diff --git a/app/snapshots/r2.test.ts b/app/snapshots/r2.test.ts index 3cd6777..66ee94e 100644 --- a/app/snapshots/r2.test.ts +++ b/app/snapshots/r2.test.ts @@ -1,4 +1,26 @@ -import { decodeXml } from './r2'; +import { decodeXml, isNetworkVisibleInUi, NETWORK_IDS } from './r2'; + +describe('network visibility', () => { + // Zeronet was removed outright in "Cobalt and fixes" (#14) to hide it from the + // page, which also stopped the API serving it — so zeronet nodes could no + // longer sync from a snapshot. It must stay served and merely unlisted. + it('serves zeronet from the API', () => { + expect(NETWORK_IDS).toContain('zeronet'); + }); + + it('hides zeronet from the snapshots page', () => { + expect(isNetworkVisibleInUi('zeronet')).toBe(false); + }); + + it('keeps the public networks visible', () => { + expect(isNetworkVisibleInUi('mainnet')).toBe(true); + expect(isNetworkVisibleInUi('sepolia')).toBe(true); + }); + + it('treats an unknown network as visible, so a new network is not hidden by accident', () => { + expect(isNetworkVisibleInUi('some-future-net')).toBe(true); + }); +}); describe('decodeXml', () => { it('decodes the XML entities R2 listings use', () => { diff --git a/app/snapshots/r2.ts b/app/snapshots/r2.ts index dbfcc6e..2828b95 100644 --- a/app/snapshots/r2.ts +++ b/app/snapshots/r2.ts @@ -17,6 +17,12 @@ type NetworkConfig = { bucket: string; publicBaseUrl: string; envPrefix: string; + /** + * Served by the API but omitted from the snapshots page. For a network that + * nodes still sync from while we don't want to advertise it publicly — + * visibility only, never a reason to stop serving the data. + */ + hiddenFromUi?: boolean; }; type R2Config = { @@ -69,6 +75,14 @@ const NETWORKS: NetworkConfig[] = [ publicBaseUrl: 'https://sepolia-v2-snapshots.base.org', envPrefix: 'BASE_SEPOLIA', }, + { + id: 'zeronet', + chainName: 'Base Zeronet', + bucket: 'base-zeronet-reth-v2-snapshots', + publicBaseUrl: 'https://zeronet-v2-snapshots.base.org', + envPrefix: 'BASE_ZERONET', + hiddenFromUi: true, + }, ]; export type SnapshotLoadFailure = { network: string; error: string }; @@ -78,6 +92,25 @@ export const SNAPSHOT_CACHE_SECONDS = 300; export const NETWORK_IDS = NETWORKS.map((network) => network.id); +/** + * Read-only view of the configured networks. Exported so the API contract test + * can assert every network stays served — see networks.contract.test.ts. + */ +export const NETWORK_CONFIGS: readonly Readonly[] = NETWORKS; + +const UI_HIDDEN_NETWORK_IDS = new Set( + NETWORKS.filter((network) => network.hiddenFromUi).map((network) => network.id), +); + +/** + * Whether a network should be listed on the snapshots page. The API serves every + * network in NETWORKS regardless — nodes sync from buckets we don't advertise — + * so filter with this at the render boundary, never in the data layer. + */ +export function isNetworkVisibleInUi(networkId: string): boolean { + return !UI_HIDDEN_NETWORK_IDS.has(networkId); +} + /** Thrown when any configured network failed, carrying the per-network detail. */ export class SnapshotLoadError extends Error { constructor(readonly failures: SnapshotLoadFailure[]) {