Conversation
…ing mid-transaction The call-mode fee deduction emulated go-ethereum's `StateDB.Prepare` by calling `evm.finalize()` in the middle of the transaction and re-inserting the state as cold. Because `finalize()` also clears the journal's logs, the deduction and refund Transfer logs were moved out into `pre_fee_logs`/`post_fee_logs` and stitched back together by the receipt builder and the statetest runner. That bypass dates from revm 33, when `ExecutionResult::Revert` carried no logs; since revm 35 (bluealloy/revm#3424) reverts and halts return the journal's logs. Do only what `Prepare` does: clear transient storage and mark every loaded account and slot cold. Transaction ids are left alone, and revm-state 42 (bluealloy/revm#3746) re-baselines EIP-2200 original values only when they change, so gas is unchanged. The fee logs stay in the journal, every `ExecutionResult` carries them in go-ethereum's order, and the receipt builder uses `result.into_logs()` directly. This also removes a latent divergence: the mid-transaction `finalize()` reset the journal's transaction id, so under revm's own `transact_many` the second token-fee transaction re-baselined the payer's balance slot and paid 2,800 more gas than when executed on its own. Slot-mode fee tokens never reach the changed code (no EVM frame, no logs); their receipts were already the result's logs alone.
|
Warning Review limit reachedNext included review available in 41 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (7)
Warning Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary
The call-mode fee-token deduction emulated go-ethereum's
StateDB.Prepareby callingevm.finalize()in the middle of the transaction. Becausefinalize()also empties the journal's logs, the deduction and refundTransferlogs were moved out intoMorphEvm::{pre_fee_logs, post_fee_logs}and stitched back together by the block executor, the receipt builder and the statetest runner.This PR does only what
Preparedoes — clear transient storage and mark every loaded account and slot cold — and leaves the logs and the undo history in the journal. EveryExecutionResultnow carries the fee logs in go-ethereum's order, and the bypass is removed from all five places.No consensus change: receipts, gas and state are identical on every path Morph executes today (tests and historical replay below).
Why the bypass existed, and why it is no longer needed
ExecutionResult::Reverthad nologslogstoRevertandHalt; morph-reth has been on revm ≥ 38 since #98 (2026-05-13) and is on 42 noworiginal_valuewhenever a slot was cold, so a baremark_coldwas unsaferevm truncates logs only back to a frame's own checkpoint. The main frame's checkpoint is taken after the deduction and the refund runs in
reimburse_caller, sopost_execution::outputreturns[deduction] + [main frame, on success] + [refund]for every outcome — the order go-ethereum'sStateDB.logsproduces.What the mid-transaction
finalize()did, and what remains:catch_error → discard_txcan now roll the deduction back.transact.pre_execution. SELFDESTRUCT is disabled. Every Morph spec is ≥ Cancun.Latent divergence removed
finalize()reset the journal's transaction id, so revm's ownExecuteEvm::transact_many(nofinalizebetween transactions) re-baselined the payer's balance slot for every transaction after the first. A token-fee MorphTx whose main frame moves the fee token cost 50,309 gas when batched vs 47,509 when executed alone (+2,800: SSTORE priced as a reset instead of a dirty write). Block execution, payload building, RPC and the prover all execute one transaction at a time, so no production path hit this. Both now cost 47,509 (new test below).Historical data (slot mode in particular)
Code paths. Slot-mode fee tokens never reach the changed code:
finalize()and log capture being removed sat insideif token_fee_info.balance_slot.is_none(), and the refund's log drain sat in the call-modeelsebranch.sload/sstore: no EVM frame, no logs. That path is untouched.[] + result.logs + [].Fixtures.
mainnet_slot_mode_fee_token_transfer_matches_geth(mainnet tx0x9ebfdac9…, block 26836567) and theslot_deduct_*golden cases pass unchanged.Replay.
morph-reth re-execute --blocks-per-chunk 1 --skip-invalid-blockswas run on local mainnet and hoodi datadirs, from Emerald to each DB's tip, checking every block's receipts root, logs bloom and gas against the canonical header. Both this branch andmainwere run.mainTransfers)Transfers)The invalid blocks come from the local datadirs, not from either binary. They were synced by older morph-reth releases and hold historical state that differs from morph-geth: nonces, token balances and contract storage. Pre-Jade state roots are not validated, so this was never caught. Spot checks against archive RPC:
0x45329608…(block 20,110,585, a plain EIP-1559 tx) is valid on chain with sender nonce 14216; the local DB has 14297.0x09dc5126…(block 5,662,569, fee token 4) succeeded on chain, but the local DB holds too small a token balance for its sender.0x136a…51ffbefore mainnet block 19,727,717 is3283533031227458551on geth and in both replays, but3282229072318333705in the local DB.With the default 5,000-block chunks, both binaries stop at the same first changeset mismatch with the same values (mainnet 19,727,717, hoodi 2,250,311).
Tests
main; this was checked by swapping the oldfinalize()block back in.fee_transfer_logs_survive_a_reverting_main_frame: the main frame reverts, and the result's logs are exactly the deduction and refundTransfers, in that order.batched_transactions_match_individual_execution:transact_manyis compared with onetransact_commitper transaction, and gas and logs are identical. The second transaction's logs are[deduction, main-frame transfer, refund].bin/morph-statetest:fee_token_calls_match_geth(15 geth-derived cases × Emerald/Jade, checking state root, logs root and gas) and the mainnet slot-mode replay pass unchanged.make fmt,make clippy,make clippy-e2e,make test(907),cargo test --doc --all(6) andmake test-e2e(128) all pass.Notes
ExecutionResultnow includes the feeTransferlogs. Receipts are unchanged. Code that reads the raw result now sees the same logs as the receipt: for example,eth_simulateV1per-calllogson a successful call that charged fees, or anything drivingMorphEvmdirectly such as the prover.reimburse_caller_token_feeand the receipt builder: drop the log caches on rebase.