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 SponsorshipAttributions from '../../db/tables/sponsorshipAttributions.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 Zones from '../Zones.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 ZoneRpc from '../../internal/ZoneRpc.js' import * as Utils from '../../handlers/internal/utils.js' import * as Sponsorship from '../../handlers/internal/sponsorship.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. * Successful managed sponsorships include the transaction's immutable * `sponsorship_details.subsidized` value. * * ```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 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')), { // The canonical header wins when Privy also sends its legacy header. externalId: c.req.header('tempo-attribution-id')?.trim() || c.req.header('x-tempo-attribution-key')?.trim(), 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)) // 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: '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(c.get('zones').get(id)?.sourceId ?? 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' } 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( c.get('zones').get(event.chainId)?.sourceId ?? 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 resolvedAttribution = mainnet && attribution.environment === 'production' ? await resolveSponsorshipAttribution(db, attribution) : undefined const sponsorshipAttribution = (() => { if (resolvedAttribution !== 'project_id_invalid') return resolvedAttribution return rejectSponsorship({ chainId: event.chainId, message: 'Attribution rejected.', reason: 'project_id_invalid', }) })() 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, ...(sponsorshipAttribution === undefined ? {} : { sponsorshipAttributionId: sponsorshipAttribution.id }), 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: Zones.chainIds({ sourceId: Viem.chainId.mainnet, zones: c.get('zones').values(), }), max: Billing.toBaseUnits(settings.spendLimit), since: Billing.periodStart(settings.period), } try { if ( mainnet && attribution.environment === 'production' && sponsorshipAttribution !== undefined ) { const promotion = await SponsoredTransactions.reservePromotion( db, { ...input, billable: false, sponsorshipAttributionId: sponsorshipAttribution.id, }, { at: new Date().toISOString(), ...(billingLimit ? { billingLimit } : {}), chainIds: Zones.chainIds({ sourceId: Viem.chainId.mainnet, zones: c.get('zones').values(), }), }, ) if (promotion.status !== 'ineligible') return { subsidized: !promotion.record.billable } } const record = !billingLimit ? await SponsoredTransactions.upsert(db, input) : await SponsoredTransactions.reserve(db, input, billingLimit) return { subsidized: !record.billable } } catch (error) { if (error instanceof SponsoredTransactions.AttributionConflictError) rejectSponsorship({ chainId: event.chainId, message: 'Attribution rejected.', reason: attribution.externalId ? 'attribution_id_invalid' : 'project_id_invalid', }) if (error instanceof SponsoredTransactions.PromotionActivationError) failSponsorship({ chainId: event.chainId, message: 'Attribution promotion activation failed.', }) 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] const chainId_body = transaction && typeof transaction === 'object' && 'chainId' in transaction ? Utils.resolveChainId(transaction.chainId) : undefined const chainId = Utils.resolveChainId(c.req.param('chainId')) ?? chainId_body ?? c.get('chainId') // Only explicit fee-payer requests stay local; other raw-sign payloads would reach node-managed signing. const rawSponsorship = request.method === 'eth_signRawTransaction' && Hex.validate(transaction, { strict: true }) && (() => { try { return Sponsorship.requestsRawSponsorship(transaction) } catch { return false } })() if ( zoneToken && c.get('zones').has(chainId) && !ZoneRpc.readMethods.has(request.method) && !ZoneRpc.writeMethods.has(request.method) && !ZoneRpc.tokenMethods.has(request.method) && !rawSponsorship ) throw new RpcResponse.MethodNotSupportedError({ data: { code: 'api_key_forbidden' }, message: 'Method is not permitted for Zone RPC.', }) 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 request attribution within the authenticated 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.externalId && options.externalId.length > 128) return { attribution, error: 'attribution_id_invalid' } const projectId = await (async () => { if (!options.projectId) return principal.projectId if (principal.projectId !== undefined) return undefined const project = await Projects.get(db, options.projectId) if (!project || project.orgId !== principal.orgId) return undefined return project.id })() if (options.projectId && !projectId) return { attribution, error: 'project_id_invalid' } return { attribution: { ...attribution, ...(options.externalId ? { externalId: options.externalId } : {}), ...(projectId ? { projectId } : {}), }, } } /** Resolves promotion attribution only for an authorized sponsored transaction. */ async function resolveSponsorshipAttribution( db: Db.Db, attribution: resolveAttribution.Attribution, ): Promise { const projectId = attribution.projectId if (!attribution.externalId && !projectId) return undefined const [billing, organization] = await Promise.all([ Billing.status(db, attribution.orgId), Organizations.get(db, attribution.orgId), ]) if (!organization || billing !== 'active' || organization.sponsorshipSubsidyDurationDays === null) return undefined // Caller-selected headers may select existing promotions, but must never create fresh budgets. if (attribution.externalId) return SponsorshipAttributions.getExternal(db, { externalId: attribution.externalId, orgId: attribution.orgId, }) if (!projectId) return undefined const project = await Projects.get(db, projectId) if (!project || project.orgId !== attribution.orgId) return 'project_id_invalid' return SponsorshipAttributions.resolveProject(db, { orgId: attribution.orgId, projectId, }) } declare namespace resolveAttribution { /** Inputs for resolving API-key project attribution. */ type Options = { /** Optional external partner attribution id supplied by the caller. */ externalId?: string | undefined /** 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' /** External partner attribution id supplied by the caller. */ externalId?: string | undefined /** 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 /** Attribution error detected before transaction validation. */ error?: 'attribution_id_invalid' | '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' } /** 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 } }