Skip to content
Draft
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
13 changes: 10 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
# 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:** Replace `Candidate`'s `input_count` and `is_segwit` fields with `segwit_count` and `legacy_count`, fixing `CoinSelector::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. Branch and bound also stops treating candidates of equal value and weight as interchangeable when their segwit/legacy counts differ, which could make it skip a cheaper selection. Replaces `Candidate::new` with `Candidate::new_segwit` and `Candidate::new_legacy`.
- **Breaking:** `CoinSelector` now owns its `Target`. It is set once, through the `SelectionProblem` passed to `CoinSelector::new`, and `CoinSelector::target()` returns it, and it is fixed for the life of the selector. Every method that took a `target: Target` argument no longer does, including `excess`, `missing`, `rate_excess`, `implied_fee`, `is_funded`, `is_within_max_weight`, `drain`, `select_until_target_met`, `select_srd`, `run_bnb`, and `bnb_solutions`. The same goes for arguments that merely restated part of the target: `weight` and `implied_feerate` no longer take `TargetOutputs`, `fee` no longer takes `target_value`, and `effective_value` and `select_all_effective` no longer take a `FeeRate`. `BnbMetric`'s methods read the target from the `CoinSelector` they are given — they are now `fn score(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32>`, `fn bound(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32>` and `fn drain(&mut self, cs: &CoinSelector<'_>) -> Drain` — so `LowestFee` no longer stores a `target` field.
- **Breaking:** Add `SelectionProblem`, which owns the target and candidates for one selection run. `CoinSelector::new` now takes `&SelectionProblem` and borrows it for its lifetime. Build one from prebuilt candidates with `SelectionProblem::new_no_ancestors(target, candidates)`. To measure a selection against a second target, pair `SelectionProblem::with_target(target)` with `CoinSelector::with_problem(&problem)`, which carries the selection, the bans and the candidate order over to the new problem.
- Charge selections for the fee needed to bring the union of their unconfirmed ancestors up to the target feerate (CPFP). Build ancestor-aware problems from `Input`/`InputGroup` and `AncestorToBump` with `SelectionProblem::new`; `SelectionProblem::new_no_ancestors` still takes prebuilt candidates. A shared ancestor is charged once, weight and fee are netted over the union, and `CoinSelector::ancestor_bump` reports the amount. Ancestor weight does not count toward `Target::max_weight`, and RBF rule 4 prices only the child. Each candidate's ancestor set is stored as a sorted `&[u32]` slice, so memory and setup time scale with the number of entries rather than candidates × ancestors.
- Add `CoinSelector::ancestor_bump_lower_bound`, the least bump the selection or any selection extending it could still owe, and `CoinSelector::addable_ancestors`. `LowestFee` uses the lower bound to bound ancestor-aware searches tightly: a funded node credits the ancestor surplus a descendant could still reach, and an unfunded one estimates the least child weight each fee constraint needs. The selector keeps that reachable surplus as a running total, so the bound costs the same at any pool size.
- Hard-prune branch-and-bound nodes whose remaining candidates cannot meet the target feerate, using a running total of what the undecided candidates are worth (a port of Bitcoin Core's `SelectCoinsBnB` lookahead). The relaxation credits still-reachable ancestor surplus, so it holds with unconfirmed ancestors too.
- `CoinSelector::is_fundable` no longer rejects a selection that is already funded. Adding a candidate that is worth more than its own weight can still lower the excess, because the first segwit input adds the witness header.
- **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.
- 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 (better-bound child first, backtracking in place) instead of best-first over a heap of cloned branches. Only the current path is held in memory, and under a round cap it reaches complete selections on large pools where the old frontier often ran out of rounds first.
- 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 round budget ran out before even the greedy selection was scored (e.g. `max_rounds` is 0), or the metric rejected it.
- **Breaking:** Remove the `Changeless` metric 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 (adding one only when it lowers the long-term fee, clears the dust threshold, and fits `Target::max_weight`), so a separate changeless objective duplicates that decision and then constrains it. Callers that required a changeless transaction should use `LowestFee` and inspect the returned `Drain`.
- **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 Down
97 changes: 73 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

```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,34 @@ 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.iter().copied());
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));
assert!(!coin_selector.is_funded(), "we didn't select enough");
println!("we didn't select enough yet we're missing: {}", coin_selector.missing());
coin_selector.select(1);
assert!(coin_selector.is_funded(target), "we should have enough now");
assert!(coin_selector.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 +69,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.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 +89,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,36 +105,38 @@ 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 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.iter().copied());

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

// 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);
Expand All @@ -150,13 +152,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)
}
Ok((score, change)) => {
println!("we found a solution with score {}", score);
Expand All @@ -175,6 +177,53 @@ println!("We are including a change output of {} value (0 means not change)", ch

```

## 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();
coin_selector.select(0);
// 1000 wu of ancestors at 1.25 sat/wu owe 1250 sats, of which they already pay 300.
assert_eq!(coin_selector.ancestor_bump(), 950);
```

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.

# Minimum Supported Rust Version (MSRV)

This library is compiles on rust v1.54 and above
Expand Down
Loading
Loading