Skip to content

fix(l1): add reverse check in BAL withdrawal validation#6463

Open
edg-l wants to merge 6 commits intomainfrom
fix/bal-withdrawal-reverse-check
Open

fix(l1): add reverse check in BAL withdrawal validation#6463
edg-l wants to merge 6 commits intomainfrom
fix/bal-withdrawal-reverse-check

Conversation

@edg-l
Copy link
Copy Markdown
Contributor

@edg-l edg-l commented Apr 10, 2026

Summary

  • validate_bal_withdrawal_index only checked the BAL->DB direction (every BAL claim matches actual state). It did not verify the reverse: that every account modified during the withdrawal/request phase has a corresponding BAL entry.
  • A malicious block builder could omit a withdrawal recipient from the BAL. Since bal_to_account_updates derives the state root entirely from BAL entries, the omitted account's withdrawal balance would be excluded from the state trie. The BAL-derived state root would match the tampered header, passing all validation.
  • This adds Part B (DB->BAL) to the withdrawal validation, mirroring the bidirectional check already present in validate_tx_execution.

Perf notes

  • Withdrawal/request phase typically touches <16 accounts, so the reverse iteration is negligible.
  • Pre-state lookups first check the last BAL entry (in-memory), only falling back to db.store when no BAL entry exists for that field.

Test plan

  • All 1963 Amsterdam EF tests pass
  • EEST test for missing withdrawal account in BAL (PR pending in execution-spec-tests)

validate_bal_withdrawal_index only checked BAL->DB direction (every BAL
claim at the withdrawal index matches the DB). A malicious builder could
omit a withdrawal recipient from the BAL, causing the BAL-derived state
root to exclude the withdrawal balance change while passing validation.

Add Part B (DB->BAL): for each account modified during the
withdrawal/request phase, verify the BAL has a corresponding entry.
This mirrors the bidirectional validation already done in
validate_tx_execution.
@github-actions github-actions bot added the L1 Ethereum client label Apr 10, 2026
@edg-l edg-l moved this to In Progress in ethrex_l1 Apr 10, 2026
@github-actions
Copy link
Copy Markdown

🤖 Kimi Code Review

Overall Assessment: The PR implements critical bidirectional validation for Block Access Lists (BAL) to prevent malicious builders from omitting state changes that occur during the withdrawal/request phase. The logic is sound, but missing storage validation in Part B is a potential vulnerability if withdrawals can modify storage (e.g., via system contracts).

Critical Issues

1. Missing Storage Validation in Part B (Lines 1582-1689)

Part B validates that every modified account has corresponding BAL entries for balance, nonce, and code, but storage changes are not validated. If the withdrawal/request phase modifies storage (e.g., through EIP-7002/EIP-7251 system contracts), a malicious builder could omit these changes from the BAL.

Recommendation: Add storage validation to Part B:

// Check if any storage slots were modified but not recorded in BAL
if let Some(bal_acct) = index.addr_to_idx.get(addr) {
    let bal_acct = &bal.accounts()[*bal_acct];
    // Verify all storage modifications at withdrawal_idx are recorded
    for (slot, value) in &account.storage {
        if !bal_acct.storage_changes.iter().any(|c| c.index == withdrawal_idx && c.slot == *slot && c.value == *value) {
            // Check if this slot was actually modified vs pre-state
            // ...
        }
    }
}

Medium Issues

2. Performance: Full State Iteration (Line 1583)

Iterating over db.current_accounts_state (potentially thousands of accounts) for every block adds O(n) overhead to validation. While necessary for security, ensure this only runs during BAL validation mode, not standard execution.

3. Silent Error Handling (Lines 1597, 1623, 1641, 1680)

Multiple uses of .ok() and unwrap_or_default() treat DB errors as "account not found":

  • If the underlying store fails (disk error, corruption), validation treats it as a default/empty account
  • This could allow a corrupted database to pass validation incorrectly

Suggestion: Distinguish between "account not found" (None) and store errors (Err):

let pre = match db.store.get_account_state(*addr) {
    Ok(Some(acct)) => (acct.balance, acct.nonce, acct.code_hash),
    Ok(None) => (U256::ZERO, 0, *EMPTY_KECCACK_HASH),
    Err(e) => return Err(EvmError::Store(e)),
};

Minor Issues

4. Code Duplication (Lines 1612-1686)

The pattern for fetching "last BAL value or pre-state" is repeated for balance, nonce, and code. Consider extracting a helper function to reduce duplication and potential inconsistencies.

5. Inconsistent Code Hash Handling (Lines 1669-1675)

The code recomputes the keccak hash of BAL code changes, but compares against account.info.code_hash which may be cached. Ensure that empty code consistently maps to EMPTY_KECCACK_HASH in both BAL and account state.

6. Panic Risk (Line 1617)

bal.accounts()[bal_acct_idx] uses unchecked indexing. While bal_acct_idx comes from the index and should be valid, consider using .get() for defense in depth:

let Some(acct) = bal.accounts().get(bal_acct_idx) else {
    return Err(EvmError::Custom(format!("BAL index corruption: invalid index {bal_acct_idx}")));
};

Positive Feedback

  • The bidirectional validation (Part A + Part B) correctly addresses the security requirement that BAL must be complete and accurate
  • Error messages are descriptive and include relevant debugging information (addresses, indices, values)
  • The use of is_unmodified() check at line 1584 efficiently skips untouched accounts

Summary

Approve with changes: Add storage validation to Part B to ensure complete state coverage, and consider handling store errors explicitly rather than silently defaulting.


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

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 10, 2026

Lines of code report

Total lines added: 150
Total lines removed: 0
Total lines changed: 150

Detailed view
+---------------------------------------+-------+------+
| File                                  | Lines | Diff |
+---------------------------------------+-------+------+
| ethrex/crates/vm/backends/levm/mod.rs | 2197  | +150 |
+---------------------------------------+-------+------+

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

  1. crates/vm/backends/levm/mod.rs:1593 and crates/vm/backends/levm/mod.rs:1619: the new reverse validation only checks balance / nonce / code_hash, but not storage. That leaves a consensus hole in the post-tx phase: extract_all_requests_levm() runs EIP-7002 / EIP-7251 system-contract calls, which are storage-backed queues, so a malformed BAL can omit storage updates at withdrawal_idx and still pass validation as long as account info is unchanged. The same gap exists in the absent from BAL entirely branch, which compares only (balance, nonce, code_hash) and will miss storage-only mutations on an omitted account. The tx-path validator already has the right pattern for reverse storage checks at crates/vm/backends/levm/mod.rs:1442; the withdrawal-path needs an equivalent seeded-vs-post storage comparison, and the absent-account branch needs to reject storage changes too.

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

@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review

Here is my review of PR #6463:


Review: fix(l1): add reverse check in BAL withdrawal validation

Summary

This PR closes a real security vulnerability: a malicious block builder could omit a withdrawal recipient from the BAL, and since bal_to_account_updates derives the state root exclusively from BAL entries, the omitted account's balance change would be excluded from the trie — allowing a tampered state root to pass validation. Adding Part B (DB→BAL) correctly closes that attack surface.

The fix is logically sound and mirrors the structure of the already-existing validate_tx_execution bidirectional check. A few concerns below.


Security / Correctness

Storage changes are not reverse-checked in Part B

validate_tx_execution Part B (lines 1442–1469) iterates over account.storage and verifies that any storage mutation without a BAL entry at the current index matches the seeded value. The new Part B in validate_bal_withdrawal_index (lines 1621–1689) checks balance, nonce, and code — but omits the storage check entirely.

The withdrawal phase runs extract_all_requests_levm (system calls on predeploys) before process_withdrawals. Those system calls can write storage. A builder who omits a storage_change entry at withdrawal_idx for a predeploy account would not be caught by Part B, even if that account IS in the BAL (the bal_acct_idx branch). Part A only validates entries that the BAL claims exist; Part B is where the omission would need to be caught.

This is the same category of vulnerability that this PR is fixing, applied to storage rather than balance. Adding the same pattern as validate_tx_execution lines 1443–1469 would close it.


DB error silenced at the "absent from BAL" path

Lines 1597–1602:

let pre = db
    .store
    .get_account_state(*addr)
    .ok()                               // DB errors discarded here
    .map(|a| (a.balance, a.nonce, a.code_hash))
    .unwrap_or_default();               // defaults to (0, 0, EMPTY_HASH)

If get_account_state fails (network partition, corruption, etc.), the pre-state silently defaults to zero, meaning a modified account can incorrectly pass the pre != post check. The same pattern exists in validate_tx_execution (line 1346) so this is consistent, but it's a silent failure mode worth documenting or propagating as an error in security-critical validation code.


Correctness Assumptions

.last() assumes no BAL entries between last_tx_idx and withdrawal_idx

Lines 1622–1631 (and analogous nonce/code blocks): the seeded balance is computed as acct.balance_changes.last(). In validate_tx_execution, the equivalent uses partition_point(|c| c.block_access_index <= seed_idx) for precision. The .last() approach is correct only if no entries exist between last_tx_idx and withdrawal_idx = last_tx_idx + 1, which should be true given the BAL structure — but this implicit assumption isn't documented. A brief comment would help maintainability.


Minor

Code mismatch error lacks diagnostic values

Lines 1683–1688: the code mismatch error reports that code changed but doesn't log the actual vs expected hash values:

return Err(EvmError::Custom(format!(
    "BAL validation failed for withdrawal: account {addr:?} code \
     changed during withdrawal/request phase but BAL has no \
     code change at index {withdrawal_idx}"
)));

The balance and nonce errors log their values (account.info.balance, seeded). Adding the hashes to the code error message would make failures easier to diagnose.

Storage not checked for accounts absent from BAL entirely

Lines 1593–1614 handle accounts that have no BAL entry at all — they only compare (balance, nonce, code_hash), not storage. This is symmetric with the gap above (Item 1), and worth closing with the same fix.


Verdict

The fix correctly addresses the stated vulnerability for the balance/nonce/code dimension. Two structural gaps remain: (a) storage mutations during the withdrawal/request phase are not reverse-checked in Part B, and (b) the "absent from BAL" path also skips storage. Both are the same family of omission as the original bug. The test plan correctly notes the EEST test is pending — coverage of these storage cases should be included there.


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

@edg-l edg-l marked this pull request as ready for review April 10, 2026 14:34
@edg-l edg-l requested a review from a team as a code owner April 10, 2026 14:34
@ethrex-project-sync ethrex-project-sync bot moved this from In Progress to In Review in ethrex_l1 Apr 10, 2026
@github-actions
Copy link
Copy Markdown

🤖 Kimi Code Review

The PR implements critical bidirectional validation for Block Access Lists (BAL) during the withdrawal/request phase. This prevents a consensus vulnerability where malicious builders could omit withdrawal recipients from the BAL, causing state root mismatches.

Security & Correctness

Correctness: The bidirectional check (Part A: BAL→DB, Part B: DB→BAL) correctly ensures that:

  1. Every BAL claim matches the actual DB state (existing)
  2. Every account modified during withdrawal has a corresponding BAL entry (new)

Line 1588-1608: The warm-access artifact detection is sound—comparing pre-state vs post-state prevents false positives on accounts that were merely touched but not mutated during the phase.

Line 1628-1639, 1654-1665, 1680-1696: The fallback logic to db.store.get_account_state when BAL has no changes at withdrawal_idx correctly handles the edge case where the last BAL entry or the original store state should be the valid baseline.

Suggestions

1. Avoid redundant store lookups (Performance)
db.store.get_account_state(*addr) is called multiple times for the same address across balance, nonce, and code checks. Consider fetching once and caching:

// Instead of three separate lookups:
let pre_state = db.store.get_account_state(*addr).ok();
let pre_balance = pre_state.as_ref().map(|a| a.balance).unwrap_or_default();
let pre_nonce = pre_state.as_ref().map(|a| a.nonce).unwrap_or_default();
let pre_code_hash = pre_state.as_ref().map(|a| a.code_hash).unwrap_or(*EMPTY_KECCACK_HASH);

2. Code deduplication (Maintainability)
The pattern for checking balance/nonce/code is identical. Consider extracting a helper to reduce duplication, though the current explicitness is acceptable for critical validation logic.

3. Error message clarity
Line 1599: Include the actual vs expected values in the error message to aid debugging:

return Err(EvmError::Custom(format!(
    "BAL validation failed: account {addr:?} modified during withdrawal \
     (pre: {pre:?}, post: {post:?}) but absent from BAL"
)));

4. Hash calculation consistency
Line 1684-1689: Ensure ethrex_common::utils::keccak matches the EVM's keccak256 implementation exactly. While likely correct, consensus-critical hash operations should use the same primitive throughout the codebase.

Verdict

The security fix is correctly implemented and addresses a legitimate attack vector. The logic for distinguishing warm accesses from genuine mutations is sound. Minor performance optimizations (caching store lookups) are recommended but not blocking.


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

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 10, 2026

Greptile Summary

This PR adds the missing DB→BAL reverse check (Part B) to validate_bal_withdrawal_index, fixing a vulnerability where a malicious block builder could omit a withdrawal recipient from the BAL and produce a valid-looking (but wrong) BAL-derived state root.

  • The new Part B checks balance, nonce, and code but omits storage, unlike the analogous Part B in validate_tx_execution (lines 1442–1469). System calls executed during the withdrawal/request phase (EIP-7002, EIP-7251 predeploys) write storage; those mutations are not covered by the new check and could still be silently omitted from the BAL.

Confidence Score: 4/5

Safe to merge for the balance-omission fix, but leaves a storage-mutation gap in Part B that mirrors the original vulnerability class for system-call predeploys.

The primary vulnerability (omitted withdrawal recipient balance) is correctly fixed and all 1963 EF tests pass. However, the new Part B diverges from the validate_tx_execution template by skipping the storage-slot check, which means storage writes by EIP-7002/EIP-7251 system calls during the withdrawal/request phase could still be omitted from the BAL undetected. This is a P1 gap in the stated goal of a bidirectional check.

crates/vm/backends/levm/mod.rs — specifically the Part B storage check absence in validate_bal_withdrawal_index (around line 1664–1689)

Important Files Changed

Filename Overview
crates/vm/backends/levm/mod.rs Adds Part B (DB→BAL) reverse check to validate_bal_withdrawal_index, correctly patching the omitted-withdrawal-recipient vulnerability; however, the new Part B omits the storage-slot check that the equivalent validate_tx_execution Part B performs, leaving system-call storage mutations (EIP-7002, EIP-7251 predeploys) uncovered.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["validate_bal_withdrawal_index(db, bal, withdrawal_idx, index)"] --> B

    subgraph PartA["Part A: BAL → DB"]
        B["For each acct in bal.accounts()"] --> C{Has change at withdrawal_idx?}
        C -- balance/nonce/code/storage --> D[Check DB matches BAL claim]
        D --> E{Match?}
        E -- no --> F[Return Err: mismatch]
        E -- yes --> B
    end

    PartA --> G

    subgraph PartB["Part B: DB → BAL (new)"]
        G["For each (addr, account) in current_accounts_state"] --> H{is_unmodified?}
        H -- yes --> G
        H -- no --> I{addr in BAL index?}
        I -- no --> J[Compare pre-state vs post-state balance/nonce/code_hash only]
        J --> K{Changed?}
        K -- yes --> L[Return Err: absent from BAL]
        K -- no --> G
        I -- yes --> M[Check balance vs last BAL entry]
        M --> N[Check nonce vs last BAL entry]
        N --> O[Check code vs last BAL entry]
        O --> P["⚠️ Storage NOT checked"]
        P --> G
    end

    PartB --> Q[Ok]
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: crates/vm/backends/levm/mod.rs
Line: 1664-1689

Comment:
**Storage mutations missing from Part B**

Part B checks balance, nonce, and code, but does not verify storage slot changes — unlike `validate_tx_execution`'s Part B (lines 1442–1469) which iterates `account.storage` and checks every slot against its BAL entry. During the withdrawal/request phase, system calls (EIP-7002, EIP-7251) write to storage in their predeploy contracts. A block builder could omit those storage changes from the BAL; the BAL-derived state root would then exclude those slot mutations, but this check would not catch the omission.

The analogous pattern from `validate_tx_execution` Part B is:

```rust
// Storage: for each slot written during withdrawal/request phase,
//          verify a corresponding BAL entry exists.
for (key_h256, &value) in &account.storage {
    let slot_u256 = U256::from_big_endian(key_h256.as_bytes());
    let pos = acct
        .storage_changes
        .partition_point(|sc| sc.slot < slot_u256);
    if pos < acct.storage_changes.len() && acct.storage_changes[pos].slot == slot_u256 {
        let sc = &acct.storage_changes[pos];
        if !has_exact_change_storage(&sc.slot_changes, withdrawal_idx) {
            let seeded = sc
                .slot_changes
                .last()
                .map(|c| c.post_value)
                .unwrap_or_default();
            if value != seeded {
                return Err(EvmError::Custom(format!(
                    "BAL validation failed for withdrawal: account {addr:?} storage slot \
                     {slot_u256} changed during withdrawal/request phase ({value}) but BAL \
                     has no change at index {withdrawal_idx} (last_bal={seeded})"
                )));
            }
        }
    }
    // Slot not in BAL storage_changes: loaded from store, skip.
}
```

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

Reviews (1): Last reviewed commit: "fix(l1): add reverse check in BAL withdr..." | Re-trigger Greptile

Comment on lines +1664 to +1689
// Code
if !has_exact_change_code(&acct.code_changes, withdrawal_idx) {
let seeded_hash = acct
.code_changes
.last()
.map(|c| {
if c.new_code.is_empty() {
*EMPTY_KECCACK_HASH
} else {
ethrex_common::utils::keccak(&c.new_code)
}
})
.unwrap_or_else(|| {
db.store
.get_account_state(*addr)
.map(|a| a.code_hash)
.unwrap_or(*EMPTY_KECCACK_HASH)
});
if account.info.code_hash != seeded_hash {
return Err(EvmError::Custom(format!(
"BAL validation failed for withdrawal: account {addr:?} code \
changed during withdrawal/request phase but BAL has no \
code change at index {withdrawal_idx}"
)));
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Storage mutations missing from Part B

Part B checks balance, nonce, and code, but does not verify storage slot changes — unlike validate_tx_execution's Part B (lines 1442–1469) which iterates account.storage and checks every slot against its BAL entry. During the withdrawal/request phase, system calls (EIP-7002, EIP-7251) write to storage in their predeploy contracts. A block builder could omit those storage changes from the BAL; the BAL-derived state root would then exclude those slot mutations, but this check would not catch the omission.

The analogous pattern from validate_tx_execution Part B is:

// Storage: for each slot written during withdrawal/request phase,
//          verify a corresponding BAL entry exists.
for (key_h256, &value) in &account.storage {
    let slot_u256 = U256::from_big_endian(key_h256.as_bytes());
    let pos = acct
        .storage_changes
        .partition_point(|sc| sc.slot < slot_u256);
    if pos < acct.storage_changes.len() && acct.storage_changes[pos].slot == slot_u256 {
        let sc = &acct.storage_changes[pos];
        if !has_exact_change_storage(&sc.slot_changes, withdrawal_idx) {
            let seeded = sc
                .slot_changes
                .last()
                .map(|c| c.post_value)
                .unwrap_or_default();
            if value != seeded {
                return Err(EvmError::Custom(format!(
                    "BAL validation failed for withdrawal: account {addr:?} storage slot \
                     {slot_u256} changed during withdrawal/request phase ({value}) but BAL \
                     has no change at index {withdrawal_idx} (last_bal={seeded})"
                )));
            }
        }
    }
    // Slot not in BAL storage_changes: loaded from store, skip.
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/vm/backends/levm/mod.rs
Line: 1664-1689

Comment:
**Storage mutations missing from Part B**

Part B checks balance, nonce, and code, but does not verify storage slot changes — unlike `validate_tx_execution`'s Part B (lines 1442–1469) which iterates `account.storage` and checks every slot against its BAL entry. During the withdrawal/request phase, system calls (EIP-7002, EIP-7251) write to storage in their predeploy contracts. A block builder could omit those storage changes from the BAL; the BAL-derived state root would then exclude those slot mutations, but this check would not catch the omission.

The analogous pattern from `validate_tx_execution` Part B is:

```rust
// Storage: for each slot written during withdrawal/request phase,
//          verify a corresponding BAL entry exists.
for (key_h256, &value) in &account.storage {
    let slot_u256 = U256::from_big_endian(key_h256.as_bytes());
    let pos = acct
        .storage_changes
        .partition_point(|sc| sc.slot < slot_u256);
    if pos < acct.storage_changes.len() && acct.storage_changes[pos].slot == slot_u256 {
        let sc = &acct.storage_changes[pos];
        if !has_exact_change_storage(&sc.slot_changes, withdrawal_idx) {
            let seeded = sc
                .slot_changes
                .last()
                .map(|c| c.post_value)
                .unwrap_or_default();
            if value != seeded {
                return Err(EvmError::Custom(format!(
                    "BAL validation failed for withdrawal: account {addr:?} storage slot \
                     {slot_u256} changed during withdrawal/request phase ({value}) but BAL \
                     has no change at index {withdrawal_idx} (last_bal={seeded})"
                )));
            }
        }
    }
    // Slot not in BAL storage_changes: loaded from store, skip.
}
```

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

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

  1. High: the new reverse validation still allows storage-only request/withdrawal mutations to be omitted for accounts that are already present in the BAL. In crates/vm/backends/levm/mod.rs:1619, Part B only checks balance/nonce/code when there is no exact withdrawal_idx entry. But this phase runs extract_all_requests_levm before withdrawals (crates/vm/backends/levm/mod.rs:323), and those Prague system calls are stateful. If a request predeploy writes storage on an account already listed in BAL and the builder omits the corresponding storage_changes at withdrawal_idx, Part A sees nothing and Part B still passes because storage is never compared against the seeded pre-withdrawal value. That means the BAL-derived state root can still exclude a real storage mutation.

  2. High: the “account absent from BAL entirely” branch also misses storage-only mutations. In crates/vm/backends/levm/mod.rs:1593, the code only compares (balance, nonce, code_hash) from store vs post-state. If the withdrawal/request phase mutates storage without changing those three fields, pre == post and validation succeeds even though the storage trie changed. This leaves the exact same consensus hole for any storage-only mutation on an otherwise unchanged account.

Direction looks right, but I would not merge this until storage is included in the reverse check for both branches.


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

@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review

Now I have all the context I need for the review. Here's my analysis:


Review: fix(l1): add reverse check in BAL withdrawal validation

Summary

This PR addresses a real security gap: validate_bal_withdrawal_index only checked BAL→DB (claims match reality) but not DB→BAL (all mutations have corresponding claims). A malicious block builder could omit a withdrawal recipient from the BAL, causing bal_to_account_updates to derive an incorrect state root. The fix is correct in principle and mirrors the existing bidirectional pattern in validate_tx_execution.


Security Concern: Part B lacks storage reverse-check

This is the most significant issue with the PR as written.

validate_tx_execution Part B iterates account.storage for each in-BAL account and validates that any slot present in the BAL's storage_changes structure does in fact have a change recorded at bal_idx if the slot was modified. The new withdrawal Part B has no equivalent loop.

The relevant system calls during the withdrawal/request phase (extract_all_requests_levm) write deterministic storage entries:

  • EIP-4788: writes timestamp → beacon_root and timestamp % BUFFER_LEN → timestamp
  • EIP-2935: writes current block hash to the history contract
  • EIP-7002 / EIP-7251: pop requests from storage queues

These predeploy contracts are almost certainly already present in the BAL from prior transaction accesses. That means they won't fall into the "absent from BAL entirely" branch — they'll take the index.addr_to_idx.get(addr) path. But Part B then only checks balance/nonce/code for such accounts, not storage.

A malicious block builder could record the predeploy in the BAL but omit the storage writes at withdrawal_idx. Part A won't flag this (it only verifies BAL claims, not omissions), and the new Part B won't catch it either. The BAL-derived state root would be missing the storage update.

Suggested addition at the end of the per-account block (after the code check), mirroring validate_tx_execution Part B:

// Storage: reverse-check that each slot in execution state
// has a corresponding BAL claim if it was modified.
for (key_h256, &value) in &account.storage {
    let slot_u256 = U256::from_big_endian(key_h256.as_bytes());
    let pos = acct
        .storage_changes
        .partition_point(|sc| sc.slot < slot_u256);
    if pos < acct.storage_changes.len()
        && acct.storage_changes[pos].slot == slot_u256
    {
        let sc = &acct.storage_changes[pos];
        if !has_exact_change_storage(&sc.slot_changes, withdrawal_idx) {
            let seeded = sc
                .slot_changes
                .last()
                .map(|c| c.post_value)
                .unwrap_or_else(|| {
                    db.store
                        .get_storage_value(*addr, *key_h256)
                        .unwrap_or_default()
                });
            if value != seeded {
                return Err(EvmError::Custom(format!(
                    "BAL validation failed for withdrawal: account {addr:?} \
                     storage slot {slot_u256} changed during withdrawal/request \
                     phase ({value}) but BAL has no change at index {withdrawal_idx}"
                )));
            }
        }
    }
    // Slot not in BAL storage_changes at all: skip (same known gap as tx validation)
}

Minor: unwrap_or_default() for the absent-from-BAL tuple

In the "absent from BAL entirely" branch (around line 1600):

let pre = db
    .store
    .get_account_state(*addr)
    .ok()
    .map(|a| (a.balance, a.nonce, a.code_hash))
    .unwrap_or_default();

If get_account_state returns Err, the tuple (U256, u64, H256) defaults to (0, 0, H256::zero()). But AccountState::default() has code_hash = EMPTY_KECCACK_HASH, not H256::zero(). In production, DynVmDatabase::get_account_state returns Ok(AccountState::default()) for non-existent addresses (via .unwrap_or_default() at the Option level), so this is not a live bug. But test databases or mocks that return Err(DatabaseError::NotFound) would get a false mismatch on code_hash.

The same pattern exists in validate_tx_execution, so this isn't introduced by this PR. But since the new code is already calling db.store.get_account_state, it could use the more defensive form:

let pre_state = db
    .store
    .get_account_state(*addr)
    .unwrap_or_default();   // AccountState::default() has code_hash = EMPTY_KECCACK_HASH
let pre = (pre_state.balance, pre_state.nonce, pre_state.code_hash);

Minor: index access can panic

let acct = &bal.accounts()[bal_acct_idx];

bal_acct_idx comes from an index built against the same bal, so this is safe in practice. The same pattern is used in validate_tx_execution. If the index is ever built from a different bal or the BAL is mutated between index construction and validation, this panics instead of returning an error. A .get(bal_acct_idx).ok_or(...) would be more resilient, but this is consistent with existing style.


What's correct

  • The fix correctly addresses the stated attack: omitting a withdrawal recipient from the BAL no longer passes validation, because Part B detects the balance change without a corresponding BAL entry at withdrawal_idx.
  • Using .last() for the seeded value (rather than partition_point against a seed index as in tx validation) is correct: the withdrawal phase runs after all transactions, so the last BAL entry is always the most recent pre-withdrawal value.
  • The Destroyed/DestroyedModified account statuses are correctly included in Part B (they're not Unmodified).
  • Error messages include sufficient context (address, value, index) for diagnosis.
  • Code structure cleanly mirrors the existing validate_tx_execution pattern.

Bottom line

The balance/nonce/code coverage fix is correct and closes the described attack vector. The missing storage reverse-check for accounts in the BAL is the key thing to address before merge — system calls during request extraction make deterministic storage writes, and without the reverse check, those writes can be silently omitted from the BAL.


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

Part B was missing storage validation. System contract calls
(extract_all_requests_levm) run during the withdrawal/request phase
and can write storage. Without this check, a malicious builder could
omit storage changes from the BAL.

Also check storage for accounts absent from BAL entirely.
@edg-l edg-l force-pushed the fix/bal-withdrawal-reverse-check branch from e4894d6 to c67cdb0 Compare April 10, 2026 14:43
Comment thread crates/vm/backends/levm/mod.rs
@github-project-automation github-project-automation bot moved this from In Review to In Progress in ethrex_l1 Apr 13, 2026
Comment thread crates/vm/backends/levm/mod.rs
edg-l added 2 commits April 14, 2026 10:17
…reverse check

Add else-branch to catch storage slots absent from BAL storage_changes
by verifying against store pre-state. Add diagnostic values to code
hash mismatch error message.
@edg-l edg-l requested review from ElFantasma and iovoid April 14, 2026 08:19
@edg-l edg-l moved this from In Progress to In Review in ethrex_l1 Apr 14, 2026
let pre = db
.store
.get_account_state(*addr)
.ok()
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.

Silently ignores db errors

@github-project-automation github-project-automation bot moved this from In Review to In Progress in ethrex_l1 Apr 15, 2026
azteca1998 and others added 2 commits April 16, 2026 18:54
…6486)

## Summary
- The Hive consume-engine Amsterdam tests for EIP-7778 and EIP-8037 were
failing because ethrex's per-tx gas limit checks were incompatible with
Amsterdam's new gas accounting rules.
- **EIP-7778** uses pre-refund gas for block accounting, so cumulative
pre-refund gas can exceed the block gas limit even when a block builder
correctly included all transactions.
- **EIP-8037** introduces 2D gas accounting (`block_gas = max(regular,
state)`), meaning cumulative total gas (regular + state) can legally
exceed the block gas limit.
- The fix skips the per-tx cumulative gas check for Amsterdam and adds a
**post-execution** block-level overflow check using `max(sum_regular,
sum_state)` in all three execution paths (sequential, pipeline,
parallel).

## Local test results
- **200/201** EIP-7778 + EIP-8037 Hive consume-engine tests pass
- **105/105** EIP-7778 + EIP-8037 EF blockchain tests pass (4 + 101)
- The single remaining Hive failure
(`test_block_regular_gas_limit[exceed=True]`) expects
`TransactionException.GAS_ALLOWANCE_EXCEEDED` but we return
`BlockException.GAS_USED_OVERFLOW` — the block is correctly rejected,
just with a different error classification.

## Test plan
- [x] All EIP-7778 EF blockchain tests pass locally
- [x] All EIP-8037 EF blockchain tests pass locally
- [x] 200/201 Hive consume-engine Amsterdam tests pass locally
- [ ] Full CI Amsterdam Hive suite passes

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

4 participants