From ee587da3edf1399f49bbb17a9d493ce3cdc3a9f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sun, 8 Feb 2026 09:03:12 +0000 Subject: [PATCH 1/3] feat: Integrate package context into fee and weight calculations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Package is set via with_package(), all fee and weight calculations now automatically include parent transaction context: - weight() returns child_weight + parent_weight - fee() returns child_fee + parent_fee - implied_feerate() returns package feerate New methods for when child-only values are needed: - weight_without_package() - fee_without_package() - package() accessor RBF calculations (replacement_excess) use child weight only since Bitcoin's RBF rule 4 applies to the replacing transaction, not the package. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 10 ++++ src/coin_selector.rs | 115 +++++++++++++++++++++++++++++++++++++++---- src/lib.rs | 2 + src/package.rs | 22 +++++++++ 4 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 src/package.rs 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..30a697f 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) @@ -210,11 +277,18 @@ impl<'a> CoinSelector<'a> { } /// 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 +301,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,15 +315,19 @@ 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`. @@ -286,8 +366,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 } 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/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, +} From 6bf3239f0109a16baff188933615ec40659af88b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Mon, 9 Feb 2026 01:13:43 +0000 Subject: [PATCH 2/3] test: Add unit tests for package-aware coin selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests cover: - weight() includes parent_weight when package is set - weight_without_package() returns child weight only - fee() includes parent_fee when package is set - fee_without_package() returns child fee only - implied_feerate() returns package feerate - package() accessor - with_package() auto-selects link indices - No package behaves normally (backwards compatible) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/coin_selector.rs | 172 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/src/coin_selector.rs b/src/coin_selector.rs index 30a697f..9ba2713 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -832,3 +832,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) + ); + } +} From 1db713ab18162a5e8bf2d39f02a08e7bf2bdd5a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Wed, 18 Feb 2026 15:57:21 +0000 Subject: [PATCH 3/3] feat: Add absolute fee target to TargetFee Add a `TargetFee::absolute` field that acts as a minimum absolute fee floor, checked independently alongside the existing feerate and replacement constraints. This supports use cases like Payjoin (BIP 78), where the receiver must not decrease the original transaction's absolute fee, and application-level policy minimums. Changes: - Add `absolute: u64` field to `TargetFee` - Add `CoinSelector::absolute_excess()` method - Integrate absolute excess into `CoinSelector::excess()` via `.min()` - Account for absolute fee in `CoinSelector::implied_fee()` - Handle absolute constraint in `LowestFee::bound()` for correct branch-and-bound behavior --- src/coin_selector.rs | 25 ++++++++++++++++++------- src/metrics/lowest_fee.rs | 15 +++++++++++++++ src/target.rs | 11 +++++++---- tests/changeless.rs | 1 + tests/common.rs | 1 + tests/lowest_fee.rs | 1 + 6 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/coin_selector.rs b/src/coin_selector.rs index 9ba2713..3ebf6ee 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -245,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)) } @@ -259,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 @@ -276,6 +277,15 @@ 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 @@ -330,20 +340,21 @@ impl<'a> CoinSelector<'a> { )) } - /// 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 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/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,