fix!: price every transaction as segwit and drop Candidate::is_segwit - #63
evanlinjin wants to merge 1 commit into
Conversation
9d6072b to
85523d8
Compare
85523d8 to
6a0d396
Compare
| /// | ||
| /// 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 { |
There was a problem hiding this comment.
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.
| // 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; | ||
| } |
There was a problem hiding this comment.
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:
| // 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.
| /// 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. |
There was a problem hiding this comment.
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:
| /// 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. |
| # 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`. |
There was a problem hiding this comment.
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)becomesCandidate::new_segwit(value, w)andCandidate::new(value, w, false)becomesCandidate::new_legacy(value, w).wno longer includes the 1-bytescriptSigLen, whichTXIN_BASE_WEIGHTalready counts; the old docs said it did.
(Adjust the wording if the new_segwit contract changes per the comment there.)
| } | ||
|
|
||
| /// The same tx with the middle input turned into a (semi-realistic) P2WPKH segwit spend. | ||
| fn legacy_three_input_tx_mixed() -> Transaction { |
There was a problem hiding this comment.
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().
| legacy_count: 0, | ||
| // the value of the input | ||
| value: 1_000_000, | ||
| // the total weight of the input(s) including their witness/scriptSig |
There was a problem hiding this comment.
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.
6a0d396 to
e51ab23
Compare
…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
e51ab23 to
26af408
Compare
`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>
26af408 to
ad5e6a2
Compare
#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, andinput_weightalways counts the 2 WU witness header.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.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