11use alloc:: boxed:: Box ;
22use alloc:: vec:: Vec ;
3+ use bitcoin:: { EcdsaSighashType , TapSighashType } ;
34use core:: cmp:: Ordering ;
45use core:: fmt:: { Debug , Display } ;
5-
66use miniscript:: bitcoin;
77use miniscript:: bitcoin:: { absolute, transaction, OutPoint , Psbt , Sequence } ;
88use miniscript:: psbt:: PsbtExt ;
@@ -42,14 +42,6 @@ pub struct PsbtParams {
4242 /// [`non_witness_utxo`]: bitcoin::psbt::Input::non_witness_utxo
4343 pub mandate_full_tx_for_segwit_v0 : bool ,
4444
45- /// Sighash type to be used for each input.
46- ///
47- /// This option only applies to [`Input`]s that include a plan, as otherwise the given PSBT
48- /// input can be expected to set a specific sighash type. Defaults to `None` which will not
49- /// set an explicit sighash type for any input. (In that case the sighash will typically
50- /// cover all of the outputs).
51- pub sighash_type : Option < bitcoin:: psbt:: PsbtSighashType > ,
52-
5345 /// Apply BIP-326 anti-fee-sniping (AFS) protection, using the given block height.
5446 ///
5547 /// * `None` (default) — no AFS is applied.
@@ -79,6 +71,34 @@ pub struct PsbtParams {
7971 ///
8072 /// [`min_locktime`]: Self::min_locktime
8173 pub anti_fee_sniping : Option < absolute:: Height > ,
74+
75+ /// Whether to write `sighash_type` on every Plan-derived input (default: `true`).
76+ ///
77+ /// A safety auto-lock fires independent of this flag: any Plan that contains a 64B Schnorr
78+ /// signature in its witness template gets `TapSighashType::Default` written. The Plan's
79+ /// `satisfaction_weight` already budgeted 64B, so a 65B sig would silently under-fund the
80+ /// tx.
81+ ///
82+ /// For inputs not hit by the safety lock, this flag selects what to write:
83+ ///
84+ /// | Plan's Schnorr placeholders | `false` | `true` |
85+ /// |-----------------------------|----------------|-------------------------|
86+ /// | All 65B | unset | `TapSighashType::All` |
87+ /// | None (ECDSA) | unset | `EcdsaSighashType::All` |
88+ ///
89+ /// `unset` leaves the choice to the signer (BIP-174 implicit `SIGHASH_ALL`); `true`
90+ /// declares the policy explicitly so finalizers enforce it.
91+ ///
92+ /// PSBT-derived inputs ([`Input::from_psbt_input`]) are never touched regardless of this
93+ /// flag.
94+ ///
95+ /// For non-`ALL` sighashes (`SINGLE`, `NONE`, `*_ANYONECANPAY`) on a Plan-derived input,
96+ /// set `psbt.inputs[i].sighash_type` directly on the returned PSBT. Plans needing non-`ALL`
97+ /// semantics on every key should be built with uniform
98+ /// `TaprootCanSign::sighash_default = false` so the safety auto-lock doesn't fire.
99+ ///
100+ /// [`Input::from_psbt_input`]: crate::Input::from_psbt_input
101+ pub declare_sighash : bool ,
82102}
83103
84104impl Default for PsbtParams {
@@ -87,8 +107,8 @@ impl Default for PsbtParams {
87107 version : transaction:: Version :: TWO ,
88108 min_locktime : absolute:: LockTime :: ZERO ,
89109 mandate_full_tx_for_segwit_v0 : true ,
90- sighash_type : None ,
91110 anti_fee_sniping : None ,
111+ declare_sighash : true ,
92112 }
93113 }
94114}
@@ -316,7 +336,26 @@ impl Selection {
316336 }
317337 }
318338
319- psbt_input. sighash_type = params. sighash_type ;
339+ // Safety auto-lock: any 64B Schnorr placeholder forces `Default`, independent
340+ // of `declare_sighash`. A 64B-budgeted Plan signed with a 65B sig would
341+ // silently under-fund the tx, and there is no caller scenario where that's
342+ // intended — so this fires even when declaration is opted out.
343+ use miniscript:: miniscript:: satisfy:: Placeholder ;
344+ let any_64b_schnorr = plan
345+ . witness_template ( )
346+ . iter ( )
347+ . filter_map ( |p| match p {
348+ Placeholder :: SchnorrSigPk ( _, _, size)
349+ | Placeholder :: SchnorrSigPkHash ( _, _, size) => Some ( * size == 64 ) ,
350+ _ => None ,
351+ } )
352+ . reduce ( |a, b| a || b) ;
353+ psbt_input. sighash_type = match ( any_64b_schnorr, params. declare_sighash ) {
354+ ( Some ( true ) , _) => Some ( TapSighashType :: Default . into ( ) ) ,
355+ ( Some ( false ) , true ) => Some ( TapSighashType :: All . into ( ) ) ,
356+ ( None , true ) => Some ( EcdsaSighashType :: All . into ( ) ) ,
357+ ( _, false ) => None ,
358+ } ;
320359
321360 continue ;
322361 }
@@ -358,6 +397,9 @@ mod tests {
358397
359398 const TEST_KEY_HEX : & str = "032b0558078bec38694a84933d659303e2575dae7e91685911454115bfd64487e3" ;
360399 const TEST_KEY_TR : & str = "[83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*" ;
400+ const TEST_KEY_TR_2 : & str = "[83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/1/*" ;
401+ const TEST_KEY_TR_3 : & str = "[44444444/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/2/*" ;
402+ const TEST_KEY_WPKH : & str = "[83737d5e/84h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*" ;
361403
362404 fn setup_cltv_input (
363405 cltv : absolute:: LockTime ,
@@ -807,4 +849,167 @@ mod tests {
807849 shuffled. sort ( ) ;
808850 assert_eq ! ( shuffled, original) ;
809851 }
852+
853+ fn input_with_assets ( desc_str : & str , assets : Assets ) -> anyhow:: Result < Input > {
854+ let secp = Secp256k1 :: new ( ) ;
855+ let ( desc, _) = Descriptor :: parse_descriptor ( & secp, desc_str) ?;
856+ let def_desc = desc. at_derivation_index ( 0 ) ?;
857+ let script_pubkey = def_desc. script_pubkey ( ) ;
858+ let plan = def_desc. plan ( & assets) . expect ( "plan" ) ;
859+ let prev_tx = Transaction {
860+ version : transaction:: Version :: TWO ,
861+ lock_time : absolute:: LockTime :: ZERO ,
862+ input : vec ! [ TxIn :: default ( ) ] ,
863+ output : vec ! [ TxOut {
864+ script_pubkey,
865+ value: Amount :: from_sat( 100_000 ) ,
866+ } ] ,
867+ } ;
868+ Ok ( Input :: from_prev_tx ( plan, prev_tx, 0 , None ) ?)
869+ }
870+
871+ fn non_default_taproot_assets ( key : & DescriptorPublicKey ) -> Assets {
872+ use miniscript:: plan:: { CanSign , TaprootCanSign } ;
873+ let mut assets = Assets :: default ( ) ;
874+ for deriv_path in key. full_derivation_paths ( ) {
875+ let can_sign = CanSign {
876+ ecdsa : true ,
877+ taproot : TaprootCanSign {
878+ sighash_default : false ,
879+ ..TaprootCanSign :: default ( )
880+ } ,
881+ } ;
882+ assets
883+ . keys
884+ . insert ( ( ( key. master_fingerprint ( ) , deriv_path) , can_sign) ) ;
885+ }
886+ assets
887+ }
888+
889+ fn run_sighash_case ( input : Input , params : PsbtParams ) -> anyhow:: Result < bitcoin:: Psbt > {
890+ let output = Output :: with_script ( ScriptBuf :: new ( ) , Amount :: from_sat ( 9_000 ) ) ;
891+ let selection = Selection :: new ( vec ! [ input] , vec ! [ output] ) ;
892+ Ok ( selection. create_psbt ( params) ?)
893+ }
894+
895+ /// `create_psbt` writes the correct `sighash_type` on Plan-derived inputs across every
896+ /// (witness-template, `declare_sighash`) combination:
897+ ///
898+ /// - 64B Schnorr Plan → `Default` (safety lock, regardless of `declare_sighash`).
899+ /// - 65B Schnorr Plan → `All` if `declare_sighash`, else unset.
900+ /// - Mixed 64B+65B Schnorr Plan → `Default` (safety lock fires on *any* 64B placeholder).
901+ /// - ECDSA Plan → `EcdsaSighashType::All` if `declare_sighash`, else unset.
902+ #[ test]
903+ fn test_sighash_policy ( ) -> anyhow:: Result < ( ) > {
904+ use miniscript:: plan:: { CanSign , TaprootCanSign } ;
905+
906+ let tr_key: DescriptorPublicKey = TEST_KEY_TR . parse ( ) ?;
907+ let wpkh_key: DescriptorPublicKey = TEST_KEY_WPKH . parse ( ) ?;
908+
909+ // Mixed-Assets Plan: one key budgeted 64B, one key budgeted 65B. Pins the "any 64B"
910+ // (not "uniformly 64B") predicate for the safety auto-lock.
911+ let mixed_assets = {
912+ let key_default: DescriptorPublicKey = TEST_KEY_TR_2 . parse ( ) ?;
913+ let key_non_default: DescriptorPublicKey = TEST_KEY_TR_3 . parse ( ) ?;
914+ let mut assets = Assets :: default ( ) ;
915+ for deriv_path in key_default. full_derivation_paths ( ) {
916+ assets. keys . insert ( (
917+ ( key_default. master_fingerprint ( ) , deriv_path) ,
918+ CanSign :: default ( ) ,
919+ ) ) ;
920+ }
921+ for deriv_path in key_non_default. full_derivation_paths ( ) {
922+ assets. keys . insert ( (
923+ ( key_non_default. master_fingerprint ( ) , deriv_path) ,
924+ CanSign {
925+ ecdsa : true ,
926+ taproot : TaprootCanSign {
927+ sighash_default : false ,
928+ ..TaprootCanSign :: default ( )
929+ } ,
930+ } ,
931+ ) ) ;
932+ }
933+ assets
934+ } ;
935+
936+ type Expected = Option < bitcoin:: psbt:: PsbtSighashType > ;
937+ let cases: Vec < ( & str , Input , bool , Expected ) > = vec ! [
938+ (
939+ "64B Tap, declare=true" ,
940+ input_with_assets(
941+ & format!( "tr({TEST_KEY_TR})" ) ,
942+ Assets :: new( ) . add( tr_key. clone( ) ) ,
943+ ) ?,
944+ true ,
945+ Some ( TapSighashType :: Default . into( ) ) ,
946+ ) ,
947+ (
948+ "64B Tap, declare=false (safety lock fires)" ,
949+ input_with_assets(
950+ & format!( "tr({TEST_KEY_TR})" ) ,
951+ Assets :: new( ) . add( tr_key. clone( ) ) ,
952+ ) ?,
953+ false ,
954+ Some ( TapSighashType :: Default . into( ) ) ,
955+ ) ,
956+ (
957+ "65B Tap, declare=true" ,
958+ input_with_assets(
959+ & format!( "tr({TEST_KEY_TR})" ) ,
960+ non_default_taproot_assets( & tr_key) ,
961+ ) ?,
962+ true ,
963+ Some ( TapSighashType :: All . into( ) ) ,
964+ ) ,
965+ (
966+ "65B Tap, declare=false" ,
967+ input_with_assets(
968+ & format!( "tr({TEST_KEY_TR})" ) ,
969+ non_default_taproot_assets( & tr_key) ,
970+ ) ?,
971+ false ,
972+ None ,
973+ ) ,
974+ (
975+ "ECDSA, declare=true" ,
976+ input_with_assets(
977+ & format!( "wpkh({TEST_KEY_WPKH})" ) ,
978+ Assets :: new( ) . add( wpkh_key. clone( ) ) ,
979+ ) ?,
980+ true ,
981+ Some ( EcdsaSighashType :: All . into( ) ) ,
982+ ) ,
983+ (
984+ "ECDSA, declare=false" ,
985+ input_with_assets(
986+ & format!( "wpkh({TEST_KEY_WPKH})" ) ,
987+ Assets :: new( ) . add( wpkh_key) ,
988+ ) ?,
989+ false ,
990+ None ,
991+ ) ,
992+ (
993+ "Mixed Tap (64B + 65B)" ,
994+ input_with_assets(
995+ & format!( "tr({TEST_KEY_TR},multi_a(2,{TEST_KEY_TR_2},{TEST_KEY_TR_3}))" ) ,
996+ mixed_assets,
997+ ) ?,
998+ true ,
999+ Some ( TapSighashType :: Default . into( ) ) ,
1000+ ) ,
1001+ ] ;
1002+
1003+ for ( name, input, declare_sighash, expected) in cases {
1004+ let psbt = run_sighash_case (
1005+ input,
1006+ PsbtParams {
1007+ declare_sighash,
1008+ ..Default :: default ( )
1009+ } ,
1010+ ) ?;
1011+ assert_eq ! ( psbt. inputs[ 0 ] . sighash_type, expected, "{name}" ) ;
1012+ }
1013+ Ok ( ( ) )
1014+ }
8101015}
0 commit comments