From 6219c8fce45d2a875592e50744b5c3a626b40302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 28 Jul 2026 07:12:43 +0000 Subject: [PATCH] Add ancestor-aware coin selection for CPFP bump fee calculation When spending unconfirmed UTXOs, miners evaluate the transaction as a package with its ancestors. This adds support for computing the package-level bump fee needed to bring ancestors up to the target feerate, with automatic deduplication of shared ancestors across candidates. - Add UnconfirmedAncestor struct (weight, fee_paid, dependent_candidates) - Add with_ancestors() builder on CoinSelector - Add selected_ancestor_bump_fee() with package-level computation - Add candidates_with_ancestors() to report the affected candidates - Fold the bump into the implied_package_fee_* helpers, so every excess calculation and implied_fee() price the package rather than the tx alone The ancestor -> candidate edge is stored on the ancestor rather than on the candidate, so `Candidate` stays `Copy` and each ancestor is counted at most once per selection without an intermediate dedup pass. Candidates with unconfirmed ancestors are banned from automatic selection. The bump is a property of the package, not of any one candidate: shared ancestors count once and an overpaying ancestor subsidizes an underpaying one, so the cost of adding a candidate depends on what else is selected. Every algorithm here ranks with per-candidate figures that cannot see ancestors, so a selectable ancestor candidate could *lower* the excess and break them silently -- select_until_target_met and select_srd reporting insufficient funds when a funding selection exists, is_fundable giving false negatives, LowestFee::bound no longer bounding, Changeless pruning branches that do hold changeless solutions. Banning keeps the reachable ancestor set, and hence the bump, identical across every selection an algorithm can reach. Bans do not block manual selection, so the CPFP flow is unaffected: select the unconfirmed UTXO you are bumping, then let the algorithms optimize the confirmed candidates around it. The invariant is noted at the sites that depend on it, and a debug_assert in selected_ancestor_bump_fee fires if a dependent candidate ever stops being banned. Co-Authored-By: Noah Joeris Co-Authored-By: Claude Opus 4.6 (1M context) --- src/coin_selector.rs | 226 +++++++++++++--- src/metrics/changeless.rs | 4 + src/metrics/lowest_fee.rs | 8 + tests/ancestor_aware.rs | 527 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 734 insertions(+), 31 deletions(-) create mode 100644 tests/ancestor_aware.rs diff --git a/src/coin_selector.rs b/src/coin_selector.rs index 604abd8..096e2c3 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -8,6 +8,21 @@ use alloc::{sync::Arc, vec::Vec}; /// `change_lower` argument of [`CoinSelector::select_srd`]. pub const CHANGE_LOWER: u64 = 50_000; +/// An unconfirmed ancestor transaction that may need a fee bump (CPFP). +/// +/// When spending unconfirmed UTXOs, miners evaluate the transaction as a package with its +/// unconfirmed ancestors. If ancestors paid below the target feerate, the child must overpay. +#[derive(Debug, Clone)] +pub struct UnconfirmedAncestor { + /// The weight of the ancestor transaction in weight units. + pub weight: u64, + /// The fee already paid by the ancestor transaction in satoshis. + pub fee_paid: u64, + /// Candidate indices whose selection includes this ancestor in the CPFP package. + /// Indices use the candidate slice passed to [`CoinSelector::new`]. + pub dependent_candidates: Vec, +} + /// [`CoinSelector`] selects/deselects coins from a set of canididate coins. /// /// You can manually select coins using methods like [`select`], or automatically with methods such @@ -18,6 +33,12 @@ pub const CHANGE_LOWER: u64 = 50_000; #[derive(Debug, Clone)] pub struct CoinSelector<'a> { candidates: &'a [Candidate], + /// CPFP lookup table (via [`CoinSelector::with_ancestors`]). Each ancestor tracks the + /// candidates that depend on it. + ancestors: &'a [UnconfirmedAncestor], + /// The union of every ancestor's `dependent_candidates`, deduplicated. These are banned from + /// automatic selection; see [`CoinSelector::with_ancestors`]. + ancestor_dependents: Bitset, selected: Bitset, banned: Bitset, candidate_order: Arc>, @@ -38,12 +59,81 @@ impl<'a> CoinSelector<'a> { pub fn new(candidates: &'a [Candidate]) -> Self { Self { candidates, + ancestors: &[], + ancestor_dependents: Bitset::with_capacity(candidates.len()), selected: Bitset::with_capacity(candidates.len()), banned: Bitset::with_capacity(candidates.len()), candidate_order: Arc::new((0..candidates.len()).collect::>()), } } + /// Set the shared ancestor data for CPFP bump fee calculations. + /// + /// Each [`UnconfirmedAncestor`] contains the indices of candidates that depend on it. Every + /// such candidate is [`ban`]ned, so the automatic selection algorithms will never pick one on + /// their own. You can still [`select`] them manually — that is the intended CPFP flow: you + /// decide which unconfirmed UTXOs to spend, and this priced the resulting package for you. + /// + /// # Why they are banned + /// + /// The ancestor bump fee is a property of the *package*, not of any one candidate: ancestors + /// shared between candidates are counted once, and an overpaying ancestor subsidizes an + /// underpaying one. So the cost of adding a candidate depends on what else is selected. + /// + /// Every automatic algorithm here ranks and accumulates using per-candidate figures + /// ([`Candidate::effective_value`], [`Candidate::value_pwu`]), which cannot see ancestors. If + /// such candidates were selectable, adding one could *lower* the excess, and the algorithms + /// break in ways that produce no error: [`select_until_target_met`] and [`select_srd`] can + /// report insufficient funds when a funding selection exists, and branch and bound's bounds + /// stop being lower bounds, silently pruning the optimal solution. + /// + /// Banning keeps the ancestor set — and hence the bump — identical across every selection an + /// algorithm can reach, which is what those algorithms need to stay correct. + /// + /// # Panics + /// + /// If any `dependent_candidates` index is out of bounds for the candidate slice passed to + /// [`CoinSelector::new`]. + /// + /// [`ban`]: Self::ban + /// [`select`]: Self::select + /// [`select_until_target_met`]: Self::select_until_target_met + /// [`select_srd`]: Self::select_srd + pub fn with_ancestors(mut self, ancestors: &'a [UnconfirmedAncestor]) -> Self { + for ancestor in ancestors { + for &candidate_index in &ancestor.dependent_candidates { + assert!( + candidate_index < self.candidates.len(), + "ancestor dependent candidate index {} out of bounds for {} candidates", + candidate_index, + self.candidates.len() + ); + self.ancestor_dependents.insert(candidate_index); + self.ban(candidate_index); + } + } + self.ancestors = ancestors; + self + } + + /// The candidates that have unconfirmed ancestors, by index into the original `candidates` + /// slice passed to [`CoinSelector::new`]. + /// + /// These are exactly the candidates [`with_ancestors`] banned from automatic selection. To + /// spend one, [`select`] it manually — the [ancestor bump fee] is then priced into every + /// excess calculation. + /// + /// Prefer this over filtering [`banned`], which also contains any candidates you banned + /// yourself. + /// + /// [`with_ancestors`]: Self::with_ancestors + /// [`select`]: Self::select + /// [`banned`]: Self::banned + /// [ancestor bump fee]: Self::selected_ancestor_bump_fee + pub fn candidates_with_ancestors(&self) -> impl Iterator + '_ { + self.ancestor_dependents.iter() + } + /// Iterate over all the candidates in their currently sorted order. Each item has the original /// index with the candidate. pub fn candidates( @@ -182,6 +272,40 @@ impl<'a> CoinSelector<'a> { + target_ouputs.output_weight_with_drain(drain_weight) } + /// Compute the package-level ancestor bump fee for the current selection at the given feerate. + /// + /// This includes ancestors with at least one selected dependent candidate, sums their weights + /// and fees once, then computes `max(0, implied_fee(total_weight, feerate) - total_fees)`. + /// + /// High-feerate ancestors subsidize low-feerate ones within the package (matching Bitcoin + /// Core's package relay approach). + pub fn selected_ancestor_bump_fee(&self, feerate: FeeRate) -> u64 { + if self.ancestors.is_empty() { + return 0; + } + debug_assert!( + self.ancestor_dependents + .iter() + .all(|i| self.banned.contains(i)), + "candidates with unconfirmed ancestors must stay banned, so that the ancestor bump \ + fee is constant across every selection an algorithm can reach" + ); + let mut total_weight = 0u64; + let mut total_fee_paid = 0u64; + for ancestor in self.ancestors { + if ancestor + .dependent_candidates + .iter() + .any(|&candidate_index| self.selected.contains(candidate_index)) + { + total_weight += ancestor.weight; + total_fee_paid += ancestor.fee_paid; + } + } + let implied = feerate.implied_fee(total_weight); + implied.saturating_sub(total_fee_paid) + } + /// How much the current selection overshoots the value needed to achieve `target`. /// /// In order for the resulting transaction to be valid this must be 0 or above. If it's above 0 @@ -208,7 +332,7 @@ impl<'a> CoinSelector<'a> { self.selected_value() as i64 - target.value() as i64 - drain.value as i64 - - self.implied_fee_from_feerate(target, drain.weights) as i64 + - self.implied_package_fee_from_feerate(target, drain.weights) as i64 } /// Same as [rate_excess](Self::rate_excess) except `target.fee.rate` is applied to the @@ -217,7 +341,7 @@ impl<'a> CoinSelector<'a> { self.selected_value() as i64 - target.value() as i64 - drain.value as i64 - - self.implied_fee_from_feerate_wu(target, drain.weights) as i64 + - self.implied_package_fee_from_feerate_wu(target, drain.weights) as i64 } /// How much the current selection overshoots the value needed to satisfy `target.fee.absolute` @@ -231,29 +355,19 @@ impl<'a> CoinSelector<'a> { /// How much the current selection overshoots the value needed to satisfy RBF's rule 4. pub fn replacement_excess(&self, target: Target, drain: Drain) -> i64 { - let mut replacement_excess_needed = 0; - if let Some(replace) = target.fee.replace { - replacement_excess_needed = - replace.min_fee_to_do_replacement(self.weight(target.outputs, drain.weights)) - } self.selected_value() as i64 - target.value() as i64 - drain.value as i64 - - replacement_excess_needed as i64 + - self.implied_package_fee_from_replacement(target, drain.weights) as i64 } /// Same as [replacement_excess](Self::replacement_excess) except the replacement fee /// is calculated using weight units directly without any conversion to vbytes. pub fn replacement_excess_wu(&self, target: Target, drain: Drain) -> i64 { - let mut replacement_excess_needed = 0; - if let Some(replace) = target.fee.replace { - replacement_excess_needed = - replace.min_fee_to_do_replacement_wu(self.weight(target.outputs, drain.weights)) - } self.selected_value() as i64 - target.value() as i64 - drain.value as i64 - - replacement_excess_needed as i64 + - self.implied_package_fee_from_replacement_wu(target, drain.weights) as i64 } /// The feerate the transaction would have if we were to use this selection of inputs to achieve @@ -272,37 +386,84 @@ impl<'a> CoinSelector<'a> { /// The fee the current selection and `drain_weight` should pay to satisfy `target_fee`. /// - /// This compares the fee calculated from the target feerate with the fee calculated from the - /// [`Replace`] constraints and returns the larger of the two. + /// This is the largest of the fees implied by `target.fee.rate`, `target.fee.absolute` and the + /// [`Replace`] constraints. The feerate and replacement fees include any [ancestor bump fee]; + /// `target.fee.absolute` is a minimum fee floor rather than an additive charge, so it does not. + /// + /// This is the exact counterpart of [`excess`](Self::excess): + /// `excess == selected_value - target.value() - drain.value - implied_fee`. /// /// `drain_weight` can be 0 to indicate no draining output. + /// + /// [ancestor bump fee]: Self::selected_ancestor_bump_fee pub fn implied_fee(&self, target: Target, drain_weights: DrainWeights) -> u64 { - let mut implied_fee = self - .implied_fee_from_feerate(target, drain_weights) - .max(target.fee.absolute); - - if let Some(replace) = target.fee.replace { - implied_fee = Ord::max( - implied_fee, - replace.min_fee_to_do_replacement(self.weight(target.outputs, drain_weights)), - ); - } - - implied_fee + self.implied_package_fee_from_feerate(target, drain_weights) + .max(target.fee.absolute) + .max(self.implied_package_fee_from_replacement(target, drain_weights)) } - fn implied_fee_from_feerate(&self, target: Target, drain_weights: DrainWeights) -> u64 { + /// The fee implied by `target.fee.rate` for the whole CPFP package — this transaction plus any + /// unconfirmed ancestors — i.e. the fee for the transaction's own weight plus the [ancestor + /// bump fee]. + /// + /// The bump is folded in here rather than at each call site because every caller needs it. + /// + /// [ancestor bump fee]: Self::selected_ancestor_bump_fee + fn implied_package_fee_from_feerate(&self, target: Target, drain_weights: DrainWeights) -> u64 { target .fee .rate .implied_fee(self.weight(target.outputs, drain_weights)) + + self.selected_ancestor_bump_fee(target.fee.rate) } - fn implied_fee_from_feerate_wu(&self, target: Target, drain_weights: DrainWeights) -> u64 { + /// Same as [`implied_package_fee_from_feerate`](Self::implied_package_fee_from_feerate) except `target.fee.rate` + /// is applied to weight units directly without any conversion to vbytes. + fn implied_package_fee_from_feerate_wu( + &self, + target: Target, + drain_weights: DrainWeights, + ) -> u64 { target .fee .rate .implied_fee_wu(self.weight(target.outputs, drain_weights)) + + self.selected_ancestor_bump_fee(target.fee.rate) + } + + /// The fee needed for the whole CPFP package to satisfy RBF's rule 4, i.e. the replacement fee + /// plus the [ancestor bump fee]. No replacement (`target.fee.replace` is `None`) still leaves + /// the bump to pay. + /// + /// [ancestor bump fee]: Self::selected_ancestor_bump_fee + fn implied_package_fee_from_replacement( + &self, + target: Target, + drain_weights: DrainWeights, + ) -> u64 { + let replacement_fee = match target.fee.replace { + Some(replace) => { + replace.min_fee_to_do_replacement(self.weight(target.outputs, drain_weights)) + } + None => 0, + }; + replacement_fee + self.selected_ancestor_bump_fee(target.fee.rate) + } + + /// Same as [`implied_package_fee_from_replacement`](Self::implied_package_fee_from_replacement) except the + /// replacement fee is calculated using weight units directly without any conversion to vbytes. + fn implied_package_fee_from_replacement_wu( + &self, + target: Target, + drain_weights: DrainWeights, + ) -> u64 { + let replacement_fee = match target.fee.replace { + Some(replace) => { + replace.min_fee_to_do_replacement_wu(self.weight(target.outputs, drain_weights)) + } + None => 0, + }; + replacement_fee + self.selected_ancestor_bump_fee(target.fee.rate) } /// The actual fee the selection would pay if it was used in a transaction that had @@ -314,8 +475,11 @@ impl<'a> CoinSelector<'a> { } /// The value of the current selected inputs minus the fee needed to pay for the selected inputs + /// and any ancestor bump fee. pub fn effective_value(&self, feerate: FeeRate) -> i64 { - self.selected_value() as i64 - (self.input_weight() as f32 * feerate.spwu()).ceil() as i64 + self.selected_value() as i64 + - (self.input_weight() as f32 * feerate.spwu()).ceil() as i64 + - self.selected_ancestor_bump_fee(feerate) as i64 } // /// Waste sum of all selected inputs. diff --git a/src/metrics/changeless.rs b/src/metrics/changeless.rs index a9c9e32..9ad299c 100644 --- a/src/metrics/changeless.rs +++ b/src/metrics/changeless.rs @@ -25,6 +25,10 @@ impl Changeless { /// NOTE: this relies on candidates being sorted so that all negative effective value candidates /// are next to each other, which [`requires_ordering_by_descending_value_pwu`] guarantees. /// + /// NOTE: it also needs `Candidate::effective_value` to tell the whole story about how much a + /// candidate lowers the excess, which holds only because `CoinSelector::with_ancestors` bans + /// candidates with unconfirmed ancestors. See its docs. + /// /// [`requires_ordering_by_descending_value_pwu`]: BnbMetric::requires_ordering_by_descending_value_pwu fn change_unavoidable(&mut self, cs: &CoinSelector<'_>, target: Target) -> bool { if self.0.drain(cs, target).is_none() { diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index 5499777..e20c723 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -146,6 +146,10 @@ impl BnbMetric for LowestFee { // `drain_value`, where `change_value` is `excess_with_drain_weight` and `spend_fee` is // `drain_spend_cost`). With `v >= 0` the difference is strictly positive: B always // costs more. + // + // NOTE: this needs the ancestor bump fee to cancel between A and B, which holds only + // because `CoinSelector::with_ancestors` bans candidates with unconfirmed ancestors. + // See its docs. if self.drain_value(cs, target).is_none() { // But a descendant might *add* a change output that improves the metric. This // happens when the current selection is changeless only because the change would be @@ -180,6 +184,10 @@ impl BnbMetric for LowestFee { Some(current_score) } else { // Step 1: select everything up until the input that hits the target. + // + // NOTE: this prices a greedy *prefix* that descendants need not select, so it is a + // lower bound only while the ancestor bump fee is the same for both. See + // `CoinSelector::with_ancestors`. let (mut cs, resize_index, to_resize) = cs .clone() .select_iter() diff --git a/tests/ancestor_aware.rs b/tests/ancestor_aware.rs new file mode 100644 index 0000000..4fadcea --- /dev/null +++ b/tests/ancestor_aware.rs @@ -0,0 +1,527 @@ +use bdk_coin_select::{ + Candidate, CoinSelector, Drain, DrainWeights, FeeRate, Replace, Target, TargetFee, + TargetOutputs, UnconfirmedAncestor, TR_KEYSPEND_TXIN_WEIGHT, +}; + +fn simple_target(feerate: f32) -> Target { + Target { + outputs: TargetOutputs { + value_sum: 100_000, + weight_sum: 200, + n_outputs: 1, + }, + fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(feerate)), + max_weight: None, + } +} + +#[test] +fn zero_ancestors_backward_compatible() { + let candidates = [Candidate { + input_count: 1, + value: 200_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + }]; + + let mut cs = CoinSelector::new(&candidates); + cs.select(0); + + assert_eq!( + cs.selected_ancestor_bump_fee(FeeRate::from_sat_per_vb(10.0)), + 0 + ); + + let target = simple_target(10.0); + let excess_no_ancestors = cs.excess(target, Drain::NONE); + assert!( + excess_no_ancestors > 0, + "should meet target without ancestors" + ); +} + +#[test] +fn single_ancestor_reduces_excess() { + // Ancestor: 400 wu, paid 10 sats (very low feerate) + let ancestors = [UnconfirmedAncestor { + weight: 400, + fee_paid: 10, + dependent_candidates: vec![0], + }]; + + let candidates = [Candidate { + input_count: 1, + value: 200_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + }]; + + let feerate = FeeRate::from_sat_per_vb(10.0); + let target = simple_target(10.0); + + // Without ancestors + let mut cs_no_anc = CoinSelector::new(&candidates); + cs_no_anc.select(0); + let excess_no_anc = cs_no_anc.excess(target, Drain::NONE); + + // With ancestors + let mut cs_with_anc = CoinSelector::new(&candidates).with_ancestors(&ancestors); + cs_with_anc.select(0); + + let bump_fee = cs_with_anc.selected_ancestor_bump_fee(feerate); + assert!(bump_fee > 0, "ancestor should need bumping"); + + let excess_with_anc = cs_with_anc.excess(target, Drain::NONE); + assert!( + excess_with_anc < excess_no_anc, + "ancestor bump fee should reduce excess: {} < {}", + excess_with_anc, + excess_no_anc + ); + assert_eq!( + excess_no_anc - excess_with_anc, + bump_fee as i64, + "excess difference should equal bump fee" + ); +} + +#[test] +fn shared_ancestors_are_deduplicated() { + // Both candidates share the same ancestor + let ancestors = [UnconfirmedAncestor { + weight: 400, + fee_paid: 10, + dependent_candidates: vec![0, 1], + }]; + + let candidates = [ + Candidate { + input_count: 1, + value: 100_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + }, + Candidate { + input_count: 1, + value: 100_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + }, + ]; + + let feerate = FeeRate::from_sat_per_vb(10.0); + + // Select only candidate 0 + let mut cs_one = CoinSelector::new(&candidates).with_ancestors(&ancestors); + cs_one.select(0); + let bump_one = cs_one.selected_ancestor_bump_fee(feerate); + + // Select both candidates + let mut cs_both = CoinSelector::new(&candidates).with_ancestors(&ancestors); + cs_both.select(0); + cs_both.select(1); + let bump_both = cs_both.selected_ancestor_bump_fee(feerate); + + // The bump fee should be the SAME because the ancestor is shared (deduplicated) + assert_eq!( + bump_one, bump_both, + "shared ancestor should only be counted once: one={} both={}", + bump_one, bump_both + ); +} + +#[test] +fn high_feerate_ancestor_subsidizes_low_feerate() { + // Two ancestors: one overpaid, one underpaid + // At package level, the overpayment subsidizes the underpayment + let ancestors = [ + UnconfirmedAncestor { + weight: 400, + fee_paid: 10, // very low fee + dependent_candidates: vec![0], + }, + UnconfirmedAncestor { + weight: 400, + fee_paid: 10_000, // very high fee + dependent_candidates: vec![0], + }, + ]; + + let candidates = [Candidate { + input_count: 1, + value: 200_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + }]; + + let feerate = FeeRate::from_sat_per_vb(10.0); + + let mut cs = CoinSelector::new(&candidates).with_ancestors(&ancestors); + cs.select(0); + + let bump = cs.selected_ancestor_bump_fee(feerate); + + // Package: total_weight = 800, total_fee_paid = 10_010 + // implied_fee at 10 sat/vb = ceil(800/4) * 10 = 2000 sats + // bump = max(0, 2000 - 10_010) = 0 + assert_eq!( + bump, 0, + "high-feerate ancestor should subsidize low-feerate ancestor in the package" + ); +} + +#[test] +fn ancestor_package_above_target_contributes_zero_bump() { + let ancestors = [UnconfirmedAncestor { + weight: 400, + fee_paid: 10_000, // way above any reasonable feerate + dependent_candidates: vec![0], + }]; + + let candidates = [Candidate { + input_count: 1, + value: 200_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + }]; + + let feerate = FeeRate::from_sat_per_vb(10.0); + + let mut cs = CoinSelector::new(&candidates).with_ancestors(&ancestors); + cs.select(0); + + assert_eq!( + cs.selected_ancestor_bump_fee(feerate), + 0, + "ancestor already above target feerate should contribute zero bump" + ); +} + +#[test] +fn different_feerates_produce_different_bump_fees() { + let ancestors = [UnconfirmedAncestor { + weight: 400, + fee_paid: 100, // 1 sat/vb + dependent_candidates: vec![0], + }]; + + let candidates = [Candidate { + input_count: 1, + value: 200_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + }]; + + let mut cs = CoinSelector::new(&candidates).with_ancestors(&ancestors); + cs.select(0); + + let bump_low = cs.selected_ancestor_bump_fee(FeeRate::from_sat_per_vb(5.0)); + let bump_high = cs.selected_ancestor_bump_fee(FeeRate::from_sat_per_vb(20.0)); + + assert!( + bump_high > bump_low, + "higher feerate should produce larger bump fee: high={} low={}", + bump_high, + bump_low + ); +} + +#[test] +fn effective_value_includes_ancestor_bump() { + let ancestors = [UnconfirmedAncestor { + weight: 400, + fee_paid: 10, + dependent_candidates: vec![0], + }]; + + let candidates = [Candidate { + input_count: 1, + value: 200_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + }]; + + let feerate = FeeRate::from_sat_per_vb(10.0); + + let mut cs_no_anc = CoinSelector::new(&candidates); + cs_no_anc.select(0); + let ev_no_anc = cs_no_anc.effective_value(feerate); + + let mut cs_with_anc = CoinSelector::new(&candidates).with_ancestors(&ancestors); + cs_with_anc.select(0); + let ev_with_anc = cs_with_anc.effective_value(feerate); + + let bump = cs_with_anc.selected_ancestor_bump_fee(feerate); + assert!(bump > 0); + assert_eq!( + ev_no_anc - ev_with_anc, + bump as i64, + "effective value difference should equal bump fee" + ); +} + +/// `implied_fee` is the exact counterpart of `excess`, so it must carry the ancestor bump. A +/// wallet sizing its change output from `implied_fee` would otherwise underpay the package by +/// exactly the bump amount. +#[test] +fn implied_fee_includes_ancestor_bump() { + let candidates = [Candidate { + input_count: 1, + value: 200_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + }]; + // 400 wu = 100 vb, so the ancestor owes 100 * 10 = 1000 sats but paid 10 => 990 sat bump. + let ancestors = [UnconfirmedAncestor { + weight: 400, + fee_paid: 10, + dependent_candidates: vec![0], + }]; + let target = simple_target(10.0); + + let mut without = CoinSelector::new(&candidates); + without.select(0); + let mut with = CoinSelector::new(&candidates).with_ancestors(&ancestors); + with.select(0); + + assert_eq!( + with.selected_ancestor_bump_fee(FeeRate::from_sat_per_vb(10.0)), + 990 + ); + assert_eq!( + with.implied_fee(target, DrainWeights::NONE) + - without.implied_fee(target, DrainWeights::NONE), + 990, + "implied_fee must carry the ancestor bump" + ); +} + +/// `excess == selected_value - target.value() - drain.value - implied_fee` must hold for every +/// combination of fee constraints. This pins the whole `*_excess` family against `implied_fee`, so +/// a bump added to one but not the other cannot go unnoticed. +#[test] +fn excess_and_implied_fee_agree() { + let candidates = [ + Candidate { + input_count: 1, + value: 200_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + }, + Candidate { + input_count: 1, + value: 50_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + }, + ]; + + let ancestor_sets: [Vec; 3] = [ + // no ancestors at all: the identity must hold on the pre-existing code paths too + vec![], + // one underpaying ancestor + vec![UnconfirmedAncestor { + weight: 400, + fee_paid: 10, + dependent_candidates: vec![0], + }], + // an underpaying ancestor plus an overpaying one that subsidizes it + vec![ + UnconfirmedAncestor { + weight: 400, + fee_paid: 10, + dependent_candidates: vec![0], + }, + UnconfirmedAncestor { + weight: 1_000, + fee_paid: 100_000, + dependent_candidates: vec![1], + }, + ], + ]; + + for ancestors in &ancestor_sets { + for absolute in [0_u64, 5_000, 500_000] { + for replace in [None, Some(Replace::new(1_000))] { + for feerate in [1.0_f32, 10.0, 50.0] { + for drain in [ + Drain::NONE, + Drain { + weights: DrainWeights::TR_KEYSPEND, + value: 20_000, + }, + ] { + let target = Target { + outputs: TargetOutputs { + value_sum: 100_000, + weight_sum: 200, + n_outputs: 1, + }, + fee: TargetFee { + rate: FeeRate::from_sat_per_vb(feerate), + replace, + absolute, + }, + max_weight: None, + }; + + for selection in [vec![], vec![0], vec![1], vec![0, 1]] { + let mut cs = CoinSelector::new(&candidates).with_ancestors(ancestors); + for i in &selection { + cs.select(*i); + } + + assert_eq!( + cs.excess(target, drain), + cs.selected_value() as i64 + - target.value() as i64 + - drain.value as i64 + - cs.implied_fee(target, drain.weights) as i64, + "identity broken: n_ancestors={} absolute={} replace={} \ + feerate={} drain={} selection={:?}", + ancestors.len(), + absolute, + replace.is_some(), + feerate, + drain.value, + selection, + ); + } + } + } + } + } + } +} + +/// Candidates with unconfirmed ancestors are banned from the automatic selection algorithms, +/// because the bump fee is a package-level property that per-candidate ranking cannot see. +#[test] +fn candidates_with_ancestors_are_banned_from_automatic_selection() { + let candidates = [ + // depends on an expensive unconfirmed ancestor + Candidate { + input_count: 1, + value: 200_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + }, + // ordinary confirmed candidate + Candidate { + input_count: 1, + value: 200_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + }, + ]; + let ancestors = [UnconfirmedAncestor { + weight: 400, + fee_paid: 10, + dependent_candidates: vec![0], + }]; + + let cs = CoinSelector::new(&candidates).with_ancestors(&ancestors); + + assert!(cs.banned().contains(0), "ancestor candidate must be banned"); + assert!(!cs.banned().contains(1), "candidate 1 has no ancestors"); + assert_eq!( + cs.unselected_indices().collect::>(), + vec![1], + "only the ancestor-free candidate is reachable" + ); + + // A greedy selection must fund itself from candidate 1 alone. + let mut greedy = cs.clone(); + greedy + .select_until_target_met(simple_target(10.0)) + .expect("candidate 1 alone covers the target"); + assert!(!greedy.is_selected(0)); + assert!(greedy.is_selected(1)); + assert_eq!( + greedy.selected_ancestor_bump_fee(FeeRate::from_sat_per_vb(10.0)), + 0, + "no ancestor is reachable, so the bump stays zero" + ); +} + +/// Banning is only about *automatic* selection — the caller can still spend an unconfirmed UTXO +/// explicitly, which is the actual CPFP flow, and the bump is priced when they do. +#[test] +fn banned_ancestor_candidates_remain_manually_selectable() { + let candidates = [Candidate { + input_count: 1, + value: 200_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + }]; + let ancestors = [UnconfirmedAncestor { + weight: 400, + fee_paid: 10, + dependent_candidates: vec![0], + }]; + + let mut cs = CoinSelector::new(&candidates).with_ancestors(&ancestors); + assert!(cs.banned().contains(0)); + + assert!(cs.select(0), "ban must not block manual selection"); + assert!(cs.is_selected(0)); + assert_eq!( + cs.selected_ancestor_bump_fee(FeeRate::from_sat_per_vb(10.0)), + 990, + "the package is still priced once the candidate is selected" + ); +} + +/// `candidates_with_ancestors` reports each affected candidate once even when several ancestors +/// name it, and excludes candidates the caller banned for their own reasons. +#[test] +fn candidates_with_ancestors_is_deduplicated_and_distinct_from_banned() { + let candidates = [ + Candidate { + input_count: 1, + value: 100_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + }, + Candidate { + input_count: 1, + value: 100_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + }, + Candidate { + input_count: 1, + value: 100_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + }, + ]; + // candidate 0 is named by both ancestors; candidate 1 by one of them + let ancestors = [ + UnconfirmedAncestor { + weight: 400, + fee_paid: 10, + dependent_candidates: vec![0, 1], + }, + UnconfirmedAncestor { + weight: 400, + fee_paid: 20, + dependent_candidates: vec![0], + }, + ]; + + let mut cs = CoinSelector::new(&candidates).with_ancestors(&ancestors); + // a ban of the caller's own, unrelated to ancestors + cs.ban(2); + + assert_eq!( + cs.candidates_with_ancestors().collect::>(), + vec![0, 1], + "each affected candidate reported once, and candidate 2 is not one of them" + ); + assert_eq!( + cs.banned().iter().collect::>(), + vec![0, 1, 2], + "banned() also carries the caller's own ban" + ); +}