From 87a30089034bce157d707f8f342562f0d55ed44a Mon Sep 17 00:00:00 2001 From: Baptiste Oueriagli Date: Thu, 24 Sep 2026 08:43:45 +0000 Subject: [PATCH] backport(paradigmxyz/reth#27252): fix(trie): state root task persists EIP-161-empty accounts into HashedAccounts --- crates/trie/parallel/src/state_root_task.rs | 200 +++++++++++++++++++- 1 file changed, 196 insertions(+), 4 deletions(-) diff --git a/crates/trie/parallel/src/state_root_task.rs b/crates/trie/parallel/src/state_root_task.rs index eabc3770649..8a50b4407e6 100644 --- a/crates/trie/parallel/src/state_root_task.rs +++ b/crates/trie/parallel/src/state_root_task.rs @@ -501,9 +501,19 @@ pub fn evm_state_to_hashed_post_state(update: EvmState) -> HashedPostState { trace!(target: "trie::parallel::sparse", ?address, ?hashed_address, "Adding account to state update"); let destroyed = account.is_selfdestructed(); - if account.info != account.original_info() { - let info = if destroyed { None } else { Some(account.info.into()) }; - hashed_state.accounts.insert(hashed_address, info); + // EIP-161: a touched account that ends up empty is deleted, so it must be emitted + // as a removal rather than as an all-zero account. This mirrors what revm does in + // the bundle path (`CacheAccount::touch_empty_eip161`) and what the sibling + // producer for this consumer already does in `send_bal_hashed_state`. + // An address that never existed and still does not exist is not a deletion: revm + // emits no transition for `LoadedNotExisting`. Skipping it matches the bundle + // producer; every `None` here becomes a storage-trie cursor walk in `StateRoot`. + let deleted = destroyed || (account.is_empty() && !account.is_loaded_as_not_existing()); + if deleted { + hashed_state.accounts.insert(hashed_address, None); + } else if account.info != account.original_info() { + // A touched but unchanged account produces no bundle transition either. + hashed_state.accounts.insert(hashed_address, Some(account.info.into())); } let mut changed_storage_iter = account @@ -537,7 +547,7 @@ mod tests { #[test] fn created_selfdestruct_does_not_emit_storage() { let address = Address::repeat_byte(0x01); - let mut account = Account::default(); + let mut account = Account::new_not_existing(TransactionId::ZERO); account.mark_touch(); assert!(account.mark_created_locally()); assert!(account.mark_selfdestructed_locally()); @@ -577,6 +587,188 @@ mod tests { assert!(!hashed_state.storages.contains_key(&hashed_address)); } + /// An account drained to zero balance, with nonce 0 and no code, is EIP-161-empty and + /// canonical execution deletes it. Asserts the converter reports the deletion as `None`, + /// matching `HashedPostState::from_bundle_state`, so `write_hashed_state` removes the + /// `HashedAccounts` row instead of upserting an all-zero one. + #[test] + fn emptied_account_is_deleted() { + let address = Address::repeat_byte(0x05); + let mut account = Account::default(); + // Pre-state: the account exists and holds a balance. + account.info.balance = U256::from(1); + account.set_current_info_as_original(); + // This block drains it. Not selfdestructed: an ordinary value transfer out. + account.mark_touch(); + account.info.balance = U256::ZERO; + assert!(account.is_empty(), "the drained account must be EIP-161-empty"); + assert!(!account.is_selfdestructed()); + + let hashed_state = + evm_state_to_hashed_post_state(EvmState::from_iter([(address, account)])); + + assert_eq!(hashed_state.accounts.get(&keccak256(address)), Some(&None)); + } + + /// A pre-existing EIP-161-empty account that is merely touched must be deleted. revm marks it + /// for removal (`touch_empty_eip161`) and the `BundleState` path reports `None`. Asserts the + /// converter emits the deletion even though `info == original_info()`, so an existing all-zero + /// `HashedAccounts` row can be cleared by a touch. + #[test] + fn touched_preexisting_empty_account_is_deleted() { + let address = Address::repeat_byte(0x06); + // Empty in the pre-state too: nonce 0, balance 0, no code. + let mut account = Account::default(); + account.set_current_info_as_original(); + account.mark_touch(); + assert!(account.is_empty()); + + let hashed_state = + evm_state_to_hashed_post_state(EvmState::from_iter([(address, account)])); + + assert_eq!(hashed_state.accounts.get(&keccak256(address)), Some(&None)); + } + + /// The two `HashedPostState` producers that feed the same consumer must agree. + /// + /// `evm_state_to_hashed_post_state` converts the raw `EvmState` handed to the state hook; + /// `HashedPostState::from_bundle_state` converts the `BundleState` revm produces from the + /// same execution. Since "perf: avoid hashing the state twice" the engine persists whichever + /// one it gets, so a disagreement between them is a disagreement about durable state. + /// + /// Asserts both producers agree for a touched account drained to EIP-161-empty, which revm + /// marks for removal in the bundle path (`CacheAccount::touch_empty_eip161`). + #[test] + fn matches_bundle_state_for_emptied_account() { + use revm::{ + database::{states::bundle_state::BundleRetention, State}, + state::AccountInfo, + DatabaseCommit, + }; + + let address = Address::repeat_byte(0x07); + let pre = AccountInfo { balance: U256::from(1), ..Default::default() }; + + // The EvmState the state hook observes: a funded account drained to empty. + let mut account = Account::from(pre.clone()); + account.mark_touch(); + account.info.balance = U256::ZERO; + let evm_state = EvmState::from_iter([(address, account)]); + + // Same execution, through revm's own bundle machinery. + let mut db = State::builder().with_bundle_update().build(); + db.insert_account(address, pre); + db.commit(evm_state.clone()); + db.merge_transitions(BundleRetention::PlainState); + let bundle = db.take_bundle(); + + let from_bundle = + HashedPostState::from_bundle_state::(bundle.state.iter()); + let from_hook = evm_state_to_hashed_post_state(evm_state); + + assert_eq!( + from_hook.accounts, from_bundle.accounts, + "state-hook and bundle producers disagree about durable account state" + ); + } + + /// An account created during the block whose final info is empty. EIP-161 deletes it, so + /// the bundle producer reports a removal. + #[test] + fn created_empty_account_matches_bundle_state() { + use revm::{ + database::{states::bundle_state::BundleRetention, State}, + DatabaseCommit, + }; + + let address = Address::repeat_byte(0x08); + let mut account = Account::default(); + account.mark_touch(); + assert!(account.mark_created_locally()); + assert!(account.is_empty()); + let evm_state = EvmState::from_iter([(address, account)]); + + let mut db = State::builder().with_bundle_update().build(); + db.commit(evm_state.clone()); + db.merge_transitions(BundleRetention::PlainState); + let bundle = db.take_bundle(); + + let from_bundle = + HashedPostState::from_bundle_state::(bundle.state.iter()); + let from_hook = evm_state_to_hashed_post_state(evm_state); + + assert_eq!( + from_hook.accounts.get(&keccak256(address)).copied().flatten(), + from_bundle.accounts.get(&keccak256(address)).copied().flatten(), + "state-hook and bundle producers disagree about a created-empty account" + ); + } + + /// A zero-value call to an address that never existed leaves it non-existent. revm's bundle + /// path emits no transition for it (`CacheAccount::touch_empty_eip161` returns `None` for + /// `LoadedNotExisting`). Asserts the converter also emits nothing, rather than a deletion + /// that would queue a `HashedAccounts` delete and a storage-trie wipe for an unused address. + #[test] + fn touched_never_existing_account_matches_bundle_state() { + use revm::{ + database::{states::bundle_state::BundleRetention, State}, + DatabaseCommit, + }; + + let address = Address::repeat_byte(0x09); + let mut account = Account::new_not_existing(TransactionId::default()); + account.mark_touch(); + let evm_state = EvmState::from_iter([(address, account)]); + + let mut db = State::builder().with_bundle_update().build(); + db.commit(evm_state.clone()); + db.merge_transitions(BundleRetention::PlainState); + let bundle = db.take_bundle(); + + let from_bundle = + HashedPostState::from_bundle_state::(bundle.state.iter()); + let from_hook = evm_state_to_hashed_post_state(evm_state); + + assert_eq!( + from_hook.accounts, from_bundle.accounts, + "state-hook and bundle producers disagree about a never-existing touched account" + ); + } + + /// An account touched without being changed. revm's bundle producer reports nothing for it. + /// Asserts the converter agrees, so a block full of zero-value calls does not rewrite + /// unchanged `HashedAccounts` rows. + #[test] + fn touched_unchanged_account_matches_bundle_state() { + use revm::{ + database::{states::bundle_state::BundleRetention, State}, + state::AccountInfo, + DatabaseCommit, + }; + + let address = Address::repeat_byte(0x0a); + let pre = AccountInfo { balance: U256::from(7), nonce: 1, ..Default::default() }; + let mut account = Account::from(pre.clone()); + account.mark_touch(); + assert!(!account.is_empty()); + let evm_state = EvmState::from_iter([(address, account)]); + + let mut db = State::builder().with_bundle_update().build(); + db.insert_account(address, pre); + db.commit(evm_state.clone()); + db.merge_transitions(BundleRetention::PlainState); + let bundle = db.take_bundle(); + + let from_bundle = + HashedPostState::from_bundle_state::(bundle.state.iter()); + let from_hook = evm_state_to_hashed_post_state(evm_state); + + assert_eq!( + from_hook.accounts, from_bundle.accounts, + "state-hook and bundle producers disagree about a touched but unchanged account" + ); + } + #[derive(Default)] struct CountingSink { access_hints: AtomicUsize,