From 9bce3c7349c142de92b399e8d1c45f2480bc9176 Mon Sep 17 00:00:00 2001 From: Montana Wong Date: Wed, 5 Aug 2026 10:04:04 -0400 Subject: [PATCH 1/3] fix(snapshots): serve zeronet from the API again, hide it only in the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Cobalt and fixes" (#14) removed zeronet to take it off the snapshots page, but it deleted the chain descriptor from app/snapshots/r2.ts as well. That also stopped /api/snapshots serving it — ?network=zeronet has been answering 400 Unknown network — so zeronet nodes can no longer sync from a snapshot. Restore the descriptor and make visibility a separate, explicit concern: - NetworkConfig gains `hiddenFromUi`, set on zeronet. The data layer keeps serving every network; only the page filters. - isNetworkVisibleInUi() is applied in app/snapshots/page.tsx, at the render boundary, so hidden networks never reach the client. - Sample data carries zeronet again, so the dev fallback mirrors what the API returns and the filter is exercised locally. - Tests cover both halves: zeronet is in NETWORK_IDS but not visible in the UI, and an unrecognized network defaults to visible so a future network is not hidden by accident. Deployment prerequisite: BASE_ZERONET_R2_ACCESS_KEY_ID / _SECRET_ACCESS_KEY must be set in Vercel before this merges. loadSnapshots throws if any network fails, so an unconfigured zeronet would 502 the whole endpoint, mainnet and sepolia included. --- app/snapshots/data.ts | 15 ++++++++++++++- app/snapshots/page.tsx | 8 ++++++-- app/snapshots/r2.test.ts | 24 +++++++++++++++++++++++- app/snapshots/r2.ts | 27 +++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 4 deletions(-) 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/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..83b9f37 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,19 @@ export const SNAPSHOT_CACHE_SECONDS = 300; export const NETWORK_IDS = NETWORKS.map((network) => network.id); +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[]) { From aa3bb64b1db279caeb5e1b4654bdf29c181f9f70 Mon Sep 17 00:00:00 2001 From: Montana Wong Date: Wed, 5 Aug 2026 10:18:31 -0400 Subject: [PATCH 2/3] ci: add a snapshots API contract check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing caught #14 dropping zeronet from /api/snapshots. The page looked right, every test passed, and the breakage only surfaced when zeronet nodes could not sync from a snapshot. Adds app/snapshots/networks.contract.test.ts, asserting the API surface rather than the rendered page: every expected network is served, hiding one from the UI is a `hiddenFromUi` flag rather than a deletion, each network carries the config the loader needs, and ids/buckets/env prefixes stay unique. Verified it has teeth by replaying #14's deletion — three assertions fail with messages naming the missing network. It also asserts each network's R2 env prefix appears in .env.example. Because loadSnapshots throws when any network fails, a network added without documented credentials 502s the whole endpoint, so this catches at PR time the config half of a failure that otherwise only shows up in production. Runs as its own `snapshots API contract` check so it can be required in branch protection and is legible in the PR list. NETWORK_CONFIGS is exported read-only for the test. --- .github/workflows/ci.yml | 23 +++++++ README.md | 4 ++ app/snapshots/networks.contract.test.ts | 82 +++++++++++++++++++++++++ app/snapshots/r2.ts | 6 ++ 4 files changed, 115 insertions(+) create mode 100644 app/snapshots/networks.contract.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43037ff..35f9869 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,29 @@ jobs: - run: npm ci - run: npm test + # Guards the snapshots API surface. #14 hid zeronet from the page by deleting + # its chain descriptor, which also stopped /api/snapshots serving it — zeronet + # nodes silently lost the ability to sync from a snapshot, and no check caught + # it. This asserts every expected network stays served and that hiding one from + # the UI is a flag rather than a deletion. Runs as its own check so it can be + # required in branch protection and is obvious in the PR list. + snapshots-api-contract: + name: snapshots API contract + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npx vitest run app/snapshots/networks.contract.test.ts + # public/llms.txt, llms-full.txt, and AGENTS.md are generated from the route # tree and committed. They go stale whenever a route is added, renamed, or # removed, so this fails if they differ from a fresh generation (it also diff --git a/README.md b/README.md index 1811ec3..579a716 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ to run the **internal** build locally with those sections visible. See - **public build excludes internal-only surfaces** — builds the default (external) target and asserts that internal-only routes 404 and never appear in the nav or sitemap, so the deployment matrix can't silently regress +- **snapshots API contract** — asserts every expected network stays served by + `/api/snapshots`. Nodes sync from these buckets, so a network must never be + dropped just to take it off the page — hide it with `hiddenFromUi` instead + (see `app/snapshots/networks.contract.test.ts`) CodeQL, StepSecurity, Heimdall, and the Vercel preview build are configured outside this repo at the org/platform level. 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/r2.ts b/app/snapshots/r2.ts index 83b9f37..2828b95 100644 --- a/app/snapshots/r2.ts +++ b/app/snapshots/r2.ts @@ -92,6 +92,12 @@ 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), ); From 28f6b00fa9a6901cf231efaf1684ec7e4dacd8af Mon Sep 17 00:00:00 2001 From: Montana Wong Date: Wed, 5 Aug 2026 10:24:18 -0400 Subject: [PATCH 3/3] ci: drop the redundant snapshots-api-contract job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract test already runs in the `test` job — the separate job re-ran the same nine assertions behind a second npm ci for no added enforcement. `test` is the gate, and a failure there names the assertion and the missing network. Keeps the test itself; documents the invariant under `test` in the README, where it actually runs. --- .github/workflows/ci.yml | 23 ----------------------- README.md | 9 ++++----- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35f9869..43037ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,29 +63,6 @@ jobs: - run: npm ci - run: npm test - # Guards the snapshots API surface. #14 hid zeronet from the page by deleting - # its chain descriptor, which also stopped /api/snapshots serving it — zeronet - # nodes silently lost the ability to sync from a snapshot, and no check caught - # it. This asserts every expected network stays served and that hiding one from - # the UI is a flag rather than a deletion. Runs as its own check so it can be - # required in branch protection and is obvious in the PR list. - snapshots-api-contract: - name: snapshots API contract - runs-on: ubuntu-latest - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: 24 - cache: npm - - run: npm ci - - run: npx vitest run app/snapshots/networks.contract.test.ts - # public/llms.txt, llms-full.txt, and AGENTS.md are generated from the route # tree and committed. They go stale whenever a route is added, renamed, or # removed, so this fails if they differ from a fresh generation (it also diff --git a/README.md b/README.md index 579a716..67d82c5 100644 --- a/README.md +++ b/README.md @@ -39,17 +39,16 @@ 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`. - **public build excludes internal-only surfaces** — builds the default (external) target and asserts that internal-only routes 404 and never appear in the nav or sitemap, so the deployment matrix can't silently regress -- **snapshots API contract** — asserts every expected network stays served by - `/api/snapshots`. Nodes sync from these buckets, so a network must never be - dropped just to take it off the page — hide it with `hiddenFromUi` instead - (see `app/snapshots/networks.contract.test.ts`) CodeQL, StepSecurity, Heimdall, and the Vercel preview build are configured outside this repo at the org/platform level.