/** @module-tag localnet */ import { Schema } from 'tapimo' import { FxOracle } from 'tapimo/apps' import { Hex, Value as core_Value } from 'ox' import { zeroHash } from 'viem' import * as TestApp from '../../../../test/App.js' import * as Runtime from '../../../../test/runtime.js' import * as Viem from '../../../../test/Viem.js' import * as Receipts from './receipts.js' // Deterministic EUR-based rate set so conversion math is exact in assertions. const fixed = FxOracle.from({ name: 'fixed', rates: async () => ({ asOf: '2026-01-01T00:00:00.000Z', base: 'EUR', rates: { AUD: '1.6', USD: '1.0' }, }), }) /** The decoded `type` names a humanized receipt may report. */ const transactionTypes = ['legacy', 'eip2930', 'eip1559', 'eip4844', 'eip7702', 'tempo', 'unknown'] let transactionHash: Hex.Hex | undefined beforeAll(async () => { const runtime = Runtime.get() transactionHash = runtime.mode === 'localnet' ? runtime.fixtures!.faucetTransactionHashes[0] : await findRecentTransactionHash().catch(() => undefined) }) describe('GET /transactions/:transactionHash/receipt', () => { test('humanizes the receipt and keeps the raw RPC under `meta.rpc`', async () => { expect(transactionHash).toBeDefined() const client = TestApp.client() const response = await client.v1.transactions[':transactionHash'].receipt.$get( { param: { transactionHash: transactionHash! }, query: {} }, TestApp.auth, ) const body = await TestApp.json(response, Receipts.schema.getReceipt.Response) expect(response.status).toBe(200) expect(response.headers.get('server-timing')?.includes('transaction_receipt;dur=')).toBe(true) expect(body.transactionHash).toBe(transactionHash!.toLowerCase()) expect(body.sender).toMatch(/^0x[0-9a-f]{40}$/) // The receipt is humanized: `status` is a decoded name and gas amounts are // plain numbers. expect(['success', 'reverted']).toContain(body.status) expect(transactionTypes).toContain(body.type) expect(typeof body.gasUsed).toBe('number') expect(Array.isArray(body.logs)).toBe(true) // `feeAmount` is the fee charged to the fee payer, derived as // ceil(gasUsed * effectiveGasPrice / 1e12), in USD base units. const expectedFee = (BigInt(body.gasUsed) * BigInt(body.effectiveGasPrice) + 10n ** 12n - 1n) / 10n ** 12n expect(body.feeAmount.baseUnits).toBe(expectedFee.toString()) expect(body.feeAmount.currency).toBe('USD') expect(body.feeAmount.decimals).toBe(6) expect(body.feeAmount.formatted).toMatch(/^\d+(\.\d+)?$/) expect('valuation' in body.feeAmount).toBe(false) expect(response.headers.get('server-timing')).not.toContain('valuation_rates;dur=') // Core fee-token metadata is present by default while the raw address stays // under `meta.rpc`. Curated fields remain opt-in. if (body.meta.rpc.feeToken) { expect(body.feeToken?.address).toBe(body.meta.rpc.feeToken) expect(typeof body.feeToken?.currency).toBe('string') expect(typeof body.feeToken?.decimals).toBe('number') expect(typeof body.feeToken?.name).toBe('string') expect(typeof body.feeToken?.symbol).toBe('string') expect(body.feeToken?.logoUri).toBeUndefined() expect(body.feeToken?.verified).toBeUndefined() } else expect(body.feeToken).toBeUndefined() expect(body.meta).not.toHaveProperty('feeToken') // The verbatim JSON-RPC receipt is always available under `meta.rpc`, where // `status` is the raw `0x1`/`0x0` flag and gas values stay hex-encoded. expect(['0x0', '0x1']).toContain(body.meta.rpc.status) expect(body.meta.rpc.gasUsed).toMatch(/^0x[0-9a-f]+$/) // Curated fee-token fields are opt-in on the top-level reference. const includedResponse = await client.v1.transactions[':transactionHash'].receipt.$get( { param: { transactionHash: transactionHash! }, query: { include: 'feeToken.logoUri,feeToken.verified' }, }, TestApp.auth, ) const includedBody = await TestApp.json(includedResponse, Receipts.schema.getReceipt.Response) expect(includedResponse.status).toBe(200) if (includedBody.feeToken) { expect(includedBody.feeToken.address).toBe(includedBody.meta.rpc.feeToken) expect(typeof includedBody.feeToken.verified).toBe('boolean') if (includedBody.feeToken.logoUri) expect(typeof includedBody.feeToken.logoUri).toBe('string') } }) test('returns 404 for an unknown receipt', async () => { const client = TestApp.client() const response = await client.v1.transactions[':transactionHash'].receipt.$get( { param: { transactionHash: zeroHash }, query: {} }, TestApp.auth, ) const body = await TestApp.json(response, Schema.ErrorResponse) expect(response.status).toBe(404) expect(body.error.code).toBe('receipt_not_found') }) test('returns 400 for an invalid transaction hash', async () => { // The typed client requires `transactionHash: `Hex.Hex`` so a literal // non-hash input cannot pass through it; exercise the validator via // `app.request` for this negative case. const response = await TestApp.create().request('/v1/transactions/not-a-hash/receipt', { headers: { authorization: `Bearer ${TestApp.key.token}` }, }) const body = await TestApp.json(response, Schema.ErrorResponse) expect(response.status).toBe(400) expect(body.error.code).toBe('transaction_invalid') }) }) describe('GET /transactions/receipts', () => { test('returns a humanized receipt page with fee-token references', async () => { const client = TestApp.client() const response = await client.v1.transactions.receipts.$get( { query: { limit: '5' } }, TestApp.auth, ) const body = await TestApp.json(response, Receipts.schema.getReceipts.Response) expect(response.status).toBe(200) expect(response.headers.get('server-timing')?.includes('receipts;dur=')).toBe(true) expect(Array.isArray(body.data)).toBe(true) // Cursor (keyset) pagination: no `page`, just an opaque `nextCursor`. expect('page' in body).toBe(false) expect(body.nextCursor === null || typeof body.nextCursor === 'string').toBe(true) expect(body.data.length).toBeLessThanOrEqual(5) if (body.nextCursor) { const cursorResponse = await client.v1.transactions.receipts.$get( { query: { cursor: body.nextCursor, limit: '5' } }, TestApp.auth, ) expect(cursorResponse.status).toBe(200) expect(cursorResponse.headers.get('cache-control')).toMatchInlineSnapshot( `"private, max-age=60, stale-while-revalidate=300"`, ) } if (body.data.length > 0) { const [receipt] = body.data // List rows reuse the detail endpoint's humanized shape: decoded // `status`/`type`, decimal gas values, and the verbatim payload under // `meta.rpc`. The indexer stores no event logs, so list rows carry none. expect(receipt!.transactionHash).toMatch(/^0x[0-9a-f]{64}$/) expect(receipt!.sender).toMatch(/^0x[0-9a-f]{40}$/) expect(['success', 'reverted']).toContain(receipt!.status) expect(transactionTypes).toContain(receipt!.type) expect(typeof receipt!.gasUsed).toBe('number') expect(receipt!.feeAmount.baseUnits).toMatch(/^\d+$/) expect(receipt!.feeAmount.currency).toBe('USD') expect(receipt!.feeAmount.decimals).toBe(6) expect(receipt!.feeAmount.formatted).toMatch(/^\d+(\.\d+)?$/) expect(receipt!.meta.rpc.status).toMatch(/^0x[0-9a-f]+$/) if (receipt!.meta.rpc.feeToken) { expect(receipt!.feeToken?.address).toBe(receipt!.meta.rpc.feeToken) expect(typeof receipt!.feeToken?.currency).toBe('string') expect(typeof receipt!.feeToken?.decimals).toBe('number') expect(typeof receipt!.feeToken?.name).toBe('string') expect(typeof receipt!.feeToken?.symbol).toBe('string') expect(receipt!.feeToken?.logoUri).toBeUndefined() expect(receipt!.feeToken?.verified).toBeUndefined() } else expect(receipt!.feeToken).toBeUndefined() expect(receipt!.meta).not.toHaveProperty('feeToken') } const includedResponse = await client.v1.transactions.receipts.$get( { query: { limit: '5', include: 'feeToken.logoUri,feeToken.verified' } }, TestApp.auth, ) const includedBody = await TestApp.json(includedResponse, Receipts.schema.getReceipts.Response) expect(includedResponse.status).toBe(200) const withFeeToken = includedBody.data.find((receipt) => receipt.feeToken) if (withFeeToken) { expect(withFeeToken.feeToken!.address).toBe(withFeeToken.meta.rpc.feeToken) expect(typeof withFeeToken.feeToken!.verified).toBe('boolean') if (withFeeToken.feeToken!.logoUri) expect(typeof withFeeToken.feeToken!.logoUri).toBe('string') } }) test('returns 400 for an invalid filter address', async () => { // The typed client requires `sender: `Hex.Hex`` so an invalid literal // cannot pass through it; exercise the validator via `app.request`. const response = await TestApp.create().request( '/v1/transactions/receipts?sender=not-an-address', { headers: { authorization: `Bearer ${TestApp.key.token}` }, }, ) const body = await TestApp.json(response, Schema.ErrorResponse) expect(response.status).toBe(400) expect(body.error.code).toBe('query_invalid') }) test('values the fee in a requested denomination', async () => { expect(transactionHash).toBeDefined() const client = TestApp.client({ fx: { oracle: fixed } }) // Denominations are case-insensitive. const response = await client.v1.transactions[':transactionHash'].receipt.$get( { param: { transactionHash: transactionHash! }, query: { 'valuation.currency': 'aud' } }, TestApp.auth, ) const body = await TestApp.json(response, Receipts.schema.getReceipt.Response) expect(response.status).toBe(200) expect(response.headers.get('cache-control')).toMatchInlineSnapshot( `"private, max-age=60, stale-while-revalidate=300"`, ) // USD -> AUD at the fixed 1.6 cross rate, floored to 6 dp. expect(body.feeAmount.valuation).toEqual({ amount: core_Value.format((BigInt(body.feeAmount.baseUnits) * 16n) / 10n, 6), currency: 'AUD', }) expect(body.meta.valuation).toMatchInlineSnapshot(` { "asOf": "2026-01-01T00:00:00.000Z", "basis": "nominal", "source": "fixed", } `) }) test('rejects the transactions-only include=receipt member', async () => { // `receipt` is a transactions-list embed; the receipts endpoints own their // own include enum and must reject it. The typed client would refuse the // literal, so exercise the validator via `app.request`. const response = await TestApp.create().request('/v1/transactions/receipts?include=receipt', { headers: { authorization: `Bearer ${TestApp.key.token}` }, }) const body = await TestApp.json(response, Schema.ErrorResponse) expect(response.status).toBe(400) expect(body.error.code).toBe('query_invalid') }) test('rejects the legacy include=feeToken member', async () => { const response = await TestApp.create().request('/v1/transactions/receipts?include=feeToken', { headers: { authorization: `Bearer ${TestApp.key.token}` }, }) const body = await TestApp.json(response, Schema.ErrorResponse) expect(response.status).toBe(400) expect(body.error.code).toBe('query_invalid') }) test('rejects generic token fields', async () => { const response = await TestApp.create().request( '/v1/transactions/receipts?include=token.logoUri', { headers: { authorization: `Bearer ${TestApp.key.token}` } }, ) const body = await TestApp.json(response, Schema.ErrorResponse) expect(response.status).toBe(400) expect(body.error.code).toBe('query_invalid') }) test('narrows receipts by execution status', async () => { const client = TestApp.client() const response = await client.v1.transactions.receipts.$get( { query: { limit: '10', status: 'success' } }, TestApp.auth, ) const body = await TestApp.json(response, Receipts.schema.getReceipts.Response) expect(response.status).toBe(200) for (const receipt of body.data) expect(receipt.status).toBe('success') }) test('narrows receipts by fee token', async () => { const client = TestApp.client() const response = await client.v1.transactions.receipts.$get( { query: { feeToken: TestApp.tokenWithHolders, include: 'totalCount', limit: '5', }, }, TestApp.auth, ) const body = await TestApp.json(response, Receipts.schema.getReceipts.Response) expect(response.status).toBe(200) expect(body.data.length).toBeGreaterThan(0) expect(body.meta).toBeDefined() expect(body.meta!.totalCount).toBeGreaterThanOrEqual(body.data.length) for (const receipt of body.data) expect(receipt.meta.rpc.feeToken).toBe(TestApp.tokenWithHolders) }) test('embeds a capped total count on demand via include=totalCount', async () => { const client = TestApp.client() // Seed a real account so the count exercises the address-scope `UNION` // workaround (`from` branch + `to`-via-`txs` branch). const seed = await client.v1.transactions.receipts.$get({ query: { limit: '5' } }, TestApp.auth) const seedBody = await TestApp.json(seed, Receipts.schema.getReceipts.Response) const account = seedBody.data[0]?.sender if (!account) return const response = await client.v1.transactions.receipts.$get( { query: { address: account, include: 'totalCount', limit: '5' } }, TestApp.auth, ) const body = await TestApp.json(response, Receipts.schema.getReceipts.Response) expect(response.status).toBe(200) expect(response.headers.get('server-timing')?.includes('receipts_count;dur=')).toBe(true) expect(body.meta).toBeDefined() expect(Number.isInteger(body.meta?.totalCount)).toBe(true) expect(body.meta!.totalCount).toBeGreaterThanOrEqual(body.data.length) expect(body.meta!.totalCount).toBeLessThanOrEqual(Schema.countCap) expect(body.meta!.totalCountCapped).toBe(body.meta!.totalCount! >= Schema.countCap) for (const receipt of body.data) expect('valuation' in receipt.feeAmount).toBe(false) expect(body.meta!.valuation).toBeUndefined() // Without `include=totalCount`, the same page omits `meta` and the count. const bare = await client.v1.transactions.receipts.$get( { query: { address: account, limit: '5' } }, TestApp.auth, ) const bareBody = await TestApp.json(bare, Receipts.schema.getReceipts.Response) expect(bare.status).toBe(200) expect(bareBody.meta).toBeUndefined() }) }) // Walk back from the chain head to find a recent mined transaction hash. This // keeps the fixture stable across runs without hard-coding a hash that the // testnet might prune. async function findRecentTransactionHash() { const client = Viem.getClient() const latest = await client.getBlockNumber() for (let n = latest; n > latest - 40n && n >= 0n; n--) { const block = await client.getBlock({ blockNumber: n, includeTransactions: true }) const hash = block.transactions[0]?.hash if (hash) return hash } return undefined } describe('GET /transactions/receipts?address=…', () => { test('returns receipts involving an address', async () => { const client = TestApp.client() // Seed a real sender from the global receipt feed. const seedResponse = await client.v1.transactions.receipts.$get( { query: { limit: '5' } }, TestApp.auth, ) const seed = await TestApp.json(seedResponse, Receipts.schema.getReceipts.Response) expect(seedResponse.status).toBe(200) const account = seed.data[0]!.sender expect(account).toMatch(/^0x[0-9a-f]{40}$/) const baseResponse = await client.v1.transactions.receipts.$get( { query: { address: account, limit: '5' } }, TestApp.auth, ) const base = await TestApp.json(baseResponse, Receipts.schema.getReceipts.Response) expect(baseResponse.status).toBe(200) expect(baseResponse.headers.get('server-timing')?.includes('receipts;dur=')).toBe(true) expect(Array.isArray(base.data)).toBe(true) expect(base.data.length).toBeLessThanOrEqual(5) expect('page' in base).toBe(false) expect(base.nextCursor === null || typeof base.nextCursor === 'string').toBe(true) // Each item is the humanized receipt shape with a decoded `status` and the // verbatim RPC payload under `meta.rpc`. expect( base.data.every( (entry) => /^0x[0-9a-f]{64}$/.test(entry.transactionHash) && /^0x[0-9a-f]{40}$/.test(entry.sender) && (entry.recipient === null || /^0x[0-9a-f]{40}$/.test(entry.recipient)) && ['success', 'reverted'].includes(entry.status) && typeof entry.meta.rpc === 'object', ), ).toBe(true) // Every row touches the subject address on one side or the other. expect( base.data.every((entry) => entry.sender === account || entry.recipient === account), ).toBe(true) }) test('narrows receipts by execution status', async () => { const client = TestApp.client() const seedResponse = await client.v1.transactions.receipts.$get( { query: { limit: '5' } }, TestApp.auth, ) const seed = await TestApp.json(seedResponse, Receipts.schema.getReceipts.Response) expect(seedResponse.status).toBe(200) const account = seed.data[0]!.sender expect(account).toMatch(/^0x[0-9a-f]{40}$/) const response = await client.v1.transactions.receipts.$get( { query: { address: account, limit: '5', status: 'success' } }, TestApp.auth, ) const body = await TestApp.json(response, Receipts.schema.getReceipts.Response) expect(response.status).toBe(200) for (const receipt of body.data) expect(receipt.status).toBe('success') }) })