diff --git a/README.md b/README.md index 4335d4b..922dd25 100644 --- a/README.md +++ b/README.md @@ -54,13 +54,13 @@ let candidates = vec![ ]; // You can now select coins! -let mut coin_selector = CoinSelector::new(&candidates); +let mut coin_selector = CoinSelector::new(&candidates, target); coin_selector.select(0); -assert!(!coin_selector.is_funded(target), "we didn't select enough"); -println!("we didn't select enough yet we're missing: {}", coin_selector.missing(target)); +assert!(!coin_selector.is_funded(), "we didn't select enough"); +println!("we didn't select enough yet we're missing: {}", coin_selector.missing()); coin_selector.select(1); -assert!(coin_selector.is_funded(target), "we should have enough now"); +assert!(coin_selector.is_funded(), "we should have enough now"); // Now we need to know if we need a change output to drain the excess if we overshot too much // @@ -69,7 +69,7 @@ assert!(coin_selector.is_funded(target), "we should have enough now"); let drain_weights = DrainWeights::TR_KEYSPEND; // Our policy is to only add a change output if the value is over 1_000 sats let change_policy = ChangePolicy::min_value(drain_weights, 1_000); -let change = coin_selector.drain(target, change_policy); +let change = coin_selector.drain(change_policy); if change.is_some() { println!("We need to add our change output to the transaction with {} value", change.value); } else { @@ -127,14 +127,14 @@ let drain_weights = bdk_coin_select::DrainWeights::default(); // You could determine this by looking at the user's transaction history and taking an average of the feerate. let long_term_feerate = FeeRate::from_sat_per_vb(10.0); -let mut coin_selector = CoinSelector::new(&candidates); - let target = Target { fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(15.0)), outputs: TargetOutputs::fund_outputs(outputs.iter().map(|output| (output.weight().to_wu(), output.value.to_sat()))), max_weight: None, }; +let mut coin_selector = CoinSelector::new(&candidates, target); + // 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); @@ -150,13 +150,13 @@ let mut metric = LowestFee { // We run the branch and bound algorithm with a max round limit of 100,000. // On success it returns the score along with the change output the metric decided on. -let change = match coin_selector.run_bnb(target, metric, 100_000) { +let change = match coin_selector.run_bnb(metric, 100_000) { Err(err) => { println!("failed to find a solution: {}", err); // fall back to naive selection - coin_selector.select_until_target_met(target).expect("a selection was impossible!"); + coin_selector.select_until_target_met().expect("a selection was impossible!"); // the metric still decides the change output for whatever we end up selecting - metric.drain(&coin_selector, target) + metric.drain(&coin_selector) } Ok((score, change)) => { println!("we found a solution with score {}", score); diff --git a/benches/coin_selector.rs b/benches/coin_selector.rs index c05eabb..18e847e 100644 --- a/benches/coin_selector.rs +++ b/benches/coin_selector.rs @@ -56,7 +56,8 @@ fn bench_coin_selector_clone(c: &mut Criterion) { let mut group = c.benchmark_group("clone"); for &n in &[64usize, 256, 1024, 4096] { let candidates = make_candidates(n); - let mut selector = CoinSelector::new(&candidates); + let (target, _) = make_bnb_inputs(&candidates); + let mut selector = CoinSelector::new(&candidates, target); // Select ~10% of candidates so `selected` is non-trivial to copy. for i in (0..n).step_by(10) { selector.select(i); @@ -74,8 +75,8 @@ fn bench_run_bnb_lowest_fee(c: &mut Criterion) { group.sample_size(20); for &n in &[20usize, 50, 100, 200] { let candidates = make_candidates(n); - let selector = CoinSelector::new(&candidates); let (target, long_term_feerate) = make_bnb_inputs(&candidates); + let selector = CoinSelector::new(&candidates, target); group.bench_with_input(BenchmarkId::from_parameter(n), &n, |b, _| { b.iter_batched( || selector.clone(), @@ -85,7 +86,7 @@ fn bench_run_bnb_lowest_fee(c: &mut Criterion) { 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(100_000)); sel }, BatchSize::SmallInput, diff --git a/src/bnb.rs b/src/bnb.rs index 0498e5c..d20db4c 100644 --- a/src/bnb.rs +++ b/src/bnb.rs @@ -1,6 +1,6 @@ use core::cmp::Reverse; -use crate::{float::Ordf32, Drain, Target}; +use crate::{float::Ordf32, Drain}; use super::CoinSelector; use alloc::collections::BinaryHeap; @@ -11,8 +11,6 @@ use alloc::collections::BinaryHeap; pub(crate) struct BnbIter<'a, M: BnbMetric> { queue: BinaryHeap>, best: Option, - /// The target the metric scores selections against. - pub(crate) target: Target, /// The `BnBMetric` that will score each selection pub(crate) metric: M, } @@ -55,7 +53,7 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { let mut return_val = None; if !branch.is_exclusion { - if let Some(score) = self.metric.score(&selector, self.target) { + if let Some(score) = self.metric.score(&selector) { let better = match self.best { Some(best_score) => score < best_score, None => true, @@ -73,11 +71,10 @@ impl<'a, M: BnbMetric> Iterator for BnbIter<'a, M> { } impl<'a, M: BnbMetric> BnbIter<'a, M> { - pub(crate) fn new(mut selector: CoinSelector<'a>, target: Target, metric: M) -> Self { + pub(crate) fn new(mut selector: CoinSelector<'a>, metric: M) -> Self { let mut iter = BnbIter { queue: BinaryHeap::default(), best: None, - target, metric, }; @@ -91,7 +88,7 @@ impl<'a, M: BnbMetric> BnbIter<'a, M> { } fn consider_adding_to_queue(&mut self, cs: &CoinSelector<'a>, is_exclusion: bool) { - let bound = self.metric.bound(cs, self.target); + let bound = self.metric.bound(cs); if let Some(bound) = bound { let is_good_enough = match self.best { Some(best) => best > bound, @@ -205,7 +202,7 @@ pub trait BnbMetric { /// Get the score of a given selection for `target`. /// /// If this returns `None`, the selection is invalid. - fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option; + fn score(&mut self, cs: &CoinSelector<'_>) -> Option; /// Get the lower bound score using a heuristic for `target`. /// @@ -214,13 +211,13 @@ pub trait BnbMetric { /// /// 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, cs: &CoinSelector<'_>) -> Option; /// The change output (a.k.a. drain) this metric decides on for the given selection and `target`, /// 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, cs: &CoinSelector<'_>) -> 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..4862e6e 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -18,6 +18,7 @@ pub const CHANGE_LOWER: u64 = 50_000; #[derive(Debug, Clone)] pub struct CoinSelector<'a> { candidates: &'a [Candidate], + target: Target, selected: Bitset, banned: Bitset, candidate_order: Arc>, @@ -35,15 +36,24 @@ impl<'a> CoinSelector<'a> { /// /// 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 { + /// + /// `target` is fixed for the life of the selector. Everything it reports is measured against + /// that one target. + pub fn new(candidates: &'a [Candidate], target: Target) -> Self { Self { candidates, + target, selected: Bitset::with_capacity(candidates.len()), banned: Bitset::with_capacity(candidates.len()), candidate_order: Arc::new((0..candidates.len()).collect::>()), } } + /// What this selector is funding. + pub fn target(&self) -> Target { + self.target + } + /// Iterate over all the candidates in their currently sorted order. Each item has the original /// index with the candidate. pub fn candidates( @@ -128,10 +138,10 @@ impl<'a> CoinSelector<'a> { /// [`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 { + pub fn is_fundable(&self) -> bool { let mut test = self.clone(); - test.select_all_effective(target.fee.rate); - test.is_funded(target) + test.select_all_effective(self.target.fee.rate); + test.is_funded() } /// Returns true if no candidates have been selected. @@ -186,15 +196,15 @@ impl<'a> CoinSelector<'a> { /// /// 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)) + pub fn excess(&self, drain: Drain) -> i64 { + self.rate_excess(drain) + .min(self.absolute_excess(drain)) + .min(self.replacement_excess(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); + /// How much extra value needs to be selected to reach the self.target. + pub fn missing(&self) -> u64 { + let excess = self.excess(Drain::NONE); if excess < 0 { excess.unsigned_abs() } else { @@ -202,56 +212,56 @@ impl<'a> CoinSelector<'a> { } } - /// 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 { + /// How much the current selection overshoots the value need to satisfy `self.target.fee.rate` and + /// `self.target.value` (while ignoring `self.target.fee.absolute`). + pub fn rate_excess(&self, drain: Drain) -> i64 { self.selected_value() as i64 - - target.value() as i64 + - self.target.value() as i64 - drain.value as i64 - - self.implied_fee_from_feerate(target, drain.weights) as i64 + - self.implied_fee_from_feerate(drain.weights) as i64 } - /// Same as [rate_excess](Self::rate_excess) except `target.fee.rate` is applied to the + /// Same as [rate_excess](Self::rate_excess) except `self.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 { + pub fn rate_excess_wu(&self, drain: Drain) -> i64 { self.selected_value() as i64 - - target.value() as i64 + - self.target.value() as i64 - drain.value as i64 - - self.implied_fee_from_feerate_wu(target, drain.weights) as i64 + - self.implied_fee_from_feerate_wu(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 { + /// How much the current selection overshoots the value needed to satisfy `self.target.fee.absolute` + /// and `self.target.value` (while ignoring `self.target.fee.rate`). + pub fn absolute_excess(&self, drain: Drain) -> i64 { self.selected_value() as i64 - - target.value() as i64 + - self.target.value() as i64 - drain.value as i64 - - target.fee.absolute as i64 + - self.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 { + pub fn replacement_excess(&self, drain: Drain) -> i64 { let mut replacement_excess_needed = 0; - if let Some(replace) = target.fee.replace { + if let Some(replace) = self.target.fee.replace { replacement_excess_needed = - replace.min_fee_to_do_replacement(self.weight(target.outputs, drain.weights)) + replace.min_fee_to_do_replacement(self.weight(self.target.outputs, drain.weights)) } self.selected_value() as i64 - - target.value() as i64 + - self.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 { + pub fn replacement_excess_wu(&self, 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)) + if let Some(replace) = self.target.fee.replace { + replacement_excess_needed = replace + .min_fee_to_do_replacement_wu(self.weight(self.target.outputs, drain.weights)) } self.selected_value() as i64 - - target.value() as i64 + - self.target.value() as i64 - drain.value as i64 - replacement_excess_needed as i64 } @@ -276,33 +286,33 @@ impl<'a> CoinSelector<'a> { /// [`Replace`] constraints and returns the larger of the two. /// /// `drain_weight` can be 0 to indicate no draining output. - pub fn implied_fee(&self, target: Target, drain_weights: DrainWeights) -> u64 { + pub fn implied_fee(&self, drain_weights: DrainWeights) -> u64 { let mut implied_fee = self - .implied_fee_from_feerate(target, drain_weights) - .max(target.fee.absolute); + .implied_fee_from_feerate(drain_weights) + .max(self.target.fee.absolute); - if let Some(replace) = target.fee.replace { + if let Some(replace) = self.target.fee.replace { implied_fee = Ord::max( implied_fee, - replace.min_fee_to_do_replacement(self.weight(target.outputs, drain_weights)), + replace.min_fee_to_do_replacement(self.weight(self.target.outputs, drain_weights)), ); } implied_fee } - fn implied_fee_from_feerate(&self, target: Target, drain_weights: DrainWeights) -> u64 { - target + fn implied_fee_from_feerate(&self, drain_weights: DrainWeights) -> u64 { + self.target .fee .rate - .implied_fee(self.weight(target.outputs, drain_weights)) + .implied_fee(self.weight(self.target.outputs, drain_weights)) } - fn implied_fee_from_feerate_wu(&self, target: Target, drain_weights: DrainWeights) -> u64 { - target + fn implied_fee_from_feerate_wu(&self, drain_weights: DrainWeights) -> u64 { + self.target .fee .rate - .implied_fee_wu(self.weight(target.outputs, drain_weights)) + .implied_fee_wu(self.weight(self.target.outputs, drain_weights)) } /// The actual fee the selection would pay if it was used in a transaction that had @@ -381,29 +391,24 @@ impl<'a> CoinSelector<'a> { /// 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 { + 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_waste(target.fee.rate, long_term_feerate); + let mut waste = self.input_waste(self.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; + let mut excess_waste = self.excess(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 += drain.weights.waste( + self.target.fee.rate, + long_term_feerate, + self.target.outputs.n_outputs, + ); } waste @@ -467,9 +472,9 @@ impl<'a> CoinSelector<'a> { /// 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, + pub fn is_within_max_weight(&self, drain_weights: DrainWeights) -> bool { + match self.target.max_weight { + Some(max_weight) => self.weight(self.target.outputs, drain_weights) <= max_weight, None => true, } } @@ -479,8 +484,8 @@ impl<'a> CoinSelector<'a> { /// /// 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 + pub fn is_funded_with_drain(&self, drain: Drain) -> bool { + self.excess(drain) >= 0 } /// Whether the selection covers the target **value** (net of input fees), i.e. [`excess`] is @@ -492,8 +497,8 @@ impl<'a> CoinSelector<'a> { /// [`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) + pub fn is_funded(&self) -> bool { + self.is_funded_with_drain(Drain::NONE) } /// Select all unselected candidates @@ -509,24 +514,18 @@ impl<'a> CoinSelector<'a> { /// 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, - }, - ); + 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 { debug_assert_eq!( - self.is_funded(target), - self.is_funded_with_drain( - target, - Drain { - weights: change_policy.drain_weights, - value: excess as u64 - } - ), + self.is_funded(), + self.is_funded_with_drain(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) @@ -546,8 +545,8 @@ impl<'a> CoinSelector<'a> { /// [`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) { + pub fn drain(&self, change_policy: ChangePolicy) -> Drain { + match self.drain_value(change_policy) { Some(value) => Drain { weights: change_policy.drain_weights, value, @@ -580,14 +579,13 @@ impl<'a> CoinSelector<'a> { /// - [`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) { + pub fn select_until_target_met(&mut self) -> Result<(), SelectError> { + self.select_until(|cs| cs.is_funded()).ok_or_else(|| { + SelectError::InsufficientFunds(InsufficientFunds { + missing: self.excess(Drain::NONE).unsigned_abs(), + }) + })?; + if !self.is_within_max_weight(DrainWeights::NONE) { return Err(SelectError::MaxWeightExceeded); } Ok(()) @@ -620,7 +618,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 +626,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 +635,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 +645,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(|| { @@ -688,10 +682,9 @@ impl<'a> CoinSelector<'a> { /// Most of the time, you would want to use [`CoinSelector::run_bnb`] instead. 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) } /// Run branch and bound to minimize the score of the provided [`BnbMetric`]. @@ -703,11 +696,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 +708,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); *self = selector; return Ok((score, drain)); } @@ -729,7 +721,7 @@ impl<'a> CoinSelector<'a> { 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.is_fundable() { return Err(NoBnbSolution::InsufficientFunds); } Err(NoBnbSolution::MaxWeightExceeded) diff --git a/src/metrics/changeless.rs b/src/metrics/changeless.rs index a9c9e32..c2c9036 100644 --- a/src/metrics/changeless.rs +++ b/src/metrics/changeless.rs @@ -1,4 +1,4 @@ -use crate::{bnb::BnbMetric, float::Ordf32, CoinSelector, Drain, Target}; +use crate::{bnb::BnbMetric, float::Ordf32, CoinSelector, Drain}; /// Constrains an `inner` metric to only changeless solutions. /// @@ -26,50 +26,50 @@ impl Changeless { /// 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() { + 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(target.fee.rate) < 0.0) + .take_while(|(_, wv)| wv.effective_value(cs.target().fee.rate) < 0.0) .for_each(|(index, _)| { least_excess.select(index); }); - self.0.drain(&least_excess, target).is_some() + self.0.drain(&least_excess).is_some() } } impl BnbMetric for Changeless { - fn drain(&mut self, _cs: &CoinSelector<'_>, _target: Target) -> Drain { + 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<'_>, target: Target) -> Option { + 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, target).is_some() { + if self.0.drain(cs).is_some() { return None; } - self.0.score(cs, target) + self.0.score(cs) } - fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { - if self.change_unavoidable(cs, target) { + 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, target) + self.0.bound(cs) } } diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index 5499777..d990f71 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, CoinSelector, Drain, DrainWeights, FeeRate}; /// 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: /// @@ -27,16 +27,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 { + fn drain_value(&self, cs: &CoinSelector<'_>) -> 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 +53,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 +68,15 @@ 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) { + fn fee_score(&self, cs: &CoinSelector<'_>) -> 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={}", @@ -96,36 +91,35 @@ impl LowestFee { } 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: &CoinSelector<'_>) -> 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: &CoinSelector<'_>) -> 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: &CoinSelector<'_>) -> 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) { + if !cs.is_within_max_weight(DrainWeights::NONE) { return None; } - if cs.is_funded(target) { - let current_score = self.fee_score(cs, target).unwrap().0; + 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 +140,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,10 +159,10 @@ 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 { + let change_is_reachable = match cs.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 + cs.weight(cs.target().outputs, self.drain_weights) + min_input_weight <= max_weight }), }; @@ -179,20 +173,18 @@ impl BnbMetric for LowestFee { 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 cs, resize_index, to_resize) = + cs.clone().select_iter().find(|(cs, _, _)| cs.is_funded())?; // 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 cs.excess(Drain::NONE) == 0 { + return Some(self.fee_score(&cs).unwrap().0); }; cs.deselect(resize_index); // 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 +200,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 +218,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 +236,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 +253,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 +265,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/tests/bnb.rs b/tests/bnb.rs index 45a22dc..55cf5e7 100644 --- a/tests/bnb.rs +++ b/tests/bnb.rs @@ -32,8 +32,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: &CoinSelector<'_>) -> Option { + let excess = cs.excess(Drain::NONE); if excess < 0 { None } else { @@ -43,13 +43,13 @@ impl BnbMetric for MinExcessThenWeight { } } - fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { + fn bound(&mut self, cs: &CoinSelector<'_>) -> Option { let mut cs = cs.clone(); - cs.select_until_target_met(target).ok()?; + cs.select_until_target_met().ok()?; Some(Ordf32(cs.input_weight() as f32)) } - fn drain(&mut self, _cs: &CoinSelector<'_>, _target: Target) -> Drain { + fn drain(&mut self, _cs: &CoinSelector<'_>) -> Drain { Drain::NONE } } @@ -68,20 +68,12 @@ fn bnb_finds_an_exact_solution_in_n_iter() { }); 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 +85,14 @@ fn bnb_finds_an_exact_solution_in_n_iter() { max_weight: None, }; - let solutions = cs.bnb_solutions(target, MinExcessThenWeight); + let solution_weight = { + let mut cs = CoinSelector::new(&solution, target); + cs.select_all(); + cs.input_weight() + }; + + let cs = CoinSelector::new(&candidates, target); + let solutions = cs.bnb_solutions(MinExcessThenWeight); let mut rounds = 0; let (best, score) = solutions @@ -116,8 +115,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 +125,8 @@ fn bnb_finds_solution_if_possible_in_n_iter() { max_weight: None, }; - let solutions = cs.bnb_solutions(target, MinExcessThenWeight); + let cs = CoinSelector::new(&candidates, target); + let solutions = cs.bnb_solutions(MinExcessThenWeight); let mut rounds = 0; let (sol, _score) = solutions @@ -139,7 +137,7 @@ fn bnb_finds_solution_if_possible_in_n_iter() { .expect("found a solution"); assert_eq!(rounds, 164); - let excess = sol.excess(target, Drain::NONE); + let excess = sol.excess(Drain::NONE); assert_eq!(excess, 0); } @@ -150,19 +148,18 @@ 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 cs = CoinSelector::new(&candidates, target); + 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)), + _ => prop_assert!(!cs.is_fundable()), } } @@ -177,20 +174,25 @@ 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 mut cs = CoinSelector::new(&solution, target); + cs.select_all(); + cs.input_weight() + }; + let mut cs = CoinSelector::new(&candidates, target); for i in 0..num_preselected.min(solution_len) { cs.select(i); } @@ -198,14 +200,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() diff --git a/tests/changeless.rs b/tests/changeless.rs index aac10a3..4e9ca81 100644 --- a/tests/changeless.rs +++ b/tests/changeless.rs @@ -53,7 +53,6 @@ proptest! { let wv = test_wv(&mut rng); let candidates = wv.take(n_candidates).collect::>(); - let cs = CoinSelector::new(&candidates); let target = Target { outputs: TargetOutputs { @@ -68,6 +67,7 @@ proptest! { }, max_weight: None, }; + let cs = CoinSelector::new(&candidates, target); let make_metric = || { Changeless(LowestFee { @@ -77,7 +77,7 @@ proptest! { }) }; - let solutions = cs.bnb_solutions(target, make_metric()); + let solutions = cs.bnb_solutions(make_metric()); println!("candidates: {:#?}", cs.candidates().collect::>()); @@ -94,7 +94,7 @@ proptest! { None => { let mut cs = cs.clone(); let mut metric = make_metric(); - let has_solution = common::exhaustive_search(&mut cs, target, &mut metric).is_some(); + let has_solution = common::exhaustive_search(&mut cs, &mut metric).is_some(); dbg!(format!("{}", cs)); assert!(!has_solution); } diff --git a/tests/common.rs b/tests/common.rs index 273b830..880d45c 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -51,7 +51,7 @@ where let target = params.target(); - let mut selection = CoinSelector::new(&candidates); + let mut selection = CoinSelector::new(&candidates, target); let mut exp_selection = selection.clone(); if metric.requires_ordering_by_descending_value_pwu() { @@ -61,8 +61,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); let exp_result_str = result_string(&exp_result.ok_or("no possible solution"), exp_change); println!( "\t\telapsed={:8}s result={}", @@ -72,7 +72,7 @@ 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 drain = metric.drain(&exp_selection); let target_value = target.value(); let replace_fee = params .replace @@ -87,8 +87,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); let result_str = result_string(&result, change); println!( "\t\telapsed={:8}s result={}", @@ -112,7 +112,7 @@ where // bonus check: ensure replacement fee is respected let selected_value = selection.selected_value(); - let drain = bnb_metric.drain(&selection, target); + let drain = bnb_metric.drain(&selection); let target_value = target.value(); let replace_fee = params .replace @@ -148,7 +148,7 @@ where let target = params.target(); let init_cs = { - let mut cs = CoinSelector::new(&candidates); + let mut cs = CoinSelector::new(&candidates, target); if metric.requires_ordering_by_descending_value_pwu() { cs.sort_candidates_by_descending_value_pwu(); } @@ -157,12 +157,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) { // 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) { + let has_change = metric.drain(&cs).is_some(); prop_assert!( score >= lb_score, "checking branch: selection={} score={} change={} lb={}", @@ -178,9 +178,9 @@ 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) { + let parent_has_change = metric.drain(&cs).is_some(); + let descendant_has_change = metric.drain(&descendant_cs).is_some(); prop_assert!( descendant_score >= lb_score, " @@ -190,7 +190,7 @@ where cs, parent_has_change, lb_score, - cs.is_funded(target), + cs.is_funded(), descendant_cs, descendant_has_change, descendant_score, @@ -340,11 +340,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 +355,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).map(|score| (cs, score))); for (child_cs, score) in iter { match &mut best { @@ -388,10 +384,8 @@ where /// [`CoinSelector::is_funded`] + [`CoinSelector::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 { - let feasible = |s: &CoinSelector| { - s.is_funded(target) && s.is_within_max_weight(target, DrainWeights::NONE) - }; +pub fn exact_selection_possible(cs: &CoinSelector) -> bool { + let feasible = |s: &CoinSelector| s.is_funded() && s.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) || ExhaustiveIter::new(cs) @@ -401,7 +395,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 +403,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 +441,8 @@ 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 cs = CoinSelector::new(&candidates, target); + let solutions = cs.bnb_solutions(metric.clone()); let best = solutions .enumerate() @@ -465,7 +458,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 +478,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 +494,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)?; Some((cs, score)) }) .collect::>(); - let sol_score = metric.score(&sol, target); + let sol_score = metric.score(&sol); for (_bench_id, (mut bench, bench_score)) in cmp_benchmarks.into_iter().enumerate() { prop_assert!( @@ -526,7 +519,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 +539,8 @@ 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); + if cs.is_funded() { + let curr_score = metric.score(&cs); 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..1d7538b 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -91,11 +91,11 @@ proptest! { params.n_candidates ]; - let mut cs = CoinSelector::new(&candidates); + let mut cs = CoinSelector::new(&candidates, params.target()); 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.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 +162,10 @@ proptest! { let target = params.target(); let metric = params.lowest_fee_metric(); - let exact_possible = common::exact_selection_possible(&CoinSelector::new(&candidates), target); + let exact_possible = common::exact_selection_possible(&CoinSelector::new(&candidates, target)); - let mut cs = CoinSelector::new(&candidates); - let bnb_found = common::bnb_search(&mut cs, target, metric, usize::MAX).is_ok(); + let mut cs = CoinSelector::new(&candidates, target); + 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)", @@ -195,23 +195,21 @@ fn combined_changeless_metric() { }; 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 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, target, metric_lowest_fee, usize::MAX) - .expect("must find solution"); + 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, target, metric_changeless, usize::MAX) - .expect("must find solution"); + 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); @@ -256,7 +254,7 @@ fn does_not_create_change_below_spend_cost() { }, ]; - let mut cs = CoinSelector::new(&candidates); + let mut cs = CoinSelector::new(&candidates, target); let drain_weights = DrainWeights { output_weight: 100, @@ -270,17 +268,17 @@ 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); + let mut expected = CoinSelector::new(&candidates, target); expected.select(0); expected }; assert_eq!(cs.selected_indices(), expected.selected_indices()); assert!( - metric.drain(&cs, target).is_none(), + metric.drain(&cs).is_none(), "optimal selection must be changeless" ); @@ -290,12 +288,7 @@ fn does_not_create_change_below_spend_cost() { with_extra_input.select(2); with_extra_input }; - assert!( - score - <= metric - .score(&with_extra_input, target) - .expect("target is met") - ); + assert!(score <= metric.score(&with_extra_input).expect("target is met")); } #[test] @@ -338,14 +331,13 @@ fn zero_fee_tx() { n_outputs: 1, }; - let mut cs = CoinSelector::new(&candidates); + let mut cs = CoinSelector::new(&candidates, target); 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) --- @@ -379,14 +371,14 @@ 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 mut cs = CoinSelector::new(&candidates, target); assert_eq!( - cs.run_bnb(target, err_metric(), 100_000).unwrap_err(), + cs.run_bnb(err_metric(), 100_000).unwrap_err(), NoBnbSolution::InsufficientFunds, ); } @@ -400,14 +392,14 @@ 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 mut cs = CoinSelector::new(&candidates, target); assert_eq!( - cs.run_bnb(target, err_metric(), 100_000).unwrap_err(), + cs.run_bnb(err_metric(), 100_000).unwrap_err(), NoBnbSolution::MaxWeightExceeded, ); } @@ -420,14 +412,14 @@ 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 mut cs = CoinSelector::new(&candidates, target); 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..1f7c9f5 100644 --- a/tests/srd.rs +++ b/tests/srd.rs @@ -36,8 +36,8 @@ 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 mut cs = CoinSelector::new(&candidates, target); + let result = cs.select_srd(drain_weights, CHANGE_LOWER, splitmix64(seed)); if let Ok(drain) = result { successes += 1; @@ -49,18 +49,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.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.excess(Drain { + weights: drain_weights, + value: 0, + }); assert_eq!(drain.value as i64, excess); } } @@ -95,8 +92,8 @@ fn srd_insufficient_funds() { 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 mut cs = CoinSelector::new(&candidates, target); + let result = cs.select_srd(drain_weights, CHANGE_LOWER, splitmix64(seed)); assert!( matches!(result, Err(SelectError::InsufficientFunds(_))), "seed {}: expected InsufficientFunds, got {:?}", @@ -129,9 +126,9 @@ 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 mut probe = CoinSelector::new(&candidates, target(200_000, 5.0)); 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); @@ -142,8 +139,8 @@ 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 mut cs = CoinSelector::new(&candidates, capped); + let result = cs.select_srd(drain_weights, CHANGE_LOWER, splitmix64(seed)); assert!( matches!(result, Err(SelectError::MaxWeightExceeded)), "seed {}: expected MaxWeightExceeded, got {:?}", @@ -166,13 +163,13 @@ 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 mut cs = CoinSelector::new(&candidates, target); + 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..9e0883d 100644 --- a/tests/weight.rs +++ b/tests/weight.rs @@ -1,6 +1,8 @@ #![allow(clippy::zero_prefixed_literal)] -use bdk_coin_select::{Candidate, CoinSelector, Drain, DrainWeights, TargetOutputs}; +use bdk_coin_select::{ + Candidate, CoinSelector, Drain, DrainWeights, Target, TargetFee, TargetOutputs, +}; use bitcoin::{consensus::Decodable, ScriptBuf, Transaction}; fn hex_val(c: u8) -> u8 { @@ -46,7 +48,12 @@ 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 mut coin_selector = CoinSelector::new(&candidates, target); coin_selector.select_all(); assert_eq!( @@ -83,13 +90,17 @@ fn segwit_two_inputs_one_output() { }) .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 mut coin_selector = CoinSelector::new(&candidates, target); coin_selector.select_all(); @@ -133,7 +144,12 @@ 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 mut coin_selector = CoinSelector::new(&candidates, target); coin_selector.select_all(); assert_eq!( @@ -191,7 +207,12 @@ 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 mut coin_selector = CoinSelector::new(&candidates, target); coin_selector.select_all(); assert_eq!(