viem-tx-sim
v0.3.0
Published
Preview transaction and batch balance changes with viem—no local fork or simulation API.
Maintainers
Readme
viem-tx-sim
Preview transaction and batch balance changes with viem over JSON-RPC, without a local fork or simulation API.
Pass your viem PublicClient, sender, and calldata to preview native and token balance changes before signing. Use the same API for one transaction or an ordered batch.
[!IMPORTANT] A simulation previews one RPC state snapshot. The signed transaction may execute against different state. Contracts can observe the injected code at
from, so do not use the result as a security boundary.
Before you install
- Runtime: Node.js 20 or newer, ESM, and
viem2.8 or newer. - RPC: Requires
eth_callwith state overrides, andeth_createAccessListfor discovery. Some RPC providers omit access lists when a probed call reverts. - Scope: Caller receives raw balance changes and reverts. Client handles token metadata, prices, gas estimation, transaction assembly, and permit signing.
Install
pnpm add viem-tx-sim viemQuick start
Use the viem client, sender, and calls from your application. List the balances you want to observe:
import { TxSimulator } from "viem-tx-sim";
const simulator = TxSimulator.create({ client });
const result = await simulator.simulate({
from,
calls,
balanceQueries: [
{ asset: "native", account: from },
{ asset: tokenAddress, account: from },
{ asset: tokenAddress, account: recipient },
],
});
if (result.status === "reverted") {
console.error(result.failingCallIndex, result.revertReason);
} else {
console.table(result.balanceDeltas);
}For a transfer of 250 token units, the table would contain:
| asset | account | before | after | delta | byCall |
| --- | --- | ---: | ---: | ---: | --- |
| 0xToken… | from | 1000n | 750n | -250n | [-250n] |
| 0xToken… | recipient | 10n | 260n | 250n | [250n] |
Each balance delta contains before, after, delta, and byCall. delta is after - before; byCall[i] is the change caused by calls[i]. result.unresolved contains unreadable queries.
See examples for discovery, sequential batches, unfunded accounts, and revert handling.
Choose your workflow
simulate() takes calls, balance queries, and optional state assumptions. It sends one eth_call. Call helper methods for discovery.
| Goal | Use | Example |
| --- | --- | --- |
| Observe known balances | simulate() with balanceQueries | Known balances |
| Discover the sender's touched ERC-20 balances | balanceQueries.forUser() | Balance discovery |
| Simulate approve-then-use or another ordered batch | simulate() with multiple calls | Sequential batch |
| Preview an unfunded or view-only account | tokenOverrides.* or nativeBalanceOverrides | Unfunded account |
| Decode reverts or inspect RPC activity | errorAbi and debug | Reverts and debugging |
How it works
flowchart TD
accTitle: Transaction simulation flow
accDescr: Optional helpers prepare inputs. One eth_call injects a ghost contract at the sender, executes calls in order, and returns balance deltas or revert details.
H["Optional helpers"] --> I["Calls, balance queries,<br/>state assumptions"]
I --> S["simulate()"]
S --> R["eth_call with<br/>state overrides"]
R --> G["Ghost contract at from"]
G --> E["Calls run in order<br/>msg.sender == from"]
E --> O["Balance deltas<br/>or revert details"]During eth_call, the package injects the runtime bytecode of a never-deployed ghost contract at from.
Downstream contracts see msg.sender == from. Calls run in order within one EVM context. The ghost contract records balance checkpoints before and after each call, then returns total and per-call changes. eth_call writes no state onchain.
The optional helpers prepare two kinds of explicit input:
balanceQueries.*discovers balances to observe.tokenOverrides.*verifies storage slots or estimates required balances and allowances.
Results and errors
Both successful and reverted simulations return balanceDeltas and unresolved. A reverted result includes failingCallIndex and revertData. Successful decoding adds revertSelector, revertReason, and revertError.
Transaction reverts are results. Infrastructure and invalid-input failures throw typed errors:
AccessListUnsupportedError: the RPC endpoint cannot create access lists.StateOverrideUnsupportedError: the RPC endpoint rejected state overrides or returned invalid simulator data.InvalidSimulationInputError: the caller supplied invalid input, such as an empty batch.
RPC providers and contracts control parts of error messages and revertReason. Treat that text as untrusted before rendering it in a UI.
Batch gas estimation
gas.estimateBatch() measures per-call execution gas for a sequential batch in one eth_call (zero access lists). Dependent non-atomic legs — the canonical approve-then-swap — cannot be eth_estimateGas-ed standalone, because the second leg reverts without the first leg's state. The ghost runs the batch sequentially in one frame through a probe-free entry point and returns each call's gas.
const estimate = await simulator.gas.estimateBatch({
from,
calls: [approveCall, swapCall],
// Prepare with tokenOverrides.* first — an unfunded account can't measure a swap.
tokenSlotOverrides,
});
// estimate.byCall[i] = { executionGas, intrinsicAndCalldataGas, suggestedLimit }
// estimate.failingCallIndex is null on success, or the index of the first reverting call.Each suggestedLimit is executionGas + intrinsicAndCalldataGas and is pre-buffer. Apply your own EIP-150 headroom before using it as a per-leg gas limit — 2× is recommended. EIP-150's 63/64 gas-forwarding rule means a limit that merely equals raw consumption can run out of gas at the innermost frame of a deep call tree, because each CALL forwards only 63/64 of remaining gas. The library returns a pre-buffer value and leaves the multiplier as a product decision: a wallet that re-estimates at broadcast can use a tighter factor.
Two error bars apply, and both should be documented to consumers:
- State drift between simulation and broadcast. Non-atomic 5792 legs broadcast as separate transactions across potentially several blocks; pools move, storage changes, and the gas a leg actually costs at broadcast can differ from the simulated value. The direction is unpredictable; the buffer absorbs modest drift, large drift needs re-estimation.
- Warm/cold access divergence (known direction). The sequential simulation runs all calls in one frame, so call 2 sees storage and accounts that call 1 already warmed (EIP-2929: 100 gas warm vs 2100 cold SLOAD, 100 vs 2600 for account access). When the same legs broadcast as separate transactions, each real transaction starts with cold state. The sequential simulation therefore systematically under-measures the gas of later calls that reuse earlier calls' warmed slots — the real cold-start transaction costs more. This compounds with the EIP-150 dilution in the same direction, a second reason the buffer should be generous. (For an atomic batch executed as one transaction the warming is real and the measurement is accurate — the divergence is specifically a non-atomic, separate-broadcast artifact.)
Limitations
- A simulation reads one RPC state snapshot. Pending transactions, later blocks, and builder behavior can change the outcome before the signed transaction lands.
balanceQueries.forUser()derives candidates frometh_createAccessList. If the RPC provider returns a revert without an access list, the helper may miss addresses touched later in the call. Supply balance queries when you know the assets. When the provider rejects the access list becausefromcannot fund the calls, discovery degrades to the direct call targets rather than throwing (direct transfers still discovered; intermediary tokens may be missed).- Permit2 measurement in
tokenOverrides.estimateRequirements()depends on the Permit2 singleton being in the discovered candidate set. When discovery degrades to direct call targets (unfundedfrom, above), a Permit2 singleton reached only through inner calls is not a candidate, so its requirements are silently unmeasured. Fundfrom, or include a direct Permit2 call in the batch, to restore measurement. - Balance queries read native balances or
balanceOf(address). The library does not model token-ID ownership or ERC-1155 balances by ID. - NFT capture (
nftQueries) reports ERC-721/1155 tokens received byfromduring simulation — via receiver callbacks (safe transfers,_safeMint) and an ERC-721 Enumerable walk (plain_minton Enumerable collections such as Uniswap V3 positions). It does not detect: NFTs sent byfrom; counter-based mints that are neither safe nor Enumerable (e.g. Uniswap V4 positions — planned); plain-_mintnon-Enumerable ERC-721s; or general ERC-1155 balances. When a batch both receives and sends tokens of the same plain-_mintEnumerable collection, the swap-and-pop reindexing an ERC-721 Enumerable transfer performs can move a newly received token below the walk's index window, under-reporting it (receipts recorded via safe-mint/safe-transfer receiver hooks are unaffected). CapturedtokenUrireflects post-simulation state and is best-effort under a gas budget — heavy on-chain renderers may return undefined. - Injecting code at
fromchanges code-size and account-type checks. The ghost contract handles ERC-1271 and common NFT receiver flows. Contracts with other EOA-versus-contract branches may choose another path during simulation. - Storage overrides require a verified balance or allowance slot. The helper puts non-standard or indirect layouts in
unresolvedand leaves their state unchanged. - Supply signed permit calldata. Your application creates and signs permits.
tokenSlotOverridesamounts must be belowuint256.max. UseOVERRIDE_TOKEN_AMOUNTwhen you construct overrides so allowance decrements and incoming transfers remain observable.
To pin a multi-step workflow, pass the same fixed blockNumber to every discovery, preparation, and simulate() call. A moving tag such as latest can resolve to a different block on each RPC request.
Public API
TxSimulator.create({ client, gas?, debug?, errorAbi? })simulate({ from, calls, balanceQueries, … })balanceQueries.forUser()andbalanceQueries.discoverErc20s()tokenOverrides.forBalances()andtokenOverrides.forAllowances()tokenOverrides.estimateRequirements()gas.estimateBatch()DEFAULT_SIMULATION_GAS_LIMITandOVERRIDE_TOKEN_AMOUNT
The package exports its public argument/result types and typed error classes. TypeScript declarations are the detailed reference.
Development
Use Node.js 20+, pnpm 10, and Foundry nightly nightly-7debd6d47628c5551837534aee507dbf552d5889.
Foundry v1.7.1 lacks the access-list-on-revert behavior required by the test suite. Replace the nightly pin with the first stable release newer than v1.7.1.
foundryup --install nightly-7debd6d47628c5551837534aee507dbf552d5889
pnpm install
pnpm verifypnpm verify runs formatting checks, linting, typechecking, the build, and the test suite. The test suite starts isolated local EVM nodes. After changing the contract, run pnpm build:contracts to regenerate src/generated/txSimulatorBytecode.ts. Do not edit generated bytecode by hand.
Run the mainnet tests with an RPC URL:
MAINNET_RPC_URL=https://your-rpc.example pnpm test:mainnetSupport and contributing
Use GitHub Issues for usage questions, reproducible bugs, provider compatibility reports, and focused proposals. Pull requests should include relevant tests and pass pnpm verify.
Report vulnerabilities through GitHub's private vulnerability form. If the form is unavailable, open an issue requesting a private contact channel without including technical details.
See the changelog for released changes.
Credits
Apoorv Lathey's transaction-simulation thread inspired the ghost-contract approach. Read docs/motivation.md for the original design notes and diagrams.
License
MIT © 2026 frontier159
