import { Hono, type Context, type TypedResponse } from 'hono' import { ZoneRpcAuthentication } from 'ox/tempo' import * as z from 'zod/mini' import type * as App from '../../../App.js' import * as Scope from '../../../Scope.js' import * as Auth from '../../../internal/Auth.js' import * as Log from '../../../internal/Log.js' import * as OpenApi from '../../../internal/OpenApi.js' import * as Response from '../../../internal/Response.js' import * as Schema from '../../../internal/Schema.js' import * as Viem from '../../../internal/Viem.js' import * as ZoneRpc from '../../../internal/ZoneRpc.js' // Request headers that must not be forwarded upstream: hop-by-hop headers, the // inbound host, and any caller auth so the consumer's Tempo API credentials never // reach the RPC service (the proxy injects its own RPC basic auth instead). const strippedHeaders = new Set([ 'authorization', 'connection', 'cookie', 'host', 'keep-alive', 'proxy-authorization', 'te', 'tempo-api-key', 'trailer', 'transfer-encoding', 'upgrade', 'x-api-key', ZoneRpcAuthentication.headerName.toLowerCase(), ]) const maxRpcInspectionBytes = 64 * 1_024 const exampleZoneToken = '0x3844647dc3ffc87cb42fdd4112431720a60e33a8c5b42156784b8440b82e360a63c5e71109b8d22eb103650d0df75fa63af4e072400f9cf8a53eb778592c71931b00000000010000000054e53ef3000000006a8df314000000006a8df440' const zoneTokenParameter = { description: 'Optional signed Zone credential forwarded only to the selected Zone RPC.', example: exampleZoneToken, in: 'header' as const, name: ZoneRpcAuthentication.headerName, required: false, schema: { pattern: '^0x[0-9a-fA-F]+$', type: 'string' as const }, } /** Never reached; carries typed passthrough response variants into Hono client inference. */ const narrowPassthrough = false as boolean type RpcInspection = { body: ReadableStream> | null result: { empty: boolean; ok: false } | { ok: true; value: unknown } | undefined } /** Zod schemas owned by the raw RPC passthrough. */ export namespace schema { /** JSON-RPC request identifier. */ export const Id = OpenApi.component( z .union([ z.string().check(z.meta({ examples: ['request-1'] })), z.number().check(z.meta({ examples: [1] })), z.null(), ]) .check( z.describe('Client-supplied JSON-RPC request id used to match responses to requests.'), z.meta({ examples: [1, 'request-1', null] }), ), 'JsonRpcId', ) /** JSON-RPC error payload. */ export const Error = OpenApi.component( z .object({ code: z .number() .check( z.int(), z.describe('Numeric JSON-RPC error code returned by the upstream RPC server.'), z.meta({ examples: [-32_601] }), ), data: z .optional(z.unknown()) .check( z.describe('Optional extra error details returned by the upstream RPC server.'), z.meta({ examples: [{ method: 'tempo_fundAddress' }] }), ), message: z .string() .check( z.describe('Human-readable JSON-RPC error message.'), z.meta({ examples: ['Method not found'] }), ), }) .check(z.describe('JSON-RPC error object returned when a method fails.')), 'JsonRpcError', ) /** JSON-RPC request payload. */ export const Request = OpenApi.component( z .object({ id: z .optional(Id) .check( z.describe( 'Client-supplied JSON-RPC request id used to match responses; omit for a notification.', ), z.meta({ examples: [1] }), ), jsonrpc: z .literal('2.0') .check( z.describe('JSON-RPC protocol version; Tempo uses `2.0`.'), z.meta({ examples: ['2.0'] }), ), method: z .string() .check( z.describe('Ethereum JSON-RPC method to call, such as `eth_blockNumber`.'), z.meta({ examples: ['eth_blockNumber'] }), ), params: z .optional(z.union([z.array(z.unknown()), z.record(z.string(), z.unknown())])) .check( z.describe('Positional array or named object parameters for the JSON-RPC method.'), z.meta({ examples: [[]] }), ), }) .check(z.describe('One Ethereum JSON-RPC request to send to Tempo.')), 'JsonRpcRequest', ) /** Successful JSON-RPC response payload. */ export const SuccessResponse = OpenApi.component( z .strictObject({ id: Id.check(z.meta({ examples: [1] })), jsonrpc: z .literal('2.0') .check( z.describe('JSON-RPC protocol version; Tempo uses `2.0`.'), z.meta({ examples: ['2.0'] }), ), result: z .unknown() .check( z.describe('Result returned by the upstream RPC method.'), z.meta({ examples: ['0x10f2c'] }), ), }) .check(z.describe('A successful JSON-RPC response returned by Tempo.')), 'JsonRpcSuccessResponse', ) /** Failed JSON-RPC response payload. */ export const ErrorResponse = OpenApi.component( z .strictObject({ error: Error.check(z.describe('JSON-RPC error returned by the upstream RPC server.')), id: Id.check(z.meta({ examples: [1] })), jsonrpc: z .literal('2.0') .check( z.describe('JSON-RPC protocol version; Tempo uses `2.0`.'), z.meta({ examples: ['2.0'] }), ), }) .check(z.describe('A failed JSON-RPC response returned by Tempo.')), 'JsonRpcErrorResponse', ) /** JSON-RPC success or error response payload. */ export const Response = OpenApi.component( z .union([SuccessResponse, ErrorResponse]) .check(z.describe('One JSON-RPC response returned by Tempo.')), 'JsonRpcResponse', ) /** JSON-RPC request or batch request payload. */ export const RequestBody = OpenApi.component( z .union([ Request, z.array(Request).check( z.minLength(1), z.meta({ examples: [ [ { id: 1, jsonrpc: '2.0', method: 'eth_blockNumber', params: [] }, { id: 2, jsonrpc: '2.0', method: 'eth_chainId', params: [] }, ], ], }), ), ]) .check( z.describe('A single JSON-RPC request or an array of requests for batch calls.'), z.meta({ examples: [{ id: 1, jsonrpc: '2.0', method: 'eth_blockNumber', params: [] }], }), ), 'JsonRpcRequestBody', ) /** JSON-RPC response or batch response payload. */ export const ResponseBody = OpenApi.component( z .union([ z .null() .check( z.describe('Empty JSON value returned for notification requests.'), z.meta({ examples: [null] }), ), Response, z.array(Response).check( z.minLength(1), z.meta({ examples: [ [ { id: 1, jsonrpc: '2.0', result: '0x10f2c' }, { id: 2, jsonrpc: '2.0', result: '0x1069' }, ], ], }), ), ]) .check(z.describe('A single JSON-RPC response or an array of responses for batch calls.')), 'JsonRpcResponseBody', ) } const inspectionRequest = z.object({ id: z.optional(schema.Id), jsonrpc: z.literal('2.0'), method: z.string(), params: z.optional(z.unknown()), }) const inspectionRequestBody = z.union([inspectionRequest, z.array(inspectionRequest)]) /** Creates the raw Tempo RPC passthrough handler. */ export function rpc() { const auth = Auth.policy({ apiKey: { scopes: ['data:read'] }, public: { rateLimit: { limit: 20, period: 'second' } }, }) return new Hono() .post( '/rpc', auth, OpenApi.documentJsonRequest(schema.RequestBody), describeRootRpcRoute(), (c) => handleRpc(c), ) .post( '/rpc/:chain{(mainnet|testnet|[0-9]+)}', auth, OpenApi.documentJsonRequest(schema.RequestBody), describeChainRpcRoute(), (c) => handleRpc(c), ) } async function handleRpc(c: Context) { if (Auth.narrowAccess) return Auth.accessError(c) const chainId = resolveChainId(c) if (!chainId) return Response.error(c, { code: 'chain_id_invalid', message: 'Invalid chain id', status: 400, }) const zone = c.get('zones').get(chainId) const zoneToken = getZoneToken(c) const apiKeyRequired = requiresApiKey(c) const inspectBody = apiKeyRequired || Boolean(zone) const inspection = inspectBody ? await inspectRpcRequest(c.req.raw) : undefined if (inspection && inspection.result === undefined) { return Response.error(c, { code: 'payload_too_large', message: 'RPC requests requiring method inspection must not exceed 64 KiB.', status: 413, }) } const body = inspection?.result?.ok ? inspection.result.value : undefined if (apiKeyRequired && !isPublicRequest(body)) return Response.error(c, { code: 'api_key_missing', message: 'Missing API key', status: 401, }) if (zone) { const requests = Array.isArray(body) ? body : [body] const allowed = requests.length > 0 && requests.every( (request) => isZoneReadMethod(request) || (isZoneWriteMethod(request) && (Boolean(zoneToken) || hasZoneWriteScope(c, chainId))) || (Boolean(zoneToken) && isZoneTokenMethod(request)), ) if (!allowed) return Response.error(c, { code: 'api_key_forbidden', message: 'Zone RPC permits read-only methods and signed transaction broadcasts. Broadcasts require a matching Zone write scope or a Zone token; account-scoped methods require a Zone token.', status: 403, }) } if (narrowPassthrough) return c.json( Response.validated(schema.ResponseBody, { id: null, jsonrpc: '2.0', result: null }), 200, ) if (narrowPassthrough) return c.body(null, 204) if (narrowPassthrough) return Response.error(c, { code: 'chain_id_unsupported', message: 'Unsupported chain id', status: 400, }) if (narrowPassthrough) return Response.upstream(c, new Error('RPC passthrough failed')) const notificationOnly = inspectBody ? isNotificationOnly(body) : await inspectNotificationOnly(c.req.raw) return passthrough(c, chainId, { body: inspection ? inspection.body : c.req.raw.body, notificationOnly, }) } function describeRootRpcRoute() { return OpenApi.describeRoute({ description: 'Proxies single or batch Ethereum JSON-RPC calls to Tempo. Zone API keys are method restricted; nonempty upstream responses pass through unchanged and are outside the stable REST contract.', operationId: 'rpcRequest', parameters: [zoneTokenParameter], responses: rpcResponses(), summary: 'Call JSON-RPC', tags: ['RPC'], }) } function describeChainRpcRoute() { return OpenApi.describeRoute({ description: 'Proxies single or batch Ethereum JSON-RPC calls to a selected Tempo chain. Zone API keys are method restricted; upstream responses pass through unchanged.', operationId: 'rpcRequestByChain', parameters: [ { description: 'Chain selector. Use the alias `mainnet` or `testnet`, or a numeric chain id (mainnet is `4217`).', example: 'testnet', in: 'path' as const, name: 'chain', required: true, schema: { pattern: '^(mainnet|testnet|[0-9]+)$', type: 'string' as const }, }, zoneTokenParameter, ], responses: rpcResponses(), summary: 'Call chain JSON-RPC', tags: ['RPC'], }) } function rpcResponses() { return { ...OpenApi.responses({ errors: { 400: { description: 'The chain id is invalid or this API deployment does not support it.', codes: ['chain_id_invalid', 'chain_id_unsupported'], }, 413: { codes: ['payload_too_large'], description: 'Zone and anonymous RPC requests must not exceed 64 KiB.', }, 502: 'The Tempo API could not reach the upstream RPC endpoint.', }, success: { description: 'JSON-RPC response returned by the upstream endpoint. Empty 200 responses are normalized to null; unexpected empty responses are reported as provider failures.', example: { id: 1, jsonrpc: '2.0', result: '0x10f2c' }, schema: schema.ResponseBody, }, }), 204: { description: 'Notification accepted with no response body.', headers: OpenApi.successHeaders, }, default: { description: 'An upstream HTTP response passed through unchanged, including its status, headers, content type, and body.', }, } } function requiresApiKey(c: Context) { if (!c.get('auth')) return false const principal = Auth.getPrincipal(c) return principal?.type !== 'api_key' && principal?.type !== 'super_admin' } function getZoneToken(c: Context) { const value = c.req.header(ZoneRpcAuthentication.headerName) return value?.trim() ? value : undefined } function isPublicRequest(body: unknown): boolean { const requests = Array.isArray(body) ? body : [body] return requests.length > 0 && requests.every((request) => isPublicMethod(request)) } function isPublicMethod(request: unknown): boolean { if (!request || typeof request !== 'object') return false const method = (request as Record)['method'] return typeof method === 'string' && method.startsWith('eth_') } function isNotificationOnly(body: unknown): boolean { const parsed = inspectionRequestBody.safeParse(body) if (!parsed.success) return false const requests = Array.isArray(parsed.data) ? parsed.data : [parsed.data] return requests.length > 0 && requests.every((request) => request.id === undefined) } async function inspectNotificationOnly(request: Request): Promise { const contentLength = Number(request.headers.get('content-length')) if (Number.isFinite(contentLength) && contentLength > maxRpcInspectionBytes) return false const body = request.clone().body if (!body) return false const reader = body.getReader() const chunks: Uint8Array[] = [] let size = 0 let complete = false while (size <= maxRpcInspectionBytes) { const read = await reader.read() if (read.done) { complete = true break } chunks.push(read.value) size += read.value.byteLength } if (!complete || size > maxRpcInspectionBytes) { void reader.cancel() return false } const bytes = new Uint8Array(size) let offset = 0 for (const chunk of chunks) { bytes.set(chunk, offset) offset += chunk.byteLength } try { return isNotificationOnly(JSON.parse(new TextDecoder().decode(bytes)) as unknown) } catch { return false } } async function inspectRpcRequest(request: Request): Promise { const contentLength = Number(request.headers.get('content-length')) if (Number.isFinite(contentLength) && contentLength > maxRpcInspectionBytes) { await request.body?.cancel() return { body: null, result: undefined } } if (!request.body) return { body: null, result: { empty: true, ok: false } } const reader = request.body.getReader() const chunks: Uint8Array[] = [] let size = 0 while (size <= maxRpcInspectionBytes) { const read = await reader.read() if (read.done) break size += read.value.byteLength if (size > maxRpcInspectionBytes) { await reader.cancel() return { body: null, result: undefined } } chunks.push(read.value) } const bytes = joinChunks(chunks, size) const body = new ReadableStream>({ start(controller) { controller.enqueue(bytes) controller.close() }, }) return { body, result: parseRpcBody(bytes) } } function hasZoneWriteScope(c: Context, chainId: number): boolean { const principal = Auth.getPrincipal(c) if (principal?.type !== 'api_key') return false const scopes = principal.apiKey.scopes return scopes.includes(Scope.wildcard) || scopes.includes(`zone:${chainId}:write`) } function isZoneReadMethod(request: unknown): boolean { if (!request || typeof request !== 'object') return false const method = (request as Record)['method'] return typeof method === 'string' && ZoneRpc.readMethods.has(method) } function isZoneWriteMethod(request: unknown): boolean { if (!request || typeof request !== 'object') return false const method = (request as Record)['method'] return typeof method === 'string' && ZoneRpc.writeMethods.has(method) } function isZoneTokenMethod(request: unknown): boolean { if (!request || typeof request !== 'object') return false const method = (request as Record)['method'] return typeof method === 'string' && ZoneRpc.tokenMethods.has(method) } async function passthrough( c: Context, chainId: z.output, options: passthrough.Options, ): Promise> { // Keep caller-selected values out of metric dimensions even if an upstream // resolver accepts a chain the deployment did not declare. const alertChainId = c.get('supportedChainIds').has(chainId) ? chainId : 0 const zone = c.get('zones').get(chainId) const zoneToken = getZoneToken(c) const rpc = c.get('rpc') const principal = Auth.getPrincipal(c) const upstream_rpc = !zone && principal?.type === 'public' && typeof rpc !== 'function' ? undefined : rpc // Anonymous callers use the built-in public RPC unless a resolver chooses a // different public upstream. const { basicAuth, bearerAuth, publicZoneUrl, zoneHeaders, url: resolvedUrl, } = Viem.resolveRpc(upstream_rpc, { chainId, principal, zone }) const usePublicZoneUrl = Boolean(zone && zoneToken) if (usePublicZoneUrl && !publicZoneUrl) { const cause = new Viem.PublicZoneRpcUnavailableError() c.set('providerFailure', rpcProviderFailure(cause, alertChainId)) return Response.upstream(c, cause) as never } const url = usePublicZoneUrl ? publicZoneUrl : resolvedUrl // The chain id is syntactically valid but this deployment has no RPC upstream // configured for it (no `rpc` config and no built-in default). if (!url) return Response.error(c, { code: 'chain_id_unsupported', message: 'Unsupported chain id', status: 400, }) as never const upstream = new URL(url) const headers = new Headers() for (const [name, value] of c.req.raw.headers) if (!strippedHeaders.has(name.toLowerCase())) headers.set(name, value) if (zoneHeaders) for (const [name, value] of new Headers(zoneHeaders)) headers.set(name, value) // Public Zone endpoints authenticate with the caller's token, never internal RPC credentials. if (!usePublicZoneUrl && basicAuth) headers.set('authorization', `Basic ${btoa(basicAuth)}`) else if (!usePublicZoneUrl && bearerAuth) headers.set('authorization', `Bearer ${bearerAuth}`) if (zone && zoneToken) headers.set(ZoneRpcAuthentication.headerName, zoneToken) // Node requires duplex for streaming; Workers types omit browser credential handling. type Init = RequestInit & { credentials: 'omit'; duplex?: 'half' } const init: Init = { // Credentials are explicit headers; automatic HTTP auth retries cannot replay a streamed body. credentials: 'omit', headers, method: c.req.method, redirect: 'manual', signal: AbortSignal.timeout(35_000), } if (options.body) { init.body = options.body init.duplex = 'half' } try { const response = await fetch(new Request(upstream, init)) // Malformed requests and public Zone authentication failures are caused // by caller input, not by the RPC provider. if (!response.ok && !(await isCallerRpcFailure(response, usePublicZoneUrl))) c.set('providerFailure', rpcProviderFailure(response, alertChainId)) if (response.status === 204 && !options.notificationOnly) c.set('providerFailure', { chainId: alertChainId, failure: 'payload', id: 'rpc', operation: 'request', }) // `Response.clone()` replaces the body stream, so capture it after classifying errors. let body = response.body if (response.status === 200) { const inspection = await inspectRpcBody(response) body = inspection.body if (inspection.result?.ok === false) { if (inspection.result.empty) { if (!options.notificationOnly) c.set('providerFailure', { chainId: alertChainId, failure: 'payload', id: 'rpc', operation: 'request', }) const headers = new Headers(response.headers) headers.delete('content-encoding') headers.delete('content-length') headers.delete('content-md5') headers.delete('digest') headers.delete('etag') headers.set('content-type', 'application/json') return c.newResponse('null', { headers, status: 200 }) as globalThis.Response & TypedResponse } c.set('providerFailure', { chainId: alertChainId, failure: 'payload', id: 'rpc', operation: 'request', }) } else if (inspection.result?.ok && inspection.result.value === null) { if (!options.notificationOnly) c.set('providerFailure', { chainId: alertChainId, failure: 'payload', id: 'rpc', operation: 'request', }) } else if (inspection.result?.ok) { const parsed = schema.ResponseBody.safeParse(inspection.result.value) if (!parsed.success) c.set('providerFailure', { chainId: alertChainId, failure: 'payload', id: 'rpc', operation: 'request', }) else { const rpc = Log.rpcErrors(parsed.data) if (rpc) c.set('rpcResponse', rpc) } } } // Stream nonempty upstream responses back verbatim so JSON-RPC bodies, // status codes, and headers survive exactly as clients expect. return c.newResponse(body, response) as globalThis.Response & TypedResponse } catch (cause) { // Timeouts and network failures become the standard 502 envelope instead // of an unhandled 500. c.set('providerFailure', rpcProviderFailure(cause, alertChainId)) return Response.upstream(c, cause) as never } } async function isCallerRpcFailure(response: globalThis.Response, usePublicZoneUrl: boolean) { const { status } = response if ( status === 400 || status === 413 || status === 415 || status === 422 || (usePublicZoneUrl && (status === 401 || status === 403)) ) return true if (status !== 403) return false // Orchestra returns HTTP 403 with JSON-RPC -32601 when its method filter // rejects caller input. Other 403s, including RPC auth failures, stay upstream failures. const body = await response .clone() .json() .catch(() => undefined) const parsed = schema.ResponseBody.safeParse(body) if (!parsed.success) return false const responses = Array.isArray(parsed.data) ? parsed.data : [parsed.data] return responses.every((response) => { if (!response || !('error' in response)) return false const error = schema.Error.safeParse(response.error) return error.success && error.data.code === -32_601 }) } declare namespace passthrough { /** Request facts needed to classify the upstream response. */ type Options = { /** Original request bytes, replayed after bounded inspection when required. */ body: ReadableStream> | null /** Whether every valid request in the payload omits an id. */ notificationOnly: boolean } } async function inspectRpcBody(response: Request | globalThis.Response): Promise { const contentLength = Number(response.headers.get('content-length')) if (Number.isFinite(contentLength) && contentLength > maxRpcInspectionBytes) return { body: response.body, result: undefined } if (!response.body) return { body: null, result: { empty: true, ok: false } } const reader = response.body.getReader() const chunks: Uint8Array[] = [] let size = 0 let complete = false while (size <= maxRpcInspectionBytes) { const read = await reader.read() if (read.done) { complete = true break } chunks.push(read.value) size += read.value.byteLength } let index = 0 const body = new ReadableStream>({ cancel: (reason) => reader.cancel(reason), async pull(controller) { const chunk = chunks[index] if (chunk) { index += 1 controller.enqueue(chunk) return } const read = await reader.read() if (read.done) controller.close() else controller.enqueue(read.value) }, }) if (!complete || size > maxRpcInspectionBytes) return { body, result: undefined } const bytes = joinChunks(chunks, size) return { body, result: parseRpcBody(bytes) } } function joinChunks(chunks: readonly Uint8Array[], size: number) { const bytes = new Uint8Array(size) let offset = 0 for (const chunk of chunks) { bytes.set(chunk, offset) offset += chunk.byteLength } return bytes } function parseRpcBody(bytes: Uint8Array): RpcInspection['result'] { try { return { ok: true, value: JSON.parse(new TextDecoder().decode(bytes)) as unknown } } catch { return { empty: bytes.byteLength === 0, ok: false } } } function rpcProviderFailure(cause: unknown, chainId: number): Log.ProviderFailure { const base = { chainId, id: 'rpc', operation: 'request' } as const if ( cause instanceof DOMException && (cause.name === 'AbortError' || cause.name === 'TimeoutError') ) return { ...base, failure: 'timeout' } if (cause instanceof globalThis.Response) { if (cause.status === 408) return { ...base, failure: 'timeout', status: cause.status } if (cause.status === 429) return { ...base, failure: 'rate_limit', status: cause.status } return { ...base, failure: 'http', status: cause.status } } if (cause instanceof TypeError) return { ...base, failure: 'network' } return { ...base, failure: 'unknown' } } function resolveChainId(c: Context) { const parameter = c.req.param('chain') if (parameter === undefined) return c.get('chainId') const parsed = Schema.ChainId.safeParse(parameter) return parsed.success ? parsed.data : undefined }