From 13a145ca7f8f1577353d620b1f6655afc57a6005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 28 May 2026 07:37:41 +0000 Subject: [PATCH 1/2] test(selection): Refactor tests to reuse key strings Previously, we had a string constants for test descriptors and test keys where the test keys were also hardcoded in the test descriptors. We remove the hard coded test descriptor and rename variables for clarity. --- src/selection.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/selection.rs b/src/selection.rs index e1dd4100..8f1437c8 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -345,16 +345,15 @@ mod tests { use miniscript::{plan::Assets, Descriptor, DescriptorPublicKey}; use rand_core::OsRng; - const TEST_DESCRIPTOR: &str = "tr([83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*)"; - const TEST_DESCRIPTOR_PK: &str = "[83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*"; - const TEST_HEX_PK: &str = "032b0558078bec38694a84933d659303e2575dae7e91685911454115bfd64487e3"; + const TEST_KEY_HEX: &str = "032b0558078bec38694a84933d659303e2575dae7e91685911454115bfd64487e3"; + const TEST_KEY_TR: &str = "[83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*"; fn setup_cltv_input( cltv: absolute::LockTime, ) -> anyhow::Result<(Input, Descriptor)> { let secp = Secp256k1::new(); - let desc_str = format!("wsh(and_v(v:pk({TEST_HEX_PK}),after({cltv})))"); - let desc_pk: DescriptorPublicKey = TEST_HEX_PK.parse()?; + let desc_str = format!("wsh(and_v(v:pk({TEST_KEY_HEX}),after({cltv})))"); + let desc_pk: DescriptorPublicKey = TEST_KEY_HEX.parse()?; let (desc, _) = Descriptor::parse_descriptor(&secp, &desc_str)?; let plan = desc .at_derivation_index(0)? @@ -472,12 +471,12 @@ mod tests { pub fn setup_test_input(confirmation_height: u32) -> anyhow::Result { let secp = Secp256k1::new(); - let desc = Descriptor::parse_descriptor(&secp, TEST_DESCRIPTOR) + let desc = Descriptor::parse_descriptor(&secp, &format!("tr({TEST_KEY_TR})")) .unwrap() .0; let def_desc = desc.at_derivation_index(0).unwrap(); let script_pubkey = def_desc.script_pubkey(); - let desc_pk: DescriptorPublicKey = TEST_DESCRIPTOR_PK.parse()?; + let desc_pk: DescriptorPublicKey = TEST_KEY_TR.parse()?; let assets = Assets::new().add(desc_pk); let plan = def_desc.plan(&assets).expect("failed to create plan"); @@ -663,8 +662,7 @@ mod tests { // `assets`, forcing planning to use the script-path leaf (which sets // `plan.relative_timelock`). let secp = Secp256k1::new(); - let desc_str = - format!("tr({TEST_HEX_PK},and_v(v:pk({TEST_DESCRIPTOR_PK}),older({csv_blocks})))"); + let desc_str = format!("tr({TEST_KEY_HEX},and_v(v:pk({TEST_KEY_TR}),older({csv_blocks})))"); let desc = Descriptor::parse_descriptor(&secp, &desc_str)? .0 .at_derivation_index(0)?; @@ -678,7 +676,7 @@ mod tests { }], }; let assets = Assets::new() - .add(TEST_DESCRIPTOR_PK.parse::()?) + .add(TEST_KEY_TR.parse::()?) .older(relative::LockTime::from_height(csv_blocks)); let plan = desc.plan(&assets).expect("script-path plan with CSV"); let status = crate::ConfirmationStatus { From 0a1da02dfe72ed345fac6b390b970584a8a1014d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Thu, 28 May 2026 07:56:52 +0000 Subject: [PATCH 2/2] feat(selection): always declare psbt input sighash type Any Plan whose Schnorr witness template includes a 64B signature gets a DEFAULT sighash type (this includes mixed-size plans). All other plans require ALL sighash. --- src/selection.rs | 138 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 137 insertions(+), 1 deletion(-) diff --git a/src/selection.rs b/src/selection.rs index 8f1437c8..b0e13ab7 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -1,8 +1,8 @@ use alloc::boxed::Box; use alloc::vec::Vec; +use bitcoin::{EcdsaSighashType, TapSighashType}; use core::cmp::Ordering; use core::fmt::{Debug, Display}; - use miniscript::bitcoin; use miniscript::bitcoin::{absolute, transaction, OutPoint, Psbt, Sequence}; use miniscript::psbt::PsbtExt; @@ -306,6 +306,25 @@ impl Selection { ))); } } + // Safety auto-lock: any 64B Schnorr placeholder forces `Default`, independent + // of `declare_sighash`. A 64B-budgeted Plan signed with a 65B sig would + // silently under-fund the tx, and there is no caller scenario where that's + // intended - so this fires even when declaration is opted out. + use miniscript::miniscript::satisfy::Placeholder; + let any_64b_schnorr = plan + .witness_template() + .iter() + .filter_map(|p| match p { + Placeholder::SchnorrSigPk(_, _, size) + | Placeholder::SchnorrSigPkHash(_, _, size) => Some(*size == 64), + _ => None, + }) + .reduce(|a, b| a || b); + psbt_input.sighash_type = match any_64b_schnorr { + Some(true) => Some(TapSighashType::Default.into()), + Some(false) => Some(TapSighashType::All.into()), + None => Some(EcdsaSighashType::All.into()), + }; continue; } @@ -347,6 +366,9 @@ mod tests { const TEST_KEY_HEX: &str = "032b0558078bec38694a84933d659303e2575dae7e91685911454115bfd64487e3"; const TEST_KEY_TR: &str = "[83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*"; + const TEST_KEY_TR_2: &str = "[83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/1/*"; + const TEST_KEY_TR_3: &str = "[44444444/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/2/*"; + const TEST_KEY_WPKH: &str = "[83737d5e/84h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*"; fn setup_cltv_input( cltv: absolute::LockTime, @@ -796,4 +818,118 @@ mod tests { shuffled.sort(); assert_eq!(shuffled, original); } + + fn input_with_assets(desc_str: &str, assets: Assets) -> anyhow::Result { + let secp = Secp256k1::new(); + let (desc, _) = Descriptor::parse_descriptor(&secp, desc_str)?; + let def_desc = desc.at_derivation_index(0)?; + let script_pubkey = def_desc.script_pubkey(); + let plan = def_desc.plan(&assets).expect("plan"); + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey, + value: Amount::from_sat(100_000), + }], + }; + Ok(Input::from_prev_tx(plan, prev_tx, 0, None)?) + } + + fn non_default_taproot_assets(key: &DescriptorPublicKey) -> Assets { + use miniscript::plan::{CanSign, TaprootCanSign}; + let mut assets = Assets::default(); + for deriv_path in key.full_derivation_paths() { + let can_sign = CanSign { + ecdsa: true, + taproot: TaprootCanSign { + sighash_default: false, + ..TaprootCanSign::default() + }, + }; + assets + .keys + .insert(((key.master_fingerprint(), deriv_path), can_sign)); + } + assets + } + + fn run_sighash_case(input: Input, params: PsbtParams) -> anyhow::Result { + let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); + let selection = Selection::new(vec![input], vec![output]); + Ok(selection.create_psbt(params)?) + } + + /// `create_psbt` writes the correct `sighash_type` on Plan-derived inputs across every + /// (witness-template, `declare_sighash`) combination: + /// + /// - 64B Schnorr Plan -> `Default`. + /// - 65B Schnorr Plan -> `All`. + /// - Mixed 64B+65B Schnorr Plan -> `Default`. + /// - ECDSA Plan -> `EcdsaSighashType::All`. + #[test] + fn test_sighash_policy() -> anyhow::Result<()> { + use miniscript::plan::CanSign; + + let tr_key: DescriptorPublicKey = TEST_KEY_TR.parse()?; + let wpkh_key: DescriptorPublicKey = TEST_KEY_WPKH.parse()?; + + // Mixed-Assets Plan: one key budgeted 64B, one key budgeted 65B. + let mixed_assets = { + let key_non_default: DescriptorPublicKey = TEST_KEY_TR_3.parse()?; + let key_default: DescriptorPublicKey = TEST_KEY_TR_2.parse()?; + + let mut assets = non_default_taproot_assets(&key_non_default); + for deriv_path in key_default.full_derivation_paths() { + assets.keys.insert(( + (key_default.master_fingerprint(), deriv_path), + CanSign::default(), + )); + } + assets + }; + + type Expected = Option; + let cases: Vec<(&str, Input, Expected)> = vec![ + ( + "64B Tap", + input_with_assets( + &format!("tr({TEST_KEY_TR})"), + Assets::new().add(tr_key.clone()), + )?, + Some(TapSighashType::Default.into()), + ), + ( + "65B Tap", + input_with_assets( + &format!("tr({TEST_KEY_TR})"), + non_default_taproot_assets(&tr_key), + )?, + Some(TapSighashType::All.into()), + ), + ( + "ECDSA", + input_with_assets( + &format!("wpkh({TEST_KEY_WPKH})"), + Assets::new().add(wpkh_key.clone()), + )?, + Some(EcdsaSighashType::All.into()), + ), + ( + "Mixed Tap (64B + 65B)", + input_with_assets( + &format!("tr({TEST_KEY_TR},multi_a(2,{TEST_KEY_TR_2},{TEST_KEY_TR_3}))"), + mixed_assets, + )?, + Some(TapSighashType::Default.into()), + ), + ]; + + for (name, input, expected) in cases { + let psbt = run_sighash_case(input, PsbtParams::default())?; + assert_eq!(psbt.inputs[0].sighash_type, expected, "{name}"); + } + Ok(()) + } }