From 5e63d3024b0bd02ca41ac07d7a5f18d5cf111c29 Mon Sep 17 00:00:00 2001 From: Dat Nguyen Date: Thu, 4 Jun 2026 16:36:31 +0700 Subject: [PATCH] feat: collision-resistant memory-location hashing via seed + u128 key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The u64 MemoryLocationHash doubles as both the MvMemory map key and the location's identity. Two distinct locations that collide in the u64 share an entry, so a crafted (FxHash is invertible) or random (birthday ~2^32) collision can make a read return another location's value and validate cleanly — wrong state. Fixed with two independent levers: - Widen MemoryLocationHash to u128 (built from two decorrelated FxHash passes). The map's own 128-bit key comparison becomes a reliable identity check, so random collisions drop to the ~2^-64 birthday bound (effectively never). The IdentityHasher projects the key to its low 64 bits for bucketing, so sharding cost is unchanged. - Mix a per-execution random seed into the hash. Unknown to submitters until the block runs, so collisions cannot be aimed at this block. The seed never escapes into results (state is keyed by real addresses), so output is identical for any seed — verified by the full test suite. Co-Authored-By: Claude Opus 4.8 --- crates/pevm/src/chain.rs | 13 +++-- crates/pevm/src/chain/ethereum.rs | 14 ++++-- crates/pevm/src/chain/rise.rs | 83 +++++++++++++++---------------- crates/pevm/src/lib.rs | 27 ++++++++-- crates/pevm/src/mv_memory.rs | 5 ++ crates/pevm/src/pevm.rs | 11 +++- crates/pevm/src/vm.rs | 31 +++++++----- 7 files changed, 111 insertions(+), 73 deletions(-) diff --git a/crates/pevm/src/chain.rs b/crates/pevm/src/chain.rs index 3045212b..4b67b332 100644 --- a/crates/pevm/src/chain.rs +++ b/crates/pevm/src/chain.rs @@ -139,18 +139,21 @@ pub trait PevmChain: Debug { true } - /// Build [`MvMemory`] - fn build_mv_memory(&self, _block_env: &BlockEnv, txs: &[Self::EvmTx]) -> MvMemory { - MvMemory::new(txs.len(), [], []) + /// Build [`MvMemory`]. `seed` is the per-execution random seed that must be + /// mixed into every location hash & tag (see [`crate::hash_deterministic`]). + fn build_mv_memory(&self, _block_env: &BlockEnv, txs: &[Self::EvmTx], seed: u64) -> MvMemory { + MvMemory::new(seed, txs.len(), [], []) } - /// Get rewards (balance increments) to beneficiary accounts, etc. + /// Get rewards (balance increments) to beneficiary accounts, etc. `seed` is + /// needed to derive the hash of any chain-specific fee recipients. fn get_rewards( &self, - beneficiary_location_hash: u64, + beneficiary_location_hash: MemoryLocationHash, gas_used: U256, gas_price: U256, basefee: u64, + seed: u64, tx: &Self::EvmTx, ) -> SmallVec<[(MemoryLocationHash, U256); 1]>; diff --git a/crates/pevm/src/chain/ethereum.rs b/crates/pevm/src/chain/ethereum.rs index b2d4e824..adeea62d 100644 --- a/crates/pevm/src/chain/ethereum.rs +++ b/crates/pevm/src/chain/ethereum.rs @@ -163,10 +163,10 @@ impl PevmChain for PevmEthereum { tx } - fn build_mv_memory(&self, block_env: &BlockEnv, txs: &[TxEnv]) -> MvMemory { + fn build_mv_memory(&self, block_env: &BlockEnv, txs: &[TxEnv], seed: u64) -> MvMemory { let block_size = txs.len(); let beneficiary_location_hash = - hash_deterministic(MemoryLocation::Basic(block_env.beneficiary)); + hash_deterministic(MemoryLocation::Basic(block_env.beneficiary), seed); // TODO: Estimate more locations based on sender, to, etc. let mut estimated_locations = HashMap::with_hasher(BuildIdentityHasher::default()); @@ -175,15 +175,21 @@ impl PevmChain for PevmEthereum { (0..block_size).collect::>(), ); - MvMemory::new(block_size, estimated_locations, [block_env.beneficiary]) + MvMemory::new( + seed, + block_size, + estimated_locations, + [block_env.beneficiary], + ) } fn get_rewards( &self, - beneficiary_location_hash: u64, + beneficiary_location_hash: MemoryLocationHash, gas_used: U256, gas_price: U256, _: u64, + _: u64, _: &Self::EvmTx, ) -> SmallVec<[(MemoryLocationHash, U256); 1]> { smallvec::smallvec![( diff --git a/crates/pevm/src/chain/rise.rs b/crates/pevm/src/chain/rise.rs index a84b69c4..ef9c246f 100644 --- a/crates/pevm/src/chain/rise.rs +++ b/crates/pevm/src/chain/rise.rs @@ -1,6 +1,4 @@ //! RISE -use std::sync::LazyLock; - use alloy_consensus::Transaction; use alloy_primitives::{Address, B256, ChainId, U256}; use alloy_rpc_types_eth::{BlockTransactions, Header}; @@ -30,14 +28,13 @@ use super::{CalculateReceiptRootError, PevmChain}; const RISE_CHAIN_ID: ChainId = 4153; // Mainnet -static BASE_FEE_RECIPIENT_LOCATION_HASH: LazyLock = - LazyLock::new(|| hash_deterministic(MemoryLocation::Basic(BASE_FEE_RECIPIENT))); - -static L1_FEE_RECIPIENT_LOCATION_HASH: LazyLock = - LazyLock::new(|| hash_deterministic(MemoryLocation::Basic(L1_FEE_RECIPIENT))); - -static OPERATOR_FEE_RECIPIENT_LOCATION_HASH: LazyLock = - LazyLock::new(|| hash_deterministic(MemoryLocation::Basic(OPERATOR_FEE_RECIPIENT))); +// Hash a basic-account location under the per-block seed. The fee recipients are +// fixed addresses, but a per-execution seed means their hashes cannot be +// precomputed once, so we derive them per block here. +#[inline] +fn basic_location_hash(address: Address, seed: u64) -> MemoryLocationHash { + hash_deterministic(MemoryLocation::Basic(address), seed) +} /// Implementation of [`PevmChain`] for RISE #[derive(Debug, Clone, PartialEq, Eq)] @@ -105,55 +102,50 @@ impl PevmChain for PevmRise { .build_op() } - fn build_mv_memory(&self, block_env: &BlockEnv, txs: &[OpTransaction]) -> MvMemory { - let beneficiary_location_hash = - hash_deterministic(MemoryLocation::Basic(block_env.beneficiary)); + fn build_mv_memory( + &self, + block_env: &BlockEnv, + txs: &[OpTransaction], + seed: u64, + ) -> MvMemory { + // The beneficiary and the three fee recipients are written by every + // non-deposit transaction, so we pre-seed them as estimates. Their hashes + // only depend on the static addresses and the block-level seed, so we + // compute them once instead of per transaction. + let recipients = [ + block_env.beneficiary, + BASE_FEE_RECIPIENT, + L1_FEE_RECIPIENT, + OPERATOR_FEE_RECIPIENT, + ]; + let recipient_hashes = recipients.map(|address| basic_location_hash(address, seed)); // TODO: Estimate more locations based on sender, to, etc. // TODO: Benchmark to check whether adding these estimated // locations helps or harms the performance. let mut estimated_locations = HashMap::with_hasher(BuildIdentityHasher::default()); - for (index, tx) in txs.iter().enumerate() { // Deposit transactions pay no fees so they write nothing to these fee recipients. if !tx.is_deposit() { - estimated_locations - .entry(beneficiary_location_hash) - .or_insert_with(|| Vec::with_capacity(txs.len())) - .push(index); - estimated_locations - .entry(*BASE_FEE_RECIPIENT_LOCATION_HASH) - .or_insert_with(|| Vec::with_capacity(txs.len())) - .push(index); - estimated_locations - .entry(*L1_FEE_RECIPIENT_LOCATION_HASH) - .or_insert_with(|| Vec::with_capacity(txs.len())) - .push(index); - estimated_locations - .entry(*OPERATOR_FEE_RECIPIENT_LOCATION_HASH) - .or_insert_with(|| Vec::with_capacity(txs.len())) - .push(index); + for &location_hash in &recipient_hashes { + estimated_locations + .entry(location_hash) + .or_insert_with(|| Vec::with_capacity(txs.len())) + .push(index); + } } } - MvMemory::new( - txs.len(), - estimated_locations, - [ - block_env.beneficiary, - BASE_FEE_RECIPIENT, - L1_FEE_RECIPIENT, - OPERATOR_FEE_RECIPIENT, - ], - ) + MvMemory::new(seed, txs.len(), estimated_locations, recipients) } fn get_rewards( &self, - beneficiary_location_hash: u64, + beneficiary_location_hash: MemoryLocationHash, gas_used: U256, gas_price: U256, basefee: u64, + seed: u64, tx: &Self::EvmTx, ) -> SmallVec<[(MemoryLocationHash, U256); 1]> { if tx.is_deposit() { @@ -165,14 +157,17 @@ impl PevmChain for PevmRise { gas_price.saturating_mul(gas_used) ), ( - *BASE_FEE_RECIPIENT_LOCATION_HASH, + basic_location_hash(BASE_FEE_RECIPIENT, seed), U256::from(basefee).saturating_mul(gas_used), ), // RISE disables DA footprint and operator fees. Annoyingly, we still // need to touch these to match revm's sequential execution for now. // Will remove once we rewrite our own EVM implementation. - (*L1_FEE_RECIPIENT_LOCATION_HASH, U256::ZERO), - (*OPERATOR_FEE_RECIPIENT_LOCATION_HASH, U256::ZERO), + (basic_location_hash(L1_FEE_RECIPIENT, seed), U256::ZERO), + ( + basic_location_hash(OPERATOR_FEE_RECIPIENT, seed), + U256::ZERO + ), ] } } diff --git a/crates/pevm/src/lib.rs b/crates/pevm/src/lib.rs index b97af51b..d2cbe4ea 100644 --- a/crates/pevm/src/lib.rs +++ b/crates/pevm/src/lib.rs @@ -41,7 +41,13 @@ enum MemoryLocation { // We then identify the locations with its hash in the multi-version // data, write and read sets, which is much faster than rehashing // on every single lookup & validation. -type MemoryLocationHash = u64; +// +// This is 128 bits wide so that distinct locations practically never collide +// (birthday bound ~2^64). Because this hash is also the key of the multi-version +// data, a collision would silently merge two locations into one entry; a 128-bit +// key makes the map's own key comparison a reliable identity check. See +// [hash_deterministic] for how the two halves are derived. +type MemoryLocationHash = u128; /// This is primarily used for memory location hash, but can also be used for /// transaction indexes, etc. @@ -51,6 +57,11 @@ impl Hasher for IdentityHasher { fn write_u64(&mut self, id: u64) { self.0 = id; } + fn write_u128(&mut self, id: u128) { + // Use the low 64 bits as the bucket hash. The full 128-bit key is still + // compared for equality by the map, so this only affects bucket spread. + self.0 = id as u64; + } fn write_usize(&mut self, id: usize) { self.0 = id as u64; } @@ -65,11 +76,17 @@ impl Hasher for IdentityHasher { /// Build an identity hasher pub type BuildIdentityHasher = BuildHasherDefault; -// TODO: Ensure it's not easy to hand-craft transactions and storage slots -// that can cause a lot of collisions that destroys pevm's performance. +// Hash a memory location into a 128-bit value, mixed with a per-execution random +// [seed]. The result is built from two FxHash passes over the same location with +// decorrelated seeds (`seed` and `!seed`) forming the low and high 64 bits, so the +// two halves collide independently and the full 128-bit value only collides at +// ~2^-128. The seed also makes collisions impossible for an attacker to aim at, as +// it is unknown until the block runs. #[inline(always)] -fn hash_deterministic(x: T) -> u64 { - FxBuildHasher.hash_one(x) +fn hash_deterministic(x: T, seed: u64) -> MemoryLocationHash { + let low = FxBuildHasher.hash_one((seed, &x)); + let high = FxBuildHasher.hash_one((!seed, &x)); + (u128::from(high) << 64) | u128::from(low) } // TODO: It would be nice if we could tie the different cases of diff --git a/crates/pevm/src/mv_memory.rs b/crates/pevm/src/mv_memory.rs index e4e54253..34b181fc 100644 --- a/crates/pevm/src/mv_memory.rs +++ b/crates/pevm/src/mv_memory.rs @@ -39,10 +39,14 @@ pub struct MvMemory { lazy_addresses: Mutex, /// New bytecodes deployed in this block pub(crate) new_bytecodes: DashMap, + /// Per-execution random seed mixed into every location hash & tag. Unknown to + /// transaction submitters until the block runs, so collisions cannot be aimed at. + pub(crate) seed: u64, } impl MvMemory { pub(crate) fn new( + seed: u64, block_size: usize, estimated_locations: impl IntoIterator)>, lazy_addresses: impl IntoIterator, @@ -70,6 +74,7 @@ impl MvMemory { // TODO: Fine-tune the number of shards, like to the next number of two from the // number of worker threads. new_bytecodes: DashMap::default(), + seed, } } diff --git a/crates/pevm/src/pevm.rs b/crates/pevm/src/pevm.rs index 6489a441..e723df99 100644 --- a/crates/pevm/src/pevm.rs +++ b/crates/pevm/src/pevm.rs @@ -1,6 +1,7 @@ use std::{ cell::UnsafeCell, fmt::Debug, + hash::{BuildHasher, RandomState}, num::NonZeroUsize, sync::{OnceLock, mpsc}, thread, @@ -224,7 +225,13 @@ impl Pevm { let block_size = txs.len(); let scheduler = Scheduler::new(block_size); - let mv_memory = chain.build_mv_memory(&block_env, &txs); + // A per-execution random seed mixed into every location hash & tag. It is + // unknown to transaction submitters until the block runs, so hash collisions + // cannot be crafted to target this block. The seed never escapes into the + // results (state is keyed by real addresses), so output is identical for any + // seed. We derive a u64 from the OS-seeded [RandomState] to avoid a dependency. + let seed = RandomState::new().hash_one(0u8); + let mv_memory = chain.build_mv_memory(&block_env, &txs, seed); self.execution_results.grow_to(block_size); @@ -288,7 +295,7 @@ impl Pevm { // We fully evaluate (the balance and nonce of) the beneficiary account // and raw transfer recipients that may have been atomically updated. for address in mv_memory.consume_lazy_addresses() { - let location_hash = hash_deterministic(MemoryLocation::Basic(address)); + let location_hash = hash_deterministic(MemoryLocation::Basic(address), mv_memory.seed); if let Some(write_history) = mv_memory.data.get(&location_hash) { let mut balance = U256::ZERO; let mut nonce = 0; diff --git a/crates/pevm/src/vm.rs b/crates/pevm/src/vm.rs index ebf4a56d..8dd432fa 100644 --- a/crates/pevm/src/vm.rs +++ b/crates/pevm/src/vm.rs @@ -182,7 +182,7 @@ impl<'a, S: Storage> VmDb<'a, S> { { return self.to_hash.unwrap(); } - hash_deterministic(MemoryLocation::Basic(*address)) + hash_deterministic(MemoryLocation::Basic(*address), self.mv_memory.seed) } // Push a new read origin. Return an error when there's already @@ -199,7 +199,8 @@ impl<'a, S: Storage> VmDb<'a, S> { } fn get_code_hash(&mut self, address: Address) -> Result, ReadError> { - let location_hash = hash_deterministic(MemoryLocation::CodeHash(address)); + let location_hash = + hash_deterministic(MemoryLocation::CodeHash(address), self.mv_memory.seed); let read_origins = self.read_set.entry(location_hash).or_default(); // Try to read the latest code hash in [MvMemory] @@ -427,7 +428,8 @@ impl Database for VmDb<'_, S> { } fn storage(&mut self, address: Address, index: U256) -> Result { - let location_hash = hash_deterministic(MemoryLocation::Storage(address, index)); + let location_hash = + hash_deterministic(MemoryLocation::Storage(address, index), self.mv_memory.seed); let read_origins = self.read_set.entry(location_hash).or_default(); @@ -513,9 +515,10 @@ impl<'a, S: Storage, C: PevmChain> Vm<'a, S, C> { block_env, txs, mv_memory, - beneficiary_location_hash: hash_deterministic(MemoryLocation::Basic( - block_env.beneficiary, - )), + beneficiary_location_hash: hash_deterministic( + MemoryLocation::Basic(block_env.beneficiary), + mv_memory.seed, + ), evm: chain.build_evm(spec_id, block_env.clone(), db), } } @@ -545,11 +548,12 @@ impl<'a, S: Storage, C: PevmChain> Vm<'a, S, C> { let full_tx = unsafe { self.txs.get_unchecked(tx_version.tx_idx) }; let tx = self.chain.tx_env(full_tx); - let from_hash = hash_deterministic(MemoryLocation::Basic(tx.caller)); + let seed = self.mv_memory.seed; + let from_hash = hash_deterministic(MemoryLocation::Basic(tx.caller), seed); let to_hash = tx .kind .to() - .map(|to| hash_deterministic(MemoryLocation::Basic(*to))); + .map(|to| hash_deterministic(MemoryLocation::Basic(*to), seed)); let has_nonce = self.chain.has_nonce(&mut self.evm, full_tx); @@ -584,7 +588,7 @@ impl<'a, S: Storage, C: PevmChain> Vm<'a, S, C> { // For now we are betting on [code_hash] triggering the sequential // fallback when we read a self-destructed contract. write_set.push(( - hash_deterministic(MemoryLocation::CodeHash(*address)), + hash_deterministic(MemoryLocation::CodeHash(*address), seed), MemoryValue::SelfDestructed, )); continue; @@ -592,7 +596,7 @@ impl<'a, S: Storage, C: PevmChain> Vm<'a, S, C> { if account.is_touched() { let account_location_hash = - hash_deterministic(MemoryLocation::Basic(*address)); + hash_deterministic(MemoryLocation::Basic(*address), seed); let read_account = ctx.db().read_accounts.get(&account_location_hash); let has_code = !account.info.is_empty_code_hash(); @@ -642,7 +646,7 @@ impl<'a, S: Storage, C: PevmChain> Vm<'a, S, C> { // Write new contract if is_new_code { write_set.push(( - hash_deterministic(MemoryLocation::CodeHash(*address)), + hash_deterministic(MemoryLocation::CodeHash(*address), seed), MemoryValue::CodeHash(account.info.code_hash), )); self.mv_memory @@ -655,7 +659,7 @@ impl<'a, S: Storage, C: PevmChain> Vm<'a, S, C> { // TODO: We should move this changed check to our read set like for account info? for (slot, value) in account.changed_storage_slots() { write_set.push(( - hash_deterministic(MemoryLocation::Storage(*address, *slot)), + hash_deterministic(MemoryLocation::Storage(*address, *slot), seed), MemoryValue::Storage(value.present_value), )); } @@ -678,12 +682,13 @@ impl<'a, S: Storage, C: PevmChain> Vm<'a, S, C> { U256::from(exec_result.tx_gas_used()), U256::from(gas_price), self.block_env.basefee, + seed, full_tx, ); for (recipient, amount) in rewards { if let Some((_, value)) = write_set .iter_mut() - .find(|(location, _)| location == &recipient) + .find(|(location, _)| *location == recipient) { match value { MemoryValue::Basic(basic) => {