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..ff90e30 --- /dev/null +++ b/app/api/tips/shadow-candidates-batch/route.ts @@ -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)); +} diff --git a/app/api/tips/shadow-candidates/route.ts b/app/api/tips/shadow-candidates/route.ts new file mode 100644 index 0000000..005776d --- /dev/null +++ b/app/api/tips/shadow-candidates/route.ts @@ -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 }); +} diff --git a/app/api/tips/shadow.ts b/app/api/tips/shadow.ts new file mode 100644 index 0000000..651b645 --- /dev/null +++ b/app/api/tips/shadow.ts @@ -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(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', 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 { + const batch = await fetchShadowCandidatesBatch(baseUrl, [canonicalHash]); + return batch[canonicalHash.toLowerCase()] ?? []; +} + +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}`; + + 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; + 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 { + const root = baseUrl.replace(/\/$/, ''); + const url = `${root}/shadow-blocks/${encodeURIComponent(hash)}`; + const summary = await fetchShadowMetrics(url); + return normalizeShadowSummary(summary); +} + +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/tips/block/[hash]/page.tsx b/app/tips/block/[hash]/page.tsx index 247ba06..c55c32c 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,70 @@ 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}
+
+ {formatAge(block.timestamp)} +
+
+ ); +} + function BlockToolbar({ chain, hash, @@ -246,6 +315,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 +356,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 +406,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..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'; @@ -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,12 @@ 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>({}); + const shadowKey = useMemo( + () => (data?.blocks ?? []).map((block) => block.hash.toLowerCase()).sort().join(','), + [data?.blocks], + ); useEffect(() => { let cancelled = false; @@ -53,6 +59,25 @@ function BlocksContent() { }; }, [chain, cursor]); + useEffect(() => { + if (!showShadowDelta || shadowKey.length === 0) { + setShadowCandidates({}); + return undefined; + } + + const controller = new AbortController(); + const hashes = shadowKey.split(','); + + tipsApi + .shadowCandidatesBatch(chain, hashes, controller.signal) + .then((response) => setShadowCandidates(response)) + .catch(() => setShadowCandidates({})); + + return () => { + controller.abort(); + }; + }, [chain, shadowKey, showShadowDelta]); + return (
@@ -74,6 +99,16 @@ function BlocksContent() { ) : null}
+ + {error ? ( @@ -91,7 +126,12 @@ function BlocksContent() {
) : data && data.blocks.length > 0 ? ( - + ) : (
No blocks available diff --git a/app/tips/components/ExplorerNav.tsx b/app/tips/components/ExplorerNav.tsx index cc1db26..92f4039 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'; +}) { 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'; diff --git a/app/tips/components/ExplorerTables.tsx b/app/tips/components/ExplorerTables.tsx index 42d2e75..4afb5fc 100644 --- a/app/tips/components/ExplorerTables.tsx +++ b/app/tips/components/ExplorerTables.tsx @@ -5,17 +5,21 @@ import Link from 'next/link'; import { cn } from '../../components/ui/cn'; import type { TipsChain } from '../chains'; import { + calculateShadowDelta, formatAction, formatAge, 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 +47,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 +70,76 @@ 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.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 ( + + + + #{formatInteger(block.number)} + +
+ {shortHash(block.hash)} +
+
+ + {formatAge(block.timestamp)} + + {formatInteger(block.transactionCount)} + {formatInteger(block.gasUsed)} + {formatInteger(block.gasLimit)} + {formatGwei(block.baseFeePerGas)} + {showDelta ? ( + + {hasGasDelta ? ( + {gasDeltaText} + ) : ( + + )} + + ) : null} + {showDelta ? ( + + {txDiffAbs !== undefined ? ( + {txDeltaText} + ) : ( + + )} + + ) : 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..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; @@ -43,6 +45,95 @@ 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 { + 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 { + 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) { + const rounded = Math.round(absValue); + return rounded === 0 ? '0' : `${sign}${rounded}`; + } + + if (absValue < 1_000_000) { + 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 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`; + } + + const formatted = trimTrailingZeros((absValue / 1_000_000_000).toFixed(2)); + 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/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..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,12 +17,19 @@ import { ChainToggle } from './components/ChainToggle'; import { MeteringCard } from './components/MeteringCard'; import type { TipsChain } from './chains'; import { tipsApi } from './library/client'; +import { + calculateShadowDelta, + 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 +100,40 @@ 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 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 ( txns
+ {showShadowDelta ? ( +
+
Gas Δ
+
+ {hasGasDelta ? gasDeltaText : '—'} +
+
Tx Δ
+
+ {txDeltaText} +
+
+ ) : null} ([]); 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; @@ -172,9 +235,39 @@ function BlocksTab({ chain }: { chain: TipsChain }) { }; }, [chain]); + useEffect(() => { + if (!showShadowDelta || shadowKey.length === 0) { + setShadowCandidates({}); + return undefined; + } + + const controller = new AbortController(); + const hashes = shadowKey.split(','); + + tipsApi + .shadowCandidatesBatch(chain, hashes, controller.signal) + .then((response) => setShadowCandidates(response)) + .catch(() => setShadowCandidates({})); + + return () => { + controller.abort(); + }; + }, [chain, shadowKey, showShadowDelta]); + return (
- Latest Blocks +
+ Latest Blocks + +
{loading && blocks.length === 0 ? (
@@ -186,8 +279,14 @@ 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… + +
+ } + > + + + ); +}