Repository navigation
fix!: price every transaction as segwit and drop Candidate::is_segwit - #63
Merged
evanlinjin merged 2 commits intoOct 2, 2026
Merged
Conversation
evanlinjin
force-pushed
the
fix/legacy-pricing-mix
branch
2 times, most recently
from
September 22, 2026 08:34
9d6072b to
85523d8
Compare
This was referenced Sep 22, 2026
evanlinjin
force-pushed
the
fix/legacy-pricing-mix
branch
from
September 22, 2026 09:52
85523d8 to
6a0d396
Compare
This was referenced Sep 22, 2026
LLFourn
requested changes
Sep 23, 2026
evanlinjin
force-pushed
the
fix/legacy-pricing-mix
branch
from
September 23, 2026 07:13
6a0d396 to
e51ab23
Compare
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
force-pushed
the
fix/legacy-pricing-mix
branch
from
September 25, 2026 03:55
e51ab23 to
26af408
Compare
evanlinjin
marked this pull request as draft
September 25, 2026 05:10
evanlinjin
force-pushed
the
fix/legacy-pricing-mix
branch
from
September 25, 2026 05:10
26af408 to
ad5e6a2
Compare
evanlinjin
force-pushed
the
fix/legacy-pricing-mix
branch
10 times, most recently
from
October 1, 2026 06:08
4e59ae8 to
3731b8f
Compare
evanlinjin
force-pushed
the
fix/legacy-pricing-mix
branch
3 times, most recently
from
October 1, 2026 22:39
e15cbb6 to
a2756cb
Compare
evanlinjin
marked this pull request as ready for review
October 1, 2026 23:24
`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`), `TXIN_BASE_WEIGHT` includes the empty witness count byte, and `TX_FIXED_FIELD_WEIGHT` includes the segwit marker and flag. 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. `TR_KEYSPEND_SATISFACTION_WEIGHT` follows that contract and drops the witness item count, so `new_tr_keyspend` is `new` with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKrCjaVEA5NaeQukuhvaWW
The exclusion branch banned every following candidate with the same `(value, weight)`, a tuple that repeated `Candidate`'s fields and left out `input_count`: candidates of equal value and weight but different input counts were banned together, as if interchangeable. `Candidate` now derives `PartialEq`, `Eq`, `PartialOrd` and `Ord`, and branch and bound compares whole candidates. The banning only works on a contiguous run, so `sort_candidates_by_descending_value_pwu` tie-breaks on the whole candidate too, keeping identical candidates next to each other. Equality and order are both defined by the struct, so a new field is picked up by both. A test pins the interleaved case: 16 candidates of equal value and weight with alternating input counts take 52 rounds, and 35,749 without the tie-break. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKrCjaVEA5NaeQukuhvaWW
evanlinjin
force-pushed
the
fix/legacy-pricing-mix
branch
from
October 1, 2026 23:47
50ed68f to
6a9bed9
Compare
Member
Author
|
@LLFourn Thanks for the review. Working through it, I decided exact legacy pricing wasn't worth its complexity, so this now prices every tx as segwit. It's much easier to reason about: one meaning for |
LLFourn
approved these changes
Oct 2, 2026
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.
#84 is stacked on this one.
Fixes
CoinSelector::input_weightundercounting 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:
Candidate::is_segwitis removed.Candidate::weightis the input's segwit-serialized weight (TxIn::segwit_weight), so legacy inputs include their 1 WU empty witness.TXIN_BASE_WEIGHTnow includes the empty witness count byte (165, was 164) andTX_FIXED_FIELD_WEIGHTincludes the segwit marker and flag (34, was 32).Candidate::new(value, satisfaction_weight, is_segwit)becomesCandidate::new(value, satisfaction_weight).satisfaction_weightis now the weight over an unsatisfiedTxIn::default(), which is exactly miniscript'sDescriptor::max_weight_to_satisfyfor every script type.TR_KEYSPEND_SATISFACTION_WEIGHTfollows this contract (65, was 66), andnew_tr_keyspendis justnewwith it.TR_KEYSPEND_TXIN_WEIGHTis 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:
max_weight_to_satisfycan't come out 1 WU short.(value, weight), so there's no second notion of candidate equality that the sort has to keep contiguous.is_segwitlower-bounding hack intests/bnb.rsgoes 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