Add deterministic block transaction drilldown

This commit is contained in:
defiQUG
2026-04-16 14:21:34 -07:00
parent e1c3b40cb0
commit b6e74eb5bd
3 changed files with 203 additions and 5 deletions

View File

@@ -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<Block | null>(null)
const [blockTransactions, setBlockTransactions] = useState<Transaction[]>([])
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) => (
<Link href={`/transactions/${transaction.hash}`} className="text-primary-600 hover:underline">
<Address address={transaction.hash} truncate showCopy={false} />
</Link>
),
},
{
header: 'From',
accessor: (transaction: Transaction) => (
<Link href={`/addresses/${transaction.from_address}`} className="text-primary-600 hover:underline">
<Address address={transaction.from_address} truncate showCopy={false} />
</Link>
),
},
{
header: 'To',
accessor: (transaction: Transaction) =>
transaction.to_address ? (
<Link href={`/addresses/${transaction.to_address}`} className="text-primary-600 hover:underline">
<Address address={transaction.to_address} truncate showCopy={false} />
</Link>
) : (
<span className="text-gray-500 dark:text-gray-400">Contract creation</span>
),
},
{
header: 'Value',
accessor: (transaction: Transaction) => formatWeiAsEth(transaction.value),
},
{
header: 'Status',
accessor: (transaction: Transaction) => (
<span className={transaction.status === 1 ? 'text-green-600' : 'text-red-600'}>
{transaction.status === 1 ? 'Success' : 'Failed'}
</span>
),
},
]
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
@@ -74,6 +152,11 @@ export default function BlockDetailPage() {
Next block
</Link>
)}
{block?.transaction_count ? (
<a href="#block-transactions" className="text-primary-600 hover:underline">
Open block transactions
</a>
) : null}
</div>
{!router.isReady || loading ? (
@@ -119,9 +202,9 @@ export default function BlockDetailPage() {
</Link>
</DetailRow>
<DetailRow label="Transactions">
<Link href={`/search?q=${block.number}`} className="text-primary-600 hover:underline">
<a href="#block-transactions" className="text-primary-600 hover:underline">
{block.transaction_count}
</Link>
</a>
</DetailRow>
<DetailRow label="Gas Used">
{block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()}
@@ -134,6 +217,57 @@ export default function BlockDetailPage() {
</dl>
</Card>
)}
{block && (
<Card title="Block Transactions" className="mt-6">
<div id="block-transactions" className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
This section shows the exact indexed transaction set for block #{block.number.toLocaleString()}, independent of generic explorer search.
</p>
{transactionsLoading ? (
<p className="text-sm text-gray-600 dark:text-gray-400">Loading block transactions...</p>
) : (
<>
<Table
columns={transactionColumns}
data={blockTransactions}
emptyMessage={
block.transaction_count > 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 ? (
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-600 dark:text-gray-400">
<span>
Showing page {transactionPage} of the indexed transactions for this block.
</span>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => setTransactionPage((current) => Math.max(1, current - 1))}
disabled={transactionsLoading || transactionPage === 1}
className="rounded bg-gray-200 px-4 py-2 text-gray-900 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-800 dark:text-gray-100"
>
Previous tx page
</button>
<button
type="button"
onClick={() => setTransactionPage((current) => current + 1)}
disabled={transactionsLoading || !canGoNextTransactionsPage}
className="rounded bg-gray-200 px-4 py-2 text-gray-900 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-800 dark:text-gray-100"
>
Next tx page
</button>
</div>
</div>
) : null}
</>
)}
</div>
</Card>
)}
</div>
)
}

View File

@@ -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()

View File

@@ -227,4 +227,26 @@ export const transactionsApi = {
return { ok: false, data: [] }
}
},
listByBlock: async (chainId: number, blockNumber: number, page = 1, pageSize = 25): Promise<ApiResponse<Transaction[]>> => {
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: [] }
}
},
}