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 }, + ) + } + }, + }, + }, +})