Skip to content

Commit b5f1d24

Browse files
authored
Support block-by-number lookup and prev/next navigation (#153)
The block detail API route now accepts a block number in addition to a hash, resolving it via RPC and caching the result. The block detail page adds prev/next navigation arrows and resets state when navigating between blocks.
1 parent c502530 commit b5f1d24

2 files changed

Lines changed: 162 additions & 33 deletions

File tree

src/app/api/block/[hash]/route.ts

Lines changed: 88 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,61 @@ async function fetchBlockFromRpc(
4747
}
4848
}
4949

50+
function isBlockNumber(identifier: string): boolean {
51+
return /^\d+$/.test(identifier);
52+
}
53+
54+
async function fetchBlockFromRpcByNumber(
55+
blockNumber: string,
56+
): Promise<Block<bigint, true> | null> {
57+
try {
58+
const block = await client.getBlock({
59+
blockNumber: BigInt(blockNumber),
60+
includeTransactions: true,
61+
});
62+
return block;
63+
} catch (error) {
64+
console.error("Failed to fetch block from RPC by number:", error);
65+
return null;
66+
}
67+
}
68+
69+
async function buildAndCacheBlockData(
70+
rpcBlock: Block<bigint, true>,
71+
hash: Hash,
72+
number: bigint,
73+
): Promise<BlockData> {
74+
const transactions: BlockTransaction[] = await Promise.all(
75+
rpcBlock.transactions.map(async (tx, index) => {
76+
const { bundleId, executionTimeUs } =
77+
await enrichTransactionWithBundleData(tx.hash);
78+
return {
79+
hash: tx.hash,
80+
from: tx.from,
81+
to: tx.to,
82+
gasUsed: tx.gas,
83+
executionTimeUs,
84+
bundleId,
85+
index,
86+
};
87+
}),
88+
);
89+
90+
const blockData: BlockData = {
91+
hash,
92+
number,
93+
timestamp: rpcBlock.timestamp,
94+
transactions,
95+
gasUsed: rpcBlock.gasUsed,
96+
gasLimit: rpcBlock.gasLimit,
97+
cachedAt: Date.now(),
98+
};
99+
100+
await cacheBlockData(blockData);
101+
102+
return blockData;
103+
}
104+
50105
// On OP Stack, the first transaction (index 0) is the L1 attributes deposit transaction.
51106
// This is not a perfect check (ideally we'd check tx.type === 'deposit' or type 0x7e),
52107
// but sufficient for filtering out system transactions that don't need simulation data.
@@ -133,9 +188,35 @@ export async function GET(
133188
{ params }: { params: Promise<{ hash: string }> },
134189
) {
135190
try {
136-
const { hash } = await params;
191+
const { hash: identifier } = await params;
137192

138-
const cachedBlock = await getBlockFromCache(hash);
193+
// If the identifier is a block number, resolve it to a hash first
194+
if (isBlockNumber(identifier)) {
195+
const rpcBlock = await fetchBlockFromRpcByNumber(identifier);
196+
if (!rpcBlock || !rpcBlock.hash || !rpcBlock.number) {
197+
return NextResponse.json({ error: "Block not found" }, { status: 404 });
198+
}
199+
200+
// Check cache by resolved hash
201+
const cachedBlock = await getBlockFromCache(rpcBlock.hash);
202+
if (cachedBlock) {
203+
const { updatedBlock, hasUpdates } =
204+
await refetchMissingTransactionSimulations(cachedBlock);
205+
if (hasUpdates) {
206+
await cacheBlockData(updatedBlock);
207+
}
208+
return NextResponse.json(serializeBlockData(updatedBlock));
209+
}
210+
211+
const blockData = await buildAndCacheBlockData(
212+
rpcBlock,
213+
rpcBlock.hash,
214+
rpcBlock.number,
215+
);
216+
return NextResponse.json(serializeBlockData(blockData));
217+
}
218+
219+
const cachedBlock = await getBlockFromCache(identifier);
139220
if (cachedBlock) {
140221
const { updatedBlock, hasUpdates } =
141222
await refetchMissingTransactionSimulations(cachedBlock);
@@ -147,39 +228,16 @@ export async function GET(
147228
return NextResponse.json(serializeBlockData(updatedBlock));
148229
}
149230

150-
const rpcBlock = await fetchBlockFromRpc(hash);
231+
const rpcBlock = await fetchBlockFromRpc(identifier);
151232
if (!rpcBlock || !rpcBlock.hash || !rpcBlock.number) {
152233
return NextResponse.json({ error: "Block not found" }, { status: 404 });
153234
}
154235

155-
const transactions: BlockTransaction[] = await Promise.all(
156-
rpcBlock.transactions.map(async (tx, index) => {
157-
const { bundleId, executionTimeUs } =
158-
await enrichTransactionWithBundleData(tx.hash);
159-
return {
160-
hash: tx.hash,
161-
from: tx.from,
162-
to: tx.to,
163-
gasUsed: tx.gas,
164-
executionTimeUs,
165-
bundleId,
166-
index,
167-
};
168-
}),
236+
const blockData = await buildAndCacheBlockData(
237+
rpcBlock,
238+
rpcBlock.hash,
239+
rpcBlock.number,
169240
);
170-
171-
const blockData: BlockData = {
172-
hash: rpcBlock.hash,
173-
number: rpcBlock.number,
174-
timestamp: rpcBlock.timestamp,
175-
transactions,
176-
gasUsed: rpcBlock.gasUsed,
177-
gasLimit: rpcBlock.gasLimit,
178-
cachedAt: Date.now(),
179-
};
180-
181-
await cacheBlockData(blockData);
182-
183241
return NextResponse.json(serializeBlockData(blockData));
184242
} catch (error) {
185243
console.error("Error fetching block data:", error);

src/app/block/[hash]/page.tsx

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,10 @@ export default function BlockPage({ params }: PageProps) {
244244
useEffect(() => {
245245
if (!hash) return;
246246

247+
setData(null);
248+
setLoading(true);
249+
setError(null);
250+
247251
const fetchData = async () => {
248252
try {
249253
const response = await fetch(`/api/block/${hash}`);
@@ -321,15 +325,82 @@ export default function BlockPage({ params }: PageProps) {
321325
>
322326
TIPS
323327
</Link>
328+
{data && (
329+
<>
330+
<div className="h-6 w-px bg-gray-200" />
331+
<div className="flex items-center gap-1">
332+
{Number(data.number) > 0 ? (
333+
<Link
334+
href={`/block/${Number(data.number) - 1}`}
335+
className="inline-flex items-center justify-center w-8 h-8 rounded-lg hover:bg-gray-100 transition-colors text-gray-500 hover:text-gray-900"
336+
title="Previous block"
337+
>
338+
<svg
339+
className="w-4 h-4"
340+
fill="none"
341+
stroke="currentColor"
342+
viewBox="0 0 24 24"
343+
>
344+
<title>Previous block</title>
345+
<path
346+
strokeLinecap="round"
347+
strokeLinejoin="round"
348+
strokeWidth={2}
349+
d="M15 19l-7-7 7-7"
350+
/>
351+
</svg>
352+
</Link>
353+
) : (
354+
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg text-gray-300 cursor-not-allowed">
355+
<svg
356+
className="w-4 h-4"
357+
fill="none"
358+
stroke="currentColor"
359+
viewBox="0 0 24 24"
360+
>
361+
<title>Previous block</title>
362+
<path
363+
strokeLinecap="round"
364+
strokeLinejoin="round"
365+
strokeWidth={2}
366+
d="M15 19l-7-7 7-7"
367+
/>
368+
</svg>
369+
</span>
370+
)}
371+
<Link
372+
href={`/block/${Number(data.number) + 1}`}
373+
className="inline-flex items-center justify-center w-8 h-8 rounded-lg hover:bg-gray-100 transition-colors text-gray-500 hover:text-gray-900"
374+
title="Next block"
375+
>
376+
<svg
377+
className="w-4 h-4"
378+
fill="none"
379+
stroke="currentColor"
380+
viewBox="0 0 24 24"
381+
>
382+
<title>Next block</title>
383+
<path
384+
strokeLinecap="round"
385+
strokeLinejoin="round"
386+
strokeWidth={2}
387+
d="M9 5l7 7-7 7"
388+
/>
389+
</svg>
390+
</Link>
391+
</div>
392+
</>
393+
)}
324394
</div>
325395
<div className="flex items-center gap-2 text-sm">
326396
<code className="font-mono text-gray-600 bg-gray-100 px-2 py-1 rounded text-xs">
327-
{hash.slice(0, 10)}...{hash.slice(-8)}
397+
{(data?.hash ?? hash).slice(0, 10)}...
398+
{(data?.hash ?? hash).slice(-8)}
328399
</code>
329-
<CopyButton text={hash} />
400+
<CopyButton text={data?.hash ?? hash} />
330401
{BLOCK_EXPLORER_URL && (
331402
<a
332-
href={`${BLOCK_EXPLORER_URL}/block/${hash}`}
403+
href={`${BLOCK_EXPLORER_URL}/block/${data?.hash ?? hash}`}
333404
target="_blank"
334405
rel="noopener noreferrer"
335406
className="p-1.5 rounded-md hover:bg-gray-100 transition-colors text-gray-400 hover:text-gray-600"

0 commit comments

Comments
 (0)