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 Fees from '../../../internal/Fees.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 Timing from '../../../internal/Timing.js' import * as Ttl from '../../../internal/Ttl.js' import * as Value from '../../../internal/Value.js' import * as FxOracle from '../FxOracle.js' import * as Tokens from './tokens.js' import * as Transactions from './transactions.js' import * as Valuation from './valuation.js' // Receipts and transactions import each other. Cross-module schemas stay lazy, // while function references resolve at call time. /** Example transaction hash surfaced in OpenAPI docs. */ const exampleHash = '0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665' /** * Computes the fee charged to the fee payer, in the fee token's base units, from * the receipt's `gasUsed` and decimal `effectiveGasPrice`. Returns undefined * when either input is missing. */ function computeFeeAmount(gasUsed: number | undefined, effectiveGasPrice: string | undefined) { if (gasUsed === undefined || effectiveGasPrice === undefined) return undefined return Fees.fromGas(BigInt(gasUsed), BigInt(effectiveGasPrice)) } /** Zod schemas owned by the receipt handlers. */ export namespace schema { // TODO(ox/zod): replace with the zod schema for `ox`'s `Log.Rpc` once ox ships // a `zod` entrypoint. /** One event log emitted by a transaction (RPC format). */ export const Log = z .looseObject({ address: Schema.Address.check( z.describe('The contract address that emitted this event log.'), ), blockHash: z .nullable(Schema.Hash) .check(z.describe('The block hash once included, or `null` while pending.')), blockNumber: z .nullable(Schema.Quantity) .check(z.describe('The block number once included, or `null` while pending.')), blockTimestamp: z .nullish(Schema.Quantity) .check(z.describe('Tempo-provided block timestamp for this log.')), data: Schema.Hex.check( z.describe('ABI-encoded data for the event parameters that are not indexed.'), z.meta({ examples: ['0xdeadbeef'] }), ), logIndex: z .nullable(Schema.Quantity) .check(z.describe('The log position within the block, or `null` while pending.')), removed: z .boolean() .check( z.describe( 'Whether this log was removed by a chain reorganization; Tempo finality means finalized logs do not reorg.', ), z.meta({ examples: [false] }), ), topics: z .array(Schema.Hash) .check( z.describe( 'Indexed event topics, including the event signature hash as the first topic.', ), ), transactionHash: z .nullable(Schema.Hash) .check(z.describe('The transaction hash for this log, or `null` while pending.')), transactionIndex: z .nullable(Schema.Quantity) .check(z.describe('The transaction position within the block, or `null` while pending.')), }) .check(z.describe('One event log emitted by a contract during transaction execution.')) const rpcLog = OpenApi.component(Log, 'RpcTransactionLog') /** 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 // `TransactionReceipt.Rpc` once ox ships a `zod` entrypoint. `blockHash`, // `logsBloom`, and `blockTimestamp` are nullish/optional so the receipt // *list* (reconstructed from the indexer, which stores neither block hash nor // bloom but does carry the block timestamp) reuses this same shape; the // detail endpoint's verbatim RPC receipt populates all of them. /** A JSON-RPC transaction receipt, passed through verbatim. */ export const Receipt = z .looseObject({ blobGasPrice: z .optional(Schema.Quantity) .check(z.describe('Blob gas price for EIP-4844 blob data.')), blobGasUsed: z .optional(Schema.Quantity) .check(z.describe('Blob gas used by EIP-4844 blob data.')), blockHash: z .nullable(Schema.Hash) .check( z.describe('The block hash, or `null` for receipts reconstructed from the index.'), ), blockNumber: Schema.Quantity.check( z.describe('The block number that included this receipt.'), ), blockTimestamp: z .nullish(Schema.Quantity) .check( z.describe( 'Block timestamp added when this receipt is reconstructed from indexed data.', ), ), contractAddress: z .nullish(Schema.Address) .check( z.describe( 'The created contract address, or `null` if the transaction did not create a contract.', ), ), cumulativeGasUsed: Schema.Quantity.check( z.describe('Total gas used in the block up to and including this transaction.'), ), effectiveGasPrice: Schema.Quantity.check( z.describe('Effective gas price paid for this transaction.'), ), feePayer: z .nullish(Schema.Address) .check(z.describe('The address that paid the transaction fee.')), feeToken: z .nullish(Schema.Address) .check( z.describe( 'The token used to pay the fee; on Tempo this is a USD stablecoin such as `pathUSD`.', ), ), from: Schema.Address.check( z.describe('The address that sent or authorized the transaction.'), ), gasUsed: Schema.Quantity.check(z.describe('Gas actually used to execute the transaction.')), logs: z .array(rpcLog) .check(z.describe('Event logs emitted by contracts while this transaction executed.')), logsBloom: z .optional(Schema.Hex) .check( z.describe('Bloom filter summarizing the logs in this receipt.'), z.meta({ examples: ['0x00'] }), ), root: z.optional(Schema.Hex).check( z.describe('Post-transaction state root used by pre-Byzantium Ethereum receipts.'), z.meta({ examples: ['0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665'], }), ), status: Schema.Quantity.check( z.describe( 'Execution status as a hex quantity: `0x1` means success and `0x0` means reverted.', ), z.meta({ examples: ['0x1'] }), ), to: z .nullable(Schema.Address) .check( z.describe('The recipient address, or `null` when the transaction created a contract.'), ), transactionHash: Schema.Hash.check( z.describe( 'A 32-byte hash, `0x`-prefixed and returned in lowercase, that identifies the transaction.', ), ), transactionIndex: Schema.Quantity.check( z.describe('The transaction position within its block.'), ), type: Schema.Hex.check( z.describe('Raw transaction type byte, such as `0x2` for EIP-1559 or `0x76` for Tempo.'), z.meta({ examples: ['0x2'] }), ), }) .check(z.describe('The transaction receipt exactly as returned by JSON-RPC.')) } const rpcReceipt = OpenApi.component(Rpc.Receipt, 'RpcTransactionReceipt') /** Receipt metadata containing valuation provenance and the verbatim JSON-RPC receipt. */ export const ReceiptMeta = z .object({ valuation: z .optional(Valuation.schema.Pricing) .check( z.describe( 'Rate provenance for the fee valuation. Present when conversion rates were consulted.', ), ), rpc: rpcReceipt.check(z.describe('The original JSON-RPC receipt payload.')), }) .check(z.describe('Metadata for this receipt, including valuation and the JSON-RPC payload.')) const receiptMeta = OpenApi.component(ReceiptMeta, 'TransactionReceiptMeta') /** The fee charged to the fee payer, carrying its value in the requested denomination. */ export const FeeAmount = z .extend(Schema.TokenAmount, { valuation: z .optional(z.nullable(Valuation.schema.Value)) .check( z.describe( 'The fee’s nominal value in the requested `valuation.currency`. `null` when rates were unavailable.', ), ), }) .check( z.describe('The fee charged to the fee payer, with its value in the requested denomination.'), ) const receiptFeeAmount = OpenApi.component(FeeAmount, 'TransactionReceiptFeeAmount') /** * A humanized transaction receipt: decoded `status`/`type`, plain-number gas * and block fields, a structured fee amount, and a decimal-string gas price. * The raw event logs are surfaced under `logs`, and the RPC receipt under `meta.rpc`. */ export const Receipt = z .object({ blockHash: z .nullable(Schema.Hash) .check( z.describe( 'The block hash, or `null` when the receipt was reconstructed from indexed data.', ), ), blockNumber: z .number() .check( z.int(), z.nonnegative(), z.describe('The block number that included this receipt.'), z.meta({ examples: [23456789] }), ), contractAddress: z .nullable(Schema.Address) .check( z.describe( 'The created contract address, or `null` if the transaction did not create a contract.', ), ), cumulativeGasUsed: z .number() .check( z.int(), z.nonnegative(), z.describe('Total gas used in the block up to and including this transaction.'), z.meta({ examples: [21000] }), ), effectiveGasPrice: Schema.DecimalString.check( z.describe('Effective gas price paid for this transaction, in attodollars per gas.'), ), feeAmount: receiptFeeAmount.check( z.describe('Fee charged to the fee payer, denominated in USD.'), ), feePayer: z .optional(Schema.Address) .check(z.describe('The address that paid the transaction fee.')), feeToken: z .optional(z.lazy(() => Tokens.schema.TokenReference)) .check( z.describe( 'The token used to pay the fee, with RPC metadata and optional curated fields.', ), ), gasUsed: z .number() .check( z.int(), z.nonnegative(), z.describe('Gas actually used to execute the transaction.'), z.meta({ examples: [21000] }), ), id: Schema.Hash.check( z.describe('Stable resource ID for this API response; it is the transaction hash.'), ), logs: z .array(rpcLog) .check( z.describe('Event logs emitted by this transaction, returned in JSON-RPC log format.'), ), meta: receiptMeta.check( z.describe( 'Receipt metadata containing valuation rate provenance and the JSON-RPC payload.', ), ), recipient: z .nullable(Schema.Address) .check( z.describe('The recipient address, or `null` when the transaction created a contract.'), ), sender: Schema.Address.check( z.describe('The address that sent or authorized the transaction.'), ), status: z .enum(['success', 'reverted']) .check( z.describe('Whether the transaction succeeded or reverted.'), z.meta({ examples: ['success'] }), ), timestamp: z .nullable(z.iso.datetime()) .check( z.describe('The block timestamp as ISO 8601, or `null` when it is unavailable.'), z.meta({ examples: ['2024-01-01T00:00:00Z'] }), ), transactionHash: Schema.Hash.check( z.describe( 'A 32-byte hash, `0x`-prefixed and returned in lowercase, that identifies the transaction.', ), ), transactionIndex: z .number() .check( z.int(), z.nonnegative(), z.describe('The transaction position within its block.'), z.meta({ examples: [0] }), ), type: z .lazy(() => Transactions.schema.TransactionType) .check(z.meta({ examples: ['eip1559'] })), }) .check( z.describe( 'A transaction outcome formatted for API clients, with status, gas, fees, logs, and the original JSON-RPC receipt under `meta.rpc`.', ), ) /** Humanized transaction receipt shared by detail, list, and transaction includes. */ export const TransactionReceipt = OpenApi.component(Receipt, 'TransactionReceipt') /** Schemas for the getReceipt operation. */ export namespace getReceipt { /** Path parameters for receipt requests. */ export const Params = z .object({ transactionHash: Schema.Hash.check( z.describe( 'A 32-byte hash, `0x`-prefixed and returned in lowercase, that identifies the transaction.', ), z.meta({ examples: [exampleHash] }), ), }) .check(z.describe('Path parameters used to look up a receipt by transaction hash.')) /** Optional curated token fields that callers opt into via `include`. */ export const Include = z .enum(['feeToken.logoUri', 'feeToken.verified']) .check(z.describe('Additional fee-token fields you can request with `include`.')) /** * Parses a comma-separated `include` query value into a list of optional * token fields to embed. Curated lookups only run when explicitly requested. */ export const includeQuery = Schema.includeQuery( Include, 'Comma-separated fee-token fields to include, such as `feeToken.logoUri,feeToken.verified`.', ) /** Query parameters for receipt requests. */ export const Query = z .strictObject({ chainId: Schema.ChainIdQuery, include: includeQuery, 'valuation.currency': Schema.Denomination, }) .check(z.describe('Query parameters for looking up a receipt.')) /** A humanized receipt, with the verbatim JSON-RPC payload under `meta.rpc`. */ export const Response = TransactionReceipt } /** Schemas for the getReceipts (list) operation. */ export namespace getReceipts { /** Optional fee-token fields and response-wide count metadata. */ export const Include = z .enum(['feeToken.logoUri', 'feeToken.verified', 'totalCount']) .check( z.describe('Related resources to include in each receipt row or in response metadata.'), ) /** Parses the comma-separated `include` value for the receipt list. */ export const includeQuery = Schema.includeQuery( Include, 'Comma-separated resources to include, such as `feeToken.logoUri,feeToken.verified,totalCount`.', ) /** Query parameters for receipt list requests. */ export const Query = z .strictObject({ address: z .optional(Schema.Address) .check( z.describe( 'Filter to receipts whose transaction has this address as sender or recipient.', ), ), 'blockNumber.from': Schema.blockNumberBound('receipts', 'from'), 'blockNumber.to': Schema.blockNumberBound('receipts', 'to'), chainId: Schema.ChainIdQuery, cursor: Schema.Cursor, feePayer: z .optional(Schema.Address) .check(z.describe('Filter to receipts whose transaction fee was paid by this address.')), feeToken: z .optional(Schema.TokenAddress) .check( z.describe('Filter to receipts whose transaction 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 receipts for transactions sent to this recipient address.')), sender: z .optional(Schema.Address) .check(z.describe('Filter to receipts for transactions sent by this sender address.')), status: z .optional(z.enum(['success', 'reverted'])) .check( z.describe('Filter to receipts with this execution status.'), z.meta({ examples: ['success'] }), ), 'timestamp.from': Schema.timestampBound('receipts', 'from'), 'timestamp.to': Schema.timestampBound('receipts', 'to'), 'valuation.currency': Schema.Denomination, }) .check(...Schema.pageChecks(), z.describe('Query parameters for listing receipts.')) /** * Page of receipts. Each item reuses the humanized receipt returned by * `GET /transactions/:transactionHash/receipt`, so the list and detail endpoints expose one * identical shape. Indexed list rows are reconstructed from the indexer, * which stores neither the block hash nor the event logs, so those fields are * `null`/empty in the list `meta.rpc` payload. */ /** Page-level resources: opt-in counts plus valuation rate provenance. */ export const Meta = z .object({ valuation: z .optional(Valuation.schema.Pricing) .check( z.describe( 'Rate provenance for fee valuations. Present when conversion rates were ' + 'consulted; absent when every value was identity-valued or rates were unavailable.', ), ), totalCount: z.optional(Schema.TotalCount), totalCountCapped: z.optional(Schema.TotalCountCapped), }) .check(z.describe('Page-level resources attached to this response.')) const meta = OpenApi.component(Meta, 'TransactionReceiptListMeta') export const Response = OpenApi.component( z .object({ data: z.array(getReceipt.Response).check(z.describe('Receipts on this page.')), meta: z .optional(meta) .check( z.describe('Page-level resources: opt-in counts and valuation rate provenance.'), ), nextCursor: Schema.NextCursor, }) .check(z.describe('A page of receipts ordered by block number and transaction position.')), 'TransactionReceiptList', ) } } /** Creates receipt handlers. */ export function receipts(options: receipts.Options = {}) { const oracle = options.fx?.oracle ?? FxOracle.ecb() return new Hono() .get( '/v1/transactions/receipts', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('query', schema.getReceipts.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'List transaction receipts across Tempo, with filters for addresses, blocks, timestamps, fees, and status.', operationId: 'getReceipts', responses: OpenApi.responses({ errors: { 400: { codes: ['chain_id_invalid', 'chain_id_unsupported', 'query_invalid'] }, 502: 'Could not read receipt or token data from an upstream service.', }, success: { description: 'A page of transaction receipts.', schema: schema.getReceipts.Response, }, }), summary: 'List transaction receipts', tags: ['Transactions'], }), Cache.response({ cacheControl: Cache.policies.feed, name: 'tempo-api:receipts:v1', key: (c) => Cache.urlKey(c, schema.getReceipts.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 carry current fee valuations and curated verification data. if (query.cursor !== undefined) Cache.setPolicy(c, Cache.policies.metadata) 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') ? countReceipts(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 receipts = await listReceipts(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'], }) // Value each fee via its own currency tag, in the requested // denomination. const denomination = query['valuation.currency'] const rates = denomination ? await Valuation.ratesFor(c, { currencies: receipts.data.map((receipt) => receipt.feeAmount.currency), denomination, oracle, }) : undefined const data = receipts.data.map((receipt) => ({ ...receipt, feeAmount: { ...receipt.feeAmount, ...(denomination ? { valuation: Valuation.valuationFor({ amount: BigInt(receipt.feeAmount.baseUnits), denomination, rates, token: receipt.feeAmount, }), } : {}), }, })) const counts = countPromise ? await countPromise : undefined const meta = { ...(rates ? { valuation: Valuation.pricing(rates, oracle) } : {}), ...counts, } return c.json( Response.validated(schema.getReceipts.Response, { data, ...(Object.keys(meta).length > 0 ? { meta } : {}), nextCursor: receipts.nextCursor, }), 200, ) } catch (cause) { if (cause instanceof Valuation.UnsupportedDenominationError) return Response.error(c, { code: 'query_invalid', message: cause.message, status: 400 }) return Response.upstream(c, cause, { page: query.page }) } }, ) .get( '/v1/transactions/:transactionHash/receipt', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('param', schema.getReceipt.Params, { code: 'transaction_invalid', message: 'Invalid transaction hash', }), OpenApi.validate('query', schema.getReceipt.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ description: 'Get the outcome of one transaction by its transaction hash.', operationId: 'getReceipt', responses: OpenApi.responses({ errors: { 400: { codes: [ 'chain_id_invalid', 'chain_id_unsupported', 'query_invalid', 'transaction_invalid', ], }, 404: { description: 'No receipt was found for that transaction hash.', codes: ['receipt_not_found'], }, 502: 'Could not read receipt or token data from upstream JSON-RPC.', }, success: { description: 'A single transaction receipt with status, gas, fees, and logs.', schema: schema.getReceipt.Response, }, }), summary: 'Get a transaction receipt', tags: ['Transactions'], }), Cache.response({ // Default to no-store so a 404 does not mask the eventual mined receipt. // Successful responses use the metadata policy for current valuations. cacheControl: Cache.policies.noStore, name: 'tempo-api:receipts:v1', key: (c) => Cache.urlKey(c, schema.getReceipt.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 receipt; a `null` result means the receipt is // unknown or the tx is still pending. The verbatim payload is surfaced // under `meta.rpc`, with humanized fields layered on top. Unmined // lookups are deliberately never cached — confirmation polling must // observe the receipt the moment the node has it. const rpc = await Timing.time(c, 'transaction_receipt', () => client.request({ method: 'eth_getTransactionReceipt', params: [hash] }), ) if (!rpc) return Response.error(c, { code: 'receipt_not_found', message: 'Transaction receipt not found', status: 404, }) const data = humanizeReceipt(Response.validated(schema.Rpc.Receipt, rpc)) // Mined receipts still carry current valuations and verification data. Cache.setPolicy(c, Cache.policies.metadata) const [enriched] = await enrichReceipts(c, { chainId, include: query.include, receipts: [data], }) if (!enriched) throw new Error('Unable to enrich transaction receipt') // Value the fee via its own currency tag, in the requested // denomination. const denomination = query['valuation.currency'] const rates = denomination ? await Valuation.ratesFor(c, { currencies: [enriched.feeAmount.currency], denomination, oracle, }) : undefined const valued = { ...enriched, feeAmount: { ...enriched.feeAmount, ...(denomination ? { valuation: Valuation.valuationFor({ amount: BigInt(enriched.feeAmount.baseUnits), denomination, rates, token: enriched.feeAmount, }), } : {}), }, meta: { ...enriched.meta, ...(rates ? { valuation: Valuation.pricing(rates, oracle) } : {}), }, } return c.json(Response.validated(schema.getReceipt.Response, valued), 200) } catch (cause) { if (cause instanceof Valuation.UnsupportedDenominationError) return Response.error(c, { code: 'query_invalid', message: cause.message, status: 400 }) return Response.upstream(c, cause) } }, ) } export declare namespace receipts { /** Options for the receipt handlers. */ type Options = { /** FX configuration backing fee valuation. */ fx?: Valuation.addresses.Fx | undefined } } /** * Lists humanized receipts with required fee-token metadata and optional curated fields. */ export async function listReceipts(c: Context, options: listReceipts.Options) { const { include, ...rest } = options const page = await getReceipts(c, rest) return { ...page, data: await enrichReceipts(c, { chainId: rest.chainId, include, receipts: page.data }), } } export declare namespace listReceipts { type Options = getReceipts.Options & { /** Optional curated fee-token fields to embed. */ include: readonly z.output[] } } export function getReceipts(c: Context, options: getReceipts.Options) { const { address, chainId, feePayer, feeToken, fromBlock, fromTimestamp, limit, order, recipient, sender, status, toBlock, toTimestamp, } = options const store = c.get('store') const tidx = c.get('getTidx')(chainId) const sortDirection = order === 'asc' ? 'ASC' : 'DESC' // Keyset pagination: anchor the page below the previous row's `(block, tx_idx)` // position instead of a numeric offset, so rows arriving at the head can't // shift items across pages. A malformed cursor 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 return Timing.time(c, 'receipts', () => Store.memoize( async () => { // Page the `receipts` table alone. Fee-token queries read metadata // denormalized on ClickHouse; other queries hydrate it from `txs`. // Paging straight off `receipts` avoids the // `receipts JOIN txs` shape, which TIDX's planner cannot execute under // address scope: a `UNION` of two JOINs (or the `(from = X OR to = X)` // form) reliably returns `db error` (HTTP 422) for high-traffic // addresses. Two index-friendly single-table queries plan cleanly. // // Build the shared filters once (everything except the address-scope // predicate, which is special-cased below). const filters: string[] = [] if (sender !== undefined) filters.push(`"from" = '${sender}'`) if (recipient !== undefined) filters.push(`"to" = '${recipient}'`) if (feePayer !== undefined) filters.push(`fee_payer = '${feePayer}'`) // ClickHouse receipts denormalize fee metadata specifically to avoid // an unbounded transaction-hash semi-join for this filter. 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 (status !== undefined) filters.push(`status = ${status === 'success' ? 1 : 0}`) if (cursor !== undefined) filters.push( Cursor.keyset([ { literal: Cursor.literal(cursor[0]!, 'int'), name: 'block_num', order }, { literal: Cursor.literal(cursor[1]!, 'int'), name: 'tx_idx', order }, ]), ) const columns = `block_num, block_timestamp, tx_idx, tx_hash, "from", "to", contract_address, gas_used, cumulative_gas_used, effective_gas_price, status, fee_payer${feeToken === undefined ? '' : ', type, fee_token'}` // For address-scoped queries, run one branch per side and `UNION` so // each branch returns at most `limit + 1` index-ordered rows, the outer // query re-merges, and plain `UNION` dedupes the self-receipt case // where `from = to = address`. The sort+limit live *inside* each branch // so the merge stays bounded. // // The `receipts` table only indexes `from`, not `to` (a `to` scan over // ~25M rows hits the indexer's statement timeout and returns `db error`, // HTTP 422). A fee-payer filter bounds the direct `to` scan through the // receipt fee-payer index. Other `to` branches resolve matching hashes // through the indexed `txs.to` and `receipts.tx_hash` columns. const sideQuery = (side: 'from' | 'to', addr: string) => { const predicate = (() => { if (side === 'from') return `"from" = '${addr}'` if (feePayer !== undefined) return `"to" = '${addr}'` return `tx_hash IN (SELECT hash FROM txs WHERE "to" = '${addr}')` })() const where = [predicate, ...filters].join(' AND ') // In the offset lane each branch over-fetches the full window // (`offset + limit + 1` rows) because the skip applies to the merged // set: the outer query re-sorts the union and discards the first // `offset` rows. return `(SELECT ${columns} FROM receipts WHERE ${where} ORDER BY block_num ${sortDirection}, tx_idx ${sortDirection} LIMIT ${(offset ?? 0) + limit + 1})` } const skip = offset !== undefined ? ` OFFSET ${offset}` : '' const query = address !== undefined ? ` ${sideQuery('from', address)} UNION ${sideQuery('to', address)} ORDER BY block_num ${sortDirection}, tx_idx ${sortDirection} LIMIT ${limit + 1}${skip} ` : ` SELECT ${columns} FROM receipts ${filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''} ORDER BY block_num ${sortDirection}, tx_idx ${sortDirection} LIMIT ${limit + 1}${skip} ` const result = await tidx.fetch({ chainId, ...(feeToken !== undefined ? { engine: 'clickhouse' } : {}), query: query as string, }) // The next page anchors below the last fetched row's `(block, tx_idx)`. const page = Cursor.paginate({ rows: result.rows, limit, key: (row) => { const block = Value.toNumber(row['block_num']) const index = Value.toNumber(row['tx_idx']) return block !== undefined && index !== undefined ? [block, index] : undefined }, }) // Enrich non-fee-token pages with metadata from `txs`. Pairing hashes // with their blocks avoids a full-table hash scan. const hashes = [ ...new Set( page.rows .map((row) => row['tx_hash']) .filter((hash): hash is string => typeof hash === 'string'), ), ] const blocks = [ ...new Set( page.rows .map((row) => Value.toNumber(row['block_num'])) .filter((block): block is number => block !== undefined), ), ] const txByHash = new Map() if (feeToken === undefined && hashes.length > 0 && blocks.length > 0) { const txs = await tidx.fetch({ chainId, query: `SELECT hash, type, fee_token FROM txs WHERE block_num IN (${blocks.join( ', ', )}) AND hash IN (${hashes.map((hash) => `'${hash}'`).join(', ')})` as string, }) for (const row of txs.rows) { const hash = row['hash'] if (typeof hash === 'string') txByHash.set(hash, { feeToken: row['fee_token'], type: row['type'] }) } } // 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 data: humanizeReceipt.Output[] = [] for (const row of page.rows) { const tx = typeof row['tx_hash'] === 'string' ? txByHash.get(row['tx_hash']) : undefined const merged = feeToken === undefined ? { ...row, fee_token: tx?.feeToken, type: tx?.type } : row const rpc = schema.Rpc.Receipt.safeParse(toRpcReceipt(merged)) if (rpc.success) data.push(humanizeReceipt(rpc.data)) } return { data, nextCursor: page.nextCursor } }, { key: `receipts:v1:${chainId}:${order}:${address ?? ''}:${sender ?? ''}:${recipient ?? ''}:${feePayer ?? ''}:${feeToken ?? ''}:${fromBlock ?? ''}:${toBlock ?? ''}:${fromTimestamp ?? ''}:${toTimestamp ?? ''}:${status ?? ''}:${cursor ? `cursor:${cursor[0]}:${cursor[1]}` : offset !== undefined ? `page:${options.page}` : 'head'}:${limit}`, store, ttl: Ttl.seconds(15), }, ), ) } export declare namespace getReceipts { 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 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 /** Only include receipts whose execution status matches. */ status?: 'reverted' | 'success' | undefined toBlock?: number | undefined toTimestamp?: string | undefined } } /** * Builds the non-cursor, non-address-scope filters shared by the receipt page * ({@link getReceipts}) and the capped count ({@link countReceipts}), so the * count always matches the page it annotates. The address scope is handled * separately (its `UNION` workaround differs between page and count). */ function countFilters(options: countReceipts.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}'`) if (options.status !== undefined) filters.push(`status = ${options.status === 'success' ? 1 : 0}`) return filters } /** * Capped total count of receipts matching the same filters as the page. The * address scope reuses the page's `UNION` workaround. Fee-payer filters make a * direct `receipts.to` predicate selective; otherwise the `to` side resolves * hashes via the `txs` index. The deduped keyset avoids double-counting * self-receipts (`from = to`). * `totalCountCapped: true` is a lower bound — at least {@link Schema.countCap} matches. */ export function countReceipts( c: Context, options: countReceipts.Options, ): Promise> { const { address, chainId } = options const store = c.get('store') const tidx = c.get('getTidx')(chainId) return Timing.time(c, 'receipts_count', () => Store.memoize( async () => { const filters = countFilters(options) let query: string if (address !== undefined) { const fromWhere = [`"from" = '${address}'`, ...filters].join(' AND ') const toPredicate = options.feePayer !== undefined ? `"to" = '${address}'` : `tx_hash IN (SELECT hash FROM txs WHERE "to" = '${address}')` const toWhere = [toPredicate, ...filters].join(' AND ') query = ` SELECT count(*) AS total FROM ( (SELECT block_num, tx_idx FROM receipts WHERE ${fromWhere} LIMIT ${Schema.countCap}) UNION (SELECT block_num, tx_idx FROM receipts 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 receipts ${where} LIMIT ${Schema.countCap} ) AS capped ` } const result = await tidx.fetch({ chainId, ...(options.feeToken !== undefined ? { engine: 'clickhouse' } : {}), query: query as string, }) const totalCount = Value.toNumber(result.rows[0]?.['total']) ?? 0 return { totalCountCapped: totalCount >= Schema.countCap, totalCount } }, { key: `receipts-count:v1:${chainId}:${address ?? ''}:${options.sender ?? ''}:${options.recipient ?? ''}:${options.feePayer ?? ''}:${options.feeToken ?? ''}:${options.fromBlock ?? ''}:${options.toBlock ?? ''}:${options.fromTimestamp ?? ''}:${options.toTimestamp ?? ''}:${options.status ?? ''}`, store, ttl: Ttl.seconds(15), }, ), ) } export declare namespace countReceipts { /** Filters for {@link countReceipts}: the page filters minus pagination. */ type Options = Pick< getReceipts.Options, | 'address' | 'chainId' | 'feePayer' | 'feeToken' | 'fromBlock' | 'fromTimestamp' | 'recipient' | 'sender' | 'status' | 'toBlock' | 'toTimestamp' > } /** * Resolves humanized receipts — including their event logs — for a page of * transactions, keyed by lowercased transaction hash. Rows without a receipt * (pending, or not yet indexed) are simply absent from the map. * * Receipt rows come from the indexed `receipts` table (skipped when the caller * already paged receipts via the `status` path) and event logs from the raw * `logs` table, both fetched once for the whole page. No `Store.memoize` here: * the route-level response cache (`Cache.response`) already absorbs repeated * requests for the same page. */ export async function embedReceipts( c: Context, options: embedReceipts.Options, ): Promise>> { const { chainId, include, rows } = options const found = new Map() // Only mined rows can have a receipt; their block numbers also constrain the // `logs` lookup to the page's exact blocks. const blocks = new Set() const hashes = new Set() for (const row of rows) { if (row.blockNumber === null) continue blocks.add(row.blockNumber) hashes.add(row.hash.toLowerCase()) } if (hashes.size === 0) return new Map() const tidx = c.get('getTidx')(chainId) const inList = [...hashes].map((hash) => `'${hash}'`).join(', ') // Raw-table queries (no signature-decoded CTE), so the inline SQL is cast to // `string`. The receipts lookup needs no block bound (`receipts.tx_hash` is // indexed — see the `sideQuery` comment in `getReceipts`); the logs lookup // uses the page's exact blocks so sparse transaction pages do not scan every // block between their oldest and newest rows. const [receipts_result, logs_result] = await Timing.time(c, 'receipts_embed', () => Promise.all([ options.receipts ? undefined : tidx.fetch({ chainId, query: `SELECT block_num, block_timestamp, tx_idx, tx_hash, "from", "to", contract_address, gas_used, cumulative_gas_used, effective_gas_price, status, fee_payer FROM receipts WHERE tx_hash IN (${inList})` as string, }), tidx.fetch({ chainId, query: `SELECT tx_hash, block_num, tx_idx, log_idx, address, selector, topic1, topic2, topic3, data, block_timestamp FROM logs WHERE block_num IN (${[...blocks].join(', ')}) AND tx_hash IN (${inList}) ORDER BY block_num ASC, log_idx ASC` as string, }), ]), ) // Reconstruct each raw `logs` row into the RPC log shape `schema.Log` // consumes (the postgres `logs` table names `topic0` `selector`), grouped by // transaction hash. Logs that fail to reconstruct are skipped. const logsByHash = new Map[]>() for (const row of logs_result.rows) { const hash = Value.toText(row['tx_hash'])?.toLowerCase() if (!hash) continue const timestamp = Value.toIsoDateTime(row['block_timestamp']) const log = schema.Log.safeParse({ address: row['address'], blockHash: null, blockNumber: Value.toHex(row['block_num']), blockTimestamp: timestamp ? Value.toHex(Math.floor(Date.parse(timestamp) / 1000)) : undefined, data: Value.toText(row['data']) ?? '0x', logIndex: Value.toHex(row['log_idx']), removed: false, topics: [row['selector'], row['topic1'], row['topic2'], row['topic3']].filter( (topic): topic is string => typeof topic === 'string' && topic.length > 0, ), transactionHash: hash, transactionIndex: Value.toHex(row['tx_idx']), }) if (!log.success) continue const logs = logsByHash.get(hash) if (logs) logs.push(log.data) else logsByHash.set(hash, [log.data]) } // Status path: the caller already paged humanized receipts, which the // receipts list reconstructs without logs — rebuild each with the page's // logs attached (both on the row and inside its `meta.rpc` payload). if (options.receipts) { for (const receipt of options.receipts) { const hash = receipt.transactionHash.toLowerCase() const logs = logsByHash.get(hash) ?? [] found.set(hash, { ...receipt, logs, meta: { ...receipt.meta, rpc: { ...receipt.meta.rpc, logs } }, }) } const receipts = await enrichReceipts(c, { chainId, include, receipts: [...found.values()] }) return new Map(receipts.map((receipt) => [receipt.transactionHash.toLowerCase(), receipt])) } const receiptRowByHash = new Map>() for (const row of receipts_result?.rows ?? []) { const hash = Value.toText(row['tx_hash'])?.toLowerCase() if (hash) receiptRowByHash.set(hash, row) } // Per transaction row, merge in the `type`/`fee_token` the `receipts` table // does not store from the transaction row itself (saving the `txs` follow-up // lookup the receipts list needs), inject the reconstructed logs, and // humanize through the same path as the receipt endpoints. for (const row of rows) { const hash = row.hash.toLowerCase() const receipt = receiptRowByHash.get(hash) if (!receipt) continue const merged = { ...receipt, fee_token: row.feeToken, // `toRpcReceipt` hex-encodes numeric columns; decode the row's raw hex // `type` byte so it round-trips through the same path. type: Value.hexToNumber(row.meta.rpc.type), } const rpc = schema.Rpc.Receipt.safeParse({ ...toRpcReceipt(merged), logs: logsByHash.get(hash) ?? [], }) if (rpc.success) found.set(hash, humanizeReceipt(rpc.data)) } const receipts = await enrichReceipts(c, { chainId, include, receipts: [...found.values()] }) return new Map(receipts.map((receipt) => [receipt.transactionHash.toLowerCase(), receipt])) } export declare namespace embedReceipts { /** Options for {@link embedReceipts}. */ type Options = { /** Target chain id. */ chainId: z.output /** Optional curated fee-token fields to embed. */ include: readonly string[] /** * Pre-fetched humanized receipts for the page (the `status`-filtered * transactions path). When provided, the receipts query is skipped and only * the page's event logs are fetched and reattached. */ receipts?: readonly humanizeReceipt.Output[] | undefined /** * The page's humanized transaction rows. Typed structurally (rather than as * the transactions schema output) so this module never touches the * transaction schemas eagerly across the import cycle. */ rows: readonly { /** Block number, or null if pending (pending rows have no receipt). */ blockNumber: number | null /** Fee-token preference declared on the transaction. */ feeToken?: Hex.Hex | undefined /** Transaction hash. */ hash: string /** Verbatim RPC payload carrying the raw `type` byte. */ meta: { rpc: { type: Hex.Hex } } }[] } } /** Resolves required RPC fee-token metadata and optional curated fields for receipts. */ export function enrichReceipts(c: Context, options: enrichReceipts.Options) { return Transactions.enrichFeeTokens(c, { chainId: options.chainId, include: options.include, rows: options.receipts, }) } export declare namespace enrichReceipts { /** Inputs for enriching humanized receipts. */ type Options = { /** Target chain id. */ chainId: z.output /** Optional curated fee-token fields to embed. */ include: readonly string[] /** Humanized receipts whose fee token is still an address. */ receipts: readonly humanizeReceipt.Output[] } } /** * Humanizes a JSON-RPC transaction receipt: decodes `status`/`type`, converts * gas price and fee values, converts block fields to numbers, and derives the * block `timestamp` from the top-level `blockTimestamp` (present when * reconstructed from the index) or the (shared) per-log timestamp — `null` when * neither is available. The verbatim payload is preserved under `meta.rpc` and * the raw logs under `logs`. Shared by the receipt detail (RPC source) and list * (reconstructed from the indexer) endpoints, so both expose an identical shape. */ export function humanizeReceipt(rpc: z.output): humanizeReceipt.Output { const gasUsed = Value.hexToNumber(rpc.gasUsed) ?? 0 const effectiveGasPrice = Value.fromHex(rpc.effectiveGasPrice) ?? '0' return { blockHash: rpc.blockHash, blockNumber: Value.hexToNumber(rpc.blockNumber) ?? 0, contractAddress: rpc.contractAddress ?? null, cumulativeGasUsed: Value.hexToNumber(rpc.cumulativeGasUsed) ?? 0, effectiveGasPrice, feeAmount: Value.tokenAmount({ baseUnits: computeFeeAmount(gasUsed, effectiveGasPrice) ?? 0n, currency: 'USD', decimals: Fees.tokenDecimals, }), feePayer: rpc.feePayer ?? undefined, feeToken: rpc.feeToken ?? undefined, gasUsed, id: rpc.transactionHash, logs: rpc.logs, meta: { rpc }, recipient: rpc.to, sender: rpc.from, status: Value.hexToNumber(rpc.status) === 1 ? 'success' : 'reverted', timestamp: Value.hexSecondsToIso(rpc.blockTimestamp ?? rpc.logs[0]?.blockTimestamp) ?? null, transactionHash: rpc.transactionHash, transactionIndex: Value.hexToNumber(rpc.transactionIndex) ?? 0, type: Transactions.decodeTransactionType(rpc.type), } } export declare namespace humanizeReceipt { /** Humanized receipt before its fee-token address is resolved to metadata. */ type Output = Omit, 'feeToken'> & { /** Address of the token used to pay the fee. */ feeToken?: z.output | undefined } } /** * Re-encodes a TIDX `receipts` row (joined to `txs`) into the JSON-RPC receipt * shape, which becomes the `rpc` payload the list endpoint humanizes through the * same path as the detail endpoint. Numeric columns become `0x` quantities; * fields the indexer does not store — the block hash, logs bloom, and event logs * — are null/empty since the `receipts` table records neither. */ export function toRpcReceipt(row: Record) { const timestamp = Value.toIsoDateTime(row['block_timestamp']) return { blockHash: null, blockNumber: Value.toHex(row['block_num']), blockTimestamp: timestamp ? Value.toHex(Math.floor(Date.parse(timestamp) / 1000)) : undefined, contractAddress: typeof row['contract_address'] === 'string' ? row['contract_address'] : null, cumulativeGasUsed: Value.toHex(row['cumulative_gas_used']), effectiveGasPrice: Value.toHex(row['effective_gas_price']), feePayer: typeof row['fee_payer'] === 'string' ? row['fee_payer'] : undefined, feeToken: typeof row['fee_token'] === 'string' ? row['fee_token'] : undefined, from: row['from'], gasUsed: Value.toHex(row['gas_used']), logs: [], status: Value.toHex(row['status']), to: typeof row['to'] === 'string' ? row['to'] : null, transactionHash: row['tx_hash'], transactionIndex: Value.toHex(row['tx_idx']), type: Value.toHex(row['type']), } }