Skip to content

fix: define Candidate::weight as segwit-serialized, fixing multi-input candidates - #61

Open
evanlinjin wants to merge 1 commit into
bitcoindevkit:masterfrom
evanlinjin:fix/multi-input-legacy-witness-weight
Open

fix: define Candidate::weight as segwit-serialized, fixing multi-input candidates#61
evanlinjin wants to merge 1 commit into
bitcoindevkit:masterfrom
evanlinjin:fix/multi-input-legacy-witness-weight

Conversation

@evanlinjin

@evanlinjin evanlinjin commented Aug 6, 2026

Copy link
Copy Markdown
Member

Bug: CoinSelector::input_weight undercounts the transaction weight when a Candidate represents more than one input.

In a segwit transaction, every input serializes a witness — a legacy input serializes an empty one, costing 1 WU. A segwit input already counts its witness in Candidate::weight; a legacy input doesn't, so input_weight compensated:

if is_segwit_tx && !candidate.is_segwit {
    weight += 1; // 1 WU per candidate
}

But the cost is 1 WU per legacy input, not per candidate:

  • candidate = 3 legacy inputs → needs +3, got +1
  • candidate = 2 legacy + 1 segwit input → needs +2, got +0 (is_segwit is true, so the branch is skipped)
  • candidate = 1 legacy input → needs +1, got +1 ✅ … the only case the tests exercised

Fix: put the byte into weight when the candidate is built, while each input is still individually visible. Candidate::weight now means the weight of the inputs as serialized in a segwit transaction: legacy inputs include their empty-witness byte.

  • A multi-input candidate is now a plain sum, mixed script types included.
  • is_segwit only decides whether the tx pays the 2 WU witness marker + flag.
  • If the selection ends up with no segwit inputs, the tx has no witness section at all, so input_weight gives 1 WU per input back.

⚠ Silent breaking change: hand-built candidates that use txin.legacy_weight().to_wu() still compile but are now 1 WU short per legacy input — use txin.legacy_weight().to_wu() + 1. Candidate::new already does this for you.

Test: one real tx (2 legacy + 1 segwit inputs) weighed under three groupings — one candidate per input, the legacy pair grouped, all three in one candidate. Each must equal tx.weight(); all three fail on unfixed master.

🤖 Generated with Claude Code

…oups

In a segwit transaction every legacy input still serializes an empty
witness. `input_weight` accounted for that byte by adding it once per
non-segwit *candidate*, which is only correct when a candidate represents
a single input. A candidate grouping N legacy inputs was undercounted by
N-1 weight units, and one mixing legacy and segwit spends by its full
legacy count -- `is_segwit` had to be true for the marker and flag, which
suppressed the adjustment its legacy inputs still needed.

Fix it at the definition instead of the use. `Candidate::weight` now
means the input weight as serialized in a segwit transaction, so a legacy
input counts its empty witness up front, where each input is still
visible individually. `is_segwit` is then only the transaction-level
marker and flag question, and grouping inputs of either script type into
one candidate works.

`input_weight` correspondingly stops adjusting per candidate: if nothing
selected is segwit the transaction has no witness section at all, so
every input takes its byte back.

Existing tests only used single-input candidates, which is why this went
unnoticed. The new test weighs one transaction under three groupings of
the same three inputs -- one per candidate, the two legacy inputs shared,
and all three shared -- and asserts all agree with the real serialized
weight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant