Skip to main content

bitcoin_rs_mempool/
rbf.rs

1use alloc::sync::Arc;
2use alloc::vec::Vec;
3
4use bitcoin::Transaction;
5use thiserror::Error;
6
7use crate::pool::tx_fee_rate;
8use crate::{EntryId, Mempool, MempoolEntry, MempoolError};
9
10/// Candidate transaction and feerate policy used for BIP125 validation.
11#[derive(Clone, Debug)]
12pub struct ReplacementCandidate {
13    /// Replacement transaction.
14    pub tx: Arc<Transaction>,
15    /// Replacement virtual size in vbytes.
16    pub vsize: u32,
17    /// Replacement fee in satoshis.
18    pub fee: u64,
19    /// Incremental relay fee rate in sat/kvB.
20    pub min_relay_fee_rate: u64,
21}
22
23impl ReplacementCandidate {
24    /// Builds a replacement candidate.
25    #[must_use]
26    pub const fn new(tx: Arc<Transaction>, vsize: u32, fee: u64, min_relay_fee_rate: u64) -> Self {
27        Self {
28            tx,
29            vsize,
30            fee,
31            min_relay_fee_rate,
32        }
33    }
34
35    /// Candidate fee rate in sat/vB multiplied by 1000.
36    #[must_use]
37    pub fn fee_rate(&self) -> u64 {
38        tx_fee_rate(self.fee, self.vsize)
39    }
40}
41
42/// Successful replacement validation result.
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct ReplacementPlan {
45    /// Directly conflicting entries and their descendants to evict.
46    pub evicted: Vec<EntryId>,
47}
48
49/// BIP125 replacement rejection reason.
50#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
51pub enum RbfError {
52    /// No directly conflicting transaction signals replaceability, directly or through ancestors.
53    #[error("BIP125 rule 1: original transactions do not opt in")]
54    Rule1NoOptIn,
55    /// Replacement spends a new unconfirmed input not spent by the originals.
56    #[error("BIP125 rule 2: replacement adds a new unconfirmed input")]
57    Rule2NewUnconfirmedInput,
58    /// Replacement absolute fee is below the conflicts it evicts.
59    #[error("BIP125 rule 3: replacement fee does not pay evicted fees")]
60    Rule3InsufficientAbsoluteFee,
61    /// Replacement does not pay the configured incremental relay fee.
62    #[error("BIP125 rule 4: replacement does not pay incremental relay fee")]
63    Rule4InsufficientIncrementalFee,
64    /// Replacement would evict more transactions than policy allows.
65    #[error("BIP125 rule 5: replacement evicts too many transactions")]
66    Rule5TooManyEvictions,
67    /// Replacement fee rate does not improve on directly conflicting transactions.
68    #[error("BIP125 rule 6: replacement fee rate is not higher than originals")]
69    Rule6InsufficientFeeRate,
70    /// A validated replacement failed insertion after evicting conflicts.
71    #[error(transparent)]
72    Mempool(#[from] MempoolError),
73}
74
75impl Mempool {
76    /// Checks BIP125 replacement rules without mutating the mempool.
77    pub fn check_replacement(
78        &self,
79        candidate: &ReplacementCandidate,
80    ) -> Result<ReplacementPlan, RbfError> {
81        let direct_conflicts = self.conflicts_for(&candidate.tx);
82        if direct_conflicts.is_empty() {
83            return Ok(ReplacementPlan {
84                evicted: Vec::new(),
85            });
86        }
87
88        if !direct_conflicts
89            .iter()
90            .any(|id| self.signals_rbf_including_ancestors(*id))
91        {
92            return Err(RbfError::Rule1NoOptIn);
93        }
94
95        let original_spends = direct_conflicts
96            .iter()
97            .filter_map(|id| self.entry(*id))
98            .flat_map(|entry| entry.tx.input.iter().map(|input| input.previous_output))
99            .collect::<Vec<_>>();
100        for input in &candidate.tx.input {
101            if self.is_unconfirmed_outpoint(input.previous_output)
102                && !original_spends.contains(&input.previous_output)
103            {
104                return Err(RbfError::Rule2NewUnconfirmedInput);
105            }
106        }
107
108        let evicted = self.conflicts_with_descendants(&candidate.tx);
109        let evicted_fee = evicted.iter().fold(0_u64, |total, id| {
110            total.saturating_add(self.entry(*id).map_or(0, |entry| entry.fee))
111        });
112        if candidate.fee < evicted_fee {
113            return Err(RbfError::Rule3InsufficientAbsoluteFee);
114        }
115
116        let incremental_fee =
117            u64::from(candidate.vsize).saturating_mul(candidate.min_relay_fee_rate) / 1_000;
118        if candidate.fee.saturating_sub(evicted_fee) < incremental_fee {
119            return Err(RbfError::Rule4InsufficientIncrementalFee);
120        }
121
122        let eviction_count = u32::try_from(evicted.len()).unwrap_or(u32::MAX);
123        if eviction_count > self.limits.max_replacement_evictions {
124            return Err(RbfError::Rule5TooManyEvictions);
125        }
126
127        let candidate_fee_rate = candidate.fee_rate();
128        if direct_conflicts.iter().any(|id| {
129            self.entry(*id)
130                .is_some_and(|entry| candidate_fee_rate <= entry.fee_rate)
131        }) {
132            return Err(RbfError::Rule6InsufficientFeeRate);
133        }
134
135        Ok(ReplacementPlan { evicted })
136    }
137
138    /// Applies a BIP125 replacement after validation and returns the new entry id.
139    pub fn replace_transaction(
140        &mut self,
141        candidate: ReplacementCandidate,
142        time: u64,
143        height: u32,
144    ) -> Result<EntryId, RbfError> {
145        let plan = self.check_replacement(&candidate)?;
146        for id in plan.evicted {
147            let _ = self.remove_entry_and_descendants(id);
148        }
149        let entry = MempoolEntry::new(candidate.tx, candidate.vsize, candidate.fee, time, height);
150        self.insert_entry(entry).map_err(RbfError::from)
151    }
152}