Skip to content
docs

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, increaseAllowance and increaseApproval, an EIP-2612 permit call, Permit2 approve and permit, single or batch, setApprovalForAll), token transfers, and the name of every contract you list as known. A Permit2 batch puts every allowance in approvals (the first one is also approval).
  • Simulated balance changes. The whole sequence runs in one simulated block through eth_simulateV1 with 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.

Terminal window
npm install @anywalletkit/tx-preview-evm
import { 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').

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 with method, label, and approval or transfer when decoded. An approval’s amount is null when 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) and gasUsed;
    • reverted: callIndex and reason;
    • 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.

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)

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.

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.