Skip to main content

kaspa_mining/mempool/model/
frontier.rs

1use crate::{
2    Policy,
3    feerate::{FeerateEstimator, FeerateEstimatorArgs},
4};
5
6use feerate_key::FeerateTransactionKey;
7use kaspa_consensus_core::{
8    block::TemplateTransactionSelector,
9    config::constants::consensus::{DEFAULT_GAS_PER_LANE_LIMIT, DEFAULT_LANES_PER_BLOCK_LIMIT},
10    mass::BlockLaneLimits,
11    subnets::SubnetworkId,
12    tx::{Transaction, TransactionId},
13};
14use kaspa_core::trace;
15use rand::{Rng, distributions::Uniform, prelude::Distribution};
16use search_tree::SearchTree;
17use selectors::{MutatingTreeSelector, SequenceSelector, SequenceSelectorInput, TakeAllSelector};
18use std::{
19    collections::{BTreeSet, BinaryHeap, HashMap, HashSet},
20    iter::FusedIterator,
21    sync::Arc,
22};
23
24pub(crate) mod feerate_key;
25pub(crate) mod search_tree;
26pub(crate) mod selectors;
27
28/// If the frontier contains less than 4x the block mass limit, we consider
29/// inplace sampling to be less efficient (due to collisions) and thus use
30/// the mutating-tree selector
31const COLLISION_FACTOR: u64 = 4;
32
33/// Multiplication factor for in-place sampling. We sample 20% more than the
34/// hard limit in order to allow the SequenceSelector to compensate for consensus rejections.
35const MASS_LIMIT_FACTOR: f64 = 1.2;
36
37/// Extra sampling stops once the greedy pack gap is at most this fraction of the block mass.
38const TARGET_GAP_FACTOR: f64 = 0.05;
39
40/// Bounds extra sampling caused by large transactions which increase sampled mass
41/// but do not help the later greedy SequenceSelector fill the block.
42const MAX_NULL_ATTEMPTS: usize = 8;
43
44/// Initial estimation of the average transaction mass.
45const INITIAL_AVG_MASS: f64 = 2036.0;
46
47/// Decay factor of average mass weighting.
48const AVG_MASS_DECAY_FACTOR: f64 = 0.99999;
49
50const DEFAULT_BLOCK_LANE_LIMITS: BlockLaneLimits =
51    BlockLaneLimits { lanes_per_block: DEFAULT_LANES_PER_BLOCK_LIMIT, gas_per_lane: DEFAULT_GAS_PER_LANE_LIMIT };
52
53/// Management of the transaction pool frontier, that is, the set of transactions in
54/// the transaction pool which have no mempool ancestors and are essentially ready
55/// to enter the next block template.
56pub struct Frontier {
57    /// Frontier transactions sorted by feerate order and searchable for weight sampling
58    search_tree: SearchTree,
59
60    /// Frontier transactions additionally grouped by lane for post-LPB capped selection
61    by_lane: HashMap<SubnetworkId, BTreeSet<FeerateTransactionKey>>,
62
63    /// Total masses: Σ_{tx in frontier} tx.mass
64    total_mass: u64,
65
66    /// Tracks the average transaction mass throughout the mempool's lifespan using a decayed weighting mechanism
67    average_transaction_mass: f64,
68
69    target_time_per_block_seconds: f64,
70}
71
72#[derive(Default)]
73struct Lanes {
74    occupied: HashSet<SubnetworkId>,
75    frozen: bool,
76}
77
78struct SampleMassTracker {
79    /// Raw sampled mass. This counts every sampled tx, including txs that the
80    /// downstream SequenceSelector might skip because they do not fit the
81    /// remaining block gap.
82    sampled: u64,
83
84    /// Remaining mass that the downstream SequenceSelector would see after
85    /// greedily accepting sampled txs that fit in their current order.
86    gap: u64,
87
88    /// The normal sampling target, currently 1.2x the block mass limit.
89    desired: u64,
90
91    /// Number of sampled txs that failed to shrink the greedy pack gap.
92    null_attempts: usize,
93
94    /// Acceptable remaining greedy-pack gap after the normal sampling target is reached.
95    target_gap: u64,
96}
97
98impl SampleMassTracker {
99    fn new(policy: &Policy) -> Self {
100        // Sample 20% more than the hard limit in order to allow the SequenceSelector to
101        // compensate for consensus rejections.
102        // Note that this is a soft target: sampling may pass it by one tx, and the
103        // tracker may extend it when a block-sized tx exposes a greedy-pack gap.
104        let desired = (policy.max_block_mass as f64 * MASS_LIMIT_FACTOR) as u64;
105
106        // Target a remaining greedy-pack gap of at most 5% of block mass
107        // (25K mass for the current 500K block mass limit).
108        let target_gap = (policy.max_block_mass as f64 * TARGET_GAP_FACTOR) as u64;
109
110        Self { sampled: 0, gap: policy.max_block_mass, desired, null_attempts: 0, target_gap }
111    }
112
113    /// Returns whether sampling should keep trying to build a useful sequence.
114    fn should_continue(&self) -> bool {
115        // Halt only if both:
116        // 1. raw sampled mass already reached the normal target; and
117        // 2. either the greedy-pack gap is small enough, or too many samples failed to shrink it.
118        self.sampled <= self.desired || (self.null_attempts < MAX_NULL_ATTEMPTS && self.gap > self.target_gap)
119    }
120
121    /// Adds the sampled mass and updates the greedy-pack gap/null-attempt counters.
122    fn record(&mut self, mass: u64) {
123        self.sampled = self.sampled.saturating_add(mass);
124
125        if let Some(gap) = self.gap.checked_sub(mass) {
126            self.gap = gap;
127        } else {
128            // This tx increased raw sampled mass but did not shrink the greedy
129            // pack gap. Bound how many such null attempts can keep sampling alive.
130            #[allow(clippy::arithmetic_side_effects, reason = "ARITH-SAFETY(COUNTER)")]
131            {
132                self.null_attempts += 1;
133            }
134        }
135    }
136}
137
138impl Frontier {
139    pub fn new(target_time_per_block_seconds: f64) -> Self {
140        Self {
141            search_tree: Default::default(),
142            by_lane: Default::default(),
143            total_mass: Default::default(),
144            average_transaction_mass: INITIAL_AVG_MASS,
145            target_time_per_block_seconds,
146        }
147    }
148}
149
150impl Frontier {
151    pub fn total_weight(&self) -> f64 {
152        self.search_tree.total_weight()
153    }
154
155    pub fn total_mass(&self) -> u64 {
156        self.total_mass
157    }
158
159    pub fn len(&self) -> usize {
160        self.search_tree.len()
161    }
162
163    pub fn is_empty(&self) -> bool {
164        self.len() == 0
165    }
166
167    pub fn insert(&mut self, key: FeerateTransactionKey) -> bool {
168        let mass = key.mass;
169        let lane = key.lane();
170        if self.search_tree.insert(key.clone()) {
171            self.by_lane.entry(lane).or_default().insert(key);
172
173            #[allow(
174                clippy::arithmetic_side_effects,
175                reason = "test_maximum_mempool_mass_fits_u64_with_large_margin ensures that self.total_mass << u64::MAX, and mass <= max block size << u64::MAX, so self.total_mass + mass << u64::MAX"
176            )]
177            {
178                self.total_mass += mass;
179            }
180            // A decaying average formula. Denote ɛ = 1 - AVG_MASS_DECAY_FACTOR. A transaction inserted N slots ago has
181            // ɛ * (1 - ɛ)^N weight within the updated average. This gives some weight to the full mempool history while
182            // giving higher importance to more recent samples.
183            self.average_transaction_mass =
184                self.average_transaction_mass * AVG_MASS_DECAY_FACTOR + mass as f64 * (1.0 - AVG_MASS_DECAY_FACTOR);
185            true
186        } else {
187            false
188        }
189    }
190
191    pub fn remove(&mut self, key: &FeerateTransactionKey) -> bool {
192        let mass = key.mass;
193        if self.search_tree.remove(key) {
194            let lane = key.lane();
195            let mut remove_lane = false;
196            if let Some(entries) = self.by_lane.get_mut(&lane) {
197                entries.remove(key);
198                remove_lane = entries.is_empty();
199            }
200            if remove_lane {
201                self.by_lane.remove(&lane);
202            }
203
204            #[allow(
205                clippy::arithmetic_side_effects,
206                reason = "Removing mass that was previously added to total_mass, so this cannot underflow."
207            )]
208            {
209                self.total_mass -= mass;
210            }
211            true
212        } else {
213            false
214        }
215    }
216
217    /// Samples the frontier in-place based on the provided policy and returns a SequenceSelector.
218    ///
219    /// This sampling algorithm should be used when frontier total mass is high enough compared to
220    /// policy mass limit so that the probability of sampling collisions remains low.
221    ///
222    /// Sampling follows the usual weighted feerate path until the sampled sequence
223    /// first occupies `policy.lanes_per_block_limit` lanes. From that point on, lane
224    /// selection is frozen and sampling continues only within the already-occupied
225    /// lanes.
226    ///
227    /// Convergence analysis:
228    ///     1. Based on the above we can safely assume that `k << n`, where `n` is the total number of frontier items
229    ///        and `k` is the number of actual samples (since `desired_mass << total_mass` and mass per item is bounded)
230    ///     2. Indeed, if the weight distribution is not too spread (i.e., `max(weights) = O(min(weights))`), `k << n` means
231    ///        that the probability of collisions is low enough and the sampling process will converge in `O(k log(n))` w.h.p.
232    ///     3. It remains to deal with the case where the weight distribution is highly biased. The process implemented below
233    ///        keeps track of the top-weight element. If the distribution is highly biased, this element will be sampled with
234    ///        sufficient probability (in constant time). Following each sampling collision we search for a consecutive range of
235    ///        top elements which were already sampled and narrow the sampling space to exclude them all. We do this by computing
236    ///        the prefix weight up to the top most item which wasn't sampled yet (inclusive) and then continue the sampling process
237    ///        over the narrowed space. This process is repeated until acquiring the desired mass.
238    ///     4. Numerical stability. Naively, one would simply subtract `total_weight -= top.weight` in order to narrow the sampling
239    ///        space. However, if `top.weight` is much larger than the remaining weight, the above f64 subtraction will yield a number
240    ///        close or equal to zero. We fix this by implementing a `log(n)` prefix weight operation.
241    ///     5. Q. Why not just use u64 weights?
242    ///        A. The current weight calculation is `feerate^alpha` with `alpha=3`. Using u64 would mean that the feerate space
243    ///           is limited to a range of size `(2^64)^(1/3) = ~2^21 = ~2M`. Already with current usages, the feerate can vary
244    ///           from `~1/50` (2000 sompi for a transaction with 100K storage mass), to `5M` (100 KAS fee for a transaction with
245    ///           2000 mass = 100·100_000_000/2000), resulting in a range of size 250M (`5M/(1/50)`).
246    ///           By using floating point arithmetics we gain the adjustment of the probability space to the accuracy level required for
247    ///           current samples. And if the space is highly biased, the repeated elimination of top items and the prefix weight computation
248    ///           will readjust it.
249    pub fn sample_inplace<R>(&self, rng: &mut R, policy: &Policy, _collisions: &mut u64) -> SequenceSelectorInput
250    where
251        R: Rng + ?Sized,
252    {
253        debug_assert!(!self.search_tree.is_empty(), "expected to be called only if not empty");
254
255        let mut distr = Uniform::new(0f64, self.total_weight());
256        let mut down_iter = self.search_tree.descending_iter();
257        let mut top = down_iter.next().unwrap();
258        let mut cache = HashSet::new();
259        let mut sequence = SequenceSelectorInput::default();
260        let mut mass = SampleMassTracker::new(policy);
261        let mut collisions = 0;
262        let mut lanes = Lanes::default();
263
264        // The sampling process is converging so the cache will eventually hold all entries, which guarantees loop exit
265        'outer: while cache.len() < self.search_tree.len() && mass.should_continue() {
266            let query = distr.sample(rng);
267            let item = {
268                let mut item = self.search_tree.search(query);
269                while !cache.insert(item.tx.id()) {
270                    #[allow(clippy::arithmetic_side_effects, reason = "ARITH-SAFETY(COUNTER)")]
271                    {
272                        collisions += 1;
273                    }
274                    // Try to narrow the sampling space in order to reduce further sampling collisions
275                    if cache.contains(&top.tx.id()) {
276                        loop {
277                            match down_iter.next() {
278                                Some(next) => top = next,
279                                None => break 'outer,
280                            }
281                            // Loop until finding a top item which was not sampled yet
282                            if !cache.contains(&top.tx.id()) {
283                                break;
284                            }
285                        }
286                        let remaining_weight = self.search_tree.prefix_weight(top);
287                        distr = Uniform::new(0f64, remaining_weight);
288                    }
289                    let query = distr.sample(rng);
290                    item = self.search_tree.search(query);
291                }
292                item
293            };
294            if lanes.occupied.len() < policy.lanes_per_block_limit {
295                lanes.occupied.insert(item.lane());
296            } else if !lanes.occupied.contains(&item.lane()) {
297                // The weighted sampler wants to spill outside the first LPB discovered lanes.
298                // Freeze L here and complete the remaining selection within those lanes only.
299                lanes.frozen = true;
300                break;
301            }
302            sequence.push(item.tx.clone(), item.mass);
303            mass.record(item.mass);
304        }
305
306        if lanes.frozen {
307            self.finish_intra_lane_selection(&mut sequence, &cache, &lanes, &mut mass);
308        }
309        trace!("[mempool frontier sample inplace] collisions: {collisions}, cache: {}", cache.len());
310        #[allow(clippy::arithmetic_side_effects, reason = "ARITH-SAFETY(COUNTER)")]
311        {
312            *_collisions += collisions;
313        }
314        sequence
315    }
316
317    /// Completes a sample by selecting transactions only from lanes that already occupy LPB slots.
318    ///
319    /// The initial sampling phase remains fully weighted until it first attempts to spill outside
320    /// the first LPB lanes. From that point on, we deterministically fill from the frozen lane set,
321    /// using a heap of per-lane heads so each selected transaction costs `O(log LPB)`.
322    ///
323    /// This is a temporary, deliberately simple policy. It is not globally optimal for miner fees,
324    /// but keeps template construction fast while bounding lane fanout.
325    fn finish_intra_lane_selection(
326        &self,
327        sequence: &mut SequenceSelectorInput,
328        cache: &HashSet<TransactionId>,
329        lanes: &Lanes,
330        mass: &mut SampleMassTracker,
331    ) {
332        let mut lane_iters = lanes
333            .occupied
334            .iter()
335            .filter_map(|lane| {
336                self.by_lane.get(lane).map(|entries| entries.iter().rev().filter(|item| !cache.contains(&item.tx.id())))
337            })
338            .collect::<Vec<_>>();
339        let mut heads = BinaryHeap::new();
340
341        // Seed the heap with the best uncached transaction from each occupied lane.
342        for (idx, iter) in lane_iters.iter_mut().enumerate() {
343            if let Some(item) = iter.next() {
344                heads.push((item.clone(), idx));
345            }
346        }
347
348        // Standard k-way merge: pop the best lane head, then replenish only that lane.
349        while mass.should_continue() {
350            let Some((item, best_idx)) = heads.pop() else {
351                break;
352            };
353
354            sequence.push(item.tx.clone(), item.mass);
355            mass.record(item.mass);
356
357            // Advance the lane we just consumed. The iterator already skips pre-freeze samples.
358            let iter = &mut lane_iters[best_idx];
359            if let Some(next) = iter.next() {
360                heads.push((next.clone(), best_idx));
361            }
362        }
363    }
364
365    /// Dynamically builds a transaction selector based on the specific state of the ready transactions frontier.
366    ///
367    /// The logic is divided into three cases:
368    ///     1. The frontier is small and can fit entirely into a block: perform no sampling and return
369    ///        a TakeAllSelector
370    ///     2. The frontier has at least ~4x the capacity of a block: expected collision rate is low, perform
371    ///        in-place k*log(n) sampling and return a SequenceSelector
372    ///     3. The frontier has 1-4x capacity of a block. In this case we expect a high collision rate while
373    ///        the number of overall transactions is still low, so we clone the weighted tree and remove selected
374    ///        or skipped candidates from the clone (performing the actual sampling out of the mempool lock)
375    ///
376    /// The above thresholds were selected based on benchmarks. Overall, this dynamic selection provides
377    /// full transaction selection in less than 150 µs even if the frontier has 1M entries (!!). See mining/benches
378    /// for more details.
379    pub fn build_selector(&self, policy: &Policy) -> Box<dyn TemplateTransactionSelector> {
380        #[allow(clippy::arithmetic_side_effects, reason = "policy.max_block_mass and COLLISION_FACTOR are small enough.")]
381        if self.total_mass <= policy.max_block_mass {
382            // TakeAll can still filter by LPB/gas, so feed it best-first.
383            Box::new(TakeAllSelector::new(self.search_tree.descending_iter().map(|k| k.tx.clone()).collect(), policy.clone()))
384        } else if self.total_mass > policy.max_block_mass * COLLISION_FACTOR {
385            let mut rng = rand::thread_rng();
386            Box::new(SequenceSelector::new(self.sample_inplace(&mut rng, policy, &mut 0), policy.clone()))
387        } else {
388            Box::new(MutatingTreeSelector::new(policy.clone(), self.search_tree.clone()))
389        }
390    }
391
392    /// Exposed for benchmarking purposes
393    pub fn build_selector_sample_inplace(&self, _collisions: &mut u64) -> Box<dyn TemplateTransactionSelector> {
394        let mut rng = rand::thread_rng();
395        let policy = Policy::new(500_000, DEFAULT_BLOCK_LANE_LIMITS);
396        Box::new(SequenceSelector::new(self.sample_inplace(&mut rng, &policy, _collisions), policy))
397    }
398
399    /// Exposed for benchmarking purposes
400    pub fn build_selector_take_all(&self) -> Box<dyn TemplateTransactionSelector> {
401        Box::new(TakeAllSelector::new(
402            self.search_tree.descending_iter().map(|k| k.tx.clone()).collect(),
403            Policy::new(500_000, DEFAULT_BLOCK_LANE_LIMITS),
404        ))
405    }
406
407    /// Exposed for benchmarking purposes
408    pub fn build_mutating_tree_selector(&self) -> Box<dyn TemplateTransactionSelector> {
409        Box::new(MutatingTreeSelector::new(Policy::new(500_000, DEFAULT_BLOCK_LANE_LIMITS), self.search_tree.clone()))
410    }
411
412    /// Builds a feerate estimator based on internal state of the ready transactions frontier
413    pub fn build_feerate_estimator(&self, args: FeerateEstimatorArgs) -> FeerateEstimator {
414        let average_transaction_mass = self.average_transaction_mass;
415        let bps = args.network_blocks_per_second as f64;
416        let mut mass_per_block = args.maximum_mass_per_block as f64;
417        let mut inclusion_interval = average_transaction_mass / (mass_per_block * bps);
418        let mut estimator = FeerateEstimator::new(self.total_weight(), inclusion_interval, self.target_time_per_block_seconds);
419
420        // Search for better estimators by possibly removing extremely high outliers
421        let mut down_iter = self.search_tree.descending_iter().peekable();
422        while let Some(current) = down_iter.next() {
423            // Update values for the coming iteration. In order to remove the outlier from the
424            // total weight, we must compensate by capturing a block slot. Note we capture the
425            // slot with correspondence to the outlier actual mass. This is important in cases
426            // where the high-feerate txs have mass which deviates from the average.
427            mass_per_block -= current.mass as f64;
428            if mass_per_block <= average_transaction_mass {
429                // Out of block slots, break
430                break;
431            }
432
433            // Re-calc the inclusion interval based on the new block "capacity".
434            // Note that inclusion_interval < 1.0 as required by the estimator, since mass_per_block > average_transaction_mass (by condition above) and bps >= 1
435            inclusion_interval = average_transaction_mass / (mass_per_block * bps);
436
437            // Compute the weight up to, and excluding, current key (which translates to zero weight if peek() is none)
438            let prefix_weight = down_iter.peek().map(|key| self.search_tree.prefix_weight(key)).unwrap_or_default();
439            let pending_estimator = FeerateEstimator::new(prefix_weight, inclusion_interval, self.target_time_per_block_seconds);
440
441            // Test the pending estimator vs. the current one
442            if pending_estimator.feerate_to_time(1.0) < estimator.feerate_to_time(1.0) {
443                estimator = pending_estimator;
444            } else {
445                // The pending estimator is no better, break. Indicates that the reduction in
446                // network mass per second is more significant than the removed weight
447                break;
448            }
449        }
450        estimator
451    }
452
453    /// Returns an iterator to the transactions in the frontier in increasing feerate order
454    pub fn ascending_iter(&self) -> impl DoubleEndedIterator<Item = &Arc<Transaction>> + ExactSizeIterator + FusedIterator {
455        self.search_tree.ascending_iter().map(|key| &key.tx)
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use crate::mempool::config::{Config, DEFAULT_MAXIMUM_TRANSACTION_COUNT};
463    use feerate_key::tests::build_feerate_key;
464    use itertools::Itertools;
465    use kaspa_consensus_core::{
466        config::params::{DEVNET_PARAMS, MAINNET_PARAMS, SIMNET_PARAMS, TESTNET_PARAMS},
467        mass::BlockMassLimits,
468        subnets::SubnetworkId,
469        tx::{Transaction, TransactionInput, TransactionOutpoint},
470    };
471    use kaspa_hashes::{HasherBase, TransactionID};
472    use rand::{SeedableRng, rngs::StdRng, thread_rng};
473    use std::collections::HashMap;
474    use std::sync::Arc;
475
476    fn build_feerate_key_with_lane(fee: u64, mass: u64, id: u64, lane: SubnetworkId, gas: u64) -> FeerateTransactionKey {
477        let mut hasher = TransactionID::new();
478        let prev = hasher.update(id.to_le_bytes()).clone().finalize();
479        let input = TransactionInput::new(TransactionOutpoint::new(prev, 0), vec![], 0, 0);
480        let tx = Arc::new(Transaction::new(0, vec![input], vec![], 0, lane, gas, vec![]));
481        FeerateTransactionKey::new(fee, mass, tx)
482    }
483
484    #[test]
485    pub fn test_highly_irregular_sampling() {
486        let mut rng = thread_rng();
487        let cap = 1000;
488        let mut map = HashMap::with_capacity(cap);
489        for i in 0..cap as u64 {
490            let mut fee: u64 = if i % (cap as u64 / 100) == 0 { 1000000 } else { rng.gen_range(1..10000) };
491            if i == 0 {
492                // Add an extremely large fee in order to create extremely high variance
493                fee = 100_000_000 * 1_000_000; // 1M KAS
494            }
495            let mass: u64 = 1650;
496            let key = build_feerate_key(fee, mass, i);
497            map.insert(key.tx.id(), key);
498        }
499
500        let mut frontier = Frontier::new(1.0);
501        for item in map.values().cloned() {
502            frontier.insert(item).then_some(()).unwrap();
503        }
504
505        let _sample = frontier.sample_inplace(&mut rng, &Policy::new(500_000, DEFAULT_BLOCK_LANE_LIMITS), &mut 0);
506    }
507
508    #[test]
509    pub fn test_mempool_sampling_small() {
510        let mut rng = thread_rng();
511        let cap = 2000;
512        let mut map = HashMap::with_capacity(cap);
513        for i in 0..cap as u64 {
514            let fee: u64 = rng.gen_range(1..1000000);
515            let mass: u64 = 1650;
516            let key = build_feerate_key(fee, mass, i);
517            map.insert(key.tx.id(), key);
518        }
519
520        let mut frontier = Frontier::new(1.0);
521        for item in map.values().cloned() {
522            frontier.insert(item).then_some(()).unwrap();
523        }
524
525        let mut selector = frontier.build_selector(&Policy::new(500_000, DEFAULT_BLOCK_LANE_LIMITS));
526        selector.select_transactions().iter().map(|k| k.gas).sum::<u64>();
527
528        let mut selector = frontier.build_selector_sample_inplace(&mut 0);
529        selector.select_transactions().iter().map(|k| k.gas).sum::<u64>();
530
531        let mut selector = frontier.build_selector_take_all();
532        selector.select_transactions().iter().map(|k| k.gas).sum::<u64>();
533
534        let mut selector = frontier.build_selector(&Policy::new(500_000, DEFAULT_BLOCK_LANE_LIMITS));
535        selector.select_transactions().iter().map(|k| k.gas).sum::<u64>();
536    }
537
538    #[test]
539    pub fn test_total_mass_tracking() {
540        let mut rng = thread_rng();
541        let cap = 10000;
542        let mut map = HashMap::with_capacity(cap);
543        for i in 0..cap as u64 {
544            let fee: u64 = if i % (cap as u64 / 100) == 0 { 1000000 } else { rng.gen_range(1..10000) };
545            let mass: u64 = rng.gen_range(1..100000); // Use distinct mass values to challenge the test
546            let key = build_feerate_key(fee, mass, i);
547            map.insert(key.tx.id(), key);
548        }
549
550        let len = cap / 2;
551        let mut frontier = Frontier::new(1.0);
552        for item in map.values().take(len).cloned() {
553            frontier.insert(item).then_some(()).unwrap();
554        }
555
556        let prev_total_mass = frontier.total_mass();
557        // Assert the total mass
558        assert_eq!(frontier.total_mass(), frontier.search_tree.ascending_iter().map(|k| k.mass).sum::<u64>());
559
560        // Add a bunch of duplicates and make sure the total mass remains the same
561        let mut dup_items = frontier.search_tree.ascending_iter().take(len / 2).cloned().collect_vec();
562        for dup in dup_items.iter().cloned() {
563            (!frontier.insert(dup)).then_some(()).unwrap();
564        }
565        assert_eq!(prev_total_mass, frontier.total_mass());
566        assert_eq!(frontier.total_mass(), frontier.search_tree.ascending_iter().map(|k| k.mass).sum::<u64>());
567
568        // Remove a few elements from the map in order to randomize the iterator
569        dup_items.iter().take(10).for_each(|k| {
570            map.remove(&k.tx.id());
571        });
572
573        // Add and remove random elements some of which will be duplicate insertions and some missing removals
574        for item in map.values().step_by(2) {
575            frontier.remove(item);
576            if let Some(item2) = dup_items.pop() {
577                frontier.insert(item2);
578            }
579        }
580        assert_eq!(frontier.total_mass(), frontier.search_tree.ascending_iter().map(|k| k.mass).sum::<u64>());
581    }
582
583    #[test]
584    fn test_maximum_mempool_mass_fits_u64_with_large_margin() {
585        const MINIMUM_HEADROOM_FACTOR: u64 = 1_000_000;
586
587        let maximum_transaction_count = u64::try_from(DEFAULT_MAXIMUM_TRANSACTION_COUNT).unwrap();
588        for params in [&MAINNET_PARAMS, &TESTNET_PARAMS, &SIMNET_PARAMS, &DEVNET_PARAMS] {
589            let maximum_mempool_mass = params
590                .block_mass_limits
591                .reference()
592                .checked_mul(maximum_transaction_count)
593                .expect("maximum mempool mass must fit in u64");
594            let headroom_factor = u64::MAX / maximum_mempool_mass;
595            assert!(headroom_factor >= MINIMUM_HEADROOM_FACTOR);
596        }
597    }
598
599    #[test]
600    fn test_sample_inplace_respects_lane_limit_after_freeze() {
601        let mut rng = StdRng::seed_from_u64(42);
602        let lanes = [
603            SubnetworkId::from_namespace([1, 1, 0, 0]),
604            SubnetworkId::from_namespace([2, 1, 0, 0]),
605            SubnetworkId::from_namespace([3, 1, 0, 0]),
606        ];
607
608        let mut frontier = Frontier::new(1.0);
609        for i in 0..90u64 {
610            let lane = lanes[(i as usize) % lanes.len()];
611            let fee = 1_000_000 - i;
612            frontier.insert(build_feerate_key_with_lane(fee, 100, i, lane, 0)).then_some(()).unwrap();
613        }
614
615        let mut policy = Policy::new(1_000, DEFAULT_BLOCK_LANE_LIMITS);
616        policy.lanes_per_block_limit = 2;
617        let sample = frontier.sample_inplace(&mut rng, &policy, &mut 0);
618        let selected_lanes = sample.iter().map(|tx| tx.tx.subnetwork_id).collect::<HashSet<_>>();
619
620        assert_eq!(selected_lanes.len(), policy.lanes_per_block_limit);
621    }
622
623    /// Epsilon used for various test comparisons
624    const EPS: f64 = 0.000001;
625    /// Test estimation floors: legacy behavior, current policy, and a higher stress floor.
626    const TEST_MIN_FEERATES: [f64; 3] = [1.0, 100.0, 1000.0];
627
628    fn minimum_standard_feerate() -> f64 {
629        Config::build_default(1_000, false, BlockMassLimits::with_shared_limit(500_000), DEFAULT_BLOCK_LANE_LIMITS).minimum_feerate()
630    }
631
632    fn assert_valid_feerate_buckets(estimations: &crate::feerate::FeerateEstimations, min_feerate: f64) {
633        for bucket in estimations.ordered_buckets() {
634            // Test for the absence of NaN, infinite or zero values in buckets.
635            assert!(
636                bucket.feerate.is_normal() && bucket.feerate >= min_feerate - EPS,
637                "bucket feerate {} must be finite and at least {}",
638                bucket.feerate,
639                min_feerate
640            );
641            assert!(
642                bucket.estimated_seconds.is_normal() && bucket.estimated_seconds > 0.0,
643                "bucket estimated seconds must be a finite number greater than zero"
644            );
645        }
646    }
647
648    #[test]
649    fn test_feerate_estimator() {
650        let mut rng = thread_rng();
651        let cap = 2000;
652        let mut map = HashMap::with_capacity(cap);
653        for i in 0..cap as u64 {
654            let mut fee: u64 = rng.gen_range(1..1000000);
655            let mass: u64 = 1650;
656            // 304 (~500,000/1650) extreme outliers is an edge case where the build estimator logic should be tested at
657            if i <= 303 {
658                // Add an extremely large fee in order to create extremely high variance
659                fee = i * 10_000_000 * 1_000_000;
660            }
661            let key = build_feerate_key(fee, mass, i);
662            map.insert(key.tx.id(), key);
663        }
664
665        for len in [0, 1, 10, 100, 200, 300, 500, 750, cap / 2, (cap * 2) / 3, (cap * 4) / 5, (cap * 5) / 6, cap] {
666            let mut frontier = Frontier::new(1.0);
667            for item in map.values().take(len).cloned() {
668                frontier.insert(item).then_some(()).unwrap();
669            }
670
671            let args = FeerateEstimatorArgs { network_blocks_per_second: 1, maximum_mass_per_block: 500_000 };
672            // We are testing that the build function actually returns and is not looping indefinitely
673            let estimator = frontier.build_feerate_estimator(args);
674
675            for min_feerate in TEST_MIN_FEERATES {
676                let estimations = estimator.calc_estimations(min_feerate);
677                assert_valid_feerate_buckets(&estimations, min_feerate);
678                dbg!(len, min_feerate, &estimator);
679                dbg!(estimations);
680            }
681        }
682    }
683
684    #[test]
685    fn test_constant_feerate_estimator() {
686        let cap = 20_000;
687        for min_feerate in TEST_MIN_FEERATES {
688            let mut map = HashMap::with_capacity(cap);
689            for i in 0..cap as u64 {
690                let mass: u64 = 1650;
691                let fee = (mass as f64 * min_feerate) as u64;
692                let key = build_feerate_key(fee, mass, i);
693                map.insert(key.tx.id(), key);
694            }
695
696            for len in [0, 1, 10, 100, 200, 300, 500, 750, cap / 2, (cap * 2) / 3, (cap * 4) / 5, (cap * 5) / 6, cap] {
697                println!();
698                println!("Testing a frontier with {} txs and min feerate {}...", len.min(cap), min_feerate);
699                let mut frontier = Frontier::new(1.0);
700                for item in map.values().take(len).cloned() {
701                    frontier.insert(item).then_some(()).unwrap();
702                }
703
704                let args = FeerateEstimatorArgs { network_blocks_per_second: 1, maximum_mass_per_block: 500_000 };
705                // We are testing that the build function actually returns and is not looping indefinitely
706                let estimator = frontier.build_feerate_estimator(args);
707                let estimations = estimator.calc_estimations(min_feerate);
708                assert_valid_feerate_buckets(&estimations, min_feerate);
709                dbg!(len, min_feerate, estimator);
710                dbg!(estimations);
711            }
712        }
713    }
714
715    #[test]
716    fn test_feerate_estimator_with_low_mass_outliers() {
717        const MIN_FEERATE: f64 = 1.0;
718        const STD_FEERATE: f64 = 10.0;
719        const HIGH_FEERATE: f64 = 1000.0;
720
721        let cap = 20_000;
722        let mut frontier = Frontier::new(1.0);
723        for i in 0..cap as u64 {
724            let (mass, fee) = if i < 200 {
725                let mass = 1650;
726                (mass, (HIGH_FEERATE * mass as f64) as u64)
727            } else {
728                let mass = 90_000;
729                (mass, (STD_FEERATE * mass as f64) as u64)
730            };
731            let key = build_feerate_key(fee, mass, i);
732            frontier.insert(key).then_some(()).unwrap();
733        }
734
735        let args = FeerateEstimatorArgs { network_blocks_per_second: 1, maximum_mass_per_block: 500_000 };
736        // We are testing that the build function actually returns and is not looping indefinitely
737        let estimator = frontier.build_feerate_estimator(args);
738        let estimations = estimator.calc_estimations(MIN_FEERATE);
739
740        // Test that estimations are not biased by the average high mass
741        let normal_feerate = estimations.normal_buckets.first().unwrap().feerate;
742        assert!(
743            normal_feerate < HIGH_FEERATE / 10.0,
744            "Normal bucket feerate is expected to be << high feerate due to small mass of high feerate txs ({}, {})",
745            normal_feerate,
746            HIGH_FEERATE
747        );
748
749        let buckets = estimations.ordered_buckets();
750        // Test for the absence of NaN, infinite or zero values in buckets
751        for b in buckets.iter() {
752            assert!(
753                b.feerate.is_normal() && b.feerate >= MIN_FEERATE - EPS,
754                "bucket feerate must be a finite number greater or equal to the minimum standard feerate"
755            );
756            assert!(
757                b.estimated_seconds.is_normal() && b.estimated_seconds > 0.0,
758                "bucket estimated seconds must be a finite number greater than zero"
759            );
760        }
761        dbg!(estimator);
762        dbg!(estimations);
763    }
764
765    #[test]
766    fn test_feerate_estimator_with_less_than_block_capacity() {
767        let mut map = HashMap::new();
768        for i in 0..304 {
769            let mass: u64 = 1650;
770            let fee = 10_000_000 * 1_000_000;
771            let key = build_feerate_key(fee, mass, i);
772            map.insert(key.tx.id(), key);
773        }
774
775        // All lens make for less than block capacity (given the mass used)
776        for len in [0, 1, 10, 100, 200, 250, 300] {
777            let mut frontier = Frontier::new(1.0);
778            for item in map.values().take(len).cloned() {
779                frontier.insert(item).then_some(()).unwrap();
780            }
781
782            let args = FeerateEstimatorArgs { network_blocks_per_second: 1, maximum_mass_per_block: 500_000 };
783            // We are testing that the build function actually returns and is not looping indefinitely
784            let estimator = frontier.build_feerate_estimator(args);
785
786            for min_feerate in TEST_MIN_FEERATES {
787                let estimations = estimator.calc_estimations(min_feerate);
788                let buckets = estimations.ordered_buckets();
789                // Test for the absence of NaN, infinite or zero values in buckets
790                for b in buckets.iter() {
791                    // Expect min feerate bcs blocks are not full
792                    assert!(
793                        (b.feerate - min_feerate).abs() <= EPS,
794                        "bucket feerate is expected to be equal to the minimum standard feerate"
795                    );
796                    assert!(
797                        b.estimated_seconds.is_normal() && b.estimated_seconds > 0.0 && b.estimated_seconds <= 1.0,
798                        "bucket estimated seconds must be a finite number greater than zero & less than 1.0"
799                    );
800                }
801                dbg!(len, min_feerate, &estimator);
802                dbg!(estimations);
803            }
804        }
805    }
806
807    #[test]
808    fn test_feerate_estimator_buckets_never_drop_below_minimum_standard_feerate() {
809        let minimum_feerate = minimum_standard_feerate();
810        let scenarios = [
811            ("empty", vec![]),
812            ("single below-floor transaction", vec![(1, 1000)]),
813            ("single minimum-feerate transaction", vec![((minimum_feerate * 1000.0) as u64, 1000)]),
814            ("less than one block", (0..100).map(|i| (50_000 + i, 1650)).collect_vec()),
815            ("around one block", (0..304).map(|i| (50_000 + i, 1650)).collect_vec()),
816            ("many low-feerate transactions", (0..2000).map(|i| (1 + i, 1650)).collect_vec()),
817            (
818                "high outliers over low-feerate backlog",
819                (0..2000).map(|i| if i < 10 { (50_000_000_000 + i, 1650) } else { (1 + i, 1650) }).collect_vec(),
820            ),
821            (
822                "mixed masses over several blocks",
823                (0..2000)
824                    .map(|i| {
825                        let mass = if i % 5 == 0 { 90_000 } else { 1650 };
826                        let fee = if i % 11 == 0 { 100_000_000 + i } else { 1 + i };
827                        (fee, mass)
828                    })
829                    .collect_vec(),
830            ),
831        ];
832
833        for (name, txs) in scenarios {
834            let mut frontier = Frontier::new(1.0);
835            for (i, (fee, mass)) in txs.into_iter().enumerate() {
836                frontier.insert(build_feerate_key(fee, mass, i as u64)).then_some(()).unwrap();
837            }
838
839            let args = FeerateEstimatorArgs { network_blocks_per_second: 1, maximum_mass_per_block: 500_000 };
840            let estimator = frontier.build_feerate_estimator(args);
841            let estimations = estimator.calc_estimations(minimum_feerate);
842            assert_valid_feerate_buckets(&estimations, minimum_feerate);
843
844            for (higher, lower) in estimations.ordered_buckets().into_iter().tuple_windows() {
845                assert!(
846                    higher.feerate + EPS >= lower.feerate,
847                    "{name}: buckets must remain ordered by decreasing feerate: {higher:?}, {lower:?}"
848                );
849            }
850        }
851    }
852}