fix: pay the empty-witness byte per legacy input, not per candidate - #62
Closed
LLFourn wants to merge 1 commit into
Closed
fix: pay the empty-witness byte per legacy input, not per candidate#62LLFourn wants to merge 1 commit into
LLFourn wants to merge 1 commit into
Conversation
LLFourn
force-pushed
the
fix/legacy-input-count
branch
from
August 10, 2026 07:24
878f416 to
a9fb6dd
Compare
LLFourn
force-pushed
the
fix/legacy-input-count
branch
from
August 10, 2026 07:26
a9fb6dd to
bbd0ced
Compare
`CoinSelector::input_weight` undercounted the weight of any `Candidate` representing more than one input. In a segwit transaction every input serializes a witness, and a legacy input's empty one costs 1 weight unit, but the code paid that byte once per candidate rather than once per legacy input, so a group of N legacy inputs came out N-1 short. Only single-input candidates were correct, and those were the only ones tested. A candidate already tells us `input_count` and `is_segwit`, which is enough to price this exactly, provided a group does not mix script types. That restriction is now documented: `is_segwit` describes all of a candidate's inputs rather than merely asserting one of them is segwit. It is a precondition of the same kind the type already relies on -- nothing enforces that `weight` or `is_segwit` are truthful either -- and grouping exists to spend UTXOs together, which gives no reason to straddle script types. `input_weight` then becomes pure addition: the witness marker, flag and one empty witness per legacy input are added when the selection contains a segwit spend, and nothing is added when it does not. `Candidate::weight` keeps the meaning it already had, so no existing fee calculation changes, and it stays at or below true marginal input weight in every context -- which is what `min_input_weight` promises and what both `Target::max_weight` prunes in `metrics/lowest_fee.rs` rely on to avoid discarding valid solutions.
LLFourn
force-pushed
the
fix/legacy-input-count
branch
from
August 10, 2026 07:27
bbd0ced to
113a095
Compare
Member
|
Thank you for working on this. The problem is that not being able to mix types in a candidate causes problems for bdk_tx.
I propose replacing |
Member
|
Replaced by #63 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An alternative to #61 — same bug, same diagnosis, smaller fix.
The bug
CoinSelector::input_weightundercounts anyCandidaterepresenting more than one input. In a segwit transaction every input serializes a witness, and a legacy input's empty one costs 1 WU — but the code pays that byte once per candidate:A group of N legacy inputs comes out N−1 short. Single-input candidates are correct, and they were the only shape the tests covered.
The fix
A candidate already carries
input_countandis_segwit. That is enough to price this exactly, provided a group does not mix script types — so this PR states that restriction and uses it:is_segwitis now documented as describing all of a candidate's inputs rather than merely asserting one of them is segwit. That is a precondition of the same kind the type already depends on — nothing enforces thatweightoris_segwitare truthful in the first place — and grouping exists to spend UTXOs together, which gives no reason to straddle script types.No struct change, no new field, and
Candidate::weightkeeps its meaning.Why this rather than #61
#61 redefines
Candidate::weightas the input weight as serialized in a segwit transaction — legacy inputs pre-pay the byte at construction — then refundsinput_countwhen the selection turns out to have no segwit input. Three things follow from that refund:Candidate::weightkeeps its meaning here. Under #61 a caller passingtxin.legacy_weight().to_wu()still compiles and is now 1 WU short per legacy input, so fees come out low and transactions may not relay; #61's own CHANGELOG flags this as a silent break. Nothing here changes what callers must compute, so there is no migration at all.No subtraction, so no underflow.
weightis public with no documented minimum, and #61'sinput_varint_weight + selected_weight - input_countcan wrap:Debug panics; release wraps to ~1.8e19 and silently poisons every downstream
weight(),excess()and BnB bound.min_input_weightstays admissible. Its doc promises "a lower bound on the extra input weight any descendant selection must take on". Under #61, adding a candidate to an all-legacy selection movesinput_weightbyc.weight − c.input_count + Δvarint, soc.weightbecomes an over-estimate and bothTarget::max_weightprunes (metrics/lowest_fee.rs:170and:264) can discard a branch holding a valid within-cap solution — the comment above:264arguing the fractional relaxation "never prunes a branch with an (integer) within-cap solution" stops holding. Here every branch only adds, soc.weightis at or below true marginal cost in every context and neither prune needs to change.The trade is that mixed-script groups stop being supported, where #61 makes them weigh correctly. That is a deliberate scope reduction: there is no use case for one in this repo, and the cost of supporting it is everything above.
Tests
Two groupings of one real mainnet transaction (2 legacy + 1 segwit inputs), each of which must equal
tx.weight()and both of which fail on master:Plus an all-legacy transaction carried by a single 3-input candidate, guarding the no-witness-section path. That one is a guard rather than a regression test — it passes under the old per-candidate accounting too, since a transaction with no witness section adds nothing either way.
cargo test(debug and release),cargo fmt --check,cargo clippy -D warningsand--no-default-featuresall pass.