From b8db9e91aef3701e9fbcc9bec804913bc90ba901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Mon, 30 Mar 2026 18:52:54 +0000 Subject: [PATCH] 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 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/coin_selector.rs | 16 ++++++++++++++-- src/metrics/lowest_fee.rs | 14 ++++++++++++++ src/target.rs | 11 +++++++---- tests/changeless.rs | 1 + tests/common.rs | 1 + tests/lowest_fee.rs | 1 + 6 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/coin_selector.rs b/src/coin_selector.rs index 3f5a29c..d6cdd7b 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -178,6 +178,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 +193,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,6 +210,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. pub fn replacement_excess(&self, target: Target, drain: Drain) -> i64 { let mut replacement_excess_needed = 0; @@ -257,7 +267,9 @@ impl<'a> CoinSelector<'a> { /// /// `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( diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index 2d7278c..730edaa 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -172,6 +172,20 @@ 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,