Skip to content

Commit b36d135

Browse files
committed
fix(levm): full-clone destroyed accts on re-fault
1 parent 6c4d8ec commit b36d135

3 files changed

Lines changed: 239 additions & 5 deletions

File tree

crates/vm/levm/src/db/gen_db.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -362,12 +362,20 @@ impl GeneralizedDatabase {
362362
// it barely reads. The touched slots are faulted back in lazily by `get_storage_value`,
363363
// which resolves a `current` miss against `initial` (the committed baseline) before the
364364
// store — so this stays correct and the diff invariant holds. See `clone_without_storage`.
365+
//
366+
// Exception: destroyed-and-recreated accounts must be full-cloned. `get_storage_value`
367+
// early-returns 0 for `DestroyedModified` *before* the `initial` fallback (an unwritten
368+
// slot of a destroyed account must read 0, never the stale value in `initial`). With an
369+
// info-only clone, a committed slot written after recreation — folded into `initial`
370+
// wholesale by the per-flush drain-back — would also read 0, since the lazy fallback is
371+
// never reached. Carrying the storage on the clone keeps those committed slots in
372+
// `current`, where the `account.storage` hit precedes the early-return.
365373
if let Some(account) = self.initial_accounts_state.get(&address) {
366-
let shallow = account.clone_without_storage();
367-
return Ok(self
368-
.current_accounts_state
369-
.entry(address)
370-
.or_insert(shallow));
374+
let clone = match account.status {
375+
AccountStatus::Destroyed | AccountStatus::DestroyedModified => account.clone(),
376+
_ => account.clone_without_storage(),
377+
};
378+
return Ok(self.current_accounts_state.entry(address).or_insert(clone));
371379
}
372380

373381
// Lazy-BAL hook: if the cursor finds this address, materialize info from the BAL
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
//! Regression tests for storage reads against `DestroyedModified` accounts that
2+
//! were drained back into `initial_accounts_state` by the streaming executor.
3+
//!
4+
//! The streaming pipeline drains `current_accounts_state` into
5+
//! `initial_accounts_state` every few txs (`get_state_transitions_tx`). A
6+
//! destroyed-and-recreated account is folded back wholesale, keeping its
7+
//! `DestroyedModified` status *and* its committed in-block storage. On the next
8+
//! tx that touches it, `load_account` re-faults it from `initial`. With the
9+
//! info-only clone optimization, `current.storage` starts empty, so an `SLOAD`
10+
//! of a committed slot must still resolve to the committed value, not `0`.
11+
12+
use bytes::Bytes;
13+
use ethrex_common::{
14+
Address, H256, U256,
15+
constants::EMPTY_KECCAK_HASH,
16+
types::{Account, AccountInfo, Code, EIP1559Transaction, Fork, Transaction, TxKind},
17+
};
18+
use ethrex_crypto::NativeCrypto;
19+
use ethrex_levm::{
20+
account::{AccountStatus, LevmAccount},
21+
db::gen_db::GeneralizedDatabase,
22+
environment::{EVMConfig, Environment},
23+
tracing::LevmCallTracer,
24+
vm::{VM, VMType},
25+
};
26+
use rustc_hash::FxHashMap;
27+
use std::sync::Arc;
28+
29+
use super::test_db::TestDatabase;
30+
31+
const ORIGIN: u64 = 0x1000;
32+
const RECIPIENT: u64 = 0xBEEF;
33+
/// Address of the destroyed-and-recreated contract under test.
34+
const TARGET: u64 = 0xDEAD;
35+
36+
/// The slot SLOADed by the later tx and its committed in-block value.
37+
fn slot() -> H256 {
38+
H256::from_low_u64_be(0x42)
39+
}
40+
fn committed_value() -> U256 {
41+
U256::from(0x2222u64)
42+
}
43+
/// A different, now-invalid value sitting in the trie/store for the same slot.
44+
/// A `DestroyedModified` account must never surface it.
45+
fn stale_store_value() -> U256 {
46+
U256::from(0x1111u64)
47+
}
48+
49+
/// Backing store whose `TARGET` account carries the *stale* (pre-destruction)
50+
/// slot value, so any read that reaches the store is distinguishable from the
51+
/// committed in-block value.
52+
fn store_with_stale_slot() -> TestDatabase {
53+
let mut db = TestDatabase::new();
54+
let mut storage = FxHashMap::default();
55+
storage.insert(slot(), stale_store_value());
56+
db.accounts.insert(
57+
Address::from_low_u64_be(TARGET),
58+
Account::new(U256::zero(), Code::default(), 1, storage),
59+
);
60+
db
61+
}
62+
63+
/// Only the origin is seeded into the cache so `VM::new` succeeds; the target is
64+
/// injected into `initial_accounts_state` afterwards to model the post-drain state.
65+
fn db_with_origin(store: TestDatabase) -> GeneralizedDatabase {
66+
let mut accounts: FxHashMap<Address, Account> = FxHashMap::default();
67+
accounts.insert(
68+
Address::from_low_u64_be(ORIGIN),
69+
Account::new(
70+
U256::from(10u64).pow(18.into()),
71+
Code::default(),
72+
0,
73+
FxHashMap::default(),
74+
),
75+
);
76+
GeneralizedDatabase::new_with_account_state(Arc::new(store), accounts)
77+
}
78+
79+
fn env(fork: Fork) -> Environment {
80+
let blob_schedule = EVMConfig::canonical_values(fork);
81+
Environment {
82+
origin: Address::from_low_u64_be(ORIGIN),
83+
gas_limit: 1_000_000,
84+
config: EVMConfig::new(fork, blob_schedule),
85+
block_number: 1,
86+
coinbase: Address::from_low_u64_be(0xCCC),
87+
timestamp: 1000,
88+
prev_randao: Some(H256::zero()),
89+
difficulty: U256::zero(),
90+
slot_number: U256::zero(),
91+
chain_id: U256::from(1),
92+
base_fee_per_gas: U256::zero(),
93+
base_blob_fee_per_gas: U256::from(1),
94+
gas_price: U256::zero(),
95+
block_excess_blob_gas: None,
96+
block_blob_gas_used: None,
97+
tx_blob_hashes: vec![],
98+
tx_max_priority_fee_per_gas: None,
99+
tx_max_fee_per_gas: Some(U256::zero()),
100+
tx_max_fee_per_blob_gas: None,
101+
tx_nonce: 0,
102+
block_gas_limit: 30_000_000,
103+
is_privileged: false,
104+
fee_token: None,
105+
disable_balance_check: true,
106+
is_system_call: false,
107+
}
108+
}
109+
110+
fn dummy_tx() -> Transaction {
111+
Transaction::EIP1559Transaction(EIP1559Transaction {
112+
chain_id: 1,
113+
nonce: 0,
114+
max_priority_fee_per_gas: 0,
115+
max_fee_per_gas: 0,
116+
gas_limit: 1_000_000,
117+
to: TxKind::Call(Address::from_low_u64_be(RECIPIENT)),
118+
value: U256::zero(),
119+
data: Bytes::new(),
120+
access_list: Default::default(),
121+
..Default::default()
122+
})
123+
}
124+
125+
/// A `DestroyedModified` account holding only the slots written after recreation,
126+
/// as the per-flush drain-back leaves it in `initial_accounts_state`.
127+
fn destroyed_modified_with(slot: H256, value: U256) -> LevmAccount {
128+
let mut storage = FxHashMap::default();
129+
storage.insert(slot, value);
130+
LevmAccount {
131+
info: AccountInfo {
132+
code_hash: *EMPTY_KECCAK_HASH,
133+
balance: U256::zero(),
134+
nonce: 1,
135+
},
136+
storage,
137+
// Trie storage was wiped on destruction; only in-block writes are valid.
138+
has_storage: false,
139+
status: AccountStatus::DestroyedModified,
140+
exists: true,
141+
}
142+
}
143+
144+
/// A later tx SLOADs a slot of a destroyed-and-recreated account that survived a
145+
/// mid-block flush. The committed in-block value must be returned, not `0`.
146+
#[test]
147+
fn sload_after_flush_returns_committed_value_for_destroyed_modified() {
148+
let mut db = db_with_origin(store_with_stale_slot());
149+
let tx = dummy_tx();
150+
let mut vm = VM::new(
151+
env(Fork::Prague),
152+
&mut db,
153+
&tx,
154+
LevmCallTracer::disabled(),
155+
VMType::L1,
156+
&NativeCrypto,
157+
)
158+
.expect("VM::new");
159+
160+
let target = Address::from_low_u64_be(TARGET);
161+
162+
// Post-drain state: the destroyed-recreated account lives in `initial` with its
163+
// committed slot, and was drained out of `current`.
164+
vm.db
165+
.initial_accounts_state
166+
.insert(target, destroyed_modified_with(slot(), committed_value()));
167+
vm.db.current_accounts_state.remove(&target);
168+
169+
// Later tx touches the account: `load_account` re-faults it from `initial`.
170+
vm.db.get_account(target).expect("load target account");
171+
172+
let value = vm
173+
.get_storage_value(target, slot())
174+
.expect("get_storage_value");
175+
176+
assert_eq!(
177+
value,
178+
committed_value(),
179+
"SLOAD of a committed slot on a re-faulted DestroyedModified account returned \
180+
the wrong value (got {value:#x}); the DestroyedModified early-return shadowed the \
181+
committed in-block value held in initial_accounts_state"
182+
);
183+
}
184+
185+
/// Guard: within the same tx, a destroyed-and-recreated account whose slot was NOT
186+
/// rewritten must read `0`, never the stale pre-destruction value left in `initial`.
187+
/// This is why the `DestroyedModified` early-return must precede the `initial` fallback
188+
/// (the fix full-clones on re-fault rather than reordering these checks).
189+
#[test]
190+
fn sload_unwritten_slot_on_destroyed_modified_reads_zero_not_stale_initial() {
191+
let mut db = db_with_origin(store_with_stale_slot());
192+
let tx = dummy_tx();
193+
let mut vm = VM::new(
194+
env(Fork::Prague),
195+
&mut db,
196+
&tx,
197+
LevmCallTracer::disabled(),
198+
VMType::L1,
199+
&NativeCrypto,
200+
)
201+
.expect("VM::new");
202+
203+
let target = Address::from_low_u64_be(TARGET);
204+
205+
// `initial` still holds the pre-destruction committed value for this slot...
206+
vm.db.initial_accounts_state.insert(
207+
target,
208+
destroyed_modified_with(slot(), U256::from(0x3333u64)),
209+
);
210+
// ...but `current` is the live destroyed-recreated account with the slot unwritten.
211+
let mut live = destroyed_modified_with(slot(), U256::zero());
212+
live.storage.clear();
213+
vm.db.current_accounts_state.insert(target, live);
214+
215+
let value = vm
216+
.get_storage_value(target, slot())
217+
.expect("get_storage_value");
218+
219+
assert_eq!(
220+
value,
221+
U256::zero(),
222+
"unwritten slot of a destroyed-and-recreated account must read 0, not the stale \
223+
pre-destruction value in initial_accounts_state (got {value:#x})"
224+
);
225+
}

test/tests/levm/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ mod test_db;
22

33
mod bal_view_tests;
44
mod bls12_tests;
5+
mod destroyed_refault_tests;
56
mod eip7702_tests;
67
mod eip7708_tests;
78
mod eip7778_tests;

0 commit comments

Comments
 (0)