fevm-replay

Real Ethereum history, replayed through an EVM written in Fe.

fevm-replay runs mainnet transactions inside fevm and checks that each one ends exactly as it did on chain: status, gas, logs and every balance, nonce, code byte and storage slot. Then it swaps a Solidity contract for its Fe port and checks the port against real traffic.

Mainnet block 26,393,604, one cell per transaction, sized by gas used

How a replay works

Everything is Fe, from the HTTP call to the verdict, and the compiler proves which parts can touch the network.

  1. Fetch

    Ask a JSON-RPC node for the transaction, its block and receipt, the accounts it read (prestate) and everything it changed (diff). A whole block takes five calls.

  2. Load

    Turn the prestate into an fevm WorldState, the transaction into a TxRequest (all five envelope types) and the header into a BlockEnv.

  3. Run

    Execute it with fevm::transact under Osaka rules, natively, at about 870 transactions a second.

  4. Compare

    Diff fevm's world against the chain's in both directions, so a wrong value and a write the chain never made are both caught.

115,213 mainnet transactions, no fevm bug

Every difference was traced to its cause, and fevm-replay now names each cause on its own.

RunTransactionsMatch
Hand-picked fixtures66
Blocks 26,394,000 to 26,394,0195,1295,129
Blocks 26,394,100 to 26,394,19924,99124,982
Blocks 26,380,000 to 26,380,29985,08784,964
Total115,213115,081
91

Older block hashes

The transaction ran BLOCKHASH for a block before its parent. Block hashes are not state, so the node's traces cannot carry them. Detected by a tracer and labeled.

40

Inconsistent node answers

Block traces from a load-balanced endpoint that disagreed with the chain. Every one matches when fetched again per transaction.

1

A node tracer bug

An account created and self-destructed in one transaction (EIP-6780) was reported as kept. The chain's own state agrees with fevm that it was deleted.

Proven against the state root, not the node

A node's traces are the starting point of every replay. With fetch-proofs, they no longer have to be trusted: fevm-replay saves the Merkle-Patricia proofs (eth_getProof) of every account and slot a block touched, at the parent and at the block, and chain checks them offline. Both headers must hash to the block's hash and its parent hash; the pre-state must match the parent's state root and the state fevm reaches after every transaction must match the block's.

$ fevm-replay fetch-block 0x192f3cb blocks/0x192f3cb
$ fevm-replay fetch-proofs blocks/0x192f3cb
$ fevm-replay chain blocks/0x192f3cb
headers: block 0x19f04411…696b and its parent hash to themselves
pre-state: 206 accounts and 673 slots proven against the parent's state root, 0 differ
chain blocks/0x192f3cb: 122/122 transactions MATCH (100%)
receipts: fevm's 122 receipts hash to the header's receiptsRoot
end state: 207 accounts and 673 slots proven against the block's state root, 0 differ

A clean run rests on one 32-byte block hash. Change one balance in the saved traces by one wei, or one byte of a proof, and the run fails. The verifier is about 300 lines of Fe on std's RLP and core's Keccak, and like the replay it cannot reach the network. fevm's own receipts are built into a trie and matched against the header's receiptsRoot for every saved block: 185 of 185 fully matching blocks agree.

Is a Fe port a drop-in replacement? Ask mainnet.

Shadow replay puts a Fe port in place of a deployed Solidity contract in every transaction that touches it, and requires the same logs and state. Only gas may differ.

We wrote WETH9 in Fe and shadowed mainnet WETH across 300 blocks. In 4,806 real transactions it found one that behaved differently: a contract that withdraws zero WETH into its fallback. Solidity's transfer passes 2,300 gas when no ETH moves, because the EVM only adds its stipend to calls that do. The Fe port passed none, so the fallback ran out of gas.

The unit tests had not caught it. The port is fixed, and a test now fails without the fix.

Then we went past mainnet's traffic. fuzz sends random calls to the deployed code and the port side by side: 245,415 well-formed calls in 2.1 seconds, none different. Against the port from before the fix, it finds the zero-withdrawal bug in a tenth of a second.

Ran Fe WETH94,806
Identical logs and state4,348
Differ only where code reads the gas left454
The zero-withdrawal bug, now fixed1
Inconsistent block traces3
Gas, Fe against deployed Solidity0.42% less

Gas per call, measured on the same mainnet calls

FunctionCallsSolidityFeChange
balanceOf720934806−13.7%
transfer44114,20713,745−3.2%
transferFrom16211,54411,105−3.8%
withdraw1409,6199,360−2.6%
deposit10417,23717,109−0.7%
approve4816,58616,427−0.9%

Blocks 26,394,000 to 26,394,019. Each transaction runs on both codes; only those the port leaves identical count.

Then every Uniswap V2 pair at once

Every V2 pair on mainnet runs the same code, so one Fe port of UniswapV2Pair stands in for all of them. Over 200 blocks (69,643 transactions) and 90 more spread across the year, 1,687 transactions ran it and none behaved differently in the end. Every difference left is a contract reading the gas left or a code hash, and each one is checked: the runs part under the code that read it. swap costs 0.9% less gas, getReserves 4.4% less.

Getting there took one real bug fix. A bot in block 26,000,008 packs data into the upper bytes of swap's to argument. Solidity keeps the low 160 bits; Fe's decoder rejected the word. See it call by call.

And USDT, the most-called contract on Ethereum

A third port, TetherToken from 2017's solc 0.4.17, keeps every quirk: no return value from transfer, fees and blacklists, an owner and a pause flag packed into one slot, and SafeMath that burns all gas on failure. Across 290 blocks, 21,230 real transactions ran it and none behaved differently; transfer costs 4.1% less gas, balanceOf 23.6% less.

Beside mainnet, 1.2 million random calls match with six Uniswap invariants checked after each, mutated mainnet transactions match too, and 61 of 74 mutants planted in the port are caught. All of it fits on one certificate, written by one command; all five ports have one, and the gas map lists the 102 mainnet contracts a cheaper port is visible to.

Two findings along the way

Where Fe saves gas: the same mainnet calls, opcode by opcode, on solc's code and on the Fe ports. Fe moves fewer values around the stack and skips EXP; it spends more copying calldata, which makes a Fe proxy the one port that costs more.

What Osaka changed: sixty blocks from before the fork, replayed under its rules. 48 of 13,022 real transactions end differently, each explained by one of three EIPs.

The compiler proves the verdict is offline

Fe functions declare the capabilities they use. Only main and the fetcher ask to start processes or read files. The function that decides MATCH or DIFF asks for a text buffer and nothing else, so calling the network from inside it is a type error.

That makes every fixture replay identically on any machine, with no node in sight.

pub fn replay_tx(
    _ block: BlockEnv,
    parent_hash: u256,
    _ extras: Value,
    _ tx: Value,
    _ receipt: Value,
    _ prestate: Value,
    _ diff: Value,
    shadow_address: u256,
    shadow_code: MemSpan,
) -> Mismatches
uses (text: mut MemBuffer)

The compiler checks the blob fee against mainnet

EIP-4844's blob base fee is a const fn typed with std's units: a Timestamp picks the fork's update fraction and the fee comes out in Wei. static_asserts run the formula at compile time on a real block, so a wrong constant does not build.

Percentages are core::fixed numbers, UFixed<1> and Fixed<1>: the scale is in the type.

pub const fn blob_base_fee(excess: u256, fraction: u256) -> Wei

// Block 26,393,604 priced its blobs at 0xb58b03 wei.
static_assert(
    blob_base_fee(excess: 0xb58bccf, fraction: BPO2_UPDATE_FRACTION)
        .as_wei() == 0xb58b03
)