From 64f76b8ef47f7aaeb6c1a02187e65217e860b7b9 Mon Sep 17 00:00:00 2001 From: o-az Date: Wed, 15 Apr 2026 20:01:26 -0700 Subject: [PATCH 1/3] feat(explorer): use tempo address activity api for history --- apps/explorer/.env.example | 2 + .../routes/api/address/history/$address.ts | 196 +++++++++++++++++- 2 files changed, 196 insertions(+), 2 deletions(-) diff --git a/apps/explorer/.env.example b/apps/explorer/.env.example index 1ab3e66e2..be4737a09 100644 --- a/apps/explorer/.env.example +++ b/apps/explorer/.env.example @@ -34,3 +34,5 @@ VITE_CONTRACT_VERIFICATION_API_BASE_URL="https://contracts.tempo.xyz" # https://vite.dev/config/server-options # https://vite.dev/config/preview-options#preview-allowedhosts # ALLOWED_HOSTS="" + +TEMPO_API_KEY="" diff --git a/apps/explorer/src/routes/api/address/history/$address.ts b/apps/explorer/src/routes/api/address/history/$address.ts index 27825655f..ac8073406 100644 --- a/apps/explorer/src/routes/api/address/history/$address.ts +++ b/apps/explorer/src/routes/api/address/history/$address.ts @@ -68,10 +68,15 @@ function toHistoryStatus( } function toFiniteTimestamp(value: unknown): number { - if (typeof value === 'number' && Number.isFinite(value)) return value + const normalizeEpoch = (epoch: number) => + epoch > 1_000_000_000_000 ? Math.floor(epoch / 1000) : epoch + + if (typeof value === 'number' && Number.isFinite(value)) { + return normalizeEpoch(value) + } if (typeof value === 'string') { const parsed = Number(value) - if (Number.isFinite(parsed)) return parsed + if (Number.isFinite(parsed)) return normalizeEpoch(parsed) const parsedDate = Date.parse(value) if (Number.isFinite(parsedDate)) return Math.floor(parsedDate / 1000) } @@ -114,6 +119,153 @@ export type HistoryResponse = { error: null | string } +type TempoActivityItem = { + hash: Hex.Hex + blockNumber: string | number + timestamp: string | number + from: Address.Address + to: Address.Address | null + value: string | number + status: 'success' | 'reverted' + gasUsed: string | number + effectiveGasPrice: string | number + events: EnrichedTransaction['knownEvents'] +} + +type TempoActivityResponse = { + items: TempoActivityItem[] + offset: number + limit: number + hasMore: boolean + includesApplied: { + transfers: boolean + zones: { + requested: boolean + private: boolean + } + } +} + +const TEMPO_ACTIVITY_API_BASE_URL = 'https://api.tempo.xyz' +const TEMPO_ACTIVITY_FETCH_LIMIT = 200 + +async function fetchTempoAddressActivity(params: { + address: Address.Address + chainId: number + include: 'all' | 'sent' | 'received' + limit: number + offset: number +}): Promise { + const apiKey = process.env.TEMPO_API_KEY + if (!apiKey) throw new Error('Missing TEMPO_API_KEY') + + const searchParams = new URLSearchParams({ + include: params.include, + includes: 'zones,transfers', + limit: params.limit.toString(), + offset: params.offset.toString(), + }) + const url = new URL( + `/chains/${params.chainId}/addresses/${params.address}/activity`, + TEMPO_ACTIVITY_API_BASE_URL, + ) + url.search = searchParams.toString() + + const response = await fetch(url, { + headers: { + Accept: 'application/json', + 'X-API-Key': apiKey, + }, + }) + + if (!response.ok) { + throw new Error(`Tempo activity API failed with ${response.status}`) + } + + return (await response.json()) as TempoActivityResponse +} + +function mapTempoActivityItem( + item: TempoActivityItem, +): import('#lib/server/build-tx-only-transactions').EnrichedTransaction { + return { + hash: item.hash, + blockNumber: toHexQuantity(item.blockNumber), + timestamp: toFiniteTimestamp(item.timestamp), + from: Address.checksum(item.from), + to: item.to ? Address.checksum(item.to) : null, + value: toHexQuantity(item.value), + status: item.status, + gasUsed: toHexQuantity(item.gasUsed), + effectiveGasPrice: toHexQuantity(item.effectiveGasPrice), + knownEvents: item.events, + } +} + +async function fetchTempoFilteredAddressActivity(params: { + address: Address.Address + chainId: number + include: 'all' | 'sent' | 'received' + offset: number + limit: number + status?: 'success' | 'reverted' | undefined + after?: number | undefined +}): Promise { + const targetCount = params.offset + params.limit + 1 + const filteredItems: TempoActivityItem[] = [] + let sourceOffset = 0 + let sourceHasMore = true + let countCapped = false + + while ( + sourceHasMore && + filteredItems.length < targetCount && + sourceOffset < HISTORY_COUNT_MAX + ) { + const page = await fetchTempoAddressActivity({ + address: params.address, + chainId: params.chainId, + include: params.include, + limit: TEMPO_ACTIVITY_FETCH_LIMIT, + offset: sourceOffset, + }) + + sourceHasMore = page.hasMore + sourceOffset += page.items.length + + for (const item of page.items) { + const timestamp = toFiniteTimestamp(item.timestamp) + if (params.status && item.status !== params.status) continue + if (params.after && timestamp < params.after) continue + filteredItems.push(item) + } + + if (page.items.length === 0) break + } + + if (sourceHasMore || sourceOffset >= HISTORY_COUNT_MAX) countCapped = true + + const pageItems = filteredItems.slice( + params.offset, + params.offset + params.limit, + ) + const hasMore = + filteredItems.length > params.offset + params.limit || countCapped + const total = countCapped + ? Math.max(filteredItems.length, params.offset + pageItems.length) + : filteredItems.length + + return { + transactions: pageItems.map(mapTempoActivityItem), + total, + offset: params.offset, + limit: params.limit, + hasMore, + countCapped, + error: null, + } +} + /** * Data sources to query for transaction history: * - txs: Direct transactions (from/to the address) @@ -205,6 +357,46 @@ export const Route = createFileRoute('/api/address/history/$address')({ const sources = parseSources(searchParams.sources) const statusFilter = searchParams.status + const canUseTempoActivityApi = + Boolean(process.env.TEMPO_API_KEY) && + !isTip20Address(address) && + !sources.emitted + + if (canUseTempoActivityApi) { + if (statusFilter || after) { + return Response.json( + await fetchTempoFilteredAddressActivity({ + address, + chainId, + include, + offset, + limit, + status: statusFilter, + after, + }), + ) + } + + const activity = await fetchTempoAddressActivity({ + address, + chainId, + include, + limit, + offset, + }) + const transactions = activity.items.map(mapTempoActivityItem) + + return Response.json({ + transactions, + total: offset + transactions.length, + offset, + limit, + hasMore: activity.hasMore, + countCapped: true, + error: null, + } satisfies HistoryResponse) + } + const fetchSize = limit + 1 const isTxOnlySource = sources.txs && !sources.transfers && !sources.emitted From ec0d6dd386e5e1e3bc0abbb302c77cf8ec27743b Mon Sep 17 00:00:00 2001 From: o-az Date: Mon, 20 Apr 2026 00:28:35 -0700 Subject: [PATCH 2/3] chore: address feedback --- apps/explorer/package.json | 2 +- .../address-history-source-selection.ts | 46 ++++++++ .../routes/api/address/history/$address.ts | 106 +++++++++--------- apps/explorer/vitest.config.ts | 56 ++++----- 4 files changed, 129 insertions(+), 81 deletions(-) create mode 100644 apps/explorer/src/lib/server/address-history-source-selection.ts diff --git a/apps/explorer/package.json b/apps/explorer/package.json index 29cf01136..3ebe922ba 100644 --- a/apps/explorer/package.json +++ b/apps/explorer/package.json @@ -21,7 +21,7 @@ "check": "pnpm check:biome && pnpm check:types", "check:biome": "biome check --write --unsafe", "check:types": "tsgo --project tsconfig.json --noEmit", - "check:types:test": "tsc --project tsconfig.test.json --noEmit", + "check:types:test": "tsc --project ./test/tsconfig.json --noEmit", "predeploy": "bash scripts/build.sh", "deploy": "bash scripts/deploy.sh", "deploy:devnet": "pnpm deploy --env devnet", diff --git a/apps/explorer/src/lib/server/address-history-source-selection.ts b/apps/explorer/src/lib/server/address-history-source-selection.ts new file mode 100644 index 000000000..db5a9e473 --- /dev/null +++ b/apps/explorer/src/lib/server/address-history-source-selection.ts @@ -0,0 +1,46 @@ +import type { SortDirection } from '#lib/server/tempo-queries' + +/** + * Data sources to query for transaction history: + * - txs: Direct transactions (from/to the address) + * - transfers: Transfer events where address is sender/recipient + * - emitted: Transfer events emitted by the address (for token contracts) + */ +export type Sources = { + txs: boolean + transfers: boolean + emitted: boolean +} + +export function parseSources(val: string | undefined): Sources { + if (val === undefined) { + return { txs: true, transfers: true, emitted: false } + } + + const parts = val + .split(',') + .map((part) => part.trim().toLowerCase()) + .filter(Boolean) + + return { + txs: parts.includes('txs'), + transfers: parts.includes('transfers'), + emitted: parts.includes('emitted'), + } +} + +export function canUseTempoActivityApi(params: { + hasTempoApiKey: boolean + isTip20: boolean + sources: Sources + sortDirection: SortDirection +}): boolean { + return ( + params.hasTempoApiKey && + !params.isTip20 && + params.sortDirection === 'desc' && + params.sources.txs && + params.sources.transfers && + !params.sources.emitted + ) +} diff --git a/apps/explorer/src/routes/api/address/history/$address.ts b/apps/explorer/src/routes/api/address/history/$address.ts index ac8073406..ddb4dcc68 100644 --- a/apps/explorer/src/routes/api/address/history/$address.ts +++ b/apps/explorer/src/routes/api/address/history/$address.ts @@ -11,6 +11,10 @@ import * as z from 'zod/mini' import { getRequestURL, hasIndexSupply } from '#lib/env' import { parseKnownEvents } from '#lib/domain/known-events' import { isTip20Address, type Metadata } from '#lib/domain/tip20' +import { + canUseTempoActivityApi, + parseSources, +} from '#lib/server/address-history-source-selection' import { fetchAddressDirectTxHistoryRows, fetchAddressHistoryTxDetailsByHashes, @@ -42,7 +46,7 @@ const abi = Object.values(Abis).flat() const [MAX_LIMIT, DEFAULT_LIMIT] = [100, 10] const HISTORY_COUNT_MAX = 10_000 const TRANSFER_EVENT_TOPIC0 = - '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' as Hex.Hex + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' function serializeBigInts(value: T): T { if (typeof value === 'bigint') { @@ -266,24 +270,6 @@ async function fetchTempoFilteredAddressActivity(params: { } } -/** - * Data sources to query for transaction history: - * - txs: Direct transactions (from/to the address) - * - transfers: Transfer events where address is sender/recipient - * - emitted: Transfer events emitted by the address (for token contracts) - */ -type Sources = { txs: boolean; transfers: boolean; emitted: boolean } - -function parseSources(val: string | undefined): Sources { - if (!val) return { txs: true, transfers: true, emitted: false } - const parts = val.split(',').map((s) => s.trim().toLowerCase()) - return { - txs: parts.includes('txs'), - transfers: parts.includes('transfers'), - emitted: parts.includes('emitted'), - } -} - const RequestParametersSchema = z.object({ offset: z.prefault(z.coerce.number(), 0), limit: z.prefault(z.coerce.number(), DEFAULT_LIMIT), @@ -356,45 +342,55 @@ export const Route = createFileRoute('/api/address/history/$address')({ const includeReceived = include === 'all' || include === 'received' const sources = parseSources(searchParams.sources) const statusFilter = searchParams.status + const tip20Address = isTip20Address(address) - const canUseTempoActivityApi = - Boolean(process.env.TEMPO_API_KEY) && - !isTip20Address(address) && - !sources.emitted - - if (canUseTempoActivityApi) { - if (statusFilter || after) { - return Response.json( - await fetchTempoFilteredAddressActivity({ - address, - chainId, - include, - offset, - limit, - status: statusFilter, - after, - }), - ) - } + const shouldUseTempoActivityApi = canUseTempoActivityApi({ + hasTempoApiKey: Boolean(process.env.TEMPO_API_KEY), + isTip20: tip20Address, + sources, + sortDirection, + }) - const activity = await fetchTempoAddressActivity({ - address, - chainId, - include, - limit, - offset, - }) - const transactions = activity.items.map(mapTempoActivityItem) + if (shouldUseTempoActivityApi) { + try { + if (statusFilter || after) { + return Response.json( + await fetchTempoFilteredAddressActivity({ + address, + chainId, + include, + offset, + limit, + status: statusFilter, + after, + }), + ) + } - return Response.json({ - transactions, - total: offset + transactions.length, - offset, - limit, - hasMore: activity.hasMore, - countCapped: true, - error: null, - } satisfies HistoryResponse) + const activity = await fetchTempoAddressActivity({ + address, + chainId, + include, + limit, + offset, + }) + const transactions = activity.items.map(mapTempoActivityItem) + + return Response.json({ + transactions, + total: offset + transactions.length, + offset, + limit, + hasMore: activity.hasMore, + countCapped: true, + error: null, + } satisfies HistoryResponse) + } catch (error) { + console.error( + '[history] Tempo activity API failed, falling back to indexed history:', + error, + ) + } } const fetchSize = limit + 1 diff --git a/apps/explorer/vitest.config.ts b/apps/explorer/vitest.config.ts index 98c2ea2df..fe1e0ace4 100644 --- a/apps/explorer/vitest.config.ts +++ b/apps/explorer/vitest.config.ts @@ -1,31 +1,37 @@ +import { loadEnv } from 'vite' import { defineConfig } from 'vitest/config' import { cloudflareTest } from '@cloudflare/vitest-pool-workers' import wranglerJSON from '#wrangler.json' with { type: 'json' } -export default defineConfig({ - resolve: { - tsconfigPaths: true, - }, - test: { - include: ['test/**/*.test.ts'], - }, - plugins: [ - cloudflareTest({ - miniflare: { - compatibilityFlags: [ - ...wranglerJSON.compatibility_flags, - 'enable_nodejs_fs_module', - 'enable_nodejs_v8_module', - 'enable_nodejs_tty_module', - 'enable_nodejs_process_v2', - 'enable_nodejs_http_modules', - 'enable_nodejs_perf_hooks_module', - ], - }, - wrangler: { - configPath: './wrangler.json', - }, - }), - ], +export default defineConfig((config) => { + const env = loadEnv(config.mode, process.cwd(), '') + + return { + resolve: { + tsconfigPaths: true, + }, + test: { + env, + include: ['test/**/*'], + }, + plugins: [ + cloudflareTest({ + miniflare: { + compatibilityFlags: [ + ...wranglerJSON.compatibility_flags, + 'enable_nodejs_fs_module', + 'enable_nodejs_v8_module', + 'enable_nodejs_tty_module', + 'enable_nodejs_process_v2', + 'enable_nodejs_http_modules', + 'enable_nodejs_perf_hooks_module', + ], + }, + wrangler: { + configPath: './wrangler.json', + }, + }), + ], + } }) From 493a7254b7d21b7f32ec81d520b56ba4aa120d57 Mon Sep 17 00:00:00 2001 From: o-az Date: Mon, 20 Apr 2026 01:09:13 -0700 Subject: [PATCH 3/3] fix(explorer): clarify activity history pagination --- .../src/routes/api/address/history/$address.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/explorer/src/routes/api/address/history/$address.ts b/apps/explorer/src/routes/api/address/history/$address.ts index ddb4dcc68..cf5a37581 100644 --- a/apps/explorer/src/routes/api/address/history/$address.ts +++ b/apps/explorer/src/routes/api/address/history/$address.ts @@ -219,8 +219,6 @@ async function fetchTempoFilteredAddressActivity(params: { const filteredItems: TempoActivityItem[] = [] let sourceOffset = 0 let sourceHasMore = true - let countCapped = false - while ( sourceHasMore && filteredItems.length < targetCount && @@ -247,16 +245,15 @@ async function fetchTempoFilteredAddressActivity(params: { if (page.items.length === 0) break } - if (sourceHasMore || sourceOffset >= HISTORY_COUNT_MAX) countCapped = true - const pageItems = filteredItems.slice( params.offset, params.offset + params.limit, ) + const countCapped = sourceHasMore const hasMore = - filteredItems.length > params.offset + params.limit || countCapped + filteredItems.length > params.offset + params.limit || sourceHasMore const total = countCapped - ? Math.max(filteredItems.length, params.offset + pageItems.length) + ? Math.max(filteredItems.length, params.offset) : filteredItems.length return { @@ -378,11 +375,13 @@ export const Route = createFileRoute('/api/address/history/$address')({ return Response.json({ transactions, - total: offset + transactions.length, + total: activity.hasMore + ? offset + transactions.length + 1 + : offset + transactions.length, offset, limit, hasMore: activity.hasMore, - countCapped: true, + countCapped: activity.hasMore, error: null, } satisfies HistoryResponse) } catch (error) {