diff --git a/crates/blockchain/tracing.rs b/crates/blockchain/tracing.rs index 8591c77f0a0..3e3dc4e7eb0 100644 --- a/crates/blockchain/tracing.rs +++ b/crates/blockchain/tracing.rs @@ -3,7 +3,11 @@ use std::{ time::Duration, }; -use ethrex_common::{H256, tracing::CallTrace, types::Block}; +use ethrex_common::{ + H256, + tracing::{CallTrace, PrestateResult}, + types::Block, +}; use ethrex_storage::Store; use ethrex_vm::{Evm, EvmError}; @@ -82,6 +86,73 @@ impl Blockchain { Ok(call_traces) } + /// Outputs the prestate trace for the given transaction. + /// If `diff_mode` is true, returns both pre and post state; otherwise returns only pre state. + /// May need to re-execute blocks in order to rebuild the transaction's prestate, up to the amount given by `reexec`. + pub async fn trace_transaction_prestate( + &self, + tx_hash: H256, + reexec: u32, + timeout: Duration, + diff_mode: bool, + ) -> Result { + let Some((_, block_hash, tx_index)) = + self.storage.get_transaction_location(tx_hash).await? + else { + return Err(ChainError::Custom("Transaction not Found".to_string())); + }; + let tx_index = tx_index as usize; + let Some(block) = self.storage.get_block_by_hash(block_hash).await? else { + return Err(ChainError::Custom("Block not Found".to_string())); + }; + let mut vm = self + .rebuild_parent_state(block.header.parent_hash, reexec) + .await?; + // Run the block until the transaction we want to trace + vm.rerun_block(&block, Some(tx_index))?; + // Trace the transaction + timeout_trace_operation(timeout, move || { + vm.trace_tx_prestate(&block, tx_index, diff_mode) + }) + .await + } + + /// Outputs the prestate trace for each transaction in the block along with the transaction's hash. + /// If `diff_mode` is true, returns both pre and post state per tx; otherwise returns only pre state. + /// May need to re-execute blocks in order to rebuild the block's prestate, up to the amount given by `reexec`. + /// Returns prestate traces from oldest to newest transaction. + pub async fn trace_block_prestate( + &self, + block: Block, + reexec: u32, + timeout: Duration, + diff_mode: bool, + ) -> Result, ChainError> { + let mut vm = self + .rebuild_parent_state(block.header.parent_hash, reexec) + .await?; + // Run system calls but stop before tx 0 + vm.rerun_block(&block, Some(0))?; + // Trace each transaction sequentially — state accumulates between calls + // We need to do this in order to pass ownership of block & evm to a blocking process without cloning + let vm = Arc::new(Mutex::new(vm)); + let block = Arc::new(block); + let mut traces = vec![]; + for index in 0..block.body.transactions.len() { + let block = block.clone(); + let vm = vm.clone(); + let tx_hash = block.as_ref().body.transactions[index].hash(); + let result = timeout_trace_operation(timeout, move || { + vm.lock() + .map_err(|_| EvmError::Custom("Unexpected Runtime Error".to_string()))? + .trace_tx_prestate(block.as_ref(), index, diff_mode) + }) + .await?; + traces.push((tx_hash, result)); + } + Ok(traces) + } + /// Rebuild the parent state for a block given its parent hash, returning an `Evm` instance with all changes cached /// Will re-execute all ancestor block's which's state is not stored up to a maximum given by `reexec` async fn rebuild_parent_state( diff --git a/crates/common/tracing.rs b/crates/common/tracing.rs index c476737577e..427bf0d5fd0 100644 --- a/crates/common/tracing.rs +++ b/crates/common/tracing.rs @@ -2,6 +2,7 @@ use bytes::Bytes; use ethereum_types::H256; use ethereum_types::{Address, U256}; use serde::Serialize; +use std::collections::HashMap; /// Collection of traces of each call frame as defined in geth's `callTracer` output /// https://geth.ethereum.org/docs/developers/evm-tracing/built-in-tracers#call-tracer @@ -65,3 +66,46 @@ pub struct CallLog { pub data: Bytes, pub position: u64, } + +/// Account state as captured by the prestateTracer. +/// Matches Geth's prestateTracer output format. +/// https://geth.ethereum.org/docs/developers/evm-tracing/built-in-tracers#prestate-tracer +#[derive(Debug, Serialize, Default, Clone)] +pub struct PrestateAccountState { + pub balance: U256, + #[serde(default, skip_serializing_if = "is_zero_nonce")] + pub nonce: u64, + #[serde( + default, + skip_serializing_if = "Bytes::is_empty", + with = "crate::serde_utils::bytes" + )] + pub code: Bytes, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub storage: HashMap, +} + +/// Per-transaction prestate trace (non-diff mode). +/// Maps account address to its state before the transaction. +pub type PrestateTrace = HashMap; + +/// Result of a prestateTracer execution — either a plain prestate map or a diff. +#[derive(Debug, Clone)] +pub enum PrestateResult { + /// Non-diff mode: map of address → pre-tx account state. + Prestate(PrestateTrace), + /// Diff mode: pre-tx and post-tx state for all touched accounts. + Diff(PrePostState), +} + +/// Per-transaction prestate trace (diff mode). +/// Contains the pre-tx and post-tx state for all touched accounts. +#[derive(Debug, Serialize, Default, Clone)] +pub struct PrePostState { + pub pre: HashMap, + pub post: HashMap, +} + +fn is_zero_nonce(n: &u64) -> bool { + *n == 0 +} diff --git a/crates/networking/rpc/tracing.rs b/crates/networking/rpc/tracing.rs index c1fba8ac32d..ec367081ab2 100644 --- a/crates/networking/rpc/tracing.rs +++ b/crates/networking/rpc/tracing.rs @@ -1,7 +1,10 @@ use std::time::Duration; use ethrex_common::H256; -use ethrex_common::{serde_utils, tracing::CallTraceFrame}; +use ethrex_common::{ + serde_utils, + tracing::{CallTraceFrame, PrestateResult}, +}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -41,6 +44,7 @@ struct TraceConfig { enum TracerType { #[default] CallTracer, + PrestateTracer, } #[derive(Deserialize, Default)] @@ -52,6 +56,13 @@ struct CallTracerConfig { with_log: bool, } +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct PrestateTracerConfig { + #[serde(default)] + diff_mode: bool, +} + type BlockTrace = Vec>; #[derive(Serialize)] @@ -96,7 +107,6 @@ impl RpcHandler for TraceTransactionRequest { ) -> Result { let reexec = self.trace_config.reexec.unwrap_or(DEFAULT_REEXEC); let timeout = self.trace_config.timeout.unwrap_or(DEFAULT_TIMEOUT); - // This match will make more sense once we support other tracers match self.trace_config.tracer { TracerType::CallTracer => { // Parse tracer config now that we know the type @@ -124,6 +134,22 @@ impl RpcHandler for TraceTransactionRequest { .ok_or(RpcErr::Internal("Empty call trace".to_string()))?; Ok(serde_json::to_value(top_frame)?) } + TracerType::PrestateTracer => { + let config = if let Some(value) = &self.trace_config.tracer_config { + serde_json::from_value(value.clone())? + } else { + PrestateTracerConfig::default() + }; + let result = context + .blockchain + .trace_transaction_prestate(self.tx_hash, reexec, timeout, config.diff_mode) + .await + .map_err(|err| RpcErr::Internal(err.to_string()))?; + match result { + PrestateResult::Prestate(trace) => Ok(serde_json::to_value(trace)?), + PrestateResult::Diff(diff) => Ok(serde_json::to_value(diff)?), + } + } } } } @@ -166,7 +192,6 @@ impl RpcHandler for TraceBlockByNumberRequest { .ok_or(RpcErr::Internal("Block not Found".to_string()))?; let reexec = self.trace_config.reexec.unwrap_or(DEFAULT_REEXEC); let timeout = self.trace_config.timeout.unwrap_or(DEFAULT_TIMEOUT); - // This match will make more sense once we support other tracers match self.trace_config.tracer { TracerType::CallTracer => { // Parse tracer config now that we know the type @@ -200,6 +225,34 @@ impl RpcHandler for TraceBlockByNumberRequest { .collect::>()?; Ok(serde_json::to_value(block_trace)?) } + TracerType::PrestateTracer => { + let config = if let Some(value) = &self.trace_config.tracer_config { + serde_json::from_value(value.clone())? + } else { + PrestateTracerConfig::default() + }; + let prestate_traces = context + .blockchain + .trace_block_prestate(block, reexec, timeout, config.diff_mode) + .await + .map_err(|err| RpcErr::Internal(err.to_string()))?; + // Each trace result is already the correct variant (Prestate or Diff) + // based on the diff_mode flag, so we serialize directly. + let block_trace: Vec = prestate_traces + .into_iter() + .map(|(hash, result)| { + let trace_value = match result { + PrestateResult::Prestate(trace) => serde_json::to_value(trace)?, + PrestateResult::Diff(diff) => serde_json::to_value(diff)?, + }; + serde_json::to_value(BlockTraceComponent { + tx_hash: hash, + result: trace_value, + }) + }) + .collect::>()?; + Ok(serde_json::to_value(block_trace)?) + } } } } diff --git a/crates/vm/backends/levm/tracing.rs b/crates/vm/backends/levm/tracing.rs index 9777f4dfbeb..fb7e07cdfa2 100644 --- a/crates/vm/backends/levm/tracing.rs +++ b/crates/vm/backends/levm/tracing.rs @@ -1,6 +1,10 @@ +use ethrex_common::constants::EMPTY_KECCACK_HASH; +use ethrex_common::tracing::{PrePostState, PrestateAccountState, PrestateResult, PrestateTrace}; use ethrex_common::types::{Block, Transaction}; -use ethrex_common::{tracing::CallTrace, types::BlockHeader}; +use ethrex_common::{Address, BigEndianHash, H256, tracing::CallTrace, types::BlockHeader}; use ethrex_crypto::Crypto; +use ethrex_levm::account::LevmAccount; +use ethrex_levm::db::gen_db::CacheDB; use ethrex_levm::vm::VMType; use ethrex_levm::{db::gen_db::GeneralizedDatabase, tracing::LevmCallTracer, vm::VM}; @@ -43,6 +47,44 @@ impl LEVM { Ok(()) } + /// Execute a transaction and capture the pre/post account state (prestateTracer). + /// + /// Captures a snapshot of all touched accounts before and after execution. + /// The `diff_mode` flag controls whether to return both pre and post state or just pre state. + /// + /// Assumes the db already contains the state from all prior transactions in the block. + pub fn trace_tx_prestate( + db: &mut GeneralizedDatabase, + block_header: &BlockHeader, + tx: &Transaction, + diff_mode: bool, + vm_type: VMType, + crypto: &dyn Crypto, + ) -> Result { + // Snapshot the current cache state before executing the tx. + let pre_snapshot: CacheDB = db.current_accounts_state.clone(); + + // Execute the transaction (updates current_accounts_state in place). + let sender = tx + .sender(crypto) + .map_err(|e| EvmError::Transaction(format!("Couldn't recover sender: {e}")))?; + let env = Self::setup_env(tx, sender, block_header, db, vm_type)?; + let mut vm = VM::new(env, db, tx, LevmCallTracer::disabled(), vm_type, crypto)?; + vm.execute()?; + + let pre_map = build_pre_state_map(&pre_snapshot, &db.current_accounts_state, db); + + if diff_mode { + let post_map = build_post_state_map(&pre_snapshot, &db.current_accounts_state, db); + Ok(PrestateResult::Diff(PrePostState { + pre: pre_map, + post: post_map, + })) + } else { + Ok(PrestateResult::Prestate(pre_map)) + } + } + /// Run transaction with callTracer activated. pub fn trace_tx_calls( db: &mut GeneralizedDatabase, @@ -79,3 +121,142 @@ impl LEVM { Ok(vec![callframe]) } } + +/// Identifies accounts touched by a transaction by comparing `pre_snapshot` +/// (cache before the tx) with `post_cache` (cache after the tx). +/// +/// Returns `(address, pre_account, post_account)` for each touched account. +/// `pre_account` is the account state before the tx ran — sourced from +/// `pre_snapshot` if the account was already cached, or from +/// `initial_accounts_state` if the account was first loaded during this tx. +fn find_touched_accounts<'a>( + pre_snapshot: &'a CacheDB, + post_cache: &'a CacheDB, + db: &'a GeneralizedDatabase, +) -> Vec<(Address, &'a LevmAccount, &'a LevmAccount)> { + let mut touched = Vec::new(); + + for (addr, post_account) in post_cache { + let pre_account = match pre_snapshot.get(addr) { + Some(pre) => { + if pre.info == post_account.info && pre.storage == post_account.storage { + continue; + } + pre + } + None => { + // Account was first loaded during this tx. + // Pre-state comes from initial_accounts_state (the pristine DB-loaded value). + let Some(initial) = db.initial_accounts_state.get(addr) else { + continue; + }; + if initial.info == post_account.info && initial.storage == post_account.storage { + continue; + } + initial + } + }; + + touched.push((*addr, pre_account, post_account)); + } + + touched +} + +/// Build the account state output for one account. +fn build_account_output(account: &LevmAccount, db: &GeneralizedDatabase) -> PrestateAccountState { + let code = if account.info.code_hash != *EMPTY_KECCACK_HASH { + db.codes + .get(&account.info.code_hash) + .map(|c| c.bytecode.clone()) + .unwrap_or_default() + } else { + bytes::Bytes::new() + }; + + let storage = account + .storage + .iter() + .filter(|(_, v)| !v.is_zero()) + .map(|(k, v)| (*k, H256::from_uint(v))) + .collect(); + + PrestateAccountState { + balance: account.info.balance, + nonce: account.info.nonce, + code, + storage, + } +} + +/// Build the pre-tx state map for all accounts touched by a transaction. +/// +/// For already-cached accounts, the pre_snapshot only contains storage slots +/// loaded by *previous* transactions. Any slot first accessed during *this* +/// transaction has its original value in `initial_accounts_state`. We merge +/// both sources so the output includes every accessed slot, then filter to +/// only slots actually touched by this tx (newly loaded or value changed). +fn build_pre_state_map( + pre_snapshot: &CacheDB, + post_cache: &CacheDB, + db: &GeneralizedDatabase, +) -> PrestateTrace { + let mut result = PrestateTrace::new(); + + for (addr, pre_account, post_account) in find_touched_accounts(pre_snapshot, post_cache, db) { + let mut state = build_account_output(pre_account, db); + + // For already-cached accounts, merge newly-loaded slots from initial_accounts_state + // and filter to only slots touched by this tx. + if let Some(pre_cached) = pre_snapshot.get(&addr) { + if let Some(initial) = db.initial_accounts_state.get(&addr) { + for (k, v) in &initial.storage { + state + .storage + .entry(*k) + .or_insert_with(|| H256::from_uint(v)); + } + } + // Only keep slots actually touched in this tx: + // - Newly loaded slots (in post but not in pre_snapshot) + // - Slots whose value changed between pre and post + state.storage.retain(|k, _| { + if !pre_cached.storage.contains_key(k) { + return true; + } + pre_cached.storage.get(k) != post_account.storage.get(k) + }); + } + + result.insert(addr, state); + } + + result +} + +/// Build the post-tx state map for all accounts touched by a transaction. +fn build_post_state_map( + pre_snapshot: &CacheDB, + post_cache: &CacheDB, + db: &GeneralizedDatabase, +) -> PrestateTrace { + let mut result = PrestateTrace::new(); + + for (addr, _, post_account) in find_touched_accounts(pre_snapshot, post_cache, db) { + let mut state = build_account_output(post_account, db); + + // For already-cached accounts, filter to only slots touched by this tx. + if let Some(pre_cached) = pre_snapshot.get(&addr) { + state.storage.retain(|k, _| { + if !pre_cached.storage.contains_key(k) { + return true; + } + pre_cached.storage.get(k) != post_account.storage.get(k) + }); + } + + result.insert(addr, state); + } + + result +} diff --git a/crates/vm/tracing.rs b/crates/vm/tracing.rs index dd9791fdcd3..1cce982bbc4 100644 --- a/crates/vm/tracing.rs +++ b/crates/vm/tracing.rs @@ -1,5 +1,5 @@ use crate::backends::levm::LEVM; -use ethrex_common::tracing::CallTrace; +use ethrex_common::tracing::{CallTrace, PrestateResult}; use ethrex_common::types::Block; use crate::{Evm, EvmError}; @@ -35,6 +35,32 @@ impl Evm { ) } + /// Executes a single tx and captures the pre/post account state (prestateTracer). + /// Assumes that the received state already contains changes from previous transactions. + pub fn trace_tx_prestate( + &mut self, + block: &Block, + tx_index: usize, + diff_mode: bool, + ) -> Result { + let tx = block + .body + .transactions + .get(tx_index) + .ok_or(EvmError::Custom( + "Missing Transaction for Trace".to_string(), + ))?; + + LEVM::trace_tx_prestate( + &mut self.db, + &block.header, + tx, + diff_mode, + self.vm_type, + self.crypto.as_ref(), + ) + } + /// Reruns the given block, saving the changes on the state, doesn't output any results or receipts. /// If the optional argument `stop_index` is set, the run will stop just before executing the transaction at that index /// and won't process the withdrawals afterwards. diff --git a/test/tests/l2/utils.rs b/test/tests/l2/utils.rs index 6dacf00e73e..2475d7a912c 100644 --- a/test/tests/l2/utils.rs +++ b/test/tests/l2/utils.rs @@ -1,7 +1,5 @@ //! Common utilities shared across L2 tests. -use std::fs::File; -use std::io::{BufRead, BufReader}; use std::path::PathBuf; /// Returns the workspace root directory. @@ -12,7 +10,10 @@ pub fn workspace_root() -> PathBuf { /// Reads environment variables from the .env file generated by the deployer. /// Skips variables that are already set in the environment. +#[cfg(feature = "l2")] pub fn read_env_file_by_config() { + use std::fs::File; + use std::io::{BufRead, BufReader}; let env_file_path = workspace_root().join("cmd/.env"); let Ok(env_file) = File::open(env_file_path) else { println!(".env file not found, skipping"); diff --git a/test/tests/levm/l2_hook_tests.rs b/test/tests/levm/l2_hook_tests.rs index 96db7236113..b47612067b9 100644 --- a/test/tests/levm/l2_hook_tests.rs +++ b/test/tests/levm/l2_hook_tests.rs @@ -1,21 +1,19 @@ //! Tests for L2 Hook: fee token storage rollback, nonatomic finalization regression, //! and privileged transaction handling. +use super::test_db::TestDatabase; use bytes::Bytes; use ethrex_common::{ Address, H256, U256, - constants::EMPTY_TRIE_HASH, types::{ - Account, AccountState, ChainConfig, Code, CodeMetadata, EIP1559Transaction, Fork, - PrivilegedL2Transaction, Transaction, TxKind, + Account, Code, EIP1559Transaction, Fork, PrivilegedL2Transaction, Transaction, TxKind, fee_config::{FeeConfig, OperatorFeeConfig}, }, }; use ethrex_crypto::NativeCrypto; use ethrex_levm::{ - db::{Database, gen_db::GeneralizedDatabase}, + db::gen_db::GeneralizedDatabase, environment::{EVMConfig, Environment}, - errors::DatabaseError, hooks::l2_hook::{ COMMON_BRIDGE_L2_ADDRESS, FEE_TOKEN_RATIO_ADDRESS, FEE_TOKEN_REGISTRY_ADDRESS, }, @@ -25,71 +23,6 @@ use ethrex_levm::{ use rustc_hash::FxHashMap; use std::sync::Arc; -// ==================== Test Database ==================== - -struct TestDatabase { - accounts: FxHashMap, -} - -impl TestDatabase { - fn new() -> Self { - Self { - accounts: FxHashMap::default(), - } - } -} - -impl Database for TestDatabase { - fn get_account_state(&self, address: Address) -> Result { - Ok(self - .accounts - .get(&address) - .map(|acc| AccountState { - nonce: acc.info.nonce, - balance: acc.info.balance, - storage_root: *EMPTY_TRIE_HASH, - code_hash: acc.info.code_hash, - }) - .unwrap_or_default()) - } - - fn get_storage_value(&self, address: Address, key: H256) -> Result { - Ok(self - .accounts - .get(&address) - .and_then(|acc| acc.storage.get(&key).copied()) - .unwrap_or_default()) - } - - fn get_block_hash(&self, _block_number: u64) -> Result { - Ok(H256::zero()) - } - - fn get_chain_config(&self) -> Result { - Ok(ChainConfig::default()) - } - - fn get_account_code(&self, code_hash: H256) -> Result { - for acc in self.accounts.values() { - if acc.info.code_hash == code_hash { - return Ok(acc.code.clone()); - } - } - Ok(Code::default()) - } - - fn get_code_metadata(&self, code_hash: H256) -> Result { - for acc in self.accounts.values() { - if acc.info.code_hash == code_hash { - return Ok(CodeMetadata { - length: acc.code.bytecode.len() as u64, - }); - } - } - Ok(CodeMetadata { length: 0 }) - } -} - // ==================== Constants ==================== const SENDER: u64 = 0x1000; diff --git a/test/tests/levm/mod.rs b/test/tests/levm/mod.rs index 4a515255055..55b2325127e 100644 --- a/test/tests/levm/mod.rs +++ b/test/tests/levm/mod.rs @@ -1,3 +1,5 @@ +mod test_db; + mod bls12_tests; mod eip7702_tests; mod eip7708_tests; @@ -10,4 +12,5 @@ mod l2_hook_tests; mod l2_privileged_tx_tests; mod memory_tests; mod precompile_tests; +mod prestate_tracer_tests; mod stack_tests; diff --git a/test/tests/levm/prestate_tracer_tests.rs b/test/tests/levm/prestate_tracer_tests.rs new file mode 100644 index 00000000000..fcdaa68f6db --- /dev/null +++ b/test/tests/levm/prestate_tracer_tests.rs @@ -0,0 +1,426 @@ +use super::test_db::TestDatabase; +use bytes::Bytes; +use ethrex_common::tracing::PrestateResult; +use ethrex_common::types::{Account, BlockHeader, Code, EIP1559Transaction, Transaction, TxKind}; +use ethrex_common::{Address, BigEndianHash, H256, U256}; +use ethrex_crypto::NativeCrypto; +use ethrex_levm::db::gen_db::GeneralizedDatabase; +use ethrex_levm::vm::VMType; +use ethrex_vm::backends::levm::LEVM; +use once_cell::sync::OnceCell; +use rustc_hash::FxHashMap; +use std::sync::Arc; + +// ── Helpers ────────────────────────────────────────────────────────────── + +/// Create an EIP-1559 tx that calls `contract` with 32-byte calldata encoding `slot`. +fn call_contract_tx(contract: Address, sender: Address, slot: H256, nonce: u64) -> Transaction { + let tx = EIP1559Transaction { + chain_id: 1, + nonce, + max_priority_fee_per_gas: 1, + max_fee_per_gas: 10, + gas_limit: 100_000, + to: TxKind::Call(contract), + value: U256::zero(), + data: Bytes::from(slot.0.to_vec()), + access_list: vec![], + signature_y_parity: false, + signature_r: U256::one(), + signature_s: U256::one(), + inner_hash: OnceCell::new(), + sender_cache: { + let cell = OnceCell::new(); + let _ = cell.set(sender); + cell + }, + cached_canonical: OnceCell::new(), + }; + Transaction::EIP1559Transaction(tx) +} + +fn default_header() -> BlockHeader { + BlockHeader { + coinbase: Address::from_low_u64_be(0xCCC), + base_fee_per_gas: Some(1), + gas_limit: 30_000_000, + ..Default::default() + } +} + +/// Contract that reads the slot given in calldata[0..32] and writes 0xFF to it. +/// +/// ```text +/// PUSH1 0xFF 60 FF +/// PUSH1 0x00 60 00 +/// CALLDATALOAD 35 +/// DUP1 80 +/// SLOAD 54 +/// POP 50 +/// SSTORE 55 +/// STOP 00 +/// ``` +fn slot_readwrite_contract(storage: FxHashMap) -> Account { + let bytecode = Bytes::from(vec![ + 0x60, 0xFF, 0x60, 0x00, 0x35, 0x80, 0x54, 0x50, 0x55, 0x00, + ]); + Account::new( + U256::zero(), + Code::from_bytecode(bytecode, &NativeCrypto), + 1, + storage, + ) +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +/// Regression test: when tx A caches account C (loading only slot0), then +/// tx B accesses a NEW slot (slot1) of the same account, the pre-state +/// trace for tx B must include slot1's original value. +/// +/// The bug was that `build_pre_state_map` would only look at `pre_snapshot` +/// storage, but `pre_snapshot` only contained slots loaded by previous txs — +/// newly-loaded slots from `initial_accounts_state` were missing. +#[test] +fn prestate_trace_includes_newly_accessed_storage_slots() { + let contract_addr = Address::from_low_u64_be(0xC000); + let sender_addr = Address::from_low_u64_be(0x1000); + + let slot0 = H256::from_low_u64_be(0); + let slot1 = H256::from_low_u64_be(1); + + // Contract has slot0=100, slot1=200 in the backing store + let mut contract_storage = FxHashMap::default(); + contract_storage.insert(slot0, U256::from(100)); + contract_storage.insert(slot1, U256::from(200)); + + let mut accounts = FxHashMap::default(); + accounts.insert(contract_addr, slot_readwrite_contract(contract_storage)); + accounts.insert( + sender_addr, + Account::new( + U256::from(10u64) * U256::from(10u64).pow(U256::from(18)), // 10 ETH + Code::default(), + 0, + FxHashMap::default(), + ), + ); + + let test_db = TestDatabase { accounts }; + let mut db = GeneralizedDatabase::new(Arc::new(test_db)); + + let header = default_header(); + + // Tx A: calls contract with slot0 → loads C into cache with only slot0 + let tx_a = call_contract_tx(contract_addr, sender_addr, slot0, 0); + LEVM::execute_tx( + &tx_a, + sender_addr, + &header, + &mut db, + VMType::L1, + &NativeCrypto, + ) + .expect("tx_a should succeed"); + + // Verify: slot1 is NOT in current_accounts_state cache (lazy loading) + assert!( + !db.current_accounts_state[&contract_addr] + .storage + .contains_key(&slot1), + "slot1 should not be cached yet after tx_a" + ); + + // Tx B: calls contract with slot1 → loads slot1 from DB, writes 0xFF + let tx_b = call_contract_tx(contract_addr, sender_addr, slot1, 1); + let result = LEVM::trace_tx_prestate(&mut db, &header, &tx_b, false, VMType::L1, &NativeCrypto) + .expect("trace should succeed"); + + let prestate = match result { + PrestateResult::Prestate(p) => p, + PrestateResult::Diff(_) => panic!("expected Prestate variant for non-diff mode"), + }; + + // The pre-state for the contract MUST include slot1's original value (200) + let contract_state = prestate + .get(&contract_addr) + .expect("contract should appear in prestate"); + + let slot1_value = contract_state + .storage + .get(&slot1) + .expect("slot1 must be in prestate storage — its original value was 200"); + + assert_eq!( + *slot1_value, + H256::from_uint(&U256::from(200)), + "slot1 pre-state should be its original value (200), not the post-tx value" + ); +} + +/// Same scenario as above but in diff mode: both pre and post maps +/// must include the newly-accessed slot. +#[test] +fn prestate_diff_mode_includes_newly_accessed_storage_slots() { + let contract_addr = Address::from_low_u64_be(0xC000); + let sender_addr = Address::from_low_u64_be(0x1000); + + let slot0 = H256::from_low_u64_be(0); + let slot1 = H256::from_low_u64_be(1); + + let mut contract_storage = FxHashMap::default(); + contract_storage.insert(slot0, U256::from(100)); + contract_storage.insert(slot1, U256::from(200)); + + let mut accounts = FxHashMap::default(); + accounts.insert(contract_addr, slot_readwrite_contract(contract_storage)); + accounts.insert( + sender_addr, + Account::new( + U256::from(10u64) * U256::from(10u64).pow(U256::from(18)), + Code::default(), + 0, + FxHashMap::default(), + ), + ); + + let test_db = TestDatabase { accounts }; + let mut db = GeneralizedDatabase::new(Arc::new(test_db)); + let header = default_header(); + + // Tx A: cache contract with slot0 + let tx_a = call_contract_tx(contract_addr, sender_addr, slot0, 0); + LEVM::execute_tx( + &tx_a, + sender_addr, + &header, + &mut db, + VMType::L1, + &NativeCrypto, + ) + .expect("tx_a should succeed"); + + // Tx B: access slot1 (new slot) in diff mode + let tx_b = call_contract_tx(contract_addr, sender_addr, slot1, 1); + let result = LEVM::trace_tx_prestate(&mut db, &header, &tx_b, true, VMType::L1, &NativeCrypto) + .expect("trace should succeed"); + + let diff = match result { + PrestateResult::Diff(d) => d, + PrestateResult::Prestate(_) => panic!("expected Diff variant for diff mode"), + }; + + // Pre-state must have slot1 = 200 (original) + let pre_state = diff.pre.get(&contract_addr).expect("contract in pre"); + let pre_val = pre_state + .storage + .get(&slot1) + .expect("slot1 must be in pre storage"); + assert_eq!(*pre_val, H256::from_uint(&U256::from(200))); + + // Post-state must have slot1 = 0xFF (written by contract) + let post_state = diff.post.get(&contract_addr).expect("contract in post"); + let post_val = post_state + .storage + .get(&slot1) + .expect("slot1 must be in post storage"); + assert_eq!(*post_val, H256::from_uint(&U256::from(0xFF))); +} + +/// When tx A touches slot0 of a contract and tx B only touches slot1, +/// the prestate trace for tx B must NOT include slot0. +#[test] +fn prestate_trace_excludes_storage_slots_from_previous_txs() { + let contract_addr = Address::from_low_u64_be(0xC000); + let sender_addr = Address::from_low_u64_be(0x1000); + + let slot0 = H256::from_low_u64_be(0); + let slot1 = H256::from_low_u64_be(1); + + let mut contract_storage = FxHashMap::default(); + contract_storage.insert(slot0, U256::from(100)); + contract_storage.insert(slot1, U256::from(200)); + + let mut accounts = FxHashMap::default(); + accounts.insert(contract_addr, slot_readwrite_contract(contract_storage)); + accounts.insert( + sender_addr, + Account::new( + U256::from(10u64) * U256::from(10u64).pow(U256::from(18)), + Code::default(), + 0, + FxHashMap::default(), + ), + ); + + let test_db = TestDatabase { accounts }; + let mut db = GeneralizedDatabase::new(Arc::new(test_db)); + let header = default_header(); + + // Tx A: touches slot0 → caches contract with slot0 + let tx_a = call_contract_tx(contract_addr, sender_addr, slot0, 0); + LEVM::execute_tx( + &tx_a, + sender_addr, + &header, + &mut db, + VMType::L1, + &NativeCrypto, + ) + .expect("tx_a should succeed"); + + // Tx B: touches only slot1 → should NOT include slot0 in prestate + let tx_b = call_contract_tx(contract_addr, sender_addr, slot1, 1); + let result = LEVM::trace_tx_prestate(&mut db, &header, &tx_b, false, VMType::L1, &NativeCrypto) + .expect("trace should succeed"); + + let prestate = match result { + PrestateResult::Prestate(p) => p, + PrestateResult::Diff(_) => panic!("expected Prestate variant"), + }; + + let contract_state = prestate + .get(&contract_addr) + .expect("contract should appear in prestate"); + + // slot1 was accessed by tx B → should be present + assert!( + contract_state.storage.contains_key(&slot1), + "slot1 must be in prestate (accessed by tx B)" + ); + + // slot0 was only accessed by tx A, not tx B → should NOT be present + assert!( + !contract_state.storage.contains_key(&slot0), + "slot0 must NOT be in prestate (only accessed by tx A, not tx B)" + ); +} + +/// Newly-created accounts (via CREATE) should appear in diff mode post state. +#[test] +fn prestate_diff_includes_created_account() { + let sender_addr = Address::from_low_u64_be(0x1000); + + // Contract bytecode: CREATE a child contract that stores 0x42 at slot 0. + // + // Child init code (deployed by CREATE): + // PUSH1 0x42 PUSH1 0x00 SSTORE -- store 0x42 at slot 0 + // PUSH1 0x01 PUSH1 0x00 RETURN -- return 1 byte of runtime code + // Hex: 60 42 60 00 55 60 01 60 00 F3 + // + // Factory bytecode: + // PUSH10 PUSH1 0x00 MSTORE -- store init code in memory + // PUSH1 0x0A PUSH1 0x16 PUSH1 0x00 CREATE -- create child + // STOP + // + // The factory stores the 10-byte init code at memory offset 0 (right-padded in the 32-byte word), + // then calls CREATE with offset=22 (0x16), size=10 (0x0A) to deploy the child. + let init_code: [u8; 10] = [0x60, 0x42, 0x60, 0x00, 0x55, 0x60, 0x01, 0x60, 0x00, 0xF3]; + let mut factory_bytecode = vec![0x69]; // PUSH10 + factory_bytecode.extend_from_slice(&init_code); + factory_bytecode.extend_from_slice(&[ + 0x60, 0x00, // PUSH1 0x00 + 0x52, // MSTORE (stores at offset 0, 32 bytes, init_code is right-padded) + 0x60, 0x0A, // PUSH1 0x0A (size = 10) + 0x60, 0x16, // PUSH1 0x16 (offset = 22, since MSTORE pads left) + 0x60, 0x00, // PUSH1 0x00 (value = 0) + 0xF0, // CREATE + 0x00, // STOP + ]); + + let factory_addr = Address::from_low_u64_be(0xF000); + + let mut accounts = FxHashMap::default(); + accounts.insert( + factory_addr, + Account::new( + U256::zero(), + Code::from_bytecode(Bytes::from(factory_bytecode), &NativeCrypto), + 1, + FxHashMap::default(), + ), + ); + accounts.insert( + sender_addr, + Account::new( + U256::from(10u64) * U256::from(10u64).pow(U256::from(18)), + Code::default(), + 0, + FxHashMap::default(), + ), + ); + + let test_db = TestDatabase { accounts }; + let mut db = GeneralizedDatabase::new(Arc::new(test_db)); + let header = default_header(); + + // Call the factory — creates a child contract + let tx = { + let inner = EIP1559Transaction { + chain_id: 1, + nonce: 0, + max_priority_fee_per_gas: 1, + max_fee_per_gas: 10, + gas_limit: 500_000, + to: TxKind::Call(factory_addr), + value: U256::zero(), + data: Bytes::new(), + access_list: vec![], + signature_y_parity: false, + signature_r: U256::one(), + signature_s: U256::one(), + inner_hash: OnceCell::new(), + sender_cache: { + let cell = OnceCell::new(); + let _ = cell.set(sender_addr); + cell + }, + cached_canonical: OnceCell::new(), + }; + Transaction::EIP1559Transaction(inner) + }; + + let result = LEVM::trace_tx_prestate(&mut db, &header, &tx, true, VMType::L1, &NativeCrypto) + .expect("trace should succeed"); + + let diff = match result { + PrestateResult::Diff(d) => d, + PrestateResult::Prestate(_) => panic!("expected Diff variant"), + }; + + // The factory's nonce should be incremented (it called CREATE) + let factory_post = diff.post.get(&factory_addr).expect("factory in post"); + assert_eq!( + factory_post.nonce, 2, + "factory nonce should be 2 after CREATE (started at 1)" + ); + + // The child account should appear in both pre (empty state) and post (created state). + // Find it as the address with nonce=0 in pre and nonce=1 in post that isn't sender/factory/coinbase. + let known_addrs = [sender_addr, factory_addr, header.coinbase]; + let child_addr = diff + .post + .keys() + .find(|addr| !known_addrs.contains(addr)) + .expect("child contract should appear in post state"); + + let child_pre = diff + .pre + .get(child_addr) + .expect("child should appear in pre (empty state before creation)"); + assert_eq!(child_pre.nonce, 0, "child pre-state nonce should be 0"); + assert_eq!( + child_pre.balance, + U256::zero(), + "child pre-state balance should be 0" + ); + + let child_post = diff + .post + .get(child_addr) + .expect("child should appear in post"); + assert_eq!( + child_post.nonce, 1, + "child post-state nonce should be 1 after creation" + ); +} diff --git a/test/tests/levm/test_db.rs b/test/tests/levm/test_db.rs new file mode 100644 index 00000000000..3e37c003d20 --- /dev/null +++ b/test/tests/levm/test_db.rs @@ -0,0 +1,74 @@ +use ethrex_common::{ + Address, H256, U256, + constants::EMPTY_TRIE_HASH, + types::{Account, AccountState, ChainConfig, Code, CodeMetadata}, +}; +use ethrex_levm::{db::Database, errors::DatabaseError}; +use rustc_hash::FxHashMap; + +/// Lightweight in-memory database for VM tests. +/// +/// Stores accounts in a `FxHashMap` and implements the +/// `Database` trait so it can back a `GeneralizedDatabase`. +pub struct TestDatabase { + pub accounts: FxHashMap, +} + +impl TestDatabase { + pub fn new() -> Self { + Self { + accounts: FxHashMap::default(), + } + } +} + +impl Database for TestDatabase { + fn get_account_state(&self, address: Address) -> Result { + Ok(self + .accounts + .get(&address) + .map(|acc| AccountState { + nonce: acc.info.nonce, + balance: acc.info.balance, + storage_root: *EMPTY_TRIE_HASH, + code_hash: acc.info.code_hash, + }) + .unwrap_or_default()) + } + + fn get_storage_value(&self, address: Address, key: H256) -> Result { + Ok(self + .accounts + .get(&address) + .and_then(|acc| acc.storage.get(&key).copied()) + .unwrap_or_default()) + } + + fn get_block_hash(&self, _block_number: u64) -> Result { + Ok(H256::zero()) + } + + fn get_chain_config(&self) -> Result { + Ok(ChainConfig::default()) + } + + fn get_account_code(&self, code_hash: H256) -> Result { + for acc in self.accounts.values() { + if acc.info.code_hash == code_hash { + return Ok(acc.code.clone()); + } + } + Ok(Code::default()) + } + + fn get_code_metadata(&self, code_hash: H256) -> Result { + for acc in self.accounts.values() { + if acc.info.code_hash == code_hash { + return Ok(CodeMetadata { + length: acc.code.bytecode.len() as u64, + }); + } + } + Ok(CodeMetadata { length: 0 }) + } +}