From 8d9ff7cf7522cf0e3121a1823c3d5a726f69548e Mon Sep 17 00:00:00 2001 From: Will Johnston Date: Thu, 20 Aug 2026 11:05:44 -0700 Subject: [PATCH 1/5] feat(tips): add shadow blocks explorer Internal-only explorer surface listing reorged-out shadow candidate blocks paired with the canonical block that replaced them, with gas and transaction deltas. Chain-aware API route proxies the shadow-metrics /shadow-blocks endpoint; offset-paginated to match upstream. Co-authored-by: OpenCode --- app/api/tips/config.ts | 11 ++ app/api/tips/shadow-blocks.test.ts | 76 +++++++++++ app/api/tips/shadow-blocks.ts | 128 ++++++++++++++++++ app/api/tips/shadow-blocks/route.ts | 49 +++++++ app/tips/components/ExplorerNav.tsx | 14 +- app/tips/components/ShadowBlockTable.tsx | 161 +++++++++++++++++++++++ app/tips/library/client.ts | 11 ++ app/tips/library/types.ts | 5 + app/tips/shadow-blocks/layout.tsx | 12 ++ app/tips/shadow-blocks/page.tsx | 150 +++++++++++++++++++++ 10 files changed, 616 insertions(+), 1 deletion(-) create mode 100644 app/api/tips/shadow-blocks.test.ts create mode 100644 app/api/tips/shadow-blocks.ts create mode 100644 app/api/tips/shadow-blocks/route.ts create mode 100644 app/tips/components/ShadowBlockTable.tsx create mode 100644 app/tips/shadow-blocks/layout.tsx create mode 100644 app/tips/shadow-blocks/page.tsx diff --git a/app/api/tips/config.ts b/app/api/tips/config.ts index ba8edd1..c5c71eb 100644 --- a/app/api/tips/config.ts +++ b/app/api/tips/config.ts @@ -86,3 +86,14 @@ export function getAuditRpcUrl(chain: TipsChain): string | undefined { export function isAuditConfigured(chain: TipsChain): boolean { return Boolean(getAuditRpcUrl(chain)); } + +// Shadow-metrics HTTP API base URL for a chain. Opt-in per chain via +// TIPS__SHADOW_METRICS_URL; when unset the shadow blocks surface is +// disabled for that chain (its route returns 503) — mirroring audit. +export function getShadowMetricsUrl(chain: TipsChain): string | undefined { + return envValue([`TIPS_${ENV_PREFIX[chain]}_SHADOW_METRICS_URL`]); +} + +export function isShadowMetricsConfigured(chain: TipsChain): boolean { + return Boolean(getShadowMetricsUrl(chain)); +} diff --git a/app/api/tips/shadow-blocks.test.ts b/app/api/tips/shadow-blocks.test.ts new file mode 100644 index 0000000..ff97991 --- /dev/null +++ b/app/api/tips/shadow-blocks.test.ts @@ -0,0 +1,76 @@ +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('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/tips/shadow-blocks.ts b/app/api/tips/shadow-blocks.ts new file mode 100644 index 0000000..2eac2b3 --- /dev/null +++ b/app/api/tips/shadow-blocks.ts @@ -0,0 +1,128 @@ +// Shadow block listing proxied from the shadow-metrics HTTP API. Chain-aware: +// the caller passes the resolved base URL (getShadowMetricsUrl(chain)). Offset- +// paginated to match the upstream /shadow-blocks endpoint. The `*Diff` fields are +// shadow − canonical (positive = shadow used more). Server-only. + +export const DEFAULT_SHADOW_BLOCKS_PAGE_LIMIT = 25; +export const MAX_SHADOW_BLOCKS_PAGE_LIMIT = 100; + +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; +} + +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 nextOffset = query.offset + blocks.length; + const hasMore = nextOffset < data.totalCount; + + return { + blocks, + page: { + offset: query.offset, + limit: query.limit, + totalCount: data.totalCount, + nextOffset: hasMore ? nextOffset : null, + hasMore, + }, + }; +} diff --git a/app/api/tips/shadow-blocks/route.ts b/app/api/tips/shadow-blocks/route.ts new file mode 100644 index 0000000..0f607e9 --- /dev/null +++ b/app/api/tips/shadow-blocks/route.ts @@ -0,0 +1,49 @@ +import { resolveTipsChain } from '../../../tips/chains'; +import { getShadowMetricsUrl } from '../config'; +import { tipsDisabledResponse } from '../guard'; +import { + InvalidShadowBlocksQueryError, + ShadowBlocksUnavailableError, + listShadowBlocks, + parseShadowBlocksQuery, +} from '../shadow-blocks'; + +export const runtime = 'nodejs'; + +// Offset-paginated shadow block list, proxied from the shadow-metrics HTTP API. +// See app/api/tips/shadow-blocks.ts. Types are re-exported for the client library. +export type { ShadowBlockSummary, ShadowBlocksPage, ShadowBlocksResponse } from '../shadow-blocks'; + +export async function GET(request: Request) { + const disabled = tipsDisabledResponse(); + if (disabled) return disabled; + const chain = resolveTipsChain(new URL(request.url).searchParams.get('chain')); + + const baseUrl = getShadowMetricsUrl(chain); + if (!baseUrl) { + return Response.json( + { error: 'Shadow metrics not configured for this chain' }, + { status: 503 }, + ); + } + + try { + const query = parseShadowBlocksQuery(new URL(request.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/tips/components/ExplorerNav.tsx b/app/tips/components/ExplorerNav.tsx index cc1db26..ec1f245 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' | 'shadow-blocks'; +}) { 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'; @@ -27,6 +33,12 @@ export function ExplorerNav({ chain, active }: { chain: TipsChain; active: 'bloc Transactions + + Shadow Blocks + ); } diff --git a/app/tips/components/ShadowBlockTable.tsx b/app/tips/components/ShadowBlockTable.tsx new file mode 100644 index 0000000..a039498 --- /dev/null +++ b/app/tips/components/ShadowBlockTable.tsx @@ -0,0 +1,161 @@ +// Purpose-built 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. Chain-aware: the canonical +// link carries ?chain= via tipsHref. Client-safe: pure formatters only. +import Link from 'next/link'; + +import { cn } from '../../components/ui/cn'; +import type { TipsChain } from '../chains'; +import { formatAge, formatInteger, shortHash } from '../library/explorer-format'; +import { tipsHref } from '../library/links'; +import type { ShadowBlockSummary } from '../library/types'; + +// Canary threshold: rows whose gas differs from canonical by more than this are +// flagged. The working requirement is "gas used within ~50%". +export const GAS_DIFF_THRESHOLD_PCT = 50; + +function formatSignedInteger(value: number): string { + const sign = value > 0 ? '+' : ''; + return `${sign}${value.toLocaleString()}`; +} + +function formatSignedPct(value: number): string { + const sign = value > 0 ? '+' : ''; + return `${sign}${value.toFixed(1)}%`; +} + +export function isGasDiffOutOfBand(block: ShadowBlockSummary): boolean { + return block.gasDiffPct !== undefined && Math.abs(block.gasDiffPct) > GAS_DIFF_THRESHOLD_PCT; +} + +function TableHeader({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function Cell({ children, className }: { children: React.ReactNode; className?: string }) { + return {children}; +} + +const linkClass = 'text-base-blue hover:underline dark:text-bds-blue-20'; + +function GasDiffCell({ block }: { block: ShadowBlockSummary }) { + if (block.gasDiffAbs === undefined || block.gasDiffPct === undefined) { + return ; + } + + const outOfBand = isGasDiffOutOfBand(block); + return ( +
+ + {formatSignedPct(block.gasDiffPct)} + + + {formatSignedInteger(block.gasDiffAbs)} + + {outOfBand ? ( + + >{GAS_DIFF_THRESHOLD_PCT}% + + ) : null} +
+ ); +} + +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, + chain, +}: { + blocks: ShadowBlockSummary[]; + chain: TipsChain; +}) { + return ( +
+ + + + Height + Age + Builder + Gas (shadow / canon) + Gas Δ + Txns (shadow / canon) + Canonical + + + + {blocks.map((block) => ( + + + #{formatInteger(block.number)} +
+ {shortHash(block.hash)} +
+
+ + {formatAge(block.timestamp)} + + + + + + {formatInteger(block.shadowGasUsed)} + / + {formatInteger(block.canonicalGasUsed)} + + + + + + {formatInteger(block.shadowTxCount)} + / + {formatInteger(block.canonicalTxCount)} + {block.txCountDiff !== undefined && block.txCountDiff !== 0 ? ( + + ({formatSignedInteger(block.txCountDiff)}) + + ) : null} + + + + {shortHash(block.canonicalHash)} + + + + ))} + +
+
+ ); +} diff --git a/app/tips/library/client.ts b/app/tips/library/client.ts index 8b5757b..c1a890c 100644 --- a/app/tips/library/client.ts +++ b/app/tips/library/client.ts @@ -10,6 +10,7 @@ import type { BlocksResponse, BundleHistoryResponse, RejectedTransactionsResponse, + ShadowBlocksResponse, TransactionHistoryResponse, TransactionsResponse, } from './types'; @@ -88,4 +89,14 @@ export const tipsApi = { get('/api/tips/rejected', chain, signal), bundle: (hash: string, chain: TipsChain, signal?: AbortSignal) => get(`/api/tips/bundle/${enc(hash)}`, chain, signal), + shadowBlocks: ( + chain: TipsChain, + options?: { offset?: number; limit?: number }, + signal?: AbortSignal, + ) => + get( + withQuery('/api/tips/shadow-blocks', { offset: options?.offset, limit: options?.limit }), + chain, + signal, + ), }; diff --git a/app/tips/library/types.ts b/app/tips/library/types.ts index 56f478b..a7cbb0c 100644 --- a/app/tips/library/types.ts +++ b/app/tips/library/types.ts @@ -19,6 +19,11 @@ export type { RejectionReason, } from '../../api/tips/s3'; export type { BlocksPage, BlockSummary, BlocksResponse } from '../../api/tips/blocks/route'; +export type { + ShadowBlockSummary, + ShadowBlocksPage, + ShadowBlocksResponse, +} from '../../api/tips/shadow-blocks/route'; export type { TransactionListItem, TransactionsResponse } from '../../api/tips/txs/route'; export type { RejectedTransactionsResponse } from '../../api/tips/rejected/route'; export type { BundleHistoryResponse } from '../../api/tips/bundle/[hash]/route'; diff --git a/app/tips/shadow-blocks/layout.tsx b/app/tips/shadow-blocks/layout.tsx new file mode 100644 index 0000000..4d09f49 --- /dev/null +++ b/app/tips/shadow-blocks/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import type { ReactNode } from 'react'; + +export const metadata: Metadata = { + title: 'Shadow Blocks · TIPS', + description: + 'Reorged-out shadow candidate blocks paired with the canonical block that replaced them, with gas and transaction deltas.', +}; + +export default function TipsShadowBlocksLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/app/tips/shadow-blocks/page.tsx b/app/tips/shadow-blocks/page.tsx new file mode 100644 index 0000000..45979f6 --- /dev/null +++ b/app/tips/shadow-blocks/page.tsx @@ -0,0 +1,150 @@ +'use client'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { Suspense, useEffect, useState } from 'react'; + +import { Card } from '../../components/ui/Card'; +import { Spinner } from '../../components/ui/Spinner'; +import { Text } from '../../components/ui/Text'; +import { ExplorerNav } from '../components/ExplorerNav'; +import { GAS_DIFF_THRESHOLD_PCT, ShadowBlockTable, isGasDiffOutOfBand } from '../components/ShadowBlockTable'; +import { tipsApi } from '../library/client'; +import { formatInteger } from '../library/explorer-format'; +import { tipsHref } from '../library/links'; +import type { ShadowBlocksResponse } from '../library/types'; +import { useTipsChain } from '../library/useTipsChain'; + +const PAGE_LIMIT = 25; + +function ShadowBlocksContent() { + const { chain } = useTipsChain(); + 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); + + tipsApi + .shadowBlocks(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(); + }; + }, [chain, offset]); + + const outOfBandCount = data?.blocks.filter(isGasDiffOutOfBand).length ?? 0; + + return ( +
+ + +
+
+ Shadow Blocks + + Reorged-out shadow candidates vs. the canonical block that replaced them. Gas Δ is + shadow − canonical; rows over ±{GAS_DIFF_THRESHOLD_PCT}% are flagged. + +
+ {offset !== undefined && offset > 0 ? ( + + Latest + + ) : null} +
+ + {error ? ( + + + {error} + + + ) : null} + + {!error && data && outOfBandCount > 0 ? ( + + + {outOfBandCount} of {data.blocks.length} shadow blocks on this page differ from canonical + by more than ±{GAS_DIFF_THRESHOLD_PCT}% gas. + + + ) : 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} +
+
+ ); +} + +export default function ShadowBlocksPage() { + return ( + + + + Loading… + + + } + > + + + ); +} From a40d5ce6218eb4e1540ac374575d2b1540d8f300 Mon Sep 17 00:00:00 2001 From: Will Johnston Date: Thu, 20 Aug 2026 13:37:00 -0700 Subject: [PATCH 2/5] refactor(shadow-explorer): promote shadow blocks to a dedicated internal section Move the shadow-blocks surface out of TIPS into a standalone, internal-only Shadow Explorer section modeled for 1:N shadow chains per network: - SHADOW__CHAINS server-side registry (chain URLs never sent to client) + /api/shadow-explorer/{chains,shadow-blocks} route handlers and guard. - Path routing /shadow-explorer///shadow-blocks with network + shadow-chain selectors; top-level nav entry. - deploy.config surface (internal-only) with middleware/llms/sitemap exclusion and the CI public-build-excludes-internal check extended. - Revert the TIPS ExplorerNav/config/client/types shadow additions. - Guard listShadowBlocks against a missing upstream totalCount. Co-authored-by: OpenCode --- .github/workflows/ci.yml | 6 +- app/api/shadow-explorer/chains/route.ts | 19 +++++ app/api/shadow-explorer/config.ts | 58 +++++++++++++ app/api/shadow-explorer/guard.ts | 9 ++ .../shadow-blocks.test.ts | 10 +++ .../shadow-blocks.ts | 13 +-- .../shadow-blocks/route.ts | 27 +++--- app/api/tips/config.ts | 11 --- app/navigation.ts | 7 ++ .../[network]/[chain]/page.tsx | 50 +++++++++++ .../[network]/[chain]/shadow-blocks/page.tsx | 35 ++++++++ app/shadow-explorer/[network]/page.tsx | 28 +++++++ .../components/ShadowBlockTable.tsx | 30 ++----- .../components/ShadowBlocksClient.tsx} | 48 +++-------- app/shadow-explorer/components/ShadowNav.tsx | 84 +++++++++++++++++++ app/shadow-explorer/flag.ts | 11 +++ app/shadow-explorer/layout.tsx | 19 +++++ app/shadow-explorer/library/client.ts | 57 +++++++++++++ app/shadow-explorer/library/format.ts | 45 ++++++++++ app/shadow-explorer/library/links.ts | 7 ++ app/shadow-explorer/library/types.ts | 11 +++ app/shadow-explorer/networks.ts | 37 ++++++++ app/shadow-explorer/page.tsx | 23 +++++ app/tips/components/ExplorerNav.tsx | 8 +- app/tips/library/client.ts | 11 --- app/tips/library/types.ts | 5 -- app/tips/shadow-blocks/layout.tsx | 12 --- deploy.config.mjs | 5 ++ deploy.config.test.mjs | 8 +- 29 files changed, 569 insertions(+), 125 deletions(-) create mode 100644 app/api/shadow-explorer/chains/route.ts create mode 100644 app/api/shadow-explorer/config.ts create mode 100644 app/api/shadow-explorer/guard.ts rename app/api/{tips => shadow-explorer}/shadow-blocks.test.ts (85%) rename app/api/{tips => shadow-explorer}/shadow-blocks.ts (88%) rename app/api/{tips => shadow-explorer}/shadow-blocks/route.ts (57%) create mode 100644 app/shadow-explorer/[network]/[chain]/page.tsx create mode 100644 app/shadow-explorer/[network]/[chain]/shadow-blocks/page.tsx create mode 100644 app/shadow-explorer/[network]/page.tsx rename app/{tips => shadow-explorer}/components/ShadowBlockTable.tsx (86%) rename app/{tips/shadow-blocks/page.tsx => shadow-explorer/components/ShadowBlocksClient.tsx} (75%) create mode 100644 app/shadow-explorer/components/ShadowNav.tsx create mode 100644 app/shadow-explorer/flag.ts create mode 100644 app/shadow-explorer/layout.tsx create mode 100644 app/shadow-explorer/library/client.ts create mode 100644 app/shadow-explorer/library/format.ts create mode 100644 app/shadow-explorer/library/links.ts create mode 100644 app/shadow-explorer/library/types.ts create mode 100644 app/shadow-explorer/networks.ts create mode 100644 app/shadow-explorer/page.tsx delete mode 100644 app/tips/shadow-blocks/layout.tsx 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/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/tips/shadow-blocks.test.ts b/app/api/shadow-explorer/shadow-blocks.test.ts similarity index 85% rename from app/api/tips/shadow-blocks.test.ts rename to app/api/shadow-explorer/shadow-blocks.test.ts index ff97991..beafa86 100644 --- a/app/api/tips/shadow-blocks.test.ts +++ b/app/api/shadow-explorer/shadow-blocks.test.ts @@ -65,6 +65,16 @@ describe('listShadowBlocks pagination', () => { 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 })); diff --git a/app/api/tips/shadow-blocks.ts b/app/api/shadow-explorer/shadow-blocks.ts similarity index 88% rename from app/api/tips/shadow-blocks.ts rename to app/api/shadow-explorer/shadow-blocks.ts index 2eac2b3..c0da7b1 100644 --- a/app/api/tips/shadow-blocks.ts +++ b/app/api/shadow-explorer/shadow-blocks.ts @@ -1,7 +1,7 @@ -// Shadow block listing proxied from the shadow-metrics HTTP API. Chain-aware: -// the caller passes the resolved base URL (getShadowMetricsUrl(chain)). Offset- -// paginated to match the upstream /shadow-blocks endpoint. The `*Diff` fields are -// shadow − canonical (positive = shadow used more). Server-only. +// 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. export const DEFAULT_SHADOW_BLOCKS_PAGE_LIMIT = 25; export const MAX_SHADOW_BLOCKS_PAGE_LIMIT = 100; @@ -112,15 +112,16 @@ export async function listShadowBlocks( 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 < data.totalCount; + const hasMore = nextOffset < totalCount; return { blocks, page: { offset: query.offset, limit: query.limit, - totalCount: data.totalCount, + totalCount, nextOffset: hasMore ? nextOffset : null, hasMore, }, diff --git a/app/api/tips/shadow-blocks/route.ts b/app/api/shadow-explorer/shadow-blocks/route.ts similarity index 57% rename from app/api/tips/shadow-blocks/route.ts rename to app/api/shadow-explorer/shadow-blocks/route.ts index 0f607e9..9137adb 100644 --- a/app/api/tips/shadow-blocks/route.ts +++ b/app/api/shadow-explorer/shadow-blocks/route.ts @@ -1,6 +1,6 @@ -import { resolveTipsChain } from '../../../tips/chains'; -import { getShadowMetricsUrl } from '../config'; -import { tipsDisabledResponse } from '../guard'; +import { resolveShadowNetwork } from '../../../shadow-explorer/networks'; +import { resolveShadowChainUrl } from '../config'; +import { shadowExplorerDisabledResponse } from '../guard'; import { InvalidShadowBlocksQueryError, ShadowBlocksUnavailableError, @@ -10,25 +10,26 @@ import { export const runtime = 'nodejs'; -// Offset-paginated shadow block list, proxied from the shadow-metrics HTTP API. -// See app/api/tips/shadow-blocks.ts. Types are re-exported for the client library. export type { ShadowBlockSummary, ShadowBlocksPage, ShadowBlocksResponse } from '../shadow-blocks'; export async function GET(request: Request) { - const disabled = tipsDisabledResponse(); + const disabled = shadowExplorerDisabledResponse(); if (disabled) return disabled; - const chain = resolveTipsChain(new URL(request.url).searchParams.get('chain')); - const baseUrl = getShadowMetricsUrl(chain); + 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 metrics not configured for this chain' }, - { status: 503 }, - ); + return Response.json({ error: 'Shadow chain not configured' }, { status: 503 }); } try { - const query = parseShadowBlocksQuery(new URL(request.url).searchParams); + const query = parseShadowBlocksQuery(url.searchParams); return Response.json(await listShadowBlocks(baseUrl, query)); } catch (error) { if (error instanceof InvalidShadowBlocksQueryError) { diff --git a/app/api/tips/config.ts b/app/api/tips/config.ts index c5c71eb..ba8edd1 100644 --- a/app/api/tips/config.ts +++ b/app/api/tips/config.ts @@ -86,14 +86,3 @@ export function getAuditRpcUrl(chain: TipsChain): string | undefined { export function isAuditConfigured(chain: TipsChain): boolean { return Boolean(getAuditRpcUrl(chain)); } - -// Shadow-metrics HTTP API base URL for a chain. Opt-in per chain via -// TIPS__SHADOW_METRICS_URL; when unset the shadow blocks surface is -// disabled for that chain (its route returns 503) — mirroring audit. -export function getShadowMetricsUrl(chain: TipsChain): string | undefined { - return envValue([`TIPS_${ENV_PREFIX[chain]}_SHADOW_METRICS_URL`]); -} - -export function isShadowMetricsConfigured(chain: TipsChain): boolean { - return Boolean(getShadowMetricsUrl(chain)); -} 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]/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..fab6844 --- /dev/null +++ b/app/shadow-explorer/[network]/[chain]/shadow-blocks/page.tsx @@ -0,0 +1,35 @@ +import { notFound } from 'next/navigation'; +import { Suspense } from 'react'; + +import { Spinner } from '../../../../components/ui/Spinner'; +import { Text } from '../../../../components/ui/Text'; +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)) 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/tips/components/ShadowBlockTable.tsx b/app/shadow-explorer/components/ShadowBlockTable.tsx similarity index 86% rename from app/tips/components/ShadowBlockTable.tsx rename to app/shadow-explorer/components/ShadowBlockTable.tsx index a039498..392fc11 100644 --- a/app/tips/components/ShadowBlockTable.tsx +++ b/app/shadow-explorer/components/ShadowBlockTable.tsx @@ -1,13 +1,10 @@ -// Purpose-built 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. Chain-aware: the canonical -// link carries ?chain= via tipsHref. Client-safe: pure formatters only. -import Link from 'next/link'; +// 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. Client-safe: pure formatters only. +import type React from 'react'; import { cn } from '../../components/ui/cn'; -import type { TipsChain } from '../chains'; -import { formatAge, formatInteger, shortHash } from '../library/explorer-format'; -import { tipsHref } from '../library/links'; +import { formatAge, formatInteger, shortHash } from '../library/format'; import type { ShadowBlockSummary } from '../library/types'; // Canary threshold: rows whose gas differs from canonical by more than this are @@ -40,8 +37,6 @@ function Cell({ children, className }: { children: React.ReactNode; className?: return {children}; } -const linkClass = 'text-base-blue hover:underline dark:text-bds-blue-20'; - function GasDiffCell({ block }: { block: ShadowBlockSummary }) { if (block.gasDiffAbs === undefined || block.gasDiffPct === undefined) { return ; @@ -86,13 +81,7 @@ function BuilderCell({ block }: { block: ShadowBlockSummary }) { ); } -export function ShadowBlockTable({ - blocks, - chain, -}: { - blocks: ShadowBlockSummary[]; - chain: TipsChain; -}) { +export function ShadowBlockTable({ blocks }: { blocks: ShadowBlockSummary[] }) { return (
@@ -144,13 +133,12 @@ export function ShadowBlockTable({ ) : null} - {shortHash(block.canonicalHash)} - + ))} diff --git a/app/tips/shadow-blocks/page.tsx b/app/shadow-explorer/components/ShadowBlocksClient.tsx similarity index 75% rename from app/tips/shadow-blocks/page.tsx rename to app/shadow-explorer/components/ShadowBlocksClient.tsx index 45979f6..735143a 100644 --- a/app/tips/shadow-blocks/page.tsx +++ b/app/shadow-explorer/components/ShadowBlocksClient.tsx @@ -2,23 +2,20 @@ import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; -import { Suspense, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Card } from '../../components/ui/Card'; import { Spinner } from '../../components/ui/Spinner'; import { Text } from '../../components/ui/Text'; -import { ExplorerNav } from '../components/ExplorerNav'; -import { GAS_DIFF_THRESHOLD_PCT, ShadowBlockTable, isGasDiffOutOfBand } from '../components/ShadowBlockTable'; -import { tipsApi } from '../library/client'; -import { formatInteger } from '../library/explorer-format'; -import { tipsHref } from '../library/links'; -import type { ShadowBlocksResponse } from '../library/types'; -import { useTipsChain } from '../library/useTipsChain'; +import { shadowExplorerApi } from '../library/client'; +import { formatInteger } from '../library/format'; +import { shadowHref } from '../library/links'; +import type { ShadowBlocksResponse, ShadowNetwork } from '../library/types'; +import { GAS_DIFF_THRESHOLD_PCT, ShadowBlockTable, isGasDiffOutOfBand } from './ShadowBlockTable'; const PAGE_LIMIT = 25; -function ShadowBlocksContent() { - const { chain } = useTipsChain(); +export function ShadowBlocksClient({ network, chain }: { network: ShadowNetwork; chain: string }) { const searchParams = useSearchParams(); const offsetParam = searchParams.get('offset'); const offset = offsetParam !== null ? Number(offsetParam) : undefined; @@ -34,8 +31,8 @@ function ShadowBlocksContent() { setError(null); setData(null); - tipsApi - .shadowBlocks(chain, { offset, limit: PAGE_LIMIT }, controller.signal) + shadowExplorerApi + .shadowBlocks(network, chain, { offset, limit: PAGE_LIMIT }, controller.signal) .then((next) => { if (!cancelled) setData(next); }) @@ -51,14 +48,12 @@ function ShadowBlocksContent() { cancelled = true; controller.abort(); }; - }, [chain, offset]); + }, [network, chain, offset]); const outOfBandCount = data?.blocks.filter(isGasDiffOutOfBand).length ?? 0; return (
- -
Shadow Blocks @@ -69,7 +64,7 @@ function ShadowBlocksContent() {
{offset !== undefined && offset > 0 ? ( Latest @@ -103,7 +98,7 @@ function ShadowBlocksContent() {
) : data && data.blocks.length > 0 ? ( - + ) : (
No shadow blocks available @@ -119,7 +114,7 @@ function ShadowBlocksContent() { {data.page.nextOffset !== null ? ( Older → @@ -131,20 +126,3 @@ function ShadowBlocksContent() {
); } - -export default function ShadowBlocksPage() { - return ( - - - - Loading… - -
- } - > - - - ); -} 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..e17a15a --- /dev/null +++ b/app/shadow-explorer/library/links.ts @@ -0,0 +1,7 @@ +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}`; +} diff --git a/app/shadow-explorer/library/types.ts b/app/shadow-explorer/library/types.ts new file mode 100644 index 0000000..6c5cead --- /dev/null +++ b/app/shadow-explorer/library/types.ts @@ -0,0 +1,11 @@ +// 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, + 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 ec1f245..92f4039 100644 --- a/app/tips/components/ExplorerNav.tsx +++ b/app/tips/components/ExplorerNav.tsx @@ -10,7 +10,7 @@ export function ExplorerNav({ active, }: { chain: TipsChain; - active: 'blocks' | 'txs' | 'shadow-blocks'; + 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'; @@ -33,12 +33,6 @@ export function ExplorerNav({ Transactions - - Shadow Blocks - ); } diff --git a/app/tips/library/client.ts b/app/tips/library/client.ts index c1a890c..8b5757b 100644 --- a/app/tips/library/client.ts +++ b/app/tips/library/client.ts @@ -10,7 +10,6 @@ import type { BlocksResponse, BundleHistoryResponse, RejectedTransactionsResponse, - ShadowBlocksResponse, TransactionHistoryResponse, TransactionsResponse, } from './types'; @@ -89,14 +88,4 @@ export const tipsApi = { get('/api/tips/rejected', chain, signal), bundle: (hash: string, chain: TipsChain, signal?: AbortSignal) => get(`/api/tips/bundle/${enc(hash)}`, chain, signal), - shadowBlocks: ( - chain: TipsChain, - options?: { offset?: number; limit?: number }, - signal?: AbortSignal, - ) => - get( - withQuery('/api/tips/shadow-blocks', { offset: options?.offset, limit: options?.limit }), - chain, - signal, - ), }; diff --git a/app/tips/library/types.ts b/app/tips/library/types.ts index a7cbb0c..56f478b 100644 --- a/app/tips/library/types.ts +++ b/app/tips/library/types.ts @@ -19,11 +19,6 @@ export type { RejectionReason, } from '../../api/tips/s3'; export type { BlocksPage, BlockSummary, BlocksResponse } from '../../api/tips/blocks/route'; -export type { - ShadowBlockSummary, - ShadowBlocksPage, - ShadowBlocksResponse, -} from '../../api/tips/shadow-blocks/route'; export type { TransactionListItem, TransactionsResponse } from '../../api/tips/txs/route'; export type { RejectedTransactionsResponse } from '../../api/tips/rejected/route'; export type { BundleHistoryResponse } from '../../api/tips/bundle/[hash]/route'; diff --git a/app/tips/shadow-blocks/layout.tsx b/app/tips/shadow-blocks/layout.tsx deleted file mode 100644 index 4d09f49..0000000 --- a/app/tips/shadow-blocks/layout.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { Metadata } from 'next'; -import type { ReactNode } from 'react'; - -export const metadata: Metadata = { - title: 'Shadow Blocks · TIPS', - description: - 'Reorged-out shadow candidate blocks paired with the canonical block that replaced them, with gas and transaction deltas.', -}; - -export default function TipsShadowBlocksLayout({ children }: { children: ReactNode }) { - return <>{children}; -} 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 () => { From 68625a74bd92e6ac3da47abaa989809b0d489da2 Mon Sep 17 00:00:00 2001 From: Will Johnston Date: Thu, 20 Aug 2026 14:13:59 -0700 Subject: [PATCH 3/5] feat(shadow-explorer): drill into shadow blocks; defer canonical to TIPS Make shadow block rows clickable and add a server-rendered block detail page (/shadow-explorer///block/) that proxies the shadow-metrics /blocks/{id} endpoint: overview + per-tx table. Keep canonical block inspection in TIPS to avoid double duty: the Canonical cell and the detail's canonical-replacement link point at /tips/block/, and the block page redirects any non-reorged (canonical) hit to TIPS so Shadow Explorer renders only reorged-out shadow candidates. Co-authored-by: OpenCode --- app/api/shadow-explorer/block-detail.ts | 65 +++++++ .../[network]/[chain]/block/[id]/page.tsx | 180 ++++++++++++++++++ .../components/ShadowBlockTable.tsx | 53 +++++- .../components/ShadowBlocksClient.tsx | 2 +- app/shadow-explorer/library/links.ts | 7 + 5 files changed, 297 insertions(+), 10 deletions(-) create mode 100644 app/api/shadow-explorer/block-detail.ts create mode 100644 app/shadow-explorer/[network]/[chain]/block/[id]/page.tsx 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/shadow-explorer/[network]/[chain]/block/[id]/page.tsx b/app/shadow-explorer/[network]/[chain]/block/[id]/page.tsx new file mode 100644 index 0000000..8b28a4e --- /dev/null +++ b/app/shadow-explorer/[network]/[chain]/block/[id]/page.tsx @@ -0,0 +1,180 @@ +import Link from 'next/link'; +import { notFound, redirect } from 'next/navigation'; + +import { Card } from '../../../../../components/ui/Card'; +import { Text } from '../../../../../components/ui/Text'; +import { + ShadowBlockNotFoundError, + fetchShadowBlockDetail, + type ShadowBlockDetail, +} from '../../../../../api/shadow-explorer/block-detail'; +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)); + } + + return ( +
+ + +
+ + ← Shadow Blocks + +
+ + {error ? ( + + + {error} + + + ) : detail ? ( + <> +
+
+ Block #{formatInteger(detail.number)} + + {detail.reorgedOut ? 'Reorged-out shadow' : 'Canonical'} + +
+ + {detail.hash} + +
+ + + {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/components/ShadowBlockTable.tsx b/app/shadow-explorer/components/ShadowBlockTable.tsx index 392fc11..b612213 100644 --- a/app/shadow-explorer/components/ShadowBlockTable.tsx +++ b/app/shadow-explorer/components/ShadowBlockTable.tsx @@ -1,10 +1,17 @@ +'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. Client-safe: pure formatters only. +// 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'; // Canary threshold: rows whose gas differs from canonical by more than this are @@ -81,7 +88,17 @@ function BuilderCell({ block }: { block: ShadowBlockSummary }) { ); } -export function ShadowBlockTable({ blocks }: { blocks: ShadowBlockSummary[] }) { +export function ShadowBlockTable({ + blocks, + network, + chain, +}: { + blocks: ShadowBlockSummary[]; + network: ShadowNetwork; + chain: string; +}) { + const router = useRouter(); + return (
@@ -97,8 +114,23 @@ export function ShadowBlockTable({ blocks }: { blocks: ShadowBlockSummary[] }) { - {blocks.map((block) => ( - + {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)}
- 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 index 735143a..4fbadb5 100644 --- a/app/shadow-explorer/components/ShadowBlocksClient.tsx +++ b/app/shadow-explorer/components/ShadowBlocksClient.tsx @@ -98,7 +98,7 @@ export function ShadowBlocksClient({ network, chain }: { network: ShadowNetwork; ) : data && data.blocks.length > 0 ? ( - + ) : (
No shadow blocks available diff --git a/app/shadow-explorer/library/links.ts b/app/shadow-explorer/library/links.ts index e17a15a..9a46d15 100644 --- a/app/shadow-explorer/library/links.ts +++ b/app/shadow-explorer/library/links.ts @@ -5,3 +5,10 @@ import type { ShadowNetwork } from '../networks'; 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}`; +} From 3bff37d8d333b346dae0308f6411f5539706a32f Mon Sep 17 00:00:00 2001 From: Will Johnston Date: Thu, 20 Aug 2026 15:23:30 -0700 Subject: [PATCH 4/5] feat(shadow-explorer): collapse list to a health verdict; per-check breakdown in drilldown Replace the per-metric columns (gas, gas delta, txns, fee inversions) with a single server-computed Health X/N verdict per row; the banner now counts blocks that failed one or more checks. The block drilldown fetches the single-block summary (GET /shadow-blocks/{id}) and renders each check pass/fail with its detail. Co-authored-by: OpenCode --- app/api/shadow-explorer/shadow-blocks.ts | 40 +++++++++ .../shadow-explorer/shadow-blocks/route.ts | 8 +- .../[network]/[chain]/block/[id]/page.tsx | 58 +++++++++++++ .../components/ShadowBlockTable.tsx | 85 ++++++------------- .../components/ShadowBlocksClient.tsx | 15 ++-- app/shadow-explorer/library/types.ts | 2 + 6 files changed, 139 insertions(+), 69 deletions(-) diff --git a/app/api/shadow-explorer/shadow-blocks.ts b/app/api/shadow-explorer/shadow-blocks.ts index c0da7b1..04a33cc 100644 --- a/app/api/shadow-explorer/shadow-blocks.ts +++ b/app/api/shadow-explorer/shadow-blocks.ts @@ -3,9 +3,27 @@ // 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; @@ -23,6 +41,7 @@ export interface ShadowBlockSummary { shadowNonDepositTxCount: number; canonicalNonDepositTxCount?: number; shadowPriorityFeeInversions: number; + health: ShadowBlockHealth; } export interface ShadowBlocksPage { @@ -127,3 +146,24 @@ export async function listShadowBlocks( }, }; } + +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 index 9137adb..fcd739b 100644 --- a/app/api/shadow-explorer/shadow-blocks/route.ts +++ b/app/api/shadow-explorer/shadow-blocks/route.ts @@ -10,7 +10,13 @@ import { export const runtime = 'nodejs'; -export type { ShadowBlockSummary, ShadowBlocksPage, ShadowBlocksResponse } from '../shadow-blocks'; +export type { + ShadowBlockSummary, + ShadowBlockHealth, + ShadowHealthCheck, + ShadowBlocksPage, + ShadowBlocksResponse, +} from '../shadow-blocks'; export async function GET(request: Request) { const disabled = shadowExplorerDisabledResponse(); diff --git a/app/shadow-explorer/[network]/[chain]/block/[id]/page.tsx b/app/shadow-explorer/[network]/[chain]/block/[id]/page.tsx index 8b28a4e..4c84e3a 100644 --- a/app/shadow-explorer/[network]/[chain]/block/[id]/page.tsx +++ b/app/shadow-explorer/[network]/[chain]/block/[id]/page.tsx @@ -2,12 +2,14 @@ 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'; @@ -51,6 +53,15 @@ export default async function ShadowBlockDetailPage({ redirect(tipsCanonicalBlockHref(network, detail.hash)); } + let health: ShadowBlockHealth | null = null; + if (detail) { + try { + health = (await fetchShadowBlock(baseUrl, id)).health; + } catch { + health = null; + } + } + return (
@@ -90,6 +101,53 @@ export default async function ShadowBlockDetailPage({
+ {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)} diff --git a/app/shadow-explorer/components/ShadowBlockTable.tsx b/app/shadow-explorer/components/ShadowBlockTable.tsx index b612213..df660d1 100644 --- a/app/shadow-explorer/components/ShadowBlockTable.tsx +++ b/app/shadow-explorer/components/ShadowBlockTable.tsx @@ -14,22 +14,9 @@ import { shadowHref, tipsCanonicalBlockHref } from '../library/links'; import type { ShadowNetwork } from '../networks'; import type { ShadowBlockSummary } from '../library/types'; -// Canary threshold: rows whose gas differs from canonical by more than this are -// flagged. The working requirement is "gas used within ~50%". -export const GAS_DIFF_THRESHOLD_PCT = 50; - -function formatSignedInteger(value: number): string { - const sign = value > 0 ? '+' : ''; - return `${sign}${value.toLocaleString()}`; -} - -function formatSignedPct(value: number): string { - const sign = value > 0 ? '+' : ''; - return `${sign}${value.toFixed(1)}%`; -} - -export function isGasDiffOutOfBand(block: ShadowBlockSummary): boolean { - return block.gasDiffPct !== undefined && Math.abs(block.gasDiffPct) > GAS_DIFF_THRESHOLD_PCT; +// 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 }) { @@ -44,31 +31,24 @@ function Cell({ children, className }: { children: React.ReactNode; className?: return {children}; } -function GasDiffCell({ block }: { block: ShadowBlockSummary }) { - if (block.gasDiffAbs === undefined || block.gasDiffPct === undefined) { - return ; +function HealthCell({ block }: { block: ShadowBlockSummary }) { + const { reconciled, passed, total } = block.health; + if (!reconciled) { + return pending; } - const outOfBand = isGasDiffOutOfBand(block); + const ok = passed === total; return ( -
- - {formatSignedPct(block.gasDiffPct)} - - - {formatSignedInteger(block.gasDiffAbs)} - - {outOfBand ? ( - - >{GAS_DIFF_THRESHOLD_PCT}% - - ) : null} -
+ + {passed}/{total} + ); } @@ -101,15 +81,13 @@ export function ShadowBlockTable({ return (
- +
HeightAgeBuilder - Gas (shadow / canon) - Gas Δ - Txns (shadow / canon) + HealthCanonical @@ -146,25 +124,10 @@ export function ShadowBlockTable({ - - {formatInteger(block.shadowGasUsed)} - / - {formatInteger(block.canonicalGasUsed)} - - - - - - {formatInteger(block.shadowTxCount)} - / - {formatInteger(block.canonicalTxCount)} - {block.txCountDiff !== undefined && block.txCountDiff !== 0 ? ( - - ({formatSignedInteger(block.txCountDiff)}) - - ) : null} - - + + + + event.stopPropagation()} diff --git a/app/shadow-explorer/components/ShadowBlocksClient.tsx b/app/shadow-explorer/components/ShadowBlocksClient.tsx index 4fbadb5..2e2fd55 100644 --- a/app/shadow-explorer/components/ShadowBlocksClient.tsx +++ b/app/shadow-explorer/components/ShadowBlocksClient.tsx @@ -11,7 +11,7 @@ import { shadowExplorerApi } from '../library/client'; import { formatInteger } from '../library/format'; import { shadowHref } from '../library/links'; import type { ShadowBlocksResponse, ShadowNetwork } from '../library/types'; -import { GAS_DIFF_THRESHOLD_PCT, ShadowBlockTable, isGasDiffOutOfBand } from './ShadowBlockTable'; +import { ShadowBlockTable, isUnhealthy } from './ShadowBlockTable'; const PAGE_LIMIT = 25; @@ -50,7 +50,7 @@ export function ShadowBlocksClient({ network, chain }: { network: ShadowNetwork; }; }, [network, chain, offset]); - const outOfBandCount = data?.blocks.filter(isGasDiffOutOfBand).length ?? 0; + const unhealthyCount = data?.blocks.filter(isUnhealthy).length ?? 0; return (
@@ -58,8 +58,9 @@ export function ShadowBlocksClient({ network, chain }: { network: ShadowNetwork;
Shadow Blocks - Reorged-out shadow candidates vs. the canonical block that replaced them. Gas Δ is - shadow − canonical; rows over ±{GAS_DIFF_THRESHOLD_PCT}% are flagged. + 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 ? ( @@ -80,11 +81,11 @@ export function ShadowBlocksClient({ network, chain }: { network: ShadowNetwork; ) : null} - {!error && data && outOfBandCount > 0 ? ( + {!error && data && unhealthyCount > 0 ? ( - {outOfBandCount} of {data.blocks.length} shadow blocks on this page differ from canonical - by more than ±{GAS_DIFF_THRESHOLD_PCT}% gas. + {unhealthyCount} of {data.blocks.length} shadow blocks on this page failed one or more + health checks. ) : null} diff --git a/app/shadow-explorer/library/types.ts b/app/shadow-explorer/library/types.ts index 6c5cead..e0c5440 100644 --- a/app/shadow-explorer/library/types.ts +++ b/app/shadow-explorer/library/types.ts @@ -4,6 +4,8 @@ export type { ShadowBlockSummary, + ShadowBlockHealth, + ShadowHealthCheck, ShadowBlocksPage, ShadowBlocksResponse, } from '../../api/shadow-explorer/shadow-blocks/route'; From 47d0e78bfa40d952086907cf78641f109c93f438 Mon Sep 17 00:00:00 2001 From: Will Johnston Date: Thu, 20 Aug 2026 16:00:18 -0700 Subject: [PATCH 5/5] fix(shadow-explorer): 404 unconfigured chain on the shadow-blocks page Validate the chain server-side (resolveShadowChainUrl) and notFound() when it is not configured, matching the overview and block-detail pages, instead of rendering chrome and a generic client fetch error. Co-authored-by: OpenCode --- app/shadow-explorer/[network]/[chain]/shadow-blocks/page.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/shadow-explorer/[network]/[chain]/shadow-blocks/page.tsx b/app/shadow-explorer/[network]/[chain]/shadow-blocks/page.tsx index fab6844..4f75f85 100644 --- a/app/shadow-explorer/[network]/[chain]/shadow-blocks/page.tsx +++ b/app/shadow-explorer/[network]/[chain]/shadow-blocks/page.tsx @@ -3,6 +3,7 @@ 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'; @@ -13,7 +14,7 @@ export default async function ShadowBlocksPage({ params: Promise<{ network: string; chain: string }>; }) { const { network, chain } = await params; - if (!isShadowNetwork(network)) notFound(); + if (!isShadowNetwork(network) || !resolveShadowChainUrl(network, chain)) notFound(); return (