From 4af1a12a2a239a25c3ab50e4943fa7897be40091 Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Sun, 5 Feb 2023 13:43:40 +0100 Subject: [PATCH 1/2] descriptors: fix the spending policy inference This also cleans it up as well as merging the checks in the parser with the analysis in the info() helper to be more confident they are consistent. --- src/descriptors.rs | 215 ++++++++++++++++++++++++++------------------- 1 file changed, 124 insertions(+), 91 deletions(-) diff --git a/src/descriptors.rs b/src/descriptors.rs index c7df1566..eb859351 100644 --- a/src/descriptors.rs +++ b/src/descriptors.rs @@ -197,11 +197,12 @@ impl ToPublicKey for DerivedPublicKey { // // All this is achieved simply through asking for a 16-bit integer, since all the // above are signaled in leftmost bits. -fn csv_check(csv_value: u32) -> Result<(), LianaDescError> { - if csv_value > 0 && u16::try_from(csv_value).is_ok() { - return Ok(()); +fn csv_check(csv_value: u32) -> Result { + if csv_value > 0 { + u16::try_from(csv_value).map_err(|_| LianaDescError::InsaneTimelock(csv_value)) + } else { + Err(LianaDescError::InsaneTimelock(csv_value)) } - Err(LianaDescError::InsaneTimelock(csv_value)) } // We require the descriptor key to: @@ -330,7 +331,7 @@ impl fmt::Display for MultipathDescriptor { } } -fn is_single_key_or_multisig(policy: &&SemanticPolicy) -> bool { +fn is_single_key_or_multisig(policy: &SemanticPolicy) -> bool { match policy { SemanticPolicy::Key(..) => true, SemanticPolicy::Threshold(_, subs) => { @@ -376,47 +377,15 @@ impl str::FromStr for MultipathDescriptor { return Err(LianaDescError::IncompatibleDesc); } - // Non-timelocked spending path may be either a single key check or a multisig. - subs.iter() - .find(is_single_key_or_multisig) - .ok_or(LianaDescError::IncompatibleDesc)?; - - // 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) => { - if subs.len() == 2 - && subs - .iter() - .any(|sub| matches!(sub, SemanticPolicy::Older(_))) - { - Some(subs) - } else { - None - } - } - _ => None, - }) - .ok_or(LianaDescError::IncompatibleDesc)?; - if recov_subs.len() != 2 { - return Err(LianaDescError::IncompatibleDesc); + // Must always contain a non-timelocked primary spending path and a timelocked recovery + // path. The PathInfo constructors perform the checks that each path is well formed. + for sub in subs { + if is_single_key_or_multisig(&sub) { + PathInfo::from_primary_path(sub)?; + } else { + PathInfo::from_recovery_path(sub)?; + } } - // Must be timelocked - let csv_value = recov_subs - .iter() - .find_map(|s| match s { - SemanticPolicy::Older(csv) => Some(csv), - _ => None, - }) - .ok_or(LianaDescError::IncompatibleDesc)?; - csv_check(csv_value.to_consensus_u32())?; - // The timelocked spending path may have a single key check or a multisig. - recov_subs - .iter() - .find(is_single_key_or_multisig) - .ok_or(LianaDescError::IncompatibleDesc)?; // All good, construct the multipath descriptor. let multi_desc = descriptor::Descriptor::Wsh(wsh_desc); @@ -462,24 +431,83 @@ pub enum PathInfo { } 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( + /// Get the information about the primary spending path. + /// Returns None if the policy does not describe the primary spending path of a Liana + /// descriptor (that is, a set of keys). + pub fn from_primary_path( policy: SemanticPolicy, - ) -> Option { + ) -> Result { match policy { - SemanticPolicy::Key(key) => Some(PathInfo::Single(key)), + SemanticPolicy::Key(key) => Ok(PathInfo::Single(key)), SemanticPolicy::Threshold(k, subs) => { - let keys: Option> = subs + let keys: Result<_, LianaDescError> = subs .into_iter() .map(|sub| match sub { - SemanticPolicy::Key(key) => Some(key), - _ => None, + SemanticPolicy::Key(key) => Ok(key), + _ => Err(LianaDescError::IncompatibleDesc), }) .collect(); - Some(PathInfo::Multi(k, keys?)) + Ok(PathInfo::Multi(k, keys?)) } - _ => None, + _ => Err(LianaDescError::IncompatibleDesc), + } + } + + /// Get the information about the recovery spending path. + /// Returns None if the policy does not describe the recovery spending path of a Liana + /// descriptor (that is, a set of keys after a timelock). + pub fn from_recovery_path( + policy: SemanticPolicy, + ) -> Result<(u16, PathInfo), LianaDescError> { + // The recovery spending path must always be a policy of type `thresh(2, older(x), thresh(n, key1, + // key2, ..))`. In the special case n == 1, it is only `thresh(2, older(x), key)`. In the + // special case n == len(keys) (i.e. it's an N-of-N multisig), it is normalized as + // `thresh(n+1, older(x), key1, key2, ...)`. + let (k, subs) = match policy { + SemanticPolicy::Threshold(k, subs) => (k, subs), + _ => return Err(LianaDescError::IncompatibleDesc), + }; + if k == 2 && subs.len() == 2 { + // The general case (as well as the n == 1 case). The sub that is not the timelock is + // of the same form as a primary path. + let tl_value = subs + .iter() + .find_map(|s| match s { + SemanticPolicy::Older(val) => Some(csv_check(val.0)), + _ => None, + }) + .ok_or(LianaDescError::IncompatibleDesc)??; + let keys_sub = subs + .into_iter() + .find(is_single_key_or_multisig) + .ok_or(LianaDescError::IncompatibleDesc)?; + PathInfo::from_primary_path(keys_sub).map(|info| (tl_value, info)) + } else if k == subs.len() && subs.len() > 2 { + // The N-of-N case. All subs but the threshold must be keys (if one had been thresh() + // of keys it would have been normalized). + let mut tl_value = None; + let mut keys = Vec::with_capacity(subs.len()); + for sub in subs { + match sub { + SemanticPolicy::Key(key) => keys.push(key), + SemanticPolicy::Older(val) => { + if tl_value.is_some() { + return Err(LianaDescError::IncompatibleDesc); + } + tl_value = Some(csv_check(val.0)?); + } + _ => return Err(LianaDescError::IncompatibleDesc), + } + } + assert!(keys.len() > 1); // At least 3 subs, only one of which may be older(). + Ok(( + tl_value.ok_or(LianaDescError::IncompatibleDesc)?, + PathInfo::Multi(k - 1, keys), + )) + } else { + // If there is less than 2 subs, there can't be both a timelock and keys. If the + // threshold is not equal to the number of subs, the timelock can't be mandatory. + Err(LianaDescError::IncompatibleDesc) } } @@ -722,43 +750,30 @@ impl MultipathDescriptor { // For now we only ever allow a single recovery path. assert_eq!(subs.len(), 2); - // 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()) + // Fetch the two spending paths' semantic policies. The primary path is identified as the + // only one that isn't timelocked. + let (prim_path_sub, reco_path_sub) = + subs.into_iter() + .fold((None, None), |(mut prim_sub, mut reco_sub), sub| { + if is_single_key_or_multisig(&sub) { + prim_sub = Some(sub); + } else { + reco_sub = Some(sub); + } + (prim_sub, reco_sub) + }); + let (prim_path_sub, reco_path_sub) = ( + prim_path_sub.expect("Must be present"), + reco_path_sub.expect("Must be present"), + ); + + // Now parse information about each spending path. + let primary_path = PathInfo::from_primary_path(prim_path_sub) .expect("Must always be a set of keys without timelock"); + let reco_path = PathInfo::from_recovery_path(reco_path_sub) + .expect("The recovery path policy must always be a timelock along with a set of keys."); - // 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) + LianaDescInfo::new(primary_path, reco_path) } /// Get the value (in blocks) of the relative timelock for the heir's spending path. @@ -1186,7 +1201,7 @@ mod tests { } #[test] - fn repro() { + fn partial_spend_info() { // 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(); @@ -1343,6 +1358,24 @@ mod tests { && info.primary_path.signed_pubkeys.contains_key(&prim_key_fg) ); assert!(info.recovery_path.is_none()); + + let desc = MultipathDescriptor::from_str("wsh(or_d(multi(2,[636adf3f/48'/1'/0'/2']tpubDEE9FvWbG4kg4gxDNrALgrWLiHwNMXNs8hk6nXNPw4VHKot16xd2251vwi2M6nsyQTkak5FJNHVHkCcuzmvpSbWHdumX3DxpDm89iTfSBaL/<0;1>/*,[ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N/<0;1>/*),and_v(v:multi(2,[636adf3f/48'/1'/1'/2']tpubDDvF2khuoBBj8vcSjQfa7iKaxsQZE7YjJ7cJL8A8eaneadMPKbHSpoSr4JD1F5LUvWD82HCxdtSppGfrMUmiNbFxrA2EHEVLnrdCFNFe75D/<0;1>/*,[ffd63c8d/48'/1'/1'/2']tpubDFMs44FD4kFt3M7Z317cFh5tdKEGN8tyQRY6Q5gcSha4NtxZfGmTVRMbsD1bWN469LstXU4aVSARDxrvxFCUjHeegfEY2cLSazMBkNCmDPD/<0;1>/*),older(2))))#xcf6jr2r").unwrap(); + let info = desc.info(); + assert_eq!(info.primary_path, PathInfo::Multi( + 2, + vec![ + descriptor::DescriptorPublicKey::from_str("[636adf3f/48'/1'/0'/2']tpubDEE9FvWbG4kg4gxDNrALgrWLiHwNMXNs8hk6nXNPw4VHKot16xd2251vwi2M6nsyQTkak5FJNHVHkCcuzmvpSbWHdumX3DxpDm89iTfSBaL/<0;1>/*").unwrap(), + descriptor::DescriptorPublicKey::from_str("[ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N/<0;1>/*").unwrap(), + ], + )); + assert_eq!(info.recovery_path, (2, PathInfo::Multi( + 2, + vec![ + descriptor::DescriptorPublicKey::from_str("[636adf3f/48'/1'/1'/2']tpubDDvF2khuoBBj8vcSjQfa7iKaxsQZE7YjJ7cJL8A8eaneadMPKbHSpoSr4JD1F5LUvWD82HCxdtSppGfrMUmiNbFxrA2EHEVLnrdCFNFe75D/<0;1>/*").unwrap(), + descriptor::DescriptorPublicKey::from_str("[ffd63c8d/48'/1'/1'/2']tpubDFMs44FD4kFt3M7Z317cFh5tdKEGN8tyQRY6Q5gcSha4NtxZfGmTVRMbsD1bWN469LstXU4aVSARDxrvxFCUjHeegfEY2cLSazMBkNCmDPD/<0;1>/*").unwrap(), + ], + ))); + // TODO: fix the partial spend info.. } // TODO: test error conditions of deserialization. From fcc1c21dbb3a735407d0352c555f448ae80fac64 Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Sun, 5 Feb 2023 14:31:24 +0100 Subject: [PATCH 2/2] descriptors: use the origin as xpub UID in partial spend info, not the fingerprint We were using the fingerprint as a stable unique identifier for xpubs in a descriptor. It's not. For instance, what if a single signer is used both in the primary and recovery path? The same fingerprint would appear in both paths and we would mix up the signatures for both paths (ie if a signature for each was provided, we would count each path as having 2 signatures). --- src/descriptors.rs | 105 ++++++++++++++++++++++++++++++++------------- 1 file changed, 74 insertions(+), 31 deletions(-) diff --git a/src/descriptors.rs b/src/descriptors.rs index eb859351..c0d534fa 100644 --- a/src/descriptors.rs +++ b/src/descriptors.rs @@ -232,16 +232,13 @@ fn is_valid_desc_key(key: &descriptor::DescriptorPublicKey) -> bool { } } -// Get the fingerprint for the key in a multipath descriptors. +// Get the origin of a key in a multipath descriptors. // Returns None if the given key isn't a multixpub. -fn key_fingerprint(key: &descriptor::DescriptorPublicKey) -> Option { +fn key_origin( + key: &descriptor::DescriptorPublicKey, +) -> Option<&(bip32::Fingerprint, bip32::DerivationPath)> { match key { - descriptor::DescriptorPublicKey::MultiXPub(ref xpub) => Some( - xpub.origin - .as_ref() - .map(|o| o.0) - .unwrap_or_else(|| xpub.xkey.fingerprint()), - ), + descriptor::DescriptorPublicKey::MultiXPub(ref xpub) => xpub.origin.as_ref(), _ => None, } } @@ -513,17 +510,25 @@ impl PathInfo { /// 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) { + pub fn thresh_origins(&self) -> (usize, HashSet<(bip32::Fingerprint, bip32::DerivationPath)>) { match self { PathInfo::Single(key) => { - let mut fingerprints = HashSet::with_capacity(1); - fingerprints.insert(key_fingerprint(key).expect("Must be a multixpub.")); - (1, fingerprints) + let mut origins = HashSet::with_capacity(1); + origins.insert( + key_origin(key) + .expect("Must be a multixpub with an origin.") + .clone(), + ); + (1, origins) } PathInfo::Multi(k, keys) => ( *k, keys.iter() - .map(|key| key_fingerprint(key).expect("Must be a multixpub.")) + .map(|key| { + key_origin(key) + .expect("Must be a multixpub with an origin.") + .clone() + }) .collect(), ), } @@ -531,22 +536,34 @@ impl PathInfo { /// Get the spend information for this descriptor based from the list of all pubkeys that /// signed the transaction. - pub fn spend_info( + pub fn spend_info<'a>( &self, - all_pubkeys_signed: impl Iterator, + all_pubkeys_signed: impl Iterator, ) -> PathSpendInfo { let mut signed_pubkeys = HashMap::new(); let mut sigs_count = 0; - let (threshold, fingerprints) = self.thresh_fingerprints(); + let (threshold, origins) = self.thresh_origins(); // For all existing signatures, pick those that are from one of our pubkeys. - for fingerprint in all_pubkeys_signed { - if fingerprints.contains(&fingerprint) { + for (fg, der_path) in all_pubkeys_signed { + // For all xpubs in the descriptor, we derive at /0/* or /1/*, so the xpub's origin's + // derivation path is the key's one without the last two derivation indexes. + if der_path.len() < 2 { + continue; + } + let parent_der_path: bip32::DerivationPath = der_path[..der_path.len() - 2].into(); + let parent_origin = (*fg, parent_der_path); + + // Now if the origin of this key without the two final derivation indexes is part of + // the set of our keys, count it as a signature for it. Note it won't mixup keys + // between spending paths, since we can't have duplicate xpubs in the descriptor and + // the (fingerprint, der_path) tuple is a UID for an xpub. + if origins.contains(&parent_origin) { sigs_count += 1; - if let Some(count) = signed_pubkeys.get_mut(&fingerprint) { + if let Some(count) = signed_pubkeys.get_mut(&parent_origin) { *count += 1; } else { - signed_pubkeys.insert(fingerprint, 1); + signed_pubkeys.insert(parent_origin, 1); } } } @@ -594,7 +611,7 @@ pub struct PathSpendInfo { 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, + pub signed_pubkeys: HashMap<(bip32::Fingerprint, bip32::DerivationPath), usize>, } /// Information about a partial spend of Liana coins @@ -820,7 +837,7 @@ impl MultipathDescriptor { let pubkeys_signed = psbt_in .partial_sigs .iter() - .filter_map(|(pk, _)| psbt_in.bip32_derivation.get(&pk.inner).map(|(fg, _)| *fg)); + .filter_map(|(pk, _)| psbt_in.bip32_derivation.get(&pk.inner)); // 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 @@ -1205,8 +1222,14 @@ mod tests { // 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(); + let prim_key_origin = ( + bip32::Fingerprint::from_str("f5acc2fd").unwrap(), + Vec::new().into(), + ); + let recov_key_origin: (_, bip32::DerivationPath) = ( + bip32::Fingerprint::from_str("8a64f2a9").unwrap(), + Vec::new().into(), + ); // A PSBT with a single input and output, no signature. nSequence is not set to use the // recovery path. @@ -1246,7 +1269,10 @@ mod tests { 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) + && info + .primary_path + .signed_pubkeys + .contains_key(&prim_key_origin) ); assert!(info.recovery_path.is_none()); @@ -1258,7 +1284,10 @@ mod tests { inner: *signed_single_psbt.inputs[0] .bip32_derivation .iter() - .find(|(_, (fg, _))| fg == &recov_key_fg) + .find(|(_, (fg, der_path))| { + fg == &recov_key_origin.0 + && der_path[..der_path.len() - 2] == recov_key_origin.1[..] + }) .unwrap() .0, }; @@ -1285,7 +1314,7 @@ mod tests { assert_eq!(recov_info.sigs_count, 1); assert!( recov_info.signed_pubkeys.len() == 1 - && recov_info.signed_pubkeys.contains_key(&recov_key_fg) + && recov_info.signed_pubkeys.contains_key(&recov_key_origin) ); // A PSBT with multiple inputs, all signed for the primary path. @@ -1299,7 +1328,10 @@ mod tests { 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) + && info + .primary_path + .signed_pubkeys + .contains_key(&prim_key_origin) ); assert!(info.recovery_path.is_none()); @@ -1317,7 +1349,10 @@ mod tests { 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) + && info + .primary_path + .signed_pubkeys + .contains_key(&prim_key_origin) ); let recov_info = info.recovery_path.unwrap(); assert_eq!(recov_info.threshold, 1); @@ -1355,7 +1390,10 @@ mod tests { 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) + && info + .primary_path + .signed_pubkeys + .contains_key(&prim_key_origin) ); assert!(info.recovery_path.is_none()); @@ -1375,7 +1413,12 @@ mod tests { descriptor::DescriptorPublicKey::from_str("[ffd63c8d/48'/1'/1'/2']tpubDFMs44FD4kFt3M7Z317cFh5tdKEGN8tyQRY6Q5gcSha4NtxZfGmTVRMbsD1bWN469LstXU4aVSARDxrvxFCUjHeegfEY2cLSazMBkNCmDPD/<0;1>/*").unwrap(), ], ))); - // TODO: fix the partial spend info.. + let psbt = psbt_from_str("cHNidP8BAIkCAAAAAWi3OFgkj1CqCDT3Swm8kbxZS9lxz4L3i4W2v9KGC7nqAQAAAAD9////AkANAwAAAAAAIgAg27lNc1rog+dOq80ohRuds4Hgg/RcpxVun2XwgpuLSrFYMwwAAAAAACIAIDyWveqaElWmFGkTbFojg1zXWHODtiipSNjfgi2DqBy9AAAAAAABAOoCAAAAAAEBsRWl70USoAFFozxc86pC7Dovttdg4kvja//3WMEJskEBAAAAAP7///8CWKmCIk4GAAAWABRKBWYWkCNS46jgF0r69Ehdnq+7T0BCDwAAAAAAIgAgTt5fs+CiB+FRzNC8lHcgWLH205sNjz1pT59ghXlG5tQCRzBEAiBXK9MF8z3bX/VnY2aefgBBmiAHPL4tyDbUOe7+KpYA4AIgL5kU0DFG8szKd+szRzz/OTUWJ0tZqij41h2eU9rSe1IBIQNBB1hy+jKsg1TihMT0dXw7etpu9TkO3NuvhBDFJlBj1cP2AQABAStAQg8AAAAAACIAIE7eX7PgogfhUczQvJR3IFix9tObDY89aU+fYIV5RubUIgICSKJsNs0zFJN58yd2aYQ+C3vhMbi0x7k0FV3wBhR4THlIMEUCIQCPWWWOhs2lThxOq/G8X2fYBRvM9MXSm7qPH+dRVYQZEwIgfut2vx3RvwZWcgEj4ohQJD5lNJlwOkA4PAiN1fjx6dABIgID3mvj1zerZKohOVhKCiskYk+3qrCum6PIwDhQ16ePACpHMEQCICZNR+0/1hPkrDQwPFmg5VjUHkh6aK9cXUu3kPbM8hirAiAyE/5NUXKfmFKij30isuyysJbq8HrURjivd+S9vdRGKQEBBZNSIQJIomw2zTMUk3nzJ3ZphD4Le+ExuLTHuTQVXfAGFHhMeSEC9OfCXl+sJOrxUFLBuMV4ZUlJYjuzNGZSld5ioY14y8FSrnNkUSED3mvj1zerZKohOVhKCiskYk+3qrCum6PIwDhQ16ePACohA+ECH+HlR+8Sf3pumaXH3IwSsoqSLCH7H1THiBP93z3ZUq9SsmgiBgJIomw2zTMUk3nzJ3ZphD4Le+ExuLTHuTQVXfAGFHhMeRxjat8/MAAAgAEAAIAAAACAAgAAgAAAAAABAAAAIgYC9OfCXl+sJOrxUFLBuMV4ZUlJYjuzNGZSld5ioY14y8Ec/9Y8jTAAAIABAACAAAAAgAIAAIAAAAAAAQAAACIGA95r49c3q2SqITlYSgorJGJPt6qwrpujyMA4UNenjwAqHGNq3z8wAACAAQAAgAEAAIACAACAAAAAAAEAAAAiBgPhAh/h5UfvEn96bpmlx9yMErKKkiwh+x9Ux4gT/d892Rz/1jyNMAAAgAEAAIABAACAAgAAgAAAAAABAAAAACICAlBQ7gGocg7eF3sXrCio+zusAC9+xfoyIV95AeR69DWvHGNq3z8wAACAAQAAgAEAAIACAACAAAAAAAMAAAAiAgMvVy984eg8Kgvj058PBHetFayWbRGb7L0DMnS9KHSJzBxjat8/MAAAgAEAAIAAAACAAgAAgAAAAAADAAAAIgIDSRIG1dn6njdjsDXenHa2lUvQHWGPLKBVrSzbQOhiIxgc/9Y8jTAAAIABAACAAAAAgAIAAIAAAAAAAwAAACICA0/epE59sVEj7Et0I4R9qJQNuX23RNvDZKCRL7eUps9FHP/WPI0wAACAAQAAgAEAAIACAACAAAAAAAMAAAAAIgICgldCOK6iHscv//2NipgaMABLV5TICU/zlP7HlQmlg08cY2rfPzAAAIABAACAAQAAgAIAAIABAAAAAQAAACICApb0p9rfpJshB3J186PGWrvzQdixcwQZWmebOUMdkquZHP/WPI0wAACAAQAAgAAAAIACAACAAQAAAAEAAAAiAgLY5q+unoDxC/HI5BaNiPq12ei1REZIcUAN304JfKXUwxz/1jyNMAAAgAEAAIABAACAAgAAgAEAAAABAAAAIgIDg6cUVCJB79cMcofiURHojxFARWyS4YEhJNRixuOZZRgcY2rfPzAAAIABAACAAAAAgAIAAIABAAAAAQAAAAA="); + let partial_info = desc.partial_spend_info(&psbt).unwrap(); + assert_eq!(partial_info.primary_path.threshold, 2); + assert_eq!(partial_info.primary_path.sigs_count, 1); + assert_eq!(partial_info.primary_path.signed_pubkeys.len(), 1); + assert!(partial_info.recovery_path.is_none()); } // TODO: test error conditions of deserialization.