diff --git a/CHANGELOG.md b/CHANGELOG.md index b6efaf7..3e5daf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - **Breaking:** `CoinSelector::run_bnb` now returns `(Ordf32, Drain)` instead of just `Ordf32`, handing back the change output the metric decided on for the winning selection. - **Breaking:** `LowestFee` no longer takes a `change_policy`. It now takes `dust_relay_feerate: FeeRate` and `drain_weights: DrainWeights`, and adds change only when doing so lowers the long-term fee and the change would not be dust. - Add `DrainWeights::dust_threshold(dust_relay_feerate)`, the minimum value a change output with these weights must have to not be dust. +- Add `CoinSelector::select_srd`, a Single Random Draw selector (port of Bitcoin Core's `SelectCoinsSRD`) that adds candidates in random order until the change reaches `change_lower`, producing a healthy-sized (privacy-friendly) change output instead of minimizing fees. Adds the `CHANGE_LOWER` constant for Core's value. - **Breaking:** `Changeless` is now `Changeless`, wrapping an inner metric it constrains to changeless solutions (e.g. `Changeless`), replacing the previous tuple-composition approach. - **Breaking:** Removed the `BnbMetric` tuple implementations (`impl BnbMetric for ((A, f32), ...)`). Weighted composition of independent metrics is no longer supported; the only composition still provided is the changeless constraint, now expressed as `Changeless`. If you relied on tuples to blend multiple objectives, there is no drop-in replacement. - **Breaking:** `CoinSelector::selected_indices` and `CoinSelector::banned` now return `&Bitset` instead of `&BTreeSet`. `Bitset` exposes `contains`/`len`/`is_empty`/`iter` (#46) diff --git a/src/coin_selector.rs b/src/coin_selector.rs index dc91b3b..604abd8 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -4,6 +4,10 @@ use crate::float::FloatExt; use crate::{bitset::Bitset, bnb::BnbMetric, float::Ordf32, ChangePolicy, FeeRate, Target}; use alloc::{sync::Arc, vec::Vec}; +/// The minimum change amount Bitcoin Core's `SelectCoinsSRD` targets; a sensible default for the +/// `change_lower` argument of [`CoinSelector::select_srd`]. +pub const CHANGE_LOWER: u64 = 50_000; + /// [`CoinSelector`] selects/deselects coins from a set of canididate coins. /// /// You can manually select coins using methods like [`select`], or automatically with methods such @@ -361,6 +365,17 @@ impl<'a> CoinSelector<'a> { }); } + /// Shuffle the candidates with Fisher-Yates algorithm. + /// + /// `rng` should yield uniform `u64`s. + pub fn shuffle_candidates(&mut self, mut rng: impl FnMut() -> u64) { + let candidates = Arc::make_mut(&mut self.candidate_order); + for i in (1..candidates.len()).rev() { + let j = (rng() % (i as u64 + 1)) as usize; + candidates.swap(i, j); + } + } + /// The waste created by the current selection as measured by the [waste metric]. /// /// You can pass in an `excess_discount` which must be between `0.0..1.0`. Passing in `1.0` gives you no discount @@ -595,6 +610,70 @@ impl<'a> CoinSelector<'a> { } } + /// Select candidates in random order ("Single Random Draw") until the change would be at least + /// `change_lower`. + /// + /// Unlike [`run_bnb`] with [`LowestFee`], this doesn't minimize fees — it deliberately produces + /// a healthy-sized (privacy-friendly) change output, avoiding tiny "toxic" change. It's a port + /// of Bitcoin Core's `SelectCoinsSRD`; pass [`CHANGE_LOWER`] for Core's value. + /// + /// The change *amount* comes out random on its own: because candidates are added in random order + /// and we stop as soon as the change reaches `change_lower`, the final change is wherever the + /// last (random) input pushed it — at or above `change_lower`. So, like Core, we use a fixed + /// lower bound rather than randomizing the target. + /// + /// On success it returns the [`Drain`] to attach, whose value is the achieved change (at least + /// `change_lower`). Returns [`SelectError::InsufficientFunds`] if the target plus `change_lower` + /// can't be met with the available candidates, or [`SelectError::MaxWeightExceeded`] if it can be + /// met but the resulting selection exceeds the weight cap. + /// + /// `rng` shuffles the candidates; it yields uniform `u64`s, e.g. `|| my_rng.next_u64()`. Any + /// already-selected candidates are kept and counted toward the target. + /// + /// [`run_bnb`]: Self::run_bnb + /// [`LowestFee`]: crate::metrics::LowestFee + // TODO: recover from exceeding `max_weight` by evicting the least-valuable inputs (matching + // Core's `max_selection_weight`) instead of erroring with `MaxWeightExceeded`. Deferred until + // the max-weight PR lands. + pub fn select_srd( + &mut self, + target: Target, + drain_weights: DrainWeights, + change_lower: u64, + rng: impl FnMut() -> u64, + ) -> Result { + self.shuffle_candidates(rng); + + let mut is_within_max_weight = false; + let mut excess = 0_i64; + + self.select_until(|cs| { + is_within_max_weight = cs.is_within_max_weight(target, drain_weights); + excess = cs.excess( + target, + Drain { + weights: drain_weights, + value: 0, + }, + ); + excess >= change_lower as i64 || !is_within_max_weight + }) + .ok_or_else(|| { + SelectError::InsufficientFunds(InsufficientFunds { + missing: (change_lower as i64 - excess).unsigned_abs(), + }) + })?; + + if !is_within_max_weight { + return Err(SelectError::MaxWeightExceeded); + } + + Ok(Drain { + weights: drain_weights, + value: excess as u64, + }) + } + /// Return an iterator that can be used to select candidates. pub fn select_iter(self) -> SelectIter<'a> { SelectIter { cs: self.clone() } diff --git a/tests/srd.rs b/tests/srd.rs new file mode 100644 index 0000000..b1b3096 --- /dev/null +++ b/tests/srd.rs @@ -0,0 +1,184 @@ +#![allow(clippy::zero_prefixed_literal)] +mod common; + +use bdk_coin_select::{ + Candidate, CoinSelector, Drain, DrainWeights, FeeRate, SelectError, Target, TargetFee, + TargetOutputs, CHANGE_LOWER, TR_SPK_WEIGHT, TXOUT_BASE_WEIGHT, +}; + +/// Deterministic, dependency-free `u64` source (SplitMix64) so we can drive `select_srd` without a +/// `rand` dependency. +fn splitmix64(mut state: u64) -> impl FnMut() -> u64 { + move || { + state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } +} + +fn target(value: u64, feerate: f32) -> Target { + Target { + fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(feerate)), + outputs: TargetOutputs::fund_outputs([(TXOUT_BASE_WEIGHT + TR_SPK_WEIGHT, value)]), + max_weight: None, + } +} + +/// Whenever SRD succeeds, the change must be at least `change_lower` and the selection must actually +/// meet the target with that drain. +#[test] +fn srd_success_yields_healthy_change_that_meets_target() { + let candidates = common::gen_candidates(60); + let target = target(200_000, 10.0); + let drain_weights = DrainWeights::TR_KEYSPEND; + + let mut successes = 0; + for seed in 0..300u64 { + let mut cs = CoinSelector::new(&candidates); + let result = cs.select_srd(target, drain_weights, CHANGE_LOWER, splitmix64(seed)); + + if let Ok(drain) = result { + successes += 1; + assert!( + drain.value >= CHANGE_LOWER, + "seed {}: change {} is below CHANGE_LOWER", + seed, + drain.value + ); + assert_eq!(drain.weights, drain_weights); + assert!( + cs.is_funded_with_drain(target, drain), + "seed {}: target not met with the returned drain", + seed + ); + // The reported change equals the actual excess available to the drain. + let excess = cs.excess( + target, + Drain { + weights: drain_weights, + value: 0, + }, + ); + assert_eq!(drain.value as i64, excess); + } + } + assert!(successes > 0, "expected SRD to succeed for some seeds"); +} + +/// SRD errors when the candidates can't cover target + change_lower. +#[test] +fn srd_insufficient_funds() { + // 3 * 50_000 = 150_000 total, well below target (200_000) + CHANGE_LOWER (50_000) + fees. + let candidates = vec![ + Candidate { + value: 50_000, + weight: 100, + input_count: 1, + is_segwit: true, + }, + Candidate { + value: 50_000, + weight: 100, + input_count: 1, + is_segwit: true, + }, + Candidate { + value: 50_000, + weight: 100, + input_count: 1, + is_segwit: true, + }, + ]; + let target = target(200_000, 5.0); + let drain_weights = DrainWeights::TR_KEYSPEND; + + for seed in 0..50u64 { + let mut cs = CoinSelector::new(&candidates); + let result = cs.select_srd(target, drain_weights, CHANGE_LOWER, splitmix64(seed)); + assert!( + matches!(result, Err(SelectError::InsufficientFunds(_))), + "seed {}: expected InsufficientFunds, got {:?}", + seed, + result + ); + } +} + +/// SRD reports [`SelectError::MaxWeightExceeded`] when reaching `change_lower` would push the +/// selection past `Target::max_weight`. (It errors rather than evicting inputs — see the `TODO` on +/// `select_srd`.) +#[test] +fn srd_max_weight_exceeded() { + // Identical candidates, so the selection's value and weight are independent of the random draw + // order — the outcome is deterministic across seeds. + let candidates = vec![ + Candidate { + value: 100_000, + weight: 1000, + input_count: 1, + is_segwit: true, + }; + 10 + ]; + let drain_weights = DrainWeights::TR_KEYSPEND; + let drain = Drain { + weights: drain_weights, + value: 0, + }; + + // Weight of the smallest selection that reaches target + change_lower, with no cap. + let mut probe = CoinSelector::new(&candidates); + probe + .select_until(|cs| cs.excess(target(200_000, 5.0), drain) >= CHANGE_LOWER as i64) + .expect("candidates can cover target + change_lower"); + let needed_weight = probe.weight(target(200_000, 5.0).outputs, drain_weights); + + // Cap just below that, so SRD trips the weight limit as it reaches `change_lower`. + let capped = Target { + max_weight: Some(needed_weight - 1), + ..target(200_000, 5.0) + }; + + for seed in 0..20u64 { + let mut cs = CoinSelector::new(&candidates); + let result = cs.select_srd(capped, drain_weights, CHANGE_LOWER, splitmix64(seed)); + assert!( + matches!(result, Err(SelectError::MaxWeightExceeded)), + "seed {}: expected MaxWeightExceeded, got {:?}", + seed, + result + ); + } +} + +/// If the already-selected inputs already provide enough change, SRD adds nothing (exercising the +/// pre-loop guard) and keeps counting them toward the target. +#[test] +fn srd_adds_nothing_when_already_sufficient() { + let candidates = common::gen_candidates(60); + let target = target(200_000, 6.0); + let drain_weights = DrainWeights::TR_KEYSPEND; + let drain = Drain { + weights: drain_weights, + value: 0, + }; + + // Preselect enough that the change already exceeds `change_lower`. + let mut cs = CoinSelector::new(&candidates); + cs.select_until(|cs| cs.excess(target, drain) >= CHANGE_LOWER as i64) + .expect("candidates can cover target + change_lower"); + let before: Vec = cs.selected_indices().iter().collect(); + + let out = cs + .select_srd(target, drain_weights, CHANGE_LOWER, splitmix64(3)) + .expect("already sufficient"); + + let after: Vec = cs.selected_indices().iter().collect(); + assert_eq!( + after, before, + "SRD selected more inputs even though the selection was already sufficient" + ); + assert!(out.value >= CHANGE_LOWER); +}