/** @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 Blocks from './blocks.js' import * as Transactions from './transactions.js' describe('OpenAPI', () => { test('publishes generator-ready block, transaction, and receipt contracts', async () => { const app = TestApp.create({ auth: false }) const spec = await (await app.request('/openapi.json')).json() const operations = { block: spec.paths['/v1/blocks/{block}'].get, blocks: spec.paths['/v1/blocks'].get, receipt: spec.paths['/v1/transactions/{transactionHash}/receipt'].get, receipts: spec.paths['/v1/transactions/receipts'].get, transaction: spec.paths['/v1/transactions/{transactionHash}'].get, transactions: spec.paths['/v1/transactions'].get, } const summarizeError = ( response: (typeof operations)[keyof typeof operations]['responses'][number], ) => { const schema = response.$ref ?? response.content?.['application/json']?.schema if (!schema || typeof schema === 'string' || !('$ref' in schema)) return schema const name = schema.$ref.split('/').at(-1)! return { codes: spec.components.schemas[name].properties.error.properties.code.enum, schema, } } const summarize = (operation: (typeof operations)[keyof typeof operations]) => ({ errors: Object.fromEntries( [400, 401, 403, 404, 429, 500, 502] .filter((status) => operation.responses[status]) .map((status) => [status, summarizeError(operation.responses[status])]), ), operationId: operation.operationId, response: operation.responses[200].content['application/json'].schema, }) expect({ components: [ 'Block', 'BlockList', 'BlockMeta', 'BlockSummary', 'RpcBlock', 'RpcTransaction', 'RpcTransactionAccessListItem', 'RpcTransactionCall', 'RpcTransactionLog', 'RpcTransactionReceipt', 'RpcTransactionSignatureEnvelope', 'Transaction', 'TransactionCall', 'TransactionList', 'TransactionListMeta', 'TransactionMeta', 'TransactionReceipt', 'TransactionReceiptFeeAmount', 'TransactionReceiptList', 'TransactionReceiptListMeta', 'TransactionReceiptMeta', 'TransactionType', ].filter((name) => spec.components.schemas[name]), descriptions: { signature: spec.components.schemas.RpcTransactionSignatureEnvelope.description, }, formats: { block: spec.components.schemas.Block.properties.timestamp.format, blockSummary: spec.components.schemas.BlockSummary.properties.timestamp.format, receipt: spec.components.schemas.TransactionReceipt.properties.timestamp.anyOf[0].format, transaction: spec.components.schemas.Transaction.properties.timestamp.anyOf[0].format, validAfter: spec.components.schemas.Transaction.properties.validAfter.anyOf[0].format, validBefore: spec.components.schemas.Transaction.properties.validBefore.anyOf[0].format, }, hiddenCompatibilityRoute: spec.paths['/chains/{chainId}/blocks/by-timestamp'], models: { blockList: spec.components.schemas.BlockList.properties.data.items, blockMeta: spec.components.schemas.Block.properties.meta, blockRpc: spec.components.schemas.BlockMeta.properties.rpc, receiptFeeAmount: spec.components.schemas.TransactionReceipt.properties.feeAmount, receiptList: spec.components.schemas.TransactionReceiptList.properties.data.items, receiptListMeta: spec.components.schemas.TransactionReceiptList.properties.meta, receiptLogs: spec.components.schemas.TransactionReceipt.properties.logs.items, receiptMeta: spec.components.schemas.TransactionReceipt.properties.meta, receiptRpc: spec.components.schemas.TransactionReceiptMeta.properties.rpc, receiptType: spec.components.schemas.TransactionReceipt.properties.type, rpcReceiptLogs: spec.components.schemas.RpcTransactionReceipt.properties.logs.items, rpcTransactionAccessList: spec.components.schemas.RpcTransaction.properties.accessList.items, rpcTransactionCalls: spec.components.schemas.RpcTransaction.properties.calls.anyOf[0].items, rpcTransactionFeePayerSignature: spec.components.schemas.RpcTransaction.properties.feePayerSignature.anyOf[0], rpcTransactionSignature: spec.components.schemas.RpcTransaction.properties.signature, transactionCalls: spec.components.schemas.Transaction.properties.calls.items, transactionList: spec.components.schemas.TransactionList.properties.data.items, transactionListMeta: spec.components.schemas.TransactionList.properties.meta, transactionMeta: spec.components.schemas.Transaction.properties.meta, transactionReceipt: spec.components.schemas.TransactionMeta.properties.receipt, transactionRpc: spec.components.schemas.TransactionMeta.properties.rpc, transactionType: spec.components.schemas.Transaction.properties.type, }, operations: Object.fromEntries( Object.entries(operations).map(([name, operation]) => [name, summarize(operation)]), ), }).toMatchInlineSnapshot(` { "components": [ "Block", "BlockList", "BlockMeta", "BlockSummary", "RpcBlock", "RpcTransaction", "RpcTransactionAccessListItem", "RpcTransactionCall", "RpcTransactionLog", "RpcTransactionReceipt", "RpcTransactionSignatureEnvelope", "Transaction", "TransactionCall", "TransactionList", "TransactionListMeta", "TransactionMeta", "TransactionReceipt", "TransactionReceiptFeeAmount", "TransactionReceiptList", "TransactionReceiptListMeta", "TransactionReceiptMeta", "TransactionType", ], "descriptions": { "signature": "A signature envelope exactly as returned by JSON-RPC.", }, "formats": { "block": "date-time", "blockSummary": "date-time", "receipt": "date-time", "transaction": "date-time", "validAfter": "date-time", "validBefore": "date-time", }, "hiddenCompatibilityRoute": undefined, "models": { "blockList": { "$ref": "#/components/schemas/BlockSummary", }, "blockMeta": { "$ref": "#/components/schemas/BlockMeta", }, "blockRpc": { "$ref": "#/components/schemas/RpcBlock", }, "receiptFeeAmount": { "$ref": "#/components/schemas/TransactionReceiptFeeAmount", }, "receiptList": { "$ref": "#/components/schemas/TransactionReceipt", }, "receiptListMeta": { "$ref": "#/components/schemas/TransactionReceiptListMeta", }, "receiptLogs": { "$ref": "#/components/schemas/RpcTransactionLog", }, "receiptMeta": { "$ref": "#/components/schemas/TransactionReceiptMeta", }, "receiptRpc": { "$ref": "#/components/schemas/RpcTransactionReceipt", }, "receiptType": { "$ref": "#/components/schemas/TransactionType", }, "rpcReceiptLogs": { "$ref": "#/components/schemas/RpcTransactionLog", }, "rpcTransactionAccessList": { "$ref": "#/components/schemas/RpcTransactionAccessListItem", }, "rpcTransactionCalls": { "$ref": "#/components/schemas/RpcTransactionCall", }, "rpcTransactionFeePayerSignature": { "$ref": "#/components/schemas/RpcTransactionSignatureEnvelope", }, "rpcTransactionSignature": { "$ref": "#/components/schemas/RpcTransactionSignatureEnvelope", }, "transactionCalls": { "$ref": "#/components/schemas/TransactionCall", }, "transactionList": { "$ref": "#/components/schemas/Transaction", }, "transactionListMeta": { "$ref": "#/components/schemas/TransactionListMeta", }, "transactionMeta": { "$ref": "#/components/schemas/TransactionMeta", }, "transactionReceipt": { "$ref": "#/components/schemas/TransactionReceipt", }, "transactionRpc": { "$ref": "#/components/schemas/RpcTransaction", }, "transactionType": { "$ref": "#/components/schemas/TransactionType", }, }, "operations": { "block": { "errors": { "400": { "codes": [ "api_key_malformed", "block_invalid", "chain_id_invalid", "chain_id_unsupported", "query_invalid", ], "schema": { "$ref": "#/components/schemas/ApiKeyMalformedOrBlockInvalidOrChainIdInvalidOrChainIdUnsupportedOrQueryInvalidError", }, }, "401": { "codes": [ "api_key_invalid", "api_key_missing", "unauthorized", ], "schema": { "$ref": "#/components/schemas/AuthenticationError", }, }, "403": { "codes": [ "api_key_forbidden", "api_key_ip_forbidden", "forbidden", ], "schema": { "$ref": "#/components/schemas/ForbiddenError", }, }, "404": { "codes": [ "block_not_found", ], "schema": { "$ref": "#/components/schemas/BlockNotFoundError", }, }, "429": "#/components/responses/RateLimited", "500": "#/components/responses/InternalError", "502": { "codes": [ "upstream_error", ], "schema": { "$ref": "#/components/schemas/UpstreamError", }, }, }, "operationId": "getBlock", "response": { "$ref": "#/components/schemas/Block", }, }, "blocks": { "errors": { "400": { "codes": [ "api_key_malformed", "chain_id_invalid", "chain_id_unsupported", "query_invalid", ], "schema": { "$ref": "#/components/schemas/ApiKeyMalformedOrChainIdInvalidOrChainIdUnsupportedOrQueryInvalidError", }, }, "401": { "codes": [ "api_key_invalid", "api_key_missing", "unauthorized", ], "schema": { "$ref": "#/components/schemas/AuthenticationError", }, }, "403": { "codes": [ "api_key_forbidden", "api_key_ip_forbidden", "forbidden", ], "schema": { "$ref": "#/components/schemas/ForbiddenError", }, }, "429": "#/components/responses/RateLimited", "500": "#/components/responses/InternalError", "502": { "codes": [ "upstream_error", ], "schema": { "$ref": "#/components/schemas/UpstreamError", }, }, }, "operationId": "getBlocks", "response": { "$ref": "#/components/schemas/BlockList", }, }, "receipt": { "errors": { "400": { "codes": [ "api_key_malformed", "chain_id_invalid", "chain_id_unsupported", "query_invalid", "transaction_invalid", ], "schema": { "$ref": "#/components/schemas/ApiKeyMalformedOrChainIdInvalidOrChainIdUnsupportedOrQueryInvalidOrTransactionInvalidError", }, }, "401": { "codes": [ "api_key_invalid", "api_key_missing", "unauthorized", ], "schema": { "$ref": "#/components/schemas/AuthenticationError", }, }, "403": { "codes": [ "api_key_forbidden", "api_key_ip_forbidden", "forbidden", ], "schema": { "$ref": "#/components/schemas/ForbiddenError", }, }, "404": { "codes": [ "receipt_not_found", ], "schema": { "$ref": "#/components/schemas/ReceiptNotFoundError", }, }, "429": "#/components/responses/RateLimited", "500": "#/components/responses/InternalError", "502": { "codes": [ "upstream_error", ], "schema": { "$ref": "#/components/schemas/UpstreamError", }, }, }, "operationId": "getReceipt", "response": { "$ref": "#/components/schemas/TransactionReceipt", }, }, "receipts": { "errors": { "400": { "codes": [ "api_key_malformed", "chain_id_invalid", "chain_id_unsupported", "query_invalid", ], "schema": { "$ref": "#/components/schemas/ApiKeyMalformedOrChainIdInvalidOrChainIdUnsupportedOrQueryInvalidError", }, }, "401": { "codes": [ "api_key_invalid", "api_key_missing", "unauthorized", ], "schema": { "$ref": "#/components/schemas/AuthenticationError", }, }, "403": { "codes": [ "api_key_forbidden", "api_key_ip_forbidden", "forbidden", ], "schema": { "$ref": "#/components/schemas/ForbiddenError", }, }, "429": "#/components/responses/RateLimited", "500": "#/components/responses/InternalError", "502": { "codes": [ "upstream_error", ], "schema": { "$ref": "#/components/schemas/UpstreamError", }, }, }, "operationId": "getReceipts", "response": { "$ref": "#/components/schemas/TransactionReceiptList", }, }, "transaction": { "errors": { "400": { "codes": [ "api_key_malformed", "chain_id_invalid", "chain_id_unsupported", "query_invalid", "transaction_invalid", ], "schema": { "$ref": "#/components/schemas/ApiKeyMalformedOrChainIdInvalidOrChainIdUnsupportedOrQueryInvalidOrTransactionInvalidError", }, }, "401": { "codes": [ "api_key_invalid", "api_key_missing", "unauthorized", ], "schema": { "$ref": "#/components/schemas/AuthenticationError", }, }, "403": { "codes": [ "api_key_forbidden", "api_key_ip_forbidden", "forbidden", ], "schema": { "$ref": "#/components/schemas/ForbiddenError", }, }, "404": { "codes": [ "transaction_not_found", ], "schema": { "$ref": "#/components/schemas/TransactionNotFoundError", }, }, "429": "#/components/responses/RateLimited", "500": "#/components/responses/InternalError", "502": { "codes": [ "upstream_error", ], "schema": { "$ref": "#/components/schemas/UpstreamError", }, }, }, "operationId": "getTransaction", "response": { "$ref": "#/components/schemas/Transaction", }, }, "transactions": { "errors": { "400": { "codes": [ "api_key_malformed", "chain_id_invalid", "chain_id_unsupported", "query_invalid", ], "schema": { "$ref": "#/components/schemas/ApiKeyMalformedOrChainIdInvalidOrChainIdUnsupportedOrQueryInvalidError", }, }, "401": { "codes": [ "api_key_invalid", "api_key_missing", "unauthorized", ], "schema": { "$ref": "#/components/schemas/AuthenticationError", }, }, "403": { "codes": [ "api_key_forbidden", "api_key_ip_forbidden", "forbidden", ], "schema": { "$ref": "#/components/schemas/ForbiddenError", }, }, "429": "#/components/responses/RateLimited", "500": "#/components/responses/InternalError", "502": { "codes": [ "upstream_error", ], "schema": { "$ref": "#/components/schemas/UpstreamError", }, }, }, "operationId": "getTransactions", "response": { "$ref": "#/components/schemas/TransactionList", }, }, }, } `) }) }) let recent: { number: bigint; hash: Hex.Hex } | undefined let recentNonEmpty: { number: bigint; hash: Hex.Hex } | undefined beforeAll(async () => { const runtime = Runtime.get() if (runtime.mode === 'localnet') { const blockNumber = runtime.fixtures!.faucetBlockNumbers[0] if (blockNumber !== undefined) { recent = await blockByNumber(BigInt(blockNumber)).catch(() => undefined) recentNonEmpty = recent } return } recent = await findRecentBlock().catch(() => undefined) recentNonEmpty = await findRecentNonEmptyBlock().catch(() => undefined) }) describe('GET /blocks/:block (latest tag)', () => { test('humanizes the head block and keeps the raw RPC under `meta.rpc`', async () => { const client = TestApp.client() const response = await client.v1.blocks[':block'].$get( { param: { block: 'latest' }, query: {} }, TestApp.auth, ) const body = await TestApp.json(response, Blocks.schema.getBlock.Response) expect(response.status).toBe(200) expect(response.headers.get('server-timing')?.includes('block;dur=')).toBe(true) // The response is humanized: decimal-string amounts, plain numbers for // block fields, and ISO-8601 timestamps. expect(typeof body.number).toBe('number') expect(body.number).toBeGreaterThan(0) expect(body.hash).toMatch(/^0x[0-9a-f]{64}$/) expect(body.parentHash).toMatch(/^0x[0-9a-f]{64}$/) expect(body.miner).toMatch(/^0x[0-9a-f]{40}$/) expect(typeof body.gasLimit).toBe('number') expect(typeof body.gasUsed).toBe('number') expect(typeof body.size).toBe('number') expect(typeof body.timestamp).toBe('string') expect(new Date(body.timestamp).getTime()).toBeGreaterThan(0) expect(typeof body.transactionCount).toBe('number') // The verbatim JSON-RPC block is always available under `meta.rpc`, // where quantities stay hex-encoded. expect(body.meta.rpc.number).toMatch(/^0x[0-9a-f]+$/) expect(body.meta.rpc.gasUsed).toMatch(/^0x[0-9a-f]+$/) expect(body.meta.rpc.timestamp).toMatch(/^0x[0-9a-f]+$/) }) }) describe('GET /blocks/:block', () => { test('resolves a decimal block number', async () => { expect(recent).toBeDefined() const client = TestApp.client() const response = await client.v1.blocks[':block'].$get( { param: { block: recent!.number.toString() }, query: {} }, TestApp.auth, ) const body = await TestApp.json(response, Blocks.schema.getBlock.Response) expect(response.status).toBe(200) expect(body.number).toBe(Number(recent!.number)) expect(body.hash).toBe(recent!.hash) }) test('resolves a 0x-prefixed hex block number', async () => { expect(recent).toBeDefined() const hex = `0x${recent!.number.toString(16)}` const client = TestApp.client() const response = await client.v1.blocks[':block'].$get( { param: { block: hex }, query: {} }, TestApp.auth, ) const body = await TestApp.json(response, Blocks.schema.getBlock.Response) expect(response.status).toBe(200) expect(body.number).toBe(Number(recent!.number)) expect(body.hash).toBe(recent!.hash) }) test('resolves a 32-byte block hash', async () => { expect(recent).toBeDefined() const client = TestApp.client() const response = await client.v1.blocks[':block'].$get( { param: { block: recent!.hash }, query: {} }, TestApp.auth, ) const body = await TestApp.json(response, Blocks.schema.getBlock.Response) expect(response.status).toBe(200) expect(body.number).toBe(Number(recent!.number)) expect(body.hash).toBe(recent!.hash) }) test('returns 404 for an unknown block number', async () => { // 2^60 is far beyond any plausible head; the node returns null. const client = TestApp.client() const response = await client.v1.blocks[':block'].$get( { param: { block: '1152921504606846976' }, query: {} }, TestApp.auth, ) const body = await TestApp.json(response, Schema.ErrorResponse) expect(response.status).toBe(404) expect(body.error.code).toBe('block_not_found') }) test('returns 404 for an unknown block hash', async () => { const client = TestApp.client() const response = await client.v1.blocks[':block'].$get( { param: { block: zeroHash }, query: {} }, TestApp.auth, ) const body = await TestApp.json(response, Schema.ErrorResponse) expect(response.status).toBe(404) expect(body.error.code).toBe('block_not_found') }) test('returns 400 for an invalid block identifier', async () => { const client = TestApp.client() const response = await client.v1.blocks[':block'].$get( { param: { block: 'not-a-block' }, query: {} }, TestApp.auth, ) const body = await TestApp.json(response, Schema.ErrorResponse) expect(response.status).toBe(400) expect(body.error.code).toBe('block_invalid') }) }) describe('GET /blocks', () => { test('returns a paginated, descending list of lightweight block rows', async () => { const client = TestApp.client() const response = await client.v1.blocks.$get({ query: { limit: '5' } }, TestApp.auth) const body = await TestApp.json(response, Blocks.schema.getBlocks.Response) expect(response.status).toBe(200) expect(Array.isArray(body.data)).toBe(true) expect(body.data.length).toBeGreaterThan(0) expect(body.data.length).toBeLessThanOrEqual(5) for (const row of body.data) { expect(typeof row.number).toBe('number') expect(typeof row.timestamp).toBe('string') expect(typeof row.transactionCount).toBe('number') // Empty blocks are included in the list and correctly report `0`. expect(row.transactionCount).toBeGreaterThanOrEqual(0) } // Default ordering is descending by block number; rows are consecutive // (no gaps, since every block — empty or not — appears). for (let i = 1; i < body.data.length; i++) { expect(body.data[i - 1]!.number).toBeGreaterThan(body.data[i]!.number) expect(body.data[i - 1]!.number - body.data[i]!.number).toBe(1) } }) test('only sets a Cache-Control header for cursor pages', async () => { const client = TestApp.client() // Head page: a point-in-time read of the live tip, never cached and with // no Cache-Control header at all. const headResponse = await client.v1.blocks.$get({ query: { limit: '5' } }, TestApp.auth) const head = await TestApp.json(headResponse, Blocks.schema.getBlocks.Response) expect(headResponse.status).toBe(200) expect(headResponse.headers.get('Cache-Control')).toBeNull() // Cursor page: anchored below the head and effectively immutable. const cursorResponse = await client.v1.blocks.$get( { query: { limit: '5', cursor: head.nextCursor! } }, TestApp.auth, ) expect(cursorResponse.status).toBe(200) expect(cursorResponse.headers.get('Cache-Control')).toContain('max-age=86400') }) test('round-trips an opaque cursor across pages', async () => { const client = TestApp.client() const firstResponse = await client.v1.blocks.$get({ query: { limit: '5' } }, TestApp.auth) const first = await TestApp.json(firstResponse, Blocks.schema.getBlocks.Response) expect(typeof first.nextCursor).toBe('string') const secondResponse = await client.v1.blocks.$get( { query: { limit: '5', cursor: first.nextCursor! } }, TestApp.auth, ) const second = await TestApp.json(secondResponse, Blocks.schema.getBlocks.Response) expect(second.data.length).toBeGreaterThan(0) // The cursor anchors strictly below the last row of the previous page, // so the first row of the next page must be older. expect(second.data[0]!.number).toBeLessThan(first.data[first.data.length - 1]!.number) }) test('serves a bounded positional page via `page`', async () => { const client = TestApp.client() // Ascending order anchors the feed at the first indexed block, so // positional pages are deterministic: the offset page must equal the // matching slice of the head page. const headResponse = await client.v1.blocks.$get( { query: { limit: '10', order: 'asc' } }, TestApp.auth, ) const head = await TestApp.json(headResponse, Blocks.schema.getBlocks.Response) expect(headResponse.status).toBe(200) expect(head.data.length).toBe(10) const offsetResponse = await client.v1.blocks.$get( { query: { limit: '5', order: 'asc', page: '2' } }, TestApp.auth, ) const page = await TestApp.json(offsetResponse, Blocks.schema.getBlocks.Response) expect(offsetResponse.status).toBe(200) expect(page.data.map((row) => row.number)).toEqual( head.data.slice(5, 10).map((row) => row.number), ) // Offset pages still return a keyset cursor, so a client can jump // positionally and then continue deep traversal by cursor. expect(typeof page.nextCursor).toBe('string') const continuedResponse = await client.v1.blocks.$get( { query: { cursor: page.nextCursor!, limit: '5', order: 'asc' } }, TestApp.auth, ) const continued = await TestApp.json(continuedResponse, Blocks.schema.getBlocks.Response) expect(continuedResponse.status).toBe(200) expect(continued.data[0]!.number).toBe(page.data[page.data.length - 1]!.number + 1) }) }) describe('GET /blocks/:block (ISO 8601 timestamp)', () => { test('resolves an ISO 8601 timestamp to the first block at or after it', async () => { const client = TestApp.client() // Seed from TIDX rather than the RPC head, which can advance beyond the // indexer's timestamp lookup watermark. const blocksResponse = await client.v1.blocks.$get({ query: { limit: '5' } }, TestApp.auth) const blocks = await TestApp.json(blocksResponse, Blocks.schema.getBlocks.Response) const indexed = blocks.data[0] expect(blocksResponse.status).toBe(200) expect(indexed).toBeDefined() const response = await client.v1.blocks[':block'].$get( { param: { block: indexed!.timestamp }, query: {} }, TestApp.auth, ) const body = await TestApp.json(response, Blocks.schema.getBlock.Response) expect(response.status).toBe(200) expect(typeof body.number).toBe('number') // The returned block's timestamp must be >= the requested timestamp. expect(new Date(body.timestamp).getTime()).toBeGreaterThanOrEqual( new Date(indexed!.timestamp).getTime(), ) }) test('returns 404 for a far-future timestamp with no indexed block', async () => { // Year 3000+ — there will not be an indexed block past this point. const client = TestApp.client() const response = await client.v1.blocks[':block'].$get( { param: { block: '3000-01-01T00:00:00Z' }, query: {} }, TestApp.auth, ) const body = await TestApp.json(response, Schema.ErrorResponse) expect(response.status).toBe(404) expect(body.error.code).toBe('block_not_found') }) }) describe('GET /chains/:chainId/blocks/by-timestamp (legacy compatibility)', () => { test('resolves the latest block at or before the timestamp (default)', async () => { expect(recent).toBeDefined() const recentBlock = await Viem.getClient().getBlock({ blockNumber: recent!.number }) const seconds = Number(recentBlock.timestamp) const client = TestApp.client() const response = await client.chains[':chainId'].blocks['by-timestamp'].$get( { param: { chainId: String(Runtime.get().chainId) }, query: { timestamp: String(seconds) } }, TestApp.auth, ) const body = await TestApp.json(response, Blocks.schema.getBlockByTimestamp.Response) expect(response.status).toBe(200) expect(typeof body.block.blockNumber).toBe('number') // `before` returns a block at or before the requested instant, within window. expect(body.block.blockTimestamp).toBeLessThanOrEqual(seconds) expect(body.block.blockTimestamp).toBeGreaterThanOrEqual(seconds - 60) }) test('resolves the earliest block at or after the timestamp with closest=after', async () => { expect(recent).toBeDefined() const recentBlock = await Viem.getClient().getBlock({ blockNumber: recent!.number }) const seconds = Number(recentBlock.timestamp) const client = TestApp.client() const response = await client.chains[':chainId'].blocks['by-timestamp'].$get( { param: { chainId: String(Runtime.get().chainId) }, query: { timestamp: String(seconds), closest: 'after' }, }, TestApp.auth, ) const body = await TestApp.json(response, Blocks.schema.getBlockByTimestamp.Response) expect(response.status).toBe(200) expect(body.block.blockTimestamp).toBeGreaterThanOrEqual(seconds) expect(body.block.blockTimestamp).toBeLessThanOrEqual(seconds + 60) }) test('returns 404 when no indexed block falls within the window', async () => { // Year 3000+ — there will not be an indexed block past this point. const client = TestApp.client() const response = await client.chains[':chainId'].blocks['by-timestamp'].$get( { param: { chainId: String(Runtime.get().chainId) }, query: { timestamp: '32503680000' } }, TestApp.auth, ) const body = await TestApp.json(response, Schema.ErrorResponse) expect(response.status).toBe(404) expect(body.error.code).toBe('block_not_found') }) }) describe('GET /transactions (single-block listing)', () => { test('lists transactions in a block via equal `blockNumber.*` bounds', async () => { expect(recentNonEmpty).toBeDefined() const block = recentNonEmpty!.number.toString() const client = TestApp.client() const response = await client.v1.transactions.$get( { query: { 'blockNumber.from': block, 'blockNumber.to': block, limit: '10' } }, TestApp.auth, ) const body = await TestApp.json(response, Transactions.schema.getTransactions.Response) expect(response.status).toBe(200) expect(Array.isArray(body.data)).toBe(true) expect(body.data.length).toBeGreaterThan(0) // Every returned transaction belongs to the requested block. for (const tx of body.data) expect(tx.blockNumber).toBe(Number(recentNonEmpty!.number)) }) test('a block hash composes via GET /blocks/:block, then the bounds', async () => { expect(recentNonEmpty).toBeDefined() const client = TestApp.client() // Resolve the hash to a number through the blocks resource… const blockResponse = await client.v1.blocks[':block'].$get( { param: { block: recentNonEmpty!.hash }, query: {} }, TestApp.auth, ) const block = await TestApp.json(blockResponse, Blocks.schema.getBlock.Response) expect(blockResponse.status).toBe(200) expect(block.number).toBe(Number(recentNonEmpty!.number)) // …then list its transactions with equal bounds. const number = block.number.toString() const response = await client.v1.transactions.$get( { query: { 'blockNumber.from': number, 'blockNumber.to': number, limit: '10' } }, 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 tx of body.data) expect(tx.blockNumber).toBe(block.number) }) }) // Walk back from the chain head to find a recent mined block with a stable // hash + number. The latest block could change between requests, so seed a // concrete identifier once for the dispatch tests. The Tempo RPC node serves // the test chain (`42431` → `rpc.testnet.tempo.xyz`). async function findRecentBlock() { const client = Viem.getClient() const latest = await client.getBlockNumber() // Step a couple of blocks back from the head to avoid racing against a // re-orged head. const number = latest > 2n ? latest - 2n : latest return await blockByNumber(number) } async function blockByNumber(number: bigint) { const block = await Viem.getClient().getBlock({ blockNumber: number }) return { number, hash: block.hash } } // Walk backwards from a small offset below the head until we find a block // with at least one transaction. Stepping below the head gives the indexer // time to ingest the block before tidx-backed endpoints query it. async function findRecentNonEmptyBlock() { const client = Viem.getClient() const latest = await client.getBlockNumber() let cursor = latest > 10n ? latest - 10n : latest for (let i = 0; i < 500; i++) { const block = await client.getBlock({ blockNumber: cursor }) if (block.transactions.length > 0) return { number: cursor, hash: block.hash } if (cursor === 0n) break cursor = cursor - 1n } return undefined }