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/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 27825655f..cf5a37581 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') { @@ -68,10 +72,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,21 +123,147 @@ export type HistoryResponse = { error: null | string } -/** - * 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()) +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 { - txs: parts.includes('txs'), - transfers: parts.includes('transfers'), - emitted: parts.includes('emitted'), + 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 + 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 + } + + const pageItems = filteredItems.slice( + params.offset, + params.offset + params.limit, + ) + const countCapped = sourceHasMore + const hasMore = + filteredItems.length > params.offset + params.limit || sourceHasMore + const total = countCapped + ? Math.max(filteredItems.length, params.offset) + : filteredItems.length + + return { + transactions: pageItems.map(mapTempoActivityItem), + total, + offset: params.offset, + limit: params.limit, + hasMore, + countCapped, + error: null, } } @@ -204,6 +339,58 @@ 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 shouldUseTempoActivityApi = canUseTempoActivityApi({ + hasTempoApiKey: Boolean(process.env.TEMPO_API_KEY), + isTip20: tip20Address, + sources, + sortDirection, + }) + + if (shouldUseTempoActivityApi) { + try { + 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: activity.hasMore + ? offset + transactions.length + 1 + : offset + transactions.length, + offset, + limit, + hasMore: activity.hasMore, + countCapped: activity.hasMore, + error: null, + } satisfies HistoryResponse) + } catch (error) { + console.error( + '[history] Tempo activity API failed, falling back to indexed history:', + error, + ) + } + } const fetchSize = limit + 1 const isTxOnlySource = 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', + }, + }), + ], + } })