diff --git a/frontend/src/hooks/useBlockTransactions.ts b/frontend/src/hooks/useBlockTransactions.ts new file mode 100644 index 0000000..65e03cb --- /dev/null +++ b/frontend/src/hooks/useBlockTransactions.ts @@ -0,0 +1,77 @@ +import { useEffect, useRef, useState } from 'react' + +import { transactionsApi, type Transaction } from '@/services/api/transactions' + +const DEFAULT_BLOCK_TRANSACTION_PAGE_SIZE = 25 + +interface UseBlockTransactionsOptions { + blockNumber: number + chainId: number + enabled: boolean +} + +export function useBlockTransactions({ blockNumber, chainId, enabled }: UseBlockTransactionsOptions) { + const [transactions, setTransactions] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(false) + const [hasNextPage, setHasNextPage] = useState(false) + const [page, setPage] = useState(1) + const previousBlockNumberRef = useRef(blockNumber) + + useEffect(() => { + if (!enabled) { + previousBlockNumberRef.current = blockNumber + if (page !== 1) { + setPage(1) + } + setTransactions([]) + setLoading(false) + setError(false) + setHasNextPage(false) + return + } + + if (previousBlockNumberRef.current !== blockNumber) { + previousBlockNumberRef.current = blockNumber + if (page !== 1) { + setPage(1) + return + } + } + + let cancelled = false + setLoading(true) + setError(false) + + void (async () => { + const result = await transactionsApi.listByBlockSafe( + chainId, + blockNumber, + page, + DEFAULT_BLOCK_TRANSACTION_PAGE_SIZE, + ) + + if (cancelled) { + return + } + + setTransactions(result.items) + setHasNextPage(result.hasNextPage) + setError(!result.ok) + setLoading(false) + })() + + return () => { + cancelled = true + } + }, [blockNumber, chainId, enabled, page]) + + return { + transactions, + loading, + error, + hasNextPage, + page, + setPage, + } +} diff --git a/frontend/src/pages/blocks/[number].tsx b/frontend/src/pages/blocks/[number].tsx index 8a80f9c..a140044 100644 --- a/frontend/src/pages/blocks/[number].tsx +++ b/frontend/src/pages/blocks/[number].tsx @@ -1,13 +1,15 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useMemo, 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 { type Transaction } from '@/services/api/transactions' +import { useBlockTransactions } from '@/hooks/useBlockTransactions' export default function BlockDetailPage() { const router = useRouter() @@ -19,17 +21,18 @@ export default function BlockDetailPage() { const [block, setBlock] = useState(null) const [loading, setLoading] = useState(true) - 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) - } finally { - setLoading(false) - } - }, [chainId, blockNumber]) + const { + transactions: blockTransactions, + loading: transactionsLoading, + error: transactionsError, + hasNextPage: hasNextTransactionsPage, + page: transactionPage, + setPage: setTransactionPage, + } = useBlockTransactions({ + blockNumber, + chainId, + enabled: router.isReady && isValidBlock, + }) useEffect(() => { if (!router.isReady) { @@ -40,12 +43,85 @@ export default function BlockDetailPage() { setBlock(null) return } - 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) : null + const transactionColumns = useMemo(() => [ + { + 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'} + + ), + }, + ], []) + + const transactionsEmptyMessage = transactionsError + ? 'Unable to load indexed block transactions right now. Please retry from this page in a moment.' + : (block?.transaction_count ?? 0) > 0 + ? 'No indexed block transactions were returned for this page yet.' + : 'This block does not contain any indexed transactions.' return (
@@ -74,6 +150,11 @@ export default function BlockDetailPage() { Next block )} + {block?.transaction_count ? ( + + Open block transactions + + ) : null}
{!router.isReady || loading ? ( @@ -119,9 +200,9 @@ export default function BlockDetailPage() { - + {block.transaction_count} - + {block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()} @@ -134,6 +215,53 @@ 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...

+ ) : ( + <> + 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..7f9146a 100644 --- a/frontend/src/services/api/transactions.test.ts +++ b/frontend/src/services/api/transactions.test.ts @@ -2,6 +2,62 @@ 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', + }, + ], + next_page_params: { page: 2, page_size: 10 }, + }), + }) + + vi.stubGlobal('fetch', fetchMock) + + const result = await transactionsApi.listByBlockSafe(138, 123, 1, 10) + + expect(result.ok).toBe(true) + expect(result.items).toHaveLength(1) + expect(result.hasNextPage).toBe(true) + expect(result.items[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'), + ) + }) + + it('returns a non-throwing failure result when the block transaction request fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down'))) + + const result = await transactionsApi.listByBlockSafe(138, 123, 1, 10) + + expect(result).toEqual({ + ok: false, + items: [], + hasNextPage: false, + }) + }) +}) + describe('transactionsApi.diagnoseMissing', () => { beforeEach(() => { vi.restoreAllMocks() diff --git a/frontend/src/services/api/transactions.ts b/frontend/src/services/api/transactions.ts index 59cbbd9..da7c4d0 100644 --- a/frontend/src/services/api/transactions.ts +++ b/frontend/src/services/api/transactions.ts @@ -76,6 +76,17 @@ export interface TransactionLookupDiagnostic { rpc_url?: string } +export interface BlockTransactionListPage { + items: Transaction[] + hasNextPage: boolean +} + +export interface SafeTransactionPage { + ok: boolean + items: T[] + hasNextPage: boolean +} + const CHAIN_138_PUBLIC_RPC_URL = 'https://rpc-http-pub.d-bis.org' function resolvePublicRpcUrl(chainId: number): string | null { @@ -227,4 +238,37 @@ 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[]; next_page_params?: Record | null }>( + `/api/v2/blocks/${blockNumber}/transactions?${params.toString()}` + ) + const data = Array.isArray(raw?.items) ? raw.items.map((item) => normalizeTransaction(item as never, chainId)) : [] + return { + data: { + items: data, + hasNextPage: raw?.next_page_params != null, + }, + } + }, + listByBlockSafe: async ( + chainId: number, + blockNumber: number, + page = 1, + pageSize = 25, + ): Promise> => { + try { + const { data } = await transactionsApi.listByBlock(chainId, blockNumber, page, pageSize) + return { + ok: true, + items: data.items, + hasNextPage: data.hasNextPage, + } + } catch { + return { ok: false, items: [], hasNextPage: false } + } + }, }