UNPKG

accounts

Version:

Tempo Accounts SDK

1,342 lines (1,235 loc) 53.2 kB
import { AbiEvent, Hex, RpcRequest, RpcResponse } from 'ox' import { Transaction as core_Transaction } from 'ox/tempo' import { type Address, type Call, type Chain, type Client, createClient, decodeFunctionData, formatUnits, http, isAddress, type Log, parseEventLogs, type Transport, zeroAddress, } from 'viem' import type { LocalAccount } from 'viem/accounts' import { simulateCalls } from 'viem/actions' import { tempo, tempoDevnet, tempoModerato } from 'viem/chains' import { Abis, Actions, Addresses, Capabilities, Transaction, VirtualAddress } from 'viem/tempo' import * as ExecutionError from '../../../core/ExecutionError.js' import * as Schema from '../../../core/Schema.js' import { type Handler, from } from '../../Handler.js' import * as Kv from '../../Kv.js' import { cached } from '../kv.js' import * as Tokenlist from '../tokenlist.js' import * as Sponsorship from './sponsorship.js' import * as Utils from './utils.js' /** Default cache TTL in seconds (10 minutes) for the verified tokenlist. */ const defaultCacheTtl = 10 * 60 /** * Instantiates a relay handler that proxies `eth_fillTransaction` * with wallet-aware enrichment (fee token resolution, simulation, * sponsorship, AMM resolution). * * @example * ```ts * import { Handler } from 'accounts/server' * * const handler = Handler.relay() * * // 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 'accounts/server' * * const handler = Handler.relay({ * feePayer: { * account: privateKeyToAccount('0x...'), * }, * }) * ``` * * @param options - Options. * @returns Request handler. */ export function relay(options: relay.Options = {}): Handler { const { cacheTtl = defaultCacheTtl, chains = [tempo, tempoModerato, tempoDevnet], kv = Kv.memory(), onRequest, path = '/', resolveTokens, transports = {}, ...rest } = options const feePayerOptions = options.feePayer // Resolves the verified tokenlist for `chainId`, sharing the KV cache with // `Handler.exchange` so a single `kv: Kv.cloudflare(env.KV)` covers both. // The relay only needs addresses, so map down from the full metadata. const getTokens = async (chainId: number): Promise<readonly Address[]> => { const tokens = await Tokenlist.fetch(chainId, kv, { cacheTtl, resolver: resolveTokens }) return tokens.map((t) => t.address) } const features = { autoSwap: options.autoSwap ?? options.features === 'all', feeTokenResolution: options.resolveTokens ?? options.features === 'all', 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, } })() const clients = new Map<number, Client>() for (const chain of chains) { const transport = transports[chain.id] ?? http() clients.set( chain.id, createClient({ chain, batch: { multicall: { deployless: true } }, transport }), ) } function getClient(chainId?: number): Client { if (chainId) { const client = clients.get(chainId) if (!client) throw new Error(`Chain ${chainId} not configured`) return client } return clients.get(chains[0]!.id)! } async function handleRequest( request: RpcRequest.RpcRequest<Schema.Ox>, options?: { chainId?: number | undefined }, ) { await onRequest?.(request) // Resolve chainId from: 1) explicit option (URL path), 2) first param object, 3) default chain. const params = 'params' in request && Array.isArray(request.params) ? request.params : [] const first = typeof params[0] === 'object' && params[0] ? (params[0] as Record<string, unknown>) : undefined const chainId = Utils.resolveChainId(first?.chainId) ?? options?.chainId ?? chains[0]!.id const client = getClient(chainId) switch (request.method) { case 'eth_fillTransaction': { const parameters = params[0] as Record<string, unknown> const capabilities = (parameters.capabilities ?? {}) as Record<string, unknown> 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' ? parameters.feePayer : 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) // 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<ReturnType<typeof fill>> let sponsored = false let feeToken = sponsoredFeeToken // Lazily resolve a swap source token when autoSwap needs one. const resolveFeeTokenForSwap = from ? async (insufficientToken: Address) => resolveFeeToken(client, { account: from, feeToken: undefined, kv, tokens: (await getTokens(chainId)).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 getTokens(chainId) 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 = (await getTokens(chainId))[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, kv, 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, kv, 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, kv, 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, kv, tokens: unsponsoredTokens, }) : requestFeeToken const transaction = { ...baseTx, ...(feeToken ? { feeToken } : {}) } filled = await fill(client, { autoSwap, feeToken, kv, 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) ?? (await getTokens(chainId))[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, kv, maxFeePerGas: transaction_filled.maxFeePerGas, }) const fee = await computeFee(client, { feeToken, gas: transaction_filled.gas, kv, maxFeePerGas: transaction_filled.maxFeePerGas, }).catch(() => undefined) return { balanceDiffs: undefined, fee } })(), // Sign as fee payer (if sponsored and not already signed). (async () => { if (!(sponsored && feePayerOptions && !alreadySigned)) return transaction_filled return Sponsorship.sign({ account: feePayerOptions.account, sender: from, transaction: transaction_filled, }) })(), // Resolve autoSwap metadata (when AMM path was taken). resolveAutoSwapMetadata(client, { autoSwap, kv, 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] 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, kv, }), 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]: balanceDiffs[zeroAddress] ?? [] } : balanceDiffs const metadata = await resolveTokenMetadata(client, { token, kv }).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 }, ) } } // @ts-expect-error case 'eth_signRawTransaction': case 'eth_sendRawTransaction': case 'eth_sendRawTransactionSync': { try { 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 }) } const serialized = params[0] 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, getClient, method: request.method as Sponsorship.handleRawTransaction.Options['method'], 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) } } 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() const isBatch = Array.isArray(body) if (!isBatch) { const request = RpcRequest.from(body as never) as RpcRequest.RpcRequest<Schema.Ox> return Response.json(await handleRequest(request, { chainId })) } const responses = await Promise.all( (body as unknown[]).map((item) => handleRequest(RpcRequest.from(item as never) as RpcRequest.RpcRequest<Schema.Ox>, { chainId, }), ), ) return Response.json(responses) } router.post(path, handlePost as never) router.post(`${path === '/' ? '' : path}/:chainId`, handlePost as never) return router } export namespace relay { export type Options = from.Options & { /** * Auto-swap options. */ autoSwap?: | false | { /** Slippage tolerance (e.g. 0.05 = 5%). @default 0.05 */ slippage?: number | undefined } | undefined /** * TTL in seconds for cached `resolveTokens` results. * @default 600 (10 minutes) */ cacheTtl?: number | undefined /** * Supported chains. The handler resolves the client based on the * `chainId` in the incoming transaction. * @default [tempo, tempoModerato, tempoDevnet] */ chains?: readonly [Chain, ...Chain[]] | 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 /** * Validates whether to sponsor the transaction. When omitted, all * transactions are sponsored. Return `false` to reject sponsorship. */ validate?: | ((request: Transaction.TransactionRequest) => boolean | Promise<boolean>) | undefined /** Sponsor display name returned from `eth_fillTransaction`. */ name?: string | undefined /** Sponsor URL returned from `eth_fillTransaction`. */ url?: string | undefined } | undefined /** * Kv store used to cache `resolveTokens` results across requests. * Provide `Kv.cloudflare(env.KV)` for cross-instance caching, or omit * for an in-process LRU. * @default Kv.memory() */ kv?: Kv.Kv | undefined /** * Resolves the list of known tokens for a chain. The relay checks * `balanceOf` for each token and picks the one with the highest balance * during fee token resolution. * @default Fetches `https://tokenlist.tempo.xyz/list/:chainId` */ resolveTokens?: | ((chainId: number) => readonly Tokenlist.Token[] | Promise<readonly Tokenlist.Token[]>) | 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 /** Function to call before handling the request. */ onRequest?: ((request: RpcRequest.RpcRequest) => Promise<void>) | undefined /** Path to use for the handler. @default "/" */ path?: string | undefined /** Transports keyed by chain ID. Defaults to `http()` for each chain. */ transports?: Record<number, Transport> | undefined } } // TODO: cleanup async function fill(client: Client, options: fill.Options) { const { autoSwap, feeToken, kv, transaction: request } = options // Skip re-formatting if already in RPC format (e.g. from viem's fillTransaction). const format = (value: Record<string, unknown>) => 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(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<string, any>).capabilities?.sponsor as | { address: Address; name?: string; url?: string } | undefined const mergedTx = mergeCallsFromRequest(result.tx as Record<string, unknown>, { ...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<string, unknown> }).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<string, unknown>, 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 }) .catch(() => 0n), resolveTokenMetadata(client, { token: resolvedFeeToken, kv }).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 kv?: Kv.Kv | undefined resolveFeeToken?: ((insufficientToken: Address) => Promise<Address | undefined>) | undefined transaction: Record<string, unknown> } } async function resolveFeeToken( client: Client, options: resolveFeeToken.Options, ): Promise<Address | undefined> { const { feeToken, account, kv, 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 = kv ? cached( kv, `fee.userToken:${client.chain?.id ?? 0}:${account.toLowerCase()}`, async () => { const result = await getUserToken() return result ? { address: result.address } : null }, { ttl: options.userTokenCacheTtl ?? 60 }, ) : 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 }).catch(() => 0n), })), ) : [], ]) // If on-chain preference is set and user has balance, use it. if (userToken) { const match = balances.find( (b) => 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 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 } declare namespace resolveFeeToken { type Options = { feeToken?: Address | undefined account?: Address | undefined kv?: Kv.Kv | 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<string, unknown>): readonly Address[] { const calls = transaction.calls as readonly { to?: Address }[] | undefined if (!calls) return [] const out: Address[] = [] const seen = new Set<string>() 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<Address, Address | null> async function resolveVirtualAddresses( client: Client, options: { calls: readonly Call[] }, ): Promise<VirtualAddresses | undefined> { const targets = getVirtualAddressTargets(options.calls) if (targets.length === 0) return undefined const masters = new Map<string, { addresses: Address[]; masterId: Hex.Hex }>() 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<Address>() 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 | undefined): 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<string, unknown>): 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, kv, 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, kv, logs, swap, tokenMetadata: tokenMetadata as never, }) : {} // Compute fee breakdown. const fee = await computeFee(client, { feeToken, gas, kv, 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, kv, 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 kv?: Kv.Kv | undefined maxFeePerGas?: bigint | undefined } } async function buildBalanceDiffs(client: Client, options: buildBalanceDiffs.Options) { const { account, kv, 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<Address>; token: Address } >() // Track total transferred per (token, spender) so we can suppress covered approvals. const transferredBySpender = new Map<string, bigint>() 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<Address>(), 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<Address>(), 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<string, { decimals: number; symbol: string; name: string }>() await Promise.all( entries.map(async (entry) => { try { const metadata = await resolveTokenMetadata(client, { token: entry.token, tokenMetadata, kv, }) 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 kv?: Kv.Kv | undefined logs: Log[] swap?: { tokenIn: Address; tokenOut: Address } | undefined tokenMetadata: Record<Address, { name: string; symbol: string; currency: string }> } } async function resolveTokenMetadata(client: Client, options: resolveTokenMetadata.Options) { const { token, tokenMetadata, kv } = options const meta = tokenMetadata?.[token] ?? tokenMetadata?.[token.toLowerCase() as Address] // TIP-20 metadata (decimals/symbol/name) is immutable per token, so cache // long-term in KV. Skips the multicall RPC on cache hits. const fetcher = () => Actions.token.getMetadata(client, { token }) const fallback = kv ? await cached(kv, `tokenMetadata:${client.chain?.id ?? 0}:${token.toLowerCase()}`, fetcher, { ttl: 24 * 60 * 60, }) : 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<Address, { name: string; symbol: string; currency: string }> | undefined kv?: Kv.Kv | undefined } } async function resolveAutoSwapMetadata(client: Client, options: resolveAutoSwapMetadata.Options) { const { autoSwap, kv, swap } = options if (!autoSwap || !swap) return undefined const [inMeta, outMeta] = await Promise.all([ resolveTokenMetadata(client, { token: swap.tokenIn, kv }).catch(() => undefined), resolveTokenMetadata(client, { token: swap.tokenOut, kv }).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 kv?: Kv.Kv | 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, kv, maxFeePerGas, tokenMetadata } = options if (!feeToken || !gas || !maxFeePerGas) return undefined try { const metadata = await resolveTokenMetadata(client, { token: feeToken, tokenMetadata, kv }) 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 kv?: Kv.Kv | undefined maxFeePerGas?: bigint | undefined t