Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/explorer/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
2 changes: 1 addition & 1 deletion apps/explorer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 46 additions & 0 deletions apps/explorer/src/lib/server/address-history-source-selection.ts
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 }
}
Comment on lines +16 to +18
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Default empty sources to standard history sources

Handle ?sources= the same way as an omitted sources parameter. With the new val === undefined check, 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 👍 / 👎.


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
)
}
221 changes: 204 additions & 17 deletions apps/explorer/src/routes/api/address/history/$address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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') {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve ascending sort when activity API path is enabled

Although the route still accepts sort and computes sortDirection, the activity API request never includes sort information, so the response order falls back to the upstream default. As a result, ?sort=asc no longer yields ascending history whenever TEMPO_API_KEY is configured, which breaks ordering consistency versus the existing fallback query path and can invalidate offset-based pagination expectations.

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

Expand Down Expand Up @@ -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 =
Expand Down
56 changes: 31 additions & 25 deletions apps/explorer/vitest.config.ts
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/**/*'],
Comment thread
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',
},
}),
],
}
})
Loading