diff --git a/src/selection.rs b/src/selection.rs index e1dd410..b0e13ab 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; } @@ -345,16 +364,18 @@ 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/*"; + 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, ) -> 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 +493,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 +684,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 +698,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 { @@ -798,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(()) + } }