|
| 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 | +} |
0 commit comments