diff --git a/src/metrics.rs b/src/metrics.rs index 1da1163..e7d4ff8 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -7,3 +7,96 @@ mod lowest_fee; pub use lowest_fee::*; 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 new file mode 100644 index 0000000..007a45a --- /dev/null +++ b/src/metrics/changeless_waste.rs @@ -0,0 +1,290 @@ +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. +/// +/// 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 { + 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). + if excess_with_drain_weight <= self.drain_spend_cost() as i64 { + return None; + } + + // ...and only if the change output would not be dust. + 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. + /// + /// 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 { + // 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; + } + + 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() + } + + /// 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. + /// + /// 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, + // 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 { + 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 bounding `D.input_weight` in the right direction. + + if rate_diff < 0.0 { + // 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: 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. 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, + ))), + 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)) + } + } + } + + fn requires_ordering_by_descending_value_pwu(&self) -> bool { + true + } +} 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)) } } diff --git a/tests/changeless_waste.rs b/tests/changeless_waste.rs new file mode 100644 index 0000000..027f280 --- /dev/null +++ b/tests/changeless_waste.rs @@ -0,0 +1,201 @@ +#![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, + 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, absolute }; + 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, + 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, absolute }; + 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)?; + } +} + +/// 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] +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, + absolute: 0, + }; + + 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, + absolute: 0, + }; + + 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" + ); + } +} 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);