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' // 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(), ]) // API-issued Zone read scopes never reach stateful or privileged RPC modules. const zoneReadMethods = new Set([ 'eth_blobBaseFee', 'eth_blockNumber', 'eth_call', 'eth_chainId', 'eth_createAccessList', 'eth_estimateGas', 'eth_feeHistory', 'eth_fillTransaction', 'eth_gasPrice', 'eth_getBalance', 'eth_getBlockByHash', 'eth_getBlockByNumber', 'eth_getBlockReceipts', 'eth_getBlockTransactionCountByHash', 'eth_getBlockTransactionCountByNumber', 'eth_getCode', 'eth_getLogs', 'eth_getProof', 'eth_getRawTransactionByHash', 'eth_getStorageAt', 'eth_getTransactionByBlockHashAndIndex', 'eth_getTransactionByBlockNumberAndIndex', 'eth_getTransactionByHash', 'eth_getTransactionCount', 'eth_getTransactionReceipt', 'eth_getUncleByBlockHashAndIndex', 'eth_getUncleByBlockNumberAndIndex', 'eth_getUncleCountByBlockHash', 'eth_getUncleCountByBlockNumber', 'eth_maxPriorityFeePerGas', 'eth_simulateV1', 'eth_syncing', 'net_listening', 'net_peerCount', 'net_version', 'web3_clientVersion', 'web3_sha3', 'zone_getEncryptionKey', 'zone_getZoneInfo', ]) // Zone write scopes broadcast caller-signed transactions only. Node-managed // accounts, signing, faucet, admin, and debug methods remain inaccessible. const zoneWriteMethods = new Set(['eth_sendRawTransaction', 'eth_sendRawTransactionSync']) const maxRpcInspectionBytes = 64 * 1_024 /** 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 = z .union([z.string(), z.number(), z.null()]) .check(z.describe('Client-supplied JSON-RPC request id used to match responses to requests.')) /** JSON-RPC error payload. */ export const Error = z .object({ code: z .number() .check( z.int(), z.describe('Numeric JSON-RPC error code returned by the upstream RPC server.'), ), data: z .optional(z.unknown()) .check(z.describe('Optional extra error details returned by the upstream RPC server.')), message: z.string().check(z.describe('Human-readable JSON-RPC error message.')), }) .check(z.describe('JSON-RPC error object returned when a method fails.')) /** JSON-RPC request payload. */ export const Request = z .object({ id: z .optional(Id) .check( z.describe('Client-supplied JSON-RPC request id used to match responses to requests.'), ), jsonrpc: z.literal('2.0').check(z.describe('JSON-RPC protocol version; Tempo uses `2.0`.')), method: z .string() .check(z.describe('Ethereum JSON-RPC method to call, such as `eth_blockNumber`.')), params: z .optional(z.unknown()) .check(z.describe('Parameters for the JSON-RPC method, usually an array.')), }) .check(z.describe('One Ethereum JSON-RPC request to send to Tempo.')) /** JSON-RPC response payload. */ export const Response = z .object({ error: z .optional(Error) .check(z.describe('JSON-RPC error object returned when a method fails.')), id: Id.check( z.describe('Client-supplied JSON-RPC request id used to match responses to requests.'), ), jsonrpc: z.literal('2.0').check(z.describe('JSON-RPC protocol version; Tempo uses `2.0`.')), result: z .optional(z.unknown()) .check(z.describe('Result returned by the upstream RPC method.')), }) .check( z.refine((response) => (response.error === undefined) !== (response.result === undefined), { error: 'A JSON-RPC response must contain exactly one of result or error.', }), z.describe('One JSON-RPC response returned by Tempo.'), ) /** JSON-RPC request or batch request payload. */ export const RequestBody = z .union([Request, z.array(Request)]) .check(z.describe('A single JSON-RPC request or an array of requests for batch calls.')) /** JSON-RPC response or batch response payload. */ export const ResponseBody = z .union([Response, z.array(Response).check(z.minLength(1))]) .check(z.describe('A single JSON-RPC response or an array of responses for batch calls.')) } /** Creates the raw Tempo RPC passthrough handler. */ export function rpc() { return new Hono().post( '/rpc/:chain{(mainnet|testnet|[0-9]+)}?', Auth.policy({ apiKey: { scopes: ['data:read'] }, public: { rateLimit: { limit: 20, period: 'second' } }, }), OpenApi.describeRoute({ description: 'Sends a single or batch Ethereum JSON-RPC request directly to Tempo. Use `/rpc/testnet`, `/rpc/mainnet`, or `/rpc/:chain` to choose a chain. Zone read scopes permit read-only methods; Zone write scopes also permit signed transaction broadcasts. A Zone token in `X-Authorization-Token` is forwarded to the authenticated Zone RPC. This passthrough is not part of the stable API contract and is supported on a best-effort basis only. It has no compatibility, latency, availability, or data-freshness guarantees, and breaking changes may happen with limited notice.', operationId: 'rpcRequest', parameters: [ { description: 'Optional 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: false, schema: { pattern: '^(mainnet|testnet|[0-9]+)$', type: 'string' as const }, }, ], requestBody: { content: { 'application/json': { example: { jsonrpc: '2.0', id: 1, method: 'eth_blockNumber', params: [] }, }, }, required: true, }, responses: 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'], }, 502: 'The upstream Tempo RPC endpoint could not complete the request.', }, success: { description: 'JSON-RPC response returned by the upstream Tempo RPC endpoint.', example: { jsonrpc: '2.0', id: 1, result: '0x10f2c' }, schema: schema.ResponseBody, }, }), summary: 'Call JSON-RPC', tags: ['RPC'], }), async (c) => { 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 && !zoneToken) const body = inspectBody ? await c.req.raw .clone() .json() .catch(() => undefined) : undefined if (apiKeyRequired && !isPublicRequest(body)) return Response.error(c, { code: 'api_key_missing', message: 'Missing API key', status: 401, }) if (zone && !zoneToken) { const requests = Array.isArray(body) ? body : [body] const allowed = requests.length > 0 && requests.every( (request) => isZoneReadMethod(request) || (isZoneWriteMethod(request) && hasZoneWriteScope(c, chainId)), ) if (!allowed) return Response.error(c, { code: 'api_key_forbidden', message: 'Zone API keys may call read-only RPC methods. A matching Zone write scope also permits signed transaction broadcasts; pass a Zone token for other methods.', status: 403, }) } if (narrowPassthrough) return c.json( Response.validated(schema.ResponseBody, { id: null, jsonrpc: '2.0', result: null }), 200, ) const notificationOnly = inspectBody ? isNotificationOnly(body) : await inspectNotificationOnly(c.req.raw) return passthrough(c, chainId, { notificationOnly }) }, ) } 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 = schema.RequestBody.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 } } 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' && zoneReadMethods.has(method) } function isZoneWriteMethod(request: unknown): boolean { if (!request || typeof request !== 'object') return false const method = (request as Record)['method'] return typeof method === 'string' && zoneWriteMethods.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) 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) // `duplex` is required when streaming a request body, but is not yet in // the DOM `RequestInit` type. const init: RequestInit & { duplex?: 'half' } = { headers, method: c.req.method, redirect: 'manual', signal: AbortSignal.timeout(35_000), } if (c.req.raw.body) { init.body = c.req.raw.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)) // `Response.clone()` replaces the body stream, so capture it after classifying errors. let body = response.body if (response.status === 200) { const inspection = await inspectRpcResponse(response) body = inspection.body if (inspection.result?.ok === false && !(inspection.result.empty && 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 the upstream response 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) => response.error?.code === -32_601) } declare namespace passthrough { /** Request facts needed to classify the upstream response. */ type Options = { /** Whether every valid request in the payload omits an id. */ notificationOnly: boolean } } async function inspectRpcResponse(response: 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 = new Uint8Array(size) let offset = 0 for (const chunk of chunks) { bytes.set(chunk, offset) offset += chunk.byteLength } const result = (() => { try { return { ok: true as const, value: JSON.parse(new TextDecoder().decode(bytes)) as unknown } } catch { return { empty: size === 0, ok: false as const } } })() return { body, result } } 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 }