diff --git a/src/descriptors.rs b/src/descriptors.rs index 37b36e56..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}, @@ -13,7 +16,11 @@ use miniscript::{ Translator, }; -use std::{collections::BTreeMap, convert::TryFrom, error, fmt, str, sync}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + convert::TryFrom, + error, fmt, str, sync, +}; use serde::{Deserialize, Serialize}; @@ -27,16 +34,22 @@ fn wu_to_vb(vb: usize) -> usize { } #[derive(Debug)] -pub enum DescCreationError { +pub enum LianaDescError { InsaneTimelock(u32), InvalidKey(Box), DuplicateKey(Box), Miniscript(miniscript::Error), IncompatibleDesc, 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 DescCreationError { +impl std::fmt::Display for LianaDescError { fn fmt(&self, f: &mut fmt::Formatter) -> std::fmt::Result { match self { Self::InsaneTimelock(tl) => { @@ -55,11 +68,15 @@ impl std::fmt::Display for DescCreationError { Self::Miniscript(e) => write!(f, "Miniscript error: '{}'.", e), Self::IncompatibleDesc => write!(f, "Descriptor is not compatible."), 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.") } } } -impl error::Error for DescCreationError {} +impl error::Error for LianaDescError {} /// A public key used in derived descriptors #[derive(Debug, Eq, PartialEq, Clone, Ord, PartialOrd, Hash)] @@ -85,7 +102,7 @@ impl fmt::Display for DerivedPublicKey { } impl str::FromStr for DerivedPublicKey { - type Err = DescCreationError; + type Err = LianaDescError; fn from_str(s: &str) -> Result { // The key is always of the form: @@ -93,37 +110,37 @@ impl str::FromStr for DerivedPublicKey { // 1 + 8 + 1 + 1 + 1 + 66 minimum if s.len() < 78 { - return Err(DescCreationError::DerivedKeyParsing); + return Err(LianaDescError::DerivedKeyParsing); } // Non-ASCII? for ch in s.as_bytes() { if *ch < 20 || *ch > 127 { - return Err(DescCreationError::DerivedKeyParsing); + return Err(LianaDescError::DerivedKeyParsing); } } if s.chars().next().expect("Size checked above") != '[' { - return Err(DescCreationError::DerivedKeyParsing); + return Err(LianaDescError::DerivedKeyParsing); } let mut parts = s[1..].split(']'); - let fg_deriv = parts.next().ok_or(DescCreationError::DerivedKeyParsing)?; - let key_str = parts.next().ok_or(DescCreationError::DerivedKeyParsing)?; + let fg_deriv = parts.next().ok_or(LianaDescError::DerivedKeyParsing)?; + let key_str = parts.next().ok_or(LianaDescError::DerivedKeyParsing)?; if fg_deriv.len() < 10 { - return Err(DescCreationError::DerivedKeyParsing); + return Err(LianaDescError::DerivedKeyParsing); } let fingerprint = bip32::Fingerprint::from_str(&fg_deriv[..8]) - .map_err(|_| DescCreationError::DerivedKeyParsing)?; + .map_err(|_| LianaDescError::DerivedKeyParsing)?; let deriv_path = bip32::DerivationPath::from_str(&fg_deriv[9..]) - .map_err(|_| DescCreationError::DerivedKeyParsing)?; + .map_err(|_| LianaDescError::DerivedKeyParsing)?; if deriv_path.into_iter().any(bip32::ChildNumber::is_hardened) { - return Err(DescCreationError::DerivedKeyParsing); + return Err(LianaDescError::DerivedKeyParsing); } - let key = bitcoin::PublicKey::from_str(key_str) - .map_err(|_| DescCreationError::DerivedKeyParsing)?; + let key = + bitcoin::PublicKey::from_str(key_str).map_err(|_| LianaDescError::DerivedKeyParsing)?; Ok(DerivedPublicKey { key, @@ -180,11 +197,11 @@ 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<(), DescCreationError> { +fn csv_check(csv_value: u32) -> Result<(), LianaDescError> { if csv_value > 0 && u16::try_from(csv_value).is_ok() { return Ok(()); } - Err(DescCreationError::InsaneTimelock(csv_value)) + Err(LianaDescError::InsaneTimelock(csv_value)) } // We require the descriptor key to: @@ -208,6 +225,81 @@ 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). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LianaDescKeys { + thresh: Option, + keys: Vec, +} + +impl LianaDescKeys { + pub fn from_single(key: descriptor::DescriptorPublicKey) -> LianaDescKeys { + LianaDescKeys { + thresh: None, + keys: vec![key], + } + } + + pub fn from_multi( + thresh: usize, + keys: Vec, + ) -> Result { + if keys.len() < 2 || keys.len() > 20 { + return Err(LianaDescError::InvalidMultiKeys(keys.len())); + } + if thresh == 0 || thresh > keys.len() { + return Err(LianaDescError::InvalidMultiThresh(thresh)); + } + Ok(LianaDescKeys { + thresh: Some(thresh), + keys, + }) + } + + pub fn keys(&self) -> &Vec { + &self.keys + } + + pub fn into_miniscript( + mut self, + as_hash: bool, + ) -> Miniscript { + if let Some(thresh) = self.thresh { + assert!(self.keys.len() >= 2 && self.keys.len() <= 20); + Miniscript::from_ast(Terminal::Multi(thresh, self.keys)) + .expect("multi is a valid Miniscript") + } else { + assert_eq!(self.keys.len(), 1); + let key = self.keys.pop().expect("Length was just asserted"); + Miniscript::from_ast(Terminal::Check(sync::Arc::from( + Miniscript::from_ast(if as_hash { + Terminal::PkH(key) + } else { + Terminal::PkK(key) + }) + .expect("pk_k is a valid Miniscript"), + ))) + .expect("Well typed") + } + } +} + /// An [InheritanceDescriptor] that contains multipath keys for (and only for) the receive keychain /// and the change keychain. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -232,15 +324,25 @@ impl fmt::Display for MultipathDescriptor { } } +fn is_single_key_or_multisig(policy: &&SemanticPolicy) -> bool { + match policy { + SemanticPolicy::Key(..) => true, + SemanticPolicy::Threshold(_, subs) => { + subs.iter().all(|sub| matches!(sub, SemanticPolicy::Key(_))) + } + _ => false, + } +} + impl str::FromStr for MultipathDescriptor { - type Err = DescCreationError; + type Err = LianaDescError; fn from_str(s: &str) -> Result { let wsh_desc = descriptor::Wsh::::from_str(s) - .map_err(DescCreationError::Miniscript)?; + .map_err(LianaDescError::Miniscript)?; let ms = match wsh_desc.as_inner() { descriptor::WshInner::Ms(ms) => ms, - _ => return Err(DescCreationError::IncompatibleDesc), + _ => return Err(LianaDescError::IncompatibleDesc), }; let invalid_key = ms.iter_pk().find_map(|pk| { if is_valid_desc_key(&pk) { @@ -250,7 +352,7 @@ impl str::FromStr for MultipathDescriptor { } }); if let Some(key) = invalid_key { - return Err(DescCreationError::InvalidKey(key.into())); + return Err(LianaDescError::InvalidKey(key.into())); } // Semantic of the Miniscript must be either the owner now, or the heir after @@ -263,41 +365,54 @@ impl str::FromStr for MultipathDescriptor { SemanticPolicy::Threshold(1, subs) => Some(subs), _ => None, } - .ok_or(DescCreationError::IncompatibleDesc)?; + .ok_or(LianaDescError::IncompatibleDesc)?; if subs.len() != 2 { - return Err(DescCreationError::IncompatibleDesc); + return Err(LianaDescError::IncompatibleDesc); } - // Owner branch + // Non-timelocked spending path may be either a single key check or a multisig. subs.iter() - .find(|s| matches!(s, SemanticPolicy::Key(_))) - .ok_or(DescCreationError::IncompatibleDesc)?; + .find(is_single_key_or_multisig) + .ok_or(LianaDescError::IncompatibleDesc)?; - // Heir branch - let heir_subs = subs + // 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(DescCreationError::IncompatibleDesc)?; - if heir_subs.len() != 2 { - return Err(DescCreationError::IncompatibleDesc); + .ok_or(LianaDescError::IncompatibleDesc)?; + if recov_subs.len() != 2 { + return Err(LianaDescError::IncompatibleDesc); } // Must be timelocked - let csv_value = heir_subs + let csv_value = recov_subs .iter() .find_map(|s| match s { SemanticPolicy::Older(csv) => Some(csv), _ => None, }) - .ok_or(DescCreationError::IncompatibleDesc)?; + .ok_or(LianaDescError::IncompatibleDesc)?; csv_check(csv_value.to_consensus_u32())?; - // And key locked - heir_subs + // The timelocked spending path may have a single key check or a multisig. + recov_subs .iter() - .find(|s| matches!(s, SemanticPolicy::Key(_))) - .ok_or(DescCreationError::IncompatibleDesc)?; + .find(is_single_key_or_multisig) + .ok_or(LianaDescError::IncompatibleDesc)?; + + // All good, construct the multipath descriptor. let multi_desc = descriptor::Descriptor::Wsh(wsh_desc); // Compute the receive and change "sub" descriptors right away. According to our pubkey @@ -333,12 +448,150 @@ 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_key: descriptor::DescriptorPublicKey, - heir_key: descriptor::DescriptorPublicKey, + owner_keys: LianaDescKeys, + heir_keys: LianaDescKeys, timelock: u16, - ) -> Result { + ) -> Result { // We require the locktime to: // - not be disabled // - be in number of blocks @@ -347,43 +600,36 @@ impl MultipathDescriptor { // // All this is achieved through asking for a 16-bit integer. if timelock == 0 { - return Err(DescCreationError::InsaneTimelock(timelock as u32)); + return Err(LianaDescError::InsaneTimelock(timelock as u32)); } let timelock = Sequence::from_height(timelock); - if let Some(key) = vec![&owner_key, &heir_key] - .iter() - .find(|k| !is_valid_desc_key(k)) - { - return Err(DescCreationError::InvalidKey((**key).clone().into())); + // Check all keys are valid according to our standard (this checks all are multipath keys). + let all_keys = owner_keys.keys().iter().chain(heir_keys.keys().iter()); + if let Some(key) = all_keys.clone().find(|k| !is_valid_desc_key(k)) { + return Err(LianaDescError::InvalidKey((*key).clone().into())); } // Check for key duplicates. They are invalid in (nonmalleable) miniscripts. - let owner_xpub = match owner_key { - descriptor::DescriptorPublicKey::MultiXPub(ref multi_xpub) => multi_xpub.xkey, - _ => unreachable!("Just checked it was a multixpub above"), - }; - let heir_xpub = match heir_key { - descriptor::DescriptorPublicKey::MultiXPub(ref multi_xpub) => multi_xpub.xkey, - _ => unreachable!("Just checked it was a multixpub above"), - }; - if owner_xpub == heir_xpub { - return Err(DescCreationError::DuplicateKey(owner_key.into())); + let mut key_set = HashSet::new(); + for key in all_keys { + let xpub = match key { + descriptor::DescriptorPublicKey::MultiXPub(ref multi_xpub) => multi_xpub.xkey, + _ => unreachable!("Just checked it was a multixpub above"), + }; + if key_set.contains(&xpub) { + return Err(LianaDescError::DuplicateKey(key.clone().into())); + } + key_set.insert(xpub); } + assert!(!key_set.is_empty()); - let owner_pk = Miniscript::from_ast(Terminal::Check(sync::Arc::from( - Miniscript::from_ast(Terminal::PkK(owner_key)).expect("pk_k is a valid Miniscript"), - ))) - .expect("Well typed"); - - let heir_pkh = Miniscript::from_ast(Terminal::Check(sync::Arc::from( - Miniscript::from_ast(Terminal::PkH(heir_key)).expect("pk_h is a valid Miniscript"), - ))) - .expect("Well typed"); - + // Create the timelocked spending path. If there is a single key we make it a pk_h() in + // order to save on the script size (since we assume the timelocked recovery path will + // seldom be used). let heir_timelock = Terminal::Older(timelock); let heir_branch = Miniscript::from_ast(Terminal::AndV( - Miniscript::from_ast(Terminal::Verify(heir_pkh.into())) + Miniscript::from_ast(Terminal::Verify(heir_keys.into_miniscript(true).into())) .expect("Well typed") .into(), Miniscript::from_ast(heir_timelock) @@ -392,9 +638,13 @@ impl MultipathDescriptor { )) .expect("Well typed"); - let tl_miniscript = - Miniscript::from_ast(Terminal::OrD(owner_pk.into(), heir_branch.into())) - .expect("Well typed"); + // Combine the timelocked spending path with the simple "primary" path. For the primary key + // we don't use a pkh since it's the one that will likely always be used. + let tl_miniscript = Miniscript::from_ast(Terminal::OrD( + owner_keys.into_miniscript(false).into(), + heir_branch.into(), + )) + .expect("Well typed"); miniscript::Segwitv0::check_local_validity(&tl_miniscript) .expect("Miniscript must be sane"); let multi_desc = descriptor::Descriptor::Wsh( @@ -442,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!(), @@ -453,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. @@ -504,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 { @@ -608,12 +968,34 @@ mod tests { use std::str::FromStr; #[test] - fn inheritance_descriptor_creation() { - let owner_key = descriptor::DescriptorPublicKey::from_str("xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap(); - let heir_key = descriptor::DescriptorPublicKey::from_str("xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*").unwrap(); + fn descriptor_creation() { + let owner_key = LianaDescKeys::from_single(descriptor::DescriptorPublicKey::from_str("xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap()); + let heir_key = LianaDescKeys::from_single(descriptor::DescriptorPublicKey::from_str("xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*").unwrap()); let timelock = 52560; assert_eq!(MultipathDescriptor::new(owner_key.clone(), heir_key.clone(), timelock).unwrap().to_string(), "wsh(or_d(pk(xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*),and_v(v:pkh(xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*),older(52560))))#8n2ydpkt"); + // A decaying multisig after 6 months. Note we can't duplicate the keys, so different ones + // are used. In practice they would both be controlled by the same entity. + let primary_keys = LianaDescKeys::from_multi( + 3, + vec![ + descriptor::DescriptorPublicKey::from_str("xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap(), + descriptor::DescriptorPublicKey::from_str("[aabb0011/10/4893]xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*").unwrap(), + descriptor::DescriptorPublicKey::from_str("xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*").unwrap() + ] + ) + .unwrap(); + let recovery_keys = LianaDescKeys::from_multi( + 2, + vec![ + descriptor::DescriptorPublicKey::from_str("xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*").unwrap(), + descriptor::DescriptorPublicKey::from_str("xpub6AA2N8RALRYgLD6jT1iXYCEDkndTeZndMtWPbtNX6sY5dPiLtf2T88ahdxrGXMUPoNadgR86sFhBXWQVgifPzDYbY9ZtwK4gqzx4y5Da1DW/<0;1>/*").unwrap(), + descriptor::DescriptorPublicKey::from_str("[aabb0011/10/4893]xpub6AyxexvxizZJffF153evmfqHcE9MV88fCNCAtP3jQjXJHwrAKri71Tq9jWUkPxj9pja4u6AkCPHY7atgxzSEa2HtDwJfrRWKK4fsfQg4o77/<0;1>/*").unwrap(), + ], + ) + .unwrap(); + assert_eq!(MultipathDescriptor::new(primary_keys, recovery_keys, 26352).unwrap().to_string(), "wsh(or_d(multi(3,xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,[aabb0011/10/4893]xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*,xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*),and_v(v:multi(2,xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*,xpub6AA2N8RALRYgLD6jT1iXYCEDkndTeZndMtWPbtNX6sY5dPiLtf2T88ahdxrGXMUPoNadgR86sFhBXWQVgifPzDYbY9ZtwK4gqzx4y5Da1DW/<0;1>/*,[aabb0011/10/4893]xpub6AyxexvxizZJffF153evmfqHcE9MV88fCNCAtP3jQjXJHwrAKri71Tq9jWUkPxj9pja4u6AkCPHY7atgxzSEa2HtDwJfrRWKK4fsfQg4o77/<0;1>/*),older(26352))))#slaa6mlr"); + // We prevent footguns with timelocks by requiring a u16. Note how the following wouldn't // compile: //MultipathDescriptor::new(owner_key.clone(), heir_key.clone(), 0x00_01_0f_00).unwrap_err(); @@ -623,38 +1005,80 @@ mod tests { // You can't use a null timelock in Miniscript. MultipathDescriptor::new(owner_key, heir_key, 0).unwrap_err(); - let owner_key = descriptor::DescriptorPublicKey::from_str("[aabb0011/10/4893]xpub661MyMwAqRbcFG59fiikD8UV762quhruT8K8bdjqy6N2o3LG7yohoCdLg1m2HAY1W6rfBrtauHkBhbfA4AQ3iazaJj5wVPhwgaRCHBW2DBg/<0;1>/*").unwrap(); - let heir_key = descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/24/32/<0;1>/*").unwrap(); + let owner_key = LianaDescKeys::from_single(descriptor::DescriptorPublicKey::from_str("[aabb0011/10/4893]xpub661MyMwAqRbcFG59fiikD8UV762quhruT8K8bdjqy6N2o3LG7yohoCdLg1m2HAY1W6rfBrtauHkBhbfA4AQ3iazaJj5wVPhwgaRCHBW2DBg/<0;1>/*").unwrap()); + let heir_key = LianaDescKeys::from_single(descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/24/32/<0;1>/*").unwrap()); let timelock = 57600; assert_eq!(MultipathDescriptor::new(owner_key.clone(), heir_key, timelock).unwrap().to_string(), "wsh(or_d(pk([aabb0011/10/4893]xpub661MyMwAqRbcFG59fiikD8UV762quhruT8K8bdjqy6N2o3LG7yohoCdLg1m2HAY1W6rfBrtauHkBhbfA4AQ3iazaJj5wVPhwgaRCHBW2DBg/<0;1>/*),and_v(v:pkh(xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/24/32/<0;1>/*),older(57600))))#l6dlpc2l"); // We can't pass a raw key, an xpub that is not deriveable, only hardened derivable, // without both the change and receive derivation paths, or with more than 2 different // derivation paths. - let heir_key = descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/<0;1>/354").unwrap(); + let heir_key = LianaDescKeys::from_single(descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/<0;1>/354").unwrap()); MultipathDescriptor::new(owner_key.clone(), heir_key, timelock).unwrap_err(); - let heir_key = descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/<0;1>/*'").unwrap(); + let heir_key = LianaDescKeys::from_single(descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/<0;1>/*'").unwrap()); MultipathDescriptor::new(owner_key.clone(), heir_key, timelock).unwrap_err(); - let heir_key = descriptor::DescriptorPublicKey::from_str( - "02e24913be26dbcfdf8e8e94870b28725cdae09b448b6c127767bf0154e3a3c8e5", - ) - .unwrap(); + let heir_key = LianaDescKeys::from_single( + descriptor::DescriptorPublicKey::from_str( + "02e24913be26dbcfdf8e8e94870b28725cdae09b448b6c127767bf0154e3a3c8e5", + ) + .unwrap(), + ); MultipathDescriptor::new(owner_key.clone(), heir_key, timelock).unwrap_err(); - let heir_key = descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/*'").unwrap(); + let heir_key = LianaDescKeys::from_single(descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/*'").unwrap()); MultipathDescriptor::new(owner_key.clone(), heir_key, timelock).unwrap_err(); - let heir_key = descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/<0;1;2>/*'").unwrap(); + let heir_key = LianaDescKeys::from_single(descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/<0;1;2>/*'").unwrap()); MultipathDescriptor::new(owner_key, heir_key, timelock).unwrap_err(); + // And it's checked even in a multisig. For instance: + let primary_keys = LianaDescKeys::from_multi( + 1, + vec![ + descriptor::DescriptorPublicKey::from_str("xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap(), + descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/<0;1>/354").unwrap(), + ] + ) + .unwrap(); + let recovery_keys = LianaDescKeys::from_multi( + 1, + vec![ + descriptor::DescriptorPublicKey::from_str("xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*").unwrap(), + descriptor::DescriptorPublicKey::from_str("xpub6AA2N8RALRYgLD6jT1iXYCEDkndTeZndMtWPbtNX6sY5dPiLtf2T88ahdxrGXMUPoNadgR86sFhBXWQVgifPzDYbY9ZtwK4gqzx4y5Da1DW/<0;1>/*").unwrap(), + ], + ) + .unwrap(); + MultipathDescriptor::new(primary_keys, recovery_keys, 26352).unwrap_err(); + // You can't pass duplicate keys, even if they are encoded differently. - let owner_key = descriptor::DescriptorPublicKey::from_str("xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap(); - let heir_key = descriptor::DescriptorPublicKey::from_str("xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap(); + let owner_key = LianaDescKeys::from_single(descriptor::DescriptorPublicKey::from_str("xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap()); + let heir_key = LianaDescKeys::from_single(descriptor::DescriptorPublicKey::from_str("xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap()); MultipathDescriptor::new(owner_key, heir_key, timelock).unwrap_err(); - let owner_key = descriptor::DescriptorPublicKey::from_str("[00aabb44]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap(); - let heir_key = descriptor::DescriptorPublicKey::from_str("xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap(); + let owner_key = LianaDescKeys::from_single(descriptor::DescriptorPublicKey::from_str("[00aabb44]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap()); + let heir_key = LianaDescKeys::from_single(descriptor::DescriptorPublicKey::from_str("xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap()); MultipathDescriptor::new(owner_key, heir_key, timelock).unwrap_err(); - let owner_key = descriptor::DescriptorPublicKey::from_str("[00aabb44]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap(); - let heir_key = descriptor::DescriptorPublicKey::from_str("[11223344/2/98]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap(); + let owner_key = LianaDescKeys::from_single(descriptor::DescriptorPublicKey::from_str("[00aabb44]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap()); + let heir_key = LianaDescKeys::from_single(descriptor::DescriptorPublicKey::from_str("[11223344/2/98]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap()); MultipathDescriptor::new(owner_key, heir_key, timelock).unwrap_err(); + + // You can't pass duplicate keys, even across multisigs. + let primary_keys = LianaDescKeys::from_multi( + 3, + vec![ + descriptor::DescriptorPublicKey::from_str("xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap(), + descriptor::DescriptorPublicKey::from_str("xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*").unwrap(), + descriptor::DescriptorPublicKey::from_str("xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*").unwrap() + ] + ) + .unwrap(); + let recovery_keys = LianaDescKeys::from_multi( + 2, + vec![ + descriptor::DescriptorPublicKey::from_str("xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*").unwrap(), + descriptor::DescriptorPublicKey::from_str("xpub6AA2N8RALRYgLD6jT1iXYCEDkndTeZndMtWPbtNX6sY5dPiLtf2T88ahdxrGXMUPoNadgR86sFhBXWQVgifPzDYbY9ZtwK4gqzx4y5Da1DW/<0;1>/*").unwrap(), + descriptor::DescriptorPublicKey::from_str("xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*").unwrap(), + ], + ) + .unwrap(); + MultipathDescriptor::new(primary_keys, recovery_keys, 26352).unwrap_err(); } #[test] @@ -711,5 +1135,203 @@ mod tests { ); } + #[test] + fn liana_desc_keys() { + let desc_key_a = descriptor::DescriptorPublicKey::from_str("xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap(); + let desc_key_b = descriptor::DescriptorPublicKey::from_str("xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*").unwrap(); + LianaDescKeys::from_single(desc_key_a.clone()); + + LianaDescKeys::from_multi(1, vec![desc_key_a.clone()]).unwrap_err(); + LianaDescKeys::from_multi(2, vec![desc_key_a.clone()]).unwrap_err(); + LianaDescKeys::from_multi(1, vec![desc_key_a.clone(), desc_key_b.clone()]).unwrap(); + LianaDescKeys::from_multi(0, vec![desc_key_a.clone(), desc_key_b.clone()]).unwrap_err(); + LianaDescKeys::from_multi(2, vec![desc_key_a.clone(), desc_key_b.clone()]).unwrap(); + LianaDescKeys::from_multi(3, vec![desc_key_a.clone(), desc_key_b]).unwrap_err(); + LianaDescKeys::from_multi(3, (0..20).map(|_| desc_key_a.clone()).collect()).unwrap(); + LianaDescKeys::from_multi(20, (0..20).map(|_| desc_key_a.clone()).collect()).unwrap(); + LianaDescKeys::from_multi(20, (0..21).map(|_| desc_key_a.clone()).collect()).unwrap_err(); + } + + fn roundtrip(desc_str: &str) { + let desc = MultipathDescriptor::from_str(desc_str).unwrap(); + assert_eq!(desc.to_string(), desc_str); + } + + #[test] + fn roundtrip_descriptor() { + // A descriptor with single keys in both primary and recovery paths + roundtrip("wsh(or_d(pk(xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*),and_v(v:pkh(xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*),older(52560))))#8n2ydpkt"); + // One with a multisig in both paths + roundtrip("wsh(or_d(multi(3,xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,[aabb0011/10/4893]xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*,xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*),and_v(v:multi(2,xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*,xpub6AA2N8RALRYgLD6jT1iXYCEDkndTeZndMtWPbtNX6sY5dPiLtf2T88ahdxrGXMUPoNadgR86sFhBXWQVgifPzDYbY9ZtwK4gqzx4y5Da1DW/<0;1>/*,[aabb0011/10/4893]xpub6AyxexvxizZJffF153evmfqHcE9MV88fCNCAtP3jQjXJHwrAKri71Tq9jWUkPxj9pja4u6AkCPHY7atgxzSEa2HtDwJfrRWKK4fsfQg4o77/<0;1>/*),older(26352))))#slaa6mlr"); + // A single key as primary path, a multisig as recovery + roundtrip("wsh(or_d(pk(xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*),and_v(v:multi(2,xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*,xpub6AA2N8RALRYgLD6jT1iXYCEDkndTeZndMtWPbtNX6sY5dPiLtf2T88ahdxrGXMUPoNadgR86sFhBXWQVgifPzDYbY9ZtwK4gqzx4y5Da1DW/<0;1>/*,[aabb0011/10/4893]xpub6AyxexvxizZJffF153evmfqHcE9MV88fCNCAtP3jQjXJHwrAKri71Tq9jWUkPxj9pja4u6AkCPHY7atgxzSEa2HtDwJfrRWKK4fsfQg4o77/<0;1>/*),older(26352))))#f5m0vfpf"); + // The other way around + 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. } diff --git a/src/testutils.rs b/src/testutils.rs index e50eafef..d49d4180 100644 --- a/src/testutils.rs +++ b/src/testutils.rs @@ -390,10 +390,9 @@ impl DummyLiana { poll_interval_secs: time::Duration::from_secs(2), }; - let owner_key = descriptor::DescriptorPublicKey::from_str("xpub68JJTXc1MWK8KLW4HGLXZBJknja7kDUJuFHnM424LbziEXsfkh1WQCiEjjHw4zLqSUm4rvhgyGkkuRowE9tCJSgt3TQB5J3SKAbZ2SdcKST/<0;1>/*").unwrap(); - let heir_key = descriptor::DescriptorPublicKey::from_str("xpub68JJTXc1MWK8PEQozKsRatrUHXKFNkD1Cb1BuQU9Xr5moCv87anqGyXLyUd4KpnDyZgo3gz4aN1r3NiaoweFW8UutBsBbgKHzaD5HkTkifK/<0;1>/*").unwrap(); - let desc = - crate::descriptors::MultipathDescriptor::new(owner_key, heir_key, 10_000).unwrap(); + let owner_key = descriptors::LianaDescKeys::from_single(descriptor::DescriptorPublicKey::from_str("xpub68JJTXc1MWK8KLW4HGLXZBJknja7kDUJuFHnM424LbziEXsfkh1WQCiEjjHw4zLqSUm4rvhgyGkkuRowE9tCJSgt3TQB5J3SKAbZ2SdcKST/<0;1>/*").unwrap()); + let heir_key = descriptors::LianaDescKeys::from_single(descriptor::DescriptorPublicKey::from_str("xpub68JJTXc1MWK8PEQozKsRatrUHXKFNkD1Cb1BuQU9Xr5moCv87anqGyXLyUd4KpnDyZgo3gz4aN1r3NiaoweFW8UutBsBbgKHzaD5HkTkifK/<0;1>/*").unwrap()); + let desc = descriptors::MultipathDescriptor::new(owner_key, heir_key, 10_000).unwrap(); let config = Config { bitcoin_config, bitcoind_config: None, diff --git a/tests/fixtures.py b/tests/fixtures.py index 4a6b6ebb..7365499a 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -3,6 +3,7 @@ from bip380.descriptors import Descriptor from concurrent import futures from test_framework.bitcoind import Bitcoind from test_framework.lianad import Lianad +from test_framework.signer import SingleSigner, MultiSigner from test_framework.utils import ( EXECUTOR_WORKERS, ) @@ -118,18 +119,63 @@ def lianad(bitcoind, directory): os.makedirs(datadir, exist_ok=True) bitcoind_cookie = os.path.join(bitcoind.bitcoin_dir, "regtest", ".cookie") - owner_hd = BIP32.from_seed(os.urandom(32), network="test") - recovery_hd = BIP32.from_seed(os.urandom(32), network="test") - owner_xpub, recovery_xpub = owner_hd.get_xpub(), recovery_hd.get_xpub() + signer = SingleSigner() + primary_xpub, recovery_xpub = ( + signer.primary_hd.get_xpub(), + signer.recovery_hd.get_xpub(), + ) csv_value = 10 main_desc = Descriptor.from_str( - f"wsh(or_d(pk({owner_xpub}/<0;1>/*),and_v(v:pkh({recovery_xpub}/<0;1>/*),older({csv_value}))))" + f"wsh(or_d(pk({primary_xpub}/<0;1>/*),and_v(v:pkh({recovery_xpub}/<0;1>/*),older({csv_value}))))" ) lianad = Lianad( datadir, - owner_hd, - recovery_hd, + signer, + main_desc, + bitcoind.rpcport, + bitcoind_cookie, + ) + + try: + lianad.start() + yield lianad + except Exception: + lianad.cleanup() + raise + + lianad.cleanup() + + +def multi_expression(thresh, keys): + exp = f"multi({thresh}," + for i, key in enumerate(keys): + exp += f"{key.get_xpub()}/<0;1>/*" + if i != len(keys) - 1: + exp += "," + return exp + ")" + + +@pytest.fixture +def lianad_multisig(bitcoind, directory): + datadir = os.path.join(directory, "lianad") + os.makedirs(datadir, exist_ok=True) + bitcoind_cookie = os.path.join(bitcoind.bitcoin_dir, "regtest", ".cookie") + + # A 3-of-4 that degrades into a 2-of-5 after 10 blocks + signer = MultiSigner(3, 4, 2, 5) + csv_value = 10 + prim_multi, recov_multi = ( + multi_expression(signer.prim_thresh, signer.prim_hds), + multi_expression(signer.recov_thresh, signer.recov_hds), + ) + main_desc = Descriptor.from_str( + f"wsh(or_d({prim_multi},and_v(v:{recov_multi},older({csv_value}))))" + ) + + lianad = Lianad( + datadir, + signer, main_desc, bitcoind.rpcport, bitcoind_cookie, diff --git a/tests/test_framework/lianad.py b/tests/test_framework/lianad.py index 51a0007b..eb273c31 100644 --- a/tests/test_framework/lianad.py +++ b/tests/test_framework/lianad.py @@ -2,7 +2,6 @@ import logging import os import shutil -from bip32.utils import coincurve from bip380.descriptors import Descriptor from bip380.miniscript import SatisfactionMaterial from test_framework.utils import ( @@ -15,11 +14,9 @@ from test_framework.utils import ( ) from test_framework.serializations import ( PSBT, - sighash_all_witness, CTxInWitness, CScriptWitness, PSBT_IN_BIP32_DERIVATION, - PSBT_IN_WITNESS_SCRIPT, PSBT_IN_PARTIAL_SIG, PSBT_IN_FINAL_SCRIPTWITNESS, ) @@ -29,8 +26,7 @@ class Lianad(TailableProc): def __init__( self, datadir, - owner_hd, - recovery_hd, + signer, multi_desc, bitcoind_rpc_port, bitcoind_cookie_path, @@ -40,8 +36,7 @@ class Lianad(TailableProc): self.datadir = datadir self.prefix = os.path.split(datadir)[-1] - self.owner_hd = owner_hd - self.recovery_hd = recovery_hd + self.signer = signer self.multi_desc = multi_desc self.receive_desc, self.change_desc = multi_desc.singlepath_descriptors() @@ -65,51 +60,6 @@ class Lianad(TailableProc): f.write(f"cookie_path = '{bitcoind_cookie_path}'\n") f.write(f"addr = '127.0.0.1:{bitcoind_rpc_port}'\n") - def sign_psbt(self, psbt, recovery=False): - """Sign a transaction. - - This will fill the 'partial_sigs' field of all inputs. Uses either the 'primary' - 'recovery' key as specified. - - :param psbt: PSBT of the transaction to be signed. - :returns: PSBT with a signature in each input for the owner's key. - """ - assert isinstance(psbt, PSBT) - - # Which key to sign the transaction with. - hd = self.recovery_hd if recovery else self.owner_hd - - # Sign each input. - for i, psbt_in in enumerate(psbt.i): - # First, gather the needed information from the PSBT input. - # 'hd_keypaths' is of the form {pubkey: (fingerprint (4 bytes), derivation path (n * 4 bytes))} - fing_der = next(iter(psbt_in.map[PSBT_IN_BIP32_DERIVATION].values())) - raw_der_path = fing_der[4:] - der_path = [ - int.from_bytes(raw_der_path[i : i + 4], byteorder="little", signed=True) - for i in range(0, len(raw_der_path), 4) - ] - script_code = psbt_in.map[PSBT_IN_WITNESS_SCRIPT] - - # Now sign the transaction. - sighash = sighash_all_witness(script_code, psbt, i) - privkey = coincurve.PrivateKey(hd.get_privkey_from_path(der_path)) - pubkey = privkey.public_key.format() - assert pubkey in psbt_in.map[PSBT_IN_BIP32_DERIVATION].keys(), ( - der_path, - fing_der, - pubkey, - psbt_in.map[PSBT_IN_BIP32_DERIVATION].keys(), - ) - sig = privkey.sign(sighash, hasher=None) + b"\x01" - logging.debug( - f"Adding signature {sig.hex()} for pubkey {pubkey.hex()} (path {der_path})" - ) - assert PSBT_IN_PARTIAL_SIG not in psbt_in.map - psbt_in.map[PSBT_IN_PARTIAL_SIG] = {pubkey: sig} - - return psbt - def finalize_psbt(self, psbt): """Create a valid witness for all inputs in the PSBT. This will fail if the PSBT input does not contain enough material. diff --git a/tests/test_framework/signer.py b/tests/test_framework/signer.py new file mode 100644 index 00000000..a4f40f23 --- /dev/null +++ b/tests/test_framework/signer.py @@ -0,0 +1,97 @@ +import logging +import os + +from bip32 import BIP32 +from bip32.utils import coincurve +from test_framework.serializations import ( + PSBT, + sighash_all_witness, + PSBT_IN_BIP32_DERIVATION, + PSBT_IN_WITNESS_SCRIPT, + PSBT_IN_PARTIAL_SIG, +) + + +def sign_psbt(psbt, hds): + """Sign a transaction. + + This will fill the 'partial_sigs' field of all inputs. + + :param psbt: PSBT of the transaction to be signed. + :param hds: the BIP32 objects to sign the transaction with. + :returns: PSBT with a signature in each input for the given keys. + """ + assert isinstance(psbt, PSBT) + + # Sign each input. + for i, psbt_in in enumerate(psbt.i): + # First, gather the needed information from the PSBT input. + # 'hd_keypaths' is of the form {pubkey: (fingerprint (4 bytes), derivation path (n * 4 bytes))} + fing_der = next(iter(psbt_in.map[PSBT_IN_BIP32_DERIVATION].values())) + raw_der_path = fing_der[4:] + der_path = [ + int.from_bytes(raw_der_path[i : i + 4], byteorder="little", signed=True) + for i in range(0, len(raw_der_path), 4) + ] + script_code = psbt_in.map[PSBT_IN_WITNESS_SCRIPT] + + # Now sign the transaction for all the given keys. + for hd in hds: + sighash = sighash_all_witness(script_code, psbt, i) + privkey = coincurve.PrivateKey(hd.get_privkey_from_path(der_path)) + pubkey = privkey.public_key.format() + assert pubkey in psbt_in.map[PSBT_IN_BIP32_DERIVATION].keys(), ( + der_path, + fing_der, + pubkey, + psbt_in.map[PSBT_IN_BIP32_DERIVATION].keys(), + ) + sig = privkey.sign(sighash, hasher=None) + b"\x01" + logging.debug( + f"Adding signature {sig.hex()} for pubkey {pubkey.hex()} (path {der_path})" + ) + if PSBT_IN_PARTIAL_SIG not in psbt_in.map: + psbt_in.map[PSBT_IN_PARTIAL_SIG] = {pubkey: sig} + else: + psbt_in.map[PSBT_IN_PARTIAL_SIG][pubkey] = sig + + return psbt + + +class SingleSigner: + def __init__(self): + self.primary_hd = BIP32.from_seed(os.urandom(32), network="test") + self.recovery_hd = BIP32.from_seed(os.urandom(32), network="test") + + def sign_psbt(self, psbt, recovery=False): + """Sign a transaction. + + This will fill the 'partial_sigs' field of all inputs. Uses either the 'primary' + 'recovery' key as specified. + + :param psbt: PSBT of the transaction to be signed. + :returns: PSBT with a signature in each input for the specified key. + """ + return sign_psbt(psbt, [self.recovery_hd if recovery else self.primary_hd]) + + +class MultiSigner: + def __init__( + self, primary_thresh, primary_hds_count, recovery_thresh, recovery_hds_count + ): + self.prim_thresh = primary_thresh + self.prim_hds = [ + BIP32.from_seed(os.urandom(32), network="test") + for _ in range(primary_hds_count) + ] + self.recov_thresh = recovery_thresh + self.recov_hds = [ + BIP32.from_seed(os.urandom(32), network="test") + for _ in range(recovery_hds_count) + ] + + def sign_psbt(self, psbt, key_indices, recovery=False): + """Sign a transaction with the keys at the specified indices.""" + hds = self.recov_hds if recovery else self.prim_hds + hds = [hds[i] for i in key_indices] + return sign_psbt(psbt, hds) diff --git a/tests/test_framework/utils.py b/tests/test_framework/utils.py index e506e676..75862282 100644 --- a/tests/test_framework/utils.py +++ b/tests/test_framework/utils.py @@ -67,7 +67,7 @@ def spend_coins(lianad, bitcoind, coins): } res = lianad.rpc.createspend(destinations, [c["outpoint"] for c in coins], 1) - signed_psbt = lianad.sign_psbt(PSBT.from_base64(res["psbt"])) + signed_psbt = lianad.signer.sign_psbt(PSBT.from_base64(res["psbt"])) finalized_psbt = lianad.finalize_psbt(signed_psbt) tx = finalized_psbt.tx.serialize_with_witness().hex() bitcoind.rpc.sendrawtransaction(tx) @@ -77,7 +77,7 @@ def spend_coins(lianad, bitcoind, coins): def sign_and_broadcast(lianad, bitcoind, psbt, recovery=False): """Sign a PSBT, finalize it, extract the transaction and broadcast it.""" - signed_psbt = lianad.sign_psbt(psbt, recovery) + signed_psbt = lianad.signer.sign_psbt(psbt, recovery) finalized_psbt = lianad.finalize_psbt(signed_psbt) tx = finalized_psbt.tx.serialize_with_witness().hex() return bitcoind.rpc.sendrawtransaction(tx) diff --git a/tests/test_misc.py b/tests/test_misc.py index 82d91b64..31308b65 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1,5 +1,74 @@ +import pytest + from fixtures import * +from test_framework.serializations import PSBT +from test_framework.utils import wait_for, RpcError -def test_startup(lianad): - pass +def test_multisig(lianad_multisig, bitcoind): + """Test using lianad with a descriptor that contains multiple keys for both + the primary and recovery paths.""" + lianad = lianad_multisig + + # Receive 3 coins in different blocks on different addresses. + for _ in range(3): + addr = lianad.rpc.getnewaddress()["address"] + txid = bitcoind.rpc.sendtoaddress(addr, 0.01) + bitcoind.generate_block(1, wait_for_mempool=txid) + wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 3) + + print(lianad.rpc.listcoins()) + # Create a spend that will create a change output, sign and broadcast it. + outpoints = [lianad.rpc.listcoins()["coins"][0]["outpoint"]] + destinations = { + bitcoind.rpc.getnewaddress(): 200_000, + } + res = lianad.rpc.createspend(destinations, outpoints, 42) + psbt = PSBT.from_base64(res["psbt"]) + txid = psbt.tx.txid().hex() + signed_psbt = lianad.signer.sign_psbt(psbt, range(3)) + lianad.rpc.updatespend(signed_psbt.to_base64()) + lianad.rpc.broadcastspend(txid) + bitcoind.generate_block(1, wait_for_mempool=txid) + wait_for( + lambda: lianad.rpc.getinfo()["block_height"] == bitcoind.rpc.getblockcount() + ) + + # Spend all coins to check we can spend from change too. Re-create some deposits. + outpoints = [ + c["outpoint"] + for c in lianad.rpc.listcoins()["coins"] + if c["spend_info"] is None + ] + destinations = { + bitcoind.rpc.getnewaddress(): 400_000, + lianad.rpc.getnewaddress()["address"]: 300_000, + lianad.rpc.getnewaddress()["address"]: 800_000, + } + res = lianad.rpc.createspend(destinations, outpoints, 42) + psbt = PSBT.from_base64(res["psbt"]) + txid = psbt.tx.txid().hex() + # If we sign only with two keys it won't be able to finalize + with pytest.raises(RpcError, match="Miniscript Error: could not satisfy at index 0"): + signed_psbt = lianad.signer.sign_psbt(psbt, range(2)) + lianad.rpc.updatespend(signed_psbt.to_base64()) + lianad.rpc.broadcastspend(txid) + # We can sign with different keys as long as there are 3 sigs + signed_psbt = lianad.signer.sign_psbt(psbt, range(1, 4)) + lianad.rpc.updatespend(signed_psbt.to_base64()) + lianad.rpc.broadcastspend(txid) + + # Generate 10 blocks to test the recovery path + bitcoind.generate_block(10, wait_for_mempool=txid) + wait_for( + lambda: lianad.rpc.getinfo()["block_height"] == bitcoind.rpc.getblockcount() + ) + + # Sweep all coins through the recovery path. It needs 2 signatures out of + # 5 keys. Sign with the second and the fifth ones. + res = lianad.rpc.createrecovery(bitcoind.rpc.getnewaddress(), 2) + reco_psbt = PSBT.from_base64(res["psbt"]) + txid = reco_psbt.tx.txid().hex() + signed_psbt = lianad.signer.sign_psbt(reco_psbt, [1, 4], recovery=True) + lianad.rpc.updatespend(signed_psbt.to_base64()) + lianad.rpc.broadcastspend(txid) diff --git a/tests/test_rpc.py b/tests/test_rpc.py index 0b45e178..83f035c6 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -262,7 +262,7 @@ def test_broadcast_spend(lianad, bitcoind): # We can't broadcast an unsigned transaction with pytest.raises(RpcError, match="Failed to finalize the spend transaction.*"): lianad.rpc.broadcastspend(txid) - signed_psbt = lianad.sign_psbt(PSBT.from_base64(res["psbt"])) + signed_psbt = lianad.signer.sign_psbt(PSBT.from_base64(res["psbt"])) lianad.rpc.updatespend(signed_psbt.to_base64()) # Now we've signed and stored it, the daemon will take care of finalizing @@ -372,7 +372,7 @@ def test_listtransactions(lianad, bitcoind): def sign_and_broadcast(psbt): txid = psbt.tx.txid().hex() - psbt = lianad.sign_psbt(psbt) + psbt = lianad.signer.sign_psbt(psbt) lianad.rpc.updatespend(psbt.to_base64()) lianad.rpc.broadcastspend(txid) return txid diff --git a/tests/test_spend.py b/tests/test_spend.py index 2d9959ff..93fb7596 100644 --- a/tests/test_spend.py +++ b/tests/test_spend.py @@ -27,7 +27,7 @@ def test_spend_change(lianad, bitcoind): assert len(spend_psbt.tx.vout) == 3 # Sign and broadcast this first Spend transaction. - signed_psbt = lianad.sign_psbt(spend_psbt) + signed_psbt = lianad.signer.sign_psbt(spend_psbt) lianad.rpc.updatespend(signed_psbt.to_base64()) spend_txid = signed_psbt.tx.txid().hex() lianad.rpc.broadcastspend(spend_txid) @@ -48,7 +48,7 @@ def test_spend_change(lianad, bitcoind): spend_psbt = PSBT.from_base64(res["psbt"]) # We can sign and broadcast it. - signed_psbt = lianad.sign_psbt(spend_psbt) + signed_psbt = lianad.signer.sign_psbt(spend_psbt) lianad.rpc.updatespend(signed_psbt.to_base64()) spend_txid = signed_psbt.tx.txid().hex() lianad.rpc.broadcastspend(spend_txid) @@ -85,7 +85,7 @@ def test_coin_marked_spent(lianad, bitcoind): def sign_and_broadcast(psbt): txid = psbt.tx.txid().hex() - psbt = lianad.sign_psbt(psbt) + psbt = lianad.signer.sign_psbt(psbt) lianad.rpc.updatespend(psbt.to_base64()) lianad.rpc.broadcastspend(txid) return txid