For the complete documentation index, see llms.txt. This page is also available as Markdown.

75107 bc medium stateless trie drops accounts changed after selfdestruct

Submitted on Apr 27th 2026 at 09:04:35 UTC by @y4y for Audit Comp | Base Azul

  • Report ID: #75107

  • Report Type: Blockchain/DLT

  • Report severity: Medium

  • Target: https://github.com/base/base/tree/v0.8.0-rc.24

  • Impacts:

    • A bug in the respective layer 0/1/2 network code that results in unintended smart contract behavior with no concrete funds at direct risk

Description

Vulnerability Details

The proof executor recomputes state roots from revm's BundleState, but it deletes every account for which bundle_account.was_destroyed() is true. AccountStatus::DestroyedChanged is not pure deletion: the account was destroyed and then modified again in the same block. Canonical L2 execution keeps the final account alive with wiped storage, empty code, and final balance / nonce, while the proof executor removes it from the trie. That produces a different state root and output root for a block that was already accepted on L2. The proposer then rejects that proof result with RootMismatch before any L1 proposal call.

Impact Details

The stateless trie builder collapses all destroyed statuses into the same delete-and-continue branch:

// base/crates/proof/executor/src/db/mod.rs
fn update_accounts(&mut self, bundle: &BundleState) -> TrieDBResult<()> {
    ...
    for (address, hashed_address, bundle_account) in sorted_state {
        if bundle_account.status.is_not_modified() {
            continue;
        }

        let account_path = Nibbles::unpack(hashed_address.as_slice());

        if bundle_account.was_destroyed() {
            self.root_node.delete(&account_path, &self.fetcher)?;
            self.storage_roots.remove(address);
            continue;
        }
        ...
    }
}

The rebuilt proof header commits the state root returned by that trie logic:

The proposer performs a just-in-time comparison against canonical output roots and rejects mismatches before any L1 proposal call is made:

Concrete example from the verified local PoC:

1

Block 1

Prefunds the future create address with 1000 wei.

2

Block 2

Deploys constructor code that executes SSTORE(0, 0x42) and SELFDESTRUCT.

3

The same block 2

Transfers 2000 wei to that same address.

4

Canonical execution ends with

  • AccountStatus::DestroyedChanged

  • code len = 0

  • slot0 = 0

  • balance = 2000

5

The proof executor derives

  • buggy state root: 0x443ea453d90e4d52d64e07ce747e53bfe56cd568d4f5edf52aa63312ec1b3c91

  • canonical state root: 0xcdafa1d0435a39b2e55ab5ec3e4965c9bcb3e713ea6f1f44c386fae56c18641e

6

That becomes

  • buggy output root: 0xf8c1b0b2adb3515056251df4c6c726d9a76ba57c572b10396291730e98ddb732

  • canonical output root: 0x7ff3e56b93374b297b93858a6f691def2f6b8e190b3bc64a5e9709425c8b0e73

7

The proposer validation path returns RootMismatch.

8

The L1 output proposer is never called.

Pre-conditions

  • A proved L2 block contains an account that ends the block as DestroyedChanged.

  • The proof executor uses TrieDB::state_root() to rebuild the block state root from the block bundle.

  • The proposer validates the proof output root against rollup_client.output_at_block(target_block).

This can be triggered by ordinary L2 transactions. It does not require malformed payloads, privileged operator input, or a trusted-role mistake.

References

  • base/crates/proof/executor/src/db/mod.rs:164-224

  • base/crates/proof/executor/src/builder/assemble.rs:31-42

  • base/crates/proof/proposer/src/pipeline.rs:1048-1175

https://gist.github.com/brandonshiyay/6b9ce19b9e132cd71eca1d550aaff13f

Proof of Concept

The above file should be added to crates/proof/executor/tests/destroyed_changed_poc.rs. This is meant to demonstrate the transaction which can lead to the incorrect proof root being computed.

This is the second half of the entire PoC chain, add to crates/proof/proposer/examples/validate_buggy_proof_result.rs. This will demonstrate how the proof generated in part 1 of PoC would be handled by a proposer for validation.

Then for those files:

  • crates/proof/proposer/src/lib.rs

  • crates/proof/proposer/src/pipeline.rs

  • crates/proof/proposer/Cargo.toml

These PoC-only changes expose the final proposer validation stage without running the full coordinator:

Was this helpful?