Skip to content

LowestFee::bound is inadmissible when TargetFee::absolute dominates, pruning cheaper change-bearing solutions #83

Description

@noahjoeris

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions