Skip to main content

signet_sim/cache/
store.rs

1use crate::cache::{CacheError, SimIdentifier, SimItem, SimItemValidity, StateSource};
2use alloy::consensus::{transaction::Recovered, TxEnvelope};
3use core::fmt;
4use lru::LruCache;
5use parking_lot::RwLock;
6use signet_bundle::{RecoveredBundle, SignetEthBundle};
7use std::{
8    collections::{BTreeMap, HashSet},
9    mem::MaybeUninit,
10    num::NonZeroUsize,
11    ops::Deref,
12    sync::Arc,
13};
14use tracing::{instrument, Span};
15
16/// A cache for the simulator.
17///
18/// This cache is used to store the items that are being simulated.
19#[derive(Clone)]
20pub struct SimCache {
21    inner: Arc<RwLock<CacheStore>>,
22    capacity: usize,
23}
24
25impl fmt::Debug for SimCache {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result {
27        f.debug_struct("SimCache").finish()
28    }
29}
30
31impl Default for SimCache {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl SimCache {
38    /// Create a new `SimCache` instance, with a default capacity of `100`.
39    pub fn new() -> Self {
40        Self { inner: Arc::new(RwLock::new(CacheStore::new())), capacity: 100 }
41    }
42
43    /// Create a new `SimCache` instance with a given capacity.
44    pub fn with_capacity(capacity: usize) -> Self {
45        Self { inner: Arc::new(RwLock::new(CacheStore::new())), capacity }
46    }
47
48    /// Fill a buffer with up to its capacity
49    pub fn write_best_to(&self, buf: &mut [MaybeUninit<(u128, SimItem)>]) -> usize {
50        let cache = self.inner.read();
51        cache.items.iter().rev().zip(buf.iter_mut()).for_each(|((cache_rank, item), slot)| {
52            // Cloning the Arc into the MaybeUninit slot
53            slot.write((*cache_rank, item.clone()));
54        });
55        // We wrote the minimum of what was in the cache and the buffer
56        std::cmp::min(cache.items.len(), buf.len())
57    }
58
59    /// Get an iterator over the best items in the cache.
60    pub fn read_best(&self, n: usize) -> Vec<(u128, SimItem)> {
61        let mut vec = Vec::with_capacity(n);
62        let n = self.write_best_to(vec.spare_capacity_mut());
63        // SAFETY: We just wrote n items.
64        unsafe { vec.set_len(n) };
65        vec
66    }
67
68    /// Get up to the `n` best items in the cache that pass preflight validity
69    /// checks (nonce and initial fee). The returned vector may be smaller than
70    /// `n` if not enough valid items are found.
71    ///
72    /// This will additionally remove items that can _never_ be valid from the
73    /// cache.
74    ///
75    /// The state sources are used to validate the items against the current
76    /// nonce and balance, to prevent simulating invalid items.
77    #[instrument(
78        level = "debug",
79        skip_all,
80        fields(
81            candidates_total = tracing::field::Empty,
82            candidates_checked = tracing::field::Empty,
83            valid_count = tracing::field::Empty,
84            future_count = tracing::field::Empty,
85            never_count = tracing::field::Empty,
86        )
87    )]
88    pub async fn read_best_valid<S, S2>(
89        &self,
90        n: usize,
91        source: &S,
92        host_source: &S2,
93    ) -> Result<Vec<(u128, SimItem)>, Box<dyn std::error::Error>>
94    where
95        S: StateSource,
96        S2: StateSource,
97    {
98        // Snapshot the entire cache under a short-lived read lock so that
99        // filtering out invalid items doesn't reduce the result set below `n`.
100        let candidates: Vec<(u128, SimItem)> = {
101            let cache = self.inner.read();
102            // Traverse the cache in reverse order (best items first).
103            cache.items.iter().rev().map(|(rank, item)| (*rank, item.clone())).collect()
104        };
105
106        let span = Span::current();
107        span.record("candidates_total", candidates.len());
108
109        let mut valid = Vec::with_capacity(n);
110        let mut never = Vec::new();
111        let mut future_count: u32 = 0;
112        let mut checked: u32 = 0;
113
114        for (rank, item) in &candidates {
115            if valid.len() >= n {
116                break;
117            }
118            checked += 1;
119
120            let validity = item.check(source, host_source).await?;
121
122            match validity {
123                SimItemValidity::Now => valid.push((*rank, item.clone())),
124                SimItemValidity::Never => never.push(*rank),
125                SimItemValidity::Future => future_count += 1,
126            }
127        }
128
129        span.record("candidates_checked", checked);
130        span.record("valid_count", valid.len());
131        span.record("future_count", future_count);
132        span.record("never_count", never.len());
133
134        // Remove never-valid items under a write lock.
135        if !never.is_empty() {
136            let mut cache = self.inner.write();
137            for rank in never {
138                cache.remove_and_disallow(rank);
139            }
140        }
141
142        Ok(valid)
143    }
144
145    /// Get the number of items in the cache.
146    pub fn len(&self) -> usize {
147        self.inner.read().items.len()
148    }
149
150    /// True if the cache is empty.
151    pub fn is_empty(&self) -> bool {
152        self.inner.read().items.is_empty()
153    }
154
155    /// Get an item by key.
156    pub fn get(&self, cache_rank: u128) -> Option<SimItem> {
157        self.inner.read().items.get(&cache_rank).cloned()
158    }
159
160    /// Remove an item by key.
161    pub fn remove(&self, cache_rank: u128) -> Option<SimItem> {
162        let mut inner = self.inner.write();
163        inner.remove(cache_rank)
164    }
165
166    /// Remove an item by key, and prevent it from being re-added for a while.
167    pub fn remove_and_disallow(&self, cache_rank: u128) -> Option<SimItem> {
168        let mut inner = self.inner.write();
169        inner.remove_and_disallow(cache_rank)
170    }
171
172    /// Add a bundle to the cache.
173    pub fn add_bundle(&self, bundle: SignetEthBundle, basefee: u64) -> Result<(), CacheError> {
174        if bundle.replacement_uuid().is_none() {
175            // If the bundle does not have a replacement UUID, we cannot add it to the cache.
176            return Err(CacheError::BundleWithoutReplacementUuid);
177        }
178
179        let item = SimItem::try_from(bundle)?;
180        let cache_rank = item.calculate_total_fee(basefee);
181
182        let mut inner = self.inner.write();
183        inner.add_inner(cache_rank, item, self.capacity);
184
185        Ok(())
186    }
187
188    /// Add an iterator of bundles to the cache. This locks the cache only once
189    ///
190    /// Bundles added should have a valid replacement UUID. Bundles without a replacement UUID will be skipped.
191    pub fn add_bundles<I, Item>(&self, item: I, basefee: u64)
192    where
193        I: IntoIterator<Item = Item>,
194        Item: Into<RecoveredBundle>,
195    {
196        let mut inner = self.inner.write();
197        inner.add_bundles(item, basefee, self.capacity);
198    }
199
200    /// Add a transaction to the cache.
201    pub fn add_tx(&self, tx: Recovered<TxEnvelope>, basefee: u64) {
202        let item = SimItem::from(tx);
203        let cache_rank = item.calculate_total_fee(basefee);
204
205        let mut inner = self.inner.write();
206        inner.add_inner(cache_rank, item, self.capacity);
207    }
208
209    /// Add an iterator of transactions to the cache. This locks the cache only once
210    pub fn add_txs<I>(&self, item: I, basefee: u64)
211    where
212        I: IntoIterator<Item = Recovered<TxEnvelope>>,
213    {
214        let mut inner = self.inner.write();
215        inner.add_txs(item, basefee, self.capacity);
216    }
217
218    /// Clean the cache by removing bundles that are not valid in the current
219    /// block.
220    pub fn clean(&self, block_number: u64, block_timestamp: u64) {
221        let mut inner = self.inner.write();
222        inner.clean(self.capacity, block_number, block_timestamp);
223    }
224
225    /// Clear the cache.
226    pub fn clear(&self) {
227        let mut inner = self.inner.write();
228        inner.clear();
229    }
230}
231
232/// Internal cache data, meant to be protected by a lock.
233struct CacheStore {
234    /// Key is the cache_rank, unique ID within the cache && the item's order in the cache. Value is [`SimItem`] itself.
235    items: BTreeMap<u128, SimItem>,
236
237    /// Key is the unique identifier for the [`SimItem`] - the UUID for
238    /// bundles, tx hash for transactions.
239    seen: HashSet<SimIdentifier<'static>>,
240
241    /// Identifiers of items that have been removed from the cache, as
242    /// they will never be valid again
243    disallowed: LruCache<SimIdentifier<'static>, ()>,
244}
245
246impl fmt::Debug for CacheStore {
247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result {
248        f.debug_struct("CacheInner").finish()
249    }
250}
251
252impl CacheStore {
253    fn new() -> Self {
254        Self {
255            items: BTreeMap::new(),
256            seen: HashSet::new(),
257            disallowed: LruCache::new(NonZeroUsize::new(128).unwrap()),
258        }
259    }
260
261    /// Add an item to the cache.
262    fn add_inner(&mut self, mut cache_rank: u128, item: SimItem, capacity: usize) {
263        // If the item is disallowed, we don't add it
264        if self.disallowed.contains(&item.identifier_owned()) {
265            return;
266        }
267
268        // Check if we've already seen this item - if so, don't add it
269        if !self.seen.insert(item.identifier_owned()) {
270            return;
271        }
272
273        // If it has the same cache_rank, we decrement (prioritizing earlier items)
274        while self.items.contains_key(&cache_rank) && cache_rank != 0 {
275            cache_rank = cache_rank.saturating_sub(1);
276        }
277
278        if self.items.len() >= capacity {
279            // If we are at capacity, we need to remove the lowest score
280            if let Some((_, item)) = self.items.pop_first() {
281                self.seen.remove(&item.identifier_owned());
282            }
283        }
284
285        self.items.insert(cache_rank, item.clone());
286    }
287
288    fn add_bundles<I, T>(&mut self, item: I, basefee: u64, capacity: usize)
289    where
290        I: IntoIterator<Item = T>,
291        T: Into<RecoveredBundle>,
292    {
293        for item in item.into_iter() {
294            let item = item.into();
295            let Ok(item) = SimItem::try_from(item) else {
296                // Skip invalid bundles
297                continue;
298            };
299            let cache_rank = item.calculate_total_fee(basefee);
300            self.add_inner(cache_rank, item, capacity);
301        }
302    }
303
304    fn add_txs<I>(&mut self, item: I, basefee: u64, capacity: usize)
305    where
306        I: IntoIterator<Item = Recovered<TxEnvelope>>,
307    {
308        for item in item.into_iter() {
309            let item = SimItem::from(item);
310            let cache_rank = item.calculate_total_fee(basefee);
311            self.add_inner(cache_rank, item, capacity);
312        }
313    }
314
315    /// Remove an item by key. This will also remove it from the seen set.
316    fn remove(&mut self, cache_rank: u128) -> Option<SimItem> {
317        if let Some(item) = self.items.remove(&cache_rank) {
318            self.seen.remove(item.identifier().as_bytes());
319            Some(item)
320        } else {
321            None
322        }
323    }
324    /// Remove an item by key, and prevent it from being re-added for a while.
325    /// This will also remove it from the seen set.
326    fn remove_and_disallow(&mut self, cache_rank: u128) -> Option<SimItem> {
327        self.remove(cache_rank).inspect(|item| {
328            self.disallowed.put(item.identifier_owned(), ());
329        })
330    }
331
332    /// Clean the cache by evicting the lowest-score items and removing bundles
333    /// that are not valid in the current block.
334    fn clean(&mut self, capacity: usize, block_number: u64, block_timestamp: u64) {
335        // Trim to capacity by dropping lower fees.
336        while self.items.len() > capacity {
337            if let Some(key) = self.items.keys().next() {
338                self.remove_and_disallow(*key);
339            }
340        }
341
342        self.items.retain(|_, item| {
343            // Retain only items that are not bundles or are valid in the current block.
344            if let SimItem::Bundle(bundle) = item.deref() {
345                let ts_range = bundle.valid_timestamp_range();
346                let bundle_block = bundle.block_number();
347
348                // NB: we don't need to recheck max_timestamp here, as never
349                // covers that.
350                let now = block_number == bundle_block && ts_range.contains(&block_timestamp);
351
352                // Never valid if the block number is past the bundle's target
353                // block or timestamp is past the bundle's max timestamp
354                let never =
355                    !now && (block_number > bundle_block || block_timestamp > *ts_range.end());
356
357                if !now {
358                    self.seen.remove(item.identifier().as_bytes());
359                }
360
361                if never {
362                    self.disallowed.put(item.identifier_owned(), ());
363                }
364
365                now
366            } else {
367                true // Non-bundle items are retained
368            }
369        });
370    }
371
372    fn clear(&mut self) {
373        self.items.clear();
374        self.seen.clear();
375    }
376}
377
378#[cfg(test)]
379mod test {
380
381    use alloy::primitives::{b256, Address};
382
383    use super::*;
384
385    #[test]
386    fn test_cache() {
387        let items = vec![
388            invalid_tx_with_score(100, 1),
389            invalid_tx_with_score(100, 2),
390            invalid_tx_with_score(100, 3),
391        ];
392
393        let cache = SimCache::with_capacity(2);
394        cache.add_txs(items.clone(), 0);
395
396        assert_eq!(cache.len(), 2);
397        assert_eq!(cache.get(300), Some(items[2].clone().into()));
398        assert_eq!(cache.get(200), Some(items[1].clone().into()));
399        assert_eq!(cache.get(100), None);
400    }
401
402    #[test]
403    fn overlap_at_zero() {
404        let items = vec![
405            invalid_tx_with_score_and_hash(
406                1,
407                1,
408                b256!("0xb36a5a0066980e8477d5d5cebf023728d3cfb837c719dc7f3aadb73d1a39f11f"),
409            ),
410            invalid_tx_with_score_and_hash(
411                1,
412                1,
413                b256!("0x04d3629f341cdcc5f72969af3c7638e106b4b5620594e6831d86f03ea048e68a"),
414            ),
415            invalid_tx_with_score_and_hash(
416                1,
417                1,
418                b256!("0x0f0b6a85c1ef6811bf86e92a3efc09f61feb1deca9da671119aaca040021598a"),
419            ),
420        ];
421
422        let cache = SimCache::with_capacity(2);
423        cache.add_txs(items.clone(), 0);
424
425        dbg!(&*cache.inner.read());
426
427        assert_eq!(cache.len(), 2);
428        assert_eq!(cache.get(0), Some(items[2].clone().into()));
429        assert_eq!(cache.get(1), Some(items[0].clone().into()));
430        assert_eq!(cache.get(2), None);
431    }
432
433    #[test]
434    fn test_cache_with_bundles() {
435        let items = vec![
436            invalid_bundle_with_score(100, 1, "fbcbb9ce-2bef-4587-9c5f-61f606ca0a1a".to_string()),
437            invalid_bundle_with_score(100, 2, "39637ce4-5f33-4eb6-8893-8cc325a6cca3".to_string()),
438            invalid_bundle_with_score(100, 3, "1c008717-b187-4e53-9601-25435f5fe8b7".to_string()),
439        ];
440
441        let cache = SimCache::with_capacity(2);
442
443        cache.add_bundles(items.clone(), 0);
444
445        assert_eq!(cache.len(), 2);
446        assert_eq!(cache.get(300), Some(items[2].clone().try_into().unwrap()));
447        assert_eq!(cache.get(200), Some(items[1].clone().try_into().unwrap()));
448        assert_eq!(cache.get(100), None);
449    }
450
451    fn invalid_bundle_with_score(
452        gas_limit: u64,
453        mpfpg: u128,
454        replacement_uuid: String,
455    ) -> signet_bundle::RecoveredBundle {
456        let tx = invalid_tx_with_score(gas_limit, mpfpg);
457        signet_bundle::RecoveredBundle::new_unchecked(
458            vec![tx],
459            vec![],
460            1,
461            Some(2),
462            Some(3),
463            vec![],
464            Some(replacement_uuid.clone()),
465            vec![],
466            None,
467            None,
468            vec![],
469            Default::default(),
470        )
471    }
472
473    fn invalid_tx_with_score(
474        gas_limit: u64,
475        mpfpg: u128,
476    ) -> Recovered<alloy::consensus::TxEnvelope> {
477        let tx = build_alloy_tx(gas_limit, mpfpg);
478
479        Recovered::new_unchecked(
480            TxEnvelope::Eip1559(alloy::consensus::Signed::new_unhashed(
481                tx,
482                alloy::signers::Signature::test_signature(),
483            )),
484            Address::with_last_byte(7),
485        )
486    }
487
488    fn invalid_tx_with_score_and_hash(
489        gas_limit: u64,
490        mpfpg: u128,
491        hash: alloy::primitives::B256,
492    ) -> Recovered<alloy::consensus::TxEnvelope> {
493        let tx = build_alloy_tx(gas_limit, mpfpg);
494
495        Recovered::new_unchecked(
496            TxEnvelope::Eip1559(alloy::consensus::Signed::new_unchecked(
497                tx,
498                alloy::signers::Signature::test_signature(),
499                hash,
500            )),
501            Address::with_last_byte(8),
502        )
503    }
504
505    fn build_alloy_tx(gas_limit: u64, mpfpg: u128) -> alloy::consensus::TxEip1559 {
506        alloy::consensus::TxEip1559 {
507            gas_limit,
508            max_priority_fee_per_gas: mpfpg,
509            max_fee_per_gas: alloy::consensus::constants::GWEI_TO_WEI as u128,
510            ..Default::default()
511        }
512    }
513}