diff --git a/apps/explorer/src/lib/queries/account.ts b/apps/explorer/src/lib/queries/account.ts index 73eecdad..4fa22bb3 100644 --- a/apps/explorer/src/lib/queries/account.ts +++ b/apps/explorer/src/lib/queries/account.ts @@ -23,12 +23,14 @@ export function transactionsQueryOptions( params: { page: number include?: 'all' | 'sent' | 'received' | undefined + sort?: 'asc' | 'desc' | undefined address: Address.Address _key?: string | undefined } & AccountRequestParameters, ) { const searchParams = new URLSearchParams({ include: params?.include ?? 'all', + sort: params?.sort ?? 'desc', limit: params.limit.toString(), offset: params.offset.toString(), }) @@ -39,6 +41,7 @@ export function transactionsQueryOptions( params.page, params.limit, params.offset, + params.sort ?? 'desc', params._key, ], queryFn: async ({ signal }): Promise => { diff --git a/apps/explorer/src/routeTree.gen.ts b/apps/explorer/src/routeTree.gen.ts index b11e86b7..af91a9b6 100644 --- a/apps/explorer/src/routeTree.gen.ts +++ b/apps/explorer/src/routeTree.gen.ts @@ -32,6 +32,7 @@ import { Route as LayoutBlockIdRouteImport } from './routes/_layout/block/$id' import { Route as LayoutAddressAddressRouteImport } from './routes/_layout/address/$address' import { Route as ApiTxTraceHashRouteImport } from './routes/api/tx/trace/$hash' import { Route as ApiTxBalanceChangesHashRouteImport } from './routes/api/tx/balance-changes/$hash' +import { Route as ApiContractCreationAddressRouteImport } from './routes/api/contract/creation/$address' 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 ApiAddressBalancesAddressRouteImport } from './routes/api/address/balances/$address' @@ -151,6 +152,12 @@ const ApiTxBalanceChangesHashRoute = ApiTxBalanceChangesHashRouteImport.update({ path: '/api/tx/balance-changes/$hash', getParentRoute: () => rootRouteImport, } as any) +const ApiContractCreationAddressRoute = + ApiContractCreationAddressRouteImport.update({ + id: '/api/contract/creation/$address', + path: '/api/contract/creation/$address', + getParentRoute: () => rootRouteImport, + } as any) const ApiAddressTxsCountAddressRoute = ApiAddressTxsCountAddressRouteImport.update({ id: '/api/address/txs-count/$address', @@ -201,6 +208,7 @@ export interface FileRoutesByFullPath { '/api/address/balances/$address': typeof ApiAddressBalancesAddressRoute '/api/address/total-value/$address': typeof ApiAddressTotalValueAddressRoute '/api/address/txs-count/$address': typeof ApiAddressTxsCountAddressRoute + '/api/contract/creation/$address': typeof ApiContractCreationAddressRoute '/api/tx/balance-changes/$hash': typeof ApiTxBalanceChangesHashRoute '/api/tx/trace/$hash': typeof ApiTxTraceHashRoute } @@ -229,6 +237,7 @@ export interface FileRoutesByTo { '/api/address/balances/$address': typeof ApiAddressBalancesAddressRoute '/api/address/total-value/$address': typeof ApiAddressTotalValueAddressRoute '/api/address/txs-count/$address': typeof ApiAddressTxsCountAddressRoute + '/api/contract/creation/$address': typeof ApiContractCreationAddressRoute '/api/tx/balance-changes/$hash': typeof ApiTxBalanceChangesHashRoute '/api/tx/trace/$hash': typeof ApiTxTraceHashRoute } @@ -259,6 +268,7 @@ export interface FileRoutesById { '/api/address/balances/$address': typeof ApiAddressBalancesAddressRoute '/api/address/total-value/$address': typeof ApiAddressTotalValueAddressRoute '/api/address/txs-count/$address': typeof ApiAddressTxsCountAddressRoute + '/api/contract/creation/$address': typeof ApiContractCreationAddressRoute '/api/tx/balance-changes/$hash': typeof ApiTxBalanceChangesHashRoute '/api/tx/trace/$hash': typeof ApiTxTraceHashRoute } @@ -289,6 +299,7 @@ export interface FileRouteTypes { | '/api/address/balances/$address' | '/api/address/total-value/$address' | '/api/address/txs-count/$address' + | '/api/contract/creation/$address' | '/api/tx/balance-changes/$hash' | '/api/tx/trace/$hash' fileRoutesByTo: FileRoutesByTo @@ -317,6 +328,7 @@ export interface FileRouteTypes { | '/api/address/balances/$address' | '/api/address/total-value/$address' | '/api/address/txs-count/$address' + | '/api/contract/creation/$address' | '/api/tx/balance-changes/$hash' | '/api/tx/trace/$hash' id: @@ -346,6 +358,7 @@ export interface FileRouteTypes { | '/api/address/balances/$address' | '/api/address/total-value/$address' | '/api/address/txs-count/$address' + | '/api/contract/creation/$address' | '/api/tx/balance-changes/$hash' | '/api/tx/trace/$hash' fileRoutesById: FileRoutesById @@ -361,6 +374,7 @@ export interface RootRouteChildren { ApiAddressBalancesAddressRoute: typeof ApiAddressBalancesAddressRoute ApiAddressTotalValueAddressRoute: typeof ApiAddressTotalValueAddressRoute ApiAddressTxsCountAddressRoute: typeof ApiAddressTxsCountAddressRoute + ApiContractCreationAddressRoute: typeof ApiContractCreationAddressRoute ApiTxBalanceChangesHashRoute: typeof ApiTxBalanceChangesHashRoute ApiTxTraceHashRoute: typeof ApiTxTraceHashRoute } @@ -528,6 +542,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiTxBalanceChangesHashRouteImport parentRoute: typeof rootRouteImport } + '/api/contract/creation/$address': { + id: '/api/contract/creation/$address' + path: '/api/contract/creation/$address' + fullPath: '/api/contract/creation/$address' + preLoaderRoute: typeof ApiContractCreationAddressRouteImport + parentRoute: typeof rootRouteImport + } '/api/address/txs-count/$address': { id: '/api/address/txs-count/$address' path: '/api/address/txs-count/$address' @@ -609,6 +630,7 @@ const rootRouteChildren: RootRouteChildren = { ApiAddressBalancesAddressRoute: ApiAddressBalancesAddressRoute, ApiAddressTotalValueAddressRoute: ApiAddressTotalValueAddressRoute, ApiAddressTxsCountAddressRoute: ApiAddressTxsCountAddressRoute, + ApiContractCreationAddressRoute: ApiContractCreationAddressRoute, ApiTxBalanceChangesHashRoute: ApiTxBalanceChangesHashRoute, ApiTxTraceHashRoute: ApiTxTraceHashRoute, } diff --git a/apps/explorer/src/routes/_layout/address/$address.tsx b/apps/explorer/src/routes/_layout/address/$address.tsx index 3a1c676b..d8b886f0 100644 --- a/apps/explorer/src/routes/_layout/address/$address.tsx +++ b/apps/explorer/src/routes/_layout/address/$address.tsx @@ -624,12 +624,34 @@ function RouteComponent() { ) } +type ContractCreationResponse = { + creation: { blockNumber: string; timestamp: string } | null + error: string | null +} + +async function fetchContractCreation( + address: Address.Address, +): Promise { + const response = await fetch(`/api/contract/creation/${address}`) + return response.json() as Promise +} + +function useContractCreation(address: Address.Address, enabled: boolean) { + return useQuery({ + queryKey: ['contract-creation', address], + queryFn: () => fetchContractCreation(address), + enabled, + staleTime: 60_000, + }) +} + function AccountCardWithTimestamps(props: { address: Address.Address assetsData: AssetData[] accountType?: AccountType }) { const { address, assetsData, accountType } = props + const isContract = accountType === 'contract' // fetch the most recent transactions (pg.1) const { data: recentData } = useQuery( @@ -652,25 +674,20 @@ function AccountCardWithTimestamps(props: { }, }) - // Use the real transaction count (not the approximate total from pagination) - // Don't fetch exact count - use API hasMore flag for pagination - // This makes the page render instantly without waiting for count query - const totalTransactions = 0 // Unknown until user navigates - const lastPageOffset = 0 // Can't calculate without total - - const { data: oldestData } = useQuery({ - ...transactionsQueryOptions({ + // Fetch the oldest transaction by sorting ascending (for non-contracts) + const { data: oldestData } = useQuery( + transactionsQueryOptions({ address, - page: Math.ceil(totalTransactions / 1), + page: 1, limit: 1, - offset: lastPageOffset, - _key: 'account-creation', + offset: 0, + sort: 'asc', + _key: 'account-oldest', }), - enabled: totalTransactions > 0, - }) + ) - const [oldestTransaction] = oldestData?.transactions ?? [] - const { data: createdTimestamp } = useBlock({ + const oldestTransaction = oldestData?.transactions?.at(0) + const { data: oldestTxTimestamp } = useBlock({ blockNumber: Hex.toBigInt(oldestTransaction?.blockNumber ?? '0x0'), query: { enabled: Boolean(oldestTransaction?.blockNumber), @@ -678,6 +695,21 @@ function AccountCardWithTimestamps(props: { }, }) + // For contracts without transactions, use binary search to find creation block + const noTransactions = !oldestTransaction + const { data: contractCreation } = useContractCreation( + address, + isContract && noTransactions, + ) + + // Use contract creation timestamp if available, otherwise fall back to oldest tx + const createdTimestamp = React.useMemo(() => { + if (contractCreation?.creation?.timestamp) { + return BigInt(contractCreation.creation.timestamp) + } + return oldestTxTimestamp + }, [contractCreation, oldestTxTimestamp]) + const totalValue = calculateTotalHoldings(assetsData) return ( diff --git a/apps/explorer/src/routes/api/contract/creation/$address.ts b/apps/explorer/src/routes/api/contract/creation/$address.ts new file mode 100644 index 00000000..04d9594e --- /dev/null +++ b/apps/explorer/src/routes/api/contract/creation/$address.ts @@ -0,0 +1,140 @@ +import { createFileRoute } from '@tanstack/react-router' +import * as Address from 'ox/Address' +import { getChainId, getPublicClient } from 'wagmi/actions' +import { zAddress } from '#lib/zod' +import { getWagmiConfig } from '#wagmi.config' + +const creationCache = new Map< + string, + { blockNumber: bigint; timestamp: bigint } +>() + +export const Route = createFileRoute('/api/contract/creation/$address')({ + server: { + handlers: { + GET: async ({ params }: { params: { address: string } }) => { + try { + const address = zAddress().parse(params.address) + Address.assert(address) + const cacheKey = address.toLowerCase() + + const cached = creationCache.get(cacheKey) + if (cached) { + return Response.json({ + creation: { + blockNumber: cached.blockNumber.toString(), + timestamp: cached.timestamp.toString(), + }, + error: null, + }) + } + + const config = getWagmiConfig() + const chainId = getChainId(config) + const client = getPublicClient(config, { chainId }) + + if (!client) { + return Response.json( + { creation: null, error: 'No client available' }, + { status: 500 }, + ) + } + + const bytecode = await client.getCode({ address }) + if (!bytecode || bytecode === '0x') { + return Response.json({ creation: null, error: null }) + } + + const latestBlock = await client.getBlockNumber() + const creationBlock = await binarySearchCreationBlock( + client, + address, + 1n, + latestBlock, + ) + + if (creationBlock === null) { + return Response.json({ creation: null, error: null }) + } + + const block = await client.getBlock({ blockNumber: creationBlock }) + + creationCache.set(cacheKey, { + blockNumber: creationBlock, + timestamp: block.timestamp, + }) + + return Response.json({ + creation: { + blockNumber: creationBlock.toString(), + timestamp: block.timestamp.toString(), + }, + error: null, + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : error + console.error('[contract/creation] Error:', errorMessage) + return Response.json( + { creation: null, error: errorMessage }, + { status: 500 }, + ) + } + }, + }, + }, +}) + +async function binarySearchCreationBlock( + client: NonNullable>, + address: Address.Address, + low: bigint, + high: bigint, +): Promise { + const MAX_BATCH_SIZE = 10 + + while (high - low > BigInt(MAX_BATCH_SIZE)) { + const mid = (low + high) / 2n + + try { + const code = await client.getCode({ + address, + blockNumber: mid, + }) + + if (code && code !== '0x') { + high = mid + } else { + low = mid + 1n + } + } catch { + low = mid + 1n + } + } + + const blocksToCheck = [] + for (let b = low; b <= high; b++) { + blocksToCheck.push(b) + } + + const results = await Promise.all( + blocksToCheck.map(async (blockNum) => { + try { + const code = await client.getCode({ + address, + blockNumber: blockNum, + }) + return { blockNum, hasCode: Boolean(code && code !== '0x') } + } catch { + return { blockNum, hasCode: false } + } + }), + ) + + for (const result of results) { + if (result.hasCode) { + return result.blockNum + } + } + + return null +}