Repository navigation
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 |
evanlinjin
added a commit
that referenced
this pull request
Oct 2, 2026
…::is_segwit 6a9bed9 fix: exclude only identical candidates together in branch and bound (志宇) 68a5642 fix!: price every transaction as segwit and drop Candidate::is_segwit (志宇) Pull request description: #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. `TXIN_BASE_WEIGHT` now includes the empty witness count byte (165, was 164) and `TX_FIXED_FIELD_WEIGHT` includes the segwit marker and flag (34, was 32). 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. `TR_KEYSPEND_SATISFACTION_WEIGHT` follows this contract (65, was 66), and `new_tr_keyspend` is just `new` with it. `TR_KEYSPEND_TXIN_WEIGHT` is unchanged. **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](https://claude.com/claude-code) ACKs for top commit: LLFourn: ACK 6a9bed9 Tree-SHA512: 485dc7b6a181a79c612256b244df27277b39278576f73be57176c3f22ab5f3350684a479f4a131bd7552325105043e4bd5fa9261bf024cd11644f62f8a56a5ff
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.