Transaction preview
A review screen that shows only “to: 0x1231…” and “data: 0x095ea7b3…” asks the user to trust
blindly. tx-preview-evm turns one call, or an approve → call sequence, into what the user
needs to see before signing:
- Decoded calls. Approvals (ERC-20
approve,increaseAllowanceandincreaseApproval, an EIP-2612permitcall, Permit2approveandpermit, single or batch,setApprovalForAll), token transfers, and the name of every contract you list as known. A Permit2 batch puts every allowance inapprovals(the first one is alsoapproval). - Simulated balance changes. The whole sequence runs in one simulated block through
eth_simulateV1with transfer tracing: “-100 USDC, +0.03 ETH”. - Checks. Named outcomes (
approval-unlimited,simulation-reverted,recipient-foreign…) that your app turns into its own words.
Nothing is signed or sent. The module only reads, through your RPC. Snippets come from
examples/tx-preview, which our CI typechecks and tests.
npm install @anywalletkit/tx-preview-evmimport { previewEvmTx, type PreviewCall, type Rpc, type TxPreview } from '@anywalletkit/tx-preview-evm'import type { Policies, WriteIntent } from '@anywalletkit/kit'import type { Swap2ExecutionPlan } from '@anywalletkit/protocol-swap2-core'import { encodeFunctionData, erc20Abi, formatUnits, type Hex } from 'viem'The module takes a function (method, params) → result, so any transport works:
/** The module takes any JSON-RPC transport: (method, params) → result. */export function jsonRpc(url: string): Rpc { return async (method, params) => { const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), }) const body = (await res.json()) as { result?: unknown; error?: { code: number; message: string; data?: string } } // Keep code and data: the module reads them to tell "no eth_simulateV1" from a revert. if (body.error) throw Object.assign(new Error(body.error.message), body.error) return body.result }}Pass JSON-RPC errors through with their code and data: the module uses them to tell a
revert from a node that has no eth_simulateV1.
Balance changes need eth_simulateV1. On public endpoints we checked (October 2026) it is
served on Ethereum, Base, Arbitrum, Optimism, Polygon and BNB Chain, and missing on
Avalanche. Where it is missing, a single call falls back to eth_call, which tells pass from
revert but shows no balance changes. A sequence can’t be simulated there at all
(simulation.status: 'unavailable').
Preview a transaction
Section titled “Preview a transaction”const WETH_BASE = '0x4200000000000000000000000000000000000006'
export async function previewWrap(from: string, amount: bigint): Promise<TxPreview> { return previewEvmTx({ chainId: 8453, from, rpc: jsonRpc('https://base-rpc.publicnode.com'), own: [from], // every address of this user: sends between them are not "foreign" known: { [WETH_BASE]: 'Wrapped Ether' }, // contracts you trust, with the name to show calls: [{ to: WETH_BASE, data: '0xd0e30db0', value: amount }], // WETH deposit() // Simulate as if the account held 1 ETH: preview a flow before it is funded. stateOverrides: { [from]: { balance: 10n ** 18n } }, })}| Input | Meaning |
|---|---|
calls |
Run in order in one block: approvals first, then the call |
own |
Every address of this user; a transfer to one of them is not “foreign” |
known |
Contracts you trust → the name to show (a provider’s routers, your tokens) |
expectedRecipient |
Where the funds should arrive; anyone else is flagged |
expectedApproval |
The allowance the flow needs; a larger approval is flagged |
stateOverrides |
Simulate with a different balance, nonce or code, e.g. to preview a flow before the account is funded |
native |
Symbol and decimals of the gas coin (default ETH, 18) |
The result has three parts:
calls. Each call withmethod,label, andapprovalortransferwhen decoded. An approval’samountisnullwhen it is unlimited (2²⁵⁵ or more) or an operator approval.simulation. One of three statuses:ok:changes(signed deltas in base units, with symbol and decimals) andgasUsed;reverted:callIndexandreason;unavailable:reason.
checks.{ code, status: 'ok' | 'warn' | 'unknown', detail }.
Balance changes count ERC-20 Transfer events to and from the sender, plus native transfers.
WETH-style Deposit / Withdrawal events count only for contracts in known, so a
look-alike event from an arbitrary contract can’t fake a balance change.
Turn checks into a verdict
Section titled “Turn checks into a verdict”Checks are codes, not text. The wording, the language and how strict to be are your app’s decisions:
export type Verdict = { level: 'ok' | 'warn' | 'danger'; lines: string[] }
/** Checks are codes, not prose: the wording (and the language) is yours. */export function verdict(preview: TxPreview): Verdict { const lines: string[] = [] let level: Verdict['level'] = 'ok' const raise = (to: Verdict['level']) => { if (to === 'danger' || level === 'ok') level = to } for (const check of preview.checks) { const d = check.detail ?? {} switch (check.code) { case 'simulation-reverted': raise('danger'); lines.push(`This transaction will fail: ${d.reason}`); break case 'simulation-unavailable': raise('warn'); lines.push('This network cannot simulate it. Check the details yourself.'); break case 'approval-unlimited': raise('danger'); lines.push(`${d.label ?? d.spender} could spend ALL of this token, now and later.`); break case 'approval-excess': raise('warn'); lines.push(`Approves ${d.amount}, but the swap needs only ${d.expected}.`); break case 'approval-unchecked': raise('warn'); lines.push(`Approves ${d.amount} to ${d.label ?? d.spender}; nothing says how much is needed.`); break case 'approval-unknown-spender': raise('warn'); lines.push(`Unknown spender ${d.spender}.`); break case 'contract-unknown': raise('warn'); lines.push(`Unknown contract ${d.address}.`); break case 'recipient-foreign': lines.push(`Sends to ${d.address}, which is not one of your accounts.`); break default: break // the "ok" codes need no line } } return { level, lines }}
/** Balance changes for the sheet: "-0.01 ETH", "+0.01 WETH". */export function balanceLines(preview: TxPreview): string[] { if (preview.simulation.status !== 'ok') return [] return preview.simulation.changes.map((c) => { const sign = c.delta < 0n ? '-' : '+' const abs = c.delta < 0n ? -c.delta : c.delta const amount = c.decimals === null ? abs.toString() : formatUnits(abs, c.decimals) return `${sign}${amount} ${c.symbol ?? c.token}` })}| Code | Status | Meaning |
|---|---|---|
contract-known / contract-unknown |
ok / warn | A contract a call goes to is in known, or isn’t — one per target, every call but approvals (a transfer-shaped call included) |
simulation-ok |
ok | Simulated; balance changes are in simulation.changes |
simulation-reverted |
warn | It will fail; detail.reason says why |
simulation-unavailable |
unknown | The network can’t simulate it |
recipient-own / recipient-foreign |
ok / warn | A recipient is, or isn’t, one of own |
approval-exact |
ok | Approves no more than expectedApproval |
approval-unchecked |
unknown | A bounded approval and no expectedApproval to compare it with |
approval-excess |
warn | Approves more than expectedApproval |
approval-unlimited |
warn | Unlimited allowance (≥ 2^128 − 1) or operator approval |
approval-unknown-spender |
warn | The spender is not in known |
approval-unparsed |
warn | A permit selector (EIP-2612 or Permit2) whose arguments can’t be read: it may grant an allowance nobody can size |
native-value |
ok / warn | A call sends native coin into a contract (detail.amount, per call); warn when that contract is not in known (detail.address) |
Preview every write
Section titled “Preview every write”Policies call confirm before every write, which makes it the place to simulate. The
intent carries the call as raw:
/** The EVM calls behind a kit write, when it has any. */function callsOf(intent: WriteIntent): PreviewCall[] | null { const raw = intent.raw as Record<string, unknown> if (intent.method === 'sendTransaction' || intent.method === 'signTransaction') { const txs = (Array.isArray(raw) ? raw : [raw]) as { to: string; data?: string; value?: bigint }[] return txs.map(({ to, data, value }) => ({ to, ...(data ? { data } : {}), value: BigInt(value ?? 0) })) } if ((intent.kind === 'approval' || intent.kind === 'revoke') && intent.method === 'approve') { const { token, spender, amount } = raw as { token: string; spender: string; amount: bigint } return [{ to: token, data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [spender as Hex, amount] }) }] } return null // messages, typed data, other chains: confirm without a simulation}
/** Policies whose confirm step simulates EVM writes before asking the user. */export function previewPolicies( rpcFor: (chainId: number) => Rpc, own: string[], ask: (intent: WriteIntent, preview: TxPreview | null) => Promise<boolean>,): Policies { return { confirm: async (intent) => { const calls = intent.chain.startsWith('eip155:') ? callsOf(intent) : null if (!calls) return ask(intent, null) const chainId = Number(intent.chain.split(':')[1]) const preview = await previewEvmTx({ chainId, from: intent.from, rpc: rpcFor(chainId), own, calls }) return ask(intent, preview) }, }}ask receives the preview, or null for writes with nothing to simulate (message
signatures, typed data, non-EVM chains), and shows your sheet. With this in the wallet’s
policies, no EVM transaction reaches the key without a simulation the user has seen. See
Policies.
Preview a swap
Section titled “Preview a swap”The swap router hands the exact plan to reviewExecution before the first approval. Preview
the approvals and the source call together, the way they will run:
/** A swap's approvals and source call, previewed as the one sequence they will run as. */export async function previewSwap(plan: Swap2ExecutionPlan, from: string, rpc: Rpc): Promise<TxPreview | null> { if (plan.source.kind !== 'call') return null // serialized / typed-order legs are checked by the provider module const approvals: PreviewCall[] = plan.approvals.map((a) => ({ to: a.token, data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [a.spender as Hex, a.amount] }), })) return previewEvmTx({ chainId: Number(plan.fromChain.split(':')[1]), from, rpc, own: [from], // The router the provider named is the contract the user is trusting. known: Object.fromEntries([plan.source.to, ...plan.approvals.map((a) => a.spender)].map((a) => [a, plan.provider])), calls: [...approvals, { to: plan.source.to, ...(plan.source.data ? { data: plan.source.data } : {}), value: plan.source.value }], expectedApproval: plan.sourceAmount, // anything above the swap input is flagged ...(plan.destChain === plan.fromChain ? { expectedRecipient: plan.recipient } : {}), })}expectedApproval: plan.sourceAmount flags a route that asks for more than it spends. For a
same-chain swap, expectedRecipient flags output going to anyone but the user. Wire it into
the router as shown in Swaps → Show a review screen.