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) => {