UNPKG

accounts

Version:

Tempo Accounts SDK

1,058 lines 51.7 kB
import { AbiEvent, Hex, RpcRequest, RpcResponse } from 'ox'; import { Transaction as core_Transaction } from 'ox/tempo'; import { createClient, decodeFunctionData, formatUnits, http, isAddress, parseEventLogs, zeroAddress, } from 'viem'; 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 { 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 = {}) { 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) => { 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(); 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) { 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, options) { 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] : 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]; const capabilities = (parameters.capabilities ?? {}); try { const from = typeof parameters.from === 'string' ? parameters.from : undefined; const requestFeeToken = typeof parameters.feeToken === 'string' ? parameters.feeToken : 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; let sponsored = false; let feeToken = sponsoredFeeToken; // Lazily resolve a swap source token when autoSwap needs one. const resolveFeeTokenForSwap = from ? async (insufficientToken) => 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.feeToken == null) filled.transaction.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 ?? (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), 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; 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), 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.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.', }, }, { request }); } const result = await client.request(request); return RpcResponse.from({ result }, { request }); } const serialized = params[0]; if (typeof serialized !== 'string' || !Sponsorship.requestsRawSponsorship(serialized)) { const result = await client.request(request); return RpcResponse.from({ result }, { request }); } const result = await Sponsorship.handleRawTransaction({ account: feePayerOptions.account, getClient, method: request.method, request: { params: 'params' in request ? request.params : undefined }, validate: feePayerOptions.validate, }); return RpcResponse.from({ result }, { request }); } catch (error) { return Utils.rpcErrorJson(request, error); } } default: { try { const result = await client.request(request); return RpcResponse.from({ result }, { request }); } catch (error) { return Utils.rpcErrorJson(request, error); } } } } const router = from(rest); async function handlePost(c) { 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); return Response.json(await handleRequest(request, { chainId })); } const responses = await Promise.all(body.map((item) => handleRequest(RpcRequest.from(item), { chainId, }))); return Response.json(responses); } router.post(path, handlePost); router.post(`${path === '/' ? '' : path}/:chainId`, handlePost); return router; } // TODO: cleanup async function fill(client, 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) => 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, deficit) { 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 ?? []; const swapCalls = buildSwapCalls(sourceToken, insufficientToken, deficit, maxAmountIn); const result = await client.request({ method: 'eth_fillTransaction', params: [ format({ ...request, calls: [...swapCalls, ...originalCalls], }), ], }); const sponsor = result.capabilities?.sponsor; const mergedTx = mergeCallsFromRequest(result.tx, { ...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], }); // 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) + 20000n); const upstreamCapabilities = result.capabilities; const sponsor = upstreamCapabilities?.sponsor; // 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; if (upstreamError) { const synthetic = new Error(upstreamError.message ?? upstreamError.errorName ?? 'UpstreamRevert'); synthetic.name = 'UpstreamRevertError'; synthetic.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, 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; const resolvedFeeToken = (mergedTx.feeToken ?? feeToken); const gas = mergedTx.gas ? BigInt(mergedTx.gas) : 0n; const maxFeePerGas = mergedTx.maxFeePerGas ? BigInt(mergedTx.maxFeePerGas) : 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, required - available); if (!swapResult) throw error; return swapResult; } } async function resolveFeeToken(client, options) { 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; for (const asset of balances) { if (asset.balance <= 0n) continue; if (!best || asset.balance > best.balance) best = asset; } if (best) return best.address; } /** * 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) { const calls = transaction.calls; if (!calls) return []; const out = []; 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; } async function resolveVirtualAddresses(client, options) { 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]); })); return Object.fromEntries(entries.flat()); } function getVirtualAddressTargets(calls) { 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()); } } return [...targets]; } function decodeTransferRecipient(data) { 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, }); 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) { const calls = transaction.calls; if (calls && calls.length > 0) return calls.map((c) => ({ ...(c.to ? { to: c.to } : {}), ...(c.data ? { data: c.data } : {}), ...(c.value ? { value: c.value } : {}), })); return [ { ...(transaction.to ? { to: transaction.to } : {}), ...(transaction.data ? { data: transaction.data } : {}), ...(transaction.value ? { value: transaction.value } : {}), }, ]; } // TODO: cleanup/remove async function simulate(client, options) { const { account, calls } = options; try { return await Actions.simulate.simulateCalls(client, { ...(account ? { account } : {}), calls: calls, 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.code ?? error.cause?.code; if (code !== -32601) throw error; const { results } = await simulateCalls(client, { ...(account ? { account } : {}), calls: calls, }); return { results, tokenMetadata: undefined }; } } async function simulateAndParseDiffs(client, 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 = []; for (const result of results) 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, }) : {}; // Compute fee breakdown. const fee = await computeFee(client, { feeToken, gas, kv, maxFeePerGas, tokenMetadata: tokenMetadata, }).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 }; } } async function buildBalanceDiffs(client, 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(); // 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, kv, }); metadataMap.set(entry.token.toLowerCase(), metadata); } catch { } })); // Build the diff array for this account. const diffs = []; 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], value: Hex.fromNumber(net), }); } return { [account]: diffs }; } async function resolveTokenMetadata(client, options) { const { token, tokenMetadata, kv } = options; const meta = tokenMetadata?.[token] ?? tokenMetadata?.[token.toLowerCase()]; // 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, }; } async function resolveAutoSwapMetadata(client, 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), formatted: formatUnits(swap.maxAmountIn, inMeta.decimals), decimals: inMeta.decimals, symbol: inMeta.symbol, name: inMeta.name, }, minOut: { token: swap.tokenOut, value: Hex.fromNumber(swap.amountOut), formatted: formatUnits(swap.amountOut, outMeta.decimals), decimals: outMeta.decimals, symbol: outMeta.symbol, name: outMeta.name, }, }; } async function computeFee(client, 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), decimals: metadata.decimals, formatted: formatUnits(amount, metadata.decimals), symbol: metadata.symbol, }; } catch { return undefined; } } function buildSwapCalls(sourceToken, targetToken, deficit, maxAmountIn) { const approve = Actions.token.approve.call({ 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 }, ]; } /** * 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