Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<M>`, wrapping an inner metric it constrains to changeless solutions (e.g. `Changeless<LowestFee>`), 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<M>`. 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<usize>`. `Bitset` exposes `contains`/`len`/`is_empty`/`iter` (#46)
Expand Down
79 changes: 79 additions & 0 deletions src/coin_selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Drain, SelectError> {
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() }
Expand Down
184 changes: 184 additions & 0 deletions tests/srd.rs
Original file line number Diff line number Diff line change
@@ -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<usize> = cs.selected_indices().iter().collect();

let out = cs
.select_srd(target, drain_weights, CHANGE_LOWER, splitmix64(3))
.expect("already sufficient");

let after: Vec<usize> = 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);
}
Loading