Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 59 additions & 15 deletions src/coin_selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
),
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion tests/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
91 changes: 89 additions & 2 deletions tests/lowest_fee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;

Expand Down Expand Up @@ -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,
},
);
}
Loading