From 281f73cf76051a3e13cda9451a5047acecdc7fb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 19 May 2026 14:48:53 +0000 Subject: [PATCH 1/4] feat(metrics): add ChangelessWaste BnB metric Adds a waste metric restricted to changeless selections. Removing the change output from the picture eliminates the non-monotonic discontinuity that complicates the general waste bound, so the LB reduces to a lower bound on `input_weight * (feerate - long_term_feerate)`. For target-not-met, rate_diff >= 0 we reuse LowestFee::bound's resize trick applied to input_weight rather than fee. Includes proptests `can_eventually_find_best_solution` and `ensure_bound_is_not_too_tight`. Co-Authored-By: Claude Opus 4.7 --- src/metrics.rs | 2 + src/metrics/changeless_waste.rs | 216 ++++++++++++++++++++++++++++++++ tests/changeless_waste.rs | 144 +++++++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 src/metrics/changeless_waste.rs create mode 100644 tests/changeless_waste.rs diff --git a/src/metrics.rs b/src/metrics.rs index 1da1163..f949438 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -7,3 +7,5 @@ mod lowest_fee; pub use lowest_fee::*; mod changeless; pub use changeless::*; +mod changeless_waste; +pub use changeless_waste::*; diff --git a/src/metrics/changeless_waste.rs b/src/metrics/changeless_waste.rs new file mode 100644 index 0000000..1b1a4ce --- /dev/null +++ b/src/metrics/changeless_waste.rs @@ -0,0 +1,216 @@ +use crate::{bnb::BnbMetric, float::Ordf32, CoinSelector, Drain, DrainWeights, FeeRate, Target}; + +/// Metric that minimizes the [waste metric] subject to the constraint that the selection produces +/// no change output. +/// +/// For a changeless selection, waste reduces to: +/// +/// > `input_weight * (feerate - long_term_feerate) + max(0, excess)` +/// +/// Excess in a changeless transaction goes to the miner as fees and is therefore fully counted as +/// waste. +/// +/// Restricting to changeless solutions removes the non-monotonic discontinuity that the general +/// (with-change) waste metric has when an input flips the change output on or off, which makes a +/// correct bound much easier to construct. +/// +/// Like [`LowestFee`], `ChangelessWaste` decides for itself whether a selection *would* have a +/// change output (using the same rule: change is worthwhile when the recovered excess outweighs the +/// future cost of spending it and clears the dust threshold). Selections that would have change are +/// rejected, so only genuinely changeless selections are scored. +/// +/// [waste metric]: https://bitcoin.stackexchange.com/questions/113622/what-does-waste-metric-mean-in-the-context-of-coin-selection +/// [`LowestFee`]: crate::metrics::LowestFee +#[derive(Clone, Copy, Debug)] +pub struct ChangelessWaste { + /// The estimated feerate needed to spend a change output later. This is used by the metric + /// even though the scored selections do not have a change output — the long-term feerate + /// defines the `feerate - long_term_feerate` weight cost of each input. + pub long_term_feerate: FeeRate, + /// The feerate used to determine the dust threshold of the change output. + pub dust_relay_feerate: FeeRate, + /// The weights of the change output that would be added. + pub drain_weights: DrainWeights, +} + +impl ChangelessWaste { + /// The value the change output would have, or `None` if this selection should be changeless. + /// + /// This is the same change decision as [`LowestFee`]: the metric owns its change policy instead + /// of taking one as input. + /// + /// [`LowestFee`]: crate::metrics::LowestFee + fn drain_value(&self, cs: &CoinSelector<'_>, target: Target) -> Option { + // The change output pays for its own weight, so the value we'd actually recover is the + // excess remaining after accounting for that weight. + let excess_with_drain_weight = cs.excess( + target, + Drain { + weights: self.drain_weights, + value: 0, + }, + ); + + // Adding change is only worth it if the value we'd recover exceeds the future cost of + // spending it (i.e. it lowers the long-term fee). + let drain_spend_cost = self + .long_term_feerate + .implied_fee_wu(self.drain_weights.spend_weight); + if excess_with_drain_weight <= drain_spend_cost as i64 { + return None; + } + + // ...and only if the change output would not be dust. + let dust_threshold = self.drain_weights.dust_threshold(self.dust_relay_feerate); + if excess_with_drain_weight < dust_threshold as i64 { + return None; + } + + Some(excess_with_drain_weight.unsigned_abs()) + } + + /// Whether every selection reachable down this branch (the current one and any superset of it) + /// would have a change output — so no changeless solution exists here and the branch can be + /// pruned. + /// + /// The change decision is assumed monotone in the excess (see [`drain_value`]), so the + /// reachable selection least likely to have change is the one with the smallest excess: the + /// current selection plus every remaining negative-effective-value candidate. If even that + /// selection still has change, then so does every reachable selection. + /// + /// 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. + /// + /// [`drain_value`]: Self::drain_value + /// [`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.drain_value(cs, target).is_none() { + return false; + } + + let mut least_excess = cs.clone(); + cs.unselected() + .rev() + .take_while(|(_, wv)| wv.effective_value(target.fee.rate) < 0.0) + .for_each(|(index, _)| { + least_excess.select(index); + }); + + self.drain_value(&least_excess, target).is_some() + } +} + +impl BnbMetric for ChangelessWaste { + fn drain(&mut self, _cs: &CoinSelector<'_>, _target: Target) -> Drain { + // By definition a changeless selection never has a change output. + Drain::NONE + } + + fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { + if !cs.is_target_met(target) { + return None; + } + // Reject selections that would have change — this metric only scores changeless solutions. + if self.drain_value(cs, target).is_some() { + return None; + } + let waste = cs.waste(target, self.long_term_feerate, Drain::NONE, 1.0); + Some(Ordf32(waste)) + } + + fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { + // Prune branches where every descendant is forced to have a change output. + if self.change_unavoidable(cs, target) { + return None; + } + + let rate_diff = target.fee.rate.spwu() - self.long_term_feerate.spwu(); + + // For any changeless target-meeting descendant D ⊇ cs: + // score(D) = D.input_weight * rate_diff + max(0, D.excess) + // + // and `D.excess >= 0` (target met), so `score(D) >= D.input_weight * rate_diff`. The + // bound therefore reduces to finding a lower bound on `D.input_weight * rate_diff`. + + if rate_diff < 0.0 { + // rate_diff < 0: the most negative `D.input_weight * rate_diff` comes from the + // largest possible input_weight, which is bounded by selecting every candidate. + // (`D` need not actually be feasible — we only need an LB on its score.) + let mut all = cs.clone(); + all.select_all(); + return Some(Ordf32(all.input_weight() as f32 * rate_diff)); + } + + // rate_diff >= 0: smaller input_weight gives a smaller `input_weight * rate_diff`, so we + // want a lower bound on `D.input_weight`. `D.input_weight >= cs.input_weight` always + // (selecting only grows), but we can do much better when the target is not yet met. + if cs.is_target_met(target) { + return Some(Ordf32(cs.input_weight() as f32 * rate_diff)); + } + + // Target not met. Use the same resize trick as `LowestFee::bound`: walk the sorted + // unselected list until we cross the target, then pretend the crossing input was + // perfectly scaled so that the target is hit with zero excess. Among all subsets of + // unselected that reach target, the highest-`value_pwu` candidates are the most + // weight-efficient — so the resize-scaled prefix is a valid lower bound on any + // target-meeting descendant's input_weight. + let (mut cs, resize_index, to_resize) = cs + .clone() + .select_iter() + .find(|(cs, _, _)| cs.is_target_met(target))?; + + // If the find selection already hits target exactly, that's the minimum-weight + // target-meeting subset; the bound is its waste (with `Drain::NONE`). + if cs.excess(target, Drain::NONE) == 0 { + return Some(Ordf32(cs.waste( + target, + self.long_term_feerate, + Drain::NONE, + 1.0, + ))); + } + cs.deselect(resize_index); + + // Compute the smallest `scale` of `to_resize` that satisfies each fee constraint. + let mut scale = Ordf32(0.0); + + let rate_excess = cs.rate_excess_wu(target, Drain::NONE) as f32; + if rate_excess < 0.0 { + let remaining = rate_excess.abs(); + let ev_resized = to_resize.effective_value(target.fee.rate); + if ev_resized > 0.0 { + scale = scale.max(Ordf32(remaining / ev_resized)); + } else { + return None; + } + } + if let Some(replace) = target.fee.replace { + let replace_excess = cs.replacement_excess_wu(target, Drain::NONE) as f32; + if replace_excess < 0.0 { + let remaining = replace_excess.abs(); + let ev_resized = to_resize.effective_value(replace.incremental_relay_feerate); + if ev_resized > 0.0 { + scale = scale.max(Ordf32(remaining / ev_resized)); + } else { + return None; + } + } + } + let absolute_excess = cs.absolute_excess(target, Drain::NONE) as f32; + if absolute_excess < 0.0 { + let remaining = absolute_excess.abs(); + if to_resize.value > 0 { + scale = scale.max(Ordf32(remaining / to_resize.value as f32)); + } else { + return None; + } + } + + let ideal_input_weight = cs.input_weight() as f32 + scale.0 * to_resize.weight as f32; + Some(Ordf32(ideal_input_weight * rate_diff)) + } + + fn requires_ordering_by_descending_value_pwu(&self) -> bool { + true + } +} diff --git a/tests/changeless_waste.rs b/tests/changeless_waste.rs new file mode 100644 index 0000000..e12f9f1 --- /dev/null +++ b/tests/changeless_waste.rs @@ -0,0 +1,144 @@ +#![allow(unused_imports)] + +mod common; +use bdk_coin_select::metrics::ChangelessWaste; +use bdk_coin_select::{ + BnbMetric, Candidate, CoinSelector, Drain, DrainWeights, FeeRate, Replace, Target, TargetFee, + TargetOutputs, TX_FIXED_FIELD_WEIGHT, +}; +use proptest::prelude::*; + +proptest! { + #![proptest_config(ProptestConfig { + ..Default::default() + })] + + #[test] + #[cfg(not(debug_assertions))] // too slow if compiling for debug + fn can_eventually_find_best_solution( + n_candidates in 1..15_usize, + target_value in 500..500_000_u64, + n_target_outputs in 1usize..150, + target_weight in 0..10_000_u32, + replace in common::maybe_replace(0u64..10_000), + feerate in 1.0..100.0_f32, + feerate_lt_diff in -5.0..50.0_f32, + drain_weight in 100..=500_u32, + drain_spend_weight in 1..=2000_u32, + drain_dust in 100..=1000_u64, + n_drain_outputs in 1usize..150, + ) { + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs }; + let candidates = common::gen_candidates(params.n_candidates); + let metric = ChangelessWaste { + long_term_feerate: params.long_term_feerate(), + dust_relay_feerate: params.dust_relay_feerate(), + drain_weights: params.drain_weights(), + }; + common::can_eventually_find_best_solution(params, candidates, metric)?; + } + + #[test] + #[cfg(not(debug_assertions))] // too slow if compiling for debug + fn ensure_bound_is_not_too_tight( + n_candidates in 0..12_usize, + target_value in 500..500_000_u64, + n_target_outputs in 1usize..150, + target_weight in 0..10_000_u32, + replace in common::maybe_replace(0u64..10_000), + feerate in 1.0..100.0_f32, + feerate_lt_diff in -5.0..50.0_f32, + drain_weight in 100..=500_u32, + drain_spend_weight in 1..=2000_u32, + drain_dust in 100..=1000_u64, + n_drain_outputs in 1usize..150, + ) { + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs }; + let candidates = common::gen_candidates(params.n_candidates); + let metric = ChangelessWaste { + long_term_feerate: params.long_term_feerate(), + dust_relay_feerate: params.dust_relay_feerate(), + drain_weights: params.drain_weights(), + }; + common::ensure_bound_is_not_too_tight(params, candidates, metric)?; + } +} + +/// Sanity-check: the BnB solution must never have a change output, and its waste must be +/// no greater than the waste of any changeless brute-force selection we try. +#[test] +fn solution_is_changeless_and_not_worse_than_naive() { + let params = common::StrategyParams { + n_candidates: 12, + target_value: 90_000, + n_target_outputs: 1, + target_weight: 200 - TX_FIXED_FIELD_WEIGHT as u32 - 1, + replace: None, + feerate: 10.0, + feerate_lt_diff: 2.0, // long_term_feerate < feerate (rate_diff > 0) + drain_weight: 200, + drain_spend_weight: 600, + drain_dust: 200, + n_drain_outputs: 1, + }; + + let candidates = common::gen_candidates(params.n_candidates); + let mut cs = CoinSelector::new(&candidates); + + let mut metric = ChangelessWaste { + long_term_feerate: params.long_term_feerate(), + dust_relay_feerate: params.dust_relay_feerate(), + drain_weights: params.drain_weights(), + }; + + match common::bnb_search(&mut cs, params.target(), metric, usize::MAX) { + Ok((_score, _rounds)) => { + // A scored solution is changeless by construction: `score` returns `None` for any + // selection the metric would give a change output, so a returned solution is one the + // metric was able to score. + assert!( + metric.score(&cs, params.target()).is_some(), + "BnB result must be changeless and meet the target" + ); + assert!(cs.is_target_met(params.target())); + } + Err(_) => { + // No changeless solution exists for this combo — that's allowed. + } + } +} + +/// When `rate_diff < 0`, the metric will tend to consolidate (add inputs to reduce input_waste), +/// but only as long as it can keep the selection changeless. +#[test] +fn consolidation_regime_stays_changeless() { + let params = common::StrategyParams { + n_candidates: 10, + target_value: 50_000, + n_target_outputs: 1, + target_weight: 200 - TX_FIXED_FIELD_WEIGHT as u32 - 1, + replace: None, + feerate: 2.0, + feerate_lt_diff: 10.0, // long_term_feerate > feerate (rate_diff < 0) + drain_weight: 200, + drain_spend_weight: 600, + drain_dust: 200, + n_drain_outputs: 1, + }; + + let candidates = common::gen_candidates(params.n_candidates); + let mut cs = CoinSelector::new(&candidates); + + let mut metric = ChangelessWaste { + long_term_feerate: params.long_term_feerate(), + dust_relay_feerate: params.dust_relay_feerate(), + drain_weights: params.drain_weights(), + }; + + if common::bnb_search(&mut cs, params.target(), metric, usize::MAX).is_ok() { + assert!( + metric.score(&cs, params.target()).is_some(), + "result must be changeless" + ); + } +} From e8f73e8d89eb3806ac82baca85b153333c77123f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Tue, 19 May 2026 16:11:08 +0000 Subject: [PATCH 2/4] perf(metrics): tighten ChangelessWaste bound for rate_diff < 0 The previous bound for `rate_diff < 0` was `all_selected.input_weight * rate_diff`, which ignored that selecting every candidate would typically force a change output (making the selection infeasible under the changeless constraint). The new bound recasts the problem as: minimize the weight of candidates excluded from `D_all` such that `excess_with_drain` drops below `change_policy.min_value`. This is a 0/1 covering knapsack; the LP relaxation (sort positive-ev_feerate candidates by `ev/weight` descending and exclude fractionally) gives a safe upper bound on `D.input_weight` for any feasible changeless descendant. Benchmark improvements (round counts, BnB cap 2M): n=15 rate_diff_neg: 3,590 -> 415 (~9x) n=20 rate_diff_neg: 34,296 -> 2,138 (~16x) n=30 rate_diff_neg: 2M cap -> 74,055 (>27x, was hitting cap) rate_diff >= 0 paths are unchanged. `ensure_bound_is_not_too_tight` proptest verifies the new LB across 256 randomized scenarios. Co-Authored-By: Claude Opus 4.7 --- src/metrics/changeless_waste.rs | 151 +++++++++++++++++++++++++------- 1 file changed, 117 insertions(+), 34 deletions(-) diff --git a/src/metrics/changeless_waste.rs b/src/metrics/changeless_waste.rs index 1b1a4ce..0abce4a 100644 --- a/src/metrics/changeless_waste.rs +++ b/src/metrics/changeless_waste.rs @@ -1,4 +1,5 @@ use crate::{bnb::BnbMetric, float::Ordf32, CoinSelector, Drain, DrainWeights, FeeRate, Target}; +use alloc::vec::Vec; /// Metric that minimizes the [waste metric] subject to the constraint that the selection produces /// no change output. @@ -41,34 +42,49 @@ impl ChangelessWaste { /// /// [`LowestFee`]: crate::metrics::LowestFee fn drain_value(&self, cs: &CoinSelector<'_>, target: Target) -> Option { - // The change output pays for its own weight, so the value we'd actually recover is the - // excess remaining after accounting for that weight. - let excess_with_drain_weight = cs.excess( - target, - Drain { - weights: self.drain_weights, - value: 0, - }, - ); + let excess_with_drain_weight = self.excess_with_drain_weight(cs, target); // Adding change is only worth it if the value we'd recover exceeds the future cost of // spending it (i.e. it lowers the long-term fee). - let drain_spend_cost = self - .long_term_feerate - .implied_fee_wu(self.drain_weights.spend_weight); - if excess_with_drain_weight <= drain_spend_cost as i64 { + if excess_with_drain_weight <= self.drain_spend_cost() as i64 { return None; } // ...and only if the change output would not be dust. - let dust_threshold = self.drain_weights.dust_threshold(self.dust_relay_feerate); - if excess_with_drain_weight < dust_threshold as i64 { + if excess_with_drain_weight < self.dust_threshold() as i64 { return None; } Some(excess_with_drain_weight.unsigned_abs()) } + /// The excess of `cs` after accounting for the weight (but not value) of a would-be change + /// output. This is the quantity the change decision (see [`drain_value`]) is made on. + /// + /// [`drain_value`]: Self::drain_value + fn excess_with_drain_weight(&self, cs: &CoinSelector<'_>, target: Target) -> i64 { + // The change output pays for its own weight, so the value we'd actually recover is the + // excess remaining after accounting for that weight. + cs.excess( + target, + Drain { + weights: self.drain_weights, + value: 0, + }, + ) + } + + /// The future fee of spending a would-be change output. Change below this is never worthwhile. + fn drain_spend_cost(&self) -> u64 { + self.long_term_feerate + .implied_fee_wu(self.drain_weights.spend_weight) + } + + /// The dust threshold of a would-be change output. Change below this is never created. + fn dust_threshold(&self) -> u64 { + self.drain_weights.dust_threshold(self.dust_relay_feerate) + } + /// Whether every selection reachable down this branch (the current one and any superset of it) /// would have a change output — so no changeless solution exists here and the branch can be /// pruned. @@ -98,6 +114,77 @@ impl ChangelessWaste { self.drain_value(&least_excess, target).is_some() } + + /// LP-relaxed upper bound on `D.input_weight` for changeless `D ⊇ cs` (used by the + /// `rate_diff < 0` branch of [`bound`]). + /// + /// Construct `D_all = cs ∪ all unselected`. If `D_all` itself is changeless, the UB is + /// `D_all.input_weight`. Otherwise we must exclude enough excess-contributing + /// (positive-`effective_value`) candidates to drop `excess_with_drain_weight` down to the + /// largest still-changeless excess. To MAXIMIZE the remaining `input_weight` we MINIMIZE the + /// excluded weight, sorting positive-`ev` candidates by `ev / weight` descending and removing + /// fractionally until the required `delta` is met. + /// + /// The LP relaxation gives a value `>=` any integer solution's excluded weight, so + /// `D_all.input_weight - LP_min` is a safe UB for any feasible `D.input_weight`. The + /// `input_weight()` segwit/varint corrections only ever ADD weight to the parent, never + /// subtract from a subset — so the additive subtraction is safe in the UB direction. + /// + /// [`bound`]: BnbMetric::bound + fn ub_changeless_input_weight(&self, cs: &CoinSelector<'_>, target: Target) -> f32 { + let mut d_all = cs.clone(); + d_all.select_all(); + let d_all_iw = d_all.input_weight() as f32; + + // The largest `excess_with_drain_weight` for which a selection is still changeless: it is + // changeless (see `drain_value`) when its excess is `<= drain_spend_cost` OR `< + // dust_threshold`, and the union of those two regions is `excess <= max(drain_spend_cost, + // dust_threshold - 1)`. + let changeless_max_excess = + (self.drain_spend_cost() as i64).max(self.dust_threshold() as i64 - 1); + let delta = self.excess_with_drain_weight(&d_all, target) - changeless_max_excess; + if delta <= 0 { + return d_all_iw; + } + let mut remaining = delta as f32; + + let mut pos: Vec<(f32, f32)> = cs + .unselected() + .filter_map(|(_, c)| { + let ev = c.effective_value(target.fee.rate); + if ev > 0.0 { + Some((ev, c.weight as f32)) + } else { + None + } + }) + .collect(); + pos.sort_by(|a, b| { + let r_a = a.0 / a.1; + let r_b = b.0 / b.1; + r_b.partial_cmp(&r_a).unwrap_or(core::cmp::Ordering::Equal) + }); + + let mut removed_weight = 0.0_f32; + for (ev, w) in pos { + if remaining <= 0.0 { + break; + } + if ev >= remaining { + removed_weight += w * (remaining / ev); + remaining = 0.0; + } else { + removed_weight += w; + remaining -= ev; + } + } + if remaining > 0.0 { + // Unreachable when `change_unavoidable = false` (which the caller already checked). + // Fall back to the loose `D_all`-based bound rather than fabricating a tight one. + return d_all_iw; + } + d_all_iw - removed_weight + } } impl BnbMetric for ChangelessWaste { @@ -130,37 +217,34 @@ impl BnbMetric for ChangelessWaste { // score(D) = D.input_weight * rate_diff + max(0, D.excess) // // and `D.excess >= 0` (target met), so `score(D) >= D.input_weight * rate_diff`. The - // bound therefore reduces to finding a lower bound on `D.input_weight * rate_diff`. + // bound therefore reduces to bounding `D.input_weight` in the right direction. if rate_diff < 0.0 { - // rate_diff < 0: the most negative `D.input_weight * rate_diff` comes from the - // largest possible input_weight, which is bounded by selecting every candidate. - // (`D` need not actually be feasible — we only need an LB on its score.) - let mut all = cs.clone(); - all.select_all(); - return Some(Ordf32(all.input_weight() as f32 * rate_diff)); + // rate_diff < 0: we want an UPPER bound on `D.input_weight`. `all_selected` is a + // safe but loose UB; we tighten by LP-relaxed knapsack over candidates that + // *must* be excluded to keep the selection changeless. + let ub = self.ub_changeless_input_weight(cs, target); + return Some(Ordf32(ub * rate_diff)); } - // rate_diff >= 0: smaller input_weight gives a smaller `input_weight * rate_diff`, so we - // want a lower bound on `D.input_weight`. `D.input_weight >= cs.input_weight` always - // (selecting only grows), but we can do much better when the target is not yet met. + // rate_diff >= 0: we want a LOWER bound on `D.input_weight`. `cs.input_weight` is a + // safe baseline (input_weight is monotone non-decreasing). Tighten with the resize + // trick when target is not yet met. if cs.is_target_met(target) { return Some(Ordf32(cs.input_weight() as f32 * rate_diff)); } - // Target not met. Use the same resize trick as `LowestFee::bound`: walk the sorted - // unselected list until we cross the target, then pretend the crossing input was - // perfectly scaled so that the target is hit with zero excess. Among all subsets of - // unselected that reach target, the highest-`value_pwu` candidates are the most - // weight-efficient — so the resize-scaled prefix is a valid lower bound on any - // target-meeting descendant's input_weight. + // Target not met. Same resize trick as `LowestFee::bound`: walk the sorted unselected + // list until we cross the target, then pretend the crossing input was perfectly + // scaled so the target is hit with zero excess. Among all subsets of unselected that + // reach target, the highest-`value_pwu` candidates are the most weight-efficient — so + // the resize-scaled prefix is a valid lower bound on any target-meeting descendant's + // input_weight. let (mut cs, resize_index, to_resize) = cs .clone() .select_iter() .find(|(cs, _, _)| cs.is_target_met(target))?; - // If the find selection already hits target exactly, that's the minimum-weight - // target-meeting subset; the bound is its waste (with `Drain::NONE`). if cs.excess(target, Drain::NONE) == 0 { return Some(Ordf32(cs.waste( target, @@ -171,7 +255,6 @@ impl BnbMetric for ChangelessWaste { } cs.deselect(resize_index); - // Compute the smallest `scale` of `to_resize` that satisfies each fee constraint. let mut scale = Ordf32(0.0); let rate_excess = cs.rate_excess_wu(target, Drain::NONE) as f32; From 48baab70d745f2ce263eaf5885a88d4af71787b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 2 Jul 2026 16:11:18 +0000 Subject: [PATCH 3/4] refactor(metrics): extract shared resize_bound helper `LowestFee::bound` and `ChangelessWaste::bound` both used the same "resize trick" to lower-bound a monotone quantity of any target-meeting descendant: walk the value_pwu-sorted unselected list until the target is crossed, then fractionally resize the crossing input to hit the target with zero excess. Extract it into a private `resize_bound` helper in `metrics.rs`, returning a `ResizeBound` enum so each caller keeps its own exact-match computation (`LowestFee` returns `score`, `ChangelessWaste` returns `waste`) while sharing the find + deselect + scale mechanism. No behavior change; all tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/metrics.rs | 91 ++++++++++++++++++++++++++++++++ src/metrics/changeless_waste.rs | 63 +++++----------------- src/metrics/lowest_fee.rs | 93 +++++---------------------------- 3 files changed, 116 insertions(+), 131 deletions(-) diff --git a/src/metrics.rs b/src/metrics.rs index f949438..e7d4ff8 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -9,3 +9,94 @@ mod changeless; pub use changeless::*; mod changeless_waste; pub use changeless_waste::*; + +use crate::{Candidate, CoinSelector, Drain, Target}; + +/// Outcome of the "resize trick" (see [`resize_bound`]). +enum ResizeBound<'a> { + /// The crossing selection hit the target with exactly zero excess, so it is itself the + /// minimum-cost target-meeting descendant. Holds that selection. + Exact(CoinSelector<'a>), + /// The crossing input must be fractionally resized. Holds the selection with the crossing input + /// deselected, the crossing candidate, and the `scale ∈ [0, 1]` of it that satisfies every fee + /// constraint with exactly zero excess. + Resize(CoinSelector<'a>, Candidate, f32), +} + +/// Shared "resize trick" used by bounds that need a tight lower bound on some monotone quantity +/// (fee, `input_weight`, …) of any target-meeting descendant `D ⊇ cs`, for the case where `cs` +/// does not yet meet the target. +/// +/// Walk the `value_pwu`-sorted unselected list until the target is first crossed, then represent +/// the crossing candidate as a fractional `scale ∈ [0, 1]` that satisfies each fee constraint +/// (rate, replacement, absolute) with exactly zero excess. Among all subsets of unselected that +/// reach the target, the highest-`value_pwu` candidates are the most efficient, so the +/// resize-scaled prefix is a valid lower bound on any target-meeting descendant. +/// +/// Callers compute their own metric value from the outcome, e.g. +/// ```ignore +/// let ideal_fee = scale * to_resize.value as f32 + cs.selected_value() as f32 - target.value() as f32; +/// let ideal_iw = cs.input_weight() as f32 + scale * to_resize.weight as f32; +/// ``` +/// +/// Returns `None` if no target-meeting descendant exists (a fee constraint cannot be satisfied by +/// any available candidate). +fn resize_bound<'a>(cs: &CoinSelector<'a>, target: Target) -> Option> { + // Step 1: select everything up until the input that first hits the target. + let (mut cs, resize_index, to_resize) = cs + .clone() + .select_iter() + .find(|(cs, _, _)| cs.is_target_met(target))?; + + // If this selection is already perfect, it is the minimum-cost target-meeting descendant. + if cs.excess(target, Drain::NONE) == 0 { + return Some(ResizeBound::Exact(cs)); + } + cs.deselect(resize_index); + + // Find the smallest `scale` of `to_resize` that satisfies every fee constraint. We imagine a + // perfect input that hits the target with zero excess: for a feerate constraint, + // + // scale = remaining_value_to_reach_feerate / effective_value_of_resized_input + // + // In this perfect scenario no extra fee is needed for weight-unit-to-vbyte rounding, so all + // computations are on weight units directly. + let mut scale = 0.0_f32; + + let rate_excess = cs.rate_excess_wu(target, Drain::NONE) as f32; + if rate_excess < 0.0 { + let remaining = rate_excess.abs(); + let ev_resized = to_resize.effective_value(target.fee.rate); + if ev_resized > 0.0 { + scale = scale.max(remaining / ev_resized); + } else { + return None; // we can never satisfy the constraint + } + } + // Replacement uses the same approach with the incremental relay feerate. + if let Some(replace) = target.fee.replace { + let replace_excess = cs.replacement_excess_wu(target, Drain::NONE) as f32; + if replace_excess < 0.0 { + let remaining = replace_excess.abs(); + let ev_resized = to_resize.effective_value(replace.incremental_relay_feerate); + if ev_resized > 0.0 { + scale = scale.max(remaining / ev_resized); + } else { + return None; // we can never satisfy the constraint + } + } + } + // 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(target, Drain::NONE) as f32; + if absolute_excess < 0.0 { + let remaining = absolute_excess.abs(); + if to_resize.value > 0 { + scale = scale.max(remaining / to_resize.value as f32); + } else { + return None; // we can never satisfy the constraint + } + } + + Some(ResizeBound::Resize(cs, to_resize, scale)) +} diff --git a/src/metrics/changeless_waste.rs b/src/metrics/changeless_waste.rs index 0abce4a..4966abd 100644 --- a/src/metrics/changeless_waste.rs +++ b/src/metrics/changeless_waste.rs @@ -234,63 +234,24 @@ impl BnbMetric for ChangelessWaste { return Some(Ordf32(cs.input_weight() as f32 * rate_diff)); } - // Target not met. Same resize trick as `LowestFee::bound`: walk the sorted unselected - // list until we cross the target, then pretend the crossing input was perfectly - // scaled so the target is hit with zero excess. Among all subsets of unselected that - // reach target, the highest-`value_pwu` candidates are the most weight-efficient — so - // the resize-scaled prefix is a valid lower bound on any target-meeting descendant's - // input_weight. - let (mut cs, resize_index, to_resize) = cs - .clone() - .select_iter() - .find(|(cs, _, _)| cs.is_target_met(target))?; - - if cs.excess(target, Drain::NONE) == 0 { - return Some(Ordf32(cs.waste( + // Target not met. Same resize trick as `LowestFee::bound`: walk the sorted unselected list + // until we cross the target, then pretend the crossing input was perfectly scaled so the + // target is hit with zero excess. The resize-scaled prefix is a valid lower bound on any + // target-meeting descendant's `input_weight` (see `resize_bound`). + match super::resize_bound(cs, target)? { + // The crossing selection is already the minimum-weight target-meeting subset; its bound + // is its own waste (with `Drain::NONE`). + super::ResizeBound::Exact(cs) => Some(Ordf32(cs.waste( target, self.long_term_feerate, Drain::NONE, 1.0, - ))); - } - cs.deselect(resize_index); - - let mut scale = Ordf32(0.0); - - let rate_excess = cs.rate_excess_wu(target, Drain::NONE) as f32; - if rate_excess < 0.0 { - let remaining = rate_excess.abs(); - let ev_resized = to_resize.effective_value(target.fee.rate); - if ev_resized > 0.0 { - scale = scale.max(Ordf32(remaining / ev_resized)); - } else { - return None; + ))), + super::ResizeBound::Resize(cs, to_resize, scale) => { + let ideal_input_weight = cs.input_weight() as f32 + scale * to_resize.weight as f32; + Some(Ordf32(ideal_input_weight * rate_diff)) } } - if let Some(replace) = target.fee.replace { - let replace_excess = cs.replacement_excess_wu(target, Drain::NONE) as f32; - if replace_excess < 0.0 { - let remaining = replace_excess.abs(); - let ev_resized = to_resize.effective_value(replace.incremental_relay_feerate); - if ev_resized > 0.0 { - scale = scale.max(Ordf32(remaining / ev_resized)); - } else { - return None; - } - } - } - let absolute_excess = cs.absolute_excess(target, Drain::NONE) as f32; - if absolute_excess < 0.0 { - let remaining = absolute_excess.abs(); - if to_resize.value > 0 { - scale = scale.max(Ordf32(remaining / to_resize.value as f32)); - } else { - return None; - } - } - - let ideal_input_weight = cs.input_weight() as f32 + scale.0 * to_resize.weight as f32; - Some(Ordf32(ideal_input_weight * rate_diff)) } fn requires_ordering_by_descending_value_pwu(&self) -> bool { diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index 744bdd0..4e2dd30 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -132,88 +132,21 @@ impl BnbMetric for LowestFee { Some(current_score) } else { - // Step 1: select everything up until the input that hits the target. - let (mut cs, resize_index, to_resize) = cs - .clone() - .select_iter() - .find(|(cs, _, _)| cs.is_target_met(target))?; - - // If this selection is already perfect, return its score directly. - if cs.excess(target, Drain::NONE) == 0 { - return Some(self.score(&cs, target).unwrap()); - }; - cs.deselect(resize_index); - - // We need to find the minimum fee we'd pay if we satisfy the feerate constraint. We do - // this by imagining we had a perfect input that perfectly hit the target. The sats per - // weight unit of this perfect input is the one at `slurp_index` but we'll do a scaled - // resize of it to fit perfectly. - // - // Here's the formaula: - // - // target_feerate = (current_input_value - current_output_value + scale * value_resized_input) / (current_weight + scale * weight_resized_input) - // - // Rearranging to find `scale` we find that: - // - // scale = remaining_value_to_reach_feerate / effective_value_of_resized_input - // - // This should be intutive since we're finding out how to scale the input we're resizing to get the effective value we need. - // - // In the perfect scenario, no additional fee would be required to pay for rounding up when converting from weight units to - // vbytes and so all fee calculations below are performed on weight units directly. - let rate_excess = cs.rate_excess_wu(target, Drain::NONE) as f32; - let mut scale = Ordf32(0.0); - - if rate_excess < 0.0 { - let remaining_value_to_reach_feerate = rate_excess.abs(); - let effective_value_of_resized_input = to_resize.effective_value(target.fee.rate); - if effective_value_of_resized_input > 0.0 { - let feerate_scale = - remaining_value_to_reach_feerate / effective_value_of_resized_input; - scale = scale.max(Ordf32(feerate_scale)); - } else { - return None; // we can never satisfy the constraint - } - } - - // We can use the same approach for replacement we just have to use the - // incremental_relay_feerate. - if let Some(replace) = target.fee.replace { - let replace_excess = cs.replacement_excess_wu(target, Drain::NONE) as f32; - if replace_excess < 0.0 { - let remaining_value_to_reach_feerate = replace_excess.abs(); - let effective_value_of_resized_input = - to_resize.effective_value(replace.incremental_relay_feerate); - if effective_value_of_resized_input > 0.0 { - let replace_scale = - remaining_value_to_reach_feerate / effective_value_of_resized_input; - scale = scale.max(Ordf32(replace_scale)); - } else { - return None; // we can never satisfy the constraint - } + // Walk the sorted unselected list until we cross the target, resizing the crossing + // input to hit the target with zero excess (see `resize_bound`). + match super::resize_bound(cs, target)? { + // The crossing selection is already perfect: return its score directly. + super::ResizeBound::Exact(cs) => Some(self.score(&cs, target).unwrap()), + super::ResizeBound::Resize(cs, to_resize, scale) => { + // `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 * to_resize.value as f32 + cs.selected_value() as f32 + - target.value() as f32; + assert!(ideal_fee >= 0.0); + + Some(Ordf32(ideal_fee)) } } - // 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(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 - - target.value() as f32; - assert!(ideal_fee >= 0.0); - - Some(Ordf32(ideal_fee)) } } From 6fccf10f80c9f3726b9b34795ed0ae50b7e28c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 2 Jul 2026 17:24:25 +0000 Subject: [PATCH 4/4] fix(metrics): ChangelessWaste bound validity under absolute/replacement fees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `ChangelessWaste::bound` paths mixed rate-based `effective_value` with the `min(rate, absolute, replacement)` excess, producing an invalid (too-tight) bound / wrong prune when a non-rate fee constraint binds. A too-tight BnB bound silently prunes the optimum, yielding a suboptimal changeless selection. - `ub_changeless_input_weight`: the LP knapsack credits each removed candidate its *rate*-based `effective_value`, but when `absolute_excess` or `replacement_excess` is the binding constraint, removing a candidate drops it by more than that (full `value`, or `value - weight*incremental_relay_feerate`). Crediting the smaller rate-ev over-removes weight, so the resulting upper bound on `input_weight` falls below the true max and the bound becomes too tight. Fall back to the trivial (always valid) `d_all.input_weight()` UB whenever an absolute fee or replacement is present; keep the tight knapsack for the rate-dominated case. - `change_unavoidable`: the "least excess" construction adds negative-*rate*-ev candidates, which does not minimise the true excess when a replacement is present (a rate-negative candidate can be replacement-positive and raise `replacement_excess`), so it could wrongly prune a branch that holds a changeless solution. Return `false` (never prune — always safe) when a replacement is present. An absolute fee is safe here since `absolute_excess` only grows as inputs are added. Adds a deterministic regression test with high-weight candidates and a binding absolute fee (the default random pool never generates the weight/value ratio that exposes the gap), and threads an `absolute` fee through the test `StrategyParams` so the ChangelessWaste proptests exercise it. Found by high-effort multi-agent code review. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/metrics/changeless_waste.rs | 30 ++++++++++++++++ tests/changeless_waste.rs | 61 +++++++++++++++++++++++++++++++-- tests/common.rs | 3 +- tests/lowest_fee.rs | 9 ++--- 4 files changed, 96 insertions(+), 7 deletions(-) diff --git a/src/metrics/changeless_waste.rs b/src/metrics/changeless_waste.rs index 4966abd..007a45a 100644 --- a/src/metrics/changeless_waste.rs +++ b/src/metrics/changeless_waste.rs @@ -100,6 +100,19 @@ impl ChangelessWaste { /// [`drain_value`]: Self::drain_value /// [`requires_ordering_by_descending_value_pwu`]: BnbMetric::requires_ordering_by_descending_value_pwu fn change_unavoidable(&mut self, cs: &CoinSelector<'_>, target: Target) -> bool { + // The "least excess" construction below adds every negative-*rate*-effective-value + // candidate to minimise the excess, which is only sound when the rate feerate is the + // binding fee constraint. With an RBF replacement (`incremental_relay_feerate < feerate`) a + // candidate can be negative at the rate yet positive at the replacement rate, so adding it + // *raises* `replacement_excess`; the rate-driven construction then no longer minimises the + // true `min(rate, absolute, replacement)` excess and could wrongly conclude change is + // unavoidable. Never prune in that case — returning `false` is always safe, it only costs + // extra search. (An absolute fee is safe here: `absolute_excess` only grows as inputs are + // added, so it can never be the constraint that a *superset* brings back under threshold.) + if target.fee.replace.is_some() { + return false; + } + if self.drain_value(cs, target).is_none() { return false; } @@ -130,12 +143,29 @@ impl ChangelessWaste { /// `input_weight()` segwit/varint corrections only ever ADD weight to the parent, never /// subtract from a subset — so the additive subtraction is safe in the UB direction. /// + /// The knapsack credits each removed candidate its *rate*-based `effective_value`, so it is + /// only valid when the rate feerate is the binding fee constraint. When an absolute fee or an + /// RBF replacement is present it falls back to the trivial (always valid) `D_all` bound — see + /// the guard below. + /// /// [`bound`]: BnbMetric::bound fn ub_changeless_input_weight(&self, cs: &CoinSelector<'_>, target: Target) -> f32 { let mut d_all = cs.clone(); d_all.select_all(); let d_all_iw = d_all.input_weight() as f32; + // The knapsack below credits each removed candidate its *rate*-based `effective_value`, + // which equals the true excess reduction only when the rate feerate is the binding fee + // constraint. But `excess` is `min(rate, absolute, replacement)`: with an absolute fee, + // removing a candidate drops `absolute_excess` by its full `value`; with an RBF replacement + // (`incremental_relay_feerate < feerate`) it drops `replacement_excess` by `value - weight * + // incremental_relay_feerate` — both larger than its rate-ev. Crediting the smaller rate-ev + // would over-remove weight and yield a *below*-true (invalid, too-tight) upper bound, so + // fall back to the trivial `D_all` upper bound whenever a non-rate constraint can bind. + if target.fee.absolute > 0 || target.fee.replace.is_some() { + return d_all_iw; + } + // The largest `excess_with_drain_weight` for which a selection is still changeless: it is // changeless (see `drain_value`) when its excess is `<= drain_spend_cost` OR `< // dust_threshold`, and the union of those two regions is `excess <= max(drain_spend_cost, diff --git a/tests/changeless_waste.rs b/tests/changeless_waste.rs index e12f9f1..027f280 100644 --- a/tests/changeless_waste.rs +++ b/tests/changeless_waste.rs @@ -27,8 +27,9 @@ proptest! { drain_spend_weight in 1..=2000_u32, drain_dust in 100..=1000_u64, n_drain_outputs in 1usize..150, + absolute in 0u64..20_000, ) { - let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs }; + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs, absolute }; let candidates = common::gen_candidates(params.n_candidates); let metric = ChangelessWaste { long_term_feerate: params.long_term_feerate(), @@ -52,8 +53,9 @@ proptest! { drain_spend_weight in 1..=2000_u32, drain_dust in 100..=1000_u64, n_drain_outputs in 1usize..150, + absolute in 0u64..20_000, ) { - let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs }; + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs, absolute }; let candidates = common::gen_candidates(params.n_candidates); let metric = ChangelessWaste { long_term_feerate: params.long_term_feerate(), @@ -64,6 +66,59 @@ proptest! { } } +/// Regression for the review finding: with a binding ABSOLUTE fee (so `excess` is bound by +/// `absolute_excess`, not `rate_excess`) and `rate_diff < 0`, the `ub_changeless_input_weight` +/// knapsack must not credit rate-based `effective_value` against an absolute-driven delta — doing +/// so over-removes weight and yields a too-tight (invalid) upper bound. +/// +/// The candidates are deliberately HIGH-WEIGHT relative to their value (so +/// `effective_value(rate) ≪ value`, opening the gap the bug lives in) — the default random pool +/// never generates these, which is why the proptest missed it. Low feerate + high absolute makes +/// `absolute_excess` the binding constraint, and mixed values let a subset land `absolute_excess` +/// inside the narrow changeless-and-target-met window with high `input_weight`. +#[test] +fn bound_is_valid_with_binding_absolute_fee() { + // rate feerate = 1 sat/vb (0.25 sat/wu); ev(rate) = value - weight*0.25. + let mut candidates: Vec = Vec::new(); + for _ in 0..8 { + candidates.push(Candidate { + value: 5_000, + weight: 10_000, // ev(rate) = 5000 - 2500 = 2500 (half the value) + input_count: 1, + is_segwit: false, + }); + } + for _ in 0..2 { + candidates.push(Candidate { + value: 1_000, + weight: 2_000, // ev(rate) = 1000 - 500 = 500 + input_count: 1, + is_segwit: false, + }); + } + + let params = common::StrategyParams { + n_candidates: candidates.len(), + target_value: 5_000, + n_target_outputs: 1, + target_weight: 0, + replace: None, + feerate: 1.0, + feerate_lt_diff: 9.0, // long_term_feerate = 10 > feerate = 1 (rate_diff < 0) + drain_weight: 200, + drain_spend_weight: 600, + drain_dust: 200, + n_drain_outputs: 1, + absolute: 27_000, // > rate-implied fee at d_all, so absolute_excess binds + }; + let metric = ChangelessWaste { + long_term_feerate: params.long_term_feerate(), + dust_relay_feerate: params.dust_relay_feerate(), + drain_weights: params.drain_weights(), + }; + common::ensure_bound_is_not_too_tight(params, candidates, metric).unwrap(); +} + /// Sanity-check: the BnB solution must never have a change output, and its waste must be /// no greater than the waste of any changeless brute-force selection we try. #[test] @@ -80,6 +135,7 @@ fn solution_is_changeless_and_not_worse_than_naive() { drain_spend_weight: 600, drain_dust: 200, n_drain_outputs: 1, + absolute: 0, }; let candidates = common::gen_candidates(params.n_candidates); @@ -124,6 +180,7 @@ fn consolidation_regime_stays_changeless() { drain_spend_weight: 600, drain_dust: 200, n_drain_outputs: 1, + absolute: 0, }; let candidates = common::gen_candidates(params.n_candidates); diff --git a/tests/common.rs b/tests/common.rs index 3728c22..05956f9 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -208,6 +208,7 @@ pub struct StrategyParams { pub drain_spend_weight: u32, pub drain_dust: u64, pub n_drain_outputs: usize, + pub absolute: u64, } impl StrategyParams { @@ -216,7 +217,7 @@ impl StrategyParams { fee: TargetFee { rate: FeeRate::from_sat_per_vb(self.feerate), replace: self.replace, - ..TargetFee::ZERO + absolute: self.absolute, }, outputs: TargetOutputs { value_sum: self.target_value, diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index efbbd36..02735f5 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -28,7 +28,7 @@ proptest! { drain_dust in 100..=1000_u64, // drain dust (sats) n_drain_outputs in 1usize..150, // the number of drain outputs ) { - let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs }; + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs, absolute: 0 }; let candidates = common::gen_candidates(params.n_candidates); let metric = params.lowest_fee_metric(); common::can_eventually_find_best_solution(params, candidates, metric)?; @@ -49,7 +49,7 @@ proptest! { drain_dust in 100..=1000_u64, // drain dust (sats) n_drain_outputs in 1usize..150, // the number of drain outputs ) { - let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs }; + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs, absolute: 0 }; let candidates = common::gen_candidates(params.n_candidates); let metric = params.lowest_fee_metric(); common::ensure_bound_is_not_too_tight(params, candidates, metric)?; @@ -72,7 +72,7 @@ proptest! { ) { println!("== TEST =="); - let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs }; + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs, absolute: 0 }; println!("{:?}", params); let candidates = core::iter::repeat(Candidate { @@ -114,7 +114,7 @@ proptest! { n_drain_outputs in 1usize..150, // the number of drain outputs ) { - let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs }; + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs, absolute: 0 }; let candidates = common::gen_candidates(params.n_candidates); let metric = params.lowest_fee_metric(); common::compare_against_benchmarks(params, candidates, metric)?; @@ -138,6 +138,7 @@ fn combined_changeless_metric() { drain_dust: 200, n_target_outputs: 1, n_drain_outputs: 1, + absolute: 0, }; let candidates = common::gen_candidates(params.n_candidates);