import { Hex, Secp256k1 } from 'ox' import { ZoneRpcAuthentication } from 'ox/tempo' import type * as Log from '../../../internal/Log.js' import type * as Viem from '../../../internal/Viem.js' import * as RequestListener from '../../../handlers/internal/requestListener.js' import * as TestApp from '../../../../test/App.js' import * as Relay from '../../../../test/Relay.js' import * as Runtime from '../../../../test/runtime.js' type JsonSchema = { $ref?: string additionalProperties?: boolean | JsonSchema anyOf?: readonly JsonSchema[] description?: string enum?: readonly string[] examples?: readonly unknown[] items?: JsonSchema minItems?: number not?: JsonSchema properties?: Record required?: readonly string[] } type OpenApiOperation = { operationId?: string parameters?: readonly { example?: unknown name: string required?: boolean }[] requestBody?: { content?: { 'application/json'?: { schema?: JsonSchema } } } responses?: Record< string, { $ref?: string content?: { 'application/json'?: { schema?: JsonSchema } } description?: string headers?: Record } > 'x-openrpc'?: string } type OpenApiDocument = { components: { schemas: Record } paths: { '/rpc'?: { post?: OpenApiOperation } '/rpc/{chain}'?: { post?: OpenApiOperation } } } /** Resolves one component reference from the generated OpenAPI document. */ function resolveSchema(document: OpenApiDocument, value: JsonSchema | undefined) { if (!value?.$ref) return value const name = value.$ref.split('/').at(-1) if (!name) throw new Error(`Invalid OpenAPI component ref ${value.$ref}`) return document.components.schemas[name] } /** Returns the exact Tempo error codes documented for one response status. */ function errorCodes( document: OpenApiDocument, response: | { content?: { 'application/json'?: { schema?: JsonSchema } } } | undefined, ) { const envelope = resolveSchema(document, response?.content?.['application/json']?.schema) return resolveSchema(document, envelope?.properties?.['error'])?.properties?.['code']?.enum } const runtime = Runtime.get() const zone = runtime.zone.chainId const zoneTokenExample = '0x3844647dc3ffc87cb42fdd4112431720a60e33a8c5b42156784b8440b82e360a63c5e71109b8d22eb103650d0df75fa63af4e072400f9cf8a53eb778592c71931b00000000010000000054e53ef3000000006a8df314000000006a8df440' const zoneOptions = TestApp.zone({ chainId: zone, rpcUrl: runtime.zone.internalRpcUrl, }) const zoneReader = { id: 'key_zone_reader', orgId: 'org_test', scopes: ['data:read', `zone:${zone}:read`], token: 'secret_zone_reader', } satisfies TestApp.kvStore.Key const zoneReaderOtherWriter = { id: 'key_zone_reader_other_writer', orgId: 'org_test', scopes: ['data:read', `zone:${zone}:read`, `zone:${zone + 1}:write`], token: 'secret_zone_reader_other_writer', } satisfies TestApp.kvStore.Key const zoneWriter = { id: 'key_zone_writer', orgId: 'org_test', scopes: ['data:read', `zone:${zone}:write`], token: 'secret_zone_writer', } satisfies TestApp.kvStore.Key // Prool's default Zone dev key. const zonePrivateKey = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' function zoneToken() { const issuedAt = Math.floor(Date.now() / 1_000) const authentication = ZoneRpcAuthentication.from({ chainId: zone, expiresAt: issuedAt + 300, issuedAt, zoneId: 1, }) return ZoneRpcAuthentication.serialize(authentication, { signature: Secp256k1.sign({ payload: ZoneRpcAuthentication.getSignPayload(authentication), privateKey: zonePrivateKey, }), }) } describe('POST /rpc', () => { test('allows 20 public requests per second', async () => { vi.useFakeTimers({ now: new Date('2026-08-12T00:00:00.000Z') }) try { const app = TestApp.create() const request = () => app.request('/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'tempo_blockNumber' }), method: 'POST', }) const responses = await Promise.all( Array.from({ length: 21 }, () => Promise.resolve(request())), ) const limited = responses.find((response) => response.status === 429)! expect(responses.filter((response) => response.status === 401)).toHaveLength(20) expect(limited.headers.get('RateLimit-Limit')).toBe('20') vi.advanceTimersByTime(1_000) expect((await Promise.resolve(request())).status).toBe(401) } finally { vi.useRealTimers() } }) test('rejects oversized anonymous requests before proxying upstream', async () => { const requests: Request[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler((request) => { requests.push(request) return Response.json({ id: 1, jsonrpc: '2.0', result: '0x1' }) }), ) try { const app = TestApp.create({ rpc: { url: server.url } }) const request = new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_call', params: [`0x${'ab'.repeat(32_768)}`, 'latest'], }), method: 'POST', }) expect(request.headers.get('content-length')).toBeNull() const response = await app.fetch(request) expect(response.status).toBe(413) expect(await response.json()).toMatchObject({ error: { code: 'payload_too_large' } }) expect(requests).toHaveLength(0) } finally { await server.closeAsync() } }) test('routes by principal: a resolver sees the request principal and routes accordingly', async () => { const fetch = mockRpc() // A resolver that routes API-key principals to the private upstream and // everything else (here, the keyless `auth: false` caller) to the public one. const rpc = ({ principal }: Viem.resolveRpc.Context) => principal?.type === 'api_key' ? { auth: 'rpc:secret', url: 'https://internal.rpc.test/json-rpc' } : { url: 'https://public.rpc.test/json-rpc' } try { // Auth disabled ⇒ no API-key principal ⇒ public RPC, no basic auth. const publicApp = TestApp.create({ auth: false, rpc }) await publicApp.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) // Valid API key ⇒ api_key principal ⇒ private profile: internal RPC + basic auth. const authedApp = TestApp.create({ rpc }) await authedApp.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'tempo-api-key': TestApp.key.token }, method: 'POST', }), ) expect(fetch.calls[0]?.url).toMatchInlineSnapshot(`"https://public.rpc.test/json-rpc"`) expect(fetch.calls[0]?.headers['authorization']).toMatchInlineSnapshot(`null`) expect(fetch.calls[1]?.url).toMatchInlineSnapshot(`"https://internal.rpc.test/json-rpc"`) expect(fetch.calls[1]?.headers['authorization']).toMatchInlineSnapshot( `"Basic cnBjOnNlY3JldA=="`, ) } finally { fetch.restore() } }) test('proxies JSON-RPC requests to the configured upstream RPC', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: false, rpc: { auth: 'rpc:secret', url: (chainId) => `https://${chainId}.rpc.test/json-rpc`, }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId', params: [] }), headers: { authorization: 'Bearer caller-secret', 'content-type': 'application/json', cookie: 'session=secret', 'tempo-api-key': 'caller-key', 'x-api-key': 'legacy-caller-key', 'x-authorization-token': 'zone-secret', 'x-client-trace': 'trace_test', }, method: 'POST', }), ) const body = await response.json() const call = fetch.calls[0]! expect(response.status).toMatchInlineSnapshot(`200`) expect(response.headers.get('x-upstream')).toMatchInlineSnapshot(`"rpc"`) expect(body).toMatchInlineSnapshot(` { "id": 1, "jsonrpc": "2.0", "result": "0xa5bf", } `) expect(call.method).toMatchInlineSnapshot(`"POST"`) expect(call.url).toBe(`https://${runtime.chainId}.rpc.test/json-rpc`) expect(call.body).toMatchInlineSnapshot( `"{"id":1,"jsonrpc":"2.0","method":"eth_chainId","params":[]}"`, ) expect(call.headers['authorization']).toMatchInlineSnapshot(`"Basic cnBjOnNlY3JldA=="`) expect(call.headers['cookie']).toMatchInlineSnapshot(`null`) expect(call.headers['tempo-api-key']).toMatchInlineSnapshot(`null`) expect(call.headers['x-api-key']).toBeNull() expect(call.headers['x-authorization-token']).toBeNull() expect(call.headers['x-client-trace']).toMatchInlineSnapshot(`"trace_test"`) } finally { fetch.restore() } }) test('records JSON-RPC server errors returned with HTTP 200', async () => { const entries: Log.Entry[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler(() => Response.json({ error: { code: -32603, message: 'internal error' }, id: 1, jsonrpc: '2.0', }), ), ) try { const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), rpc: { url: server.url }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toBe(200) expect(await response.json()).toMatchInlineSnapshot(` { "error": { "code": -32603, "message": "internal error", }, "id": 1, "jsonrpc": "2.0", } `) expect(entries[0]).toMatchObject({ level: 'error', route: '/rpc', rpc: { code: -32603, errors: 1, serverErrors: 1 }, status: 200, }) } finally { await server.closeAsync() } }) test('attributes non-success RPC responses to the upstream', async () => { const entries: Log.Entry[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler(() => Response.json({ error: 'unavailable' }, { status: 503 }), ), ) try { const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), rpc: { url: server.url }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toBe(503) expect(entries[0]).toMatchObject({ provider: { chainId: runtime.chainId, failure: 'http', id: 'rpc', operation: 'request', status: 503, }, }) } finally { await server.closeAsync() } }) test.each([400, 413, 415, 422])( 'keeps caller HTTP %s responses out of upstream alerts', async (status) => { const entries: Log.Entry[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler(() => Response.json({ error: 'invalid request' }, { status }), ), ) try { const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), rpc: { url: server.url }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: '{', method: 'POST', }), ) expect(response.status).toBe(status) expect(entries[0]?.provider).toBeUndefined() } finally { await server.closeAsync() } }, ) test('keeps RPC method-filter responses out of upstream alerts', async () => { const entries: Log.Entry[] = [] const body = { error: { code: -32_601, message: 'method not allowed: tempo_sponsor' }, id: null, jsonrpc: '2.0', } const server = await Relay.createServer( RequestListener.fromFetchHandler(() => Response.json(body, { status: 403 })), ) try { const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), rpc: { url: server.url }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'tempo_sponsor' }), method: 'POST', }), ) expect(response.status).toBe(403) expect(await response.json()).toEqual(body) expect(entries[0]?.provider).toBeUndefined() } finally { await server.closeAsync() } }) test('attributes RPC authorization responses to the upstream', async () => { const entries: Log.Entry[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler(() => Response.json( { error: { code: -32_001, message: 'unauthorized: invalid credentials' }, id: null, jsonrpc: '2.0', }, { status: 403 }, ), ), ) try { const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), rpc: { url: server.url }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toBe(403) expect(entries[0]).toMatchObject({ provider: { chainId: runtime.chainId, failure: 'http', id: 'rpc', operation: 'request', status: 403, }, }) } finally { await server.closeAsync() } }) test('attributes RPC transport failures to the upstream', async () => { const entries: Log.Entry[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler(() => Response.json({ id: 1, jsonrpc: '2.0', result: '0x1' }), ), ) const url = server.url await server.closeAsync() const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), rpc: { url }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toBe(502) expect(entries[0]).toMatchObject({ provider: { chainId: runtime.chainId, failure: 'network', id: 'rpc', operation: 'request', }, }) }) test.each([ { body: { id: 1, jsonrpc: '2.0' }, name: 'missing result' }, { body: { error: { code: -32_601, message: 'method not allowed' }, id: 1, jsonrpc: '2.0', result: null, }, name: 'result and error', }, { body: [], name: 'empty batch' }, { body: null, name: 'null' }, ])('attributes malformed JSON-RPC $name payloads to the upstream', async ({ body }) => { const entries: Log.Entry[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler(() => Response.json(body)), ) try { const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), rpc: { url: server.url }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toBe(200) expect(entries[0]).toMatchObject({ provider: { chainId: runtime.chainId, failure: 'payload', id: 'rpc', operation: 'request', }, }) } finally { await server.closeAsync() } }) test.each([ { body: 'null', status: 200 }, { body: '', status: 204 }, ])('handles empty malformed $status responses as upstream failures', async ({ body, status }) => { const entries: Log.Entry[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler(() => new Response(null, { status })), ) try { const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), rpc: { url: server.url }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toBe(status) expect(await response.text()).toBe(body) expect(entries[0]).toMatchObject({ provider: { chainId: runtime.chainId, failure: 'payload', id: 'rpc', operation: 'request', }, }) } finally { await server.closeAsync() } }) test.each([ { body: 'null', name: 'empty', response: () => new Response(null, { status: 200 }), status: 200, }, { body: 'null', name: 'null', response: () => Response.json(null), status: 200 }, { body: '', name: 'no-content', response: () => new Response(null, { status: 204 }), status: 204, }, ])('accepts $name responses to JSON-RPC notifications', async ({ body, response, status }) => { const entries: Log.Entry[] = [] const server = await Relay.createServer(RequestListener.fromFetchHandler(response)) try { const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), rpc: { url: server.url }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ jsonrpc: '2.0', method: 'eth_sendRawTransaction' }), method: 'POST', }), ) expect(response.status).toBe(status) expect(await response.text()).toBe(body) expect(entries[0]?.provider).toBeUndefined() } finally { await server.closeAsync() } }) test('preserves large streaming request bodies during inspection', async () => { const bodies: string[] = [] const requestBody = JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_call', params: [`0x${'ab'.repeat(40_000)}`, 'latest'], }) const server = await Relay.createServer( RequestListener.fromFetchHandler(async (request) => { bodies.push(await request.text()) return Response.json({ id: 1, jsonrpc: '2.0', result: '0x' }) }), ) try { const app = TestApp.create({ auth: false, rpc: { url: server.url } }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: requestBody, method: 'POST' }), ) expect(response.status).toBe(200) expect(bodies).toEqual([requestBody]) } finally { await server.closeAsync() } }) test('preserves RPC responses larger than the inspection bound', async () => { const result = `0x${'ab'.repeat(40_000)}` const server = await Relay.createServer( RequestListener.fromFetchHandler(() => Response.json({ id: 1, jsonrpc: '2.0', result })), ) try { const app = TestApp.create({ auth: false, rpc: { url: server.url } }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_getLogs' }), method: 'POST', }), ) expect(response.status).toBe(200) expect(await response.json()).toEqual({ id: 1, jsonrpc: '2.0', result }) } finally { await server.closeAsync() } }) test('does not attribute Zone caller-auth responses to the RPC provider', async () => { const entries: Log.Entry[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler(() => Response.json( { error: { code: -32_000, message: 'invalid zone credential' }, id: 1, jsonrpc: '2.0', }, { status: 401 }, ), ), ) try { const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), rpc: { ...TestApp.rpc, publicZoneUrl: server.url }, zones: [zoneOptions], }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { [ZoneRpcAuthentication.headerName]: 'invalid-zone-credential' }, method: 'POST', }), ) expect(response.status).toBe(401) expect(entries[0]?.provider).toBeUndefined() } finally { await server.closeAsync() } }) test('forwards inferred RPC bearer auth', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: false, rpc: { auth: 'rpc-token', url: 'https://internal.rpc.test/json-rpc' }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toBe(200) expect(fetch.calls[0]?.headers['authorization']).toBe('Bearer rpc-token') } finally { fetch.restore() } }) test('allows eth-prefixed methods anonymously', async () => { const fetch = mockRpc() try { const app = TestApp.create({ rpc: { auth: 'rpc:secret', url: (chainId) => `https://${chainId}.rpc.test/json-rpc`, }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc/testnet', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_fillTransaction' }), method: 'POST', }), ) expect(response.status).toMatchInlineSnapshot(`200`) expect(fetch.calls[0]?.url).toMatchInlineSnapshot(`"https://rpc.testnet.tempo.xyz/"`) expect(fetch.calls[0]?.headers['authorization']).toMatchInlineSnapshot(`null`) } finally { fetch.restore() } }) test('requires an API key for non-eth methods', async () => { const fetch = mockRpc() try { const app = TestApp.create() const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'tempo_fundAddress' }), method: 'POST', }), ) const body = (await response.json()) as Record const { requestId, ...stable } = body expect(response.status).toMatchInlineSnapshot(`401`) expect(typeof requestId).toMatchInlineSnapshot(`"string"`) expect(stable).toMatchInlineSnapshot(` { "error": { "code": "api_key_missing", "message": "Missing API key", }, } `) expect(fetch.calls).toMatchInlineSnapshot(`[]`) } finally { fetch.restore() } }) test('requires an API key when a batch includes a private method', async () => { const fetch = mockRpc() try { const app = TestApp.create() const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify([ { id: 1, jsonrpc: '2.0', method: 'eth_chainId' }, { id: 2, jsonrpc: '2.0', method: 'tempo_fundAddress' }, ]), method: 'POST', }), ) const body = (await response.json()) as { error?: { code?: unknown } } expect(response.status).toMatchInlineSnapshot(`401`) expect(body.error?.code).toMatchInlineSnapshot(`"api_key_missing"`) expect(fetch.calls).toMatchInlineSnapshot(`[]`) } finally { fetch.restore() } }) test('allows API keys to call private methods', async () => { const fetch = mockRpc() try { const app = TestApp.create() const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'tempo_fundAddress' }), headers: { 'tempo-api-key': TestApp.key.token }, method: 'POST', }), ) expect(response.status).toMatchInlineSnapshot(`200`) expect(fetch.calls[0]?.body).toMatchInlineSnapshot( `"{"id":1,"jsonrpc":"2.0","method":"tempo_fundAddress"}"`, ) } finally { fetch.restore() } }) test('uses the requested chain id for upstream URL resolution', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: false, rpc: { url: (chainId) => `https://${chainId}.rpc.test` }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc/4217', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toMatchInlineSnapshot(`200`) expect(fetch.calls[0]?.url).toMatchInlineSnapshot(`"https://4217.rpc.test/"`) } finally { fetch.restore() } }) test('uses mainnet for the mainnet shortcut', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: false, rpc: { url: (chainId) => `https://${chainId}.rpc.test` }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc/mainnet', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toMatchInlineSnapshot(`200`) expect(fetch.calls[0]?.url).toMatchInlineSnapshot(`"https://4217.rpc.test/"`) } finally { fetch.restore() } }) test('uses testnet for the testnet shortcut', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: false, rpc: { url: (chainId) => `https://${chainId}.rpc.test` }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc/testnet', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toMatchInlineSnapshot(`200`) expect(fetch.calls[0]?.url).toMatchInlineSnapshot(`"https://42431.rpc.test/"`) } finally { fetch.restore() } }) test('proxies zone chain ids and forwards the caller zone auth token', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: false, rpc: { auth: 'rpc:secret', publicZoneUrl: `https://${zone}.rpc.test`, url: () => 'https://internal.rpc.test', }, zones: [zoneOptions], }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_getBalance' }), headers: { authorization: 'Bearer caller-secret', 'x-authorization-token': 'zone-token', }, method: 'POST', }), ) const call = fetch.calls[0]! expect(response.status).toMatchInlineSnapshot(`200`) expect(call.url).toBe(`https://${zone}.rpc.test/`) // Public Zone URLs receive only the caller's token. expect(call.headers['x-authorization-token']).toMatchInlineSnapshot(`"zone-token"`) expect(call.headers['authorization']).toMatchInlineSnapshot(`null`) expect(call.headers['tempo-api-key']).toMatchInlineSnapshot(`null`) } finally { fetch.restore() } }) test('refuses a zone rpc call without a zone scope or auth token', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: false, zones: [TestApp.zone({ chainId: zone, rpcUrl: `https://${zone}.rpc.test` })], }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_getBalance' }), method: 'POST', }), ) const body = (await response.json()) as { error: { code: string; message: string } } expect(response.status).toBe(403) expect(body.error.code).toBe('api_key_forbidden') expect(body.error.message).toContain(`zone:${zone}:read`) // Refused before any upstream round-trip. expect(fetch.calls).toHaveLength(0) } finally { fetch.restore() } }) test('routes Zone-scoped reads to the internal RPC', async () => { const app = TestApp.create({ auth: { keys: [zoneReader] }, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'content-type': 'application/json', 'tempo-api-key': zoneReader.token }, method: 'POST', }), ) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ result: Hex.fromNumber(zone) }) }) test('allows Zone-scoped eth_fillTransaction calls', async () => { const app = TestApp.create({ auth: { keys: [zoneReader] }, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_fillTransaction' }), headers: { 'content-type': 'application/json', 'tempo-api-key': zoneReader.token }, method: 'POST', }), ) expect(response.status).toBe(200) }) test('allows Zone-scoped metadata reads with internal RPC auth', async () => { const authorization: (string | null)[] = [] const bodies: string[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler(async (request) => { authorization.push(request.headers.get('authorization')) bodies.push(await request.text()) return Response.json({ id: 1, jsonrpc: '2.0', result: {} }) }), ) try { const app = TestApp.create({ auth: { keys: [zoneReader] }, rpc: ({ chainId }) => (chainId === zone ? { auth: 'rpc:secret', url: server.url } : {}), zones: [zoneOptions], }) for (const method of ['zone_getEncryptionKey', 'zone_getZoneInfo']) { const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method }), headers: { 'content-type': 'application/json', 'tempo-api-key': zoneReader.token }, method: 'POST', }), ) expect(response.status).toBe(200) } expect(authorization).toEqual(['Basic cnBjOnNlY3JldA==', 'Basic cnBjOnNlY3JldA==']) expect(bodies).toEqual([ '{"id":1,"jsonrpc":"2.0","method":"zone_getEncryptionKey"}', '{"id":1,"jsonrpc":"2.0","method":"zone_getZoneInfo"}', ]) } finally { await server.closeAsync() } }) test('blocks caller-authenticated Zone methods for Zone read scopes', async () => { const app = TestApp.create({ auth: { keys: [zoneReader] }, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'zone_getAuthorizationTokenInfo', }), headers: { 'content-type': 'application/json', 'tempo-api-key': zoneReader.token }, method: 'POST', }), ) expect(response.status).toBe(403) expect(await response.json()).toMatchObject({ error: { code: 'api_key_forbidden' } }) }) test('rejects writes without the matching Zone write scope', async () => { for (const key of [zoneReader, zoneReaderOtherWriter]) { const app = TestApp.create({ auth: { keys: [key] }, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_sendRawTransaction' }), headers: { 'content-type': 'application/json', 'tempo-api-key': key.token }, method: 'POST', }), ) expect(response.status).toBe(403) expect(await response.json()).toMatchObject({ error: { code: 'api_key_forbidden' } }) } }) test('allows Zone-scoped signed transaction broadcasts', async () => { const app = TestApp.create({ auth: { keys: [zoneWriter] }, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_sendRawTransactionSync', params: ['0x76'], }), headers: { 'content-type': 'application/json', 'tempo-api-key': zoneWriter.token }, method: 'POST', }), ) expect(response.status).toBe(200) }) test('allows Zone write scopes to read', async () => { const app = TestApp.create({ auth: { keys: [zoneWriter] }, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'content-type': 'application/json', 'tempo-api-key': zoneWriter.token }, method: 'POST', }), ) expect(response.status).toBe(200) }) test('keeps unlocked-account methods blocked for Zone write scopes', async () => { const app = TestApp.create({ auth: { keys: [zoneWriter] }, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_sendTransaction' }), headers: { 'content-type': 'application/json', 'tempo-api-key': zoneWriter.token }, method: 'POST', }), ) expect(response.status).toBe(403) expect(await response.json()).toMatchObject({ error: { code: 'api_key_forbidden' } }) }) test('rejects a Zone-scoped batch containing a write', async () => { const app = TestApp.create({ auth: { keys: [zoneReader] }, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify([ { id: 1, jsonrpc: '2.0', method: 'eth_chainId' }, { id: 2, jsonrpc: '2.0', method: 'eth_sendRawTransaction' }, ]), headers: { 'content-type': 'application/json', 'tempo-api-key': zoneReader.token }, method: 'POST', }), ) expect(response.status).toBe(403) expect(await response.json()).toMatchObject({ error: { code: 'api_key_forbidden' } }) }) test('allows a Zone write scope to batch reads and signed broadcasts', async () => { const app = TestApp.create({ auth: { keys: [zoneWriter] }, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify([ { id: 1, jsonrpc: '2.0', method: 'eth_chainId' }, { id: 2, jsonrpc: '2.0', method: 'eth_sendRawTransaction', params: ['0x76'] }, ]), headers: { 'content-type': 'application/json', 'tempo-api-key': zoneWriter.token }, method: 'POST', }), ) expect(response.status).toBe(200) }) test('routes caller Zone tokens to the private RPC', async () => { const app = TestApp.create({ auth: false, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'content-type': 'application/json', [ZoneRpcAuthentication.headerName]: zoneToken(), }, method: 'POST', }), ) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ result: Hex.fromNumber(zone) }) }) test('rejects invalid Zone tokens at the private RPC', async () => { const app = TestApp.create({ auth: false, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'content-type': 'application/json', [ZoneRpcAuthentication.headerName]: 'invalid', }, method: 'POST', }), ) expect(response.status).toBe(403) }) test('rejects invalid chain ids before proxying upstream', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: false }) const response = await app.fetch( new Request('http://tempo-api.test/rpc/0', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) const body = (await response.json()) as Record const { requestId, ...stable } = body expect(response.status).toMatchInlineSnapshot(`400`) expect(typeof requestId).toMatchInlineSnapshot(`"string"`) expect(stable).toMatchInlineSnapshot(` { "error": { "code": "chain_id_invalid", "message": "Invalid chain id", }, } `) expect(fetch.calls).toMatchInlineSnapshot(`[]`) } finally { fetch.restore() } }) test('requires the read scope for API keys', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: { keys: [ { id: 'key_indexer_only', orgId: 'org_test', scopes: ['indexer:query'], token: 'secret_indexer_key', }, ], }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'tempo-api-key': 'secret_indexer_key' }, method: 'POST', }), ) const body = (await response.json()) as Record const { requestId, ...stable } = body expect(response.status).toMatchInlineSnapshot(`403`) expect(typeof requestId).toMatchInlineSnapshot(`"string"`) expect(stable).toMatchInlineSnapshot(` { "error": { "code": "api_key_forbidden", "message": "API key missing required scope", }, } `) expect(fetch.calls).toMatchInlineSnapshot(`[]`) } finally { fetch.restore() } }) test('uses the RPC path for sandbox chain checks', async () => { const app = TestApp.create({ auth: { keys: [ { environment: 'sandbox', id: 'key_sandbox', orgId: 'org_test', scopes: ['data:read'], token: 'secret_sandbox_key', }, ], }, defaultChainId: 4217, }) const request = (path: string) => app.fetch( new Request(`http://tempo-api.test${path}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'tempo-api-key': 'secret_sandbox_key' }, method: 'POST', }), ) const rejected = await request('/rpc/4217?chainId=42431') const rejectedDefault = await request('/rpc?chainId=42431') const rejectedTrailing = await request('/rpc/?chainId=42431') expect(rejected.status).toMatchInlineSnapshot(`403`) expect(rejectedDefault.status).toMatchInlineSnapshot(`403`) expect(rejectedTrailing.status).toMatchInlineSnapshot(`404`) }) test('uses the RPC path below the configured base path', async () => { const app = TestApp.create({ auth: { keys: [ { environment: 'sandbox', id: 'key_sandbox', orgId: 'org_test', scopes: ['data:read'], token: 'secret_sandbox_key', }, ], }, defaultChainId: 4217, path: '/api', }) const response = await app.fetch( new Request('http://tempo-api.test/api/rpc?chainId=42431', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'tempo-api-key': 'secret_sandbox_key' }, method: 'POST', }), ) expect(response.status).toMatchInlineSnapshot(`403`) }) test('documents generator-ready JSON-RPC contracts', async () => { const app = TestApp.create({ auth: false }) const document = (await (await app.request('/openapi.json')).json()) as OpenApiDocument const root = document.paths['/rpc']?.post const chain = document.paths['/rpc/{chain}']?.post if (!root || !chain) throw new Error('Missing RPC OpenAPI operations') expect({ chain: { operationId: chain.operationId, parameters: chain.parameters?.map((parameter) => ({ example: parameter.example, name: parameter.name, required: parameter.required, })), request: chain.requestBody?.content?.['application/json']?.schema, response: chain.responses?.['200']?.content?.['application/json']?.schema, xOpenrpc: chain['x-openrpc'], }, root: { operationId: root.operationId, parameters: root.parameters?.map((parameter) => ({ example: parameter.example, name: parameter.name, required: parameter.required, })), request: root.requestBody?.content?.['application/json']?.schema, response: root.responses?.['200']?.content?.['application/json']?.schema, xOpenrpc: root['x-openrpc'], }, }).toMatchObject({ chain: { operationId: 'rpcRequestByChain', parameters: [ { example: 'testnet', name: 'chain', required: true }, { example: zoneTokenExample, name: 'X-Authorization-Token', required: false }, ], request: { $ref: '#/components/schemas/JsonRpcRequestBody' }, response: { $ref: '#/components/schemas/JsonRpcResponseBody' }, xOpenrpc: undefined, }, root: { operationId: 'rpcRequest', parameters: [{ example: zoneTokenExample, name: 'X-Authorization-Token', required: false }], request: { $ref: '#/components/schemas/JsonRpcRequestBody' }, response: { $ref: '#/components/schemas/JsonRpcResponseBody' }, xOpenrpc: '/openrpc.json', }, }) expect({ error: document.components.schemas['JsonRpcError']?.required, errorResponseAdditionalProperties: document.components.schemas['JsonRpcErrorResponse']?.additionalProperties, errorResponse: document.components.schemas['JsonRpcErrorResponse']?.required, errorResponseProperties: Object.keys( document.components.schemas['JsonRpcErrorResponse']?.properties ?? {}, ), requestParams: document.components.schemas['JsonRpcRequest']?.properties?.['params']?.anyOf, request: document.components.schemas['JsonRpcRequest']?.required, requestBody: document.components.schemas['JsonRpcRequestBody']?.anyOf, response: document.components.schemas['JsonRpcResponse']?.anyOf, responseBody: document.components.schemas['JsonRpcResponseBody']?.anyOf, successResponse: document.components.schemas['JsonRpcSuccessResponse']?.required, successResponseAdditionalProperties: document.components.schemas['JsonRpcSuccessResponse']?.additionalProperties, successResponseProperties: Object.keys( document.components.schemas['JsonRpcSuccessResponse']?.properties ?? {}, ), }).toMatchObject({ error: ['code', 'message'], errorResponseAdditionalProperties: false, errorResponse: ['error', 'id', 'jsonrpc'], errorResponseProperties: ['error', 'id', 'jsonrpc'], requestParams: [{ type: 'array' }, { type: 'object' }], request: ['jsonrpc', 'method'], requestBody: [ { $ref: '#/components/schemas/JsonRpcRequest' }, { items: { $ref: '#/components/schemas/JsonRpcRequest' }, minItems: 1, type: 'array', }, ], response: [ { $ref: '#/components/schemas/JsonRpcSuccessResponse' }, { $ref: '#/components/schemas/JsonRpcErrorResponse' }, ], responseBody: [ { type: 'null' }, { $ref: '#/components/schemas/JsonRpcResponse' }, { items: { $ref: '#/components/schemas/JsonRpcResponse' }, minItems: 1, type: 'array', }, ], successResponse: ['id', 'jsonrpc', 'result'], successResponseAdditionalProperties: false, successResponseProperties: ['id', 'jsonrpc', 'result'], }) expect({ 204: root.responses?.['204']?.description, '204Headers': Object.keys(root.responses?.['204']?.headers ?? {}), 400: errorCodes(document, root.responses?.['400']), 401: errorCodes(document, root.responses?.['401']), 403: errorCodes(document, root.responses?.['403']), 413: errorCodes(document, root.responses?.['413']), 429: root.responses?.['429']?.$ref, 500: root.responses?.['500']?.$ref, 502: errorCodes(document, root.responses?.['502']), 504: root.responses?.['504']?.$ref, default: root.responses?.['default']?.description, }).toEqual({ 204: 'Notification accepted with no response body.', '204Headers': [ 'RateLimit-Limit', 'RateLimit-Remaining', 'RateLimit-Reset', 'RateLimit-Scope', 'tempo-request-id', ], 400: ['api_key_malformed', 'chain_id_invalid', 'chain_id_unsupported'], 401: ['api_key_invalid', 'api_key_missing', 'unauthorized'], 403: ['api_key_forbidden', 'api_key_ip_forbidden', 'forbidden'], 413: ['payload_too_large'], 429: '#/components/responses/RateLimited', 500: '#/components/responses/InternalError', 502: ['upstream_error'], 504: '#/components/responses/RequestTimeout', default: 'An upstream HTTP response passed through unchanged, including its status, headers, content type, and body.', }) expect({ 204: chain.responses?.['204']?.description, '204Headers': Object.keys(chain.responses?.['204']?.headers ?? {}), 400: errorCodes(document, chain.responses?.['400']), 401: errorCodes(document, chain.responses?.['401']), 403: errorCodes(document, chain.responses?.['403']), 413: errorCodes(document, chain.responses?.['413']), 429: chain.responses?.['429']?.$ref, 500: chain.responses?.['500']?.$ref, 502: errorCodes(document, chain.responses?.['502']), 504: chain.responses?.['504']?.$ref, default: chain.responses?.['default']?.description, }).toEqual({ 204: 'Notification accepted with no response body.', '204Headers': [ 'RateLimit-Limit', 'RateLimit-Remaining', 'RateLimit-Reset', 'RateLimit-Scope', 'tempo-request-id', ], 400: ['api_key_malformed', 'chain_id_invalid', 'chain_id_unsupported'], 401: ['api_key_invalid', 'api_key_missing', 'unauthorized'], 403: ['api_key_forbidden', 'api_key_ip_forbidden', 'forbidden'], 413: ['payload_too_large'], 429: '#/components/responses/RateLimited', 500: '#/components/responses/InternalError', 502: ['upstream_error'], 504: '#/components/responses/RequestTimeout', default: 'An upstream HTTP response passed through unchanged, including its status, headers, content type, and body.', }) for (const name of [ 'JsonRpcError', 'JsonRpcErrorResponse', 'JsonRpcId', 'JsonRpcRequest', 'JsonRpcRequestBody', 'JsonRpcResponse', 'JsonRpcResponseBody', 'JsonRpcSuccessResponse', ]) { const component = document.components.schemas[name] expect(component?.description, name).toBeTruthy() for (const [field, value] of Object.entries(component?.properties ?? {})) if (!value.$ref && !value.not) expect(value.examples, `${name}.${field}`).toBeTruthy() } }) }) describe('POST /rpc/relay', () => { test('rejects anonymous callers', async () => { const app = TestApp.create() const response = await app.fetch( new Request('http://tempo-api.test/rpc/relay', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) const body = (await response.json()) as Record const { requestId, ...stable } = body expect(response.status).toMatchInlineSnapshot(`401`) expect(typeof requestId).toMatchInlineSnapshot(`"string"`) expect(stable).toMatchInlineSnapshot(` { "error": { "code": "api_key_missing", "message": "Missing API key", }, } `) }) test('rejects keys missing the relay scope', async () => { // The shared test key holds the data/indexer scopes only. const app = TestApp.create() const response = await app.fetch( new Request('http://tempo-api.test/rpc/relay', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'tempo-api-key': TestApp.key.token }, method: 'POST', }), ) const body = (await response.json()) as Record const { requestId, ...stable } = body expect(response.status).toMatchInlineSnapshot(`403`) expect(typeof requestId).toMatchInlineSnapshot(`"string"`) expect(stable).toMatchInlineSnapshot(` { "error": { "code": "api_key_forbidden", "message": "API key missing required scope", }, } `) }) test('routes chain-scoped mounts to the relay pipeline', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: { keys: [ { id: 'key_relay', orgId: 'org_test', scopes: ['rpc-relay:read'], token: 'secret_relay_key', }, ], }, rpc: { url: () => 'https://internal.rpc.test/json-rpc' }, }) // Without a fee payer configured the relay pipeline answers this method // itself; a proxied request would return the mock upstream's result. const response = await app.fetch( new Request('http://tempo-api.test/rpc/relay/42431', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_signRawTransaction', params: ['0x76'] }), // prettier-ignore headers: { 'tempo-api-key': 'secret_relay_key' }, method: 'POST', }), ) expect(response.status).toMatchInlineSnapshot(`200`) const body = (await response.json()) as { error?: { message: string } | undefined } expect(body.error?.message).toMatchInlineSnapshot( `"eth_signRawTransaction requires a fee payer to be configured on the relay. Set the \`feePayer\` option in \`Handler.relay()\` to enable transaction sponsorship."`, ) expect(fetch.calls).toMatchInlineSnapshot(`[]`) } finally { fetch.restore() } }) test('proxies for keys holding rpc-relay:read', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: { keys: [ { id: 'key_relay', orgId: 'org_test', scopes: ['rpc-relay:read'], token: 'secret_relay_key', }, ], }, rpc: { url: () => 'https://internal.rpc.test/json-rpc' }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc/relay', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'tempo-api-key': 'secret_relay_key' }, method: 'POST', }), ) expect(response.status).toMatchInlineSnapshot(`200`) expect(await response.json()).toMatchInlineSnapshot(` { "id": 1, "jsonrpc": "2.0", "result": "0xa5bf", } `) expect(fetch.calls[0]?.url).toMatchInlineSnapshot(`"https://internal.rpc.test/json-rpc"`) } finally { fetch.restore() } }) }) function mockRpc() { const original = globalThis.fetch const calls: { body: string headers: Record method: string url: string }[] = [] const spy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { const request = input instanceof Request ? input : new Request(input, init) calls.push({ body: await request.text(), headers: { authorization: request.headers.get('authorization'), cookie: request.headers.get('cookie'), 'tempo-api-key': request.headers.get('tempo-api-key'), 'x-api-key': request.headers.get('x-api-key'), 'x-authorization-token': request.headers.get('x-authorization-token'), 'x-client-trace': request.headers.get('x-client-trace'), }, method: request.method, url: request.url, }) return new Response(JSON.stringify({ id: 1, jsonrpc: '2.0', result: '0xa5bf' }), { headers: { 'content-type': 'application/json', 'x-upstream': 'rpc' }, }) }) return { calls, restore() { spy.mockRestore() globalThis.fetch = original }, } }