diff --git a/Cargo.toml b/Cargo.toml index f48f6fc..7554d91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,12 @@ std = [] rand = "0.9" proptest = "1.7" bitcoin = "0.32" +criterion = "0.5" + +[[bench]] +name = "coin_selector" +harness = false + +[[bench]] +name = "lowest_fee_bounds" +harness = false diff --git a/benches/coin_selector.rs b/benches/coin_selector.rs new file mode 100644 index 0000000..c7496c0 --- /dev/null +++ b/benches/coin_selector.rs @@ -0,0 +1,95 @@ +//! Benchmarks for `CoinSelector`. +//! +//! Two groups: +//! - `clone`: direct cost of `CoinSelector::clone()`, the operation `Bitset` +//! was introduced to make cheap. +//! - `run_bnb_lowest_fee`: end-to-end Branch-and-Bound throughput on a +//! deterministic synthetic pool using the `LowestFee` metric. +//! +//! Run with `cargo bench`. Filter with `cargo bench -- `. + +use bdk_coin_select::{ + metrics::LowestFee, Candidate, ChangePolicy, CoinSelector, DrainWeights, FeeRate, Target, + TargetFee, TargetOutputs, TR_SPK_WEIGHT, TXIN_BASE_WEIGHT, TXOUT_BASE_WEIGHT, +}; +use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion}; +use std::hint::black_box; + +/// Deterministic synthetic pool of P2WPKH-shaped UTXOs. +/// +/// Values grow super-linearly so the pool resembles a real wallet's mix of +/// small/medium/large UTXOs rather than uniform values. +fn make_candidates(n: usize) -> Vec { + const P2WPKH_SAT_W: u64 = 107; + (0..n) + .map(|i| { + let i = i as u64; + let value = 1_000 + i * 137 + i * i; + Candidate { + value, + weight: TXIN_BASE_WEIGHT + P2WPKH_SAT_W, + input_count: 1, + is_segwit: true, + } + }) + .collect() +} + +fn make_bnb_inputs(candidates: &[Candidate]) -> (Target, ChangePolicy, FeeRate) { + let target_fr = FeeRate::from_sat_per_vb(2.0); + let long_term_fr = FeeRate::from_sat_per_vb(10.0); + let total: u64 = candidates.iter().map(|c| c.value).sum(); + let target = Target { + fee: TargetFee::from_feerate(target_fr), + outputs: TargetOutputs::fund_outputs([(TXOUT_BASE_WEIGHT + TR_SPK_WEIGHT, total / 2)]), + }; + let change_policy = + ChangePolicy::min_value_and_waste(DrainWeights::TR_KEYSPEND, 294, target_fr, long_term_fr); + (target, change_policy, long_term_fr) +} + +fn bench_coin_selector_clone(c: &mut Criterion) { + let mut group = c.benchmark_group("clone"); + for &n in &[64usize, 256, 1024, 4096] { + let candidates = make_candidates(n); + let mut selector = CoinSelector::new(&candidates); + // Select ~10% of candidates so `selected` is non-trivial to copy. + for i in (0..n).step_by(10) { + selector.select(i); + } + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { + b.iter(|| black_box(selector.clone())); + }); + } + group.finish(); +} + +fn bench_run_bnb_lowest_fee(c: &mut Criterion) { + let mut group = c.benchmark_group("run_bnb_lowest_fee"); + // Cap iterations so the largest case fits in a benchmark sample. + group.sample_size(20); + for &n in &[20usize, 50, 100, 200] { + let candidates = make_candidates(n); + let selector = CoinSelector::new(&candidates); + let (target, change_policy, long_term_feerate) = make_bnb_inputs(&candidates); + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { + b.iter_batched( + || selector.clone(), + |mut sel| { + let metric = LowestFee { + target, + long_term_feerate, + change_policy, + }; + let _ = sel.run_bnb(metric, black_box(100_000)); + sel + }, + BatchSize::SmallInput, + ); + }); + } + group.finish(); +} + +criterion_group!(benches, bench_coin_selector_clone, bench_run_bnb_lowest_fee); +criterion_main!(benches); diff --git a/benches/lowest_fee_bounds.rs b/benches/lowest_fee_bounds.rs new file mode 100644 index 0000000..d095d38 --- /dev/null +++ b/benches/lowest_fee_bounds.rs @@ -0,0 +1,370 @@ +//! Benchmark comparing `LowestFee` bound versions: +//! - V1: the previous bound — single-benchmark estimate (uses only the lowest-`value_pwu` +//! unselected candidate to estimate the cost of going changeless when `cs` has change). +//! - V2: the LP-relaxed knapsack bound — sorts all negative-`ev` unselected candidates by +//! `|ev|/value` descending and fractionally fills until the deficit is covered. Tighter +//! when the single benchmark candidate's `|ev|` is smaller than the amount-above-threshold. + +use bdk_coin_select::{ + float::Ordf32, metrics::LowestFee, BnbMetric, Candidate, ChangePolicy, CoinSelector, Drain, + DrainWeights, FeeRate, Target, TargetFee, TargetOutputs, TR_SPK_WEIGHT, TXIN_BASE_WEIGHT, + TXOUT_BASE_WEIGHT, +}; +use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion}; +use std::hint::black_box; + +// --------------------------------------------------------------------------- +// V1: previous bound. Single-benchmark "extra value to extinguish change" estimate. +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy)] +struct LowestFeeV1 { + target: Target, + long_term_feerate: FeeRate, + change_policy: ChangePolicy, +} + +impl BnbMetric for LowestFeeV1 { + fn score(&mut self, cs: &CoinSelector<'_>) -> Option { + if !cs.is_target_met(self.target) { + return None; + } + let drain = cs.drain(self.target, self.change_policy); + let fee_for_the_tx = cs.fee(self.target.value(), drain.value); + assert!(fee_for_the_tx >= 0); + let spend_fee = drain.weights.spend_fee(self.long_term_feerate); + Some(Ordf32((fee_for_the_tx as u64 + spend_fee) as f32)) + } + + fn bound(&mut self, cs: &CoinSelector<'_>) -> Option { + if cs.is_target_met(self.target) { + let current_score = self.score(cs).unwrap(); + let drain_value = cs.drain_value(self.target, self.change_policy); + if let Some(drain_value) = drain_value { + // V1: single-benchmark candidate (the last unselected = lowest value_pwu). + let amount_above_change_threshold = drain_value - self.change_policy.min_value; + if let Some((_, low_sats_per_wu_candidate)) = cs.unselected().next_back() { + let ev = low_sats_per_wu_candidate.effective_value(self.target.fee.rate); + if ev < -0.0 { + let value_per_negative_effective_value = + low_sats_per_wu_candidate.value as f32 / ev.abs(); + let extra_value_needed_to_get_rid_of_change = amount_above_change_threshold + as f32 + * value_per_negative_effective_value; + let cost_of_getting_rid_of_change = + extra_value_needed_to_get_rid_of_change + drain_value as f32; + let cost_of_change = self.change_policy.drain_weights.waste( + self.target.fee.rate, + self.long_term_feerate, + self.target.outputs.n_outputs, + ); + let best_score_without_change = Ordf32( + current_score.0 + cost_of_getting_rid_of_change - cost_of_change, + ); + if best_score_without_change < current_score { + return Some(best_score_without_change); + } + } + } + } else { + let cost_of_adding_change = self.change_policy.drain_weights.waste( + self.target.fee.rate, + self.long_term_feerate, + self.target.outputs.n_outputs, + ); + let cost_of_no_change = cs.excess(self.target, Drain::NONE); + let best_score_with_change = + Ordf32(current_score.0 - cost_of_no_change as f32 + cost_of_adding_change); + if best_score_with_change < current_score { + return Some(best_score_with_change); + } + } + return Some(current_score); + } + // Target not met — same resize trick as the current LowestFee, inlined for the + // benchmark (we don't share with the in-src `resize_bound` helper to keep V1 fully + // self-contained). + let (mut cs, resize_index, to_resize) = cs + .clone() + .select_iter() + .find(|(c, _, _)| c.is_target_met(self.target))?; + if cs.excess(self.target, Drain::NONE) == 0 { + return Some(self.score(&cs).unwrap()); + } + cs.deselect(resize_index); + let mut scale = 0.0_f32; + let rate_excess = cs.rate_excess_wu(self.target, Drain::NONE) as f32; + if rate_excess < 0.0 { + let r = rate_excess.abs(); + let ev = to_resize.effective_value(self.target.fee.rate); + if ev > 0.0 { + scale = scale.max(r / ev); + } else { + return None; + } + } + if let Some(replace) = self.target.fee.replace { + let r = cs.replacement_excess_wu(self.target, Drain::NONE) as f32; + if r < 0.0 { + let ev = to_resize.effective_value(replace.incremental_relay_feerate); + if ev > 0.0 { + scale = scale.max(r.abs() / ev); + } else { + return None; + } + } + } + let abs_excess = cs.absolute_excess(self.target, Drain::NONE) as f32; + if abs_excess < 0.0 { + if to_resize.value > 0 { + scale = scale.max(abs_excess.abs() / to_resize.value as f32); + } else { + return None; + } + } + let ideal_fee = scale * to_resize.value as f32 + cs.selected_value() as f32 + - self.target.value() as f32; + Some(Ordf32(ideal_fee.max(0.0))) + } + + fn requires_ordering_by_descending_value_pwu(&self) -> bool { + true + } +} + +// --------------------------------------------------------------------------- +// Pool / scenarios. +// --------------------------------------------------------------------------- + +/// Diverse synthetic pool: bulk of normal-sized UTXOs plus a sprinkling of low-value +/// "dust" UTXOs that will have negative `ev` at the test feerates. The dust ones are what +/// trigger the "go changeless by adding negative-ev inputs" branch of `LowestFee::bound`. +fn make_candidates(n: usize) -> Vec { + const P2WPKH_SAT_W: u64 = 107; + let dust_every = 4; // every 4th candidate is dust + (0..n) + .map(|i| { + let i_u = i as u64; + let (value, weight) = if i % dust_every == 0 { + // Dust: small value, larger weight. value=200, weight ~= 400 -> value_pwu=0.5. + // At feerate 10 vb (= 2.5 sat/wu), ev = 200 - 400*2.5 = -800 (negative). + (200 + (i_u % 50), TXIN_BASE_WEIGHT + 250 + (i_u % 50)) + } else { + // Normal: scaled up. value range ~1k-70k. + ( + 1_000 + i_u * 137 + i_u * i_u, + TXIN_BASE_WEIGHT + P2WPKH_SAT_W, + ) + }; + Candidate { + value, + weight, + input_count: 1, + is_segwit: true, + } + }) + .collect() +} + +#[derive(Clone, Copy)] +struct Scenario { + name: &'static str, + target_feerate_vb: f32, + long_term_feerate_vb: f32, + target_fraction: f32, // fraction of total candidate value as target +} + +const SCENARIOS: &[Scenario] = &[ + Scenario { + name: "rate_pos_half", + target_feerate_vb: 10.0, + long_term_feerate_vb: 2.0, + target_fraction: 0.5, + }, + Scenario { + name: "rate_neg_half", + target_feerate_vb: 2.0, + long_term_feerate_vb: 10.0, + target_fraction: 0.5, + }, + // Small-target scenarios are where the V1 single-benchmark estimate is most likely to + // diverge from V2 — the "amount above change threshold" is large relative to a single + // candidate's `|ev|`. + Scenario { + name: "rate_pos_small", + target_feerate_vb: 10.0, + long_term_feerate_vb: 2.0, + target_fraction: 0.05, + }, + Scenario { + name: "rate_neg_small", + target_feerate_vb: 2.0, + long_term_feerate_vb: 10.0, + target_fraction: 0.05, + }, +]; + +fn make_inputs(candidates: &[Candidate], s: Scenario) -> (Target, ChangePolicy, FeeRate) { + let target_fr = FeeRate::from_sat_per_vb(s.target_feerate_vb); + let long_term_fr = FeeRate::from_sat_per_vb(s.long_term_feerate_vb); + let total: u64 = candidates.iter().map(|c| c.value).sum(); + let target_value = ((total as f32) * s.target_fraction) as u64; + let target = Target { + fee: TargetFee::from_feerate(target_fr), + outputs: TargetOutputs::fund_outputs([(TXOUT_BASE_WEIGHT + TR_SPK_WEIGHT, target_value)]), + }; + let change_policy = + ChangePolicy::min_value_and_waste(DrainWeights::TR_KEYSPEND, 294, target_fr, long_term_fr); + (target, change_policy, long_term_fr) +} + +struct RunResult { + best: Option, + rounds: usize, + completed: bool, +} + +fn run_and_count(cs: &CoinSelector<'_>, metric: M, max_rounds: usize) -> RunResult { + let mut rounds = 0usize; + let mut best: Option = None; + let mut iter = cs.bnb_solutions(metric); + while rounds < max_rounds { + match iter.next() { + Some(step) => { + rounds += 1; + if let Some((_, score)) = step { + best = Some(score.0); + } + } + None => { + return RunResult { + best, + rounds, + completed: true, + }; + } + } + } + RunResult { + best, + rounds, + completed: false, + } +} + +fn fmt_rounds(r: &RunResult) -> String { + if r.completed { + format!("{}", r.rounds) + } else { + format!("{} (cap)", r.rounds) + } +} + +fn print_round_report() { + const MAX_ROUNDS: usize = 2_000_000; + println!(); + println!( + "=== LowestFee bound version comparison (cap {}) ===", + MAX_ROUNDS + ); + println!( + "{:>16} {:>5} {:>14} {:>14}", + "scenario", "n", "v1_rounds", "v2_rounds" + ); + for &n in &[20usize, 50, 100, 200] { + let candidates = make_candidates(n); + for &s in SCENARIOS { + let (target, change_policy, long_term_feerate) = make_inputs(&candidates, s); + let cs = CoinSelector::new(&candidates); + + let r1 = run_and_count( + &cs, + LowestFeeV1 { + target, + long_term_feerate, + change_policy, + }, + MAX_ROUNDS, + ); + let r2 = run_and_count( + &cs, + LowestFee { + target, + long_term_feerate, + change_policy, + }, + MAX_ROUNDS, + ); + + if r1.completed && r2.completed { + assert_eq!( + r1.best, r2.best, + "V1 and V2 disagree at n={n} scenario={}", + s.name + ); + } + + println!( + "{:>16} {:>5} {:>14} {:>14}", + s.name, + n, + fmt_rounds(&r1), + fmt_rounds(&r2), + ); + } + } + println!(); +} + +fn bench_versions(c: &mut Criterion) { + print_round_report(); + + let mut group = c.benchmark_group("lowest_fee_bounds"); + group.sample_size(10); + + for &n in &[50usize, 100] { + let candidates = make_candidates(n); + for &s in SCENARIOS { + let (target, change_policy, long_term_feerate) = make_inputs(&candidates, s); + let selector = CoinSelector::new(&candidates); + + let id_v1 = BenchmarkId::new(format!("v1/{}", s.name), n); + group.bench_with_input(id_v1, &n, |b, _| { + b.iter_batched( + || selector.clone(), + |mut sel| { + let m = LowestFeeV1 { + target, + long_term_feerate, + change_policy, + }; + let _ = sel.run_bnb(m, black_box(2_000_000)); + sel + }, + BatchSize::SmallInput, + ); + }); + + let id_v2 = BenchmarkId::new(format!("v2/{}", s.name), n); + group.bench_with_input(id_v2, &n, |b, _| { + b.iter_batched( + || selector.clone(), + |mut sel| { + let m = LowestFee { + target, + long_term_feerate, + change_policy, + }; + let _ = sel.run_bnb(m, black_box(2_000_000)); + sel + }, + BatchSize::SmallInput, + ); + }); + } + } + group.finish(); +} + +criterion_group!(benches, bench_versions); +criterion_main!(benches); diff --git a/src/metrics.rs b/src/metrics.rs index 3bd35eb..a4f71e3 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -1,10 +1,14 @@ //! Branch and bound metrics that can be passed to [`CoinSelector::bnb_solutions`] or //! [`CoinSelector::run_bnb`]. -use crate::{bnb::BnbMetric, float::Ordf32, ChangePolicy, CoinSelector, Drain, Target}; +use alloc::vec::Vec; + +use crate::{bnb::BnbMetric, float::Ordf32, Candidate, ChangePolicy, CoinSelector, Drain, Target}; mod lowest_fee; pub use lowest_fee::*; mod changeless; pub use changeless::*; +mod changeless_waste; +pub use changeless_waste::*; // Returns a drain if the current selection and every possible future selection would have a change // output (otherwise Drain::none()) by using the heurisitic that if it has change with the current @@ -32,6 +36,144 @@ fn change_lower_bound(cs: &CoinSelector, target: Target, change_policy: ChangePo } } +/// Shortcut for `cs.excess(target, Drain { weights: change_policy.drain_weights, value: 0 })`. +/// +/// This is the "would-be excess" if a change output were materialised — the same quantity +/// `ChangePolicy::drain_value` compares against `min_value` to decide whether to add change. +fn excess_with_drain(cs: &CoinSelector<'_>, target: Target, change_policy: ChangePolicy) -> i64 { + cs.excess( + target, + Drain { + weights: change_policy.drain_weights, + value: 0, + }, + ) +} + +/// Shared "resize trick" used by every bound that needs a tight lower bound on the +/// `input_weight` / `selected_value` of any target-meeting descendant `D ⊇ cs`. +/// +/// Walks the `value_pwu`-sorted unselected list until the target is first crossed, then represents +/// the crossing candidate as a fractional `scale ∈ [0, 1]` that would satisfy each fee constraint +/// (rate, replacement, absolute) with exactly zero excess. +/// +/// Returns `Some((cs_after_deselect, to_resize, scale))` where: +/// - `cs_after_deselect` is the selection just before crossing target (the crossing input +/// deselected). +/// - `to_resize` is the candidate that crossed. +/// - `scale ∈ [0, 1]`. `scale == 1.0` is the special case where the find result already hit +/// target with zero excess — using the full candidate is equivalent to a perfect resize. +/// +/// Callers compute their own metric value, e.g. +/// ```ignore +/// let ideal_fee = scale * to_resize.value as f32 + cs_after.selected_value() as f32 - target.value() as f32; +/// let ideal_iw = cs_after.input_weight() as f32 + scale * to_resize.weight as f32; +/// ``` +/// +/// Returns `None` if no target-meeting descendant exists (some fee constraint cannot be satisfied +/// by any candidate available). +fn resize_bound<'a>( + cs: &CoinSelector<'a>, + target: Target, +) -> Option<(CoinSelector<'a>, Candidate, f32)> { + let (mut cs_iter, resize_index, to_resize) = cs + .clone() + .select_iter() + .find(|(c, _, _)| c.is_target_met(target))?; + + // Exact-match special case: the find result hit target with zero excess. Treat as a + // "perfect" full resize so the caller's `cs + scale * to_resize.X` formula recovers the + // find-result quantities exactly. + if cs_iter.excess(target, Drain::NONE) == 0 { + cs_iter.deselect(resize_index); + return Some((cs_iter, to_resize, 1.0)); + } + cs_iter.deselect(resize_index); + + let mut scale = 0.0_f32; + + let rate_excess = cs_iter.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; + } + } + if let Some(replace) = target.fee.replace { + let replace_excess = cs_iter.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; + } + } + } + let absolute_excess = cs_iter.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; + } + } + + Some((cs_iter, to_resize, scale)) +} + +/// LP-relaxed fractional knapsack: minimum total `cost` of a fractional subset of `items` +/// whose `contribution`s sum to at least `delta`. +/// +/// `items` yields `(contribution, cost)` pairs; both must be positive. The LP-optimal greedy +/// is to sort by `contribution / cost` descending (best ev-reduction per unit cost first) and +/// take each item fully until the last one, which is taken fractionally to exactly cover the +/// remaining deficit. +/// +/// Returns `None` if even taking every item doesn't cover `delta` (infeasible). Otherwise +/// returns `Some(min_total_cost)`. The LP-optimal cost is `<=` the cost of any integer +/// solution, so the result is a safe lower bound on the achievable cost. +/// +/// Used by metric bounds that need to estimate "minimum cost to satisfy a deficit": +/// - `ChangelessWaste`'s `rate_diff < 0` UB: cost = excluded weight, contribution = ev to +/// exclude in order to drop `excess_with_drain` below `min_value`. +/// - `LowestFee`'s "get rid of change" branch: cost = value paid as fees, contribution = `|ev|` +/// added to push excess below `min_value`. +fn lp_min_cost_to_cover(items: impl IntoIterator, delta: f32) -> Option { + let mut sorted: Vec<_> = items.into_iter().collect(); + sorted.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 remaining = delta; + let mut total_cost = 0.0_f32; + for (contribution, cost) in sorted { + if remaining <= 0.0 { + break; + } + if contribution >= remaining { + total_cost += cost * (remaining / contribution); + remaining = 0.0; + } else { + total_cost += cost; + remaining -= contribution; + } + } + + if remaining > 0.0 { + None + } else { + Some(total_cost) + } +} + macro_rules! impl_for_tuple { ($($a:ident $b:tt)*) => { impl<$($a),*> BnbMetric for ($(($a, f32)),*) diff --git a/src/metrics/changeless_waste.rs b/src/metrics/changeless_waste.rs new file mode 100644 index 0000000..44e5ac5 --- /dev/null +++ b/src/metrics/changeless_waste.rs @@ -0,0 +1,131 @@ +use super::{change_lower_bound, excess_with_drain, lp_min_cost_to_cover, resize_bound}; +use crate::{bnb::BnbMetric, float::Ordf32, ChangePolicy, CoinSelector, Drain, 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. +/// +/// [waste metric]: https://bitcoin.stackexchange.com/questions/113622/what-does-waste-metric-mean-in-the-context-of-coin-selection +#[derive(Clone, Copy, Debug)] +pub struct ChangelessWaste { + /// The target parameters for the resultant selection. + pub target: Target, + /// 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, + /// Policy to determine the change output (if any) of a given selection. Selections whose + /// excess would trigger this policy are not scored. + pub change_policy: ChangePolicy, +} + +impl BnbMetric for ChangelessWaste { + fn score(&mut self, cs: &CoinSelector<'_>) -> Option { + if !cs.is_target_met(self.target) { + return None; + } + if cs.drain_value(self.target, self.change_policy).is_some() { + return None; + } + let waste = cs.waste(self.target, self.long_term_feerate, Drain::NONE, 1.0); + Some(Ordf32(waste)) + } + + fn bound(&mut self, cs: &CoinSelector<'_>) -> Option { + // Prune branches where every descendant is forced to have a change output. This is the + // existing pruning used by the `Changeless` metric. + if change_lower_bound(cs, self.target, self.change_policy).is_some() { + return None; + } + + let rate_diff = self.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 = ub_changeless_input_weight(cs, self.target, self.change_policy); + 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(self.target) { + return Some(Ordf32(cs.input_weight() as f32 * rate_diff)); + } + let (cs_after, to_resize, scale) = resize_bound(cs, self.target)?; + let ideal_iw = cs_after.input_weight() as f32 + scale * to_resize.weight as f32; + Some(Ordf32(ideal_iw * rate_diff)) + } + + fn requires_ordering_by_descending_value_pwu(&self) -> bool { + true + } +} + +/// LP-relaxed upper bound on `D.input_weight` for changeless `D ⊇ cs` (used by the +/// `rate_diff < 0` branch of `ChangelessWaste::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-`ev_feerate`) candidates to drop `excess(drain_weights)` below +/// `change_policy.min_value`. To MAXIMIZE the remaining `input_weight` we MINIMIZE the +/// excluded weight, sorting positive-`ev_feerate` candidates by `ev_feerate / 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. +fn ub_changeless_input_weight( + cs: &CoinSelector<'_>, + target: Target, + change_policy: ChangePolicy, +) -> f32 { + let mut d_all = cs.clone(); + d_all.select_all(); + let d_all_iw = d_all.input_weight() as f32; + + let delta = excess_with_drain(&d_all, target, change_policy) - change_policy.min_value as i64; + if delta <= 0 { + return d_all_iw; + } + + // LP-min weight to exclude such that excluded ev sum >= delta. Items: positive-ev_feerate + // unselected candidates (contribution = ev, cost = weight). + match lp_min_cost_to_cover( + 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 + } + }), + delta as f32, + ) { + Some(removed_weight) => d_all_iw - removed_weight, + None => { + // Unreachable when `change_lower_bound = None` (which we already checked). Fall + // back to the loose `D_all`-based bound rather than fabricating a tight one. + d_all_iw + } + } +} diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index 730edaa..a933daa 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -53,43 +53,38 @@ impl BnbMetric for LowestFee { // I think this whole if statement could be removed if we made this metric decide the change policy if let Some(drain_value) = drain_value { - // it's possible that adding another input might reduce your long term fee if it - // gets rid of an expensive change output. Our strategy is to take the lowest sat - // per value candidate we have and use it as a benchmark. We imagine it has the - // perfect value (but the same sats per weight unit) to get rid of the change output - // by adding negative effective value (i.e. perfectly reducing excess to the point - // where change wouldn't be added according to the policy). - // - // TODO: This metric could be tighter by being more complicated but this seems to be - // good enough for now. - let amount_above_change_threshold = drain_value - self.change_policy.min_value; - - if let Some((_, low_sats_per_wu_candidate)) = cs.unselected().next_back() { - let ev = low_sats_per_wu_candidate.effective_value(self.target.fee.rate); - // we can only reduce excess if ev is negative - if ev < -0.0 { - let value_per_negative_effective_value = - low_sats_per_wu_candidate.value as f32 / ev.abs(); - // this is how much absolute value we have to add to cancel out the excess - let extra_value_needed_to_get_rid_of_change = amount_above_change_threshold - as f32 - * value_per_negative_effective_value; - - // NOTE: the drain_value goes to fees if we get rid of it so it's part of - // the cost of removing the change output - let cost_of_getting_rid_of_change = - extra_value_needed_to_get_rid_of_change + drain_value as f32; - let cost_of_change = self.change_policy.drain_weights.waste( - self.target.fee.rate, - self.long_term_feerate, - self.target.outputs.n_outputs, - ); - let best_score_without_change = Ordf32( - current_score.0 + cost_of_getting_rid_of_change - cost_of_change, - ); - if best_score_without_change < current_score { - return Some(best_score_without_change); + // It might be possible to reduce the long-term fee by adding negative-ev inputs + // until `excess` falls below `min_value`, making the selection changeless. The + // LP-relaxed minimum cost to do so: sort negative-ev unselected candidates by + // `|ev| / value` descending (most excess reduction per unit value cost) and take + // fractionally until the deficit is covered. The total value added becomes extra + // fee paid (since the changeless tx has no drain to absorb it). + let amount_above_change_threshold = + (drain_value - self.change_policy.min_value) as f32; + let extra_value_needed_to_get_rid_of_change = super::lp_min_cost_to_cover( + cs.unselected().filter_map(|(_, c)| { + let ev = c.effective_value(self.target.fee.rate); + if ev < 0.0 { + Some((-ev, c.value as f32)) + } else { + None } + }), + amount_above_change_threshold, + ); + if let Some(extra_value) = extra_value_needed_to_get_rid_of_change { + // NOTE: the drain_value goes to fees if we get rid of it so it's part of + // the cost of removing the change output. + let cost_of_getting_rid_of_change = extra_value + drain_value as f32; + let cost_of_change = self.change_policy.drain_weights.waste( + self.target.fee.rate, + self.long_term_feerate, + self.target.outputs.n_outputs, + ); + let best_score_without_change = + Ordf32(current_score.0 + cost_of_getting_rid_of_change - cost_of_change); + if best_score_without_change < current_score { + return Some(best_score_without_change); } } } else { @@ -110,88 +105,12 @@ 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(self.target))?; - - // If this selection is already perfect, return its score directly. - if cs.excess(self.target, Drain::NONE) == 0 { - return Some(self.score(&cs).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(self.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(self.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) = self.target.fee.replace { - let replace_excess = cs.replacement_excess_wu(self.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 - } - } - } - // 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 + // Target not met. Use the shared resize trick to compute the ideal fee assuming a + // perfectly-scaled crossing input that hits the target with zero excess. + let (cs_after, to_resize, scale) = super::resize_bound(cs, self.target)?; + let ideal_fee = scale * to_resize.value as f32 + cs_after.selected_value() as f32 - self.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..aed2296 --- /dev/null +++ b/tests/changeless_waste.rs @@ -0,0 +1,134 @@ +#![allow(unused_imports)] + +mod common; +use bdk_coin_select::metrics::ChangelessWaste; +use bdk_coin_select::{ + BnbMetric, Candidate, ChangePolicy, 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 change_policy = ChangePolicy::min_value(params.drain_weights(), params.drain_dust); + let metric = ChangelessWaste { target: params.target(), long_term_feerate: params.long_term_feerate(), change_policy }; + common::can_eventually_find_best_solution(params, candidates, change_policy, 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 change_policy = ChangePolicy::min_value(params.drain_weights(), params.drain_dust); + let metric = ChangelessWaste { target: params.target(), long_term_feerate: params.long_term_feerate(), change_policy }; + common::ensure_bound_is_not_too_tight(params, candidates, change_policy, 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 change_policy = ChangePolicy::min_value(params.drain_weights(), params.drain_dust); + let metric = ChangelessWaste { + target: params.target(), + long_term_feerate: params.long_term_feerate(), + change_policy, + }; + + match common::bnb_search(&mut cs, metric, usize::MAX) { + Ok((_score, _rounds)) => { + // Result must be changeless. + let drain = cs.drain(params.target(), change_policy); + assert!(drain.is_none(), "BnB result must be changeless"); + 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 change_policy = ChangePolicy::min_value(params.drain_weights(), params.drain_dust); + let metric = ChangelessWaste { + target: params.target(), + long_term_feerate: params.long_term_feerate(), + change_policy, + }; + + if common::bnb_search(&mut cs, metric, usize::MAX).is_ok() { + let drain = cs.drain(params.target(), change_policy); + assert!(drain.is_none(), "result must be changeless"); + } +}