diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0208fc..e3a5c22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,7 +127,9 @@ jobs: # Internal-only routes must 404 on the public build. for route in /tips /tips/block/0x1 /tips/bundles/0x1 /api/tips/blocks \ /benchmark /benchmark/run/latest /benchmark/run-comparison/1 \ - /benchmark/load-tests/sepolia; do + /benchmark/load-tests/sepolia \ + /shadow-explorer /shadow-explorer/mainnet/canary/shadow-blocks \ + /api/shadow-explorer/chains /api/shadow-explorer/shadow-blocks; do code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3000${route}") if [ "${code}" != "404" ]; then echo "FAIL: ${route} returned ${code}, expected 404" @@ -145,7 +147,7 @@ jobs: done # No nav link to, or sitemap entry for, an internal-only section. - for section in /tips /benchmark; do + for section in /tips /benchmark /shadow-explorer; do if curl -s http://localhost:3000/ | grep -q "href=\"${section}\""; then echo "FAIL: public homepage links to ${section}" fail=1 diff --git a/app/api/shadow-explorer/block-detail.ts b/app/api/shadow-explorer/block-detail.ts new file mode 100644 index 0000000..b6dd876 --- /dev/null +++ b/app/api/shadow-explorer/block-detail.ts @@ -0,0 +1,65 @@ +// Single block detail proxied from a shadow chain's shadow-metrics /blocks/{id} +// endpoint. `id` is a decimal block number or a 0x block hash (canonical or a +// reorged-out shadow block). Server-only. + +export interface ShadowTxSummary { + index: number; + hash: string; + from?: string; + to?: string; + gasUsed?: number; + gasLimit: number; + txType: string; +} + +export interface ShadowBlockDetail { + number: number; + hash: string; + parentHash: string; + timestamp: number; + gasUsed: number; + gasLimit: number; + baseFeePerGas?: number; + reorgedOut: boolean; + canonicalHash?: string; + txCount: number; + transactions: ShadowTxSummary[]; +} + +export class ShadowBlockNotFoundError extends Error { + constructor(message = 'block not found') { + super(message); + this.name = 'ShadowBlockNotFoundError'; + } +} + +export class ShadowBlockDetailUnavailableError extends Error { + constructor(message = 'block detail unavailable') { + super(message); + this.name = 'ShadowBlockDetailUnavailableError'; + } +} + +export async function fetchShadowBlockDetail( + baseUrl: string, + id: string, +): Promise { + const root = baseUrl.replace(/\/$/, ''); + const url = `${root}/blocks/${encodeURIComponent(id)}`; + + let response: Response; + try { + response = await fetch(url, { cache: 'no-store' }); + } catch { + throw new ShadowBlockDetailUnavailableError('failed to reach shadow-metrics'); + } + + if (response.status === 404) { + throw new ShadowBlockNotFoundError(); + } + if (!response.ok) { + throw new ShadowBlockDetailUnavailableError(`shadow-metrics responded ${response.status}`); + } + + return (await response.json()) as ShadowBlockDetail; +} diff --git a/app/api/shadow-explorer/chains/route.ts b/app/api/shadow-explorer/chains/route.ts new file mode 100644 index 0000000..9040156 --- /dev/null +++ b/app/api/shadow-explorer/chains/route.ts @@ -0,0 +1,19 @@ +import type { ShadowChainInfo } from '../../../shadow-explorer/networks'; +import { resolveShadowNetwork } from '../../../shadow-explorer/networks'; +import { listShadowChains } from '../config'; +import { shadowExplorerDisabledResponse } from '../guard'; + +export const runtime = 'nodejs'; + +export interface ShadowChainsResponse { + chains: ShadowChainInfo[]; +} + +export async function GET(request: Request) { + const disabled = shadowExplorerDisabledResponse(); + if (disabled) return disabled; + + const network = resolveShadowNetwork(new URL(request.url).searchParams.get('network')); + const body: ShadowChainsResponse = { chains: listShadowChains(network) }; + return Response.json(body); +} diff --git a/app/api/shadow-explorer/config.ts b/app/api/shadow-explorer/config.ts new file mode 100644 index 0000000..2109570 --- /dev/null +++ b/app/api/shadow-explorer/config.ts @@ -0,0 +1,58 @@ +// Server-only config registry for Shadow Explorer. Each network can serve 1:N +// shadow chains, declared in a single JSON env var per network: +// +// SHADOW__CHAINS = [ +// { "id": "canary", "label": "Canary (latest RC)", "purpose": "…", "url": "http://…" }, +// { "id": "experimental", "label": "Experimental", "purpose": "…", "url": "http://…" } +// ] +// +// where is MAINNET | SEPOLIA | ZERONET. `url` is the shadow-metrics HTTP +// API base for that chain and never leaves the server; listShadowChains strips it +// before the client sees the list. Malformed JSON or entries missing id/url are +// skipped rather than throwing, so one bad entry can't take the section down. +import type { ShadowChainInfo, ShadowNetwork } from '../../shadow-explorer/networks'; + +const ENV_PREFIX: Record = { + mainnet: 'MAINNET', + sepolia: 'SEPOLIA', + zeronet: 'ZERONET', +}; + +interface ShadowChainConfig extends ShadowChainInfo { + url: string; +} + +function parseChains(network: ShadowNetwork): ShadowChainConfig[] { + const raw = process.env[`SHADOW_${ENV_PREFIX[network]}_CHAINS`]; + if (!raw) return []; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + + return parsed.flatMap((entry) => { + if (typeof entry !== 'object' || entry === null) return []; + const { id, label, url, purpose } = entry as Record; + if (typeof id !== 'string' || typeof url !== 'string') return []; + return [ + { + id, + label: typeof label === 'string' && label.length > 0 ? label : id, + purpose: typeof purpose === 'string' ? purpose : undefined, + url, + }, + ]; + }); +} + +export function listShadowChains(network: ShadowNetwork): ShadowChainInfo[] { + return parseChains(network).map(({ id, label, purpose }) => ({ id, label, purpose })); +} + +export function resolveShadowChainUrl(network: ShadowNetwork, chainId: string): string | undefined { + return parseChains(network).find((chain) => chain.id === chainId)?.url; +} diff --git a/app/api/shadow-explorer/guard.ts b/app/api/shadow-explorer/guard.ts new file mode 100644 index 0000000..c002ea1 --- /dev/null +++ b/app/api/shadow-explorer/guard.ts @@ -0,0 +1,9 @@ +import { SHADOW_EXPLORER_ENABLED } from '../../shadow-explorer/flag'; + +// Returns a 404 Response when Shadow Explorer is disabled (the public/Vercel +// build), else null. Call at the top of every Shadow Explorer API route so the +// section is fully absent from the public deployment — not just hidden in the +// UI — and its existence isn't leaked via 500s from missing configuration. +export function shadowExplorerDisabledResponse(): Response | null { + return SHADOW_EXPLORER_ENABLED ? null : Response.json({ error: 'Not found' }, { status: 404 }); +} diff --git a/app/api/shadow-explorer/shadow-blocks.test.ts b/app/api/shadow-explorer/shadow-blocks.test.ts new file mode 100644 index 0000000..beafa86 --- /dev/null +++ b/app/api/shadow-explorer/shadow-blocks.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; + +import { afterEach, describe, test, vi } from 'vitest'; + +import { + ShadowBlocksUnavailableError, + listShadowBlocks, + parseShadowBlocksQuery, +} from './shadow-blocks'; + +describe('shadow blocks query parsing', () => { + test('defaults offset to 0 and limit to the page default', () => { + assert.deepEqual(parseShadowBlocksQuery(new URLSearchParams('')), { offset: 0, limit: 25 }); + }); + + test('reads offset and limit', () => { + assert.deepEqual(parseShadowBlocksQuery(new URLSearchParams('offset=50&limit=10')), { + offset: 50, + limit: 10, + }); + }); + + test('validates offset and limit', () => { + assert.throws( + () => parseShadowBlocksQuery(new URLSearchParams('offset=-1')), + /offset must be a non-negative integer/, + ); + assert.throws( + () => parseShadowBlocksQuery(new URLSearchParams('limit=101')), + /limit must be between/, + ); + }); +}); + +describe('listShadowBlocks pagination', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('computes nextOffset and hasMore when more rows remain', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + Response.json({ blocks: [{ number: 3 }, { number: 2 }], totalCount: 5 }), + ); + + const result = await listShadowBlocks('http://shadow.internal:8080/', { offset: 0, limit: 2 }); + + assert.equal(result.blocks.length, 2); + assert.deepEqual(result.page, { + offset: 0, + limit: 2, + totalCount: 5, + nextOffset: 2, + hasMore: true, + }); + }); + + test('nextOffset is null on the final page', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + Response.json({ blocks: [{ number: 1 }], totalCount: 5 }), + ); + + const result = await listShadowBlocks('http://shadow.internal:8080', { offset: 4, limit: 2 }); + + assert.equal(result.page.nextOffset, null); + assert.equal(result.page.hasMore, false); + }); + + test('defaults a missing upstream totalCount to 0', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ blocks: [] })); + + const result = await listShadowBlocks('http://shadow.internal:8080', { offset: 0, limit: 2 }); + + assert.equal(result.page.totalCount, 0); + assert.equal(result.page.hasMore, false); + assert.equal(result.page.nextOffset, null); + }); + + test('maps a non-ok upstream response to ShadowBlocksUnavailableError', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('nope', { status: 503 })); + + await assert.rejects( + () => listShadowBlocks('http://shadow.internal:8080', { offset: 0, limit: 2 }), + ShadowBlocksUnavailableError, + ); + }); +}); diff --git a/app/api/shadow-explorer/shadow-blocks.ts b/app/api/shadow-explorer/shadow-blocks.ts new file mode 100644 index 0000000..04a33cc --- /dev/null +++ b/app/api/shadow-explorer/shadow-blocks.ts @@ -0,0 +1,169 @@ +// Shadow block listing proxied from a shadow chain's shadow-metrics HTTP API. +// The caller resolves the base URL via resolveShadowChainUrl(network, chainId). +// Offset-paginated to match the upstream /shadow-blocks endpoint. The `*Diff` +// fields are shadow − canonical (positive = shadow used more). Server-only. + +import { ShadowBlockNotFoundError } from './block-detail'; + +export const DEFAULT_SHADOW_BLOCKS_PAGE_LIMIT = 25; +export const MAX_SHADOW_BLOCKS_PAGE_LIMIT = 100; + +export interface ShadowHealthCheck { + id: string; + label: string; + passed: boolean; + detail: string; +} + +// Release-health verdict computed server-side (shadow-metrics). `reconciled` is +// false when the canonical replacement isn't persisted yet, so `checks` is empty. +export interface ShadowBlockHealth { + reconciled: boolean; + passed: number; + total: number; + checks: ShadowHealthCheck[]; +} + +export interface ShadowBlockSummary { + number: number; + hash: string; + canonicalHash: string; + timestamp: number; + shadowBuilderVersion: string; + canonicalBuilderVersion?: string; + shadowGasUsed: number; + canonicalGasUsed?: number; + gasDiffAbs?: number; + gasDiffPct?: number; + shadowTxCount: number; + canonicalTxCount?: number; + txCountDiff?: number; + shadowNonDepositTxCount: number; + canonicalNonDepositTxCount?: number; + shadowPriorityFeeInversions: number; + health: ShadowBlockHealth; +} + +export interface ShadowBlocksPage { + offset: number; + limit: number; + totalCount: number; + nextOffset: number | null; + hasMore: boolean; +} + +export interface ShadowBlocksResponse { + blocks: ShadowBlockSummary[]; + page: ShadowBlocksPage; +} + +export interface ShadowBlocksQuery { + offset: number; + limit: number; +} + +export class InvalidShadowBlocksQueryError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidShadowBlocksQueryError'; + } +} + +export class ShadowBlocksUnavailableError extends Error { + constructor(message = 'shadow blocks unavailable') { + super(message); + this.name = 'ShadowBlocksUnavailableError'; + } +} + +function parseNonNegativeInteger(value: string | null, name: string): number | null { + if (value === null) return null; + if (!/^(0|[1-9]\d*)$/.test(value)) { + throw new InvalidShadowBlocksQueryError(`${name} must be a non-negative integer`); + } + + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw new InvalidShadowBlocksQueryError(`${name} is too large`); + } + return parsed; +} + +export function parseShadowBlocksQuery(searchParams: URLSearchParams): ShadowBlocksQuery { + const offset = parseNonNegativeInteger(searchParams.get('offset'), 'offset') ?? 0; + const rawLimit = searchParams.get('limit'); + const limit = + rawLimit === null + ? DEFAULT_SHADOW_BLOCKS_PAGE_LIMIT + : parseNonNegativeInteger(rawLimit, 'limit'); + + if (limit === null || limit < 1 || limit > MAX_SHADOW_BLOCKS_PAGE_LIMIT) { + throw new InvalidShadowBlocksQueryError( + `limit must be between 1 and ${MAX_SHADOW_BLOCKS_PAGE_LIMIT}`, + ); + } + + return { offset, limit }; +} + +interface UpstreamShadowBlocksResponse { + blocks: ShadowBlockSummary[]; + totalCount: number; +} + +export async function listShadowBlocks( + baseUrl: string, + query: ShadowBlocksQuery, +): Promise { + const root = baseUrl.replace(/\/$/, ''); + const url = `${root}/shadow-blocks?limit=${query.limit}&offset=${query.offset}`; + + let response: Response; + try { + response = await fetch(url, { cache: 'no-store' }); + } catch { + throw new ShadowBlocksUnavailableError('failed to reach shadow-metrics'); + } + + if (!response.ok) { + throw new ShadowBlocksUnavailableError(`shadow-metrics responded ${response.status}`); + } + + const data = (await response.json()) as UpstreamShadowBlocksResponse; + const blocks = data.blocks ?? []; + const totalCount = data.totalCount ?? 0; + const nextOffset = query.offset + blocks.length; + const hasMore = nextOffset < totalCount; + + return { + blocks, + page: { + offset: query.offset, + limit: query.limit, + totalCount, + nextOffset: hasMore ? nextOffset : null, + hasMore, + }, + }; +} + +export async function fetchShadowBlock(baseUrl: string, id: string): Promise { + const root = baseUrl.replace(/\/$/, ''); + const url = `${root}/shadow-blocks/${encodeURIComponent(id)}`; + + let response: Response; + try { + response = await fetch(url, { cache: 'no-store' }); + } catch { + throw new ShadowBlocksUnavailableError('failed to reach shadow-metrics'); + } + + if (response.status === 404) { + throw new ShadowBlockNotFoundError(); + } + if (!response.ok) { + throw new ShadowBlocksUnavailableError(`shadow-metrics responded ${response.status}`); + } + + return (await response.json()) as ShadowBlockSummary; +} diff --git a/app/api/shadow-explorer/shadow-blocks/route.ts b/app/api/shadow-explorer/shadow-blocks/route.ts new file mode 100644 index 0000000..fcd739b --- /dev/null +++ b/app/api/shadow-explorer/shadow-blocks/route.ts @@ -0,0 +1,56 @@ +import { resolveShadowNetwork } from '../../../shadow-explorer/networks'; +import { resolveShadowChainUrl } from '../config'; +import { shadowExplorerDisabledResponse } from '../guard'; +import { + InvalidShadowBlocksQueryError, + ShadowBlocksUnavailableError, + listShadowBlocks, + parseShadowBlocksQuery, +} from '../shadow-blocks'; + +export const runtime = 'nodejs'; + +export type { + ShadowBlockSummary, + ShadowBlockHealth, + ShadowHealthCheck, + ShadowBlocksPage, + ShadowBlocksResponse, +} from '../shadow-blocks'; + +export async function GET(request: Request) { + const disabled = shadowExplorerDisabledResponse(); + if (disabled) return disabled; + + const url = new URL(request.url); + const network = resolveShadowNetwork(url.searchParams.get('network')); + const chainId = url.searchParams.get('chain'); + if (!chainId) { + return Response.json({ error: 'Missing chain parameter' }, { status: 400 }); + } + + const baseUrl = resolveShadowChainUrl(network, chainId); + if (!baseUrl) { + return Response.json({ error: 'Shadow chain not configured' }, { status: 503 }); + } + + try { + const query = parseShadowBlocksQuery(url.searchParams); + return Response.json(await listShadowBlocks(baseUrl, query)); + } catch (error) { + if (error instanceof InvalidShadowBlocksQueryError) { + return Response.json({ error: error.message }, { status: 400 }); + } + + console.error('Error fetching shadow blocks:', error); + return Response.json( + { + error: + error instanceof ShadowBlocksUnavailableError + ? 'Shadow blocks unavailable' + : 'Internal server error', + }, + { status: error instanceof ShadowBlocksUnavailableError ? 503 : 500 }, + ); + } +} diff --git a/app/navigation.ts b/app/navigation.ts index 2ea0d8f..0dfdc2d 100644 --- a/app/navigation.ts +++ b/app/navigation.ts @@ -1,4 +1,5 @@ import { BENCHMARK_ENABLED } from './benchmark/flag'; +import { SHADOW_EXPLORER_ENABLED } from './shadow-explorer/flag'; import { TIPS_ENABLED } from './tips/flag'; export type NavIcon = 'home' | 'snapshots' | 'upgrades' | 'changelog' | 'vibenet' | 'overview' | 'demos' | 'faucet' | 'explorer' | 'tips' | 'benchmark' | 'runs' | 'loadtest'; @@ -40,6 +41,12 @@ export const NAV_ITEMS: NavItem[] = [ ...(TIPS_ENABLED ? [{ label: 'TIPS', href: '/tips', icon: 'tips', enabled: true } as NavItem] : []), + // Shadow Explorer is internal-only; present only in the internal build target + // (deploy.config.mjs). See app/shadow-explorer/flag.ts. Network + shadow chain + // are carried in the URL path, so this entry needs no static children. + ...(SHADOW_EXPLORER_ENABLED + ? [{ label: 'Shadow Explorer', href: '/shadow-explorer', icon: 'explorer', enabled: true } as NavItem] + : []), // Benchmark is internal-only; present only in the internal build target // (deploy.config.mjs). See app/benchmark/flag.ts. The two children were the // report's own in-page tab bar upstream. diff --git a/app/shadow-explorer/[network]/[chain]/block/[id]/page.tsx b/app/shadow-explorer/[network]/[chain]/block/[id]/page.tsx new file mode 100644 index 0000000..4c84e3a --- /dev/null +++ b/app/shadow-explorer/[network]/[chain]/block/[id]/page.tsx @@ -0,0 +1,238 @@ +import Link from 'next/link'; +import { notFound, redirect } from 'next/navigation'; + +import { Card } from '../../../../../components/ui/Card'; +import { cn } from '../../../../../components/ui/cn'; +import { Text } from '../../../../../components/ui/Text'; +import { + ShadowBlockNotFoundError, + fetchShadowBlockDetail, + type ShadowBlockDetail, +} from '../../../../../api/shadow-explorer/block-detail'; +import { fetchShadowBlock, type ShadowBlockHealth } from '../../../../../api/shadow-explorer/shadow-blocks'; +import { resolveShadowChainUrl } from '../../../../../api/shadow-explorer/config'; +import { ShadowNav } from '../../../../components/ShadowNav'; +import { formatAge, formatInteger, shortHash } from '../../../../library/format'; +import { shadowHref, tipsCanonicalBlockHref } from '../../../../library/links'; +import { isShadowNetwork } from '../../../../networks'; + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {label} + + {children} +
+ ); +} + +export default async function ShadowBlockDetailPage({ + params, +}: { + params: Promise<{ network: string; chain: string; id: string }>; +}) { + const { network, chain, id } = await params; + if (!isShadowNetwork(network)) notFound(); + + const baseUrl = resolveShadowChainUrl(network, chain); + if (!baseUrl) notFound(); + + let detail: ShadowBlockDetail | null = null; + let error: string | null = null; + try { + detail = await fetchShadowBlockDetail(baseUrl, id); + } catch (err) { + if (err instanceof ShadowBlockNotFoundError) notFound(); + error = 'Failed to load block'; + } + + // Shadow Explorer only owns reorged-out shadow candidates. Canonical blocks + // belong to TIPS, so hand a canonical hit off to the TIPS block explorer. + if (detail && !detail.reorgedOut) { + redirect(tipsCanonicalBlockHref(network, detail.hash)); + } + + let health: ShadowBlockHealth | null = null; + if (detail) { + try { + health = (await fetchShadowBlock(baseUrl, id)).health; + } catch { + health = null; + } + } + + return ( +
+ + +
+ + ← Shadow Blocks + +
+ + {error ? ( + + + {error} + + + ) : detail ? ( + <> +
+
+ Block #{formatInteger(detail.number)} + + {detail.reorgedOut ? 'Reorged-out shadow' : 'Canonical'} + +
+ + {detail.hash} + +
+ + {health && health.reconciled ? ( + +
+ Release health + + {health.passed}/{health.total} + +
+
    + {health.checks.map((check) => ( +
  • + + {check.passed ? '✓' : '✗'} + +
    +
    {check.label}
    +
    + {check.detail} +
    +
    +
  • + ))} +
+
+ ) : health && !health.reconciled ? ( + + + Health pending — canonical replacement not reconciled yet. + + + ) : null} + + + {formatAge(detail.timestamp)} + {formatInteger(detail.txCount)} + {formatInteger(detail.gasUsed)} + {formatInteger(detail.gasLimit)} + {detail.baseFeePerGas !== undefined ? ( + {formatInteger(detail.baseFeePerGas)} + ) : null} + + + {shortHash(detail.parentHash)} + + + {detail.canonicalHash ? ( + + + {shortHash(detail.canonicalHash)} + + + ) : null} + + +
+ Transactions + + {detail.transactions.length > 0 ? ( +
+ + + + + + + + + + + + + {detail.transactions.map((tx) => ( + + + + + + + + + ))} + +
+ # + + Hash + + From + + To + + Gas used + + Type +
{tx.index} + {shortHash(tx.hash)} + + {tx.from ? shortHash(tx.from, 6, 4) : '—'} + + {tx.to ? shortHash(tx.to, 6, 4) : '—'} + + {tx.gasUsed !== undefined ? formatInteger(tx.gasUsed) : '—'} + {tx.txType}
+
+ ) : ( +
+ No transactions in this block +
+ )} +
+
+ + ) : null} +
+ ); +} diff --git a/app/shadow-explorer/[network]/[chain]/page.tsx b/app/shadow-explorer/[network]/[chain]/page.tsx new file mode 100644 index 0000000..140837a --- /dev/null +++ b/app/shadow-explorer/[network]/[chain]/page.tsx @@ -0,0 +1,50 @@ +import Link from 'next/link'; +import { notFound } from 'next/navigation'; + +import { Card } from '../../../components/ui/Card'; +import { Text } from '../../../components/ui/Text'; +import { listShadowChains } from '../../../api/shadow-explorer/config'; +import { ShadowNav } from '../../components/ShadowNav'; +import { shadowHref } from '../../library/links'; +import { isShadowNetwork } from '../../networks'; + +export default async function ShadowChainOverview({ + params, +}: { + params: Promise<{ network: string; chain: string }>; +}) { + const { network, chain } = await params; + if (!isShadowNetwork(network)) notFound(); + + const info = listShadowChains(network).find((entry) => entry.id === chain); + if (!info) notFound(); + + return ( +
+ + +
+ {info.label} + {info.purpose ? ( + + {info.purpose} + + ) : null} +
+ + + Shadow Blocks + + Reorged-out shadow candidate blocks paired with the canonical block that replaced them, + with gas and transaction deltas. + + + View shadow blocks → + + +
+ ); +} diff --git a/app/shadow-explorer/[network]/[chain]/shadow-blocks/page.tsx b/app/shadow-explorer/[network]/[chain]/shadow-blocks/page.tsx new file mode 100644 index 0000000..4f75f85 --- /dev/null +++ b/app/shadow-explorer/[network]/[chain]/shadow-blocks/page.tsx @@ -0,0 +1,36 @@ +import { notFound } from 'next/navigation'; +import { Suspense } from 'react'; + +import { Spinner } from '../../../../components/ui/Spinner'; +import { Text } from '../../../../components/ui/Text'; +import { resolveShadowChainUrl } from '../../../../api/shadow-explorer/config'; +import { ShadowBlocksClient } from '../../../components/ShadowBlocksClient'; +import { ShadowNav } from '../../../components/ShadowNav'; +import { isShadowNetwork } from '../../../networks'; + +export default async function ShadowBlocksPage({ + params, +}: { + params: Promise<{ network: string; chain: string }>; +}) { + const { network, chain } = await params; + if (!isShadowNetwork(network) || !resolveShadowChainUrl(network, chain)) notFound(); + + return ( +
+ + + + + Loading… + +
+ } + > + + + + ); +} diff --git a/app/shadow-explorer/[network]/page.tsx b/app/shadow-explorer/[network]/page.tsx new file mode 100644 index 0000000..4a5b88d --- /dev/null +++ b/app/shadow-explorer/[network]/page.tsx @@ -0,0 +1,28 @@ +import { notFound, redirect } from 'next/navigation'; + +import { EmptyState } from '../../components/ui/EmptyState'; +import { listShadowChains } from '../../api/shadow-explorer/config'; +import { isShadowNetwork } from '../networks'; + +export default async function ShadowNetworkIndex({ + params, +}: { + params: Promise<{ network: string }>; +}) { + const { network } = await params; + if (!isShadowNetwork(network)) notFound(); + + const chains = listShadowChains(network); + if (chains.length === 0) { + return ( +
+ +
+ ); + } + + redirect(`/shadow-explorer/${network}/${chains[0].id}/shadow-blocks`); +} diff --git a/app/shadow-explorer/components/ShadowBlockTable.tsx b/app/shadow-explorer/components/ShadowBlockTable.tsx new file mode 100644 index 0000000..df660d1 --- /dev/null +++ b/app/shadow-explorer/components/ShadowBlockTable.tsx @@ -0,0 +1,147 @@ +'use client'; + +// Table for the shadow block explorer. Each row is a reorged-out shadow block +// paired with the canonical block that replaced it, surfacing the gas/tx deltas +// used to validate a builder canary. Rows are clickable: the row drills into the +// shadow block, the Canonical cell drills into the canonical block. +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import type React from 'react'; + +import { cn } from '../../components/ui/cn'; +import { formatAge, formatInteger, shortHash } from '../library/format'; +import { shadowHref, tipsCanonicalBlockHref } from '../library/links'; +import type { ShadowNetwork } from '../networks'; +import type { ShadowBlockSummary } from '../library/types'; + +// A reconciled block is unhealthy when it failed at least one health check. +export function isUnhealthy(block: ShadowBlockSummary): boolean { + return block.health.reconciled && block.health.passed < block.health.total; +} + +function TableHeader({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function Cell({ children, className }: { children: React.ReactNode; className?: string }) { + return {children}; +} + +function HealthCell({ block }: { block: ShadowBlockSummary }) { + const { reconciled, passed, total } = block.health; + if (!reconciled) { + return pending; + } + + const ok = passed === total; + return ( + + {passed}/{total} + + ); +} + +function BuilderCell({ block }: { block: ShadowBlockSummary }) { + const changed = + block.canonicalBuilderVersion !== undefined && + block.canonicalBuilderVersion !== block.shadowBuilderVersion; + return ( +
+ {block.shadowBuilderVersion} + {changed ? ( + + canon: {block.canonicalBuilderVersion} + + ) : null} +
+ ); +} + +export function ShadowBlockTable({ + blocks, + network, + chain, +}: { + blocks: ShadowBlockSummary[]; + network: ShadowNetwork; + chain: string; +}) { + const router = useRouter(); + + return ( +
+ + + + Height + Age + Builder + Health + Canonical + + + + {blocks.map((block) => { + const open = () => router.push(shadowHref(network, chain, `/block/${block.hash}`)); + return ( + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + open(); + } + }} + className="cursor-pointer hover:bg-bds-gray-5/60 focus:bg-bds-gray-5/60 focus:outline-none dark:hover:bg-white/5 dark:focus:bg-white/5" + > + + #{formatInteger(block.number)} +
+ {shortHash(block.hash)} +
+
+ + {formatAge(block.timestamp)} + + + + + + + + + event.stopPropagation()} + className="font-mono text-base-blue hover:underline dark:text-bds-blue-20" + title={`View canonical block in TIPS: ${block.canonicalHash}`} + > + {shortHash(block.canonicalHash)} + + + + ); + })} + +
+
+ ); +} diff --git a/app/shadow-explorer/components/ShadowBlocksClient.tsx b/app/shadow-explorer/components/ShadowBlocksClient.tsx new file mode 100644 index 0000000..2e2fd55 --- /dev/null +++ b/app/shadow-explorer/components/ShadowBlocksClient.tsx @@ -0,0 +1,129 @@ +'use client'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +import { Card } from '../../components/ui/Card'; +import { Spinner } from '../../components/ui/Spinner'; +import { Text } from '../../components/ui/Text'; +import { shadowExplorerApi } from '../library/client'; +import { formatInteger } from '../library/format'; +import { shadowHref } from '../library/links'; +import type { ShadowBlocksResponse, ShadowNetwork } from '../library/types'; +import { ShadowBlockTable, isUnhealthy } from './ShadowBlockTable'; + +const PAGE_LIMIT = 25; + +export function ShadowBlocksClient({ network, chain }: { network: ShadowNetwork; chain: string }) { + const searchParams = useSearchParams(); + const offsetParam = searchParams.get('offset'); + const offset = offsetParam !== null ? Number(offsetParam) : undefined; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + const controller = new AbortController(); + setLoading(true); + setError(null); + setData(null); + + shadowExplorerApi + .shadowBlocks(network, chain, { offset, limit: PAGE_LIMIT }, controller.signal) + .then((next) => { + if (!cancelled) setData(next); + }) + .catch(() => { + if (controller.signal.aborted || cancelled) return; + setError('Failed to fetch shadow blocks'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [network, chain, offset]); + + const unhealthyCount = data?.blocks.filter(isUnhealthy).length ?? 0; + + return ( +
+
+
+ Shadow Blocks + + Reorged-out shadow candidates vs. the canonical block that replaced them. Health is the + number of release checks passed (gas within ±50%, tx counts match, no priority-fee + inversions); open a row for the breakdown. + +
+ {offset !== undefined && offset > 0 ? ( + + Latest + + ) : null} +
+ + {error ? ( + + + {error} + + + ) : null} + + {!error && data && unhealthyCount > 0 ? ( + + + {unhealthyCount} of {data.blocks.length} shadow blocks on this page failed one or more + health checks. + + + ) : null} + + + {loading ? ( +
+ + + Loading shadow blocks… + +
+ ) : data && data.blocks.length > 0 ? ( + + ) : ( +
+ No shadow blocks available +
+ )} + + {data ? ( +
+ + {data.page.totalCount > 0 + ? `${formatInteger(data.page.totalCount)} reorged shadow blocks` + : 'No shadow blocks'} + + {data.page.nextOffset !== null ? ( + + Older → + + ) : null} +
+ ) : null} +
+
+ ); +} diff --git a/app/shadow-explorer/components/ShadowNav.tsx b/app/shadow-explorer/components/ShadowNav.tsx new file mode 100644 index 0000000..3c991b7 --- /dev/null +++ b/app/shadow-explorer/components/ShadowNav.tsx @@ -0,0 +1,84 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +import { Tabs } from '../../components/ui/Tabs'; +import { shadowExplorerApi } from '../library/client'; +import { shadowHref } from '../library/links'; +import { SHADOW_NETWORKS, type ShadowChainInfo, type ShadowNetwork } from '../networks'; + +const linkClass = + 'text-sm text-bds-gray-60 transition-colors hover:text-black dark:text-bds-gray-40 dark:hover:text-white'; +const activeClass = 'text-sm font-medium text-black dark:text-white'; + +// Section chrome: a network selector, a shadow-chain (variant) selector for the +// selected network, and the per-chain view tabs. Switching network routes to +// that network's root, which redirects to its first configured chain. +export function ShadowNav({ + network, + chain, + active, +}: { + network: ShadowNetwork; + chain: string; + active: 'overview' | 'shadow-blocks'; +}) { + const router = useRouter(); + const [chains, setChains] = useState([]); + + useEffect(() => { + let cancelled = false; + shadowExplorerApi + .chains(network) + .then((response) => { + if (!cancelled) setChains(response.chains); + }) + .catch(() => { + if (!cancelled) setChains([]); + }); + return () => { + cancelled = true; + }; + }, [network]); + + const subpath = active === 'shadow-blocks' ? '/shadow-blocks' : ''; + + return ( +
+
+ ({ value: n.id, label: n.label }))} + onChange={(value) => router.push(`/shadow-explorer/${value}`)} + /> + {chains.length > 0 ? ( + ({ value: c.id, label: c.label }))} + onChange={(value) => router.push(shadowHref(network, value, subpath))} + /> + ) : null} +
+
+ + Overview + + + Shadow Blocks + +
+
+ ); +} diff --git a/app/shadow-explorer/flag.ts b/app/shadow-explorer/flag.ts new file mode 100644 index 0000000..d80fb7a --- /dev/null +++ b/app/shadow-explorer/flag.ts @@ -0,0 +1,11 @@ +// Whether the Shadow Explorer section is included in this build. Derived from +// the deployment matrix (deploy.config.mjs) — Shadow Explorer ships to the +// internal target only. Consumers import this named constant; the matrix is the +// source of truth. +// +// The target is fixed for a given build, so when disabled the section is +// unreachable: the nav entry is dropped, middleware 404s /shadow-explorer and +// its subtree, and the API routes 404 via app/api/shadow-explorer/guard.ts. +import { surfaceEnabled } from '../../deploy.config.mjs'; + +export const SHADOW_EXPLORER_ENABLED: boolean = surfaceEnabled('shadow-explorer'); diff --git a/app/shadow-explorer/layout.tsx b/app/shadow-explorer/layout.tsx new file mode 100644 index 0000000..e4a439f --- /dev/null +++ b/app/shadow-explorer/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import type { ReactNode } from 'react'; + +import { SHADOW_EXPLORER_ENABLED } from './flag'; + +export const metadata: Metadata = { + title: 'Shadow Explorer · Base Chain', + description: + 'Explore shadow chains per network: reorged-out shadow candidate blocks paired with the canonical block that replaced them, with gas and transaction deltas.', +}; + +export default function ShadowExplorerLayout({ children }: { children: ReactNode }) { + // Server guard: 404 the whole /shadow-explorer subtree on a direct visit when + // the section is disabled. With the flag off this branch is a compile-time + // constant, so the section is unreachable in the public build. + if (!SHADOW_EXPLORER_ENABLED) notFound(); + return
{children}
; +} diff --git a/app/shadow-explorer/library/client.ts b/app/shadow-explorer/library/client.ts new file mode 100644 index 0000000..5edc889 --- /dev/null +++ b/app/shadow-explorer/library/client.ts @@ -0,0 +1,57 @@ +// Fetch client for the Shadow Explorer API (/api/shadow-explorer/*, same-origin +// route handlers). Unlike the TIPS client, requests are addressed by explicit +// network + shadow-chain params rather than a single ?chain=. + +import type { ShadowChainsResponse } from '../../api/shadow-explorer/chains/route'; +import type { ShadowBlocksResponse } from '../../api/shadow-explorer/shadow-blocks/route'; +import type { ShadowNetwork } from '../networks'; + +export class ShadowExplorerApiError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'ShadowExplorerApiError'; + this.status = status; + } +} + +async function get(path: string, signal?: AbortSignal): Promise { + const response = await fetch(path, { cache: 'no-store', signal }); + if (!response.ok) { + throw new ShadowExplorerApiError( + `Shadow Explorer API request to ${path} failed (${response.status})`, + response.status, + ); + } + return (await response.json()) as T; +} + +function withQuery(path: string, params: Record): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) search.set(key, String(value)); + } + const qs = search.toString(); + return qs ? `${path}?${qs}` : path; +} + +export const shadowExplorerApi = { + chains: (network: ShadowNetwork, signal?: AbortSignal) => + get(withQuery('/api/shadow-explorer/chains', { network }), signal), + shadowBlocks: ( + network: ShadowNetwork, + chain: string, + options?: { offset?: number; limit?: number }, + signal?: AbortSignal, + ) => + get( + withQuery('/api/shadow-explorer/shadow-blocks', { + network, + chain, + offset: options?.offset, + limit: options?.limit, + }), + signal, + ), +}; diff --git a/app/shadow-explorer/library/format.ts b/app/shadow-explorer/library/format.ts new file mode 100644 index 0000000..77c7069 --- /dev/null +++ b/app/shadow-explorer/library/format.ts @@ -0,0 +1,45 @@ +// Pure, dependency-free formatters for the Shadow Explorer surfaces. Client-safe: +// no env, no server imports. + +export type NumericValue = bigint | number | string | null | undefined; + +function toBigInt(value: NumericValue): bigint | null { + if (value === null || value === undefined || value === '') { + return null; + } + if (typeof value === 'bigint') { + return value; + } + if (typeof value === 'number') { + return Number.isSafeInteger(value) ? BigInt(value) : null; + } + try { + return BigInt(value); + } catch { + return null; + } +} + +export function formatInteger(value: NumericValue): string { + const parsed = toBigInt(value); + return parsed === null ? '—' : parsed.toLocaleString(); +} + +export function formatAge( + timestamp: NumericValue, + nowSeconds = Math.floor(Date.now() / 1000), +): string { + const parsed = toBigInt(timestamp); + if (parsed === null) return '—'; + + const seconds = Math.max(0, nowSeconds - Number(parsed)); + if (seconds < 60) return seconds <= 0 ? 'now' : `${seconds}s ago`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return `${Math.floor(seconds / 86400)}d ago`; +} + +export function shortHash(value: string, prefix = 10, suffix = 8): string { + if (value.length <= prefix + suffix + 3) return value; + return `${value.slice(0, prefix)}...${value.slice(-suffix)}`; +} diff --git a/app/shadow-explorer/library/links.ts b/app/shadow-explorer/library/links.ts new file mode 100644 index 0000000..9a46d15 --- /dev/null +++ b/app/shadow-explorer/library/links.ts @@ -0,0 +1,14 @@ +import type { ShadowNetwork } from '../networks'; + +// Builds an internal Shadow Explorer path. Network and shadow chain are path +// segments (not query params), so links are self-describing and shareable. +export function shadowHref(network: ShadowNetwork, chain: string, path = ''): string { + return `/shadow-explorer/${network}/${encodeURIComponent(chain)}${path}`; +} + +// Canonical block inspection is TIPS's domain (S3/RPC), not shadow-explorer's, so +// canonical references link out to it. TIPS chains share the same network ids, +// carried as ?chain=. +export function tipsCanonicalBlockHref(network: ShadowNetwork, hash: string): string { + return `/tips/block/${encodeURIComponent(hash)}?chain=${network}`; +} diff --git a/app/shadow-explorer/library/types.ts b/app/shadow-explorer/library/types.ts new file mode 100644 index 0000000..e0c5440 --- /dev/null +++ b/app/shadow-explorer/library/types.ts @@ -0,0 +1,13 @@ +// Shadow Explorer API response types. Re-exported type-only (erased at build, so +// no server code reaches the client bundle) from the route handlers and the +// client-safe network model. + +export type { + ShadowBlockSummary, + ShadowBlockHealth, + ShadowHealthCheck, + ShadowBlocksPage, + ShadowBlocksResponse, +} from '../../api/shadow-explorer/shadow-blocks/route'; +export type { ShadowChainsResponse } from '../../api/shadow-explorer/chains/route'; +export type { ShadowChainInfo, ShadowNetwork, ShadowNetworkInfo } from '../networks'; diff --git a/app/shadow-explorer/networks.ts b/app/shadow-explorer/networks.ts new file mode 100644 index 0000000..62981f7 --- /dev/null +++ b/app/shadow-explorer/networks.ts @@ -0,0 +1,37 @@ +// Network + shadow-chain model for the Shadow Explorer section. Client-safe: no +// env, no server imports. A shadow surface is addressed by two dimensions — the +// underlying network (mainnet/sepolia/zeronet) and one of 1:N shadow chains +// configured for that network (e.g. a release-candidate canary, an experimental +// build). Both dimensions live in the URL path (/shadow-explorer///...). + +export type ShadowNetwork = 'mainnet' | 'sepolia' | 'zeronet'; + +export type ShadowNetworkInfo = { + id: ShadowNetwork; + label: string; +}; + +export const SHADOW_NETWORKS: readonly ShadowNetworkInfo[] = [ + { id: 'mainnet', label: 'Base Mainnet' }, + { id: 'sepolia', label: 'Base Sepolia' }, + { id: 'zeronet', label: 'Zeronet' }, +]; + +export const DEFAULT_SHADOW_NETWORK: ShadowNetwork = 'mainnet'; + +export function isShadowNetwork(value: string | null | undefined): value is ShadowNetwork { + return value === 'mainnet' || value === 'sepolia' || value === 'zeronet'; +} + +export function resolveShadowNetwork(value: string | null | undefined): ShadowNetwork { + return isShadowNetwork(value) ? value : DEFAULT_SHADOW_NETWORK; +} + +// One selectable shadow chain within a network. `url` (the shadow-metrics base +// URL) is intentionally absent: it stays server-side in the config registry and +// is never sent to the client. +export interface ShadowChainInfo { + id: string; + label: string; + purpose?: string; +} diff --git a/app/shadow-explorer/page.tsx b/app/shadow-explorer/page.tsx new file mode 100644 index 0000000..190a6cb --- /dev/null +++ b/app/shadow-explorer/page.tsx @@ -0,0 +1,23 @@ +import { redirect } from 'next/navigation'; + +import { EmptyState } from '../components/ui/EmptyState'; +import { listShadowChains } from '../api/shadow-explorer/config'; +import { SHADOW_NETWORKS } from './networks'; + +export default function ShadowExplorerIndex() { + for (const network of SHADOW_NETWORKS) { + const chains = listShadowChains(network.id); + if (chains.length > 0) { + redirect(`/shadow-explorer/${network.id}/${chains[0].id}/shadow-blocks`); + } + } + + return ( +
+ +
+ ); +} diff --git a/app/tips/components/ExplorerNav.tsx b/app/tips/components/ExplorerNav.tsx index cc1db26..92f4039 100644 --- a/app/tips/components/ExplorerNav.tsx +++ b/app/tips/components/ExplorerNav.tsx @@ -5,7 +5,13 @@ import { tipsHref } from '../library/links'; // Shared sub-nav for the Basescan-style explorer surfaces (/tips/blocks, /tips/txs): // a back link to the TIPS dashboard plus links between the two list views. -export function ExplorerNav({ chain, active }: { chain: TipsChain; active: 'blocks' | 'txs' }) { +export function ExplorerNav({ + chain, + active, +}: { + chain: TipsChain; + active: 'blocks' | 'txs'; +}) { const linkClass = 'text-sm text-bds-gray-60 transition-colors hover:text-black dark:text-bds-gray-40 dark:hover:text-white'; const activeClass = 'text-sm font-medium text-black dark:text-white'; diff --git a/deploy.config.mjs b/deploy.config.mjs index 37ea241..ceb8cab 100644 --- a/deploy.config.mjs +++ b/deploy.config.mjs @@ -40,6 +40,11 @@ export const SURFACES = { routePrefixes: ['/benchmark'], targets: ['internal'], }, + 'shadow-explorer': { + routePrefixes: ['/shadow-explorer'], + apiPrefixes: ['/api/shadow-explorer'], + targets: ['internal'], + }, }; /** Is a surface included in the current build target? Unknown key => yes. */ diff --git a/deploy.config.test.mjs b/deploy.config.test.mjs index c99a0f1..929529d 100644 --- a/deploy.config.test.mjs +++ b/deploy.config.test.mjs @@ -28,19 +28,22 @@ describe('deploy.config', () => { const c = await loadWithTarget('external'); expect(c.surfaceEnabled('tips')).toBe(false); expect(c.surfaceEnabled('benchmark')).toBe(false); + expect(c.surfaceEnabled('shadow-explorer')).toBe(false); }); it('reports the disabled route + api prefixes and subtree globs', async () => { const c = await loadWithTarget('external'); - expect(c.disabledRoutePrefixes()).toEqual(['/tips', '/benchmark']); + expect(c.disabledRoutePrefixes()).toEqual(['/tips', '/benchmark', '/shadow-explorer']); // Benchmark contributes no api prefix: it calls the report API directly // from the browser rather than through a route handler in this app. - expect(c.disabledApiPrefixes()).toEqual(['/api/tips']); + expect(c.disabledApiPrefixes()).toEqual(['/api/tips', '/api/shadow-explorer']); expect(c.disabledRouteGlobs()).toEqual([ '/tips', '/tips/**', '/benchmark', '/benchmark/**', + '/shadow-explorer', + '/shadow-explorer/**', ]); }); }); @@ -51,6 +54,7 @@ describe('deploy.config', () => { expect(c.TARGET).toBe('internal'); expect(c.surfaceEnabled('tips')).toBe(true); expect(c.surfaceEnabled('benchmark')).toBe(true); + expect(c.surfaceEnabled('shadow-explorer')).toBe(true); }); it('disables nothing', async () => {