Running a Transaction Simulator on a Mainnet Fork

Why simulate before you send

Every DeFi transaction is a bet that the on-chain state you read is still true when your transaction lands. Slippage settings, approval amounts, oracle prices—all of them can shift between the moment you preview a swap in the UI and the moment the transaction confirms. A local transaction simulator lets you run the exact calldata against real contract state and see the outcome before your wallet signs anything.

This is not about building a test suite for a protocol. It is about having a personal preflight check so that a $500 swap or a lending deposit does what you expect, costs what you expect, and does not silently approve more tokens than you intended.

What you need

The setup is minimal:

  • Anvil (part of Foundry): spins up a local Ethereum node forked from mainnet. Anvil docs
  • An RPC endpoint: Alchemy, Infura, or any provider that supports eth_getStorageAt and archive access. Free tiers work fine for occasional forks.
  • cast (also part of Foundry): sends transactions and reads state from the command line. Cast docs
  • Optionally, a small Python or JS script to wrap repeated simulation patterns.

If you already have Foundry installed (curl -L https://foundry.paradigm.xyz | bash && foundryup), you have everything.

Starting a fork and running your first simulation

Spin up a fork pinned to a recent block:

anvil \
  --fork-url $RPC_URL \
  --fork-block-number 21900000 \
  --chain-id 1 \
  --accounts 5

This gives you a local node at http://127.0.0.1:8545 with the full Ethereum state at block 21900000. Five test accounts are pre-funded with 10000 ETH each—enough to simulate any transaction without worrying about gas.

Now suppose you want to simulate a USDC approval before you send it on mainnet. You can impersonate your own address and run it locally:

# Impersonate your wallet on the fork
cast rpc anvil_impersonateAccount 0xYourWalletAddress \
  --rpc-url http://127.0.0.1:8545

# Simulate approving a spender for 500 USDC (6 decimals)
cast send 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \
  "approve(address,uint256)" \
  0xSpenderContractAddress \
  500000000 \
  --from 0xYourWalletAddress \
  --rpc-url http://127.0.0.1:8545

# Check the allowance the fork recorded
cast call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \
  "allowance(address,address)(uint256)" \
  0xYourWalletAddress \
  0xSpenderContractAddress \
  --rpc-url http://127.0.0.1:8545

If the allowance comes back as 500000000, the approval did what you expected. If the contract had some non-standard behavior or a proxy redirect, you would see it here before spending gas on mainnet.

Simulating a swap end-to-end

Approvals are simple. Swaps are where simulation pays for itself. Here is a more complete example that simulates a Uniswap V3 exact-input swap on the fork:

ROUTER="0xE592427A0AEce92De3Edee1F18E0157C05861564"
WETH="0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
USDC="0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
MY_ADDR="0xYourWalletAddress"

# Check WETH balance before
cast call $WETH "balanceOf(address)(uint256)" $MY_ADDR \
  --rpc-url http://127.0.0.1:8545

# Approve the router to spend WETH
cast send $WETH "approve(address,uint256)" $ROUTER $(cast max-uint) \
  --from $MY_ADDR --rpc-url http://127.0.0.1:8545

# Execute the swap: 0.1 WETH -> USDC, 0.5% slippage
# exactInputSingle params struct
cast send $ROUTER \
  "exactInputSingle((address,address,uint24,address,uint256,uint256,uint256,uint160))" \
  "($WETH,$USDC,3000,$MY_ADDR,$(date +%s)00,100000000000000000,0,0)" \
  --from $MY_ADDR --rpc-url http://127.0.0.1:8545

# Check USDC balance after
cast call $USDC "balanceOf(address)(uint256)" $MY_ADDR \
  --rpc-url http://127.0.0.1:8545

The USDC balance delta tells you exactly how much you would have received at this block. Compare that to the quote the DEX UI gave you. If they diverge significantly, something is off—maybe the pool moved, maybe the router is routing through a different path than expected.

A small Python wrapper for repeated simulations

When I want to simulate a transaction repeatedly (different amounts, different blocks), I use a thin wrapper around cast calls:

import subprocess
import json


def cast_call(to: str, sig: str, args: list[str], rpc: str = "http://127.0.0.1:8545") -> str:
    """Run a read-only call via cast and return the result."""
    cmd = ["cast", "call", to, sig] + args + ["--rpc-url", rpc]
    result = subprocess.run(cmd, capture_output=True, text=True, check=True)
    return result.stdout.strip()


def cast_send(to: str, sig: str, args: list[str], sender: str, rpc: str = "http://127.0.0.1:8545") -> str:
    """Send a state-changing transaction via cast."""
    cmd = ["cast", "send", to, sig] + args + ["--from", sender, "--rpc-url", rpc]
    result = subprocess.run(cmd, capture_output=True, text=True, check=True)
    return result.stdout.strip()


def get_balance(token: str, holder: str, rpc: str = "http://127.0.0.1:8545") -> int:
    """Read an ERC-20 balance and return it as an integer."""
    raw = cast_call(token, "balanceOf(address)(uint256)", [holder], rpc)
    return int(raw)


def simulate_swap(
    router: str,
    token_in: str,
    token_out: str,
    amount_in: int,
    fee: int,
    sender: str,
    rpc: str = "http://127.0.0.1:8545",
) -> dict:
    """Simulate a Uniswap V3 exactInputSingle and return balance changes."""
    before = get_balance(token_out, sender, rpc)

    # Approve
    cast_send(token_in, "approve(address,uint256)", [router, str(amount_in)], sender, rpc)

    # Swap
    deadline = "9999999999"
    params = f"({token_in},{token_out},{fee},{sender},{deadline},{amount_in},0,0)"
    cast_send(
        router,
        "exactInputSingle((address,address,uint24,address,uint256,uint256,uint256,uint160))",
        [params],
        sender,
        rpc,
    )

    after = get_balance(token_out, sender, rpc)
    return {"amount_out": after - before, "amount_in": amount_in}


if __name__ == "__main__":
    result = simulate_swap(
        router="0xE592427A0AEce92De3Edee1F18E0157C05861564",
        token_in="0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",  # WETH
        token_out="0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",  # USDC
        amount_in=100000000000000000,  # 0.1 WETH
        fee=3000,
        sender="0xYourWalletAddress",
    )
    print(f"Swapped {result['amount_in']} wei WETH -> {result['amount_out']} USDC units")

This is not production tooling. It is a personal preflight script that takes 30 lines and tells me whether a transaction will do what I think before I sign it with real keys.

What simulation catches that UIs miss

A few things I have caught by simulating locally that were not obvious from the dApp frontend:

  • Unlimited approvals by default. Several router UIs set type(uint256).max as the approval amount. Simulating the approval call locally shows the exact allowance that gets written, so I can decide whether to cap it.
  • Unexpected intermediate tokens. A swap that shows “WETH to USDC” in the UI might route through DAI or WBTC internally. Tracing the fork transaction with cast run --trace reveals every hop.
  • Gas costs on the wrong order of magnitude. A complex multicall that looks like a single swap in the UI might consume 500k+ gas. Seeing the actual gas used on the fork lets me set a realistic gas limit.
  • Reverts from stale state. If the pool price moved since the UI fetched its quote, the simulation reverts locally instead of burning gas on mainnet.

Risk analysis

  • Fork drift. The fork reflects state at a pinned block. If you simulate at block N but send the real transaction at block N+50, pool prices, liquidity depth, and oracle values may have changed. Simulation reduces surprise but does not eliminate it. Anvil docs
  • RPC provider limits. Forking pulls state lazily from your RPC. Free-tier providers throttle archive requests, which can cause the fork to stall or return errors on deep storage reads. If simulations fail intermittently, check your provider’s rate limits before blaming the contract.
  • False confidence from deterministic environments. A fork has no competing transactions, no MEV, and no gas auctions. A swap that returns exactly $247.32 on the fork might return $245 on mainnet after sandwich bots and priority fee competition. Treat fork results as an upper bound, not a guarantee.
  • Impersonation hides signing errors. When you impersonate an address on the fork, you skip wallet signing entirely. Bugs in how you encode calldata for your hardware wallet or how a dApp constructs the transaction object will not surface until you try for real. Simulate the logic, but still do a small real test before scaling up.

Practical takeaways

  • Keep Anvil and cast installed as part of your DeFi toolkit, even if you never write Solidity. They work as a transaction previewer for any EVM chain.
  • Pin a recent block number when forking. Unpinned forks hit the latest block and produce non-reproducible results.
  • Simulate approvals, not just swaps. Knowing exactly what allowance a dApp sets is worth the 10 seconds it takes to check on a fork.
  • Treat fork results as directional. The real transaction will face competition and state changes that the fork cannot replicate.
  • For sub-$1k wallets, the cost of a failed or surprising transaction is a meaningful percentage of the portfolio. A quick simulation is cheap insurance.