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: [] } + } + }, }