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.
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.
Everything is Fe, from the HTTP call to the verdict, and the compiler proves which parts can touch the network.
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.
Turn the prestate into an fevm WorldState, the transaction into a TxRequest (all five envelope types) and the header into a BlockEnv.
Execute it with fevm::transact under Osaka rules, natively, at about 870 transactions a second.
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.
Every difference was traced to its cause, and fevm-replay now names each cause on its own.
| Run | Transactions | Match |
|---|---|---|
| Hand-picked fixtures | 6 | 6 |
| Blocks 26,394,000 to 26,394,019 | 5,129 | 5,129 |
| Blocks 26,394,100 to 26,394,199 | 24,991 | 24,982 |
| Blocks 26,380,000 to 26,380,299 | 85,087 | 84,964 |
| Total | 115,213 | 115,081 |
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.
Block traces from a load-balanced endpoint that disagreed with the chain. Every one matches when fetched again per transaction.
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.
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.
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.
| Function | Calls | Solidity | Fe | Change |
|---|---|---|---|---|
balanceOf | 720 | 934 | 806 | −13.7% |
transfer | 441 | 14,207 | 13,745 | −3.2% |
transferFrom | 162 | 11,544 | 11,105 | −3.8% |
withdraw | 140 | 9,619 | 9,360 | −2.6% |
deposit | 104 | 17,237 | 17,109 | −0.7% |
approve | 48 | 16,586 | 16,427 | −0.9% |
Blocks 26,394,000 to 26,394,019. Each transaction runs on both codes; only those the port leaves identical count.
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.
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.
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.
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)
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
)