import { AbiEvent, Hex, RpcRequest, RpcResponse } from 'ox' import { Transaction as core_Transaction } from 'ox/tempo' import { type Address, type Call, type Client, createClient, decodeFunctionData, formatUnits, http, isAddress, type Log, parseEventLogs, zeroAddress, } from 'viem' import type { LocalAccount } from 'viem/accounts' import { simulateCalls } from 'viem/actions' import { Abis, Actions, Addresses, Capabilities, VirtualAddress } from 'viem/tempo' import { tempoMainnet, tempoTestnet } from 'viem/tempo/chains' import * as ExecutionError from './internal/executionError.js' import * as TempoApi from '../Client.js' import type * as RpcSchema from '../internal/RpcSchema.js' import { type Handler, from } from '../Handler.js' import * as Store from '../internal/Store.js' import * as Multisig from './internal/multisig.js' import * as Sponsorship from './internal/sponsorship.js' import * as Utils from './internal/utils.js' const defaultChains = [tempoMainnet, tempoTestnet] as const /** * Instantiates a relay handler that proxies `eth_fillTransaction` * with wallet-aware enrichment (fee token resolution, simulation, * sponsorship, AMM resolution). * * @example * ```ts * import { Handler } from 'tapimo' * * const handler = Handler.relay({ apiKey: process.env.TEMPO_API_KEY }) * * // Plug handler into your server framework of choice: * createServer(handler.listener) // Node.js * Bun.serve(handler) // Bun * Deno.serve(handler) // Deno * app.use(handler.listener) // Express * app.all('*', c => handler.fetch(c.req.raw)) // Hono * export const GET = handler.fetch // Next.js * export const POST = handler.fetch // Next.js * ``` * * @example * With sponsorship * * ```ts * import { privateKeyToAccount } from 'viem/accounts' * import { Handler } from 'tapimo' * * const handler = Handler.relay({ * feePayer: { * account: privateKeyToAccount('0x...'), * }, * }) * ``` * * @param options - Options. * @returns Request handler. */ export function relay(options: relay.Options = {}): Handler { const { apiKey, getClient: getClient_, internal_allowUnsafeUrls = false, onRequest, path = '/', resolveTokens: resolveTokens_, ...rest } = options const getClient = getClient_ ?? defaultGetClient({ apiKey }) const resolveTokens = resolveTokens_ ?? defaultResolveTokens({ apiKey }) const feePayerOptions = options.feePayer // Adapt the state store into the multisig operation store once, up front. const multisigOptions = options.multisig ? { ...options.multisig, store: Multisig.fromStore(options.multisig.store) } : undefined const getTokens = async (chainId: number | undefined): Promise => { if (chainId === undefined) return [] return await resolveTokens(chainId) } // Memoize cache for TIP-20 metadata and user fee-token preferences. Kept // opt-in: an always-on cache would pin a raced null preference (set, then // fill before the set mines) for the whole TTL. const store = options.cache const features = { autoSwap: options.autoSwap ?? options.features === 'all', feeTokenResolution: options.features === 'all' || Boolean(resolveTokens_), simulate: options.features === 'all', } const autoSwap = (() => { if (!features.autoSwap) return undefined if (options.autoSwap === false) return undefined return { slippage: (typeof options.autoSwap === 'object' ? options.autoSwap?.slippage : undefined) ?? 0.05, } })() async function handleRequest( request: RpcRequest.RpcRequest, options?: { chainId?: number | undefined }, ) { await onRequest?.(request) const params = 'params' in request && Array.isArray(request.params) ? request.params : [] const first = typeof params[0] === 'object' && params[0] ? (params[0] as Record) : undefined const chainId_body = Utils.resolveChainId(first?.['chainId']) const chainId_path = options?.chainId if (chainId_body !== undefined && chainId_path !== undefined && chainId_body !== chainId_path) return Utils.rpcErrorJson( request, new RpcResponse.InvalidParamsError({ message: 'Conflicting chain ids.' }), ) let chainId: number | undefined let client: Client try { chainId = chainId_path ?? chainId_body ?? getClient().chain?.id client = getClient(chainId) } catch (error) { return Utils.rpcErrorJson(request, error) } switch (request.method) { case 'eth_fillTransaction': { const parameters = params[0] as Record const capabilities = (parameters['capabilities'] ?? {}) as Record try { const from = typeof parameters['from'] === 'string' ? (parameters['from'] as Address) : undefined const requestFeeToken = typeof parameters['feeToken'] === 'string' ? (parameters['feeToken'] as Address) : undefined const externalFeePayerUrl = typeof parameters['feePayer'] === 'string' ? ExternalFeePayerUrl.normalize(parameters['feePayer'], { allowUnsafe: internal_allowUnsafeUrls, }) : undefined const requestsSponsorship = (!!feePayerOptions || !!externalFeePayerUrl) && parameters['feePayer'] !== false // Default to `true`. Dapps that don't render diffs can pass // `capabilities.balanceDiffs: false` to skip the post-fill // `tempo_simulateV1` round trip (~250-400ms). const requireBalanceDiffs = capabilities['balanceDiffs'] !== false const { feePayer: _feePayer, ...normalized } = Utils.normalizeFillTransactionRequest(parameters) const hasMultisigHints = (typeof normalized['multisig'] === 'object' && normalized['multisig'] !== null) || (typeof normalized['multisigInit'] === 'object' && normalized['multisigInit'] !== null) || typeof normalized['multisigSignatureCount'] !== 'undefined' const shouldDeferFeePayerSignature = feePayerOptions && multisigOptions && hasMultisigHints // When sponsoring, the sponsor's preferred fee token (if set) // overrides whatever the consumer asked for — the sponsor is // paying, so the sponsor picks. Falls back to the request's // feeToken otherwise. const sponsoredFeeToken = requestsSponsorship ? (feePayerOptions?.feeToken ?? requestFeeToken) : requestFeeToken const baseTx = { ...normalized, ...(typeof chainId !== 'undefined' ? { chainId } : {}), ...(sponsoredFeeToken ? { feeToken: sponsoredFeeToken } : {}), } let filled: Awaited> let sponsored = false let feeToken = sponsoredFeeToken const tokens = getTokens(chainId) // Lazily resolve a swap source token when autoSwap needs one. const resolveFeeTokenForSwap = from ? async (insufficientToken: Address) => resolveFeeToken(client, { account: from, feeToken: undefined, store, tokens: (await tokens).filter( (t) => t.toLowerCase() !== insufficientToken.toLowerCase(), ), }) : undefined // When no sponsor will pay, prefer fee tokens the user actually // holds. Extend the configured token list with any TIP20 token // the transaction is calling — typically the token being // transferred — so a user transferring USDC.e can pay gas in // USDC.e even when the configured list defaults to pathUSD. const configuredTokens = await tokens const unsponsoredTokens = [ ...configuredTokens, ...callTargetTokens(baseTx).filter( (t) => !configuredTokens.some((rt) => rt.toLowerCase() === t.toLowerCase()), ), ] // When the app provides its own fee payer URL, route the fill // through that service so it can sign the transaction. const fillClient = externalFeePayerUrl ? createClient({ chain: client.chain, batch: { multicall: { deployless: true } }, transport: http(externalFeePayerUrl), }) : client if (requestsSponsorship && (externalFeePayerUrl || !feePayerOptions?.validate)) { // Path A: sponsorship guaranteed — skip fee token resolution, // fill once with feePayer, then parallelize the rest. Taken when: // - no validate is configured (no gating), OR // - the app supplied its own external fee payer URL (that // relay is the sponsorship authority — the wallet's own // `validate` only governs the wallet's own fee payer). // Default to the chain's first token so the broadcast envelope // always carries a feeToken the chain can charge. if (!feeToken) feeToken = configuredTokens[0] const transaction = { ...baseTx, feePayer: true, ...(feeToken ? { feeToken } : {}), } if (Sponsorship.isPreparedTransaction(transaction)) { filled = { transaction: Utils.normalizeTempoTransaction(transaction), sponsor: undefined, } } else { filled = await fill(fillClient, { autoSwap, feeToken, store, resolveFeeToken: resolveFeeTokenForSwap, transaction, }) // The chain echoes feeToken: null for sponsored fills served // by viem clients that strip feeToken pre-sign; re-inject // before the spread in mergeCallsFromRequest drops it. if ( feeToken && filled?.transaction && (filled.transaction as { feeToken?: Address | null }).feeToken == null ) (filled.transaction as { feeToken?: Address }).feeToken = feeToken } sponsored = true } else if (requestsSponsorship && feePayerOptions?.validate) { // Path B: sponsorship possible but may be rejected. // Speculatively fill the sponsored variant, validate, and only // fall back to the unsponsored path on rejection. The accept // case (common) avoids the upfront `resolveFeeToken` round // trips and the second `eth_fillTransaction`. const sponsoredTx = { ...baseTx, feePayer: true } if (Sponsorship.isPreparedTransaction(sponsoredTx)) { // Already prepared — skip fills, just validate sponsorship. const prepared = { transaction: Utils.normalizeTempoTransaction(sponsoredTx), sponsor: undefined, } sponsored = await Sponsorship.shouldSponsor({ sender: from, transaction: prepared.transaction, validate: feePayerOptions!.validate, }) filled = prepared } else { const options = { autoSwap, store, resolveFeeToken: resolveFeeTokenForSwap, } const fill_sponsored = await fill(fillClient, { ...options, feeToken, transaction: sponsoredTx, }) sponsored = await Sponsorship.shouldSponsor({ sender: from, transaction: fill_sponsored.transaction, validate: feePayerOptions!.validate, }) if (sponsored) { filled = fill_sponsored } else { // Sponsor rejected — resolve fee token and fill unsponsored. const feeToken_unsponsored = features.feeTokenResolution ? await resolveFeeToken(client, { account: from, feeToken: requestFeeToken, store, tokens: unsponsoredTokens, }) : requestFeeToken const tx_unsponsored = { ...baseTx, ...(feeToken_unsponsored ? { feeToken: feeToken_unsponsored } : {}), } filled = await fill(client, { ...options, feeToken: feeToken_unsponsored, transaction: tx_unsponsored, }) feeToken = feeToken_unsponsored } } } else { // Path C: no sponsorship configured — resolve fee token, fill once. feeToken = features.feeTokenResolution ? await resolveFeeToken(client, { account: from, feeToken: requestFeeToken, store, tokens: unsponsoredTokens, }) : requestFeeToken const transaction = { ...baseTx, ...(feeToken ? { feeToken } : {}) } filled = await fill(client, { autoSwap, feeToken, store, resolveFeeToken: resolveFeeTokenForSwap, transaction, }) } const transaction_filled = filled.transaction const swap = 'swap' in filled ? filled.swap : undefined if (!feeToken) feeToken = (transaction_filled.feeToken as Address | undefined) ?? configuredTokens[0] // Parallelize: simulate, fee payer signing, and autoSwap metadata. const alreadySigned = 'feePayerSignature' in transaction_filled && transaction_filled.feePayerSignature != null const calls = extractCalls(transaction_filled) const [simulation, transaction_final, autoSwap_, virtualAddresses] = await Promise.all([ // Simulate and compute balance diffs + fee. // When `capabilities.balanceDiffs` is `false`, skip simulate // and compute fee directly from cached metadata. (async () => { if (!features.simulate) return { balanceDiffs: undefined, fee: undefined } if (requireBalanceDiffs) return simulateAndParseDiffs(client, { account: from, calls, swap, feeToken, gas: transaction_filled.gas, store, maxFeePerGas: transaction_filled.maxFeePerGas, }) const fee = await computeFee(client, { feeToken, gas: transaction_filled.gas, store, maxFeePerGas: transaction_filled.maxFeePerGas, }).catch(() => undefined) return { balanceDiffs: undefined, fee } })(), // Sign as fee payer (if sponsored and not already signed). (async () => { // Multisig finalization changes the sender signature. Sign after quorum. if (shouldDeferFeePayerSignature) return transaction_filled // Never sign with the managed fee payer for an app-provided // external fee payer URL: that relay is the sponsorship authority, // and signing here would bypass this fee payer's `validate` gate. if (!(sponsored && feePayerOptions && !externalFeePayerUrl && !alreadySigned)) return transaction_filled return Sponsorship.sign({ account: feePayerOptions.account, onSponsored: feePayerOptions.onSponsored, sender: from, transaction: transaction_filled, }) })(), // Resolve autoSwap metadata (when AMM path was taken). resolveAutoSwapMetadata(client, { autoSwap, store, swap }), // Resolve virtual-address recipients so wallets can show the // eventual master address before signing. resolveVirtualAddresses(client, { calls }), ]) const { balanceDiffs, fee } = simulation const sponsor = (() => { if (!sponsored) return undefined // App-provided fee payer: relay back the sponsor from the upstream response. if (externalFeePayerUrl) return filled.sponsor if (feePayerOptions) return Sponsorship.getSponsor(feePayerOptions) return filled.sponsor })() return RpcResponse.from( { result: { ...(sponsor ? { sponsor } : {}), tx: core_Transaction.toRpc(transaction_final as core_Transaction.Transaction), capabilities: { balanceDiffs, fee, sponsored: !!sponsor, ...(sponsor ? { sponsor } : {}), ...(autoSwap_ ? { autoSwap: autoSwap_ } : {}), ...(virtualAddresses ? { virtualAddresses } : {}), }, }, }, { request }, ) } catch (error) { if (!(error instanceof Error)) return Utils.rpcErrorJson(request, error) if (capabilities['errors'] !== true) return Utils.rpcErrorJson(request, error) const revert = ExecutionError.parse(error) const parameters = request.params?.[0] as Record const stub = { from: parameters['from'], to: parameters['to'] ?? null, gas: '0x0', nonce: '0x0', value: '0x0', maxFeePerGas: '0x0', maxPriorityFeePerGas: '0x0', } if (revert?.errorName === 'InsufficientBalance') { const args = revert.args as [bigint, bigint, Address] const [available, required, token] = args const normalized = Utils.normalizeFillTransactionRequest(parameters) // Simulate from zero address for optimistic balance diffs. const optimisticCalls = normalized ? extractCalls(normalized) : undefined const [{ balanceDiffs }, virtualAddresses] = optimisticCalls ? await Promise.all([ simulateAndParseDiffs(client, { account: zeroAddress, calls: optimisticCalls, store, }), resolveVirtualAddresses(client, { calls: optimisticCalls }), ]) : [{ balanceDiffs: undefined }, undefined] // Re-key balance diffs from zero address to the real sender. const senderDiffs = parameters['from'] && balanceDiffs ? { [parameters['from'] as Address]: balanceDiffs[zeroAddress] ?? [] } : balanceDiffs const metadata = await resolveTokenMetadata(client, { token, store }).catch( () => undefined, ) const deficit = required - available return RpcResponse.from( { result: { tx: stub, capabilities: { balanceDiffs: senderDiffs, error: ExecutionError.serialize(revert), requireFunds: metadata ? { amount: Hex.fromNumber(deficit) as `0x${string}`, decimals: metadata.decimals, formatted: formatUnits(deficit, metadata.decimals), token, symbol: metadata.symbol, } : undefined, sponsored: false, ...(virtualAddresses ? { virtualAddresses } : {}), }, }, }, { request }, ) } const normalized = Utils.normalizeFillTransactionRequest(parameters) const virtualAddresses = normalized ? await resolveVirtualAddresses(client, { calls: extractCalls(normalized) }).catch( () => undefined, ) : undefined return RpcResponse.from( { result: { tx: stub, capabilities: { error: ExecutionError.serialize(revert), sponsored: false, ...(virtualAddresses ? { virtualAddresses } : {}), }, }, }, { request }, ) } } case 'eth_signRawTransaction': case 'eth_sendRawTransaction': case 'eth_sendRawTransactionSync': { try { const serialized = params[0] if ( multisigOptions && (request.method === 'eth_sendRawTransaction' || request.method === 'eth_sendRawTransactionSync') && typeof serialized === 'string' && Multisig.isMultisigTransaction(serialized as Hex.Hex) ) { const result = await Multisig.handleRawTransaction({ claimTtl: multisigOptions.claimTtl, finalize: multisigOptions.finalize, getClient, method: request.method, request: { params: 'params' in request ? request.params : undefined }, resolveConfig: multisigOptions.resolveConfig, sponsor: feePayerOptions ? { account: feePayerOptions.account, feeToken: feePayerOptions.feeToken, onSponsored: feePayerOptions.onSponsored, resolveFeeToken: async (chainId) => (await getTokens(chainId))[0], validate: feePayerOptions.validate, } : undefined, store: multisigOptions.store, }) return RpcResponse.from({ result } as never, { request } as never) } if (!feePayerOptions) { // eth_signRawTransaction is a signing method that only the // relay can fulfill — forwarding it to the RPC node returns // an opaque "Method not found". Return an actionable error. if ((request as { method: string }).method === 'eth_signRawTransaction') { return RpcResponse.from( { error: { code: -32601, message: 'eth_signRawTransaction requires a fee payer to be configured on the relay. ' + 'Set the `feePayer` option in `Handler.relay()` to enable transaction sponsorship.', }, } as never, { request } as never, ) } const result = await client.request(request as never) return RpcResponse.from({ result }, { request }) } if ( typeof serialized !== 'string' || !Sponsorship.requestsRawSponsorship(serialized as `0x${string}`) ) { const result = await client.request(request as never) return RpcResponse.from({ result }, { request }) } const result = await Sponsorship.handleRawTransaction({ account: feePayerOptions.account, feeToken: feePayerOptions.feeToken, getFeeToken: async (chainId) => (await getTokens(chainId))[0], getClient, method: request.method as Sponsorship.handleRawTransaction.Options['method'], onSponsored: feePayerOptions.onSponsored, request: { params: 'params' in request ? request.params : undefined }, validate: feePayerOptions.validate, }) return RpcResponse.from({ result } as never, { request } as never) } catch (error) { return Utils.rpcErrorJson(request, error) } } case 'eth_getTransactionByHash': case 'eth_getTransactionReceipt': { try { if (multisigOptions) { const aliased = await Multisig.handleGetTransaction({ getClient, method: request.method, request: { params: 'params' in request ? request.params : undefined }, store: multisigOptions.store, }) if (aliased) return RpcResponse.from({ result: aliased.result } as never, { request } as never) } const result = await client.request(request as never) return RpcResponse.from({ result }, { request }) } catch (error) { return Utils.rpcErrorJson(request, error) } } default: { try { const result = await client.request(request as never) return RpcResponse.from({ result }, { request }) } catch (error) { return Utils.rpcErrorJson(request, error) } } } } const router = from(rest) async function handlePost(c: { req: { raw: Request; param: (key: string) => string } }) { const chainId = Utils.resolveChainId(c.req.param('chainId')) const body = await c.req.raw.json().catch(() => undefined) if (body === undefined) return Response.json(rpcProtocolError('parse')) const isBatch = Array.isArray(body) if (!isBatch) { if (!isRpcRequest(body)) return Response.json(rpcProtocolError('invalid_request')) const request = RpcRequest.from(body as never) as RpcRequest.RpcRequest const response = await handleRequest(request, { chainId }).catch((error) => Utils.rpcErrorJson(request, error), ) return Response.json(response) } if (body.length === 0) return Response.json(rpcProtocolError('invalid_request')) const responses = await Promise.all( body.map((item) => { if (!isRpcRequest(item)) return Promise.resolve(rpcProtocolError('invalid_request')) return handleRequest( RpcRequest.from(item as never) as RpcRequest.RpcRequest, { chainId }, ).catch((error) => Utils.rpcErrorJson(item as RpcRequest.RpcRequest, error)) }), ) return Response.json(responses) } router.post(path, handlePost as never) router.post(`${path === '/' ? '' : path}/:chainId`, handlePost as never) return router } function isRpcRequest(value: unknown): value is Record { if (!value || typeof value !== 'object' || Array.isArray(value)) return false const request = value as Record if (request['jsonrpc'] !== '2.0' || typeof request['method'] !== 'string') return false const id = request['id'] if (id !== undefined && id !== null && typeof id !== 'number' && typeof id !== 'string') return false const params = request['params'] if (params === undefined || Array.isArray(params)) return true return Boolean(params) && typeof params === 'object' } function rpcProtocolError(type: 'invalid_request' | 'parse') { return { error: type === 'parse' ? { code: RpcResponse.ParseError.code, message: 'Parse error' } : { code: RpcResponse.InvalidRequestError.code, message: 'Invalid Request' }, id: null, jsonrpc: '2.0', } } function defaultGetClient(options: { apiKey?: string | undefined }) { const clients = new Map() for (const chain of defaultChains) { const transport = options.apiKey ? http(new URL(`/rpc/${chain.id}`, TempoApi.defaultUrl).toString(), { fetchOptions: { headers: { 'tempo-api-key': options.apiKey } }, }) : http() clients.set( chain.id, createClient({ batch: { multicall: { deployless: true } }, chain, transport }), ) } return (chainId: number = tempoMainnet.id): Client => { const client = clients.get(chainId) if (!client) throw new Error(`Chain ${chainId} not configured`) return client } } function defaultResolveTokens(options: { apiKey?: string | undefined }) { const client = TempoApi.create({ apiKey: options.apiKey }) return async (chainId: number): Promise => { if (!defaultChains.some((chain) => chain.id === chainId)) return [] const response = await client.v1.tokenlist.$get({ query: { chainId: String(chainId) } }) if (response.status !== 200) return [] const { tokens } = await response.json() return tokens.map((token) => token.address) } } export namespace relay { export type Options = from.Options & { /** * Tempo API key used by the default RPC clients and token resolver. When * omitted, RPC clients use each chain's default transport. */ apiKey?: string | undefined /** * Auto-swap options. */ autoSwap?: | false | { /** Slippage tolerance (e.g. 0.05 = 5%). @default 0.05 */ slippage?: number | undefined } | undefined /** * Resolves a viem client for a chain id. The handler resolves the client * from the `chainId` in the incoming transaction (falling back to the * client's default chain). Defaults to Tempo mainnet and testnet clients. */ getClient?: ((chainId?: number) => Client) | undefined /** * Fee payer / sponsor configuration. When provided, the relay will * sign `feePayerSignature` on the filled transaction. */ feePayer?: | { /** Account to use as the fee payer. */ account: LocalAccount /** Preferred fee token the sponsor wants to pay fees in. */ feeToken?: Address | undefined /** * Called once the fee payer has signed a raw transaction. */ onSponsored?: Sponsorship.handleRawTransaction.Options['onSponsored'] /** * Validates whether to sponsor the transaction. When omitted, all * transactions are sponsored. Return `false` to reject sponsorship. */ validate?: Sponsorship.Validate | undefined /** Sponsor display name returned from `eth_fillTransaction`. */ name?: string | undefined /** Sponsor URL returned from `eth_fillTransaction`. */ url?: string | undefined } | undefined /** * Native multisig configuration. When provided, the relay collects owner * approvals from multisig raw transaction submissions and broadcasts once * collected owner weight meets the configured threshold. */ multisig?: Multisig.Options | undefined /** * Relay features. * * - `'all'` — enables fee token resolution, auto-swap, * fee payer, and simulation (balance diffs + fee breakdown). * - `undefined` (default) — only fee payers. */ features?: 'all' | undefined /** * Allows app-provided external fee payer URLs to use non-HTTPS protocols * or local/private hosts. Intended only for trusted development setups. * @default false */ internal_allowUnsafeUrls?: boolean | undefined /** * In-process cache backing TIP-20 metadata and user fee-token preference * memoization. Omit to read upstream on every fill. */ cache?: Store.Store | undefined /** Function to call before handling the request. */ onRequest?: ((request: RpcRequest.RpcRequest) => Promise) | undefined /** Path to use for the handler. @default "/" */ path?: string | undefined /** * Resolves the known fee-token candidates for a chain. Defaults to the * Tempo API verified token list for mainnet and testnet. */ resolveTokens?: | ((chainId: number) => readonly Address[] | Promise) | undefined } } // TODO: cleanup async function fill(client: Client, options: fill.Options) { const { autoSwap, feeToken, store, transaction: request } = options // Skip re-formatting if already in RPC format (e.g. from viem's fillTransaction). const format = (value: Record) => value['type'] === '0x76' ? value : Utils.formatFillTransactionRequest(client, value) // Re-fill the transaction with prepended swap calls so the user can // mint the missing `insufficientToken` (typically the fee token) from // a token they already hold. Returns null if no source token is // available or autoSwap is disabled. async function fillWithSwap(insufficientToken: Address, deficit: bigint) { if (!autoSwap) return null const sourceToken = feeToken && feeToken.toLowerCase() !== insufficientToken.toLowerCase() ? feeToken : await options.resolveFeeToken?.(insufficientToken) if (!sourceToken || sourceToken.toLowerCase() === insufficientToken.toLowerCase()) return null const maxAmountIn = deficit + (deficit * BigInt(Math.round(autoSwap.slippage * 1000))) / 1000n const originalCalls = (request['calls'] as Call[] | undefined) ?? [] const swapCalls = buildSwapCalls(client, sourceToken, insufficientToken, deficit, maxAmountIn) const result = await client.request({ method: 'eth_fillTransaction', params: [ format({ ...request, calls: [...swapCalls, ...originalCalls], }) as never, ], }) const sponsor = (result as Record)['capabilities']?.sponsor as | { address: Address; name?: string; url?: string } | undefined const mergedTx = mergeCallsFromRequest(result.tx as Record, { ...request, calls: [...swapCalls, ...originalCalls], }) return { transaction: Utils.normalizeTempoTransaction(mergedTx), sponsor, swap: { calls: swapCalls, tokenIn: sourceToken, tokenOut: insufficientToken, amountOut: deficit, maxAmountIn, }, } } try { const formatted = format(request) const result = await client.request({ method: 'eth_fillTransaction', params: [formatted as never], }) // FIXME: node estimates gas with secp256k1 dummy sig + null feePayerSignature. // Actual tx has larger keychain/webAuthn sigs + real fee payer sig, costing // more intrinsic gas. Mirror the bump from viem's tempo chainConfig. // Skip if another relay already bumped (indicated by feePayerSignature). // @ts-expect-error if (result.tx.gas && request.feePayer && !result.tx.feePayerSignature) result.tx.gas = Hex.fromNumber(BigInt(result.tx.gas) + 20_000n) const upstreamCapabilities = (result as { capabilities?: Record }).capabilities const sponsor = upstreamCapabilities?.['sponsor'] as | { address: Address; name?: string; url?: string } | undefined // External fee-payer relays surface chain reverts (e.g. InsufficientBalance, // InsufficientAllowance) inside `capabilities.error` with a stub `tx` // instead of throwing. Re-throw all upstream errors so the outer handler // either recovers via autoSwap (InsufficientBalance) or propagates the // error to the caller. Without this, the wallet would otherwise sign the // zero-stub tx with its own fee payer and broadcast garbage. const upstreamError = upstreamCapabilities?.['error'] as | { errorName?: string; message?: string; data?: `0x${string}` } | undefined if (upstreamError) { const synthetic = new Error( upstreamError.message ?? upstreamError.errorName ?? 'UpstreamRevert', ) synthetic.name = 'UpstreamRevertError' ;(synthetic as { data?: `0x${string}` | undefined }).data = upstreamError.data throw synthetic } // Reconstruct a `swap` shape from upstream's `capabilities.autoSwap` so the // wallet relay's outer code can re-resolve autoSwap metadata locally — // otherwise upstream-driven swaps are silently dropped from the response. const swap = extractSwapFromCapabilities(upstreamCapabilities?.['autoSwap']) // The chain's `eth_fillTransaction` doesn't echo back `calls`, so merge // them in from the original request before normalizing — otherwise the // typed envelope built for sponsorship signing throws CallsEmptyError. const mergedTx = mergeCallsFromRequest(result.tx as Record, request) // The node's `eth_fillTransaction` may pick a fee token the user // doesn't yet hold (e.g. one this transaction will mint via a // swap). Tempo's gas check runs before the calls execute, so the // tx would revert at broadcast with `have 0 want N`. Validate the // sender's pre-tx balance against the computed gas cost and, if // short, autoSwap a token they do hold into the fee token. if (autoSwap && !swap) { const fromAddress = request['from'] as Address | undefined const resolvedFeeToken = ((mergedTx['feeToken'] as Address | undefined) ?? feeToken) as | Address | undefined const gas = mergedTx['gas'] ? BigInt(mergedTx['gas'] as `0x${string}`) : 0n const maxFeePerGas = mergedTx['maxFeePerGas'] ? BigInt(mergedTx['maxFeePerGas'] as `0x${string}`) : 0n if (fromAddress && resolvedFeeToken && gas > 0n && maxFeePerGas > 0n) { const [balance, metadata] = await Promise.all([ Actions.token .getBalance(client, { account: fromAddress, token: resolvedFeeToken }) .then((balance) => balance.amount) .catch(() => 0n), resolveTokenMetadata(client, { token: resolvedFeeToken, store }).catch(() => undefined), ]) if (metadata) { const requiredFee = (gas * maxFeePerGas) / 10n ** BigInt(Math.max(0, 18 - metadata.decimals)) if (balance < requiredFee) { // Best-effort: if the swap itself can't be filled (e.g. the // source token also has insufficient balance), fall through // and return the original tx with the resolved feeToken // rather than failing the whole request. const swapResult = await fillWithSwap(resolvedFeeToken, requiredFee - balance).catch( () => null, ) if (swapResult) return swapResult } } } } return { transaction: Utils.normalizeTempoTransaction(mergedTx), sponsor, ...(swap ? { swap } : {}), } } catch (error) { if (!(error instanceof Error)) throw error if (!autoSwap) throw error const revert = ExecutionError.parse(error) if (revert?.errorName !== 'InsufficientBalance') throw error const [available, required, token] = revert.args if (typeof available === 'undefined' || typeof required === 'undefined' || !token) throw error const swapResult = await fillWithSwap(token as Address, required - available) if (!swapResult) throw error return swapResult } } declare namespace fill { type Options = { autoSwap?: { slippage: number } | undefined feeToken?: Address | undefined store?: Store.Store | undefined resolveFeeToken?: ((insufficientToken: Address) => Promise
) | undefined transaction: Record } } async function resolveFeeToken( client: Client, options: resolveFeeToken.Options, ): Promise
{ const { feeToken, account, store, tokens } = options if (feeToken) return feeToken if (!account) return undefined // Cache the user's preferred fee token for `userTokenCacheTtl` seconds. // The on-chain preference rarely changes; a short TTL avoids the // ~150-250ms `userTokens` multicall on every unsponsored fill while // still picking up updates within a minute. The cached value is just a // hint — if the user's balance is 0 we fall back to the highest-balance // token from the configured list. const getUserToken = () => Actions.fee.getUserToken(client, { account }).catch(() => null) const userTokenPromise = store ? Store.memoize( async () => { const result = await getUserToken() return result ? { address: result.address } : null }, { key: `fee.userToken:${client.chain?.id ?? 0}:${account.toLowerCase()}`, store, ttl: (options.userTokenCacheTtl ?? 60) * 1000, }, ) : getUserToken() const [userToken, balances] = await Promise.all([ userTokenPromise, tokens ? Promise.all( tokens.map(async (token) => ({ address: token, balance: await Actions.token .getBalance(client, { account, token }) .then((balance) => balance.amount) .catch(() => 0n), })), ) : [], ]) // If on-chain preference is set and user has balance, use it. if (userToken) { const match = balances.find( (b: { address: Address; balance: bigint }) => b.address.toLowerCase() === userToken.address.toLowerCase() && b.balance > 0n, ) if (match) return userToken.address // Token list may not include the preference — check on-chain directly. if (!match) { try { const { amount: balance } = await Actions.token.getBalance(client, { account, token: userToken.address, }) if (balance > 0n) return userToken.address } catch {} } } // Pick the token with the highest balance. let best: { address: Address; balance: bigint } | undefined for (const asset of balances) { if (asset.balance <= 0n) continue if (!best || asset.balance > best.balance) best = asset } if (best) return best.address return undefined } declare namespace resolveFeeToken { type Options = { feeToken?: Address | undefined account?: Address | undefined store?: Store.Store | undefined tokens?: readonly Address[] | undefined /** TTL in seconds for the cached `userTokens` lookup. @default 60 */ userTokenCacheTtl?: number | undefined } } /** * Extracts unique TIP20 token addresses (Tempo `0x20c0…` prefix) that the * transaction's calls target. Used as fallback fee-token candidates so a * user transferring a token they hold can pay gas in that token even when * the configured fee-token list defaults to one they don't hold. */ function callTargetTokens(transaction: Record): readonly Address[] { const calls = transaction['calls'] as readonly { to?: Address }[] | undefined if (!calls) return [] const out: Address[] = [] const seen = new Set() for (const c of calls) { if (!c.to) continue const lower = c.to.toLowerCase() if (!lower.startsWith('0x20c0')) continue if (seen.has(lower)) continue seen.add(lower) out.push(c.to) } return out } // TODO: Replace with viem's Tempo fillTransaction capability type once released. type VirtualAddresses = Record async function resolveVirtualAddresses( client: Client, options: { calls: readonly Call[] }, ): Promise { const targets = getVirtualAddressTargets(options.calls) if (targets.length === 0) return undefined const masters = new Map() for (const address of targets) { const { masterId } = VirtualAddress.parse(address) const lower = masterId.toLowerCase() const entry = masters.get(lower) ?? { addresses: [], masterId } entry.addresses.push(address) masters.set(lower, entry) } const entries = await Promise.all( [...masters.values()].map(async ({ addresses, masterId }) => { const master = await Actions.virtualAddress.getMasterAddress(client, { masterId }) return addresses.map((address) => [address, master] as const) }), ) return Object.fromEntries(entries.flat()) as VirtualAddresses } function getVirtualAddressTargets(calls: readonly Call[]): readonly Address[] { const targets = new Set
() for (const call of calls) { for (const address of [call.to, decodeTransferRecipient(call.data)]) { if (!address || !isAddress(address) || !VirtualAddress.isVirtual(address)) continue targets.add(address.toLowerCase() as Address) } } return [...targets] } function decodeTransferRecipient(data?: string): Address | undefined { if (!data) return undefined const selector = data.slice(0, 10).toLowerCase() if (!transferSelectors.has(selector)) return undefined try { const { args, functionName } = decodeFunctionData({ abi: Abis.tip20, data: data as Hex.Hex, }) if ( (functionName === 'transfer' || functionName === 'transferWithMemo') && typeof args[0] === 'string' ) return args[0] if ( (functionName === 'transferFrom' || functionName === 'transferFromWithMemo') && typeof args[1] === 'string' ) return args[1] } catch {} return undefined } const transferSelectors = new Set(['0xa9059cbb', '0x95777d59', '0x23b872dd', '0x929c2539']) // TODO: cleanup/remove function extractCalls(transaction: Record): readonly Call[] { const calls = transaction['calls'] as readonly Call[] | undefined if (calls && calls.length > 0) return calls.map((c) => ({ ...(c.to ? { to: c.to } : {}), ...(c.data ? { data: c.data } : {}), ...(c.value ? { value: c.value } : {}), })) as readonly Call[] return [ { ...(transaction['to'] ? { to: transaction['to'] as Address } : {}), ...(transaction['data'] ? { data: transaction['data'] as `0x${string}` } : {}), ...(transaction['value'] ? { value: transaction['value'] as bigint } : {}), }, ] as readonly Call[] } // TODO: cleanup/remove async function simulate(client: Client, options: simulate.Options) { const { account, calls } = options try { return await Actions.simulate.simulateCalls(client, { ...(account ? { account } : {}), calls: calls as Call[], traceTransfers: true, }) } catch (error) { // TODO: Remove fallback once all nodes support tempo_simulateV1. // Fall back to viem's simulateCalls (eth_simulateV1) if the Tempo // method (tempo_simulateV1) is not supported. const code = (error as { code?: number | undefined }).code ?? (error as { cause?: { code?: number | undefined } | undefined }).cause?.code if (code !== -32601) throw error const { results } = await simulateCalls(client, { ...(account ? { account } : {}), calls: calls as Call[], }) return { results, tokenMetadata: undefined } } } declare namespace simulate { type Options = { account?: Address | undefined calls: readonly Call[] } } async function simulateAndParseDiffs(client: Client, options: simulateAndParseDiffs.Options) { const { account, calls, swap, feeToken, gas, store, maxFeePerGas } = options try { const { results, tokenMetadata } = await simulate(client, { account: account === zeroAddress ? undefined : account, calls, }) // Collect all logs across all call results. const logs: (typeof results)[number]['logs'] = [] for (const result of results as { logs?: (typeof logs)[number][] | undefined }[]) if (result.logs) logs.push(...result.logs) // Build per-token balance diffs relative to the sender. const balanceDiffs = account ? await buildBalanceDiffs(client, { account, store, logs, swap, tokenMetadata: tokenMetadata as never, }) : {} // Compute fee breakdown. const fee = await computeFee(client, { feeToken, gas, store, maxFeePerGas, tokenMetadata: tokenMetadata as never, }).catch(() => undefined) return { balanceDiffs, fee } } catch { // Simulation failures should not block the fill response — // return empty diffs with fee computed from transaction fields. const fee = await computeFee(client, { feeToken, gas, store, maxFeePerGas }) return { balanceDiffs: undefined, fee } } } declare namespace simulateAndParseDiffs { type Options = { account?: Address | undefined calls: readonly Call[] swap?: { tokenIn: Address; tokenOut: Address } | undefined feeToken?: Address | undefined gas?: bigint | undefined store?: Store.Store | undefined maxFeePerGas?: bigint | undefined } } async function buildBalanceDiffs(client: Client, options: buildBalanceDiffs.Options) { const { account, store, logs, swap, tokenMetadata } = options const accountLower = account.toLowerCase() const dexLower = Addresses.stablecoinDex.toLowerCase() const swapTokenIn = swap?.tokenIn.toLowerCase() const swapTokenOut = swap?.tokenOut.toLowerCase() const transferLogs = parseEventLogs({ abi: [AbiEvent.fromAbi(Abis.tip20, 'Transfer')], eventName: 'Transfer', logs, }) const approvalLogs = parseEventLogs({ abi: [AbiEvent.fromAbi(Abis.tip20, 'Approval')], eventName: 'Approval', logs, }) // Track net movement per token: incoming vs outgoing. const tokenMap = new Map< string, { incoming: bigint; outgoing: bigint; recipients: Set
; token: Address } >() // Track total transferred per (token, spender) so we can suppress covered approvals. const transferredBySpender = new Map() for (const log of transferLogs) { const token = log.address.toLowerCase() const fromLower = log.args.from.toLowerCase() const toLower = log.args.to.toLowerCase() // Skip swap-related transfers (reported in capabilities.autoSwap instead). if (swap) { if (token === swapTokenIn && fromLower === accountLower && toLower === dexLower) continue if (token === swapTokenOut && fromLower === dexLower && toLower === accountLower) continue } const entry = tokenMap.get(token) ?? { incoming: 0n, outgoing: 0n, recipients: new Set
(), token: log.address, } if (fromLower === accountLower) { entry.outgoing += log.args.amount entry.recipients.add(log.args.to) const key = `${token}:${toLower}` transferredBySpender.set(key, (transferredBySpender.get(key) ?? 0n) + log.args.amount) } if (toLower === accountLower) entry.incoming += log.args.amount tokenMap.set(token, entry) } // Treat approvals as outgoing unless the spender already transferred >= approval amount. for (const log of approvalLogs) { if (log.args.owner.toLowerCase() !== accountLower) continue const token = log.address.toLowerCase() // Skip swap-related approvals (reported in capabilities.autoSwap instead). if (swap && token === swapTokenIn && log.args.spender.toLowerCase() === dexLower) continue const spenderKey = `${token}:${log.args.spender.toLowerCase()}` const transferred = transferredBySpender.get(spenderKey) ?? 0n if (log.args.amount <= transferred) continue const entry = tokenMap.get(token) ?? { incoming: 0n, outgoing: 0n, recipients: new Set
(), token: log.address, } entry.outgoing += log.args.amount - transferred entry.recipients.add(log.args.spender) tokenMap.set(token, entry) } // Collect unique tokens that need decimals. const entries = [...tokenMap.values()].filter((e) => { const net = e.outgoing > e.incoming ? e.outgoing - e.incoming : e.incoming - e.outgoing return net > 0n }) if (entries.length === 0) return {} // Resolve metadata for all tokens in parallel (simulation metadata first, RPC fallback). const metadataMap = new Map() await Promise.all( entries.map(async (entry) => { try { const metadata = await resolveTokenMetadata(client, { token: entry.token, tokenMetadata, store, }) metadataMap.set(entry.token.toLowerCase(), metadata) } catch {} }), ) // Build the diff array for this account. const diffs: Capabilities.BalanceDiff[] = [] for (const entry of entries) { const net = entry.outgoing > entry.incoming ? entry.outgoing - entry.incoming : entry.incoming - entry.outgoing const direction = entry.outgoing > entry.incoming ? 'outgoing' : 'incoming' const meta = metadataMap.get(entry.token.toLowerCase()) const decimals = meta?.decimals ?? 0 diffs.push({ address: entry.token, decimals, direction, formatted: formatUnits(net, decimals), name: meta?.name ?? '', symbol: meta?.symbol ?? '', recipients: [...entry.recipients] as Address[], value: Hex.fromNumber(net) as `0x${string}`, }) } return { [account]: diffs } } declare namespace buildBalanceDiffs { type Options = { account: Address store?: Store.Store | undefined logs: Log[] swap?: { tokenIn: Address; tokenOut: Address } | undefined tokenMetadata: Record } } async function resolveTokenMetadata(client: Client, options: resolveTokenMetadata.Options) { const { token, tokenMetadata, store } = options const meta = tokenMetadata?.[token] ?? tokenMetadata?.[token.toLowerCase() as Address] // TIP-20 metadata (decimals/symbol/name) is immutable per token, so cache // long-term. Skips the multicall RPC on cache hits. const fetcher = () => Actions.token.getMetadata(client, { token }) const fallback = store ? await Store.memoize(fetcher, { key: `tokenMetadata:${client.chain?.id ?? 0}:${token.toLowerCase()}`, store, ttl: 24 * 60 * 60 * 1000, }) : await fetcher() return { decimals: fallback.decimals ?? 6, symbol: meta?.symbol || fallback.symbol, name: meta?.name || fallback.name, } } declare namespace resolveTokenMetadata { type Options = { token: Address tokenMetadata?: Record | undefined store?: Store.Store | undefined } } async function resolveAutoSwapMetadata(client: Client, options: resolveAutoSwapMetadata.Options) { const { autoSwap, store, swap } = options if (!autoSwap || !swap) return undefined const [inMeta, outMeta] = await Promise.all([ resolveTokenMetadata(client, { token: swap.tokenIn, store }).catch(() => undefined), resolveTokenMetadata(client, { token: swap.tokenOut, store }).catch(() => undefined), ]) if (!inMeta || !outMeta) return undefined return { calls: swap.calls.map((c) => ({ to: c.to, data: c.data })), slippage: autoSwap.slippage, maxIn: { token: swap.tokenIn, value: Hex.fromNumber(swap.maxAmountIn) as `0x${string}`, formatted: formatUnits(swap.maxAmountIn, inMeta.decimals), decimals: inMeta.decimals, symbol: inMeta.symbol, name: inMeta.name, }, minOut: { token: swap.tokenOut, value: Hex.fromNumber(swap.amountOut) as `0x${string}`, formatted: formatUnits(swap.amountOut, outMeta.decimals), decimals: outMeta.decimals, symbol: outMeta.symbol, name: outMeta.name, }, } } declare namespace resolveAutoSwapMetadata { type Options = { autoSwap?: { slippage: number } | undefined store?: Store.Store | undefined swap?: | { calls: readonly { to: Address; data: `0x${string}` }[] tokenIn: Address tokenOut: Address amountOut: bigint maxAmountIn: bigint } | undefined } } async function computeFee(client: Client, options: computeFee.Options) { const { feeToken, gas, store, maxFeePerGas, tokenMetadata } = options if (!feeToken || !gas || !maxFeePerGas) return undefined try { const metadata = await resolveTokenMetadata(client, { token: feeToken, tokenMetadata, store }) const raw = gas * maxFeePerGas const amount = raw / 10n ** BigInt(18 - metadata.decimals) return { amount: Hex.fromNumber(amount) as `0x${string}`, decimals: metadata.decimals, formatted: formatUnits(amount, metadata.decimals), symbol: metadata.symbol, } } catch { return undefined } } declare namespace computeFee { type Options = { feeToken?: Address | undefined gas?: bigint | undefined store?: Store.Store | undefined maxFeePerGas?: bigint | undefined tokenMetadata?: Record | undefined } } function buildSwapCalls( client: Client, sourceToken: Address, targetToken: Address, deficit: bigint, maxAmountIn: bigint, ) { const approve = Actions.token.approve.call(client, { token: sourceToken, spender: Addresses.stablecoinDex, amount: maxAmountIn, }) const buy = Actions.dex.buy.call({ tokenIn: sourceToken, tokenOut: targetToken, amountOut: deficit, maxAmountIn, }) return [ { to: approve.to, data: approve.data, value: 0n }, { to: buy.to, data: buy.data, value: 0n }, ] as const } /** * Merges the original fill request into the result tx. The chain's * `eth_fillTransaction` returns only the "filled" gas/nonce/fee fields and * omits envelope inputs like `calls`, `chainId`, `validBefore`, `nonceKey`, * `keyData`, `keyType`, `feePayer`. Without these the typed Tempo envelope * built for sponsorship signing throws `CallsEmptyError` or * `Cannot convert undefined to a BigInt` when serializing. * * Result fields take precedence (they are the chain's authoritative filled * values); request fields fill in everything else. Calls are normalized * separately so legacy `to`/`data`/`value` requests are also supported. */ function mergeCallsFromRequest( resultTx: Record, request: Record, ): Record { const merged: Record = { ...request, ...resultTx } const resultCalls = resultTx['calls'] if (Array.isArray(resultCalls) && resultCalls.length > 0) return merged const reqCalls = request['calls'] if (Array.isArray(reqCalls) && reqCalls.length > 0) { merged['calls'] = reqCalls return merged } const { to, data, value } = request if (typeof to === 'undefined' && typeof data === 'undefined' && typeof value === 'undefined') return merged merged['calls'] = [ { ...(typeof to !== 'undefined' ? { to } : {}), ...(typeof data !== 'undefined' ? { data } : {}), ...(typeof value !== 'undefined' ? { value } : {}), }, ] return merged } namespace ExternalFeePayerUrl { type Options = { /** Whether to allow non-HTTPS or local/private external fee payer URLs. */ allowUnsafe?: boolean | undefined } /** Normalizes external fee-payer relay URLs before forwarding fill payloads. */ export function normalize(value: string, options: Options = {}): string { let url: URL try { url = new URL(value) } catch { throw new RpcResponse.InvalidParamsError({ message: 'Invalid fee payer URL.' }) } const allowUnsafe = options.allowUnsafe === true if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new RpcResponse.InvalidParamsError({ message: 'Invalid fee payer URL protocol.' }) if (!allowUnsafe && url.protocol !== 'https:') throw new RpcResponse.InvalidParamsError({ message: 'Invalid fee payer URL protocol.' }) if (!allowUnsafe && isUnsafeHostname(url.hostname)) throw new RpcResponse.InvalidParamsError({ message: 'Invalid fee payer URL host.' }) url.username = '' url.password = '' url.hash = '' return url.href } function isUnsafeHostname(hostname: string): boolean { const host = normalizeHostname(hostname) if (host === 'localhost' || host.endsWith('.localhost')) return true const ipv4 = parseIpv4(host) if (ipv4) return isUnsafeIpv4(ipv4) if (!host.includes(':')) return false const ipv6 = parseIpv6(host) if (!ipv6) return true return isUnsafeIpv6(ipv6) } function normalizeHostname(hostname: string): string { return hostname .toLowerCase() .replace(/^\[|\]$/g, '') .split('%')[0]! } function parseIpv4(value: string): [number, number, number, number] | undefined { const parts = value.split('.') if (parts.length !== 4 || parts.some((part) => !/^\d+$/.test(part))) return undefined const numbers = parts.map((part) => Number(part)) if (numbers.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return undefined return numbers as [number, number, number, number] } function isUnsafeIpv4(parts: [number, number, number, number]): boolean { const [a, b] = parts if (a === 0 || a === 10 || a === 127) return true if (a === 100 && b >= 64 && b <= 127) return true if (a === 169 && b === 254) return true if (a === 172 && b >= 16 && b <= 31) return true if (a === 192 && b === 168) return true return a >= 224 } function parseIpv6( value: string, ): [number, number, number, number, number, number, number, number] | undefined { const halves = value.split('::') if (halves.length > 2) return undefined const left = halves[0] ? halves[0].split(':') : [] const right = halves[1] ? halves[1].split(':') : [] const missing = halves.length === 2 ? 8 - left.length - right.length : 0 if (missing < 0) return undefined const parts = [...left, ...Array(missing).fill('0'), ...right] if (parts.length !== 8) return undefined const numbers = parts.map((part) => /^[\da-f]{1,4}$/i.test(part) ? Number.parseInt(part, 16) : NaN, ) if (numbers.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)) return undefined return numbers as [number, number, number, number, number, number, number, number] } function isUnsafeIpv6(parts: [number, number, number, number, number, number, number, number]) { const [a] = parts if (parts.every((part) => part === 0)) return true if (parts.slice(0, 7).every((part) => part === 0) && parts[7] === 1) return true if ((a & 0xfe00) === 0xfc00) return true if ((a & 0xffc0) === 0xfe80) return true if ((a & 0xffc0) === 0xfec0) return true if ((a & 0xff00) === 0xff00) return true const ipv4 = ipv4FromIpv6(parts) return ipv4 ? isUnsafeIpv4(ipv4) : false } function ipv4FromIpv6( parts: [number, number, number, number, number, number, number, number], ): [number, number, number, number] | undefined { const mapped = parts.slice(0, 5).every((part) => part === 0) && parts[5] === 0xffff const compatible = parts.slice(0, 6).every((part) => part === 0) if (!mapped && !compatible) return undefined return [parts[6]! >> 8, parts[6]! & 0xff, parts[7]! >> 8, parts[7]! & 0xff] } } /** * Reconstructs a `swap` shape (matching the inner autoSwap branch's return * value) from an upstream relay's `capabilities.autoSwap`. Used so a wallet * relay forwarding to an external feePayer URL can re-emit autoSwap metadata * locally without losing track of the upstream's swap. */ function extractSwapFromCapabilities(autoSwap: unknown): | { calls: readonly { to: Address; data: `0x${string}` }[] tokenIn: Address tokenOut: Address amountOut: bigint maxAmountIn: bigint } | undefined { if (!autoSwap || typeof autoSwap !== 'object') return undefined const a = autoSwap as { calls?: readonly { to: Address; data: `0x${string}` }[] maxIn?: { token?: Address; value?: `0x${string}` } minOut?: { token?: Address; value?: `0x${string}` } } if (!a.calls || !a.maxIn?.token || !a.maxIn.value || !a.minOut?.token || !a.minOut.value) return undefined return { calls: a.calls, tokenIn: a.maxIn.token, tokenOut: a.minOut.token, amountOut: BigInt(a.minOut.value), maxAmountIn: BigInt(a.maxIn.value), } }