Skip to content
4 changes: 4 additions & 0 deletions app/api/tips/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
42 changes: 42 additions & 0 deletions app/api/tips/shadow-block/[hash]/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
42 changes: 42 additions & 0 deletions app/api/tips/shadow-candidates-batch/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { resolveTipsChain } from '../../../tips/chains';
import { getShadowMetricsUrl } from '../config';
import { tipsDisabledResponse } from '../guard';
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;

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)
.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));
}
36 changes: 36 additions & 0 deletions app/api/tips/shadow-candidates/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { resolveTipsChain } from '../../../tips/chains';
import { getShadowMetricsUrl } from '../config';
import { tipsDisabledResponse } from '../guard';
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';

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 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 });
}

const candidates = await fetchShadowCandidates(baseUrl, normalized);
return Response.json({ candidates });
}
167 changes: 167 additions & 0 deletions app/api/tips/shadow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// 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;
shadowGasUsed: number;
shadowTxCount: number;
shadowNonDepositTxCount: 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;
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';
}
}

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<T>(url: string): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SHADOW_FETCH_TIMEOUT_MS);
let response: Response;
try {
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) {
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<ShadowBlockSummary[]> {
const batch = await fetchShadowCandidatesBatch(baseUrl, [canonicalHash]);
return batch[canonicalHash.toLowerCase()] ?? [];
}

export async function fetchShadowCandidatesBatch(
baseUrl: string,
hashes: string[],
): Promise<Record<string, ShadowBlockSummary[]>> {
if (hashes.length === 0) return {};
const root = baseUrl.replace(/\/$/, '');
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', signal: controller.signal });
if (!response.ok) return {};
const data = (await response.json()) as Record<string, ShadowBlockSummaryWire[]>;
return Object.fromEntries(
Object.entries(data).map(([hash, summaries]) => [
hash.toLowerCase(),
summaries.map(normalizeShadowSummary),
]),
);
} catch {
return {};
} finally {
clearTimeout(timeout);
}
}

export async function fetchShadowBlockSummary(
baseUrl: string,
hash: string,
): Promise<ShadowBlockSummary> {
const root = baseUrl.replace(/\/$/, '');
const url = `${root}/shadow-blocks/${encodeURIComponent(hash)}`;
const summary = await fetchShadowMetrics<ShadowBlockSummaryWire>(url);
return normalizeShadowSummary(summary);
}

export async function fetchShadowBlockDetail(
baseUrl: string,
hash: string,
): Promise<ShadowBlockDetail> {
const root = baseUrl.replace(/\/$/, '');
const url = `${root}/blocks/${encodeURIComponent(hash)}`;
return fetchShadowMetrics<ShadowBlockDetail>(url);
}
Loading
Loading