import { Hash, type Hex, RpcResponse, Signature } from 'ox' import { TxEnvelopeTempo } from 'ox/tempo' import type { Address, Client } from 'viem' import type { LocalAccount } from 'viem/accounts' import { Transaction } from 'viem/tempo' import * as Utils from './utils.js' /** * Sponsorship approval callback. The candidate always carries its `chainId`: * raw submissions deserialize it, and fills validate only prepared/filled * transactions. Anything but `true` refuses; raw submissions surface a named * reason to the caller while fills fall back to an unsponsored fill. */ export type Validate = ( request: Transaction.TransactionRequest & { chainId?: number | Hex.Hex | undefined }, ) => Validation | Promise /** A sponsorship verdict: `true` sponsors; `false` or a named reason refuses. */ export type Validation = | boolean | 'billing_past_due' | 'billing_required' | 'fee_token_unsupported' | 'spend_limit_exceeded' | 'tx_fee_limit_exceeded' /** Refusal messages keyed by named validation reason. */ const refusalMessages = { billing_past_due: 'Billing past due.', billing_required: 'Billing required.', fee_token_unsupported: 'Fee token unsupported.', spend_limit_exceeded: 'Spend limit exceeded.', tx_fee_limit_exceeded: 'Transaction fee limit exceeded.', } as const satisfies Record, string> /** Returns sponsor metadata for `eth_fillTransaction` responses. */ export function getSponsor(options: getSponsor.Options): getSponsor.ReturnType { const { account, name, url } = options return { address: account.address, ...(name ? { name } : {}), ...(url ? { url } : {}), } } export declare namespace getSponsor { type Options = { /** Account used for sponsorship. */ account: LocalAccount /** Optional display name. */ name?: string | undefined /** Optional display URL. */ url?: string | undefined } type ReturnType = { /** Sponsor address. */ address: Address /** Sponsor display name. */ name?: string | undefined /** Sponsor display URL. */ url?: string | undefined } } /** Returns whether the fee payer approves a filled transaction. */ export async function shouldSponsor(options: shouldSponsor.Options) { const { sender, transaction, validate } = options if (!validate) return true // Named refusal reasons collapse to false: fills fall back to an // unsponsored fill instead of surfacing the reason. const verdict = await validate({ ...transaction, from: sender, } as Transaction.TransactionRequest) return verdict === true } export declare namespace shouldSponsor { type Options = { /** Sender address from the original request. */ sender?: Address | undefined /** Filled transaction to validate. */ transaction: Record /** Optional sponsorship approval callback. */ validate?: Validate | undefined } } /** Returns whether a raw Tempo transaction is explicitly requesting sponsorship. */ export function requestsRawSponsorship(serialized: `0x${string}`) { if (!Utils.isSerializedTempoTransaction(serialized)) return false const transaction = Transaction.deserialize(serialized) return 'feePayerSignature' in transaction && transaction.feePayerSignature === null } /** Returns `true` when a fill request already has the fields needed for sponsorship signing. */ export function isPreparedTransaction(value: Record) { return ( typeof value['from'] === 'string' && typeof Utils.resolveChainId(value['chainId']) === 'number' && typeof value['gas'] !== 'undefined' && typeof value['nonce'] !== 'undefined' && (typeof value['maxFeePerGas'] !== 'undefined' || typeof value['gasPrice'] !== 'undefined') ) } /** Signs a filled transaction as the fee payer. */ export async function sign(options: sign.Options) { const { account, transaction, sender } = options const from = (transaction['from'] as Address | undefined) ?? sender const { signature: _, ...withoutSenderSig } = transaction const prepared = { ...withoutSenderSig, from } if (!prepared.from) throw new RpcResponse.InvalidParamsError({ message: 'Transaction sender must be provided before fee payer signing.', }) if (!account.sign) throw new Error('Fee payer account cannot sign transactions.') const chainId = Utils.resolveChainId(transaction['chainId']) if (chainId === undefined) throw new RpcResponse.InvalidParamsError({ message: 'Transaction chainId must be provided before fee payer signing.', }) const envelope = TxEnvelopeTempo.from(prepared as never) const signPayload = TxEnvelopeTempo.getFeePayerSignPayload(envelope, { sender: prepared.from }) const feePayerSignature = Signature.from(await account.sign({ hash: signPayload })) // Awaited before the signature leaves the relay: a fill-signed envelope can // broadcast anywhere (even directly to a node), so the commitment records as // an intent — no transaction hash until the sender signs. const feeToken = transaction['feeToken'] as Address | null | undefined await options.onSponsored?.({ chainId, ...(feeToken ? { feeToken } : {}), method: 'eth_fillTransaction', sender: prepared.from, signPayload, transaction: TxEnvelopeTempo.serialize(envelope, { feePayerSignature }), }) return { ...prepared, feePayerSignature } } export declare namespace sign { type Options = { /** Account used as the fee payer. */ account: LocalAccount /** Called once the fee payer has signed the fill. Awaited: a throw aborts the fill. */ onSponsored?: ((event: SponsoredEvent) => Promise | void) | undefined /** Filled transaction to sign. */ transaction: Record /** Sender address from the original request. */ sender?: Address | undefined } } /** Handles `eth_signRawTransaction` and broadcast methods for sponsored Tempo transactions. */ export async function handleRawTransaction(options: handleRawTransaction.Options) { const { account, feeToken: sponsorFeeToken, getClient, method, request, validate } = options const serialized = request.params?.[0] as `0x76${string}` | undefined if (!Utils.isSerializedTempoTransaction(serialized)) throw new RpcResponse.InvalidParamsError({ message: 'Only Tempo (0x76/0x78) transactions are supported.', }) const transaction = Transaction.deserialize(serialized) // Prefer sender recovered from raw envelope; multisig finalize path supplies fallback sender. const sender = transaction.from ?? options.sender // Sponsorship only applies after sender has signed original transaction. if (!transaction.signature || !sender) throw new RpcResponse.InvalidParamsError({ message: 'Transaction must be signed by the sender before fee payer signing.', }) if (!account.sign) throw new Error('Fee payer account cannot sign transactions.') const client = getClient(transaction.chainId) const feeToken_chain = (client.chain as { feeToken?: Address | undefined } | undefined)?.feeToken const feeToken = (transaction.feeToken as Address | null | undefined) ?? sponsorFeeToken ?? (await options.getFeeToken?.(transaction.chainId)) ?? feeToken_chain const transaction_sponsored = feeToken ? { ...transaction, feeToken } : transaction if (validate) { const verdict = await validate(transaction_sponsored as Transaction.TransactionRequest) if (verdict !== true) throw new RpcResponse.InvalidParamsError( verdict === false ? { message: 'Sponsorship rejected.' } : { data: { code: verdict }, message: refusalMessages[verdict] }, ) } const envelope = TxEnvelopeTempo.from(transaction_sponsored as never) const signPayload = TxEnvelopeTempo.getFeePayerSignPayload(envelope, { sender }) const feePayerSignature = Signature.from(await account.sign({ hash: signPayload })) const serializedTransaction = TxEnvelopeTempo.serialize(envelope, { feePayerSignature, signature: transaction.signature, }) // Awaited before any broadcast so a recording failure refuses sponsorship — // no sponsored transaction ever reaches the chain unrecorded. await options.onSponsored?.({ chainId: transaction.chainId, ...(feeToken ? { feeToken } : {}), method, sender, signPayload, transaction: serializedTransaction, transactionHash: Hash.keccak256(serializedTransaction), }) // Raw-sign requests stop after fee-payer signature is added; send methods broadcast it. if (method === 'eth_signRawTransaction') return serializedTransaction return await client.request({ method: method as never, params: [serializedTransaction], }) } export declare namespace handleRawTransaction { type Options = { /** Account used as the fee payer. */ account: LocalAccount /** Optional token the fee payer prefers for sponsored raw transactions. */ feeToken?: Address | undefined /** Optional fee-token resolver used when the raw envelope omits `feeToken`. */ getFeeToken?: ((chainId: number) => Promise
) | undefined /** Client resolver keyed by transaction `chainId`. */ getClient: (chainId?: number) => Client /** Raw transaction method to handle. */ method: 'eth_signRawTransaction' | 'eth_sendRawTransaction' | 'eth_sendRawTransactionSync' /** Called once the fee payer has signed, before any broadcast. Awaited: a throw aborts the request. */ onSponsored?: ((event: SponsoredEvent) => Promise | void) | undefined /** Incoming JSON-RPC request. */ request: { params?: readonly unknown[] | undefined } /** Sender address to use if it cannot be recovered from the raw envelope. */ sender?: Address | undefined /** Optional sponsorship approval callback. */ validate?: Validate | undefined } } /** Facts of one sponsorship commitment, emitted at fee-payer signing time. */ export type SponsoredEvent = { /** Chain the sponsored transaction targets. */ chainId: number /** Fee token the sponsorship resolved, when known. */ feeToken?: Address | undefined /** Method that triggered sponsorship: a fill (intent) or a raw submission. */ method: | 'eth_fillTransaction' | 'eth_signRawTransaction' | 'eth_sendRawTransaction' | 'eth_sendRawTransactionSync' /** Transaction sender. */ sender: Address /** Fee-payer sign payload — a stable identity for the sponsored envelope. */ signPayload: Hex.Hex /** Serialized sponsored transaction (sender-unsigned for fill intents). */ transaction: Hex.Hex /** Transaction hash (keccak256 of the signed envelope); absent for fill intents, whose senders have not signed yet. */ transactionHash?: Hex.Hex | undefined }