diff --git a/README.md b/README.md index ada89aa..b8091a5 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ let target = Target { let candidates = vec![ Candidate { - // How many inputs does this candidate represents. Needed so we can + // How many inputs does this candidate represents. Needed so we can // figure out the weight of the varint that encodes the number of inputs input_count: 1, // the value of the input @@ -39,15 +39,18 @@ let candidates = vec![ weight: TR_KEYSPEND_TXIN_WEIGHT, // wether it's a segwit input. Needed so we know whether to include the // segwit header in total weight calculations. - is_segwit: true + is_segwit: true, + // indices into the shared ancestor slice (empty = confirmed or no ancestors) + ancestors: vec![], }, Candidate { - // A candidate can represent multiple inputs in the case where you + // A candidate can represent multiple inputs in the case where you // always want some inputs to be spent together. input_count: 2, weight: 2*TR_KEYSPEND_TXIN_WEIGHT, value: 3_000_000, - is_segwit: true + is_segwit: true, + ancestors: vec![], } ]; @@ -106,19 +109,22 @@ let candidates = [ input_count: 1, value: 400_000, weight: TR_KEYSPEND_TXIN_WEIGHT, - is_segwit: true + is_segwit: true, + ancestors: vec![], }, Candidate { input_count: 1, value: 200_000, weight: TR_KEYSPEND_TXIN_WEIGHT, - is_segwit: true + is_segwit: true, + ancestors: vec![], }, Candidate { input_count: 1, value: 11_000, weight: TR_KEYSPEND_TXIN_WEIGHT, - is_segwit: true + is_segwit: true, + ancestors: vec![], } ]; let drain_weights = bdk_coin_select::DrainWeights::default(); diff --git a/src/coin_selector.rs b/src/coin_selector.rs index d6cdd7b..bcbf064 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -2,7 +2,19 @@ use super::*; #[allow(unused)] // some bug in <= 1.48.0 sees this as unused when it isn't use crate::float::FloatExt; use crate::{bnb::BnbMetric, float::Ordf32, ChangePolicy, FeeRate, Target}; -use alloc::{borrow::Cow, collections::BTreeSet}; +use alloc::{borrow::Cow, collections::BTreeSet, vec::Vec}; + +/// 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, Copy)] +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, +} /// [`CoinSelector`] selects/deselects coins from a set of canididate coins. /// @@ -14,6 +26,7 @@ use alloc::{borrow::Cow, collections::BTreeSet}; #[derive(Debug, Clone)] pub struct CoinSelector<'a> { candidates: &'a [Candidate], + ancestors: &'a [UnconfirmedAncestor], selected: Cow<'a, BTreeSet>, banned: Cow<'a, BTreeSet>, candidate_order: Cow<'a, [usize]>, @@ -34,26 +47,35 @@ impl<'a> CoinSelector<'a> { pub fn new(candidates: &'a [Candidate]) -> Self { Self { candidates, + ancestors: &[], selected: Cow::Owned(Default::default()), banned: Cow::Owned(Default::default()), candidate_order: Cow::Owned((0..candidates.len()).collect()), } } + /// Set the shared ancestor data for CPFP bump fee calculations. + /// + /// Each [`Candidate`]'s `ancestors` field contains indices into this slice. + pub fn with_ancestors(mut self, ancestors: &'a [UnconfirmedAncestor]) -> Self { + self.ancestors = ancestors; + self + } + /// Iterate over all the candidates in their currently sorted order. Each item has the original /// index with the candidate. pub fn candidates( &self, - ) -> impl DoubleEndedIterator + ExactSizeIterator + '_ { + ) -> impl DoubleEndedIterator + ExactSizeIterator + '_ { self.candidate_order .iter() - .map(move |i| (*i, self.candidates[*i])) + .map(move |i| (*i, &self.candidates[*i])) } /// Get the candidate at `index`. `index` refers to its position in the original `candidates` /// slice passed into [`CoinSelector::new`]. - pub fn candidate(&self, index: usize) -> Candidate { - self.candidates[index] + pub fn candidate(&self, index: usize) -> &Candidate { + &self.candidates[index] } /// Deselect a candidate at `index`. `index` refers to its position in the original `candidates` @@ -172,6 +194,36 @@ 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 collects unique ancestor indices across all selected candidates, sums their weights + /// and fees, 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; + } + let mut indices: Vec = self + .selected + .iter() + .flat_map(|&i| self.candidates[i].ancestors.iter().copied()) + .collect(); + indices.sort_unstable(); + indices.dedup(); + + let mut total_weight = 0u64; + let mut total_fee_paid = 0u64; + for anc_index in indices { + let anc = &self.ancestors[anc_index]; + total_weight += anc.weight; + total_fee_paid += anc.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 @@ -199,6 +251,7 @@ impl<'a> CoinSelector<'a> { - target.value() as i64 - drain.value as i64 - self.implied_fee_from_feerate(target, drain.weights) as i64 + - self.selected_ancestor_bump_fee(target.fee.rate) as i64 } /// Same as [rate_excess](Self::rate_excess) except `target.fee.rate` is applied to the @@ -208,6 +261,7 @@ impl<'a> CoinSelector<'a> { - target.value() as i64 - drain.value as i64 - self.implied_fee_from_feerate_wu(target, drain.weights) as i64 + - self.selected_ancestor_bump_fee(target.fee.rate) as i64 } /// How much the current selection overshoots the value needed to satisfy `target.fee.absolute` @@ -230,6 +284,7 @@ impl<'a> CoinSelector<'a> { - target.value() as i64 - drain.value as i64 - replacement_excess_needed as i64 + - self.selected_ancestor_bump_fee(target.fee.rate) as i64 } /// Same as [replacement_excess](Self::replacement_excess) except the replacement fee @@ -244,6 +299,7 @@ impl<'a> CoinSelector<'a> { - target.value() as i64 - drain.value as i64 - replacement_excess_needed as i64 + - self.selected_ancestor_bump_fee(target.fee.rate) as i64 } /// The feerate the transaction would have if we were to use this selection of inputs to achieve @@ -304,8 +360,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. @@ -324,11 +383,11 @@ impl<'a> CoinSelector<'a> { /// [`unselected`]: CoinSelector::unselected pub fn sort_candidates_by(&mut self, mut cmp: F) where - F: FnMut((usize, Candidate), (usize, Candidate)) -> core::cmp::Ordering, + F: FnMut((usize, &Candidate), (usize, &Candidate)) -> core::cmp::Ordering, { let order = self.candidate_order.to_mut(); let candidates = &self.candidates; - order.sort_by(|a, b| cmp((*a, candidates[*a]), (*b, candidates[*b]))) + order.sort_by(|a, b| cmp((*a, &candidates[*a]), (*b, &candidates[*b]))) } /// Sorts the candidates by the key function. @@ -342,10 +401,10 @@ impl<'a> CoinSelector<'a> { /// [`unselected`]: CoinSelector::unselected pub fn sort_candidates_by_key(&mut self, mut key_fn: F) where - F: FnMut((usize, Candidate)) -> K, + F: FnMut((usize, &Candidate)) -> K, K: Ord, { - self.sort_candidates_by(|a, b| key_fn(a).cmp(&key_fn(b))) + self.sort_candidates_by(|a, b| key_fn(a).cmp(&key_fn(b))); } /// Sorts the candidates by descending value per weight unit, tie-breaking with value. @@ -391,10 +450,10 @@ impl<'a> CoinSelector<'a> { /// The selected candidates with their index. pub fn selected( &self, - ) -> impl ExactSizeIterator + DoubleEndedIterator + '_ { + ) -> impl ExactSizeIterator + DoubleEndedIterator + '_ { self.selected .iter() - .map(move |&index| (index, self.candidates[index])) + .map(move |&index| (index, &self.candidates[index])) } /// The unselected candidates with their index. @@ -402,9 +461,9 @@ impl<'a> CoinSelector<'a> { /// The candidates are returned in sorted order. See [`sort_candidates_by`]. /// /// [`sort_candidates_by`]: Self::sort_candidates_by - pub fn unselected(&self) -> impl DoubleEndedIterator + '_ { + pub fn unselected(&self) -> impl DoubleEndedIterator + '_ { self.unselected_indices() - .map(move |i| (i, self.candidates[i])) + .map(move |i| (i, &self.candidates[i])) } /// The indices of the selelcted candidates. @@ -624,20 +683,23 @@ pub struct SelectIter<'a> { } impl<'a> Iterator for SelectIter<'a> { - type Item = (CoinSelector<'a>, usize, Candidate); + type Item = (CoinSelector<'a>, usize, &'a Candidate); fn next(&mut self) -> Option { - let (index, wv) = self.cs.unselected().next()?; + let index = self.cs.unselected_indices().next()?; + // Access the underlying slice directly to get the `'a` lifetime. + let candidates: &'a [Candidate] = self.cs.candidates; self.cs.select(index); - Some((self.cs.clone(), index, wv)) + Some((self.cs.clone(), index, &candidates[index])) } } -impl DoubleEndedIterator for SelectIter<'_> { +impl<'a> DoubleEndedIterator for SelectIter<'a> { fn next_back(&mut self) -> Option { - let (index, wv) = self.cs.unselected().next_back()?; + let index = self.cs.unselected_indices().next_back()?; + let candidates: &'a [Candidate] = self.cs.candidates; self.cs.select(index); - Some((self.cs.clone(), index, wv)) + Some((self.cs.clone(), index, &candidates[index])) } } @@ -682,7 +744,7 @@ impl std::error::Error for NoBnbSolution {} /// A `Candidate` represents an input candidate for [`CoinSelector`]. /// /// This can either be a single UTXO, or a group of UTXOs that should be spent together. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub struct Candidate { /// Total value of the UTXO(s) that this [`Candidate`] represents. pub value: u64, @@ -694,6 +756,12 @@ pub struct Candidate { pub input_count: usize, /// Whether this [`Candidate`] contains at least one segwit spend. pub is_segwit: bool, + /// Indices into the shared [`UnconfirmedAncestor`] slice (passed to + /// [`CoinSelector::with_ancestors`]) that this candidate depends on. + /// + /// When multiple candidates share ancestors, those ancestors are automatically deduplicated + /// during bump fee computation. + pub ancestors: Vec, } impl Candidate { @@ -707,13 +775,14 @@ impl Candidate { /// /// `satisfaction_weight` is the weight of `scriptSigLen + scriptSig + scriptWitnessLen + /// scriptWitness`. - pub fn new(value: u64, satisfaction_weight: u64, is_segwit: bool) -> Candidate { + pub fn new(value: u64, satisfaction_weight: u64, is_segwit: bool) -> Self { let weight = TXIN_BASE_WEIGHT + satisfaction_weight; Candidate { value, weight, input_count: 1, is_segwit, + ancestors: Vec::new(), } } diff --git a/tests/ancestor_aware.rs b/tests/ancestor_aware.rs new file mode 100644 index 0000000..8c311f7 --- /dev/null +++ b/tests/ancestor_aware.rs @@ -0,0 +1,261 @@ +use bdk_coin_select::{ + Candidate, CoinSelector, Drain, FeeRate, 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)), + } +} + +#[test] +fn zero_ancestors_backward_compatible() { + let candidates = [Candidate { + input_count: 1, + value: 200_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + ancestors: vec![], + }]; + + 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, + }]; + + let candidates = [Candidate { + input_count: 1, + value: 200_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + ancestors: vec![0], + }]; + + 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, + }]; + + let candidates = [ + Candidate { + input_count: 1, + value: 100_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + ancestors: vec![0], // points to ancestor 0 + }, + Candidate { + input_count: 1, + value: 100_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + ancestors: vec![0], // also points to ancestor 0 + }, + ]; + + 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 + }, + UnconfirmedAncestor { + weight: 400, + fee_paid: 10_000, // very high fee + }, + ]; + + let candidates = [Candidate { + input_count: 1, + value: 200_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + ancestors: vec![0, 1], // depends on both ancestors + }]; + + 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 + }]; + + let candidates = [Candidate { + input_count: 1, + value: 200_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + ancestors: vec![0], + }]; + + 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 + }]; + + let candidates = [Candidate { + input_count: 1, + value: 200_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + ancestors: vec![0], + }]; + + 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, + }]; + + let candidates = [Candidate { + input_count: 1, + value: 200_000, + weight: TR_KEYSPEND_TXIN_WEIGHT, + is_segwit: true, + ancestors: vec![0], + }]; + + 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" + ); +} diff --git a/tests/bnb.rs b/tests/bnb.rs index 6a2f437..7bd4bdd 100644 --- a/tests/bnb.rs +++ b/tests/bnb.rs @@ -16,6 +16,7 @@ fn test_wv(mut rng: impl RngCore) -> impl Iterator { weight: 100, input_count: rng.random_range(1..2), is_segwit: rng.random_bool(0.5), + ancestors: vec![], }; // HACK: set is_segwit = true for all these tests because you can't actually lower bound // things easily with how segwit inputs interfere with their weights. We can't modify the diff --git a/tests/changeless.rs b/tests/changeless.rs index 42224e9..9d3e2f8 100644 --- a/tests/changeless.rs +++ b/tests/changeless.rs @@ -17,6 +17,7 @@ fn test_wv(mut rng: impl RngCore) -> impl Iterator { weight: rng.random_range(0..100), input_count: rng.random_range(1..2), is_segwit: false, + ancestors: vec![], } }) } diff --git a/tests/common.rs b/tests/common.rs index 8b8cb9d..c27b3b8 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -254,6 +254,7 @@ pub fn gen_candidates(n: usize) -> Vec { weight, input_count, is_segwit, + ancestors: vec![], } }) .take(n) diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index 27e2948..9c93ddb 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -82,6 +82,7 @@ proptest! { weight: (32 + 4 + 4 + 1) * 4 + 64 + 32, input_count: 1, is_segwit: true, + ancestors: vec![], }) .take(params.n_candidates) .collect::>(); @@ -200,12 +201,14 @@ fn adding_another_input_to_remove_change() { weight: 100, input_count: 1, is_segwit: true, + ancestors: vec![], }, Candidate { value: 50_000, weight: 100, input_count: 1, is_segwit: true, + ancestors: vec![], }, // NOTE: this input has negative effective value Candidate { @@ -213,6 +216,7 @@ fn adding_another_input_to_remove_change() { weight: 100, input_count: 1, is_segwit: true, + ancestors: vec![], }, ]; @@ -292,12 +296,14 @@ fn zero_fee_tx() { weight: 100, input_count: 1, is_segwit: true, + ancestors: vec![], }, Candidate { value: 50_000, weight: 100, input_count: 1, is_segwit: true, + ancestors: vec![], }, ]; diff --git a/tests/weight.rs b/tests/weight.rs index 6a8dbb5..44fe1f7 100644 --- a/tests/weight.rs +++ b/tests/weight.rs @@ -37,6 +37,7 @@ fn segwit_one_input_one_output() { weight: txin.segwit_weight().to_wu(), input_count: 1, is_segwit: true, + ancestors: vec![], }) .collect::>(); @@ -80,6 +81,7 @@ fn segwit_two_inputs_one_output() { weight: txin.segwit_weight().to_wu(), input_count: 1, is_segwit: true, + ancestors: vec![], }) .collect::>(); @@ -124,6 +126,7 @@ fn legacy_three_inputs() { weight: txin.legacy_weight().to_wu(), input_count: 1, is_segwit: false, + ancestors: vec![], }) .collect::>(); @@ -181,6 +184,7 @@ fn legacy_three_inputs_one_segwit() { .to_wu(), input_count: 1, is_segwit, + ancestors: vec![], } }) .collect::>();