Skip to content

Commit ca41645

Browse files
committed
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.
1 parent 13a145c commit ca41645

1 file changed

Lines changed: 148 additions & 1 deletion

File tree

src/selection.rs

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
use alloc::boxed::Box;
22
use alloc::vec::Vec;
3+
use bitcoin::{EcdsaSighashType, TapSighashType};
34
use core::cmp::Ordering;
45
use core::fmt::{Debug, Display};
5-
66
use miniscript::bitcoin;
77
use miniscript::bitcoin::{absolute, transaction, OutPoint, Psbt, Sequence};
88
use miniscript::psbt::PsbtExt;
@@ -306,6 +306,25 @@ impl Selection {
306306
)));
307307
}
308308
}
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+
};
309328

310329
continue;
311330
}
@@ -347,6 +366,9 @@ mod tests {
347366

348367
const TEST_KEY_HEX: &str = "032b0558078bec38694a84933d659303e2575dae7e91685911454115bfd64487e3";
349368
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/*";
350372

351373
fn setup_cltv_input(
352374
cltv: absolute::LockTime,
@@ -796,4 +818,129 @@ mod tests {
796818
shuffled.sort();
797819
assert_eq!(shuffled, original);
798820
}
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+
}
799946
}

0 commit comments

Comments
 (0)