|
1 | 1 | use alloc::boxed::Box; |
2 | 2 | use alloc::vec::Vec; |
| 3 | +use bitcoin::{EcdsaSighashType, TapSighashType}; |
3 | 4 | use core::cmp::Ordering; |
4 | 5 | use core::fmt::{Debug, Display}; |
5 | | - |
6 | 6 | use miniscript::bitcoin; |
7 | 7 | use miniscript::bitcoin::{absolute, transaction, OutPoint, Psbt, Sequence}; |
8 | 8 | use miniscript::psbt::PsbtExt; |
@@ -306,6 +306,25 @@ impl Selection { |
306 | 306 | ))); |
307 | 307 | } |
308 | 308 | } |
| 309 | + // Safety auto-lock: any 64B Schnorr placeholder forces `Default`, independent |
| 310 | + // of `declare_sighash`. A 64B-budgeted Plan signed with a 65B sig would |
| 311 | + // silently under-fund the tx, and there is no caller scenario where that's |
| 312 | + // intended — so this fires even when declaration is opted out. |
| 313 | + use miniscript::miniscript::satisfy::Placeholder; |
| 314 | + let any_64b_schnorr = plan |
| 315 | + .witness_template() |
| 316 | + .iter() |
| 317 | + .filter_map(|p| match p { |
| 318 | + Placeholder::SchnorrSigPk(_, _, size) |
| 319 | + | Placeholder::SchnorrSigPkHash(_, _, size) => Some(*size == 64), |
| 320 | + _ => None, |
| 321 | + }) |
| 322 | + .reduce(|a, b| a || b); |
| 323 | + psbt_input.sighash_type = match any_64b_schnorr { |
| 324 | + Some(true) => Some(TapSighashType::Default.into()), |
| 325 | + Some(false) => Some(TapSighashType::All.into()), |
| 326 | + None => Some(EcdsaSighashType::All.into()), |
| 327 | + }; |
309 | 328 |
|
310 | 329 | continue; |
311 | 330 | } |
@@ -347,6 +366,9 @@ mod tests { |
347 | 366 |
|
348 | 367 | const TEST_KEY_HEX: &str = "032b0558078bec38694a84933d659303e2575dae7e91685911454115bfd64487e3"; |
349 | 368 | const TEST_KEY_TR: &str = "[83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*"; |
| 369 | + const TEST_KEY_TR_2: &str = "[83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/1/*"; |
| 370 | + const TEST_KEY_TR_3: &str = "[44444444/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/2/*"; |
| 371 | + const TEST_KEY_WPKH: &str = "[83737d5e/84h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*"; |
350 | 372 |
|
351 | 373 | fn setup_cltv_input( |
352 | 374 | cltv: absolute::LockTime, |
@@ -796,4 +818,129 @@ mod tests { |
796 | 818 | shuffled.sort(); |
797 | 819 | assert_eq!(shuffled, original); |
798 | 820 | } |
| 821 | + |
| 822 | + fn input_with_assets(desc_str: &str, assets: Assets) -> anyhow::Result<Input> { |
| 823 | + let secp = Secp256k1::new(); |
| 824 | + let (desc, _) = Descriptor::parse_descriptor(&secp, desc_str)?; |
| 825 | + let def_desc = desc.at_derivation_index(0)?; |
| 826 | + let script_pubkey = def_desc.script_pubkey(); |
| 827 | + let plan = def_desc.plan(&assets).expect("plan"); |
| 828 | + let prev_tx = Transaction { |
| 829 | + version: transaction::Version::TWO, |
| 830 | + lock_time: absolute::LockTime::ZERO, |
| 831 | + input: vec![TxIn::default()], |
| 832 | + output: vec![TxOut { |
| 833 | + script_pubkey, |
| 834 | + value: Amount::from_sat(100_000), |
| 835 | + }], |
| 836 | + }; |
| 837 | + Ok(Input::from_prev_tx(plan, prev_tx, 0, None)?) |
| 838 | + } |
| 839 | + |
| 840 | + fn non_default_taproot_assets(key: &DescriptorPublicKey) -> Assets { |
| 841 | + use miniscript::plan::{CanSign, TaprootCanSign}; |
| 842 | + let mut assets = Assets::default(); |
| 843 | + for deriv_path in key.full_derivation_paths() { |
| 844 | + let can_sign = CanSign { |
| 845 | + ecdsa: true, |
| 846 | + taproot: TaprootCanSign { |
| 847 | + sighash_default: false, |
| 848 | + ..TaprootCanSign::default() |
| 849 | + }, |
| 850 | + }; |
| 851 | + assets |
| 852 | + .keys |
| 853 | + .insert(((key.master_fingerprint(), deriv_path), can_sign)); |
| 854 | + } |
| 855 | + assets |
| 856 | + } |
| 857 | + |
| 858 | + fn run_sighash_case(input: Input, params: PsbtParams) -> anyhow::Result<bitcoin::Psbt> { |
| 859 | + let output = Output::with_script(ScriptBuf::new(), Amount::from_sat(9_000)); |
| 860 | + let selection = Selection::new(vec![input], vec![output]); |
| 861 | + Ok(selection.create_psbt(params)?) |
| 862 | + } |
| 863 | + |
| 864 | + /// `create_psbt` writes the correct `sighash_type` on Plan-derived inputs across every |
| 865 | + /// (witness-template, `declare_sighash`) combination: |
| 866 | + /// |
| 867 | + /// - 64B Schnorr Plan → `Default`. |
| 868 | + /// - 65B Schnorr Plan → `All`. |
| 869 | + /// - Mixed 64B+65B Schnorr Plan → `Default`. |
| 870 | + /// - ECDSA Plan → `EcdsaSighashType::All`. |
| 871 | + #[test] |
| 872 | + fn test_sighash_policy() -> anyhow::Result<()> { |
| 873 | + use miniscript::plan::{CanSign, TaprootCanSign}; |
| 874 | + |
| 875 | + let tr_key: DescriptorPublicKey = TEST_KEY_TR.parse()?; |
| 876 | + let wpkh_key: DescriptorPublicKey = TEST_KEY_WPKH.parse()?; |
| 877 | + |
| 878 | + // Mixed-Assets Plan: one key budgeted 64B, one key budgeted 65B. |
| 879 | + let mixed_assets = { |
| 880 | + let key_default: DescriptorPublicKey = TEST_KEY_TR_2.parse()?; |
| 881 | + let key_non_default: DescriptorPublicKey = TEST_KEY_TR_3.parse()?; |
| 882 | + let mut assets = Assets::default(); |
| 883 | + for deriv_path in key_default.full_derivation_paths() { |
| 884 | + assets.keys.insert(( |
| 885 | + (key_default.master_fingerprint(), deriv_path), |
| 886 | + CanSign::default(), |
| 887 | + )); |
| 888 | + } |
| 889 | + for deriv_path in key_non_default.full_derivation_paths() { |
| 890 | + assets.keys.insert(( |
| 891 | + (key_non_default.master_fingerprint(), deriv_path), |
| 892 | + CanSign { |
| 893 | + ecdsa: true, |
| 894 | + taproot: TaprootCanSign { |
| 895 | + sighash_default: false, |
| 896 | + ..TaprootCanSign::default() |
| 897 | + }, |
| 898 | + }, |
| 899 | + )); |
| 900 | + } |
| 901 | + assets |
| 902 | + }; |
| 903 | + |
| 904 | + type Expected = Option<bitcoin::psbt::PsbtSighashType>; |
| 905 | + let cases: Vec<(&str, Input, Expected)> = vec![ |
| 906 | + ( |
| 907 | + "64B Tap", |
| 908 | + input_with_assets( |
| 909 | + &format!("tr({TEST_KEY_TR})"), |
| 910 | + Assets::new().add(tr_key.clone()), |
| 911 | + )?, |
| 912 | + Some(TapSighashType::Default.into()), |
| 913 | + ), |
| 914 | + ( |
| 915 | + "65B Tap", |
| 916 | + input_with_assets( |
| 917 | + &format!("tr({TEST_KEY_TR})"), |
| 918 | + non_default_taproot_assets(&tr_key), |
| 919 | + )?, |
| 920 | + Some(TapSighashType::All.into()), |
| 921 | + ), |
| 922 | + ( |
| 923 | + "ECDSA", |
| 924 | + input_with_assets( |
| 925 | + &format!("wpkh({TEST_KEY_WPKH})"), |
| 926 | + Assets::new().add(wpkh_key.clone()), |
| 927 | + )?, |
| 928 | + Some(EcdsaSighashType::All.into()), |
| 929 | + ), |
| 930 | + ( |
| 931 | + "Mixed Tap (64B + 65B)", |
| 932 | + input_with_assets( |
| 933 | + &format!("tr({TEST_KEY_TR},multi_a(2,{TEST_KEY_TR_2},{TEST_KEY_TR_3}))"), |
| 934 | + mixed_assets, |
| 935 | + )?, |
| 936 | + Some(TapSighashType::Default.into()), |
| 937 | + ), |
| 938 | + ]; |
| 939 | + |
| 940 | + for (name, input, expected) in cases { |
| 941 | + let psbt = run_sighash_case(input, PsbtParams::default())?; |
| 942 | + assert_eq!(psbt.inputs[0].sighash_type, expected, "{name}"); |
| 943 | + } |
| 944 | + Ok(()) |
| 945 | + } |
799 | 946 | } |
0 commit comments