Describe the bug
In the funded, changeless arm of LowestFee::bound, the optimistic score for a change-bearing descendant is estimated as:
current_score - cost_of_no_change + drain_weights.waste(target.fee.rate, long_term_feerate, n_outputs)
This assumes adding the drain output always raises the transaction fee by output_weight * target.fee.rate. When TargetFee::absolute dominates, the fee stays at the absolute floor after change is added, so the output-weight part of waste is never actually paid. The bound then overestimates the best change-bearing descendant, best_score_with_change < current_score fails, and the branch is pruned even though a descendant with change has a lower long-term fee than the incumbent. run_bnb returns a selection that pays more than necessary.
This issue was found by AI.
To Reproduce
use bdk_coin_select::{
metrics::LowestFee, BnbMetric, Candidate, CoinSelector, DrainWeights, FeeRate, Target,
TargetFee, TargetOutputs,
};
#[test]
fn absolute_fee_bound_keeps_cheaper_change_descendant_reachable() {
let candidates = [
Candidate { value: 2_020, weight: 164, input_count: 1, is_segwit: false },
Candidate { value: 2_015, weight: 200, input_count: 1, is_segwit: false },
Candidate { value: 124, weight: 700, input_count: 1, is_segwit: false },
];
let target = Target {
outputs: TargetOutputs { value_sum: 1_000, weight_sum: 0, n_outputs: 0 },
fee: TargetFee { rate: FeeRate::from_sat_per_wu(1.0), absolute: 1_000, replace: None },
max_weight: None,
};
let mut metric = LowestFee {
long_term_feerate: FeeRate::from_sat_per_wu(0.25),
dust_relay_feerate: FeeRate::from_sat_per_wu(1.0),
drain_weights: DrainWeights { output_weight: 100, spend_weight: 40, n_outputs: 1 },
};
// Selecting candidates 0 and 1 with change scores 1010.
let mut expected = CoinSelector::new(&candidates);
expected.select(0);
expected.select(1);
let expected_score = metric.score(&expected, target).expect("valid selection");
assert!(metric.drain(&expected, target).is_some());
// Fails: BnB returns candidate 1 alone (changeless) with score 1015.
let mut cs = CoinSelector::new(&candidates);
let (score, _) = cs.run_bnb(target, metric, usize::MAX).expect("a valid selection exists");
assert_eq!(score, expected_score);
}
Expected behavior
bound should remain a valid lower bound when the absolute fee dominates, so that run_bnb does not prune a branch containing a lower-fee solution.
Describe the bug
In the funded, changeless arm of
LowestFee::bound, the optimistic score for a change-bearing descendant is estimated as:This assumes adding the drain output always raises the transaction fee by
output_weight * target.fee.rate. WhenTargetFee::absolutedominates, the fee stays at the absolute floor after change is added, so the output-weight part ofwasteis never actually paid. The bound then overestimates the best change-bearing descendant,best_score_with_change < current_scorefails, and the branch is pruned even though a descendant with change has a lower long-term fee than the incumbent.run_bnbreturns a selection that pays more than necessary.This issue was found by AI.
To Reproduce
Expected behavior
boundshould remain a valid lower bound when the absolute fee dominates, so thatrun_bnbdoes not prune a branch containing a lower-fee solution.