diff --git a/apps/explorer/src/comps/Pagination.tsx b/apps/explorer/src/comps/Pagination.tsx
index 86b81480..d7ddb19b 100644
--- a/apps/explorer/src/comps/Pagination.tsx
+++ b/apps/explorer/src/comps/Pagination.tsx
@@ -230,9 +230,10 @@ export namespace Pagination {
export function Simple(props: Simple.Props) {
const { page, pages, fetching, countLoading, disableLastPage } = props
const isIndefinite = typeof pages !== 'number'
+ const totalPages = typeof pages === 'number' ? pages : 0
const disableNext = isIndefinite
? !(pages as { hasMore: boolean } | undefined)?.hasMore
- : page >= pages
+ : page >= totalPages
return (
{Pagination.numFormat.format(page)}
- {' of '}
- {isIndefinite || countLoading
- ? '…'
- : typeof pages === 'number' && pages > 0
- ? Pagination.numFormat.format(pages)
- : '…'}
+ {!isIndefinite && totalPages > 0 && (
+ <>
+ {' of '}
+ {countLoading ? '…' : Pagination.numFormat.format(totalPages)}
+ >
+ )}
- {typeof pages === 'number' && (
+ {totalPages > 0 && (
({ ...prev, page: pages })}
- disabled={page >= pages || disableLastPage}
+ search={(prev) => ({ ...prev, page: totalPages })}
+ disabled={page >= totalPages || disableLastPage}
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,6 +316,7 @@ export namespace Pagination {
countLoading?: boolean
/** Disable "Last page" button when we can't reliably navigate there */
disableLastPage?: boolean
+ hasMore?: boolean
}
}
@@ -344,7 +346,7 @@ export namespace Pagination {
{loading
? '…'
- : `${capped ? '> ' : ''}${Pagination.numFormat.format(totalItems)}`}
+ : `${Pagination.numFormat.format(totalItems)}${capped ? '+' : ''}`}
{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 00000000..a2f96e7f
--- /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 b11e86b7..c475c727 100644
--- a/apps/explorer/src/routeTree.gen.ts
+++ b/apps/explorer/src/routeTree.gen.ts
@@ -34,6 +34,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'
import { Route as ApiAddressBalancesAddressRouteImport } from './routes/api/address/balances/$address'
import { Route as LayoutBlockCountdownTargetBlockRouteImport } from './routes/_layout/block/countdown.$targetBlock'
@@ -163,6 +165,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)
const ApiAddressBalancesAddressRoute =
ApiAddressBalancesAddressRouteImport.update({
id: '/api/address/balances/$address',
@@ -197,6 +210,8 @@ export interface FileRoutesByFullPath {
'/api/address/$address': typeof ApiAddressAddressRoute
'/api/tokens/count': typeof ApiTokensCountRoute
'/demo/': typeof LayoutDemoIndexRoute
+ '/api/address/events-count/$address': typeof ApiAddressEventsCountAddressRoute
+ '/api/address/events/$address': typeof ApiAddressEventsAddressRoute
'/block/countdown/$targetBlock': typeof LayoutBlockCountdownTargetBlockRoute
'/api/address/balances/$address': typeof ApiAddressBalancesAddressRoute
'/api/address/total-value/$address': typeof ApiAddressTotalValueAddressRoute
@@ -225,6 +240,8 @@ export interface FileRoutesByTo {
'/api/address/$address': typeof ApiAddressAddressRoute
'/api/tokens/count': typeof ApiTokensCountRoute
'/demo': typeof LayoutDemoIndexRoute
+ '/api/address/events-count/$address': typeof ApiAddressEventsCountAddressRoute
+ '/api/address/events/$address': typeof ApiAddressEventsAddressRoute
'/block/countdown/$targetBlock': typeof LayoutBlockCountdownTargetBlockRoute
'/api/address/balances/$address': typeof ApiAddressBalancesAddressRoute
'/api/address/total-value/$address': typeof ApiAddressTotalValueAddressRoute
@@ -255,6 +272,8 @@ export interface FileRoutesById {
'/api/address/$address': typeof ApiAddressAddressRoute
'/api/tokens/count': typeof ApiTokensCountRoute
'/_layout/demo/': typeof LayoutDemoIndexRoute
+ '/api/address/events-count/$address': typeof ApiAddressEventsCountAddressRoute
+ '/api/address/events/$address': typeof ApiAddressEventsAddressRoute
'/_layout/block/countdown/$targetBlock': typeof LayoutBlockCountdownTargetBlockRoute
'/api/address/balances/$address': typeof ApiAddressBalancesAddressRoute
'/api/address/total-value/$address': typeof ApiAddressTotalValueAddressRoute
@@ -285,6 +304,8 @@ export interface FileRouteTypes {
| '/api/address/$address'
| '/api/tokens/count'
| '/demo/'
+ | '/api/address/events-count/$address'
+ | '/api/address/events/$address'
| '/block/countdown/$targetBlock'
| '/api/address/balances/$address'
| '/api/address/total-value/$address'
@@ -313,6 +334,8 @@ export interface FileRouteTypes {
| '/api/address/$address'
| '/api/tokens/count'
| '/demo'
+ | '/api/address/events-count/$address'
+ | '/api/address/events/$address'
| '/block/countdown/$targetBlock'
| '/api/address/balances/$address'
| '/api/address/total-value/$address'
@@ -342,6 +365,8 @@ export interface FileRouteTypes {
| '/api/address/$address'
| '/api/tokens/count'
| '/_layout/demo/'
+ | '/api/address/events-count/$address'
+ | '/api/address/events/$address'
| '/_layout/block/countdown/$targetBlock'
| '/api/address/balances/$address'
| '/api/address/total-value/$address'
@@ -357,6 +382,8 @@ export interface RootRouteChildren {
ApiSearchRoute: typeof ApiSearchRoute
ApiAbiBatchRoute: typeof ApiAbiBatchRoute
ApiAddressAddressRoute: typeof ApiAddressAddressRoute
+ ApiAddressEventsCountAddressRoute: typeof ApiAddressEventsCountAddressRoute
+ ApiAddressEventsAddressRoute: typeof ApiAddressEventsAddressRoute
ApiTokensCountRoute: typeof ApiTokensCountRoute
ApiAddressBalancesAddressRoute: typeof ApiAddressBalancesAddressRoute
ApiAddressTotalValueAddressRoute: typeof ApiAddressTotalValueAddressRoute
@@ -542,6 +569,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
+ }
'/api/address/balances/$address': {
id: '/api/address/balances/$address'
path: '/api/address/balances/$address'
@@ -605,6 +646,8 @@ const rootRouteChildren: RootRouteChildren = {
ApiSearchRoute: ApiSearchRoute,
ApiAbiBatchRoute: ApiAbiBatchRoute,
ApiAddressAddressRoute: ApiAddressAddressRoute,
+ ApiAddressEventsCountAddressRoute: ApiAddressEventsCountAddressRoute,
+ ApiAddressEventsAddressRoute: ApiAddressEventsAddressRoute,
ApiTokensCountRoute: ApiTokensCountRoute,
ApiAddressBalancesAddressRoute: ApiAddressBalancesAddressRoute,
ApiAddressTotalValueAddressRoute: ApiAddressTotalValueAddressRoute,
diff --git a/apps/explorer/src/routes/_layout/address/$address.tsx b/apps/explorer/src/routes/_layout/address/$address.tsx
index 53e2038a..52052f02 100644
--- a/apps/explorer/src/routes/_layout/address/$address.tsx
+++ b/apps/explorer/src/routes/_layout/address/$address.tsx
@@ -31,8 +31,10 @@ import {
useTimeFormat,
} from '#comps/TimeFormat'
import { TokenIcon } from '#comps/TokenIcon'
+import { TxEventDescription } from '#comps/TxEventDescription'
import {
BatchTransactionDataContext,
+ getPerspectiveEvent,
type TransactionData,
TransactionDescription,
TransactionTimestamp,
@@ -53,9 +55,10 @@ 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/queries'
import { useIsMounted, useMediaQuery } from '#lib/hooks'
import { buildAddressDescription, buildAddressOgImageUrl } from '#lib/og'
import { withLoaderTiming } from '#lib/profiling'
@@ -63,6 +66,13 @@ import {
type TransactionsData,
transactionsQueryOptions,
} from '#lib/queries/account'
+import {
+ type AddressEventData,
+ type AddressEventsApiResponse,
+ type AddressEventsCountResponse,
+ addressEventsQueryOptions,
+ addressEventsCountQueryOptions,
+} from '#lib/queries/address-events.ts'
import { getWagmiConfig } from '#wagmi.config.ts'
import { getApiUrl } from '#lib/env.ts'
@@ -227,7 +237,7 @@ const defaultSearchValues = {
const ASSETS_PER_PAGE = 10
const TabSchema = z.prefault(
- z.enum(['history', 'assets', 'contract', 'interact']),
+ z.enum(['history', 'assets', 'events', 'contract', 'interact']),
defaultSearchValues.tab,
)
@@ -345,22 +355,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
@@ -398,6 +435,8 @@ export const Route = createFileRoute('/_layout/address/$address')({
contractInfo,
contractSource,
transactionsData,
+ eventsData,
+ eventsCountData,
balancesData,
txCountResponse,
totalValueResponse,
@@ -504,6 +543,8 @@ function RouteComponent() {
contractInfo,
contractSource,
transactionsData,
+ eventsData: initialEventsData,
+ eventsCountData: initialEventsCountData,
balancesData,
} = Route.useLoaderData()
@@ -517,6 +558,7 @@ function RouteComponent() {
// When URL has a hash fragment (e.g., #functionName), switch to interact tab
const isContract = accountType === 'contract'
+ const hasContract = Boolean(contractInfo || contractSource)
React.useEffect(() => {
// Only redirect if:
@@ -564,16 +606,21 @@ function RouteComponent() {
const setActiveSection = React.useCallback(
(newIndex: number) => {
- const tabs: TabValue[] = ['history', 'assets', 'contract', 'interact']
-
+ const tabs: TabValue[] = [
+ 'history',
+ 'assets',
+ 'events',
+ 'contract',
+ 'interact',
+ ]
const newTab = tabs[newIndex] ?? 'history'
navigate({
to: '.',
- search: { page, tab: newTab, limit },
+ search: { page: 1, tab: newTab, limit },
resetScroll: false,
})
},
- [navigate, page, limit],
+ [navigate, limit],
)
const activeSection =
@@ -581,11 +628,13 @@ function RouteComponent() {
? 0
: tab === 'assets'
? 1
- : tab === 'contract'
+ : tab === 'events'
? 2
- : tab === 'interact'
+ : tab === 'contract'
? 3
- : 0
+ : tab === 'interact'
+ ? 4
+ : 0
const { data: assetsData } = useBalancesData(address, balancesData)
@@ -608,9 +657,12 @@ function RouteComponent() {
limit={limit}
activeSection={activeSection}
onSectionChange={setActiveSection}
+ hasContract={hasContract}
contractInfo={contractInfo}
contractSource={contractSource}
initialData={transactionsData}
+ initialEventsData={initialEventsData}
+ initialEventsCountData={initialEventsCountData}
assetsData={assetsData}
live={live}
isContract={accountType === 'contract'}
@@ -693,9 +745,12 @@ function SectionsWrapper(props: {
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
isContract: boolean
@@ -706,9 +761,12 @@ function SectionsWrapper(props: {
limit,
activeSection,
onSectionChange,
+ hasContract,
contractInfo,
contractSource,
initialData,
+ initialEventsData,
+ initialEventsCountData,
assetsData,
live,
isContract,
@@ -746,12 +804,11 @@ 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,
})
-
/**
* use initialData until mounted to avoid hydration mismatch
* (tanstack query may have fresher cached data that differs from SSR)
@@ -770,6 +827,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,
@@ -815,6 +907,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 - visible when it's a contract
+ {
+ title: 'Events',
+ totalItems:
+ eventsData && (exactEventsCount ?? eventsPaginationTotal),
+ itemsLabel: 'events',
+ visible: hasContract,
+ content: eventsErrorDisplay ?? (
+
+ events.map((event) => ({
+ cells: [
+ ,
+ ,
+ ,
+
+
+
,
+ ],
+ link: {
+ href: `/tx/${event.txHash}`,
+ title: `View transaction ${event.txHash}`,
+ },
+ }))
+ }
+ totalItems={eventsPaginationTotal}
+ 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."
+ />
+ ),
+ },
// Contract tab - ABI + Source Code (always shown, disabled when no data)
{
title: 'Contract',
@@ -1052,6 +1228,64 @@ function TransactionFeeCellInner(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: Address.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 727abffe..2cbd6840 100644
--- a/apps/explorer/src/routes/api/address/$address.ts
+++ b/apps/explorer/src/routes/api/address/$address.ts
@@ -369,10 +369,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 Response.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 00000000..bc02a548
--- /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 { getWagmiConfig } 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 = getWagmiConfig().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 00000000..60454a01
--- /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 { getWagmiConfig } 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 = getWagmiConfig().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 },
+ )
+ }
+ },
+ },
+ },
+})