From 3ea85fa9506bc96440096a9d11cceffdbf5eab44 Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Sun, 15 Jan 2023 14:12:23 +0100 Subject: [PATCH] descriptor: introduce a helper to analyze a PSBT spending Liana coins --- src/descriptors.rs | 496 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 471 insertions(+), 25 deletions(-) diff --git a/src/descriptors.rs b/src/descriptors.rs index 02121c95..420c58e6 100644 --- a/src/descriptors.rs +++ b/src/descriptors.rs @@ -4,7 +4,10 @@ use miniscript::{ blockdata::transaction::Sequence, hashes::{hash160, ripemd160, sha256}, secp256k1, - util::bip32, + util::{ + bip32, + psbt::{Input as PsbtIn, Psbt}, + }, }, descriptor, hash256, miniscript::{decode::Terminal, Miniscript}, @@ -14,7 +17,7 @@ use miniscript::{ }; use std::{ - collections::{BTreeMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet}, convert::TryFrom, error, fmt, str, sync, }; @@ -40,6 +43,10 @@ pub enum LianaDescError { DerivedKeyParsing, InvalidMultiThresh(usize), InvalidMultiKeys(usize), + /// Different number of PSBT vs tx inputs, etc.. + InsanePsbt, + /// Not all inputs' sequence the same, not all inputs signed with the same key, .. + InconsistentPsbt, } impl std::fmt::Display for LianaDescError { @@ -63,6 +70,8 @@ impl std::fmt::Display for LianaDescError { Self::DerivedKeyParsing => write!(f, "Parsing derived key,"), Self::InvalidMultiThresh(thresh) => write!(f, "Invalid threshold value '{}'. The threshold must be > to 0 and <= to the number of keys.", thresh), Self::InvalidMultiKeys(n_keys) => write!(f, "Invalid number of keys '{}'. Between 2 and 20 keys must be given to use multiple keys in a specific path.", n_keys), + Self::InsanePsbt => write!(f, "Analyzed PSBT is empty or malformed."), + Self::InconsistentPsbt => write!(f, "Analyzed PSBT is inconsistent across inputs.") } } } @@ -130,8 +139,8 @@ impl str::FromStr for DerivedPublicKey { return Err(LianaDescError::DerivedKeyParsing); } - let key = bitcoin::PublicKey::from_str(key_str) - .map_err(|_| LianaDescError::DerivedKeyParsing)?; + let key = + bitcoin::PublicKey::from_str(key_str).map_err(|_| LianaDescError::DerivedKeyParsing)?; Ok(DerivedPublicKey { key, @@ -216,6 +225,20 @@ fn is_valid_desc_key(key: &descriptor::DescriptorPublicKey) -> bool { } } +// Get the fingerprint for the key in a multipath descriptors. +// Returns None if the given key isn't a multixpub. +fn key_fingerprint(key: &descriptor::DescriptorPublicKey) -> Option { + match key { + descriptor::DescriptorPublicKey::MultiXPub(ref xpub) => Some( + xpub.origin + .as_ref() + .map(|o| o.0) + .unwrap_or_else(|| xpub.xkey.fingerprint()), + ), + _ => None, + } +} + /// The keys in one of the two spending paths of a Liana descriptor. /// May either be a single key, or between 2 and 20 keys along with a threshold (between two and /// the number of keys). @@ -352,11 +375,22 @@ impl str::FromStr for MultipathDescriptor { .find(is_single_key_or_multisig) .ok_or(LianaDescError::IncompatibleDesc)?; - // Recovery spending path + // The recovery spending path is always a 2-of-2 between a timelock and a set of + // keys. let recov_subs = subs .iter() .find_map(|s| match s { - SemanticPolicy::Threshold(2, subs) => Some(subs), + SemanticPolicy::Threshold(2, subs) => { + if subs.len() == 2 + && subs + .iter() + .any(|sub| matches!(sub, SemanticPolicy::Older(_))) + { + Some(subs) + } else { + None + } + } _ => None, }) .ok_or(LianaDescError::IncompatibleDesc)?; @@ -414,6 +448,144 @@ impl PartialEq> for Inhe } } +/// Information about a single spending path in the descriptor. +#[derive(Debug, Eq, PartialEq, Clone, Ord, PartialOrd, Hash)] +pub enum PathInfo { + Single(descriptor::DescriptorPublicKey), + Multi(usize, Vec), +} + +impl PathInfo { + /// Get the spending path info from a policy describing a single key or a multisig. + /// Will return None if the policy isn't a key or a multisig. + pub fn from_single_key_or_multisig( + policy: SemanticPolicy, + ) -> Option { + match policy { + SemanticPolicy::Key(key) => Some(PathInfo::Single(key)), + SemanticPolicy::Threshold(k, subs) => { + let keys: Option> = subs + .into_iter() + .map(|sub| match sub { + SemanticPolicy::Key(key) => Some(key), + _ => None, + }) + .collect(); + Some(PathInfo::Multi(k, keys?)) + } + _ => None, + } + } + + /// Get the required number of keys for spending through this path, and the set of keys + /// that can be used to provide a signature for this path. + pub fn thresh_fingerprints(&self) -> (usize, HashSet) { + match self { + PathInfo::Single(key) => { + let mut fingerprints = HashSet::with_capacity(1); + fingerprints.insert(key_fingerprint(key).expect("Must be a multixpub.")); + (1, fingerprints) + } + PathInfo::Multi(k, keys) => ( + *k, + keys.iter() + .map(|key| key_fingerprint(key).expect("Must be a multixpub.")) + .collect(), + ), + } + } + + /// Get the spend information for this descriptor based from the list of all pubkeys that + /// signed the transaction. + pub fn spend_info( + &self, + all_pubkeys_signed: impl Iterator, + ) -> PathSpendInfo { + let mut signed_pubkeys = HashMap::new(); + let mut sigs_count = 0; + let (threshold, fingerprints) = self.thresh_fingerprints(); + + // For all existing signatures, pick those that are from one of our pubkeys. + for fingerprint in all_pubkeys_signed { + if fingerprints.contains(&fingerprint) { + sigs_count += 1; + if let Some(count) = signed_pubkeys.get_mut(&fingerprint) { + *count += 1; + } else { + signed_pubkeys.insert(fingerprint, 1); + } + } + } + + PathSpendInfo { + threshold, + sigs_count, + signed_pubkeys, + } + } +} + +/// Information about the descriptor: how many keys are present in each path, what's the timelock +/// of the recovery path, what's the threshold if there are multiple keys, etc.. +#[derive(Debug, Eq, PartialEq, Clone, Ord, PartialOrd, Hash)] +pub struct LianaDescInfo { + primary_path: PathInfo, + recovery_path: (u16, PathInfo), +} + +impl LianaDescInfo { + fn new(primary_path: PathInfo, recovery_path: (u16, PathInfo)) -> LianaDescInfo { + LianaDescInfo { + primary_path, + recovery_path, + } + } + + pub fn primary_path(&self) -> &PathInfo { + &self.primary_path + } + + /// Timelock and path info for the recovery path. + pub fn recovery_path(&self) -> (u16, &PathInfo) { + (self.recovery_path.0, &self.recovery_path.1) + } +} + +/// Partial spend information for a specific spending path within a descriptor. +#[derive(Debug, Eq, PartialEq, Clone)] +pub struct PathSpendInfo { + /// The required number of signatures to provide to spend through this path. + pub threshold: usize, + /// The number of signatures provided. + pub sigs_count: usize, + /// The keys for which a signature was provided and the number (always >=1) of + /// signatures provided for this key. + pub signed_pubkeys: HashMap, +} + +/// Information about a partial spend of Liana coins +#[derive(Debug, Eq, PartialEq, Clone)] +pub struct PartialSpendInfo { + /// Number of signatures present for the primary path + primary_path: PathSpendInfo, + /// Number of signatures present for the recovery path, only present if the path is available + /// in the first place. + recovery_path: Option, +} + +impl PartialSpendInfo { + /// Get the number of signatures present for the primary path + pub fn primary_path(&self) -> &PathSpendInfo { + &self.primary_path + } + + /// Get the number of signatures present for the recovery path. Only present if the path is + /// available in the first place. + pub fn recovery_path(&self) -> &Option { + &self.recovery_path + } +} + impl MultipathDescriptor { pub fn new( owner_keys: LianaDescKeys, @@ -520,8 +692,9 @@ impl MultipathDescriptor { &self.change_desc } - /// Get the value (in blocks) of the relative timelock for the heir's spending path. - pub fn timelock_value(&self) -> u32 { + /// Parse information about this descriptor + pub fn info(&self) -> LianaDescInfo { + // Get the Miniscript let wsh_desc = match &self.multi_desc { descriptor::Descriptor::Wsh(desc) => desc, _ => unreachable!(), @@ -531,31 +704,61 @@ impl MultipathDescriptor { _ => unreachable!(), }; + // Lift the semantic policy from the Miniscript let policy = ms .lift() .expect("Lifting can't fail on a Miniscript") .normalized(); let subs = match policy { SemanticPolicy::Threshold(1, subs) => subs, - _ => unreachable!(), + _ => unreachable!("The policy is always 'one of the primary or the recovery path'"), }; - let heir_subs = subs - .iter() - .find_map(|s| match s { - SemanticPolicy::Threshold(2, subs) => Some(subs), - _ => None, - }) - .expect("Always present"); - let csv = heir_subs - .iter() - .find_map(|s| match s { - SemanticPolicy::Older(csv) => Some(csv), - _ => None, - }) - .expect("Always present"); + // For now we only ever allow a single recovery path. + assert_eq!(subs.len(), 2); - assert!(csv.is_height_locked()); - csv.to_consensus_u32() + // Fetch the primary, non-timelocked path from the two sub-policies. Then parse information + // about it. + let prim_path_pos = subs + .iter() + .position(|sub| is_single_key_or_multisig(&sub)) + .expect( + "One of the two available paths must always be a set of keys without timelock.", + ); + let primary_path = PathInfo::from_single_key_or_multisig(subs[prim_path_pos].clone()) + .expect("Must always be a set of keys without timelock"); + + // Since there is only two subs, the timelocked recovery path must be the other sub. From + // the recovery sub policy fetch the timelock policy on the one hand, and the set of keys + // on the other one. + let reco_path_pos = prim_path_pos ^ 1; + let reco_subs = match subs[reco_path_pos] { + SemanticPolicy::Threshold(2, ref subs) => subs, + _ => unreachable!( + "The recovery path policy must be two subs: a timelock + a set of keys." + ), + }; + let (csv_pos, csv) = reco_subs + .iter() + .enumerate() + .find_map(|(i, s)| match s { + SemanticPolicy::Older(csv) => Some(( + i, + u16::try_from(csv.0).expect("Must always be a 'clean' block height"), + )), + _ => None, + }) + .expect("A relative timelock policy is always present in the recovery path."); + let recovery_keys = PathInfo::from_single_key_or_multisig(reco_subs[csv_pos ^ 1].clone()) + .expect("Must always be a set of keys alongside the timelock"); + let recovery_path = (csv, recovery_keys); + + LianaDescInfo::new(primary_path, recovery_path) + } + + /// Get the value (in blocks) of the relative timelock for the heir's spending path. + pub fn timelock_value(&self) -> u32 { + // TODO: make it return a u16 + self.info().recovery_path.0 as u32 } /// Get the maximum size in WU of a satisfaction for this descriptor. @@ -582,6 +785,85 @@ impl MultipathDescriptor { // txid + vout + nSequence + empty scriptSig + witness 32 + 4 + 4 + 1 + wu_to_vb(self.max_sat_weight()) } + + /// Get some information about a PSBT input spending Liana coins. + /// This analysis assumes that: + /// - The PSBT input actually spend a Liana coin for this descriptor. Otherwise the analysis will be off. + /// - The signatures contained in the PSBT input are valid for this script. + pub fn partial_spend_info_txin( + &self, + psbt_in: &PsbtIn, + txin: &bitcoin::TxIn, + ) -> PartialSpendInfo { + // Get the identifier of all the keys that signed this transaction. + let pubkeys_signed = psbt_in + .partial_sigs + .iter() + .filter_map(|(pk, _)| psbt_in.bip32_derivation.get(&pk.inner).map(|(fg, _)| *fg)); + + // Determine the structure of the descriptor. Then compute the spend info for the primary + // and recovery paths. Only provide the spend info for the recovery path if it is available + // (ie if the nSequence is >= to the chosen CSV value). + let desc_info = self.info(); + let primary_path = desc_info.primary_path.spend_info(pubkeys_signed.clone()); + let recovery_path = if txin.sequence.is_height_locked() + && txin.sequence.0 >= desc_info.recovery_path.0 as u32 + { + Some(desc_info.recovery_path.1.spend_info(pubkeys_signed)) + } else { + None + }; + + PartialSpendInfo { + primary_path, + recovery_path, + } + } + + // TODO: decide whether we should check the signatures too. To be useful it should check pubkeys + // correspond to those in the script. And we could be checking the witness scripts are all for + // our descriptor too.. + /// Get some information about a PSBT spending Liana coins. + /// This analysis assumes that: + /// - The PSBT only contains input that spends Liana coins. Otherwise the analysis will be off. + /// - The PSBT is consistent across inputs (the sequence is the same across inputs, the + /// signatures are either absent or present for all inputs, ..) + /// - The provided signatures are valid for this script. + pub fn partial_spend_info(&self, psbt: &Psbt) -> Result { + // Check the PSBT isn't empty or malformed. + if psbt.inputs.len() != psbt.unsigned_tx.input.len() + || psbt.outputs.len() != psbt.unsigned_tx.output.len() + || psbt.inputs.is_empty() + || psbt.outputs.is_empty() + { + return Err(LianaDescError::InsanePsbt); + } + + // We are doing this analysis at a transaction level. We assume that if an input + // is set to use the recovery path, all are. If one input is signed with a key, all + // must be. + // This gets the information needed to analyze the number of signatures from the + // first input, and checks that this info matches on all inputs. + let (mut psbt_ins, mut txins) = (psbt.inputs.iter(), psbt.unsigned_tx.input.iter()); + let (first_psbt_in, first_txin) = ( + psbt_ins + .next() + .expect("We checked at least one is present."), + txins.next().expect("We checked at least one is present."), + ); + let spend_info = self.partial_spend_info_txin(first_psbt_in, first_txin); + for (psbt_in, txin) in psbt_ins.zip(txins) { + // TODO: maybe it's better to not error if one of the input has more, or different + // signatures? Instead of erroring we could ignore the superfluous data? + if txin.sequence != first_txin.sequence + || spend_info != self.partial_spend_info_txin(psbt_in, txin) + { + return Err(LianaDescError::InconsistentPsbt); + } + } + + Ok(spend_info) + } } impl InheritanceDescriptor { @@ -887,5 +1169,169 @@ mod tests { roundtrip("wsh(or_d(multi(3,xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,[aabb0011/10/4893]xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*,xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*),and_v(v:pk(xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*),older(26352))))#3f4xttt3"); } + fn psbt_from_str(psbt_str: &str) -> Psbt { + bitcoin::consensus::deserialize(&base64::decode(psbt_str).unwrap()).unwrap() + } + + #[test] + fn repro() { + // A simple descriptor with 1 keys as primary path and 1 recovery key. + let desc = MultipathDescriptor::from_str("wsh(or_d(pk([f5acc2fd]tpubD6NzVbkrYhZ4YgUx2ZLNt2rLYAMTdYysCRzKoLu2BeSHKvzqPaBDvf17GeBPnExUVPkuBpx4kniP964e2MxyzzazcXLptxLXModSVCVEV1T/<0;1>/*),and_v(v:pkh([8a64f2a9]tpubD6NzVbkrYhZ4WmzFjvQrp7sDa4ECUxTi9oby8K4FZkd3XCBtEdKwUiQyYJaxiJo5y42gyDWEczrFpozEjeLxMPxjf2WtkfcbpUdfvNnozWF/<0;1>/*),older(10))))#d72le4dr").unwrap(); + let desc_info = desc.info(); + let prim_key_fg = bip32::Fingerprint::from_str("f5acc2fd").unwrap(); + let recov_key_fg = bip32::Fingerprint::from_str("8a64f2a9").unwrap(); + + // A PSBT with a single input and output, no signature. nSequence is not set to use the + // recovery path. + let mut unsigned_single_psbt: Psbt = psbt_from_str("cHNidP8BAHECAAAAAUSHuliRtuCX1S6JxRuDRqDCKkWfKmWL5sV9ukZ/wzvfAAAAAAD9////AogTAAAAAAAAFgAUIxe7UY6LJ6y5mFBoWTOoVispDmdwFwAAAAAAABYAFKqO83TK+t/KdpAt21z2HGC7/Z2FAAAAAAABASsQJwAAAAAAACIAIIIySQjGCTeyx/rKUQx8qobjhJeNCiVCliBJPdyRX6XKAQVBIQI2cqWpc9UAW2gZt2WkKjvi8KoMCui00pRlL6wG32uKDKxzZHapFNYASzIYkEdH9bJz6nnqUG3uBB8kiK1asmgiBgI2cqWpc9UAW2gZt2WkKjvi8KoMCui00pRlL6wG32uKDAz1rML9AAAAAG8AAAAiBgMLcbOxsfLe6+3r1UcjQo77HY0As8OKE4l37yj0/qhIyQyKZPKpAAAAAG8AAAAAAAA="); + let info = desc.partial_spend_info(&unsigned_single_psbt).unwrap(); + assert_eq!(info.primary_path.threshold, 1); + assert_eq!(info.primary_path.sigs_count, 0); + assert!(info.primary_path.signed_pubkeys.is_empty()); + assert!(info.recovery_path.is_none()); + + // If we set the sequence too low we still won't have the recovery path info. + unsigned_single_psbt.unsigned_tx.input[0].sequence = + Sequence::from_height(desc_info.recovery_path.0 - 1); + let info = desc.partial_spend_info(&unsigned_single_psbt).unwrap(); + assert!(info.recovery_path.is_none()); + + // Now if we set the sequence at the right value we'll have it. + unsigned_single_psbt.unsigned_tx.input[0].sequence = + Sequence::from_height(desc_info.recovery_path.0); + let info = desc.partial_spend_info(&unsigned_single_psbt).unwrap(); + assert!(info.recovery_path.is_some()); + + // Even if it's a bit too high (as long as it's still a block height and activated) + unsigned_single_psbt.unsigned_tx.input[0].sequence = + Sequence::from_height(desc_info.recovery_path.0 + 42); + let info = desc.partial_spend_info(&unsigned_single_psbt).unwrap(); + let recov_info = info.recovery_path.unwrap(); + assert_eq!(recov_info.threshold, 1); + assert_eq!(recov_info.sigs_count, 0); + assert!(recov_info.signed_pubkeys.is_empty()); + + // The same PSBT but with an (invalid) signature for the primary key. + let mut signed_single_psbt = psbt_from_str("cHNidP8BAHECAAAAAUSHuliRtuCX1S6JxRuDRqDCKkWfKmWL5sV9ukZ/wzvfAAAAAAD9////AogTAAAAAAAAFgAUIxe7UY6LJ6y5mFBoWTOoVispDmdwFwAAAAAAABYAFKqO83TK+t/KdpAt21z2HGC7/Z2FAAAAAAABASsQJwAAAAAAACIAIIIySQjGCTeyx/rKUQx8qobjhJeNCiVCliBJPdyRX6XKIgICNnKlqXPVAFtoGbdlpCo74vCqDArotNKUZS+sBt9rigxIMEUCIQCYZusUL8bdi2PnjWao4bIDDgMQ9Dj2Lcup3/VmkGbYJAIgX/wF5HsqugC5JzvU2cGOmUWtHr2Pg0N4912qogYgDH4BAQVBIQI2cqWpc9UAW2gZt2WkKjvi8KoMCui00pRlL6wG32uKDKxzZHapFNYASzIYkEdH9bJz6nnqUG3uBB8kiK1asmgiBgI2cqWpc9UAW2gZt2WkKjvi8KoMCui00pRlL6wG32uKDAz1rML9AAAAAG8AAAAiBgMLcbOxsfLe6+3r1UcjQo77HY0As8OKE4l37yj0/qhIyQyKZPKpAAAAAG8AAAAAAAA="); + let info = desc.partial_spend_info(&signed_single_psbt).unwrap(); + assert_eq!(signed_single_psbt.inputs[0].partial_sigs.len(), 1); + assert_eq!(info.primary_path.threshold, 1); + assert_eq!(info.primary_path.sigs_count, 1); + assert!( + info.primary_path.signed_pubkeys.len() == 1 + && info.primary_path.signed_pubkeys.contains_key(&prim_key_fg) + ); + assert!(info.recovery_path.is_none()); + + // Now enable the recovery path and add a signature for the recovery key. + signed_single_psbt.unsigned_tx.input[0].sequence = + Sequence::from_height(desc_info.recovery_path.0); + let recov_pubkey = bitcoin::PublicKey { + compressed: true, + inner: *signed_single_psbt.inputs[0] + .bip32_derivation + .iter() + .find(|(_, (fg, _))| fg == &recov_key_fg) + .unwrap() + .0, + }; + let prim_key = *signed_single_psbt.inputs[0] + .partial_sigs + .iter() + .next() + .unwrap() + .0; + let sig = signed_single_psbt.inputs[0] + .partial_sigs + .remove(&prim_key) + .unwrap(); + signed_single_psbt.inputs[0] + .partial_sigs + .insert(recov_pubkey, sig); + let info = desc.partial_spend_info(&signed_single_psbt).unwrap(); + assert_eq!(signed_single_psbt.inputs[0].partial_sigs.len(), 1); + assert_eq!(info.primary_path.threshold, 1); + assert_eq!(info.primary_path.sigs_count, 0); + assert!(info.primary_path.signed_pubkeys.is_empty()); + let recov_info = info.recovery_path.unwrap(); + assert_eq!(recov_info.threshold, 1); + assert_eq!(recov_info.sigs_count, 1); + assert!( + recov_info.signed_pubkeys.len() == 1 + && recov_info.signed_pubkeys.contains_key(&recov_key_fg) + ); + + // A PSBT with multiple inputs, all signed for the primary path. + let psbt: Psbt = psbt_from_str("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"); + let info = desc.partial_spend_info(&psbt).unwrap(); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.partial_sigs.len() == 1)); + assert_eq!(info.primary_path.threshold, 1); + assert_eq!(info.primary_path.sigs_count, 1); + assert!( + info.primary_path.signed_pubkeys.len() == 1 + && info.primary_path.signed_pubkeys.contains_key(&prim_key_fg) + ); + assert!(info.recovery_path.is_none()); + + // Enable the recovery path, it should show no recovery sig. + let mut rec_psbt = psbt.clone(); + for txin in rec_psbt.unsigned_tx.input.iter_mut() { + txin.sequence = Sequence::from_height(desc_info.recovery_path.0); + } + let info = desc.partial_spend_info(&rec_psbt).unwrap(); + assert!(rec_psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.partial_sigs.len() == 1)); + assert_eq!(info.primary_path.threshold, 1); + assert_eq!(info.primary_path.sigs_count, 1); + assert!( + info.primary_path.signed_pubkeys.len() == 1 + && info.primary_path.signed_pubkeys.contains_key(&prim_key_fg) + ); + let recov_info = info.recovery_path.unwrap(); + assert_eq!(recov_info.threshold, 1); + assert_eq!(recov_info.sigs_count, 0); + assert!(recov_info.signed_pubkeys.is_empty()); + + // If the sequence of one of the input is different from the other ones, it'll return + // an error since the analysis is on the whole transaction. + let mut inconsistent_psbt = psbt.clone(); + inconsistent_psbt.unsigned_tx.input[0].sequence = + Sequence::from_height(desc_info.recovery_path.0 + 1); + assert!(desc + .partial_spend_info(&inconsistent_psbt) + .unwrap_err() + .to_string() + .contains("Analyzed PSBT is inconsistent across inputs.")); + + // Same if all inputs don't have the same number of signatures. + let mut inconsistent_psbt = psbt.clone(); + inconsistent_psbt.inputs[0].partial_sigs.clear(); + assert!(desc + .partial_spend_info(&inconsistent_psbt) + .unwrap_err() + .to_string() + .contains("Analyzed PSBT is inconsistent across inputs.")); + + // If we analyze a descriptor with a multisig we'll get the right threshold. + let desc = MultipathDescriptor::from_str("wsh(or_d(multi(2,[f5acc2fd]tpubD6NzVbkrYhZ4YgUx2ZLNt2rLYAMTdYysCRzKoLu2BeSHKvzqPaBDvf17GeBPnExUVPkuBpx4kniP964e2MxyzzazcXLptxLXModSVCVEV1T/<0;1>/*,[00112233]xpub6FC8vmQGGfSuQGfKG5L73fZ7WjXit8TzfJYDKwTtHkhrbAhU5Kma41oenVq6aMnpgULJRXpQuxnVysyfdpRhVgD6vYe7XLbFDhmvYmDrAVq/<0;1>/*,[aabbccdd]xpub68XtbpvDM19d39wEKdvadHkZ4FGKf4tnryKzAacttp8BLX3uHj7eK8shRnFBhZ2UL83S9dwXe42Qm6eG6BkR1jy8XwUSNBcHKtET7j4V5FB/<0;1>/*),and_v(v:pkh([8a64f2a9]tpubD6NzVbkrYhZ4WmzFjvQrp7sDa4ECUxTi9oby8K4FZkd3XCBtEdKwUiQyYJaxiJo5y42gyDWEczrFpozEjeLxMPxjf2WtkfcbpUdfvNnozWF/<0;1>/*),older(10))))#2kgxuax5").unwrap(); + let info = desc.partial_spend_info(&psbt).unwrap(); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.partial_sigs.len() == 1)); + assert_eq!(info.primary_path.threshold, 2); + assert_eq!(info.primary_path.sigs_count, 1); + assert!( + info.primary_path.signed_pubkeys.len() == 1 + && info.primary_path.signed_pubkeys.contains_key(&prim_key_fg) + ); + assert!(info.recovery_path.is_none()); + } + // TODO: test error conditions of deserialization. }