diff --git a/CHANGELOG.md b/CHANGELOG.md index 41e1592..bf9042f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# Unreleased + +- Add package-aware coin selection for CPFP (Child Pays for Parent) scenarios + - New `Package` struct to specify parent transaction fee and weight + - `CoinSelector::with_package()` to configure package context and auto-select linking inputs + - `weight()` and `fee()` now include parent context when package is set + - New `weight_without_package()` and `fee_without_package()` for child-only values + - `implied_feerate()` returns package feerate when package is set + - RBF calculations correctly use child weight only + # 0.4.0 - Use `u64` for weights instead of u32 diff --git a/src/coin_selector.rs b/src/coin_selector.rs index 3f5a29c..3ebf6ee 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -17,6 +17,7 @@ pub struct CoinSelector<'a> { selected: Cow<'a, BTreeSet>, banned: Cow<'a, BTreeSet>, candidate_order: Cow<'a, [usize]>, + package: Option, } impl<'a> CoinSelector<'a> { @@ -37,9 +38,55 @@ impl<'a> CoinSelector<'a> { selected: Cow::Owned(Default::default()), banned: Cow::Owned(Default::default()), candidate_order: Cow::Owned((0..candidates.len()).collect()), + package: None, } } + /// Configures package-aware coin selection for CPFP (Child Pays for Parent) scenarios. + /// + /// When you need to bump the feerate of unconfirmed parent transactions, this method lets you + /// specify the parent context and which candidates link to those parents. + /// + /// `package` describes the aggregate fee and weight of the parent transactions. + /// `link_indices` are the indices of candidates that spend outputs from the parent + /// transactions. These will be automatically selected since they must be included to create + /// the child relationship. + /// + /// # Example + /// + /// ``` + /// # use bdk_coin_select::*; + /// // Parent tx has 200 sats fee and 400 weight units + /// let package = Package { parent_fee: 200, parent_weight: 400 }; + /// + /// // Candidate at index 0 spends from the parent + /// # let candidates = vec![Candidate::new_tr_keyspend(10_000)]; + /// let selector = CoinSelector::new(&candidates).with_package(package, [0]); + /// + /// // Index 0 is now selected + /// assert!(selector.is_selected(0)); + /// ``` + pub fn with_package( + mut self, + package: Package, + link_indices: impl IntoIterator, + ) -> Self { + self.package = Some(package); + for index in link_indices { + self.select(index); + } + self + } + + /// Returns the package context if set. + /// + /// See [`with_package`] for more information on package-aware coin selection. + /// + /// [`with_package`]: Self::with_package + pub fn package(&self) -> Option { + self.package + } + /// Iterate over all the candidates in their currently sorted order. Each item has the original /// index with the candidate. pub fn candidates( @@ -164,9 +211,29 @@ impl<'a> CoinSelector<'a> { /// Current weight of transaction implied by the selection. /// + /// When a [`Package`] is set, this includes the parent transaction weight. Use + /// [`weight_without_package`] if you need the child transaction weight only. + /// /// If you don't have any drain outputs (only target outputs) just set drain_weights to /// [`DrainWeights::NONE`]. + /// + /// [`weight_without_package`]: Self::weight_without_package pub fn weight(&self, target_ouputs: TargetOutputs, drain_weight: DrainWeights) -> u64 { + let child_weight = self.weight_without_package(target_ouputs, drain_weight); + match self.package { + Some(pkg) => child_weight + pkg.parent_weight, + None => child_weight, + } + } + + /// Weight of the child transaction only, excluding any package parent weight. + /// + /// This is useful for RBF calculations where constraints apply to the child transaction only. + pub fn weight_without_package( + &self, + target_ouputs: TargetOutputs, + drain_weight: DrainWeights, + ) -> u64 { TX_FIXED_FIELD_WEIGHT + self.input_weight() + target_ouputs.output_weight_with_drain(drain_weight) @@ -178,6 +245,7 @@ impl<'a> CoinSelector<'a> { /// this means the transaction will overpay for what it needs to reach `target`. pub fn excess(&self, target: Target, drain: Drain) -> i64 { self.rate_excess(target, drain) + .min(self.absolute_excess(target, drain)) .min(self.replacement_excess(target, drain)) } @@ -192,7 +260,7 @@ impl<'a> CoinSelector<'a> { } /// How much the current selection overshoots the value need to satisfy `target.fee.rate` and - /// `target.value` (while ignoring `target.min_fee`). + /// `target.value` (while ignoring `target.fee.absolute`). pub fn rate_excess(&self, target: Target, drain: Drain) -> i64 { self.selected_value() as i64 - target.value() as i64 @@ -209,12 +277,28 @@ impl<'a> CoinSelector<'a> { - self.implied_fee_from_feerate_wu(target, drain.weights) as i64 } + /// How much the current selection overshoots the value needed to satisfy `target.fee.absolute` + /// and `target.value` (while ignoring `target.fee.rate`). + pub fn absolute_excess(&self, target: Target, drain: Drain) -> i64 { + self.selected_value() as i64 + - target.value() as i64 + - drain.value as i64 + - target.fee.absolute as i64 + } + /// How much the current selection overshoots the value needed to satisfy RBF's rule 4. + /// + /// Note: RBF constraints apply to the child transaction only, so this method uses + /// [`weight_without_package`] even when a package is set. + /// + /// [`weight_without_package`]: Self::weight_without_package 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)) + // RBF rule 4 applies to the child transaction only + replacement_excess_needed = replace.min_fee_to_do_replacement( + self.weight_without_package(target.outputs, drain.weights), + ) } self.selected_value() as i64 - target.value() as i64 @@ -227,8 +311,10 @@ impl<'a> CoinSelector<'a> { 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)) + // RBF rule 4 applies to the child transaction only + replacement_excess_needed = replace.min_fee_to_do_replacement_wu( + self.weight_without_package(target.outputs, drain.weights), + ) } self.selected_value() as i64 - target.value() as i64 @@ -239,31 +325,36 @@ impl<'a> CoinSelector<'a> { /// The feerate the transaction would have if we were to use this selection of inputs to achieve /// the `target`'s value and weight. It is essentially telling you what target feerate you currently have. /// + /// When a [`Package`] is set, this returns the package feerate: + /// `(parent_fee + child_fee) / (parent_weight + child_weight)`. + /// /// Returns `None` if the feerate would be negative or infinity. pub fn implied_feerate(&self, target_outputs: TargetOutputs, drain: Drain) -> Option { - let numerator = - self.selected_value() as i64 - target_outputs.value_sum as i64 - drain.value as i64; - let denom = self.weight(target_outputs, drain.weights); - if numerator < 0 || denom == 0 { + let total_fee = self.fee(target_outputs.value_sum, drain.value); + let total_weight = self.weight(target_outputs, drain.weights); + if total_fee < 0 || total_weight == 0 { return None; } - Some(FeeRate::from_sat_per_wu(numerator as f32 / denom as f32)) + Some(FeeRate::from_sat_per_wu( + total_fee as f32 / total_weight as f32, + )) } - /// The fee the current selection and `drain_weight` should pay to satisfy `target_fee`. + /// 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. /// /// `drain_weight` can be 0 to indicate no draining output. pub fn implied_fee(&self, target: Target, drain_weights: DrainWeights) -> u64 { - let mut implied_fee = self.implied_fee_from_feerate(target, drain_weights); + 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 = replace + .min_fee_to_do_replacement(self.weight(target.outputs, drain_weights)) + .max(implied_fee); } implied_fee @@ -286,8 +377,25 @@ impl<'a> CoinSelector<'a> { /// The actual fee the selection would pay if it was used in a transaction that had /// `target_value` value for outputs and change output of `drain_value`. /// + /// When a [`Package`] is set, this includes the parent transaction fee. Use + /// [`fee_without_package`] if you need the child transaction fee only. + /// /// This can be negative when the selection is invalid (outputs are greater than inputs). + /// + /// [`fee_without_package`]: Self::fee_without_package pub fn fee(&self, target_value: u64, drain_value: u64) -> i64 { + let child_fee = self.fee_without_package(target_value, drain_value); + match self.package { + Some(pkg) => child_fee + pkg.parent_fee as i64, + None => child_fee, + } + } + + /// Fee of the child transaction only, excluding any package parent fee. + /// + /// This is useful when you need to know what the child transaction actually pays, + /// separate from the package context. + pub fn fee_without_package(&self, target_value: u64, drain_value: u64) -> i64 { self.selected_value() as i64 - target_value as i64 - drain_value as i64 } @@ -735,3 +843,175 @@ impl Candidate { self.implied_fee(feerate) / self.value as f32 } } + +#[cfg(test)] +mod test { + use super::*; + use alloc::vec::Vec; + + /// Helper to create a simple test setup + fn test_setup() -> (Vec, TargetOutputs, DrainWeights) { + let candidates = vec![ + Candidate::new_tr_keyspend(100_000), + Candidate::new_tr_keyspend(50_000), + ]; + let target_outputs = TargetOutputs { + value_sum: 80_000, + weight_sum: 200, + n_outputs: 1, + }; + let drain_weights = DrainWeights::NONE; + (candidates, target_outputs, drain_weights) + } + + #[test] + fn weight_includes_parent_weight_when_package_set() { + let (candidates, target_outputs, drain_weights) = test_setup(); + let package = Package { + parent_fee: 500, + parent_weight: 400, + }; + + let selector_without = CoinSelector::new(&candidates); + let selector_with = CoinSelector::new(&candidates).with_package(package, []); + + let child_weight = selector_without.weight(target_outputs, drain_weights); + let package_weight = selector_with.weight(target_outputs, drain_weights); + + assert_eq!(package_weight, child_weight + 400); + } + + #[test] + fn weight_without_package_returns_child_only() { + let (candidates, target_outputs, drain_weights) = test_setup(); + let package = Package { + parent_fee: 500, + parent_weight: 400, + }; + + let selector = CoinSelector::new(&candidates).with_package(package, []); + + let child_weight = selector.weight_without_package(target_outputs, drain_weights); + let package_weight = selector.weight(target_outputs, drain_weights); + + assert_eq!(package_weight, child_weight + 400); + } + + #[test] + fn fee_includes_parent_fee_when_package_set() { + let (candidates, ..) = test_setup(); + let package = Package { + parent_fee: 500, + parent_weight: 400, + }; + + let mut selector_without = CoinSelector::new(&candidates); + selector_without.select(0); // Select first candidate (100_000 sats) + + let mut selector_with = CoinSelector::new(&candidates).with_package(package, []); + selector_with.select(0); + + let target_value = 80_000; + let drain_value = 0; + + let child_fee = selector_without.fee(target_value, drain_value); + let package_fee = selector_with.fee(target_value, drain_value); + + // child_fee = 100_000 - 80_000 = 20_000 + assert_eq!(child_fee, 20_000); + // package_fee = child_fee + parent_fee = 20_000 + 500 = 20_500 + assert_eq!(package_fee, child_fee + 500); + } + + #[test] + fn fee_without_package_returns_child_only() { + let (candidates, ..) = test_setup(); + let package = Package { + parent_fee: 500, + parent_weight: 400, + }; + + let mut selector = CoinSelector::new(&candidates).with_package(package, []); + selector.select(0); + + let target_value = 80_000; + let drain_value = 0; + + let child_fee = selector.fee_without_package(target_value, drain_value); + let package_fee = selector.fee(target_value, drain_value); + + assert_eq!(child_fee, 20_000); + assert_eq!(package_fee, child_fee + 500); + } + + #[test] + fn implied_feerate_returns_package_feerate() { + let (candidates, target_outputs, ..) = test_setup(); + let package = Package { + parent_fee: 500, + parent_weight: 400, + }; + + let mut selector = CoinSelector::new(&candidates).with_package(package, []); + selector.select(0); + + let drain = Drain::NONE; + let feerate = selector.implied_feerate(target_outputs, drain).unwrap(); + + // package_fee = 20_000 (child) + 500 (parent) = 20_500 + // package_weight = child_weight + 400 + let child_weight = selector.weight_without_package(target_outputs, drain.weights); + let expected_feerate = 20_500.0 / (child_weight + 400) as f32; + + assert!((feerate.spwu() - expected_feerate).abs() < 0.001); + } + + #[test] + fn package_accessor_returns_package() { + let (candidates, ..) = test_setup(); + let package = Package { + parent_fee: 500, + parent_weight: 400, + }; + + let selector_without = CoinSelector::new(&candidates); + let selector_with = CoinSelector::new(&candidates).with_package(package, []); + + assert!(selector_without.package().is_none()); + assert_eq!(selector_with.package(), Some(package)); + } + + #[test] + fn with_package_auto_selects_link_indices() { + let (candidates, ..) = test_setup(); + let package = Package { + parent_fee: 500, + parent_weight: 400, + }; + + let selector = CoinSelector::new(&candidates).with_package(package, [0, 1]); + + assert!(selector.is_selected(0)); + assert!(selector.is_selected(1)); + } + + #[test] + fn no_package_behaves_normally() { + let (candidates, target_outputs, drain_weights) = test_setup(); + + let mut selector = CoinSelector::new(&candidates); + selector.select(0); + + // weight() and weight_without_package() should be equal + assert_eq!( + selector.weight(target_outputs, drain_weights), + selector.weight_without_package(target_outputs, drain_weights) + ); + + // fee() and fee_without_package() should be equal + assert_eq!( + selector.fee(80_000, 0), + selector.fee_without_package(80_000, 0) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index ac237b4..7cfc320 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,8 @@ mod target; pub use target::*; mod drain; pub use drain::*; +mod package; +pub use package::*; /// Txin "base" fields include `outpoint` (32+4) and `nSequence` (4) and 1 byte for the scriptSig /// length. diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index 2d7278c..c382fe8 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -172,6 +172,21 @@ impl BnbMetric for LowestFee { } } } + + // Handle absolute fee constraint. Unlike feerate and replacement, the + // absolute fee is a fixed amount (not weight-proportional), so we just + // need enough raw value to cover the gap. + let absolute_excess = cs.absolute_excess(self.target, Drain::NONE) as f32; + if absolute_excess < 0.0 { + let remaining = absolute_excess.abs(); + if to_resize.value > 0 { + let absolute_scale = remaining / to_resize.value as f32; + scale = scale.max(Ordf32(absolute_scale)); + } else { + return None; // we can never satisfy the constraint + } + } + // `scale` could be 0 even if `is_target_met` is `false` due to the latter being based on // rounded-up vbytes. let ideal_fee = scale.0 * to_resize.value as f32 + cs.selected_value() as f32 diff --git a/src/package.rs b/src/package.rs new file mode 100644 index 0000000..1f69e4b --- /dev/null +++ b/src/package.rs @@ -0,0 +1,22 @@ +/// Context for package-aware coin selection (CPFP scenarios). +/// +/// When a transaction has unconfirmed parents, miners evaluate the *package feerate* rather than +/// the child's feerate alone. This struct captures the aggregate fee and weight of all parent +/// transactions so that coin selection can target a feerate that makes the entire package +/// attractive to miners. +/// +/// The package feerate is calculated as: +/// ```text +/// package_feerate = (parent_fee + child_fee) / (parent_weight + child_weight) +/// ``` +/// +/// Use [`CoinSelector::with_package`] to create a package-aware coin selector. +/// +/// [`CoinSelector::with_package`]: crate::CoinSelector::with_package +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Package { + /// Total fees already paid by all parent transactions (in satoshis). + pub parent_fee: u64, + /// Total weight of all parent transactions (in weight units). + pub parent_weight: u64, +} diff --git a/src/target.rs b/src/target.rs index 36a8803..1bd3ae0 100644 --- a/src/target.rs +++ b/src/target.rs @@ -81,8 +81,10 @@ impl TargetOutputs { /// [RBF rule 4]: https://github.com/bitcoin/bitcoin/blob/master/doc/policy/mempool-replacements.md#current-replace-by-fee-policy /// [`ChangePolicy`]: crate::ChangePolicy pub struct TargetFee { - /// The feerate the transaction must have + /// The minimum feerate the transaction must have. pub rate: FeeRate, + /// The minimum absolute fee the transaction must have. + pub absolute: u64, /// The fee must enough enough to replace this pub replace: Option, } @@ -92,15 +94,16 @@ impl Default for TargetFee { fn default() -> Self { Self { rate: FeeRate::DEFAULT_MIN_RELAY, - replace: None, + ..Self::ZERO } } } impl TargetFee { - /// A target fee of 0 sats per vbyte (and no replacement) + /// A target fee of 0 sats per vbyte, 0 absolute fee (and no replacement) pub const ZERO: Self = TargetFee { rate: FeeRate::ZERO, + absolute: 0, replace: None, }; @@ -108,7 +111,7 @@ impl TargetFee { pub fn from_feerate(feerate: FeeRate) -> Self { Self { rate: feerate, - replace: None, + ..Self::ZERO } } } diff --git a/tests/changeless.rs b/tests/changeless.rs index 774e2a6..42224e9 100644 --- a/tests/changeless.rs +++ b/tests/changeless.rs @@ -66,6 +66,7 @@ proptest! { fee: TargetFee { rate: feerate, replace, + ..TargetFee::ZERO } }; diff --git a/tests/common.rs b/tests/common.rs index 59dcb23..8b8cb9d 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -214,6 +214,7 @@ impl StrategyParams { fee: TargetFee { rate: FeeRate::from_sat_per_vb(self.feerate), replace: self.replace, + ..TargetFee::ZERO }, outputs: TargetOutputs { value_sum: self.target_value, diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index a14296f..27e2948 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -277,6 +277,7 @@ fn zero_fee_tx() { fee: TargetFee { rate: target_feerate, replace: None, + ..TargetFee::ZERO }, outputs: TargetOutputs { value_sum: 99_870,