Skip to content
Merged
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
15 changes: 15 additions & 0 deletions app/api/internal-explorer/block-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,21 @@ async function fetchBlockByNumber(
};
}

export async function fetchBlocksByNumbers(
rpcUrl: string,
numbers: number[],
): Promise<BlockSummary[]> {
const blocks = await Promise.all(
numbers.map((blockNumber) => fetchBlockByNumber(rpcUrl, blockNumber)),
);

if (blocks.some((block) => block === null)) {
throw new BlockListUnavailableError('failed to fetch one or more blocks');
}

return blocks as BlockSummary[];
}

export async function listBlocks(rpcUrl: string, query: BlockListQuery): Promise<BlocksResponse> {
const latestBlockNumber = await fetchLatestBlockNumber(rpcUrl);
if (latestBlockNumber === null) {
Expand Down
55 changes: 55 additions & 0 deletions app/api/internal-explorer/blocks-by-numbers/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { resolveExplorerChain } from '../../../internal-explorer/chains';
import { BlockListUnavailableError, fetchBlocksByNumbers } from '../block-list';
import { getRpcUrl } from '../config';
import { explorerDisabledResponse } from '../guard';

export const runtime = 'nodejs';

export type { BlockSummary } from '../block-list';

const MAX_NUMBERS = 100;

export async function GET(request: Request) {
const disabled = explorerDisabledResponse();
if (disabled) return disabled;

const url = new URL(request.url);
const chain = resolveExplorerChain(url.searchParams.get('chain'));

const raw = url.searchParams.get('numbers');
if (!raw) {
return Response.json({ error: 'Missing block numbers' }, { status: 400 });
}

const numbers = raw
.split(',')
.map((value) => value.trim())
.filter(Boolean);

if (numbers.length === 0 || numbers.length > MAX_NUMBERS) {
return Response.json({ error: 'Invalid block numbers' }, { status: 400 });
}

if (numbers.some((value) => !/^(0|[1-9]\d*)$/.test(value))) {
return Response.json({ error: 'Invalid block numbers' }, { status: 400 });
}

const parsed = numbers.map(Number);
if (parsed.some((value) => !Number.isSafeInteger(value))) {
return Response.json({ error: 'Invalid block numbers' }, { status: 400 });
}

try {
return Response.json(await fetchBlocksByNumbers(getRpcUrl(chain), parsed));
} catch (error) {
return Response.json(
{
error:
error instanceof BlockListUnavailableError
? 'Block list unavailable'
: 'Internal server error',
},
{ status: error instanceof BlockListUnavailableError ? 503 : 500 },
);
}
}
48 changes: 48 additions & 0 deletions app/api/internal-explorer/shadow-blocks/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { resolveExplorerChain } from '../../../internal-explorer/chains';
import { getShadowMetricsUrl } from '../config';
import { explorerDisabledResponse } from '../guard';
import { ShadowUnavailableError, fetchRecentShadowBlocks } from '../shadow';

export const runtime = 'nodejs';

const DEFAULT_LIMIT = 25;
const MAX_LIMIT = 100;

// undefined = absent; null = present but malformed (caller should reject).
function parsePositiveInt(value: string | null): number | undefined | null {
if (value === null) return undefined;
if (!/^[1-9]\d*$/.test(value)) return null;
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : null;
}

export async function GET(request: Request) {
const disabled = explorerDisabledResponse();
if (disabled) return disabled;

const url = new URL(request.url);
const chain = resolveExplorerChain(url.searchParams.get('chain'));

const baseUrl = getShadowMetricsUrl(chain);
if (!baseUrl) {
return Response.json({ error: 'Shadow metrics not configured' }, { status: 503 });
}

const rawLimit = parsePositiveInt(url.searchParams.get('limit'));
const before = parsePositiveInt(url.searchParams.get('before'));
if (rawLimit === null || before === null) {
return Response.json({ error: 'Invalid limit or before' }, { status: 400 });
}

const limit = Math.min(rawLimit ?? DEFAULT_LIMIT, MAX_LIMIT);

try {
return Response.json(await fetchRecentShadowBlocks(baseUrl, { limit, before }));
} catch (error) {
console.error('Error fetching recent shadow blocks:', error);
return Response.json(
{ error: 'Shadow blocks unavailable' },
{ status: error instanceof ShadowUnavailableError ? 503 : 500 },
);
}
}
14 changes: 14 additions & 0 deletions app/api/internal-explorer/shadow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,20 @@ export async function fetchShadowCandidatesBatch(
}
}

export async function fetchRecentShadowBlocks(
baseUrl: string,
options: { limit?: number; before?: number } = {},
): Promise<ShadowBlockSummary[]> {
const root = baseUrl.replace(/\/$/, '');
const params = new URLSearchParams();
if (options.limit !== undefined) params.set('limit', String(options.limit));
if (options.before !== undefined) params.set('before', String(options.before));
const query = params.toString();
const url = query ? `${root}/shadow-blocks?${query}` : `${root}/shadow-blocks`;
const summaries = await fetchShadowMetrics<ShadowBlockSummaryWire[]>(url);
return summaries.map(normalizeShadowSummary);
}

export async function fetchShadowBlockSummary(
baseUrl: string,
hash: string,
Expand Down
80 changes: 64 additions & 16 deletions app/internal-explorer/blocks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useMemo, useState } from 'react';
import { Suspense, useEffect, useState } from 'react';

import { Card } from '../../components/ui/Card';
import { Spinner } from '../../components/ui/Spinner';
Expand All @@ -15,31 +15,33 @@ import { formatInteger } from '../library/explorer-format';
import { explorerHref } from '../library/links';
import type { BlocksResponse, ShadowBlockSummary } from '../library/types';
import { useExplorerChain } from '../library/useExplorerChain';
import { useShadowDelta } from '../library/useShadowDelta';

const PAGE_LIMIT = 25;

function BlocksContent() {
const { chain } = useExplorerChain();
const { showShadowDelta, setShowShadowDelta } = useShadowDelta();
const searchParams = useSearchParams();
const cursorParam = searchParams.get('cursor');
const cursor = cursorParam !== null ? Number(cursorParam) : undefined;

const [data, setData] = useState<BlocksResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showShadowDelta, setShowShadowDelta] = useState(false);
const [shadowCandidates, setShadowCandidates] = useState<Record<string, ShadowBlockSummary[]>>({});
const shadowKey = useMemo(
() => (data?.blocks ?? []).map((block) => block.hash.toLowerCase()).sort().join(','),
[data?.blocks],
);

// Canonical-block view: paginate the chain tip directly. The shadow-delta
// view has its own source below, so this fetch stands down when it is on.
useEffect(() => {
if (showShadowDelta) return undefined;

let cancelled = false;
const controller = new AbortController();
setLoading(true);
setError(null);
setData(null);
setShadowCandidates({});

explorerApi
.blocksPage(chain, { cursor, limit: PAGE_LIMIT }, controller.signal)
Expand All @@ -58,26 +60,72 @@ function BlocksContent() {
cancelled = true;
controller.abort();
};
}, [chain, cursor]);
}, [chain, cursor, showShadowDelta]);

// The canonical tip rarely holds a block with a shadow replacement, so this
// view is driven by recent shadow blocks (dense, newest first) with each
// canonical block resolved by number to fill the base columns.
useEffect(() => {
if (!showShadowDelta || shadowKey.length === 0) {
setShadowCandidates({});
return undefined;
}
if (!showShadowDelta) return undefined;

let cancelled = false;
const controller = new AbortController();
const hashes = shadowKey.split(',');
setLoading(true);
setError(null);
setData(null);
setShadowCandidates({});

explorerApi
.shadowCandidatesBatch(chain, hashes, controller.signal)
.then((response) => setShadowCandidates(response))
.catch(() => setShadowCandidates({}));
.recentShadowBlocks(chain, { limit: PAGE_LIMIT }, controller.signal)
.then(async (shadows) => {
if (cancelled) return;
if (shadows.length === 0) {
setData({
blocks: [],
page: { cursor: null, limit: PAGE_LIMIT, latestBlockNumber: 0, nextCursor: null, hasMore: false },
});
return;
}

const numbers = shadows.map((shadow) => shadow.number);
const canonicalBlocks = await explorerApi.blocksByNumbers(chain, numbers, controller.signal);
if (cancelled) return;

const canonicalByNumber = new Map(canonicalBlocks.map((block) => [block.number, block]));
const orderedBlocks = shadows
.map((shadow) => canonicalByNumber.get(shadow.number))
.filter((block): block is NonNullable<typeof block> => block !== undefined);
const candidates = Object.fromEntries(
shadows
.filter((shadow) => shadow.canonicalHash !== undefined)
.map((shadow) => [shadow.canonicalHash!.toLowerCase(), [shadow]]),
);

setShadowCandidates(candidates);
setData({
blocks: orderedBlocks,
page: {
cursor: null,
limit: PAGE_LIMIT,
latestBlockNumber: orderedBlocks[0]?.number ?? 0,
nextCursor: null,
hasMore: false,
},
});
})
.catch(() => {
if (controller.signal.aborted || cancelled) return;
setError('Failed to fetch shadow blocks');
})
.finally(() => {
if (!cancelled) setLoading(false);
});

return () => {
cancelled = true;
controller.abort();
};
}, [chain, shadowKey, showShadowDelta]);
}, [chain, showShadowDelta]);

return (
<div className="animate-in flex flex-col gap-6">
Expand Down
20 changes: 20 additions & 0 deletions app/internal-explorer/library/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ExplorerChain } from '../chains';
import type {
BlockDetailResponse,
BlocksResponse,
BlockSummary,
LatestActiveBlockResponse,
BundleHistoryResponse,
RejectedTransactionsResponse,
Expand Down Expand Up @@ -119,4 +120,23 @@ export const explorerApi = {
chain,
signal,
),
recentShadowBlocks: (
chain: ExplorerChain,
options?: { limit?: number; before?: number },
signal?: AbortSignal,
) =>
get<ShadowBlockSummary[]>(
withQuery('/api/internal-explorer/shadow-blocks', {
limit: options?.limit,
before: options?.before,
}),
chain,
signal,
),
blocksByNumbers: (chain: ExplorerChain, numbers: number[], signal?: AbortSignal) =>
get<BlockSummary[]>(
withQuery('/api/internal-explorer/blocks-by-numbers', { numbers: numbers.join(',') }),
chain,
signal,
),
};
39 changes: 39 additions & 0 deletions app/internal-explorer/library/useShadowDelta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use client';

import { useCallback } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';

type UseShadowDelta = {
/** Whether the shadow-delta view is enabled, read from `?shadowDelta=`. */
showShadowDelta: boolean;
/** Update `?shadowDelta=` in place, preserving the path and other query params. */
setShowShadowDelta: (next: boolean) => void;
};

// Reads the shadow-delta toggle from the URL (`?shadowDelta=1`) and provides a
// setter that rewrites just that query param via router.replace — so the toggle
// persists across refresh and navigation, stays shareable, and never pushes a
// new history entry. Mirrors useExplorerChain.
export function useShadowDelta(): UseShadowDelta {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();

const showShadowDelta = searchParams.get('shadowDelta') === '1';

const setShowShadowDelta = useCallback(
(next: boolean) => {
const params = new URLSearchParams(searchParams.toString());
if (next) {
params.set('shadowDelta', '1');
} else {
params.delete('shadowDelta');
}
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
},
[router, pathname, searchParams],
);

return { showShadowDelta, setShowShadowDelta };
}
Loading
Loading