Documentation
fevm-replay is a command-line tool written in Fe. It needs a Linux x86-64 or macOS AArch64 host (on Windows, use WSL), a C compiler, and curl for fetching.
Quickstart
fevm needs std::native::sha256_compress, which reached Fe's master just after the 26.6.0 release, so the project pins Fe at the commit fevm itself pins. Building Fe from source needs Rust 1.99, Node.js and the tree-sitter CLI; cranelift is the native backend.
npm install -g tree-sitter-cli@0.24.7
rustup toolchain install 1.99.0 --profile minimal
cargo +1.99.0 install --locked --root ~/fe \
--git https://github.com/argotorg/fe \
--rev 4608e5fdfa3783ec78347f297ad0fced8928fcc7 --features cranelift fe
export PATH="$HOME/fe/bin:$PATH"
git clone https://github.com/PugarHuda/fevm-replay
cd fevm-replay
fe test --backend native .
fe build --backend native -O 2 --emit executable --out-dir out .
./out/fevm_replay fixtures/mainnet/*.json
The last command replays seven saved mainnet transactions and prints MATCH for each.
Commands
| Command | What it does |
|---|---|
fevm_replay <fixture.json>... | Replays each single-transaction fixture and prints MATCH or DIFF with every difference. The exit status is the number of fixtures that did not match. |
fevm_replay fetch <txhash> <fixture.json> [rpc] | Asks the node about one transaction and writes its fixture. |
fevm_replay fetch-block <0x-number> <dir> [rpc] | Saves the node's answers for a whole block in dir, with five calls plus one per missing delegation target. |
fevm_replay block <dir> [<address>|<deployed.hex> <runtime.bin>] | Replays every transaction of a saved block. With an address and runtime code, runs a shadow replay; with a file of deployed code instead of an address, shadows every account running that code. |
fevm_replay fetch-code <address> <file> [rpc] | Saves the code deployed at an address, as hex. |
fevm_replay fuzz <deployed.hex> <port.bin> [runs] [steps] [seed] | Random calls against the deployed code and a port, compared after each. Exits 1 if a well-formed call differs. |
fevm_replay fuzz-pair <deployed.hex> <port.bin> <token.hex> [runs] [steps] [seed] | The same for a Uniswap V2 pair between two tokens running token.hex, with a factory and flash-swap callees. |
fevm_replay range <first> <count> <dir> [<address>|<deployed.hex> <runtime.bin>] [rpc] | Fetches the blocks not yet saved under dir, replays them and prints totals. Run it again to resume. A block whose transactions read an older block's hash gets the 256 hashes before it and replays again. |
fevm_replay fetch-hashes <dir> [rpc] | Adds the 256 block hashes before a saved block to its delegates.json, for transactions that read an older block's hash. |
fevm_replay fuzz-block <dir>... <deployed.hex> <port.bin> [mutations] [seed] [rpc] | With an RPC URL last, state a changed calldata reaches beyond its transaction's prestate is fetched (eth_createAccessList, values at the parent block brought forward by the block's earlier diffs). Mainnet-seeded fuzzing: mutates every transaction of the saved blocks that touches the deployed code and runs it on its own pre-state with the deployed code and with the port. Exits 1 on a difference. |
fevm_replay trace <dir> <index> <address|deployed.hex> <port.bin> | Both runs of one saved transaction, deployed and shadowed, call by call as JSON: depth, opcode, the account each call runs as, target, value, gas given and used, status, calldata and output (first 256 bytes), and where the runs part. The explorer reads this. |
fevm_replay chain <dir> [rpc] | Replays a saved block as one chain: each transaction on the state the previous one left, checked against the chain's receipt and diff; then credits the withdrawals and, with an RPC URL, checks every recipient's balance against the node's. |
fevm_replay mutants <port-dir> <Contract> <deployed.hex> <token.hex|-> <count> [<dir>...] | Plants count operator mutants in the port's src/lib.fe, builds each with fe, and checks it with random calls and mutations of the saved blocks. Prints the survivors and the share killed. |
fevm_replay certify <out.html> <deployed.hex> <port-dir> <Contract> <token.hex|-> <mutants> <dir>... | All of the checks for one port, written to one HTML page. Exits 1 if anything differs. |
fevm_replay fuzz-usdt <deployed.hex> <port.bin> [runs] [steps] [seed] | Random calls against USDT as deployed and its port, the owner's functions included. usdt as the token argument of certify or mutants selects the same scenario. |
fevm_replay fuzz-proxy <deployed.hex> <port.bin> <implementation.hex> [runs] [steps] [seed] | Random calls against an upgradeable proxy and its port, in front of the implementation code: forwarded calls, upgrades, upgradeToAndCall, admin changes. proxy:<implementation.hex> selects it in certify or mutants. |
fevm_replay opgas <address|deployed.hex> <port.bin> <selector> <dir>... | Gas per opcode in calls to one function, on the deployed code and on the port, over the saved blocks. |
The node needs the debug namespace. The default is the free https://eth.drpc.org; pass another URL as the last argument of fetch or fetch-block. Fetch blocks a few minutes behind the head: near the head, a load-balanced endpoint can answer from nodes that disagree.
Reading the output
DIFF fixtures/negative/storage.json
storage 0x…dac17f958d2ee523a2206206994597c13d831ec7 slot 0x03416c29…
chain 0x…7d44e33f
fevm 0x…7d44e33e
Each difference names what differs and shows the chain's value above fevm's: status, gas used, a log, an account's balance, nonce or code size, or a storage slot. A block replay labels a difference whose cause it can name:
| Label | Meaning |
|---|---|
| (ran BLOCKHASH…) | The transaction read a block hash older than its parent's, which the node's traces cannot carry. Give the fixture a blockHashes object to replay it exactly. |
| (reads gas after the shadow code ran) | Shadow replay only. Other code read the gas left after the port ran, so a cheaper or dearer port may change what it does. |
| no label | Anything else. In a plain replay, fetch the transaction again with fetch: block traces from load-balanced nodes are sometimes inconsistent. In a shadow replay, the port behaved differently. |
block also refuses data whose receipts name a different block hash than the header, and asks you to fetch again.
Fixture format
A fixture is one JSON object holding the node's answers about one transaction, so it replays offline and identically on any machine.
| Field | Source |
|---|---|
block | eth_getBlockByNumber of the transaction's block |
tx | eth_getTransactionByHash |
receipt | eth_getTransactionReceipt |
prestate | debug_traceTransaction with prestateTracer: every account and slot it read |
diff | The same tracer with diffMode: what it changed |
chainId, blobBaseFee | eth_chainId, and EIP-4844's fee from the block's excess blob gas under mainnet's BPO2 schedule |
delegates (optional) | Code of EIP-7702 delegation targets the prestate leaves out, from eth_getCode at the parent block |
blockHashes (optional) | Block numbers to hashes, for BLOCKHASH beyond the parent |
Block directories
fetch-block saves the raw JSON-RPC responses: block.json (with full transactions), receipts.json (eth_getBlockReceipts), prestate.json and diff.json (debug_traceBlockByNumber), chain.json, and delegates.json. A mainnet block takes about 22 MB.
Shadow replay
Shadow replay answers one question: does this Fe port behave exactly like the Solidity contract it replaces, on real traffic? For every transaction that touches the address, fevm-replay replaces the code there with the port's runtime code and requires the same logs, balances, nonces and storage as on chain.
(cd ports/weth9 && fe build -O 2 --out-dir ../../out/weth9 .)
./out/fevm_replay range 26394000 20 blocks \
0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 out/weth9/WETH9.runtime.bin
Gas may differ, and with it what the sender paid and the coinbase earned, so those two balances are settled by the gas difference before comparing. The summary line counts transactions that ran the port, those identical, those that differ only where code read the gas left, and those that behaved differently, and totals the gas on chain and with the port. It then lists the average gas per call of each function of the shadowed contract, on the deployed code and on the port, over the transactions the port left identical (each runs a second time on the deployed code for this).
Code that reads the gas left can legitimately act differently when a port is cheaper: MEV bots that size a builder payment from gas used, and ERC-4337 bundles that record it. A GasWatch tracer detects this, so only a difference without such a reading counts against the port.
Shadow fuzzing
Mainnet only makes the calls it happens to make. fuzz makes the rest: seeded random calls in two copies of one world, one running the deployed code and one running the port, compared after every call (status, return data, logs and all state). Callers include contracts that accept and reject ETH, so withdrawals into fallbacks are exercised.
./out/fevm_replay fetch-code 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 weth9.hex
./out/fevm_replay fuzz weth9.hex out/weth9/WETH9.runtime.bin 20000 60 1000
Calls are counted in three groups: well-formed, calldata shorter than its arguments, and address arguments with dirty upper bits. Fe's ABI decoder rejects the last two where solc 0.4 lets them through, so only a well-formed difference fails the run. The first difference is printed with its sender, target, value, calldata and the outcome on each side, and the seed and call number replay it.
fuzz-pair does the same for a Uniswap V2 pair: two tokens running WETH9's code, a factory whose feeTo is unset or set, and flash-swap callees that accept or call back into the pair. It prints how many calls of each function succeeded, so you can see what the run reached beyond reverts. Exceptional halts compare as one outcome, and Error(string) reasons that differ only in their padding are counted apart: solc 0.5 pads them with whatever memory held.
A difference is shrunk: its run is regenerated from the seed and every call it does not need is dropped, so the report ends with the shortest sequence that still differs. A pair run also checks the port against Uniswap V2's invariants after every call: the lock is released, k never falls in a successful swap, and the supply is zero or at least the 1,000 locked at the first deposit.
./out/fevm_replay fuzz-pair pair.hex out/pair/UniswapV2Pair.runtime.bin weth9.hex 20000 60 1000
The call generators target WETH9's and UniswapV2Pair's interfaces. Another contract needs its own in src/fuzz.fe.
Shadowing every copy of a contract
Every Uniswap V2 pair on mainnet runs the same runtime code. Pass a file of that code in place of an address, and every account in a transaction's prestate whose code is exactly that runs the port:
./out/fevm_replay fetch-code 0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc pair.hex
./out/fevm_replay range 26400000 127 blocks pair.hex out/pair/UniswapV2Pair.runtime.bin
A contract that checks EXTCODEHASH of what it calls sees the port as other code. Transactions where other code ran EXTCODEHASH or EXTCODECOPY are labeled, like those that read the gas left.
Fuzzing from mainnet
fuzz-block starts from real transactions. For each one in the saved blocks whose pre-state holds the deployed code, it runs mutations on that pre-state twice, once with the deployed code and once with the port, with gas free so only behavior can differ. A mutation changes either:
- the transaction's own calldata, which exercises whatever router or bot sent it; or
- one of the calls the shadowed contract really received, in flight. Its account runs a small proxy that patches one calldata word and
DELEGATECALLs the real code at another address. The transaction itself runs unchanged, so a changed swap lands with its input tokens already in the pair and the real router asmsg.sender.
./out/fevm_replay fuzz-block blocks/* pair.hex out/pair/UniswapV2Pair.runtime.bin 50 7
In flight, the changed call's own outcome decides: if its status or output differs, the port behaved differently, whatever its callers do with gas. Differences where other code read the gas left or code hashes are counted apart, and so are those where a mutation gave upper bits to a word shaped like an address, and those where only the deployed code ran out of gas (in flight, both agree given twice the gas; otherwise the runs part at a call, same calldata, that ran out on the deployed code alone). The port running out of gas is always a difference. Over 200 blocks (42,800 runs) none differ. The first difference prints both outputs and the call, in the order calls ended, where the two runs part.
Accounts and slots the original transaction did not touch are absent from its pre-state and read as empty in both runs, so a mutation that wanders off is compared fairly but not realistically.
Checking the labels
A difference labeled as a gas or code-hash reading is only excused once the reading explains it. Every call is recorded with its caller, the account it runs as (the caller's for DELEGATECALL), its value, and whether its code ran GAS after the port first ran. A gas-labeled difference is explained when the runs part at or under a call whose code read the gas left, or, with every call alike, when the first differing log or state belongs to such an account. Reports give the counts and name the contracts. A code-hash difference is replayed with the port at one shadowed account at a time, and the report says whether the port ran at all.
A block as one chain
chain builds the block's pre-state from every transaction's prestate, last first, so each account and slot holds its value from the start of the block. Each transaction then runs on what the one before it left, and is checked against the chain's receipt and its state diff; after a difference it carries on from the chain's state, so one difference stays one. Withdrawals (EIP-4895) are credited after the last transaction. With an RPC URL, a recipient no transaction touched starts from its balance at the parent block, and every recipient's final balance is compared with the node's.
Mutation testing
mutants finds every operator in code lines of the port's source (not in comments, strings, attributes or use lines), spreads count mutants over them, and builds each in /tmp/fevm-replay-mutant with fe from PATH. A mutant is killed by a well-formed random call that differs or a broken invariant, or else by a mainnet mutation that differs. The unchanged port is checked first and must pass. A survivor either means the same as the original or shows a blind spot.
Port certificates
certify builds the port from source, records the runtime code hashes of the port and the deployed code, replays the saved blocks with the port in place of every copy of the deployed code, mutates their transactions (20 per transaction), runs 1,000 random sessions, and scores the checks with mutants (on the first ten blocks). The page it writes is self-contained. The Fe UniswapV2Pair certificate covers 200 blocks.
Writing a port
ports/weth9 is the worked example. A port is only a drop-in replacement if it keeps the deployed contract's:
The repository has three: ports/weth9 (solc 0.4.19), ports/uniswap-v2-pair (solc 0.5.16) and ports/usdt (solc 0.4.17). All three take address arguments as words and keep their low 160 bits, as solc's decoder does, with the selectors written out (#[selector = 0xa9059cbb]) since Fe checks a sol(...) signature against the field types.
- function selectors and ABI encoding, including return values;
- event signatures and which arguments are indexed;
- storage layout, slot for slot. Fe's
StorageMapderives keys like a Solidity mapping, and nested maps like nested mappings; - fallback behavior and revert data (WETH9 reverts with none);
- gas passed to calls it makes. WETH9's
transfergives 2,300 gas even when no ETH moves, which is the bug shadow replay found.
Architecture
| Module | Role |
|---|---|
src/fetch.fe | JSON-RPC through curl, started with Spawn; fixtures, block directories, delegation targets, the blob base fee |
src/load.fe | JSON to fevm: load_world, load_tx, load_block, add_extras |
src/replay.fe | replay_tx: load, fevm::transact, compare. No host capabilities |
src/compare.fe | Applies the chain's diff to the prestate, then compares worlds, receipts and logs in both directions |
src/fuzz.fe | Shadow fuzzing: random calls against deployed code and its port, compared after each |
src/watch.fe | GasWatch, an fevm tracer that records GAS after the shadow code ran, EXTCODEHASH, and BLOCKHASH |
src/lib.fe | The command line |
Limitations
- Each transaction runs alone on its own pre-state. Block-level effects such as system contracts and withdrawals are not replayed.
- Osaka rules and mainnet's BPO2 blob schedule only.
fetch-blockloads only the parent's block hash.fetch-hashes(orrange, for blocks that need them) adds the 256 before it at 86 calls a block.- fevm's tracer does not expose the stack, so the target of
EXTCODEHASHis found by replaying with the port at one account at a time, and gas readings are tied to differences through the call tree. - A mutation that reaches a slot the original transaction never touched reads it as empty in both runs, unless
fuzz-blockis given an RPC URL; then calldata changes fetch what they touch (in-flight changes do not). - fevm implements Osaka, so replays cover blocks from December 2025 on.
- The node's traces are the reference, so a node bug shows up as a difference. That has happened, and the chain's own state settled it.