Skip to content

Commit afff4c6

Browse files
evanlinjinclaude
andcommitted
feat(selection)!: declare sighash_type on Plan-derived inputs
Replaces `PsbtParams::sighash_type: Option<PsbtSighashType>` (a uniform tx-wide override) with `PsbtParams::declare_sighash: bool` (default `true`). A safety auto-lock always fires independent of `declare_sighash`: any Plan whose Schnorr witness template includes a 64B signature gets `TapSighashType::Default` written, preventing silent 64B-budget / 65B-sig under-funding. Mixed-size Plans land here too — the 65B-declared keys are over-funded by 1B each rather than risk under-funding the 64B-declared ones. For the remaining weight-safe inputs, `declare_sighash` controls whether to declare the implicit BIP-174 default explicitly: - `true`: write `TapSighashType::All` / `EcdsaSighashType::All` so finalizers enforce. - `false`: leave unset, caller manages. Breaking: callers using `PsbtParams::sighash_type` must move to post-construction `psbt.inputs[i].sighash_type` mutation. Exhaustive struct-literal construction of `PsbtParams` needs the new `declare_sighash` field (or `..Default::default()`). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent dca56db commit afff4c6

1 file changed

Lines changed: 216 additions & 11 deletions

File tree

src/selection.rs

Lines changed: 216 additions & 11 deletions
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;
@@ -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

84104
impl 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

Comments
 (0)