// TODO(tidx-v1): Restore `scanTransactions` from commit `49ad800` after TIDX v1 ships. Remove the dual fetch, dedupe, sort, and slice. import { Hono, type Context } from 'hono' import { Hex } from 'ox' import * as z from 'zod/mini' import type * as App from '../../../App.js' import * as Auth from '../../../internal/Auth.js' import * as Cache from '../../../internal/Cache.js' import * as Cursor from '../../../internal/Cursor.js' import * as OpenApi from '../../../internal/OpenApi.js' import * as Response from '../../../internal/Response.js' import * as Schema from '../../../internal/Schema.js' import * as Store from '../../../internal/Store.js' import * as Tidx from '../../../internal/Tidx.js' import * as Timing from '../../../internal/Timing.js' import * as Ttl from '../../../internal/Ttl.js' import * as Value from '../../../internal/Value.js' import * as VerifiedTokens from '../../../internal/VerifiedTokens.js' import * as Receipts from './receipts.js' import * as Tokens from './tokens.js' /** Example transaction hash surfaced in OpenAPI docs. */ const exampleHash = '0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665' /** A decoded transaction type name. */ type TransactionType = | 'legacy' | 'eip2930' | 'eip1559' | 'eip4844' | 'eip7702' | 'tempo' | 'unknown' /** * Maps a transaction `type` byte (as a number) to its human-readable name. The * raw `0x…` byte is always preserved under `rpc.type`. */ const transactionTypeByNumber: Record = { 0: 'legacy', 1: 'eip2930', 2: 'eip1559', 3: 'eip4844', 4: 'eip7702', 118: 'tempo', } /** Decodes a transaction `type` (hex string or number) into its name. */ export function decodeTransactionType(value: unknown): TransactionType { const byte = typeof value === 'number' ? value : Value.hexToNumber(value) return (byte !== undefined && transactionTypeByNumber[byte]) || 'unknown' } /** Zod schemas owned by the transaction handlers. */ export namespace schema { // TODO(ox/zod): replace with the zod schema for `ox`'s `AccessList.Item` once // ox ships a `zod` entrypoint. /** One EIP-2930 access list entry (RPC format). */ export const AccessListItem = z .looseObject({ address: Schema.Address.check( z.describe('The account whose storage slots are listed in this access-list entry.'), ), storageKeys: z .array(Schema.Hash) .check(z.describe('The storage slot keys the transaction plans to access.')), }) .check( z.describe('One EIP-2930 access-list entry that predeclares an account and storage slots.'), ) const rpcAccessListItem = OpenApi.component(AccessListItem, 'RpcTransactionAccessListItem') // TODO(ox/zod): replace with the zod schema for the RPC `Call` in `ox/tempo`'s // `TxEnvelopeTempo`/`Transaction` types once ox ships a `zod` entrypoint. /** One call within a Tempo account-abstraction transaction (RPC format). */ export const Call = z .looseObject({ data: z .nullish(Schema.Hex) .check( z.describe( 'Call data for this call; this is the same bytes as `input` when both are present.', ), z.meta({ examples: ['0xdeadbeef'] }), ), input: z .nullish(Schema.Hex) .check( z.describe('Call data sent to the contract, as `0x`-prefixed bytes.'), z.meta({ examples: ['0xdeadbeef'] }), ), to: z .nullish(Schema.Address) .check( z.describe('The address this call targets, or `null` when the call creates a contract.'), ), value: z .nullish(Schema.Quantity) .check(z.describe('Native value sent with this call, as a hex quantity.')), }) .check(z.describe('One operation inside a Tempo account-abstraction transaction.')) const rpcCall = OpenApi.component(Call, 'RpcTransactionCall') // TODO(ox/zod): replace with the zod schema for `ox/tempo`'s // `SignatureEnvelope.Rpc` (the secp256k1 / p256 / webAuthn / keychain union) // once ox ships a `zod` entrypoint. /** * A signature envelope (RPC format), passed through verbatim. Every field is * optional because the envelope is a union (secp256k1 / p256 / webAuthn / * keychain) whose shape — including whether a `type` discriminator is present — * varies; the loose object preserves whatever the RPC returns. */ export const SignatureEnvelope = z .looseObject({ type: z .optional(z.string()) .check( z.describe( 'The signature scheme used by this envelope, such as `secp256k1`, `p256`, or `keychain` when the RPC includes it.', ), z.meta({ examples: ['secp256k1'] }), ), }) .check(z.describe('The signature envelope exactly as returned by JSON-RPC.')) const rpcSignatureEnvelope = OpenApi.component( SignatureEnvelope, 'RpcTransactionSignatureEnvelope', ) /** The verbatim JSON-RPC payloads, surfaced under each resource's `rpc` field. */ export namespace Rpc { // TODO(ox/zod): replace with the zod schema for `ox/tempo`'s // `Transaction.Rpc` union once ox ships a `zod` entrypoint. /** A JSON-RPC transaction, passed through verbatim. */ export const Transaction = z .looseObject({ aaAuthorizationList: z .optional(z.array(z.looseObject({}))) .check( z.describe('Tempo account-abstraction authorizations attached to the transaction.'), ), accessList: z .optional(z.array(rpcAccessListItem)) .check( z.describe( 'EIP-2930 access list that predeclares accounts and storage slots for the transaction.', ), ), authorizationList: z .optional(z.array(z.looseObject({}))) .check(z.describe('EIP-7702 authorizations attached to the transaction.')), blobVersionedHashes: z .optional(z.array(Schema.Hash)) .check(z.describe('Versioned blob hashes for EIP-4844 blob data.')), blockHash: z .nullable(Schema.Hash) .check( z.describe('The block hash once included, or `null` while the transaction is pending.'), ), blockNumber: z .nullable(Schema.Quantity) .check( z.describe( 'The block number once included, or `null` while the transaction is pending.', ), ), blockTimestamp: z .nullish(Schema.Quantity) .check(z.describe('The block timestamp for the block that included this transaction.')), calls: z .optional( z.union([ z.array(rpcCall).check( z.meta({ examples: [[{ data: '0xdeadbeef', input: '0xdeadbeef', to: null, value: '0x0' }]], }), ), z.null().check(z.meta({ examples: [null] })), ]), ) .check( z.describe('Decoded calls for Tempo account-abstraction transactions.'), z.meta({ examples: [[{ data: '0xdeadbeef', input: '0xdeadbeef', to: null, value: '0x0' }]], }), ), chainId: z .optional(Schema.Quantity) .check( z.describe( 'The chain ID that the transaction is valid on; legacy transactions may omit it.', ), ), feePayer: z .optional(Schema.Address) .check( z.describe('The address that paid the transaction fee.'), z.meta({ examples: ['0xbe058e1c4df8a4366a387bf595b284246a93039e'] }), ), feePayerSignature: z .nullish(rpcSignatureEnvelope) .check( z.describe('A signature envelope exactly as returned by JSON-RPC.'), z.meta({ examples: [{ type: 'secp256k1' }] }), ), feeToken: z .nullish(Schema.Address) .check( z.describe( 'The fee token requested by the transaction; Tempo fees are paid in USD stablecoins such as `pathUSD`.', ), ), from: Schema.Address.check( z.describe('The address that submitted or authorized the transaction.'), ), gas: Schema.Quantity.check(z.describe('Maximum gas the transaction is allowed to use.')), gasPrice: z .optional(Schema.Quantity) .check( z.describe( 'Gas price for legacy-style transactions, expressed as a decimal string when humanized.', ), ), hash: Schema.Hash.check( z.describe( 'A 32-byte hash, `0x`-prefixed and returned in lowercase, that identifies this transaction.', ), ), input: z .optional(Schema.Hex) .check( z.describe('Call data for non-Tempo transaction formats, as `0x`-prefixed bytes.'), z.meta({ examples: ['0xdeadbeef'] }), ), keyAuthorization: z .nullish(z.looseObject({})) .check( z.describe('Key authorization data attached to this Tempo transaction.'), z.meta({ examples: [{}] }), ), maxFeePerBlobGas: z .optional(Schema.Quantity) .check(z.describe('Maximum fee per blob gas for EIP-4844 blob data.')), maxFeePerGas: z .optional(Schema.Quantity) .check(z.describe('Maximum total fee per gas the sender is willing to pay.')), maxPriorityFeePerGas: z .optional(Schema.Quantity) .check(z.describe('Maximum priority fee per gas for EIP-1559-style transactions.')), nonce: Schema.Quantity.check( z.describe('Nonce from the sender account that orders and de-duplicates transactions.'), ), nonceKey: z .optional(Schema.Quantity) .check(z.describe('Tempo two-dimensional nonce key used to group nonce sequences.')), r: z .optional(Schema.Quantity) .check(z.describe('The `r` value from the transaction ECDSA signature.')), s: z .optional(Schema.Quantity) .check(z.describe('The `s` value from the transaction ECDSA signature.')), signature: z .optional(rpcSignatureEnvelope) .check(z.describe('A signature envelope exactly as returned by JSON-RPC.')), to: z .nullish(Schema.Address) .check( z.describe('The recipient address, or `null` when the transaction creates a contract.'), ), transactionIndex: z .nullable(Schema.Quantity) .check(z.describe('The transaction position within its block, or `null` while pending.')), type: Schema.Hex.check( z.describe('Raw transaction type byte, such as `0x2` for EIP-1559 or `0x76` for Tempo.'), z.meta({ examples: ['0x2'] }), ), v: z .optional(Schema.Quantity) .check(z.describe('The `v` recovery value from the transaction ECDSA signature.')), validAfter: z .nullish(Schema.Quantity) .check( z.describe('Earliest Unix timestamp when this Tempo transaction may be included.'), z.meta({ examples: ['0x0'] }), ), validBefore: z .nullish(Schema.Quantity) .check( z.describe('Latest Unix timestamp when this Tempo transaction may be included.'), z.meta({ examples: ['0xffffffff'] }), ), value: z .optional(Schema.Quantity) .check(z.describe('Native value sent by non-Tempo transaction formats.')), yParity: z .optional(Schema.Quantity) .check(z.describe('The y-parity value from the transaction signature.')), }) .check(z.describe('The transaction exactly as returned by JSON-RPC.')) } const rpcTransaction = OpenApi.component(Rpc.Transaction, 'RpcTransaction') /** Transaction metadata with the raw RPC transaction and optional receipt. */ export const TransactionMeta = z .object({ receipt: z .optional(z.lazy(() => Receipts.schema.TransactionReceipt)) .check( z.describe('The transaction outcome included only when you request `include=receipt`.'), ), rpc: rpcTransaction.check(z.describe('The original JSON-RPC transaction payload.')), }) .check( z.describe( 'Metadata for this transaction: the original JSON-RPC payload plus any requested `include` resources.', ), ) const transactionMeta = OpenApi.component(TransactionMeta, 'TransactionMeta') /** Decoded transaction `type`; the raw `0x…` byte stays under `rpc.type`. */ export const TransactionType = OpenApi.component( z .enum(['legacy', 'eip2930', 'eip1559', 'eip4844', 'eip7702', 'tempo', 'unknown']) .check( z.describe('Human-readable transaction type decoded from the raw JSON-RPC `type` byte.'), z.meta({ examples: ['eip1559'] }), ), 'TransactionType', ) /** One humanized call within a Tempo account-abstraction transaction. */ export const HumanCall = z .object({ data: z .optional(Schema.Hex) .check( z.describe('Call data sent to the contract, as `0x`-prefixed bytes.'), z.meta({ examples: ['0xdeadbeef'] }), ), to: z .nullable(Schema.Address) .check(z.describe('The address this call targets, or `null` when it creates a contract.')), }) .check(z.describe('One decoded call inside a Tempo account-abstraction transaction.')) const transactionCall = OpenApi.component(HumanCall, 'TransactionCall') /** * A humanized transaction: decoded `type`, decimal-string amounts, ISO 8601 * timestamps, and plain-number block fields. The verbatim JSON-RPC transaction * is always available under `meta.rpc`. */ export const Transaction = z .object({ blockHash: z .nullable(Schema.Hash) .check( z.describe('The block hash once included, or `null` while the transaction is pending.'), ), blockNumber: z .nullable(z.number().check(z.int(), z.nonnegative())) .check( z.describe('The block number once included, or `null` while the transaction is pending.'), z.meta({ examples: [23456789] }), ), calls: z .optional(z.array(transactionCall)) .check( z.describe('Decoded calls for Tempo account-abstraction transactions.'), z.meta({ examples: [[{ data: '0xdeadbeef', to: null }]] }), ), chainId: z .optional(z.number().check(z.int(), z.nonnegative())) .check( z.describe('The chain ID that this transaction belongs to.'), z.meta({ examples: [4217] }), ), feeToken: z .optional(z.lazy(() => Tokens.schema.TokenReference)) .check( z.describe( 'The token requested for transaction fees, with RPC metadata and optional curated fields.', ), ), gas: z .number() .check( z.int(), z.nonnegative(), z.describe('Maximum gas the transaction is allowed to use.'), z.meta({ examples: [21000] }), ), gasPrice: z .optional(Schema.DecimalString) .check( z.describe( 'Gas price for legacy-style transactions, expressed as a decimal string when humanized.', ), ), hash: Schema.Hash.check( z.describe( 'A 32-byte hash, `0x`-prefixed and returned in lowercase, that identifies this transaction.', ), ), id: Schema.Hash.check( z.describe('Stable resource ID for this API response; it is the transaction hash.'), ), input: z .optional(Schema.Hex) .check( z.describe('Call data for non-Tempo transaction formats, as `0x`-prefixed bytes.'), z.meta({ examples: ['0xdeadbeef'] }), ), maxFeePerGas: z .optional(Schema.DecimalString) .check(z.describe('Maximum total fee per gas the sender is willing to pay.')), maxPriorityFeePerGas: z .optional(Schema.DecimalString) .check(z.describe('Maximum priority fee per gas for EIP-1559-style transactions.')), meta: transactionMeta.check( z.describe('The original JSON-RPC payload plus any resources requested with `include`.'), ), nonce: z .number() .check( z.int(), z.nonnegative(), z.describe('Nonce from the sender account that orders and de-duplicates transactions.'), z.meta({ examples: [0] }), ), nonceKey: z .optional(Schema.Hex) .check( z.describe('Tempo two-dimensional nonce key used by Tempo transactions.'), z.meta({ examples: ['0x0'] }), ), recipient: z .nullable(Schema.Address) .check( z.describe('The recipient address, or `null` when the transaction creates a contract.'), ), sender: Schema.Address.check( z.describe('The address that submitted or authorized the transaction.'), ), timestamp: z .nullable(z.iso.datetime()) .check( z.describe( 'The block timestamp as ISO 8601, or `null` while the transaction is pending.', ), z.meta({ examples: ['2024-01-01T00:00:00Z'] }), ), transactionIndex: z .nullable(z.number().check(z.int(), z.nonnegative())) .check( z.describe('The transaction position within its block, or `null` while pending.'), z.meta({ examples: [0] }), ), type: TransactionType, validAfter: z .nullish(z.iso.datetime()) .check( z.describe('Earliest ISO 8601 time when this Tempo transaction may be included.'), z.meta({ examples: ['2024-01-01T00:00:00Z'] }), ), validBefore: z .nullish(z.iso.datetime()) .check( z.describe('Latest ISO 8601 time when this Tempo transaction may be included.'), z.meta({ examples: ['2024-01-01T00:00:00Z'] }), ), value: Schema.DecimalString.check( z.describe('Native value transferred by this transaction, as a decimal string.'), ), }) .check( z.describe( 'A transaction formatted for API clients, with decoded fields and the original JSON-RPC payload under `meta.rpc`.', ), ) const transaction = OpenApi.component(Transaction, 'Transaction') /** Schemas for the getTransaction operation. */ export namespace getTransaction { /** Path parameters for transaction requests. */ export const Params = z .object({ transactionHash: Schema.Hash.check( z.describe( 'A 32-byte hash, `0x`-prefixed and returned in lowercase, that identifies this transaction.', ), z.meta({ examples: [exampleHash] }), ), }) .check(z.describe('Path parameters used to look up a transaction.')) /** Optional receipt and fee-token fields that callers opt into via `include`. */ export const Include = z .enum(['feeToken.logoUri', 'feeToken.verified', 'receipt']) .check(z.describe('Related resources you can request with the `include` query parameter.')) /** * Parses a comma-separated `include` query value into a list of optional * resources to embed. Curated fee-token fields and the receipt only run when * explicitly requested. */ export const includeQuery = Schema.includeQuery( Include, 'Comma-separated related resources to include, such as `feeToken.logoUri,feeToken.verified,receipt`.', ) /** Query parameters for transaction requests. */ export const Query = z .strictObject({ chainId: Schema.ChainIdQuery, include: includeQuery, }) .check(z.describe('Query parameters for looking up a transaction.')) /** A humanized transaction, with the verbatim JSON-RPC payload under `rpc`. */ export const Response = transaction } /** Schemas for the getTransactions (list) operation. */ export namespace getTransactions { /** * Optional resources for transaction rows plus the response-wide capped * `totalCount`. The detail route keeps its own include schema. */ export const Include = z .enum(['feeToken.logoUri', 'feeToken.verified', 'receipt', 'totalCount']) .check( z.describe('Related resources to include in each transaction row or in response metadata.'), ) /** Parses the comma-separated `include` value for the transaction list. */ export const includeQuery = Schema.includeQuery( Include, 'Comma-separated related resources to include, such as `feeToken.logoUri,feeToken.verified,receipt,totalCount`.', ) /** Query parameters for transaction list requests. */ export const Query = z .strictObject({ address: z .optional(Schema.Address) .check( z.describe( 'Filter to transactions where this address is either the sender or recipient.', ), ), 'blockNumber.from': Schema.blockNumberBound('transactions', 'from'), 'blockNumber.to': Schema.blockNumberBound('transactions', 'to'), chainId: Schema.ChainIdQuery, cursor: Schema.Cursor, feePayer: z .optional(Schema.Address) .check(z.describe('Filter to transactions whose fee payer is this address.')), feeToken: z .optional(Schema.TokenAddress) .check(z.describe('Filter to transactions whose fee was paid in this token address.')), include: includeQuery, limit: Schema.Limit, order: Schema.Order, page: Schema.Page, recipient: z .optional(Schema.Address) .check(z.describe('Filter to transactions sent to this recipient address.')), sender: z .optional(Schema.Address) .check(z.describe('Filter to transactions sent by this sender address.')), status: z .optional(z.enum(['success', 'reverted'])) .check( z.describe('Filter to transactions whose receipt shows this execution status.'), z.meta({ examples: ['success'] }), ), 'timestamp.from': Schema.timestampBound('transactions', 'from'), 'timestamp.to': Schema.timestampBound('transactions', 'to'), }) .check(...Schema.pageChecks(), z.describe('Query parameters for listing transactions.')) /** Response metadata requested through `include`, such as capped counts. */ export const Meta = OpenApi.component(Schema.CountMeta, 'TransactionListMeta') /** * Page of transactions. Each item reuses the humanized transaction returned * by `GET /transactions/:transactionHash`, so the list and detail endpoints * expose one identical shape. Indexed list rows omit fields the indexer does * not store (signatures, access list), reflected in their `rpc` payload. */ export const Response = OpenApi.component( z .object({ data: z.array(getTransaction.Response).check(z.describe('Transactions on this page.')), meta: z .optional(Meta) .check( z.describe('Response-level metadata requested with `include`, such as `totalCount`.'), ), nextCursor: Schema.NextCursor, }) .check( z.describe('A page of transactions ordered by block number and transaction position.'), ), 'TransactionList', ) } } /** Creates transaction handlers. */ export function transactions() { return new Hono() .get( '/v1/transactions', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('query', schema.getTransactions.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'List transactions across Tempo, with filters for addresses, blocks, timestamps, fees, and status.', operationId: 'getTransactions', responses: OpenApi.responses({ errors: { 400: { codes: ['chain_id_invalid', 'chain_id_unsupported', 'query_invalid'] }, 502: 'Could not read transaction or token data from an upstream service.', }, success: { description: 'A page of transactions.', schema: schema.getTransactions.Response, }, }), summary: 'List transactions', tags: ['Transactions'], }), Cache.response({ cacheControl: Cache.policies.feed, name: 'tempo-api:transactions:v1', key: (c) => Cache.urlKey(c, schema.getTransactions.Query), }), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Invalid query parameters', }) const query = c.req.valid('query') const chainId = query.chainId ?? c.get('chainId') // Cursor pages are anchored below the head. Cache chain data as immutable, // but refresh requested curated token fields on the metadata schedule. if (query.cursor !== undefined) Cache.setPolicy( c, query.include.some((resource) => resource.startsWith('feeToken.')) ? Cache.policies.metadata : Cache.policies.immutable, ) try { // `totalCount` is opt-in and shares the page's filters; run it // concurrently with the page. Best-effort: a count failure omits // `meta` rather than failing the page. const countPromise = query.include.includes('totalCount') ? countTransactions(c, { address: query.address, chainId, feePayer: query.feePayer, feeToken: query.feeToken, fromBlock: query['blockNumber.from'], fromTimestamp: query['timestamp.from'], recipient: query.recipient, sender: query.sender, status: query.status, toBlock: query['blockNumber.to'], toTimestamp: query['timestamp.to'], }).catch(() => undefined) : undefined const transactions = await listTransactions(c, { address: query.address, chainId, cursor: query.cursor, feePayer: query.feePayer, feeToken: query.feeToken, fromBlock: query['blockNumber.from'], fromTimestamp: query['timestamp.from'], include: query.include.filter( (resource): resource is z.output => resource !== 'totalCount', ), limit: query.limit, order: query.order, page: query.page, recipient: query.recipient, sender: query.sender, status: query.status, toBlock: query['blockNumber.to'], toTimestamp: query['timestamp.to'], }) const meta = countPromise ? await countPromise : undefined return c.json( Response.validated(schema.getTransactions.Response, { ...transactions, ...(meta ? { meta } : {}), }), 200, ) } catch (cause) { return Response.upstream(c, cause, { page: query.page }) } }, ) .get( '/v1/transactions/:transactionHash', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('param', schema.getTransaction.Params, { code: 'transaction_invalid', message: 'Invalid transaction hash', }), OpenApi.validate('query', schema.getTransaction.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'Get one transaction by its 32-byte transaction hash.', operationId: 'getTransaction', responses: OpenApi.responses({ errors: { 400: { codes: [ 'chain_id_invalid', 'chain_id_unsupported', 'query_invalid', 'transaction_invalid', ], }, 404: { description: 'No transaction was found for that hash.', codes: ['transaction_not_found'], }, 502: 'Could not read transaction or token data from upstream JSON-RPC.', }, success: { description: 'A single transaction with decoded fields and original JSON-RPC data.', schema: schema.getTransaction.Response, }, }), summary: 'Get a transaction by hash', tags: ['Transactions'], }), Cache.response({ // Default to no-store: a pending (or not-found) transaction must not be // cached, or it would mask the mined result. The handler upgrades a mined // transaction to `immutable` below. cacheControl: Cache.policies.noStore, name: 'tempo-api:transactions:v1', key: (c) => Cache.urlKey(c, schema.getTransaction.Query), }), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'transaction_invalid', message: 'Invalid request parameters', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Invalid request parameters', }) const { transactionHash } = c.req.valid('param') const query = c.req.valid('query') const chainId = query.chainId ?? c.get('chainId') const client = c.get('getClient')(chainId) // `Schema.Hash` lowercases to a plain string; re-narrow to the hex // template literal viem requires at the RPC boundary. const hash = transactionHash as Hex.Hex try { // Fetch the JSON-RPC transaction; a `null` result means the // transaction is unknown rather than an error. The verbatim payload is // surfaced under `rpc`, with humanized fields layered on top. Unmined // lookups (pending or unknown hash) are deliberately never cached — // confirmation polling must observe the mined result the moment the // node does. const rpc = await Timing.time(c, 'transaction', () => client.request({ method: 'eth_getTransactionByHash', params: [hash] }), ) if (!rpc) return Response.error(c, { code: 'transaction_not_found', message: 'Transaction not found', status: 404, }) let data = humanizeTransaction(Response.validated(schema.Rpc.Transaction, rpc)) // Mined chain data is immutable, while curated token fields can still // change. Pending transactions keep the route's no-store policy. if (data.blockNumber !== null) Cache.setPolicy( c, query.include.some((resource) => resource.startsWith('feeToken.')) ? Cache.policies.metadata : Cache.policies.immutable, ) // The receipt is opt-in: fetching it is an extra RPC, so it only runs // when requested via `include=receipt` and the transaction is mined. // A null receipt (still pending) is simply omitted. if (query.include.includes('receipt') && data.blockNumber !== null) { const receipt = await Timing.time(c, 'receipt', () => client.request({ method: 'eth_getTransactionReceipt', params: [hash] }), ) if (receipt) { const humanized = Receipts.humanizeReceipt( Response.validated(Receipts.schema.Rpc.Receipt, receipt), ) const [receipt_enriched] = await enrichFeeTokens(c, { chainId, include: query.include, rows: [humanized], }) if (!receipt_enriched) throw new Error('Unable to enrich transaction receipt') data = { ...data, meta: { ...data.meta, receipt: receipt_enriched } } } } const [data_enriched] = await enrichFeeTokens(c, { chainId, include: query.include, rows: [data], }) if (!data_enriched) throw new Error('Unable to enrich transaction') return c.json(Response.validated(schema.getTransaction.Response, data_enriched), 200) } catch (cause) { return Response.upstream(c, cause) } }, ) } /** * Lists humanized transactions with required fee-token metadata and opt-in * resources. Status and fee-payer filters page through receipts because those * fields are indexed there. */ export async function listTransactions( c: Context, options: listTransactions.Options, ) { const { include, ...rest } = options // `status` lives on receipts, whose fee-payer index also avoids an unbounded // `txs.fee_payer` scan. Its cursor matches the transaction cursor domain. const receiptDriven = rest.status !== undefined || rest.feePayer !== undefined const receiptPage = receiptDriven ? await Receipts.getReceipts(c, { address: rest.address, chainId: rest.chainId, cursor: rest.cursor, feePayer: rest.feePayer, feeToken: rest.feeToken, fromBlock: rest.fromBlock, fromTimestamp: rest.fromTimestamp, limit: rest.limit, order: rest.order, page: rest.page, recipient: rest.recipient, sender: rest.sender, status: rest.status, toBlock: rest.toBlock, toTimestamp: rest.toTimestamp, }) : undefined const page = receiptPage ? { data: await transactionsForReceipts(c, { chainId: rest.chainId, receipts: receiptPage.data, }), nextCursor: receiptPage.nextCursor, } : await getTransactions(c, rest) let data = page.data if (include.includes('receipt') && data.length > 0) { const receipts = await Receipts.embedReceipts(c, { chainId: rest.chainId, include, receipts: receiptPage?.data, rows: data, }) data = data.map((row) => { const receipt = receipts.get(row.hash.toLowerCase()) return receipt ? { ...row, meta: { ...row.meta, receipt } } : row }) } return { ...page, data: await enrichFeeTokens(c, { chainId: rest.chainId, include, rows: data }), } } export declare namespace listTransactions { type Options = getTransactions.Options & { /** Optional receipt and curated token fields to embed. */ include: readonly z.output[] } } /** * Fetches the underlying transactions for a page of receipts in one indexed * `txs` query and returns them humanized, ordered to match the receipt page. * Receipts whose transaction row is missing (not yet indexed) are skipped. * Backs status- and fee-payer-filtered lists, which page receipts first. */ async function transactionsForReceipts( c: Context, options: transactionsForReceipts.Options, ): Promise { const { chainId, receipts } = options if (receipts.length === 0) return [] const tidx = c.get('getTidx')(chainId) const blocks = [...new Set(receipts.map((receipt) => receipt.blockNumber))] const hashes = [...new Set(receipts.map((receipt) => receipt.transactionHash))] // `txs` is sorted by `(block_num, idx)` with no hash index, so the hash list // must be paired with the page's block numbers — a bare `hash IN` full-scans // the table. Raw-table query, so the inline SQL is cast to `string`. const result = await Timing.time(c, 'transactions', () => tidx.fetch({ chainId, query: `SELECT ${transactionColumns} FROM txs WHERE block_num IN (${blocks.join(', ')}) AND hash IN (${hashes.map((hash) => `'${hash}'`).join(', ')})` as string, }), ) const byHash = new Map() for (const row of result.rows) { const rpc = schema.Rpc.Transaction.safeParse(toRpcTransaction(row)) if (rpc.success) byHash.set(rpc.data.hash.toLowerCase(), humanizeTransaction(rpc.data)) } return receipts .map((receipt) => byHash.get(receipt.transactionHash.toLowerCase())) .filter((transaction) => transaction !== undefined) } declare namespace transactionsForReceipts { /** Inputs for matching receipt rows to transactions. */ type Options = { /** Chain used to fetch matching transaction rows. */ chainId: z.output /** Receipt positions and hashes in page order. */ receipts: readonly Receipt[] } /** Receipt fields needed for transaction hydration. */ type Receipt = { /** Block containing the receipt. */ blockNumber: number /** Hash of the transaction that produced the receipt. */ transactionHash: string } } /** Resolves required fee-token metadata and optional curated fields without mutating input rows. */ export async function enrichFeeTokens( c: Context, options: enrichFeeTokens.Options, ): Promise[]> { const { chainId, include, rows } = options const includeLogoUri = include.includes('feeToken.logoUri') const includeVerified = include.includes('feeToken.verified') const uniqueTokens = [ ...new Set( rows .map((row) => row.feeToken) .filter((address): address is z.output => address !== undefined) .map((address) => address.toLowerCase()) .map((address) => Schema.Address.parse(address)), ), ] if (uniqueTokens.length === 0) return rows.map((row) => ({ ...row }) as enrichFeeTokens.Output) const snapshot = includeLogoUri || includeVerified ? await VerifiedTokens.snapshot(c, chainId).catch(() => undefined) : undefined const resolved = await Timing.time(c, 'tokens', () => Promise.all( uniqueTokens.map(async (address) => { const [logoUri, metadata] = await Promise.all([ includeLogoUri ? Tokens.getTokenLogo(c, { address, chainId }).catch(() => undefined) : Promise.resolve(undefined), Tokens.getTokenMetadata(c, { address, chainId }), ]) return [ address, { address, currency: metadata.currency, decimals: metadata.decimals, ...(includeLogoUri ? { logoUri: logoUri ?? snapshot?.byAddress.get(address)?.logoUri ?? metadata.logoUri, } : {}), name: metadata.name, symbol: metadata.symbol, ...(includeVerified && snapshot ? { verified: snapshot.byAddress.has(address) } : {}), }, ] as const }), ), ) const tokensByAddress = new Map(resolved) return rows.map((row) => { if (row.feeToken === undefined) return { ...row } as enrichFeeTokens.Output const address = Schema.Address.parse(row.feeToken.toLowerCase()) const feeToken = tokensByAddress.get(address) if (!feeToken) throw new Error('Unable to resolve fee token metadata') return { ...row, feeToken } as enrichFeeTokens.Output }) } export declare namespace enrichFeeTokens { /** Fee-token enrichment inputs. */ type Options = { /** Chain used for token metadata resolution. */ chainId: z.output /** Requested optional token fields. */ include: readonly string[] /** Rows carrying unresolved fee-token addresses. */ rows: readonly (row & Unresolved)[] } /** A copied row whose fee-token address has been replaced by metadata. */ type Output = Omit & { /** Resolved fee-token metadata when the row declares a fee token. */ feeToken?: z.output | undefined } /** Row field before fee-token metadata resolution. */ type Unresolved = { /** Fee-token address from indexed or RPC data. */ feeToken?: z.output | undefined } } export function getTransactions(c: Context, options: getTransactions.Options) { const { chainId } = options const store = c.get('store') const tidx = c.get('getTidx')(chainId) const cursor = options.cursor ? Cursor.decode(options.cursor, ['int', 'int']) : undefined return Timing.time(c, 'transactions', () => Store.memoize( async () => { const page = await scanTransactions({ tidx }, options) return { data: page.items.map((item) => item.data), nextCursor: page.nextCursor, } }, { key: `transactions:v1:${chainId}:${options.order}:${options.address ?? ''}:${options.sender ?? ''}:${options.recipient ?? ''}:${options.feePayer ?? ''}:${options.feeToken ?? ''}:${options.fromBlock ?? ''}:${options.toBlock ?? ''}:${options.fromTimestamp ?? ''}:${options.toTimestamp ?? ''}:${cursor ? `cursor:${cursor[0]}:${cursor[1]}` : options.page !== undefined && options.page > 1 ? `page:${options.page}` : 'head'}:${options.limit}`, store, ttl: Ttl.seconds(15), }, ), ) } /** * Context-free core of {@link getTransactions}: builds the `txs` query (with * address sides merged in application code), applies keyset pagination on * `(block_num, idx)`, and reconstructs each row through the same * `toRpcTransaction` → `humanizeTransaction` path the detail endpoint uses. Used * by the Hono routes (via {@link getTransactions}, which adds Server-Timing + * memoization) and by the webhook poller (uncached, ascending, per-row cursors). */ export async function scanTransactions( deps: scanTransactions.Deps, options: getTransactions.Options, ): Promise { const { address, chainId, feePayer, feeToken, fromBlock, fromTimestamp, includeCallRecipients, limit, order, recipient, sender, toBlock, toTimestamp, } = options const { tidx } = deps const sortDirection = order === 'asc' ? 'ASC' : 'DESC' // Keyset pagination: anchor the page below the previous row's `(block, idx)` // position instead of a numeric offset, so rows arriving at the head can't // shift items across pages. A malformed cursor decodes to `undefined` and // falls back to the head page. const cursor = options.cursor ? Cursor.decode(options.cursor, ['int', 'int']) : undefined // Bounded positional lane (exclusive with `cursor` at the schema): page 1 is // the head page, so it shares the head's SQL and cache entries; deeper pages // translate to a SQL OFFSET of `(page - 1) * limit` rows. const offset = options.page !== undefined && options.page > 1 ? (options.page - 1) * limit : undefined // Raw transactions from the `txs` table (Postgres engine). `txs` is a // standard table, not a signature-decoded CTE, so the inline query is // cast to `string` and needs no `signatures`. Order by block then // in-block index so transactions stay stable across pages, and fetch one // extra row to detect `hasMore` without a separate count query. // // Build the shared filters once (everything except the address-scope // predicate); the `address` filter is special-cased below because TIDX // rejects both `(from = X OR to = X)` and the equivalent ordered `UNION` // for some accounts. const filters: string[] = [] if (sender !== undefined) filters.push(`"from" = '${sender}'`) if (recipient !== undefined) // For Tempo AA transactions the meaningful recipient is often an inner call // target, so `includeCallRecipients` also matches `calls[].to` (a JSONB array // of `{ to, value, input }`). The `@>` containment hits the partial GIN index // on `calls`; the root `"to"` branch covers non-AA and single-call AA txs // (whose `"to"` mirrors `calls[0].to` and which the partial index omits). filters.push( includeCallRecipients ? `("to" = '${recipient}' OR calls @> '[{"to": "${recipient}"}]')` : `"to" = '${recipient}'`, ) if (feePayer !== undefined) filters.push(`fee_payer = '${feePayer}'`) if (feeToken !== undefined) filters.push(`fee_token = '${feeToken}'`) if (fromBlock !== undefined) filters.push(`block_num >= ${fromBlock}`) if (toBlock !== undefined) filters.push(`block_num <= ${toBlock}`) if (fromTimestamp !== undefined) filters.push(`block_timestamp >= '${fromTimestamp}'`) if (toTimestamp !== undefined) filters.push(`block_timestamp <= '${toTimestamp}'`) if (cursor !== undefined) filters.push( Cursor.keyset([ { literal: Cursor.literal(cursor[0]!, 'int'), name: 'block_num', order }, { literal: Cursor.literal(cursor[1]!, 'int'), name: 'idx', order }, ]), ) // Address-scoped queries run each side independently so each request uses one // index and no SQL compound scan. Each side over-fetches the full positional // window because the offset applies after the two result sets are merged. const queryLimit = address === undefined ? limit + 1 : (offset ?? 0) + limit + 1 const skip = address === undefined && offset !== undefined ? ` OFFSET ${offset}` : '' const filtered = address !== undefined || sender !== undefined || recipient !== undefined || feePayer !== undefined || feeToken !== undefined const fetchRows = async (where: readonly string[]) => { try { return await tidx.fetch({ chainId, // Raw-table query (no signature-decoded CTE), so the inline SQL is cast // to `string`. query: ` SELECT ${transactionColumns} FROM txs ${where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY block_num ${sortDirection}, idx ${sortDirection} LIMIT ${queryLimit}${skip} ` as string, }) } catch (error) { // Planner mis-selectivity turns these top-N scans into a block-ordered // walk that times out when matches sit far from the scan boundary; // `block_num + 0` blocks that walk, forcing the filter index plus a sort. if (!filtered || !Tidx.isDeterministicError(error)) throw error return await tidx.fetch({ chainId, // Raw-table query (no signature-decoded CTE), so the inline SQL is cast // to `string`. query: ` SELECT ${transactionColumns} FROM txs ${where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY block_num + 0 ${sortDirection}, idx ${sortDirection} LIMIT ${queryLimit}${skip} ` as string, }) } } const rows = await (async () => { if (address === undefined) return (await fetchRows(filters)).rows const [from, to] = await Promise.all([ fetchRows([`"from" = '${address}'`, ...filters]), fetchRows([`"to" = '${address}'`, ...filters]), ]) const seen = new Set() return [...from.rows, ...to.rows] .filter((row) => { const hash = Value.toText(row['hash'])?.toLowerCase() if (!hash) return true if (seen.has(hash)) return false seen.add(hash) return true }) .sort((a, b) => { const block = (Value.toNumber(a['block_num']) ?? 0) - (Value.toNumber(b['block_num']) ?? 0) const index = (Value.toNumber(a['idx']) ?? 0) - (Value.toNumber(b['idx']) ?? 0) const order = block === 0 ? index : block return sortDirection === 'ASC' ? order : -order }) .slice(offset ?? 0, (offset ?? 0) + limit + 1) })() // The next page anchors below the last fetched row's `(block, idx)`. const page = Cursor.paginate({ rows, limit, key: (row) => { const block = Value.toNumber(row['block_num']) const index = Value.toNumber(row['idx']) return block !== undefined && index !== undefined ? [block, index] : undefined }, }) // Reconstruct the RPC shape from the indexed columns, then humanize it // through the same path as the detail endpoint so the list and detail // responses are identical. Rows that fail to reconstruct are skipped. const items: scanTransactions.Item[] = [] for (const row of page.rows) { const rpc = schema.Rpc.Transaction.safeParse(toRpcTransaction(row)) const block = Value.toNumber(row['block_num']) const index = Value.toNumber(row['idx']) if (rpc.success && block !== undefined && index !== undefined) items.push({ cursor: [block, index], data: humanizeTransaction(rpc.data) }) } return { hasMore: page.hasMore, items, limit, nextCursor: page.nextCursor } } export declare namespace getTransactions { type Options = { address?: z.output | undefined chainId: z.output /** Opaque keyset cursor anchoring the page; omit for the head page. */ cursor?: string | undefined feePayer?: z.output | undefined feeToken?: z.output | undefined fromBlock?: number | undefined fromTimestamp?: string | undefined /** * When true, the `recipient` filter also matches any inner account-abstraction * call target (`calls[].to`), not just the root `to`. Off by default so read * endpoints keep root-level recipient semantics. */ includeCallRecipients?: boolean | undefined limit: number /** 1-indexed page number (positional pagination; exclusive with `cursor`). */ page?: number | undefined order: 'asc' | 'desc' recipient?: z.output | undefined sender?: z.output | undefined /** * Filter to transactions whose receipt shows this execution status. * Handled by {@link listTransactions} via the receipts-driven path; the raw * `txs` scan ignores it (`txs` does not store execution status). */ status?: 'reverted' | 'success' | undefined toBlock?: number | undefined toTimestamp?: string | undefined } } /** * Builds the non-cursor, non-address-scope `txs` filters shared by the page * ({@link scanTransactions}) and the capped count ({@link countTransactions}), * so the count always matches the page it annotates. The address scope is * handled separately (it `UNION`s the two sides, as the page does). */ function countFilters(options: countTransactions.Options): string[] { const filters: string[] = [] if (options.sender !== undefined) filters.push(`"from" = '${options.sender}'`) if (options.recipient !== undefined) filters.push(`"to" = '${options.recipient}'`) if (options.feePayer !== undefined) filters.push(`fee_payer = '${options.feePayer}'`) if (options.feeToken !== undefined) filters.push(`fee_token = '${options.feeToken}'`) if (options.fromBlock !== undefined) filters.push(`block_num >= ${options.fromBlock}`) if (options.toBlock !== undefined) filters.push(`block_num <= ${options.toBlock}`) if (options.fromTimestamp !== undefined) filters.push(`block_timestamp >= '${options.fromTimestamp}'`) if (options.toTimestamp !== undefined) filters.push(`block_timestamp <= '${options.toTimestamp}'`) return filters } /** * Capped total count of transactions matching the same filters as the page. * * Status and fee-payer filters are delegated to {@link Receipts.countReceipts}, * mirroring the page's receipts-driven path. The address scope reuses the * page's `UNION` of the two sides; each side hits its own (`from`/`to`) index, * and the deduped keyset count avoids double-counting self-transactions * (`from = to`). `totalCountCapped: true` is a lower bound — at least * {@link Schema.countCap} matches. */ export function countTransactions( c: Context, options: countTransactions.Options, ): Promise> { // Match the receipt-driven page path and avoid scanning unindexed // `txs.fee_payer` values when counting. if (options.status !== undefined || options.feePayer !== undefined) return Receipts.countReceipts(c, options) const { address, chainId } = options const store = c.get('store') const tidx = c.get('getTidx')(chainId) return Timing.time(c, 'transactions_count', () => Store.memoize( async () => { const filters = countFilters(options) let query: string if (address !== undefined) { const fromWhere = [`"from" = '${address}'`, ...filters].join(' AND ') const toWhere = [`"to" = '${address}'`, ...filters].join(' AND ') query = ` SELECT count(*) AS total FROM ( (SELECT block_num, idx FROM txs WHERE ${fromWhere} LIMIT ${Schema.countCap}) UNION (SELECT block_num, idx FROM txs WHERE ${toWhere} LIMIT ${Schema.countCap}) ) AS capped ` } else { const where = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '' query = ` SELECT count(*) AS total FROM ( SELECT 1 FROM txs ${where} LIMIT ${Schema.countCap} ) AS capped ` } const result = await tidx.fetch({ chainId, query: query as string }) const totalCount = Value.toNumber(result.rows[0]?.['total']) ?? 0 return { totalCountCapped: totalCount >= Schema.countCap, totalCount } }, { key: `transactions-count:v1:${chainId}:${address ?? ''}:${options.sender ?? ''}:${options.recipient ?? ''}:${options.feePayer ?? ''}:${options.feeToken ?? ''}:${options.fromBlock ?? ''}:${options.toBlock ?? ''}:${options.fromTimestamp ?? ''}:${options.toTimestamp ?? ''}`, store, ttl: Ttl.seconds(15), }, ), ) } export declare namespace countTransactions { /** Filters for {@link countTransactions}: the page filters minus pagination. */ type Options = Pick< getTransactions.Options, | 'address' | 'chainId' | 'feePayer' | 'feeToken' | 'fromBlock' | 'fromTimestamp' | 'recipient' | 'sender' | 'status' | 'toBlock' | 'toTimestamp' > } export declare namespace scanTransactions { /** Dependencies for {@link scanTransactions}. */ type Deps = { /** TIDX query client for the target chain. */ tidx: Tidx.Client } /** A humanized transaction row plus its `(block_num, idx)` cursor. */ type Item = { /** Keyset position of the row. */ cursor: readonly [blockNumber: number, transactionIndex: number] /** The humanized transaction (same shape the read endpoint returns). */ data: humanizeTransaction.Output } /** A page of scanned transactions. */ type Page = { hasMore: boolean items: readonly Item[] limit: number nextCursor: string | null } } /** * The `txs` columns {@link toRpcTransaction} reconstructs a transaction from. * Shared by the list scan and receipt-driven paths so the queries cannot drift * from the reconstruction. */ const transactionColumns = `block_num, block_timestamp, idx, hash, type, "from", "to", value, input, gas_limit, max_fee_per_gas, max_priority_fee_per_gas, nonce_key, nonce, fee_token, fee_payer, valid_before, valid_after` /** * Re-encodes a TIDX `txs` row into the JSON-RPC transaction shape, which becomes * the `rpc` payload that the list endpoint humanizes through the same path as * the detail endpoint. Numeric columns become `0x` quantities; fields the * indexer does not store (signatures, access list) are omitted, and `blockHash` * is null since the schema requires it. The all-zero `nonce_key` that the * indexer records for non-Tempo transactions is dropped to match the RPC * response, which omits it. */ function toRpcTransaction(row: Record) { const timestamp = Value.toIsoDateTime(row['block_timestamp']) const nonceKey = row['nonce_key'] return { blockHash: null, blockNumber: Value.toHex(row['block_num']) ?? null, blockTimestamp: timestamp ? Value.toHex(Math.floor(Date.parse(timestamp) / 1000)) : undefined, feePayer: typeof row['fee_payer'] === 'string' ? row['fee_payer'] : undefined, feeToken: typeof row['fee_token'] === 'string' ? row['fee_token'] : undefined, from: row['from'], gas: Value.toHex(row['gas_limit']), hash: row['hash'], input: typeof row['input'] === 'string' ? row['input'] : undefined, maxFeePerGas: Value.toHex(row['max_fee_per_gas']), maxPriorityFeePerGas: Value.toHex(row['max_priority_fee_per_gas']), nonce: Value.toHex(row['nonce']), nonceKey: typeof nonceKey === 'string' && !/^0x0+$/.test(nonceKey) ? nonceKey : undefined, to: typeof row['to'] === 'string' ? row['to'] : undefined, transactionIndex: Value.toHex(row['idx']) ?? null, type: Value.toHex(row['type']), validAfter: Value.toHex(row['valid_after']), validBefore: Value.toHex(row['valid_before']), value: Value.toHex(row['value']), } } /** Humanizes one call within a Tempo account-abstraction transaction. */ function humanizeCall(call: z.output): z.output { return { data: call.input ?? call.data ?? undefined, to: call.to ?? null, } } /** * Humanizes a JSON-RPC transaction: decodes `type`, converts hex quantities to * decimal strings, hex block fields to numbers, and timestamps to ISO 8601, * while preserving the verbatim payload under `rpc`. The same function serves * the detail endpoint (RPC source) and the list endpoint (reconstructed from the * indexer), so both expose an identical shape. */ export function humanizeTransaction( rpc: z.output, ): humanizeTransaction.Output { return { blockHash: rpc.blockHash, blockNumber: Value.hexToNumber(rpc.blockNumber) ?? null, calls: rpc.calls?.map(humanizeCall), chainId: Value.hexToNumber(rpc.chainId), feeToken: rpc.feeToken ?? undefined, gas: Value.hexToNumber(rpc.gas) ?? 0, gasPrice: Value.fromHex(rpc.gasPrice), hash: rpc.hash, id: rpc.hash, input: rpc.input, maxFeePerGas: Value.fromHex(rpc.maxFeePerGas), maxPriorityFeePerGas: Value.fromHex(rpc.maxPriorityFeePerGas), meta: { rpc }, nonce: Value.hexToNumber(rpc.nonce) ?? 0, nonceKey: rpc.nonceKey, recipient: rpc.to ?? null, sender: rpc.from, timestamp: Value.hexSecondsToIso(rpc.blockTimestamp) ?? null, transactionIndex: Value.hexToNumber(rpc.transactionIndex) ?? null, type: decodeTransactionType(rpc.type), validAfter: rpc.validAfter == null ? undefined : Value.hexSecondsToIso(rpc.validAfter), validBefore: rpc.validBefore == null ? undefined : Value.hexSecondsToIso(rpc.validBefore), value: Value.fromHex(rpc.value) ?? '0', } } export declare namespace humanizeTransaction { /** Humanized fields before asynchronous fee-token metadata resolution. */ type Output = Omit, 'feeToken'> & { /** Unresolved fee-token address from the RPC transaction. */ feeToken?: z.output | undefined } }