From 4d5349ef39cb72dbb0d98bce4073cbaa40ddd3a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Mon, 6 Jul 2026 07:19:49 +0000 Subject: [PATCH] feat!: split NoBnbSolution into infeasible vs round-limit variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `run_bnb` failing conflated three different cases behind one struct. Make `NoBnbSolution` an enum that says which happened: - `InsufficientFunds` — the candidates can't cover the target value. - `MaxWeightExceeded` — the value is coverable, but every selection busts `max_weight` (mirrors `SelectError`). - `RoundLimit { max_rounds, rounds }` — hit the round cap; a solution may still exist with more rounds. The distinction is derivable in `run_bnb` without giving `BnbMetric` an error channel: if the search runs to completion (rounds < max_rounds, or a final pull drains the iterator) then no selection satisfies the target — a genuine infeasibility — and a cheap `is_fundable` value check splits it into value vs weight. Otherwise we merely ran out of rounds. Breaking: `NoBnbSolution` is now an enum; its `max_rounds`/`rounds` fields move under the `RoundLimit` variant. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/coin_selector.rs | 74 +++++++++++++++++++++++++++-------- tests/common.rs | 2 +- tests/lowest_fee.rs | 91 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 149 insertions(+), 18 deletions(-) diff --git a/src/coin_selector.rs b/src/coin_selector.rs index 5f07d93..dc91b3b 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -636,10 +636,24 @@ impl<'a> CoinSelector<'a> { .inspect(|_| rounds += 1) .flatten() .last(); - let (selector, score) = best.ok_or(NoBnbSolution { max_rounds, rounds })?; - let drain = iter.metric.drain(&selector, target); - *self = selector; - Ok((score, drain)) + if let Some((selector, score)) = best { + let drain = iter.metric.drain(&selector, target); + *self = selector; + return Ok((score, drain)); + } + + // No solution. If the iterator still has an item we stopped at the round limit and a + // solution may still exist with a larger `max_rounds`. Otherwise the tree was fully + // explored, so no selection satisfies the target — a genuine infeasibility, split into + // value vs weight. + if iter.next().is_some() { + assert_eq!(rounds, max_rounds); // still-yielding ⟹ we truncated at the cap + return Err(NoBnbSolution::RoundLimit { max_rounds, rounds }); + } + if !self.is_fundable(target) { + return Err(NoBnbSolution::InsufficientFunds); + } + Err(NoBnbSolution::MaxWeightExceeded) } } @@ -742,22 +756,52 @@ impl core::fmt::Display for SelectError { #[cfg(feature = "std")] impl std::error::Error for SelectError {} -/// Error type for when a solution cannot be found by branch-and-bound. +/// Error returned by [`CoinSelector::run_bnb`] when it yields no solution. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct NoBnbSolution { - /// Maximum rounds set by the caller. - pub max_rounds: usize, - /// Number of branch-and-bound rounds performed. - pub rounds: usize, +pub enum NoBnbSolution { + /// The candidates can't cover the target value, so no selection is possible. + InsufficientFunds, + /// Some selection covers the target value, but every one of them exceeds + /// [`Target::max_weight`]. + /// + /// Only reachable with a metric that enforces the cap (e.g. [`LowestFee`]); a cap-blind metric + /// returns an over-cap selection rather than failing. + /// + /// [`LowestFee`]: crate::metrics::LowestFee + MaxWeightExceeded, + /// The round limit was reached before the search finished — a solution may still exist with a + /// larger `max_rounds`. + RoundLimit { + /// Maximum rounds set by the caller. + max_rounds: usize, + /// Number of branch-and-bound rounds performed. + rounds: usize, + }, } +// Allow this for now due to MSRV +#[allow(clippy::uninlined_format_args)] impl core::fmt::Display for NoBnbSolution { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!( - f, - "No bnb solution found after {} rounds (max rounds is {}).", - self.rounds, self.max_rounds - ) + match self { + NoBnbSolution::InsufficientFunds => { + write!( + f, + "no bnb solution: candidates cannot cover the target value" + ) + } + NoBnbSolution::MaxWeightExceeded => { + write!( + f, + "no bnb solution: no selection meets the target within max_weight" + ) + } + NoBnbSolution::RoundLimit { max_rounds, rounds } => write!( + f, + "no bnb solution found after {} rounds (max rounds is {})", + rounds, max_rounds + ), + } } } diff --git a/tests/common.rs b/tests/common.rs index d62ee97..273b830 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -415,7 +415,7 @@ where .take(max_rounds) .flatten() .last() - .ok_or(NoBnbSolution { max_rounds, rounds })?; + .ok_or(NoBnbSolution::RoundLimit { max_rounds, rounds })?; println!("\t\tsolution={}, score={}", selection, score); *cs = selection; diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index 4ee3ba6..d6b8cba 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -3,8 +3,8 @@ mod common; use bdk_coin_select::metrics::{Changeless, LowestFee}; use bdk_coin_select::{ - BnbMetric, Candidate, ChangePolicy, CoinSelector, Drain, DrainWeights, FeeRate, Replace, - Target, TargetFee, TargetOutputs, TX_FIXED_FIELD_WEIGHT, + BnbMetric, Candidate, ChangePolicy, CoinSelector, Drain, DrainWeights, FeeRate, NoBnbSolution, + Replace, Target, TargetFee, TargetOutputs, TX_FIXED_FIELD_WEIGHT, }; use proptest::prelude::*; @@ -347,3 +347,90 @@ fn zero_fee_tx() { let (_score, _rounds) = common::bnb_search(&mut cs, target, metric, 1000).expect("must find solution"); } + +// --- `run_bnb` failure classification (`NoBnbSolution` variants) --- + +fn err_candidate(value: u64) -> Candidate { + Candidate { + value, + weight: 272, // ~1 P2WPKH input + input_count: 1, + is_segwit: true, + } +} + +fn err_metric() -> LowestFee { + LowestFee { + long_term_feerate: FeeRate::from_sat_per_vb(1.0), + dust_relay_feerate: FeeRate::from_sat_per_vb(1.0), + drain_weights: DrainWeights::TR_KEYSPEND, + } +} + +fn err_outputs(value_sum: u64) -> TargetOutputs { + TargetOutputs { + value_sum, + weight_sum: 100, + n_outputs: 1, + } +} + +#[test] +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, + }; + assert_eq!( + cs.run_bnb(target, err_metric(), 100_000).unwrap_err(), + NoBnbSolution::InsufficientFunds, + ); +} + +#[test] +fn run_bnb_reports_max_weight_exceeded() { + // The candidates *can* cover the value, but a 1 WU cap fits nothing, so the search exhausts + // without ever finding a within-cap selection. + let candidates = [ + err_candidate(100_000), + 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), + }; + assert_eq!( + cs.run_bnb(target, err_metric(), 100_000).unwrap_err(), + NoBnbSolution::MaxWeightExceeded, + ); +} + +#[test] +fn run_bnb_reports_round_limit() { + // A solvable target, but zero rounds: we can't conclude infeasibility, only that we gave up. + let candidates = [ + err_candidate(100_000), + 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, + }; + assert_eq!( + cs.run_bnb(target, err_metric(), 0).unwrap_err(), + NoBnbSolution::RoundLimit { + max_rounds: 0, + rounds: 0, + }, + ); +}