Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6f837a4
fix!: replace Candidate input_count/is_segwit with segwit_count/legac…
evanlinjin Aug 12, 2026
a37cef9
refactor!: replace Candidate::new with Candidate::new_segwit and new_…
evanlinjin Aug 12, 2026
89ec889
Give `CoinSelector` its target instead of threading it through every …
evanlinjin Aug 4, 2026
5f8384a
feat!: introduce SelectionProblem; CoinSelector borrows it
evanlinjin Aug 14, 2026
baa3dd4
feat: charge selections for the ancestors they drag in
evanlinjin Aug 12, 2026
ce2a557
perf: speed up ancestor-aware LowestFee
evanlinjin Aug 14, 2026
8962079
perf: tighten ancestor-aware LowestFee bound
evanlinjin Aug 13, 2026
c4938f6
perf!: make BnB metrics delta-aware
evanlinjin Aug 14, 2026
58b26c6
fix: harden delta-aware ancestor selection
evanlinjin Aug 13, 2026
28c5455
bench: restore capped BnB frontier coverage
evanlinjin Aug 13, 2026
3a3005e
perf: compact ancestor-aware BnB cache
evanlinjin Aug 13, 2026
2cd779c
feat: add dedicated LowestFeeChangeless metric
evanlinjin Aug 13, 2026
7e965cf
docs: polish ancestor-aware API guidance
evanlinjin Aug 14, 2026
2398ab2
perf: search branch and bound with in-place DFS
evanlinjin Aug 14, 2026
5c71ff8
perf: drop the pool-size cap on the changeless bound
evanlinjin Aug 14, 2026
d43fcb2
feat: seed branch and bound with a greedy incumbent
evanlinjin Aug 14, 2026
7d48f88
fixup! perf: search branch and bound with in-place DFS
evanlinjin Aug 14, 2026
84816f8
perf: let the ancestor bound prove the deficits it can
evanlinjin Aug 14, 2026
dc4fe6a
perf: hard-prune on a lookahead over the undecided candidates
evanlinjin Aug 14, 2026
a71f693
feat!: remove the changeless metrics
evanlinjin Aug 15, 2026
ac5119d
perf: make the ancestor bound's candidate scan independent of pool size
evanlinjin Aug 15, 2026
deae182
feat!: drop CoinSelector methods that SelectionView already caches
evanlinjin Aug 15, 2026
db34d81
feat!: move the remaining aggregate queries onto SelectionView
evanlinjin Aug 16, 2026
9c40ae2
refactor: drop the redundant score from BnbIter's seed
evanlinjin Aug 16, 2026
5dded08
perf!: store per-candidate ancestor sets sparsely
evanlinjin Aug 16, 2026
e93d1f9
feat: seed the search with an ancestry-aware greedy prefix too
evanlinjin Aug 16, 2026
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
17 changes: 12 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
# Unreleased

- **Breaking:** `BnbMetric`'s `score`, `bound`, and `drain` take the `target: Target` as a parameter, and `CoinSelector::run_bnb`/`bnb_solutions` gain a leading `target` argument. Consequently `LowestFee` and `Changeless` no longer store a `target` field. This removes the target that `Changeless<M>` previously had to keep in sync with its inner metric, and aligns the metric API with the rest of `CoinSelector`, where `target` is always passed in.
- **Breaking:** Move the aggregate queries off `CoinSelector` and onto `SelectionView`, which already answered them from its cache while `CoinSelector` recomputed them by iterating. Obtain a view with `CoinSelector::compute_view` and call them there. Removed: `absolute_excess`, `ancestor_bump`, `ancestor_bump_lower_bound`, `drain`, `drain_value`, `effective_value`, `excess`, `fee`, `implied_fee`, `implied_feerate`, `input_weight`, `is_funded`, `is_funded_with_drain`, `is_within_max_weight`, `missing`, `rate_excess`, `rate_excess_wu`, `replacement_excess`, `replacement_excess_wu`, `selected_value`, `waste` and `weight`. `CoinSelector::select_until` now hands its predicate a `&SelectionView` instead of a `&CoinSelector`, and maintains that view's aggregates incrementally, so a predicate such as `|view| view.is_funded()` costs the same at every step rather than rescanning the growing selection.
- **Breaking:** Replace `Candidate`'s `input_count` and `is_segwit` fields with `segwit_count` and `legacy_count`, fixing `SelectionView::input_weight` undercounting candidates that group multiple inputs: in a segwit transaction every legacy input still serializes an empty witness (1 WU), which was previously paid once per candidate instead of once per legacy input, so a group of N legacy inputs came out N-1 WU short. Splitting the count by script type also means a single candidate may now mix legacy and segwit inputs and still be priced exactly. Replaces `Candidate::new` with `Candidate::new_segwit` and `Candidate::new_legacy`.
- **Breaking:** Add `SelectionProblem`, which owns the target, candidates, and optional unconfirmed-ancestor data for a selection run. `CoinSelector::new` now takes `&SelectionProblem`, and the selector borrows it for its lifetime.
- Charge selections for the fee needed to bring the union of their unconfirmed ancestors up to the target feerate. Build ancestor-aware problems from `Input`/`InputGroup` and `AncestorToBump` with `SelectionProblem::new`; use `SelectionProblem::new_no_ancestors` for prebuilt candidates that need no CPFP bump.
- Add `SelectionView`, a cached view obtained with `CoinSelector::compute_view`. `BnbMetric::{score, bound, drain}` now consume `&SelectionView`; branch and bound maintains its aggregates incrementally while the underlying selector remains unchanged.
- Add a per-branch cursor to avoid repeatedly scanning already-decided candidates during branch-and-bound search.
- **Breaking:** `BnbMetric` metrics now decide the change output themselves. The trait gains a `drain(&mut self, cs) -> Drain` method; call it on a branch-and-bound solution (or the `LowestFee` metric directly) to get the change output the metric optimized against, instead of computing a separate `ChangePolicy`.
- **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.
- **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, the value is at least the dust threshold, and the transaction with change fits `Target::max_weight`.
- 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.
- Search branch and bound depth-first (best-child first, in-place backtracking) instead of best-first over a heap of cloned branches. Under a round cap this finds complete solutions on large pools where the old frontier often exhausted the budget without a selection.
- Hard-prune branch-and-bound nodes whose remaining candidates cannot meet the target feerate, using a running total the selection cache maintains (a port of Bitcoin Core's `SelectCoinsBnB` lookahead). The relaxation credits still-reachable ancestor surplus, so it holds with unconfirmed ancestors too.
- Let the ancestor-aware `LowestFee` bound prune a subtree when the best input still available cannot close a fee deficit at any weight. Previously this path was never allowed to claim infeasibility at all, because funding is not monotone with unconfirmed ancestors; that argument does not cover this case, which the relaxation can prove outright.
- Seed branch and bound with the greedy selection, so a search that runs out of rounds returns the best selection it has instead of `NoBnbSolution::RoundLimit`. `RoundLimit` now means the metric rejected the greedy selection too.
- **Breaking:** Remove the changeless metrics: `Changeless`, `LowestFeeChangeless`, and the `BnbMetric` tuple implementations (`impl BnbMetric for ((A, f32), ...)`). Generic metric composition is no longer supported. `LowestFee` decides for itself whether a selection should carry change, so a separate changeless objective is no longer maintained.
- **Breaking:** `CoinSelector::selected_indices` and `CoinSelector::banned` now return `&Bitset` instead of `&BTreeSet<usize>`. `Bitset` exposes `contains`/`len`/`is_empty`/`iter` (#46)
- Replace the internal `Cow<BTreeSet>`/`Cow<[usize]>` selection state with a `Bitset` and an `Arc`-shared candidate order, making the per-branch clones in branch-and-bound substantially cheaper (#46)
- Fix compilation error when building with `--no-default-features` (#36)
Expand All @@ -27,4 +35,3 @@
- No more `base_weight` in `CoinSelector`. Weight of the outputs is tracked in `target`.
- You now account for the number of outputs in both drain and target and their weight.
- Removed waste metric because it was pretty broken and took a lot to maintain

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,8 @@ criterion = "0.5"
[[bench]]
name = "coin_selector"
harness = false

# Enable debug symbols so profilers (perf, samply, flamegraph) can resolve
# function names. No runtime cost.
[profile.bench]
debug = true
113 changes: 82 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
> ⚠ This work is only ready to use by those who expect (potentially catastrophic) bugs and will have
> the time to investigate them and contribute back to this crate.

## Synopis
## Synopsis

```rust
use std::str::FromStr;
use bdk_coin_select::{ CoinSelector, Candidate, TR_KEYSPEND_TXIN_WEIGHT, Drain, FeeRate, Target, ChangePolicy, TargetOutputs, TargetFee, DrainWeights};
use bdk_coin_select::{ CoinSelector, Candidate, SelectionProblem, TR_KEYSPEND_TXIN_WEIGHT, Drain, FeeRate, Target, ChangePolicy, TargetOutputs, TargetFee, DrainWeights};
use bitcoin::{ Amount, Address, Network, Transaction, TxIn, TxOut };

let recipient_addr: Address = "tb1pvjf9t34fznr53u5tqhejz4nr69luzkhlvsdsdfq9pglutrpve2xq7hps46"
Expand All @@ -33,34 +33,36 @@ let candidates = vec![
Candidate {
// How many inputs does this candidate represents. Needed so we can
// figure out the weight of the varint that encodes the number of inputs
input_count: 1,
// and whether segwit transaction fields need to be counted in.
segwit_count: 1,
legacy_count: 0,
// the value of the input
value: 1_000_000,
// the total weight of the input(s) including their witness/scriptSig
// you may need to use miniscript to figure out the correct value here.
weight: TR_KEYSPEND_TXIN_WEIGHT,
// wether it's a segwit input. Needed so we know whether to include the
// segwit header in total weight calculations.
is_segwit: true
},
Candidate {
// A candidate can represent multiple inputs in the case where you
// always want some inputs to be spent together.
input_count: 2,
segwit_count: 2,
legacy_count: 0,
weight: 2*TR_KEYSPEND_TXIN_WEIGHT,
value: 3_000_000,
is_segwit: true
}
];

// You can now select coins!
let mut coin_selector = CoinSelector::new(&candidates);
let problem = SelectionProblem::new_no_ancestors(target, candidates);
let mut coin_selector = CoinSelector::new(&problem);
coin_selector.select(0);

assert!(!coin_selector.is_funded(target), "we didn't select enough");
println!("we didn't select enough yet we're missing: {}", coin_selector.missing(target));
// Aggregate queries live on a cached view of the current selection.
let view = coin_selector.compute_view();
assert!(!view.is_funded(), "we didn't select enough");
println!("we didn't select enough yet we're missing: {}", view.missing());
coin_selector.select(1);
assert!(coin_selector.is_funded(target), "we should have enough now");
assert!(coin_selector.compute_view().is_funded(), "we should have enough now");

// Now we need to know if we need a change output to drain the excess if we overshot too much
//
Expand All @@ -69,7 +71,7 @@ assert!(coin_selector.is_funded(target), "we should have enough now");
let drain_weights = DrainWeights::TR_KEYSPEND;
// Our policy is to only add a change output if the value is over 1_000 sats
let change_policy = ChangePolicy::min_value(drain_weights, 1_000);
let change = coin_selector.drain(target, change_policy);
let change = coin_selector.compute_view().drain(change_policy);
if change.is_some() {
println!("We need to add our change output to the transaction with {} value", change.value);
} else {
Expand All @@ -89,7 +91,7 @@ metric by implementing the [`BnbMetric`] yourself but we don't recommend this.

```rust
use std::str::FromStr;
use bdk_coin_select::{ BnbMetric, Candidate, CoinSelector, FeeRate, Target, TargetFee, TargetOutputs, TR_KEYSPEND_TXIN_WEIGHT};
use bdk_coin_select::{ BnbMetric, Candidate, CoinSelector, FeeRate, SelectionProblem, Target, TargetFee, TargetOutputs, TR_KEYSPEND_TXIN_WEIGHT};
use bdk_coin_select::metrics::LowestFee;
use bitcoin::{ Address, Amount, Network, Transaction, TxIn, TxOut };

Expand All @@ -105,43 +107,49 @@ let outputs = vec![TxOut {

let candidates = [
Candidate {
input_count: 1,
segwit_count: 1,
legacy_count: 0,
value: 400_000,
weight: TR_KEYSPEND_TXIN_WEIGHT,
is_segwit: true
},
Candidate {
input_count: 1,
segwit_count: 1,
legacy_count: 0,
value: 200_000,
weight: TR_KEYSPEND_TXIN_WEIGHT,
is_segwit: true
},
Candidate {
input_count: 1,
segwit_count: 1,
legacy_count: 0,
value: 11_000,
weight: TR_KEYSPEND_TXIN_WEIGHT,
is_segwit: true
}
];
let drain_weights = bdk_coin_select::DrainWeights::default();
// You could determine this by looking at the user's transaction history and taking an average of the feerate.
let drain_weights = bdk_coin_select::DrainWeights::TR_KEYSPEND;
// A wallet-policy or fee-estimator assumption for the future spend of change.
let long_term_feerate = FeeRate::from_sat_per_vb(10.0);

let mut coin_selector = CoinSelector::new(&candidates);

let target = Target {
fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(15.0)),
outputs: TargetOutputs::fund_outputs(outputs.iter().map(|output| (output.weight().to_wu(), output.value.to_sat()))),
max_weight: None,
};

let problem = SelectionProblem::new_no_ancestors(target, candidates);
let mut coin_selector = CoinSelector::new(&problem);

// For repeated read-only calculations, compute a cached view of the current selection.
let empty_view = coin_selector.compute_view();
assert_eq!(empty_view.selected_value(), 0);

// The feerate used to work out whether a change output would be dust (and so shouldn't be added).
// The standard dust relay feerate is 3 sat/vb.
let dust_relay_feerate = FeeRate::from_sat_per_vb(3.0);

// The LowestFee metric tries to make selections that minimize your total fees paid over time. It
// decides for itself whether to add a change output: change is added whenever doing so reduces the
// long-term fee (factoring in the cost to spend the output later on) and the change wouldn't be dust.
// long-term fee (factoring in the cost to spend the output later on), the value is at least the
// dust threshold, and the transaction with change fits its weight cap.
let mut metric = LowestFee {
long_term_feerate, // used to calculate the cost of spending the change output in the future
dust_relay_feerate,
Expand All @@ -150,13 +158,13 @@ let mut metric = LowestFee {

// We run the branch and bound algorithm with a max round limit of 100,000.
// On success it returns the score along with the change output the metric decided on.
let change = match coin_selector.run_bnb(target, metric, 100_000) {
let change = match coin_selector.run_bnb(metric, 100_000) {
Err(err) => {
println!("failed to find a solution: {}", err);
// fall back to naive selection
coin_selector.select_until_target_met(target).expect("a selection was impossible!");
coin_selector.select_until_target_met().expect("a selection was impossible!");
// the metric still decides the change output for whatever we end up selecting
metric.drain(&coin_selector, target)
metric.drain(&coin_selector.compute_view())
}
Ok((score, change)) => {
println!("we found a solution with score {}", score);
Expand All @@ -170,12 +178,55 @@ let selection = coin_selector
.collect::<Vec<_>>();

println!("we selected {} inputs", selection.len());
println!("We are including a change output of {} value (0 means not change)", change.value);
println!("We are including a change output of {} value (0 means no change)", change.value);


```

# Minimum Supported Rust Version (MSRV)
## Unconfirmed ancestors

Use `SelectionProblem::new` when spending unconfirmed UTXOs. Supply every unconfirmed transaction
that created an input and all of its transitive unconfirmed ancestors; missing transaction ids are
treated as confirmed and can make the required CPFP fee too low. Parent lists contain direct parents
only. Ancestors shared by several selected inputs are charged once over their union.

```rust
use bdk_coin_select::{
AncestorToBump, FeeRate, Input, SelectionProblem, Target, TargetFee, TargetOutputs,
};

let target = Target {
fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(5.0)),
outputs: TargetOutputs::fund_outputs([(136, 50_000)]),
max_weight: None,
};
let inputs = [Input {
value: 100_000,
weight: 272,
is_segwit: true,
residing_txid: "child",
}];
let ancestors = [
AncestorToBump {
txid: "parent",
weight: 400,
fee: 100,
parents: vec![],
},
AncestorToBump {
txid: "child",
weight: 600,
fee: 200,
parents: vec!["parent"],
},
];
let problem = SelectionProblem::new(target, inputs, ancestors);
let mut coin_selector = problem.selector();
```

Adding an input may drag in more fee debt than value, so funding is not necessarily monotone for
ancestor-aware problems. `run_bnb` accounts for this and de-duplicates shared ancestors.

This library is compiles on rust v1.54 and above
## Minimum Supported Rust Version (MSRV)

This library compiles on Rust 1.54 and above.
Loading
Loading