Skip to content

fix!: price every transaction as segwit and drop Candidate::is_segwit - #63

Draft
evanlinjin wants to merge 1 commit into
bitcoindevkit:masterfrom
evanlinjin:fix/legacy-pricing-mix
Draft

evanlinjin wants to merge 1 commit into
bitcoindevkit:masterfrom
evanlinjin:fix/legacy-pricing-mix

Conversation

@evanlinjin

@evanlinjin evanlinjin commented Aug 12, 2026 •

Copy link
Copy Markdown
Member

#84 is stacked on this one.

Fixes CoinSelector::input_weight undercounting candidates that group multiple legacy inputs in a segwit transaction (where each legacy input serializes a 1 WU empty witness). Previously it added 1 WU per non-segwit candidate, so a candidate grouping N legacy inputs came out N-1 WU short.

Instead of tracking segwit and legacy input counts to price mixed transactions exactly (the previous revision of this PR), this prices every transaction as segwit:

  1. Candidate::is_segwit is removed. Candidate::weight is the input's segwit-serialized weight (TxIn::segwit_weight), so legacy inputs include their 1 WU empty witness, and input_weight always counts the 2 WU witness header.
  2. Candidate::new(value, satisfaction_weight, is_segwit) becomes Candidate::new(value, satisfaction_weight). satisfaction_weight is now the weight over an unsatisfied TxIn::default(), which is exactly miniscript's Descriptor::max_weight_to_satisfy for every script type.

Trade-off: a transaction that spends only legacy inputs is overestimated by 2 WU plus 1 WU per input (under 1 sat per input at 1 sat/vB). It only ever overpays, so it never undershoots the target feerate. In return:

  • Mixed and grouped candidates are priced exactly with no per-type bookkeeping, and a candidate can freely mix legacy and segwit inputs.
  • One constructor with one contract, so a segwit max_weight_to_satisfy can't come out 1 WU short.
  • Branch and bound's duplicate-banning stays on (value, weight), so there's no second notion of candidate equality that the sort has to keep contiguous.
  • The is_segwit lower-bounding hack in tests/bnb.rs goes away.

Note: This replaces #62 and #61. #61 introduced empty_witness_refund, and #62 forbade mixing legacy and segwit inputs in a candidate. Both are unnecessary once every transaction is priced as segwit.

🤖 Generated with Claude Code

Comment thread src/coin_selector.rs Outdated
///
/// Note that [`TXIN_BASE_WEIGHT`] already accounts for the outpoint, `nSequence`, and 1 byte for
/// `scriptSigLen`.
pub fn new_segwit(value: u64, satisfaction_weight: u64) -> Candidate {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The contract here is correct against the serialization rules, but it isn't what the usual source of this number returns. rust-miniscript's Descriptor::max_weight_to_satisfy is defined as txin.segwit_weight() - TxIn::default().segwit_weight(), and TxIn::default() already carries the 1 WU empty-witness count, so it excludes scriptWitnessLen. With miniscript 12.3.7:

descriptor max_weight_to_satisfy what this doc asks for
pkh → new_legacy 428 428 ✓
wpkh → new_segwit 107 108
sh(wpkh) → new_segwit 199 200
tr → new_segwit 66 67

So new_legacy(v, desc.max_weight_to_satisfy()) is exact, and new_segwit(v, desc.max_weight_to_satisfy()) is 1 WU short on every segwit input. That's the same shape of bug this PR fixes, and the compiler won't flag it.

The split constructors let us make both take the same number. Define satisfaction_weight as the weight the input adds over an unsatisfied TxIn (which is exactly max_weight_to_satisfy), and have new_segwit add the witness-count byte itself:

pub fn new_segwit(value: u64, satisfaction_weight: u64) -> Candidate {
    Candidate {
        value,
        // `TxIn::default()`'s empty witness count is 1 WU, which the satisfaction weight excludes.
        weight: TXIN_BASE_WEIGHT + 1 + satisfaction_weight,
        segwit_count: 1,
        legacy_count: 0,
    }
}

Then new_tr_keyspend would pass TR_KEYSPEND_SATISFACTION_WEIGHT - 1 (or the constant drops its witness_len term), and both docs can say "e.g. Descriptor::max_weight_to_satisfy". If you'd rather keep the current contract, the doc should at least say that max_weight_to_satisfy needs + 1 here.

Comment thread src/bnb.rs Outdated
Comment on lines 140 to 160
// for the exclusion branch, we keep banning if candidates have the same weight, value and
// input counts. The counts matter because a segwit and a legacy input of equal weight
// change the tx weight differently.
let mut is_first_ban = true;
let mut exclusion_cs = cs.clone();
let to_ban = (next.value, next.weight);
let to_ban = (
next.value,
next.weight,
next.segwit_count,
next.legacy_count,
);
for (next_index, next) in cs.unselected() {
if (next.value, next.weight) != to_ban {
if (
next.value,
next.weight,
next.segwit_count,
next.legacy_count,
) != to_ban
{
break;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop only bans a contiguous run of identical candidates, so it relies on the sort placing identical candidates next to each other. Before this PR that held because (value, weight) also fixes the sort key (value_pwu, value). Now the ban key includes the counts and the sort key doesn't, so equal-value, equal-weight candidates of different script types can end up interleaved, and nothing gets co-banned. In a LowestFee probe (candidates of 10,000 sats / 400 WU, half segwit and half legacy, 10 sat/vB, 78,000 sat output):

candidates interleaved grouped by type
16 24,541 rounds 46
20 293,939 55
24 >2,000,000 55

The real problem is two independent definitions of "same candidate". The tuple here also repeats Candidate's field list, which is how input_count was left out of the old key: two candidates of equal value and weight with 1 and 2 inputs were banned together. I'd define the equivalence in one place. Add PartialEq, Eq to Candidate's derive (additive, non-breaking) and compare whole candidates:

Suggested change
// for the exclusion branch, we keep banning if candidates have the same weight, value and
// input counts. The counts matter because a segwit and a legacy input of equal weight
// change the tx weight differently.
let mut is_first_ban = true;
let mut exclusion_cs = cs.clone();
let to_ban = (next.value, next.weight);
let to_ban = (
next.value,
next.weight,
next.segwit_count,
next.legacy_count,
);
for (next_index, next) in cs.unselected() {
if (next.value, next.weight) != to_ban {
if (
next.value,
next.weight,
next.segwit_count,
next.legacy_count,
) != to_ban
{
break;
}
// for the exclusion branch, we keep banning identical candidates since selecting any one of
// them is equivalent to selecting another. This relies on the sort placing identical
// candidates next to each other.
let mut is_first_ban = true;
let mut exclusion_cs = cs.clone();
let to_ban = next;
for (next_index, next) in cs.unselected() {
if next != to_ban {
break;
}

Then make sort_candidates_by_descending_value_pwu tie-break on every field of the candidate, not just value, so identical candidates are always contiguous. Please also pin the interleaved case with a round-count test (e.g. the 16-candidate case above must finish within a small bound). I checked that the suggestion builds and passes the existing BnB tests.

Comment thread src/coin_selector.rs Outdated
Comment on lines +896 to +903
/// Total weight of including this/these UTXO(s).
/// `txin` fields: `prevout`, `nSequence`, `scriptSigLen`, `scriptSig`, `scriptWitnessLen`,
/// `scriptWitness` should all be included.
///
/// For legacy inputs, do *not* include the `scriptWitnessLen` byte: a legacy input only
/// serializes an (empty) witness when the transaction has a witness section, and
/// [`CoinSelector::input_weight`] adds that 1 WU per legacy input once any segwit input is
/// selected.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two paragraphs contradict each other: the first says scriptWitnessLen is always included, the second says not for legacy inputs. Say it once, per input type:

Suggested change
/// Total weight of including this/these UTXO(s).
/// `txin` fields: `prevout`, `nSequence`, `scriptSigLen`, `scriptSig`, `scriptWitnessLen`,
/// `scriptWitness` should all be included.
///
/// For legacy inputs, do *not* include the `scriptWitnessLen` byte: a legacy input only
/// serializes an (empty) witness when the transaction has a witness section, and
/// [`CoinSelector::input_weight`] adds that 1 WU per legacy input once any segwit input is
/// selected.
/// Total weight of including this/these UTXO(s): `prevout`, `nSequence`, `scriptSigLen` and
/// `scriptSig` for every input, plus `scriptWitnessLen` and `scriptWitness` for segwit inputs only.
///
/// Legacy inputs must not include the 1 WU empty witness they serialize in a segwit transaction;
/// [`CoinSelector::input_weight`] adds it per legacy input once any segwit input is selected.

Comment thread CHANGELOG.md Outdated
# Unreleased

- **Breaking:** `BnbMetric`'s `score`, `bound`, and `drain` take the `target: Target` as a parameter, and `CoinSelector::run_bnb`/`bnb_solutions` gain a leading `target` argument. Consequently `LowestFee` and `Changeless` no longer store a `target` field. This removes the target that `Changeless<M>` previously had to keep in sync with its inner metric, and aligns the metric API with the rest of `CoinSelector`, where `target` is always passed in.
- **Breaking:** Replace `Candidate`'s `input_count` and `is_segwit` fields with `segwit_count` and `legacy_count`, fixing `CoinSelector::input_weight` undercounting candidates that group multiple inputs: in a segwit transaction every legacy input still serializes an empty witness (1 WU), which was previously paid once per candidate instead of once per legacy input, so a group of N legacy inputs came out N-1 WU short. Splitting the count by script type also means a single candidate may now mix legacy and segwit inputs and still be priced exactly. Branch and bound also stops treating candidates of equal value and weight as interchangeable when their segwit/legacy counts differ, which could make it skip a cheaper selection. Replaces `Candidate::new` with `Candidate::new_segwit` and `Candidate::new_legacy`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Callers migrating from Candidate::new need to know that what they pass has changed, not only the name. The old doc said satisfaction_weight covers scriptSigLen + scriptSig + scriptWitnessLen + scriptWitness, but TXIN_BASE_WEIGHT already includes the scriptSigLen byte, so anyone who followed it over-counted by 4 WU. Please add a migration line after the last sentence, e.g.:

Candidate::new(value, w, true) becomes Candidate::new_segwit(value, w) and Candidate::new(value, w, false) becomes Candidate::new_legacy(value, w). w no longer includes the 1-byte scriptSigLen, which TXIN_BASE_WEIGHT already counts; the old docs said it did.

(Adjust the wording if the new_segwit contract changes per the comment there.)

Comment thread tests/weight.rs Outdated
}

/// The same tx with the middle input turned into a (semi-realistic) P2WPKH segwit spend.
fn legacy_three_input_tx_mixed() -> Transaction {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing pins the new constructor docs, and they're the part callers can get wrong without the compiler noticing. The mixed tx already exercises the tricky cases: a 253-byte scriptSig (3-byte varint) and a P2WPKH witness. Something like this checks each constructor against the real input weight (I ran it locally and it passes against the current docs):

#[test]
fn new_segwit_and_new_legacy_match_real_inputs() {
    let tx = legacy_three_input_tx_mixed();
    for (i, txin) in tx.input.iter().enumerate() {
        let script_sig = txin.script_sig.len() as u64;
        let extra_script_sig_len = if script_sig < 253 { 0 } else { 2 };
        let script_sig_weight = (script_sig + extra_script_sig_len) * 4;
        if i == 1 {
            let candidate = Candidate::new_segwit(0, txin.witness.size() as u64 + script_sig_weight);
            assert_eq!(candidate.weight, txin.segwit_weight().to_wu());
        } else {
            let candidate = Candidate::new_legacy(0, script_sig_weight);
            assert_eq!(candidate.weight, txin.legacy_weight().to_wu());
        }
    }
}

Nit while you're here: legacy_three_inputs and legacy_three_inputs_one_segwit still inline the same hex that THREE_INPUT_LEGACY_TX_HEX now holds. They could use legacy_three_input_tx() / legacy_three_input_tx_mixed().

Comment thread README.md Outdated
legacy_count: 0,
// the value of the input
value: 1_000_000,
// the total weight of the input(s) including their witness/scriptSig

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still says weight includes the witness for every input. A caller who follows it for a legacy candidate adds the empty-witness byte themselves, and input_weight then adds it again, overpricing each legacy input by 1 WU. The example candidates are segwit, but this comment is the first thing people read about weight. Please state the legacy exception here, in the same words as the Candidate::weight docs.

@evanlinjin
evanlinjin force-pushed the fix/legacy-pricing-mix branch from 6a0d396 to e51ab23 Compare September 23, 2026 07:13
evanlinjin added a commit that referenced this pull request Sep 25, 2026
…ough every call

57114a8 Give `CoinSelector` its target instead of threading it through every call (志宇)

Pull request description:

  ## Why

  A `CoinSelector` is built for one target and evaluated against it throughout — yet every method took the target as a parameter. Nothing stopped `cs.excess(target_a, drain)` being followed by `cs.is_funded(target_b)`, and the metrics' correctness arguments (e.g. `LowestFee::bound`'s proof that a changeless superset always costs more) are all stated at a *fixed* target, held together by convention rather than by types.

  The crate had already reached this conclusion one layer down: `BnbIter` stored the target as a field, took it once in `new`, and re-passed it into `metric.score`/`metric.bound` at every node. This moves the binding up to where it belongs and deletes the re-threading.

  It also unblocks follow-up work: with the selector knowing its target feerate, ancestor-aware CPFP pricing (#24) can be derived internally at the right rate instead of being validated at every call site. That branch is based on this one.

  ## What

  `CoinSelector::new(candidates, target)` owns the target; `CoinSelector::target()` exposes it for metrics that read it. Twenty signatures **lose** a parameter: fifteen public methods (`excess`, `implied_fee`, `is_funded`, `drain`, `select_until_target_met`, the four `*_excess`, …), `bnb_solutions`/`run_bnb`, and all three `BnbMetric` methods. Across the existing tests and benches, no selector was ever evaluated against more than one target — the per-call flexibility had no consumer.

  ## Breaking changes

  External `BnbMetric` implementations drop the `target: Target` parameter:

  ```rust
  fn score(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32>;
  fn bound(&mut self, cs: &CoinSelector<'_>) -> Option<Ordf32>;
  fn drain(&mut self, cs: &CoinSelector<'_>) -> Drain;
  ```

  Call sites move the target from each method call to `CoinSelector::new`. To evaluate a second target, `CoinSelector::with_target` copies the selector — selection, bans and candidate order — onto it.

  Arguments that merely restated part of the target go the same way: `weight` and `implied_feerate` no longer take `TargetOutputs`, `fee` no longer takes `target_value`, and `effective_value` and `select_all_effective` no longer take a `FeeRate`.

  The unreleased CHANGELOG entry that described the previous target-as-a-parameter API is rewritten to describe this one.

  ## Stack

  This is the base of a stack: #59 → #63 → #84.

  🤖 Generated with [Claude Code](https://claude.com/claude-code)

ACKs for top commit:
  noahjoeris:
    ACK 57114a8

Tree-SHA512: bd51a7b4a1a91881e60efffa8ca57b8f2eb52be9aa03162b2420c5f1b24145375450cfdc96e43b0dc40de4ae56be8317fe77a211b22aa2972519a5473b385486
@evanlinjin
evanlinjin force-pushed the fix/legacy-pricing-mix branch from e51ab23 to 26af408 Compare September 25, 2026 03:55
`CoinSelector::input_weight` added the 1 WU empty witness once per
legacy candidate, undercounting candidates that group several legacy
inputs in a segwit transaction. Instead of tracking segwit and legacy
input counts to price mixed transactions exactly, always price the
transaction as segwit: `Candidate::weight` is the input's segwit
serialized weight (`TxIn::segwit_weight`) and the 2 WU witness header is
always counted. An all-legacy transaction is overestimated by 2 WU plus
1 WU per input, which never undershoots the target feerate.

`Candidate::new` loses its `is_segwit` argument, and `satisfaction_weight`
is now the weight over an unsatisfied `TxIn::default()`, which is exactly
miniscript's `Descriptor::max_weight_to_satisfy` for every script type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@evanlinjin
evanlinjin marked this pull request as draft September 25, 2026 05:10
@evanlinjin
evanlinjin force-pushed the fix/legacy-pricing-mix branch from 26af408 to ad5e6a2 Compare September 25, 2026 05:10
@evanlinjin evanlinjin changed the title fix!: exact weight pricing for grouped legacy inputs in mixed txs fix!: price every transaction as segwit and drop Candidate::is_segwit Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants