diff --git a/crates/sim/Cargo.toml b/crates/sim/Cargo.toml index 6cdab05..4bd8599 100644 --- a/crates/sim/Cargo.toml +++ b/crates/sim/Cargo.toml @@ -20,6 +20,10 @@ alloy.workspace = true tokio.workspace = true tracing.workspace = true trevm.workspace = true +thiserror.workspace = true + +parking_lot.workspace = true [dev-dependencies] tracing-subscriber.workspace = true +alloy = { workspace = true, features = ["getrandom"] } \ No newline at end of file diff --git a/crates/sim/src/cache.rs b/crates/sim/src/cache.rs index fdaf1bc..e81d17c 100644 --- a/crates/sim/src/cache.rs +++ b/crates/sim/src/cache.rs @@ -1,8 +1,11 @@ -use crate::SimItem; +use crate::{item::SimIdentifier, CacheError, SimItem}; +use alloy::consensus::TxEnvelope; use core::fmt; +use parking_lot::RwLock; +use signet_bundle::SignetEthBundle; use std::{ - collections::BTreeMap, - sync::{Arc, RwLock, RwLockWriteGuard}, + collections::{BTreeMap, HashSet}, + sync::Arc, }; /// A cache for the simulator. @@ -10,7 +13,7 @@ use std::{ /// This cache is used to store the items that are being simulated. #[derive(Clone)] pub struct SimCache { - inner: Arc>>, + inner: Arc>, capacity: usize, } @@ -27,89 +30,126 @@ impl Default for SimCache { } impl SimCache { - /// Create a new `SimCache` instance. + /// Create a new `SimCache` instance, with a default capacity of `100`. pub fn new() -> Self { - Self { inner: Arc::new(RwLock::new(BTreeMap::new())), capacity: 100 } + Self { inner: Arc::new(RwLock::new(CacheInner::new())), capacity: 100 } } /// Create a new `SimCache` instance with a given capacity. pub fn with_capacity(capacity: usize) -> Self { - Self { inner: Arc::new(RwLock::new(BTreeMap::new())), capacity } + Self { inner: Arc::new(RwLock::new(CacheInner::new())), capacity } } /// Get an iterator over the best items in the cache. pub fn read_best(&self, n: usize) -> Vec<(u128, SimItem)> { - self.inner.read().unwrap().iter().rev().take(n).map(|(k, v)| (*k, v.clone())).collect() + self.inner.read().items.iter().rev().take(n).map(|(k, item)| (*k, item.clone())).collect() } /// Get the number of items in the cache. pub fn len(&self) -> usize { - self.inner.read().unwrap().len() + self.inner.read().items.len() } /// True if the cache is empty. pub fn is_empty(&self) -> bool { - self.inner.read().unwrap().is_empty() + self.inner.read().items.is_empty() } /// Get an item by key. pub fn get(&self, key: u128) -> Option { - self.inner.read().unwrap().get(&key).cloned() + self.inner.read().items.get(&key).cloned() } /// Remove an item by key. pub fn remove(&self, key: u128) -> Option { - self.inner.write().unwrap().remove(&key) + let mut inner = self.inner.write(); + if let Some(item) = inner.items.remove(&key) { + inner.seen.remove(item.identifier().as_bytes()); + Some(item) + } else { + None + } } - fn add_inner( - guard: &mut RwLockWriteGuard<'_, BTreeMap>, - mut score: u128, - item: SimItem, - capacity: usize, - ) { + fn add_inner(inner: &mut CacheInner, mut score: u128, item: SimItem, capacity: usize) { + // Check if we've already seen this item - if so, don't add it + if !inner.seen.insert(item.identifier_owned()) { + return; + } + // If it has the same score, we decrement (prioritizing earlier items) - while guard.contains_key(&score) && score != 0 { + while inner.items.contains_key(&score) && score != 0 { score = score.saturating_sub(1); } - if guard.len() >= capacity { + if inner.items.len() >= capacity { // If we are at capacity, we need to remove the lowest score - guard.pop_first(); + if let Some((_, item)) = inner.items.pop_first() { + inner.seen.remove(&item.identifier_owned()); + } } - guard.entry(score).or_insert(item); + inner.items.insert(score, item.clone()); } - /// Add an item to the cache. - /// - /// The basefee is used to calculate an estimated fee for the item. - pub fn add_item(&self, item: impl Into, basefee: u64) { - let item = item.into(); + /// Add a bundle to the cache. + pub fn add_bundle(&self, bundle: SignetEthBundle, basefee: u64) -> Result<(), CacheError> { + if bundle.replacement_uuid().is_none() { + // If the bundle does not have a replacement UUID, we cannot add it to the cache. + return Err(CacheError::BundleWithoutReplacementUuid); + } - // Calculate the total fee for the item. + let item = SimItem::try_from(bundle)?; let score = item.calculate_total_fee(basefee); - let mut inner = self.inner.write().unwrap(); - + let mut inner = self.inner.write(); Self::add_inner(&mut inner, score, item, self.capacity); + + Ok(()) } - /// Add an iterator of items to the cache. This locks the cache only once - pub fn add_items(&self, item: I, basefee: u64) + /// Add an iterator of bundles to the cache. This locks the cache only once + /// + /// Bundles added should have a valid replacement UUID. Bundles without a replacement UUID will be skipped. + pub fn add_bundles(&self, item: I, basefee: u64) -> Result<(), CacheError> where I: IntoIterator, - Item: Into, + Item: Into, { - let iter = item.into_iter().map(|item| { + let mut inner = self.inner.write(); + + for item in item.into_iter() { let item = item.into(); + let Ok(item) = SimItem::try_from(item) else { + // Skip invalid bundles + continue; + }; let score = item.calculate_total_fee(basefee); - (score, item) - }); + Self::add_inner(&mut inner, score, item, self.capacity); + } + + Ok(()) + } - let mut inner = self.inner.write().unwrap(); + /// Add a transaction to the cache. + pub fn add_tx(&self, tx: TxEnvelope, basefee: u64) { + let item = SimItem::from(tx); + let score = item.calculate_total_fee(basefee); + + let mut inner = self.inner.write(); + Self::add_inner(&mut inner, score, item, self.capacity); + } - for (score, item) in iter { + /// Add an iterator of transactions to the cache. This locks the cache only once + pub fn add_txs(&self, item: I, basefee: u64) + where + I: IntoIterator, + { + let mut inner = self.inner.write(); + + for item in item.into_iter() { + let item = SimItem::from(item); + let score = item.calculate_total_fee(basefee); Self::add_inner(&mut inner, score, item, self.capacity); } } @@ -117,79 +157,146 @@ impl SimCache { /// Clean the cache by removing bundles that are not valid in the current /// block. pub fn clean(&self, block_number: u64, block_timestamp: u64) { - let mut inner = self.inner.write().unwrap(); + let mut inner = self.inner.write(); // Trim to capacity by dropping lower fees. - while inner.len() > self.capacity { - inner.pop_first(); + while inner.items.len() > self.capacity { + if let Some((_, item)) = inner.items.pop_first() { + // Drop the identifier from the seen cache as well. + inner.seen.remove(item.identifier().as_bytes()); + } } - inner.retain(|_, value| { - let SimItem::Bundle(bundle) = value else { - return true; - }; - if bundle.bundle.block_number != block_number { - return false; - } - if let Some(timestamp) = bundle.min_timestamp() { - if timestamp > block_timestamp { - return false; - } - } - if let Some(timestamp) = bundle.max_timestamp() { - if timestamp < block_timestamp { - return false; + let CacheInner { ref mut items, ref mut seen } = *inner; + + items.retain(|_, item| { + // Retain only items that are not bundles or are valid in the current block. + if let SimItem::Bundle(bundle) = item { + let should_remove = bundle.bundle.block_number == block_number + && bundle.min_timestamp().is_some_and(|ts| ts <= block_timestamp) + && bundle.max_timestamp().is_some_and(|ts| ts >= block_timestamp); + + let retain = !should_remove; + + if should_remove { + seen.remove(item.identifier().as_bytes()); } + retain + } else { + true // Non-bundle items are retained } - true - }) + }); } /// Clear the cache. pub fn clear(&self) { - let mut inner = self.inner.write().unwrap(); - inner.clear(); + let mut inner = self.inner.write(); + inner.items.clear(); + inner.seen.clear(); + } +} + +/// Internal cache data, meant to be protected by a lock. +struct CacheInner { + items: BTreeMap, + seen: HashSet>, +} + +impl fmt::Debug for CacheInner { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CacheInner").finish() + } +} + +impl CacheInner { + fn new() -> Self { + Self { items: BTreeMap::new(), seen: HashSet::new() } } } #[cfg(test)] mod test { + use alloy::primitives::b256; + use super::*; - use crate::SimItem; #[test] fn test_cache() { let items = vec![ - SimItem::invalid_item_with_score(100, 1), - SimItem::invalid_item_with_score(100, 2), - SimItem::invalid_item_with_score(100, 3), + invalid_tx_with_score(100, 1), + invalid_tx_with_score(100, 2), + invalid_tx_with_score(100, 3), ]; let cache = SimCache::with_capacity(2); - cache.add_items(items, 0); + cache.add_txs(items.clone(), 0); assert_eq!(cache.len(), 2); - assert_eq!(cache.get(300), Some(SimItem::invalid_item_with_score(100, 3))); - assert_eq!(cache.get(200), Some(SimItem::invalid_item_with_score(100, 2))); + assert_eq!(cache.get(300), Some(items[2].clone().into())); + assert_eq!(cache.get(200), Some(items[1].clone().into())); assert_eq!(cache.get(100), None); } #[test] fn overlap_at_zero() { let items = vec![ - SimItem::invalid_item_with_score(1, 1), - SimItem::invalid_item_with_score(1, 1), - SimItem::invalid_item_with_score(1, 1), + invalid_tx_with_score_and_hash( + 1, + 1, + b256!("0xb36a5a0066980e8477d5d5cebf023728d3cfb837c719dc7f3aadb73d1a39f11f"), + ), + invalid_tx_with_score_and_hash( + 1, + 1, + b256!("0x04d3629f341cdcc5f72969af3c7638e106b4b5620594e6831d86f03ea048e68a"), + ), + invalid_tx_with_score_and_hash( + 1, + 1, + b256!("0x0f0b6a85c1ef6811bf86e92a3efc09f61feb1deca9da671119aaca040021598a"), + ), ]; let cache = SimCache::with_capacity(2); - cache.add_items(items, 0); + cache.add_txs(items.clone(), 0); - dbg!(&*cache.inner.read().unwrap()); + dbg!(&*cache.inner.read()); assert_eq!(cache.len(), 2); - assert_eq!(cache.get(0), Some(SimItem::invalid_item_with_score(1, 1))); - assert_eq!(cache.get(1), Some(SimItem::invalid_item_with_score(1, 1))); + assert_eq!(cache.get(0), Some(items[2].clone().into())); + assert_eq!(cache.get(1), Some(items[0].clone().into())); assert_eq!(cache.get(2), None); } + + fn invalid_tx_with_score(gas_limit: u64, mpfpg: u128) -> alloy::consensus::TxEnvelope { + let tx = build_alloy_tx(gas_limit, mpfpg); + + TxEnvelope::Eip1559(alloy::consensus::Signed::new_unhashed( + tx, + alloy::signers::Signature::test_signature(), + )) + } + + fn invalid_tx_with_score_and_hash( + gas_limit: u64, + mpfpg: u128, + hash: alloy::primitives::B256, + ) -> alloy::consensus::TxEnvelope { + let tx = build_alloy_tx(gas_limit, mpfpg); + + TxEnvelope::Eip1559(alloy::consensus::Signed::new_unchecked( + tx, + alloy::signers::Signature::test_signature(), + hash, + )) + } + + fn build_alloy_tx(gas_limit: u64, mpfpg: u128) -> alloy::consensus::TxEip1559 { + alloy::consensus::TxEip1559 { + gas_limit, + max_priority_fee_per_gas: mpfpg, + max_fee_per_gas: alloy::consensus::constants::GWEI_TO_WEI as u128, + ..Default::default() + } + } } diff --git a/crates/sim/src/env.rs b/crates/sim/src/env.rs index 0c8b601..1d27a77 100644 --- a/crates/sim/src/env.rs +++ b/crates/sim/src/env.rs @@ -302,6 +302,7 @@ where let score = beneficiary_balance.saturating_sub(initial_beneficiary_balance); trace!( + ?identifier, gas_used = gas_used, score = %score, reverted = !success, @@ -343,6 +344,7 @@ where let cache = trevm.into_db().into_cache(); trace!( + ?identifier, gas_used = gas_used, score = %score, "Bundle simulation successful" diff --git a/crates/sim/src/error.rs b/crates/sim/src/error.rs new file mode 100644 index 0000000..81f08c7 --- /dev/null +++ b/crates/sim/src/error.rs @@ -0,0 +1,7 @@ +/// Possible errors that can occur when using the cache. +#[derive(Debug, Clone, Copy, thiserror::Error)] +pub enum CacheError { + /// The bundle does not have a replacement UUID, which is required for caching. + #[error("bundle has no replacement UUID")] + BundleWithoutReplacementUuid, +} diff --git a/crates/sim/src/item.rs b/crates/sim/src/item.rs index d086822..284c470 100644 --- a/crates/sim/src/item.rs +++ b/crates/sim/src/item.rs @@ -1,22 +1,32 @@ use alloy::{ consensus::{Transaction, TxEnvelope}, eips::Decodable2718, + primitives::TxHash, }; use signet_bundle::SignetEthBundle; +use std::{ + borrow::{Borrow, Cow}, + hash::Hash, +}; /// An item that can be simulated. #[derive(Debug, Clone, PartialEq)] pub enum SimItem { /// A bundle to be simulated. Bundle(SignetEthBundle), - /// A transaction to be simulated. Tx(TxEnvelope), } -impl From for SimItem { - fn from(bundle: SignetEthBundle) -> Self { - Self::Bundle(bundle) +impl TryFrom for SimItem { + type Error = crate::CacheError; + + fn try_from(bundle: SignetEthBundle) -> Result { + if bundle.replacement_uuid().is_some() { + Ok(Self::Bundle(bundle)) + } else { + Err(crate::CacheError::BundleWithoutReplacementUuid) + } } } @@ -64,35 +74,98 @@ impl SimItem { // Testing functions impl SimItem { - /// Create an invalid test item. This will be a [`TxEnvelope`] containing - /// an EIP-1559 transaction with an invalid signature and hash. - #[doc(hidden)] - pub fn invalid_item() -> Self { - TxEnvelope::Eip1559(alloy::consensus::Signed::new_unchecked( - alloy::consensus::TxEip1559::default(), - alloy::signers::Signature::test_signature(), - Default::default(), - )) - .into() - } - - /// Create an invalid test item with a given gas limit and max priority fee - /// per gas. As [`Self::invalid_test_item`] but with a custom gas limit and - /// `max_priority_fee_per_gas`. - #[doc(hidden)] - pub fn invalid_item_with_score(gas_limit: u64, mpfpg: u128) -> Self { - let tx = alloy::consensus::TxEip1559 { - gas_limit, - max_priority_fee_per_gas: mpfpg, - max_fee_per_gas: alloy::consensus::constants::GWEI_TO_WEI as u128, - ..Default::default() - }; - - let tx = TxEnvelope::Eip1559(alloy::consensus::Signed::new_unchecked( - tx, - alloy::signers::Signature::test_signature(), - Default::default(), - )); - tx.into() + /// Returns a unique identifier for this item, which can be used to + /// distinguish it from other items. + pub fn identifier(&self) -> SimIdentifier<'_> { + match self { + Self::Bundle(bundle) => { + SimIdentifier::Bundle(Cow::Borrowed(bundle.replacement_uuid().unwrap())) + } + Self::Tx(tx) => SimIdentifier::Tx(*tx.hash()), + } + } + + /// Returns an unique, owned identifier for this item. + pub fn identifier_owned(&self) -> SimIdentifier<'static> { + match self { + Self::Bundle(bundle) => { + SimIdentifier::Bundle(Cow::Owned(bundle.replacement_uuid().unwrap().to_string())) + } + Self::Tx(tx) => SimIdentifier::Tx(*tx.hash()), + } + } +} + +/// A simulation cache item identifier. +#[derive(Debug, Clone)] +pub enum SimIdentifier<'a> { + /// A bundle identifier. + Bundle(Cow<'a, str>), + /// A transaction identifier. + Tx(TxHash), +} + +impl core::fmt::Display for SimIdentifier<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Bundle(id) => write!(f, "{}", id), + Self::Tx(id) => write!(f, "{}", id), + } + } +} + +impl PartialEq for SimIdentifier<'_> { + fn eq(&self, other: &Self) -> bool { + self.as_bytes().eq(other.as_bytes()) + } +} + +impl Eq for SimIdentifier<'_> {} + +impl Hash for SimIdentifier<'_> { + fn hash(&self, state: &mut H) { + self.as_bytes().hash(state); + } +} + +impl Borrow<[u8]> for SimIdentifier<'_> { + fn borrow(&self) -> &[u8] { + self.as_bytes() + } +} + +impl From for SimIdentifier<'_> { + fn from(tx_hash: TxHash) -> Self { + Self::Tx(tx_hash) + } +} + +impl SimIdentifier<'_> { + /// Create a new [`SimIdentifier::Bundle`]. + pub const fn bundle<'a>(id: Cow<'a, str>) -> SimIdentifier<'a> { + SimIdentifier::Bundle(id) + } + + /// Create a new [`SimIdentifier::Tx`]. + pub const fn tx(id: TxHash) -> Self { + Self::Tx(id) + } + + /// Check if this identifier is a bundle. + pub const fn is_bundle(&self) -> bool { + matches!(self, Self::Bundle(_)) + } + + /// Check if this identifier is a transaction. + pub const fn is_tx(&self) -> bool { + matches!(self, Self::Tx(_)) + } + + /// Get the identifier as a byte slice. + pub fn as_bytes(&self) -> &[u8] { + match self { + Self::Bundle(id) => id.as_bytes(), + Self::Tx(id) => id.as_ref(), + } } } diff --git a/crates/sim/src/lib.rs b/crates/sim/src/lib.rs index 17af74a..85c6783 100644 --- a/crates/sim/src/lib.rs +++ b/crates/sim/src/lib.rs @@ -23,8 +23,11 @@ pub use cache::SimCache; mod env; pub use env::{SharedSimEnv, SimEnv}; +mod error; +pub use error::CacheError; + mod item; -pub use item::SimItem; +pub use item::{SimIdentifier, SimItem}; mod outcome; pub use outcome::SimOutcomeWithCache; diff --git a/crates/sim/src/task.rs b/crates/sim/src/task.rs index ac21907..70cec78 100644 --- a/crates/sim/src/task.rs +++ b/crates/sim/src/task.rs @@ -64,7 +64,7 @@ where let gas_allowed = self.max_gas - self.block.gas_used(); if let Some(simulated) = self.env.sim_round(gas_allowed).await { - tracing::debug!(score = %simulated.score, gas_used = simulated.gas_used, "Adding item to block"); + tracing::debug!(score = %simulated.score, gas_used = simulated.gas_used, identifier = %simulated.item.identifier(), "Adding item to block"); self.block.ingest(simulated); } } diff --git a/crates/test-utils/tests/basic_sim.rs b/crates/test-utils/tests/basic_sim.rs index 95fb9b1..50aab43 100644 --- a/crates/test-utils/tests/basic_sim.rs +++ b/crates/test-utils/tests/basic_sim.rs @@ -44,7 +44,7 @@ pub async fn test_simulator() { // Set up 10 simple sends with escalating priority fee let sim_cache = SimCache::new(); for (i, sender) in TEST_SIGNERS.iter().enumerate() { - sim_cache.add_item( + sim_cache.add_tx( signed_simple_send( sender, TEST_USERS[i],