accounts
Version:
Tempo Accounts SDK
1,342 lines (1,235 loc) • 53.2 kB
text/typescript
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