From 8d9ff7cf7522cf0e3121a1823c3d5a726f69548e Mon Sep 17 00:00:00 2001 From: Will Johnston Date: Thu, 20 Aug 2026 11:05:44 -0700 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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 (
From 4700859621403735ebea55db721decb67764ddcc Mon Sep 17 00:00:00 2001 From: Will Johnston Date: Fri, 21 Aug 2026 13:29:04 -0700 Subject: [PATCH 6/7] feat(tips): shadow blocks on canonical block detail + shadow delta toggle on block lists Integrate shadow blocks into TIPS instead of a separate section: the canonical block detail lists its shadow blocks (linking to /tips/shadow-block/[hash]), and the Latest Blocks + block explorer lists get a 'Show shadow delta' toggle showing gas and tx deltas vs canonical (inline % + absolute). Adds tips shadow proxies (shadow-candidates, shadow-candidates-batch, shadow-block) + client/types. Removes the /shadow-explorer section and all shadow health UI. Co-authored-by: OpenCode --- .github/workflows/ci.yml | 6 +- app/api/shadow-explorer/block-detail.ts | 65 ----- app/api/shadow-explorer/chains/route.ts | 19 -- app/api/shadow-explorer/config.ts | 58 ----- app/api/shadow-explorer/guard.ts | 9 - app/api/shadow-explorer/shadow-blocks.test.ts | 86 ------- app/api/shadow-explorer/shadow-blocks.ts | 169 ------------- .../shadow-explorer/shadow-blocks/route.ts | 56 ----- app/api/tips/config.ts | 4 + app/api/tips/shadow-block/[hash]/route.ts | 42 ++++ app/api/tips/shadow-candidates-batch/route.ts | 30 +++ app/api/tips/shadow-candidates/route.ts | 45 ++++ app/api/tips/shadow.ts | 122 +++++++++ app/navigation.ts | 7 - .../[network]/[chain]/block/[id]/page.tsx | 238 ------------------ .../[network]/[chain]/page.tsx | 50 ---- .../[network]/[chain]/shadow-blocks/page.tsx | 36 --- app/shadow-explorer/[network]/page.tsx | 28 --- .../components/ShadowBlockTable.tsx | 147 ----------- .../components/ShadowBlocksClient.tsx | 129 ---------- 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 | 14 -- app/shadow-explorer/library/types.ts | 13 - app/shadow-explorer/networks.ts | 37 --- app/shadow-explorer/page.tsx | 23 -- app/tips/block/[hash]/page.tsx | 111 +++++++- app/tips/blocks/page.tsx | 40 ++- app/tips/components/ExplorerTables.tsx | 100 ++++++-- app/tips/library/client.ts | 20 ++ app/tips/library/explorer-format.ts | 37 +++ app/tips/library/types.ts | 2 + app/tips/page.tsx | 91 ++++++- app/tips/shadow-block/[hash]/page.tsx | 214 ++++++++++++++++ deploy.config.mjs | 5 - deploy.config.test.mjs | 8 +- 39 files changed, 833 insertions(+), 1444 deletions(-) delete mode 100644 app/api/shadow-explorer/block-detail.ts delete mode 100644 app/api/shadow-explorer/chains/route.ts delete mode 100644 app/api/shadow-explorer/config.ts delete mode 100644 app/api/shadow-explorer/guard.ts delete mode 100644 app/api/shadow-explorer/shadow-blocks.test.ts delete mode 100644 app/api/shadow-explorer/shadow-blocks.ts delete mode 100644 app/api/shadow-explorer/shadow-blocks/route.ts create mode 100644 app/api/tips/shadow-block/[hash]/route.ts create mode 100644 app/api/tips/shadow-candidates-batch/route.ts create mode 100644 app/api/tips/shadow-candidates/route.ts create mode 100644 app/api/tips/shadow.ts delete mode 100644 app/shadow-explorer/[network]/[chain]/block/[id]/page.tsx delete mode 100644 app/shadow-explorer/[network]/[chain]/page.tsx delete mode 100644 app/shadow-explorer/[network]/[chain]/shadow-blocks/page.tsx delete mode 100644 app/shadow-explorer/[network]/page.tsx delete mode 100644 app/shadow-explorer/components/ShadowBlockTable.tsx delete mode 100644 app/shadow-explorer/components/ShadowBlocksClient.tsx delete mode 100644 app/shadow-explorer/components/ShadowNav.tsx delete mode 100644 app/shadow-explorer/flag.ts delete mode 100644 app/shadow-explorer/layout.tsx delete mode 100644 app/shadow-explorer/library/client.ts delete mode 100644 app/shadow-explorer/library/format.ts delete mode 100644 app/shadow-explorer/library/links.ts delete mode 100644 app/shadow-explorer/library/types.ts delete mode 100644 app/shadow-explorer/networks.ts delete mode 100644 app/shadow-explorer/page.tsx create mode 100644 app/tips/shadow-block/[hash]/page.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3a5c22..e0208fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,9 +127,7 @@ 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 \ - /shadow-explorer /shadow-explorer/mainnet/canary/shadow-blocks \ - /api/shadow-explorer/chains /api/shadow-explorer/shadow-blocks; do + /benchmark/load-tests/sepolia; 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" @@ -147,7 +145,7 @@ jobs: done # No nav link to, or sitemap entry for, an internal-only section. - for section in /tips /benchmark /shadow-explorer; do + for section in /tips /benchmark; 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 deleted file mode 100644 index b6dd876..0000000 --- a/app/api/shadow-explorer/block-detail.ts +++ /dev/null @@ -1,65 +0,0 @@ -// 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 deleted file mode 100644 index 9040156..0000000 --- a/app/api/shadow-explorer/chains/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 2109570..0000000 --- a/app/api/shadow-explorer/config.ts +++ /dev/null @@ -1,58 +0,0 @@ -// 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 deleted file mode 100644 index c002ea1..0000000 --- a/app/api/shadow-explorer/guard.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index beafa86..0000000 --- a/app/api/shadow-explorer/shadow-blocks.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index 04a33cc..0000000 --- a/app/api/shadow-explorer/shadow-blocks.ts +++ /dev/null @@ -1,169 +0,0 @@ -// 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 deleted file mode 100644 index fcd739b..0000000 --- a/app/api/shadow-explorer/shadow-blocks/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -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/api/tips/config.ts b/app/api/tips/config.ts index ba8edd1..5692f79 100644 --- a/app/api/tips/config.ts +++ b/app/api/tips/config.ts @@ -83,6 +83,10 @@ export function getAuditRpcUrl(chain: TipsChain): string | undefined { return envValue([`TIPS_${ENV_PREFIX[chain]}_AUDIT_RPC_URL`]); } +export function getShadowMetricsUrl(chain: TipsChain): string | undefined { + return envValue([`TIPS_${ENV_PREFIX[chain]}_SHADOW_METRICS_URL`]); +} + export function isAuditConfigured(chain: TipsChain): boolean { return Boolean(getAuditRpcUrl(chain)); } diff --git a/app/api/tips/shadow-block/[hash]/route.ts b/app/api/tips/shadow-block/[hash]/route.ts new file mode 100644 index 0000000..92f5669 --- /dev/null +++ b/app/api/tips/shadow-block/[hash]/route.ts @@ -0,0 +1,42 @@ +import { resolveTipsChain } from '../../../../tips/chains'; +import { getShadowMetricsUrl } from '../../config'; +import { tipsDisabledResponse } from '../../guard'; +import { + ShadowNotFoundError, + ShadowUnavailableError, + fetchShadowBlockDetail, + fetchShadowBlockSummary, +} from '../../shadow'; + +export const runtime = 'nodejs'; + +export async function GET(request: Request, { params }: { params: Promise<{ hash: string }> }) { + const disabled = tipsDisabledResponse(); + if (disabled) return disabled; + + const url = new URL(request.url); + const chain = resolveTipsChain(url.searchParams.get('chain')); + const baseUrl = getShadowMetricsUrl(chain); + if (!baseUrl) { + return Response.json({ error: 'Shadow metrics not configured' }, { status: 503 }); + } + + try { + const { hash } = await params; + const [summary, detail] = await Promise.all([ + fetchShadowBlockSummary(baseUrl, hash), + fetchShadowBlockDetail(baseUrl, hash), + ]); + return Response.json({ summary, detail }); + } catch (error) { + if (error instanceof ShadowNotFoundError) { + return Response.json({ error: 'Shadow block not found' }, { status: 404 }); + } + + console.error('Error fetching shadow block:', error); + return Response.json( + { error: 'Shadow block unavailable' }, + { status: error instanceof ShadowUnavailableError ? 503 : 500 }, + ); + } +} diff --git a/app/api/tips/shadow-candidates-batch/route.ts b/app/api/tips/shadow-candidates-batch/route.ts new file mode 100644 index 0000000..10b607e --- /dev/null +++ b/app/api/tips/shadow-candidates-batch/route.ts @@ -0,0 +1,30 @@ +import { resolveTipsChain } from '../../../tips/chains'; +import { getShadowMetricsUrl } from '../config'; +import { tipsDisabledResponse } from '../guard'; +import { fetchShadowCandidatesBatch } from '../shadow'; + +export const runtime = 'nodejs'; + +export async function GET(request: Request) { + const disabled = tipsDisabledResponse(); + if (disabled) return disabled; + + const url = new URL(request.url); + const chain = resolveTipsChain(url.searchParams.get('chain')); + const canonical = url.searchParams.get('canonical'); + if (!canonical) { + return Response.json({ error: 'Missing canonical hashes' }, { status: 400 }); + } + + const baseUrl = getShadowMetricsUrl(chain); + if (!baseUrl) { + return Response.json({ error: 'Shadow metrics not configured' }, { status: 503 }); + } + + const hashes = canonical + .split(',') + .map((hash) => hash.trim()) + .filter(Boolean); + + return Response.json(await fetchShadowCandidatesBatch(baseUrl, hashes)); +} diff --git a/app/api/tips/shadow-candidates/route.ts b/app/api/tips/shadow-candidates/route.ts new file mode 100644 index 0000000..e18054a --- /dev/null +++ b/app/api/tips/shadow-candidates/route.ts @@ -0,0 +1,45 @@ +import { resolveTipsChain } from '../../../tips/chains'; +import { getShadowMetricsUrl } from '../config'; +import { tipsDisabledResponse } from '../guard'; +import { + ShadowNotFoundError, + ShadowUnavailableError, + fetchShadowCandidates, +} from '../shadow'; + +export const runtime = 'nodejs'; + +// Re-export for client typing. +export type { ShadowBlockSummary } from '../shadow'; + +export async function GET(request: Request) { + const disabled = tipsDisabledResponse(); + if (disabled) return disabled; + + const url = new URL(request.url); + const chain = resolveTipsChain(url.searchParams.get('chain')); + const canonical = url.searchParams.get('canonical'); + if (!canonical) { + return Response.json({ error: 'Missing canonical hash' }, { status: 400 }); + } + + const baseUrl = getShadowMetricsUrl(chain); + if (!baseUrl) { + return Response.json({ error: 'Shadow metrics not configured' }, { status: 503 }); + } + + try { + const candidates = await fetchShadowCandidates(baseUrl, canonical); + return Response.json({ candidates }); + } catch (error) { + if (error instanceof ShadowNotFoundError) { + return Response.json({ candidates: [] }); + } + + console.error('Error fetching shadow candidates:', error); + return Response.json( + { error: 'Shadow candidates unavailable' }, + { status: error instanceof ShadowUnavailableError ? 503 : 500 }, + ); + } +} diff --git a/app/api/tips/shadow.ts b/app/api/tips/shadow.ts new file mode 100644 index 0000000..6a7d5be --- /dev/null +++ b/app/api/tips/shadow.ts @@ -0,0 +1,122 @@ +// Shadow-metrics proxy types + fetchers for TIPS shadow blocks. +// Server-only: do not import from client bundles. + +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 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 ShadowUnavailableError extends Error { + constructor(message = 'shadow metrics unavailable') { + super(message); + this.name = 'ShadowUnavailableError'; + } +} + +export class ShadowNotFoundError extends Error { + constructor(message = 'shadow block not found') { + super(message); + this.name = 'ShadowNotFoundError'; + } +} + +async function fetchShadowMetrics(url: string): Promise { + let response: Response; + try { + response = await fetch(url, { cache: 'no-store' }); + } catch { + throw new ShadowUnavailableError('failed to reach shadow-metrics'); + } + + if (response.status === 404) { + throw new ShadowNotFoundError(); + } + + if (!response.ok) { + throw new ShadowUnavailableError(`shadow-metrics responded ${response.status}`); + } + + return (await response.json()) as T; +} + +export async function fetchShadowCandidates( + baseUrl: string, + canonicalHash: string, +): Promise { + const batch = await fetchShadowCandidatesBatch(baseUrl, [canonicalHash]); + return batch[canonicalHash] ?? []; +} + +export async function fetchShadowCandidatesBatch( + baseUrl: string, + hashes: string[], +): Promise> { + if (hashes.length === 0) return {}; + const root = baseUrl.replace(/\/$/, ''); + const canonical = encodeURIComponent(hashes.join(',')); + const url = `${root}/shadow-candidates?canonical=${canonical}`; + + try { + const response = await fetch(url, { cache: 'no-store' }); + if (!response.ok) return {}; + return (await response.json()) as Record; + } catch { + return {}; + } +} + +export async function fetchShadowBlockSummary( + baseUrl: string, + hash: string, +): Promise { + const root = baseUrl.replace(/\/$/, ''); + const url = `${root}/shadow-blocks/${encodeURIComponent(hash)}`; + return fetchShadowMetrics(url); +} + +export async function fetchShadowBlockDetail( + baseUrl: string, + hash: string, +): Promise { + const root = baseUrl.replace(/\/$/, ''); + const url = `${root}/blocks/${encodeURIComponent(hash)}`; + return fetchShadowMetrics(url); +} diff --git a/app/navigation.ts b/app/navigation.ts index 0dfdc2d..2ea0d8f 100644 --- a/app/navigation.ts +++ b/app/navigation.ts @@ -1,5 +1,4 @@ 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'; @@ -41,12 +40,6 @@ 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 deleted file mode 100644 index 4c84e3a..0000000 --- a/app/shadow-explorer/[network]/[chain]/block/[id]/page.tsx +++ /dev/null @@ -1,238 +0,0 @@ -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 deleted file mode 100644 index 140837a..0000000 --- a/app/shadow-explorer/[network]/[chain]/page.tsx +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 4f75f85..0000000 --- a/app/shadow-explorer/[network]/[chain]/shadow-blocks/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 4a5b88d..0000000 --- a/app/shadow-explorer/[network]/page.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index df660d1..0000000 --- a/app/shadow-explorer/components/ShadowBlockTable.tsx +++ /dev/null @@ -1,147 +0,0 @@ -'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 deleted file mode 100644 index 2e2fd55..0000000 --- a/app/shadow-explorer/components/ShadowBlocksClient.tsx +++ /dev/null @@ -1,129 +0,0 @@ -'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 deleted file mode 100644 index 3c991b7..0000000 --- a/app/shadow-explorer/components/ShadowNav.tsx +++ /dev/null @@ -1,84 +0,0 @@ -'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 deleted file mode 100644 index d80fb7a..0000000 --- a/app/shadow-explorer/flag.ts +++ /dev/null @@ -1,11 +0,0 @@ -// 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 deleted file mode 100644 index e4a439f..0000000 --- a/app/shadow-explorer/layout.tsx +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 5edc889..0000000 --- a/app/shadow-explorer/library/client.ts +++ /dev/null @@ -1,57 +0,0 @@ -// 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 deleted file mode 100644 index 77c7069..0000000 --- a/app/shadow-explorer/library/format.ts +++ /dev/null @@ -1,45 +0,0 @@ -// 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 deleted file mode 100644 index 9a46d15..0000000 --- a/app/shadow-explorer/library/links.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index e0c5440..0000000 --- a/app/shadow-explorer/library/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -// 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 deleted file mode 100644 index 62981f7..0000000 --- a/app/shadow-explorer/networks.ts +++ /dev/null @@ -1,37 +0,0 @@ -// 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 deleted file mode 100644 index 190a6cb..0000000 --- a/app/shadow-explorer/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -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/block/[hash]/page.tsx b/app/tips/block/[hash]/page.tsx index 247ba06..1109840 100644 --- a/app/tips/block/[hash]/page.tsx +++ b/app/tips/block/[hash]/page.tsx @@ -1,6 +1,7 @@ 'use client'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { Suspense, useEffect, useState } from 'react'; import { Card } from '../../../components/ui/Card'; @@ -13,10 +14,14 @@ import { EventHistoryRow } from '../../components/EventHistoryRow'; import { TipsExplorerLink } from '../../components/TipsExplorerLink'; import type { TipsChain } from '../../chains'; import { tipsApi, TipsApiError } from '../../library/client'; -import { formatGwei } from '../../library/explorer-format'; +import { formatAge, formatGwei, formatInteger } from '../../library/explorer-format'; import { shortHash } from '../../library/format'; import { tipsHref } from '../../library/links'; -import type { BlockDetailResponse, BlockDetailTransaction } from '../../library/types'; +import type { + BlockDetailResponse, + BlockDetailTransaction, + ShadowBlockSummary, +} from '../../library/types'; import { useTipsChain } from '../../library/useTipsChain'; interface PageProps { @@ -176,6 +181,78 @@ function BlockStats({ block }: { block: BlockDetailResponse }) { ); } +const CANDIDATE_HEADER = + 'whitespace-nowrap px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-bds-gray-60 dark:text-bds-gray-40'; + +function ShadowCandidatesTable({ + candidates, + chain, +}: { + candidates: ShadowBlockSummary[]; + chain: TipsChain; +}) { + const router = useRouter(); + + return ( +
+ + + + + + + + + + {candidates.map((block) => { + const open = () => router.push(tipsHref(`/tips/shadow-block/${block.hash}`, chain)); + 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" + > + + + + + ); + })} + +
BlockBuilderAge
+ #{formatInteger(block.number)} +
+ {shortHash(block.hash)} +
+
+
+ {block.shadowBuilderVersion} + {block.canonicalBuilderVersion && + block.canonicalBuilderVersion !== block.shadowBuilderVersion ? ( + + canon: {block.canonicalBuilderVersion} + + ) : null} +
+
+ {formatAge(block.timestamp)} +
+
+ ); +} + function BlockToolbar({ chain, hash, @@ -246,6 +323,7 @@ function BlockContent({ params }: PageProps) { const { chain } = useTipsChain(); const [hash, setHash] = useState(''); const [data, setData] = useState(null); + const [shadowCandidates, setShadowCandidates] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -286,6 +364,21 @@ function BlockContent({ params }: PageProps) { }; }, [hash, chain]); + useEffect(() => { + if (!data?.hash) return; + const controller = new AbortController(); + setShadowCandidates(null); + + tipsApi + .shadowCandidates(chain, data.hash, controller.signal) + .then((response) => setShadowCandidates(response.candidates)) + .catch(() => setShadowCandidates(null)); + + return () => { + controller.abort(); + }; + }, [chain, data?.hash]); + if (!hash || loading) { return (
@@ -321,6 +414,20 @@ function BlockContent({ params }: PageProps) { + {shadowCandidates && shadowCandidates.length > 0 ? ( +
+
+ Shadow blocks + + Shadow blocks reorged out in favor of this block. + +
+ + + +
+ ) : null} +
Transactions diff --git a/app/tips/blocks/page.tsx b/app/tips/blocks/page.tsx index 574fa4f..64b0be3 100644 --- a/app/tips/blocks/page.tsx +++ b/app/tips/blocks/page.tsx @@ -12,7 +12,7 @@ import { ExplorerNav } from '../components/ExplorerNav'; import { tipsApi } from '../library/client'; import { formatInteger } from '../library/explorer-format'; import { tipsHref } from '../library/links'; -import type { BlocksResponse } from '../library/types'; +import type { BlocksResponse, ShadowBlockSummary } from '../library/types'; import { useTipsChain } from '../library/useTipsChain'; const PAGE_LIMIT = 25; @@ -26,6 +26,8 @@ function BlocksContent() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [showShadowDelta, setShowShadowDelta] = useState(false); + const [shadowCandidates, setShadowCandidates] = useState>({}); useEffect(() => { let cancelled = false; @@ -53,6 +55,25 @@ function BlocksContent() { }; }, [chain, cursor]); + useEffect(() => { + if (!showShadowDelta || !data?.blocks.length) { + setShadowCandidates({}); + return undefined; + } + + const controller = new AbortController(); + const hashes = data.blocks.map((block) => block.hash); + + tipsApi + .shadowCandidatesBatch(chain, hashes, controller.signal) + .then((response) => setShadowCandidates(response)) + .catch(() => setShadowCandidates({})); + + return () => { + controller.abort(); + }; + }, [chain, data?.blocks, showShadowDelta]); + return (
@@ -74,6 +95,16 @@ function BlocksContent() { ) : null}
+ + {error ? ( @@ -91,7 +122,12 @@ function BlocksContent() {
) : data && data.blocks.length > 0 ? ( - + ) : (
No blocks available diff --git a/app/tips/components/ExplorerTables.tsx b/app/tips/components/ExplorerTables.tsx index 42d2e75..ad9e341 100644 --- a/app/tips/components/ExplorerTables.tsx +++ b/app/tips/components/ExplorerTables.tsx @@ -10,12 +10,15 @@ import { formatEth, formatGwei, formatInteger, + formatSignedGas, + formatSignedInteger, + formatSignedPct, type NumericValue, shortAddress, shortHash, } from '../library/explorer-format'; import { tipsHref } from '../library/links'; -import type { BlockSummary } from '../library/types'; +import type { BlockSummary, ShadowBlockSummary } from '../library/types'; export interface TransactionTableItem { hash: string; @@ -43,10 +46,21 @@ function Cell({ children, className }: { children: React.ReactNode; className?: return {children}; } -export function BlockTable({ blocks, chain }: { blocks: BlockSummary[]; chain: TipsChain }) { +export function BlockTable({ + blocks, + chain, + showShadowDelta, + shadowBlocks, +}: { + blocks: BlockSummary[]; + chain: TipsChain; + showShadowDelta?: boolean; + shadowBlocks?: Record; +}) { + const showDelta = Boolean(showShadowDelta); return (
- +
Block @@ -55,28 +69,70 @@ export function BlockTable({ blocks, chain }: { blocks: BlockSummary[]; chain: T Gas UsedGas LimitBase Fee + {showDelta ? Gas Δ : null} + {showDelta ? Tx Δ : null} - {blocks.map((block) => ( - - - - #{formatInteger(block.number)} - -
- {shortHash(block.hash)} -
-
- - {formatAge(block.timestamp)} - - {formatInteger(block.transactionCount)} - {formatInteger(block.gasUsed)} - {formatInteger(block.gasLimit)} - {formatGwei(block.baseFeePerGas)} - - ))} + {blocks.map((block) => { + const shadowBlock = shadowBlocks?.[block.hash]?.[0]; + const gasDiffPct = shadowBlock?.gasDiffPct; + const gasDiffAbs = shadowBlock?.gasDiffAbs; + const txCountDiff = shadowBlock?.txCountDiff; + const canonicalTxCount = shadowBlock?.canonicalTxCount; + const txDiffPct = + canonicalTxCount && canonicalTxCount > 0 && txCountDiff !== undefined + ? (txCountDiff / canonicalTxCount) * 100 + : undefined; + const hasGasDelta = gasDiffPct !== undefined && gasDiffAbs !== undefined; + const gasDeltaClass = + gasDiffPct !== undefined && Math.abs(gasDiffPct) > 50 + ? 'text-bds-red-70 dark:text-bds-red-20' + : 'text-black dark:text-white'; + + return ( + + + + #{formatInteger(block.number)} + +
+ {shortHash(block.hash)} +
+
+ + {formatAge(block.timestamp)} + + {formatInteger(block.transactionCount)} + {formatInteger(block.gasUsed)} + {formatInteger(block.gasLimit)} + {formatGwei(block.baseFeePerGas)} + {showDelta ? ( + + {hasGasDelta ? ( + + {formatSignedPct(gasDiffPct)} ({formatSignedGas(gasDiffAbs)}) + + ) : ( + + )} + + ) : null} + {showDelta ? ( + + {txCountDiff !== undefined ? ( + + {txDiffPct !== undefined ? `${formatSignedPct(txDiffPct)} ` : ''}( + {formatSignedInteger(txCountDiff)}) + + ) : ( + + )} + + ) : null} + + ); + })}
diff --git a/app/tips/library/client.ts b/app/tips/library/client.ts index 8b5757b..d542a62 100644 --- a/app/tips/library/client.ts +++ b/app/tips/library/client.ts @@ -10,6 +10,8 @@ import type { BlocksResponse, BundleHistoryResponse, RejectedTransactionsResponse, + ShadowBlockDetail, + ShadowBlockSummary, TransactionHistoryResponse, TransactionsResponse, } from './types'; @@ -88,4 +90,22 @@ export const tipsApi = { get('/api/tips/rejected', chain, signal), bundle: (hash: string, chain: TipsChain, signal?: AbortSignal) => get(`/api/tips/bundle/${enc(hash)}`, chain, signal), + shadowCandidates: (chain: TipsChain, canonicalHash: string, signal?: AbortSignal) => + get<{ candidates: ShadowBlockSummary[] }>( + withQuery('/api/tips/shadow-candidates', { canonical: canonicalHash }), + chain, + signal, + ), + shadowCandidatesBatch: (chain: TipsChain, hashes: string[], signal?: AbortSignal) => + get>( + withQuery('/api/tips/shadow-candidates-batch', { canonical: hashes.join(',') }), + chain, + signal, + ), + shadowBlock: (hash: string, chain: TipsChain, signal?: AbortSignal) => + get<{ summary: ShadowBlockSummary; detail: ShadowBlockDetail }>( + `/api/tips/shadow-block/${enc(hash)}`, + chain, + signal, + ), }; diff --git a/app/tips/library/explorer-format.ts b/app/tips/library/explorer-format.ts index e164b59..edb9de2 100644 --- a/app/tips/library/explorer-format.ts +++ b/app/tips/library/explorer-format.ts @@ -43,6 +43,43 @@ export function formatInteger(value: NumericValue): string { return parsed === null ? '—' : parsed.toLocaleString(); } +export function formatSignedInteger(value: number): string { + return `${value > 0 ? '+' : ''}${value.toLocaleString()}`; +} + +export function formatSignedPct(value: number): string { + return `${value > 0 ? '+' : ''}${value.toFixed(1)}%`; +} + +function trimTrailingZeros(value: string): string { + if (!value.includes('.')) return value; + const trimmed = value.replace(/0+$/, ''); + return trimmed.endsWith('.') ? trimmed.slice(0, -1) : trimmed; +} + +export function formatSignedGas(value: number): string { + if (value === 0) return '0'; + const sign = value > 0 ? '+' : '-'; + const absValue = Math.abs(value); + + if (absValue < 1_000) { + return `${sign}${Math.round(absValue)}`; + } + + if (absValue < 1_000_000) { + const formatted = trimTrailingZeros((absValue / 1_000).toFixed(1)); + return `${sign}${formatted}K`; + } + + if (absValue < 1_000_000_000) { + const formatted = trimTrailingZeros((absValue / 1_000_000).toFixed(2)); + return `${sign}${formatted}M`; + } + + const formatted = trimTrailingZeros((absValue / 1_000_000_000).toFixed(2)); + return `${sign}${formatted}B`; +} + export function formatEth(value: NumericValue): string { const formatted = formatUnits(value, WEI_PER_ETH, 6); return formatted === '—' ? formatted : `${formatted} ETH`; diff --git a/app/tips/library/types.ts b/app/tips/library/types.ts index 56f478b..607ef2c 100644 --- a/app/tips/library/types.ts +++ b/app/tips/library/types.ts @@ -34,6 +34,8 @@ export type { TransactionLookupResponse, } from '../../api/tips/transaction-lookup'; export type { AuditTransactionEventRecord } from '../../api/tips/audit-events'; +export type { ShadowBlockSummary } from '../../api/tips/shadow-candidates/route'; +export type { ShadowBlockDetail, ShadowTxSummary } from '../../api/tips/shadow'; import type { BundleEvent } from '../../api/tips/s3'; import type { MeterBundleResponse, MeterBundleResult, RejectionReason } from '../../api/tips/s3'; diff --git a/app/tips/page.tsx b/app/tips/page.tsx index 7b6e5a5..36ff3ef 100644 --- a/app/tips/page.tsx +++ b/app/tips/page.tsx @@ -17,12 +17,14 @@ import { ChainToggle } from './components/ChainToggle'; import { MeteringCard } from './components/MeteringCard'; import type { TipsChain } from './chains'; import { tipsApi } from './library/client'; +import { formatSignedGas, formatSignedInteger, formatSignedPct } from './library/explorer-format'; import { formatGasPrice, formatHexValue, shortHash, timeAgoFromSeconds } from './library/format'; import { tipsHref } from './library/links'; import { formatRejectionReason, type BlockSummary, type RejectedTransaction, + type ShadowBlockSummary, } from './library/types'; import { useTipsChain } from './library/useTipsChain'; @@ -93,7 +95,31 @@ function SearchBar({ chain, onError }: { chain: TipsChain; onError: (error: stri // --- Blocks --------------------------------------------------------------- -function BlockRow({ block, chain }: { block: BlockSummary; chain: TipsChain }) { +function BlockRow({ + block, + chain, + showShadowDelta, + shadowBlock, +}: { + block: BlockSummary; + chain: TipsChain; + showShadowDelta: boolean; + shadowBlock?: ShadowBlockSummary; +}) { + const gasDiffPct = shadowBlock?.gasDiffPct; + const gasDiffAbs = shadowBlock?.gasDiffAbs; + const txCountDiff = shadowBlock?.txCountDiff; + const canonicalTxCount = shadowBlock?.canonicalTxCount; + const txDiffPct = + canonicalTxCount && canonicalTxCount > 0 && txCountDiff !== undefined + ? (txCountDiff / canonicalTxCount) * 100 + : undefined; + const hasGasDelta = gasDiffPct !== undefined && gasDiffAbs !== undefined; + const gasDeltaClass = + gasDiffPct !== undefined && Math.abs(gasDiffPct) > 50 + ? 'text-bds-red-70 dark:text-bds-red-20' + : 'text-foreground'; + return ( txns
+ {showShadowDelta ? ( +
+
Gas Δ
+
+ {hasGasDelta ? `${formatSignedPct(gasDiffPct)} (${formatSignedGas(gasDiffAbs)})` : '—'} +
+
Tx Δ
+
+ {txCountDiff !== undefined + ? `${txDiffPct !== undefined ? `${formatSignedPct(txDiffPct)} ` : ''}(${formatSignedInteger( + txCountDiff, + )})` + : '—'} +
+
+ ) : null} ([]); const [loading, setLoading] = useState(true); + const [showShadowDelta, setShowShadowDelta] = useState(false); + const [shadowCandidates, setShadowCandidates] = useState>({}); useEffect(() => { let cancelled = false; @@ -172,9 +221,39 @@ function BlocksTab({ chain }: { chain: TipsChain }) { }; }, [chain]); + useEffect(() => { + if (!showShadowDelta || blocks.length === 0) { + setShadowCandidates({}); + return undefined; + } + + const controller = new AbortController(); + const hashes = blocks.map((block) => block.hash); + + tipsApi + .shadowCandidatesBatch(chain, hashes, controller.signal) + .then((response) => setShadowCandidates(response)) + .catch(() => setShadowCandidates({})); + + return () => { + controller.abort(); + }; + }, [blocks, chain, showShadowDelta]); + return (
- Latest Blocks +
+ Latest Blocks + +
{loading && blocks.length === 0 ? (
@@ -186,7 +265,13 @@ function BlocksTab({ chain }: { chain: TipsChain }) { ) : blocks.length > 0 ? (
{blocks.map((block) => ( - + ))}
) : ( diff --git a/app/tips/shadow-block/[hash]/page.tsx b/app/tips/shadow-block/[hash]/page.tsx new file mode 100644 index 0000000..e9a8242 --- /dev/null +++ b/app/tips/shadow-block/[hash]/page.tsx @@ -0,0 +1,214 @@ +'use client'; + +import Link from 'next/link'; +import { Suspense, useEffect, useState } from 'react'; + +import { Card } from '../../../components/ui/Card'; +import { EmptyState } from '../../../components/ui/EmptyState'; +import { Spinner } from '../../../components/ui/Spinner'; +import { Text } from '../../../components/ui/Text'; +import { tipsApi, TipsApiError } from '../../library/client'; +import { formatAge, formatInteger } from '../../library/explorer-format'; +import { shortHash } from '../../library/format'; +import { tipsHref } from '../../library/links'; +import type { ShadowBlockDetail, ShadowBlockSummary } from '../../library/types'; +import { useTipsChain } from '../../library/useTipsChain'; + +interface PageProps { + params: Promise<{ hash: string }>; +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {label} + + {children} +
+ ); +} + +function ShadowBlockContent({ params }: PageProps) { + const { chain } = useTipsChain(); + const [hash, setHash] = useState(''); + const [summary, setSummary] = useState(null); + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + void params.then((p) => setHash(p.hash)); + }, [params]); + + useEffect(() => { + if (!hash) return; + let cancelled = false; + setLoading(true); + setError(null); + setSummary(null); + setDetail(null); + + async function load() { + try { + const response = await tipsApi.shadowBlock(hash, chain); + if (!cancelled) { + setSummary(response.summary); + setDetail(response.detail); + setError(null); + } + } catch (err) { + if (cancelled) return; + setError( + err instanceof TipsApiError && err.status === 404 + ? 'Shadow block not found' + : 'Failed to fetch shadow block data', + ); + } finally { + if (!cancelled) setLoading(false); + } + } + + void load(); + return () => { + cancelled = true; + }; + }, [hash, chain]); + + if (!hash || loading) { + return ( +
+ + + Loading shadow block… + +
+ ); + } + + return ( +
+
+ + ← TIPS + +
+ + {error ? : null} + + {summary && detail ? ( +
+
+
+ Shadow Block #{formatInteger(summary.number)} + + Reorged-out shadow + +
+ + {summary.hash} + +
+ + + {formatAge(detail.timestamp)} + {formatInteger(detail.txCount)} + {formatInteger(detail.gasUsed)} + {formatInteger(detail.gasLimit)} + + + {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} +
+ ); +} + +export default function ShadowBlockPage({ params }: PageProps) { + return ( + + + + Loading shadow block… + +
+ } + > + + + ); +} diff --git a/deploy.config.mjs b/deploy.config.mjs index ceb8cab..37ea241 100644 --- a/deploy.config.mjs +++ b/deploy.config.mjs @@ -40,11 +40,6 @@ 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 929529d..c99a0f1 100644 --- a/deploy.config.test.mjs +++ b/deploy.config.test.mjs @@ -28,22 +28,19 @@ 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', '/shadow-explorer']); + expect(c.disabledRoutePrefixes()).toEqual(['/tips', '/benchmark']); // 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', '/api/shadow-explorer']); + expect(c.disabledApiPrefixes()).toEqual(['/api/tips']); expect(c.disabledRouteGlobs()).toEqual([ '/tips', '/tips/**', '/benchmark', '/benchmark/**', - '/shadow-explorer', - '/shadow-explorer/**', ]); }); }); @@ -54,7 +51,6 @@ 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 186cb0fa8849077390c31a09d67c323f59f9556c Mon Sep 17 00:00:00 2001 From: Will Johnston Date: Fri, 21 Aug 2026 14:06:45 -0700 Subject: [PATCH 7/7] refactor(tips): compute shadow deltas client-side; harden shadow proxies Backend now returns shadow-only summaries, so gas/tx deltas are computed in the UI against the canonical block TIPS already has (gasUsed threaded through the dashboard block list). Show absolute delta even when the percentage is undefined (canonical value 0). Batch shadow fetches key on the visible canonical-hash set to avoid refetching every poll; add fetch timeouts; validate/cap canonical-hash input in the proxy routes. Co-authored-by: OpenCode --- app/api/tips/shadow-candidates-batch/route.ts | 14 +++- app/api/tips/shadow-candidates/route.ts | 29 +++----- app/api/tips/shadow.ts | 69 ++++++++++++++---- app/tips/block/[hash]/page.tsx | 10 +-- app/tips/blocks/page.tsx | 12 ++-- app/tips/components/ExplorerTables.tsx | 43 +++++++----- app/tips/library/explorer-format.ts | 62 ++++++++++++++-- app/tips/page.tsx | 70 +++++++++++-------- 8 files changed, 214 insertions(+), 95 deletions(-) diff --git a/app/api/tips/shadow-candidates-batch/route.ts b/app/api/tips/shadow-candidates-batch/route.ts index 10b607e..ff90e30 100644 --- a/app/api/tips/shadow-candidates-batch/route.ts +++ b/app/api/tips/shadow-candidates-batch/route.ts @@ -5,6 +5,9 @@ import { fetchShadowCandidatesBatch } from '../shadow'; export const runtime = 'nodejs'; +const HASH_PATTERN = /^0x[0-9a-f]{64}$/i; +const MAX_CANONICAL_BATCH = 200; + export async function GET(request: Request) { const disabled = tipsDisabledResponse(); if (disabled) return disabled; @@ -24,7 +27,16 @@ export async function GET(request: Request) { const hashes = canonical .split(',') .map((hash) => hash.trim()) - .filter(Boolean); + .filter(Boolean) + .map((hash) => hash.toLowerCase()); + + if (hashes.length === 0 || hashes.length > MAX_CANONICAL_BATCH) { + return Response.json({ error: 'Invalid canonical hashes' }, { status: 400 }); + } + + if (hashes.some((hash) => !HASH_PATTERN.test(hash))) { + return Response.json({ error: 'Invalid canonical hashes' }, { status: 400 }); + } return Response.json(await fetchShadowCandidatesBatch(baseUrl, hashes)); } diff --git a/app/api/tips/shadow-candidates/route.ts b/app/api/tips/shadow-candidates/route.ts index e18054a..005776d 100644 --- a/app/api/tips/shadow-candidates/route.ts +++ b/app/api/tips/shadow-candidates/route.ts @@ -1,14 +1,12 @@ import { resolveTipsChain } from '../../../tips/chains'; import { getShadowMetricsUrl } from '../config'; import { tipsDisabledResponse } from '../guard'; -import { - ShadowNotFoundError, - ShadowUnavailableError, - fetchShadowCandidates, -} from '../shadow'; +import { fetchShadowCandidates } from '../shadow'; export const runtime = 'nodejs'; +const HASH_PATTERN = /^0x[0-9a-f]{64}$/i; + // Re-export for client typing. export type { ShadowBlockSummary } from '../shadow'; @@ -23,23 +21,16 @@ export async function GET(request: Request) { return Response.json({ error: 'Missing canonical hash' }, { status: 400 }); } + const normalized = canonical.trim().toLowerCase(); + if (!HASH_PATTERN.test(normalized)) { + return Response.json({ error: 'Invalid canonical hash' }, { status: 400 }); + } + const baseUrl = getShadowMetricsUrl(chain); if (!baseUrl) { return Response.json({ error: 'Shadow metrics not configured' }, { status: 503 }); } - try { - const candidates = await fetchShadowCandidates(baseUrl, canonical); - return Response.json({ candidates }); - } catch (error) { - if (error instanceof ShadowNotFoundError) { - return Response.json({ candidates: [] }); - } - - console.error('Error fetching shadow candidates:', error); - return Response.json( - { error: 'Shadow candidates unavailable' }, - { status: error instanceof ShadowUnavailableError ? 503 : 500 }, - ); - } + const candidates = await fetchShadowCandidates(baseUrl, normalized); + return Response.json({ candidates }); } diff --git a/app/api/tips/shadow.ts b/app/api/tips/shadow.ts index 6a7d5be..651b645 100644 --- a/app/api/tips/shadow.ts +++ b/app/api/tips/shadow.ts @@ -7,19 +7,24 @@ export interface ShadowBlockSummary { 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; } +interface ShadowBlockSummaryWire { + number: number; + hash: string; + canonicalHash: string; + timestamp: number; + shadowBuilderVersion: string; + shadowGasUsed: string | number; + shadowTxCount: string | number; + shadowNonDepositTxCount: string | number; + shadowPriorityFeeInversions: string | number; +} + export interface ShadowTxSummary { index: number; hash: string; @@ -58,12 +63,41 @@ export class ShadowNotFoundError extends Error { } } +const SHADOW_FETCH_TIMEOUT_MS = 4000; + +function parseShadowNumber(value: string | number): number { + if (typeof value === 'number') return value; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function normalizeShadowSummary(summary: ShadowBlockSummaryWire): ShadowBlockSummary { + return { + number: summary.number, + hash: summary.hash.toLowerCase(), + canonicalHash: summary.canonicalHash.toLowerCase(), + timestamp: summary.timestamp, + shadowBuilderVersion: summary.shadowBuilderVersion, + shadowGasUsed: parseShadowNumber(summary.shadowGasUsed), + shadowTxCount: parseShadowNumber(summary.shadowTxCount), + shadowNonDepositTxCount: parseShadowNumber(summary.shadowNonDepositTxCount), + shadowPriorityFeeInversions: parseShadowNumber(summary.shadowPriorityFeeInversions), + }; +} + async function fetchShadowMetrics(url: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SHADOW_FETCH_TIMEOUT_MS); let response: Response; try { - response = await fetch(url, { cache: 'no-store' }); + response = await fetch(url, { cache: 'no-store', signal: controller.signal }); } catch { + if (controller.signal.aborted) { + throw new ShadowUnavailableError('shadow-metrics request timed out'); + } throw new ShadowUnavailableError('failed to reach shadow-metrics'); + } finally { + clearTimeout(timeout); } if (response.status === 404) { @@ -82,7 +116,7 @@ export async function fetchShadowCandidates( canonicalHash: string, ): Promise { const batch = await fetchShadowCandidatesBatch(baseUrl, [canonicalHash]); - return batch[canonicalHash] ?? []; + return batch[canonicalHash.toLowerCase()] ?? []; } export async function fetchShadowCandidatesBatch( @@ -94,12 +128,22 @@ export async function fetchShadowCandidatesBatch( const canonical = encodeURIComponent(hashes.join(',')); const url = `${root}/shadow-candidates?canonical=${canonical}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SHADOW_FETCH_TIMEOUT_MS); try { - const response = await fetch(url, { cache: 'no-store' }); + const response = await fetch(url, { cache: 'no-store', signal: controller.signal }); if (!response.ok) return {}; - return (await response.json()) as Record; + const data = (await response.json()) as Record; + return Object.fromEntries( + Object.entries(data).map(([hash, summaries]) => [ + hash.toLowerCase(), + summaries.map(normalizeShadowSummary), + ]), + ); } catch { return {}; + } finally { + clearTimeout(timeout); } } @@ -109,7 +153,8 @@ export async function fetchShadowBlockSummary( ): Promise { const root = baseUrl.replace(/\/$/, ''); const url = `${root}/shadow-blocks/${encodeURIComponent(hash)}`; - return fetchShadowMetrics(url); + const summary = await fetchShadowMetrics(url); + return normalizeShadowSummary(summary); } export async function fetchShadowBlockDetail( diff --git a/app/tips/block/[hash]/page.tsx b/app/tips/block/[hash]/page.tsx index 1109840..c55c32c 100644 --- a/app/tips/block/[hash]/page.tsx +++ b/app/tips/block/[hash]/page.tsx @@ -231,15 +231,7 @@ function ShadowCandidatesTable({ -
- {block.shadowBuilderVersion} - {block.canonicalBuilderVersion && - block.canonicalBuilderVersion !== block.shadowBuilderVersion ? ( - - canon: {block.canonicalBuilderVersion} - - ) : null} -
+
{block.shadowBuilderVersion}
{formatAge(block.timestamp)} diff --git a/app/tips/blocks/page.tsx b/app/tips/blocks/page.tsx index 64b0be3..13d87ab 100644 --- a/app/tips/blocks/page.tsx +++ b/app/tips/blocks/page.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; -import { Suspense, useEffect, useState } from 'react'; +import { Suspense, useEffect, useMemo, useState } from 'react'; import { Card } from '../../components/ui/Card'; import { Spinner } from '../../components/ui/Spinner'; @@ -28,6 +28,10 @@ function BlocksContent() { const [error, setError] = useState(null); const [showShadowDelta, setShowShadowDelta] = useState(false); const [shadowCandidates, setShadowCandidates] = useState>({}); + const shadowKey = useMemo( + () => (data?.blocks ?? []).map((block) => block.hash.toLowerCase()).sort().join(','), + [data?.blocks], + ); useEffect(() => { let cancelled = false; @@ -56,13 +60,13 @@ function BlocksContent() { }, [chain, cursor]); useEffect(() => { - if (!showShadowDelta || !data?.blocks.length) { + if (!showShadowDelta || shadowKey.length === 0) { setShadowCandidates({}); return undefined; } const controller = new AbortController(); - const hashes = data.blocks.map((block) => block.hash); + const hashes = shadowKey.split(','); tipsApi .shadowCandidatesBatch(chain, hashes, controller.signal) @@ -72,7 +76,7 @@ function BlocksContent() { return () => { controller.abort(); }; - }, [chain, data?.blocks, showShadowDelta]); + }, [chain, shadowKey, showShadowDelta]); return (
diff --git a/app/tips/components/ExplorerTables.tsx b/app/tips/components/ExplorerTables.tsx index ad9e341..4afb5fc 100644 --- a/app/tips/components/ExplorerTables.tsx +++ b/app/tips/components/ExplorerTables.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { cn } from '../../components/ui/cn'; import type { TipsChain } from '../chains'; import { + calculateShadowDelta, formatAction, formatAge, formatEth, @@ -75,20 +76,31 @@ export function BlockTable({ {blocks.map((block) => { - const shadowBlock = shadowBlocks?.[block.hash]?.[0]; - const gasDiffPct = shadowBlock?.gasDiffPct; - const gasDiffAbs = shadowBlock?.gasDiffAbs; - const txCountDiff = shadowBlock?.txCountDiff; - const canonicalTxCount = shadowBlock?.canonicalTxCount; - const txDiffPct = - canonicalTxCount && canonicalTxCount > 0 && txCountDiff !== undefined - ? (txCountDiff / canonicalTxCount) * 100 - : undefined; - const hasGasDelta = gasDiffPct !== undefined && gasDiffAbs !== undefined; + const shadowBlock = shadowBlocks?.[block.hash.toLowerCase()]?.[0]; + const delta = shadowBlock + ? calculateShadowDelta(block.gasUsed, block.transactionCount, shadowBlock) + : null; + const gasDiffPct = delta?.gasDiffPct; + const gasDiffAbs = delta?.gasDiffAbs; + const txDiffAbs = delta?.txDiffAbs; + const txDiffPct = delta?.txDiffPct; + const hasGasDelta = gasDiffAbs !== undefined; const gasDeltaClass = gasDiffPct !== undefined && Math.abs(gasDiffPct) > 50 ? 'text-bds-red-70 dark:text-bds-red-20' : 'text-black dark:text-white'; + const gasDeltaText = + gasDiffAbs !== undefined + ? `${gasDiffPct !== undefined ? `${formatSignedPct(gasDiffPct)} ` : ''}(${formatSignedGas( + gasDiffAbs, + )})` + : '—'; + const txDeltaText = + txDiffAbs !== undefined + ? `${txDiffPct !== undefined ? `${formatSignedPct(txDiffPct)} ` : ''}(${formatSignedInteger( + txDiffAbs, + )})` + : '—'; return ( @@ -110,9 +122,7 @@ export function BlockTable({ {showDelta ? ( {hasGasDelta ? ( - - {formatSignedPct(gasDiffPct)} ({formatSignedGas(gasDiffAbs)}) - + {gasDeltaText} ) : ( )} @@ -120,11 +130,8 @@ export function BlockTable({ ) : null} {showDelta ? ( - {txCountDiff !== undefined ? ( - - {txDiffPct !== undefined ? `${formatSignedPct(txDiffPct)} ` : ''}( - {formatSignedInteger(txCountDiff)}) - + {txDiffAbs !== undefined ? ( + {txDeltaText} ) : ( )} diff --git a/app/tips/library/explorer-format.ts b/app/tips/library/explorer-format.ts index edb9de2..4531538 100644 --- a/app/tips/library/explorer-format.ts +++ b/app/tips/library/explorer-format.ts @@ -2,6 +2,8 @@ // (blocks / txs / txn detail). Client-safe: no env, no server imports — usable // from both the server list modules and client components. +import type { ShadowBlockSummary } from './types'; + export type NumericValue = bigint | number | string | null | undefined; const WEI_PER_GWEI = 10n ** 9n; @@ -48,7 +50,9 @@ export function formatSignedInteger(value: number): string { } export function formatSignedPct(value: number): string { - return `${value > 0 ? '+' : ''}${value.toFixed(1)}%`; + const rounded = Number(value.toFixed(1)); + const clamped = Object.is(rounded, -0) ? 0 : rounded; + return `${clamped > 0 ? '+' : ''}${clamped.toFixed(1)}%`; } function trimTrailingZeros(value: string): string { @@ -63,16 +67,27 @@ export function formatSignedGas(value: number): string { const absValue = Math.abs(value); if (absValue < 1_000) { - return `${sign}${Math.round(absValue)}`; + const rounded = Math.round(absValue); + return rounded === 0 ? '0' : `${sign}${rounded}`; } if (absValue < 1_000_000) { - const formatted = trimTrailingZeros((absValue / 1_000).toFixed(1)); + const rounded = Number((absValue / 1_000).toFixed(1)); + if (rounded >= 1000) { + const formatted = trimTrailingZeros((absValue / 1_000_000).toFixed(2)); + return `${sign}${formatted}M`; + } + const formatted = trimTrailingZeros(rounded.toFixed(1)); return `${sign}${formatted}K`; } if (absValue < 1_000_000_000) { - const formatted = trimTrailingZeros((absValue / 1_000_000).toFixed(2)); + const rounded = Number((absValue / 1_000_000).toFixed(2)); + if (rounded >= 1000) { + const formatted = trimTrailingZeros((absValue / 1_000_000_000).toFixed(2)); + return `${sign}${formatted}B`; + } + const formatted = trimTrailingZeros(rounded.toFixed(2)); return `${sign}${formatted}M`; } @@ -80,6 +95,45 @@ export function formatSignedGas(value: number): string { return `${sign}${formatted}B`; } +function toNumber(value: NumericValue): number | null { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value === 'bigint') { + const maxSafe = BigInt(Number.MAX_SAFE_INTEGER); + if (value > maxSafe || value < -maxSafe) return null; + return Number(value); + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +export function calculateShadowDelta( + canonicalGasUsed: NumericValue, + canonicalTxCount: NumericValue, + shadow: ShadowBlockSummary, +): { + gasDiffAbs: number; + gasDiffPct?: number; + txDiffAbs: number; + txDiffPct?: number; +} | null { + const canonicalGas = toNumber(canonicalGasUsed); + const canonicalTx = toNumber(canonicalTxCount); + const shadowGas = toNumber(shadow.shadowGasUsed); + const shadowTx = toNumber(shadow.shadowTxCount); + + if (canonicalGas === null || canonicalTx === null || shadowGas === null || shadowTx === null) { + return null; + } + + const gasDiffAbs = shadowGas - canonicalGas; + const gasDiffPct = canonicalGas > 0 ? (gasDiffAbs / canonicalGas) * 100 : undefined; + const txDiffAbs = shadowTx - canonicalTx; + const txDiffPct = canonicalTx > 0 ? (txDiffAbs / canonicalTx) * 100 : undefined; + + return { gasDiffAbs, gasDiffPct, txDiffAbs, txDiffPct }; +} + export function formatEth(value: NumericValue): string { const formatted = formatUnits(value, WEI_PER_ETH, 6); return formatted === '—' ? formatted : `${formatted} ETH`; diff --git a/app/tips/page.tsx b/app/tips/page.tsx index 36ff3ef..2367248 100644 --- a/app/tips/page.tsx +++ b/app/tips/page.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { Suspense, useCallback, useEffect, useState } from 'react'; +import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import type { FormEvent } from 'react'; import { Banner } from '../components/ui/Banner'; @@ -17,7 +17,12 @@ import { ChainToggle } from './components/ChainToggle'; import { MeteringCard } from './components/MeteringCard'; import type { TipsChain } from './chains'; import { tipsApi } from './library/client'; -import { formatSignedGas, formatSignedInteger, formatSignedPct } from './library/explorer-format'; +import { + calculateShadowDelta, + formatSignedGas, + formatSignedInteger, + formatSignedPct, +} from './library/explorer-format'; import { formatGasPrice, formatHexValue, shortHash, timeAgoFromSeconds } from './library/format'; import { tipsHref } from './library/links'; import { @@ -106,19 +111,28 @@ function BlockRow({ showShadowDelta: boolean; shadowBlock?: ShadowBlockSummary; }) { - const gasDiffPct = shadowBlock?.gasDiffPct; - const gasDiffAbs = shadowBlock?.gasDiffAbs; - const txCountDiff = shadowBlock?.txCountDiff; - const canonicalTxCount = shadowBlock?.canonicalTxCount; - const txDiffPct = - canonicalTxCount && canonicalTxCount > 0 && txCountDiff !== undefined - ? (txCountDiff / canonicalTxCount) * 100 - : undefined; - const hasGasDelta = gasDiffPct !== undefined && gasDiffAbs !== undefined; + const delta = shadowBlock ? calculateShadowDelta(block.gasUsed, block.transactionCount, shadowBlock) : null; + const gasDiffPct = delta?.gasDiffPct; + const gasDiffAbs = delta?.gasDiffAbs; + const txDiffAbs = delta?.txDiffAbs; + const txDiffPct = delta?.txDiffPct; + const hasGasDelta = gasDiffAbs !== undefined; const gasDeltaClass = gasDiffPct !== undefined && Math.abs(gasDiffPct) > 50 ? 'text-bds-red-70 dark:text-bds-red-20' : 'text-foreground'; + const gasDeltaText = + gasDiffAbs !== undefined + ? `${gasDiffPct !== undefined ? `${formatSignedPct(gasDiffPct)} ` : ''}(${formatSignedGas( + gasDiffAbs, + )})` + : '—'; + const txDeltaText = + txDiffAbs !== undefined + ? `${txDiffPct !== undefined ? `${formatSignedPct(txDiffPct)} ` : ''}(${formatSignedInteger( + txDiffAbs, + )})` + : '—'; return ( - {hasGasDelta ? `${formatSignedPct(gasDiffPct)} (${formatSignedGas(gasDiffAbs)})` : '—'} + {hasGasDelta ? gasDeltaText : '—'}
Tx Δ
- {txCountDiff !== undefined - ? `${txDiffPct !== undefined ? `${formatSignedPct(txDiffPct)} ` : ''}(${formatSignedInteger( - txCountDiff, - )})` - : '—'} + {txDeltaText}
) : null} @@ -197,6 +207,10 @@ function BlocksTab({ chain }: { chain: TipsChain }) { const [loading, setLoading] = useState(true); const [showShadowDelta, setShowShadowDelta] = useState(false); const [shadowCandidates, setShadowCandidates] = useState>({}); + const shadowKey = useMemo( + () => blocks.map((block) => block.hash.toLowerCase()).sort().join(','), + [blocks], + ); useEffect(() => { let cancelled = false; @@ -222,13 +236,13 @@ function BlocksTab({ chain }: { chain: TipsChain }) { }, [chain]); useEffect(() => { - if (!showShadowDelta || blocks.length === 0) { + if (!showShadowDelta || shadowKey.length === 0) { setShadowCandidates({}); return undefined; } const controller = new AbortController(); - const hashes = blocks.map((block) => block.hash); + const hashes = shadowKey.split(','); tipsApi .shadowCandidatesBatch(chain, hashes, controller.signal) @@ -238,7 +252,7 @@ function BlocksTab({ chain }: { chain: TipsChain }) { return () => { controller.abort(); }; - }, [blocks, chain, showShadowDelta]); + }, [chain, shadowKey, showShadowDelta]); return (
@@ -265,14 +279,14 @@ function BlocksTab({ chain }: { chain: TipsChain }) { ) : blocks.length > 0 ? (
{blocks.map((block) => ( - - ))} + + ))}
) : (