Skip to content

LowestFee::bound's resize lower bound is inadmissible: greedy prefix's segwit corrections inflate ideal_fee #56

Description

@evanlinjin

Summary

LowestFee::bound's "resize trick" lower bound is inadmissible: the crossing point and scale are computed from the greedy prefix's actual fee and weight, which include input_weight()'s non-additive corrections (+2 wu witness header once the tx is segwit, +1 wu per legacy input in a segwit tx, input-count varint growth). A target-meeting descendant that avoids the prefix's segwit corrections pays less fee than the returned ideal_fee, so bound() can exceed a real descendant's score(). Per the BnbMetric::bound contract this silently prunes the branch containing the true lowest-fee selection, and BnB returns a suboptimal (higher-fee) coin selection.

Verified on current master (cd8cec4) with a deterministic 136-sat violation (repro below).

Mechanism

bound() (target not yet met) walks the value_pwu-sorted unselected list until is_funded, deselects the crossing input, and computes scale from rate_excess_wu of the prefix — quantities that embed the prefix's corrected weight. With a segwit candidate at the head of the ordering:

  • the prefix carries +2 (witness header) and +1 per legacy input of correction weight;
  • an all-legacy descendant avoids those corrections, so it can meet the target with a real fee a few sats below ideal_fee.

The same correction-blindness affects the walk in two more spots (found while reviewing #47, which hit the analogous bug in its new metric):

  1. the max_weight fractional prune compares the prefix's corrected weight against the cap, so it can return None (prune) although a correction-avoiding descendant fits under the cap;
  2. the select_iter().find(is_funded)? -> None prune assumes prefix-funded-ness is exhaustive, but a subset that excludes a positive-ev candidate whose marginal corrections outweigh its ev can be funded while no prefix is.

Reproduction (against master)

use bdk_coin_select::metrics::LowestFee;
use bdk_coin_select::{
    BnbMetric, Candidate, CoinSelector, Drain, DrainWeights, FeeRate, Target, TargetFee,
    TargetOutputs,
};

#[test]
fn lowest_fee_bound_beaten_by_correction_avoiding_descendant() {
    // feerate 100 sat/vb (25 spwu).
    let rate = FeeRate::from_sat_per_vb(100.0);
    let candidates = vec![
        // SW: same raw weight as L1/L2 but 10 sats more -> best value_pwu, so the greedy
        // prefix takes it first and carries +2 (header) +1 (L1's witness-length byte) wu of
        // corrections that the all-legacy descendant {L1, L2} avoids.
        Candidate { value: 20_010, weight: 400, input_count: 1, is_segwit: true },
        Candidate { value: 20_000, weight: 400, input_count: 1, is_segwit: false },
        Candidate { value: 20_000, weight: 400, input_count: 1, is_segwit: false },
    ];
    let mut cs = CoinSelector::new(&candidates);
    cs.sort_candidates_by_descending_value_pwu();

    // D = {L1, L2}.
    let mut d = cs.clone();
    for (idx, c) in cs.candidates().collect::<Vec<_>>() {
        if !c.is_segwit {
            d.select(idx);
        }
    }

    let mut t = Target {
        fee: TargetFee { rate, absolute: 0, replace: None },
        outputs: TargetOutputs { value_sum: 0, weight_sum: 100, n_outputs: 1 },
        max_weight: None,
    };
    // Pad so W_D % 4 == 0: fee(W_D) has no vbyte rounding and the prefix's +3 wu of
    // corrections cross a vbyte boundary (the walk cannot stop at {SW, L1}).
    let w_d = d.weight(t.outputs, DrainWeights::NONE);
    t.outputs.weight_sum += (4 - (w_d % 4)) % 4;
    // T such that D overshoots by exactly 2 sats.
    let fee_d = rate.implied_fee(d.weight(t.outputs, DrainWeights::NONE));
    t.outputs.value_sum = 40_000 - fee_d - 2;

    let mut metric = LowestFee {
        long_term_feerate: FeeRate::from_sat_per_vb(1.0),
        dust_relay_feerate: FeeRate::from_sat_per_vb(1.0),
        drain_weights: DrainWeights { output_weight: 100, spend_weight: 600, n_outputs: 1 },
    };

    let score = metric.score(&d, t).expect("D is funded and scoreable");
    let bound = metric.bound(&cs, t).expect("root bound exists");
    assert!(bound <= score, "INADMISSIBLE: bound {:?} > score {:?}", bound, score);
}

Output on cd8cec4:

excess(D)=2 score(D)=Ordf32(23502.0) bound(root)=Ordf32(23638.0)
INADMISSIBLE: bound Ordf32(23638.0) > score Ordf32(23502.0)

The walk selects SW, cannot stop at {SW, L1} (the +3 wu of corrections push it across a vbyte boundary), crosses at L2, and returns ideal_fee based on the corrected prefix — 136 sats above what {L1, L2} actually pays.

Suggested fix

#47 hit the same defect in its ChangelessWaste metric and fixed it by replacing the resize walk with a fractional knapsack computed entirely in raw-weight-linear arithmetic: any target-met descendant D ⊇ cs satisfies Σ ev_lin(D∖cs) >= gap with ev_lin = value - raw_weight * spwu and gap = target.value + W(cs)*spwu - selected_value(cs) (the true fee only rounds up from the linear fee, and cs's corrections are common to every descendant while corrections only grow). The greedy order is unchanged (ev_lin/raw = value_pwu - spwu is monotone in value_pwu), and the resulting quantity cannot be undercut by correction avoidance. The same treatment works in fee space: ideal_fee_lin = gap-covering value sum instead of the prefix-derived scale * value. That PR's discussion/commits (resize_bound caveat note, ChangelessWaste::bound derivation) have the details.

Found during a review of #47.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions