-
Notifications
You must be signed in to change notification settings - Fork 107
feat(explorer): use tempo address activity api for history #849
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<T>(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<TempoActivityResponse> { | ||
| 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(), | ||
|
Comment on lines
+166
to
+170
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Although the route still accepts Useful? React with 👍 / 👎. |
||
| }) | ||
| 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<HistoryResponse> { | ||
| 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 = | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/**/*'], | ||
|
o-az marked this conversation as resolved.
|
||
| }, | ||
| 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', | ||
| }, | ||
| }), | ||
| ], | ||
| } | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sourcesto standard history sourcesHandle
?sources=the same way as an omittedsourcesparameter. With the newval === undefinedcheck, an empty string now produces{ txs: false, transfers: false, emitted: false }, and the history route returns no transactions even when activity exists. This regresses prior behavior (if (!val)) and affects clients that send an empty query value instead of omitting the parameter.Useful? React with 👍 / 👎.