import { Hono } from 'hono' import { createMiddleware } from 'hono/factory' import { Hash, Hex, RpcResponse } from 'ox' import { ZoneRpcAuthentication } from 'ox/tempo' import { Addresses, TokenId, Transaction } from 'viem/tempo' import type * as App from '../../App.js' import * as BillingSettings from '../../db/tables/billingSettings.js' import * as Db from '../../db/Db.js' import * as Organizations from '../../db/tables/organizations.js' import * as Projects from '../../db/tables/projects.js' import * as SponsoredTransactions from '../../db/tables/sponsoredTransactions.js' import * as Fees from '../../internal/Fees.js' import * as Log from '../../internal/Log.js' import * as Billing from '../management/Billing.js' import * as Handler from '../../Handler.js' import * as Auth from '../../internal/Auth.js' import * as OpenApi from '../../internal/OpenApi.js' import * as Path from '../../internal/Path.js' import * as VerifiedTokens from '../../internal/VerifiedTokens.js' import * as Viem from '../../internal/Viem.js' import * as Scope from '../../Scope.js' /** * The relay route group exposes wallet features at `/rpc/relay` and fee * sponsorship at `/rpc/sponsor`. Mount it after `data()`, which publishes the * verified-token and zone context used by relay handlers. * * ```ts * App.create(options).route('/', data()).route('/', relay()) * ``` */ export function relay(options: relay.Options = {}) { const handler_relay = middleware({ ...options, features: 'all', path: 'rpc/relay' }) const handler_sponsor = middleware({ ...options, path: 'rpc/sponsor' }) return new Hono() .use( '/rpc/relay', Auth.policy({ apiKey: { scopes: ['rpc-relay:read'] }, mpp: false, public: false }), OpenApi.describeRoute({ hide: true, summary: 'Relay RPC' }), ) .use( '/rpc/relay/*', Auth.policy({ apiKey: { scopes: ['rpc-relay:read'] }, mpp: false, public: false }), OpenApi.describeRoute({ hide: true, summary: 'Relay RPC' }), ) .all('/rpc/relay', handler_relay) .all('/rpc/relay/:chainId', handler_relay) .use( '/rpc/sponsor', Auth.policy({ apiKey: { scopes: ['rpc-relay:sponsor'] }, mpp: false, public: false }), OpenApi.describeRoute({ hide: true, summary: 'Sponsor RPC' }), ) .use( '/rpc/sponsor/*', Auth.policy({ apiKey: { scopes: ['rpc-relay:sponsor'] }, mpp: false, public: false }), OpenApi.describeRoute({ hide: true, summary: 'Sponsor RPC' }), ) .all('/rpc/sponsor', handler_sponsor) .all('/rpc/sponsor/:chainId', handler_sponsor) } export declare namespace relay { /** Options for the relay route group. */ type Options = { feePayer?: | (Pick< NonNullable, 'account' | 'feeToken' | 'name' | 'url' > & { /** Per-transaction fee cap (decimal USD string); orgs tighten it, never loosen. @default '1.00' */ txFeeLimit?: string | undefined }) | undefined } } // Builds request-aware RPC middleware. Viem clients are memoized upstream, so // constructing `Handler.relay` per request stays cheap. function middleware(options: middleware.Options) { // The sponsor mount promises a fee-payer signature. The relay mount may // fall back to self-payment when policy refuses sponsorship. const sponsorshipRequired = options.path === 'rpc/sponsor' // Platform-wide per-transaction cap; org settings tighten it, never loosen. const txFeeLimit = options.feePayer ? Billing.toBaseUnits(options.feePayer.txFeeLimit ?? '1.00') : 0n return createMiddleware(async (c) => { const principal = Auth.getPrincipal(c) const requestedAt = new Date().toISOString() const verifiedTokens = c.get('verifiedTokens') const zoneToken_header = c.req.header(ZoneRpcAuthentication.headerName) const zoneToken = zoneToken_header?.trim() ? zoneToken_header : undefined let chainId: number | null | undefined let internalErrors = 0 let reason: Log.SponsorshipReason | null | undefined let rejections = 0 const reasons = new Set() const requests: rejectionDiagnostics.Request[] = [] const recordRejection = (options: middleware.Rejection) => { const id = options.chainId !== undefined && Number.isSafeInteger(options.chainId) && options.chainId >= 0 ? options.chainId : null if (chainId !== null) chainId = chainId === undefined || chainId === id ? id : null if (options.reason === 'internal_error') internalErrors = Math.min(internalErrors + 1, 65_535) if (reason !== null) reason = reason === undefined || reason === options.reason ? options.reason : null reasons.add(options.reason) rejections = Math.min(rejections + 1, 65_535) } const rejectSponsorship = (options: middleware.Refusal): never => { recordRejection({ chainId: options.chainId, reason: options.reason }) throw new RpcResponse.InvalidParamsError({ data: { code: options.reason }, message: options.message, }) } const failSponsorship = (options: middleware.Failure): never => { recordRejection({ chainId: options.chainId, reason: 'internal_error' }) // Capability-form fills serialize `Error.message`, so retain the // internal detail only as a cause. throw new Error('Internal error', { cause: new Error(options.message) }) } const feePayer = await (async () => { if (!options.feePayer) return undefined const resolved = await resolveAttribution(Db.get(c.get('db')), { principal, projectId: c.req.header('tempo-project-id')?.trim(), }) const attribution = resolved?.attribution const attributionError = resolved?.error const sponsorable = attribution !== undefined && principal?.type === 'api_key' && (principal.apiKey.scopes.includes('rpc-relay:sponsor') || principal.apiKey.scopes.includes(Scope.wildcard)) let promotionState_promise: Promise const promotionState = () => (promotionState_promise ??= (async () => { if (!attribution) return { status: 'inactive' } const db = Db.get(c.get('db')) const organization = await Organizations.get(db, attribution.orgId) if (!organization || organization.sponsorshipSubsidyDurationDays === null) return { status: 'inactive' } // Missing project attribution opts out of the subsidy but must not interrupt sponsorship. if (!attribution.projectId) return { status: 'inactive' } const project = await Projects.get(db, attribution.projectId) if (!project) return { message: 'Attributed project disappeared.', status: 'error' } if (project.orgId !== attribution.orgId) return { message: 'Attributed project changed ownership.', status: 'error' } if ( (project.sponsorshipSubsidyStartsAt === null) !== (project.sponsorshipSubsidyEndsAt === null) ) return { message: 'Project promotion window is inconsistent.', status: 'error' } if ( project.sponsorshipSubsidyEndsAt !== null && Date.parse(requestedAt) >= Date.parse(project.sponsorshipSubsidyEndsAt) ) return { status: 'inactive' } return { status: 'active' } })()) const validatePromotion = async (chainId: number) => { const state = await promotionState() if (state.status === 'error') failSponsorship({ chainId, message: state.message }) } // Lifted so validation and the sponsored-token default read one value. const feeToken_sponsor = options.feePayer.feeToken ?? Addresses.pathUsd return { ...options.feePayer, feeToken: feeToken_sponsor, validate: async (transaction) => { // Allowlist the chains this deployment serves rather than denylisting // only mainnet: the relay resolves chainId from the request body, so // the query-string chain guard never sees it. const id = Number(transaction.chainId) if (attributionError) rejectSponsorship({ chainId: id, message: 'Project attribution rejected.', reason: attributionError, }) if (!attribution) { if (sponsorshipRequired) rejectSponsorship({ chainId: id, message: 'Sponsorship rejected.', reason: 'api_key_required', }) return false } if (!sponsorable) { if (sponsorshipRequired) failSponsorship({ chainId: id, message: 'Sponsor route accepted an ineligible API key.', }) return false } if (!c.get('supportedChainIds').has(id)) { if (sponsorshipRequired) rejectSponsorship({ chainId: id, message: 'Sponsorship rejected.', reason: 'chain_id_unsupported', }) return false } if (!Viem.isMainnet(id)) return true // Mainnet is production-only: a sandbox key resolves chainId from the // request body, so the auth-layer sandbox/mainnet guard never sees // it. Refuse here, or sandbox spend would sponsor mainnet unbilled. if (attribution.environment !== 'production') { if (sponsorshipRequired) rejectSponsorship({ chainId: id, message: 'Sponsorship rejected.', reason: 'production_api_key_required', }) return false } const db = Db.get(c.get('db')) // Enforcement reads hit the primary, never the cached replica: a // stale `past_due` or another isolate's state must not keep the gate open. const billing = await Billing.status(db, attribution.orgId) if (billing === 'past_due') { if (sponsorshipRequired) rejectSponsorship({ chainId: id, message: 'Billing past due.', reason: 'billing_past_due', }) return 'billing_past_due' } if (billing !== 'active') { if (sponsorshipRequired) rejectSponsorship({ chainId: id, message: 'Billing required.', reason: 'billing_required', }) return 'billing_required' } await validatePromotion(id) const feeToken = (() => { try { return TokenId.toAddress(transaction.feeToken ?? feeToken_sponsor) } catch { return undefined } })() const verified = feeToken ? (await VerifiedTokens.read(Db.get(c.get('dbCached')), id))?.byAddress.get(feeToken.toLowerCase()) // prettier-ignore : undefined if ( !verified || verified.currency.toLowerCase() !== 'usd' || verified.decimals !== Fees.tokenDecimals ) { if (sponsorshipRequired) rejectSponsorship({ chainId: id, message: 'Fee token unsupported.', reason: 'fee_token_unsupported', }) return 'fee_token_unsupported' } // Fail closed: a transaction whose signed cap cannot be computed // cannot be bounded, so it never sponsors on mainnet. const feeMax = Fees.maxOf(transaction) if (feeMax === undefined) { if (sponsorshipRequired) rejectSponsorship({ chainId: id, message: 'Transaction fee limit exceeded.', reason: 'tx_fee_limit_exceeded', }) return 'tx_fee_limit_exceeded' } const settings = await BillingSettings.get(db, attribution.orgId) const cap = (() => { if (settings?.txFeeLimit == null) return txFeeLimit const org = Billing.toBaseUnits(settings.txFeeLimit) return org < txFeeLimit ? org : txFeeLimit })() if (feeMax > cap) { if (sponsorshipRequired) rejectSponsorship({ chainId: id, message: 'Transaction fee limit exceeded.', reason: 'tx_fee_limit_exceeded', }) return 'tx_fee_limit_exceeded' } // The period spend limit is enforced atomically at recording time // (`onSponsored` → `SponsoredTransactions.reserve`); a check here // would be a check-then-act race a burst could slip through. return true }, onSponsored: async (event) => { if (!attribution) return const mainnet = Viem.isMainnet(event.chainId) // Defense in depth: `validate` already refuses non-production // mainnet, so reaching here with that pair is a wiring bug. if (mainnet && attribution.environment !== 'production') failSponsorship({ chainId: event.chainId, message: 'Non-production key reached mainnet sponsorship recording.', }) // The cap recomputes from canonical envelope bytes, mirroring the // reconciliation convention; pending rows charge it to spend limits. const feeMax = Fees.maxOf(Transaction.deserialize(event.transaction as `0x76${string}`)) const db = Db.get(c.get('db')) const feeToken = (() => { try { return event.feeToken !== undefined ? TokenId.toAddress(event.feeToken) : undefined } catch { return undefined } })() const currency = feeToken ? (await VerifiedTokens.read(Db.get(c.get('dbCached')), event.chainId))?.byAddress .get(feeToken.toLowerCase()) ?.currency.toLowerCase() : undefined const input = { apiKeyId: attribution.apiKeyId, billable: mainnet && attribution.environment === 'production', chainId: event.chainId, environment: attribution.environment, orgId: attribution.orgId, ...(attribution.projectId === undefined ? {} : { projectId: attribution.projectId }), signPayload: event.signPayload, transaction: event.transaction, ...(currency ? { currency } : {}), ...(feeMax !== undefined ? { feeMax: feeMax.toString() } : {}), ...(feeToken ? { feeToken } : {}), ...(event.transactionHash ? { transactionHash: event.transactionHash } : {}), } // Re-read settings on the authoritative path: the limit may have // changed since `validate`, and only mainnet spend is limited. const settings = mainnet ? await BillingSettings.get(db, attribution.orgId) : undefined const billingLimit = settings?.spendLimit == null ? undefined : { chainIds: [Viem.chainId.mainnet], max: Billing.toBaseUnits(settings.spendLimit), since: Billing.periodStart(settings.period), } if ( mainnet && attribution.environment === 'production' && attribution.projectId !== undefined ) try { const promotion = await SponsoredTransactions.reservePromotion( db, { ...input, billable: false }, { at: new Date().toISOString(), ...(billingLimit ? { billingLimit } : {}), chainIds: [Viem.chainId.mainnet], projectId: attribution.projectId, }, ) if (promotion.status !== 'ineligible') return } catch (error) { if (error instanceof SponsoredTransactions.AttributionConflictError) rejectSponsorship({ chainId: event.chainId, message: 'Project attribution rejected.', reason: 'project_id_invalid', }) if (error instanceof SponsoredTransactions.PromotionActivationError) failSponsorship({ chainId: event.chainId, message: 'Project promotion activation failed.', }) if (error instanceof SponsoredTransactions.PeriodSpendLimitError) rejectSponsorship({ chainId: event.chainId, message: 'Spend limit exceeded.', reason: 'spend_limit_exceeded', }) throw error } if (!billingLimit) return void (await SponsoredTransactions.upsert(db, input)) try { await SponsoredTransactions.reserve(db, input, billingLimit) } catch (error) { if (error instanceof SponsoredTransactions.PeriodSpendLimitError) rejectSponsorship({ chainId: event.chainId, message: 'Spend limit exceeded.', reason: 'spend_limit_exceeded', }) throw error } }, } satisfies Handler.relay.Options['feePayer'] })() const authorizeChainId = (chainId: number | undefined) => { const resolved = chainId ?? c.get('chainId') if (!c.get('supportedChainIds').has(resolved)) { if (sponsorshipRequired) rejectSponsorship({ chainId: resolved, message: 'Unsupported chain id.', reason: 'chain_id_unsupported', }) throw new RpcResponse.InvalidParamsError({ message: 'Unsupported chain id.' }) } if (c.get('zones').has(resolved)) { const scopes = principal?.type === 'api_key' ? principal.apiKey.scopes : undefined const allowed = scopes?.includes(Scope.wildcard) || scopes?.includes(`zone:${resolved}:read`) || zoneToken !== undefined if (allowed) return resolved const message = `Chain id ${resolved} is a zone. Use an API key granting \`zone:${resolved}:read\`, or pass a Zone token via \`X-Authorization-Token\`.` if (sponsorshipRequired) rejectSponsorship({ chainId: resolved, message, reason: 'api_key_forbidden', }) throw new RpcResponse.InvalidParamsError({ data: { code: 'api_key_forbidden' }, message, }) } return resolved } const zoneClients = new Map() const getClient = (chainId: number | undefined) => { const resolved = authorizeChainId(chainId) as Viem.ChainId const zone = c.get('zones').get(resolved) if (!zone || !zoneToken) return c.get('getClient')(resolved) const existing = zoneClients.get(resolved) if (existing) return existing const client = Viem.getClient({ chainId: resolved, principal, rpc: c.get('rpc'), zone, zoneToken, }) zoneClients.set(resolved, client) return client } // Dispatching a standalone Hono via `fetch(c.req.raw)` bypasses Hono's // mount-path stripping, so its routes use the full external path. const handler = Handler.relay({ cache: c.get('store'), ...(options.features ? { features: options.features } : {}), getClient, onRequest: async (request) => { const method = sponsorshipMethod(request.method) const params = 'params' in request && Array.isArray(request.params) ? request.params : [] const transaction = params[0] requests.push({ id: rpcId('id' in request ? request.id : undefined), ...(method === undefined ? {} : { method }), ...(method !== undefined && requests.length === 0 && method !== 'eth_fillTransaction' && Hex.validate(transaction, { strict: true }) ? { payloadHash: Hash.keccak256(transaction) } : {}), }) }, path: Path.join(c.get('basePath'), options.path), ...(feePayer ? { feePayer } : {}), ...(verifiedTokens ? { resolveTokens: async (chainId: number) => { const snapshot = await VerifiedTokens.read(Db.get(c.get('dbCached')), chainId) return snapshot?.list.map((token) => token.address) ?? [] }, } : {}), }) const response = await handler.fetch(c.req.raw) const body: unknown = await response .clone() .json() .catch(() => undefined) const rpc = Log.rpcErrors(body) if (rpc) c.set('rpcResponse', rpc) if (rejections > 0) { const matched = rejectionDiagnostics({ body, reasons, requests, }) // Capability-form fill refusals are successful JSON-RPC responses, so // only a non-batch request can be attributed without error matching. const rejected = matched.length === 0 && !Array.isArray(body) && requests.length === 1 ? requests : matched const methods = new Set(rejected.map((request) => request.method)) const method = methods.size === 1 ? rejected[0]?.method : undefined const payloadHash = !Array.isArray(body) && rejected.length === 1 ? rejected[0]?.payloadHash : undefined c.set('sponsorship', { ...(chainId === null || chainId === undefined ? {} : { chainId }), ...(internalErrors === 0 ? {} : { internalErrors }), ...(method === undefined ? {} : { method }), outcome: 'rejected', ...(payloadHash === undefined ? {} : { payloadHash }), ...(reason === null || reason === undefined ? {} : { reason }), rejections, }) } return response }) } function rejectionDiagnostics(options: rejectionDiagnostics.Options) { const requests = new Map() for (const request of options.requests) { if (requests.has(request.id)) requests.set(request.id, null) else requests.set(request.id, request) } const responses = Array.isArray(options.body) ? options.body : [options.body] return responses.flatMap((response) => { if (!response || typeof response !== 'object' || Array.isArray(response)) return [] const record = response as Record if (record['jsonrpc'] !== '2.0') return [] const error = record['error'] if (!error || typeof error !== 'object' || Array.isArray(error)) return [] const data = (error as Record)['data'] if (!data || typeof data !== 'object' || Array.isArray(data)) return [] const code = (data as Record)['code'] if (typeof code !== 'string' || !options.reasons.has(code as Log.SponsorshipReason)) return [] const request = requests.get(rpcId(record['id'])) return request?.method === undefined ? [] : [request] }) } declare namespace rejectionDiagnostics { /** JSON-RPC request id used only for request-response matching. */ type Id = number | string | null | undefined /** Inputs for matching request metadata to sponsorship rejection responses. */ type Options = { /** Parsed single or batch JSON-RPC response. */ body: unknown /** Sponsorship codes recorded while handling the request. */ reasons: ReadonlySet /** Bounded request metadata in request order. */ requests: readonly Request[] } /** Bounded metadata for one valid JSON-RPC request. */ type Request = { /** JSON-RPC request id, retained only for response matching. */ id: Id /** Sponsorship-capable JSON-RPC method. */ method?: Log.SponsorshipMethod | undefined /** Raw transaction hash, captured only for the first valid request. */ payloadHash?: Hex.Hex | undefined } } function rpcId(value: unknown): rejectionDiagnostics.Id { if (typeof value === 'number' || typeof value === 'string' || value === null) return value return undefined } function sponsorshipMethod(value: unknown): Log.SponsorshipMethod | undefined { switch (value) { case 'eth_fillTransaction': case 'eth_sendRawTransaction': case 'eth_sendRawTransactionSync': case 'eth_signRawTransaction': return value default: return undefined } } /** Resolves optional request-level project attribution within the API key's organization. */ async function resolveAttribution( db: Db.Db, options: resolveAttribution.Options, ): Promise { if (options.principal?.type !== 'api_key') return undefined const { principal } = options const attribution = { apiKeyId: principal.id, environment: principal.environment, orgId: principal.orgId, ...(principal.projectId === undefined ? {} : { projectId: principal.projectId }), } if (!options.projectId) return { attribution } if (principal.projectId !== undefined) return { attribution, error: 'project_id_invalid' } const project = await Projects.get(db, options.projectId) if (!project || project.orgId !== principal.orgId) return { attribution, error: 'project_id_invalid' } return { attribution: { ...attribution, projectId: project.id } } } declare namespace resolveAttribution { /** Inputs for resolving API-key project attribution. */ type Options = { /** Authenticated request principal. */ principal: Auth.Principal | null /** Optional project id supplied by the caller. */ projectId?: string | undefined } /** Resolved API-key attribution. */ type Attribution = { /** API key id. */ apiKeyId: string /** API key environment. */ environment: 'production' | 'sandbox' /** API key organization id. */ orgId: string /** Attributed project id. */ projectId?: string | undefined } /** Attribution result with an optional policy error. */ type Result = { /** Attribution retained for request diagnostics. */ attribution: Attribution /** Project attribution error detected before transaction validation. */ error?: 'project_id_invalid' | undefined } } declare namespace middleware { /** One invariant sponsorship failure. */ type Failure = { /** Candidate transaction chain. */ chainId: number /** Internal error message hidden from the client. */ message: string } /** Internal relay mount configuration. */ type Options = relay.Options & { /** Optional wallet feature bundle. */ features?: Handler.relay.Options['features'] /** RPC mount path. */ path: 'rpc/relay' | 'rpc/sponsor' } /** Cached promotion validation state; request accounting is applied per batch item. */ type PromotionState = { status: 'active' | 'inactive' } | { message: string; status: 'error' } /** One sponsorship rejection recorded for request logging. */ type Rejection = { /** Candidate transaction chain, validated before it enters the log entry. */ chainId?: number | undefined /** Stable client error code. */ reason: Log.SponsorshipReason } /** One client-visible sponsorship refusal. */ type Refusal = { /** Candidate transaction chain. */ chainId?: number | undefined /** Client-safe error message. */ message: string /** Stable client error code. */ reason: Exclude } }