From b6e74eb5bd10183ce6b039221f63d9398a2ca648 Mon Sep 17 00:00:00 2001 From: defiQUG Date: Thu, 16 Apr 2026 14:21:34 -0700 Subject: [PATCH 1/6] Add deterministic block transaction drilldown --- frontend/src/pages/blocks/[number].tsx | 144 +++++++++++++++++- .../src/services/api/transactions.test.ts | 42 +++++ frontend/src/services/api/transactions.ts | 22 +++ 3 files changed, 203 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/blocks/[number].tsx b/frontend/src/pages/blocks/[number].tsx index 8a80f9c..b83ed4d 100644 --- a/frontend/src/pages/blocks/[number].tsx +++ b/frontend/src/pages/blocks/[number].tsx @@ -3,11 +3,12 @@ import { useCallback, useEffect, useState } from 'react' import { useRouter } from 'next/router' import { blocksApi, Block } from '@/services/api/blocks' -import { Card, Address } from '@/libs/frontend-ui-primitives' +import { Card, Address, Table } from '@/libs/frontend-ui-primitives' import Link from 'next/link' import { DetailRow } from '@/components/common/DetailRow' import PageIntro from '@/components/common/PageIntro' -import { formatTimestamp } from '@/utils/format' +import { formatTimestamp, formatWeiAsEth } from '@/utils/format' +import { transactionsApi, type Transaction } from '@/services/api/transactions' export default function BlockDetailPage() { const router = useRouter() @@ -17,7 +18,11 @@ export default function BlockDetailPage() { const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138') const [block, setBlock] = useState(null) + const [blockTransactions, setBlockTransactions] = useState([]) const [loading, setLoading] = useState(true) + const [transactionsLoading, setTransactionsLoading] = useState(true) + const [transactionPage, setTransactionPage] = useState(1) + const blockTransactionPageSize = 25 const loadBlock = useCallback(async () => { setLoading(true) @@ -26,26 +31,99 @@ export default function BlockDetailPage() { setBlock(response.data) } catch (error) { console.error('Failed to load block:', error) + setBlock(null) } finally { setLoading(false) } }, [chainId, blockNumber]) + const loadBlockTransactions = useCallback(async () => { + setTransactionsLoading(true) + try { + const { ok, data } = await transactionsApi.listByBlockSafe(chainId, blockNumber, transactionPage, blockTransactionPageSize) + setBlockTransactions(ok ? data : []) + } catch (error) { + console.error('Failed to load block transactions:', error) + setBlockTransactions([]) + } finally { + setTransactionsLoading(false) + } + }, [blockNumber, blockTransactionPageSize, chainId, transactionPage]) + useEffect(() => { if (!router.isReady) { return } if (!isValidBlock) { setLoading(false) + setTransactionsLoading(false) setBlock(null) + setBlockTransactions([]) return } - loadBlock() + void loadBlock() }, [isValidBlock, loadBlock, router.isReady]) + useEffect(() => { + if (!router.isReady || !isValidBlock) { + return + } + setTransactionPage(1) + }, [blockNumber, isValidBlock, router.isReady]) + + useEffect(() => { + if (!router.isReady || !isValidBlock) { + return + } + void loadBlockTransactions() + }, [isValidBlock, loadBlockTransactions, router.isReady]) + const gasUtilization = block && block.gas_limit > 0 ? Math.round((block.gas_used / block.gas_limit) * 100) : null + const canGoNextTransactionsPage = blockTransactions.length === blockTransactionPageSize + + const transactionColumns = [ + { + header: 'Hash', + accessor: (transaction: Transaction) => ( + +
+ + ), + }, + { + header: 'From', + accessor: (transaction: Transaction) => ( + +
+ + ), + }, + { + header: 'To', + accessor: (transaction: Transaction) => + transaction.to_address ? ( + +
+ + ) : ( + Contract creation + ), + }, + { + header: 'Value', + accessor: (transaction: Transaction) => formatWeiAsEth(transaction.value), + }, + { + header: 'Status', + accessor: (transaction: Transaction) => ( + + {transaction.status === 1 ? 'Success' : 'Failed'} + + ), + }, + ] return (
@@ -74,6 +152,11 @@ export default function BlockDetailPage() { Next block )} + {block?.transaction_count ? ( + + Open block transactions + + ) : null}
{!router.isReady || loading ? ( @@ -119,9 +202,9 @@ export default function BlockDetailPage() { - + {block.transaction_count} - + {block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()} @@ -134,6 +217,57 @@ export default function BlockDetailPage() { )} + + {block && ( + +
+

+ This section shows the exact indexed transaction set for block #{block.number.toLocaleString()}, independent of generic explorer search. +

+ {transactionsLoading ? ( +

Loading block transactions...

+ ) : ( + <> + 0 + ? 'No indexed block transactions were returned for this page yet.' + : 'This block does not contain any indexed transactions.' + } + keyExtractor={(transaction) => transaction.hash} + /> + {block.transaction_count > 0 ? ( +
+ + Showing page {transactionPage} of the indexed transactions for this block. + +
+ + +
+
+ ) : null} + + )} + + + )} ) } diff --git a/frontend/src/services/api/transactions.test.ts b/frontend/src/services/api/transactions.test.ts index e31e701..b140d7b 100644 --- a/frontend/src/services/api/transactions.test.ts +++ b/frontend/src/services/api/transactions.test.ts @@ -2,6 +2,48 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { transactionsApi } from './transactions' +describe('transactionsApi.listByBlockSafe', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('returns normalized transactions for a specific block', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + items: [ + { + hash: '0xabc', + block_number: 123, + block_hash: '0xdef', + transaction_index: 0, + from: { hash: '0x0000000000000000000000000000000000000001' }, + to: { hash: '0x0000000000000000000000000000000000000002' }, + value: '0', + gas_price: '1', + gas: '21000', + gas_used: '21000', + status: 'ok', + timestamp: '2026-04-16T09:40:12.000000Z', + }, + ], + }), + }) + + vi.stubGlobal('fetch', fetchMock) + + const result = await transactionsApi.listByBlockSafe(138, 123, 1, 10) + + expect(result.ok).toBe(true) + expect(result.data).toHaveLength(1) + expect(result.data[0]?.hash).toBe('0xabc') + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0]?.[0]).toEqual( + expect.stringContaining('/api/v2/blocks/123/transactions?page=1&page_size=10'), + ) + }) +}) + describe('transactionsApi.diagnoseMissing', () => { beforeEach(() => { vi.restoreAllMocks() diff --git a/frontend/src/services/api/transactions.ts b/frontend/src/services/api/transactions.ts index 59cbbd9..8df0947 100644 --- a/frontend/src/services/api/transactions.ts +++ b/frontend/src/services/api/transactions.ts @@ -227,4 +227,26 @@ export const transactionsApi = { return { ok: false, data: [] } } }, + listByBlock: async (chainId: number, blockNumber: number, page = 1, pageSize = 25): Promise> => { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + }) + const raw = await fetchBlockscoutJson<{ items?: unknown[] }>(`/api/v2/blocks/${blockNumber}/transactions?${params.toString()}`) + const data = Array.isArray(raw?.items) ? raw.items.map((item) => normalizeTransaction(item as never, chainId)) : [] + return { data } + }, + listByBlockSafe: async ( + chainId: number, + blockNumber: number, + page = 1, + pageSize = 25, + ): Promise<{ ok: boolean; data: Transaction[] }> => { + try { + const { data } = await transactionsApi.listByBlock(chainId, blockNumber, page, pageSize) + return { ok: true, data } + } catch { + return { ok: false, data: [] } + } + }, } From 2e59b9d19c8eb6fc28d29c262534df5c49d45e68 Mon Sep 17 00:00:00 2001 From: defiQUG Date: Thu, 16 Apr 2026 14:27:44 -0700 Subject: [PATCH 2/6] Tighten block transaction drilldown paging --- frontend/src/pages/blocks/[number].tsx | 21 ++++++++++---- .../src/services/api/transactions.test.ts | 14 ++++++++++ frontend/src/services/api/transactions.ts | 28 +++++++++++++++---- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/frontend/src/pages/blocks/[number].tsx b/frontend/src/pages/blocks/[number].tsx index b83ed4d..97fd0a0 100644 --- a/frontend/src/pages/blocks/[number].tsx +++ b/frontend/src/pages/blocks/[number].tsx @@ -21,6 +21,8 @@ export default function BlockDetailPage() { const [blockTransactions, setBlockTransactions] = useState([]) const [loading, setLoading] = useState(true) const [transactionsLoading, setTransactionsLoading] = useState(true) + const [transactionsError, setTransactionsError] = useState(false) + const [hasNextTransactionsPage, setHasNextTransactionsPage] = useState(false) const [transactionPage, setTransactionPage] = useState(1) const blockTransactionPageSize = 25 @@ -39,12 +41,17 @@ export default function BlockDetailPage() { const loadBlockTransactions = useCallback(async () => { setTransactionsLoading(true) + setTransactionsError(false) try { - const { ok, data } = await transactionsApi.listByBlockSafe(chainId, blockNumber, transactionPage, blockTransactionPageSize) - setBlockTransactions(ok ? data : []) + const { ok, data, hasNextPage } = await transactionsApi.listByBlockSafe(chainId, blockNumber, transactionPage, blockTransactionPageSize) + setBlockTransactions(data) + setHasNextTransactionsPage(hasNextPage) + setTransactionsError(!ok) } catch (error) { console.error('Failed to load block transactions:', error) setBlockTransactions([]) + setHasNextTransactionsPage(false) + setTransactionsError(true) } finally { setTransactionsLoading(false) } @@ -57,6 +64,8 @@ export default function BlockDetailPage() { if (!isValidBlock) { setLoading(false) setTransactionsLoading(false) + setTransactionsError(false) + setHasNextTransactionsPage(false) setBlock(null) setBlockTransactions([]) return @@ -81,8 +90,6 @@ export default function BlockDetailPage() { const gasUtilization = block && block.gas_limit > 0 ? Math.round((block.gas_used / block.gas_limit) * 100) : null - const canGoNextTransactionsPage = blockTransactions.length === blockTransactionPageSize - const transactionColumns = [ { header: 'Hash', @@ -232,7 +239,9 @@ export default function BlockDetailPage() { columns={transactionColumns} data={blockTransactions} emptyMessage={ - block.transaction_count > 0 + transactionsError + ? 'Unable to load indexed block transactions right now. Please retry from this page in a moment.' + : block.transaction_count > 0 ? 'No indexed block transactions were returned for this page yet.' : 'This block does not contain any indexed transactions.' } @@ -255,7 +264,7 @@ export default function BlockDetailPage() {
0 - ? 'No indexed block transactions were returned for this page yet.' - : 'This block does not contain any indexed transactions.' - } + emptyMessage={transactionsEmptyMessage} keyExtractor={(transaction) => transaction.hash} /> {block.transaction_count > 0 ? ( From 06070e479ed5d0185f615e6629164132d306467d Mon Sep 17 00:00:00 2001 From: defiQUG Date: Thu, 16 Apr 2026 15:06:26 -0700 Subject: [PATCH 5/6] Guard block transaction fetch state --- frontend/src/hooks/useBlockTransactions.ts | 62 ++++++++++++---------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/frontend/src/hooks/useBlockTransactions.ts b/frontend/src/hooks/useBlockTransactions.ts index 7bbec81..65e03cb 100644 --- a/frontend/src/hooks/useBlockTransactions.ts +++ b/frontend/src/hooks/useBlockTransactions.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { transactionsApi, type Transaction } from '@/services/api/transactions' @@ -16,9 +16,14 @@ export function useBlockTransactions({ blockNumber, chainId, enabled }: UseBlock const [error, setError] = useState(false) const [hasNextPage, setHasNextPage] = useState(false) const [page, setPage] = useState(1) + const previousBlockNumberRef = useRef(blockNumber) - const loadTransactions = useCallback(async () => { + useEffect(() => { if (!enabled) { + previousBlockNumberRef.current = blockNumber + if (page !== 1) { + setPage(1) + } setTransactions([]) setLoading(false) setError(false) @@ -26,37 +31,40 @@ export function useBlockTransactions({ blockNumber, chainId, enabled }: UseBlock return } + if (previousBlockNumberRef.current !== blockNumber) { + previousBlockNumberRef.current = blockNumber + if (page !== 1) { + setPage(1) + return + } + } + + let cancelled = false setLoading(true) setError(false) - const result = await transactionsApi.listByBlockSafe( - chainId, - blockNumber, - page, - DEFAULT_BLOCK_TRANSACTION_PAGE_SIZE, - ) - setTransactions(result.items) - setHasNextPage(result.hasNextPage) - setError(!result.ok) - setLoading(false) - }, [blockNumber, chainId, enabled, page]) + void (async () => { + const result = await transactionsApi.listByBlockSafe( + chainId, + blockNumber, + page, + DEFAULT_BLOCK_TRANSACTION_PAGE_SIZE, + ) - useEffect(() => { - if (!enabled) { - setPage(1) - setTransactions([]) + if (cancelled) { + return + } + + setTransactions(result.items) + setHasNextPage(result.hasNextPage) + setError(!result.ok) setLoading(false) - setError(false) - setHasNextPage(false) - return + })() + + return () => { + cancelled = true } - - setPage(1) - }, [blockNumber, enabled]) - - useEffect(() => { - void loadTransactions() - }, [loadTransactions]) + }, [blockNumber, chainId, enabled, page]) return { transactions, From 39d2f2482bb00e73ca15543b10a536dd7c05af73 Mon Sep 17 00:00:00 2001 From: defiQUG Date: Thu, 16 Apr 2026 15:46:48 -0700 Subject: [PATCH 6/6] Guard block detail fetch state --- frontend/src/pages/blocks/[number].tsx | 45 +++++++++++++++++--------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/frontend/src/pages/blocks/[number].tsx b/frontend/src/pages/blocks/[number].tsx index fd88b51..a140044 100644 --- a/frontend/src/pages/blocks/[number].tsx +++ b/frontend/src/pages/blocks/[number].tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useRouter } from 'next/router' import { blocksApi, Block } from '@/services/api/blocks' import { Card, Address, Table } from '@/libs/frontend-ui-primitives' @@ -34,19 +34,6 @@ export default function BlockDetailPage() { enabled: router.isReady && isValidBlock, }) - const loadBlock = useCallback(async () => { - setLoading(true) - try { - const response = await blocksApi.getByNumber(chainId, blockNumber) - setBlock(response.data) - } catch (error) { - console.error('Failed to load block:', error) - setBlock(null) - } finally { - setLoading(false) - } - }, [chainId, blockNumber]) - useEffect(() => { if (!router.isReady) { return @@ -56,8 +43,34 @@ export default function BlockDetailPage() { setBlock(null) return } - void loadBlock() - }, [isValidBlock, loadBlock, router.isReady]) + + let cancelled = false + setLoading(true) + + void (async () => { + try { + const response = await blocksApi.getByNumber(chainId, blockNumber) + if (cancelled) { + return + } + setBlock(response.data) + } catch (error) { + console.error('Failed to load block:', error) + if (cancelled) { + return + } + setBlock(null) + } finally { + if (!cancelled) { + setLoading(false) + } + } + })() + + return () => { + cancelled = true + } + }, [blockNumber, chainId, isValidBlock, router.isReady]) const gasUtilization = block && block.gas_limit > 0 ? Math.round((block.gas_used / block.gas_limit) * 100)