From 3731b8f1e7a040b3e44789657e737ee7a69cb729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 25 Sep 2026 05:10:07 +0000 Subject: [PATCH] fix!: price every transaction as segwit and drop Candidate::is_segwit `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 Claude-Session: https://claude.ai/code/session_01TKrCjaVEA5NaeQukuhvaWW --- CHANGELOG.md | 3 ++ Cargo.toml | 1 + README.md | 13 +++---- benches/coin_selector.rs | 1 - src/coin_selector.rs | 57 ++++++++++++------------------ src/lib.rs | 20 +++++++---- tests/bnb.rs | 14 +++----- tests/changeless.rs | 1 - tests/common.rs | 4 +-- tests/lowest_fee.rs | 11 ++---- tests/srd.rs | 4 --- tests/weight.rs | 75 +++++++++++++++++++++++++++------------- 12 files changed, 103 insertions(+), 101 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3891a90..54bfec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Unreleased +- **Breaking:** Every transaction is now priced as segwit, and `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. A transaction that spends only legacy inputs is overestimated by 2 WU plus 1 WU per input, and never undershoots the target feerate. The same margin applies to `Target::max_weight`, so such a selection close to the cap can be rejected even though the real transaction fits. This fixes `CoinSelector::input_weight` undercounting candidates that group several legacy inputs in a segwit transaction. +- **Breaking:** `Candidate::new(value, satisfaction_weight, is_segwit)` is now `Candidate::new(value, satisfaction_weight)`, and `satisfaction_weight` now means the weight over an unsatisfied `TxIn::default()`, which is what miniscript's `Descriptor::max_weight_to_satisfy` returns for every script type. Previously it was documented as including the `scriptSig` length and the `scriptWitness` stack item count. To migrate, pass `max_weight_to_satisfy()?.to_wu()` directly. +- **Breaking:** Public weight constants changed value without changing type. `TXIN_BASE_WEIGHT` is now 165 (was 164) and includes the stack item count of an empty `scriptWitness`. `TX_FIXED_FIELD_WEIGHT` is now 34 (was 32) and includes the segwit `marker` and `flag`. `TR_KEYSPEND_SATISFACTION_WEIGHT` is now 65 (was 66) and excludes the `scriptWitness` stack item count. `TR_KEYSPEND_TXIN_WEIGHT` is unchanged at 230. - **Breaking:** `CoinSelector` now owns its `Target`. `CoinSelector::new(candidates, target)` takes it and `CoinSelector::target()` returns it, and it is fixed for the life of the selector. Every method that took a `target: Target` argument no longer does, including `excess`, `missing`, `rate_excess`, `implied_fee`, `is_funded`, `is_within_max_weight`, `drain`, `select_until_target_met`, `select_srd`, `run_bnb`, and `bnb_solutions`. The same goes for arguments that merely restated part of the target: `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`. `BnbMetric`'s methods read the target from the `CoinSelector` they are given — they are now `fn score(&mut self, cs: &CoinSelector<'_>) -> Option`, `fn bound(&mut self, cs: &CoinSelector<'_>) -> Option` and `fn drain(&mut self, cs: &CoinSelector<'_>) -> Drain` — so `LowestFee` and `Changeless` no longer store a `target` field. This removes the target that `Changeless` previously had to keep in sync with its inner metric. To measure a selection against a second target, use `CoinSelector::with_target(target)`, which copies the selection, the bans and the candidate order over to the new target; `CoinSelector::new` starts from an empty selection. - **Breaking:** `BnbMetric` metrics now decide the change output themselves. The trait gains a `drain(&mut self, cs) -> Drain` method; call it on a branch-and-bound solution (or the `LowestFee` metric directly) to get the change output the metric optimized against, instead of computing a separate `ChangePolicy`. - **Breaking:** `CoinSelector::run_bnb` now returns `(Ordf32, Drain)` instead of just `Ordf32`, handing back the change output the metric decided on for the winning selection. diff --git a/Cargo.toml b/Cargo.toml index 14cabad..c9473d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ std = [] rand = "0.9" proptest = "1.7" bitcoin = "0.32" +miniscript = "12" criterion = "0.5" [[bench]] diff --git a/README.md b/README.md index 922dd25..5b52ea1 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,11 @@ let candidates = vec![ input_count: 1, // the value of the input value: 1_000_000, - // the total weight of the input(s) including their witness/scriptSig - // you may need to use miniscript to figure out the correct value here. + // the total weight of the input(s) as serialized in a segwit tx, i.e. + // `TxIn::segwit_weight`. Legacy inputs include their 1 WU empty witness. + // `Candidate::new(value, descriptor.max_weight_to_satisfy()?.to_wu())` + // computes this for you. weight: TR_KEYSPEND_TXIN_WEIGHT, - // wether it's a segwit input. Needed so we know whether to include the - // segwit header in total weight calculations. - is_segwit: true }, Candidate { // A candidate can represent multiple inputs in the case where you @@ -49,7 +48,6 @@ let candidates = vec![ input_count: 2, weight: 2*TR_KEYSPEND_TXIN_WEIGHT, value: 3_000_000, - is_segwit: true } ]; @@ -108,19 +106,16 @@ let candidates = [ input_count: 1, value: 400_000, weight: TR_KEYSPEND_TXIN_WEIGHT, - is_segwit: true }, Candidate { input_count: 1, value: 200_000, weight: TR_KEYSPEND_TXIN_WEIGHT, - is_segwit: true }, Candidate { input_count: 1, value: 11_000, weight: TR_KEYSPEND_TXIN_WEIGHT, - is_segwit: true } ]; let drain_weights = bdk_coin_select::DrainWeights::default(); diff --git a/benches/coin_selector.rs b/benches/coin_selector.rs index 18e847e..6b25066 100644 --- a/benches/coin_selector.rs +++ b/benches/coin_selector.rs @@ -34,7 +34,6 @@ fn make_candidates(n: usize) -> Vec { value, weight: TXIN_BASE_WEIGHT + P2WPKH_SAT_W, input_count: 1, - is_segwit: true, } }) .collect() diff --git a/src/coin_selector.rs b/src/coin_selector.rs index 2866c3b..1935a5d 100644 --- a/src/coin_selector.rs +++ b/src/coin_selector.rs @@ -183,29 +183,15 @@ impl<'a> CoinSelector<'a> { self.selected.is_empty() } - /// The weight of the inputs including the witness header and the varint for the number of - /// inputs. + /// The weight of the inputs including the varint for the number of inputs. + /// + /// Inputs are priced as segwit, so each legacy input is overestimated by its 1 WU empty + /// witness when no segwit input is selected (see [`Candidate::weight`]). pub fn input_weight(&self) -> u64 { - let is_segwit_tx = self.selected().any(|(_, wv)| wv.is_segwit); - let witness_header_extra_weight = is_segwit_tx as u64 * 2; - let input_count = self.selected().map(|(_, wv)| wv.input_count).sum::(); let input_varint_weight = varint_size(input_count) * 4; - - let selected_weight: u64 = self - .selected() - .map(|(_, candidate)| { - let mut weight = candidate.weight; - if is_segwit_tx && !candidate.is_segwit { - // non-segwit candidates do not have the witness length field included in their - // weight field so we need to add 1 here if it's in a segwit tx. - weight += 1; - } - weight - }) - .sum(); - - input_varint_weight + selected_weight + witness_header_extra_weight + let selected_weight: u64 = self.selected().map(|(_, wv)| wv.weight).sum(); + input_varint_weight + selected_weight } /// Absolute value sum of all selected inputs. @@ -921,34 +907,37 @@ impl std::error::Error for NoBnbSolution {} pub struct Candidate { /// Total value of the UTXO(s) that this [`Candidate`] represents. pub value: u64, - /// Total weight of including this/these UTXO(s). - /// `txin` fields: `prevout`, `nSequence`, `scriptSigLen`, `scriptSig`, `scriptWitnessLen`, - /// `scriptWitness` should all be included. + /// Total weight of the input(s) as serialized in a segwit transaction, i.e. the sum of + /// `TxIn::segwit_weight` from rust-bitcoin. That is `prevout`, `scriptSig` and its length, + /// `nSequence`, and `scriptWitness` with its stack item count. A legacy input's empty + /// `scriptWitness` still serializes its stack item count (1 WU) in a segwit transaction. + /// + /// [`CoinSelector`] always prices the transaction as segwit. A transaction that spends only + /// legacy inputs has no witness section, so its weight is overestimated by 2 WU plus 1 WU per + /// input. This never undershoots the target feerate, but such a selection within that margin of + /// [`Target::max_weight`] is rejected even though the real transaction would fit. pub weight: u64, /// Total number of inputs; so we can calculate extra `varint` weight due to `vin` len changes. pub input_count: usize, - /// Whether this [`Candidate`] contains at least one segwit spend. - pub is_segwit: bool, } impl Candidate { /// Create a [`Candidate`] input that spends a single taproot keyspend output. pub fn new_tr_keyspend(value: u64) -> Self { - let weight = TR_KEYSPEND_SATISFACTION_WEIGHT; - Self::new(value, weight, true) + Self::new(value, TR_KEYSPEND_SATISFACTION_WEIGHT) } - /// Create a new [`Candidate`] that represents a single input. + /// Create a new [`Candidate`] that represents a single input of any script type. /// - /// `satisfaction_weight` is the weight of `scriptSigLen + scriptSig + scriptWitnessLen + - /// scriptWitness`. - pub fn new(value: u64, satisfaction_weight: u64, is_segwit: bool) -> Candidate { - let weight = TXIN_BASE_WEIGHT + satisfaction_weight; + /// `satisfaction_weight` is the weight the input adds over an unsatisfied `TxIn::default()`, + /// which is exactly what miniscript's `Descriptor::max_weight_to_satisfy()?.to_wu()` returns. + /// It excludes the 1-byte `scriptSig` length and the 1-byte `scriptWitness` stack item count, + /// which [`TXIN_BASE_WEIGHT`] covers. + pub fn new(value: u64, satisfaction_weight: u64) -> Candidate { Candidate { value, - weight, + weight: TXIN_BASE_WEIGHT + satisfaction_weight, input_count: 1, - is_segwit, } } diff --git a/src/lib.rs b/src/lib.rs index 34c86ad..d59ac14 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,9 +29,10 @@ pub use target::*; mod drain; pub use drain::*; -/// Txin "base" fields include `outpoint` (32+4) and `nSequence` (4) and 1 byte for the scriptSig -/// length. -pub const TXIN_BASE_WEIGHT: u64 = (32 + 4 + 4 + 1) * 4; +/// The weight of an unsatisfied txin: the non-discounted `prevout` (32+4), `nSequence` (4) and +/// empty `scriptSig` length (1), plus the discounted empty `scriptWitness` stack item count (1). +pub const TXIN_BASE_WEIGHT: u64 = + (32 + 4 /* prevout */ + 4 /* nSequence */ + 1 /* scriptSig length */) * 4 + 1 /* stack item count */; /// The weight of a TXOUT with a zero length `scriptPubKey` #[allow(clippy::identity_op)] @@ -41,11 +42,16 @@ pub const TXOUT_BASE_WEIGHT: u64 = // The spk length + (4 * 1); -/// The weight of the `nVersion` and `nLockTime` transaction fields -pub const TX_FIXED_FIELD_WEIGHT: u64 = (4 /* nVersion */ + 4/* nLockTime */) * 4; +/// The weight of the non-discounted `nVersion`, `nLockTime` fields and the discounted segwit +/// `marker` and `flag`. +pub const TX_FIXED_FIELD_WEIGHT: u64 = (4 /* nVersion */ + 4/* nLockTime */) * 4 + 2; -/// The weight of a taproot keyspend witness -pub const TR_KEYSPEND_SATISFACTION_WEIGHT: u64 = 1 /*witness_len*/ + 1 /*item len*/ + 64 /*signature*/; +/// The weight of a taproot keyspend `scriptWitness`, excluding the stack item count that +/// [`TXIN_BASE_WEIGHT`] covers. +/// +/// This assumes a 64-byte `SIGHASH_DEFAULT` signature. miniscript's `max_weight_to_satisfy` assumes +/// the worst case, a 65-byte signature with an explicit sighash byte, and so returns 1 WU more. +pub const TR_KEYSPEND_SATISFACTION_WEIGHT: u64 = 1 /* stack item length */ + 64 /* signature */; /// The weight of a segwit `v1` (taproot) script pubkey in an output. This does not include the weight of /// the `TxOut` itself or the script pubkey length field. diff --git a/tests/bnb.rs b/tests/bnb.rs index 55cf5e7..dbd5fad 100644 --- a/tests/bnb.rs +++ b/tests/bnb.rs @@ -11,16 +11,13 @@ use proptest::{prelude::*, proptest, test_runner::*}; fn test_wv(mut rng: impl RngCore) -> impl Iterator { core::iter::repeat_with(move || { let value = rng.random_range(0..1_000); - let mut candidate = Candidate { + let candidate = Candidate { value, weight: 100, input_count: rng.random_range(1..2), - is_segwit: rng.random_bool(0.5), }; - // HACK: set is_segwit = true for all these tests because you can't actually lower bound - // things easily with how segwit inputs interfere with their weights. We can't modify the - // above since that would change what we pull from rng. - candidate.is_segwit = true; + // This used to draw `is_segwit`. Keep drawing so the rng stream stays the same. + let _ = rng.random_bool(0.5); candidate }) } @@ -62,10 +59,7 @@ fn bnb_finds_an_exact_solution_in_n_iter() { let num_additional_canidates = 12; let mut rng = TestRng::deterministic_rng(RngAlgorithm::ChaCha); - let mut wv = test_wv(&mut rng).map(|mut candidate| { - candidate.is_segwit = true; - candidate - }); + let mut wv = test_wv(&mut rng); let solution: Vec = (0..solution_len).map(|_| wv.next().unwrap()).collect(); let target_value = solution.iter().map(|c| c.value).sum(); diff --git a/tests/changeless.rs b/tests/changeless.rs index 4e9ca81..194b1d4 100644 --- a/tests/changeless.rs +++ b/tests/changeless.rs @@ -15,7 +15,6 @@ fn test_wv(mut rng: impl RngCore) -> impl Iterator { value, weight: rng.random_range(0..100), input_count: rng.random_range(1..2), - is_segwit: false, } }) } diff --git a/tests/common.rs b/tests/common.rs index ffbaadc..1218f87 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -264,13 +264,13 @@ pub fn gen_candidates(n: usize) -> Vec { let value = rng.random_range(1..500_001); let weight = rng.random_range(1..2001); let input_count = rng.random_range(1..3); - let is_segwit = rng.random_bool(0.01); + // This used to draw `is_segwit`. Keep drawing so the rng stream stays the same. + let _ = rng.random_bool(0.01); Candidate { value, weight, input_count, - is_segwit, } }) .take(n) diff --git a/tests/lowest_fee.rs b/tests/lowest_fee.rs index 1d7538b..776f8c1 100644 --- a/tests/lowest_fee.rs +++ b/tests/lowest_fee.rs @@ -4,7 +4,7 @@ mod common; use bdk_coin_select::metrics::{Changeless, LowestFee}; use bdk_coin_select::{ BnbMetric, Candidate, ChangePolicy, CoinSelector, Drain, DrainWeights, FeeRate, NoBnbSolution, - Replace, Target, TargetFee, TargetOutputs, TX_FIXED_FIELD_WEIGHT, + Replace, Target, TargetFee, TargetOutputs, TXIN_BASE_WEIGHT, TX_FIXED_FIELD_WEIGHT, }; use proptest::prelude::*; @@ -84,9 +84,8 @@ proptest! { let candidates = vec![ Candidate { value: 20_000, - weight: (32 + 4 + 4 + 1) * 4 + 64 + 32, + weight: TXIN_BASE_WEIGHT + 64 + 32, input_count: 1, - is_segwit: true, }; params.n_candidates ]; @@ -237,20 +236,17 @@ fn does_not_create_change_below_spend_cost() { value: 100_000, weight: 100, input_count: 1, - is_segwit: true, }, Candidate { value: 50_000, weight: 100, input_count: 1, - is_segwit: true, }, // NOTE: this input has negative effective value Candidate { value: 10, weight: 100, input_count: 1, - is_segwit: true, }, ]; @@ -315,13 +311,11 @@ fn zero_fee_tx() { value: 100_000, weight: 100, input_count: 1, - is_segwit: true, }, Candidate { value: 50_000, weight: 100, input_count: 1, - is_segwit: true, }, ]; @@ -347,7 +341,6 @@ fn err_candidate(value: u64) -> Candidate { value, weight: 272, // ~1 P2WPKH input input_count: 1, - is_segwit: true, } } diff --git a/tests/srd.rs b/tests/srd.rs index 92dc251..70c8b81 100644 --- a/tests/srd.rs +++ b/tests/srd.rs @@ -73,19 +73,16 @@ fn srd_insufficient_funds() { value: 50_000, weight: 100, input_count: 1, - is_segwit: true, }, Candidate { value: 50_000, weight: 100, input_count: 1, - is_segwit: true, }, Candidate { value: 50_000, weight: 100, input_count: 1, - is_segwit: true, }, ]; let target = target(200_000, 5.0); @@ -115,7 +112,6 @@ fn srd_max_weight_exceeded() { value: 100_000, weight: 1000, input_count: 1, - is_segwit: true, }; 10 ]; diff --git a/tests/weight.rs b/tests/weight.rs index 3163fc8..8c16499 100644 --- a/tests/weight.rs +++ b/tests/weight.rs @@ -3,7 +3,8 @@ use bdk_coin_select::{ Candidate, CoinSelector, Drain, DrainWeights, Target, TargetFee, TargetOutputs, }; -use bitcoin::{consensus::Decodable, ScriptBuf, Transaction}; +use bitcoin::{consensus::Decodable, Script, ScriptBuf, Transaction}; +use miniscript::{Descriptor, Miniscript}; fn hex_val(c: u8) -> u8 { match c { @@ -38,7 +39,6 @@ fn segwit_one_input_one_output() { value, weight: txin.segwit_weight().to_wu(), input_count: 1, - is_segwit: true, }) .collect::>(); @@ -86,7 +86,6 @@ fn segwit_two_inputs_one_output() { value, weight: txin.segwit_weight().to_wu(), input_count: 1, - is_segwit: true, }) .collect::>(); @@ -132,9 +131,8 @@ fn legacy_three_inputs() { .zip(input_values) .map(|(txin, value)| Candidate { value, - weight: txin.legacy_weight().to_wu(), + weight: txin.segwit_weight().to_wu(), input_count: 1, - is_segwit: false, }) .collect::>(); @@ -152,9 +150,11 @@ fn legacy_three_inputs() { let mut coin_selector = CoinSelector::new(&candidates, target); coin_selector.select_all(); + // Every tx is priced as segwit, so an all-legacy tx pays for the 2 WU witness header and a + // 1 WU empty witness per input that it doesn't actually serialize. assert_eq!( coin_selector.weight(DrainWeights::NONE), - orig_weight.to_wu() + orig_weight.to_wu() + 2 + 3 ); assert_eq!( (coin_selector @@ -163,14 +163,14 @@ fn legacy_three_inputs() { .as_sat_vb() * 10.0) .round(), - 99.2 * 10.0 + 99.1 * 10.0 ); } -#[test] -fn legacy_three_inputs_one_segwit() { +/// The legacy tx from `legacy_three_inputs`, except the middle input is changed to a P2WPKH spend. +/// Inputs 0 and 2 are legacy, each with a 253-byte scriptSig (a 3-byte length varint). +fn legacy_three_inputs_one_segwit_tx() -> Transaction { // FROM https://mempool.space/tx/5f231df4f73694b3cca9211e336451c20dab136e0a843c2e3166cdcb093e91f4 - // Except we change the middle input to segwit let tx_bytes = hex_decode("0100000003fe785783e14669f638ba902c26e8e3d7036fb183237bc00f8a10542191c7171300000000fdfd00004730440220418996f20477d143d02ad47e74e5949641b6c2904159ab7c592d2cfc659f9bd802205b18f18ac86b714971f84a8b74a4cb14ad5c1a5b9d0d939bb32c6ae4032f4ea10148304502210091296ff8dd87b5ebfc3d47cb82cfe4750d52c544a2b88a85970354a4d0d4b1db022069632067ee6f30f06145f649bc76d5e5d5e6404dbe985e006fcde938f778c297014c695221030502b8ade694d57a6e86998180a64f4ce993372830dc796c3d561ad8b2a504de210272b68e1c037c4630eff7ea5858640cc0748e36f5de82fb38529ef1fd0a89670d2103ba0544a3a2aa9f2314022760b78b5c833aebf6f88468a089550f93834a2886ed53aeffffffff7e048a7c53a8af656e24442c65fe4c4299b1494f6c7579fe0fd9fa741ce83e3279000000fc004730440220018fa343acccd048ed8f8f179e1b6ae27435a41b5fb2c1d96a5a772777acc6dc022074783814f2100c6fc4d4c976f941212be50825814502ca0cbe3f929db789979e0147304402206373f01b73fb09876d0f5ee3087e0614cab3be249934bc2b7eb64ee67f53dc8302200b50f8a327020172b82aaba7480c77ecf07bb32322a05f4afbc543aa97d2fde8014c69522103039d906b2494e310f6c7774c98618be552720d04781e073dd3ff25d5906f22662103d82026baa529619b103ec6341d548a7eb6d924061a8469a7416155513a3071c12102e452bc4aa726d44646ba80db70465683b30efde282a19aa35c6029ae8925df5e53aeffffffffef80f0b1cc543de4f73d59c02a3c575ae5d0af17c1e11e6be7abe3325c777507ad000000fdfd00004730440220220fee11bf836621a11a8ea9100a4600c109c13895f11468d3e2062210c5481902201c5c8a462175538e87b8248e1ed3927c3a461c66d1b46215641c875e86eb22c4014830450221008d2de8c2f20a720129c372791e595b9602b1a9bce99618497aec5266148ffc1302203a493359d700ed96323f8805ed03e909959ff0f22eff359028db6861486b1555014c6952210374a4add33567f09967592c5bcdc3db421fdbba67bac4636328f96d941da31bd221039636c2ffac90afb7499b16e265078113dfb2d77b54270e37353217c9eaeaf3052103d0bcea6d10cdd2f16018ea71572631708e26f457f67cda36a7f816a87f7791d253aeffffffff04977261000000000016001470385d054721987f41521648d7b2f5c77f735d6bee92030000000000225120d0cda1b675a0b369964cbfa381721aae3549dd2c9c6f2cf71ff67d5bc277afd3f2aaf30000000000160014ed2d41ba08313dbb2630a7106b2fedafc14aa121d4f0c70000000000220020e5c7c00d174631d2d1e365d6347b016fb87b6a0c08902d8e443989cb771fa7ec00000000"); let mut tx = Transaction::consensus_decode(&mut tx_bytes.as_slice()).unwrap(); tx.input[1].script_sig = ScriptBuf::default(); @@ -179,25 +179,21 @@ fn legacy_three_inputs_one_segwit() { hex_decode("3045022100bdc115b86e9c863279132b4808459cf9b266c8f6a9c14a3dfd956986b807e3320220265833b85197679687c5d5eed1b2637489b34249d44cf5d2d40bc7b514181a5101"), hex_decode("02077741a668889ce15d59365886375aea47a7691941d7a0d301697edbc773b45b"), ].into(); + tx +} + +#[test] +fn legacy_three_inputs_one_segwit() { + let tx = legacy_three_inputs_one_segwit_tx(); let input_values = vec![022_680_000, 006_558_175, 006_558_200]; let candidates = tx .input .iter() .zip(input_values) - .enumerate() - .map(|(i, (txin, value))| { - let is_segwit = i == 1; - Candidate { - value, - weight: if is_segwit { - txin.segwit_weight() - } else { - txin.legacy_weight() - } - .to_wu(), - input_count: 1, - is_segwit, - } + .map(|(txin, value)| Candidate { + value, + weight: txin.segwit_weight().to_wu(), + input_count: 1, }) .collect::>(); @@ -232,3 +228,34 @@ fn new_tr_keyspend_correct_weight() { Candidate::new_tr_keyspend(420).weight ); } + +/// What `Candidate::new` gives when passed miniscript's `max_weight_to_satisfy`. +fn miniscript_estimate(descriptor: &Descriptor) -> u64 { + Candidate::new(0, descriptor.max_weight_to_satisfy().unwrap().to_wu()).weight +} + +#[test] +fn new_with_miniscript_max_weight_to_satisfy_covers_real_inputs() { + // miniscript assumes every ECDSA signature is 72 bytes (with the sighash byte). + let tx = legacy_three_inputs_one_segwit_tx(); + + // P2WPKH: its signature is 72 bytes, so the estimate is exact. + let p2wpkh = &tx.input[1]; + let pubkey = bitcoin::PublicKey::from_slice(p2wpkh.witness.last().unwrap()).unwrap(); + let descriptor = Descriptor::new_wpkh(pubkey).unwrap(); + assert_eq!( + miniscript_estimate(&descriptor), + p2wpkh.segwit_weight().to_wu() + ); + + // P2SH 2-of-3 multisig: one of its signatures is 71 bytes, so the estimate is one scriptSig + // byte (4 WU) over. The redeem script is the last push in the scriptSig. + let p2sh = &tx.input[0]; + let redeem_script = p2sh.script_sig.instructions().last().unwrap().unwrap(); + let redeem_script = Script::from_bytes(redeem_script.push_bytes().unwrap().as_bytes()); + let descriptor = Descriptor::new_sh(Miniscript::parse(redeem_script).unwrap()).unwrap(); + assert_eq!( + miniscript_estimate(&descriptor), + p2sh.segwit_weight().to_wu() + 4 + ); +}