Skip to content

fix(l2): preserve storage backup in undo_last_transaction#6510

Merged
ilitteri merged 11 commits intomainfrom
fix/l2/undo-last-tx-storage-backup
May 6, 2026
Merged

fix(l2): preserve storage backup in undo_last_transaction#6510
ilitteri merged 11 commits intomainfrom
fix/l2/undo-last-tx-storage-backup

Conversation

@avilagaston9
Copy link
Copy Markdown
Contributor

@avilagaston9 avilagaston9 commented Apr 21, 2026

Motivation

undo_last_transaction silently fails to restore storage slot changes on L2, corrupting state when transactions are speculatively executed and then rolled back (e.g., Credible Layer integration).

The root cause: L2Hook::finalize_non_privileged_execution clears call_frame_backup at line 151 to scope its Phase 2 rollback, but this also destroys the SSTORE backup before BackupHook::finalize_execution can capture it into tx_backup. The result is that undo_last_transaction restores account info (balances, nonces) but silently loses all storage slot changes.

Description

Instead of clearing the backup, save it with std::mem::take before Phase 2 begins, then merge it back after Phase 2 succeeds. This preserves the Phase 2 rollback semantics (on error, only Phase 2 mutations are reverted) while ensuring BackupHook sees the complete execution + finalize backup.

How to Test

cargo test -p ethrex-test --test ethrex_tests 'levm::l2_hook'

New regression test undo_last_transaction_restores_storage_slots: creates a contract with slot[0] = 42, writes 0xDEAD, calls undo_last_transaction(), asserts the slot is restored. Without the fix, it remains 0xDEAD.

Checklist

  • All existing l2_hook_tests pass (5/5)
  • New regression test added and verified (fails without fix, passes with fix)
  • cargo fmt clean
  • cargo clippy clean (1 pre-existing warning unrelated to this change)

…_transaction

restores SSTORE changes. The L2Hook's finalize_non_privileged_execution was clearing
call_frame_backup to scope its Phase 2 rollback, but this destroyed the SSTORE backup
before BackupHook could save it into tx_backup. As a result, undo_last_transaction
restored account info (balances, nonces) but silently lost all storage slot changes.

Fix: save the execution backup with std::mem::take before Phase 2 begins, then merge
it back after Phase 2 succeeds. Phase 2's rollback still only undoes Phase 2 mutations
(same behavior as before), but BackupHook now sees both execution and finalize backups.

This is critical for the Credible Layer integration where transactions are executed
speculatively during block building and undone if the sidecar rejects them.
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 21, 2026

Lines of code report

Total lines added: 7
Total lines removed: 0
Total lines changed: 7

Detailed view
+--------------------------------------------+-------+------+
| File                                       | Lines | Diff |
+--------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/call_frame.rs    | 374   | +4   |
+--------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/hooks/l2_hook.rs | 675   | +3   |
+--------------------------------------------+-------+------+

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 21, 2026

Benchmark Results Comparison

No significant difference was registered for any benchmark run.

Detailed Results

Benchmark Results: BubbleSort

Command Mean [s] Min [s] Max [s] Relative
main_revm_BubbleSort 3.023 ± 0.032 2.991 3.098 1.13 ± 0.01
main_levm_BubbleSort 2.674 ± 0.017 2.652 2.700 1.00
pr_revm_BubbleSort 3.087 ± 0.036 3.037 3.145 1.15 ± 0.02
pr_levm_BubbleSort 2.694 ± 0.018 2.675 2.721 1.01 ± 0.01

Benchmark Results: ERC20Approval

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Approval 985.5 ± 11.0 973.7 1009.4 1.00
main_levm_ERC20Approval 1014.4 ± 10.1 1001.8 1033.2 1.03 ± 0.02
pr_revm_ERC20Approval 997.4 ± 12.4 986.6 1018.3 1.01 ± 0.02
pr_levm_ERC20Approval 1026.1 ± 12.1 1008.9 1048.6 1.04 ± 0.02

Benchmark Results: ERC20Mint

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Mint 134.5 ± 2.5 132.8 141.1 1.00
main_levm_ERC20Mint 147.4 ± 0.8 146.7 149.2 1.10 ± 0.02
pr_revm_ERC20Mint 139.7 ± 1.6 137.5 141.7 1.04 ± 0.02
pr_levm_ERC20Mint 148.5 ± 2.0 146.5 152.2 1.10 ± 0.03

Benchmark Results: ERC20Transfer

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Transfer 235.9 ± 8.1 231.1 258.2 1.00
main_levm_ERC20Transfer 250.9 ± 4.9 247.1 263.7 1.06 ± 0.04
pr_revm_ERC20Transfer 237.8 ± 1.7 235.8 241.2 1.01 ± 0.04
pr_levm_ERC20Transfer 251.4 ± 2.4 248.6 256.3 1.07 ± 0.04

Benchmark Results: Factorial

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Factorial 228.4 ± 20.5 220.8 286.7 1.01 ± 0.09
main_levm_Factorial 253.5 ± 2.1 251.0 257.7 1.13 ± 0.03
pr_revm_Factorial 225.2 ± 5.8 220.6 237.9 1.00
pr_levm_Factorial 251.1 ± 3.2 247.3 259.2 1.11 ± 0.03

Benchmark Results: FactorialRecursive

Command Mean [s] Min [s] Max [s] Relative
main_revm_FactorialRecursive 1.599 ± 0.043 1.538 1.675 1.05 ± 0.03
main_levm_FactorialRecursive 1.525 ± 0.012 1.512 1.546 1.00
pr_revm_FactorialRecursive 1.640 ± 0.029 1.593 1.676 1.08 ± 0.02
pr_levm_FactorialRecursive 1.545 ± 0.034 1.510 1.621 1.01 ± 0.02

Benchmark Results: Fibonacci

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Fibonacci 200.9 ± 2.3 196.4 203.0 1.00
main_levm_Fibonacci 230.9 ± 4.8 226.4 240.4 1.15 ± 0.03
pr_revm_Fibonacci 202.2 ± 2.7 195.4 204.7 1.01 ± 0.02
pr_levm_Fibonacci 230.8 ± 3.4 227.8 237.8 1.15 ± 0.02

Benchmark Results: FibonacciRecursive

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_FibonacciRecursive 840.1 ± 10.1 828.9 855.8 1.23 ± 0.02
main_levm_FibonacciRecursive 689.0 ± 8.9 679.4 707.5 1.01 ± 0.02
pr_revm_FibonacciRecursive 863.0 ± 23.1 842.6 907.1 1.26 ± 0.04
pr_levm_FibonacciRecursive 685.5 ± 10.2 671.9 705.8 1.00

Benchmark Results: ManyHashes

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ManyHashes 8.3 ± 0.1 8.3 8.5 1.00
main_levm_ManyHashes 9.9 ± 0.1 9.8 10.1 1.19 ± 0.01
pr_revm_ManyHashes 8.4 ± 0.0 8.4 8.5 1.01 ± 0.01
pr_levm_ManyHashes 10.0 ± 0.1 9.9 10.1 1.19 ± 0.01

Benchmark Results: MstoreBench

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_MstoreBench 259.4 ± 6.5 254.7 272.1 1.11 ± 0.03
main_levm_MstoreBench 235.3 ± 1.5 233.4 238.1 1.00 ± 0.01
pr_revm_MstoreBench 259.8 ± 6.8 255.0 272.6 1.11 ± 0.03
pr_levm_MstoreBench 234.5 ± 1.4 232.3 236.7 1.00

Benchmark Results: Push

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Push 285.8 ± 0.7 284.7 287.3 1.00 ± 0.01
main_levm_Push 288.0 ± 7.6 284.0 309.2 1.01 ± 0.03
pr_revm_Push 287.1 ± 2.1 284.6 291.6 1.01 ± 0.01
pr_levm_Push 285.3 ± 1.5 283.3 287.7 1.00

Benchmark Results: SstoreBench_no_opt

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_SstoreBench_no_opt 173.4 ± 4.8 167.9 185.4 1.72 ± 0.05
main_levm_SstoreBench_no_opt 101.9 ± 2.5 99.9 107.1 1.01 ± 0.03
pr_revm_SstoreBench_no_opt 173.0 ± 5.4 167.6 186.0 1.72 ± 0.06
pr_levm_SstoreBench_no_opt 100.5 ± 0.8 100.0 102.9 1.00

instead of letting other's inner map wholesale replace self's. Previously,
if both backups recorded slots for the same address, plain HashMap::extend
on original_account_storage_slots would drop any slots only present in
self, even when other's inner map contained unrelated slots.

This affects both the L2Hook finalize merge path and BackupHook::finalize_execution,
which both rely on extend to combine temporally-separate backups into a single
tx-level undo snapshot. With the fix, per-slot merging preserves every slot
backed up by either side (other wins per duplicate slot key, matching the
"older backup has more original values" semantics already expected for
original_accounts_info).
…sion test.

This is the complement to finalize_mutation_failure_reverts_all_changes: that
test asserts Phase 2 mutations ARE rolled back on finalize failure; this one
asserts execution-time storage writes are NOT. Together they lock in the
invariant that restore_cache_state on Phase 2 error is scoped to Phase 2's
backup only — a future refactor that merged the saved execution_backup into
call_frame_backup before the error check would regress this test.

Also extract non_privileged_l2_env helper and reuse it in both tests to
remove the ~25-line Environment block duplicated between them.
…orage_slots.

Collapses another ~25 lines of duplicated Environment setup to a single call —
base_fee == gas_price and priority_fee = 0 maps to non_privileged_l2_env(.., gas_price, gas_price).
It asserted that execution-time SSTOREs remain in the DB when Phase 2 finalize
errors — but that behavior depends on the caller-discards-VM-on-Err contract and
isn't a guarantee worth locking in. A future strengthening of the rollback (undo
execution mutations on Phase 2 error too, closer to classical EVM semantics)
would have been falsely flagged as a regression by this test.
finalize_mutation_failure_reverts_all_changes already covers the useful assertion
(Phase 2 mutations ARE rolled back).
…ess instead of letting other's inner map wholesale replace self's. Previously, if both backups recorded slots for the same address, plain HashMap::extend on original_account_storage_slots would drop any slots only present in self, even when other's inner map contained unrelated slots.

This affects both the L2Hook finalize merge path and BackupHook::finalize_execution, which both rely on extend to combine temporally-separate backups into a single tx-level undo snapshot. With the fix, per-slot merging preserves every slot backed up by either side (other wins per duplicate slot key, matching the "older backup has more original values" semantics already expected for original_accounts_info).
@avilagaston9 avilagaston9 marked this pull request as ready for review April 22, 2026 18:24
@avilagaston9 avilagaston9 requested a review from ilitteri as a code owner April 22, 2026 18:24
Copilot AI review requested due to automatic review settings April 22, 2026 18:24
@avilagaston9 avilagaston9 requested review from a team and ManuelBilbao as code owners April 22, 2026 18:24
@github-actions
Copy link
Copy Markdown

🤖 Kimi Code Review

This PR fixes a critical storage rollback bug in the L2 hook where call_frame_backup was being cleared before BackupHook could capture execution-time storage changes for transaction-level undo.

Review Summary

Correctness: ✅ Correct. The per-slot merge in CallFrameBackup::extend (call_frame.rs:331-334) properly preserves storage slots when merging backups from the same address, preventing data loss.

Security/Consensus: ✅ Critical fix. The previous behavior caused undo_last_transaction to restore account info but silently drop storage modifications, leading to state corruption in L2 speculative execution contexts.

Code Quality: Excellent. The use of std::mem::take (l2_hook.rs:151) is idiomatic and efficient, and the test coverage is comprehensive.

Detailed Analysis

1. call_frame.rs - Per-slot merge logic

The change from a plain HashMap::extend to the nested iteration (lines 329-334) correctly handles the case where both self and other contain entries for the same address. Previously, the inner storage map from other would completely replace self's map, dropping any slots unique to self. The new logic merges the inner slot maps, preserving all original values.

Note: The order of operations in l2_hook.rs:203-206 is important—extending Phase 2's backup with Phase 1's backup ensures that Phase 1's earlier original values take precedence over any duplicate entries in Phase 2 (which might have recorded intermediate values).

2. l2_hook.rs - Backup preservation

The flow is correct:

  1. Line 151: Save execution backup (containing SSTOREs from main execution) via mem::take
  2. Lines 153-200: Run Phase 1 (fallible, no state changes) and Phase 2 (mutations). Phase 2 mutations populate the now-empty call_frame_backup
  3. Lines 203-206: On success, merge execution backup back so the tx-level snapshot includes both phases

The comment at line 178-179 correctly notes that call_frame_backup is empty during Phase 2, ensuring rollback only undoes finalize mutations on failure.

3. Test coverage

The new test undo_last_transaction_restores_storage_slots (l2_hook_tests.rs:694-814) effectively regression-tests the vulnerability by:

  • Executing a transaction that modifies storage (0x42 → 0xDEAD)
  • Calling undo_last_transaction
  • Asserting restoration to the original value (0x42)

The helper function non_privileged_l2_env (lines 205-228) reduces test boilerplate appropriately.

Minor Observations

  • l2_hook_tests.rs:233: The #[rustfmt::skip] attribute is appropriate for manual bytecode layout.
  • l2_hook.rs:151: Consider adding a debug_assert or comment clarifying that execution_backup is intentionally dropped (not restored) in the error path at line 192, since restore_cache() should only rollback Phase 2 mutations, not the execution phase.

Verdict: Approve. The fix is minimal, correct, and well-tested.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

No findings.

The fix looks consistent with the hook ordering: L2Hook now preserves the execution backup across finalize by take + merge in l2_hook.rs, and CallFrameBackup::extend now correctly merges per-address storage maps instead of dropping previously captured slots in call_frame.rs. That matches how BackupHook builds tx_backup in backup_hook.rs, so undo_last_transaction() should now retain both execution-time and finalize-time storage rollback data.

The added regression in l2_hook_tests.rs covers the reported storage-loss path well. I could not run the targeted tests here because cargo/rustup attempted to write under read-only home directories (/home/runner/.cargo, /home/runner/.rustup) in this sandbox, so runtime verification is still pending outside this environment.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 22, 2026

Greptile Summary

This PR fixes a silent data-corruption bug in undo_last_transaction on L2: finalize_non_privileged_execution was unconditionally clearing call_frame_backup before Phase 2, destroying the SSTORE rollback data before BackupHook::finalize_execution could capture it into tx_backup. The fix uses std::mem::take to save the execution backup, runs Phase 2 against an empty backup (preserving Phase 2-only rollback), then merges both backups back together.

The extend method in CallFrameBackup is also improved from a whole-inner-map replacement to a per-slot merge, which additionally prevents a secondary bug where Phase 2 storage changes to extra slots could be silently dropped if execution had already touched the same address.

Confidence Score: 5/5

Safe to merge; the fix is correct, well-scoped, and directly validated by the new regression test.

All findings are P2 style suggestions. The core logic — using mem::take before Phase 2 and merging back on success — correctly preserves Phase 2-only rollback semantics while restoring full storage visibility to BackupHook. The improved per-slot extend is a meaningful secondary correctness fix. No regressions are introduced.

No files require special attention.

Important Files Changed

Filename Overview
crates/vm/levm/src/hooks/l2_hook.rs Core fix: replaces clear() with std::mem::take + post-Phase-2 merge, correctly preserving SSTORE backups for undo_last_transaction while keeping Phase 2 rollback semantics intact.
crates/vm/levm/src/call_frame.rs Improves CallFrameBackup::extend from coarse outer-map replacement to per-slot merging, preventing a secondary data loss when two phases touched different slots of the same address.
test/tests/levm/l2_hook_tests.rs Adds targeted regression test that verifies storage-slot restoration after undo_last_transaction; also extracts a non_privileged_l2_env helper to reduce boilerplate in existing tests.

Sequence Diagram

sequenceDiagram
    participant L2Hook
    participant VM as VM (call_frame_backup)
    participant BackupHook

    Note over L2Hook,BackupHook: BEFORE fix
    L2Hook->>VM: call_frame_backup.clear() ← destroys SSTORE data
    L2Hook->>VM: apply_finalize_mutations() (Phase 2)
    BackupHook->>VM: clone call_frame_backup → tx_backup (missing storage!)

    Note over L2Hook,BackupHook: AFTER fix
    L2Hook->>VM: execution_backup = mem::take(call_frame_backup)
    Note over VM: call_frame_backup is now empty
    L2Hook->>VM: apply_finalize_mutations() (Phase 2 records into empty backup)
    alt Phase 2 fails
        L2Hook->>VM: restore_cache_state() (only Phase 2 reverted)
    else Phase 2 succeeds
        L2Hook->>VM: call_frame_backup.extend(execution_backup)
        Note over VM: backup has BOTH execution + Phase 2 entries
        BackupHook->>VM: clone call_frame_backup → tx_backup (complete!)
    end
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: test/tests/levm/l2_hook_tests.rs
Line: 229

Comment:
**Potential panic on underflow in test helper**

`gas_price - base_fee_per_gas` is a bare `u64` subtraction. In debug builds, passing `base_fee_per_gas > gas_price` panics; in release builds it wraps silently. Since this helper will likely attract more callers, `saturating_sub` makes the invariant self-documenting without changing existing behaviour.

```suggestion
        tx_max_priority_fee_per_gas: Some(U256::from(gas_price.saturating_sub(base_fee_per_gas))),
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "Fix CallFrameBackup::extend to merge inn..." | Re-trigger Greptile

Comment thread test/tests/levm/l2_hook_tests.rs Outdated
base_fee_per_gas: U256::from(base_fee_per_gas),
base_blob_fee_per_gas: U256::from(1),
gas_price: U256::from(gas_price),
tx_max_priority_fee_per_gas: Some(U256::from(gas_price - base_fee_per_gas)),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Potential panic on underflow in test helper

gas_price - base_fee_per_gas is a bare u64 subtraction. In debug builds, passing base_fee_per_gas > gas_price panics; in release builds it wraps silently. Since this helper will likely attract more callers, saturating_sub makes the invariant self-documenting without changing existing behaviour.

Suggested change
tx_max_priority_fee_per_gas: Some(U256::from(gas_price - base_fee_per_gas)),
tx_max_priority_fee_per_gas: Some(U256::from(gas_price.saturating_sub(base_fee_per_gas))),
Prompt To Fix With AI
This is a comment left during a code review.
Path: test/tests/levm/l2_hook_tests.rs
Line: 229

Comment:
**Potential panic on underflow in test helper**

`gas_price - base_fee_per_gas` is a bare `u64` subtraction. In debug builds, passing `base_fee_per_gas > gas_price` panics; in release builds it wraps silently. Since this helper will likely attract more callers, `saturating_sub` makes the invariant self-documenting without changing existing behaviour.

```suggestion
        tx_max_priority_fee_per_gas: Some(U256::from(gas_price.saturating_sub(base_fee_per_gas))),
```

How can I resolve this? If you propose a fix, please make it concise.

@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review

Here is the review:


PR #6510 Review — fix(l2): preserve storage backup in undo_last_transaction

Overall: The fix is correct and addresses a real state-corruption bug. The root cause analysis is accurate, and the solution is clean. A few issues worth flagging before merging.


crates/vm/levm/src/call_frame.rsCallFrameBackup::extend

Critical correctness fix. The old HashMap::extend on original_account_storage_slots would replace an address's entire inner slot map with other's, discarding slots only present in self for the same address. The new per-slot merge is correct:

// Per-slot merge: plain HashMap::extend would let `other`'s inner slot map
// replace `self`'s, dropping any slots `self` had for the same address.
for (address, other_storage) in other.original_account_storage_slots {
    self.original_account_storage_slots
        .entry(address)
        .or_default()
        .extend(other_storage);
}

"other wins" semantics are not documented. The inner .extend(other_storage) means other's slot values overwrite self's for the same slot key. The same "other wins" applies to original_accounts_info via plain HashMap::extend. This is the correct semantic in both call sites (the older/more-original backup is always passed as other), but it's the opposite of merge_call_frame_backup_with_parent which uses "self wins." The divergent semantics between these two merge functions on the same data structure is a maintenance hazard. A short doc comment on extend stating "caller is responsible for passing the more-original backup as other, since other's values take precedence for conflicting keys" would prevent future misuse.

bal_checkpoint is silently dropped from other. The comment says "it's specific to each call frame" and was there before this PR, but it's now more load-bearing. When L2Hook merges execution_backup back via extend, the bal_checkpoint captured during SSTORE execution is discarded. If the execution phase makes Block Access List changes that need to be tracked for undo_last_transaction, the BAL restoration will be incomplete. It's worth explicitly verifying this is safe, even if the answer is "BAL changes are tracked separately" — that reasoning should be documented here.


crates/vm/levm/src/hooks/l2_hook.rsfinalize_non_privileged_execution

The fix logic is correct. Using std::mem::take instead of clear() is a clean, zero-cost way to preserve the execution-phase backup while still presenting an empty call_frame_backup to Phase 2.

Hook execution order is an implicit, load-bearing invariant. The fix only works because L2Hook::finalize_execution (index 0 in the hooks vec) runs before BackupHook::finalize_execution (index 1), as defined in hook.rs:30–35. If those positions were ever swapped, BackupHook would again see an empty or incomplete backup. This ordering is currently silent — there is no comment in l2_hooks() or in finalize_non_privileged_execution documenting why the order matters. A one-line comment in hook.rs at the l2_hooks() definition is enough to protect this invariant:

// Order matters: L2Hook must finalize before BackupHook so the execution-phase
// backup is merged into call_frame_backup before BackupHook captures tx_backup.
vec![
    Rc::new(RefCell::new(L2Hook::new(fee_config))),
    Rc::new(RefCell::new(BackupHook::default())),
]

Phase 2 failure drops execution_backup — is this intentional? When apply_finalize_mutations returns Err, the function calls restore_cache_state() to undo Phase 2's mutations, then returns the error. execution_backup is simply dropped at that point. The existing comment explains this is safe because "the Err propagates to the caller, which discards the entire VM context." That reasoning holds as long as the DB cache is scoped to the VM (not shared). If that assumption ever changes this becomes a leak of half-applied execution-phase changes into the DB. Worth a brief comment noting the dependency on VM-scoped cache lifetime.


test/tests/levm/l2_hook_tests.rs

Test is well-structured. The regression test undo_last_transaction_restores_storage_slots is clear: set a known slot value, execute a transaction that overwrites it, call undo_last_transaction, assert slot is restored. It fails without the fix and passes with it — exactly what a regression test should do.

non_privileged_l2_env subtraction can panic in debug mode. Line:

tx_max_priority_fee_per_gas: Some(U256::from(gas_price - base_fee_per_gas)),

If a future caller passes base_fee_per_gas > gas_price, this panics in debug mode and wraps silently in release mode. Since this is test code with controlled inputs it's low-risk, but gas_price.saturating_sub(base_fee_per_gas) or an explicit assert!(gas_price >= base_fee_per_gas) at the top of the function would make the invariant explicit and prevent confusing arithmetic panics in future tests.

finalize_mutation_failure_reverts_all_changes refactoring is correct. The replacement of the inline Environment construction with non_privileged_l2_env is safe. Environment derives Default, and all the formerly-explicit zero/false/None fields (difficulty, slot_number, is_privileged, tx_blob_hashes, etc.) match their Default values.


Summary

Severity Item
Medium bal_checkpoint from execution_backup is silently dropped in extend; correctness for BAL restoration in undo_last_transaction should be verified and documented.
Medium Hook execution order is a silent, load-bearing invariant — add a comment in l2_hooks().
Low extend "other wins" semantics are undocumented and asymmetric with merge_call_frame_backup_with_parent; add a doc comment.
Low gas_price - base_fee_per_gas in non_privileged_l2_env can panic; use saturating_sub or assert.
Info Phase 2 failure silently drops execution_backup; the safety argument depends on VM-scoped cache and should be noted.

Automated review by Claude (Anthropic) · sonnet · custom prompt

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes an L2 rollback correctness issue where undo_last_transaction could restore account fields but miss storage slot changes because L2Hook::finalize_non_privileged_execution cleared call_frame_backup before BackupHook captured the tx snapshot.

Changes:

  • Preserve execution-time call_frame_backup across L2 finalize “Phase 2” by mem::take + merge-back so tx-level undo sees full backup data.
  • Fix CallFrameBackup::extend to merge per-address storage-slot maps without dropping slots when both backups touch the same address.
  • Add a regression test ensuring undo_last_transaction() restores SSTORE’d storage slots.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
crates/vm/levm/src/hooks/l2_hook.rs Preserves and re-merges execution backups around Phase 2 finalize mutations so BackupHook can snapshot storage changes for tx undo.
crates/vm/levm/src/call_frame.rs Corrects backup merging to avoid losing storage-slot entries for the same address during extend.
test/tests/levm/l2_hook_tests.rs Adds helper for non-privileged L2 env setup and a regression test for storage restoration via undo_last_transaction.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +228 to +231
gas_price: U256::from(gas_price),
tx_max_priority_fee_per_gas: Some(U256::from(gas_price - base_fee_per_gas)),
tx_max_fee_per_gas: Some(U256::from(gas_price)),
block_gas_limit: gas_limit * 2,
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gas_price - base_fee_per_gas can underflow (panic in debug, wrap in release) if a future test calls non_privileged_l2_env with gas_price < base_fee_per_gas. Consider using saturating_sub, checked_sub with an expect, or asserting gas_price >= base_fee_per_gas to make the helper robust.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 0c3c45c

Copy link
Copy Markdown
Contributor

@ElFantasma ElFantasma left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just one suggestion. It LGTM

@@ -326,8 +326,14 @@ impl CallFrameBackup {
}

pub fn extend(&mut self, other: CallFrameBackup) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The overwrite-on-duplicate behavior here is correct (callers always pass the older/more-original backup as other, so its values win for duplicate slots → truly-original is preserved), but that invariant is non-obvious. Worth documenting:

/// Merges `other` into `self`, per-address. For slots present in both,
/// `other`'s values win. Callers MUST pass the older/more-original backup
/// as `other` so the truly-original value is preserved (matches the
/// `or_insert` semantic in `backup_storage_slot`).

Both current call sites (l2_hook::finalize_non_privileged_execution passing execution_backup and backup_hook::finalize_execution passing pre_execution_backup) follow this — but a future caller could easily get it wrong without the comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 0c3c45c

avilagaston9 and others added 3 commits May 4, 2026 16:16
…o a future caller doesn't pass arguments in the wrong order. Both current callers (L2Hook merging execution_backup, BackupHook merging pre_execution_backup) rely on the older/more-original backup being passed as `other` to preserve the truly-original value, matching backup_storage_slot's or_insert behavior.

Also switch non_privileged_l2_env from a bare `gas_price - base_fee_per_gas` (debug-panic / release-wrap on underflow) to saturating_sub, since the helper will likely attract more callers with arbitrary fee values.
… `#[cfg(feature = "l2")]` in test/tests/l2/utils.rs. The function's only callers (shared_bridge.rs, integration_tests.rs) are already feature-gated, so the function and its imports were emitting dead-code / unused-imports warnings on default builds.
@ilitteri ilitteri added this pull request to the merge queue May 6, 2026
Merged via the queue into main with commit b26a68e May 6, 2026
86 of 88 checks passed
@ilitteri ilitteri deleted the fix/l2/undo-last-tx-storage-backup branch May 6, 2026 17:45
@github-project-automation github-project-automation Bot moved this to Done in ethrex_l2 May 6, 2026
avilagaston9 added a commit that referenced this pull request May 6, 2026
… changes:

the v11.0.0 workspace version bump (#6571), the --no-precompile-cache CLI flag
for benchmarking (#6562), and the L2Hook storage backup fix in
undo_last_transaction (#6510).

The only conflict was in test/tests/l2/utils.rs, where both branches gated
read_env_file_by_config behind #[cfg(feature = "l2")] using different styles.
Kept the local approach (single cfg on the function with use statements inside)
over the per-import cfg gates from main, since both achieve the same outcome
and the local form is less duplicative.

test/tests/levm/l2_hook_tests.rs was auto-merged cleanly: the local refactor
that extracted TestDatabase into super::test_db combines correctly with the
new non_privileged_l2_env helper and the undo_last_transaction_restores_storage_slots
regression test from main.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L2 Rollup client

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

5 participants