diff --git a/CHANGELOG.md b/CHANGELOG.md index 3891a90..7edfa02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,17 @@ # Unreleased +- **Breaking:** Every transaction is now priced as segwit, and `Candidate::is_segwit` is removed. `Candidate::weight` is the input's segwit-serialized weight (`TxIn::segwit_weight`), so legacy inputs include their 1 WU empty witness. A transaction that spends only legacy inputs is overestimated by 2 WU plus 1 WU per input, and never undershoots the target feerate. This fixes `CoinSelector::input_weight` undercounting candidates that group several legacy inputs in a segwit transaction. +- **Breaking:** `Candidate::new(value, satisfaction_weight, is_segwit)` is now `Candidate::new(value, satisfaction_weight)`, and `satisfaction_weight` now means the weight over an unsatisfied `TxIn::default()`, which is what miniscript's `Descriptor::max_weight_to_satisfy` returns for every script type. Previously it was documented as including `scriptSigLen` and `scriptWitnessLen`. To migrate, pass `max_weight_to_satisfy()?.to_wu()` directly. +- **Breaking:** Public weight constants changed value without changing type. `TXIN_BASE_WEIGHT` is now 165 (was 164) and includes the empty witness count byte. `TX_FIXED_FIELD_WEIGHT` is now 34 (was 32) and includes the segwit marker and flag. `TR_KEYSPEND_SATISFACTION_WEIGHT` is now 65 (was 66) and excludes the witness item count. `TR_KEYSPEND_TXIN_WEIGHT` is unchanged at 230. - **Breaking:** `CoinSelector` now owns its `Target`. `CoinSelector::new(candidates, target)` takes it and `CoinSelector::target()` returns it, and it is fixed for the life of the selector. Every method that took a `target: Target` argument no longer does, including `excess`, `missing`, `rate_excess`, `implied_fee`, `is_funded`, `is_within_max_weight`, `drain`, `select_until_target_met`, `select_srd`, `run_bnb`, and `bnb_solutions`. The same goes for arguments that merely restated part of the target: `weight` and `implied_feerate` no longer take `TargetOutputs`, `fee` no longer takes `target_value`, and `effective_value` and `select_all_effective` no longer take a `FeeRate`. `BnbMetric`'s methods read the target from the `CoinSelector` they are given — they are now `fn score(&mut self, cs: &CoinSelector<'_>) -> Option`, `fn bound(&mut self, cs: &CoinSelector<'_>) -> Option` and `fn drain(&mut self, cs: &CoinSelector<'_>) -> Drain` — so `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. To measure a selection against a second target, use `CoinSelector::with_target(target)`, which copies the selection, the bans and the candidate order over to the new target; `CoinSelector::new` starts from an empty selection. - **Breaking:** `BnbMetric` metrics now decide the change output themselves. The trait gains a `drain(&mut self, cs) -> Drain` method; call it on a branch-and-bound solution (or the `LowestFee` metric directly) to get the change output the metric optimized against, instead of computing a separate `ChangePolicy`. - **Breaking:** `CoinSelector::run_bnb` now returns `(Ordf32, Drain)` instead of just `Ordf32`, handing back the change output the metric decided on for the winning selection. - **Breaking:** `LowestFee` no longer takes a `change_policy`. It now takes `dust_relay_feerate: FeeRate` and `drain_weights: DrainWeights`, and adds change only when doing so lowers the long-term fee and the change would not be dust. - Add `DrainWeights::dust_threshold(dust_relay_feerate)`, the minimum value a change output with these weights must have to not be dust. - Add `CoinSelector::select_srd`, a Single Random Draw selector (port of Bitcoin Core's `SelectCoinsSRD`) that adds candidates in random order until the change reaches `change_lower`, producing a healthy-sized (privacy-friendly) change output instead of minimizing fees. Adds the `CHANGE_LOWER` constant for Core's value. -- **Breaking:** `Changeless` is now `Changeless`, 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 (better-bound child first, backtracking in place) instead of best-first over a heap of cloned branches. Only the current path is held in memory, and under a round cap it reaches complete selections on large pools where the old frontier often ran out of rounds first. +- Seed branch and bound with the greedy selection, so a search that runs out of rounds returns the best selection it has instead of `NoBnbSolution::RoundLimit`. `RoundLimit` now means the round budget ran out before even the greedy selection was scored (e.g. `max_rounds` is 0), or the metric rejected it. +- **Breaking:** Remove the `Changeless` metric and the `BnbMetric` tuple implementations (`impl BnbMetric for ((A, f32), ...)`). Generic metric composition is no longer supported. `LowestFee` decides for itself whether a selection should carry change (adding one only when it lowers the long-term fee, clears the dust threshold, and fits `Target::max_weight`), so a separate changeless objective duplicates that decision and then constrains it. Callers that required a changeless transaction should use `LowestFee` and inspect the returned `Drain`. - **Breaking:** `CoinSelector::selected_indices` and `CoinSelector::banned` now return `&Bitset` instead of `&BTreeSet`. `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) diff --git a/README.md b/README.md index 922dd25..5b52ea1 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,11 @@ let candidates = vec![ input_count: 1, // 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. + // the total weight of the input(s) as serialized in a segwit tx, i.e. + // `TxIn::segwit_weight`. Legacy inputs include their 1 WU empty witness. + // `Candidate::new(value, descriptor.max_weight_to_satisfy()?.to_wu())` + // computes this for you. 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 @@ -49,7 +48,6 @@ let candidates = vec![ input_count: 2, weight: 2*TR_KEYSPEND_TXIN_WEIGHT, value: 3_000_000, - is_segwit: true } ]; @@ -108,19 +106,16 @@ let candidates = [ input_count: 1, value: 400_000, weight: TR_KEYSPEND_TXIN_WEIGHT, - is_segwit: true }, Candidate { input_count: 1, value: 200_000, weight: TR_KEYSPEND_TXIN_WEIGHT, - is_segwit: true }, Candidate { input_count: 1, value: 11_000, weight: TR_KEYSPEND_TXIN_WEIGHT, - is_segwit: true } ]; let drain_weights = bdk_coin_select::DrainWeights::default(); diff --git a/benches/coin_selector.rs b/benches/coin_selector.rs index 18e847e..6b25066 100644 --- a/benches/coin_selector.rs +++ b/benches/coin_selector.rs @@ -34,7 +34,6 @@ fn make_candidates(n: usize) -> Vec { value, weight: TXIN_BASE_WEIGHT + P2WPKH_SAT_W, input_count: 1, - is_segwit: true, } }) .collect() diff --git a/src/bnb.rs b/src/bnb.rs index 48c7b2c..4752531 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -1,199 +1,354 @@ -use core::cmp::Reverse; - use crate::{float::Ordf32, Drain}; 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`]. +/// +/// The tree is searched depth-first, visiting the child with the better bound first and +/// backtracking in place, so only the current path is held in memory. #[derive(Debug)] pub(crate) struct BnbIter<'a, M: BnbMetric> { - queue: BinaryHeap>, + selector: CoinSelector<'a>, + stack: Vec, best: Option, + /// 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, /// The `BnBMetric` that will score each selection pub(crate) metric: M, } +/// A decision on the current path: either `index` was selected, or `banned` (`index` and the +/// candidates interchangeable with it) were banned. +#[derive(Debug)] +struct Frame { + is_inclusion: bool, + index: usize, + /// Position of `index` in the candidate order. + cursor: usize, + /// Position to resume scanning for the next undecided candidate below this frame. + next_cursor: usize, + banned: Vec, + /// Whether the other child of this frame's parent still needs visiting. + 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; + } + // { // println!("=========================== {:?}", self.best); - // for thing in self.queue.iter() { - // println!("{} {:?}", &thing.selector, thing.lower_bound); + // println!("{} {:?}", &self.selector, self.metric.bound(&self.selector)); + // 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; + // An exclusion node has the same selection as its parent, which was already scored. + 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) { - 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.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>, metric: M) -> Self { + if metric.requires_ordering_by_descending_value_pwu() { + selector.sort_candidates_by_descending_value_pwu(); + } + let mut iter = BnbIter { - queue: BinaryHeap::default(), + selector, + stack: Vec::new(), best: None, + seed: None, + exhausted: false, metric, }; - if iter.metric.requires_ordering_by_descending_value_pwu() { - selector.sort_candidates_by_descending_value_pwu(); - } + iter.seed_greedy_incumbent(); - iter.consider_adding_to_queue(&selector, false); + if !iter.bound_is_promising() { + iter.exhausted = true; + } iter } - fn consider_adding_to_queue(&mut self, cs: &CoinSelector<'a>, is_exclusion: bool) { - let bound = self.metric.bound(cs); - 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. + 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) { + 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(&self.selector)?; + let better = match self.best { + Some(best_score) => score < best_score, + None => true, }; + if better { + self.best = Some(score); + Some(score) + } else { + None + } + } - let mut inclusion_cs = cs.clone(); - inclusion_cs.select(next_index); - self.consider_adding_to_queue(&inclusion_cs, false); + fn is_promising(&self, bound: Option) -> bool { + match (bound, self.best) { + (Some(bound), Some(best)) => best > bound, + (Some(_), None) => true, + (None, _) => false, + } + } - // 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(); + fn bound_is_promising(&mut self) -> bool { + let bound = self.metric.bound(&self.selector); + self.is_promising(bound) + } + + fn cursor(&self) -> usize { + self.stack.last().map_or(0, |frame| frame.next_cursor) + } + + /// The first undecided candidate at or after `start` in the candidate order, as + /// `(index, 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 + } + + /// The candidates to ban when excluding `index`, and the cursor to resume from. + /// + /// For the exclusion branch, we keep banning candidates that have the same value and weight as + /// the one we exclude. Candidates are only compared until the first mismatch, since this + /// exploits them being adjacent in the sorted order. + fn exclusion_plan(&self, index: usize, cursor: usize) -> (Vec, usize) { + let next = self.selector.candidate(index); let to_ban = (next.value, next.weight); - for (next_index, next) in cs.unselected() { + 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) != to_ban { 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); + // println!("banning: [{}] {:?}", next_index, next); + banned.push(next_index); + next_cursor += 1; } - self.consider_adding_to_queue(&exclusion_cs, true); + (banned, next_cursor) } -} -#[derive(Debug, Clone)] -struct Branch<'a> { - lower_bound: Ordf32, - selector: CoinSelector<'a>, - is_exclusion: bool, -} + fn apply_exclude(&mut self, banned: &[usize]) { + for &index in banned { + self.selector.ban(index); + } + } -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_exclude(&mut self, banned: &[usize]) { + for &index in banned { + self.selector.unban(index); + } } -} -impl PartialOrd for Branch<'_> { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) + fn push_include(&mut self, index: usize, cursor: usize, sibling_pending: bool) { + self.selector.select(index); + self.stack.push(Frame { + is_inclusion: true, + index, + cursor, + next_cursor: cursor + 1, + banned: Vec::new(), + sibling_pending, + }); } -} -impl PartialEq for Branch<'_> { - fn eq(&self, other: &Self) -> bool { - self.lower_bound == other.lower_bound + 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, + }); } -} -impl Eq for Branch<'_> {} + /// Step into the more promising child of the current node. Returns `false` if neither child + /// can beat the incumbent (or there are no undecided candidates left). + fn descend(&mut self) -> bool { + let (index, cursor) = match self.next_candidate(self.cursor()) { + Some(next) => next, + None => return false, + }; + + self.selector.select(index); + let inc_bound = self.metric.bound(&self.selector); + let inc_ok = self.is_promising(inc_bound); + self.selector.deselect(index); + + let (banned, exc_next_cursor) = self.exclusion_plan(index, cursor); + self.apply_exclude(&banned); + let exc_bound = self.metric.bound(&self.selector); + let exc_ok = self.is_promising(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) => { + // NOTE: We tiebreak equal bounds by preferring inclusion. We do this because we + // want to try and get to evaluating complete selections as soon as possible. + 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 + } + } + } + + /// Unwind the path until a frame whose pending sibling can still beat the incumbent, and step + /// into that sibling. Returns `false` once the whole tree is exhausted. + /// + /// The sibling's bound is recomputed here rather than reused from [`descend`](Self::descend): + /// the incumbent may have improved since. + 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.selector.deselect(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.selector.select(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.selector.deselect(frame.index); + } + } + } + false + } +} /// A branch and bound metric where we minimize the [`Ordf32`] score. /// diff --git a/src/coin_selector.rs b/src/coin_selector.rs index 2866c3b..5dda85a 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -22,6 +22,11 @@ pub struct CoinSelector<'a> { selected: Bitset, banned: Bitset, candidate_order: Arc>, + /// Running sums over the selected candidates, kept up to date by [`select`](Self::select) and + /// [`deselect`](Self::deselect) so the aggregate queries don't rescan the selection. + selected_value: u64, + selected_weight: u64, + selected_input_count: usize, } impl<'a> CoinSelector<'a> { @@ -46,6 +51,9 @@ impl<'a> CoinSelector<'a> { selected: Bitset::with_capacity(candidates.len()), banned: Bitset::with_capacity(candidates.len()), candidate_order: Arc::new((0..candidates.len()).collect::>()), + selected_value: 0, + selected_weight: 0, + selected_input_count: 0, } } @@ -106,7 +114,14 @@ impl<'a> CoinSelector<'a> { /// Deselect a candidate at `index`. `index` refers to its position in the original `candidates` /// slice passed into [`CoinSelector::new`]. pub fn deselect(&mut self, index: usize) -> bool { - self.selected.remove(index) + let removed = self.selected.remove(index); + if removed { + let candidate = self.candidates[index]; + self.selected_value -= candidate.value; + self.selected_weight -= candidate.weight; + self.selected_input_count -= candidate.input_count; + } + removed } /// Convienince method to pick elements of a slice by the indexes that are currently selected. @@ -120,7 +135,14 @@ impl<'a> CoinSelector<'a> { /// slice passed into [`CoinSelector::new`]. pub fn select(&mut self, index: usize) -> bool { assert!(index < self.candidates.len()); - self.selected.insert(index) + let inserted = self.selected.insert(index); + if inserted { + let candidate = self.candidates[index]; + self.selected_value += candidate.value; + self.selected_weight += candidate.weight; + self.selected_input_count += candidate.input_count; + } + inserted } /// Select the next unselected candidate in the sorted order fo the candidates. @@ -145,6 +167,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 @@ -183,37 +209,18 @@ impl<'a> CoinSelector<'a> { self.selected.is_empty() } - /// The weight of the inputs including the witness header and the varint for the number of - /// inputs. + /// The weight of the inputs including the varint for the number of inputs. + /// + /// Inputs are priced as segwit, so each legacy input is overestimated by its 1 WU empty + /// witness when no segwit input is selected (see [`Candidate::weight`]). 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 + let input_varint_weight = varint_size(self.selected_input_count) * 4; + input_varint_weight + self.selected_weight } /// Absolute value sum of all selected inputs. pub fn selected_value(&self) -> u64 { - self.selected - .iter() - .map(|index| self.candidates[index].value) - .sum() + self.selected_value } /// Current weight of transaction implied by the selection. @@ -602,7 +609,7 @@ impl<'a> CoinSelector<'a> { { continue; } - self.selected.insert(cand_index); + self.select(cand_index); } } @@ -921,34 +928,36 @@ impl std::error::Error for NoBnbSolution {} pub struct Candidate { /// Total value of the UTXO(s) that this [`Candidate`] represents. pub value: u64, - /// Total weight of including this/these UTXO(s). - /// `txin` fields: `prevout`, `nSequence`, `scriptSigLen`, `scriptSig`, `scriptWitnessLen`, - /// `scriptWitness` should all be included. + /// Total weight of the input(s) as serialized in a segwit transaction, i.e. the sum of + /// `TxIn::segwit_weight` from rust-bitcoin. That is `prevout`, `nSequence`, `scriptSigLen`, + /// `scriptSig`, `scriptWitnessLen` and `scriptWitness`, including the 1 WU empty witness a legacy + /// input serializes in a segwit transaction. + /// + /// [`CoinSelector`] always prices the transaction as segwit. A transaction that spends only + /// legacy inputs has no witness section, so its weight is overestimated by 2 WU plus 1 WU per + /// input. This never undershoots the target feerate. 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, } 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(value, TR_KEYSPEND_SATISFACTION_WEIGHT) } - /// Create a new [`Candidate`] that represents a single input. + /// Create a new [`Candidate`] that represents a single input of any script type. /// - /// `satisfaction_weight` is the weight of `scriptSigLen + scriptSig + scriptWitnessLen + - /// scriptWitness`. - pub fn new(value: u64, satisfaction_weight: u64, is_segwit: bool) -> Candidate { - let weight = TXIN_BASE_WEIGHT + satisfaction_weight; + /// `satisfaction_weight` is the weight the input adds over an unsatisfied `TxIn::default()`, + /// which is exactly what miniscript's `Descriptor::max_weight_to_satisfy()?.to_wu()` returns. It + /// excludes the 1-byte `scriptSigLen` and the 1-byte `scriptWitnessLen`, which + /// [`TXIN_BASE_WEIGHT`] covers. + pub fn new(value: u64, satisfaction_weight: u64) -> Candidate { Candidate { value, - weight, + weight: TXIN_BASE_WEIGHT + satisfaction_weight, input_count: 1, - is_segwit, } } diff --git a/src/lib.rs b/src/lib.rs index 34c86ad..07360df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,9 +29,9 @@ pub use target::*; mod drain; pub use drain::*; -/// Txin "base" fields include `outpoint` (32+4) and `nSequence` (4) and 1 byte for the scriptSig -/// length. -pub const TXIN_BASE_WEIGHT: u64 = (32 + 4 + 4 + 1) * 4; +/// Txin "base" fields include `outpoint` (32+4), `nSequence` (4), 1 byte for the empty scriptSig +/// length and 1 byte for the empty witness length. +pub const TXIN_BASE_WEIGHT: u64 = (32 + 4 + 4 + 1) * 4 + 1; /// The weight of a TXOUT with a zero length `scriptPubKey` #[allow(clippy::identity_op)] @@ -41,11 +41,13 @@ pub const TXOUT_BASE_WEIGHT: u64 = // The spk length + (4 * 1); -/// The weight of the `nVersion` and `nLockTime` transaction fields -pub const TX_FIXED_FIELD_WEIGHT: u64 = (4 /* nVersion */ + 4/* nLockTime */) * 4; +/// The weight of the non-discounted `nVersion`, `nLockTime` fields and the discounted segwit marker +/// and flag. +pub const TX_FIXED_FIELD_WEIGHT: u64 = (4 /* nVersion */ + 4/* nLockTime */) * 4 + 2; -/// The weight of a taproot keyspend witness -pub const TR_KEYSPEND_SATISFACTION_WEIGHT: u64 = 1 /*witness_len*/ + 1 /*item len*/ + 64 /*signature*/; +/// The weight of a taproot keyspend witness, excluding the witness item count that +/// [`TXIN_BASE_WEIGHT`] covers. +pub const TR_KEYSPEND_SATISFACTION_WEIGHT: u64 = 1 /*item len*/ + 64 /*signature*/; /// The weight of a segwit `v1` (taproot) script pubkey in an output. This does not include the weight of /// the `TxOut` itself or the script pubkey length field. 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 c2c9036..0000000 --- a/src/metrics/changeless.rs +++ /dev/null @@ -1,79 +0,0 @@ -use crate::{bnb::BnbMetric, float::Ordf32, CoinSelector, Drain}; - -/// 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<'_>) -> bool { - if self.0.drain(cs).is_none() { - return false; - } - - let mut least_excess = cs.clone(); - cs.unselected() - .rev() - .take_while(|(_, wv)| wv.effective_value(cs.target().fee.rate) < 0.0) - .for_each(|(index, _)| { - least_excess.select(index); - }); - - self.0.drain(&least_excess).is_some() - } -} - -impl BnbMetric for Changeless { - fn drain(&mut self, _cs: &CoinSelector<'_>) -> Drain { - // by definition a changeless selection never has a change output - Drain::NONE - } - - fn score(&mut self, cs: &CoinSelector<'_>) -> 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).is_some() { - return None; - } - self.0.score(cs) - } - - fn bound(&mut self, cs: &CoinSelector<'_>) -> Option { - if self.change_unavoidable(cs) { - // 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) - } - } - - fn requires_ordering_by_descending_value_pwu(&self) -> bool { - true - } -} diff --git a/tests/bnb.rs b/tests/bnb.rs index 55cf5e7..396c805 100644 --- a/tests/bnb.rs +++ b/tests/bnb.rs @@ -11,16 +11,13 @@ 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), }; - // 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; + // This used to draw `is_segwit`. Keep drawing so the rng stream stays the same. + let _ = rng.random_bool(0.5); candidate }) } @@ -62,10 +59,7 @@ 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 target_value = solution.iter().map(|c| c.value).sum(); @@ -102,7 +96,7 @@ fn bnb_finds_an_exact_solution_in_n_iter() { .last() .expect("it found a solution"); - assert_eq!(rounds, 3194); + assert_eq!(rounds, 62453); assert_eq!(best.input_weight(), solution_weight); assert_eq!(best.selected_value(), target_value, "score={:?}", score); } @@ -136,7 +130,7 @@ fn bnb_finds_solution_if_possible_in_n_iter() { .last() .expect("found a solution"); - assert_eq!(rounds, 164); + assert_eq!(rounds, 95); let excess = sol.excess(Drain::NONE); assert_eq!(excess, 0); } 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 4e9ca81..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 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 cs = CoinSelector::new(&candidates, target); - - 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(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, &mut metric).is_some(); - dbg!(format!("{}", cs)); - assert!(!has_solution); - } - } - dbg!(start.elapsed()); - } -} diff --git a/tests/common.rs b/tests/common.rs index ffbaadc..1218f87 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -264,13 +264,13 @@ pub fn gen_candidates(n: usize) -> Vec { 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); + // This used to draw `is_segwit`. Keep drawing so the rng stream stays the same. + let _ = rng.random_bool(0.01); Candidate { value, weight, input_count, - is_segwit, } }) .take(n) diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index 1d7538b..15a557d 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -1,7 +1,7 @@ #![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, @@ -86,7 +86,6 @@ proptest! { value: 20_000, weight: (32 + 4 + 4 + 1) * 4 + 64 + 32, input_count: 1, - is_segwit: true, }; params.n_candidates ]; @@ -174,47 +173,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 target = params.target(); - let mut cs_a = CoinSelector::new(&candidates, target); - let mut cs_b = CoinSelector::new(&candidates, 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, 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, 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 @@ -237,20 +195,17 @@ fn does_not_create_change_below_spend_cost() { value: 100_000, weight: 100, input_count: 1, - is_segwit: true, }, Candidate { value: 50_000, weight: 100, input_count: 1, - is_segwit: true, }, // NOTE: this input has negative effective value Candidate { value: 10, weight: 100, input_count: 1, - is_segwit: true, }, ]; @@ -315,13 +270,11 @@ fn zero_fee_tx() { value: 100_000, weight: 100, input_count: 1, - is_segwit: true, }, Candidate { value: 50_000, weight: 100, input_count: 1, - is_segwit: true, }, ]; @@ -347,7 +300,6 @@ fn err_candidate(value: u64) -> Candidate { value, weight: 272, // ~1 P2WPKH input input_count: 1, - is_segwit: true, } } @@ -404,6 +356,24 @@ fn run_bnb_reports_max_weight_exceeded() { ); } +/// 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 mut cs = CoinSelector::new(&candidates, target); + cs.run_bnb(err_metric(), 1).expect("the seed is a solution"); + assert!(cs.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. diff --git a/tests/srd.rs b/tests/srd.rs index 92dc251..70c8b81 100644 --- a/tests/srd.rs +++ b/tests/srd.rs @@ -73,19 +73,16 @@ fn srd_insufficient_funds() { value: 50_000, weight: 100, input_count: 1, - is_segwit: true, }, Candidate { value: 50_000, weight: 100, input_count: 1, - is_segwit: true, }, Candidate { value: 50_000, weight: 100, input_count: 1, - is_segwit: true, }, ]; let target = target(200_000, 5.0); @@ -115,7 +112,6 @@ fn srd_max_weight_exceeded() { value: 100_000, weight: 1000, input_count: 1, - is_segwit: true, }; 10 ]; diff --git a/tests/weight.rs b/tests/weight.rs index 3163fc8..da95611 100644 --- a/tests/weight.rs +++ b/tests/weight.rs @@ -4,6 +4,7 @@ use bdk_coin_select::{ Candidate, CoinSelector, Drain, DrainWeights, Target, TargetFee, TargetOutputs, }; use bitcoin::{consensus::Decodable, ScriptBuf, Transaction}; +use proptest::prelude::*; fn hex_val(c: u8) -> u8 { match c { @@ -38,7 +39,6 @@ fn segwit_one_input_one_output() { value, weight: txin.segwit_weight().to_wu(), input_count: 1, - is_segwit: true, }) .collect::>(); @@ -86,7 +86,6 @@ fn segwit_two_inputs_one_output() { value, weight: txin.segwit_weight().to_wu(), input_count: 1, - is_segwit: true, }) .collect::>(); @@ -132,9 +131,8 @@ fn legacy_three_inputs() { .zip(input_values) .map(|(txin, value)| Candidate { value, - weight: txin.legacy_weight().to_wu(), + weight: txin.segwit_weight().to_wu(), input_count: 1, - is_segwit: false, }) .collect::>(); @@ -152,9 +150,11 @@ fn legacy_three_inputs() { let mut coin_selector = CoinSelector::new(&candidates, target); coin_selector.select_all(); + // Every tx is priced as segwit, so an all-legacy tx pays for the 2 WU witness header and a + // 1 WU empty witness per input that it doesn't actually serialize. assert_eq!( coin_selector.weight(DrainWeights::NONE), - orig_weight.to_wu() + orig_weight.to_wu() + 2 + 3 ); assert_eq!( (coin_selector @@ -163,7 +163,7 @@ fn legacy_three_inputs() { .as_sat_vb() * 10.0) .round(), - 99.2 * 10.0 + 99.1 * 10.0 ); } @@ -184,20 +184,10 @@ fn legacy_three_inputs_one_segwit() { .input .iter() .zip(input_values) - .enumerate() - .map(|(i, (txin, value))| { - let is_segwit = i == 1; - Candidate { - value, - weight: if is_segwit { - txin.segwit_weight() - } else { - txin.legacy_weight() - } - .to_wu(), - input_count: 1, - is_segwit, - } + .map(|(txin, value)| Candidate { + value, + weight: txin.segwit_weight().to_wu(), + input_count: 1, }) .collect::>(); @@ -232,3 +222,62 @@ fn new_tr_keyspend_correct_weight() { Candidate::new_tr_keyspend(420).weight ); } + +#[test] +fn new_adds_satisfaction_weight_to_unsatisfied_txin() { + // miniscript's `max_weight_to_satisfy` is the weight over `TxIn::default()`, so passing it + // straight to `Candidate::new` must give the real `segwit_weight` for any script type. + assert_eq!( + Candidate::new(0, 0).weight, + bitcoin::TxIn::default().segwit_weight().to_wu() + ); +} + +proptest! { + /// `CoinSelector` keeps running sums of the selected candidates. After any sequence of + /// selects and deselects they must match a recompute from the selected set. + #[test] + fn running_sums_match_recompute( + candidates in proptest::collection::vec( + (0u64..1_000_000, 0u64..2_000, 0usize..4).prop_map( + |(value, weight, input_count)| Candidate { + value, + weight, + input_count, + }, + ), + 1..300, + ), + ops in proptest::collection::vec((any::(), any::()), 0..600), + ) { + let mut cs = CoinSelector::new( + &candidates, + Target { + fee: TargetFee::ZERO, + outputs: TargetOutputs::fund_outputs([]), + max_weight: None, + }, + ); + for (index, select) in ops { + let index = index.index(candidates.len()); + if select { + cs.select(index); + } else { + cs.deselect(index); + } + + let selected = cs.selected().map(|(_, c)| c).collect::>(); + let input_count = selected.iter().map(|c| c.input_count).sum::(); + let varint_size = match input_count { + 0..=0xfc => 1, + 0xfd..=0xffff => 3, + _ => 5, + }; + let expected_weight = + varint_size * 4 + selected.iter().map(|c| c.weight).sum::(); + + prop_assert_eq!(cs.input_weight(), expected_weight); + prop_assert_eq!(cs.selected_value(), selected.iter().map(|c| c.value).sum::()); + } + } +}