diff --git a/README.md b/README.md index 098a027..4335d4b 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ let outputs = vec![TxOut { let target = Target { outputs: TargetOutputs::fund_outputs(outputs.iter().map(|output| (output.weight().to_wu(), output.value.to_sat()))), - fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(42.0)) + fee: TargetFee::from_feerate(FeeRate::from_sat_per_vb(42.0)), + // An optional cap on the resulting transaction weight (e.g. for TRUC). `None` = unconstrained. + max_weight: None, }; let candidates = vec![ @@ -55,10 +57,10 @@ let candidates = vec![ let mut coin_selector = CoinSelector::new(&candidates); coin_selector.select(0); -assert!(!coin_selector.is_target_met(target), "we didn't select enough"); +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)); coin_selector.select(1); -assert!(coin_selector.is_target_met(target), "we should have enough now"); +assert!(coin_selector.is_funded(target), "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 // @@ -130,6 +132,7 @@ 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, }; // The feerate used to work out whether a change output would be dust (and so shouldn't be added). diff --git a/benches/coin_selector.rs b/benches/coin_selector.rs index 76a8da9..eadd10d 100644 --- a/benches/coin_selector.rs +++ b/benches/coin_selector.rs @@ -42,6 +42,7 @@ fn make_bnb_inputs(candidates: &[Candidate]) -> (Target, FeeRate) { let target = Target { fee: TargetFee::from_feerate(target_fr), outputs: TargetOutputs::fund_outputs([(TXOUT_BASE_WEIGHT + TR_SPK_WEIGHT, total / 2)]), + max_weight: None, }; (target, long_term_fr) } diff --git a/src/coin_selector.rs b/src/coin_selector.rs index f1b439b..8954334 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -111,17 +111,23 @@ impl<'a> CoinSelector<'a> { self.selected.contains(index) } - /// Is meeting this `target` possible with the current selection with this `drain` (i.e. change output). - /// Note this will respect [`ban`]ned candidates. + /// Whether the candidates can cover this `target`'s **value** (net of input fees) — i.e. whether + /// enough value is reachable for [`is_funded`] to hold. Respects [`ban`]ned candidates. /// - /// This simply selects all effective inputs at the target's feerate and checks whether we have - /// enough value. + /// Selecting *all* effective inputs maximizes the value available, so if that can't meet the + /// target value, nothing can. Monotone, hence exact. + /// + /// NOTE: this does **not** account for [`Target::max_weight`] — a `true` result can still be + /// infeasible under the weight cap. Use [`select_until_target_met`] or branch and bound (both of + /// which enforce the cap) to actually build a selection. /// /// [`ban`]: Self::ban - pub fn is_selection_possible(&self, target: Target) -> bool { + /// [`is_funded`]: Self::is_funded + /// [`select_until_target_met`]: Self::select_until_target_met + pub fn is_fundable(&self, target: Target) -> bool { let mut test = self.clone(); test.select_all_effective(target.fee.rate); - test.is_target_met(target) + test.is_funded(target) } /// Returns true if no candidates have been selected. @@ -429,20 +435,38 @@ impl<'a> CoinSelector<'a> { self.unselected_indices().next().is_none() } - /// Whether the constraints of `Target` have been met if we include a specific `drain` ouput. + /// Whether the tx implied by the current selection and `drain` is within [`Target::max_weight`]. /// - /// Note if [`is_target_met`] is true and the `drain` is produced from the [`drain`] method then - /// this method will also always be true. + /// 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: Drain) -> bool { + match target.max_weight { + Some(max_weight) => self.weight(target.outputs, drain.weights) <= max_weight, + None => true, + } + } + + /// Whether the selection covers the target value (i.e. [`excess`](Self::excess) is + /// non-negative), ignoring [`Target::max_weight`]. /// - /// [`is_target_met`]: Self::is_target_met - /// [`drain`]: Self::drain - pub fn is_target_met_with_drain(&self, target: Target, drain: Drain) -> bool { + /// This is **monotone**: selecting more never un-meets it. It deliberately does *not* include + /// the weight cap — see [`is_within_max_weight`](Self::is_within_max_weight). + pub fn is_funded_with_drain(&self, target: Target, drain: Drain) -> bool { self.excess(target, drain) >= 0 } - /// Whether the constraints of `Target` have been met. - pub fn is_target_met(&self, target: Target) -> bool { - self.is_target_met_with_drain(target, Drain::NONE) + /// Whether the selection covers the target **value** (net of input fees), i.e. [`excess`] is + /// non-negative. **Monotone** (selecting more never un-meets it), and it deliberately does + /// *not* check [`Target::max_weight`] — that is the separate, anti-monotone + /// [`is_within_max_weight`]. See [`is_funded_with_drain`] for the version that + /// accounts for a specific `drain`. + /// + /// [`excess`]: Self::excess + /// [`is_within_max_weight`]: Self::is_within_max_weight + /// [`is_funded_with_drain`]: Self::is_funded_with_drain + pub fn is_funded(&self, target: Target) -> bool { + self.is_funded_with_drain(target, Drain::NONE) } /// Select all unselected candidates @@ -468,8 +492,8 @@ impl<'a> CoinSelector<'a> { ); if excess > change_policy.min_value as i64 { debug_assert_eq!( - self.is_target_met(target), - self.is_target_met_with_drain( + self.is_funded(target), + self.is_funded_with_drain( target, Drain { weights: change_policy.drain_weights, @@ -488,12 +512,12 @@ impl<'a> CoinSelector<'a> { /// `change_policy`. If it should not, then it will return [`Drain::NONE`]. The value of the /// `Drain` will be the same as [`drain_value`]. /// - /// If [`is_target_met`] returns true for this selection then [`is_target_met_with_drain`] will + /// If [`is_funded`] returns true for this selection then [`is_funded_with_drain`] will /// also be true if you pass in the drain returned from this method. /// /// [`drain_value`]: Self::drain_value - /// [`is_target_met_with_drain`]: Self::is_target_met_with_drain - /// [`is_target_met`]: Self::is_target_met + /// [`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) { @@ -521,14 +545,25 @@ impl<'a> CoinSelector<'a> { } } - /// Select candidates until `target` has been met assuming the `drain` output is attached. + /// Select candidates until `target` has been met. /// - /// Returns an error if the target was unable to be met. - pub fn select_until_target_met(&mut self, target: Target) -> Result<(), InsufficientFunds> { - self.select_until(|cs| cs.is_target_met(target)) - .ok_or_else(|| InsufficientFunds { - missing: self.excess(target, Drain::NONE).unsigned_abs(), - }) + /// # Errors + /// + /// - [`SelectError::InsufficientFunds`] if the candidates can't cover the target value. + /// - [`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, Drain::NONE) { + return Err(SelectError::MaxWeightExceeded); + } + Ok(()) } /// Select candidates until some predicate has been satisfied. @@ -663,6 +698,38 @@ impl core::fmt::Display for InsufficientFunds { #[cfg(feature = "std")] impl std::error::Error for InsufficientFunds {} +/// Error returned by [`CoinSelector::select_until_target_met`]. +#[derive(Clone, Debug, Copy, PartialEq, Eq)] +pub enum SelectError { + /// The candidates can't cover the target value. + InsufficientFunds(InsufficientFunds), + /// The value target is met, but the resulting selection exceeds [`Target::max_weight`]. + MaxWeightExceeded, +} + +impl From for SelectError { + fn from(e: InsufficientFunds) -> Self { + SelectError::InsufficientFunds(e) + } +} + +impl core::fmt::Display for SelectError { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + match self { + SelectError::InsufficientFunds(e) => write!(f, "{}", e), + SelectError::MaxWeightExceeded => { + write!( + f, + "Selection meets the target value but exceeds `max_weight`." + ) + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for SelectError {} + /// Error type for when a solution cannot be found by branch-and-bound. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct NoBnbSolution { diff --git a/src/metrics/lowest_fee.rs b/src/metrics/lowest_fee.rs index 744bdd0..02b4728 100644 --- a/src/metrics/lowest_fee.rs +++ b/src/metrics/lowest_fee.rs @@ -53,8 +53,47 @@ impl LowestFee { return None; } + // ...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). + let drain = Drain { + weights: self.drain_weights, + value: excess_with_drain_weight.unsigned_abs(), + }; + if !cs.is_within_max_weight(target, drain) { + return None; + } + Some(excess_with_drain_weight.unsigned_abs()) } + + /// The long-term-fee score **ignoring** the `max_weight` cap, together with the drain it + /// assumes. `None` iff the value target isn't met. Used inside [`bound`](BnbMetric::bound), + /// where ignoring the (feasibility) cap only loosens the lower bound and never makes it + /// inadmissible; [`score`](BnbMetric::score) reuses the returned drain for its cap check so the + /// drain is only decided once. + fn fee_score(&self, cs: &CoinSelector<'_>, target: Target) -> Option<(Ordf32, Drain)> { + if !cs.is_funded(target) { + 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); + assert!( + fee_for_the_tx >= 0, + "must not be called unless selection has met target: fee={}", + fee_for_the_tx + ); + let fee_for_spending_drain = drain.weights.spend_fee(self.long_term_feerate); + Some(( + Ordf32((fee_for_the_tx as u64 + fee_for_spending_drain) as f32), + drain, + )) + } } impl BnbMetric for LowestFee { @@ -67,30 +106,27 @@ impl BnbMetric for LowestFee { } fn score(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { - if !cs.is_target_met(target) { + let (score, drain) = self.fee_score(cs, target)?; + // 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) { return None; } - - let long_term_fee = { - let drain = self.drain(cs, target); - let fee_for_the_tx = cs.fee(target.value(), drain.value); - assert!( - fee_for_the_tx >= 0, - "must not be called unless selection has met target: fee={}", - fee_for_the_tx - ); - // `spend_fee` rounds up here. We could use floats but I felt it was just better to - // accept the extra 1 sat penality to having a change output - let fee_for_spending_drain = drain.weights.spend_fee(self.long_term_feerate); - fee_for_the_tx as u64 + fee_for_spending_drain - }; - - Some(Ordf32(long_term_fee as f32)) + Some(score) } fn bound(&mut self, cs: &CoinSelector<'_>, target: Target) -> Option { - if cs.is_target_met(target) { - let current_score = self.score(cs, target).unwrap(); + // 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, Drain::NONE) { + return None; + } + + if cs.is_funded(target) { + let current_score = self.fee_score(cs, target).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 @@ -125,7 +161,17 @@ impl BnbMetric for LowestFee { let best_score_with_change = Ordf32(current_score.0 - cost_of_no_change as f32 + cost_of_adding_change); - if best_score_with_change < current_score { + // max_weight-aware: recovering that value requires a change output (and more + // inputs to clear the dust threshold), which only makes the tx heavier. If a change + // output can't fit the cap now it never will down this branch, so don't credit the + // improvement — keep `current_score` (a tighter, still-admissible bound). + let change_output = Drain { + weights: self.drain_weights, + value: 0, + }; + if best_score_with_change < current_score + && cs.is_within_max_weight(target, change_output) + { return Some(best_score_with_change); } } @@ -136,11 +182,11 @@ impl BnbMetric for LowestFee { let (mut cs, resize_index, to_resize) = cs .clone() .select_iter() - .find(|(cs, _, _)| cs.is_target_met(target))?; + .find(|(cs, _, _)| cs.is_funded(target))?; // If this selection is already perfect, return its score directly. if cs.excess(target, Drain::NONE) == 0 { - return Some(self.score(&cs, target).unwrap()); + return Some(self.fee_score(&cs, target).unwrap().0); }; cs.deselect(resize_index); @@ -207,7 +253,20 @@ impl BnbMetric for LowestFee { } } - // `scale` could be 0 even if `is_target_met` is `false` due to the latter being based on + // max_weight-aware: reaching the feerate needs a perfect input weighing + // `scale * to_resize.weight`. `to_resize` is the best value-per-weight input available, + // so if even that (fractionally) can't fit the remaining weight budget, no within-cap + // selection down this branch reaches the target -> prune. This is the fractional + // relaxation, so it never prunes a branch that has an (integer) within-cap solution. + if let Some(max_weight) = target.max_weight { + let budget = + max_weight.saturating_sub(cs.weight(target.outputs, DrainWeights::NONE)); + if scale.0 * to_resize.weight as f32 > budget as f32 { + return None; + } + } + + // `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; diff --git a/src/target.rs b/src/target.rs index 1bd3ae0..86463ea 100644 --- a/src/target.rs +++ b/src/target.rs @@ -7,6 +7,11 @@ pub struct Target { pub fee: TargetFee, /// The aggregate properties of outputs you're trying to fund pub outputs: TargetOutputs, + /// Maximum allowed weight of the resulting transaction (WU). `None` = unconstrained. + /// + /// This is a feasibility constraint on the answer (the sibling of the value target: a lower + /// bound on value, this an upper bound on weight). Relevant e.g. for TRUC/BIP-431 packages. + pub max_weight: Option, } impl Target { diff --git a/tests/bnb.rs b/tests/bnb.rs index 3e65022..45a22dc 100644 --- a/tests/bnb.rs +++ b/tests/bnb.rs @@ -90,6 +90,7 @@ fn bnb_finds_an_exact_solution_in_n_iter() { }, // 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); @@ -124,6 +125,7 @@ fn bnb_finds_solution_if_possible_in_n_iter() { n_outputs: 1, }, fee: TargetFee::default(), + max_weight: None, }; let solutions = cs.bnb_solutions(target, MinExcessThenWeight); @@ -143,6 +145,7 @@ fn bnb_finds_solution_if_possible_in_n_iter() { proptest! { #[test] + #[cfg(not(debug_assertions))] // too slow if compiling for debug fn bnb_always_finds_solution_if_possible(num_inputs in 1usize..18, target_value in 0u64..10_000) { let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); let wv = test_wv(&mut rng); @@ -152,13 +155,14 @@ proptest! { 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); 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_selection_possible(target)), + _ => prop_assert!(!cs.is_fundable(target)), } } @@ -198,6 +202,7 @@ proptest! { 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); diff --git a/tests/changeless.rs b/tests/changeless.rs index 37b8da5..aac10a3 100644 --- a/tests/changeless.rs +++ b/tests/changeless.rs @@ -65,7 +65,8 @@ proptest! { rate: feerate, replace, ..TargetFee::ZERO - } + }, + max_weight: None, }; let make_metric = || { diff --git a/tests/common.rs b/tests/common.rs index 3728c22..0c26a20 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -25,6 +25,13 @@ pub fn maybe_replace( proptest::option::of(replace(fee_strategy)) } +/// Strategy for an optional [`Target::max_weight`] cap (`None` = unconstrained). +pub fn maybe_max_weight( + weight_strategy: impl Strategy, +) -> impl Strategy> { + proptest::option::of(weight_strategy) +} + /// Used for constructing a proptest that compares an exhaustive search result with a bnb result /// with the given metric. /// @@ -183,7 +190,7 @@ where cs, parent_has_change, lb_score, - cs.is_target_met(target), + cs.is_funded(target), descendant_cs, descendant_has_change, descendant_score, @@ -208,6 +215,7 @@ pub struct StrategyParams { pub drain_spend_weight: u32, pub drain_dust: u64, pub n_drain_outputs: usize, + pub max_weight: Option, } impl StrategyParams { @@ -223,6 +231,7 @@ impl StrategyParams { weight_sum: self.target_weight as u64, n_outputs: self.n_target_outputs, }, + max_weight: self.max_weight, } } @@ -293,7 +302,7 @@ pub struct ExhaustiveIter<'a> { } impl<'a> ExhaustiveIter<'a> { - fn new(cs: &CoinSelector<'a>) -> Option { + pub fn new(cs: &CoinSelector<'a>) -> Option { let mut iter = Self { stack: Vec::new() }; iter.push_branches(cs); Some(iter) @@ -372,6 +381,23 @@ where best.map(|(_, score)| (score, rounds)) } +/// Exact feasibility oracle: does *any* subset of the currently-unbanned candidates (added to the +/// current selection) meet `target`, i.e. cover the value **and** stay within `max_weight`? +/// +/// Enumerates every subset via [`ExhaustiveIter`] and reuses the real +/// [`CoinSelector::is_funded`] + [`CoinSelector::is_within_max_weight`], so it inherits the +/// 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, Drain::NONE); + // the current selection itself (no additions) is a valid subset and isn't yielded by the iter + feasible(cs) + || ExhaustiveIter::new(cs) + .map(|mut iter| iter.any(|(subset, _)| feasible(&subset))) + .unwrap_or(false) +} + pub fn bnb_search( cs: &mut CoinSelector, target: Target, @@ -451,6 +477,16 @@ pub fn compare_against_benchmarks( all_effective_selected.select_all_effective(target.fee.rate); all_effective_selected }, + { + // Lightest value-meeting greedy selection. Under a binding `max_weight` the + // bulk benchmarks above are all over-cap (score `None`) and get filtered out; + // this one is the relevant baseline that stays feasible when a light solution + // 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); + greedy + }, ]; // add some random selections -- technically it's possible that one of these is better but it's very unlikely if our algorithm is working correctly. @@ -458,13 +494,25 @@ pub fn compare_against_benchmarks( (0..10).map(|_| randomly_satisfy_target(&cs, target, &mut rng, metric.clone())), ); + // Only compare against benchmarks that are themselves *valid* solutions. A benchmark + // can meet the target value yet bust `max_weight` (e.g. `select_all` on a tight cap), + // in which case its score is `None` and it isn't a real solution to compare against. let cmp_benchmarks = cmp_benchmarks .into_iter() - .filter(|cs| cs.is_target_met(target)); + .filter_map(|cs| { + let score = metric.clone().score(&cs, target)?; + Some((cs, score)) + }) + .collect::>(); let sol_score = metric.score(&sol, target); - for (_bench_id, mut bench) in cmp_benchmarks.enumerate() { - let bench_score = metric.score(&bench, target); + for (_bench_id, (mut bench, bench_score)) in cmp_benchmarks.into_iter().enumerate() { + prop_assert!( + sol_score.is_some(), + "bnb must be able to find solution if benchmark can" + ); + let sol_score = sol_score.expect("must be some"); + if sol_score > bench_score { dbg!(_bench_id); println!("bnb solution: {}", sol); @@ -475,7 +523,9 @@ pub fn compare_against_benchmarks( } } None => { - prop_assert!(!cs.is_selection_possible(target)); + // 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)); } } @@ -495,7 +545,7 @@ 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_target_met(target) { + if cs.is_funded(target) { let curr_score = metric.score(&cs, target); if let Some(last_score) = last_score { if curr_score.is_none() || curr_score.unwrap() > last_score { diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index efbbd36..4ee3ba6 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -27,8 +27,9 @@ proptest! { drain_spend_weight in 1..=2000_u32, // drain spend weight (wu) drain_dust in 100..=1000_u64, // drain dust (sats) n_drain_outputs in 1usize..150, // the number of drain outputs + max_weight in common::maybe_max_weight(500u64..4_000), // optional max tx weight cap (wu) ) { - let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs }; + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs , max_weight }; let candidates = common::gen_candidates(params.n_candidates); let metric = params.lowest_fee_metric(); common::can_eventually_find_best_solution(params, candidates, metric)?; @@ -48,8 +49,9 @@ proptest! { drain_spend_weight in 1..=2000_u32, // drain spend weight (wu) drain_dust in 100..=1000_u64, // drain dust (sats) n_drain_outputs in 1usize..150, // the number of drain outputs + max_weight in common::maybe_max_weight(500u64..4_000), // optional max tx weight cap (wu) ) { - let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs }; + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs , max_weight }; let candidates = common::gen_candidates(params.n_candidates); let metric = params.lowest_fee_metric(); common::ensure_bound_is_not_too_tight(params, candidates, metric)?; @@ -69,25 +71,30 @@ proptest! { drain_spend_weight in 1..=2000_u32, // drain spend weight (wu) drain_dust in 100..=1000_u64, // drain dust (sats) n_drain_outputs in 1usize..150, // the number of drain outputs + // No `max_weight` here: `n` is too large for the exhaustive oracle, and this test's + // impossibility check relies on the (value-only) `is_fundable`, so a weight cap + // (which BnB could fail on while value is reachable) would break it. Cap handling is + // covered by `bnb_respects_max_weight` and `can_eventually_find_best_solution`. ) { println!("== TEST =="); - let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs }; + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs, max_weight: None }; println!("{:?}", params); - let candidates = core::iter::repeat(Candidate { + let candidates = vec![ + Candidate { value: 20_000, weight: (32 + 4 + 4 + 1) * 4 + 64 + 32, input_count: 1, is_segwit: true, - }) - .take(params.n_candidates) - .collect::>(); + }; + params.n_candidates + ]; let mut cs = CoinSelector::new(&candidates); let metric = params.lowest_fee_metric(); - let is_impossible = !cs.is_selection_possible(params.target()); + let is_impossible = !cs.is_fundable(params.target()); match common::bnb_search(&mut cs, params.target(), 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 @@ -101,7 +108,9 @@ proptest! { #[test] #[cfg(not(debug_assertions))] // too slow if compiling for debug fn compare_against_benchmarks( - n_candidates in 0..50_usize, // candidates (n) + // `n` is kept small: the no-solution branch asserts against `exact_selection_possible`, + // an exhaustive O(2^n) oracle (needed because it must be exact w.r.t. the weight cap). + n_candidates in 0..16_usize, // candidates (n) target_value in 500..1_000_000_u64, // target value (sats) n_target_outputs in 1usize..150, // the number of outputs we're funding target_weight in 0..10_000_u32, // the sum of the weight of the outputs (wu) @@ -112,15 +121,59 @@ proptest! { drain_spend_weight in 1..=2000_u32, // drain spend weight (wu) drain_dust in 100..=1000_u64, // drain dust (sats) n_drain_outputs in 1usize..150, // the number of drain outputs + max_weight in common::maybe_max_weight(500u64..4_000), // optional max tx weight cap (wu) ) { - let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs }; + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs , max_weight }; let candidates = common::gen_candidates(params.n_candidates); let metric = params.lowest_fee_metric(); common::compare_against_benchmarks(params, candidates, metric)?; } } +proptest! { + // Cheap cases (small n), so run many more than the default to stress the max_weight prune. + #![proptest_config(ProptestConfig { cases: 512, ..Default::default() })] + + /// Cross-check `max_weight` handling against an exact, exhaustive feasibility oracle. + /// + /// BnB with unlimited rounds is itself an exact feasibility detector (nothing is pruned before + /// the first incumbent; the only pre-incumbent prune is the weight hard-prune). So + /// `bnb_found == exact_possible` must hold — a mismatch means the prune dropped a feasible + /// subtree. `n` is kept small because the exact oracle is exponential. + #[test] + #[cfg(not(debug_assertions))] // too slow if compiling for debug + fn bnb_respects_max_weight( + n_candidates in 1..12_usize, + target_value in 500..500_000_u64, + n_target_outputs in 1usize..150, + target_weight in 0..10_000_u32, + replace in common::maybe_replace(0u64..10_000), + feerate in 1.0..100.0_f32, + feerate_lt_diff in -5.0..50.0_f32, + drain_weight in 100..=500_u32, + drain_spend_weight in 1..=2000_u32, + drain_dust in 100..=1000_u64, + n_drain_outputs in 1usize..150, + max_weight in common::maybe_max_weight(500u64..4_000), // TRUC-tight -> binds often, small DP + ) { + let params = common::StrategyParams { n_candidates, target_value, n_target_outputs, target_weight, replace, feerate, feerate_lt_diff, drain_weight, drain_spend_weight, drain_dust, n_drain_outputs, max_weight }; + let candidates = common::gen_candidates(params.n_candidates); + let target = params.target(); + let metric = params.lowest_fee_metric(); + + 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(); + prop_assert_eq!( + bnb_found, exact_possible, + "bnb_found={} but exact_possible={} (weight prune may have dropped a feasible subtree)", + bnb_found, exact_possible + ); + } +} + /// 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`. @@ -138,6 +191,7 @@ fn combined_changeless_metric() { drain_dust: 200, n_target_outputs: 1, n_drain_outputs: 1, + max_weight: None, }; let candidates = common::gen_candidates(params.n_candidates); @@ -177,6 +231,7 @@ fn does_not_create_change_below_spend_cost() { weight_sum: 200 - TX_FIXED_FIELD_WEIGHT - 1, n_outputs: 1, }, + max_weight: None, }; let candidates = vec![ @@ -259,6 +314,7 @@ fn zero_fee_tx() { weight_sum: 200 - TX_FIXED_FIELD_WEIGHT - 1, n_outputs: 1, }, + max_weight: None, }; let candidates = vec![