diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e5daf0..ef85dc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 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`, 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. +- 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`. `Bitset` exposes `contains`/`len`/`is_empty`/`iter` (#46) - Replace the internal `Cow`/`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) @@ -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 - diff --git a/Cargo.toml b/Cargo.toml index 14cabad..dba3e7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/README.md b/README.md index 4335d4b..caa2f54 100644 --- a/README.md +++ b/README.md @@ -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" @@ -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 // @@ -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 { @@ -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 }; @@ -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, @@ -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); @@ -170,12 +178,55 @@ let selection = coin_selector .collect::>(); 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. diff --git a/benches/coin_selector.rs b/benches/coin_selector.rs index c05eabb..96e133d 100644 --- a/benches/coin_selector.rs +++ b/benches/coin_selector.rs @@ -1,10 +1,18 @@ //! Benchmarks for `CoinSelector`. //! -//! Two groups: +//! Groups include selector construction and cloning, cached-view construction, and end-to-end BnB +//! with and without ancestors. Linear operations cover wallet (~1k) through exchange (~10M) pools; +//! BnB sizes remain moderate because its search space is exponential. +//! //! - `clone`: direct cost of `CoinSelector::clone()`, the operation `Bitset` //! was introduced to make cheap. -//! - `run_bnb_lowest_fee`: end-to-end Branch-and-Bound throughput on a -//! deterministic synthetic pool using the `LowestFee` metric. +//! - `run_bnb_lowest_fee`: end-to-end Branch-and-Bound solution finding on a deterministic +//! synthetic pool using the `LowestFee` metric. +//! - `run_bnb_lowest_fee_exhaust_cap`: large-pool BnB under the same fixed round cap. DFS still +//! produces a solution at these sizes; the cap mainly limits how long we spend proving it. +//! - `run_bnb_lowest_fee_ancestors`: the same, but where the coins sit on unconfirmed ancestors that +//! need bumping — covering both the private and shared ancestor paths, which cost different +//! amounts per fee calculation. //! //! Run with `cargo bench`. Filter with `cargo bench -- `. @@ -14,12 +22,16 @@ #![allow(clippy::incompatible_msrv)] use bdk_coin_select::{ - metrics::LowestFee, Candidate, CoinSelector, DrainWeights, FeeRate, Target, TargetFee, - TargetOutputs, TR_SPK_WEIGHT, TXIN_BASE_WEIGHT, TXOUT_BASE_WEIGHT, + metrics::LowestFee, AncestorToBump, Candidate, CoinSelector, DrainWeights, FeeRate, Input, + SelectionProblem, Target, TargetFee, TargetOutputs, TR_SPK_WEIGHT, TXIN_BASE_WEIGHT, + TXOUT_BASE_WEIGHT, }; use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion}; use std::hint::black_box; +const LARGE_N: &[usize] = &[64, 1_024, 16_384, 262_144, 1_048_576, 10_000_000]; +const SPARSE_SELECTED: usize = 100; + /// Deterministic synthetic pool of P2WPKH-shaped UTXOs. /// /// Values grow super-linearly so the pool resembles a real wallet's mix of @@ -29,21 +41,45 @@ fn make_candidates(n: usize) -> Vec { (0..n) .map(|i| { let i = i as u64; - let value = 1_000 + i * 137 + i * i; + let value = 1_000 + i.wrapping_mul(137).wrapping_add(i.wrapping_mul(i)); Candidate { value, weight: TXIN_BASE_WEIGHT + P2WPKH_SAT_W, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, } }) .collect() } +fn select_sparse(selector: &mut CoinSelector<'_>, n: usize) { + let count = SPARSE_SELECTED.min(n); + let stride = (n / count.max(1)).max(1); + for index in (0..n).step_by(stride).take(count) { + selector.select(index); + } +} + +fn bench_coin_selector_new(c: &mut Criterion) { + let mut group = c.benchmark_group("new"); + group.sample_size(20); + for &n in LARGE_N { + let candidates = make_candidates(n); + let (target, _) = make_bnb_inputs(&candidates); + let problem = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { + b.iter(|| black_box(CoinSelector::new(&problem))); + }); + } + group.finish(); +} + fn make_bnb_inputs(candidates: &[Candidate]) -> (Target, FeeRate) { let target_fr = FeeRate::from_sat_per_vb(2.0); let long_term_fr = FeeRate::from_sat_per_vb(10.0); - let total: u64 = candidates.iter().map(|c| c.value).sum(); + let total = candidates + .iter() + .fold(0_u64, |sum, candidate| sum.wrapping_add(candidate.value)); let target = Target { fee: TargetFee::from_feerate(target_fr), outputs: TargetOutputs::fund_outputs([(TXOUT_BASE_WEIGHT + TR_SPK_WEIGHT, total / 2)]), @@ -54,13 +90,13 @@ fn make_bnb_inputs(candidates: &[Candidate]) -> (Target, FeeRate) { fn bench_coin_selector_clone(c: &mut Criterion) { let mut group = c.benchmark_group("clone"); - for &n in &[64usize, 256, 1024, 4096] { + group.sample_size(20); + for &n in LARGE_N { let candidates = make_candidates(n); - let mut selector = CoinSelector::new(&candidates); - // Select ~10% of candidates so `selected` is non-trivial to copy. - for i in (0..n).step_by(10) { - selector.select(i); - } + let (target, _) = make_bnb_inputs(&candidates); + let problem = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut selector = CoinSelector::new(&problem); + select_sparse(&mut selector, n); group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { b.iter(|| black_box(selector.clone())); }); @@ -68,24 +104,54 @@ fn bench_coin_selector_clone(c: &mut Criterion) { group.finish(); } -fn bench_run_bnb_lowest_fee(c: &mut Criterion) { - let mut group = c.benchmark_group("run_bnb_lowest_fee"); - // Cap iterations so the largest case fits in a benchmark sample. +fn bench_compute_view(c: &mut Criterion) { + let mut group = c.benchmark_group("compute_view"); group.sample_size(20); - for &n in &[20usize, 50, 100, 200] { + for &n in LARGE_N { + let candidates = make_candidates(n); + let (target, _) = make_bnb_inputs(&candidates); + let problem = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut selector = CoinSelector::new(&problem); + select_sparse(&mut selector, n); + group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { + b.iter(|| black_box(selector.compute_view().selected_value())); + }); + } + group.finish(); +} + +const MAX_ROUNDS: usize = 100_000; + +fn bench_run_bnb_lowest_fee_sizes( + c: &mut Criterion, + group_name: &str, + sizes: &[usize], + expect_solution: bool, +) { + let mut group = c.benchmark_group(group_name); + group.sample_size(10); + for &n in sizes { let candidates = make_candidates(n); - let selector = CoinSelector::new(&candidates); let (target, long_term_feerate) = make_bnb_inputs(&candidates); + let problem = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let selector = CoinSelector::new(&problem); + let metric = || LowestFee { + long_term_feerate, + dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), + drain_weights: DrainWeights::TR_KEYSPEND, + }; + assert_eq!( + selector.clone().run_bnb(metric(), MAX_ROUNDS).is_ok(), + expect_solution, + "{}/{} changed search path", + group_name, + n, + ); group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { b.iter_batched( || selector.clone(), |mut sel| { - let metric = LowestFee { - long_term_feerate, - dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), - drain_weights: DrainWeights::TR_KEYSPEND, - }; - let _ = sel.run_bnb(target, metric, black_box(100_000)); + let _ = sel.run_bnb(metric(), black_box(MAX_ROUNDS)); sel }, BatchSize::SmallInput, @@ -95,5 +161,119 @@ fn bench_run_bnb_lowest_fee(c: &mut Criterion) { group.finish(); } -criterion_group!(benches, bench_coin_selector_clone, bench_run_bnb_lowest_fee); +fn bench_run_bnb_lowest_fee(c: &mut Criterion) { + bench_run_bnb_lowest_fee_sizes(c, "run_bnb_lowest_fee", &[20, 50, 100], true); +} + +fn bench_run_bnb_lowest_fee_exhaust_cap(c: &mut Criterion) { + bench_run_bnb_lowest_fee_sizes( + c, + "run_bnb_lowest_fee_exhaust_cap", + &[200, 500, 1_000], + true, + ); +} + +/// Deterministic synthetic pool where every third coin sits on an unconfirmed chain that still owes +/// fees, so every fee calculation has to work out the bump. +/// +/// With `share`, all such coins sit on the *same* chain, which is the case that cannot be folded into +/// the candidates up front and has to be de-duplicated per selection. +fn make_ancestor_problem(n: usize, share: bool) -> SelectionProblem { + const P2WPKH_SAT_W: u64 = 107; + const CONFIRMED: usize = usize::MAX; + + let mut ancestors = Vec::new(); + let mut residing = Vec::with_capacity(n); + let mut shared_tip = None; + for i in 0..n { + if i % 3 != 0 { + residing.push(CONFIRMED); + continue; + } + match (share, shared_tip) { + (true, Some(tip)) => residing.push(tip), + _ => { + // A two-long chain: an unpaid parent and a tip that pays a little. + let parent = ancestors.len(); + ancestors.push(AncestorToBump { + txid: parent, + weight: 800, + fee: 0, + parents: vec![], + }); + let tip = ancestors.len(); + ancestors.push(AncestorToBump { + txid: tip, + weight: 800, + fee: 200, + parents: vec![parent], + }); + residing.push(tip); + shared_tip = Some(tip); + } + } + } + + let inputs = (0..n).map(|i| { + let value = 1_000 + i as u64 * 137 + (i * i) as u64; + Input { + value, + weight: TXIN_BASE_WEIGHT + P2WPKH_SAT_W, + is_segwit: true, + residing_txid: residing[i], + } + }); + + let total: u64 = (0..n) + .map(|i| 1_000 + i as u64 * 137 + (i * i) as u64) + .sum(); + let target = Target { + fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(2.0)), + outputs: TargetOutputs::fund_outputs([(TXOUT_BASE_WEIGHT + TR_SPK_WEIGHT, total / 2)]), + max_weight: None, + }; + SelectionProblem::new(target, inputs, ancestors) +} + +fn bench_run_bnb_lowest_fee_ancestors(c: &mut Criterion) { + let mut group = c.benchmark_group("run_bnb_lowest_fee_ancestors"); + group.sample_size(20); + for &share in &[false, true] { + let kind = match share { + false => "private", + true => "shared", + }; + for &n in &[20usize, 50, 100] { + let problem = make_ancestor_problem(n, share); + let selector = CoinSelector::new(&problem); + group.bench_with_input(BenchmarkId::new(kind, n), &n, |b, _| { + b.iter_batched( + || selector.clone(), + |mut sel| { + let metric = LowestFee { + long_term_feerate: FeeRate::from_sat_per_vb(10.0), + dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), + drain_weights: DrainWeights::TR_KEYSPEND, + }; + let _ = sel.run_bnb(metric, black_box(100_000)); + sel + }, + BatchSize::SmallInput, + ); + }); + } + } + group.finish(); +} + +criterion_group!( + benches, + bench_coin_selector_new, + bench_coin_selector_clone, + bench_compute_view, + bench_run_bnb_lowest_fee, + bench_run_bnb_lowest_fee_exhaust_cap, + bench_run_bnb_lowest_fee_ancestors +); criterion_main!(benches); diff --git a/src/bnb.rs b/src/bnb.rs index 0498e5c..a9f61c8 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -1,226 +1,580 @@ -use core::cmp::Reverse; +use crate::{float::Ordf32, Drain, SelectionCache, SelectionView}; -use crate::{float::Ordf32, Drain, Target}; +/// Pass counts for iterative deepening, for measurement only. +/// +/// `BnbIter` is `pub(crate)`, so a plain accessor would not be reachable from a benchmark harness. +/// A process-global counter is enough: the harness runs one search at a time. +pub mod deepening_stats { + use core::sync::atomic::{AtomicU64, Ordering}; + + static PASSES: AtomicU64 = AtomicU64::new(0); + static HANDOVER: AtomicU64 = AtomicU64::new(0); + + pub(crate) fn record_pass() { + PASSES.fetch_add(1, Ordering::Relaxed); + } + + /// Passes started since [`reset`]. One pass is one traversal from the root under one threshold. + pub fn passes() -> u64 { + PASSES.load(Ordering::Relaxed) + } + + pub(crate) fn record_dive_handover(nodes: u64) { + HANDOVER.store(nodes, Ordering::Relaxed); + } + + /// Nodes the opening dive spent before handing over to deepening. 0 means it never handed over. + pub fn dive_handover() -> u64 { + HANDOVER.load(Ordering::Relaxed) + } + + pub fn reset() { + PASSES.store(0, Ordering::Relaxed); + HANDOVER.store(0, Ordering::Relaxed); + } +} use super::CoinSelector; -use alloc::collections::BinaryHeap; +use alloc::vec::Vec; /// An [`Iterator`] that iterates over rounds of branch and bound to minimize the score of the /// provided [`BnbMetric`]. #[derive(Debug)] pub(crate) struct BnbIter<'a, M: BnbMetric> { - queue: BinaryHeap>, + selector: CoinSelector<'a>, + cache: SelectionCache, + stack: Vec, best: Option, - /// The target the metric scores selections against. - pub(crate) target: Target, + /// The greedy selection, yielded before the first node is expanded. Its score is `best`: + /// nothing else can have run yet, so the two are set together. See + /// [`seed_greedy_incumbent`](BnbIter::seed_greedy_incumbent). + seed: Option>, + exhausted: bool, + /// Iterative deepening: the current pass's ceiling on the bound. `None` disables deepening + /// entirely, which is the traversal exactly as it was before. + threshold: Option, + /// Smallest bound rejected *by the threshold* this pass — the next pass's floor. + /// + /// Children rejected for being no better than the incumbent are deliberately not recorded: + /// they can never become interesting, and folding them in would waste passes. + next_threshold: Option, + /// Relative step for the threshold schedule. The schedule is a speed knob, never a correctness + /// one: raising the threshold past the next rejected bound only ever *adds* nodes to a pass. + deepening: Option, + /// Still in the opening dive, before any threshold applies. + /// + /// Deepening reconstructs the queue's ordering but is not anytime: under a ceiling low enough to + /// be useful, the early passes may not reach a complete selection at all. On a pool too large to + /// exhaust that is strictly worse than diving, which reaches leaves immediately. So dive first, + /// and deepen only once the dive stops paying — the incumbent carries over, so the deepening + /// phase cannot return anything worse than the dive already found. + diving: bool, + /// Smallest number of nodes the opening dive is always given before it may hand over. + dive_floor: u64, + /// Nodes expanded so far, and the count when the incumbent last improved. + nodes: u64, + last_improvement: u64, /// The `BnBMetric` that will score each selection pub(crate) metric: M, } +#[derive(Debug)] +struct Frame { + is_inclusion: bool, + index: usize, + cursor: usize, + next_cursor: usize, + banned: Vec, + sibling_pending: bool, +} + impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { type Item = Option<(CoinSelector<'a>, Ordf32)>; fn next(&mut self) -> Option { + if let Some(seed) = self.seed.take() { + let score = self.best.expect("the seed and `best` are set together"); + return Some(Some((seed, score))); + } + + if self.exhausted { + return None; + } + + self.nodes += 1; + if self.diving && self.dive_is_stalled() { + self.stop_diving(); + } + // { // println!("=========================== {:?}", self.best); - // for thing in self.queue.iter() { - // println!("{} {:?}", &thing.selector, thing.lower_bound); + // println!("{} {:?}", &self.selector, self.bound_of_current()); + // for frame in self.stack.iter() { + // println!( + // "\t{} [{}] cursor={} sibling_pending={}", + // if frame.is_inclusion { "IN " } else { "EX " }, + // frame.index, + // frame.cursor, + // frame.sibling_pending, + // ); // } // let _ = std::io::stdin().read_line(&mut alloc::string::String::new()); // } - let branch = self.queue.pop()?; - if let Some(best) = &self.best { - // If the next thing in queue is not better than our best we're done. - if *best < branch.lower_bound { - // println!( - // "\t\t(SKIP) branch={} inclusion={} lb={:?}, score={:?}", - // branch.selector, - // !branch.is_exclusion, - // branch.lower_bound, - // self.metric.score(&branch.selector), - // ); - return None; - } - } - // println!( - // "\t\t( POP) branch={} inclusion={} lb={:?}, score={:?}", - // branch.selector, - // !branch.is_exclusion, - // branch.lower_bound, - // self.metric.score(&branch.selector), - // ); - - let selector = branch.selector; + let return_val = if !self.is_exclusion_node() { + self.try_record_best() + .map(|score| (self.selector.clone(), score)) + } else { + None + }; - let mut return_val = None; - if !branch.is_exclusion { - if let Some(score) = self.metric.score(&selector, self.target) { - let better = match self.best { - Some(best_score) => score < best_score, - None => true, - }; - if better { - self.best = Some(score); - return_val = Some(score); - } - }; + if !self.descend() && !self.backtrack_to_next_branch() && !self.start_next_pass() { + self.exhausted = true; } - self.insert_new_branches(&selector); - Some(return_val.map(|score| (selector, score))) + Some(return_val) } } impl<'a, M: BnbMetric> BnbIter<'a, M> { - pub(crate) fn new(mut selector: CoinSelector<'a>, target: Target, metric: M) -> Self { + pub(crate) fn new(selector: CoinSelector<'a>, metric: M) -> Self { + Self::with_deepening(selector, metric, None) + } + + pub(crate) fn with_deepening( + selector: CoinSelector<'a>, + metric: M, + deepening: Option, + ) -> Self { + Self::configured(selector, metric, deepening, None) + } + + pub(crate) fn configured( + mut selector: CoinSelector<'a>, + metric: M, + deepening: Option, + dive_first: Option, + ) -> Self { + if metric.requires_ordering_by_descending_value_pwu() { + selector.sort_candidates_by_descending_value_pwu(); + } + + let cache = SelectionCache::from_selector(&selector); let mut iter = BnbIter { - queue: BinaryHeap::default(), + selector, + cache, + stack: Vec::new(), best: None, - target, + seed: None, + exhausted: false, + threshold: None, + next_threshold: None, + deepening, + diving: dive_first.is_some() && deepening.is_some(), + dive_floor: dive_first.unwrap_or(0), + nodes: 0, + last_improvement: 0, metric, }; - if iter.metric.requires_ordering_by_descending_value_pwu() { - selector.sort_candidates_by_descending_value_pwu(); + iter.seed_greedy_incumbent(); + + // Incumbent-only: the root must not be rejected by a threshold that is derived from it. + if !iter.bound_is_promising() { + iter.exhausted = true; } - iter.consider_adding_to_queue(&selector, false); + // The first pass admits exactly the root. A hybrid search sets this when it stops diving. + if deepening.is_some() && !iter.diving { + iter.threshold = iter.bound_of_current(); + } iter } - fn consider_adding_to_queue(&mut self, cs: &CoinSelector<'a>, is_exclusion: bool) { - let bound = self.metric.bound(cs, self.target); - if let Some(bound) = bound { - let is_good_enough = match self.best { - Some(best) => best > bound, - None => true, - }; - if is_good_enough { - let branch = Branch { - lower_bound: bound, - selector: cs.clone(), - is_exclusion, - }; - /*println!( - "\t\t(PUSH) branch={} inclusion={} lb={:?} score={:?}", - branch.selector, - !branch.is_exclusion, - branch.lower_bound, - self.metric.score(&branch.selector), - );*/ - self.queue.push(branch); - } /* else { - println!( - "\t\t( REJ) branch={} inclusion={} lb={:?} score={:?}", - cs, - !is_exclusion, - bound, - self.metric.score(cs), - ); - }*/ - } /*else { - println!( - "\t\t(NO B) branch={} inclusion={} score={:?}", - cs, - !is_exclusion, - self.metric.score(cs), - ); - }*/ - } - - fn insert_new_branches(&mut self, cs: &CoinSelector<'a>) { - let (next_index, next) = match cs.unselected().next() { - Some(c) => c, - None => return, // exhausted + /// Score the greedy prefix and adopt it as the incumbent. + /// + /// Without this the search is not anytime: a caller that runs out of rounds before the first + /// complete selection gets nothing back and falls through to whatever fallback it has, which on + /// a large pool is far worse than the selection a single greedy pass would have handed it. The + /// seed costs one round and one scored selection, and since it is only an incumbent — the bound + /// is unchanged and still admissible — the optimum stays reachable. + /// + /// It yields nothing for a metric that rejects the greedy prefix outright: overshooting the + /// target is exactly what a greedy pass does. + fn seed_greedy_incumbent(&mut self) { + let mut seed = self.selector.clone(); + if seed.select_until_target_met().is_err() { + return; + } + if let Some(score) = self.metric.score(&seed.compute_view()) { + self.best = Some(score); + self.seed = Some(seed); + } + } + + fn is_exclusion_node(&self) -> bool { + self.stack.last().map_or(false, |frame| !frame.is_inclusion) + } + + fn try_record_best(&mut self) -> Option { + let score = self + .metric + .score(&SelectionView::with_cache(&self.selector, &self.cache))?; + let better = match self.best { + Some(best_score) => score < best_score, + None => true, }; + if better { + self.best = Some(score); + self.last_improvement = self.nodes; + Some(score) + } else { + None + } + } + + fn bound_of_current(&mut self) -> Option { + self.metric + .bound(&SelectionView::with_cache(&self.selector, &self.cache)) + } + + fn is_promising(&self, bound: Option) -> bool { + match (bound, self.best) { + (Some(bound), Some(best)) => best > bound, + (Some(_), None) => true, + (None, _) => false, + } + } - let mut inclusion_cs = cs.clone(); - inclusion_cs.select(next_index); - self.consider_adding_to_queue(&inclusion_cs, false); + /// Whether to descend into a child, and the place the deepening threshold is applied. + /// + /// Takes `&mut self` because a child rejected by the threshold contributes the next pass's + /// floor. Rejection by the incumbent contributes nothing. + fn admit(&mut self, bound: Option) -> bool { + if !self.is_promising(bound) { + return false; + } + let bound = match bound { + Some(bound) => bound, + None => return false, + }; + if let Some(threshold) = self.threshold { + if bound > threshold { + self.next_threshold = Some(match self.next_threshold { + Some(next) if next <= bound => next, + _ => bound, + }); + return false; + } + } + true + } - // for the exclusion branch, we keep banning if candidates have the same weight and value - let mut is_first_ban = true; - let mut exclusion_cs = cs.clone(); - let to_ban = (next.value, next.weight); - for (next_index, next) in cs.unselected() { - if (next.value, next.weight) != to_ban { + fn bound_is_promising(&mut self) -> bool { + let bound = self.bound_of_current(); + self.admit(bound) + } + + /// Unwind every frame, leaving the selector and cache as they were at the root. + /// + /// In place, using the same undo paths backtracking uses — rebuilding the `CoinSelector` or the + /// `SelectionCache` per pass is what would put the memory back. + fn reset_to_root(&mut self) { + while let Some(frame) = self.stack.pop() { + if frame.is_inclusion { + self.undo_include(frame.index); + } else { + self.undo_exclude(&frame.banned); + } + } + } + + /// Whether the opening dive has stopped paying for itself. + /// + /// The rule is self-scaling rather than a tuned constant: give up once the search has gone as + /// long without an improvement as it took to find the one it has. A dive that keeps creeping + /// downward — which is what a pool too large to exhaust does — keeps its budget; a dive that is + /// stuck, which is the failure this is here to fix, hands over quickly. + fn dive_is_stalled(&self) -> bool { + self.nodes.saturating_sub(self.last_improvement) + > self.last_improvement.max(self.dive_floor) + } + + /// Leave the opening dive and begin deepening, keeping the incumbent the dive found. + fn stop_diving(&mut self) { + self.diving = false; + self.reset_to_root(); + self.threshold = self.bound_of_current(); + crate::bnb::deepening_stats::record_dive_handover(self.nodes); + } + + /// Raise the threshold and restart from the root. `false` means the search is over. + fn start_next_pass(&mut self) -> bool { + // A dive that runs out of tree has explored everything; there is nothing to deepen into. + if self.diving { + return false; + } + let eps = match self.deepening { + Some(eps) => eps, + None => return false, + }; + // The incumbent is proven optimal: every node that could beat it had a bound at or below + // the threshold, so it was visited this pass or an earlier one. + if let (Some(best), Some(threshold)) = (self.best, self.threshold) { + if best <= threshold { + return false; + } + } + // Nothing was rejected by the threshold, so the whole tree is explored. + let next = match self.next_threshold.take() { + Some(next) => next, + None => return false, + }; + let grown = match self.threshold { + Some(threshold) => { + let stepped = threshold.0 * (1.0 + eps); + Ordf32(if stepped > next.0 { stepped } else { next.0 }) + } + None => next, + }; + self.threshold = Some(grown); + crate::bnb::deepening_stats::record_pass(); + self.reset_to_root(); + true + } + + fn cursor(&self) -> usize { + self.stack.last().map_or(0, |frame| frame.next_cursor) + } + + fn next_candidate(&self, start: usize) -> Option<(usize, usize)> { + for (cursor, (index, _)) in (start..).zip(self.selector.candidates().skip(start)) { + if !self.selector.is_selected(index) && !self.selector.banned().contains(index) { + return Some((index, cursor)); + } + } + None + } + + fn exclusion_plan(&self, index: usize, cursor: usize) -> (Vec, usize) { + let next = self.selector.candidate(index); + let to_ban = ( + next.value, + next.weight, + next.segwit_count, + next.legacy_count, + ); + let to_ban_drags_in = self.selector.problem().drags_in(index); + let mut banned = alloc::vec![index]; + let mut next_cursor = cursor + 1; + for (next_index, next) in self.selector.candidates().skip(cursor + 1) { + if self.selector.is_selected(next_index) || self.selector.banned().contains(next_index) + { + next_cursor += 1; + continue; + } + if ( + next.value, + next.weight, + next.segwit_count, + next.legacy_count, + ) != to_ban + || self.selector.problem().drags_in(next_index) != to_ban_drags_in + { break; } - let (_index, _candidate) = exclusion_cs - .candidates() - .find(|(i, _)| *i == next_index) - .expect("must have index since we are planning to ban it"); - if is_first_ban { - is_first_ban = false; - } /*else { - println!("banning: [{}] {:?}", _index, _candidate); - }*/ - exclusion_cs.ban(next_index); - } - self.consider_adding_to_queue(&exclusion_cs, true); + // println!("banning: [{}] {:?}", next_index, next); + banned.push(next_index); + next_cursor += 1; + } + (banned, next_cursor) } -} -#[derive(Debug, Clone)] -struct Branch<'a> { - lower_bound: Ordf32, - selector: CoinSelector<'a>, - is_exclusion: bool, -} + fn apply_include(&mut self, index: usize) { + let candidate = self.selector.candidate(index); + self.selector.select(index); + self.cache + .add(self.selector.problem(), index, candidate, true); + } -impl Ord for Branch<'_> { - fn cmp(&self, other: &Self) -> core::cmp::Ordering { - // NOTE: Reverse comparision `lower_bound` because we want a min-heap (by default BinaryHeap - // is a max-heap). - // NOTE: We tiebreak equal scores based on whether it's exlusion or not (preferring - // inclusion). We do this because we want to try and get to evaluating complete selection - // returning actual scores as soon as possible. - core::cmp::Ord::cmp( - &(Reverse(&self.lower_bound), !self.is_exclusion), - &(Reverse(&other.lower_bound), !other.is_exclusion), - ) + fn undo_include(&mut self, index: usize) { + let candidate = self.selector.candidate(index); + self.selector.deselect(index); + self.cache + .sub(self.selector.problem(), index, candidate, true); } -} -impl PartialOrd for Branch<'_> { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) + fn apply_exclude(&mut self, banned: &[usize]) { + for &index in banned { + self.selector.ban(index); + self.cache.ban(self.selector.problem(), index); + } } -} -impl PartialEq for Branch<'_> { - fn eq(&self, other: &Self) -> bool { - self.lower_bound == other.lower_bound + fn undo_exclude(&mut self, banned: &[usize]) { + for &index in banned.iter().rev() { + self.selector.unban(index); + self.cache.unban(self.selector.problem(), index); + } + } + + fn push_include(&mut self, index: usize, cursor: usize, sibling_pending: bool) { + self.apply_include(index); + self.stack.push(Frame { + is_inclusion: true, + index, + cursor, + next_cursor: cursor + 1, + banned: Vec::new(), + sibling_pending, + }); + } + + fn push_exclude( + &mut self, + index: usize, + cursor: usize, + banned: Vec, + next_cursor: usize, + sibling_pending: bool, + ) { + self.apply_exclude(&banned); + self.stack.push(Frame { + is_inclusion: false, + index, + cursor, + next_cursor, + banned, + sibling_pending, + }); + } + + fn descend(&mut self) -> bool { + let (index, cursor) = match self.next_candidate(self.cursor()) { + Some(next) => next, + None => return false, + }; + + self.apply_include(index); + let inc_bound = self.bound_of_current(); + let inc_ok = self.admit(inc_bound); + self.undo_include(index); + + let (banned, exc_next_cursor) = self.exclusion_plan(index, cursor); + self.apply_exclude(&banned); + let exc_bound = self.bound_of_current(); + let exc_ok = self.admit(exc_bound); + self.undo_exclude(&banned); + + // println!( + // "\t\t(DESC) branch={} next=[{}] inc_lb={:?}{} exc_lb={:?}{}", + // self.selector, + // index, + // inc_bound, + // if inc_ok { "" } else { " (REJ)" }, + // exc_bound, + // if exc_ok { "" } else { " (REJ)" }, + // ); + + match (inc_ok, exc_ok) { + (false, false) => false, + (true, false) => { + self.push_include(index, cursor, false); + true + } + (false, true) => { + self.push_exclude(index, cursor, banned, exc_next_cursor, false); + true + } + (true, true) => { + // Equal bounds prefer inclusion, matching the previous best-first tie-break. + let include_first = match (inc_bound, exc_bound) { + (Some(inc), Some(exc)) => inc <= exc, + _ => true, + }; + if include_first { + self.push_include(index, cursor, true); + } else { + self.push_exclude(index, cursor, banned, exc_next_cursor, true); + } + true + } + } } -} -impl Eq for Branch<'_> {} + fn backtrack_to_next_branch(&mut self) -> bool { + while let Some(frame) = self.stack.pop() { + // println!( + // "\t\t(BACK) undo {} [{}] sibling_pending={}", + // if frame.is_inclusion { "IN " } else { "EX " }, + // frame.index, + // frame.sibling_pending, + // ); + if frame.is_inclusion { + self.undo_include(frame.index); + if frame.sibling_pending { + let (banned, next_cursor) = self.exclusion_plan(frame.index, frame.cursor); + self.apply_exclude(&banned); + if self.bound_is_promising() { + self.stack.push(Frame { + is_inclusion: false, + index: frame.index, + cursor: frame.cursor, + next_cursor, + banned, + sibling_pending: false, + }); + return true; + } + self.undo_exclude(&banned); + } + } else { + self.undo_exclude(&frame.banned); + if frame.sibling_pending { + self.apply_include(frame.index); + if self.bound_is_promising() { + self.stack.push(Frame { + is_inclusion: true, + index: frame.index, + cursor: frame.cursor, + next_cursor: frame.cursor + 1, + banned: Vec::new(), + sibling_pending: false, + }); + return true; + } + self.undo_include(frame.index); + } + } + } + false + } +} /// A branch and bound metric where we minimize the [`Ordf32`] score. /// /// This is to be used as input for [`CoinSelector::run_bnb`] or [`CoinSelector::bnb_solutions`]. pub trait BnbMetric { - /// Get the score of a given selection for `target`. + /// Get the score of a given selection. /// /// If this returns `None`, the selection is invalid. - fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option; + fn score(&mut self, view: &SelectionView<'_>) -> Option; - /// Get the lower bound score using a heuristic for `target`. + /// Get the lower bound score using a heuristic. /// /// This represents the best possible score of all descendant branches (according to the /// heuristic). /// /// If this returns `None`, the current branch and all descendant branches will not have valid /// solutions. - fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option; + fn bound(&mut self, view: &SelectionView<'_>) -> Option; - /// The change output (a.k.a. drain) this metric decides on for the given selection and `target`, + /// The change output (a.k.a. drain) this metric decides on for the given selection, /// or [`Drain::NONE`] if it decides there should be no change. /// /// Call this on a branch-and-bound solution to get the change output the metric optimized against. - fn drain(&mut self, cs: &CoinSelector<'_>, target: Target) -> Drain; + fn drain(&mut self, view: &SelectionView<'_>) -> Drain; /// Returns whether the metric requies we order candidates by descending value per weight unit. fn requires_ordering_by_descending_value_pwu(&self) -> bool { diff --git a/src/coin_selector.rs b/src/coin_selector.rs index 604abd8..1ae2579 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -1,14 +1,14 @@ use super::*; #[allow(unused)] // some bug in <= 1.48.0 sees this as unused when it isn't use crate::float::FloatExt; -use crate::{bitset::Bitset, bnb::BnbMetric, float::Ordf32, ChangePolicy, FeeRate, Target}; +use crate::{bitset::Bitset, bnb::BnbMetric, float::Ordf32, FeeRate, SelectionProblem, 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. +/// [`CoinSelector`] selects or deselects coins from a set of candidate coins. /// /// You can manually select coins using methods like [`select`], or automatically with methods such /// as [`bnb_solutions`]. @@ -17,70 +17,81 @@ pub const CHANGE_LOWER: u64 = 50_000; /// [`bnb_solutions`]: CoinSelector::bnb_solutions #[derive(Debug, Clone)] pub struct CoinSelector<'a> { - candidates: &'a [Candidate], + problem: &'a SelectionProblem, selected: Bitset, banned: Bitset, candidate_order: Arc>, } impl<'a> CoinSelector<'a> { - /// Creates a new coin selector from some candidate inputs and a `base_weight`. + /// Creates a new coin selector for `problem`. /// - /// The `base_weight` is the weight of the transaction without any inputs and without a change - /// output. + /// The [`SelectionProblem`] is fixed for the life of the selector: target, candidates, and any + /// ancestor-bump data. Methods refer to candidates by index into + /// [`SelectionProblem::candidates`]. /// - /// The `CoinSelector` does not keep track of the final transaction's output count. The caller - /// is responsible for including the potential output-count varint weight change in the - /// corresponding [`DrainWeights`]. - /// - /// Note that methods in `CoinSelector` will refer to inputs by the index in the `candidates` - /// slice you pass in. - pub fn new(candidates: &'a [Candidate]) -> Self { + /// Record the number of potential change outputs in [`DrainWeights::n_outputs`]. The selector + /// then accounts for the resulting output-count varint weight change automatically. + pub fn new(problem: &'a SelectionProblem) -> Self { + let n = problem.len(); Self { - candidates, - selected: Bitset::with_capacity(candidates.len()), - banned: Bitset::with_capacity(candidates.len()), - candidate_order: Arc::new((0..candidates.len()).collect::>()), + problem, + selected: Bitset::with_capacity(n), + banned: Bitset::with_capacity(n), + candidate_order: Arc::new((0..n).collect::>()), } } + /// What this selector is funding. + pub fn target(&self) -> Target { + self.problem.target() + } + + /// The selection problem this selector is solving. + pub fn problem(&self) -> &'a SelectionProblem { + self.problem + } + + /// Build a cached view of the current selection for aggregate queries and hypothetical updates. + pub fn compute_view(&'a self) -> SelectionView<'a> { + SelectionView::from_selector(self) + } + /// Iterate over all the candidates in their currently sorted order. Each item has the original /// index with the candidate. pub fn candidates( &self, ) -> impl DoubleEndedIterator + ExactSizeIterator + '_ { - self.candidate_order - .iter() - .map(move |i| (*i, self.candidates[*i])) + let cands = self.problem.candidates(); + self.candidate_order.iter().map(move |i| (*i, cands[*i])) } - /// Get the candidate at `index`. `index` refers to its position in the original `candidates` - /// slice passed into [`CoinSelector::new`]. + /// Get the candidate at `index`. `index` refers to its position in + /// [`SelectionProblem::candidates`]. pub fn candidate(&self, index: usize) -> Candidate { - self.candidates[index] + self.problem.candidate(index) } - /// Deselect a candidate at `index`. `index` refers to its position in the original `candidates` - /// slice passed into [`CoinSelector::new`]. + /// Deselect a candidate at `index`, its position in [`SelectionProblem::candidates`]. pub fn deselect(&mut self, index: usize) -> bool { self.selected.remove(index) } - /// Convienince method to pick elements of a slice by the indexes that are currently selected. - /// Obviously the slice must represent the inputs ordered in the same way as when they were - /// passed to `Candidates::new`. + /// Convenience method to pick elements of a slice by the indices that are currently selected. + /// + /// The slice must contain one element per [`SelectionProblem::candidates`] entry in construction + /// order. pub fn apply_selection(&self, candidates: &'a [T]) -> impl Iterator + '_ { self.selected.iter().map(move |i| &candidates[i]) } - /// Select the input at `index`. `index` refers to its position in the original `candidates` - /// slice passed into [`CoinSelector::new`]. + /// Select the candidate at `index`, its position in [`SelectionProblem::candidates`]. pub fn select(&mut self, index: usize) -> bool { - assert!(index < self.candidates.len()); + assert!(index < self.problem.len()); self.selected.insert(index) } - /// Select the next unselected candidate in the sorted order fo the candidates. + /// Select the next unselected candidate in the current candidate order. pub fn select_next(&mut self) -> bool { let next = self.unselected_indices().next(); if let Some(next) = next { @@ -94,7 +105,7 @@ impl<'a> CoinSelector<'a> { /// Ban an input from being selected. Banning the input means it won't show up in [`unselected`] /// or [`unselected_indices`]. Note it can still be manually selected. /// - /// `index` refers to its position in the original `candidates` slice passed into [`CoinSelector::new`]. + /// `index` is its position in [`SelectionProblem::candidates`]. /// /// [`unselected`]: Self::unselected /// [`unselected_indices`]: Self::unselected_indices @@ -102,6 +113,10 @@ impl<'a> CoinSelector<'a> { self.banned.insert(index); } + pub(crate) fn unban(&mut self, index: usize) { + self.banned.remove(index); + } + /// Gets the list of inputs that have been banned by [`ban`]. /// /// [`ban`]: Self::ban @@ -109,226 +124,59 @@ impl<'a> CoinSelector<'a> { &self.banned } - /// Is the input at `index` selected. `index` refers to its position in the original - /// `candidates` slice passed into [`CoinSelector::new`]. + /// Whether the candidate at `index` in [`SelectionProblem::candidates`] is selected. pub fn is_selected(&self, index: usize) -> bool { self.selected.contains(index) } - /// Whether the candidates can cover this `target`'s **value** (net of input fees) — i.e. whether - /// enough value is reachable for [`is_funded`] to hold. Respects [`ban`]ned candidates. - /// - /// Selecting *all* effective inputs maximizes the value available, so if that can't meet the - /// target value, nothing can. Monotone, hence exact. - /// - /// NOTE: this does **not** account for [`Target::max_weight`] — a `true` result can still be - /// infeasible under the weight cap. Use [`select_until_target_met`] or branch and bound (both of - /// which enforce the cap) to actually build a selection. - /// - /// [`ban`]: Self::ban - /// [`is_funded`]: Self::is_funded - /// [`select_until_target_met`]: Self::select_until_target_met - pub fn is_fundable(&self, target: Target) -> bool { - let mut test = self.clone(); - test.select_all_effective(target.fee.rate); - test.is_funded(target) - } - /// Returns true if no candidates have been selected. pub fn is_empty(&self) -> bool { self.selected.is_empty() } - /// The weight of the inputs including the witness header and the varint for the number of - /// inputs. - pub fn input_weight(&self) -> u64 { - let is_segwit_tx = self.selected().any(|(_, wv)| wv.is_segwit); - let witness_header_extra_weight = is_segwit_tx as u64 * 2; - - let input_count = self.selected().map(|(_, wv)| wv.input_count).sum::(); - let input_varint_weight = varint_size(input_count) * 4; - - let selected_weight: u64 = self - .selected() - .map(|(_, candidate)| { - let mut weight = candidate.weight; - if is_segwit_tx && !candidate.is_segwit { - // non-segwit candidates do not have the witness length field included in their - // weight field so we need to add 1 here if it's in a segwit tx. - weight += 1; - } - weight - }) - .sum(); - - input_varint_weight + selected_weight + witness_header_extra_weight - } - - /// Absolute value sum of all selected inputs. - pub fn selected_value(&self) -> u64 { - self.selected - .iter() - .map(|index| self.candidates[index].value) - .sum() - } - - /// Current weight of transaction implied by the selection. + /// The unconfirmed ancestors the current selection drags in (indices into + /// [`SelectionProblem::ancestors`]). /// - /// If you don't have any drain outputs (only target outputs) just set drain_weights to - /// [`DrainWeights::NONE`]. - pub fn weight(&self, target_ouputs: TargetOutputs, drain_weight: DrainWeights) -> u64 { - TX_FIXED_FIELD_WEIGHT - + self.input_weight() - + target_ouputs.output_weight_with_drain(drain_weight) - } - - /// How much the current selection overshoots the value needed to achieve `target`. - /// - /// In order for the resulting transaction to be valid this must be 0 or above. If it's above 0 - /// this means the transaction will overpay for what it needs to reach `target`. - pub fn excess(&self, target: Target, drain: Drain) -> i64 { - self.rate_excess(target, drain) - .min(self.absolute_excess(target, drain)) - .min(self.replacement_excess(target, drain)) - } - - /// How much extra value needs to be selected to reach the target. - pub fn missing(&self, target: Target) -> u64 { - let excess = self.excess(target, Drain::NONE); - if excess < 0 { - excess.unsigned_abs() - } else { - 0 - } - } - - /// How much the current selection overshoots the value need to satisfy `target.fee.rate` and - /// `target.value` (while ignoring `target.fee.absolute`). - pub fn rate_excess(&self, target: Target, drain: Drain) -> i64 { - self.selected_value() as i64 - - target.value() as i64 - - drain.value as i64 - - self.implied_fee_from_feerate(target, drain.weights) as i64 - } - - /// Same as [rate_excess](Self::rate_excess) except `target.fee.rate` is applied to the - /// implied transaction's weight units directly without any conversion to vbytes. - pub fn rate_excess_wu(&self, target: Target, drain: Drain) -> i64 { - self.selected_value() as i64 - - target.value() as i64 - - drain.value as i64 - - self.implied_fee_from_feerate_wu(target, drain.weights) as i64 - } - - /// How much the current selection overshoots the value needed to satisfy `target.fee.absolute` - /// and `target.value` (while ignoring `target.fee.rate`). - pub fn absolute_excess(&self, target: Target, drain: Drain) -> i64 { - self.selected_value() as i64 - - target.value() as i64 - - drain.value as i64 - - target.fee.absolute as i64 - } - - /// How much the current selection overshoots the value needed to satisfy RBF's rule 4. - pub fn replacement_excess(&self, target: Target, drain: Drain) -> i64 { - let mut replacement_excess_needed = 0; - if let Some(replace) = target.fee.replace { - replacement_excess_needed = - replace.min_fee_to_do_replacement(self.weight(target.outputs, drain.weights)) - } - self.selected_value() as i64 - - target.value() as i64 - - drain.value as i64 - - replacement_excess_needed as i64 - } - - /// Same as [replacement_excess](Self::replacement_excess) except the replacement fee - /// is calculated using weight units directly without any conversion to vbytes. - pub fn replacement_excess_wu(&self, target: Target, drain: Drain) -> i64 { - let mut replacement_excess_needed = 0; - if let Some(replace) = target.fee.replace { - replacement_excess_needed = - replace.min_fee_to_do_replacement_wu(self.weight(target.outputs, drain.weights)) - } - self.selected_value() as i64 - - target.value() as i64 - - drain.value as i64 - - replacement_excess_needed as i64 - } - - /// The feerate the transaction would have if we were to use this selection of inputs to achieve - /// the `target`'s value and weight. It is essentially telling you what target feerate you currently have. - /// - /// Returns `None` if the feerate would be negative or infinity. - pub fn implied_feerate(&self, target_outputs: TargetOutputs, drain: Drain) -> Option { - let numerator = - self.selected_value() as i64 - target_outputs.value_sum as i64 - drain.value as i64; - let denom = self.weight(target_outputs, drain.weights); - if numerator < 0 || denom == 0 { - return None; + /// This is the **union** over the selected candidates, so an ancestor shared by several of them + /// appears once. Derived from `selected` on demand: deselecting a candidate keeps an ancestor + /// that another selected candidate still drags in. + pub fn selected_ancestors(&self) -> Bitset { + let mut union = Bitset::with_capacity(self.problem.ancestors().len()); + if self.problem.has_ancestors() { + for cand_index in self.selected.iter() { + for anc_index in self.problem.drags_in(cand_index).iter() { + union.insert(anc_index); + } + } } - Some(FeeRate::from_sat_per_wu(numerator as f32 / denom as f32)) + union } - /// The fee the current selection and `drain_weight` should pay to satisfy `target_fee`. - /// - /// This compares the fee calculated from the target feerate with the fee calculated from the - /// [`Replace`] constraints and returns the larger of the two. + /// The unconfirmed ancestors that are not dragged in yet but could still be, i.e. those of the + /// [`unselected`](Self::unselected) candidates. Respects [`ban`](Self::ban). /// - /// `drain_weight` can be 0 to indicate no draining output. - pub fn implied_fee(&self, target: Target, drain_weights: DrainWeights) -> u64 { - let mut implied_fee = self - .implied_fee_from_feerate(target, drain_weights) - .max(target.fee.absolute); - - if let Some(replace) = target.fee.replace { - implied_fee = Ord::max( - implied_fee, - replace.min_fee_to_do_replacement(self.weight(target.outputs, drain_weights)), - ); + /// These are exactly the ancestors a descendant of this selection can add. + pub fn addable_ancestors(&self) -> Bitset { + let mut union = Bitset::with_capacity(self.problem.ancestors().len()); + if self.problem.has_ancestors() { + let already = self.selected_ancestors(); + for cand_index in self.unselected_indices() { + for anc_index in self.problem.drags_in(cand_index).iter() { + if !already.contains(anc_index) { + union.insert(anc_index); + } + } + } } - - implied_fee + union } - fn implied_fee_from_feerate(&self, target: Target, drain_weights: DrainWeights) -> u64 { - target - .fee - .rate - .implied_fee(self.weight(target.outputs, drain_weights)) - } - - fn implied_fee_from_feerate_wu(&self, target: Target, drain_weights: DrainWeights) -> u64 { - target - .fee - .rate - .implied_fee_wu(self.weight(target.outputs, drain_weights)) - } - - /// The actual fee the selection would pay if it was used in a transaction that had - /// `target_value` value for outputs and change output of `drain_value`. + /// Sorts the candidates by the comparison function. /// - /// This can be negative when the selection is invalid (outputs are greater than inputs). - pub fn fee(&self, target_value: u64, drain_value: u64) -> i64 { - self.selected_value() as i64 - target_value as i64 - drain_value as i64 - } - - /// The value of the current selected inputs minus the fee needed to pay for the selected inputs - pub fn effective_value(&self, feerate: FeeRate) -> i64 { - self.selected_value() as i64 - (self.input_weight() as f32 * feerate.spwu()).ceil() as i64 - } - - // /// Waste sum of all selected inputs. - fn input_waste(&self, feerate: FeeRate, long_term_feerate: FeeRate) -> f32 { - self.input_weight() as f32 * (feerate.spwu() - long_term_feerate.spwu()) - } - - /// Sorts the candidates by the comparision function. - /// - /// The comparision function takes the candidates's index and the [`Candidate`]. + /// The comparison function takes the candidate's index and the [`Candidate`]. /// /// Note this function does not change the index of the candidates after sorting, just the order - /// in which they will be returned when interating over them in [`candidates`] and [`unselected`]. + /// in which they will be returned when iterating over them in [`candidates`] and [`unselected`]. /// /// [`candidates`]: CoinSelector::candidates /// [`unselected`]: CoinSelector::unselected @@ -336,17 +184,17 @@ impl<'a> CoinSelector<'a> { where F: FnMut((usize, Candidate), (usize, Candidate)) -> core::cmp::Ordering, { - let candidates = &self.candidates; + let candidates = self.problem.candidates(); Arc::make_mut(&mut self.candidate_order) .sort_by(|a, b| cmp((*a, candidates[*a]), (*b, candidates[*b]))) } /// Sorts the candidates by the key function. /// - /// The key function takes the candidates's index and the [`Candidate`]. + /// The key function takes the candidate's index and the [`Candidate`]. /// /// Note this function does not change the index of the candidates after sorting, just the order - /// in which they will be returned when interating over them in [`candidates`] and [`unselected`]. + /// in which they will be returned when iterating over them in [`candidates`] and [`unselected`]. /// /// [`candidates`]: CoinSelector::candidates /// [`unselected`]: CoinSelector::unselected @@ -376,46 +224,12 @@ impl<'a> CoinSelector<'a> { } } - /// 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 - /// - /// [waste metric]: https://bitcoin.stackexchange.com/questions/113622/what-does-waste-metric-mean-in-the-context-of-coin-selection - pub fn waste( - &self, - target: Target, - long_term_feerate: FeeRate, - drain: Drain, - excess_discount: f32, - ) -> f32 { - debug_assert!((0.0..=1.0).contains(&excess_discount)); - let mut waste = self.input_waste(target.fee.rate, long_term_feerate); - - if drain.is_none() { - // We don't allow negative excess waste since negative excess just means you haven't - // satisified target yet in which case you probably shouldn't be calling this function. - let mut excess_waste = self.excess(target, drain).max(0) as f32; - // we allow caller to discount this waste depending on how wasteful excess actually is - // to them. - excess_waste *= excess_discount.clamp(0.0, 1.0); - waste += excess_waste; - } else { - waste += - drain - .weights - .waste(target.fee.rate, long_term_feerate, target.outputs.n_outputs); - } - - waste - } - /// The selected candidates with their index. pub fn selected( &self, ) -> impl ExactSizeIterator + DoubleEndedIterator + '_ { - self.selected - .iter() - .map(move |index| (index, self.candidates[index])) + let cands = self.problem.candidates(); + self.selected.iter().map(move |index| (index, cands[index])) } /// The unselected candidates with their index. @@ -424,8 +238,8 @@ impl<'a> CoinSelector<'a> { /// /// [`sort_candidates_by`]: Self::sort_candidates_by pub fn unselected(&self) -> impl DoubleEndedIterator + '_ { - self.unselected_indices() - .map(move |i| (i, self.candidates[i])) + let cands = self.problem.candidates(); + self.unselected_indices().map(move |i| (i, cands[i])) } /// The weight of the lightest unselected (addable) candidate, or `None` when nothing is left to @@ -439,7 +253,7 @@ impl<'a> CoinSelector<'a> { .min() } - /// The indices of the selelcted candidates. + /// The indices of the selected candidates. pub fn selected_indices(&self) -> &Bitset { &self.selected } @@ -461,41 +275,6 @@ impl<'a> CoinSelector<'a> { self.unselected_indices().next().is_none() } - /// Whether the tx implied by the current selection plus a drain of `drain_weights` is within - /// [`Target::max_weight`]. Pass [`DrainWeights::NONE`] for a changeless tx. - /// - /// Always `true` when `max_weight` is `None`. Note this is the *anti-monotone* half of - /// feasibility (adding inputs adds weight), so it is kept separate from the monotone - /// value-only [`is_funded`](Self::is_funded). - pub fn is_within_max_weight(&self, target: Target, drain_weights: DrainWeights) -> bool { - match target.max_weight { - Some(max_weight) => self.weight(target.outputs, drain_weights) <= max_weight, - None => true, - } - } - - /// Whether the selection covers the target value (i.e. [`excess`](Self::excess) is - /// non-negative), ignoring [`Target::max_weight`]. - /// - /// This is **monotone**: selecting more never un-meets it. It deliberately does *not* include - /// the weight cap — see [`is_within_max_weight`](Self::is_within_max_weight). - pub fn is_funded_with_drain(&self, target: Target, drain: Drain) -> bool { - self.excess(target, drain) >= 0 - } - - /// Whether the selection covers the target **value** (net of input fees), i.e. [`excess`] is - /// non-negative. **Monotone** (selecting more never un-meets it), and it deliberately does - /// *not* check [`Target::max_weight`] — that is the separate, anti-monotone - /// [`is_within_max_weight`]. See [`is_funded_with_drain`] for the version that - /// accounts for a specific `drain`. - /// - /// [`excess`]: Self::excess - /// [`is_within_max_weight`]: Self::is_within_max_weight - /// [`is_funded_with_drain`]: Self::is_funded_with_drain - pub fn is_funded(&self, target: Target) -> bool { - self.is_funded_with_drain(target, Drain::NONE) - } - /// Select all unselected candidates pub fn select_all(&mut self) { loop { @@ -505,66 +284,20 @@ impl<'a> CoinSelector<'a> { } } - /// The value of the change output should have to drain the excess value while maintaining the - /// constraints of `target` and respecting `change_policy`. - /// - /// If not change output should be added according to policy then it will return `None`. - pub fn drain_value(&self, target: Target, change_policy: ChangePolicy) -> Option { - let excess = self.excess( - target, - Drain { - weights: change_policy.drain_weights, - value: 0, - }, - ); - if excess > change_policy.min_value as i64 { - debug_assert_eq!( - self.is_funded(target), - self.is_funded_with_drain( - target, - Drain { - weights: change_policy.drain_weights, - value: excess as u64 - } - ), - "if the target is met without a drain it must be met after adding the drain" - ); - Some(excess as u64) - } else { - None - } - } - - /// Figures out whether the current selection should have a change output given the - /// `change_policy`. If it should not, then it will return [`Drain::NONE`]. The value of the - /// `Drain` will be the same as [`drain_value`]. - /// - /// If [`is_funded`] returns true for this selection then [`is_funded_with_drain`] will - /// also be true if you pass in the drain returned from this method. - /// - /// [`drain_value`]: Self::drain_value - /// [`is_funded_with_drain`]: Self::is_funded_with_drain - /// [`is_funded`]: Self::is_funded - #[must_use] - pub fn drain(&self, target: Target, change_policy: ChangePolicy) -> Drain { - match self.drain_value(target, change_policy) { - Some(value) => Drain { - weights: change_policy.drain_weights, - value, - }, - None => Drain::NONE, - } - } - /// Select all candidates with an *effective value* greater than 0 at the provided `feerate`. /// - /// A candidate if effective if it provides more value than it takes to pay for at `feerate`. + /// A candidate is effective if it provides more value than it costs at `feerate`. + /// + /// This looks at each candidate's own value and weight only: a candidate that pays for itself + /// but drags in an unconfirmed ancestor still counts as effective, even if the resulting + /// [`ancestor_bump`](SelectionView::ancestor_bump) outweighs it. Selection-dependent input-count and + /// witness serialization overhead are also excluded from this standalone calculation. pub fn select_all_effective(&mut self, feerate: FeeRate) { for i in 0..self.candidate_order.len() { let cand_index = self.candidate_order[i]; if self.selected.contains(cand_index) || self.banned.contains(cand_index) - || self.candidates[cand_index].effective_value(feerate) <= 0.0 + || self.problem.candidates()[cand_index].effective_value(feerate) <= 0.0 { continue; } @@ -576,37 +309,54 @@ impl<'a> CoinSelector<'a> { /// /// # Errors /// - /// - [`SelectError::InsufficientFunds`] if the candidates can't cover the target value. + /// - [`SelectError::InsufficientFunds`] if this in-order greedy selection exhausts the candidates + /// without covering the target value. Another subset may still work; use branch and bound to + /// search for one. /// - [`SelectError::MaxWeightExceeded`] if the value is met but the resulting selection exceeds /// [`Target::max_weight`]. Note this only reflects *this* in-order greedy selection; a /// different selection might still fit the cap (use branch and bound to search for one). - pub fn select_until_target_met(&mut self, target: Target) -> Result<(), SelectError> { - self.select_until(|cs| cs.is_funded(target)) - .ok_or_else(|| { - SelectError::InsufficientFunds(InsufficientFunds { - missing: self.excess(target, Drain::NONE).unsigned_abs(), - }) - })?; - if !self.is_within_max_weight(target, DrainWeights::NONE) { + /// + /// This is especially relevant with unconfirmed ancestors: selecting everything can fail while + /// a subset that drags in less ancestor fee debt would meet the target. + pub fn select_until_target_met(&mut self) -> Result<(), SelectError> { + let mut excess = 0_i64; + let mut is_within_max_weight = true; + self.select_until(|view| { + excess = view.excess(Drain::NONE); + is_within_max_weight = view.is_within_max_weight(DrainWeights::NONE); + excess >= 0 + }) + .ok_or_else(|| { + SelectError::InsufficientFunds(InsufficientFunds { + missing: excess.unsigned_abs(), + }) + })?; + if !is_within_max_weight { return Err(SelectError::MaxWeightExceeded); } Ok(()) } /// Select candidates until some predicate has been satisfied. + /// + /// The predicate is handed a [`SelectionView`] whose aggregates are updated incrementally as + /// candidates are selected, so a predicate built from cached queries costs the same at every + /// step regardless of how much is already selected. #[must_use] pub fn select_until( &mut self, - mut predicate: impl FnMut(&CoinSelector<'a>) -> bool, + mut predicate: impl FnMut(&SelectionView<'_>) -> bool, ) -> Option<()> { + let mut cache = SelectionCache::from_selector(self); loop { - if predicate(&*self) { + if predicate(&SelectionView::with_cache(self, &cache)) { break Some(()); } - if !self.select_next() { - break None; - } + let index = self.unselected_indices().next()?; + let candidate = self.candidate(index); + self.select(index); + cache.add(self.problem, index, candidate, true); } } @@ -620,7 +370,7 @@ impl<'a> CoinSelector<'a> { /// 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. + /// lower bound rather than randomizing the self.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` @@ -628,7 +378,7 @@ impl<'a> CoinSelector<'a> { /// 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. + /// already-selected candidates are kept and counted toward the self.target(). /// /// [`run_bnb`]: Self::run_bnb /// [`LowestFee`]: crate::metrics::LowestFee @@ -637,7 +387,6 @@ impl<'a> CoinSelector<'a> { // the max-weight PR lands. pub fn select_srd( &mut self, - target: Target, drain_weights: DrainWeights, change_lower: u64, rng: impl FnMut() -> u64, @@ -648,14 +397,11 @@ impl<'a> CoinSelector<'a> { 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, - }, - ); + is_within_max_weight = cs.is_within_max_weight(drain_weights); + excess = cs.excess(Drain { + weights: drain_weights, + value: 0, + }); excess >= change_lower as i64 || !is_within_max_weight }) .ok_or_else(|| { @@ -683,15 +429,67 @@ impl<'a> CoinSelector<'a> { /// [`BnbMetric`]. /// /// Not every iteration will return a solution. If a solution is found, we return the selection - /// and score. Each subsequent solution of the iterator guarantees a higher score than the last. + /// and score. Each subsequent solution guarantees a lower (better) score than the last. /// - /// Most of the time, you would want to use [`CoinSelector::run_bnb`] instead. + /// Most callers should use [`CoinSelector::run_bnb`] instead, especially when they need the + /// change output selected by the metric. pub fn bnb_solutions( &self, - target: Target, metric: M, ) -> impl Iterator, Ordf32)>> { - crate::bnb::BnbIter::new(self.clone(), target, metric) + crate::bnb::BnbIter::new(self.clone(), metric) + } + + /// [`bnb_solutions`](Self::bnb_solutions), searched with iterative deepening on the bound. + /// + /// The traversal stays depth-first and so stays linear in memory, but it runs in passes under a + /// rising ceiling on the bound, which recovers the node ordering a priority queue would give. + /// `eps` is the relative step between thresholds: smaller follows the queue's order more + /// closely and re-expands more, larger degenerates toward a plain dive. + pub fn bnb_solutions_with_deepening( + &self, + metric: M, + eps: f32, + ) -> impl Iterator, Ordf32)>> { + crate::bnb::BnbIter::with_deepening(self.clone(), metric, Some(eps)) + } + + /// [`bnb_solutions`](Self::bnb_solutions), dived first and then deepened. + /// + /// Depth-first reaches complete selections immediately but prunes against whatever its dive + /// order found; deepening recovers a priority queue's node ordering but reaches complete + /// selections late. This takes both: dive until the incumbent stops improving, then deepen from + /// the root keeping that incumbent. Because the incumbent only ever improves, this cannot + /// return a worse selection than the dive alone would have. + pub fn bnb_solutions_hybrid( + &self, + metric: M, + eps: f32, + ) -> impl Iterator, Ordf32)>> { + self.bnb_solutions_hybrid_with_floor(metric, eps, Self::DEFAULT_DIVE_FLOOR_PER_CANDIDATE) + } + + /// How long the opening dive is protected for, per candidate. + /// + /// The dive needs a floor or it hands over before it has found anything, because the greedy + /// incumbent is set before the first node and so leaves the "time since last improvement" rule + /// with nothing to measure against. The floor has to scale with something, and the budget is not + /// visible here — a caller may be spending rounds or wall clock. Candidate count is: a dive to a + /// leaf costs at most one node per candidate, so this is that depth times a constant. + /// + /// Measured over 42 fixtures at 10 ms, 100 ms and 1000 ms; 200 was the best single value, and + /// the metric is not sharply peaked around it. + pub const DEFAULT_DIVE_FLOOR_PER_CANDIDATE: u64 = 200; + + /// [`bnb_solutions_hybrid`](Self::bnb_solutions_hybrid) with the dive floor chosen explicitly. + pub fn bnb_solutions_hybrid_with_floor( + &self, + metric: M, + eps: f32, + floor_per_candidate: u64, + ) -> impl Iterator, Ordf32)>> { + let floor = floor_per_candidate.saturating_mul(self.candidates().count() as u64); + crate::bnb::BnbIter::configured(self.clone(), metric, Some(eps), Some(floor)) } /// Run branch and bound to minimize the score of the provided [`BnbMetric`]. @@ -703,11 +501,10 @@ impl<'a> CoinSelector<'a> { /// Use [`CoinSelector::bnb_solutions`] to access the branch and bound iterator directly. pub fn run_bnb( &mut self, - target: Target, metric: M, max_rounds: usize, ) -> Result<(Ordf32, Drain), NoBnbSolution> { - let mut iter = crate::bnb::BnbIter::new(self.clone(), target, metric); + let mut iter = crate::bnb::BnbIter::new(self.clone(), metric); let mut rounds = 0_usize; let best = iter .by_ref() @@ -716,7 +513,7 @@ impl<'a> CoinSelector<'a> { .flatten() .last(); if let Some((selector, score)) = best { - let drain = iter.metric.drain(&selector, target); + let drain = iter.metric.drain(&selector.compute_view()); *self = selector; return Ok((score, drain)); } @@ -724,12 +521,13 @@ impl<'a> CoinSelector<'a> { // No solution. If the iterator still has an item we stopped at the round limit and a // solution may still exist with a larger `max_rounds`. Otherwise the tree was fully // explored, so no selection satisfies the target — a genuine infeasibility, split into - // value vs weight. + // value vs weight. (With unconfirmed ancestors `is_fundable` is only a heuristic, so the + // split between the two can be wrong — the infeasibility itself is not.) if iter.next().is_some() { assert_eq!(rounds, max_rounds); // still-yielding ⟹ we truncated at the cap return Err(NoBnbSolution::RoundLimit { max_rounds, rounds }); } - if !self.is_fundable(target) { + if !self.compute_view().is_fundable() { return Err(NoBnbSolution::InsufficientFunds); } Err(NoBnbSolution::MaxWeightExceeded) @@ -839,6 +637,11 @@ impl std::error::Error for SelectError {} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NoBnbSolution { /// The candidates can't cover the target value, so no selection is possible. + /// + /// With unconfirmed ancestors this is decided by the heuristic + /// [`SelectionView::is_fundable`](crate::SelectionView::is_fundable), so it may be reported where + /// [`MaxWeightExceeded`](Self::MaxWeightExceeded) fits better, and vice versa. Either way the + /// search was exhaustive: there is no solution. InsufficientFunds, /// Some selection covers the target value, but every one of them exceeds /// [`Target::max_weight`]. @@ -889,7 +692,11 @@ impl std::error::Error for NoBnbSolution {} /// A `Candidate` represents an input candidate for [`CoinSelector`]. /// -/// This can either be a single UTXO, or a group of UTXOs that should be spent together. +/// This can either be a single UTXO, or a group of UTXOs that should be spent together. A group +/// may mix legacy and segwit inputs; set [`legacy_count`] and [`segwit_count`] accordingly. +/// +/// [`legacy_count`]: Candidate::legacy_count +/// [`segwit_count`]: Candidate::segwit_count #[derive(Debug, Clone, Copy)] pub struct Candidate { /// Total value of the UTXO(s) that this [`Candidate`] represents. @@ -897,31 +704,66 @@ pub struct Candidate { /// Total weight of including this/these UTXO(s). /// `txin` fields: `prevout`, `nSequence`, `scriptSigLen`, `scriptSig`, `scriptWitnessLen`, /// `scriptWitness` should all be included. + /// + /// For legacy inputs, do *not* include the `scriptWitnessLen` byte: a legacy input only + /// serializes an (empty) witness when the transaction has a witness section, and + /// [`SelectionView::input_weight`] adds that 1 WU per legacy input once any segwit input is + /// selected. pub weight: u64, - /// Total number of inputs; so we can calculate extra `varint` weight due to `vin` len changes. - pub input_count: usize, - /// Whether this [`Candidate`] contains at least one segwit spend. - pub is_segwit: bool, + /// Total number of segwit inputs. + /// + /// If any selected candidate has a non-zero `segwit_count`, the transaction serializes a + /// witness section (marker + flag, 2 WU) and every input — including legacy ones — pays for + /// a witness. + pub segwit_count: usize, + /// Total number of legacy (non-segwit) inputs. + /// + /// Each legacy input serializes an empty witness (1 WU) when the transaction has a witness + /// section; [`SelectionView::input_weight`] prices this per legacy input, so grouped legacy + /// inputs are counted exactly. + pub legacy_count: usize, } impl Candidate { /// Create a [`Candidate`] input that spends a single taproot keyspend output. pub fn new_tr_keyspend(value: u64) -> Self { let weight = TR_KEYSPEND_SATISFACTION_WEIGHT; - Self::new(value, weight, true) + Self::new_segwit(value, weight) } - /// Create a new [`Candidate`] that represents a single input. + /// Create a new [`Candidate`] that represents a single segwit input. + /// + /// `satisfaction_weight` is the additional weight (in weight units) required to satisfy the input + /// beyond [`TXIN_BASE_WEIGHT`] (e.g. `scriptWitnessLen + scriptWitness` in WU at 1 WU/byte, plus + /// any `scriptSig` data and extra `scriptSigLen` varint bytes if nested/wrapped segwit). + /// + /// Note that [`TXIN_BASE_WEIGHT`] already accounts for the outpoint, `nSequence`, and 1 byte for + /// `scriptSigLen`. + pub fn new_segwit(value: u64, satisfaction_weight: u64) -> Candidate { + let weight = TXIN_BASE_WEIGHT + satisfaction_weight; + Candidate { + value, + weight, + segwit_count: 1, + legacy_count: 0, + } + } + + /// Create a new [`Candidate`] that represents a single legacy (non-segwit) input. + /// + /// `satisfaction_weight` is the additional weight (in weight units) required to satisfy the input + /// beyond [`TXIN_BASE_WEIGHT`] (e.g. `scriptSig` at 4 WU/byte, plus 4 WU per extra `scriptSigLen` + /// varint byte if `scriptSig` exceeds 252 bytes). /// - /// `satisfaction_weight` is the weight of `scriptSigLen + scriptSig + scriptWitnessLen + - /// scriptWitness`. - pub fn new(value: u64, satisfaction_weight: u64, is_segwit: bool) -> Candidate { + /// Note that [`TXIN_BASE_WEIGHT`] already accounts for the outpoint, `nSequence`, and 1 byte for + /// `scriptSigLen`. + pub fn new_legacy(value: u64, satisfaction_weight: u64) -> Candidate { let weight = TXIN_BASE_WEIGHT + satisfaction_weight; Candidate { value, weight, - input_count: 1, - is_segwit, + segwit_count: 0, + legacy_count: 1, } } diff --git a/src/drain.rs b/src/drain.rs index 98067ef..d5ed673 100644 --- a/src/drain.rs +++ b/src/drain.rs @@ -70,10 +70,11 @@ impl DrainWeights { /// A drain (A.K.A. change) output. /// Technically it could represent multiple outputs. /// -/// This is returned from [`CoinSelector::drain`]. Note if `drain` returns a drain where `is_none()` -/// returns true then **no change should be added** to the transaction. +/// This is returned from [`SelectionView::drain`]. Note if `drain` +/// returns a drain where `is_none()` returns true then **no change should be added** to the +/// transaction. /// -/// [`CoinSelector::drain`]: crate::CoinSelector::drain +/// [`SelectionView::drain`]: crate::SelectionView::drain #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] pub struct Drain { /// Weight of adding drain output and spending the drain output. diff --git a/src/lib.rs b/src/lib.rs index 34c86ad..5a4d9fe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,9 @@ pub use bitset::*; mod coin_selector; pub mod float; pub use coin_selector::*; +mod selection_view; +use selection_view::SelectionCache; +pub use selection_view::SelectionView; mod bnb; pub use bnb::*; @@ -28,6 +31,8 @@ mod target; pub use target::*; mod drain; pub use drain::*; +mod selection_problem; +pub use selection_problem::*; /// Txin "base" fields include `outpoint` (32+4) and `nSequence` (4) and 1 byte for the scriptSig /// length. @@ -59,7 +64,7 @@ pub const TR_KEYSPEND_TXIN_WEIGHT: u64 = TXIN_BASE_WEIGHT + TR_KEYSPEND_SATISFAC pub const TR_DUST_RELAY_MIN_VALUE: u64 = 330; /// Helper to calculate varint size. `v` is the value the varint represents. -const fn varint_size(v: usize) -> u64 { +pub(crate) const fn varint_size(v: usize) -> u64 { if v <= 0xfc { return 1; } diff --git a/src/metrics.rs b/src/metrics.rs index 1da1163..2d841d0 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -5,5 +5,3 @@ //! [`CoinSelector::run_bnb`]: crate::CoinSelector::run_bnb mod lowest_fee; pub use lowest_fee::*; -mod changeless; -pub use changeless::*; diff --git a/src/metrics/changeless.rs b/src/metrics/changeless.rs deleted file mode 100644 index a9c9e32..0000000 --- a/src/metrics/changeless.rs +++ /dev/null @@ -1,79 +0,0 @@ -use crate::{bnb::BnbMetric, float::Ordf32, CoinSelector, Drain, Target}; - -/// Constrains an `inner` metric to only changeless solutions. -/// -/// A selection is scored by `inner` only if the inner metric decides it should *not* have a change -/// output (see [`BnbMetric::drain`]); otherwise it is treated as invalid. This lets you find, for -/// example, the lowest-fee changeless solution via `Changeless`. -#[derive(Clone, Copy, Debug)] -pub struct Changeless( - /// The inner metric that scores changeless solutions and owns the change decision. - pub M, -); - -impl Changeless { - /// Whether every selection reachable down this branch (the current one and any superset of it) - /// would have a change output according to the inner metric — so no changeless solution exists - /// here and the branch can be pruned. - /// - /// The inner metric only adds change once the excess is large enough (we assume its change - /// decision is monotone in the excess). So the reachable selection least likely to have change - /// is the one with the smallest excess — the current selection plus every remaining - /// negative-effective-value candidate, since each of those lowers the excess. If even that - /// selection still has change, then so does every reachable selection. - /// - /// NOTE: this relies on candidates being sorted so that all negative effective value candidates - /// are next to each other, which [`requires_ordering_by_descending_value_pwu`] guarantees. - /// - /// [`requires_ordering_by_descending_value_pwu`]: BnbMetric::requires_ordering_by_descending_value_pwu - fn change_unavoidable(&mut self, cs: &CoinSelector<'_>, target: Target) -> bool { - if self.0.drain(cs, target).is_none() { - return false; - } - - let mut least_excess = cs.clone(); - cs.unselected() - .rev() - .take_while(|(_, wv)| wv.effective_value(target.fee.rate) < 0.0) - .for_each(|(index, _)| { - least_excess.select(index); - }); - - self.0.drain(&least_excess, target).is_some() - } -} - -impl BnbMetric for Changeless { - fn drain(&mut self, _cs: &CoinSelector<'_>, _target: Target) -> Drain { - // by definition a changeless selection never has a change output - Drain::NONE - } - - fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { - // Reject selections that have change. We don't need an explicit target-met check: `inner` - // returns `None` for invalid (e.g. not-target-met) selections. - // - // NOTE: for metrics whose `score` recomputes the drain (e.g. `LowestFee`), this evaluates - // the drain decision twice per node. Sharing it would mean threading the drain into - // `score`, which we avoid to keep metrics composable. - if self.0.drain(cs, target).is_some() { - return None; - } - self.0.score(cs, target) - } - - fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { - if self.change_unavoidable(cs, target) { - // every descendant has change, so no changeless solution is reachable - None - } else { - // the changeless-constrained optimum is no better than the inner metric's unconstrained - // optimum, so the inner bound is a valid lower bound - self.0.bound(cs, target) - } - } - - fn requires_ordering_by_descending_value_pwu(&self) -> bool { - true - } -} diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index 5499777..baf8bf9 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -1,11 +1,11 @@ -use crate::{float::Ordf32, BnbMetric, CoinSelector, Drain, DrainWeights, FeeRate, Target}; +use crate::{float::Ordf32, BnbMetric, Drain, DrainWeights, FeeRate, SelectionView}; /// Metric that aims to minimize transaction fees. The future fee for spending the change output is /// included in this calculation. /// /// The fee is simply: /// -/// > `inputs - outputs` where `outputs = target.value + change_value` +/// > `inputs - outputs` where `outputs = cs.target().value + change_value` /// /// But the total value includes the cost of spending the change output if it exists: /// @@ -13,8 +13,29 @@ use crate::{float::Ordf32, BnbMetric, CoinSelector, Drain, DrainWeights, FeeRate /// /// Unlike other metrics, `LowestFee` decides for itself whether a selection should have a change /// output: change is added whenever doing so lowers the long-term fee (i.e. the recovered excess -/// outweighs the future cost of spending the change) and the resulting change value is above the -/// dust threshold implied by `dust_relay_feerate`. +/// outweighs the future cost of spending the change), the resulting value is at least the dust +/// threshold implied by `dust_relay_feerate`, and the transaction with change fits +/// [`Target::max_weight`](crate::Target::max_weight). +/// +/// # Unconfirmed ancestors +/// +/// When the [`SelectionProblem`] has unconfirmed ancestors, the fee a selection must pay includes +/// the [`SelectionView::ancestor_bump`](crate::SelectionView::ancestor_bump) of the ancestors it drags +/// in, so the search naturally prefers coins that drag in nothing or share an already-paid-for +/// ancestor. Ancestor fees are netted over the union, allowing an overpaying ancestor to offset an +/// underpaying one without subsidizing the child itself. The score remains the child transaction's +/// fee: the bump is inside it, not added on top. +/// +/// The bound uses a child-weight relaxation when ancestors are present (see +/// [`bound`](BnbMetric::bound)): a funded node credits reachable ancestor surplus and possible future +/// change, clamped to the monotone fee floor, while an unfunded one estimates the least child weight +/// needed to meet each fee constraint. Neither reasons from "select everything and it is still +/// unfunded", which does not follow while funding is not monotone. Infeasibility comes from the two +/// prunes that can prove it: the lookahead in [`bound`](BnbMetric::bound), which relaxes the +/// ancestor bump to its branch-wide floor rather than assuming it only grows, and the relaxation's +/// own test for a deficit the most optimistic input still available cannot close at any weight. +/// +/// [`SelectionProblem`]: crate::SelectionProblem #[derive(Clone, Copy)] pub struct LowestFee { /// The estimated feerate needed to spend our change output later. @@ -27,16 +48,13 @@ pub struct LowestFee { impl LowestFee { /// The value the change output should have, or `None` if this selection should be changeless. - fn drain_value(&self, cs: &CoinSelector<'_>, target: Target) -> Option { + pub(super) fn drain_value(&self, cs: &SelectionView<'_>) -> Option { // The change output pays for its own weight, so the value we'd actually recover is the // excess remaining after accounting for that weight. - let excess_with_drain_weight = cs.excess( - target, - Drain { - weights: self.drain_weights, - value: 0, - }, - ); + let excess_with_drain_weight = cs.excess(Drain { + weights: self.drain_weights, + value: 0, + }); // Adding change is only worth it if the value we'd recover exceeds the future cost of // spending it (i.e. it lowers the long-term fee). @@ -56,7 +74,7 @@ impl LowestFee { // ...and only if the change output would not push the tx over `max_weight`. If it would, // we refuse the drain and the excess goes to fee instead (a slightly conservative choice: // it can refuse change even when a no-change tx of this selection would fit). - if !cs.is_within_max_weight(target, self.drain_weights) { + if !cs.is_within_max_weight(self.drain_weights) { return None; } @@ -71,17 +89,20 @@ impl LowestFee { /// inside [`bound`](BnbMetric::bound): deferring the changeless rejection only loosens the lower /// bound and never makes it inadmissible, and `score` reuses the returned drain for its cap /// check so the drain is decided once. - fn fee_score(&self, cs: &CoinSelector<'_>, target: Target) -> Option<(Ordf32, Drain)> { - if !cs.is_funded(target) { + /// + /// The score is the *child* transaction's fee (plus the future cost of spending its change). + /// Any [`SelectionView::ancestor_bump`] is not added on top: it is already inside the child's fee, + /// because covering it is what [`SelectionView::is_funded`] demands and what the change + /// calculation gives up. + fn fee_score(&self, cs: &SelectionView<'_>) -> Option<(Ordf32, Drain)> { + if !cs.is_funded() { return None; } - let drain = self - .drain_value(cs, target) - .map_or(Drain::NONE, |value| Drain { - weights: self.drain_weights, - value, - }); - let fee_for_the_tx = cs.fee(target.value(), drain.value); + let drain = self.drain_value(cs).map_or(Drain::NONE, |value| Drain { + weights: self.drain_weights, + value, + }); + let fee_for_the_tx = cs.fee(cs.target().value(), drain.value); assert!( fee_for_the_tx >= 0, "must not be called unless selection has met target: fee={}", @@ -93,39 +114,173 @@ impl LowestFee { drain, )) } + + /// Whether a descendant of `cs` could still add both a change output and at least one more + /// input under `max_weight`. Same test as the no-ancestor funded path. + fn change_is_reachable(&self, cs: &SelectionView<'_>) -> bool { + match cs.target().max_weight { + None => true, + Some(max_weight) => cs.min_input_weight().map_or(false, |min_input_weight| { + cs.weight(cs.target().outputs, self.drain_weights) + min_input_weight <= max_weight + }), + } + } + + /// Tighter than [`SelectionView::fee_floor`] once the value shortfall proves that every funded + /// descendant must add some child input weight. + /// + /// Returns `None` only for the one infeasibility this relaxation can actually prove: a fee + /// constraint whose deficit the best input still available cannot close at any weight. It is + /// *not* allowed to reason from "select everything and it is still unfunded" — a fat private + /// deficit can un-fund a prefix that a subset would have funded. (The caller has already + /// hard-pruned on child `max_weight`, which is monotone.) + /// + /// The three fee constraints get independent fractional relaxations. Their maximum is still a + /// lower bound on the real added child weight. Candidate ancestry is ignored and the global bump + /// floor is used instead, avoiding package-surplus double counting. Flooring the fractional + /// weight keeps floating-point error in the safe direction. + fn bound_with_ancestors(&self, cs: &SelectionView<'_>) -> Option { + if cs.is_funded() { + let (_, drain) = self.fee_score(cs).unwrap(); + let current_score = cs.fee(cs.target().value(), drain.value) as u64 + + drain.weights.spend_fee(self.long_term_feerate); + let surplus = cs + .ancestor_bump() + .saturating_sub(cs.ancestor_bump_lower_bound()); + let mut bound = current_score.saturating_sub(surplus); + if drain.is_none() { + let cost_of_adding_change = self.drain_weights.waste( + cs.target().fee.rate, + self.long_term_feerate, + cs.target().outputs.n_outputs, + ); + // Subtract the large integer terms before converting anything to float. Casting the + // non-negative waste to u64 floors it, keeping the bound conservative. + let with_change = current_score + .saturating_sub(surplus) + .saturating_sub(cs.excess(Drain::NONE) as u64) + .saturating_add(cost_of_adding_change as u64); + if self.change_is_reachable(cs) { + bound = bound.min(with_change); + } + } + return Some(Ordf32(bound.max(cs.fee_floor()) as f32)); + } + + let target = cs.target(); + let bump = cs.ancestor_bump_lower_bound(); + let current_weight = cs.weight(target.outputs, DrainWeights::NONE); + let selected_value = cs.selected_value() as f64; + let value_target = target.value() as f64; + let target_rate = target.fee.rate.spwu() as f64; + let rate_deficit = (value_target + target_rate * current_weight as f64 + bump as f64 + - selected_value) + .max(0.0); + let absolute_deficit = + (value_target + target.fee.absolute as f64 - selected_value).max(0.0); + let (replace_deficit, replace_rate) = target.fee.replace.map_or((0.0, 0.0), |replace| { + let rate = replace.incremental_relay_feerate.spwu() as f64; + ( + (value_target + replace.fee as f64 + rate * current_weight as f64 - selected_value) + .max(0.0), + rate, + ) + }); + + let best_value = cs.best_undecided_value_pwu(); + let weightless_value = cs.has_weightless_undecided_value(); + let best_rate_gain = (best_value - target_rate).max(0.0); + let best_replace_gain = (best_value - replace_rate).max(0.0); + + // Bitcoin Core computes `is_feerate_high` once and lets it decide whether a prune that is + // only sometimes valid may fire, rather than dropping the prune outright. Same shape here. + // A deficit that no available input can close at any weight is not a claim about + // monotonicity: descendants only add, the deficit already uses the branch-wide bump floor, + // and the gain already ignores whatever ancestors those inputs would drag in. So this much + // infeasibility is provable even though the general case is not, and saying so prunes the + // subtree instead of ranking it. + let unreachable = + |deficit: f64, gain_pwu: f64| !weightless_value && deficit > 0.0 && gain_pwu <= 0.0; + if unreachable(rate_deficit, best_rate_gain) + || unreachable(absolute_deficit, best_value) + || unreachable(replace_deficit, best_replace_gain) + { + return None; + } + + // Treat the best candidate as unlimited fractional input. If no positive gain is available, + // or a positive-value zero-weight candidate exists, fall back to zero added weight rather + // than claiming infeasibility. + let weight_for = |deficit: f64, gain_pwu: f64| match (deficit, gain_pwu) { + (deficit, gain) if !weightless_value && deficit > 0.0 && gain > 0.0 => deficit / gain, + _ => 0.0, + }; + let added_weight = weight_for(rate_deficit, best_rate_gain) + .max(weight_for(absolute_deficit, best_value)) + .max(weight_for(replace_deficit, best_replace_gain)); + + // `added_weight` is non-negative, so conversion to u64 truncates (floors) it. + let added_weight = added_weight as u64; + let weight = match current_weight.checked_add(added_weight) { + Some(weight) if added_weight != u64::MAX => weight, + _ => return Some(Ordf32(cs.fee_floor() as f32)), + }; + let mut bound = (target.fee.rate.implied_fee_wu(weight) + bump).max(target.fee.absolute); + if let Some(replace) = target.fee.replace { + bound = bound.max(replace.min_fee_to_do_replacement_wu(weight)); + } + Some(Ordf32(bound as f32)) + } } impl BnbMetric for LowestFee { - fn drain(&mut self, cs: &CoinSelector<'_>, target: Target) -> Drain { - self.drain_value(cs, target) - .map_or(Drain::NONE, |value| Drain { - weights: self.drain_weights, - value, - }) + fn drain(&mut self, cs: &SelectionView<'_>) -> Drain { + self.drain_value(cs).map_or(Drain::NONE, |value| Drain { + weights: self.drain_weights, + value, + }) } - fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { - let (score, drain) = self.fee_score(cs, target)?; + fn score(&mut self, cs: &SelectionView<'_>) -> Option { + let (score, drain) = self.fee_score(cs)?; // A final selection must fit the weight cap. `drain_value` already refuses an over-cap // change, but a changeless selection can still be too heavy on its own. Reuse the drain // `fee_score` already decided rather than recomputing it here. - if !cs.is_within_max_weight(target, drain.weights) { + if !cs.is_within_max_weight(drain.weights) { return None; } Some(score) } - fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { + fn bound(&mut self, cs: &SelectionView<'_>) -> Option { // Weight hard-prune: input weight only grows as this branch is extended, so the lightest // solution in the subtree is this selection with no drain. If even that busts `max_weight`, // the whole subtree is infeasible -> prune. (Also keeps `fee_score(cs).unwrap()` below // sound: a value-met but over-cap node would otherwise score `None`.) - if !cs.is_within_max_weight(target, DrainWeights::NONE) { + // + // Ancestor weight is *not* part of this: `max_weight` caps the child transaction only. + if !cs.is_within_max_weight(DrainWeights::NONE) { + return None; + } + + // Lookahead hard-prune (Bitcoin Core's `curr_available_value` test): if everything still + // undecided cannot close the feerate gap, no descendant is funded, so the subtree is empty. + // Funding needs every fee constraint met, so failing this one alone is enough to prune. + // Constant-time, and it fires before either relaxation below does any work. + if cs.best_reachable_rate_excess_wu() < 0 { return None; } - if cs.is_funded(target) { - let current_score = self.fee_score(cs, target).unwrap().0; + // With unconfirmed ancestors, funding is not monotone, so neither this path nor the one + // below may reason from "select everything and it is still unfunded". Emptiness is claimed + // only where it is provable: by the lookahead above, which relaxes the bump to its + // branch-wide floor, and by `bound_with_ancestors`' own unclosable-deficit test. + if cs.problem().has_ancestors() { + return self.bound_with_ancestors(cs); + } + + if cs.is_funded() { + let current_score = self.fee_score(cs).unwrap().0; // `current_score` is already a valid lower bound for a selection that has change: a // descendant can never lower the fee by removing an existing (worthwhile) change @@ -146,17 +301,17 @@ impl BnbMetric for LowestFee { // `drain_value`, where `change_value` is `excess_with_drain_weight` and `spend_fee` is // `drain_spend_cost`). With `v >= 0` the difference is strictly positive: B always // costs more. - if self.drain_value(cs, target).is_none() { + if self.drain_value(cs).is_none() { // But a descendant might *add* a change output that improves the metric. This // happens when the current selection is changeless only because the change would be // dust: a descendant with more excess could clear the dust threshold and recover // value that is currently burned to fees. let cost_of_adding_change = self.drain_weights.waste( - target.fee.rate, + cs.target().fee.rate, self.long_term_feerate, - target.outputs.n_outputs, + cs.target().outputs.n_outputs, ); - let cost_of_no_change = cs.excess(target, Drain::NONE); + let cost_of_no_change = cs.excess(Drain::NONE); let best_score_with_change = Ordf32(current_score.0 - cost_of_no_change as f32 + cost_of_adding_change); @@ -165,34 +320,33 @@ impl BnbMetric for LowestFee { // of which only make the tx heavier. If there's no room for both under the cap the // improvement is unreachable down this branch, so don't credit it — keep // `current_score` (a tighter, still-admissible bound). - let change_is_reachable = match target.max_weight { - None => true, - Some(max_weight) => cs.min_input_weight().map_or(false, |min_input_weight| { - cs.weight(target.outputs, self.drain_weights) + min_input_weight - <= max_weight - }), - }; - if change_is_reachable && best_score_with_change < current_score { + if self.change_is_reachable(cs) && best_score_with_change < current_score { return Some(best_score_with_change); } } Some(current_score) } else { - // Step 1: select everything up until the input that hits the target. - let (mut cs, resize_index, to_resize) = cs - .clone() - .select_iter() - .find(|(cs, _, _)| cs.is_funded(target))?; + // Step 1: select everything up until the input that hits the cs.target(). + let mut local = cs.clone(); + let mut unselected = cs.unselected(); + let (resize_index, to_resize) = loop { + let (index, candidate) = unselected.next()?; + local.add_unchecked(index); + if local.is_funded() { + break (index, candidate); + } + }; // If this selection is already perfect, return its score directly. - if cs.excess(target, Drain::NONE) == 0 { - return Some(self.fee_score(&cs, target).unwrap().0); + if local.excess(Drain::NONE) == 0 { + return Some(self.fee_score(&local).unwrap().0); }; - cs.deselect(resize_index); + local.sub_unchecked(resize_index); + let cs = &local; // We need to find the minimum fee we'd pay if we satisfy the feerate constraint. We do - // this by imagining we had a perfect input that perfectly hit the target. The sats per + // this by imagining we had a perfect input that perfectly hit the cs.target(). The sats per // weight unit of this perfect input is that of `to_resize` but we'll do a scaled // resize of it to fit perfectly. // @@ -208,12 +362,13 @@ impl BnbMetric for LowestFee { // // In the perfect scenario, no additional fee would be required to pay for rounding up when converting from weight units to // vbytes and so all fee calculations below are performed on weight units directly. - let rate_excess = cs.rate_excess_wu(target, Drain::NONE) as f32; + let rate_excess = cs.rate_excess_wu(Drain::NONE) as f32; let mut scale = Ordf32(0.0); if rate_excess < 0.0 { let remaining_value_to_reach_feerate = rate_excess.abs(); - let effective_value_of_resized_input = to_resize.effective_value(target.fee.rate); + let effective_value_of_resized_input = + to_resize.effective_value(cs.target().fee.rate); if effective_value_of_resized_input > 0.0 { let feerate_scale = remaining_value_to_reach_feerate / effective_value_of_resized_input; @@ -225,8 +380,8 @@ impl BnbMetric for LowestFee { // We can use the same approach for replacement we just have to use the // incremental_relay_feerate. - if let Some(replace) = target.fee.replace { - let replace_excess = cs.replacement_excess_wu(target, Drain::NONE) as f32; + if let Some(replace) = cs.target().fee.replace { + let replace_excess = cs.replacement_excess_wu(Drain::NONE) as f32; if replace_excess < 0.0 { let remaining_value_to_reach_feerate = replace_excess.abs(); let effective_value_of_resized_input = @@ -243,7 +398,7 @@ impl BnbMetric for LowestFee { // Handle absolute fee constraint. Unlike feerate and replacement, the // absolute fee is a fixed amount (not weight-proportional), so we just // need enough raw value to cover the gap. - let absolute_excess = cs.absolute_excess(target, Drain::NONE) as f32; + let absolute_excess = cs.absolute_excess(Drain::NONE) as f32; if absolute_excess < 0.0 { let remaining = absolute_excess.abs(); if to_resize.value > 0 { @@ -260,8 +415,8 @@ impl BnbMetric for LowestFee { // no within-cap selection down this branch reaches the target -> prune. This is the // fractional relaxation, so it never prunes a branch with an (integer) within-cap // solution. - if let Some(max_weight) = target.max_weight { - if cs.weight(target.outputs, DrainWeights::NONE) as f32 + if let Some(max_weight) = cs.target().max_weight { + if cs.weight(cs.target().outputs, DrainWeights::NONE) as f32 + scale.0 * to_resize.weight as f32 > max_weight as f32 { @@ -272,7 +427,7 @@ impl BnbMetric for LowestFee { // `scale` could be 0 even if `is_funded` is `false` due to the latter being based on // rounded-up vbytes. let ideal_fee = scale.0 * to_resize.value as f32 + cs.selected_value() as f32 - - target.value() as f32; + - cs.target().value() as f32; assert!(ideal_fee >= 0.0); Some(Ordf32(ideal_fee)) diff --git a/src/selection_problem.rs b/src/selection_problem.rs new file mode 100644 index 0000000..7fd401f --- /dev/null +++ b/src/selection_problem.rs @@ -0,0 +1,482 @@ +use alloc::collections::BTreeMap; +use alloc::vec::Vec; + +use crate::bitset::Bitset; +use crate::{Candidate, CoinSelector, FeeRate, Target}; + +/// An unconfirmed ancestor that may need bumping to the target feerate (CPFP). +/// +/// `Txid` is whatever the caller keys transactions by. This crate has no `bitcoin` dependency. +#[derive(Debug, Clone)] +pub struct AncestorToBump { + /// Caller-chosen id for this transaction. + pub txid: Txid, + /// Weight of this transaction in weight units. + pub weight: u64, + /// Fee this transaction already pays, in satoshis. + pub fee: u64, + /// Direct parents only; transitive ancestors are derived when building a [`SelectionProblem`]. + pub parents: Vec, +} + +/// One or more UTXOs that must be spent together, described on their own terms. +/// +/// Everything here is intrinsic to the coins. Hand these to [`SelectionProblem::new`], which pairs +/// each group with the ancestors it drags in. +pub type InputGroup = Vec>; + +/// A single UTXO, before it is folded into a [`Candidate`]. +#[derive(Debug, Clone, Copy)] +pub struct Input { + /// Value of the UTXO in satoshis. + pub value: u64, + /// Input weight as for [`Candidate::weight`] (legacy inputs omit the empty-witness byte). + pub weight: u64, + /// Whether this input is segwit. + pub is_segwit: bool, + /// Transaction that created this UTXO (may be unconfirmed). + pub residing_txid: Txid, +} + +impl From> for InputGroup { + fn from(input: Input) -> Self { + alloc::vec![input] + } +} + +/// Target, candidates, and (optional) ancestor-bump data for one coin-selection run. +/// +/// Build with [`SelectionProblem::new_no_ancestors`] when nothing is unconfirmed, or +/// [`SelectionProblem::new`] when spending unconfirmed UTXOs. Pass a reference to +/// [`CoinSelector::new`]. +/// +/// Ancestor bump figures are stored here (not on [`Candidate`]) so candidates stay a plain +/// description of inputs. Every unconfirmed transaction that created an input, and all of its +/// transitive unconfirmed ancestors, must be supplied for accurate CPFP pricing. Any absent id, +/// including an [`Input::residing_txid`] or parent id, is treated as confirmed and ignored, which +/// can underestimate the required fee. Deficits are computed against the full supplied ancestor +/// union; unlike Bitcoin Core, this does not remove transactions that could already be mined at an +/// intermediate feerate, so it may also conservatively overestimate a bump. +/// +/// What a selection actually owes is +/// [`SelectionView::ancestor_bump`](crate::SelectionView::ancestor_bump): the shortfall of the +/// ancestors its selected candidates drag in, each charged once, weight and fee netted over the +/// union. +#[derive(Debug, Clone)] +pub struct SelectionProblem { + target: Target, + candidates: Vec, + /// Weight and fee of each ancestor, after txids are dropped. + ancestors: Vec<(u64, u64)>, + /// Per-candidate set of ancestor indices dragged in by selecting that candidate. + /// + /// Empty when the problem has no ancestors (see [`has_ancestors`](Self::has_ancestors)); use + /// [`drags_in`](Self::drags_in) rather than indexing this directly. + drags_in: Vec, + /// Summed weight and fee of the ancestors *only* this candidate can drag in. + /// + /// No other candidate reaches them, so they arrive exactly when this candidate is selected. + /// Summed rather than reduced to a bump because the target rate must be applied to the total + /// weight of the whole selection once, and because an ancestor paying above the rate has to be + /// able to subsidize one paying below it. + private: Vec<(u64, u64)>, + /// [`drags_in`](Self::drags_in) restricted to ancestors reachable via several candidates, which + /// are the only ones that still need de-duplicating at selection time. + shared_drags_in: Vec, + /// Whether any ancestor is reachable via exactly one candidate. + has_private_ancestors: bool, + /// Whether any ancestor is reachable via more than one candidate. + has_shared_ancestors: bool, +} + +/// The fee still owed so the ancestors in `set` meet `rate`, over the whole set at once. +/// +/// Weights and fees are netted across the set, so an overpaying ancestor subsidizes an underpaying +/// one and the result saturates at 0 (the child is never credited). +fn bump_of(ancestors: &[(u64, u64)], rate: FeeRate, set: &Bitset) -> u64 { + let (weight, fee) = set.iter().fold((0_u64, 0_u64), |(w, f), anc_i| { + let (anc_w, anc_f) = ancestors[anc_i]; + (w + anc_w, f + anc_f) + }); + rate.implied_fee_wu(weight).saturating_sub(fee) +} + +impl SelectionProblem { + /// A problem with no unconfirmed ancestors. + /// + /// `candidates` are taken as-is. + pub fn new_no_ancestors( + target: Target, + candidates: impl IntoIterator, + ) -> Self { + let candidates: Vec = candidates.into_iter().collect(); + let n = candidates.len(); + Self { + target, + candidates, + ancestors: Vec::new(), + drags_in: (0..n).map(|_| Bitset::with_capacity(0)).collect(), + private: alloc::vec![(0, 0); n], + shared_drags_in: (0..n).map(|_| Bitset::with_capacity(0)).collect(), + has_private_ancestors: false, + has_shared_ancestors: false, + } + } + + /// Build candidates from input groups and the unconfirmed ancestors they may drag in. + /// + /// Each input group must be non-empty, and every `AncestorToBump::txid` must be unique. Supply + /// every unconfirmed residing transaction and transitive unconfirmed ancestor needed for + /// accurate pricing; absent ids are assumed confirmed. + /// + /// For each input group, the residing txids and their transitive parents (restricted to + /// `ancestors_to_bump`) form that candidate's `drags_in` set. Ancestors only one candidate can + /// reach are folded into [`private_ancestors`](Self::private_ancestors); the rest stay in + /// [`shared_drags_in`](Self::shared_drags_in) to be de-duplicated per selection. + pub fn new(target: Target, input_groups: G, ancestors_to_bump: A) -> Self + where + Txid: Copy + Ord + Eq, + G: IntoIterator, + G::Item: Into>, + A: IntoIterator, + A::Item: Into>, + { + let ancestors: Vec> = + ancestors_to_bump.into_iter().map(Into::into).collect(); + + let txid_to_anc: BTreeMap = ancestors + .iter() + .enumerate() + .map(|(i, a)| (a.txid, i)) + .collect(); + + let n_anc = ancestors.len(); + let anc_weight_fee: Vec<(u64, u64)> = ancestors.iter().map(|a| (a.weight, a.fee)).collect(); + let mut candidates = Vec::new(); + let mut drags_in = Vec::new(); + + for input_group in input_groups { + let mut cand = Candidate { + value: 0, + weight: 0, + segwit_count: 0, + legacy_count: 0, + }; + let mut dragged = Bitset::with_capacity(n_anc); + + for input in input_group.into() { + cand.value += input.value; + cand.weight += input.weight; + match input.is_segwit { + true => cand.segwit_count += 1, + false => cand.legacy_count += 1, + } + + let mut txid_stack = alloc::vec![input.residing_txid]; + while let Some(txid) = txid_stack.pop() { + if let Some(&anc_i) = txid_to_anc.get(&txid) { + if dragged.insert(anc_i) { + txid_stack.extend(ancestors[anc_i].parents.iter().copied()); + } + } + } + } + + candidates.push(cand); + drags_in.push(dragged); + } + + // An ancestor no other candidate can reach arrives exactly when this one is selected, so its + // weight and fee can be folded into the candidate now. The rest still have to be + // de-duplicated at selection time. + let mut reachable_by = alloc::vec![0_u32; n_anc]; + for dragged in &drags_in { + for anc_i in dragged.iter() { + reachable_by[anc_i] += 1; + } + } + let mut private = Vec::with_capacity(drags_in.len()); + let mut shared_drags_in = Vec::with_capacity(drags_in.len()); + let mut has_private_ancestors = false; + let mut has_shared_ancestors = false; + for dragged in &drags_in { + let mut private_weight_fee = (0_u64, 0_u64); + let mut shared = Bitset::with_capacity(n_anc); + for anc_i in dragged.iter() { + if reachable_by[anc_i] == 1 { + let (weight, fee) = anc_weight_fee[anc_i]; + private_weight_fee.0 += weight; + private_weight_fee.1 += fee; + has_private_ancestors = true; + } else { + shared.insert(anc_i); + has_shared_ancestors = true; + } + } + private.push(private_weight_fee); + shared_drags_in.push(shared); + } + + Self { + target, + candidates, + ancestors: anc_weight_fee, + drags_in, + private, + shared_drags_in, + has_private_ancestors, + has_shared_ancestors, + } + } + + /// What this problem is funding. + pub fn target(&self) -> Target { + self.target + } + + /// All candidates, in construction order. + pub fn candidates(&self) -> &[Candidate] { + &self.candidates + } + + /// Candidate at `index`. + pub fn candidate(&self, index: usize) -> Candidate { + self.candidates[index] + } + + /// Number of candidates. + pub fn len(&self) -> usize { + self.candidates.len() + } + + /// Whether there are no candidates. + pub fn is_empty(&self) -> bool { + self.candidates.is_empty() + } + + /// Ancestors as `(weight, fee)` pairs, in the order supplied to [`SelectionProblem::new`]. + /// + /// Supplied ancestors that no candidate reaches remain in this slice but are not charged. + pub fn ancestors(&self) -> &[(u64, u64)] { + &self.ancestors + } + + /// Whether any candidate drags in an unconfirmed ancestor. + /// + /// `false` means every fee calculation reduces to the plain (child-only) case, allowing branch + /// and bound to use its tighter no-ancestor bounds. + pub fn has_ancestors(&self) -> bool { + self.has_private_ancestors || self.has_shared_ancestors + } + + /// Ancestor indices dragged in by selecting candidate `index`. + pub fn drags_in(&self, index: usize) -> &Bitset { + &self.drags_in[index] + } + + /// Summed `(weight, fee)` of the ancestors only candidate `index` can drag in. + /// + /// Deliberately not reduced to a bump: the target rate applies to the total ancestor weight of + /// the whole selection at once, and an ancestor paying above the rate must be able to subsidize + /// one paying below it. See [`SelectionView::ancestor_bump`](crate::SelectionView::ancestor_bump). + pub fn private_ancestors(&self, index: usize) -> (u64, u64) { + self.private[index] + } + + /// [`drags_in`](Self::drags_in) restricted to the ancestors that several candidates can reach. + /// + /// Those are the only ones that can be dragged in twice over, so they are the only ones a + /// selection has to de-duplicate; the rest are folded into + /// [`private_ancestors`](Self::private_ancestors). + pub fn shared_drags_in(&self, index: usize) -> &Bitset { + &self.shared_drags_in[index] + } + + /// Whether any ancestor is reachable via exactly one candidate. + /// + /// When `false`, every ancestor is shared and [`private_ancestors`](Self::private_ancestors) is + /// `(0, 0)` throughout, so summing it can be skipped. + pub fn has_private_ancestors(&self) -> bool { + self.has_private_ancestors + } + + /// Whether any ancestor is reachable via more than one candidate. + /// + /// When `false`, what a selection owes is a plain sum over its selected candidates — nothing has + /// to be de-duplicated. + pub fn has_shared_ancestors(&self) -> bool { + self.has_shared_ancestors + } + + pub(crate) fn ancestor_fee_precision_slack(&self) -> u64 { + let rate = self.target.fee.rate.spwu() as f64; + if rate == 0.0 { + return 0; + } + let total_weight = self + .ancestors + .iter() + .fold(0_u64, |sum, (weight, _)| sum.saturating_add(*weight)); + let max_fee = total_weight as f64 * rate; + if max_fee <= (1_u64 << f32::MANTISSA_DIGITS) as f64 { + return 0; + } + // Conversion and multiplication each round in `implied_fee_wu`. Two f32 epsilons plus one + // satoshi conservatively cover their combined error for every ancestor subset. + ((max_fee * 2.0 * f32::EPSILON as f64).min(u64::MAX as f64) as u64).saturating_add(1) + } + + /// The fee still owed so the ancestors only this candidate would drag in meet + /// [`Target::fee`](crate::TargetFee)'s rate, as if it were the only selected candidate. + /// + /// Informational: must never be summed over a selection (shared ancestors would be charged + /// twice). What a selection owes is [`SelectionView::ancestor_bump`](crate::SelectionView::ancestor_bump). + pub fn local_bump(&self, index: usize) -> u64 { + bump_of(&self.ancestors, self.target.fee.rate, self.drags_in(index)) + } + + /// A [`CoinSelector`] over this problem. + pub fn selector(&self) -> CoinSelector<'_> { + CoinSelector::new(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FeeRate, TargetFee, TargetOutputs}; + + fn target(feerate_sat_vb: f32) -> Target { + Target { + fee: TargetFee { + rate: FeeRate::from_sat_per_vb(feerate_sat_vb), + absolute: 0, + replace: None, + }, + outputs: TargetOutputs { + value_sum: 0, + weight_sum: 0, + n_outputs: 0, + }, + max_weight: None, + } + } + + #[test] + fn no_ancestors_round_trip() { + let cands = [ + Candidate::new_segwit(100_000, 100), + Candidate::new_legacy(50_000, 200), + ]; + let p = SelectionProblem::new_no_ancestors(target(10.0), cands); + assert_eq!(p.len(), 2); + assert_eq!(p.candidate(0).value, 100_000); + assert_eq!(p.candidate(1).legacy_count, 1); + assert!(p.ancestors().is_empty()); + assert_eq!(p.local_bump(0), 0); + assert_eq!(p.local_bump(1), 0); + assert!(p.drags_in(0).is_empty()); + } + + #[test] + fn transitive_parents() { + // UTXO on C; C parents B; B parents A. All unconfirmed. + let ancestors = [ + AncestorToBump { + txid: "A", + weight: 400, + fee: 0, + parents: vec![], + }, + AncestorToBump { + txid: "B", + weight: 400, + fee: 0, + parents: vec!["A"], + }, + AncestorToBump { + txid: "C", + weight: 400, + fee: 0, + parents: vec!["B"], + }, + ]; + let inputs = [Input { + value: 10_000, + weight: 272, + is_segwit: true, + residing_txid: "C", + }]; + let p = SelectionProblem::new(target(10.0), inputs, ancestors); + assert_eq!(p.len(), 1); + let dragged: Vec<_> = p.drags_in(0).iter().collect(); + assert_eq!(dragged, vec![0, 1, 2]); // A, B, C + } + + #[test] + fn shared_ancestor_in_both_drags_in() { + let ancestors = [AncestorToBump { + txid: "P", + weight: 1_000, + fee: 0, + parents: vec![], + }]; + let inputs = [ + Input { + value: 10_000, + weight: 272, + is_segwit: true, + residing_txid: "P", + }, + Input { + value: 20_000, + weight: 272, + is_segwit: true, + residing_txid: "P", + }, + ]; + let p = SelectionProblem::new(target(10.0), inputs, ancestors); + assert!(p.drags_in(0).contains(0)); + assert!(p.drags_in(1).contains(0)); + assert_eq!(p.local_bump(0), p.local_bump(1)); + assert!(p.local_bump(0) > 0); + } + + #[test] + fn overpaying_ancestor_zero_bump() { + // weight 400 wu at 1 sat/vb => ~100 sats implied; fee already 10_000 + let ancestors = [AncestorToBump { + txid: "P", + weight: 400, + fee: 10_000, + parents: vec![], + }]; + let inputs = [Input { + value: 10_000, + weight: 272, + is_segwit: true, + residing_txid: "P", + }]; + let p = SelectionProblem::new(target(1.0), inputs, ancestors); + assert_eq!(p.local_bump(0), 0); + } + + #[test] + fn unknown_parent_ignored() { + let ancestors = [AncestorToBump { + txid: "child", + weight: 400, + fee: 0, + parents: vec!["confirmed_parent"], + }]; + let inputs = [Input { + value: 10_000, + weight: 272, + is_segwit: true, + residing_txid: "child", + }]; + let p = SelectionProblem::new(target(10.0), inputs, ancestors); + let dragged: Vec<_> = p.drags_in(0).iter().collect(); + assert_eq!(dragged, vec![0]); // only child + } +} diff --git a/src/selection_view.rs b/src/selection_view.rs new file mode 100644 index 0000000..200b448 --- /dev/null +++ b/src/selection_view.rs @@ -0,0 +1,995 @@ +//! Cached selection queries and hypothetical updates. + +use alloc::{borrow::Cow, vec::Vec}; +use core::ops::Deref; + +#[allow(unused)] +use crate::float::FloatExt; +use crate::{ + varint_size, Bitset, Candidate, ChangePolicy, CoinSelector, Drain, DrainWeights, FeeRate, + SelectionProblem, TargetOutputs, TX_FIXED_FIELD_WEIGHT, +}; + +/// Running aggregates used by branch and bound. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SelectionCache { + value_sum: u64, + weight_sum: u64, + segwit_count: usize, + legacy_count: usize, + private_weight: u64, + private_fee: u64, + shared_refcounts: Vec, + shared_weight: u64, + shared_fee: u64, + private_reachable_surplus: f64, + shared_reachable_refcounts: Vec, + shared_reachable_surplus: f64, + ancestor_fee_precision_slack: u64, + /// Value and weight of the still-undecided candidates worth selecting, i.e. those neither + /// selected nor banned whose standalone effective value is positive. Candidates that cost more + /// weight than they bring are left out because they only ever lower the total, so the pair + /// stays an upper bound on what the rest of this branch can still contribute. + undecided_value: u64, + undecided_weight: u64, + /// Undecided candidates that weigh nothing but carry value. They make every fee deficit + /// closable at zero added weight, so the bound must know whether any remain. + undecided_weightless_value: usize, + selected: Bitset, +} + +impl SelectionCache { + pub(crate) fn from_selector(selector: &CoinSelector<'_>) -> Self { + let mut cache = Self { + value_sum: 0, + weight_sum: 0, + segwit_count: 0, + legacy_count: 0, + private_weight: 0, + private_fee: 0, + shared_refcounts: alloc::vec![ + 0; + if selector.problem().has_shared_ancestors() { + selector.problem().ancestors().len() + } else { + 0 + } + ], + shared_weight: 0, + shared_fee: 0, + private_reachable_surplus: 0.0, + shared_reachable_refcounts: alloc::vec![ + 0; + if selector.problem().has_shared_ancestors() { + selector.problem().ancestors().len() + } else { + 0 + } + ], + shared_reachable_surplus: 0.0, + ancestor_fee_precision_slack: selector.problem().ancestor_fee_precision_slack(), + undecided_value: 0, + undecided_weight: 0, + undecided_weightless_value: 0, + // BnB transitions each candidate exactly once, so it needs no duplicate-tracking + // bitset. Public hypothetical updates allocate this lazily in `track_selected`. + selected: Bitset::default(), + }; + // Every candidate starts undecided and reachable; the two loops below then account for the + // ones that are already selected or already banned. + for (index, _) in selector.candidates() { + cache.add_reachable(selector.problem(), index); + } + for (index, candidate) in selector.selected() { + cache.add(selector.problem(), index, candidate, true); + } + for index in selector.banned().iter() { + if !selector.is_selected(index) { + cache.ban(selector.problem(), index); + } + } + cache + } + + fn ancestor_surplus(problem: &SelectionProblem, (weight, fee): (u64, u64)) -> f64 { + (fee as f64 - weight as f64 * problem.target().fee.rate.spwu() as f64).max(0.0) + } + + /// Whether this candidate brings in more value than its own weight costs at the target + /// feerate. One that doesn't can only ever lower a running total, so the undecided aggregate + /// leaves it out and stays an upper bound. + fn is_worth_selecting(problem: &SelectionProblem, index: usize) -> bool { + problem + .candidate(index) + .effective_value(problem.target().fee.rate) + > 0.0 + } + + fn add_reachable(&mut self, problem: &SelectionProblem, index: usize) { + if Self::is_worth_selecting(problem, index) { + let candidate = problem.candidate(index); + self.undecided_value += candidate.value; + self.undecided_weight += candidate.weight; + if candidate.weight == 0 && candidate.value > 0 { + self.undecided_weightless_value += 1; + } + } + if !problem.has_ancestors() { + return; + } + if problem.has_private_ancestors() { + self.private_reachable_surplus += + Self::ancestor_surplus(problem, problem.private_ancestors(index)); + } + if problem.has_shared_ancestors() { + for ancestor in problem.shared_drags_in(index).iter() { + if self.shared_reachable_refcounts[ancestor] == 0 + && self.shared_refcounts[ancestor] == 0 + { + self.shared_reachable_surplus += + Self::ancestor_surplus(problem, problem.ancestors()[ancestor]); + } + self.shared_reachable_refcounts[ancestor] += 1; + } + } + } + + fn remove_reachable(&mut self, problem: &SelectionProblem, index: usize) { + if Self::is_worth_selecting(problem, index) { + let candidate = problem.candidate(index); + self.undecided_value -= candidate.value; + self.undecided_weight -= candidate.weight; + if candidate.weight == 0 && candidate.value > 0 { + self.undecided_weightless_value -= 1; + } + } + if !problem.has_ancestors() { + return; + } + if problem.has_private_ancestors() { + self.private_reachable_surplus -= + Self::ancestor_surplus(problem, problem.private_ancestors(index)); + } + if problem.has_shared_ancestors() { + for ancestor in problem.shared_drags_in(index).iter() { + self.shared_reachable_refcounts[ancestor] -= 1; + if self.shared_reachable_refcounts[ancestor] == 0 + && self.shared_refcounts[ancestor] == 0 + { + self.shared_reachable_surplus -= + Self::ancestor_surplus(problem, problem.ancestors()[ancestor]); + } + } + } + } + + fn input_weight(&self) -> u64 { + let is_segwit_tx = self.segwit_count > 0; + let witness_header_extra_weight = is_segwit_tx as u64 * 2; + let input_count = self.segwit_count + self.legacy_count; + let input_varint_weight = varint_size(input_count) * 4; + let legacy_witness_lengths = is_segwit_tx as u64 * self.legacy_count as u64; + input_varint_weight + self.weight_sum + legacy_witness_lengths + witness_header_extra_weight + } + + pub(crate) fn add( + &mut self, + problem: &SelectionProblem, + index: usize, + candidate: Candidate, + was_reachable: bool, + ) { + if self.selected.capacity() > 0 && !self.selected.insert(index) { + return; + } + self.value_sum += candidate.value; + self.weight_sum += candidate.weight; + self.segwit_count += candidate.segwit_count; + self.legacy_count += candidate.legacy_count; + if was_reachable { + self.remove_reachable(problem, index); + } + + if problem.has_private_ancestors() { + let (weight, fee) = problem.private_ancestors(index); + self.private_weight += weight; + self.private_fee += fee; + } + if problem.has_shared_ancestors() { + for ancestor in problem.shared_drags_in(index).iter() { + if self.shared_refcounts[ancestor] == 0 { + let (weight, fee) = problem.ancestors()[ancestor]; + self.shared_weight += weight; + self.shared_fee += fee; + if self.shared_reachable_refcounts[ancestor] > 0 { + self.shared_reachable_surplus -= + Self::ancestor_surplus(problem, (weight, fee)); + } + } + self.shared_refcounts[ancestor] += 1; + } + } + } + + pub(crate) fn sub( + &mut self, + problem: &SelectionProblem, + index: usize, + candidate: Candidate, + is_addable: bool, + ) { + if self.selected.capacity() > 0 && !self.selected.remove(index) { + return; + } + self.value_sum -= candidate.value; + self.weight_sum -= candidate.weight; + self.segwit_count -= candidate.segwit_count; + self.legacy_count -= candidate.legacy_count; + + if problem.has_private_ancestors() { + let (weight, fee) = problem.private_ancestors(index); + self.private_weight -= weight; + self.private_fee -= fee; + } + if problem.has_shared_ancestors() { + for ancestor in problem.shared_drags_in(index).iter() { + self.shared_refcounts[ancestor] -= 1; + if self.shared_refcounts[ancestor] == 0 { + let (weight, fee) = problem.ancestors()[ancestor]; + self.shared_weight -= weight; + self.shared_fee -= fee; + if self.shared_reachable_refcounts[ancestor] > 0 { + self.shared_reachable_surplus += + Self::ancestor_surplus(problem, (weight, fee)); + } + } + } + } + if is_addable { + self.add_reachable(problem, index); + } + } + + pub(crate) fn ban(&mut self, problem: &SelectionProblem, index: usize) { + self.remove_reachable(problem, index); + } + + pub(crate) fn unban(&mut self, problem: &SelectionProblem, index: usize) { + self.add_reachable(problem, index); + } +} + +/// A cached view over a [`CoinSelector`] that supports hypothetical updates. +/// +/// [`add`](Self::add) and [`sub`](Self::sub) update this view's copy-on-write aggregates without +/// changing the underlying selector. Aggregate methods on `SelectionView` reflect those updates, +/// while [`selector`](Self::selector) and methods reached through [`Deref`] still reflect the base +/// selector's selected set. Branch and bound maintains the cache incrementally. For ad-hoc use, +/// [`CoinSelector::compute_view`] builds it from the current selection. +#[derive(Clone, Debug)] +pub struct SelectionView<'a> { + selector: &'a CoinSelector<'a>, + cache: Cow<'a, SelectionCache>, +} + +impl<'a> Deref for SelectionView<'a> { + type Target = CoinSelector<'a>; + + fn deref(&self) -> &Self::Target { + self.selector + } +} + +impl<'a> SelectionView<'a> { + pub(crate) fn with_cache(selector: &'a CoinSelector<'a>, cache: &'a SelectionCache) -> Self { + Self { + selector, + cache: Cow::Borrowed(cache), + } + } + + pub(crate) fn from_selector(selector: &'a CoinSelector<'a>) -> Self { + Self { + selector, + cache: Cow::Owned(SelectionCache::from_selector(selector)), + } + } + + /// The underlying selector, which is not changed by hypothetical view updates. + pub fn selector(&self) -> &'a CoinSelector<'a> { + self.selector + } + + fn track_selected(&mut self) { + if self.cache.selected.capacity() > 0 || self.selector.problem().is_empty() { + return; + } + let mut selected = Bitset::with_capacity(self.selector.problem().len()); + for (index, _) in self.selector.selected() { + selected.insert(index); + } + self.cache.to_mut().selected = selected; + } + + /// Apply a hypothetical selection to this view's cached aggregates. + /// + /// Does nothing if the candidate was already selected in the view. Aggregate query methods on + /// this view reflect the update; selection-set methods reached through [`Deref`] do not. + pub fn add(&mut self, index: usize) { + self.track_selected(); + let candidate = self.selector.candidate(index); + self.cache.to_mut().add( + self.selector.problem(), + index, + candidate, + !self.selector.banned().contains(index), + ); + } + + /// Apply a hypothetical deselection to this view's cached aggregates. + /// + /// Does nothing if the candidate was not selected in the view. Aggregate query methods on this + /// view reflect the update; selection-set methods reached through [`Deref`] do not. + pub fn sub(&mut self, index: usize) { + self.track_selected(); + let candidate = self.selector.candidate(index); + self.cache.to_mut().sub( + self.selector.problem(), + index, + candidate, + !self.selector.banned().contains(index), + ); + } + + pub(crate) fn add_unchecked(&mut self, index: usize) { + let candidate = self.selector.candidate(index); + self.cache + .to_mut() + .add(self.selector.problem(), index, candidate, true); + } + + pub(crate) fn sub_unchecked(&mut self, index: usize) { + let candidate = self.selector.candidate(index); + self.cache.to_mut().sub( + self.selector.problem(), + index, + candidate, + !self.selector.banned().contains(index), + ); + } + + /// Absolute value sum of selected inputs. + pub fn selected_value(&self) -> u64 { + self.cache.value_sum + } + + /// Input weight including the input-count varint and witness serialization overhead. + pub fn input_weight(&self) -> u64 { + self.cache.input_weight() + } + + /// Current child transaction weight. + pub fn weight(&self, target_outputs: TargetOutputs, drain_weights: DrainWeights) -> u64 { + TX_FIXED_FIELD_WEIGHT + + self.input_weight() + + target_outputs.output_weight_with_drain(drain_weights) + } + + /// Ancestor fee bump owed by the current selection, with shared ancestors charged once. + pub fn ancestor_bump(&self) -> u64 { + let weight = self.cache.private_weight + self.cache.shared_weight; + let fee = self.cache.private_fee + self.cache.shared_fee; + self.target() + .fee + .rate + .implied_fee_wu(weight) + .saturating_sub(fee) + } + + /// Whether any undecided candidate weighs nothing but carries value. + /// + /// Such a candidate closes any fee deficit at zero added weight, so no relaxation may claim a + /// deficit is unreachable while one remains. + pub(crate) fn has_weightless_undecided_value(&self) -> bool { + self.cache.undecided_weightless_value > 0 + } + + /// The greatest value-per-weight among undecided candidates, in `f64`. + /// + /// Candidates are ordered by *`f32`* value-per-weight, so the `f64` maximum can only lie inside + /// the run sharing the first undecided candidate's `f32` key: two exact ratios can tie in `f32` + /// and be ordered either way, and picking the lower one would overstate the weight a deficit + /// needs. Scanning that run rather than the whole tail keeps the exact answer while making the + /// query independent of the pool size, which matters because branch and bound asks it at every + /// unfunded node. + /// + /// Zero-weight candidates sort first (their ratio is infinite) and are skipped here; use + /// [`has_weightless_undecided_value`](Self::has_weightless_undecided_value) for those. + /// + /// Assumes the descending value-per-weight order that + /// [`BnbMetric::requires_ordering_by_descending_value_pwu`](crate::BnbMetric::requires_ordering_by_descending_value_pwu) + /// asks for; a debug assertion checks the result against a full scan. + pub(crate) fn best_undecided_value_pwu(&self) -> f64 { + let mut best = 0.0_f64; + let mut key: Option = None; + for (_, candidate) in self.unselected() { + if candidate.weight == 0 { + continue; + } + let candidate_key = crate::float::Ordf32(candidate.value_pwu()); + match key { + None => key = Some(candidate_key), + Some(first) if candidate_key != first => break, + _ => {} + } + best = best.max(candidate.value as f64 / candidate.weight as f64); + } + debug_assert_eq!( + best, + self.unselected() + .filter(|(_, c)| c.weight > 0) + .map(|(_, c)| c.value as f64 / c.weight as f64) + .fold(0.0_f64, f64::max), + "candidates are not in descending value-per-weight order, so the tie-run scan is wrong" + ); + best + } + + /// Lower bound on the ancestor bump owed by this branch or any descendant. + /// + /// Branch and bound maintains both selected obligations and still-reachable surplus in the + /// cache, so this query is constant-time. + pub fn ancestor_bump_lower_bound(&self) -> u64 { + if !self.selector.problem().has_ancestors() { + return 0; + } + + let spwu = self.target().fee.rate.spwu() as f64; + let owed = (self.cache.private_weight + self.cache.shared_weight) as f64 * spwu + - (self.cache.private_fee + self.cache.shared_fee) as f64; + let bound = + owed - self.cache.private_reachable_surplus - self.cache.shared_reachable_surplus; + if bound <= 0.0 { + 0 + } else { + (bound as u64).saturating_sub(self.cache.ancestor_fee_precision_slack) + } + } + + /// The most any descendant of this branch could still improve the feerate constraint. + /// + /// This is Bitcoin Core's `SelectCoinsBnB` lookahead (`curr_available_value`): the search keeps + /// a running total of what the undecided candidates can contribute, and a node whose total + /// still cannot close the gap has an empty subtree. Constant-time against the cache. + /// + /// Both terms are one-sided, so the result is an over-estimate and never prunes a branch that + /// holds a solution. The undecided pair counts only candidates worth selecting, and the current + /// ancestor bump is swapped for [`ancestor_bump_lower_bound`](Self::ancestor_bump_lower_bound), + /// which holds for this branch and every descendant — so a subsidizing ancestor that a + /// descendant might drag in is credited here rather than assumed away. The input-count varint + /// and witness overhead those candidates would add is ignored for the same reason: leaving it + /// out can only make this larger. + pub(crate) fn best_reachable_rate_excess_wu(&self) -> i64 { + self.rate_excess_wu(Drain::NONE) + self.ancestor_bump() as i64 + - self.ancestor_bump_lower_bound() as i64 + + self.cache.undecided_value as i64 + - self + .target() + .fee + .rate + .implied_fee_wu(self.cache.undecided_weight) as i64 + } + + fn implied_fee_from_feerate(&self, drain_weights: DrainWeights) -> u64 { + self.target() + .fee + .rate + .implied_fee(self.weight(self.target().outputs, drain_weights)) + + self.ancestor_bump() + } + + fn implied_fee_from_feerate_wu(&self, drain_weights: DrainWeights) -> u64 { + self.target() + .fee + .rate + .implied_fee_wu(self.weight(self.target().outputs, drain_weights)) + + self.ancestor_bump() + } + + /// Excess against all target fee constraints. + pub fn excess(&self, drain: Drain) -> i64 { + self.rate_excess(drain) + .min(self.absolute_excess(drain)) + .min(self.replacement_excess(drain)) + } + + /// Excess against the target feerate, including ancestor bumping. + pub fn rate_excess(&self, drain: Drain) -> i64 { + self.selected_value() as i64 + - self.target().value() as i64 + - drain.value as i64 + - self.implied_fee_from_feerate(drain.weights) as i64 + } + + /// Weight-unit version of [`rate_excess`](Self::rate_excess). + pub fn rate_excess_wu(&self, drain: Drain) -> i64 { + self.selected_value() as i64 + - self.target().value() as i64 + - drain.value as i64 + - self.implied_fee_from_feerate_wu(drain.weights) as i64 + } + + /// Excess against the absolute fee target. + pub fn absolute_excess(&self, drain: Drain) -> i64 { + self.selected_value() as i64 + - self.target().value() as i64 + - drain.value as i64 + - self.target().fee.absolute as i64 + } + + /// Excess against replacement rule 4. + pub fn replacement_excess(&self, drain: Drain) -> i64 { + let fee = self.target().fee.replace.map_or(0, |replace| { + replace.min_fee_to_do_replacement(self.weight(self.target().outputs, drain.weights)) + }); + self.selected_value() as i64 + - self.target().value() as i64 + - drain.value as i64 + - fee as i64 + } + + /// Weight-unit version of [`replacement_excess`](Self::replacement_excess). + pub fn replacement_excess_wu(&self, drain: Drain) -> i64 { + let fee = self.target().fee.replace.map_or(0, |replace| { + replace.min_fee_to_do_replacement_wu(self.weight(self.target().outputs, drain.weights)) + }); + self.selected_value() as i64 + - self.target().value() as i64 + - drain.value as i64 + - fee as i64 + } + + /// Whether the value and fee target is met with `drain`. + pub fn is_funded_with_drain(&self, drain: Drain) -> bool { + self.excess(drain) >= 0 + } + + /// Whether the value and fee target is met without change. + pub fn is_funded(&self) -> bool { + self.is_funded_with_drain(Drain::NONE) + } + + /// Whether the target appears reachable after adding every remaining candidate with positive + /// standalone effective value. + /// + /// The current selection is checked first because transaction framing can make adding a + /// standalone-positive candidate reduce actual excess. This remains a heuristic: framing and + /// ancestors make marginal effective values selection-dependent. + pub fn is_fundable(&self) -> bool { + if self.is_funded() { + return true; + } + let mut local = self.clone(); + local.track_selected(); + for (index, candidate) in self.selector.candidates() { + if !local.cache.selected.contains(index) + && !self.selector.banned().contains(index) + && candidate.effective_value(self.target().fee.rate) > 0.0 + { + local.add(index); + } + } + local.is_funded() + } + + /// Additional value needed to meet the target. + pub fn missing(&self) -> u64 { + let excess = self.excess(Drain::NONE); + if excess < 0 { + excess.unsigned_abs() + } else { + 0 + } + } + + /// Whether this child transaction fits its target weight cap. + pub fn is_within_max_weight(&self, drain_weights: DrainWeights) -> bool { + self.target().max_weight.map_or(true, |max_weight| { + self.weight(self.target().outputs, drain_weights) <= max_weight + }) + } + + /// Actual child fee for the supplied output values. + pub fn fee(&self, target_value: u64, drain_value: u64) -> i64 { + self.selected_value() as i64 - target_value as i64 - drain_value as i64 + } + + /// Fee required by all target fee constraints for this selection. + pub fn implied_fee(&self, drain_weights: DrainWeights) -> u64 { + let mut fee = self + .implied_fee_from_feerate(drain_weights) + .max(self.target().fee.absolute); + if let Some(replace) = self.target().fee.replace { + fee = fee.max( + replace + .min_fee_to_do_replacement(self.weight(self.target().outputs, drain_weights)), + ); + } + fee + } + + /// Child transaction feerate implied by the selection and outputs. + pub fn implied_feerate(&self, target_outputs: TargetOutputs, drain: Drain) -> Option { + let fee = + self.selected_value() as i64 - target_outputs.value_sum as i64 - drain.value as i64; + let weight = self.weight(target_outputs, drain.weights); + if fee < 0 || weight == 0 { + return None; + } + Some(FeeRate::from_sat_per_wu(fee as f32 / weight as f32)) + } + + /// A lower bound on the child fee for this branch and every descendant. + pub(crate) fn fee_floor(&self) -> u64 { + let weight = self.weight(self.target().outputs, DrainWeights::NONE); + let rate_floor = self + .target() + .fee + .rate + .implied_fee_wu(weight) + .min(self.target().fee.rate.implied_fee(weight)); + let mut floor = + (rate_floor + self.ancestor_bump_lower_bound()).max(self.target().fee.absolute); + if let Some(replace) = self.target().fee.replace { + floor = floor.max( + replace + .min_fee_to_do_replacement_wu(weight) + .min(replace.min_fee_to_do_replacement(weight)), + ); + } + floor + } + + /// The value of a policy-controlled change output, if one should be added. + pub fn drain_value(&self, change_policy: ChangePolicy) -> Option { + let excess = self.excess(Drain { + weights: change_policy.drain_weights, + value: 0, + }); + if excess > change_policy.min_value as i64 { + Some(excess as u64) + } else { + None + } + } + + /// A policy-controlled change output. + pub fn drain(&self, change_policy: ChangePolicy) -> Drain { + self.drain_value(change_policy) + .map_or(Drain::NONE, |value| Drain { + weights: change_policy.drain_weights, + value, + }) + } + + /// The current selection's effective value at `feerate`. + pub fn effective_value(&self, feerate: FeeRate) -> i64 { + self.selected_value() as i64 - (self.input_weight() as f32 * feerate.spwu()).ceil() as i64 + } + + /// Waste created by this selection. + pub fn waste(&self, long_term_feerate: FeeRate, drain: Drain, excess_discount: f32) -> f32 { + debug_assert!((0.0..=1.0).contains(&excess_discount)); + let mut waste = + self.input_weight() as f32 * (self.target().fee.rate.spwu() - long_term_feerate.spwu()); + if drain.is_none() { + waste += self.excess(drain).max(0) as f32 * excess_discount.clamp(0.0, 1.0); + } else { + waste += drain.weights.waste( + self.target().fee.rate, + long_term_feerate, + self.target().outputs.n_outputs, + ); + } + waste + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AncestorToBump, Input, Target, TargetFee, TargetOutputs}; + + fn target() -> Target { + Target { + fee: TargetFee::ZERO, + outputs: TargetOutputs { + value_sum: 0, + weight_sum: 0, + n_outputs: 0, + }, + max_weight: None, + } + } + + /// Straightforward iteration over the selected set, kept here as an independent reference for + /// the running aggregates the cache maintains. + fn expected_input_weight(selector: &CoinSelector<'_>) -> u64 { + let is_segwit_tx = selector.selected().any(|(_, c)| c.segwit_count > 0); + let input_count: usize = selector + .selected() + .map(|(_, c)| c.segwit_count + c.legacy_count) + .sum(); + let selected_weight: u64 = selector + .selected() + .map(|(_, c)| c.weight + is_segwit_tx as u64 * c.legacy_count as u64) + .sum(); + varint_size(input_count) * 4 + selected_weight + is_segwit_tx as u64 * 2 + } + + #[test] + fn shared_ancestor_is_cached_once_and_removed_on_last_sub() { + let ancestors = [AncestorToBump { + txid: 1, + weight: 400, + fee: 0, + parents: alloc::vec![], + }]; + let inputs = [ + Input { + value: 1_000, + weight: 100, + is_segwit: true, + residing_txid: 1, + }, + Input { + value: 2_000, + weight: 100, + is_segwit: false, + residing_txid: 1, + }, + ]; + let problem = SelectionProblem::new(target(), inputs, ancestors); + let selector = problem.selector(); + let mut view = selector.compute_view(); + view.add(0); + let once = view.ancestor_bump(); + view.add(1); + assert_eq!(view.ancestor_bump(), once); + view.sub(0); + assert_eq!(view.ancestor_bump(), once); + view.sub(1); + assert_eq!(view.ancestor_bump(), 0); + } + + #[test] + fn hypothetical_banned_candidate_does_not_change_reachability() { + let ancestors = [AncestorToBump { + txid: 1, + weight: 400, + fee: 1_000, + parents: alloc::vec![], + }]; + let inputs = [ + Input { + value: 1_000, + weight: 100, + is_segwit: true, + residing_txid: 1, + }, + Input { + value: 2_000, + weight: 100, + is_segwit: true, + residing_txid: 1, + }, + ]; + let problem = SelectionProblem::new(target(), inputs, ancestors); + let mut selector = problem.selector(); + selector.ban(0); + selector.ban(1); + let mut view = selector.compute_view(); + let bound = view.ancestor_bump_lower_bound(); + + view.add(0); + view.sub(0); + assert_eq!(view.ancestor_bump_lower_bound(), bound); + } + + #[test] + fn cache_matches_iteration_for_every_mixed_selection() { + let candidates = [ + Candidate::new_segwit(1_000, 100), + Candidate::new_legacy(2_000, 200), + Candidate { + value: 3_000, + weight: 350, + segwit_count: 2, + legacy_count: 3, + }, + ]; + let problem = SelectionProblem::new_no_ancestors(target(), candidates); + for mask in 0..1 << candidates.len() { + let mut selector = problem.selector(); + for index in 0..candidates.len() { + if mask & (1 << index) != 0 { + selector.select(index); + } + } + let view = selector.compute_view(); + assert_eq!( + view.selected_value(), + selector.selected().map(|(_, c)| c.value).sum::() + ); + assert_eq!(view.input_weight(), expected_input_weight(&selector)); + } + } + + #[test] + fn hypothetical_updates_have_set_semantics_without_ancestors() { + let candidates = [ + Candidate::new_segwit(1_000, 100), + Candidate::new_legacy(2_000, 200), + ]; + let problem = SelectionProblem::new_no_ancestors(target(), candidates); + let mut selector = problem.selector(); + selector.select(0); + let mut view = selector.compute_view(); + + let initial_value = view.selected_value(); + let initial_weight = view.input_weight(); + view.add(0); + assert_eq!(view.selected_value(), initial_value); + assert_eq!(view.input_weight(), initial_weight); + + view.sub(1); + assert_eq!(view.selected_value(), initial_value); + assert_eq!(view.input_weight(), initial_weight); + + view.sub(0); + view.sub(0); + assert_eq!(view.selected_value(), 0); + + view.add(1); + view.add(1); + assert_eq!(view.selected_value(), candidates[1].value); + assert_eq!(view.input_weight(), { + let mut expected = problem.selector(); + expected.select(1); + expected_input_weight(&expected) + }); + } + + #[test] + fn is_fundable_uses_hypothetical_selection_state() { + let mut target = target(); + target.outputs.value_sum = 2_000; + let candidates = [ + Candidate::new_segwit(1_000, 100), + Candidate::new_segwit(1_000, 100), + ]; + let problem = SelectionProblem::new_no_ancestors(target, candidates); + let mut selector = problem.selector(); + selector.select(0); + let mut view = selector.compute_view(); + + view.sub(0); + assert!(view.is_fundable()); + view.add(1); + assert!(view.is_fundable()); + } + + #[test] + fn is_fundable_never_rejects_an_already_funded_mixed_selection() { + let mut target = target(); + target.outputs.value_sum = 1_000; + target.fee = TargetFee::from_feerate(FeeRate::from_sat_per_vb(4.0)); + let candidates = [ + Candidate { + value: 1_201, + weight: 158, + segwit_count: 1, + legacy_count: 0, + }, + Candidate { + value: 165, + weight: 164, + segwit_count: 0, + legacy_count: 3, + }, + ]; + let problem = SelectionProblem::new_no_ancestors(target, candidates); + let mut selector = problem.selector(); + selector.select(0); + assert!(selector.compute_view().is_funded()); + + let mut all = selector.clone(); + all.select(1); + assert!(!all.compute_view().is_funded()); + assert!(selector.compute_view().is_fundable()); + } + + #[test] + fn hypothetical_ancestor_queries_match_selector_mutations() { + let mut target = target(); + target.fee = TargetFee::from_feerate(FeeRate::from_sat_per_vb(4.0)); + let ancestors = [ + AncestorToBump { + txid: 1, + weight: 400, + fee: 0, + parents: alloc::vec![], + }, + AncestorToBump { + txid: 2, + weight: 400, + fee: 800, + parents: alloc::vec![], + }, + ]; + let groups = [ + alloc::vec![ + Input { + value: 1_000, + weight: 100, + is_segwit: true, + residing_txid: 1, + }, + Input { + value: 1_000, + weight: 100, + is_segwit: false, + residing_txid: 2, + }, + ], + alloc::vec![Input { + value: 2_000, + weight: 100, + is_segwit: true, + residing_txid: 1, + }], + ]; + let problem = SelectionProblem::new(target, groups, ancestors); + let base = problem.selector(); + let mut actual = base.clone(); + let mut hypothetical = base.compute_view(); + + for index in 0..2 { + actual.select(index); + hypothetical.add(index); + assert_eq!( + hypothetical.ancestor_bump(), + actual.compute_view().ancestor_bump() + ); + assert_eq!( + hypothetical.ancestor_bump_lower_bound(), + actual.compute_view().ancestor_bump_lower_bound() + ); + assert_eq!( + hypothetical.excess(Drain::NONE), + actual.compute_view().excess(Drain::NONE) + ); + } + + actual.deselect(0); + hypothetical.sub(0); + assert_eq!( + hypothetical.ancestor_bump(), + actual.compute_view().ancestor_bump() + ); + assert_eq!( + hypothetical.ancestor_bump_lower_bound(), + actual.compute_view().ancestor_bump_lower_bound() + ); + + actual.ban(0); + hypothetical + .cache + .to_mut() + .ban(hypothetical.selector.problem(), 0); + assert_eq!( + hypothetical.ancestor_bump_lower_bound(), + actual.compute_view().ancestor_bump_lower_bound() + ); + } +} diff --git a/tests/ancestor.proptest-regressions b/tests/ancestor.proptest-regressions new file mode 100644 index 0000000..a9d66c7 --- /dev/null +++ b/tests/ancestor.proptest-regressions @@ -0,0 +1,9 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 89e69058dd34f3215542f3ec44fbccc338577a6bfab488505a5e83dc5c1e88f6 # shrinks to spec = AncestorProblemSpec { candidates: [(1000, 200, 0), (1000, 200, 4)], ancestors: [(200, 0, 0)], target_value: 10000, feerate: 1.0, max_weight: None } +cc a13749585a44ffd51e890b50e8c58d6fc505895069ea32e5f211cc18fb96eacc # shrinks to spec = AncestorProblemSpec { candidates: [(24030, 664, 0), (114687, 721, 1), (58432, 646, 3), (66845, 200, 1)], ancestors: [(200, 1117, 0), (1977, 0, 1)], target_value: 172127, feerate: 2.2867246, max_weight: None } +cc 0028100a2247a35fd2ec3aa9bace2d5c5062812f0378553aac4da2e7016bf5c1 # shrinks to spec = AncestorProblemSpec { candidates: [(104036, 200, 0), (1000, 200, 1), (132930, 200, 2)], ancestors: [(600, 0, 0), (200, 233, 0)], target_value: 237606, feerate: 1.2605457, max_weight: None } diff --git a/tests/ancestor.rs b/tests/ancestor.rs new file mode 100644 index 0000000..0683df7 --- /dev/null +++ b/tests/ancestor.rs @@ -0,0 +1,1030 @@ +#![allow(unused_imports)] +//! Coin selection over candidates that drag in unconfirmed ancestors (CPFP). +//! +//! The invariant under test is that a selection's fee obligation includes the bump owed by the +//! **union** of the ancestors its selected candidates drag in — each ancestor charged exactly once, +//! weights and fees netted over the union — and that `LowestFee` branch and bound stays correct +//! under the resulting non-monotone funding. + +mod common; + +use bdk_coin_select::{ + float::Ordf32, metrics::LowestFee, AncestorToBump, BnbMetric, Candidate, CoinSelector, Drain, + DrainWeights, FeeRate, Input, Replace, SelectionProblem, Target, TargetFee, TargetOutputs, + TX_FIXED_FIELD_WEIGHT, +}; +use proptest::prelude::*; + +/// Not a txid of any ancestor we pass in, so inputs residing on it are treated as confirmed. +const CONFIRMED: &str = "confirmed"; + +const P2WPKH_INPUT_WEIGHT: u64 = 272; + +fn target(feerate_sat_per_vb: f32, value: u64) -> Target { + Target { + fee: TargetFee { + rate: FeeRate::from_sat_per_vb(feerate_sat_per_vb), + absolute: 0, + replace: None, + }, + outputs: TargetOutputs { + value_sum: value, + weight_sum: 100, + n_outputs: 1, + }, + max_weight: None, + } +} + +fn input(value: u64, residing_txid: &'static str) -> Input<&'static str> { + Input { + value, + weight: P2WPKH_INPUT_WEIGHT, + is_segwit: true, + residing_txid, + } +} + +fn ancestor( + txid: &'static str, + weight: u64, + fee: u64, + parents: Vec<&'static str>, +) -> AncestorToBump<&'static str> { + AncestorToBump { + txid, + weight, + fee, + parents, + } +} + +fn metric() -> LowestFee { + LowestFee { + long_term_feerate: FeeRate::from_sat_per_vb(1.0), + dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), + drain_weights: DrainWeights::TR_KEYSPEND, + } +} + +/// The bump is charged on top of the child's own feerate obligation, so it eats exactly that much +/// excess relative to the same selection with nothing unconfirmed behind it. +#[test] +fn bump_is_charged_on_top_of_the_childs_own_fee() { + let t = target(10.0, 90_000); + // 1000 wu at 10 sat/vb (2.5 sat/wu) => 2500 sats owed, and the ancestor pays nothing. + let problem = + SelectionProblem::new(t, [input(100_000, "P")], [ancestor("P", 1_000, 0, vec![])]); + let mut cs = problem.selector(); + cs.select(0); + + assert_eq!(cs.compute_view().ancestor_bump(), 2_500); + + let no_ancestors = SelectionProblem::new_no_ancestors( + t, + [Candidate { + value: 100_000, + weight: P2WPKH_INPUT_WEIGHT, + segwit_count: 1, + legacy_count: 0, + }], + ); + let mut clean_cs = no_ancestors.selector(); + clean_cs.select(0); + + assert_eq!(clean_cs.compute_view().ancestor_bump(), 0); + assert_eq!( + cs.compute_view().weight(t.outputs, DrainWeights::NONE), + clean_cs + .compute_view() + .weight(t.outputs, DrainWeights::NONE) + ); + assert_eq!( + cs.compute_view().excess(Drain::NONE), + clean_cs.compute_view().excess(Drain::NONE) - 2_500, + "the bump is the only difference between the two selections" + ); + assert_eq!( + cs.compute_view().implied_fee(DrainWeights::NONE), + clean_cs.compute_view().implied_fee(DrainWeights::NONE) + 2_500 + ); +} + +/// An unconfirmed ancestor can cost more than the coin sitting on it is worth: funding is no longer +/// monotone in the selection. +#[test] +fn dragged_in_ancestor_can_unfund_a_selection() { + let t = target(10.0, 90_000); + let problem = SelectionProblem::new( + t, + [input(100_000, CONFIRMED), input(100_000, "P")], + // 100_000 wu at 2.5 sat/wu => 250_000 sats owed: far more than the coin is worth. + [ancestor("P", 100_000, 0, vec![])], + ); + + let mut clean_only = problem.selector(); + clean_only.select(0); + assert!(clean_only.compute_view().is_funded()); + + let mut both = problem.selector(); + both.select(0); + both.select(1); + assert!( + !both.compute_view().is_funded(), + "adding a coin with an expensive ancestor un-funds a funded selection" + ); +} + +/// A shared ancestor is paid for once, no matter how many selected candidates drag it in — summing +/// the per-candidate `local_bump` figures would pay for it twice. +#[test] +fn shared_ancestor_is_charged_once() { + let t = target(10.0, 10_000); + let problem = SelectionProblem::new( + t, + [input(50_000, "P"), input(60_000, "P")], + [ancestor("P", 1_000, 0, vec![])], + ); + + assert_eq!(problem.local_bump(0), 2_500); + assert_eq!(problem.local_bump(1), 2_500); + + let mut cs = problem.selector(); + cs.select(0); + cs.select(1); + + assert_eq!(cs.selected_ancestors().len(), 1); + assert_eq!(cs.compute_view().ancestor_bump(), 2_500); + assert_ne!( + cs.compute_view().ancestor_bump(), + problem.local_bump(0) + problem.local_bump(1) + ); +} + +/// Deselecting one of two candidates that share an ancestor keeps the ancestor: it is still dragged +/// in by the other one. +#[test] +fn deselecting_keeps_an_ancestor_another_candidate_still_drags_in() { + let t = target(10.0, 10_000); + let problem = SelectionProblem::new( + t, + [ + input(50_000, "P"), + input(60_000, "P"), + input(70_000, CONFIRMED), + ], + [ancestor("P", 1_000, 0, vec![])], + ); + let mut cs = problem.selector(); + + cs.select(0); + cs.select(1); + assert_eq!(cs.compute_view().ancestor_bump(), 2_500); + + cs.deselect(0); + assert_eq!( + cs.compute_view().ancestor_bump(), + 2_500, + "candidate 1 still drags in P" + ); + + cs.select(2); + assert_eq!( + cs.compute_view().ancestor_bump(), + 2_500, + "a confirmed coin drags in nothing" + ); + + cs.deselect(1); + assert_eq!( + cs.compute_view().ancestor_bump(), + 0, + "nothing selected drags in P anymore" + ); +} + +/// The whole transitive chain is charged, and fees are netted across it (not per ancestor). +#[test] +fn transitive_ancestors_are_netted_as_one_package() { + let t = target(10.0, 10_000); + let problem = SelectionProblem::new( + t, + [input(50_000, "B")], + [ + ancestor("A", 400, 0, vec![]), + ancestor("B", 400, 1_000, vec!["A"]), + ], + ); + let mut cs = problem.selector(); + cs.select(0); + + // Union: weight 800 => 2000 sats owed at 2.5 sat/wu, of which B already paid 1000. + assert_eq!(cs.selected_ancestors().len(), 2); + assert_eq!(cs.compute_view().ancestor_bump(), 1_000); +} + +/// Dragging in an ancestor that overpays *lowers* what the selection owes, because the deficit is +/// netted over the union. This is what makes a funded selection's fee a bad lower bound for its +/// descendants. +#[test] +fn overpaying_ancestor_offsets_an_underpaying_one() { + let t = target(1.0, 10_000); // 0.25 sat/wu + let problem = SelectionProblem::new( + t, + [input(50_000, "RICH"), input(50_000, "POOR")], + [ + ancestor("RICH", 400, 10_000, vec![]), + ancestor("POOR", 400, 0, vec![]), + ], + ); + + let mut poor_only = problem.selector(); + poor_only.select(1); + assert_eq!(poor_only.compute_view().ancestor_bump(), 100); + + let mut rich_only = problem.selector(); + rich_only.select(0); + assert_eq!( + rich_only.compute_view().ancestor_bump(), + 0, + "never credits the child" + ); + + let mut both = problem.selector(); + both.select(0); + both.select(1); + assert_eq!( + both.compute_view().ancestor_bump(), + 0, + "RICH's surplus covers POOR's deficit, so the superset owes less" + ); +} + +/// Ancestor weight is not part of the child transaction, so it must not count against +/// [`Target::max_weight`]. +#[test] +fn ancestor_weight_does_not_count_against_max_weight() { + let mut t = target(10.0, 10_000); + let heavy = 100_000; + let problem = SelectionProblem::new( + t, + [input(50_000, "P")], + [ancestor("P", heavy, heavy, vec![])], + ); + let mut cs = problem.selector(); + cs.select(0); + + let child_weight = cs.compute_view().weight(t.outputs, DrainWeights::NONE); + assert!(child_weight < heavy); + + t.max_weight = Some(child_weight); + let capped = SelectionProblem::new( + t, + [input(50_000, "P")], + [ancestor("P", heavy, heavy, vec![])], + ); + let mut capped_cs = capped.selector(); + capped_cs.select(0); + assert!(capped_cs + .compute_view() + .is_within_max_weight(DrainWeights::NONE)); +} + +/// Two coins of equal value and weight are *not* interchangeable when only one of them drags in an +/// ancestor, so branch and bound must not ban them as a group. +/// +/// Here the only fundable selection is the clean coin alone, and it sits *after* the coin with the +/// expensive ancestor in the search order (equal value-per-weight, so the sort is stable). If the +/// exclusion branch banned it along with its look-alike, the search would report no solution. +#[test] +fn look_alikes_with_different_ancestors_are_not_banned_together() { + let t = target(10.0, 90_000); + let problem = SelectionProblem::new( + t, + [input(100_000, "P"), input(100_000, CONFIRMED)], + [ancestor("P", 100_000, 0, vec![])], + ); + + assert_eq!(problem.candidate(0).value, problem.candidate(1).value); + assert_eq!(problem.candidate(0).weight, problem.candidate(1).weight); + + let mut cs = problem.selector(); + let (_score, _drain) = cs + .run_bnb(metric(), 100_000) + .expect("the clean coin funds the target on its own"); + + assert!(cs.is_selected(1)); + assert!(!cs.is_selected(0)); +} + +/// The bump has to be inside the fee the metric reports, not added on top of it. +#[test] +fn score_is_the_childs_fee_which_already_covers_the_bump() { + let t = target(10.0, 90_000); + let problem = + SelectionProblem::new(t, [input(100_000, "P")], [ancestor("P", 1_000, 0, vec![])]); + let mut cs = problem.selector(); + cs.select(0); + + let mut m = metric(); + let score = m.score(&cs.compute_view()).expect("funded"); + let drain = m.drain(&cs.compute_view()); + assert_eq!( + score, + Ordf32( + (cs.compute_view().fee(t.value(), drain.value) as u64 + + drain.weights.spend_fee(m.long_term_feerate)) as f32 + ) + ); + assert!( + cs.compute_view().fee(t.value(), drain.value) as u64 >= cs.compute_view().ancestor_bump(), + "a funded selection's child fee covers the bump" + ); +} + +// --- the bump lower bound used by `LowestFee`'s bound --- + +/// With nothing overpaying within reach, no descendant can owe less than this selection does, so the +/// lower bound is the full bump — the figure branch and bound gets to keep. +#[test] +fn bump_lower_bound_is_the_full_bump_when_nothing_overpays() { + let t = target(10.0, 10_000); + let problem = SelectionProblem::new( + t, + [ + input(50_000, "P"), + input(60_000, "Q"), + input(70_000, CONFIRMED), + ], + [ + ancestor("P", 1_000, 0, vec![]), + ancestor("Q", 2_000, 0, vec![]), + ], + ); + + let mut cs = problem.selector(); + cs.select(0); + assert_eq!(cs.compute_view().ancestor_bump(), 2_500); + assert_eq!( + cs.compute_view().ancestor_bump_lower_bound(), + 2_500, + "Q only ever adds to what is owed, so it cannot lower the floor" + ); + + // The reachable-but-unselected ancestors are exactly Q's. + let addable: Vec<_> = cs.addable_ancestors().iter().collect(); + assert_eq!(addable, vec![1]); +} + +/// A reachable ancestor that overpays is exactly what a descendant could use to owe less, so the +/// bound gives up precisely that surplus and no more. +#[test] +fn bump_lower_bound_gives_up_the_reachable_surplus() { + let t = target(1.0, 10_000); // 0.25 sat/wu + let problem = SelectionProblem::new( + t, + [input(50_000, "POOR"), input(50_000, "RICH")], + [ + ancestor("POOR", 4_000, 0, vec![]), // owes 1_000 + ancestor("RICH", 400, 10_000, vec![]), // overpays by 9_900 + ], + ); + + let mut cs = problem.selector(); + cs.select(0); + assert_eq!(cs.compute_view().ancestor_bump(), 1_000); + assert_eq!( + cs.compute_view().ancestor_bump_lower_bound(), + 0, + "RICH's 9_900 surplus swamps the 1_000 owed" + ); + + // Which is not pessimism: that descendant really does owe nothing. + let mut both = cs.clone(); + both.select(1); + assert_eq!(both.compute_view().ancestor_bump(), 0); +} + +/// Only the surplus actually within reach is given up. +#[test] +fn bump_lower_bound_only_credits_reachable_surplus() { + let t = target(1.0, 10_000); // 0.25 sat/wu + let problem = SelectionProblem::new( + t, + [input(50_000, "POOR"), input(50_000, "RICH")], + [ + ancestor("POOR", 4_000, 0, vec![]), // owes 1_000 + ancestor("RICH", 400, 1_100, vec![]), // overpays by 1_000 + ], + ); + + let mut cs = problem.selector(); + cs.select(0); + assert_eq!(cs.compute_view().ancestor_bump(), 1_000); + assert_eq!(cs.compute_view().ancestor_bump_lower_bound(), 0); + + // Ban the coin that would bring RICH in and the surplus is out of reach again. + let mut banned = cs.clone(); + banned.ban(1); + assert!(banned.addable_ancestors().is_empty()); + assert_eq!(banned.compute_view().ancestor_bump_lower_bound(), 1_000); + + // Likewise once there is nothing left to add. + let mut exhausted = cs.clone(); + exhausted.select(1); + assert!(exhausted.is_exhausted()); + assert_eq!( + exhausted.compute_view().ancestor_bump_lower_bound(), + exhausted.compute_view().ancestor_bump() + ); +} + +/// The whole point: the fee floor `LowestFee` bounds with actually charges for the ancestors. +#[test] +fn bound_credits_the_bump_when_nothing_overpays() { + let t = target(10.0, 90_000); + let problem = SelectionProblem::new( + t, + [input(100_000, "P"), input(100_000, CONFIRMED)], + [ancestor("P", 1_000, 0, vec![])], + ); + + let mut cs = problem.selector(); + cs.select(0); + + let child_fee = t + .fee + .rate + .implied_fee_wu(cs.compute_view().weight(t.outputs, DrainWeights::NONE)); + let bound = metric() + .bound(&cs.compute_view()) + .expect("within max_weight"); + assert!( + bound >= Ordf32((child_fee + 2_500) as f32), + "bound {} must charge the child's own fee ({}) plus the 2_500 bump", + bound, + child_fee + ); +} + +#[test] +fn bump_lower_bound_accounts_for_large_f32_fee_rounding() { + let t = target(172.0, 1_000); // exactly 43 sat/wu + let problem = SelectionProblem::new( + t, + [input(20_000_000, "P")], + [ancestor("P", 399_999, 0, vec![])], + ); + let mut cs = problem.selector(); + cs.select(0); + + assert!( + cs.compute_view().ancestor_bump_lower_bound() <= cs.compute_view().ancestor_bump(), + "the f64 relaxation must not exceed the f32 fee obligation" + ); + let view = cs.compute_view(); + assert!(view.ancestor_bump_lower_bound() <= view.ancestor_bump()); +} + +/// An ancestor only one candidate can reach is folded into that candidate up front; the rest are +/// left to be de-duplicated per selection. +#[test] +fn ancestors_are_split_into_private_and_shared() { + let t = target(10.0, 10_000); + let problem = SelectionProblem::new( + t, + [ + vec![input(50_000, "MINE")], + vec![input(50_000, "OURS")], + vec![input(50_000, "OURS")], + ], + [ + ancestor("MINE", 1_000, 7, vec![]), + ancestor("OURS", 2_000, 9, vec![]), + ], + ); + + assert!(problem.has_shared_ancestors()); + + // Candidate 0 is the only one that can reach MINE, so it is charged for it directly. + assert_eq!(problem.private_ancestors(0), (1_000, 7)); + assert!(problem.shared_drags_in(0).is_empty()); + + // OURS is reachable two ways, so it stays in the shared set for both. + assert_eq!(problem.private_ancestors(1), (0, 0)); + assert_eq!(problem.private_ancestors(2), (0, 0)); + assert_eq!( + problem.shared_drags_in(1).iter().collect::>(), + vec![1] + ); + assert_eq!( + problem.shared_drags_in(2).iter().collect::>(), + vec![1] + ); + + // Either way `drags_in` still describes the full truth. + assert_eq!(problem.drags_in(0).iter().collect::>(), vec![0]); + assert_eq!(problem.drags_in(1).iter().collect::>(), vec![1]); + + // And a problem where nothing is shared says so, which is what lets the bump skip + // de-duplication entirely. + let unshared = SelectionProblem::new( + t, + [input(50_000, "MINE")], + [ancestor("MINE", 1_000, 0, vec![])], + ); + assert!(!unshared.has_shared_ancestors()); + assert!(unshared.has_ancestors()); +} + +/// A funded node's bound must give up the surplus a descendant could still pick up — otherwise it +/// sits above that descendant's score. +#[test] +fn funded_bound_gives_up_reachable_surplus() { + let t = target(1.0, 10_000); // 0.25 sat/wu + let problem = SelectionProblem::new( + t, + [input(50_000, "POOR"), input(50_000, "RICH")], + [ + ancestor("POOR", 4_000, 0, vec![]), // owes 1_000 + ancestor("RICH", 400, 10_000, vec![]), // overpays by 9_900 + ], + ); + + let mut cs = problem.selector(); + cs.select(0); + assert!(cs.compute_view().is_funded()); + assert_eq!(cs.compute_view().ancestor_bump(), 1_000); + assert_eq!(cs.compute_view().ancestor_bump_lower_bound(), 0); + + let score = metric().score(&cs.compute_view()).unwrap(); + let bound = metric().bound(&cs.compute_view()).unwrap(); + assert!( + bound <= Ordf32(score.0 - 1_000.0), + "bound {} must sit at least the 1_000 surplus below score {}", + bound, + score + ); + + let mut both = cs.clone(); + both.select(1); + let both_score = metric().score(&both.compute_view()).unwrap(); + assert!( + bound <= both_score, + "bound {} above descendant score {}", + bound, + both_score + ); +} + +/// Subtracting two large `f32`s can round the bound upward. Surplus is therefore subtracted in +/// integer space before the result is converted to the metric's `f32` score. +#[test] +fn funded_bound_subtracts_surplus_before_float_conversion() { + let t = Target { + fee: TargetFee { + rate: FeeRate::from_sat_per_vb(20_000.0), // 5_000 sat/wu + absolute: 0, + replace: None, + }, + outputs: TargetOutputs { + value_sum: 0, + weight_sum: 100, + n_outputs: 1, + }, + max_weight: None, + }; + let problem = SelectionProblem::new( + t, + [ + Input { + value: 1_998_700_000, + weight: 0, + is_segwit: false, + residing_txid: "POOR", + }, + Input { + value: 0, + weight: 0, + is_segwit: false, + residing_txid: "RICH", + }, + ], + [ + ancestor("POOR", 400_000, 2_000_000, vec![]), // owes 1_998_000_000 + ancestor("RICH", 0, 1_998_000_000, vec![]), // cancels POOR exactly + ], + ); + let mut metric = LowestFee { + long_term_feerate: FeeRate::from_sat_per_vb(1.0), + dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), + drain_weights: DrainWeights::NONE, + }; + + let mut node = problem.selector(); + node.select(0); + assert_eq!(node.compute_view().ancestor_bump(), 1_998_000_000); + assert_eq!(node.compute_view().ancestor_bump_lower_bound(), 0); + let bound = metric.bound(&node.compute_view()).unwrap(); + + let mut descendant = node.clone(); + descendant.select(1); + let score = metric.score(&descendant.compute_view()).unwrap(); + assert_eq!(score, Ordf32(700_000.0)); + assert!(bound <= score, "bound {} above descendant {}", bound, score); +} + +/// Selecting everything can un-fund, but that must not make the bound claim the subtree is empty. +#[test] +fn unfunded_bound_does_not_claim_infeasibility() { + let t = target(10.0, 90_000); + let problem = SelectionProblem::new( + t, + [input(100_000, CONFIRMED), input(100_000, "P")], + [ancestor("P", 100_000, 0, vec![])], + ); + + let cs = problem.selector(); + assert!(!cs.compute_view().is_funded()); + assert!( + metric().bound(&cs.compute_view()).is_some(), + "an unfunded root with a live funded subset must not be pruned" + ); +} + +/// Existing package surplus can pay a later candidate's private deficit. Pricing that deficit as +/// the candidate's marginal cost would put the bound above the descendant's score. +#[test] +fn unfunded_bound_credits_selected_package_surplus() { + let t = target(1.0, 100_000); // 0.25 sat/wu + let problem = SelectionProblem::new( + t, + [input(60_000, "RICH"), input(50_000, "POOR")], + [ + ancestor("RICH", 400, 10_000, vec![]), // surplus 9_900 + ancestor("POOR", 4_000, 0, vec![]), // deficit 1_000 + ], + ); + + let mut node = problem.selector(); + node.select(0); + assert!(!node.compute_view().is_funded()); + + let bound = metric().bound(&node.compute_view()).unwrap(); + let mut descendant = node.clone(); + descendant.select(1); + let score = metric().score(&descendant.compute_view()).unwrap(); + assert!( + bound <= score, + "bound {} above package-subsidized descendant {}", + bound, + score + ); +} + +/// The absolute fee is already the final child fee floor; the resize must not add target-rate +/// marginal cost on top of it. +#[test] +fn unfunded_bound_does_not_double_count_absolute_fee() { + let mut t = target(1.0, 100_000); + t.fee.absolute = 5_000; + let problem = + SelectionProblem::new(t, [input(105_000, "P")], [ancestor("P", 4_000, 0, vec![])]); + + let root = problem.selector(); + let bound = metric().bound(&root.compute_view()).unwrap(); + let mut descendant = root.clone(); + descendant.select(0); + let score = metric().score(&descendant.compute_view()).unwrap(); + assert_eq!(score, Ordf32(5_000.0)); + assert!(bound <= score, "bound {} above descendant {}", bound, score); +} + +/// RBF rule 4 prices only child weight. Ancestor weight must not enter its effective value, and the +/// replacement floor must not be charged twice. +#[test] +fn unfunded_bound_does_not_double_count_rbf_fee() { + let mut t = target(1.0, 100_000); + t.fee.replace = Some(Replace { + fee: 5_000, + incremental_relay_feerate: FeeRate::from_sat_per_vb(1.0), + }); + let problem = + SelectionProblem::new(t, [input(105_104, "P")], [ancestor("P", 4_000, 0, vec![])]); + + let root = problem.selector(); + let bound = metric().bound(&root.compute_view()).unwrap(); + let mut descendant = root.clone(); + descendant.select(0); + let score = metric().score(&descendant.compute_view()).unwrap(); + assert_eq!(score, Ordf32(5_104.0)); + assert!(bound <= score, "bound {} above descendant {}", bound, score); +} + +/// Surplus cannot be cherry-picked: an ancestor arrives only by selecting a candidate, which drags +/// in that candidate's whole chain. So a coin whose parent overpays but whose grandparent does not +/// offers no way to owe less, and the bound must not pretend otherwise. +#[test] +fn bump_lower_bound_nets_ancestors_that_must_arrive_together() { + let t = target(1.0, 10_000); // 0.25 sat/wu + let problem = SelectionProblem::new( + t, + [input(50_000, "POOR"), input(50_000, "RICH")], + [ + ancestor("POOR", 4_000, 0, vec![]), // owes 1_000 + // Selecting the second coin brings RICH *and* its unpaid parent GRAN. + ancestor("GRAN", 8_000, 0, vec![]), // owes 2_000 + ancestor("RICH", 400, 10_000, vec!["GRAN"]), // overpays by 9_900 + ], + ); + + let mut cs = problem.selector(); + cs.select(0); + assert_eq!(cs.compute_view().ancestor_bump(), 1_000); + + // RICH's 9_900 surplus is real, but only comes with GRAN's 2_000 deficit: still a net surplus. + assert_eq!(cs.compute_view().ancestor_bump_lower_bound(), 0); + let mut both = cs.clone(); + both.select(1); + assert_eq!( + both.compute_view().ancestor_bump(), + 0, + "that descendant really owes nothing" + ); + + // Now make the chain's deficit outweigh the surplus. Crediting RICH alone would wrongly drop the + // bound to 0; netting the chain keeps the full bump. + let deep = SelectionProblem::new( + t, + [input(50_000, "POOR"), input(50_000, "RICH")], + [ + ancestor("POOR", 4_000, 0, vec![]), + ancestor("GRAN", 80_000, 0, vec![]), // owes 20_000 + ancestor("RICH", 400, 10_000, vec!["GRAN"]), + ], + ); + let mut cs = deep.selector(); + cs.select(0); + assert_eq!(cs.compute_view().ancestor_bump(), 1_000); + assert_eq!( + cs.compute_view().ancestor_bump_lower_bound(), + 1_000, + "taking RICH means taking GRAN, which costs far more than RICH's surplus is worth" + ); + + let mut both = cs.clone(); + both.select(1); + assert!( + both.compute_view().ancestor_bump() > 1_000, + "confirmed by the descendant, which owes more, not less" + ); +} + +/// An ancestor several candidates can reach cannot be tied to any one of them, so its surplus is +/// credited on its own rather than netted against a particular candidate's other ancestors. +#[test] +fn bump_lower_bound_credits_shared_surplus_on_its_own() { + let t = target(1.0, 10_000); // 0.25 sat/wu + let problem = SelectionProblem::new( + t, + [ + vec![input(50_000, "POOR")], + // Both of these reach RICH; the second also drags in its own expensive chain. + vec![input(50_000, "RICH")], + vec![input(50_000, "RICH"), input(50_000, "HEAVY")], + ], + [ + ancestor("POOR", 4_000, 0, vec![]), // owes 1_000 + ancestor("RICH", 400, 10_000, vec![]), // overpays by 9_900 + ancestor("HEAVY", 80_000, 0, vec![]), // owes 20_000 + ], + ); + + let mut cs = problem.selector(); + cs.select(0); + assert_eq!(cs.compute_view().ancestor_bump(), 1_000); + assert_eq!( + cs.compute_view().ancestor_bump_lower_bound(), + 0, + "RICH is reachable without HEAVY, so its surplus counts" + ); +} + +// --- randomized cross-checks --- + +/// Spec for a randomly generated ancestor problem. Indices are taken modulo the relevant length so +/// any combination of generated numbers describes a valid (acyclic) problem. +#[derive(Debug, Clone)] +struct AncestorProblemSpec { + /// `(value, weight, residing_txid_selector)` per candidate. + candidates: Vec<(u64, u64, usize)>, + /// `(weight, fee, parent_selector)` per unconfirmed ancestor. + ancestors: Vec<(u64, u64, usize)>, + target_value: u64, + feerate: f32, + max_weight: Option, +} + +impl AncestorProblemSpec { + fn build(&self) -> SelectionProblem { + let n_anc = self.ancestors.len(); + + let ancestors: Vec> = self + .ancestors + .iter() + .enumerate() + .map(|(i, &(weight, fee, parent_sel))| { + // Parents are strictly earlier ancestors (keeps the graph acyclic); selecting `i` + // itself means "no unconfirmed parent". + let parent = parent_sel % (i + 1); + AncestorToBump { + txid: i, + weight, + fee, + parents: if parent == i { vec![] } else { vec![parent] }, + } + }) + .collect(); + + let inputs: Vec> = self + .candidates + .iter() + .map(|&(value, weight, residing_sel)| Input { + value, + weight, + is_segwit: true, + // `n_anc` means the coin sits on a confirmed tx (no matching txid). + residing_txid: residing_sel % (n_anc + 1), + }) + .collect(); + + let mut t = target(self.feerate, self.target_value); + t.max_weight = self.max_weight; + SelectionProblem::new(t, inputs, ancestors) + } +} + +fn spec_strategy() -> impl Strategy { + ( + prop::collection::vec((1_000u64..200_000, 200u64..1_000, 0usize..8), 1..6), + prop::collection::vec((200u64..4_000, 0u64..3_000, 0usize..8), 0..4), + 10_000u64..400_000, + 1.0f32..30.0, + proptest::option::of(400u64..3_000), + ) + .prop_map( + |(candidates, ancestors, target_value, feerate, max_weight)| AncestorProblemSpec { + candidates, + ancestors, + target_value, + feerate, + max_weight, + }, + ) +} + +/// Independently computed bump for a selection, straight from the definition: union the ancestor +/// sets of the selected candidates, sum weight and fee over that union, and take the shortfall. +fn expected_bump(problem: &SelectionProblem, cs: &CoinSelector<'_>, feerate: FeeRate) -> u64 { + let mut union = std::collections::BTreeSet::new(); + for i in cs.selected_indices().iter() { + union.extend(problem.drags_in(i).iter()); + } + let (weight, fee) = union + .iter() + .map(|&i| problem.ancestors()[i]) + .fold((0u64, 0u64), |(w, f), (aw, af)| (w + aw, f + af)); + feerate.implied_fee_wu(weight).saturating_sub(fee) +} + +proptest! { + /// Every selection's bump must equal the union-derived figure — in particular it must never be + /// the sum of the per-candidate `local_bump`s when ancestors are shared. + #[test] + fn bump_matches_union_definition(spec in spec_strategy()) { + let problem = spec.build(); + let feerate = problem.target().fee.rate; + let cs = problem.selector(); + + prop_assert_eq!(cs.compute_view().ancestor_bump(), expected_bump(&problem, &cs, feerate)); + + for (node, _) in common::ExhaustiveIter::new(&cs).into_iter().flatten() { + prop_assert_eq!( + node.compute_view().ancestor_bump(), + expected_bump(&problem, &node, feerate), + "selection={}", node + ); + } + } + + /// The bump lower bound must hold for the whole subtree, which is what lets the fee floor credit + /// it: no selection reachable from a node may owe less than the node's bound says. + #[test] + fn bump_lower_bound_holds_for_every_descendant(spec in spec_strategy()) { + let problem = spec.build(); + let root = problem.selector(); + + let nodes = std::iter::once(root.clone()).chain( + common::ExhaustiveIter::new(&root) + .into_iter() + .flatten() + .map(|(node, _)| node), + ); + + for node in nodes { + let lower_bound = node.compute_view().ancestor_bump_lower_bound(); + prop_assert!( + lower_bound <= node.compute_view().ancestor_bump(), + "node={} lb={} owes={}", node, lower_bound, node.compute_view().ancestor_bump() + ); + + for (descendant, inclusion) in common::ExhaustiveIter::new(&node).into_iter().flatten() { + if !inclusion { + continue; + } + prop_assert!( + lower_bound <= descendant.compute_view().ancestor_bump(), + "node={} lb={} descendant={} owes={}", + node, lower_bound, descendant, descendant.compute_view().ancestor_bump() + ); + } + } + } + + /// The bound must never exceed the score of any selection in its subtree (else branch and bound + /// can prune the optimum), and `None` must really mean "nothing in this subtree is valid". + #[test] + fn bound_is_admissible_with_ancestors(spec in spec_strategy()) { + let problem = spec.build(); + let mut metric = metric(); + + let mut root = problem.selector(); + if metric.requires_ordering_by_descending_value_pwu() { + root.sort_candidates_by_descending_value_pwu(); + } + + let nodes = std::iter::once(root.clone()).chain( + common::ExhaustiveIter::new(&root) + .into_iter() + .flatten() + .map(|(node, _)| node), + ); + + for node in nodes { + let bound = metric.bound(&node.compute_view()); + let subtree = std::iter::once(node.clone()).chain( + common::ExhaustiveIter::new(&node) + .into_iter() + .flatten() + .filter(|(_, inclusion)| *inclusion) + .map(|(descendant, _)| descendant), + ); + + for descendant in subtree { + let score = metric.score(&descendant.compute_view()); + match bound { + Some(lb) => if let Some(score) = score { + prop_assert!( + score >= lb, + "bound too tight: node={} lb={} descendant={} score={}", + node, lb, descendant, score + ); + }, + None => prop_assert!( + score.is_none(), + "pruned a subtree with a solution: node={} descendant={} score={:?}", + node, descendant, score + ), + } + } + } + } + + /// With unlimited rounds, branch and bound must land on the same optimum as brute force — both + /// the score and the feasibility verdict. + #[test] + fn bnb_finds_the_brute_force_optimum(spec in spec_strategy()) { + let problem = spec.build(); + + let mut exhaustive_cs = problem.selector(); + let mut exhaustive_metric = metric(); + let expected = common::exhaustive_search(&mut exhaustive_cs, &mut exhaustive_metric); + + let mut bnb_cs = problem.selector(); + let found = common::bnb_search(&mut bnb_cs, metric(), usize::MAX); + + match (expected, found) { + (Some((expected_score, _)), Ok((score, _))) => { + prop_assert_eq!(score, expected_score, "bnb={} exhaustive={}", bnb_cs, exhaustive_cs); + } + (None, Err(_)) => {} + (expected, found) => prop_assert!( + false, + "disagreement: exhaustive={:?} bnb={:?}", + expected.map(|(score, _)| score), + found.map(|(score, _)| score), + ), + } + } + +} diff --git a/tests/bnb.rs b/tests/bnb.rs index 45a22dc..080c9e1 100644 --- a/tests/bnb.rs +++ b/tests/bnb.rs @@ -1,6 +1,7 @@ mod common; use bdk_coin_select::{ - float::Ordf32, BnbMetric, Candidate, CoinSelector, Drain, Target, TargetFee, TargetOutputs, + float::Ordf32, BnbMetric, Candidate, CoinSelector, Drain, SelectionProblem, SelectionView, + Target, TargetFee, TargetOutputs, }; #[macro_use] extern crate alloc; @@ -11,16 +12,16 @@ use proptest::{prelude::*, proptest, test_runner::*}; fn test_wv(mut rng: impl RngCore) -> impl Iterator { core::iter::repeat_with(move || { let value = rng.random_range(0..1_000); - let mut candidate = Candidate { + let candidate = Candidate { value, weight: 100, - input_count: rng.random_range(1..2), - is_segwit: rng.random_bool(0.5), + segwit_count: rng.random_range(1..2), + legacy_count: 0, }; - // HACK: set is_segwit = true for all these tests because you can't actually lower bound - // things easily with how segwit inputs interfere with their weights. We can't modify the - // above since that would change what we pull from rng. - candidate.is_segwit = true; + // Keep drawing the bool these tests always drew so the rng stream (and therefore the + // generated cases) is unchanged. All candidates are segwit: mixing in legacy inputs makes + // their weights context-dependent, which these tests can't lower-bound easily. + let _ = rng.random_bool(0.5); candidate }) } @@ -32,8 +33,8 @@ struct MinExcessThenWeight; const EXCESS_RATIO: f32 = 1_000_000_f32; impl BnbMetric for MinExcessThenWeight { - fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { - let excess = cs.excess(target, Drain::NONE); + fn score(&mut self, cs: &SelectionView<'_>) -> Option { + let excess = cs.excess(Drain::NONE); if excess < 0 { None } else { @@ -43,13 +44,13 @@ impl BnbMetric for MinExcessThenWeight { } } - fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { - let mut cs = cs.clone(); - cs.select_until_target_met(target).ok()?; - Some(Ordf32(cs.input_weight() as f32)) + fn bound(&mut self, cs: &SelectionView<'_>) -> Option { + let mut cs = cs.selector().clone(); + cs.select_until_target_met().ok()?; + Some(Ordf32(cs.compute_view().input_weight() as f32)) } - fn drain(&mut self, _cs: &CoinSelector<'_>, _target: Target) -> Drain { + fn drain(&mut self, _cs: &SelectionView<'_>) -> Drain { Drain::NONE } } @@ -62,26 +63,15 @@ fn bnb_finds_an_exact_solution_in_n_iter() { let num_additional_canidates = 12; let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); - let mut wv = test_wv(&mut rng).map(|mut candidate| { - candidate.is_segwit = true; - candidate - }); + let mut wv = test_wv(&mut rng); let solution: Vec = (0..solution_len).map(|_| wv.next().unwrap()).collect(); - let solution_weight = { - let mut cs = CoinSelector::new(&solution); - cs.select_all(); - cs.input_weight() - }; - let target_value = solution.iter().map(|c| c.value).sum(); - let mut candidates = solution; + let mut candidates = solution.clone(); candidates.extend(wv.take(num_additional_canidates)); candidates.sort_unstable_by_key(|wv| core::cmp::Reverse(wv.value)); - let cs = CoinSelector::new(&candidates); - let target = Target { outputs: TargetOutputs { value_sum: target_value, @@ -93,7 +83,16 @@ fn bnb_finds_an_exact_solution_in_n_iter() { max_weight: None, }; - let solutions = cs.bnb_solutions(target, MinExcessThenWeight); + let solution_weight = { + let problem = SelectionProblem::new_no_ancestors(target, solution.iter().copied()); + let mut cs = CoinSelector::new(&problem); + cs.select_all(); + cs.compute_view().input_weight() + }; + + let problem_2 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let cs = CoinSelector::new(&problem_2); + let solutions = cs.bnb_solutions(MinExcessThenWeight); let mut rounds = 0; let (best, score) = solutions @@ -103,9 +102,14 @@ fn bnb_finds_an_exact_solution_in_n_iter() { .last() .expect("it found a solution"); - assert_eq!(rounds, 3194); - assert_eq!(best.input_weight(), solution_weight); - assert_eq!(best.selected_value(), target_value, "score={:?}", score); + assert_eq!(rounds, 62453); + assert_eq!(best.compute_view().input_weight(), solution_weight); + assert_eq!( + best.compute_view().selected_value(), + target_value, + "score={:?}", + score + ); } #[test] @@ -116,8 +120,6 @@ fn bnb_finds_solution_if_possible_in_n_iter() { let wv = test_wv(&mut rng); let candidates = wv.take(num_inputs).collect::>(); - let cs = CoinSelector::new(&candidates); - let target = Target { outputs: TargetOutputs { value_sum: target_value, @@ -128,7 +130,9 @@ fn bnb_finds_solution_if_possible_in_n_iter() { max_weight: None, }; - let solutions = cs.bnb_solutions(target, MinExcessThenWeight); + let problem_3 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let cs = CoinSelector::new(&problem_3); + let solutions = cs.bnb_solutions(MinExcessThenWeight); let mut rounds = 0; let (sol, _score) = solutions @@ -138,11 +142,44 @@ fn bnb_finds_solution_if_possible_in_n_iter() { .last() .expect("found a solution"); - assert_eq!(rounds, 164); - let excess = sol.excess(target, Drain::NONE); + assert_eq!(rounds, 95); + let excess = sol.compute_view().excess(Drain::NONE); assert_eq!(excess, 0); } +#[test] +fn exclusion_cursor_skips_preselected_equivalent_candidate() { + let candidates = [ + Candidate::new_legacy(500, 100), + Candidate::new_legacy(500, 100), + Candidate::new_legacy(400, 100), + ]; + let target = Target { + outputs: TargetOutputs { + value_sum: 900, + weight_sum: 0, + n_outputs: 1, + }, + fee: TargetFee::ZERO, + max_weight: None, + }; + let problem = SelectionProblem::new_no_ancestors(target, candidates); + let mut selector = problem.selector(); + selector.select(1); + + selector + .run_bnb(MinExcessThenWeight, 1_000) + .expect("must find a solution"); + + assert_eq!( + selector.selected_indices().iter().collect::>(), + vec![1, 2] + ); + for (index, _) in selector.selected() { + assert!(!selector.banned().contains(index)); + } +} + proptest! { #[test] #[cfg(not(debug_assertions))] // too slow if compiling for debug @@ -150,19 +187,19 @@ proptest! { let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); let wv = test_wv(&mut rng); let candidates = wv.take(num_inputs).collect::>(); - let cs = CoinSelector::new(&candidates); let target = Target { outputs: TargetOutputs { value_sum: target_value, weight_sum: 0, n_outputs: 1 }, fee: TargetFee::ZERO, max_weight: None, }; - - let solutions = cs.bnb_solutions(target, MinExcessThenWeight); + let problem_4 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let cs = CoinSelector::new(&problem_4); + let solutions = cs.bnb_solutions(MinExcessThenWeight); match solutions.enumerate().filter_map(|(i, sol)| Some((i, sol?))).last() { - Some((_i, (sol, _score))) => assert!(sol.selected_value() >= target_value), - _ => prop_assert!(!cs.is_fundable(target)), + Some((_i, (sol, _score))) => assert!(sol.compute_view().selected_value() >= target_value), + _ => prop_assert!(!cs.compute_view().is_fundable()), } } @@ -177,20 +214,27 @@ proptest! { let mut wv = test_wv(&mut rng); let solution: Vec = (0..solution_len).map(|_| wv.next().unwrap()).collect(); - let solution_weight = { - let mut cs = CoinSelector::new(&solution); - cs.select_all(); - cs.input_weight() - }; - let target_value = solution.iter().map(|c| c.value).sum(); - let mut candidates = solution; + let mut candidates = solution.clone(); candidates.extend(wv.take(num_additional_canidates)); - let mut cs = CoinSelector::new(&candidates); + let target = Target { + outputs: TargetOutputs { value_sum: target_value, weight_sum: 0, n_outputs: 1 }, + // we're trying to find an exact selection value so set fees to 0 + fee: TargetFee::ZERO, + max_weight: None, + }; + let solution_weight = { + let problem_5 = SelectionProblem::new_no_ancestors(target, solution.iter().copied()); + let mut cs = CoinSelector::new(&problem_5); + cs.select_all(); + cs.compute_view().input_weight() + }; + let problem_6 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_6); for i in 0..num_preselected.min(solution_len) { cs.select(i); } @@ -198,14 +242,7 @@ proptest! { // sort in descending value cs.sort_candidates_by_key(|(_, wv)| core::cmp::Reverse(wv.value)); - let target = Target { - outputs: TargetOutputs { value_sum: target_value, weight_sum: 0, n_outputs: 1 }, - // we're trying to find an exact selection value so set fees to 0 - fee: TargetFee::ZERO, - max_weight: None, - }; - - let solutions = cs.bnb_solutions(target, MinExcessThenWeight); + let solutions = cs.bnb_solutions(MinExcessThenWeight); let (_i, (best, _score)) = solutions .enumerate() @@ -213,7 +250,7 @@ proptest! { .last() .expect("it found a solution"); - prop_assert!(best.input_weight() <= solution_weight); - prop_assert_eq!(best.selected_value(), target.value()); + prop_assert!(best.compute_view().input_weight() <= solution_weight); + prop_assert_eq!(best.compute_view().selected_value(), target.value()); } } diff --git a/tests/changeless.proptest-regressions b/tests/changeless.proptest-regressions deleted file mode 100644 index 97089e2..0000000 --- a/tests/changeless.proptest-regressions +++ /dev/null @@ -1,9 +0,0 @@ -# Seeds for failure cases proptest has generated in the past. It is -# automatically read and these particular cases re-run before any -# novel cases are generated. -# -# It is recommended to check this file in to source control so that -# everyone who runs the test benefits from these saved cases. -cc b03fc0267d15cf4455c7f00feed18d1ba82a783a38bf689dacdd572356013877 # shrinks to num_inputs = 7, target = 1277, feerate = 1.0, min_fee = 177, base_weight = 0, long_term_feerate_diff = 0.0, change_weight = 1, change_spend_weight = 1 -cc 2ba2cfa2412c0f3c9de4eb35caeefa1a086797f5c3f5fc0528396cc90a85d993 # shrinks to num_inputs = 5, target = 908, feerate = 7.823237, replace = None, base_weight = 222, long_term_feerate_diff = 2.4063222, change_weight = 14, change_spend_weight = 14 -cc fc2f2211d811690b78ca4206be874e5c3e99727626f79306bbdd25d59df9c27b # shrinks to num_inputs = 10, target = 3821, feerate = 4.104783, replace = None, base_weight = 321, long_term_feerate_diff = 2.7914581, change_weight = 1, change_spend_weight = 1 diff --git a/tests/changeless.rs b/tests/changeless.rs deleted file mode 100644 index aac10a3..0000000 --- a/tests/changeless.rs +++ /dev/null @@ -1,104 +0,0 @@ -#![allow(unused)] -mod common; -use bdk_coin_select::{ - float::Ordf32, - metrics::{Changeless, LowestFee}, - Candidate, CoinSelector, DrainWeights, FeeRate, Target, TargetFee, TargetOutputs, -}; -use proptest::{prelude::*, proptest, test_runner::*}; -use rand::{prelude::IteratorRandom, Rng, RngCore}; - -fn test_wv(mut rng: impl RngCore) -> impl Iterator { - core::iter::repeat_with(move || { - let value = rng.random_range(0..1_000); - Candidate { - value, - weight: rng.random_range(0..100), - input_count: rng.random_range(1..2), - is_segwit: false, - } - }) -} - -proptest! { - #![proptest_config(ProptestConfig { - ..Default::default() - })] - - #[test] - #[cfg(not(debug_assertions))] // too slow if compiling for debug - fn compare_against_benchmarks( - n_candidates in 0..15_usize, // candidates (n) - target_value in 500..1_000_000_u64, // target value (sats) - n_target_outputs in 1..150_usize, // the number of outputs we're funding - target_weight in 0..10_000_u32, // the sum of the weight of the outputs (wu) - replace in common::maybe_replace(0..10_000u64), // The weight of the transaction we're replacing - feerate in 1.0..100.0_f32, // feerate (sats/vb) - feerate_lt_diff in -5.0..50.0_f32, // longterm feerate diff (sats/vb) - drain_weight in 100..=500_u32, // drain weight (wu) - drain_spend_weight in 1..=2000_u32, // drain spend weight (wu) - drain_dust in 100..=1000_u64, // drain dust (sats) - n_drain_outputs in 1..150usize, // the number of drain outputs - ) { - println!("======================================="); - let start = std::time::Instant::now(); - let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); - let feerate = FeeRate::from_sat_per_vb(feerate); - let drain_weights = DrainWeights { - output_weight: drain_weight as u64, - spend_weight: drain_spend_weight as u64, - n_outputs: n_drain_outputs, - }; - - let wv = test_wv(&mut rng); - let candidates = wv.take(n_candidates).collect::>(); - - let cs = CoinSelector::new(&candidates); - - let target = Target { - outputs: TargetOutputs { - n_outputs: n_target_outputs, - value_sum: target_value, - weight_sum: target_weight as u64, - }, - fee: TargetFee { - rate: feerate, - replace, - ..TargetFee::ZERO - }, - max_weight: None, - }; - - let make_metric = || { - Changeless(LowestFee { - long_term_feerate: feerate, - dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), - drain_weights, - }) - }; - - let solutions = cs.bnb_solutions(target, make_metric()); - - println!("candidates: {:#?}", cs.candidates().collect::>()); - - let best = solutions - .enumerate() - .filter_map(|(i, sol)| Some((i, sol?))) - .last(); - - - match best { - Some((_i, (_sol, _score))) => { - /* there is nothing to check about a changeless solution */ - } - None => { - let mut cs = cs.clone(); - let mut metric = make_metric(); - let has_solution = common::exhaustive_search(&mut cs, target, &mut metric).is_some(); - dbg!(format!("{}", cs)); - assert!(!has_solution); - } - } - dbg!(start.elapsed()); - } -} diff --git a/tests/common.rs b/tests/common.rs index 273b830..acfc2aa 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -2,7 +2,7 @@ use bdk_coin_select::{ float::Ordf32, metrics::LowestFee, BnbMetric, Candidate, CoinSelector, Drain, DrainWeights, - FeeRate, NoBnbSolution, Replace, Target, TargetFee, TargetOutputs, + FeeRate, NoBnbSolution, Replace, SelectionProblem, Target, TargetFee, TargetOutputs, }; use proptest::{ prelude::*, @@ -51,7 +51,8 @@ where let target = params.target(); - let mut selection = CoinSelector::new(&candidates); + let problem = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut selection = CoinSelector::new(&problem); let mut exp_selection = selection.clone(); if metric.requires_ordering_by_descending_value_pwu() { @@ -61,8 +62,8 @@ where println!("\texhaustive search:"); let now = std::time::Instant::now(); - let exp_result = exhaustive_search(&mut exp_selection, target, &mut metric); - let exp_change = metric.drain(&exp_selection, target); + let exp_result = exhaustive_search(&mut exp_selection, &mut metric); + let exp_change = metric.drain(&exp_selection.compute_view()); let exp_result_str = result_string(&exp_result.ok_or("no possible solution"), exp_change); println!( "\t\telapsed={:8}s result={}", @@ -71,14 +72,17 @@ where ); // bonus check: ensure replacement fee is respected if exp_result.is_some() { - let selected_value = exp_selection.selected_value(); - let drain = metric.drain(&exp_selection, target); + let selected_value = exp_selection.compute_view().selected_value(); + let drain = metric.drain(&exp_selection.compute_view()); let target_value = target.value(); let replace_fee = params .replace .map(|replace| { - replace - .min_fee_to_do_replacement(exp_selection.weight(target.outputs, drain.weights)) + replace.min_fee_to_do_replacement( + exp_selection + .compute_view() + .weight(target.outputs, drain.weights), + ) }) .unwrap_or(0); assert!(selected_value - target_value - drain.value >= replace_fee); @@ -87,8 +91,8 @@ where println!("\tbranch and bound:"); let now = std::time::Instant::now(); let mut bnb_metric = metric.clone(); - let result = bnb_search(&mut selection, target, metric, usize::MAX); - let change = bnb_metric.drain(&selection, target); + let result = bnb_search(&mut selection, metric, usize::MAX); + let change = bnb_metric.drain(&selection.compute_view()); let result_str = result_string(&result, change); println!( "\t\telapsed={:8}s result={}", @@ -111,14 +115,17 @@ where ); // bonus check: ensure replacement fee is respected - let selected_value = selection.selected_value(); - let drain = bnb_metric.drain(&selection, target); + let selected_value = selection.compute_view().selected_value(); + let drain = bnb_metric.drain(&selection.compute_view()); let target_value = target.value(); let replace_fee = params .replace .map(|replace| { - replace - .min_fee_to_do_replacement(selection.weight(target.outputs, drain.weights)) + replace.min_fee_to_do_replacement( + selection + .compute_view() + .weight(target.outputs, drain.weights), + ) }) .unwrap_or(0); assert!(selected_value - target_value - drain.value >= replace_fee); @@ -147,8 +154,9 @@ where let target = params.target(); + let problem_2 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); let init_cs = { - let mut cs = CoinSelector::new(&candidates); + let mut cs = CoinSelector::new(&problem_2); if metric.requires_ordering_by_descending_value_pwu() { cs.sort_candidates_by_descending_value_pwu(); } @@ -157,12 +165,12 @@ where print_candidates(¶ms, &init_cs); for (cs, _) in ExhaustiveIter::new(&init_cs).into_iter().flatten() { - if let Some(lb_score) = metric.bound(&cs, target) { + if let Some(lb_score) = metric.bound(&cs.compute_view()) { // This is the branch's lower bound. In other words, this is the BEST selection // possible (can overshoot) traversing down this branch. Let's check that! - if let Some(score) = metric.score(&cs, target) { - let has_change = metric.drain(&cs, target).is_some(); + if let Some(score) = metric.score(&cs.compute_view()) { + let has_change = metric.drain(&cs.compute_view()).is_some(); prop_assert!( score >= lb_score, "checking branch: selection={} score={} change={} lb={}", @@ -178,9 +186,10 @@ where .flatten() .filter(|(_, inc)| *inc) { - if let Some(descendant_score) = metric.score(&descendant_cs, target) { - let parent_has_change = metric.drain(&cs, target).is_some(); - let descendant_has_change = metric.drain(&descendant_cs, target).is_some(); + if let Some(descendant_score) = metric.score(&descendant_cs.compute_view()) { + let parent_has_change = metric.drain(&cs.compute_view()).is_some(); + let descendant_has_change = + metric.drain(&descendant_cs.compute_view()).is_some(); prop_assert!( descendant_score >= lb_score, " @@ -190,7 +199,7 @@ where cs, parent_has_change, lb_score, - cs.is_funded(target), + cs.compute_view().is_funded(), descendant_cs, descendant_has_change, descendant_score, @@ -269,14 +278,21 @@ pub fn gen_candidates(n: usize) -> Vec { core::iter::repeat_with(move || { let value = rng.random_range(1..500_001); let weight = rng.random_range(1..2001); - let input_count = rng.random_range(1..3); - let is_segwit = rng.random_bool(0.01); + + let (mut legacy_count, mut segwit_count); + loop { + legacy_count = rng.random_range(0..3); + segwit_count = if rng.random_bool(0.01) { 1 } else { 0 }; + if legacy_count > 0 || segwit_count > 0 { + break; + } + } Candidate { value, weight, - input_count, - is_segwit, + segwit_count, + legacy_count, } }) .take(n) @@ -340,11 +356,7 @@ impl<'a> Iterator for ExhaustiveIter<'a> { } } -pub fn exhaustive_search( - cs: &mut CoinSelector, - target: Target, - metric: &mut M, -) -> Option<(Ordf32, usize)> +pub fn exhaustive_search(cs: &mut CoinSelector, metric: &mut M) -> Option<(Ordf32, usize)> where M: BnbMetric, { @@ -359,7 +371,7 @@ where .enumerate() .inspect(|(i, _)| rounds = *i) .filter(|(_, (_, inclusion))| *inclusion) - .filter_map(|(_, (cs, _))| metric.score(&cs, target).map(|score| (cs, score))); + .filter_map(|(_, (cs, _))| metric.score(&cs.compute_view()).map(|score| (cs, score))); for (child_cs, score) in iter { match &mut best { @@ -385,12 +397,13 @@ where /// current selection) meet `target`, i.e. cover the value **and** stay within `max_weight`? /// /// Enumerates every subset via [`ExhaustiveIter`] and reuses the real -/// [`CoinSelector::is_funded`] + [`CoinSelector::is_within_max_weight`], so it inherits the +/// [`SelectionView::is_funded`] + [`SelectionView::is_within_max_weight`], so it inherits the /// exact weight model and is independent of the BnB weight prune it audits. Exponential — small `n` /// only. -pub fn exact_selection_possible(cs: &CoinSelector, target: Target) -> bool { +pub fn exact_selection_possible(cs: &CoinSelector) -> bool { let feasible = |s: &CoinSelector| { - s.is_funded(target) && s.is_within_max_weight(target, DrainWeights::NONE) + let view = s.compute_view(); + view.is_funded() && view.is_within_max_weight(DrainWeights::NONE) }; // the current selection itself (no additions) is a valid subset and isn't yielded by the iter feasible(cs) @@ -401,7 +414,6 @@ pub fn exact_selection_possible(cs: &CoinSelector, target: Target) -> bool { pub fn bnb_search( cs: &mut CoinSelector, - target: Target, metric: M, max_rounds: usize, ) -> Result<(Ordf32, usize), NoBnbSolution> @@ -410,7 +422,7 @@ where { let mut rounds = 0_usize; let (selection, score) = cs - .bnb_solutions(target, metric) + .bnb_solutions(metric) .inspect(|_| rounds += 1) .take(max_rounds) .flatten() @@ -448,8 +460,9 @@ pub fn compare_against_benchmarks( let start = std::time::Instant::now(); let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); let target = params.target(); - let cs = CoinSelector::new(&candidates); - let solutions = cs.bnb_solutions(target, metric.clone()); + let problem_3 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let cs = CoinSelector::new(&problem_3); + let solutions = cs.bnb_solutions(metric.clone()); let best = solutions .enumerate() @@ -465,7 +478,7 @@ pub fn compare_against_benchmarks( core::cmp::Reverse(Ordf32(wv.effective_value(target.fee.rate))) }); // we filter out failing onces below - let _ = naive_select.select_until_target_met(target); + let _ = naive_select.select_until_target_met(); naive_select }, { @@ -485,7 +498,7 @@ pub fn compare_against_benchmarks( // exists, so the comparison below isn't vacuous. let mut greedy = cs.clone(); greedy.sort_candidates_by_descending_value_pwu(); - let _ = greedy.select_until_target_met(target); + let _ = greedy.select_until_target_met(); greedy }, ]; @@ -501,11 +514,11 @@ pub fn compare_against_benchmarks( let cmp_benchmarks = cmp_benchmarks .into_iter() .filter_map(|cs| { - let score = metric.clone().score(&cs, target)?; + let score = metric.clone().score(&cs.compute_view())?; Some((cs, score)) }) .collect::>(); - let sol_score = metric.score(&sol, target); + let sol_score = metric.score(&sol.compute_view()); for (_bench_id, (mut bench, bench_score)) in cmp_benchmarks.into_iter().enumerate() { prop_assert!( @@ -526,7 +539,7 @@ pub fn compare_against_benchmarks( None => { // Full feasibility (value *and* max_weight) is needed here; `is_fundable` // only covers value, so use the exact exhaustive oracle to assert impossibility. - prop_assert!(!exact_selection_possible(&cs, target)); + prop_assert!(!exact_selection_possible(&cs)); } } @@ -546,8 +559,9 @@ fn randomly_satisfy_target<'a, R: rand::Rng>( let mut last_score: Option = None; while let Some(next) = cs.unselected_indices().choose(rng) { cs.select(next); - if cs.is_funded(target) { - let curr_score = metric.score(&cs, target); + let view = cs.compute_view(); + if view.is_funded() { + let curr_score = metric.score(&view); if let Some(last_score) = last_score { if curr_score.is_none() || curr_score.unwrap() > last_score { break; diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index d6b8cba..ce5c4b5 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -1,17 +1,14 @@ #![allow(unused_imports)] - mod common; -use bdk_coin_select::metrics::{Changeless, LowestFee}; +use bdk_coin_select::metrics::LowestFee; use bdk_coin_select::{ BnbMetric, Candidate, ChangePolicy, CoinSelector, Drain, DrainWeights, FeeRate, NoBnbSolution, - Replace, Target, TargetFee, TargetOutputs, TX_FIXED_FIELD_WEIGHT, + Replace, SelectionProblem, Target, TargetFee, TargetOutputs, TX_FIXED_FIELD_WEIGHT, }; use proptest::prelude::*; proptest! { - #![proptest_config(ProptestConfig { - ..Default::default() - })] + #![proptest_config(ProptestConfig::default())] #[test] #[cfg(not(debug_assertions))] // too slow if compiling for debug @@ -85,17 +82,18 @@ proptest! { Candidate { value: 20_000, weight: (32 + 4 + 4 + 1) * 4 + 64 + 32, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }; params.n_candidates ]; - let mut cs = CoinSelector::new(&candidates); + let problem = SelectionProblem::new_no_ancestors(params.target(), candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem); let metric = params.lowest_fee_metric(); - let is_impossible = !cs.is_fundable(params.target()); - match common::bnb_search(&mut cs, params.target(), metric, params.n_candidates * 10) { + let is_impossible = !cs.compute_view().is_fundable(); + match common::bnb_search(&mut cs, metric, params.n_candidates * 10) { Ok((score, rounds)) => { // the +1 is because the iterator will always try selecting nothing as a solution so we have // to do one extra iteration to try that @@ -162,10 +160,12 @@ proptest! { let target = params.target(); let metric = params.lowest_fee_metric(); - let exact_possible = common::exact_selection_possible(&CoinSelector::new(&candidates), target); + let problem_2 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let exact_possible = common::exact_selection_possible(&CoinSelector::new(&problem_2)); - let mut cs = CoinSelector::new(&candidates); - let bnb_found = common::bnb_search(&mut cs, target, metric, usize::MAX).is_ok(); + let problem_3 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_3); + let bnb_found = common::bnb_search(&mut cs, metric, usize::MAX).is_ok(); prop_assert_eq!( bnb_found, exact_possible, "bnb_found={} but exact_possible={} (weight prune may have dropped a feasible subtree)", @@ -174,49 +174,6 @@ proptest! { } } -/// We wrap `LowestFee` in `Changeless` to derive a metric that finds the lowest-fee changeless -/// solution. Constraining to changeless should never take fewer rounds than the unconstrained -/// `LowestFee`. -#[test] -fn combined_changeless_metric() { - let params = common::StrategyParams { - n_candidates: 100, - target_value: 100_000, - target_weight: 1000 - TX_FIXED_FIELD_WEIGHT as u32 - 1, - replace: None, - feerate: 5.0, - feerate_lt_diff: -4.0, - drain_weight: 200, - drain_spend_weight: 600, - drain_dust: 200, - n_target_outputs: 1, - n_drain_outputs: 1, - max_weight: None, - }; - - let candidates = common::gen_candidates(params.n_candidates); - let mut cs_a = CoinSelector::new(&candidates); - let mut cs_b = CoinSelector::new(&candidates); - - let target = params.target(); - let metric_lowest_fee = params.lowest_fee_metric(); - - let metric_changeless = Changeless(params.lowest_fee_metric()); - - // cs_a uses the unconstrained metric - let (score, rounds) = common::bnb_search(&mut cs_a, target, metric_lowest_fee, usize::MAX) - .expect("must find solution"); - println!("score={:?} rounds={}", score, rounds); - - // cs_b uses the changeless-constrained metric - let (combined_score, combined_rounds) = - common::bnb_search(&mut cs_b, target, metric_changeless, usize::MAX) - .expect("must find solution"); - println!("score={:?} rounds={}", combined_score, combined_rounds); - - assert!(combined_rounds >= rounds); -} - /// Because this metric decides change optimally, it never creates a change output whose value /// wouldn't cover the future cost of spending it. Here a single input overshoots the target by only /// ~130 sats — far less than the drain's spend cost — so the fee-optimal choice is to burn the @@ -234,29 +191,30 @@ fn does_not_create_change_below_spend_cost() { max_weight: None, }; - let candidates = vec![ + let candidates = [ Candidate { value: 100_000, weight: 100, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }, Candidate { value: 50_000, weight: 100, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }, // NOTE: this input has negative effective value Candidate { value: 10, weight: 100, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }, ]; - let mut cs = CoinSelector::new(&candidates); + let problem_6 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_6); let drain_weights = DrainWeights { output_weight: 100, @@ -270,17 +228,15 @@ fn does_not_create_change_below_spend_cost() { drain_weights, }; - let (score, _) = common::bnb_search(&mut cs, target, metric, 10).expect("finds solution"); + let (score, _) = common::bnb_search(&mut cs, metric, 10).expect("finds solution"); // The optimal selection is candidate 0 alone, and it must be changeless. - let expected = { - let mut expected = CoinSelector::new(&candidates); - expected.select(0); - expected - }; + let problem_7 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut expected = CoinSelector::new(&problem_7); + expected.select(0); assert_eq!(cs.selected_indices(), expected.selected_indices()); assert!( - metric.drain(&cs, target).is_none(), + metric.drain(&cs.compute_view()).is_none(), "optimal selection must be changeless" ); @@ -293,7 +249,7 @@ fn does_not_create_change_below_spend_cost() { assert!( score <= metric - .score(&with_extra_input, target) + .score(&with_extra_input.compute_view()) .expect("target is met") ); } @@ -317,18 +273,18 @@ fn zero_fee_tx() { max_weight: None, }; - let candidates = vec![ + let candidates = [ Candidate { value: 100_000, weight: 100, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }, Candidate { value: 50_000, weight: 100, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }, ]; @@ -338,14 +294,14 @@ fn zero_fee_tx() { n_outputs: 1, }; - let mut cs = CoinSelector::new(&candidates); + let problem_8 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_8); let metric = LowestFee { long_term_feerate, dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), drain_weights, }; - let (_score, _rounds) = - common::bnb_search(&mut cs, target, metric, 1000).expect("must find solution"); + let (_score, _rounds) = common::bnb_search(&mut cs, metric, 1000).expect("must find solution"); } // --- `run_bnb` failure classification (`NoBnbSolution` variants) --- @@ -354,8 +310,8 @@ fn err_candidate(value: u64) -> Candidate { Candidate { value, weight: 272, // ~1 P2WPKH input - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, } } @@ -379,14 +335,15 @@ fn err_outputs(value_sum: u64) -> TargetOutputs { fn run_bnb_reports_insufficient_funds() { // Two 100k inputs can't cover a 10M target: the value is simply unreachable. let candidates = [err_candidate(100_000), err_candidate(100_000)]; - let mut cs = CoinSelector::new(&candidates); let target = Target { outputs: err_outputs(10_000_000), fee: TargetFee::ZERO, max_weight: None, }; + let problem_9 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_9); assert_eq!( - cs.run_bnb(target, err_metric(), 100_000).unwrap_err(), + cs.run_bnb(err_metric(), 100_000).unwrap_err(), NoBnbSolution::InsufficientFunds, ); } @@ -400,18 +357,38 @@ fn run_bnb_reports_max_weight_exceeded() { err_candidate(100_000), err_candidate(100_000), ]; - let mut cs = CoinSelector::new(&candidates); let target = Target { outputs: err_outputs(250_000), fee: TargetFee::ZERO, max_weight: Some(1), }; + let problem_10 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_10); assert_eq!( - cs.run_bnb(target, err_metric(), 100_000).unwrap_err(), + cs.run_bnb(err_metric(), 100_000).unwrap_err(), NoBnbSolution::MaxWeightExceeded, ); } +/// The search is seeded with the greedy selection, so a budget too small to search anything still +/// comes back with a usable answer instead of `RoundLimit`. Without that, a caller on a large pool +/// falls through to whatever fallback it has for something branch and bound could have covered. +#[test] +fn run_bnb_returns_the_greedy_selection_on_a_tight_budget() { + let candidates = core::iter::repeat(err_candidate(100_000)) + .take(500) + .collect::>(); + let target = Target { + outputs: err_outputs(1_000_000), + fee: TargetFee::ZERO, + max_weight: None, + }; + let problem_12 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_12); + cs.run_bnb(err_metric(), 1).expect("the seed is a solution"); + assert!(cs.compute_view().is_funded()); +} + #[test] fn run_bnb_reports_round_limit() { // A solvable target, but zero rounds: we can't conclude infeasibility, only that we gave up. @@ -420,14 +397,15 @@ fn run_bnb_reports_round_limit() { err_candidate(100_000), err_candidate(100_000), ]; - let mut cs = CoinSelector::new(&candidates); let target = Target { outputs: err_outputs(250_000), fee: TargetFee::ZERO, max_weight: None, }; + let problem_11 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_11); assert_eq!( - cs.run_bnb(target, err_metric(), 0).unwrap_err(), + cs.run_bnb(err_metric(), 0).unwrap_err(), NoBnbSolution::RoundLimit { max_rounds: 0, rounds: 0, diff --git a/tests/srd.rs b/tests/srd.rs index b1b3096..4c22c64 100644 --- a/tests/srd.rs +++ b/tests/srd.rs @@ -2,8 +2,8 @@ mod common; use bdk_coin_select::{ - Candidate, CoinSelector, Drain, DrainWeights, FeeRate, SelectError, Target, TargetFee, - TargetOutputs, CHANGE_LOWER, TR_SPK_WEIGHT, TXOUT_BASE_WEIGHT, + Candidate, CoinSelector, Drain, DrainWeights, FeeRate, SelectError, SelectionProblem, 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 @@ -36,8 +36,9 @@ fn srd_success_yields_healthy_change_that_meets_target() { 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)); + let problem = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem); + let result = cs.select_srd(drain_weights, CHANGE_LOWER, splitmix64(seed)); if let Ok(drain) = result { successes += 1; @@ -49,18 +50,15 @@ fn srd_success_yields_healthy_change_that_meets_target() { ); assert_eq!(drain.weights, drain_weights); assert!( - cs.is_funded_with_drain(target, drain), + cs.compute_view().is_funded_with_drain(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, - }, - ); + let excess = cs.compute_view().excess(Drain { + weights: drain_weights, + value: 0, + }); assert_eq!(drain.value as i64, excess); } } @@ -71,32 +69,33 @@ fn srd_success_yields_healthy_change_that_meets_target() { #[test] fn srd_insufficient_funds() { // 3 * 50_000 = 150_000 total, well below target (200_000) + CHANGE_LOWER (50_000) + fees. - let candidates = vec![ + let candidates = [ Candidate { value: 50_000, weight: 100, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }, Candidate { value: 50_000, weight: 100, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }, Candidate { value: 50_000, weight: 100, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }, ]; 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)); + let problem_2 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_2); + let result = cs.select_srd(drain_weights, CHANGE_LOWER, splitmix64(seed)); assert!( matches!(result, Err(SelectError::InsufficientFunds(_))), "seed {}: expected InsufficientFunds, got {:?}", @@ -117,8 +116,8 @@ fn srd_max_weight_exceeded() { Candidate { value: 100_000, weight: 1000, - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }; 10 ]; @@ -129,11 +128,15 @@ fn srd_max_weight_exceeded() { }; // Weight of the smallest selection that reaches target + change_lower, with no cap. - let mut probe = CoinSelector::new(&candidates); + let problem_3 = + SelectionProblem::new_no_ancestors(target(200_000, 5.0), candidates.iter().copied()); + let mut probe = CoinSelector::new(&problem_3); probe - .select_until(|cs| cs.excess(target(200_000, 5.0), drain) >= CHANGE_LOWER as i64) + .select_until(|cs| cs.excess(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); + let needed_weight = probe + .compute_view() + .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 { @@ -142,8 +145,9 @@ fn srd_max_weight_exceeded() { }; for seed in 0..20u64 { - let mut cs = CoinSelector::new(&candidates); - let result = cs.select_srd(capped, drain_weights, CHANGE_LOWER, splitmix64(seed)); + let problem_4 = SelectionProblem::new_no_ancestors(capped, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_4); + let result = cs.select_srd(drain_weights, CHANGE_LOWER, splitmix64(seed)); assert!( matches!(result, Err(SelectError::MaxWeightExceeded)), "seed {}: expected MaxWeightExceeded, got {:?}", @@ -166,13 +170,14 @@ fn srd_adds_nothing_when_already_sufficient() { }; // 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) + let problem_5 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut cs = CoinSelector::new(&problem_5); + cs.select_until(|cs| cs.excess(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)) + .select_srd(drain_weights, CHANGE_LOWER, splitmix64(3)) .expect("already sufficient"); let after: Vec = cs.selected_indices().iter().collect(); diff --git a/tests/weight.rs b/tests/weight.rs index 6a8dbb5..b7e65b7 100644 --- a/tests/weight.rs +++ b/tests/weight.rs @@ -1,6 +1,9 @@ #![allow(clippy::zero_prefixed_literal)] -use bdk_coin_select::{Candidate, CoinSelector, Drain, DrainWeights, TargetOutputs}; +use bdk_coin_select::{ + Candidate, CoinSelector, Drain, DrainWeights, SelectionProblem, Target, TargetFee, + TargetOutputs, +}; use bitcoin::{consensus::Decodable, ScriptBuf, Transaction}; fn hex_val(c: u8) -> u8 { @@ -21,6 +24,26 @@ pub fn hex_decode(hex: &str) -> Vec { bytes } +// https://mempool.space/tx/5f231df4f73694b3cca9211e336451c20dab136e0a843c2e3166cdcb093e91f4 +const THREE_INPUT_LEGACY_TX_HEX: &str = "0100000003fe785783e14669f638ba902c26e8e3d7036fb183237bc00f8a10542191c7171300000000fdfd00004730440220418996f20477d143d02ad47e74e5949641b6c2904159ab7c592d2cfc659f9bd802205b18f18ac86b714971f84a8b74a4cb14ad5c1a5b9d0d939bb32c6ae4032f4ea10148304502210091296ff8dd87b5ebfc3d47cb82cfe4750d52c544a2b88a85970354a4d0d4b1db022069632067ee6f30f06145f649bc76d5e5d5e6404dbe985e006fcde938f778c297014c695221030502b8ade694d57a6e86998180a64f4ce993372830dc796c3d561ad8b2a504de210272b68e1c037c4630eff7ea5858640cc0748e36f5de82fb38529ef1fd0a89670d2103ba0544a3a2aa9f2314022760b78b5c833aebf6f88468a089550f93834a2886ed53aeffffffff7e048a7c53a8af656e24442c65fe4c4299b1494f6c7579fe0fd9fa741ce83e3279000000fc004730440220018fa343acccd048ed8f8f179e1b6ae27435a41b5fb2c1d96a5a772777acc6dc022074783814f2100c6fc4d4c976f941212be50825814502ca0cbe3f929db789979e0147304402206373f01b73fb09876d0f5ee3087e0614cab3be249934bc2b7eb64ee67f53dc8302200b50f8a327020172b82aaba7480c77ecf07bb32322a05f4afbc543aa97d2fde8014c69522103039d906b2494e310f6c7774c98618be552720d04781e073dd3ff25d5906f22662103d82026baa529619b103ec6341d548a7eb6d924061a8469a7416155513a3071c12102e452bc4aa726d44646ba80db70465683b30efde282a19aa35c6029ae8925df5e53aeffffffffef80f0b1cc543de4f73d59c02a3c575ae5d0af17c1e11e6be7abe3325c777507ad000000fdfd00004730440220220fee11bf836621a11a8ea9100a4600c109c13895f11468d3e2062210c5481902201c5c8a462175538e87b8248e1ed3927c3a461c66d1b46215641c875e86eb22c4014830450221008d2de8c2f20a720129c372791e595b9602b1a9bce99618497aec5266148ffc1302203a493359d700ed96323f8805ed03e909959ff0f22eff359028db6861486b1555014c6952210374a4add33567f09967592c5bcdc3db421fdbba67bac4636328f96d941da31bd221039636c2ffac90afb7499b16e265078113dfb2d77b54270e37353217c9eaeaf3052103d0bcea6d10cdd2f16018ea71572631708e26f457f67cda36a7f816a87f7791d253aeffffffff04977261000000000016001470385d054721987f41521648d7b2f5c77f735d6bee92030000000000225120d0cda1b675a0b369964cbfa381721aae3549dd2c9c6f2cf71ff67d5bc277afd3f2aaf30000000000160014ed2d41ba08313dbb2630a7106b2fedafc14aa121d4f0c70000000000220020e5c7c00d174631d2d1e365d6347b016fb87b6a0c08902d8e443989cb771fa7ec00000000"; + +/// The 3-legacy-input mainnet tx above. +fn legacy_three_input_tx() -> Transaction { + Transaction::consensus_decode(&mut hex_decode(THREE_INPUT_LEGACY_TX_HEX).as_slice()).unwrap() +} + +/// The same tx with the middle input turned into a (semi-realistic) P2WPKH segwit spend. +fn legacy_three_input_tx_mixed() -> Transaction { + let mut tx = legacy_three_input_tx(); + tx.input[1].script_sig = ScriptBuf::default(); + tx.input[1].witness = vec![ + // semi-realistic p2wpkh spend + hex_decode("3045022100bdc115b86e9c863279132b4808459cf9b266c8f6a9c14a3dfd956986b807e3320220265833b85197679687c5d5eed1b2637489b34249d44cf5d2d40bc7b514181a5101"), + hex_decode("02077741a668889ce15d59365886375aea47a7691941d7a0d301697edbc773b45b"), + ].into(); + tx +} + #[test] fn segwit_one_input_one_output() { // FROM https://mempool.space/tx/e627fbb7f775a57fd398bf9b150655d4ac3e1f8afed4255e74ee10d7a345a9cc @@ -35,8 +58,8 @@ fn segwit_one_input_one_output() { .map(|(txin, value)| Candidate { value, weight: txin.segwit_weight().to_wu(), - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }) .collect::>(); @@ -46,15 +69,24 @@ fn segwit_one_input_one_output() { n_outputs: tx.output.len(), }; - let mut coin_selector = CoinSelector::new(&candidates); + let target = Target { + outputs: target_ouputs, + fee: TargetFee::ZERO, + max_weight: None, + }; + let problem = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut coin_selector = CoinSelector::new(&problem); coin_selector.select_all(); assert_eq!( - coin_selector.weight(target_ouputs, DrainWeights::NONE), + coin_selector + .compute_view() + .weight(target_ouputs, DrainWeights::NONE), tx.weight().to_wu() ); assert_eq!( (coin_selector + .compute_view() .implied_feerate(target_ouputs, Drain::NONE) .unwrap() .as_sat_vb() @@ -78,27 +110,35 @@ fn segwit_two_inputs_one_output() { .map(|(txin, value)| Candidate { value, weight: txin.segwit_weight().to_wu(), - input_count: 1, - is_segwit: true, + segwit_count: 1, + legacy_count: 0, }) .collect::>(); - let mut coin_selector = CoinSelector::new(&candidates); - let target_ouputs = TargetOutputs { value_sum: tx.output.iter().map(|output| output.value.to_sat()).sum(), weight_sum: tx.output.iter().map(|output| output.weight().to_wu()).sum(), n_outputs: tx.output.len(), }; + let target = Target { + outputs: target_ouputs, + fee: TargetFee::ZERO, + max_weight: None, + }; + let problem_2 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut coin_selector = CoinSelector::new(&problem_2); coin_selector.select_all(); assert_eq!( - coin_selector.weight(target_ouputs, DrainWeights::NONE), + coin_selector + .compute_view() + .weight(target_ouputs, DrainWeights::NONE), tx.weight().to_wu() ); assert_eq!( (coin_selector + .compute_view() .implied_feerate(target_ouputs, Drain::NONE) .unwrap() .as_sat_vb() @@ -122,8 +162,8 @@ fn legacy_three_inputs() { .map(|(txin, value)| Candidate { value, weight: txin.legacy_weight().to_wu(), - input_count: 1, - is_segwit: false, + segwit_count: 0, + legacy_count: 1, }) .collect::>(); @@ -133,15 +173,24 @@ fn legacy_three_inputs() { n_outputs: tx.output.len(), }; - let mut coin_selector = CoinSelector::new(&candidates); + let target = Target { + outputs: target_ouputs, + fee: TargetFee::ZERO, + max_weight: None, + }; + let problem_3 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut coin_selector = CoinSelector::new(&problem_3); coin_selector.select_all(); assert_eq!( - coin_selector.weight(target_ouputs, DrainWeights::NONE), + coin_selector + .compute_view() + .weight(target_ouputs, DrainWeights::NONE), orig_weight.to_wu() ); assert_eq!( (coin_selector + .compute_view() .implied_feerate(target_ouputs, Drain::NONE) .unwrap() .as_sat_vb() @@ -179,8 +228,8 @@ fn legacy_three_inputs_one_segwit() { txin.legacy_weight() } .to_wu(), - input_count: 1, - is_segwit, + segwit_count: is_segwit as usize, + legacy_count: !is_segwit as usize, } }) .collect::>(); @@ -191,11 +240,147 @@ fn legacy_three_inputs_one_segwit() { n_outputs: tx.output.len(), }; - let mut coin_selector = CoinSelector::new(&candidates); + let target = Target { + outputs: target_ouputs, + fee: TargetFee::ZERO, + max_weight: None, + }; + let problem_4 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut coin_selector = CoinSelector::new(&problem_4); + coin_selector.select_all(); + + assert_eq!( + coin_selector + .compute_view() + .weight(target_ouputs, DrainWeights::NONE), + tx.weight().to_wu() + ); +} + +#[test] +fn legacy_three_inputs_grouped() { + // Same tx as `legacy_three_inputs`, but all three legacy inputs carried by a single candidate. + // No witness section is serialized, so nothing is added per legacy input — this guards the + // all-legacy path (it also passed under the old per-candidate accounting). + let tx = legacy_three_input_tx(); + let input_values = [022_680_000, 006_558_175, 006_558_200]; + + let candidates = [Candidate { + value: input_values.iter().sum(), + weight: tx + .input + .iter() + .map(|txin| txin.legacy_weight().to_wu()) + .sum(), + segwit_count: 0, + legacy_count: tx.input.len(), + }]; + + let target_ouputs = TargetOutputs { + value_sum: tx.output.iter().map(|output| output.value.to_sat()).sum(), + weight_sum: tx.output.iter().map(|output| output.weight().to_wu()).sum(), + n_outputs: tx.output.len(), + }; + + let target = Target { + outputs: target_ouputs, + fee: TargetFee::ZERO, + max_weight: None, + }; + let problem_5 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut coin_selector = CoinSelector::new(&problem_5); + coin_selector.select_all(); + + assert_eq!( + coin_selector + .compute_view() + .weight(target_ouputs, DrainWeights::NONE), + tx.weight().to_wu() + ); +} + +#[test] +fn legacy_pair_grouped_with_segwit_input() { + // Same tx as `legacy_three_inputs_one_segwit`, but the two legacy inputs are grouped into a + // single candidate. In a segwit tx each legacy input still serializes an (empty) witness + // costing 1 WU, so the grouped candidate must pay 2 WU — not 1 — for its two empty witnesses. + let tx = legacy_three_input_tx_mixed(); + let input_values = [022_680_000, 006_558_175, 006_558_200]; + + let candidates = [ + Candidate { + value: input_values[0] + input_values[2], + weight: tx.input[0].legacy_weight().to_wu() + tx.input[2].legacy_weight().to_wu(), + segwit_count: 0, + legacy_count: 2, + }, + Candidate { + value: input_values[1], + weight: tx.input[1].segwit_weight().to_wu(), + segwit_count: 1, + legacy_count: 0, + }, + ]; + + let target_ouputs = TargetOutputs { + value_sum: tx.output.iter().map(|output| output.value.to_sat()).sum(), + weight_sum: tx.output.iter().map(|output| output.weight().to_wu()).sum(), + n_outputs: tx.output.len(), + }; + + let target = Target { + outputs: target_ouputs, + fee: TargetFee::ZERO, + max_weight: None, + }; + let problem_6 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut coin_selector = CoinSelector::new(&problem_6); + coin_selector.select_all(); + + assert_eq!( + coin_selector + .compute_view() + .weight(target_ouputs, DrainWeights::NONE), + tx.weight().to_wu() + ); +} + +#[test] +fn mixed_group_all_inputs_one_candidate() { + // Same tx as `legacy_three_inputs_one_segwit`, with all three inputs — legacy *and* segwit — + // in a single mixed candidate. `legacy_count`/`segwit_count` price this exactly: 2 WU for the + // two empty legacy witnesses, the segwit header once, and a 3-input varint. + let tx = legacy_three_input_tx_mixed(); + let input_values = [022_680_000, 006_558_175, 006_558_200]; + + let candidates = [Candidate { + value: input_values.iter().sum(), + weight: tx.input[0].legacy_weight().to_wu() + + tx.input[1].segwit_weight().to_wu() + + tx.input[2].legacy_weight().to_wu(), + segwit_count: 1, + legacy_count: 2, + }]; + + let target_ouputs = TargetOutputs { + value_sum: tx.output.iter().map(|output| output.value.to_sat()).sum(), + weight_sum: tx.output.iter().map(|output| output.weight().to_wu()).sum(), + n_outputs: tx.output.len(), + }; + + let target = Target { + outputs: target_ouputs, + fee: TargetFee::ZERO, + max_weight: None, + }; + let problem_7 = SelectionProblem::new_no_ancestors(target, candidates.iter().copied()); + let mut coin_selector = CoinSelector::new(&problem_7); coin_selector.select_all(); assert_eq!( - coin_selector.weight(target_ouputs, DrainWeights::NONE), + coin_selector + .compute_view() + .weight(target_ouputs, DrainWeights::NONE), tx.weight().to_wu() ); }