Skip to content

refactor(revm): keep fee-token logs in the journal instead of finalizing mid-transaction - #215

Open
panos-xyz wants to merge 1 commit into
mainfrom
refactor/fee-token-logs-in-journal
Open

panos-xyz wants to merge 1 commit into
mainfrom
refactor/fee-token-logs-in-journal

Conversation

@panos-xyz

Copy link
Copy Markdown
Contributor

Summary

The call-mode fee-token deduction emulated go-ethereum's StateDB.Prepare by calling evm.finalize() in the middle of the transaction. Because finalize() also empties the journal's logs, the deduction and refund Transfer logs were moved out into MorphEvm::{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 Prepare does — clear transient storage and mark every loaded account and slot cold — and leaves the logs and the undo history in the journal. Every ExecutionResult now 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

when the bypass was added now
Logs on a failed main frame #48 (2026-03-13) ran on revm 33.1.0, where ExecutionResult::Revert had no logs revm 35.0.0 (bluealloy/revm#3424, released 2026-03-03) added logs to Revert and Halt; morph-reth has been on revm ≥ 38 since #98 (2026-05-13) and is on 42 now
EIP-2200 original value revm ≤ 41 re-baselined original_value whenever a slot was cold, so a bare mark_cold was unsafe revm-state 42 (bluealloy/revm#3746) re-baselines only when the transaction id changes

revm 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, so post_execution::output returns [deduction] + [main frame, on success] + [refund] for every outcome — the order go-ethereum's StateDB.logs produces.

What the mid-transaction finalize() did, and what remains:

  • Transient storage: still cleared (explicitly).
  • Access-list warmth: still dropped. The same set of accounts and slots is marked cold.
  • Logs: now kept in the journal. The output order is unchanged.
  • Undo history: now kept. This only matters on the error path, where catch_error → discard_tx can now roll the deduction back.
  • Transaction id: no longer reset. It was reset to 0, which it already is under per-transaction transact.
  • Coinbase and access-list warm set, selfdestruct set, pre-Spurious-Dragon normalization: these steps have no effect at this point. The coinbase and access list are loaded later, in pre_execution. SELFDESTRUCT is disabled. Every Morph spec is ≥ Cancun.

Latent divergence removed

finalize() reset the journal's transaction id, so revm's own ExecuteEvm::transact_many (no finalize between 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:

  • The finalize() and log capture being removed sat inside if token_fee_info.balance_slot.is_none(), and the refund's log drain sat in the call-mode else branch.
  • Slot mode moves balances with direct sload/sstore: no EVM frame, no logs. That path is untouched.
  • Slot-mode receipts were already [] + result.logs + [].

Fixtures. mainnet_slot_mode_fee_token_transfer_matches_geth (mainnet tx 0x9ebfdac9…, block 26836567) and the slot_deduct_* golden cases pass unchanged.

Replay. morph-reth re-execute --blocks-per-chunk 1 --skip-invalid-blocks was 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 and main were run.

network blocks fee-token activity in range this PR main
mainnet 19,720,027–22,881,633 (3,161,329; crosses Jade at 22,013,629) slot-mode tokens 1, 3, 4, 5, 6 (fee-vault token balances go 0 → non-zero); call-mode token 2 (158 fee Transfers) 3,161,051 pass, 278 invalid the same 278 (numbers and hashes identical)
hoodi 2,203,829–6,950,014 (4,746,184) slot-mode tokens 4, 5, 9, 10, 11; call-mode tokens 1, 2, 3, 6, 7 (~1,027 fee Transfers) 4,746,182 pass, 2 invalid the same 2

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:

  • Mainnet tx 0x45329608… (block 20,110,585, a plain EIP-1559 tx) is valid on chain with sender nonce 14216; the local DB has 14297.
  • Hoodi tx 0x09dc5126… (block 5,662,569, fee token 4) succeeded on chain, but the local DB holds too small a token balance for its sender.
  • Token 3's balance slot 0x136a…51ff before mainnet block 19,727,717 is 3283533031227458551 on geth and in both replays, but 3282229072318333705 in 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

  • New handler tests. Both fail on main; this was checked by swapping the old finalize() 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 refund Transfers, in that order.
    • batched_transactions_match_individual_execution: transact_many is compared with one transact_commit per transaction, and gas and logs are identical. The second transaction's logs are [deduction, main-frame transfer, refund].
  • Receipt builder: the three tests of the removed stitching are replaced by two pass-through tests.
  • 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) and make test-e2e (128) all pass.

Notes

…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.
@coderabbitai

coderabbitai Bot commented Sep 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 41 minutes.

Check out review usage here.

View limit details

Limit 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.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 90ba51a7-594c-4591-bfbd-52d424c171fc

📥 Commits

Reviewing files that changed from the base of the PR and between 4955f7c and 46819d6.

📒 Files selected for processing (7)
  • bin/morph-statetest/src/runner.rs
  • crates/evm/src/block/mod.rs
  • crates/evm/src/block/receipt.rs
  • crates/evm/src/evm.rs
  • crates/node/tests/it/morph_tx.rs
  • crates/revm/src/evm.rs
  • crates/revm/src/handler.rs

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread crates/revm/src/handler.rs Dismissed
Comment thread crates/revm/src/handler.rs Dismissed
Comment thread crates/revm/src/handler.rs Dismissed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants