diff --git a/bin/reth-bench/src/bench/generate_big_block.rs b/bin/reth-bench/src/bench/generate_big_block.rs index 6c025770570..5826cce7712 100644 --- a/bin/reth-bench/src/bench/generate_big_block.rs +++ b/bin/reth-bench/src/bench/generate_big_block.rs @@ -29,7 +29,10 @@ use reth_ethereum_cli::chainspec::EthereumChainSpecParser; use reth_ethereum_primitives::Receipt; use reth_primitives_traits::proofs; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, future::Future}; +use std::{ + collections::{HashMap, HashSet}, + future::Future, +}; use tracing::{info, warn}; use crate::bench::helpers::fetch_block_access_list; @@ -717,6 +720,17 @@ fn merge_account_changes(existing: &mut AccountChanges, incoming: AccountChanges existing.balance_changes.extend(incoming.balance_changes); existing.nonce_changes.extend(incoming.nonce_changes); existing.code_changes.extend(incoming.code_changes); + + // EIP-7928 invariant: a slot must appear in either storage_changes or storage_reads, + // not both. Per-block BALs respect this, but merging blocks can produce a slot + // that is read in one block and changed in another. Without this normalization, + // an empty read entry can shadow the real writes during BAL deserialization, + // making reads of that slot fall through to stale snapshot state. + let written: HashSet<_> = + existing.storage_changes.iter().map(|slot_changes| slot_changes.slot).collect(); + existing.storage_reads.retain(|slot| !written.contains(slot)); + let mut seen = HashSet::with_capacity(existing.storage_reads.len()); + existing.storage_reads.retain(|slot| seen.insert(*slot)); } fn merge_slot_changes(existing: &mut Vec, incoming: Vec) { @@ -836,4 +850,54 @@ mod tests { assert_eq!(other.address, Address::repeat_byte(0x22)); assert_eq!(other.storage_changes[0].changes[0].block_access_index, 3); } + + #[test] + fn merge_account_changes_normalizes_storage_reads_after_cross_block_merge() { + let address = Address::repeat_byte(0x33); + const A: U256 = U256::from_limbs([1, 0, 0, 0]); + const B: U256 = U256::from_limbs([2, 0, 0, 0]); + const C: U256 = U256::from_limbs([3, 0, 0, 0]); + const D: U256 = U256::from_limbs([4, 0, 0, 0]); + + // Each AccountChanges value is valid on its own: storage slots only appear in + // either reads or changes. The invalid read/change overlap is introduced when + // these per-block BAL entries are merged for a standalone big block. + let mut existing = AccountChanges { + address, + storage_changes: vec![SlotChanges::new(A, vec![StorageChange::new(0, U256::from(10))])], + storage_reads: vec![B, C], + balance_changes: vec![], + nonce_changes: vec![], + code_changes: vec![], + }; + + // B is read before it is written by the incoming block, and A is written before + // it appears as a read in the incoming block. C is read in both blocks, so the + // merge should also dedupe it. D remains read-only. + let incoming = AccountChanges { + address, + storage_changes: vec![SlotChanges::new(B, vec![StorageChange::new(1, U256::from(20))])], + storage_reads: vec![A, C, D], + balance_changes: vec![], + nonce_changes: vec![], + code_changes: vec![], + }; + + merge_account_changes(&mut existing, incoming); + + // Written slots remain represented by storage_changes, while storage_reads only + // keeps unique read-only slots in first-seen order. + assert_eq!( + existing + .storage_changes + .iter() + .map(|slot_changes| slot_changes.slot) + .collect::>(), + vec![A, B] + ); + assert_eq!(existing.storage_reads, vec![C, D]); + assert!(existing.storage_reads.iter().all(|read_slot| { + !existing.storage_changes.iter().any(|slot_changes| slot_changes.slot == *read_slot) + })); + } }