Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
65 changes: 65 additions & 0 deletions app/api/shadow-explorer/block-detail.ts
Original file line number Diff line number Diff line change
@@ -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<ShadowBlockDetail> {
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;
}
19 changes: 19 additions & 0 deletions app/api/shadow-explorer/chains/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
58 changes: 58 additions & 0 deletions app/api/shadow-explorer/config.ts
Original file line number Diff line number Diff line change
@@ -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_<NET>_CHAINS = [
// { "id": "canary", "label": "Canary (latest RC)", "purpose": "…", "url": "http://…" },
// { "id": "experimental", "label": "Experimental", "purpose": "…", "url": "http://…" }
// ]
//
// where <NET> 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<ShadowNetwork, string> = {
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<string, unknown>;
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;
}
9 changes: 9 additions & 0 deletions app/api/shadow-explorer/guard.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
86 changes: 86 additions & 0 deletions app/api/shadow-explorer/shadow-blocks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';

import { afterEach, describe, test, vi } from 'vitest';

import {
ShadowBlocksUnavailableError,
listShadowBlocks,
parseShadowBlocksQuery,
} from './shadow-blocks';

describe('shadow blocks query parsing', () => {
test('defaults offset to 0 and limit to the page default', () => {
assert.deepEqual(parseShadowBlocksQuery(new URLSearchParams('')), { offset: 0, limit: 25 });
});

test('reads offset and limit', () => {
assert.deepEqual(parseShadowBlocksQuery(new URLSearchParams('offset=50&limit=10')), {
offset: 50,
limit: 10,
});
});

test('validates offset and limit', () => {
assert.throws(
() => parseShadowBlocksQuery(new URLSearchParams('offset=-1')),
/offset must be a non-negative integer/,
);
assert.throws(
() => parseShadowBlocksQuery(new URLSearchParams('limit=101')),
/limit must be between/,
);
});
});

describe('listShadowBlocks pagination', () => {
afterEach(() => {
vi.restoreAllMocks();
});

test('computes nextOffset and hasMore when more rows remain', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
Response.json({ blocks: [{ number: 3 }, { number: 2 }], totalCount: 5 }),
);

const result = await listShadowBlocks('http://shadow.internal:8080/', { offset: 0, limit: 2 });

assert.equal(result.blocks.length, 2);
assert.deepEqual(result.page, {
offset: 0,
limit: 2,
totalCount: 5,
nextOffset: 2,
hasMore: true,
});
});

test('nextOffset is null on the final page', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
Response.json({ blocks: [{ number: 1 }], totalCount: 5 }),
);

const result = await listShadowBlocks('http://shadow.internal:8080', { offset: 4, limit: 2 });

assert.equal(result.page.nextOffset, null);
assert.equal(result.page.hasMore, false);
});

test('defaults a missing upstream totalCount to 0', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ blocks: [] }));

const result = await listShadowBlocks('http://shadow.internal:8080', { offset: 0, limit: 2 });

assert.equal(result.page.totalCount, 0);
assert.equal(result.page.hasMore, false);
assert.equal(result.page.nextOffset, null);
});

test('maps a non-ok upstream response to ShadowBlocksUnavailableError', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('nope', { status: 503 }));

await assert.rejects(
() => listShadowBlocks('http://shadow.internal:8080', { offset: 0, limit: 2 }),
ShadowBlocksUnavailableError,
);
});
});
Loading
Loading