Skip to main content

ethrex_blockchain/
mempool.rs

1use std::{
2    cmp::Reverse,
3    collections::{BTreeMap, VecDeque, hash_map::Entry},
4    sync::RwLock,
5    sync::atomic::{AtomicU64, Ordering},
6    time::{Duration, Instant},
7};
8
9use rustc_hash::{FxHashMap, FxHashSet};
10
11use crate::error::MempoolError;
12use ethrex_common::{
13    Address, H160, H256, U256,
14    types::{
15        BlobTuple, BlobsBundle, BlockHeader, ChainConfig, Fork, MempoolTransaction, Transaction,
16        TxType, kzg_commitment_to_versioned_hash,
17    },
18};
19use ethrex_crypto::NativeCrypto;
20use ethrex_storage::error::StoreError;
21use ethrex_vm::{intrinsic_gas_dimensions, intrinsic_gas_floor};
22use tracing::warn;
23
24/// Maximum number of alternate announcers tracked per hash. Bounds the memory
25/// used by the alternates map and prevents pathological peers from filling it.
26///
27/// TODO(#6849): expose this through `BlockchainOptions` / CLI like the
28/// other mempool ceilings (`max_mempool_size`, RBF price-bumps). 8 is
29/// conservative; high-fan-in benchmarks and Hive adversarial-mempool scenarios
30/// might want to raise it. FIFO eviction keeps the cap safe regardless.
31pub const MAX_ALTERNATES_PER_HASH: usize = 8;
32
33/// Maximum number of blob (EIP-4844) transactions retained in the mempool,
34/// independent of `max_mempool_size`. Blob txs live in a dedicated sub-pool so a
35/// flood of regular transactions cannot evict them, and the sub-pool itself is
36/// evicted by value/nonce (see `remove_worst_blob_transaction`), never FIFO, so
37/// the node keeps the (scarce, high-value, includable) blob txs it needs to build
38/// full blocks.
39///
40/// Sized to comfortably hold several blocks' worth of includable blobs (Amsterdam
41/// allows up to 21 blobs/block) while bounding worst-case memory: blobs are held
42/// in RAM, so the bound is this count times the per-tx limit (`MAX_BLOB_TX_SIZE`,
43/// ~1 MiB) ⇒ ~0.5 GiB worst case.
44///
45/// TODO(#6849): expose through CLI and prefer a byte-based cap (like geth's
46/// blobpool `datacap`) so memory is bounded regardless of blobs-per-tx.
47pub const MAX_BLOB_MEMPOOL_SIZE: usize = 512;
48
49/// An alternate announcer for a known-in-flight transaction hash. Carries the
50/// announcer's own announced type and size so the eventual retry can validate
51/// the response against the alternate's metadata (which may differ from the
52/// primary announcer's, e.g. when one peer advertises a bare blob tx while
53/// another advertises the full sidecar).
54#[derive(Debug, Clone, Copy)]
55pub struct Alternate {
56    pub peer_id: H256,
57    pub tx_type: u8,
58    pub tx_size: usize,
59}
60
61#[derive(Debug, Default)]
62struct MempoolInner {
63    broadcast_pool: FxHashSet<H256>,
64    transaction_pool: FxHashMap<H256, MempoolTransaction>,
65    blobs_bundle_pool: FxHashMap<H256, BlobsBundle>,
66    /// Transaction hashes that have been requested via GetPooledTransactions
67    /// but whose responses haven't arrived yet. Used to avoid sending duplicate
68    /// requests when multiple peers announce the same transaction.
69    in_flight_txs: FxHashSet<H256>,
70    /// For each announced hash, the queue of *alternate* announcers that also
71    /// advertised it while the hash was already in-flight from someone else.
72    /// Each entry carries the announcer's own announced type and size so the
73    /// retry can validate the response against the alternate's metadata (which
74    /// may differ from the primary's). Used as a fallback list when an in-flight
75    /// request fails or the responding peer disconnects. The `Instant` records
76    /// the last time the entry was touched so a periodic pruner can drop stale
77    /// entries.
78    alternates: FxHashMap<H256, (VecDeque<Alternate>, Instant)>,
79    /// Maps blob versioned hashes to transaction hashes that include them and a position inside
80    /// blob bundle where blob and its adjacent data is available.
81    blobs_bundle_by_versioned_hash: FxHashMap<H256, FxHashMap<H256, usize>>,
82    txs_by_sender_nonce: BTreeMap<(H160, u64), H256>,
83    txs_order: VecDeque<H256>,
84    max_mempool_size: usize,
85    max_blob_mempool_size: usize,
86    // Max number of transactions to let the mempool order queue grow before pruning it
87    mempool_prune_threshold: usize,
88}
89
90impl MempoolInner {
91    fn new(max_mempool_size: usize) -> Self {
92        MempoolInner {
93            txs_order: VecDeque::with_capacity(max_mempool_size * 2),
94            transaction_pool: FxHashMap::with_capacity_and_hasher(
95                max_mempool_size,
96                Default::default(),
97            ),
98            max_mempool_size,
99            max_blob_mempool_size: MAX_BLOB_MEMPOOL_SIZE,
100            mempool_prune_threshold: max_mempool_size + max_mempool_size / 2,
101            ..Default::default()
102        }
103    }
104
105    /// Remove a transaction from the pool with the transaction pool lock already taken
106    fn remove_transaction_with_lock(&mut self, hash: &H256) -> Result<(), StoreError> {
107        let Some(tx) = self.transaction_pool.remove(hash) else {
108            return Ok(());
109        };
110        if matches!(tx.tx_type(), TxType::EIP4844) {
111            self.remove_blob_bundle(hash);
112        }
113
114        self.txs_by_sender_nonce.remove(&(tx.sender(), tx.nonce()));
115        self.broadcast_pool.remove(hash);
116
117        Ok(())
118    }
119
120    /// Remove a blobs bundle from the pool
121    pub fn remove_blob_bundle(&mut self, hash: &H256) {
122        let Some(h) = self.blobs_bundle_pool.remove(hash) else {
123            return;
124        };
125
126        for commitment in &h.commitments {
127            let versioned_hash = kzg_commitment_to_versioned_hash(commitment);
128            if let Entry::Occupied(mut entry) =
129                self.blobs_bundle_by_versioned_hash.entry(versioned_hash)
130            {
131                let txn_to_bundle = entry.get_mut();
132                txn_to_bundle.remove(hash);
133                if txn_to_bundle.is_empty() {
134                    entry.remove();
135                }
136            }
137        }
138    }
139
140    /// Number of blob (EIP-4844) txs currently in the pool. Each blob tx has
141    /// exactly one bundle entry, so the bundle pool size is the blob tx count.
142    fn blob_tx_count(&self) -> usize {
143        self.blobs_bundle_pool.len()
144    }
145
146    /// Number of non-blob txs currently in the pool.
147    fn regular_tx_count(&self) -> usize {
148        // `saturating_sub`: a blob bundle is inserted before its tx (see
149        // `add_blob_transaction_to_pool`), so in that window the bundle count can
150        // briefly exceed the tx entries. Treat the undercount as 0 regular txs
151        // rather than underflowing (which would wrongly trigger eviction).
152        self.transaction_pool
153            .len()
154            .saturating_sub(self.blob_tx_count())
155    }
156
157    /// Evict the oldest regular (non-blob) transactions until the regular pool is
158    /// back under its cap. Only drains `txs_order`, so blob txs are never evicted
159    /// by regular-tx pressure.
160    fn remove_oldest_regular_transaction(&mut self) -> Result<(), StoreError> {
161        while self.regular_tx_count() >= self.max_mempool_size {
162            if let Some(oldest_hash) = self.txs_order.pop_front() {
163                self.remove_transaction_with_lock(&oldest_hash)?;
164            } else {
165                warn!(
166                    "Regular mempool is full but there are no transactions to remove, this should not happen and will make the mempool grow indefinitely"
167                );
168                break;
169            }
170        }
171
172        Ok(())
173    }
174
175    /// Evict blob transactions until the blob sub-pool is back under its cap.
176    ///
177    /// Unlike a FIFO, this drops the *least includable* blob tx first. "Least
178    /// includable" is approximated by how deep a tx sits in its own sender's
179    /// queue: the nonce offset from that sender's lowest pooled blob nonce. A
180    /// large offset means the tx sits behind earlier same-sender blobs and
181    /// can't be included until those clear, so it is the safest to drop. Ties
182    /// are broken by lowest blob fee.
183    ///
184    /// The offset is measured per-sender on purpose. A raw cross-sender nonce
185    /// comparison would penalize long-lived high-throughput senders (e.g. a
186    /// rollup sequencer) whose on-wire nonces are large but whose txs are
187    /// perfectly includable. Measuring within a sender preserves the
188    /// low-offset, ready-to-include blobs the block builder actually needs
189    /// instead of FIFO-evicting them just because they arrived early.
190    fn remove_worst_blob_transaction(&mut self) -> Result<(), StoreError> {
191        while self.blob_tx_count() > self.max_blob_mempool_size {
192            // `blobs_bundle_pool` is keyed by blob-tx hash, so its keys are
193            // exactly the blob txs currently held. First pass: lowest pooled
194            // blob nonce per sender, the per-sender baseline for the offset.
195            let mut min_nonce_by_sender: FxHashMap<Address, u64> = FxHashMap::default();
196            for tx in self
197                .blobs_bundle_pool
198                .keys()
199                .filter_map(|hash| self.transaction_pool.get(hash))
200            {
201                min_nonce_by_sender
202                    .entry(tx.sender())
203                    .and_modify(|n| *n = (*n).min(tx.nonce()))
204                    .or_insert(tx.nonce());
205            }
206            // O(N) scan over the blob sub-pool (N <= max_blob_mempool_size, 512
207            // today). Fine at this cap; revisit (e.g. a priority index) before
208            // exposing a much larger cap via CLI.
209            let worst = self
210                .blobs_bundle_pool
211                .keys()
212                .filter_map(|hash| self.transaction_pool.get(hash).map(|tx| (*hash, tx)))
213                .max_by_key(|(_, tx)| {
214                    let baseline = min_nonce_by_sender.get(&tx.sender()).copied().unwrap_or(0);
215                    let offset = tx.nonce().saturating_sub(baseline);
216                    (offset, Reverse(tx.max_fee_per_blob_gas()))
217                })
218                .map(|(hash, _)| hash);
219            match worst {
220                Some(hash) => self.remove_transaction_with_lock(&hash)?,
221                None => {
222                    warn!(
223                        "Blob mempool is over cap but no evictable blob transaction is present, this should not happen"
224                    );
225                    break;
226                }
227            }
228        }
229
230        Ok(())
231    }
232}
233
234#[derive(Debug, Default)]
235pub struct Mempool {
236    inner: RwLock<MempoolInner>,
237    /// Signaled on transaction and blobs bundle insertions so payload
238    /// builders can await new work instead of busy-looping.
239    tx_added: tokio::sync::Notify,
240    /// Monotonic counter incremented on every transaction insertion. Used by
241    /// the payload builder to detect whether new txs landed since it last
242    /// snapshotted the mempool, so it can decide whether a stale build is safe
243    /// to return.
244    tx_seq: AtomicU64,
245}
246
247impl Mempool {
248    pub fn new(max_mempool_size: usize) -> Self {
249        Mempool {
250            inner: RwLock::new(MempoolInner::new(max_mempool_size)),
251            tx_added: tokio::sync::Notify::new(),
252            tx_seq: AtomicU64::new(0),
253        }
254    }
255
256    /// Override the blob sub-pool capacity (defaults to [`MAX_BLOB_MEMPOOL_SIZE`]).
257    /// Builder-style; intended for configuration and tests.
258    pub fn with_max_blob_mempool_size(self, max_blob_mempool_size: usize) -> Self {
259        if let Ok(mut inner) = self.inner.write() {
260            inner.max_blob_mempool_size = max_blob_mempool_size;
261        }
262        self
263    }
264
265    pub(crate) fn tx_added(&self) -> &tokio::sync::Notify {
266        &self.tx_added
267    }
268
269    pub(crate) fn tx_seq(&self) -> u64 {
270        self.tx_seq.load(Ordering::Acquire)
271    }
272
273    fn write(&self) -> Result<std::sync::RwLockWriteGuard<'_, MempoolInner>, StoreError> {
274        self.inner
275            .write()
276            .map_err(|error| StoreError::MempoolWriteLock(error.to_string()))
277    }
278
279    fn read(&self) -> Result<std::sync::RwLockReadGuard<'_, MempoolInner>, StoreError> {
280        self.inner
281            .read()
282            .map_err(|error| StoreError::MempoolReadLock(error.to_string()))
283    }
284
285    /// Add transaction to the pool without doing validity checks
286    pub fn add_transaction(
287        &self,
288        hash: H256,
289        sender: Address,
290        transaction: MempoolTransaction,
291    ) -> Result<(), StoreError> {
292        let mut inner = self.write()?;
293        let is_blob = matches!(transaction.tx_type(), TxType::EIP4844);
294        // Prune the regular order queue if it has grown too much
295        if inner.txs_order.len() > inner.mempool_prune_threshold {
296            // NOTE: we do this to avoid borrow checker errors
297            let txpool = core::mem::take(&mut inner.transaction_pool);
298            inner.txs_order.retain(|tx| txpool.contains_key(tx));
299            inner.transaction_pool = txpool;
300        }
301        // Blob txs are evicted against their own cap so a flood of regular txs
302        // can't push them out (and vice versa). Blob eviction is value/nonce
303        // ordered (see `remove_worst_blob_transaction`), not FIFO, so it never
304        // drops the next-includable blob tx; regular txs stay FIFO.
305        if is_blob {
306            // The bundle is inserted before the tx (see add_blob_transaction_to_pool),
307            // so the incoming blob is already counted by `blob_tx_count`.
308            if inner.blob_tx_count() > inner.max_blob_mempool_size {
309                inner.remove_worst_blob_transaction()?;
310            }
311        } else {
312            // The regular tx isn't in the pool yet (inserted below), so
313            // `regular_tx_count()` is the count *before* this tx: `>= max` means
314            // we're already at cap and must evict to make room. (Mirror of the
315            // blob branch, which uses `>` because the bundle is inserted first
316            // and is therefore already counted by `blob_tx_count`.)
317            if inner.regular_tx_count() >= inner.max_mempool_size {
318                inner.remove_oldest_regular_transaction()?;
319            }
320            inner.txs_order.push_back(hash);
321        }
322        inner
323            .txs_by_sender_nonce
324            .insert((sender, transaction.nonce()), hash);
325        inner.transaction_pool.insert(hash, transaction);
326        inner.broadcast_pool.insert(hash);
327        inner.alternates.remove(&hash);
328        // Drop the write lock before notifying to avoid holding it while waking waiters
329        drop(inner);
330        // Bump `tx_seq` *after* releasing the write lock. The payload builder
331        // snapshots `tx_seq` before reading the mempool; with this ordering,
332        // any reader that observes the new tx is guaranteed to also observe a
333        // bumped seq on its next load, so the builder never misses a tx it
334        // already incorporated as "new since last build".
335        self.tx_seq.fetch_add(1, Ordering::Release);
336        self.tx_added.notify_waiters();
337
338        Ok(())
339    }
340
341    pub fn get_txs_for_broadcast(&self) -> Result<Vec<MempoolTransaction>, StoreError> {
342        let inner = self.read()?;
343        let txs = inner
344            .transaction_pool
345            .iter()
346            .filter_map(|(hash, tx)| {
347                if !inner.broadcast_pool.contains(hash) {
348                    None
349                } else {
350                    Some(tx.clone())
351                }
352            })
353            .collect::<Vec<_>>();
354        Ok(txs)
355    }
356
357    pub fn remove_broadcasted_txs(&self, hashes: &[H256]) -> Result<(), StoreError> {
358        let mut inner = self.write()?;
359        for hash in hashes {
360            inner.broadcast_pool.remove(hash);
361        }
362        Ok(())
363    }
364
365    /// `(hash, sender, nonce)` for every blob tx in the pool. `blobs_bundle_pool`
366    /// is keyed by blob-tx hash, so its keys are exactly the held blob txs.
367    pub fn blob_txs(&self) -> Result<Vec<(H256, Address, u64)>, StoreError> {
368        let inner = self.read()?;
369        Ok(inner
370            .blobs_bundle_pool
371            .keys()
372            .filter_map(|hash| {
373                inner
374                    .transaction_pool
375                    .get(hash)
376                    .map(|tx| (*hash, tx.sender(), tx.nonce()))
377            })
378            .collect())
379    }
380
381    /// Add a blobs bundle to the pool by its blob transaction hash
382    pub fn add_blobs_bundle(
383        &self,
384        tx_hash: H256,
385        blobs_bundle: BlobsBundle,
386    ) -> Result<(), StoreError> {
387        let mut mempool = self.write()?;
388        for (i, c) in blobs_bundle.commitments.iter().enumerate() {
389            let versioned_hash = kzg_commitment_to_versioned_hash(c);
390            mempool
391                .blobs_bundle_by_versioned_hash
392                .entry(versioned_hash)
393                .or_default()
394                .insert(tx_hash, i);
395        }
396        mempool.blobs_bundle_pool.insert(tx_hash, blobs_bundle);
397        Ok(())
398    }
399
400    /// Get a blobs bundle to the pool given its blob transaction hash
401    pub fn get_blobs_bundle(&self, tx_hash: H256) -> Result<Option<BlobsBundle>, StoreError> {
402        Ok(self.read()?.blobs_bundle_pool.get(&tx_hash).cloned())
403    }
404
405    /// Remove a transaction from the pool
406    pub fn remove_transaction(&self, hash: &H256) -> Result<(), StoreError> {
407        let mut inner = self.write()?;
408        inner.remove_transaction_with_lock(hash)?;
409        Ok(())
410    }
411
412    /// Applies the filter and returns a set of suitable transactions from the mempool.
413    /// These transactions will be grouped by sender and sorted by nonce
414    pub fn filter_transactions(
415        &self,
416        filter: &PendingTxFilter,
417    ) -> Result<FxHashMap<Address, Vec<MempoolTransaction>>, StoreError> {
418        let filter_tx = |tx: &Transaction| -> bool {
419            // Filter by tx type
420            let is_blob_tx = matches!(tx, Transaction::EIP4844Transaction(_));
421            if filter.only_plain_txs && is_blob_tx || filter.only_blob_txs && !is_blob_tx {
422                return false;
423            }
424
425            // Filter by tip & base_fee
426            if let Some(min_tip) = filter.min_tip.map(U256::from) {
427                if tx
428                    .effective_gas_tip(filter.base_fee)
429                    .is_none_or(|tip| tip < min_tip)
430                {
431                    return false;
432                }
433            // This is a temporary fix to avoid invalid transactions to be included.
434            // This should be removed once https://github.com/lambdaclass/ethrex/issues/680
435            // is addressed.
436            } else if tx.effective_gas_tip(filter.base_fee).is_none() {
437                return false;
438            }
439
440            // Filter by blob gas fee
441            if is_blob_tx
442                && let Some(blob_fee) = filter.blob_fee
443                && tx
444                    .max_fee_per_blob_gas()
445                    .is_none_or(|fee| fee < blob_fee.into())
446            {
447                return false;
448            }
449            true
450        };
451        self.filter_transactions_with_filter_fn(&filter_tx)
452    }
453
454    /// Gets all the transactions in the mempool
455    pub fn get_all_txs_by_sender(
456        &self,
457    ) -> Result<FxHashMap<Address, Vec<MempoolTransaction>>, StoreError> {
458        let mut txs_by_sender: FxHashMap<Address, Vec<MempoolTransaction>> =
459            FxHashMap::with_capacity_and_hasher(128, Default::default());
460        let tx_pool = &self.read()?.transaction_pool;
461
462        for (_, tx) in tx_pool.iter() {
463            txs_by_sender
464                .entry(tx.sender())
465                .or_insert_with(|| Vec::with_capacity(128))
466                .push(tx.clone())
467        }
468
469        txs_by_sender.iter_mut().for_each(|(_, txs)| txs.sort());
470        Ok(txs_by_sender)
471    }
472
473    /// Applies the filter and returns a set of suitable transactions from the mempool.
474    /// These transactions will be grouped by sender and sorted by nonce
475    pub fn filter_transactions_with_filter_fn(
476        &self,
477        filter: &dyn Fn(&Transaction) -> bool,
478    ) -> Result<FxHashMap<Address, Vec<MempoolTransaction>>, StoreError> {
479        let mut txs_by_sender: FxHashMap<Address, Vec<MempoolTransaction>> =
480            FxHashMap::with_capacity_and_hasher(128, Default::default());
481        let tx_pool = &self.read()?.transaction_pool;
482
483        for (_, tx) in tx_pool.iter() {
484            if filter(tx) {
485                txs_by_sender
486                    .entry(tx.sender())
487                    .or_insert_with(|| Vec::with_capacity(128))
488                    .push(tx.clone())
489            }
490        }
491
492        txs_by_sender.iter_mut().for_each(|(_, txs)| txs.sort());
493        Ok(txs_by_sender)
494    }
495
496    /// Filters hashes to those not already in the mempool or in-flight, and
497    /// atomically marks the returned hashes as in-flight under a single write
498    /// lock so that concurrent peer handlers cannot request the same hashes.
499    ///
500    /// For hashes that get filtered out *because they're already in-flight
501    /// from another peer*, records `announcer` as a fallback so the request
502    /// can be retried against this peer if the original responder fails. New
503    /// hashes that the caller is about to request do not need an alternates
504    /// entry yet: the caller is the primary, and one will be created only if
505    /// some other peer later announces the same hash while it's in-flight.
506    /// Reserve hashes the caller wants to request, returning only those that are
507    /// neither already in-flight nor already in the pool. Any hash filtered out
508    /// because it's in-flight from another peer is registered with the caller's
509    /// own (type, size) metadata as an alternate, so a later retry can validate
510    /// the response against this announcer's announcement.
511    ///
512    /// `hashes`, `types`, and `sizes` must be the same length (one entry per
513    /// announced hash).
514    pub fn reserve_unknown_hashes(
515        &self,
516        hashes: &[H256],
517        types: &[u8],
518        sizes: &[usize],
519        announcer: H256,
520    ) -> Result<Vec<H256>, StoreError> {
521        debug_assert_eq!(hashes.len(), types.len());
522        debug_assert_eq!(hashes.len(), sizes.len());
523
524        let mut inner = self.write()?;
525
526        let unknown: Vec<H256> = hashes
527            .iter()
528            .filter(|hash| {
529                !inner.in_flight_txs.contains(hash) && !inner.transaction_pool.contains_key(hash)
530            })
531            .copied()
532            .collect();
533
534        inner.in_flight_txs.extend(unknown.iter().copied());
535
536        // Register alternates only for hashes the caller will *not* request
537        // (i.e. those already in-flight from someone else). Skip pool hits
538        // and skip hashes we just reserved for this peer.
539        if hashes.len() > unknown.len() {
540            let unknown_set: FxHashSet<H256> = unknown.iter().copied().collect();
541            let now = Instant::now();
542            for (i, hash) in hashes.iter().enumerate() {
543                if unknown_set.contains(hash) || inner.transaction_pool.contains_key(hash) {
544                    continue;
545                }
546                let alt = Alternate {
547                    peer_id: announcer,
548                    tx_type: types[i],
549                    tx_size: sizes[i],
550                };
551                let entry = inner
552                    .alternates
553                    .entry(*hash)
554                    .or_insert_with(|| (VecDeque::new(), now));
555                entry.1 = now;
556                if !entry.0.iter().any(|a| a.peer_id == announcer) {
557                    if entry.0.len() >= MAX_ALTERNATES_PER_HASH {
558                        entry.0.pop_front();
559                    }
560                    entry.0.push_back(alt);
561                }
562            }
563        }
564
565        Ok(unknown)
566    }
567
568    /// Removes transaction hashes from the in-flight set, typically called
569    /// when the GetPooledTransactions response arrives (or the connection drops).
570    pub fn clear_in_flight_txs(&self, hashes: &[H256]) -> Result<(), StoreError> {
571        let mut inner = self.write()?;
572        for hash in hashes {
573            inner.in_flight_txs.remove(hash);
574        }
575        Ok(())
576    }
577
578    /// Pops the next alternate announcer for the given hash, if any. Returns
579    /// `Ok(None)` when no alternates remain. The caller uses the popped
580    /// `Alternate` to look up the peer connection and build a retry request
581    /// against that peer's own announcement metadata.
582    pub fn pop_alternate(&self, hash: H256) -> Result<Option<Alternate>, StoreError> {
583        let mut inner = self.write()?;
584        let Some(entry) = inner.alternates.get_mut(&hash) else {
585            return Ok(None);
586        };
587        let popped = entry.0.pop_front();
588        if entry.0.is_empty() {
589            inner.alternates.remove(&hash);
590        }
591        Ok(popped)
592    }
593
594    /// Drop alternates entries that haven't been touched in the last `ttl`.
595    /// Called periodically to bound the size of the alternates map when
596    /// announced txs never make it into the pool.
597    pub fn prune_alternates(&self, ttl: Duration) -> Result<(), StoreError> {
598        let mut inner = self.write()?;
599        let now = Instant::now();
600        inner
601            .alternates
602            .retain(|_, (_, last_seen)| now.saturating_duration_since(*last_seen) < ttl);
603        Ok(())
604    }
605
606    pub fn get_transaction_by_hash(
607        &self,
608        transaction_hash: H256,
609    ) -> Result<Option<Transaction>, StoreError> {
610        let tx = self
611            .read()?
612            .transaction_pool
613            .get(&transaction_hash)
614            .map(|e| e.transaction().clone());
615
616        Ok(tx)
617    }
618
619    pub fn get_nonce(&self, address: &Address) -> Result<Option<u64>, MempoolError> {
620        Ok(self
621            .read()?
622            .txs_by_sender_nonce
623            .range((*address, 0)..=(*address, u64::MAX))
624            .last()
625            .map(|((_address, nonce), _hash)| nonce + 1))
626    }
627
628    pub fn get_mempool_size(&self) -> Result<(u64, u64), MempoolError> {
629        let txs_size = {
630            let pool_lock = &self.read()?.transaction_pool;
631            pool_lock.len()
632        };
633        let blobs_size = {
634            let pool_lock = &self.read()?.blobs_bundle_pool;
635            pool_lock.len()
636        };
637
638        Ok((txs_size as u64, blobs_size as u64))
639    }
640
641    /// Returns all transactions currently in the pool
642    pub fn content(&self) -> Result<Vec<Transaction>, MempoolError> {
643        let pooled_transactions = &self.read()?.transaction_pool;
644        Ok(pooled_transactions
645            .values()
646            .map(MempoolTransaction::transaction)
647            .cloned()
648            .collect())
649    }
650
651    /// Returns all blobs bundles currently in the pool
652    pub fn get_blobs_bundle_pool(&self) -> Result<Vec<BlobsBundle>, MempoolError> {
653        let blobs_bundle_pool = &self.read()?.blobs_bundle_pool;
654        Ok(blobs_bundle_pool.values().cloned().collect())
655    }
656
657    /// Returns blobs data (blob, commitment, proof) associated with the versioned hashes
658    pub fn get_blobs_data_by_versioned_hashes(
659        &self,
660        versioned_hashes: &[H256],
661    ) -> Result<Vec<Option<BlobTuple>>, MempoolError> {
662        let mempool = self.read()?;
663        let blobs_bundle_pool = &mempool.blobs_bundle_pool;
664        let blobs_bundle_by_versioned_hash = &mempool.blobs_bundle_by_versioned_hash;
665        let mut res = vec![None; versioned_hashes.len()];
666        for (idx, vh) in versioned_hashes.iter().enumerate() {
667            if let Some((found_hash, inner_pos)) = blobs_bundle_by_versioned_hash
668                .get(vh)
669                .and_then(|h| h.iter().next())
670            {
671                res[idx] = blobs_bundle_pool
672                    .get(found_hash)
673                    .and_then(|b| b.get_blob_tuple_by_index(*inner_pos))
674            }
675        }
676        Ok(res)
677    }
678
679    /// Returns the status of the mempool, which is the number of transactions currently in
680    /// the pool. Until we add "queue" transactions.
681    pub fn status(&self) -> Result<u64, MempoolError> {
682        let pool_lock = &self.read()?.transaction_pool;
683
684        Ok(pool_lock.len() as u64)
685    }
686
687    pub fn contains_sender_nonce(
688        &self,
689        sender: Address,
690        nonce: u64,
691        received_hash: H256,
692    ) -> Result<Option<MempoolTransaction>, MempoolError> {
693        let Some(hash) = self
694            .read()?
695            .txs_by_sender_nonce
696            .get(&(sender, nonce))
697            .cloned()
698        else {
699            return Ok(None);
700        };
701        if hash == received_hash {
702            return Ok(None);
703        }
704
705        let transaction_pool = &self.read()?.transaction_pool;
706        let tx = transaction_pool.get(&hash).cloned();
707        Ok(tx)
708    }
709
710    pub fn contains_tx(&self, tx_hash: H256) -> Result<bool, MempoolError> {
711        let contains = self.read()?.transaction_pool.contains_key(&tx_hash);
712        Ok(contains)
713    }
714
715    pub fn find_tx_to_replace(
716        &self,
717        sender: Address,
718        nonce: u64,
719        tx: &Transaction,
720    ) -> Result<Option<H256>, MempoolError> {
721        let Some(tx_in_pool) = self.contains_sender_nonce(sender, nonce, tx.hash(&NativeCrypto))?
722        else {
723            return Ok(None);
724        };
725        let is_a_replacement_tx = {
726            // EIP-1559 values
727            let old_tx_max_fee_per_gas = tx_in_pool.max_fee_per_gas().unwrap_or_default();
728            let old_tx_max_priority_fee_per_gas = tx_in_pool.max_priority_fee().unwrap_or_default();
729            let new_tx_max_fee_per_gas = tx.max_fee_per_gas().unwrap_or_default();
730            let new_tx_max_priority_fee_per_gas = tx.max_priority_fee().unwrap_or_default();
731
732            // Legacy tx values
733            let old_tx_gas_price = tx_in_pool.gas_price();
734            let new_tx_gas_price = tx.gas_price();
735
736            // EIP-4844 values
737            let old_tx_max_fee_per_blob = tx_in_pool.max_fee_per_blob_gas();
738            let new_tx_max_fee_per_blob = tx.max_fee_per_blob_gas();
739
740            let eip4844_higher_fees = if let (Some(old_blob_fee), Some(new_blob_fee)) =
741                (old_tx_max_fee_per_blob, new_tx_max_fee_per_blob)
742            {
743                new_blob_fee > old_blob_fee
744            } else {
745                true // We are marking it as always true if the tx is not eip-4844
746            };
747
748            let eip1559_higher_fees = new_tx_max_fee_per_gas > old_tx_max_fee_per_gas
749                && new_tx_max_priority_fee_per_gas > old_tx_max_priority_fee_per_gas;
750            let legacy_higher_fees = new_tx_gas_price > old_tx_gas_price;
751
752            eip4844_higher_fees && (eip1559_higher_fees || legacy_higher_fees)
753        };
754
755        if !is_a_replacement_tx {
756            return Err(MempoolError::UnderpricedReplacement);
757        }
758
759        Ok(Some(tx_in_pool.hash(&NativeCrypto)))
760    }
761}
762
763/// Filter applied by the payload builder when querying pending transactions
764/// from the pool. NOT a mempool admission gate — all fields here are
765/// query-time filters used to pick block-includable transactions. Admission
766/// rules are enforced in `Blockchain::validate_transaction`.
767#[derive(Debug, Default)]
768pub struct PendingTxFilter {
769    /// Minimum effective priority fee for a transaction to be surfaced to
770    /// the payload builder. This is a block-building filter, not an
771    /// admission check — see `crates/common/types/constants.rs::MIN_GAS_TIP`.
772    pub min_tip: Option<u64>,
773    pub base_fee: Option<u64>,
774    pub blob_fee: Option<u64>,
775    pub only_plain_txs: bool,
776    pub only_blob_txs: bool,
777}
778
779pub fn transaction_intrinsic_gas(
780    tx: &Transaction,
781    header: &BlockHeader,
782    config: &ChainConfig,
783) -> Result<u64, MempoolError> {
784    // Mempool admission must charge the same intrinsic gas LEVM enforces at
785    // execution, or we admit txs the VM later rejects (pool pollution, wasted
786    // payload-builder cycles). Reuse the VM's two helpers directly rather than
787    // re-deriving the cost here:
788    //   - `intrinsic_gas_dimensions` → (regular, state) including the EIP-7702
789    //     per-authorization-tuple cost and EIP-7981 access-list data bytes;
790    //   - `intrinsic_gas_floor` → the EIP-7623/7976 calldata floor.
791    // The VM requires `gas_limit >= max(intrinsic_regular + intrinsic_state,
792    // floor)` (two separate checks in `validate_gas_allowance` +
793    // `validate_min_gas_limit`); mirror that max here. This is fork-general,
794    // so it covers Prague (auth-list cost + calldata floor) as well as
795    // Amsterdam, and keeps mempool admission in lockstep with the VM.
796    let fork = config.fork(header.timestamp);
797    let (regular, state) = intrinsic_gas_dimensions(tx, fork, header.gas_limit)
798        .map_err(|e| MempoolError::IntrinsicGasError(e.to_string()))?;
799    let intrinsic = regular
800        .checked_add(state)
801        .ok_or(MempoolError::TxGasOverflowError)?;
802    // The EIP-7623 calldata floor only exists from Prague onward; the VM gates
803    // it the same way (`fork >= Fork::Prague` in the default hook). Applying it
804    // pre-Prague would spuriously raise the admission threshold.
805    let calldata_floor = if fork >= Fork::Prague {
806        intrinsic_gas_floor(tx, fork).map_err(|e| MempoolError::IntrinsicGasError(e.to_string()))?
807    } else {
808        0
809    };
810    Ok(intrinsic.max(calldata_floor))
811}