From 773aabb86acc1cacb63a8a8f19761515531efd89 Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Thu, 18 Dec 2025 15:18:16 +0000 Subject: [PATCH 1/4] feat: /address events tab --- apps/explorer/src/comps/DataGrid.tsx | 5 +- apps/explorer/src/comps/Pagination.tsx | 33 +- .../src/lib/queries/address-events.ts | 82 +++++ apps/explorer/src/routeTree.gen.ts | 43 +++ .../src/routes/_layout/address/$address.tsx | 330 +++++++++++++++--- .../src/routes/api/address/$address.ts | 4 +- .../api/address/events-count/$address.ts | 82 +++++ .../src/routes/api/address/events/$address.ts | 143 ++++++++ 8 files changed, 657 insertions(+), 65 deletions(-) create mode 100644 apps/explorer/src/lib/queries/address-events.ts create mode 100644 apps/explorer/src/routes/api/address/events-count/$address.ts create mode 100644 apps/explorer/src/routes/api/address/events/$address.ts diff --git a/apps/explorer/src/comps/DataGrid.tsx b/apps/explorer/src/comps/DataGrid.tsx index 7d43fe54c..951fc08b7 100644 --- a/apps/explorer/src/comps/DataGrid.tsx +++ b/apps/explorer/src/comps/DataGrid.tsx @@ -15,6 +15,7 @@ export function DataGrid(props: DataGrid.Props) { loading = false, countLoading = false, disableLastPage = false, + hasMore, itemsLabel = 'items', itemsPerPage = 10, pagination = 'default', @@ -157,10 +158,11 @@ export function DataGrid(props: DataGrid.Props) {
{/* Show transaction count - loading state shown while fetching */} 0 ? page < totalPages : hasMore return (
{Pagination.numFormat.format(page)} - {' '} - of{' '} - {countLoading - ? '…' - : totalPages > 0 - ? Pagination.numFormat.format(totalPages) - : '…'} + + {totalPages > 0 && ( + <> + {' '} + of {countLoading ? '…' : Pagination.numFormat.format(totalPages)} + + )} = totalPages} + disabled={!canGoNext} className={cx( 'rounded-full border border-base-border hover:bg-alt flex items-center justify-center cursor-pointer active:translate-y-[0.5px] aria-disabled:cursor-not-allowed aria-disabled:opacity-50 size-[24px] text-primary', )} @@ -295,7 +303,7 @@ export namespace Pagination { to="." resetScroll={false} search={(prev) => ({ ...prev, page: totalPages, live: false })} - disabled={page >= totalPages || disableLastPage} + disabled={page >= totalPages || disableLastPage || totalPages === 0} className={cx( 'rounded-full border border-base-border hover:bg-alt flex items-center justify-center cursor-pointer active:translate-y-[0.5px] aria-disabled:cursor-not-allowed aria-disabled:opacity-50 size-[24px] text-primary', )} @@ -315,12 +323,15 @@ export namespace Pagination { countLoading?: boolean /** Disable "Last page" button when we can't reliably navigate there */ disableLastPage?: boolean + hasMore?: boolean } } export function Count(props: Count.Props) { const { page, totalPages, totalItems, itemsLabel, loading, className } = props + const displayCount = + totalItems > 1000 ? '1000+' : Pagination.numFormat.format(totalItems) return (
)} - {loading ? '…' : Pagination.numFormat.format(totalItems)} + {loading ? '…' : displayCount} {itemsLabel}
diff --git a/apps/explorer/src/lib/queries/address-events.ts b/apps/explorer/src/lib/queries/address-events.ts new file mode 100644 index 000000000..a2f96e7ff --- /dev/null +++ b/apps/explorer/src/lib/queries/address-events.ts @@ -0,0 +1,82 @@ +import { keepPreviousData, queryOptions } from '@tanstack/react-query' +import type { Address, Hex } from 'ox' + +export type AddressEventData = { + txHash: Hex.Hex + blockNumber: Hex.Hex + blockTimestamp: number | null + logIndex: number + contractAddress: Address.Address + topics: Hex.Hex[] + data: Hex.Hex +} + +export type AddressEventsApiResponse = { + events: AddressEventData[] + total: number + offset: number + limit: number + hasMore: boolean + error: null | string +} + +type AddressEventsRequestParameters = { + offset: number + limit: number +} + +export function addressEventsQueryOptions( + params: { + page: number + address: Address.Address + } & AddressEventsRequestParameters, +) { + const searchParams = new URLSearchParams({ + limit: params.limit.toString(), + offset: params.offset.toString(), + }) + return queryOptions({ + queryKey: [ + 'account-events', + params.address, + params.page, + params.limit, + params.offset, + ], + queryFn: async (): Promise => { + const response = await fetch( + `${__BASE_URL__}/api/address/events/${params.address}?${searchParams}`, + ) + const data = await response.json() + return data as AddressEventsApiResponse + }, + staleTime: 10_000, + refetchInterval: false, + refetchOnWindowFocus: false, + placeholderData: keepPreviousData, + }) +} + +export type AddressEventsData = Awaited< + ReturnType['queryFn']>> +> + +export type AddressEventsCountResponse = { + data: number | null + isExact: boolean + error: string | null +} + +export function addressEventsCountQueryOptions(address: Address.Address) { + return queryOptions({ + queryKey: ['address-events-count', address], + queryFn: async (): Promise => { + const response = await fetch( + `${__BASE_URL__}/api/address/events-count/${address}`, + ) + return response.json() as Promise + }, + staleTime: 60_000, + retry: false, + }) +} diff --git a/apps/explorer/src/routeTree.gen.ts b/apps/explorer/src/routeTree.gen.ts index 9df474610..b5dec2fb6 100644 --- a/apps/explorer/src/routeTree.gen.ts +++ b/apps/explorer/src/routeTree.gen.ts @@ -32,6 +32,8 @@ import { Route as ApiTxTraceHashRouteImport } from './routes/api/tx/trace/$hash' import { Route as ApiTxBalanceChangesHashRouteImport } from './routes/api/tx/balance-changes/$hash' import { Route as ApiAddressTxsCountAddressRouteImport } from './routes/api/address/txs-count/$address' import { Route as ApiAddressTotalValueAddressRouteImport } from './routes/api/address/total-value/$address' +import { Route as ApiAddressEventsAddressRouteImport } from './routes/api/address/events/$address' +import { Route as ApiAddressEventsCountAddressRouteImport } from './routes/api/address/events-count/$address' const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', @@ -149,6 +151,17 @@ const ApiAddressTotalValueAddressRoute = path: '/api/address/total-value/$address', getParentRoute: () => rootRouteImport, } as any) +const ApiAddressEventsAddressRoute = ApiAddressEventsAddressRouteImport.update({ + id: '/api/address/events/$address', + path: '/api/address/events/$address', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAddressEventsCountAddressRoute = + ApiAddressEventsCountAddressRouteImport.update({ + id: '/api/address/events-count/$address', + path: '/api/address/events-count/$address', + getParentRoute: () => rootRouteImport, + } as any) export interface FileRoutesByFullPath { '/blocks': typeof LayoutBlocksRoute @@ -169,6 +182,8 @@ export interface FileRoutesByFullPath { '/tx/$hash': typeof LayoutTxHashRoute '/api/address/$address': typeof ApiAddressAddressRoute '/demo': typeof LayoutDemoIndexRoute + '/api/address/events-count/$address': typeof ApiAddressEventsCountAddressRoute + '/api/address/events/$address': typeof ApiAddressEventsAddressRoute '/api/address/total-value/$address': typeof ApiAddressTotalValueAddressRoute '/api/address/txs-count/$address': typeof ApiAddressTxsCountAddressRoute '/api/tx/balance-changes/$hash': typeof ApiTxBalanceChangesHashRoute @@ -193,6 +208,8 @@ export interface FileRoutesByTo { '/tx/$hash': typeof LayoutTxHashRoute '/api/address/$address': typeof ApiAddressAddressRoute '/demo': typeof LayoutDemoIndexRoute + '/api/address/events-count/$address': typeof ApiAddressEventsCountAddressRoute + '/api/address/events/$address': typeof ApiAddressEventsAddressRoute '/api/address/total-value/$address': typeof ApiAddressTotalValueAddressRoute '/api/address/txs-count/$address': typeof ApiAddressTxsCountAddressRoute '/api/tx/balance-changes/$hash': typeof ApiTxBalanceChangesHashRoute @@ -219,6 +236,8 @@ export interface FileRoutesById { '/_layout/tx/$hash': typeof LayoutTxHashRoute '/api/address/$address': typeof ApiAddressAddressRoute '/_layout/demo/': typeof LayoutDemoIndexRoute + '/api/address/events-count/$address': typeof ApiAddressEventsCountAddressRoute + '/api/address/events/$address': typeof ApiAddressEventsAddressRoute '/api/address/total-value/$address': typeof ApiAddressTotalValueAddressRoute '/api/address/txs-count/$address': typeof ApiAddressTxsCountAddressRoute '/api/tx/balance-changes/$hash': typeof ApiTxBalanceChangesHashRoute @@ -245,6 +264,8 @@ export interface FileRouteTypes { | '/tx/$hash' | '/api/address/$address' | '/demo' + | '/api/address/events-count/$address' + | '/api/address/events/$address' | '/api/address/total-value/$address' | '/api/address/txs-count/$address' | '/api/tx/balance-changes/$hash' @@ -269,6 +290,8 @@ export interface FileRouteTypes { | '/tx/$hash' | '/api/address/$address' | '/demo' + | '/api/address/events-count/$address' + | '/api/address/events/$address' | '/api/address/total-value/$address' | '/api/address/txs-count/$address' | '/api/tx/balance-changes/$hash' @@ -294,6 +317,8 @@ export interface FileRouteTypes { | '/_layout/tx/$hash' | '/api/address/$address' | '/_layout/demo/' + | '/api/address/events-count/$address' + | '/api/address/events/$address' | '/api/address/total-value/$address' | '/api/address/txs-count/$address' | '/api/tx/balance-changes/$hash' @@ -307,6 +332,8 @@ export interface RootRouteChildren { ApiSearchRoute: typeof ApiSearchRoute ApiTunnelRoute: typeof ApiTunnelRoute ApiAddressAddressRoute: typeof ApiAddressAddressRoute + ApiAddressEventsCountAddressRoute: typeof ApiAddressEventsCountAddressRoute + ApiAddressEventsAddressRoute: typeof ApiAddressEventsAddressRoute ApiAddressTotalValueAddressRoute: typeof ApiAddressTotalValueAddressRoute ApiAddressTxsCountAddressRoute: typeof ApiAddressTxsCountAddressRoute ApiTxBalanceChangesHashRoute: typeof ApiTxBalanceChangesHashRoute @@ -476,6 +503,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAddressTotalValueAddressRouteImport parentRoute: typeof rootRouteImport } + '/api/address/events/$address': { + id: '/api/address/events/$address' + path: '/api/address/events/$address' + fullPath: '/api/address/events/$address' + preLoaderRoute: typeof ApiAddressEventsAddressRouteImport + parentRoute: typeof rootRouteImport + } + '/api/address/events-count/$address': { + id: '/api/address/events-count/$address' + path: '/api/address/events-count/$address' + fullPath: '/api/address/events-count/$address' + preLoaderRoute: typeof ApiAddressEventsCountAddressRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -521,6 +562,8 @@ const rootRouteChildren: RootRouteChildren = { ApiSearchRoute: ApiSearchRoute, ApiTunnelRoute: ApiTunnelRoute, ApiAddressAddressRoute: ApiAddressAddressRoute, + ApiAddressEventsCountAddressRoute: ApiAddressEventsCountAddressRoute, + ApiAddressEventsAddressRoute: ApiAddressEventsAddressRoute, ApiAddressTotalValueAddressRoute: ApiAddressTotalValueAddressRoute, ApiAddressTxsCountAddressRoute: ApiAddressTxsCountAddressRoute, ApiTxBalanceChangesHashRoute: ApiTxBalanceChangesHashRoute, diff --git a/apps/explorer/src/routes/_layout/address/$address.tsx b/apps/explorer/src/routes/_layout/address/$address.tsx index 66b56325d..cafd834ef 100644 --- a/apps/explorer/src/routes/_layout/address/$address.tsx +++ b/apps/explorer/src/routes/_layout/address/$address.tsx @@ -9,7 +9,7 @@ import { useNavigate, useRouter, } from '@tanstack/react-router' -import { Address, Hex } from 'ox' +import { Address as OxAddress, Hex } from 'ox' import * as React from 'react' import { Hooks } from 'tempo.ts/wagmi' import { formatUnits, isHash, type RpcTransaction as Transaction } from 'viem' @@ -36,8 +36,10 @@ import { useTimeFormat, } from '#comps/TimeFormat' import { TokenIcon } from '#comps/TokenIcon' +import { TxEventDescription } from '#comps/TxEventDescription' import { BatchTransactionDataContext, + getPerspectiveEvent, type TransactionData, TransactionDescription, TransactionTimestamp, @@ -56,18 +58,26 @@ import { getContractBytecode, getContractInfo, } from '#lib/domain/contracts' -import { parseKnownEvents } from '#lib/domain/known-events' +import { parseKnownEvent, parseKnownEvents } from '#lib/domain/known-events' import * as Tip20 from '#lib/domain/tip20' import { DateFormatter, HexFormatter, PriceFormatter } from '#lib/formatting' +import { useLookupSignature } from '#lib/abi' import { useIsMounted, useMediaQuery } from '#lib/hooks' import { buildAddressDescription, buildAddressOgImageUrl } from '#lib/og' import { type TransactionsData, transactionsQueryOptions, } from '#lib/queries/account.ts' +import { + type AddressEventData, + type AddressEventsApiResponse, + type AddressEventsCountResponse, + addressEventsQueryOptions, + addressEventsCountQueryOptions, +} from '#lib/queries/address-events.ts' import { config, getConfig } from '#wagmi.config.ts' -async function fetchAddressTotalValue(address: Address.Address) { +async function fetchAddressTotalValue(address: OxAddress.Address) { const response = await fetch( `${__BASE_URL__}/api/address/total-value/${address}`, { headers: { 'Content-Type': 'application/json' } }, @@ -75,7 +85,7 @@ async function fetchAddressTotalValue(address: Address.Address) { return response.json() as Promise<{ totalValue: number }> } -async function fetchAddressTotalCount(address: Address.Address) { +async function fetchAddressTotalCount(address: OxAddress.Address) { const response = await fetch( `${__BASE_URL__}/api/address/txs-count/${address}`, { headers: { 'Content-Type': 'application/json' } }, @@ -99,11 +109,11 @@ const defaultSearchValues = { tab: 'history', } as const -type TabValue = 'history' | 'assets' | 'contract' +type TabValue = 'history' | 'events' | 'assets' | 'contract' function useBatchTransactionData( transactions: Transaction[], - viewer: Address.Address, + viewer: OxAddress.Address, ) { const hashes = React.useMemo( () => transactions.map((tx) => tx.hash).filter(isHash), @@ -154,12 +164,12 @@ const assets = [ ] as const type AssetData = { - address: Address.Address + address: OxAddress.Address metadata: { name?: string; symbol?: string; decimals?: number } | undefined balance: bigint | undefined } -function useAssetsData(accountAddress: Address.Address): AssetData[] { +function useAssetsData(accountAddress: OxAddress.Address): AssetData[] { // Track hydration to avoid SSR/client mismatch with cached query data const isMounted = useIsMounted() @@ -254,7 +264,7 @@ export const Route = createFileRoute('/_layout/address/$address')({ defaultSearchValues.limit, ), tab: z.prefault( - z.enum(['history', 'assets', 'contract']), + z.enum(['history', 'assets', 'events', 'contract']), defaultSearchValues.tab, ), live: z.prefault(z.boolean(), false), @@ -266,7 +276,7 @@ export const Route = createFileRoute('/_layout/address/$address')({ loader: async ({ deps: { page, limit, live }, params, context }) => { const { address } = params // Only throw notFound for truly invalid addresses - if (!Address.validate(address)) + if (!OxAddress.validate(address)) throw notFound({ routeId: rootRouteId, data: { error: 'Invalid address format' }, @@ -330,22 +340,49 @@ export const Route = createFileRoute('/_layout/address/$address')({ new Promise((r) => setTimeout(() => r(undefined), ms)), ]) - const transactionsData = await timeout( - context.queryClient - .ensureQueryData( - transactionsQueryOptions({ - address, - page, - limit, - offset, + const [transactionsData, eventsData, eventsCountData] = await Promise.all([ + timeout( + context.queryClient + .ensureQueryData( + transactionsQueryOptions({ + address, + page, + limit, + offset, + }), + ) + .catch((error) => { + console.error('Fetch transactions error:', error) + return undefined }), - ) - .catch((error) => { - console.error('Fetch transactions error:', error) - return undefined - }), - QUERY_TIMEOUT_MS, - ) + QUERY_TIMEOUT_MS, + ), + timeout( + context.queryClient + .ensureQueryData( + addressEventsQueryOptions({ + address, + page, + limit, + offset, + }), + ) + .catch((error) => { + console.error('Fetch events error:', error) + return undefined + }), + QUERY_TIMEOUT_MS, + ), + timeout( + context.queryClient + .ensureQueryData(addressEventsCountQueryOptions(address)) + .catch((error) => { + console.error('Fetch events count error:', error) + return undefined + }), + QUERY_TIMEOUT_MS, + ), + ]) // Fire off optional loaders without blocking page render // These will populate the cache if successful but won't delay the page load @@ -373,6 +410,8 @@ export const Route = createFileRoute('/_layout/address/$address')({ contractInfo, contractSource, transactionsData, + eventsData, + eventsCountData, txCountResponse, totalValueResponse, } @@ -397,7 +436,7 @@ export const Route = createFileRoute('/_layout/address/$address')({ try { // Fetch holdings by directly reading balances from known tokens - const accountAddress = params.address as Address.Address + const accountAddress = params.address as OxAddress.Address const tokenResults = await timeout( Promise.all( assets.map(async (tokenAddress) => { @@ -497,10 +536,16 @@ function RouteComponent() { const location = useLocation() const { address } = Route.useParams() const { page, tab, live, limit } = Route.useSearch() - const { hasContract, contractInfo, contractSource, transactionsData } = - Route.useLoaderData() + const { + hasContract, + contractInfo, + contractSource, + transactionsData, + eventsData: initialEventsData, + eventsCountData: initialEventsCountData, + } = Route.useLoaderData() - Address.assert(address) + OxAddress.assert(address) const hash = location.hash @@ -554,20 +599,21 @@ function RouteComponent() { const setActiveSection = React.useCallback( (newIndex: number) => { const tabs: TabValue[] = hasContract - ? ['history', 'assets', 'contract'] + ? ['history', 'assets', 'events', 'contract'] : ['history', 'assets'] const newTab = tabs[newIndex] ?? 'history' navigate({ to: '.', - search: { page, tab: newTab, limit }, + search: { page: 1, tab: newTab, limit }, resetScroll: false, }) }, - [navigate, page, limit, hasContract], + [navigate, limit, hasContract], ) - const activeSection = - tab === 'history' ? 0 : tab === 'assets' ? 1 : hasContract ? 2 : 0 + const tabIndices = hasContract + ? { history: 0, assets: 1, events: 2, contract: 3 } + : { history: 0, assets: 1 } const assetsData = useAssetsData(address) @@ -583,11 +629,14 @@ function RouteComponent() { address={address} page={page} limit={limit} - activeSection={activeSection} + activeSection={tabIndices[tab] ?? 0} onSectionChange={setActiveSection} + hasContract={hasContract} contractInfo={contractInfo} contractSource={contractSource} initialData={transactionsData} + initialEventsData={initialEventsData} + initialEventsCountData={initialEventsCountData} assetsData={assetsData} live={live} /> @@ -596,7 +645,7 @@ function RouteComponent() { } function AccountCardWithTimestamps(props: { - address: Address.Address + address: OxAddress.Address assetsData: AssetData[] }) { const { address, assetsData } = props @@ -662,14 +711,17 @@ function AccountCardWithTimestamps(props: { } function SectionsWrapper(props: { - address: Address.Address + address: OxAddress.Address page: number limit: number activeSection: number onSectionChange: (index: number) => void + hasContract: boolean contractInfo: ContractInfo | undefined contractSource?: ContractSource | undefined initialData: TransactionsData | undefined + initialEventsData: AddressEventsApiResponse | undefined + initialEventsCountData: AddressEventsCountResponse | undefined assetsData: AssetData[] live: boolean }) { @@ -679,9 +731,12 @@ function SectionsWrapper(props: { limit, activeSection, onSectionChange, + hasContract, contractInfo, contractSource, initialData, + initialEventsData, + initialEventsCountData, assetsData, live, } = props @@ -693,7 +748,7 @@ function SectionsWrapper(props: { // Fetch contract source client-side if not provided by SSR loader // This ensures source code is available when navigating directly to ?tab=contract - const isContractTabActive = activeSection === 2 + const isContractTabActive = activeSection === 3 const contractSourceQuery = useQuery({ ...useContractSourceQueryOptions({ address }), initialData: contractSource, @@ -712,19 +767,14 @@ function SectionsWrapper(props: { limit, offset: (page - 1) * limit, }), - initialData: page === 1 ? initialData : undefined, + initialData: initialData, // Override refetch settings reactively based on tab state refetchInterval: shouldAutoRefresh ? 4_000 : false, refetchOnWindowFocus: shouldAutoRefresh, }) - const { - transactions, - total: approximateTotal, - hasMore, - } = data ?? { + const { transactions, total: approximateTotal } = data ?? { transactions: [], total: 0, - hasMore: false, } // Fetch exact total count in the background (only when on history tab) @@ -738,6 +788,41 @@ function SectionsWrapper(props: { enabled: isHistoryTabActive, }) + // Events tab data fetching + const { + data: eventsData, + isPlaceholderData: isEventsPlaceholderData, + error: eventsError, + } = useQuery({ + ...addressEventsQueryOptions({ + address, + page, + limit, + offset: (page - 1) * limit, + }), + initialData: initialEventsData, + }) + const { events, total: eventsApproximateTotal, hasMore: eventsHasMore } = eventsData ?? { + events: [] as AddressEventData[], + total: 0, + hasMore: false, + } + + // Fetch exact events count in the background + const eventsCountQuery = useQuery({ + ...addressEventsCountQueryOptions(address), + initialData: initialEventsCountData, + }) + + // SSR data first, then query data + const exactEventsCount = + initialEventsCountData?.data ?? eventsCountQuery.data?.data + + // Use exact count for events pagination + const eventsPaginationTotal = + exactEventsCount ?? + (eventsApproximateTotal > 0 ? eventsApproximateTotal : events.length) + const batchTransactionDataContextValue = useBatchTransactionData( transactions, address, @@ -751,11 +836,9 @@ function SectionsWrapper(props: { // For pagination: always use hasMore-based estimate // This ensures we only show pages that have data - const paginationTotal = hasMore - ? Math.max(approximateTotal + limit, (page + 1) * limit) - : approximateTotal > 0 - ? approximateTotal - : transactions.length + // Use server total directly since we fetch a buffer with all transactions + const paginationTotal = + approximateTotal > 0 ? approximateTotal : transactions.length const isMobile = useMediaQuery('(max-width: 799px)') const mode = isMobile ? 'stacked' : 'tabs' @@ -793,6 +876,30 @@ function SectionsWrapper(props: { { label: 'Total', align: 'end', width: '0.5fr' }, ] + const eventsErrorDisplay = eventsError ? ( +
+

Failed to load events

+

+ {eventsError instanceof Error ? eventsError.message : 'Unknown error'} +

+
+ ) : null + + const eventsColumns: DataGrid.Column[] = [ + { label: 'Time', align: 'start', width: '0.5fr' }, + { label: 'Description', align: 'start', width: '1.5fr' }, + { + label: Event, + align: 'end', + width: '1fr', + }, + { + label: Hash, + align: 'end', + width: '1.5fr', + }, + ] + return ( ), }, + // Events tab - only shown for contracts + ...(hasContract + ? [ + { + title: 'Events', + totalItems: + eventsData && (exactEventsCount ?? eventsPaginationTotal), + itemsLabel: 'events', + content: eventsErrorDisplay ?? ( + + events.map((event) => ({ + cells: [ + , + , + , +
+ +
, + ], + link: { + href: `/tx/${event.txHash}`, + title: `View transaction ${event.txHash}`, + }, + })) + } + totalItems={eventsPaginationTotal} + displayCount={exactEventsCount === null ? Infinity : exactEventsCount} + page={page} + fetching={isEventsPlaceholderData} + loading={!eventsData} + countLoading={exactEventsCount === undefined} + itemsLabel="events" + itemsPerPage={limit} + pagination="simple" + emptyState="No events found." + hasMore={eventsHasMore} + /> + ), + }, + ] + : []), // Contract tab - shown for known contracts OR verified sources ...(contractInfo || resolvedContractSource ? [ @@ -958,7 +1126,7 @@ function TransactionTimeCell(props: { hash: Hex.Hex; format: TimeFormat }) { function TransactionDescCell(props: { transaction: Transaction - accountAddress: Address.Address + accountAddress: OxAddress.Address }) { const { transaction, accountAddress } = props const batchData = useTransactionDataFromBatch(transaction.hash) @@ -995,6 +1163,64 @@ function TransactionFeeCell(props: { hash: Hex.Hex }) { ) } +function EventTimeCell(props: { blockNumber: Hex.Hex; format: TimeFormat }) { + const { blockNumber, format } = props + const { data: timestamp } = useBlock({ + blockNumber: Hex.toBigInt(blockNumber), + query: { + select: (block) => block.timestamp, + staleTime: Infinity, + }, + }) + if (!timestamp) return + return +} + +function EventSignatureCell(props: { selector: Hex.Hex | undefined }) { + const { selector } = props + const { data: signature, isFetching } = useLookupSignature({ selector }) + + if (!selector) return + if (isFetching) return + if (!signature) return + + const name = signature.split('(')[0] + return {name} +} + +function EventDescCell(props: { + event: AddressEventData + accountAddress: OxAddress.Address +}) { + const { event, accountAddress } = props + + const log = React.useMemo( + () => ({ + address: event.contractAddress, + topics: event.topics as [Hex.Hex, ...Hex.Hex[]], + data: event.data, + blockNumber: Hex.toBigInt(event.blockNumber), + transactionHash: event.txHash, + logIndex: event.logIndex, + blockHash: '0x' as Hex.Hex, + transactionIndex: 0, + removed: false, + }), + [event], + ) + + const knownEvent = React.useMemo(() => parseKnownEvent(log), [log]) + + if (!knownEvent) { + const selector = event.topics[0]?.slice(0, 10) ?? 'unknown' + return {selector} + } + + const perspectiveEvent = getPerspectiveEvent(knownEvent, accountAddress) + + return +} + function AssetName(props: { asset: AssetData }) { const { asset } = props if (!asset.metadata?.name) return diff --git a/apps/explorer/src/routes/api/address/$address.ts b/apps/explorer/src/routes/api/address/$address.ts index a703bb093..98183d55c 100644 --- a/apps/explorer/src/routes/api/address/$address.ts +++ b/apps/explorer/src/routes/api/address/$address.ts @@ -221,10 +221,12 @@ export const Route = createFileRoute('/api/address/$address')({ } const nextOffset = offset + transactions.length + // Use sortedHashes.length as total (the buffer we fetched) + const total = sortedHashes.length return json({ transactions, - total: hasMore ? nextOffset + 1 : nextOffset, + total, offset: nextOffset, limit, hasMore, diff --git a/apps/explorer/src/routes/api/address/events-count/$address.ts b/apps/explorer/src/routes/api/address/events-count/$address.ts new file mode 100644 index 000000000..af565ddbe --- /dev/null +++ b/apps/explorer/src/routes/api/address/events-count/$address.ts @@ -0,0 +1,82 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import * as IDX from 'idxs' +import { Address } from 'ox' + +import { zAddress } from '#lib/zod.ts' +import { config } from '#wagmi.config.ts' + +const IS = IDX.IndexSupply.create({ + apiKey: process.env.INDEXER_API_KEY, +}) + +const QB = IDX.QueryBuilder.from(IS) + +const QUERY_TIMEOUT_MS = 8_000 + +// ,void timeout on addresses with many events +const MAX_COUNT = 1000 + +class QueryTimeoutError extends Error { + constructor(ms: number) { + super(`Query timed out after ${ms}ms`) + this.name = 'QueryTimeoutError' + } +} + +function withTimeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new QueryTimeoutError(ms)), ms), + ), + ]) +} + +export const Route = createFileRoute('/api/address/events-count/$address')({ + server: { + handlers: { + GET: async ({ params }) => { + try { + const address = zAddress().parse(params.address) + Address.assert(address) + + const chainId = config.getClient().chain.id + + const result = await withTimeout( + QB.selectFrom('logs') + .select(['log_idx']) + .where('chain', '=', chainId) + .where('address', '=', address) + .limit(MAX_COUNT + 1) + .execute(), + QUERY_TIMEOUT_MS, + ) + + const count = result.length + const isExact = count <= MAX_COUNT + + return json({ + data: isExact ? count : null, + isExact, + error: null, + }) + } catch (error) { + console.error(error) + if (error instanceof QueryTimeoutError) { + return json({ + data: null, + isExact: false, + error: null, + }) + } + const errorMessage = error instanceof Error ? error.message : error + return json( + { data: null, isExact: false, error: errorMessage }, + { status: 500 }, + ) + } + }, + }, + }, +}) diff --git a/apps/explorer/src/routes/api/address/events/$address.ts b/apps/explorer/src/routes/api/address/events/$address.ts new file mode 100644 index 000000000..c239c3276 --- /dev/null +++ b/apps/explorer/src/routes/api/address/events/$address.ts @@ -0,0 +1,143 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import * as IDX from 'idxs' +import { Address, Hex } from 'ox' +import * as z from 'zod/mini' + +import { zAddress } from '#lib/zod' +import { config } from '#wagmi.config' + +const IS = IDX.IndexSupply.create({ + apiKey: process.env.INDEXER_API_KEY, +}) + +const QB = IDX.QueryBuilder.from(IS) + +const MAX_LIMIT = 1_000 +const QUERY_TIMEOUT_MS = 8_000 + +class QueryTimeoutError extends Error { + constructor(ms: number) { + super(`Query timed out after ${ms}ms`) + this.name = 'QueryTimeoutError' + } +} + +function withTimeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new QueryTimeoutError(ms)), ms), + ), + ]) +} + +const RequestParametersSchema = z.object({ + offset: z.prefault(z.coerce.number(), 0), + limit: z.prefault(z.coerce.number(), 10), +}) + +type EventData = { + txHash: Hex.Hex + blockNumber: Hex.Hex + blockTimestamp: number | null + logIndex: number + contractAddress: Address.Address + topics: Hex.Hex[] + data: Hex.Hex +} + +type EventsApiResponse = { + events: EventData[] + total: number + offset: number + limit: number + hasMore: boolean + error: null | string +} + +export const Route = createFileRoute('/api/address/events/$address')({ + server: { + handlers: { + GET: async ({ params, request }) => { + try { + const url = new URL(request.url, __BASE_URL__ || 'http://localhost') + const address = zAddress().parse(params.address) + Address.assert(address) + + const parseParams = RequestParametersSchema.safeParse( + Object.fromEntries(url.searchParams), + ) + if (!parseParams.success) + return json( + { error: z.prettifyError(parseParams.error) }, + { status: 400 }, + ) + + const { offset, limit: rawLimit } = parseParams.data + + if (rawLimit > MAX_LIMIT) + return json({ error: 'Limit is too high' }, { status: 400 }) + const limit = Math.max(1, rawLimit) + + const chainId = config.getClient().chain.id + + const result = await withTimeout( + QB.selectFrom('logs') + .select([ + 'tx_hash', + 'block_num', + 'log_idx', + 'address', + 'topics', + 'data', + ]) + .where('chain', '=', chainId) + .where('address', '=', address) + .orderBy('block_num', 'desc') + .orderBy('log_idx', 'desc') + .offset(offset) + .limit(limit + 1) + .execute(), + QUERY_TIMEOUT_MS, + ) + + const hasMore = result.length > limit + const logs = hasMore ? result.slice(0, limit) : result + + const events: EventData[] = logs.map((log) => ({ + txHash: log.tx_hash, + blockNumber: Hex.fromNumber(log.block_num), + blockTimestamp: null, + logIndex: log.log_idx, + contractAddress: Address.checksum(log.address), + topics: log.topics, + data: log.data, + })) + + return json({ + events, + total: events.length, + offset: offset + events.length, + limit, + hasMore, + error: null, + } satisfies EventsApiResponse) + } catch (error) { + console.error(error) + if (error instanceof QueryTimeoutError) { + return json( + { events: [], total: 0, offset: 0, limit: 10, hasMore: false, error: 'Query timed out. This contract may have too many events.' }, + { status: 504 }, + ) + } + const errorMessage = error instanceof Error ? error.message : error + return json( + { events: [], total: 0, offset: 0, limit: 10, hasMore: false, error: errorMessage }, + { status: 500 }, + ) + } + }, + }, + }, +}) From 3b8c0be2d7955f45aeee601899af6501f75ba7ad Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Wed, 28 Jan 2026 20:00:14 +0000 Subject: [PATCH 2/4] fix(explorer): fix import paths and unused variable - Change #lib/abi to #lib/queries for useLookupSignature - Use getWagmiConfig() instead of non-existent config export - Remove unused hasMore destructuring from DataGrid --- apps/explorer/src/comps/DataGrid.tsx | 1 - apps/explorer/src/routes/_layout/address/$address.tsx | 2 +- apps/explorer/src/routes/api/address/events-count/$address.ts | 4 ++-- apps/explorer/src/routes/api/address/events/$address.ts | 4 ++-- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/explorer/src/comps/DataGrid.tsx b/apps/explorer/src/comps/DataGrid.tsx index 258769d75..235bb1814 100644 --- a/apps/explorer/src/comps/DataGrid.tsx +++ b/apps/explorer/src/comps/DataGrid.tsx @@ -18,7 +18,6 @@ export function DataGrid(props: DataGrid.Props) { loading = false, countLoading = false, disableLastPage = false, - hasMore, itemsLabel = 'items', itemsPerPage = 10, pagination = 'default', diff --git a/apps/explorer/src/routes/_layout/address/$address.tsx b/apps/explorer/src/routes/_layout/address/$address.tsx index 16444aa96..434b92b08 100644 --- a/apps/explorer/src/routes/_layout/address/$address.tsx +++ b/apps/explorer/src/routes/_layout/address/$address.tsx @@ -58,7 +58,7 @@ import { import { parseKnownEvent, parseKnownEvents } from '#lib/domain/known-events' import * as Tip20 from '#lib/domain/tip20' import { DateFormatter, HexFormatter, PriceFormatter } from '#lib/formatting' -import { useLookupSignature } from '#lib/abi' +import { useLookupSignature } from '#lib/queries' import { useIsMounted, useMediaQuery } from '#lib/hooks' import { buildAddressDescription, buildAddressOgImageUrl } from '#lib/og' import { withLoaderTiming } from '#lib/profiling' diff --git a/apps/explorer/src/routes/api/address/events-count/$address.ts b/apps/explorer/src/routes/api/address/events-count/$address.ts index af565ddbe..bc02a548c 100644 --- a/apps/explorer/src/routes/api/address/events-count/$address.ts +++ b/apps/explorer/src/routes/api/address/events-count/$address.ts @@ -4,7 +4,7 @@ import * as IDX from 'idxs' import { Address } from 'ox' import { zAddress } from '#lib/zod.ts' -import { config } from '#wagmi.config.ts' +import { getWagmiConfig } from '#wagmi.config.ts' const IS = IDX.IndexSupply.create({ apiKey: process.env.INDEXER_API_KEY, @@ -41,7 +41,7 @@ export const Route = createFileRoute('/api/address/events-count/$address')({ const address = zAddress().parse(params.address) Address.assert(address) - const chainId = config.getClient().chain.id + const chainId = getWagmiConfig().getClient().chain.id const result = await withTimeout( QB.selectFrom('logs') diff --git a/apps/explorer/src/routes/api/address/events/$address.ts b/apps/explorer/src/routes/api/address/events/$address.ts index c239c3276..60454a01d 100644 --- a/apps/explorer/src/routes/api/address/events/$address.ts +++ b/apps/explorer/src/routes/api/address/events/$address.ts @@ -5,7 +5,7 @@ import { Address, Hex } from 'ox' import * as z from 'zod/mini' import { zAddress } from '#lib/zod' -import { config } from '#wagmi.config' +import { getWagmiConfig } from '#wagmi.config' const IS = IDX.IndexSupply.create({ apiKey: process.env.INDEXER_API_KEY, @@ -80,7 +80,7 @@ export const Route = createFileRoute('/api/address/events/$address')({ return json({ error: 'Limit is too high' }, { status: 400 }) const limit = Math.max(1, rawLimit) - const chainId = config.getClient().chain.id + const chainId = getWagmiConfig().getClient().chain.id const result = await withTimeout( QB.selectFrom('logs') From 81fe979623eb325d796fd961aab6b0370cd2fecf Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Wed, 28 Jan 2026 20:11:56 +0000 Subject: [PATCH 3/4] fix(explorer): fix events pagination display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show "1,000+" instead of "∞" when events count exceeds limit - Use pages={{ hasMore }} for indefinite pagination - Disable last page button when count is unknown - Change capped count format from "> X" to "X+" --- apps/explorer/src/comps/DataGrid.tsx | 1 - apps/explorer/src/comps/Pagination.tsx | 2 +- apps/explorer/src/routes/_layout/address/$address.tsx | 6 ++++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/explorer/src/comps/DataGrid.tsx b/apps/explorer/src/comps/DataGrid.tsx index 235bb1814..93337cf0f 100644 --- a/apps/explorer/src/comps/DataGrid.tsx +++ b/apps/explorer/src/comps/DataGrid.tsx @@ -260,6 +260,5 @@ export namespace DataGrid { pagination?: 'default' | 'simple' | React.ReactNode emptyState?: React.ReactNode flexible?: boolean - hasMore?: boolean } } diff --git a/apps/explorer/src/comps/Pagination.tsx b/apps/explorer/src/comps/Pagination.tsx index d1efa72e5..054a36d05 100644 --- a/apps/explorer/src/comps/Pagination.tsx +++ b/apps/explorer/src/comps/Pagination.tsx @@ -346,7 +346,7 @@ export namespace Pagination { {loading ? '…' - : `${capped ? '> ' : ''}${Pagination.numFormat.format(totalItems)}`} + : `${Pagination.numFormat.format(totalItems)}${capped ? '+' : ''}`} {itemsLabel}
diff --git a/apps/explorer/src/routes/_layout/address/$address.tsx b/apps/explorer/src/routes/_layout/address/$address.tsx index 434b92b08..52052f022 100644 --- a/apps/explorer/src/routes/_layout/address/$address.tsx +++ b/apps/explorer/src/routes/_layout/address/$address.tsx @@ -1093,16 +1093,18 @@ function SectionsWrapper(props: { })) } totalItems={eventsPaginationTotal} - displayCount={exactEventsCount === null ? Infinity : exactEventsCount} + pages={exactEventsCount === null ? { hasMore: eventsHasMore } : undefined} + displayCount={exactEventsCount === null ? 1000 : exactEventsCount} + displayCountCapped={exactEventsCount === null} page={page} fetching={isEventsPlaceholderData} loading={!eventsData} countLoading={exactEventsCount === undefined} + disableLastPage={exactEventsCount === null} itemsLabel="events" itemsPerPage={limit} pagination="simple" emptyState="No events found." - hasMore={eventsHasMore} /> ), }, From 7ff96fc3c6ec8473deb9b6736175d13922b8ca6e Mon Sep 17 00:00:00 2001 From: Pierre Bertet Date: Wed, 28 Jan 2026 20:16:40 +0000 Subject: [PATCH 4/4] fix(explorer): hide 'of X' for indefinite pagination --- apps/explorer/src/comps/Pagination.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/explorer/src/comps/Pagination.tsx b/apps/explorer/src/comps/Pagination.tsx index 054a36d05..d7ddb19be 100644 --- a/apps/explorer/src/comps/Pagination.tsx +++ b/apps/explorer/src/comps/Pagination.tsx @@ -267,12 +267,12 @@ export namespace Pagination { {Pagination.numFormat.format(page)} - {' of '} - {isIndefinite || countLoading - ? '…' - : totalPages > 0 - ? Pagination.numFormat.format(totalPages) - : '…'} + {!isIndefinite && totalPages > 0 && ( + <> + {' of '} + {countLoading ? '…' : Pagination.numFormat.format(totalPages)} + + )}