/** @module-tag localnet */ import { Schema } from 'tapimo' import { Hex } 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 Transactions from './transactions.js' /** The decoded `type` names a humanized transaction 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', () => { test('parses granular fee-token includes', () => { expect( Transactions.schema.getTransaction.Query.parse({ include: 'feeToken.logoUri,feeToken.verified,receipt', }).include, ).toMatchInlineSnapshot(` [ "feeToken.logoUri", "feeToken.verified", "receipt", ] `) expect( Transactions.schema.getTransaction.Query.safeParse({ include: 'feeToken' }).success, ).toBe(false) expect( Transactions.schema.getTransaction.Query.safeParse({ include: 'token.logoUri' }).success, ).toBe(false) }) test('humanizes the transaction and resolves its fee token', async () => { if (!transactionHash) { console.warn('[live-rpc] no recent transaction found; skipping transaction happy path') return } const client = TestApp.client() const response = await client.v1.transactions[':transactionHash'].$get( { param: { transactionHash: transactionHash }, query: {} }, TestApp.auth, ) const body = await TestApp.json(response, Transactions.schema.getTransaction.Response) expect(response.status).toBe(200) expect(response.headers.get('server-timing')?.includes('transaction;dur=')).toBe(true) expect(body.hash).toBe(transactionHash.toLowerCase()) // The response is humanized: decoded `type`, decimal-string amounts, plain // numbers for block/nonce, and ISO-8601 timestamps. expect(body.sender).toMatch(/^0x[0-9a-f]{40}$/) expect(transactionTypes).toContain(body.type) expect(typeof body.gas).toBe('number') expect(typeof body.nonce).toBe('number') // The verbatim JSON-RPC transaction is always available under `meta.rpc`, // where quantities stay hex-encoded and `type` is the raw `0x…` byte. expect(body.meta.rpc.hash).toBe(transactionHash.toLowerCase()) expect(body.meta.rpc.type).toMatch(/^0x[0-9a-f]+$/) expect(body.meta.rpc.gas).toMatch(/^0x[0-9a-f]+$/) // Receipt-derived fields (execution status, gas used) are not on the // transaction; they live on `/transactions/:transactionHash/receipt` instead. expect('status' in body).toBe(false) expect('gasUsed' in body).toBe(false) if (body.feeToken) { expect(body.feeToken.address).toBe(body.meta.rpc.feeToken) expect(typeof body.feeToken.currency).toBe('string') expect(Number.isInteger(body.feeToken.decimals)).toBe(true) expect(typeof body.feeToken.name).toBe('string') expect(typeof body.feeToken.symbol).toBe('string') expect(body.feeToken.logoUri).toBeUndefined() expect(body.feeToken.verified).toBeUndefined() expect(body.feeToken).not.toHaveProperty('amount') expect(body.feeToken).not.toHaveProperty('id') } // Curated fields are independently opt-in on the fee-token reference. const includedResponse = await client.v1.transactions[':transactionHash'].$get( { param: { transactionHash: transactionHash }, query: { include: 'feeToken.logoUri,feeToken.verified' }, }, TestApp.auth, ) const includedBody = await TestApp.json( includedResponse, Transactions.schema.getTransaction.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') } }) test('embeds the receipt on demand via include=receipt', async () => { const client = TestApp.client() // Seed a recent mined hash from the list page so the fixture stays stable // across runs. const seedResponse = await client.v1.transactions.$get({ query: { limit: '5' } }, TestApp.auth) const seed = await TestApp.json(seedResponse, Transactions.schema.getTransactions.Response) expect(seedResponse.status).toBe(200) const hash = seed.data[0]?.hash expect(hash).toBeDefined() const response = await client.v1.transactions[':transactionHash'].$get( { param: { transactionHash: hash! }, query: { include: 'receipt' } }, TestApp.auth, ) const body = await TestApp.json(response, Transactions.schema.getTransaction.Response) expect(response.status).toBe(200) expect(response.headers.get('server-timing')?.includes('receipt;dur=')).toBe(true) // The verbatim RPC receipt is humanized and embedded under `meta.receipt`, // describing the transaction itself; its raw event logs come straight from // the RPC response. expect({ logsIsArray: Array.isArray(body.meta.receipt?.logs), matchesHash: body.meta.receipt?.transactionHash === body.hash, statusDecoded: ['success', 'reverted'].includes(body.meta.receipt?.status ?? ''), }).toMatchInlineSnapshot(` { "logsIsArray": true, "matchesHash": true, "statusDecoded": true, } `) }) test('returns 404 for an unknown transaction', async () => { const client = TestApp.client() const response = await client.v1.transactions[':transactionHash'].$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('transaction_not_found') }) test('returns 400 for an invalid transaction hash', async () => { // The typed client requires `transaction: `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', { 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', () => { test('parses granular fee-token and list includes', () => { expect( Transactions.schema.getTransactions.Query.parse({ include: 'feeToken.logoUri,feeToken.verified,receipt,totalCount', }).include, ).toMatchInlineSnapshot(` [ "feeToken.logoUri", "feeToken.verified", "receipt", "totalCount", ] `) expect( Transactions.schema.getTransactions.Query.safeParse({ include: 'feeToken' }).success, ).toBe(false) expect( Transactions.schema.getTransactions.Query.safeParse({ include: 'token.verified' }).success, ).toBe(false) }) test('returns a humanized transaction page with fee-token references', async () => { const client = TestApp.client() const response = await client.v1.transactions.$get({ query: { limit: '5' } }, TestApp.auth) const body = await TestApp.json(response, Transactions.schema.getTransactions.Response) expect(response.status).toBe(200) expect(response.headers.get('server-timing')?.includes('transactions;dur=')).toBe(true) expect(Array.isArray(body.data)).toBe(true) // Cursor (keyset) pagination: the head page has no `page`, but carries an // opaque `nextCursor` (a string when more rows follow, else null). expect('page' in body).toBe(false) expect(body.nextCursor === null || typeof body.nextCursor === 'string').toBe(true) expect(body.data.length).toBeLessThanOrEqual(5) // Walking the cursor must yield a disjoint next page (no offset-style overlap). if (body.nextCursor) { const next = await client.v1.transactions.$get( { query: { limit: '5', cursor: body.nextCursor } }, TestApp.auth, ) const nextBody = await TestApp.json(next, Transactions.schema.getTransactions.Response) expect(next.status).toBe(200) const firstHashes = new Set(body.data.map((t) => t.hash)) for (const t of nextBody.data) expect(firstHashes.has(t.hash)).toBe(false) } if (body.data.length > 0) { const [transaction] = body.data // List rows reuse the detail endpoint's humanized shape: decoded `type`, // decimal amounts, plain-number block/nonce, and the verbatim payload // under `meta.rpc`. expect(transaction!.hash).toMatch(/^0x[0-9a-f]{64}$/) expect(transaction!.sender).toMatch(/^0x[0-9a-f]{40}$/) expect(transactionTypes).toContain(transaction!.type) expect(typeof transaction!.gas).toBe('number') expect(typeof transaction!.nonce).toBe('number') expect(transaction!.meta.rpc.type).toMatch(/^0x[0-9a-f]+$/) // Receipt-derived fields are not on the list payload, matching the detail // endpoint; fee-token core metadata is included by default. expect('status' in transaction!).toBe(false) if (transaction!.feeToken) { expect(transaction!.feeToken.address).toBe(transaction!.meta.rpc.feeToken) expect(typeof transaction!.feeToken.symbol).toBe('string') expect(transaction!.feeToken.logoUri).toBeUndefined() expect(transaction!.feeToken.verified).toBeUndefined() } } const includedResponse = await client.v1.transactions.$get( { query: { limit: '5', include: 'feeToken.logoUri,feeToken.verified' } }, TestApp.auth, ) const includedBody = await TestApp.json( includedResponse, Transactions.schema.getTransactions.Response, ) expect(includedResponse.status).toBe(200) const withFeeToken = includedBody.data.find((transaction) => transaction.feeToken) if (withFeeToken) { expect(withFeeToken.feeToken!.address).toBe(withFeeToken.meta.rpc.feeToken) expect(typeof withFeeToken.feeToken!.verified).toBe('boolean') } }) test('embeds receipts on demand via include=receipt', async () => { const client = TestApp.client() const response = await client.v1.transactions.$get( { query: { include: 'receipt', limit: '5' } }, TestApp.auth, ) const body = await TestApp.json(response, Transactions.schema.getTransactions.Response) expect(response.status).toBe(200) expect(body.data.length).toBeGreaterThan(0) // Every mined row carries its own receipt under `meta.receipt`, matched by // hash, with a decoded status and the page's event logs reattached (the // head of the feed reliably contains at least one log-emitting transfer). expect({ everyReceiptMatchesHash: body.data.every( (transaction) => transaction.meta.receipt?.transactionHash === transaction.hash, ), everyStatusDecoded: body.data.every((transaction) => ['success', 'reverted'].includes(transaction.meta.receipt?.status ?? ''), ), someReceiptHasLogs: body.data.some( (transaction) => (transaction.meta.receipt?.logs.length ?? 0) > 0, ), }).toMatchInlineSnapshot(` { "everyReceiptMatchesHash": true, "everyStatusDecoded": true, "someReceiptHasLogs": true, } `) }) test('filters by execution status and paginates the receipts-driven path', async () => { const client = TestApp.client() const response = await client.v1.transactions.$get( { query: { include: 'receipt', limit: '5', status: 'success' } }, TestApp.auth, ) const body = await TestApp.json(response, Transactions.schema.getTransactions.Response) expect(response.status).toBe(200) expect(body.data.length).toBeGreaterThan(0) for (const transaction of body.data) expect(transaction.meta.receipt?.status).toBe('success') // The receipts `(block_num, tx_idx)` cursor pages the status path: page 2 // parses against the same schema and stays disjoint from page 1. expect(typeof body.nextCursor).toBe('string') const next = await client.v1.transactions.$get( { query: { cursor: body.nextCursor!, include: 'receipt', limit: '5', status: 'success' } }, TestApp.auth, ) const nextBody = await TestApp.json(next, Transactions.schema.getTransactions.Response) expect(next.status).toBe(200) const firstHashes = new Set(body.data.map((transaction) => transaction.hash)) expect({ page2Disjoint: nextBody.data.every((transaction) => !firstHashes.has(transaction.hash)), page2EveryReceiptSuccessful: nextBody.data.every( (transaction) => transaction.meta.receipt?.status === 'success', ), }).toMatchInlineSnapshot(` { "page2Disjoint": true, "page2EveryReceiptSuccessful": true, } `) }) test('filters and counts by fee payer through receipts with pagination', async () => { const client = TestApp.client() const seedResponse = await client.v1.transactions.$get({ query: { limit: '50' } }, TestApp.auth) const seed = await TestApp.json(seedResponse, Transactions.schema.getTransactions.Response) const feePayers = seed.data.flatMap((transaction) => { const feePayer = transaction.meta.rpc.feePayer return typeof feePayer === 'string' ? [feePayer] : [] }) const feePayer = feePayers.find( (candidate) => feePayers.filter((value) => value === candidate).length > 5, ) if (!feePayer) return const first = await client.v1.transactions.$get( { query: { feePayer, include: 'totalCount', limit: '5' } }, TestApp.auth, ) const firstBody = await TestApp.json(first, Transactions.schema.getTransactions.Response) expect(first.status).toBe(200) expect(first.headers.get('server-timing')).toContain('receipts;dur=') expect(first.headers.get('server-timing')).toContain('receipts_count;dur=') expect(firstBody.data).toHaveLength(5) expect(firstBody.meta?.totalCount).toBeGreaterThanOrEqual(firstBody.data.length) expect(firstBody.data.every((transaction) => transaction.meta.rpc.feePayer === feePayer)).toBe( true, ) if (!firstBody.nextCursor) return const second = await client.v1.transactions.$get( { query: { cursor: firstBody.nextCursor, feePayer, limit: '5' } }, TestApp.auth, ) const secondBody = await TestApp.json(second, Transactions.schema.getTransactions.Response) expect(second.status).toBe(200) expect(secondBody.data.length).toBeGreaterThan(0) expect(secondBody.data.every((transaction) => transaction.meta.rpc.feePayer === feePayer)).toBe( true, ) const firstHashes = new Set(firstBody.data.map((transaction) => transaction.hash)) expect(secondBody.data.every((transaction) => !firstHashes.has(transaction.hash))).toBe(true) }) test('filters reverted transactions by execution status', async () => { const client = TestApp.client() const response = await client.v1.transactions.$get( { query: { include: 'receipt', limit: '5', status: 'reverted' } }, TestApp.auth, ) const body = await TestApp.json(response, Transactions.schema.getTransactions.Response) expect(response.status).toBe(200) // The head of the feed may contain no reverted transactions; every returned // row must be reverted (vacuously true on an empty page). expect( body.data.every( (transaction) => transaction.meta.receipt?.status === 'reverted' && transaction.meta.receipt.transactionHash === transaction.hash, ), ).toBe(true) }) 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?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('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` path. const seed = await client.v1.transactions.$get({ query: { limit: '5' } }, TestApp.auth) const seedBody = await TestApp.json(seed, Transactions.schema.getTransactions.Response) const account = seedBody.data[0]?.sender if (!account) return const response = await client.v1.transactions.$get( { query: { address: account, include: 'totalCount', limit: '5' } }, TestApp.auth, ) const body = await TestApp.json(response, Transactions.schema.getTransactions.Response) expect(response.status).toBe(200) expect(response.headers.get('server-timing')?.includes('transactions_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) // Without `include=totalCount`, the same page omits `meta` and the count. const bare = await client.v1.transactions.$get( { query: { address: account, limit: '5' } }, TestApp.auth, ) const bareBody = await TestApp.json(bare, Transactions.schema.getTransactions.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. The Tempo RPC node serves the test chain // (`42431` → `rpc.testnet.tempo.xyz`). 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?address=…', () => { test('returns transactions involving an address', async () => { const client = TestApp.client() // Seed a real sender from the global transaction feed. const seedResponse = await client.v1.transactions.$get({ query: { limit: '5' } }, TestApp.auth) const seed = await TestApp.json(seedResponse, Transactions.schema.getTransactions.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.$get( { query: { address: account, limit: '5' } }, TestApp.auth, ) const base = await TestApp.json(baseResponse, Transactions.schema.getTransactions.Response) expect(baseResponse.status).toBe(200) expect(baseResponse.headers.get('server-timing')?.includes('transactions;dur=')).toBe(true) expect(Array.isArray(base.data)).toBe(true) expect(base.data.length).toBeLessThanOrEqual(5) // Pagination fields stay at the response root; the feed is cursor (keyset) // paginated, so there's no `page` but an opaque `nextCursor`. expect('page' in base).toBe(false) expect(base.nextCursor === null || typeof base.nextCursor === 'string').toBe(true) // Each item is the humanized transaction shape, with the verbatim RPC payload // under `meta.rpc`. expect( base.data.every( (entry) => /^0x[0-9a-f]{64}$/.test(entry.hash) && /^0x[0-9a-f]{40}$/.test(entry.sender) && (entry.recipient === null || /^0x[0-9a-f]{40}$/.test(entry.recipient)) && 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('serves a bounded positional page for an address scope via `page`', async () => { const client = TestApp.client() // Seed a real sender from the global feed. Ascending order anchors the // scoped feed at the address's first transaction, so positional pages are // deterministic against the head page. This exercises the offset lane of // the per-side `UNION` query (branches over-fetch the full window, the // outer merge applies the skip). const seedResponse = await client.v1.transactions.$get({ query: { limit: '5' } }, TestApp.auth) const seed = await TestApp.json(seedResponse, Transactions.schema.getTransactions.Response) expect(seedResponse.status).toBe(200) const account = seed.data[0]!.sender const headResponse = await client.v1.transactions.$get( { query: { address: account, limit: '10', order: 'asc' } }, TestApp.auth, ) const head = await TestApp.json(headResponse, Transactions.schema.getTransactions.Response) expect(headResponse.status).toBe(200) if (head.data.length < 10) return const offsetResponse = await client.v1.transactions.$get( { query: { address: account, limit: '5', order: 'asc', page: '2' } }, TestApp.auth, ) const page = await TestApp.json(offsetResponse, Transactions.schema.getTransactions.Response) expect(offsetResponse.status).toBe(200) expect(page.data.map((entry) => entry.hash)).toEqual( head.data.slice(5, 10).map((entry) => entry.hash), ) }) })