Skip to content

Commit c44425b

Browse files
evanlinjinclaude
andcommitted
feat: add CoinSelector::select_srd (Single Random Draw)
Port of Bitcoin Core's `SelectCoinsSRD`: add candidates in random order until the change reaches a fixed `change_lower`, yielding a healthy-sized (privacy-friendly) change output rather than minimizing fees. Like Core, the threshold is fixed and the change amount comes out random from the draw itself. The stop condition reuses `excess(target, drain_weights)`, so no new fee math. RNG is a dependency-free `impl FnMut() -> u64`. Adds the `CHANGE_LOWER` constant (Core's 50k). Maximum-selection-weight handling is left as a TODO pending the max-weight PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e2e154a commit c44425b

3 files changed

Lines changed: 255 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- **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.
55
- **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.
66
- Add `DrainWeights::dust_threshold(dust_relay_feerate)`, the minimum value a change output with these weights must have to not be dust.
7+
- 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.
78
- **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.
89
- **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.
910
- **Breaking:** `CoinSelector::selected_indices` and `CoinSelector::banned` now return `&Bitset` instead of `&BTreeSet<usize>`. `Bitset` exposes `contains`/`len`/`is_empty`/`iter` (#46)

src/coin_selector.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ use crate::float::FloatExt;
44
use crate::{bitset::Bitset, bnb::BnbMetric, float::Ordf32, ChangePolicy, FeeRate, Target};
55
use alloc::{sync::Arc, vec::Vec};
66

7+
/// The change amount Bitcoin Core's `SelectCoinsSRD` selects for; a sensible default for the
8+
/// `change_lower` argument of [`CoinSelector::select_srd`].
9+
pub const CHANGE_LOWER: u64 = 50_000;
10+
711
/// [`CoinSelector`] selects/deselects coins from a set of canididate coins.
812
///
913
/// You can manually select coins using methods like [`select`], or automatically with methods such
@@ -548,6 +552,70 @@ impl<'a> CoinSelector<'a> {
548552
}
549553
}
550554

555+
/// Select candidates in random order ("Single Random Draw") until the change would be at least
556+
/// `change_lower`.
557+
///
558+
/// Unlike [`run_bnb`] with [`LowestFee`], this doesn't minimize fees — it deliberately produces
559+
/// a healthy-sized (privacy-friendly) change output, avoiding tiny "toxic" change. It's a port
560+
/// of Bitcoin Core's `SelectCoinsSRD`; pass [`CHANGE_LOWER`] for Core's value.
561+
///
562+
/// The change *amount* comes out random on its own: because candidates are added in random order
563+
/// and we stop as soon as the change reaches `change_lower`, the final change is wherever the
564+
/// last (random) input pushed it — at or above `change_lower`. So, like Core, we use a fixed
565+
/// lower bound rather than randomizing the target.
566+
///
567+
/// On success it returns the [`Drain`] to attach, whose value is the achieved change (at least
568+
/// `change_lower`). Returns [`InsufficientFunds`] if the target plus `change_lower` can't be met
569+
/// with the available candidates.
570+
///
571+
/// `rng` shuffles the candidates; it yields uniform `u64`s, e.g. `|| my_rng.next_u64()`. Any
572+
/// already-selected candidates are kept and counted toward the target.
573+
///
574+
/// [`run_bnb`]: Self::run_bnb
575+
/// [`LowestFee`]: crate::metrics::LowestFee
576+
// TODO: honor a maximum selection weight (evicting the least valuable inputs when exceeded),
577+
// matching Core's `max_selection_weight`. Deferred until the max-weight PR lands.
578+
pub fn select_srd(
579+
&mut self,
580+
target: Target,
581+
drain_weights: DrainWeights,
582+
change_lower: u64,
583+
mut rng: impl FnMut() -> u64,
584+
) -> Result<Drain, InsufficientFunds> {
585+
// `excess` with the drain weights (and zero value) is exactly the change this selection could
586+
// give, net of the change output's own fee — so meeting `change_lower` here means the change
587+
// output will be at least `change_lower`.
588+
let drain = Drain {
589+
weights: drain_weights,
590+
value: 0,
591+
};
592+
593+
// Shuffle the unselected candidates (Fisher-Yates) and add them until we have enough change.
594+
let mut candidates: Vec<usize> = self.unselected_indices().collect();
595+
for i in (1..candidates.len()).rev() {
596+
let j = (rng() % (i as u64 + 1)) as usize;
597+
candidates.swap(i, j);
598+
}
599+
for index in candidates {
600+
if self.excess(target, drain) >= change_lower as i64 {
601+
break;
602+
}
603+
self.select(index);
604+
}
605+
606+
let change = self.excess(target, drain);
607+
if change >= change_lower as i64 {
608+
Ok(Drain {
609+
weights: drain_weights,
610+
value: change as u64,
611+
})
612+
} else {
613+
Err(InsufficientFunds {
614+
missing: (change_lower as i64 - change).unsigned_abs(),
615+
})
616+
}
617+
}
618+
551619
/// Return an iterator that can be used to select candidates.
552620
pub fn select_iter(self) -> SelectIter<'a> {
553621
SelectIter { cs: self.clone() }

tests/srd.rs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
#![allow(clippy::zero_prefixed_literal)]
2+
mod common;
3+
4+
use bdk_coin_select::{
5+
Candidate, CoinSelector, Drain, DrainWeights, FeeRate, Target, TargetFee, TargetOutputs,
6+
CHANGE_LOWER, TR_SPK_WEIGHT, TXOUT_BASE_WEIGHT,
7+
};
8+
9+
/// Deterministic, dependency-free `u64` source (SplitMix64) so we can drive `select_srd` without a
10+
/// `rand` dependency.
11+
fn splitmix64(mut state: u64) -> impl FnMut() -> u64 {
12+
move || {
13+
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
14+
let mut z = state;
15+
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
16+
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
17+
z ^ (z >> 31)
18+
}
19+
}
20+
21+
fn target(value: u64, feerate: f32) -> Target {
22+
Target {
23+
fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(feerate)),
24+
outputs: TargetOutputs::fund_outputs([(TXOUT_BASE_WEIGHT + TR_SPK_WEIGHT, value)]),
25+
}
26+
}
27+
28+
/// Whenever SRD succeeds, the change must be at least `change_lower` and the selection must actually
29+
/// meet the target with that drain.
30+
#[test]
31+
fn srd_success_yields_healthy_change_that_meets_target() {
32+
let candidates = common::gen_candidates(60);
33+
let target = target(200_000, 10.0);
34+
let drain_weights = DrainWeights::TR_KEYSPEND;
35+
36+
let mut successes = 0;
37+
for seed in 0..300u64 {
38+
let mut cs = CoinSelector::new(&candidates);
39+
let result = cs.select_srd(
40+
target,
41+
drain_weights,
42+
CHANGE_LOWER,
43+
splitmix64(seed),
44+
);
45+
46+
if let Ok(drain) = result {
47+
successes += 1;
48+
assert!(
49+
drain.value >= CHANGE_LOWER,
50+
"seed {}: change {} is below CHANGE_LOWER",
51+
seed,
52+
drain.value
53+
);
54+
assert_eq!(drain.weights, drain_weights);
55+
assert!(
56+
cs.is_target_met_with_drain(target, drain),
57+
"seed {}: target not met with the returned drain",
58+
seed
59+
);
60+
// The reported change equals the actual excess available to the drain.
61+
let excess = cs.excess(
62+
target,
63+
Drain {
64+
weights: drain_weights,
65+
value: 0,
66+
},
67+
);
68+
assert_eq!(drain.value as i64, excess);
69+
}
70+
}
71+
assert!(successes > 0, "expected SRD to succeed for some seeds");
72+
}
73+
74+
/// A custom `change_lower` is honored: the change is at least the requested amount.
75+
#[test]
76+
fn srd_respects_custom_change_lower() {
77+
let candidates = common::gen_candidates(60);
78+
let target = target(200_000, 5.0);
79+
let drain_weights = DrainWeights::TR_KEYSPEND;
80+
let change_lower = 123_456;
81+
82+
let mut cs = CoinSelector::new(&candidates);
83+
let drain = cs
84+
.select_srd(target, drain_weights, change_lower, splitmix64(7))
85+
.expect("plenty of funds");
86+
assert!(
87+
drain.value >= change_lower,
88+
"change {} below change_lower",
89+
drain.value
90+
);
91+
}
92+
93+
/// SRD errors when the candidates can't cover target + change_lower.
94+
#[test]
95+
fn srd_insufficient_funds() {
96+
// 3 * 50_000 = 150_000 total, well below target (200_000) + CHANGE_LOWER (50_000) + fees.
97+
let candidates = vec![
98+
Candidate {
99+
value: 50_000,
100+
weight: 100,
101+
input_count: 1,
102+
is_segwit: true,
103+
},
104+
Candidate {
105+
value: 50_000,
106+
weight: 100,
107+
input_count: 1,
108+
is_segwit: true,
109+
},
110+
Candidate {
111+
value: 50_000,
112+
weight: 100,
113+
input_count: 1,
114+
is_segwit: true,
115+
},
116+
];
117+
let target = target(200_000, 5.0);
118+
let drain_weights = DrainWeights::TR_KEYSPEND;
119+
120+
for seed in 0..50u64 {
121+
let mut cs = CoinSelector::new(&candidates);
122+
let result = cs.select_srd(
123+
target,
124+
drain_weights,
125+
CHANGE_LOWER,
126+
splitmix64(seed),
127+
);
128+
assert!(result.is_err(), "seed {}: expected InsufficientFunds", seed);
129+
}
130+
}
131+
132+
/// The same seed produces the same selection and drain.
133+
#[test]
134+
fn srd_is_deterministic_for_a_given_seed() {
135+
let candidates = common::gen_candidates(60);
136+
let target = target(200_000, 8.0);
137+
let drain_weights = DrainWeights::TR_KEYSPEND;
138+
139+
let mut cs_a = CoinSelector::new(&candidates);
140+
let a = cs_a.select_srd(
141+
target,
142+
drain_weights,
143+
CHANGE_LOWER,
144+
splitmix64(99),
145+
);
146+
let mut cs_b = CoinSelector::new(&candidates);
147+
let b = cs_b.select_srd(
148+
target,
149+
drain_weights,
150+
CHANGE_LOWER,
151+
splitmix64(99),
152+
);
153+
154+
assert_eq!(a, b);
155+
assert_eq!(cs_a.selected_indices(), cs_b.selected_indices());
156+
}
157+
158+
/// Already-selected candidates are kept and counted toward the target.
159+
#[test]
160+
fn srd_keeps_preselected_inputs() {
161+
let candidates = common::gen_candidates(60);
162+
let target = target(200_000, 6.0);
163+
let drain_weights = DrainWeights::TR_KEYSPEND;
164+
165+
let mut cs = CoinSelector::new(&candidates);
166+
cs.select(0);
167+
cs.select(1);
168+
let preselected: Vec<usize> = cs.selected_indices().iter().collect();
169+
170+
let _ = cs
171+
.select_srd(
172+
target,
173+
drain_weights,
174+
CHANGE_LOWER,
175+
splitmix64(3),
176+
)
177+
.expect("plenty of funds");
178+
179+
for i in preselected {
180+
assert!(
181+
cs.selected_indices().contains(i),
182+
"preselected {} was dropped",
183+
i
184+
);
185+
}
186+
}

0 commit comments

Comments
 (0)