diff --git a/.cirrus.yml b/.cirrus.yml index 4c420593..8bfbfa55 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -16,12 +16,19 @@ task: matrix: - USE_MIN_BITCOIN_VERSION: 'TRUE' - USE_MIN_BITCOIN_VERSION: 'FALSE' + - USE_TAPROOT: 1 - name: 'RPC functional tests' env: TEST_GROUP: tests/test_rpc.py + matrix: + - USE_TAPROOT: 0 + - USE_TAPROOT: 1 - name: 'Chain functional tests' env: TEST_GROUP: tests/test_chain.py + matrix: + - USE_TAPROOT: 0 + - USE_TAPROOT: 1 cargo_registry_cache: folders: $CARGO_HOME/registry @@ -34,7 +41,12 @@ task: fingerprint_script: - rustc --version - cat Cargo.lock - lianad_build_script: cargo build --release + tests_tools_cache: + folder: tests/tools/taproot_signer/target + fingerprint_script: + - rustc --version + - cat tests/tools/taproot_signer/Cargo.lock + lianad_build_script: cargo build --release && cd tests/tools/taproot_signer && cargo build --release deps_script: apt update && apt install -y python3 python3-pip diff --git a/fuzz/fuzz_targets/descriptor_parse.rs b/fuzz/fuzz_targets/descriptor_parse.rs index 9d7563a5..8a3b095d 100644 --- a/fuzz/fuzz_targets/descriptor_parse.rs +++ b/fuzz/fuzz_targets/descriptor_parse.rs @@ -67,10 +67,12 @@ fuzz_target!(|data: &[u8]| { desc.receive_descriptor().derive(der_index, &SECP256K1), desc.change_descriptor().derive(der_index, &SECP256K1), ]; + let mut psbt_in = Default::default(); + let mut psbt_out = Default::default(); for desc in der_descs { desc.address(Network::Bitcoin); desc.script_pubkey(); - desc.witness_script(); - desc.bip32_derivations(); + desc.update_psbt_in(&mut psbt_in); + desc.update_change_psbt_out(&mut psbt_out); } }); diff --git a/fuzz/fuzz_targets/descriptors.rs b/fuzz/fuzz_targets/descriptors.rs index 61eaeca2..b358d2d7 100644 --- a/fuzz/fuzz_targets/descriptors.rs +++ b/fuzz/fuzz_targets/descriptors.rs @@ -101,6 +101,7 @@ struct Config { pub recovery_paths: Vec, pub der_index: u16, pub dummy_psbt: DummyPsbt, + pub use_taproot: bool, } fuzz_target!(|config: Config| { @@ -126,7 +127,12 @@ fuzz_target!(|config: Config| { } else { return; }; - let policy = if let Ok(policy) = LianaPolicy::new(prim_path_info, rec_paths_info) { + let policy = if config.use_taproot { + LianaPolicy::new(prim_path_info, rec_paths_info) + } else { + LianaPolicy::new_legacy(prim_path_info, rec_paths_info) + }; + let policy = if let Ok(policy) = policy { policy } else { return; @@ -154,11 +160,13 @@ fuzz_target!(|config: Config| { desc.receive_descriptor().derive(der_index, &SECP256K1), desc.change_descriptor().derive(der_index, &SECP256K1), ]; + let mut psbt_in = Default::default(); + let mut psbt_out = Default::default(); for desc in &der_descs { desc.address(Network::Bitcoin); desc.script_pubkey(); - desc.witness_script(); - desc.bip32_derivations(); + desc.update_psbt_in(&mut psbt_in); + desc.update_change_psbt_out(&mut psbt_out); } // Exercise the methods gathering information from a PSBT. TODO: get more useful PSBTs. @@ -172,12 +180,11 @@ fuzz_target!(|config: Config| { // for outputs. let rec_desc = &der_descs[0]; for psbt_in in psbt.inputs.iter_mut() { - psbt_in.witness_script = Some(rec_desc.witness_script()); - psbt_in.bip32_derivation = rec_desc.bip32_derivations(); + rec_desc.update_psbt_in(psbt_in); } let change_desc = &der_descs[1]; for psbt_out in psbt.outputs.iter_mut() { - psbt_out.bip32_derivation = change_desc.bip32_derivations(); + change_desc.update_change_psbt_out(psbt_out); } // Now get the spend info again with these info. diff --git a/src/bitcoin/d/mod.rs b/src/bitcoin/d/mod.rs index faaaf371..41e25216 100644 --- a/src/bitcoin/d/mod.rs +++ b/src/bitcoin/d/mod.rs @@ -45,6 +45,9 @@ const BITCOIND_RETRY_LIMIT: usize = 60; // The minimum bitcoind version that can be used with lianad. const MIN_BITCOIND_VERSION: u64 = 240000; +// The minimum bitcoind version that can be used with lianad and a Taproot descriptor. +const MIN_TAPROOT_BITCOIND_VERSION: u64 = 260000; + /// An error in the bitcoind interface. #[derive(Debug)] pub enum BitcoindError { @@ -129,8 +132,8 @@ impl std::fmt::Display for BitcoindError { BitcoindError::InvalidVersion(v) => { write!( f, - "Invalid bitcoind version '{}', minimum supported is '{}'.", - v, MIN_BITCOIND_VERSION + "Invalid bitcoind version '{}', minimum supported is '{}' and minimum supported if using Taproot is '{}'.", + v, MIN_BITCOIND_VERSION, MIN_TAPROOT_BITCOIND_VERSION ) } BitcoindError::NetworkMismatch(conf_net, bitcoind_net) => { @@ -699,12 +702,16 @@ impl BitcoinD { pub fn node_sanity_checks( &self, config_network: bitcoin::Network, + is_taproot: bool, ) -> Result<(), BitcoindError> { // Check the minimum supported bitcoind version let version = self.get_bitcoind_version(); if version < MIN_BITCOIND_VERSION { return Err(BitcoindError::InvalidVersion(version)); } + if is_taproot && version < MIN_TAPROOT_BITCOIND_VERSION { + return Err(BitcoindError::InvalidVersion(version)); + } // Check bitcoind is running on the right network let bitcoind_net = self.get_network_bip70(); @@ -747,13 +754,27 @@ impl BitcoinD { // Check our main descriptor is imported in this wallet. let receive_desc = main_descriptor.receive_descriptor(); let change_desc = main_descriptor.change_descriptor(); - let desc_list: Vec = self + let desc_list: Vec<_> = self .list_descriptors() .into_iter() - .map(|entry| entry.desc) + .filter_map(|entry| { + match descriptor::Descriptor::::from_str( + &entry.desc, + ) { + Ok(desc) => Some(desc), + Err(e) => { + log::error!( + "Error deserializing descriptor: {}. Descriptor: {}.", + e, + entry.desc + ); + None + } + } + }) .collect(); - if !desc_list.contains(&receive_desc.to_string()) - || !desc_list.contains(&change_desc.to_string()) + if !desc_list.iter().any(|desc| *receive_desc == *desc) + || !desc_list.iter().any(|desc| *change_desc == *desc) { return Err(BitcoindError::Wallet( self.watchonly_wallet_path.clone(), diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9a4bf835..6cb372d7 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -591,6 +591,12 @@ impl DaemonControl { psbtin .partial_sigs .extend(db_psbtin.partial_sigs.clone().into_iter()); + psbtin + .tap_script_sigs + .extend(db_psbtin.tap_script_sigs.clone().into_iter()); + if psbtin.tap_key_sig.is_none() { + psbtin.tap_key_sig = db_psbtin.tap_key_sig; + } } } else { // If the transaction doesn't exist in DB already, sanity check its inputs. diff --git a/src/config.rs b/src/config.rs index f73cda88..08a98923 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,7 +14,7 @@ where { let string = String::deserialize(deserializer)?; T::from_str(&string) - .map_err(|e| de::Error::custom(format!("Error parsing descriptor '{}': '{}'", string, e))) + .map_err(|e| de::Error::custom(format!("Error parsing '{}': {}", string, e))) } pub fn serialize_to_string( @@ -319,6 +319,26 @@ mod tests { #[cfg(unix)] // On non-UNIX there is no 'daemon' member. assert_eq!(toml_str, serialized); + // A valid, round-tripping, config for a Taproot descriptor. + let toml_str = r#" + data_dir = '/home/wizardsardine/custom/folder/' + daemon = false + log_level = 'TRACE' + main_descriptor = 'tr([abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,and_v(v:pk([abcdef01]xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*),older(52560)))#0mt7e93c' + + [bitcoin_config] + network = 'bitcoin' + poll_interval_secs = 18 + + [bitcoind_config] + cookie_path = '/home/user/.bitcoin/.cookie' + addr = '127.0.0.1:8332' + "#.trim_start().replace(" ", ""); + let parsed = toml::from_str::(&toml_str).expect("Deserializing toml_str"); + let serialized = toml::to_string_pretty(&parsed).expect("Serializing to toml"); + #[cfg(unix)] // On non-UNIX there is no 'daemon' member. + assert_eq!(toml_str, serialized); + // A valid, round-tripping, config with `auth` instead of `cookie_path` let toml_str = r#" data_dir = '/home/wizardsardine/custom/folder/' diff --git a/src/descriptors/analysis.rs b/src/descriptors/analysis.rs index dbedc421..2420672f 100644 --- a/src/descriptors/analysis.rs +++ b/src/descriptors/analysis.rs @@ -1,7 +1,11 @@ use miniscript::{ - bitcoin::{bip32, Sequence}, + bitcoin::{ + self, bip32, + hashes::{sha256, Hash}, + secp256k1, Sequence, + }, descriptor, - policy::{compiler, Concrete as ConcretePolicy, Liftable, Semantic as SemanticPolicy}, + policy::{Concrete as ConcretePolicy, Liftable, Semantic as SemanticPolicy}, ScriptContext, }; @@ -9,6 +13,7 @@ use std::{ collections::{BTreeMap, HashMap, HashSet}, convert::TryFrom, error, fmt, + str::FromStr, }; #[derive(Debug)] @@ -22,10 +27,10 @@ pub enum LianaPolicyError { InvalidMultiThresh(usize), InvalidMultiKeys(usize), IncompatibleDesc, + PolicyAnalysis(miniscript::Error), /// The spending policy is not a valid Miniscript policy: it may for instance be malleable, or /// overflow some limit. - InvalidPolicy(compiler::CompilerError), - PolicyAnalysis(miniscript::Error), + InvalidPolicy(miniscript::Error), } impl std::fmt::Display for LianaPolicyError { @@ -357,6 +362,91 @@ impl PathInfo { } } +// See +// https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs: +// > One example of such a point is H = +// > lift_x(0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0) which is constructed +// > by taking the hash of the standard uncompressed encoding of the secp256k1 base point G as X +// > coordinate. +fn bip341_nums() -> secp256k1::PublicKey { + secp256k1::PublicKey::from_str( + "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0", + ) + .expect("Valid pubkey: NUMS from BIP341") +} + +// Given a descpubkey, extract its xpub assuming it is a multixpub. Returns None otherwise. +fn get_multi_xkey(desc_key: &descriptor::DescriptorPublicKey) -> Option<&bip32::Xpub> { + if let descriptor::DescriptorPublicKey::MultiXPub(descriptor::DescriptorMultiXKey { + xkey, + .. + }) = desc_key + { + Some(xkey) + } else { + None + } +} + +// Construct an unspendable xpub to be used as internal key in a Taproot descriptor, in a way which +// could eventually be standardized into wallet policies for a signer to display to the user +// "UNSPENDABLE" upon registration (instead of a meaningless key). +// See https://delvingbitcoin.org/t/unspendable-keys-in-descriptors/304/21. +// +// Returns `None` if: +// - The given descriptor does not contain a Taptree with at least a key in each leaf. +// - The keys contained in the descriptor aren't all MultiXPub's. +fn unspendable_internal_xpub( + desc: &descriptor::Tr, +) -> Option { + let tap_tree = desc.tap_tree().as_ref()?; + + // Fetch the network to use for the unspendable key from the first key in the descriptor. + let first_key = tap_tree.iter().flat_map(|(_, ms)| ms.iter_pk()).next()?; + let network = get_multi_xkey(&first_key)?.network; + + // Compute the chaincode to use for the xpub. This is the sha256() of the concatenation of all + // the xpubs' pubkey part in the Taptree. + let concat = + tap_tree + .iter() + .flat_map(|(_, ms)| ms.iter_pk()) + .try_fold(Vec::new(), |mut acc, pk| { + let xkey = get_multi_xkey(&pk)?; + acc.extend_from_slice(&xkey.public_key.serialize()); + Some(acc) + })?; + let chain_code = bip32::ChainCode::from(sha256::Hash::hash(&concat).as_ref()); + + // Construct the unspendable key. The pubkey part is always BIP341's NUMS. + let public_key = bip341_nums(); + Some(bip32::Xpub { + public_key, + chain_code, + depth: 0, + parent_fingerprint: [0; 4].into(), + child_number: 0.into(), + network, + }) +} + +fn unspendable_internal_key( + desc: &descriptor::Tr, +) -> Option { + Some(descriptor::DescriptorPublicKey::MultiXPub( + descriptor::DescriptorMultiXKey { + origin: None, + xkey: unspendable_internal_xpub(desc)?, + derivation_paths: descriptor::DerivPaths::new(vec![ + [0.into()][..].into(), + [1.into()][..].into(), + ]) + .expect("Non empty vec"), + wildcard: descriptor::Wildcard::Unhardened, + }, + )) +} + /// A Liana spending policy is one composed of at least two spending paths: /// - A directly available path with any number of keys checks; or /// - One or more recovery paths with any number of keys checks, behind increasing relative @@ -370,13 +460,15 @@ impl PathInfo { pub struct LianaPolicy { pub(super) primary_path: PathInfo, pub(super) recovery_paths: BTreeMap, + is_taproot: bool, } impl LianaPolicy { /// Create a new Liana policy from a given configuration. - pub fn new( + fn _new( primary_path: PathInfo, recovery_paths: BTreeMap, + is_taproot: bool, ) -> Result { if recovery_paths.is_empty() { return Err(LianaPolicyError::MissingRecoveryPath); @@ -430,32 +522,73 @@ impl LianaPolicy { let policy = LianaPolicy { primary_path, recovery_paths, + is_taproot, }; - policy.clone().into_miniscript()?; + policy.clone().into_multipath_descriptor_fallible()?; Ok(policy) } + /// Create a new Liana policy for use under a Taproot context. + pub fn new( + primary_path: PathInfo, + recovery_paths: BTreeMap, + ) -> Result { + Self::_new(primary_path, recovery_paths, /* is_taproot = */ true) + } + + /// Create a new Liana policy for use under a P2WSH context. + pub fn new_legacy( + primary_path: PathInfo, + recovery_paths: BTreeMap, + ) -> Result { + Self::_new(primary_path, recovery_paths, /* is_taproot = */ false) + } + /// Create a Liana policy from a descriptor. This will check the descriptor is correctly formed /// (P2WSH, multipath, ..) and has a valid Liana semantic. pub fn from_multipath_descriptor( desc: &descriptor::Descriptor, ) -> Result { - // For now we only allow P2WSH descriptors. - let wsh_desc = match &desc { - descriptor::Descriptor::Wsh(desc) => desc, - _ => return Err(LianaPolicyError::IncompatibleDesc), - }; - // Lift a semantic policy out of this Miniscript and normalize it to make sure we compare // apples to apples below. - let ms = match wsh_desc.as_inner() { - descriptor::WshInner::Ms(ms) => ms, + let policy = match desc { + descriptor::Descriptor::Wsh(wsh_desc) => { + let ms = match wsh_desc.as_inner() { + descriptor::WshInner::Ms(ms) => ms, + _ => return Err(LianaPolicyError::IncompatibleDesc), + }; + ms.lift().map_err(LianaPolicyError::PolicyAnalysis)? + } + descriptor::Descriptor::Tr(desc) => { + // For Taproot, make sure to not take the internal key into account in the semantic + // policy if it's unspendable. + if let Some(tree) = desc.tap_tree() { + let tree_policy = tree.lift().map_err(LianaPolicyError::PolicyAnalysis)?; + let unspend_int_xpub = unspendable_internal_xpub(desc) + .ok_or(LianaPolicyError::IncompatibleDesc)?; + let desc_int_xpub = get_multi_xkey(desc.internal_key()) + .ok_or(LianaPolicyError::IncompatibleDesc)?; + if *desc_int_xpub == unspend_int_xpub { + tree_policy + } else { + SemanticPolicy::Threshold( + 1, + vec![ + SemanticPolicy::Key(desc.internal_key().clone()), + tree_policy, + ], + ) + } + } else { + // A Liana descriptor must contain a timelocked path. + return Err(LianaPolicyError::IncompatibleDesc); + } + } + // We only allow P2WSH and Taproot descriptors. _ => return Err(LianaPolicyError::IncompatibleDesc), - }; - let policy = ms - .lift() - .map_err(LianaPolicyError::PolicyAnalysis)? - .normalized(); + } + .normalized(); + let is_taproot = matches!(desc, descriptor::Descriptor::Tr(..)); // The policy must always be "1 of N spending paths" with at least an always-available // primary path with at least one key, and at least one timelocked recovery path with at @@ -499,7 +632,7 @@ impl LianaPolicy { // Use the constructor for sanity checking the keys and the Miniscript policy. Note this // makes sure the recovery paths mapping isn't empty, too. let prim_path = primary_path.ok_or(LianaPolicyError::IncompatibleDesc)?; - LianaPolicy::new(prim_path, recovery_paths) + LianaPolicy::_new(prim_path, recovery_paths, is_taproot) } pub fn primary_path(&self) -> &PathInfo { @@ -513,15 +646,11 @@ impl LianaPolicy { &self.recovery_paths } - fn into_miniscript( - self, - ) -> Result< - miniscript::Miniscript, - LianaPolicyError, - > { + fn into_policy(self) -> miniscript::policy::Concrete { let LianaPolicy { primary_path, recovery_paths, + .. } = self; // Start with the primary spending path. We'll then or() all the recovery paths to it. @@ -529,20 +658,76 @@ impl LianaPolicy { // Incrementally create the top-level policy using all recovery paths. assert!(!recovery_paths.is_empty()); - let tl_policy = - recovery_paths - .into_iter() - .fold(primary_keys, |tl_policy, (timelock, path_info)| { - let timelock = ConcretePolicy::Older(Sequence::from_height(timelock)); - let keys = path_info.into_ms_policy(); - let recovery_branch = ConcretePolicy::And(vec![keys.into(), timelock.into()]); - // We assume the larger the timelock the less likely a branch would be used. - ConcretePolicy::Or(vec![(99, tl_policy.into()), (1, recovery_branch.into())]) - }); + recovery_paths + .into_iter() + .fold(primary_keys, |tl_policy, (timelock, path_info)| { + let timelock = ConcretePolicy::Older(Sequence::from_height(timelock)); + let keys = path_info.into_ms_policy(); + let recovery_branch = ConcretePolicy::And(vec![keys.into(), timelock.into()]); + // We assume the larger the timelock the less likely a branch would be used. + ConcretePolicy::Or(vec![(99, tl_policy.into()), (1, recovery_branch.into())]) + }) + } - tl_policy - .compile::() - .map_err(LianaPolicyError::InvalidPolicy) + fn into_multipath_descriptor_fallible( + self, + ) -> Result, LianaPolicyError> { + if self.is_taproot { + // If compiling to a Taproot descriptor and we can't have an internal key, we want to + // compute a deterministic unspendable key to use as internal key. We compute it from + // the xpubs in the Taptree as per + // https://delvingbitcoin.org/t/unspendable-keys-in-descriptors/304/21. However, there + // is clearly an inter-dependency here: we need an internal key to get the Taptree, and + // vice-versa. So we use a dummy internal key. If it ends up as the internal key in the + // compiled descriptor, we replace it with a deterministically computed unspendable + // internal key. + let dummy_internal_key = + descriptor::DescriptorPublicKey::XPub(descriptor::DescriptorXKey:: { + origin: None, + xkey: bip32::Xpub { + public_key: bip341_nums(), + chain_code: [0; 32].into(), + depth: 0, + parent_fingerprint: [0; 4].into(), + child_number: 0.into(), + network: bitcoin::Network::Regtest, + }, + derivation_path: vec![].into(), + wildcard: descriptor::Wildcard::None, + }); + let policy = self.into_policy(); + let desc = policy + .clone() + .compile_tr(Some(dummy_internal_key.clone())) + .map_err(LianaPolicyError::InvalidPolicy)?; + let inner_desc = if let descriptor::Descriptor::Tr(ref d) = desc { + d + } else { + unreachable!("compile_tr() always gives a tr() descriptor."); + }; + if inner_desc.internal_key() == &dummy_internal_key { + // Unfortunately to replace the dummy internal key with the correct one we need to + // perform the computation again. + let actual_internal_key = unspendable_internal_key(inner_desc) + .expect("Desc has a Taptree and only multixpubs."); + policy + .compile_tr(Some(actual_internal_key)) + .map_err(LianaPolicyError::InvalidPolicy) + } else { + // A key from the policy could be used as internal key. No need for a deterministic + // internal key. + Ok(desc) + } + } else { + let ms = self + .into_policy() + .compile::() + .map_err(|e| LianaPolicyError::InvalidPolicy(e.into()))?; + miniscript::Segwitv0::check_local_validity(&ms).expect("Miniscript must be sane"); + Ok(descriptor::Descriptor::Wsh( + descriptor::Wsh::new(ms).expect("Must pass sanity checks"), + )) + } } /// Create a descriptor from this spending policy with multipath key expressions. Note this @@ -552,11 +737,8 @@ impl LianaPolicy { pub fn into_multipath_descriptor( self, ) -> descriptor::Descriptor { - let ms = self - .into_miniscript() - .expect("This is always checked when creating a LianaPolicy."); - miniscript::Segwitv0::check_local_validity(&ms).expect("Miniscript must be sane"); - descriptor::Descriptor::Wsh(descriptor::Wsh::new(ms).expect("Must pass sanity checks")) + self.into_multipath_descriptor_fallible() + .expect("This is always checked when creating a LianaPolicy.") } } diff --git a/src/descriptors/mod.rs b/src/descriptors/mod.rs index 2d249b54..022faeef 100644 --- a/src/descriptors/mod.rs +++ b/src/descriptors/mod.rs @@ -2,13 +2,20 @@ use miniscript::{ bitcoin::{ self, bip32, constants::WITNESS_SCALE_FACTOR, - psbt::{Input as PsbtIn, Psbt}, + psbt::{Input as PsbtIn, Output as PsbtOut, Psbt}, secp256k1, }, - descriptor, translate_hash_clone, ForEachKey, TranslatePk, Translator, + descriptor, + psbt::{PsbtInputExt, PsbtOutputExt}, + translate_hash_clone, ForEachKey, TranslatePk, Translator, }; -use std::{collections::BTreeMap, convert::TryInto, error, fmt, str}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + convert::TryInto, + error, fmt, + str::{self, FromStr}, +}; use serde::{Deserialize, Serialize}; @@ -49,6 +56,29 @@ impl From for LianaDescError { } } +// Whether the key identified by its fingerprint+derivation path was derived from one of the xpubs +// for this spending path. +fn key_is_for_path( + path_origins: &HashMap>, + fg: &bip32::Fingerprint, + der_path: &bip32::DerivationPath, +) -> bool { + // Does it come from a signer used in this spending path? + if let Some(der_paths) = path_origins.get(fg) { + // Get the derivation path from the master fingerprint to the parent used to + // derive this key, in order to check whether it's part of the derivation paths + // used in this spending path (only checking the fingerprint isn't sufficient + // as a single signer may be used in more than one spending path). + // NOTE: this assumes there is only one derivation step after the key used in + // the policy. This is fine, because the keys in the policy are normalized (so + // the derivation path up to the wildcard is part of the origin). + if let Some((_, der_path_no_wildcard)) = der_path[..].split_last() { + return der_paths.contains(&der_path_no_wildcard.into()); + } + } + false +} + /// An [SinglePathLianaDesc] that contains multipath keys for (and only for) the receive keychain /// and the change keychain. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -198,6 +228,8 @@ impl LianaDescriptor { .0 } + // TODO: on Taproot we should use this for recovery but keyspend size if there is a spendable + // internal key. /// Get the maximum size difference of a transaction input spending a Script derived from this /// descriptor before and after satisfaction. The returned value is in weight units. /// Callers are expected to account for the Segwit marker (2 WU). This takes into account the @@ -235,6 +267,11 @@ impl LianaDescriptor { 32 + 4 + 4 + 1 + self.max_sat_vbytes() } + /// Whether this is a Taproot descriptor. + pub fn is_taproot(&self) -> bool { + matches!(self.multi_desc, descriptor::Descriptor::Tr(..)) + } + /// 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. @@ -244,11 +281,38 @@ impl LianaDescriptor { 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)); + let is_taproot = self.is_taproot(); + // Get the origin ECDSA or Schnorr signatures, depending on the descriptor type. + let pubkeys_signed = (!is_taproot) + .then(|| { + // ECDSA sigs. + psbt_in + .partial_sigs + .iter() + .filter_map(|(pk, _)| psbt_in.bip32_derivation.get(&pk.inner)) + }) + .into_iter() + .flatten() + .chain( + is_taproot + .then(|| { + // Tapscript Schnorr sigs. + psbt_in + .tap_script_sigs + .iter() + .filter_map(|((pk, _), _)| { + psbt_in.tap_key_origins.get(pk).map(|or| &or.1) + }) + // Tapkey Schnorr sig. + .chain(psbt_in.tap_key_sig.and_then(|_| { + psbt_in + .tap_internal_key + .and_then(|pk| psbt_in.tap_key_origins.get(&pk).map(|or| &or.1)) + })) + }) + .into_iter() + .flatten(), + ); // 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 @@ -378,24 +442,14 @@ impl LianaDescriptor { // Go through all the PSBT inputs and drop the BIP32 derivations for keys that are not from // this spending path. for psbt_in in psbt.inputs.iter_mut() { - psbt_in.bip32_derivation.retain(|_, (fg, der_path)| { - // Does it come from a signer used in this spending path? - if let Some(der_paths) = path_origins.get(fg) { - // Get the derivation path from the master fingerprint to the parent used to - // derive this key, in order to check whether it's part of the derivation paths - // used in this spending path (only checking the fingerprint isn't sufficient - // as a single signer may be used in more than one spending path). - // NOTE: this assumes there is only one derivation step after the key used in - // the policy. This is fine, because the keys in the policy are normalized (so - // the derivation path up to the wildcard is part of the origin). - if let Some((_, der_path_no_wildcard)) = der_path[..].split_last() { - if der_paths.contains(&der_path_no_wildcard.into()) { - return true; - } - } - } - false - }); + // Perform it for both legacy and Taproot origins, as if one is set the other should be + // empty so it's a noop. + psbt_in + .bip32_derivation + .retain(|_, (fg, der_path)| key_is_for_path(&path_origins, fg, der_path)); + psbt_in + .tap_key_origins + .retain(|_, (_, (fg, der_path))| key_is_for_path(&path_origins, fg, der_path)); } psbt @@ -505,6 +559,11 @@ impl SinglePathLianaDesc { } } +pub enum DescKeysOrigins { + Wsh(BTreeMap), + Tr(BTreeMap), +} + /// Map of a raw public key to the xpub used to derive it and its derivation path pub type Bip32Deriv = BTreeMap; @@ -519,11 +578,13 @@ impl DerivedSinglePathLianaDesc { self.0.script_pubkey() } - pub fn witness_script(&self) -> bitcoin::ScriptBuf { + // NB: panics if called for a Taproot descriptor. + fn witness_script(&self) -> bitcoin::ScriptBuf { self.0.explicit_script().expect("Not a Taproot descriptor") } - pub fn bip32_derivations(&self) -> Bip32Deriv { + // NB: panics if called for a Taproot descriptor. + fn bip32_derivations(&self) -> Bip32Deriv { let ms = match self.0 { descriptor::Descriptor::Wsh(ref wsh) => match wsh.as_inner() { descriptor::WshInner::Ms(ms) => ms, @@ -531,7 +592,7 @@ impl DerivedSinglePathLianaDesc { unreachable!("None of our descriptors is a sorted multi") } }, - _ => unreachable!("All our descriptors are always P2WSH"), + _ => unreachable!("Must never be called for a Taproot descriptor."), }; // For DerivedPublicKey, Pk::Hash == Self. @@ -539,15 +600,54 @@ impl DerivedSinglePathLianaDesc { .map(|k| (k.key.inner, (k.origin.0, k.origin.1))) .collect() } + + // FIXME: update_with_descriptor() needs a Descriptor. This is a temporary hack to + // avoid having to duplicate the cumbersome logic here. Could use translate_pk() instead in the + // future. + fn definite_desc(&self) -> descriptor::Descriptor { + descriptor::Descriptor::<_>::from_str(&self.0.to_string()).expect("Must roundtrip") + } + + /// Update the PSBT input information with data from this derived descriptor. + pub fn update_psbt_in(&self, psbtin: &mut PsbtIn) { + match self.0 { + descriptor::Descriptor::Wsh(_) => { + psbtin.bip32_derivation = self.bip32_derivations(); + psbtin.witness_script = Some(self.witness_script()); + } + descriptor::Descriptor::Tr(_) => { + let desc = self.definite_desc(); + if let Err(e) = psbtin.update_with_descriptor_unchecked(&desc) { + log::error!("BUG! Please report this! Error when adding key origins for desc: {}. Descriptor: {}.", e, desc); + } + } + _ => unreachable!("Only ever a wsh() or a tr() descriptor."), + } + } + + /// Update the info of a PSBT output for a change output with data from this derived + /// descriptor. + pub fn update_change_psbt_out(&self, psbtout: &mut PsbtOut) { + match self.0 { + descriptor::Descriptor::Wsh(_) => { + psbtout.bip32_derivation = self.bip32_derivations(); + } + descriptor::Descriptor::Tr(_) => { + let desc = self.definite_desc(); + if let Err(e) = psbtout.update_with_descriptor_unchecked(&desc) { + log::error!("BUG! Please report this! Error when adding key origins for desc: {}. Descriptor: {}.", e, desc); + } + } + _ => unreachable!("Only ever a wsh() or a tr() descriptor."), + } + } } #[cfg(test)] mod tests { use super::*; - use bitcoin::Sequence; - - use std::str::FromStr; + use bitcoin::{hashes::Hash, Sequence}; use crate::signer::HotSigner; @@ -572,16 +672,25 @@ mod tests { #[test] fn descriptor_creation() { + // Simple 1 primary key, 1 recovery key. let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap()); let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*").unwrap()); let timelock = 52560; - let policy = LianaPolicy::new( + let policy = LianaPolicy::new_legacy( owner_key.clone(), [(timelock, heir_key.clone())].iter().cloned().collect(), ) .unwrap(); assert_eq!(LianaDescriptor::new(policy).to_string(), "wsh(or_d(pk([abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*),and_v(v:pkh([abcdef01]xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*),older(52560))))#g7vk9r5l"); + // Same under Taproot. + let policy = LianaPolicy::new( + owner_key.clone(), + [(timelock, heir_key.clone())].iter().cloned().collect(), + ) + .unwrap(); + assert_eq!(LianaDescriptor::new(policy).to_string(), "tr([abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,and_v(v:pk([abcdef01]xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*),older(52560)))#0mt7e93c"); + // A 3-of-3 multisig decaying into a 2-of-3 multisig after 6 months. Trying to mimic a // real situation, we use keys from 3 different origins (in practice, 3 different devices // held by 3 different persons). @@ -603,12 +712,20 @@ mod tests { descriptor::DescriptorPublicKey::from_str("[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/1/<0;1>/*").unwrap(), ], ); + let policy = LianaPolicy::new_legacy( + primary_keys.clone(), + [(26352, recovery_keys.clone())].iter().cloned().collect(), + ) + .unwrap(); + assert_eq!(LianaDescriptor::new(policy).to_string(), "wsh(or_d(multi(3,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/0/<0;1>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/0/<0;1>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/0/<0;1>/*),and_v(v:thresh(2,pkh([aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/1/<0;1>/*),a:pkh([aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/1/<0;1>/*),a:pkh([aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/1/<0;1>/*)),older(26352))))#prj7nktq"); + + // Same under Taproot. let policy = LianaPolicy::new( primary_keys, [(26352, recovery_keys)].iter().cloned().collect(), ) .unwrap(); - assert_eq!(LianaDescriptor::new(policy).to_string(), "wsh(or_d(multi(3,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/0/<0;1>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/0/<0;1>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/0/<0;1>/*),and_v(v:thresh(2,pkh([aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/1/<0;1>/*),a:pkh([aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/1/<0;1>/*),a:pkh([aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/1/<0;1>/*)),older(26352))))#prj7nktq"); + assert_eq!(LianaDescriptor::new(policy.clone()).to_string(), "tr(xpub661MyMwAqRbcFERisZuMzFcfg3Ur3dKB17kb8iEG89ZJYMHTWqKQGRdLjTXC6Byr8kjKo6JabFfRCm3ETM4woq7DxUXuUxxRFHfog4Peh41/<0;1>/*,{and_v(v:multi_a(2,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/1/<0;1>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/1/<0;1>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/1/<0;1>/*),older(26352)),multi_a(3,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/0/<0;1>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/0/<0;1>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/0/<0;1>/*)})#tugn7xtx"); // Another derivation step before the wildcard is taken into account. // desc_b is the very same descriptor as desc_a, except the very first xpub's derivation @@ -641,26 +758,34 @@ mod tests { descriptor::DescriptorPublicKey::from_str("[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<2;3>/*").unwrap(), ], ); + let policy = LianaPolicy::new_legacy( + primary_keys.clone(), + [(26352, recovery_keys.clone())].iter().cloned().collect(), + ) + .unwrap(); + assert_eq!(LianaDescriptor::new(policy).to_string(), "wsh(or_d(multi(3,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*),and_v(v:thresh(2,pkh([aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<2;3>/*),a:pkh([aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<2;3>/*),a:pkh([aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<2;3>/*)),older(26352))))#d2h994td"); + + // Same under Taproot. let policy = LianaPolicy::new( primary_keys, [(26352, recovery_keys)].iter().cloned().collect(), ) .unwrap(); - assert_eq!(LianaDescriptor::new(policy).to_string(), "wsh(or_d(multi(3,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*),and_v(v:thresh(2,pkh([aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<2;3>/*),a:pkh([aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<2;3>/*),a:pkh([aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<2;3>/*)),older(26352))))#d2h994td"); + assert_eq!(LianaDescriptor::new(policy.clone()).to_string(), "tr(xpub661MyMwAqRbcFERisZuMzFcfg3Ur3dKB17kb8iEG89ZJYMHTWqKQGRdLjTXC6Byr8kjKo6JabFfRCm3ETM4woq7DxUXuUxxRFHfog4Peh41/<0;1>/*,{and_v(v:multi_a(2,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<2;3>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<2;3>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<2;3>/*),older(26352)),multi_a(3,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*)})#ayju5dfr"); // We prevent footguns with timelocks by requiring a u16. Note how the following wouldn't // compile: - //LianaPolicy::new(owner_key.clone(), heir_key.clone(), 0x00_01_0f_00).unwrap_err(); - //LianaPolicy::new(owner_key.clone(), heir_key.clone(), (1 << 31) + 1).unwrap_err(); - //LianaPolicy::new(owner_key, heir_key, (1 << 22) + 1).unwrap_err(); + //LianaPolicy::new_legacy(owner_key.clone(), heir_key.clone(), 0x00_01_0f_00).unwrap_err(); + //LianaPolicy::new_legacy(owner_key.clone(), heir_key.clone(), (1 << 31) + 1).unwrap_err(); + //LianaPolicy::new_legacy(owner_key, heir_key, (1 << 22) + 1).unwrap_err(); // You can't use a null timelock in Miniscript. - LianaPolicy::new(owner_key, [(0, heir_key)].iter().cloned().collect()).unwrap_err(); + LianaPolicy::new_legacy(owner_key, [(0, heir_key)].iter().cloned().collect()).unwrap_err(); let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[aabb0011/10/4893]xpub661MyMwAqRbcFG59fiikD8UV762quhruT8K8bdjqy6N2o3LG7yohoCdLg1m2HAY1W6rfBrtauHkBhbfA4AQ3iazaJj5wVPhwgaRCHBW2DBg/<0;1>/*").unwrap()); let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/24/32/<0;1>/*").unwrap()); let timelock = 57600; - let policy = LianaPolicy::new( + let policy = LianaPolicy::new_legacy( owner_key.clone(), [(timelock, heir_key)].iter().cloned().collect(), ) @@ -671,13 +796,13 @@ mod tests { // without both the change and receive derivation paths, or with more than 2 different // derivation paths. let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/<0;1>/354").unwrap()); - LianaPolicy::new( + LianaPolicy::new_legacy( owner_key.clone(), [(timelock, heir_key)].iter().cloned().collect(), ) .unwrap_err(); let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/<0;1>/*'").unwrap()); - LianaPolicy::new( + LianaPolicy::new_legacy( owner_key.clone(), [(timelock, heir_key)].iter().cloned().collect(), ) @@ -688,19 +813,20 @@ mod tests { ) .unwrap(), ); - LianaPolicy::new( + LianaPolicy::new_legacy( owner_key.clone(), [(timelock, heir_key)].iter().cloned().collect(), ) .unwrap_err(); let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/*'").unwrap()); - LianaPolicy::new( + LianaPolicy::new_legacy( owner_key.clone(), [(timelock, heir_key)].iter().cloned().collect(), ) .unwrap_err(); let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/<0;1;2>/*'").unwrap()); - LianaPolicy::new(owner_key, [(timelock, heir_key)].iter().cloned().collect()).unwrap_err(); + LianaPolicy::new_legacy(owner_key, [(timelock, heir_key)].iter().cloned().collect()) + .unwrap_err(); // And it's checked even in a multisig. For instance: let primary_keys = PathInfo::Multi( @@ -717,6 +843,13 @@ mod tests { descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6AA2N8RALRYgLD6jT1iXYCEDkndTeZndMtWPbtNX6sY5dPiLtf2T88ahdxrGXMUPoNadgR86sFhBXWQVgifPzDYbY9ZtwK4gqzx4y5Da1DW/<0;1>/*").unwrap(), ], ); + LianaPolicy::new_legacy( + primary_keys.clone(), + [(26352, recovery_keys.clone())].iter().cloned().collect(), + ) + .unwrap_err(); + + // It's also checked under Taproot context. LianaPolicy::new( primary_keys, [(26352, recovery_keys)].iter().cloned().collect(), @@ -726,13 +859,16 @@ mod tests { // You can't pass duplicate keys, even if they are encoded differently. let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap()); let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap()); - LianaPolicy::new(owner_key, [(timelock, heir_key)].iter().cloned().collect()).unwrap_err(); + LianaPolicy::new_legacy(owner_key, [(timelock, heir_key)].iter().cloned().collect()) + .unwrap_err(); let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[00aabb44]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap()); let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap()); - LianaPolicy::new(owner_key, [(timelock, heir_key)].iter().cloned().collect()).unwrap_err(); + LianaPolicy::new_legacy(owner_key, [(timelock, heir_key)].iter().cloned().collect()) + .unwrap_err(); let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[00aabb44]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap()); let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[11223344/2/98]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap()); - LianaPolicy::new(owner_key, [(timelock, heir_key)].iter().cloned().collect()).unwrap_err(); + LianaPolicy::new_legacy(owner_key, [(timelock, heir_key)].iter().cloned().collect()) + .unwrap_err(); // You can't pass duplicate keys, even across multisigs. let primary_keys = PathInfo::Multi( @@ -751,6 +887,14 @@ mod tests { descriptor::DescriptorPublicKey::from_str("[abcdef02]xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*").unwrap(), ], ); + let err = LianaPolicy::new_legacy( + primary_keys.clone(), + [(26352, recovery_keys.clone())].iter().cloned().collect(), + ) + .unwrap_err(); + assert!(matches!(err, LianaPolicyError::DuplicateKey(_))); + + // It's also checked under Taproot. let err = LianaPolicy::new( primary_keys, [(26352, recovery_keys)].iter().cloned().collect(), @@ -767,6 +911,14 @@ mod tests { ] ); let recovery_keys = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef02]xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*").unwrap()); + let err = LianaPolicy::new_legacy( + primary_keys.clone(), + [(26352, recovery_keys.clone())].iter().cloned().collect(), + ) + .unwrap_err(); + assert!(matches!(err, LianaPolicyError::DuplicateOriginSamePath(_))); + + // It's also checked under Taproot. let err = LianaPolicy::new( primary_keys, [(26352, recovery_keys)].iter().cloned().collect(), @@ -783,6 +935,14 @@ mod tests { ] ); let primary_keys = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef02]xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*").unwrap()); + let err = LianaPolicy::new_legacy( + primary_keys.clone(), + [(26352, recovery_keys.clone())].iter().cloned().collect(), + ) + .unwrap_err(); + assert!(matches!(err, LianaPolicyError::DuplicateOriginSamePath(_))); + + // It's also checked under Taproot. let err = LianaPolicy::new( primary_keys, [(26352, recovery_keys)].iter().cloned().collect(), @@ -799,6 +959,13 @@ mod tests { ] ); let recovery_keys = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*").unwrap()); + LianaPolicy::new_legacy( + primary_keys.clone(), + [(26352, recovery_keys.clone())].iter().cloned().collect(), + ) + .unwrap(); + + // It's also possible under Taproot. LianaPolicy::new( primary_keys, [(26352, recovery_keys)].iter().cloned().collect(), @@ -809,18 +976,39 @@ mod tests { let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap()); let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*").unwrap()); let timelock = 52560; + LianaPolicy::new_legacy( + owner_key.clone(), + [(timelock, heir_key.clone())].iter().cloned().collect(), + ) + .unwrap_err(); LianaPolicy::new(owner_key, [(timelock, heir_key)].iter().cloned().collect()).unwrap_err(); // One of the xpub isn't normalized. let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap()); let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[aabbccdd]xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/42'/<0;1>/*").unwrap()); let timelock = 52560; + LianaPolicy::new_legacy( + owner_key.clone(), + [(timelock, heir_key.clone())].iter().cloned().collect(), + ) + .unwrap_err(); LianaPolicy::new(owner_key, [(timelock, heir_key)].iter().cloned().collect()).unwrap_err(); // A 1-of-N multisig as primary path. LianaDescriptor::from_str("wsh(or_d(multi(1,[573fb35b/48'/1'/0'/2']tpubDFKp9T7WAYDcENSjoifkrpq1gMDF47KGJcJrpxzX23Qor8wuGbrEVs9utNq1MDS8E2WXJSBk1qoPQLpwyokW7DiUNPwFuxQkL7owNkLAb9W/<0;1>/*,[573fb35c/48'/1'/1'/2']tpubDFGezyzuHJPhdP3jHGW7v7Hwes4Hihqv5W2yyCmRY9VZJCRchETvxrMC8uECeJZdxQ14V4iD4DecoArkUSDwj8ogYE9WEv4MNZr12thNHCs/<0;1>/*),and_v(v:multi(2,[573fb35b/48'/1'/2'/2']tpubDDwxQauiaU964vPzt5Vd7jnDHEUtp2Vc34PaWpEXg5TQ3bRccxnc1MKKh88Hi7xiMeZo9Tm6fBcq4UGXqnDtGUniJLjqAD8SjQ8Eci3aSR7/<0;1>/*,[573fb35c/48'/1'/3'/2']tpubDE37XAVB5CQ1x85md3BQ5uHCoMwT5fgT8X13zzCUQ3x5o2jskYxKjj7Qcxt1Jpj4QB8tqspn2dooPCekRuQDYrDHov7J1ueUNu2wcvgRDxr/<0;1>/*),older(1000))))#fccaqlhh").unwrap(); } + #[test] + fn descriptor_unspendable_internal_key() { + // We correctly detect a deterministically derived unspendable internal key. + LianaDescriptor::from_str("tr(tpubD6NzVbkrYhZ4YdBUPkUhDYj6Sd1QK8vgiCf5RwHnAnSNK5ozemAZzPTYZbgQq4diod7oxFJJYGa8FNRHzRo7URkixzQTuudh38xRRdSc4Hu/<0;1>/*,{and_v(v:multi_a(1,[ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N/<2;3>/*,[da2ee873/48'/1'/0'/2']tpubDEbXY6RbN9mxAvQW797WxReGGkrdyRfdYcehVVaQQcQ3kyfhxSMcnU9qGpUVRHXXALvBtc99jcuxx5tkzcLaJbAukSNpP9h2ti4XFRosv1g/<2;3>/*),older(2)),multi_a(2,[ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N/<0;1>/*,[da2ee873/48'/1'/0'/2']tpubDEbXY6RbN9mxAvQW797WxReGGkrdyRfdYcehVVaQQcQ3kyfhxSMcnU9qGpUVRHXXALvBtc99jcuxx5tkzcLaJbAukSNpP9h2ti4XFRosv1g/<0;1>/*)})").unwrap(); + // Even if it has an origin. + LianaDescriptor::from_str("tr([00000000/1/2/3]tpubD6NzVbkrYhZ4YdBUPkUhDYj6Sd1QK8vgiCf5RwHnAnSNK5ozemAZzPTYZbgQq4diod7oxFJJYGa8FNRHzRo7URkixzQTuudh38xRRdSc4Hu/<0;1>/*,{and_v(v:multi_a(1,[ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N/<2;3>/*,[da2ee873/48'/1'/0'/2']tpubDEbXY6RbN9mxAvQW797WxReGGkrdyRfdYcehVVaQQcQ3kyfhxSMcnU9qGpUVRHXXALvBtc99jcuxx5tkzcLaJbAukSNpP9h2ti4XFRosv1g/<2;3>/*),older(2)),multi_a(2,[ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N/<0;1>/*,[da2ee873/48'/1'/0'/2']tpubDEbXY6RbN9mxAvQW797WxReGGkrdyRfdYcehVVaQQcQ3kyfhxSMcnU9qGpUVRHXXALvBtc99jcuxx5tkzcLaJbAukSNpP9h2ti4XFRosv1g/<0;1>/*)})").unwrap(); + // We'll correctly detect a non-deterministically derived unspendable internal key and + // refuse to parse the descriptor (because it makes it have 2 primary spending paths). + LianaDescriptor::from_str("tr(tpubDCaEmvN8YCgyfjNfX6j7r71h1Gx5pqVDAjT145hd46R4DhN8cuHUC39bqRXd43xnroUNKTUqFi9RGCLtxAxxwB6ysVhAh5k26q7AkNUxF7b/<0;1>/*,{and_v(v:multi_a(1,[ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N/<2;3>/*,[da2ee873/48'/1'/0'/2']tpubDEbXY6RbN9mxAvQW797WxReGGkrdyRfdYcehVVaQQcQ3kyfhxSMcnU9qGpUVRHXXALvBtc99jcuxx5tkzcLaJbAukSNpP9h2ti4XFRosv1g/<2;3>/*),older(2)),multi_a(2,[ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N/<0;1>/*,[da2ee873/48'/1'/0'/2']tpubDEbXY6RbN9mxAvQW797WxReGGkrdyRfdYcehVVaQQcQ3kyfhxSMcnU9qGpUVRHXXALvBtc99jcuxx5tkzcLaJbAukSNpP9h2ti4XFRosv1g/<0;1>/*)})").unwrap_err(); + } + #[test] fn inheritance_descriptor_derivation() { let secp = secp256k1::Secp256k1::verification_only(); @@ -833,8 +1021,10 @@ mod tests { // Sanity check we can call the methods on the derived desc der_desc.script_pubkey(); - der_desc.witness_script(); - assert!(!der_desc.bip32_derivations().is_empty()); + let mut psbt_in = PsbtIn::default(); + der_desc.update_psbt_in(&mut psbt_in); + assert!(psbt_in.witness_script.is_some()); + assert!(!psbt_in.bip32_derivation.is_empty()); } #[test] @@ -887,75 +1077,92 @@ mod tests { let mut twenty_nine_keys = twenty_eight_keys.clone(); twenty_nine_keys.push(random_desc_key(&secp)); + // Test various scenarii which should pass or fail on both Taproot and P2WSH. + macro_rules! test_liana_desc_keys { + ($constructor:expr) => { + $constructor( + prim_path.clone(), + [(1, PathInfo::Multi(2, vec![random_desc_key(&secp)]))] + .iter() + .cloned() + .collect(), + ) + .unwrap_err(); + $constructor( + prim_path.clone(), + [( + 1, + PathInfo::Multi(1, vec![random_desc_key(&secp), random_desc_key(&secp)]), + )] + .iter() + .cloned() + .collect(), + ) + .unwrap(); + $constructor( + prim_path.clone(), + [( + 1, + PathInfo::Multi(0, vec![random_desc_key(&secp), random_desc_key(&secp)]), + )] + .iter() + .cloned() + .collect(), + ) + .unwrap_err(); + $constructor( + prim_path.clone(), + [( + 1, + PathInfo::Multi(2, vec![random_desc_key(&secp), random_desc_key(&secp)]), + )] + .iter() + .cloned() + .collect(), + ) + .unwrap(); + $constructor( + prim_path.clone(), + [( + 1, + PathInfo::Multi(3, vec![random_desc_key(&secp), random_desc_key(&secp)]), + )] + .iter() + .cloned() + .collect(), + ) + .unwrap_err(); + $constructor( + prim_path.clone(), + [(1, PathInfo::Multi(3, twenty_eight_keys.clone()))] + .iter() + .cloned() + .collect(), + ) + .unwrap(); + $constructor( + prim_path.clone(), + [(1, PathInfo::Multi(20, twenty_eight_keys.clone()))] + .iter() + .cloned() + .collect(), + ) + .unwrap(); + }; + } + test_liana_desc_keys!(LianaPolicy::new_legacy); + test_liana_desc_keys!(LianaPolicy::new); + + // A 20-of-28 should pass on Taproot but fail on P2WSH. LianaPolicy::new( prim_path.clone(), - [(1, PathInfo::Multi(2, vec![random_desc_key(&secp)]))] - .iter() - .cloned() - .collect(), - ) - .unwrap_err(); - LianaPolicy::new( - prim_path.clone(), - [( - 1, - PathInfo::Multi(1, vec![random_desc_key(&secp), random_desc_key(&secp)]), - )] - .iter() - .cloned() - .collect(), - ) - .unwrap(); - LianaPolicy::new( - prim_path.clone(), - [( - 1, - PathInfo::Multi(0, vec![random_desc_key(&secp), random_desc_key(&secp)]), - )] - .iter() - .cloned() - .collect(), - ) - .unwrap_err(); - LianaPolicy::new( - prim_path.clone(), - [( - 1, - PathInfo::Multi(2, vec![random_desc_key(&secp), random_desc_key(&secp)]), - )] - .iter() - .cloned() - .collect(), - ) - .unwrap(); - LianaPolicy::new( - prim_path.clone(), - [( - 1, - PathInfo::Multi(3, vec![random_desc_key(&secp), random_desc_key(&secp)]), - )] - .iter() - .cloned() - .collect(), - ) - .unwrap_err(); - LianaPolicy::new( - prim_path.clone(), - [(1, PathInfo::Multi(3, twenty_eight_keys.clone()))] + [(1, PathInfo::Multi(20, twenty_nine_keys.clone()))] .iter() .cloned() .collect(), ) .unwrap(); - LianaPolicy::new( - prim_path.clone(), - [(1, PathInfo::Multi(20, twenty_eight_keys))] - .iter() - .cloned() - .collect(), - ) - .unwrap(); - LianaPolicy::new( + LianaPolicy::new_legacy( prim_path, [(1, PathInfo::Multi(20, twenty_nine_keys))] .iter() @@ -970,16 +1177,26 @@ mod tests { assert_eq!(desc.to_string(), desc_str); } + // Make sure the string representation of our descriptors roundtrip. The Taproot ones were + // generated manually with our code because of the potential need to compute the internal key + // deterministically. #[test] fn roundtrip_descriptor() { // A descriptor with single keys in both primary and recovery paths roundtrip("wsh(or_d(pk([aabbccdd]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*),and_v(v:pkh([aabbccdd]xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*),older(52560))))#7437yjrs"); + roundtrip("tr([8344c025]xpub661MyMwAqRbcG2SYC6YSRsUGvcSxXEZm1kjiQRTEaAqart1PQk1N1hVTTEsGfaBx6xQ5gDYXXtbourodE6ZE5qZTnaMgmehNs8GGEEY9YK6/<0;1>/*,and_v(v:pk([158fd0ef]xpub661MyMwAqRbcF2KsCnvJ4mqWXXrwd3799wCyQrLk2iNDC6CfK8UcfnABdeTpXyoJnBhRTybmtBLDAuTuHye1eQMq43BSLtR2miA6t9KqmWU/<0;1>/*),older(4242)))#zy3kddhj"); // One with a multisig in both paths roundtrip("wsh(or_d(multi(3,[aabbccdd]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,[aabb0011/10/4893]xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*,[aabb0022]xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*),and_v(v:multi(2,[aabbccdd]xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*,[aabb0011]xpub6AA2N8RALRYgLD6jT1iXYCEDkndTeZndMtWPbtNX6sY5dPiLtf2T88ahdxrGXMUPoNadgR86sFhBXWQVgifPzDYbY9ZtwK4gqzx4y5Da1DW/<0;1>/*,[aabb0022/10/4893]xpub6AyxexvxizZJffF153evmfqHcE9MV88fCNCAtP3jQjXJHwrAKri71Tq9jWUkPxj9pja4u6AkCPHY7atgxzSEa2HtDwJfrRWKK4fsfQg4o77/<0;1>/*),older(26352))))#csjdk94l"); + roundtrip("tr(xpub661MyMwAqRbcGg7oXkMMptXXJAGxQtVM7LZeqXNNdxPiWyEmuJdoyFD3NhRpL1YTo313XRWZmiUXTkcEK9EFQHrbie6NBNAvL2CZXp941Li/<0;1>/*,{and_v(v:multi_a(2,[6b882e01]xpub661MyMwAqRbcGRU9psMcDAPd2L2ShzwoenySSSjWpkd7u8Wv7PCPtH5fi6WYYbQAqSG4U3NbuYASCRMkWVYm7yb97iTY4MUKKZ3N8XwCERJ/<0;1>/*,[66b98303]xpub661MyMwAqRbcGicAwMZ5pHCWrB4DEMBGUtvkV2KMMLypR8dbr7g2uV9vzE9w3oDKRqtTV6HYTqHHvusxNwJUXAvRH6BFhKUPTgGMiLPnSmK/<0;1>/*,[9c04a03b]xpub661MyMwAqRbcFCBt8Gjs71UqoMe8V4PSHECuCowg1TR7EkGWLLbu2WanQtcWutzwahrTcicsuL25Q7r6EyfbEKF2jSoekmnw9soZfoiLZXu/<0;1>/*),older(42421)),multi_a(3,[30188cc2]xpub661MyMwAqRbcGeoYQgqUapNKDLBiNE7fcGs6ibKi39GjuiRmV1JgXcfAwHjt7PLLofmz4PPL66NTwAxaTwGtL8YB67RhRspAzbKgneqpenb/<0;1>/*,[aea08adc]xpub661MyMwAqRbcGVL3W5qKT8pjZ3BXcDEJghDj67rKLQYwmTaJLud8RWyYwZQ9LdzkcNtCSCHVypZdUUxd4z2k5hCfb6qprGgwAKqpmaKJTnS/<0;1>/*,[85e33ca4]xpub661MyMwAqRbcFHP9bmnRofzha8c4DHADC7ToPz3kYdov5DDDtgdBEQ3kVcwdjjqAGC8eJZ65CLF2cA9XHhUsJJqKxbE9asj8RUNmGjCJErX/<0;1>/*)})#zm4kj6yd"); // A single key as primary path, a multisig as recovery roundtrip("wsh(or_d(pk([aabbccdd]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*),and_v(v:multi(2,[aabbccdd]xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*,[aabb0011]xpub6AA2N8RALRYgLD6jT1iXYCEDkndTeZndMtWPbtNX6sY5dPiLtf2T88ahdxrGXMUPoNadgR86sFhBXWQVgifPzDYbY9ZtwK4gqzx4y5Da1DW/<0;1>/*,[aabb0022/10/4893]xpub6AyxexvxizZJffF153evmfqHcE9MV88fCNCAtP3jQjXJHwrAKri71Tq9jWUkPxj9pja4u6AkCPHY7atgxzSEa2HtDwJfrRWKK4fsfQg4o77/<0;1>/*),older(26352))))#sc9gw0z0"); + roundtrip("tr([46f4cf22]xpub661MyMwAqRbcG22nAyuZc7MUXR559qKKkHVdi7TKy3Q8m91FKN9heKhP7jWj6SJdyAA9zfQgjQUNWkqjPGJdcH6uFD8mEWJnTps5emjoi9L/<0;1>/*,and_v(v:multi_a(2,[f9eb379c]xpub661MyMwAqRbcF2FpaYbnrN7K6uPhiwg5u1LiqmsMSTnphuhQzpPv9RGdERxDd7pnnrEC8hxttAPi4wbSVsKeJYiHYymfpuxSD7TALTXqjq6/<0;1>/*,[1fc462f2]xpub661MyMwAqRbcEtYavp2XsS9QfH93wyVQnkWenWxWuWdaxDtjBqfzFfWPY83z3da5oYv2XmwgTT97GhGwX9HUGDEP4FERzzgmwaGNAz1emZr/<0;1>/*,[ebcef2a0]xpub661MyMwAqRbcH3JihNDbpEqpmT9xjY5YWd9VwVWQqoWWrggurHs7wsvTXM7ggK5X3wwATxiijwJPe73y9beirtorQebMuL4hR7dbU7akrk7/<0;1>/*),older(124)))#rgrm8l4v"); // The other way around roundtrip("wsh(or_d(multi(3,[aabbccdd]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,[aabb0011/10/4893]xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*,[aabb0022]xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*),and_v(v:pk([aabbccdd]xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*),older(26352))))#kjajav3j"); + roundtrip("tr(xpub661MyMwAqRbcFWPZkATtyZ3cZboVifGEpVoDNLRotSvymYNbb652s75MJs7x6Dsh1K4WidtHAvWiyWu6ufbvP3RG9ozXYhZA83rAniyTAcQ/<0;1>/*,{and_v(v:pk([5c1f5207]xpub661MyMwAqRbcGjhDHfE45ivpxoBGywTdSJa5vWgB5L5BjUjfdvfwQr618o6hjLSCdLruGwu8WFLbgQa7179EC3HEiEUAceLSHArRgsUPRFe/<0;1>/*),older(42)),multi_a(2,[f25498f5]xpub661MyMwAqRbcEZKeQW5gkVY61KyFdmR2ntRmHdnoyWPE5PrgcpAFhotgSftuQVkw1DXoeE7wGQpXkaijczBFNVSFYs4UN2ZsN8tiBR2cffw/<0;1>/*,[0adab7f3]xpub661MyMwAqRbcGrUYLnLEAqJEjmy9NejNXvjneoochu285TgBszNaS5usEnosxKXQxPS5ppWp923EXmi4JgYJWZa4cwEzTX6n6aacG1bN99j/<0;1>/*,[78428935]xpub661MyMwAqRbcGqCdcSemPFvTbhm5swDzRYxP9azdQGPwgcAYumXhFuQUdNFLxaf61uRB7UdpxsYoP3hMW3sqQF7ErWQm5RznBbbVMm6z6wc/<0;1>/*)})#s235c44f"); + // More than 2 spending paths. + roundtrip("wsh(or_i(and_v(v:thresh(1,pkh([19e064b0]xpub661MyMwAqRbcGRgGoZDVccAfLzLuvxkXevrGCq66XGV9mmRLfJ1aiAZNtVUTfxFMoSmPNJLmEywZn8yQXBzVHVENMRbj3VpVvdzCteCkgq6/<0;1>/*),a:pkh([00454cc2]xpub661MyMwAqRbcExaDFtcC1pVGounzi9bmVb4nBxVr3reFEsFpCTn5VVwuDiUFeJkJtppEC7Gzk3cW8htEB9Q3DcXV28SAHioi2oJZv6oTobF/<0;1>/*)),older(1678)),or_i(and_v(v:thresh(2,pkh([0143d6e6]xpub661MyMwAqRbcFuLKDCSKk2r3KKN7FWXZnx9s7V1xScv7N1qs7xXPxjrarBaPkuzV9ji7Hquwf4G6G12pXFmaeqwXWhKzwSp1j8JgLHTKUDn/<0;1>/*),a:pkh([cd1f0cf2]xpub661MyMwAqRbcGcFwqwdNkHojx6ffeQEXPfopamNscuf4CXaLrKVMkCTpffiFNJ3okep7bgNVx13N1rryW3nQNiPAsrkr5zL9T3vo2ww4fC8/<0;1>/*),a:pkh([d76af68c]xpub661MyMwAqRbcGVFCMA5yqLiF9xj6G9QoqFDQdqmnyCZDTFTfpfUgzevrohjSrMTjoBYyB5YvKjtEqqX9U6yjgDCYRT8e7DeYqLnu6DbFAgj/<0;1>/*)),older(43)),or_d(multi(2,[5b016400]xpub661MyMwAqRbcGZFpjHB8mvxGnDGDdEBetsFu25nC69SrGAJKJVctsFwNNY5VwPMVx7aXL6m1LKAeA5qAE3Wheh5cAKBdxqSFrRBd3Vf7eTX/<0;1>/*,[6b0a6b3f]xpub661MyMwAqRbcGzZBMBU1evaZmfwmEVkzU8oRhu4y7DSaaHHoQeHDbM47JNqeEbJRGGhMNd1Hp9oP2NdYbRHxxcd7YfABiNfULyVW9vDg5cx/<0;1>/*,[1c4eb5d3]xpub661MyMwAqRbcGFr2mWaBr1rX3xfnv75FbzP1hPW7LEzYMzDZV5wPVgcrYEZWxwu8ALUTRJ5ioutE3mz5dTQBWKEkvxCytV3QeNdm4cDHr6p/<0;1>/*),and_v(v:pkh([5c055660]xpub661MyMwAqRbcEqgeH5cqyxRwY4UG21ey1MBJkNBX2xSTmGS9dCRmGQezqHE9mXUXzs9HqFzNEN2KkNw5o8xpqAXw2XxsVhGVm1LbRaEnxyT/<0;1>/*),older(42))))))#hd246u4a"); + roundtrip("tr(xpub661MyMwAqRbcGqmqNapgQ9kqrLcDeZLHPktzsBcZXTtNx7aEay8NKQPizKcpu2fUejNbZzhZQaZLeDWL3nt8zg9QbFLBUTRQu4qqcSzeEmF/<0;1>/*,{{and_v(v:multi_a(1,[b4e32970]xpub661MyMwAqRbcEbs6ohRoUqTckEfLeT3vB2EsuWuckrEuDSKqdFXV6so8xJb4kvA4ZxT6hCydyFKsKwJrDm2LgSfTCphVqZgQbLzF49KwaXc/<0;1>/*,[c318e87f]xpub661MyMwAqRbcG2qnrFJ2MhKFSHehbVkK38gFfG7zXwasN51dKrL4kffj1HRd2zFhAZeQsjYKS8YaiN4sC4gVPHR28qXdQf7pf7nbYoefg6T/<0;1>/*),older(1678)),{and_v(v:pk([6c0d38a3]xpub661MyMwAqRbcF87hAvenL8GHW7qxhtn8Y9zHVkQbuTsd6RVtWkhBY5gh6m4Rua9ENmYDx7jTb8kbiyVB9iaLAbyRudxPFVTFoGPp6rTqoZn/<0;1>/*),older(42)),and_v(v:multi_a(2,[2e1370a6]xpub661MyMwAqRbcGRzCgSNLW7VFUFdwvC1dFXmKgWbZwQERj2QfNQuy5diCQSHNXuQYSS9FwXykLeWKtnZ5yRJ4ZHZzYqWf13FUY4PbDpBhipr/<0;1>/*,[fae2633e]xpub661MyMwAqRbcG9qKwZ7F363Mx3Ai3H2aMXAWTjvYCZrH4wqDEDLnsVghWFrwTKwpDGGzsSDCL7vPTiaiY7DhhdV2bY6RdPNGd7bF9om1MFz/<0;1>/*,[2ae87e33]xpub661MyMwAqRbcGw8ZvGfdLEjhCk4YC9hZrrUceKipiH32ANDMQccYFqq91kH8RpcwGiPnCbUWFo1S6ZGY2GxbVdJFsMYXqzpL1byJ1D3G2Mh/<0;1>/*),older(43))}},multi_a(2,[40f48611]xpub661MyMwAqRbcGUkDb45NBcMYwaaSE3fhsMNwvdf2psYhrqhFmRJY9n8irJuEB3juhK5LQPBiiqdr2gixMmC7Nmtg3Mwu4C5wbeagaAzbb9W/<0;1>/*,[a2bdfbe5]xpub661MyMwAqRbcH228eUBaJvc7Va1y7cGyEH9DZ5vPneKgZDX8eMsSd8PHS3uRYCFySyHPy3VfGfS8vKb5FzcS2MbNorNVv2c3Hn7AvVJJZ73/<0;1>/*,[028ece7a]xpub661MyMwAqRbcG9W1pZzs7rvWVtHeW1anzABj8iQRBbnz8yLf7vgUmYkVsydLf1hLffibgfzUjTBcrNCDKaBNnuqLtsp1xyiLSZJyLDtEjkF/<0;1>/*)})#xgzxdvrv"); } fn psbt_from_str(psbt_str: &str) -> Psbt { @@ -987,7 +1204,7 @@ mod tests { } #[test] - fn partial_spend_info() { + fn partial_spend_info_p2wsh() { let secp = secp256k1::Secp256k1::signing_only(); // A simple descriptor with 1 keys as primary path and 1 recovery key. @@ -1178,7 +1395,7 @@ mod tests { let first_reco_path = PathInfo::Multi(3, (0..5).map(|_| random_desc_key(&secp)).collect()); let sec_reco_path = PathInfo::Multi(2, (0..5).map(|_| random_desc_key(&secp)).collect()); let third_reco_path = PathInfo::Multi(1, (0..5).map(|_| random_desc_key(&secp)).collect()); - let liana_policy = LianaPolicy::new( + let liana_policy = LianaPolicy::new_legacy( prim_path.clone(), [ (26784, first_reco_path.clone()), @@ -1318,8 +1535,320 @@ mod tests { } } + // The same as above but adapted for Taproot. + #[test] + fn partial_spend_info_taproot() { + let secp = secp256k1::Secp256k1::signing_only(); + let dummy_xonly_pubkeys = [ + bitcoin::XOnlyPublicKey::from_str( + "85899827df71b16f2f0eed47cb920e3963f5204e171f5ef7ea4eeec3d5ecc607", + ) + .unwrap(), + bitcoin::XOnlyPublicKey::from_str( + "0b71b3b1b1f2deebedebd54723428efb1d8d00b3c38a138977ef28f4fea848c9", + ) + .unwrap(), + ]; + let dummy_sig = bitcoin::taproot::Signature::from_slice(&[0; 64]).unwrap(); + let dummy_leafhash = bitcoin::TapLeafHash::from_slice(&[0; 32]).unwrap(); + let dummy_psbt = psbt_from_str("cHNidP8BAHECAAAAAUSHuliRtuCX1S6JxRuDRqDCKkWfKmWL5sV9ukZ/wzvfAAAAAAD9////AogTAAAAAAAAFgAUIxe7UY6LJ6y5mFBoWTOoVispDmdwFwAAAAAAABYAFKqO83TK+t/KdpAt21z2HGC7/Z2FAAAAAAABASsQJwAAAAAAACIAIIIySQjGCTeyx/rKUQx8qobjhJeNCiVCliBJPdyRX6XKAQVBIQI2cqWpc9UAW2gZt2WkKjvi8KoMCui00pRlL6wG32uKDKxzZHapFNYASzIYkEdH9bJz6nnqUG3uBB8kiK1asmgAAAA="); + + // A simple descriptor with 1 keys as primary path and 1 recovery key. + let desc = LianaDescriptor::from_str("tr([f5acc2fd]tpubD6NzVbkrYhZ4YgUx2ZLNt2rLYAMTdYysCRzKoLu2BeSHKvzqPaBDvf17GeBPnExUVPkuBpx4kniP964e2MxyzzazcXLptxLXModSVCVEV1T/<0;1>/*,and_v(v:pkh([8a64f2a9]tpubD6NzVbkrYhZ4WmzFjvQrp7sDa4ECUxTi9oby8K4FZkd3XCBtEdKwUiQyYJaxiJo5y42gyDWEczrFpozEjeLxMPxjf2WtkfcbpUdfvNnozWF/<0;1>/*),older(10)))").unwrap(); + let desc_info = desc.policy(); + let prim_key_fg = bip32::Fingerprint::from_str("f5acc2fd").unwrap(); + let prim_key_origin = (prim_key_fg, [0.into(), 0.into()][..].into()); + let recov_key_origin: (_, bip32::DerivationPath) = ( + bip32::Fingerprint::from_str("8a64f2a9").unwrap(), + [0.into(), 4242.into()][..].into(), + ); + + // 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 = dummy_psbt.clone(); + 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_paths.is_empty()); + + // 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_paths.keys().next().unwrap() - 1); + let info = desc.partial_spend_info(&unsigned_single_psbt).unwrap(); + assert!(info.recovery_paths.is_empty()); + + // Now if we set the sequence at the right value we'll have it. + let timelock = *desc_info.recovery_paths.keys().next().unwrap(); + unsigned_single_psbt.unsigned_tx.input[0].sequence = Sequence::from_height(timelock); + let info = desc.partial_spend_info(&unsigned_single_psbt).unwrap(); + assert!(info.recovery_paths.contains_key(&timelock)); + + // 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(timelock + 42); + let info = desc.partial_spend_info(&unsigned_single_psbt).unwrap(); + let recov_info = info.recovery_paths.get(&timelock).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 = dummy_psbt.clone(); + signed_single_psbt.inputs[0].tap_internal_key = Some(dummy_xonly_pubkeys[0]); + signed_single_psbt.inputs[0].tap_key_sig = Some(dummy_sig); + signed_single_psbt.inputs[0].tap_key_origins = [( + dummy_xonly_pubkeys[0], + (vec![dummy_leafhash], prim_key_origin.clone()), + )] + .iter() + .cloned() + .collect(); + let info = desc.partial_spend_info(&signed_single_psbt).unwrap(); + 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_paths.is_empty()); + + // Now enable the recovery path and add a signature for the recovery key. + let mut signed_recov_psbt = dummy_psbt.clone(); + signed_recov_psbt.unsigned_tx.input[0].sequence = Sequence::from_height(timelock); + let recov_key = dummy_xonly_pubkeys[1]; + signed_recov_psbt.inputs[0] + .tap_script_sigs + .insert((recov_key, dummy_leafhash), dummy_sig); + signed_recov_psbt.inputs[0].tap_key_origins = + [(recov_key, (vec![dummy_leafhash], recov_key_origin.clone()))] + .iter() + .cloned() + .collect(); + let info = desc.partial_spend_info(&signed_recov_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()); + let recov_info = info.recovery_paths.get(&timelock).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_origin.0) + ); + + // A PSBT with multiple inputs, all signed for the primary path but with an ECDSA + // signature. We must not account for those signatures since this is a Taproot descriptor. + let psbt: Psbt = psbt_from_str("cHNidP8BAP0fAQIAAAAGAGo6V8K5MtKcQ8vRFedf5oJiOREiH4JJcEniyRv2800BAAAAAP3///9e3dVLjWKPAGwDeuUOmKFzOYEP5Ipu4LWdOPA+lITrRgAAAAAA/f///7cl9oeu9ssBXKnkWMCUnlgZPXhb+qQO2+OPeLEsbdGkAQAAAAD9////idkxRErbs34vsHUZ7QCYaiVaAFDV9gxNvvtwQLozwHsAAAAAAP3///9EakyJhd2PjwYh1I7zT2cmcTFI5g1nBd3srLeL7wKEewIAAAAA/f///7BcaP77nMaA2NjT/hyI6zueB/2jU/jK4oxmSqMaFkAzAQAAAAD9////AUAfAAAAAAAAFgAUqo7zdMr638p2kC3bXPYcYLv9nYUAAAAAAAEA/X4BAgAAAAABApEoe5xCmSi8hNTtIFwsy46aj3hlcLrtFrug39v5wy+EAQAAAGpHMEQCIDeI8JTWCTyX6opCCJBhWc4FytH8g6fxDaH+Wa/QqUoMAiAgbITpz8TBhwxhv/W4xEXzehZpOjOTjKnPw36GIy6SHAEhA6QnYCHUbU045FVh6ZwRwYTVineqRrB9tbqagxjaaBKh/v///+v1seDE9gGsZiWwewQs3TKuh0KSBIHiEtG8ABbz2DpAAQAAAAD+////Aqhaex4AAAAAFgAUkcVOEjVMct0jyCzhZN6zBT+lvTQvIAAAAAAAACIAIKKDUd/GWjAnwU99llS9TAK2dK80/nSRNLjmrhj0odUEAAJHMEQCICSn+boh4ItAa3/b4gRUpdfblKdcWtMLKZrgSEFFrC+zAiBtXCx/Dq0NutLSu1qmzFF1lpwSCB3w3MAxp5W90z7b/QEhA51S2ERUi0bg+l+bnJMJeAfDknaetMTagfQR9+AOrVKlxdMkAAEBKy8gAAAAAAAAIgAgooNR38ZaMCfBT32WVL1MArZ0rzT+dJE0uOauGPSh1QQiAgN+zbSfdr8oJBtlKomnQTHynF2b/UhovAwf0eS8awRSqUgwRQIhAJhm6xQvxt2LY+eNZqjhsgMOAxD0OPYty6nf9WaQZtgkAiBf/AXkeyq6ALknO9TZwY6ZRa0evY+DQ3j3XaqiBiAMfgEBBUEhA37NtJ92vygkG2UqiadBMfKcXZv9SGi8DB/R5LxrBFKprHNkdqkUxttmGj2sqzzaxSaacJTnJPDCbY6IrVqyaCIGAv9qeBDEB+5kvM/sZ8jQ7QApfZcDrqtq5OAe2gQ1V+pmDIpk8qkAAAAA0AAAACIGA37NtJ92vygkG2UqiadBMfKcXZv9SGi8DB/R5LxrBFKpDPWswv0AAAAA0AAAAAABAOoCAAAAAAEB0OPoVJs9ihvnAwjO16k/wGJuEus1IEE1Yo2KBjC2NSEAAAAAAP7///8C6AMAAAAAAAAiACBfeUS9jQv6O1a96Aw/mPV6gHxHl3mfj+f0frfAs2sMpP1QGgAAAAAAFgAUDS4UAIpdm1RlFYmg0OoCxW0yBT4CRzBEAiAPvbNlnhiUxLNshxN83AuK/lGWwlpXOvmcqoxsMLzIKwIgWwATJuYPf9buLe9z5SnXVnPVL0q6UZaWE5mjCvEl1RUBIQI54LFZmq9Lw0pxKpEGeqI74NnIfQmLMDcv5ySplUS1/wDMJAABASvoAwAAAAAAACIAIF95RL2NC/o7Vr3oDD+Y9XqAfEeXeZ+P5/R+t8CzawykIgICYn4eZbb6KGoxB1PEv/XPiujZFDhfoi/rJPtfHPVML2lHMEQCIDOHEqKdBozXIPLVgtBj3eWC1MeIxcKYDADe4zw0DbcMAiAq4+dbkTNCAjyCxJi0TKz5DWrPulxrqOdjMRHWngXHsQEBBUEhAmJ+HmW2+ihqMQdTxL/1z4ro2RQ4X6Iv6yT7Xxz1TC9prHNkdqkUzc/gCLoe6rQw63CGXhIR3YRz1qCIrVqyaCIGAmJ+HmW2+ihqMQdTxL/1z4ro2RQ4X6Iv6yT7Xxz1TC9pDPWswv0AAAAAqgAAACIGA8JCTIzdSoTJhiKN1pn+NnlkyuKOndiTgH2NIX+yNsYqDIpk8qkAAAAAqgAAAAABAOoCAAAAAAEBRGpMiYXdj48GIdSO809nJnExSOYNZwXd7Ky3i+8ChHsAAAAAAP7///8COMMQAAAAAAAWABQ5rnyuG5T8iuhqfaGAmpzlybo3t+gDAAAAAAAAIgAg7Kz3CX1RBjIvbK9LBYztmi7F1XIxQpX6mtCUkflvvl8CRzBEAiBaYx4sOHckEZwDnSrbb1ivc6seX4Puasm1PBGnBWgSTQIgCeUiXvd90ajI3F4/BHifLUI4fVIgVQFCqLTbbeXQD5oBIQOmGm+gTRx1slzF+wn8NhZoR1xfSYgoKX6bpRSVRjLcEXrOJAABASvoAwAAAAAAACIAIOys9wl9UQYyL2yvSwWM7ZouxdVyMUKV+prQlJH5b75fIgID0X2UJhC5+2jgJqUrihxZxDZHK7jgPFlrUYzoSHQTmP9HMEQCIEM4K8lVACvE2oSMZHDJiOeD81qsYgAvgpRgcSYgKc3AAiAQjdDr2COBea69W+2iVbnODuH3QwacgShW3dS4yeggJAEBBUEhA9F9lCYQufto4CalK4ocWcQ2Ryu44DxZa1GM6Eh0E5j/rHNkdqkU0DTexcgOQQ+BFjgS031OTxcWiH2IrVqyaCIGA9F9lCYQufto4CalK4ocWcQ2Ryu44DxZa1GM6Eh0E5j/DPWswv0AAAAAvwAAACIGA/xg4Uvem3JHVPpyTLP5JWiUH/yk3Y/uUI6JkZasCmHhDIpk8qkAAAAAvwAAAAABAOoCAAAAAAEBmG+mPq0O6QSWEMctsMjvv5LzWHGoT8wsA9Oa05kxIxsBAAAAAP7///8C6AMAAAAAAAAiACDUvIILFr0OxybADV3fB7ms7+ufnFZgicHR0nbI+LFCw1UoGwAAAAAAFgAUC+1ZjCC1lmMcvJ/4JkevqoZF4igCRzBEAiA3d8o96CNgNWHUkaINWHTvAUinjUINvXq0KBeWcsSWuwIgKfzRNWFR2LDbnB/fMBsBY/ylVXcSYwLs8YC+kmko1zIBIQOpEfsLv0htuertA1sgzCwGvHB0vE4zFO69wWEoHClKmAfMJAABASvoAwAAAAAAACIAINS8ggsWvQ7HJsANXd8Huazv65+cVmCJwdHSdsj4sULDIgID96jZc0sCi0IIXf2CpfE7tY+9LRmMsOdSTTHelFxfCwJHMEQCIHlaiMMznx8Cag8Y3X2gXi9Qtg0ZuyHEC6DsOzipSGOKAiAV2eC+S3Mbq6ig5QtRvTBsq5M3hCBdEJQlOrLVhWWt6AEBBUEhA/eo2XNLAotCCF39gqXxO7WPvS0ZjLDnUk0x3pRcXwsCrHNkdqkUyJ+Cbx7vYVY665yjJnMNODyYrAuIrVqyaCIGAt8UyDXk+mW3Y6IZNIBuDJHkdOaZi/UEShkN5L3GiHR5DIpk8qkAAAAAuAAAACIGA/eo2XNLAotCCF39gqXxO7WPvS0ZjLDnUk0x3pRcXwsCDPWswv0AAAAAuAAAAAABAP0JAQIAAAAAAQG7Zoy4I3J9x+OybAlIhxVKcYRuPFrkDFJfxMiC3kIqIAEAAAAA/v///wO5xxAAAAAAABYAFHgBzs9wJNVk6YwR81IMKmckTmC56AMAAAAAAAAWABTQ/LmJix5JoHBOr8LcgEChXHdLROgDAAAAAAAAIgAg7Kz3CX1RBjIvbK9LBYztmi7F1XIxQpX6mtCUkflvvl8CRzBEAiA+sIKnWVE3SmngjUgJdu1K2teW6eqeolfGe0d11b+irAIgL20zSabXaFRNM8dqVlcFsfNJ0exukzvxEOKl/OcF8VsBIQJrUspHq45AMSwbm24//2a9JM8XHFWbOKpyV+gNCtW71nrOJAABASvoAwAAAAAAACIAIOys9wl9UQYyL2yvSwWM7ZouxdVyMUKV+prQlJH5b75fIgID0X2UJhC5+2jgJqUrihxZxDZHK7jgPFlrUYzoSHQTmP9IMEUCIQCmDhJ9fyhlQwPruoOUemDuldtRu3ZkiTM3DA0OhkguSQIgYerNaYdP43DcqI5tnnL3n4jEeMHFCs+TBkOd6hDnqAkBAQVBIQPRfZQmELn7aOAmpSuKHFnENkcruOA8WWtRjOhIdBOY/6xzZHapFNA03sXIDkEPgRY4EtN9Tk8XFoh9iK1asmgiBgPRfZQmELn7aOAmpSuKHFnENkcruOA8WWtRjOhIdBOY/wz1rML9AAAAAL8AAAAiBgP8YOFL3ptyR1T6ckyz+SVolB/8pN2P7lCOiZGWrAph4QyKZPKpAAAAAL8AAAAAAQDqAgAAAAABAT6/vc6qBRzhQyjVtkC25NS2BvGyl2XjjEsw3e8vAesjAAAAAAD+////AgPBAO4HAAAAFgAUEwiWd/qI1ergMUw0F1+qLys5G/foAwAAAAAAACIAIOOPEiwmp2ZXR7ciyrveITXw0tn6zbQUA1Eikd9QlHRhAkcwRAIgJMZdO5A5u2UIMrAOgrR4NcxfNgZI6OfY7GKlZP0O8yUCIDFujbBRnamLEbf0887qidnXo6UgQA9IwTx6Zomd4RvJASEDoNmR2/XcqSyCWrE1tjGJ1oLWlKt4zsFekK9oyB4Hl0HF0yQAAQEr6AMAAAAAAAAiACDjjxIsJqdmV0e3Isq73iE18NLZ+s20FANRIpHfUJR0YSICAo3uyJxKHR9Z8fwvU7cywQCnZyPvtMl3nv54wPW1GSGqSDBFAiEAlLY98zqEL/xTUvm9ZKy5kBa4UWfr4Ryu6BmSZjseXPQCIGy7efKbZLQSDq8RhgNNjl1384gWFTN7nPwWV//SGriyAQEFQSECje7InEodH1nx/C9TtzLBAKdnI++0yXee/njA9bUZIaqsc2R2qRQhPRlaLsh/M/K/9fvbjxF/M20cNoitWrJoIgYCF7Rj5jFhe5L6VDzP5m2BeaG0mA9e7+6fMeWkWxLwpbAMimTyqQAAAADNAAAAIgYCje7InEodH1nx/C9TtzLBAKdnI++0yXee/njA9bUZIaoM9azC/QAAAADNAAAAAAA="); + 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, 0); + assert!(info.primary_path.signed_pubkeys.is_empty()); + assert!(info.recovery_paths.is_empty()); + + // If we analyze a descriptor with a multisig we'll get the right threshold. + let desc = LianaDescriptor::new( + LianaPolicy::new( + PathInfo::Multi( + 2, + vec![ + descriptor::DescriptorPublicKey::from_str("[f5acc2fd]tpubD6NzVbkrYhZ4YgUx2ZLNt2rLYAMTdYysCRzKoLu2BeSHKvzqPaBDvf17GeBPnExUVPkuBpx4kniP964e2MxyzzazcXLptxLXModSVCVEV1T/<0;1>/*").unwrap(), + random_desc_key(&secp), + random_desc_key(&secp), + ], + ), + [( + 42, + PathInfo::Multi( + 1, + (0..3).map(|_| random_desc_key(&secp)).collect() + ), + )] + .iter() + .cloned() + .collect(), + ) + .unwrap(), + ); + let prim_key = dummy_xonly_pubkeys[0]; + let mut psbt = dummy_psbt.clone(); + psbt.inputs[0] + .tap_script_sigs + .insert((prim_key, dummy_leafhash), dummy_sig); + psbt.inputs[0].tap_key_origins = + [(prim_key, (vec![dummy_leafhash], prim_key_origin.clone()))] + .iter() + .cloned() + .collect(); + let info = desc.partial_spend_info(&psbt).unwrap(); + 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_paths.is_empty()); + + // A not very well thought-out decaying multisig. + let prim_path = PathInfo::Multi(3, vec![ + descriptor::DescriptorPublicKey::from_str("[f5acc2fd]tpubD6NzVbkrYhZ4YgUx2ZLNt2rLYAMTdYysCRzKoLu2BeSHKvzqPaBDvf17GeBPnExUVPkuBpx4kniP964e2MxyzzazcXLptxLXModSVCVEV1T/<0;1>/*").unwrap(), + random_desc_key(&secp), + random_desc_key(&secp), + ]); + let first_reco_path = PathInfo::Multi(3, (0..5).map(|_| random_desc_key(&secp)).collect()); + let sec_reco_path = PathInfo::Multi(2, (0..5).map(|_| random_desc_key(&secp)).collect()); + let third_reco_path = PathInfo::Multi(1, (0..5).map(|_| random_desc_key(&secp)).collect()); + let liana_policy = LianaPolicy::new( + prim_path.clone(), + [ + (26784, first_reco_path.clone()), + (53568, sec_reco_path.clone()), + (62496, third_reco_path.clone()), + ] + .iter() + .cloned() + .collect(), + ) + .unwrap(); + let desc = LianaDescriptor::new(liana_policy.clone()); + let policy = desc.policy(); + assert_eq!(policy, liana_policy); + let mut psbt = dummy_psbt.clone(); + let empty_partial_info = desc.partial_spend_info(&psbt).unwrap(); + assert_eq!(empty_partial_info.primary_path.threshold, 3); + assert_eq!(empty_partial_info.primary_path.sigs_count, 0); + assert_eq!( + empty_partial_info.primary_path.sigs_count, + empty_partial_info.primary_path.signed_pubkeys.len() + ); + assert!(empty_partial_info.recovery_paths.is_empty()); + + // Now set a signature for the primary path. All recovery paths still empty, a signature is + // present for the primary path. + let prim_key = dummy_xonly_pubkeys[0]; + psbt.inputs[0] + .tap_script_sigs + .insert((prim_key, dummy_leafhash), dummy_sig); + psbt.inputs[0].tap_key_origins = + [(prim_key, (vec![dummy_leafhash], prim_key_origin.clone()))] + .iter() + .cloned() + .collect(); + let partial_info = desc.partial_spend_info(&psbt).unwrap(); + assert_eq!(partial_info.primary_path.threshold, 3); + assert_eq!(partial_info.primary_path.sigs_count, 1); + assert_eq!( + partial_info.primary_path.sigs_count, + partial_info.primary_path.signed_pubkeys.len() + ); + assert!(partial_info.recovery_paths.is_empty()); + + // Now enable the first recovery path and make the signature be for this path. + let fingerprint = first_reco_path + .thresh_origins() + .1 + .into_iter() + .next() + .unwrap() + .0; + psbt.inputs[0] + .tap_key_origins + .get_mut(&prim_key) + .unwrap() + .1 + .0 = fingerprint; + let partial_info = desc.partial_spend_info(&psbt).unwrap(); + assert_eq!(partial_info.primary_path.threshold, 3); + assert_eq!(partial_info.primary_path.sigs_count, 0); + assert_eq!( + partial_info.primary_path.sigs_count, + partial_info.primary_path.signed_pubkeys.len() + ); + assert!(partial_info.recovery_paths.is_empty()); + psbt.unsigned_tx.input[0].sequence = bitcoin::Sequence::from_height(26784); + let partial_info = desc.partial_spend_info(&psbt).unwrap(); + assert_eq!(partial_info.recovery_paths.len(), 1); + assert_eq!(partial_info.recovery_paths[&26784].threshold, 3); + assert_eq!(partial_info.recovery_paths[&26784].sigs_count, 1); + assert_eq!( + partial_info.recovery_paths[&26784].signed_pubkeys.len(), + partial_info.recovery_paths[&26784].sigs_count + ); + + // Now enable the second recovery path and make the signature be for this path. + let fingerprint = sec_reco_path + .thresh_origins() + .1 + .into_iter() + .next() + .unwrap() + .0; + psbt.inputs[0] + .tap_key_origins + .get_mut(&prim_key) + .unwrap() + .1 + .0 = fingerprint; + psbt.unsigned_tx.input[0].sequence = bitcoin::Sequence::from_height(53568); + let partial_info = desc.partial_spend_info(&psbt).unwrap(); + assert_eq!(partial_info.primary_path.threshold, 3); + assert_eq!(partial_info.primary_path.sigs_count, 0); + assert_eq!( + partial_info.primary_path.sigs_count, + partial_info.primary_path.signed_pubkeys.len() + ); + assert_eq!(partial_info.recovery_paths.len(), 2); + assert_eq!(partial_info.recovery_paths[&26784].threshold, 3); + assert_eq!(partial_info.recovery_paths[&26784].sigs_count, 0); + assert_eq!(partial_info.recovery_paths[&53568].threshold, 2); + assert_eq!(partial_info.recovery_paths[&53568].sigs_count, 1); + for rec_path in partial_info.recovery_paths.values() { + assert_eq!(rec_path.sigs_count, rec_path.signed_pubkeys.len()); + } + + // Finally do the same for the third recovery path. + let fingerprint = third_reco_path + .thresh_origins() + .1 + .into_iter() + .next() + .unwrap() + .0; + psbt.inputs[0] + .tap_key_origins + .get_mut(&prim_key) + .unwrap() + .1 + .0 = fingerprint; + psbt.unsigned_tx.input[0].sequence = bitcoin::Sequence::from_height(53568); + psbt.unsigned_tx.input[0].sequence = bitcoin::Sequence::from_height(62496); + let partial_info = desc.partial_spend_info(&psbt).unwrap(); + assert_eq!(partial_info.primary_path.threshold, 3); + assert_eq!(partial_info.primary_path.sigs_count, 0); + assert_eq!( + partial_info.primary_path.sigs_count, + partial_info.primary_path.signed_pubkeys.len() + ); + assert_eq!(partial_info.recovery_paths.len(), 3); + assert_eq!(partial_info.recovery_paths[&26784].threshold, 3); + assert_eq!(partial_info.recovery_paths[&26784].sigs_count, 0); + assert_eq!(partial_info.recovery_paths[&53568].threshold, 2); + assert_eq!(partial_info.recovery_paths[&53568].sigs_count, 0); + assert_eq!(partial_info.recovery_paths[&62496].threshold, 1); + assert_eq!(partial_info.recovery_paths[&62496].sigs_count, 1); + for rec_path in partial_info.recovery_paths.values() { + assert_eq!(rec_path.sigs_count, rec_path.signed_pubkeys.len()); + } + } + #[test] fn bip32_derivs_pruning() { + let secp = secp256k1::Secp256k1::signing_only(); + let dummy_leafhash = bitcoin::TapLeafHash::from_slice(&[0; 32]).unwrap(); + // A signet descriptor created using Liana v2. let desc = LianaDescriptor::from_str("wsh(or_i(and_v(v:thresh(3,pkh([636adf3f/48'/1'/0'/2']tpubDEE9FvWbG4kg4gxDNrALgrWLiHwNMXNs8hk6nXNPw4VHKot16xd2251vwi2M6nsyQTkak5FJNHVHkCcuzmvpSbWHdumX3DxpDm89iTfSBaL/<4;5>/*),a:pkh([172ba1bc/48'/1'/0'/2']tpubDEgTZEAraUrKmnbyKJuXYGFPzNCm82bjMqd2GRy2HKviJ1moLtEZrHoUeG2o6uyWLEGx4yBWpctAmxcBx1b5nrrrBo5LjskRxRMDmwkuKxq/<4;5>/*),a:pkh([903115ef/48'/1'/0'/2']tpubDF2Hqd3HXUn5bDMVa2gssqmdTjQsLm9Vc8CSSJFk4YwQg8PChCZiWopAeQ6ZCEWt21n1W8ApEGxEvtB8uPnWW6EG3fwPAFnFM8US4QmgKvp/<4;5>/*)),older(6)),or_d(multi(3,[636adf3f/48'/1'/0'/2']tpubDEE9FvWbG4kg4gxDNrALgrWLiHwNMXNs8hk6nXNPw4VHKot16xd2251vwi2M6nsyQTkak5FJNHVHkCcuzmvpSbWHdumX3DxpDm89iTfSBaL/<0;1>/*,[172ba1bc/48'/1'/0'/2']tpubDEgTZEAraUrKmnbyKJuXYGFPzNCm82bjMqd2GRy2HKviJ1moLtEZrHoUeG2o6uyWLEGx4yBWpctAmxcBx1b5nrrrBo5LjskRxRMDmwkuKxq/<0;1>/*,[903115ef/48'/1'/0'/2']tpubDF2Hqd3HXUn5bDMVa2gssqmdTjQsLm9Vc8CSSJFk4YwQg8PChCZiWopAeQ6ZCEWt21n1W8ApEGxEvtB8uPnWW6EG3fwPAFnFM8US4QmgKvp/<0;1>/*),and_v(v:thresh(2,pkh([636adf3f/48'/1'/0'/2']tpubDEE9FvWbG4kg4gxDNrALgrWLiHwNMXNs8hk6nXNPw4VHKot16xd2251vwi2M6nsyQTkak5FJNHVHkCcuzmvpSbWHdumX3DxpDm89iTfSBaL/<2;3>/*),a:pkh([172ba1bc/48'/1'/0'/2']tpubDEgTZEAraUrKmnbyKJuXYGFPzNCm82bjMqd2GRy2HKviJ1moLtEZrHoUeG2o6uyWLEGx4yBWpctAmxcBx1b5nrrrBo5LjskRxRMDmwkuKxq/<2;3>/*),a:pkh([903115ef/48'/1'/0'/2']tpubDF2Hqd3HXUn5bDMVa2gssqmdTjQsLm9Vc8CSSJFk4YwQg8PChCZiWopAeQ6ZCEWt21n1W8ApEGxEvtB8uPnWW6EG3fwPAFnFM8US4QmgKvp/<2;3>/*)),older(3)))))#jxya9h7u").unwrap(); // A spend PSBT created using Liana v2. @@ -1347,6 +1876,70 @@ mod tests { let psbt = desc.prune_bip32_derivs_last_avail(psbt).unwrap(); assert_eq!(psbt.inputs[0].bip32_derivation.len(), 3); assert_eq!(psbt, pruned_psbt); + + // Now do the same but with a Taproot descriptor. + let (prim_key, rec_key) = (random_desc_key(&secp), random_desc_key(&secp)); + let prim_origin = + if let descriptor::DescriptorPublicKey::MultiXPub(descriptor::DescriptorMultiXKey { + ref origin, + .. + }) = prim_key + { + (origin.as_ref().unwrap().0, [0.into(), 1.into()][..].into()) + } else { + unreachable!(); + }; + let rec_origin = + if let descriptor::DescriptorPublicKey::MultiXPub(descriptor::DescriptorMultiXKey { + xkey, + .. + }) = rec_key + { + (xkey.fingerprint(), [0.into(), 1.into()][..].into()) + } else { + unreachable!(); + }; + let tap_desc = LianaDescriptor::new( + LianaPolicy::new( + PathInfo::Single(prim_key), + [(14, PathInfo::Single(rec_key))].iter().cloned().collect(), + ) + .unwrap(), + ); + let prim_path_info = tap_desc.policy().primary_path; + let rec_path_info = &tap_desc.policy().recovery_paths[&14]; + let mut tap_psbt = psbt; + let dummy_xonly_pubkey = bitcoin::XOnlyPublicKey::from_str( + "85899827df71b16f2f0eed47cb920e3963f5204e171f5ef7ea4eeec3d5ecc607", + ) + .unwrap(); + + // Set the origins for the primary key. Pruning all but the primary origins should conserve + // it. Pruning all but the recovery should drop it. + tap_psbt.inputs[0].bip32_derivation.clear(); + tap_psbt.inputs[0].tap_key_origins = + [(dummy_xonly_pubkey, (vec![dummy_leafhash], prim_origin))] + .iter() + .cloned() + .collect(); + assert_eq!(tap_psbt.inputs[0].tap_key_origins.len(), 1); + let tap_psbt = tap_desc.prune_bip32_derivs(tap_psbt, &prim_path_info); + assert_eq!(tap_psbt.inputs[0].tap_key_origins.len(), 1); + let mut tap_psbt = tap_desc.prune_bip32_derivs(tap_psbt, rec_path_info); + assert!(tap_psbt.inputs[0].tap_key_origins.is_empty()); + + // Do the opposite. + tap_psbt.inputs[0].bip32_derivation.clear(); + tap_psbt.inputs[0].tap_key_origins = + [(dummy_xonly_pubkey, (vec![dummy_leafhash], rec_origin))] + .iter() + .cloned() + .collect(); + assert_eq!(tap_psbt.inputs[0].tap_key_origins.len(), 1); + let tap_psbt = tap_desc.prune_bip32_derivs(tap_psbt, rec_path_info); + assert_eq!(tap_psbt.inputs[0].tap_key_origins.len(), 1); + let tap_psbt = tap_desc.prune_bip32_derivs(tap_psbt, &prim_path_info); + assert!(tap_psbt.inputs[0].tap_key_origins.is_empty()); } #[test] diff --git a/src/lib.rs b/src/lib.rs index c3a3b52e..b608b24e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -229,7 +229,10 @@ fn setup_bitcoind( .as_ref() .ok_or(StartupError::MissingBitcoindConfig)?; let bitcoind = BitcoinD::new(bitcoind_config, wo_path_str)?; - bitcoind.node_sanity_checks(config.bitcoin_config.network)?; + bitcoind.node_sanity_checks( + config.bitcoin_config.network, + config.main_descriptor.is_taproot(), + )?; if fresh_data_dir { log::info!("Creating a new watchonly wallet on bitcoind."); bitcoind.create_watchonly_wallet(&config.main_descriptor)?; diff --git a/src/signer.rs b/src/signer.rs index e074e233..5b53343f 100644 --- a/src/signer.rs +++ b/src/signer.rs @@ -18,7 +18,8 @@ use miniscript::bitcoin::{ bip32::{self, Error as Bip32Error}, ecdsa, hashes::Hash, - psbt::Psbt, + key::TapTweak, + psbt::{Input as PsbtIn, Psbt}, secp256k1, sighash, }; @@ -228,56 +229,167 @@ impl HotSigner { bip32::Xpub::from_priv(secp, &xpriv) } + // Provide an ECDSA signature for this transaction input from the PSBT input information. + fn sign_p2wsh( + &self, + secp: &secp256k1::Secp256k1, + sighash_cache: &mut sighash::SighashCache<&bitcoin::Transaction>, + master_fingerprint: bip32::Fingerprint, + psbt_in: &mut PsbtIn, + input_index: usize, + ) -> Result<(), SignerError> { + // First of all compute the sighash for this input. We assume P2WSH spend: the sighash + // script code is always the witness script. + let witscript = psbt_in + .witness_script + .as_ref() + .ok_or(SignerError::IncompletePsbt)?; + let value = psbt_in + .witness_utxo + .as_ref() + .ok_or(SignerError::IncompletePsbt)? + .value; + let sig_type = sighash::EcdsaSighashType::All; + let sighash = sighash_cache + .p2wsh_signature_hash(input_index, witscript, value, sig_type) + .map_err(|_| SignerError::InsanePsbt)?; + let sighash = secp256k1::Message::from_digest_slice(sighash.as_byte_array()) + .expect("Sighash is always 32 bytes."); + + // Then provide a signature for all the keys they asked for. + for (curr_pubkey, (fingerprint, der_path)) in psbt_in.bip32_derivation.iter() { + if *fingerprint != master_fingerprint { + continue; + } + let privkey = self.xpriv_at(der_path, secp).to_priv(); + let pubkey = privkey.public_key(secp); + if pubkey.inner != *curr_pubkey { + return Err(SignerError::InsanePsbt); + } + let sig = secp.sign_ecdsa_low_r(&sighash, &privkey.inner); + psbt_in.partial_sigs.insert( + pubkey, + ecdsa::Signature { + sig, + hash_ty: sig_type, + }, + ); + } + + Ok(()) + } + + // Provide a BIP340 signature for this transaction input from the PSBT input information. + fn sign_taproot( + &self, + secp: &secp256k1::Secp256k1, + sighash_cache: &mut sighash::SighashCache<&bitcoin::Transaction>, + master_fingerprint: bip32::Fingerprint, + prevouts: &[bitcoin::TxOut], + psbt_in: &mut PsbtIn, + input_index: usize, + ) -> Result<(), SignerError> { + let sig_type = sighash::TapSighashType::Default; + let prevouts = sighash::Prevouts::All(prevouts); + + // If the details of the internal key are filled, provide a keypath signature. + if let Some(ref int_key) = psbt_in.tap_internal_key { + // NB: we don't check for empty leaf hashes on purpose, in case the internal key also + // appears in a leaf. + if let Some((_, (fg, der_path))) = psbt_in.tap_key_origins.get(int_key) { + if *fg == master_fingerprint { + let privkey = self.xpriv_at(der_path, secp).to_priv(); + let keypair = secp256k1::Keypair::from_secret_key(secp, &privkey.inner); + if keypair.x_only_public_key().0 != *int_key { + return Err(SignerError::InsanePsbt); + } + let keypair = keypair.tap_tweak(secp, psbt_in.tap_merkle_root).to_inner(); + let sighash = sighash_cache + .taproot_key_spend_signature_hash(input_index, &prevouts, sig_type) + .map_err(|_| SignerError::InsanePsbt)?; + let sighash = secp256k1::Message::from_digest_slice(sighash.as_byte_array()) + .expect("Sighash is always 32 bytes."); + let sig = secp.sign_schnorr_no_aux_rand(&sighash, &keypair); + let sig = bitcoin::taproot::Signature { + sig, + hash_ty: sig_type, + }; + psbt_in.tap_key_sig = Some(sig); + } + } + } + + // Now sign for all the public keys derived from our master secret, in all the leaves where + // they are present. + for (pubkey, (leaf_hashes, (fg, der_path))) in &psbt_in.tap_key_origins { + if *fg != master_fingerprint { + continue; + } + + for leaf_hash in leaf_hashes { + let privkey = self.xpriv_at(der_path, secp).to_priv(); + let keypair = secp256k1::Keypair::from_secret_key(secp, &privkey.inner); + let sighash = sighash_cache + .taproot_script_spend_signature_hash( + input_index, + &prevouts, + *leaf_hash, + sig_type, + ) + .map_err(|_| SignerError::InsanePsbt)?; + let sighash = secp256k1::Message::from_digest_slice(sighash.as_byte_array()) + .expect("Sighash is always 32 bytes."); + let sig = secp.sign_schnorr_no_aux_rand(&sighash, &keypair); + let sig = bitcoin::taproot::Signature { + sig, + hash_ty: sig_type, + }; + psbt_in.tap_script_sigs.insert((*pubkey, *leaf_hash), sig); + } + } + + Ok(()) + } + /// Sign all inputs of the given PSBT. /// /// **This does not perform any check. It will blindly sign anything that's passed.** pub fn sign_psbt( &self, mut psbt: Psbt, - secp: &secp256k1::Secp256k1, + secp: &secp256k1::Secp256k1, ) -> Result { let master_fingerprint = self.fingerprint(secp); let mut sighash_cache = sighash::SighashCache::new(&psbt.unsigned_tx); + let prevouts: Vec<_> = psbt + .inputs + .iter() + .filter_map(|psbt_in| psbt_in.witness_utxo.clone()) + .collect(); + if prevouts.len() != psbt.inputs.len() { + return Err(SignerError::IncompletePsbt); + } + // Sign each input in the PSBT. for i in 0..psbt.inputs.len() { - // First of all compute the sighash for this input. We assume P2WSH spend: the sighash - // script code is always the witness script. - let witscript = psbt.inputs[i] - .witness_script - .as_ref() - .ok_or(SignerError::IncompletePsbt)?; - let value = psbt.inputs[i] - .witness_utxo - .as_ref() - .ok_or(SignerError::IncompletePsbt)? - .value; - let sig_type = sighash::EcdsaSighashType::All; - let sighash = sighash_cache - .p2wsh_signature_hash(i, witscript, value, sig_type) - .map_err(|_| SignerError::InsanePsbt)?; - let sighash = secp256k1::Message::from_digest_slice(sighash.as_byte_array()) - .expect("Sighash is always 32 bytes."); - - // Then provide a signature for all the keys they asked for. - let input = &mut psbt.inputs[i]; // for borrowck reasons - for (curr_pubkey, (fingerprint, der_path)) in input.bip32_derivation.iter() { - if *fingerprint != master_fingerprint { - continue; - } - let privkey = self.xpriv_at(der_path, secp).to_priv(); - let pubkey = privkey.public_key(secp); - if pubkey.inner != *curr_pubkey { - return Err(SignerError::InsanePsbt); - } - let sig = secp.sign_ecdsa_low_r(&sighash, &privkey.inner); - input.partial_sigs.insert( - pubkey, - ecdsa::Signature { - sig, - hash_ty: sig_type, - }, - ); + if psbt.inputs[i].witness_script.is_some() { + self.sign_p2wsh( + secp, + &mut sighash_cache, + master_fingerprint, + &mut psbt.inputs[i], + i, + )?; + } else { + self.sign_taproot( + secp, + &mut sighash_cache, + master_fingerprint, + &prevouts, + &mut psbt.inputs[i], + i, + )?; } } @@ -357,7 +469,7 @@ mod tests { } #[test] - fn hot_signer_sign() { + fn hot_signer_sign_p2wsh() { let secp = secp256k1::Secp256k1::new(); let network = bitcoin::Network::Bitcoin; @@ -419,14 +531,22 @@ mod tests { wildcard: Wildcard::Unhardened, }); let recov_keys = descriptors::PathInfo::Single(recov_key); - let policy = - descriptors::LianaPolicy::new(prim_keys, [(46, recov_keys)].iter().cloned().collect()) - .unwrap(); + let policy = descriptors::LianaPolicy::new_legacy( + prim_keys, + [(46, recov_keys)].iter().cloned().collect(), + ) + .unwrap(); let desc = descriptors::LianaDescriptor::new(policy); // Create a dummy PSBT spending a coin from this descriptor with a single input and single // (external) output. We'll be modifying it as we go. let spent_coin_desc = desc.receive_descriptor().derive(42.into(), &secp); + let mut psbt_in = PsbtIn::default(); + spent_coin_desc.update_psbt_in(&mut psbt_in); + psbt_in.witness_utxo = Some(bitcoin::TxOut { + value: Amount::from_sat(19_000), + script_pubkey: spent_coin_desc.script_pubkey(), + }); let mut dummy_psbt = Psbt { unsigned_tx: bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, @@ -453,15 +573,7 @@ mod tests { xpub: BTreeMap::new(), proprietary: BTreeMap::new(), unknown: BTreeMap::new(), - inputs: vec![PsbtIn { - witness_script: Some(spent_coin_desc.witness_script()), - bip32_derivation: spent_coin_desc.bip32_derivations(), - witness_utxo: Some(bitcoin::TxOut { - value: Amount::from_sat(19_000), - script_pubkey: spent_coin_desc.script_pubkey(), - }), - ..PsbtIn::default() - }], + inputs: vec![psbt_in], outputs: Vec::new(), }; @@ -469,12 +581,20 @@ mod tests { // that it manages. let psbt = dummy_psbt.clone(); assert!(psbt.inputs[0].partial_sigs.is_empty()); + assert!(psbt.inputs[0].tap_key_sig.is_none()); + assert!(psbt.inputs[0].tap_script_sigs.is_empty()); let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap(); assert_eq!(psbt.inputs[0].partial_sigs.len(), 1); + assert!(psbt.inputs[0].tap_key_sig.is_none()); + assert!(psbt.inputs[0].tap_script_sigs.is_empty()); let psbt = prim_signer_b.sign_psbt(psbt, &secp).unwrap(); assert_eq!(psbt.inputs[0].partial_sigs.len(), 2); + assert!(psbt.inputs[0].tap_key_sig.is_none()); + assert!(psbt.inputs[0].tap_script_sigs.is_empty()); let psbt = recov_signer.sign_psbt(psbt, &secp).unwrap(); assert_eq!(psbt.inputs[0].partial_sigs.len(), 4); + assert!(psbt.inputs[0].tap_key_sig.is_none()); + assert!(psbt.inputs[0].tap_script_sigs.is_empty()); // We can add another external output to the transaction, we can still sign without issue. // The output can be insane, we don't check it. It doesn't even need an accompanying PSBT @@ -482,25 +602,31 @@ mod tests { dummy_psbt.unsigned_tx.output.push(bitcoin::TxOut::NULL); let psbt = dummy_psbt.clone(); assert!(psbt.inputs[0].partial_sigs.is_empty()); + assert!(psbt.inputs[0].tap_key_sig.is_none()); + assert!(psbt.inputs[0].tap_script_sigs.is_empty()); let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap(); assert_eq!(psbt.inputs[0].partial_sigs.len(), 1); + assert!(psbt.inputs[0].tap_key_sig.is_none()); + assert!(psbt.inputs[0].tap_script_sigs.is_empty()); let psbt = prim_signer_b.sign_psbt(psbt, &secp).unwrap(); assert_eq!(psbt.inputs[0].partial_sigs.len(), 2); + assert!(psbt.inputs[0].tap_key_sig.is_none()); + assert!(psbt.inputs[0].tap_script_sigs.is_empty()); let psbt = recov_signer.sign_psbt(psbt, &secp).unwrap(); assert_eq!(psbt.inputs[0].partial_sigs.len(), 4); + assert!(psbt.inputs[0].tap_key_sig.is_none()); + assert!(psbt.inputs[0].tap_script_sigs.is_empty()); // We can add another input to the PSBT. If we don't attach also another transaction input // it will fail. let other_spent_coin_desc = desc.receive_descriptor().derive(84.into(), &secp); - dummy_psbt.inputs.push(PsbtIn { - witness_script: Some(other_spent_coin_desc.witness_script()), - bip32_derivation: other_spent_coin_desc.bip32_derivations(), - witness_utxo: Some(bitcoin::TxOut { - value: Amount::from_sat(19_000), - script_pubkey: other_spent_coin_desc.script_pubkey(), - }), - ..PsbtIn::default() + let mut psbt_in = PsbtIn::default(); + other_spent_coin_desc.update_psbt_in(&mut psbt_in); + psbt_in.witness_utxo = Some(bitcoin::TxOut { + value: Amount::from_sat(19_000), + script_pubkey: other_spent_coin_desc.script_pubkey(), }); + dummy_psbt.inputs.push(psbt_in); let psbt = dummy_psbt.clone(); assert!(prim_signer_a .sign_psbt(psbt, &secp) @@ -524,31 +650,63 @@ mod tests { .inputs .iter() .all(|psbt_in| psbt_in.partial_sigs.is_empty())); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_script_sigs.is_empty())); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_key_sig.is_none())); let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap(); assert!(psbt .inputs .iter() .all(|psbt_in| psbt_in.partial_sigs.len() == 1)); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_script_sigs.is_empty())); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_key_sig.is_none())); let psbt = prim_signer_b.sign_psbt(psbt, &secp).unwrap(); assert!(psbt .inputs .iter() .all(|psbt_in| psbt_in.partial_sigs.len() == 2)); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_script_sigs.is_empty())); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_key_sig.is_none())); let psbt = recov_signer.sign_psbt(psbt, &secp).unwrap(); assert!(psbt .inputs .iter() .all(|psbt_in| psbt_in.partial_sigs.len() == 4)); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_script_sigs.is_empty())); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_key_sig.is_none())); - // If the witness script is missing for one of the inputs it'll tell us the PSBT is - // incomplete. + // If the witness script is missing for one of the inputs it'll assume it's a Taproot input + // and provide Taproot signatures. But since we haven't provided any Taproot details it + // won't fill anything. let mut psbt = dummy_psbt.clone(); psbt.inputs[1].witness_script = None; - assert!(prim_signer_a - .sign_psbt(psbt, &secp) - .unwrap_err() - .to_string() - .contains("The PSBT is missing some information necessary for signing.")); + let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap(); + assert!(psbt.inputs[1].partial_sigs.is_empty()); + assert!(psbt.inputs[1].tap_key_sig.is_none()); + assert!(psbt.inputs[1].tap_script_sigs.is_empty()); // If the witness utxo is missing for one of the inputs it'll tell us the PSBT is // incomplete. @@ -571,6 +729,322 @@ mod tests { assert_eq!(psbt.inputs[1].partial_sigs.len(), 1); } + #[test] + fn hot_signer_sign_taproot() { + let secp = secp256k1::Secp256k1::new(); + let network = bitcoin::Network::Bitcoin; + + // Create a Liana descriptor with as primary path a 2-of-3 with three hot signers and a + // single hot signer as recovery path. (The recovery path signer is also used in the + // primary path.) Use various random derivation paths. + let (prim_signer_a, prim_signer_b, recov_signer) = ( + HotSigner::generate(network).unwrap(), + HotSigner::generate(network).unwrap(), + HotSigner::generate(network).unwrap(), + ); + let origin_der = bip32::DerivationPath::from_str("m/0'/12'/42").unwrap(); + let xkey = prim_signer_a.xpub_at(&origin_der, &secp); + let prim_key_a = DescriptorPublicKey::MultiXPub(DescriptorMultiXKey { + origin: Some((prim_signer_a.fingerprint(&secp), origin_der)), + xkey, + derivation_paths: DerivPaths::new(vec![ + bip32::DerivationPath::from_str("m/420/56/0").unwrap(), + bip32::DerivationPath::from_str("m/420/56/1").unwrap(), + ]) + .unwrap(), + wildcard: Wildcard::Unhardened, + }); + let origin_der = bip32::DerivationPath::from_str("m/18'/24'").unwrap(); + let xkey = prim_signer_b.xpub_at(&origin_der, &secp); + let prim_key_b = DescriptorPublicKey::MultiXPub(DescriptorMultiXKey { + origin: Some((prim_signer_b.fingerprint(&secp), origin_der)), + xkey, + derivation_paths: DerivPaths::new(vec![ + bip32::DerivationPath::from_str("m/31/0").unwrap(), + bip32::DerivationPath::from_str("m/31/1").unwrap(), + ]) + .unwrap(), + wildcard: Wildcard::Unhardened, + }); + let origin_der = bip32::DerivationPath::from_str("m/18'/25'").unwrap(); + let xkey = recov_signer.xpub_at(&origin_der, &secp); + let prim_key_c = DescriptorPublicKey::MultiXPub(DescriptorMultiXKey { + origin: Some((recov_signer.fingerprint(&secp), origin_der)), + xkey, + derivation_paths: DerivPaths::new(vec![ + bip32::DerivationPath::from_str("m/0").unwrap(), + bip32::DerivationPath::from_str("m/1").unwrap(), + ]) + .unwrap(), + wildcard: Wildcard::Unhardened, + }); + let prim_keys = + descriptors::PathInfo::Multi(2, vec![prim_key_a.clone(), prim_key_b, prim_key_c]); + let origin_der = bip32::DerivationPath::from_str("m/1/2'/3/4'").unwrap(); + let xkey = recov_signer.xpub_at(&origin_der, &secp); + let recov_key = DescriptorPublicKey::MultiXPub(DescriptorMultiXKey { + origin: Some((recov_signer.fingerprint(&secp), origin_der)), + xkey, + derivation_paths: DerivPaths::new(vec![ + bip32::DerivationPath::from_str("m/5/6/0").unwrap(), + bip32::DerivationPath::from_str("m/5/6/1").unwrap(), + ]) + .unwrap(), + wildcard: Wildcard::Unhardened, + }); + let recov_keys = descriptors::PathInfo::Single(recov_key.clone()); + let policy = + descriptors::LianaPolicy::new(prim_keys, [(46, recov_keys)].iter().cloned().collect()) + .unwrap(); + let desc = descriptors::LianaDescriptor::new(policy); + + // Create a dummy PSBT spending a coin from this descriptor with a single input and single + // (external) output. We'll be modifying it as we go. + let spent_coin_desc = desc.receive_descriptor().derive(42.into(), &secp); + let mut psbt_in = PsbtIn::default(); + spent_coin_desc.update_psbt_in(&mut psbt_in); + psbt_in.witness_utxo = Some(bitcoin::TxOut { + value: Amount::from_sat(19_000), + script_pubkey: spent_coin_desc.script_pubkey(), + }); + let mut dummy_psbt = Psbt { + unsigned_tx: bitcoin::Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: absolute::LockTime::Blocks(absolute::Height::ZERO), + input: vec![bitcoin::TxIn { + sequence: bitcoin::Sequence::ENABLE_RBF_NO_LOCKTIME, + previous_output: bitcoin::OutPoint::from_str( + "4613e078e4cdbb0fce1bc6e44b028f0e11621a134a1605efdc456c32d155c922:19", + ) + .unwrap(), + ..bitcoin::TxIn::default() + }], + output: vec![bitcoin::TxOut { + value: Amount::from_sat(18_420), + script_pubkey: bitcoin::Address::from_str( + "bc1qvklensptw5lk7d470ds60pcpsr0psdpgyvwepv", + ) + .unwrap() + .payload() + .script_pubkey(), + }], + }, + version: 0, + xpub: BTreeMap::new(), + proprietary: BTreeMap::new(), + unknown: BTreeMap::new(), + inputs: vec![psbt_in], + outputs: Vec::new(), + }; + + // Sign the PSBT with the two primary signers. The recovery signer will sign for the two keys + // that it manages. + let psbt = dummy_psbt.clone(); + assert!(psbt.inputs[0].partial_sigs.is_empty()); + assert!(psbt.inputs[0].tap_key_sig.is_none()); + assert!(psbt.inputs[0].tap_script_sigs.is_empty()); + let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap(); + assert_eq!(psbt.inputs[0].tap_script_sigs.len(), 1); + assert!(psbt.inputs[0].tap_key_sig.is_none()); + assert!(psbt.inputs[0].partial_sigs.is_empty()); + let psbt = prim_signer_b.sign_psbt(psbt, &secp).unwrap(); + assert_eq!(psbt.inputs[0].tap_script_sigs.len(), 2); + assert!(psbt.inputs[0].tap_key_sig.is_none()); + assert!(psbt.inputs[0].partial_sigs.is_empty()); + let psbt = recov_signer.sign_psbt(psbt, &secp).unwrap(); + assert_eq!(psbt.inputs[0].tap_script_sigs.len(), 4); + assert!(psbt.inputs[0].tap_key_sig.is_none()); + assert!(psbt.inputs[0].partial_sigs.is_empty()); + + // We can add another external output to the transaction, we can still sign without issue. + // The output can be insane, we don't check it. It doesn't even need an accompanying PSBT + // output. + dummy_psbt.unsigned_tx.output.push(bitcoin::TxOut::NULL); + let psbt = dummy_psbt.clone(); + assert!(psbt.inputs[0].tap_script_sigs.is_empty()); + assert!(psbt.inputs[0].tap_key_sig.is_none()); + assert!(psbt.inputs[0].tap_script_sigs.is_empty()); + let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap(); + assert_eq!(psbt.inputs[0].tap_script_sigs.len(), 1); + assert!(psbt.inputs[0].tap_key_sig.is_none()); + assert!(psbt.inputs[0].partial_sigs.is_empty()); + let psbt = prim_signer_b.sign_psbt(psbt, &secp).unwrap(); + assert_eq!(psbt.inputs[0].tap_script_sigs.len(), 2); + assert!(psbt.inputs[0].tap_key_sig.is_none()); + assert!(psbt.inputs[0].partial_sigs.is_empty()); + let psbt = recov_signer.sign_psbt(psbt, &secp).unwrap(); + assert_eq!(psbt.inputs[0].tap_script_sigs.len(), 4); + assert!(psbt.inputs[0].tap_key_sig.is_none()); + assert!(psbt.inputs[0].partial_sigs.is_empty()); + + // We can add another input to the PSBT. If we don't attach also another transaction input + // it will fail. + let other_spent_coin_desc = desc.receive_descriptor().derive(84.into(), &secp); + let mut psbt_in = PsbtIn::default(); + other_spent_coin_desc.update_psbt_in(&mut psbt_in); + psbt_in.witness_utxo = Some(bitcoin::TxOut { + value: Amount::from_sat(19_000), + script_pubkey: other_spent_coin_desc.script_pubkey(), + }); + dummy_psbt.inputs.push(psbt_in); + let psbt = dummy_psbt.clone(); + assert!(prim_signer_a + .sign_psbt(psbt, &secp) + .unwrap_err() + .to_string() + .contains("Information contained in the PSBT is wrong")); + + // But now if we add the inputs also to the transaction itself, it will have signed both + // inputs. + dummy_psbt.unsigned_tx.input.push(bitcoin::TxIn { + // Note the sequence can be different. We don't care. + sequence: bitcoin::Sequence::ENABLE_LOCKTIME_NO_RBF, + previous_output: bitcoin::OutPoint::from_str( + "5613e078e4cdbb0fce1bc6e44b028f0e11621a134a1605efdc456c32d155c922:0", + ) + .unwrap(), + ..bitcoin::TxIn::default() + }); + let psbt = dummy_psbt.clone(); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_script_sigs.is_empty())); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_script_sigs.is_empty())); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_key_sig.is_none())); + let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap(); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_script_sigs.len() == 1)); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.partial_sigs.is_empty())); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_key_sig.is_none())); + let psbt = prim_signer_b.sign_psbt(psbt, &secp).unwrap(); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_script_sigs.len() == 2)); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.partial_sigs.is_empty())); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_key_sig.is_none())); + let psbt = recov_signer.sign_psbt(psbt, &secp).unwrap(); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_script_sigs.len() == 4)); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.partial_sigs.is_empty())); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_key_sig.is_none())); + + // If the witness script is set it'll assume it's a P2WSH input and provide ECDSA sigs. + // But since we haven't provided any P2WSH details it won't fill anything. + let mut psbt = dummy_psbt.clone(); + psbt.inputs[1].witness_script = Some(Default::default()); + let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap(); + assert!(psbt.inputs[1].partial_sigs.is_empty()); + assert!(psbt.inputs[1].tap_key_sig.is_none()); + assert!(psbt.inputs[1].tap_script_sigs.is_empty()); + + // If the witness utxo is missing for one of the inputs it'll tell us the PSBT is + // incomplete. + let mut psbt = dummy_psbt.clone(); + psbt.inputs[1].witness_utxo = None; + assert!(prim_signer_a + .sign_psbt(psbt, &secp) + .unwrap_err() + .to_string() + .contains("The PSBT is missing some information necessary for signing.")); + + // If we remove the BIP32 derivations for the first input it will only provide signatures + // for the second one. + let mut psbt = dummy_psbt.clone(); + assert!(psbt.inputs[0].tap_script_sigs.is_empty()); + assert!(psbt.inputs[1].tap_script_sigs.is_empty()); + psbt.inputs[0].tap_key_origins.clear(); + let psbt = prim_signer_b.sign_psbt(psbt, &secp).unwrap(); + assert!(psbt.inputs[0].tap_script_sigs.is_empty()); + assert_eq!(psbt.inputs[1].tap_script_sigs.len(), 1); + + // Now use a Taproot descriptor such as there is a single primary key as the internal key. + let prim_keys = descriptors::PathInfo::Single(prim_key_a); + let recov_keys = descriptors::PathInfo::Single(recov_key); + let policy = + descriptors::LianaPolicy::new(prim_keys, [(42, recov_keys)].iter().cloned().collect()) + .unwrap(); + let desc = descriptors::LianaDescriptor::new(policy); + let spent_coin_desc = desc.receive_descriptor().derive(412.into(), &secp); + + // Update the two inputs with the details for this descriptor. + dummy_psbt.inputs[0].tap_key_origins.clear(); + spent_coin_desc.update_psbt_in(&mut dummy_psbt.inputs[0]); + dummy_psbt.inputs[1].tap_key_origins.clear(); + spent_coin_desc.update_psbt_in(&mut dummy_psbt.inputs[1]); + + // Sign the PSBT with the primary and recovery signers. The prim signer will add a sig for + // the key path and the recov signer for the script path. + let psbt = dummy_psbt.clone(); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_script_sigs.is_empty())); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_script_sigs.is_empty())); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_key_sig.is_none())); + let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap(); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_key_sig.is_some())); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_script_sigs.is_empty())); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.partial_sigs.is_empty())); + let psbt = recov_signer.sign_psbt(psbt, &secp).unwrap(); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_key_sig.is_some())); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.tap_script_sigs.len() == 1)); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.partial_sigs.is_empty())); + } + #[test] fn signer_set_net() { let secp = secp256k1::Secp256k1::signing_only(); diff --git a/src/spend.rs b/src/spend.rs index d545375e..0118ef0b 100644 --- a/src/spend.rs +++ b/src/spend.rs @@ -114,7 +114,7 @@ fn sanity_check_psbt( // index set for signing devices to recognize them as ours. let mut value_in = 0; for psbtin in psbt.inputs.iter() { - if psbtin.bip32_derivation.is_empty() { + if psbtin.bip32_derivation.is_empty() && psbtin.tap_key_origins.is_empty() { return Err(SpendCreationError::SanityCheckFailure(psbt.clone())); } value_in += psbtin @@ -649,20 +649,17 @@ pub fn create_spend( }); // If it's an address of ours, signal it as change to signing devices by adding the // BIP32 derivation path to the PSBT output. - let bip32_derivation = if let Some(AddrInfo { index, is_change }) = address.info { + let mut psbt_out = PsbtOut::default(); + if let Some(AddrInfo { index, is_change }) = address.info { let desc = if is_change { main_descriptor.change_descriptor() } else { main_descriptor.receive_descriptor() }; - desc.derive(index, secp).bip32_derivations() - } else { - Default::default() - }; - psbt_outs.push(PsbtOut { - bip32_derivation, - ..PsbtOut::default() - }); + desc.derive(index, secp) + .update_change_psbt_out(&mut psbt_out) + } + psbt_outs.push(psbt_out); } assert_eq!(tx.output.is_empty(), is_self_send); @@ -718,24 +715,21 @@ pub fn create_spend( // If the change address is ours, tell the signers by setting the BIP32 derivations in the // PSBT output. - let bip32_derivation = if let Some(AddrInfo { index, is_change }) = change_addr.info { + let mut psbt_out = PsbtOut::default(); + if let Some(AddrInfo { index, is_change }) = change_addr.info { let desc = if is_change { main_descriptor.change_descriptor() } else { main_descriptor.receive_descriptor() }; - desc.derive(index, secp).bip32_derivations() - } else { - Default::default() - }; + desc.derive(index, secp) + .update_change_psbt_out(&mut psbt_out); + } // TODO: shuffle once we have Taproot change_txo.value = change_amount; tx.output.push(change_txo); - psbt_outs.push(PsbtOut { - bip32_derivation, - ..PsbtOut::default() - }); + psbt_outs.push(psbt_out); } else if max_change_amount.to_sat() > 0 { warnings.push(CreateSpendWarning::ChangeAddedToFee( max_change_amount.to_sat(), @@ -762,21 +756,17 @@ pub fn create_spend( }); // Populate the PSBT input with the information needed by signers. + let mut psbt_in = PsbtIn::default(); let coin_desc = derived_desc(secp, main_descriptor, cand); - let witness_script = Some(coin_desc.witness_script()); - let witness_utxo = Some(bitcoin::TxOut { + coin_desc.update_psbt_in(&mut psbt_in); + psbt_in.witness_utxo = Some(bitcoin::TxOut { value: cand.amount, script_pubkey: coin_desc.script_pubkey(), }); - let non_witness_utxo = tx_getter.get_tx(&cand.outpoint.txid); - let bip32_derivation = coin_desc.bip32_derivations(); - psbt_ins.push(PsbtIn { - witness_script, - witness_utxo, - bip32_derivation, - non_witness_utxo, - ..PsbtIn::default() - }); + if !main_descriptor.is_taproot() { + psbt_in.non_witness_utxo = tx_getter.get_tx(&cand.outpoint.txid); + } + psbt_ins.push(psbt_in); } // Finally, create the PSBT with all inputs and outputs, sanity check it and return it. diff --git a/src/testutils.rs b/src/testutils.rs index 2bee5012..9e2adfc0 100644 --- a/src/testutils.rs +++ b/src/testutils.rs @@ -481,7 +481,7 @@ impl DummyLiana { let owner_key = descriptors::PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[aabbccdd]xpub68JJTXc1MWK8KLW4HGLXZBJknja7kDUJuFHnM424LbziEXsfkh1WQCiEjjHw4zLqSUm4rvhgyGkkuRowE9tCJSgt3TQB5J3SKAbZ2SdcKST/<0;1>/*").unwrap()); let heir_key = descriptors::PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[aabbccdd]xpub68JJTXc1MWK8PEQozKsRatrUHXKFNkD1Cb1BuQU9Xr5moCv87anqGyXLyUd4KpnDyZgo3gz4aN1r3NiaoweFW8UutBsBbgKHzaD5HkTkifK/<0;1>/*").unwrap()); - let policy = descriptors::LianaPolicy::new( + let policy = descriptors::LianaPolicy::new_legacy( owner_key, [(10_000, heir_key)].iter().cloned().collect(), ) diff --git a/tests/README.md b/tests/README.md index a6d920b4..eb726db3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -44,6 +44,16 @@ From the root of the repository: pytest tests/ ``` +For running the tests under Taproot a `bitcoind` version 26.0 or superior must be used. It can be +pointed to using the `BITCOIND_PATH` variable. For now, one must also compile the `taproot_signer` +Rust program: +``` +(cd tests/tools/taproot_signer && cargo build --release) +``` + +Then the test suite can be run by using Taproot descriptors instead of P2WSH descriptors by setting +the `USE_TAPROOT` environment variable to `1`. + ### Tips and tricks #### Logging diff --git a/tests/fixtures.py b/tests/fixtures.py index ee8d5b7c..68b6e9ee 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -5,10 +5,9 @@ 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, -) +from test_framework.utils import EXECUTOR_WORKERS, USE_TAPROOT +import hashlib import os import pytest import shutil @@ -120,22 +119,36 @@ def xpub_fingerprint(hd): return _pubkey_to_fingerprint(hd.pubkey).hex() +def single_key_desc(prim_fg, prim_xpub, reco_fg, reco_xpub, csv_value, is_taproot): + if is_taproot: + return f"tr([{prim_fg}]{prim_xpub}/<0;1>/*,and_v(v:pk([{reco_fg}]{reco_xpub}/<0;1>/*),older({csv_value})))" + else: + return f"wsh(or_d(pk([{prim_fg}]{prim_xpub}/<0;1>/*),and_v(v:pkh([{reco_fg}]{reco_xpub}/<0;1>/*),older({csv_value}))))" + + @pytest.fixture def lianad(bitcoind, directory): datadir = os.path.join(directory, "lianad") os.makedirs(datadir, exist_ok=True) bitcoind_cookie = os.path.join(bitcoind.bitcoin_dir, "regtest", ".cookie") - signer = SingleSigner() + signer = SingleSigner(is_taproot=USE_TAPROOT) (prim_fingerprint, primary_xpub), (reco_fingerprint, recovery_xpub) = ( (xpub_fingerprint(signer.primary_hd), signer.primary_hd.get_xpub()), (xpub_fingerprint(signer.recovery_hd), signer.recovery_hd.get_xpub()), ) csv_value = 10 - # NOTE: origins are the actual xpub themselves which is incorrect but make it + # NOTE: origins are the actual xpub themselves which is incorrect but makes it # possible to differentiate them. main_desc = Descriptor.from_str( - f"wsh(or_d(pk([{prim_fingerprint}]{primary_xpub}/<0;1>/*),and_v(v:pkh([{reco_fingerprint}]{recovery_xpub}/<0;1>/*),older({csv_value}))))" + single_key_desc( + prim_fingerprint, + primary_xpub, + reco_fingerprint, + recovery_xpub, + csv_value, + is_taproot=USE_TAPROOT, + ) ) lianad = Lianad( @@ -156,8 +169,19 @@ def lianad(bitcoind, directory): lianad.cleanup() -def multi_expression(thresh, keys): - exp = f"multi({thresh}," +def unspendable_internal_xpub(xpubs): + """Deterministic, unique, unspendable internal key. + See See https://delvingbitcoin.org/t/unspendable-keys-in-descriptors/304/21 + """ + chaincode = hashlib.sha256(b"".join(xpub.pubkey for xpub in xpubs)).digest() + bip341_nums = bytes.fromhex( + "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + ) + return BIP32(chaincode, pubkey=bip341_nums, network="test") + + +def multi_expression(thresh, keys, is_taproot): + exp = f"multi_a({thresh}," if is_taproot else f"multi({thresh}," for i, key in enumerate(keys): # NOTE: origins are the actual xpub themselves which is incorrect but make it # possible to differentiate them. @@ -168,6 +192,21 @@ def multi_expression(thresh, keys): return exp + ")" +def multisig_desc(multi_signer, csv_value, is_taproot): + prim_multi, recov_multi = ( + multi_expression(3, multi_signer.prim_hds, is_taproot), + multi_expression(2, multi_signer.recov_hds[csv_value], is_taproot), + ) + if is_taproot: + all_xpubs = [ + hd for hd in multi_signer.prim_hds + multi_signer.recov_hds[csv_value] + ] + internal_key = unspendable_internal_xpub(all_xpubs).get_xpub() + return f"tr([00000000]{internal_key}/<0;1>/*,{{{prim_multi},and_v(v:{recov_multi},older({csv_value}))}})" + else: + return f"wsh(or_d({prim_multi},and_v(v:{recov_multi},older({csv_value}))))" + + @pytest.fixture def lianad_multisig(bitcoind, directory): datadir = os.path.join(directory, "lianad") @@ -176,13 +215,9 @@ def lianad_multisig(bitcoind, directory): # A 3-of-4 that degrades into a 2-of-5 after 10 blocks csv_value = 10 - signer = MultiSigner(4, {csv_value: 5}) - prim_multi, recov_multi = ( - multi_expression(3, signer.prim_hds), - multi_expression(2, signer.recov_hds[csv_value]), - ) + signer = MultiSigner(4, {csv_value: 5}, is_taproot=USE_TAPROOT) main_desc = Descriptor.from_str( - f"wsh(or_d({prim_multi},and_v(v:{recov_multi},older({csv_value}))))" + multisig_desc(signer, csv_value, is_taproot=USE_TAPROOT) ) lianad = Lianad( @@ -203,6 +238,28 @@ def lianad_multisig(bitcoind, directory): lianad.cleanup() +def multipath_desc(multi_signer, csv_values, is_taproot): + prim_multi = multi_expression(3, multi_signer.prim_hds, is_taproot) + first_recov_multi = multi_expression( + 3, multi_signer.recov_hds[csv_values[0]], is_taproot + ) + second_recov_multi = multi_expression( + 1, multi_signer.recov_hds[csv_values[1]], is_taproot + ) + if is_taproot: + all_xpubs = [ + hd + for hd in multi_signer.prim_hds + + multi_signer.recov_hds[csv_values[0]] + + multi_signer.recov_hds[csv_values[1]] + ] + internal_key = unspendable_internal_xpub(all_xpubs).get_xpub() + # On purpose we use a single leaf instead of 3 different ones. It shouldn't be an issue. + return f"tr([00000000]{internal_key}/<0;1>/*,or_d({prim_multi},or_i(and_v(v:{first_recov_multi},older({csv_values[0]})),and_v(v:{second_recov_multi},older({csv_values[1]})))))" + else: + return f"wsh(or_d({prim_multi},or_i(and_v(v:{first_recov_multi},older({csv_values[0]})),and_v(v:{second_recov_multi},older({csv_values[1]})))))" + + @pytest.fixture def lianad_multipath(bitcoind, directory): datadir = os.path.join(directory, "lianad") @@ -211,12 +268,11 @@ def lianad_multipath(bitcoind, directory): # A 3-of-4 that degrades into a 3-of-5 after 10 blocks and into a 1-of-10 after 20 blocks. csv_values = [10, 20] - signer = MultiSigner(4, {csv_values[0]: 5, csv_values[1]: 10}) - prim_multi = multi_expression(3, signer.prim_hds) - first_recov_multi = multi_expression(3, signer.recov_hds[csv_values[0]]) - second_recov_multi = multi_expression(1, signer.recov_hds[csv_values[1]]) + signer = MultiSigner( + 4, {csv_values[0]: 5, csv_values[1]: 10}, is_taproot=USE_TAPROOT + ) main_desc = Descriptor.from_str( - f"wsh(or_d({prim_multi},or_i(and_v(v:{first_recov_multi},older({csv_values[0]})),and_v(v:{second_recov_multi},older({csv_values[1]})))))" + multipath_desc(signer, csv_values, is_taproot=USE_TAPROOT) ) lianad = Lianad( diff --git a/tests/requirements.txt b/tests/requirements.txt index ed93af45..bc55b0ca 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -4,4 +4,4 @@ pytest-timeout==1.3.4 ephemeral_port_reserve==1.1.1 bip32~=3.0 -https://github.com/darosior/python-bip380/archive/f25eb2add9a5d461e382635231a5f971652fc8e1.zip +https://github.com/darosior/python-bip380/archive/fb61971d9128e663f110ea2734c1d023e7e0266b.zip diff --git a/tests/test_chain.py b/tests/test_chain.py index 1ed1c133..e4d6a99f 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -10,6 +10,7 @@ from test_framework.utils import ( COIN, sign_and_broadcast, sign_and_broadcast_psbt, + USE_TAPROOT, ) from test_framework.serializations import PSBT @@ -354,6 +355,9 @@ def test_rescan_and_recovery(lianad, bitcoind): sign_and_broadcast(lianad, bitcoind, reco_psbt, recovery=True) +@pytest.mark.skipif( + USE_TAPROOT, reason="Needs a finalizer implemented in the Python test framework." +) def test_conflicting_unconfirmed_spend_txs(lianad, bitcoind): """Test we'll update the spending txid of a coin if a conflicting spend enters our mempool.""" # Get an (unconfirmed, on purpose) coin to be spent by 2 different txs. diff --git a/tests/test_framework/signer.py b/tests/test_framework/signer.py index 48004556..b0332eb9 100644 --- a/tests/test_framework/signer.py +++ b/tests/test_framework/signer.py @@ -1,5 +1,6 @@ import logging import os +import subprocess from bip32 import BIP32 from bip32.utils import coincurve @@ -9,10 +10,16 @@ from test_framework.serializations import ( PSBT_IN_BIP32_DERIVATION, PSBT_IN_WITNESS_SCRIPT, PSBT_IN_PARTIAL_SIG, + PSBT_IN_TAP_KEY_SIG, + PSBT_IN_TAP_SCRIPT_SIG, + PSBT_IN_TAP_LEAF_SCRIPT, + PSBT_IN_TAP_BIP32_DERIVATION, + PSBT_IN_TAP_INTERNAL_KEY, + PSBT_IN_TAP_MERKLE_ROOT, ) -def sign_psbt(psbt, hds): +def sign_psbt_wsh(psbt, hds): """Sign a transaction. This will fill the 'partial_sigs' field of all inputs. @@ -58,12 +65,47 @@ def sign_psbt(psbt, hds): return psbt +def sign_psbt_taproot(psbt, hds): + """Sign a transaction. + + This will fill the 'tap_script_sig' / 'tap_key_sig' 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) + + # This file is under tests/test_framework/ and we want tests/tools/taproot_signer/target/release/taproot_signer. + bin_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "tools", + "taproot_signer", + "target", + "release", + "taproot_signer", + ) + if not os.path.exists(bin_path): + raise Exception( + "Please compile the Taproot signer under tests/tools using 'cargo bin --release'." + ) + + psbt_str = psbt.to_base64() + for hd in hds: + xprv = hd.get_xpriv() + proc = subprocess.run([bin_path, psbt_str, xprv], capture_output=True, check=True) + psbt_str = proc.stdout.decode("utf-8") + + return PSBT.from_base64(psbt_str) + + class SingleSigner: """Assumes a simple 1-primary path 1-recovery path Liana descriptor.""" - def __init__(self): + def __init__(self, is_taproot): self.primary_hd = BIP32.from_seed(os.urandom(32), network="test") self.recovery_hd = BIP32.from_seed(os.urandom(32), network="test") + self.is_taproot = is_taproot def sign_psbt(self, psbt, recovery=False): """Sign a transaction. @@ -75,13 +117,17 @@ class SingleSigner: :returns: PSBT with a signature in each input for the specified key. """ assert isinstance(recovery, bool) - return sign_psbt(psbt, [self.recovery_hd if recovery else self.primary_hd]) + if self.is_taproot: + return sign_psbt_taproot( + psbt, [self.recovery_hd if recovery else self.primary_hd] + ) + return sign_psbt_wsh(psbt, [self.recovery_hd if recovery else self.primary_hd]) class MultiSigner: """A signer that has multiple keys and may have multiple recovery path.""" - def __init__(self, primary_hds_count, recovery_hds_counts): + def __init__(self, primary_hds_count, recovery_hds_counts, is_taproot): self.prim_hds = [ BIP32.from_seed(os.urandom(32), network="test") for _ in range(primary_hds_count) @@ -91,6 +137,7 @@ class MultiSigner: self.recov_hds[timelock] = [ BIP32.from_seed(os.urandom(32), network="test") for _ in range(count) ] + self.is_taproot = is_taproot def sign_psbt(self, psbt, key_indices): """Sign a transaction with the keys at the specified indices. @@ -106,4 +153,6 @@ class MultiSigner: ] else: hds = [self.prim_hds[i] for i in key_indices] - return sign_psbt(psbt, hds) + if self.is_taproot: + return sign_psbt_taproot(psbt, hds) + return sign_psbt_wsh(psbt, hds) diff --git a/tests/test_framework/utils.py b/tests/test_framework/utils.py index 4aabf759..ffa50aa3 100644 --- a/tests/test_framework/utils.py +++ b/tests/test_framework/utils.py @@ -24,6 +24,7 @@ DEFAULT_BITCOIND_PATH = "bitcoind" BITCOIND_PATH = os.getenv("BITCOIND_PATH", DEFAULT_BITCOIND_PATH) OLD_LIANAD_PATH = os.getenv("OLD_LIANAD_PATH", None) IS_NOT_BITCOIND_24 = bool(int(os.getenv("IS_NOT_BITCOIND_24", True))) +USE_TAPROOT = bool(int(os.getenv("USE_TAPROOT", False))) # TODO: switch to True in a couple releases. COIN = 10**8 @@ -71,6 +72,21 @@ def get_txid(hex_tx): return tx.txid().hex() +def sign_and_broadcast(lianad, bitcoind, psbt, recovery=False): + """Sign a PSBT, finalize it, extract the transaction and broadcast it.""" + signed_psbt = lianad.signer.sign_psbt(psbt, recovery) + # Under Taproot i didn't bother implementing a finalizer in the test suite. + if USE_TAPROOT: + lianad.rpc.updatespend(signed_psbt.to_base64()) + txid = signed_psbt.tx.txid().hex() + lianad.rpc.broadcastspend(txid) + lianad.rpc.delspendtx(txid) + return txid + finalized_psbt = lianad.finalize_psbt(signed_psbt) + tx = finalized_psbt.tx.serialize_with_witness().hex() + return bitcoind.rpc.sendrawtransaction(tx) + + def spend_coins(lianad, bitcoind, coins): """Spend these coins, no matter how. This will create a single transaction spending them all at once at the minimum @@ -84,21 +100,8 @@ def spend_coins(lianad, bitcoind, coins): bitcoind.rpc.getnewaddress(): total_value - 11 - 31 - 300 * len(coins) } res = lianad.rpc.createspend(destinations, [c["outpoint"] for c in coins], 1) - - 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) - - return tx - - -def sign_and_broadcast(lianad, bitcoind, psbt, recovery=False): - """Sign a PSBT, finalize it, extract the transaction and broadcast it.""" - 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) + txid = sign_and_broadcast(lianad, bitcoind, PSBT.from_base64(res["psbt"])) + return bitcoind.rpc.getrawtransaction(txid) def sign_and_broadcast_psbt(lianad, psbt): diff --git a/tests/test_misc.py b/tests/test_misc.py index c8d4ec91..f4a81c5f 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -14,6 +14,7 @@ from test_framework.utils import ( COIN, TIMEOUT, IS_NOT_BITCOIND_24, + USE_TAPROOT, ) from threading import Thread @@ -66,9 +67,7 @@ def receive_and_send(lianad, bitcoind): 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" - ): + with pytest.raises(RpcError, match="ould 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) @@ -242,7 +241,7 @@ def test_coinbase_deposit(lianad, bitcoind): @pytest.mark.skipif( - OLD_LIANAD_PATH is None, reason="Need the old lianad binary to create the datadir." + OLD_LIANAD_PATH is None or USE_TAPROOT, reason="Need the old lianad binary to create the datadir." ) def test_migration(lianad_multisig, bitcoind): """Test we can start a newer lianad on a datadir created by an older lianad.""" diff --git a/tests/test_rpc.py b/tests/test_rpc.py index 87e74af9..e02e0c50 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -17,6 +17,7 @@ from test_framework.utils import ( spend_coins, sign_and_broadcast, sign_and_broadcast_psbt, + USE_TAPROOT, ) @@ -405,13 +406,21 @@ def test_create_spend(lianad, bitcoind): assert len(spend_psbt.o) == 4 assert len(spend_psbt.tx.vout) == 4 - # The transaction must contain the spent transaction for each input. + # The transaction must contain the spent transaction for each input for P2WSH. But not for Taproot. # We don't make assumptions about the ordering of PSBT inputs. - assert sorted( - [psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in spend_psbt.i] - ) == sorted( - [bytes.fromhex(bitcoind.rpc.gettransaction(op[:64])["hex"]) for op in outpoints] - ) + if USE_TAPROOT: + assert all( + PSBT_IN_NON_WITNESS_UTXO not in psbt_in.map for psbt_in in spend_psbt.i + ) + else: + assert sorted( + [psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in spend_psbt.i] + ) == sorted( + [ + bytes.fromhex(bitcoind.rpc.gettransaction(op[:64])["hex"]) + for op in outpoints + ] + ) # We can sign it and broadcast it. sign_and_broadcast(lianad, bitcoind, PSBT.from_base64(res["psbt"])) @@ -1092,9 +1101,9 @@ def test_rbfpsbt_bump_fee(lianad, bitcoind): rbf_1_res = lianad.rpc.rbfpsbt(first_txid, False, 10) rbf_1_psbt = PSBT.from_base64(rbf_1_res["psbt"]) # The inputs are the same in both (no new inputs needed in the replacement). - assert sorted( - psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in first_psbt.i - ) == sorted(psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_1_psbt.i) + assert sorted(i.prevout.serialize() for i in first_psbt.tx.vin) == sorted( + i.prevout.serialize() for i in rbf_1_psbt.tx.vin + ) # Check non-change output is the same in both. assert first_psbt.tx.vout[0].nValue == rbf_1_psbt.tx.vout[0].nValue assert first_psbt.tx.vout[0].scriptPubKey == rbf_1_psbt.tx.vout[0].scriptPubKey @@ -1114,8 +1123,9 @@ def test_rbfpsbt_bump_fee(lianad, bitcoind): # transaction: with pytest.raises(RpcError, match=f"Feerate too low: 10."): lianad.rpc.rbfpsbt(first_txid, False, 10) - # Using 11 for feerate works. - lianad.rpc.rbfpsbt(first_txid, False, 11) + # Using 11 for feerate works for P2WSH. For Taproot we need 12. + feerate = 12 if USE_TAPROOT else 11 + lianad.rpc.rbfpsbt(first_txid, False, feerate) # Add a new transaction spending the change from the first RBF. desc_1_destinations = { bitcoind.rpc.getnewaddress(): 500_000, @@ -1149,12 +1159,12 @@ def test_rbfpsbt_bump_fee(lianad, bitcoind): ) ) # Now replace the first RBF, which will also remove its descendants. - rbf_2_res = lianad.rpc.rbfpsbt(rbf_1_txid, False, 11) + rbf_2_res = lianad.rpc.rbfpsbt(rbf_1_txid, False, feerate) rbf_2_psbt = PSBT.from_base64(rbf_2_res["psbt"]) # The inputs are the same in both (no new inputs needed in the replacement). - assert sorted( - psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_1_psbt.i - ) == sorted(psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_2_psbt.i) + assert sorted(i.prevout.serialize() for i in rbf_1_psbt.tx.vin) == sorted( + i.prevout.serialize() for i in rbf_2_psbt.tx.vin + ) # Check non-change output is the same in both. assert rbf_1_psbt.tx.vout[0].nValue == rbf_2_psbt.tx.vout[0].nValue assert rbf_1_psbt.tx.vout[0].scriptPubKey == rbf_2_psbt.tx.vout[0].scriptPubKey @@ -1204,7 +1214,8 @@ def test_rbfpsbt_insufficient_funds(lianad, bitcoind): spend_txid_1 = sign_and_broadcast_psbt(lianad, spend_psbt_1) # We don't have sufficient funds to bump the fee. - assert "missing" in lianad.rpc.rbfpsbt(spend_txid_1, False, 2) + feerate = 3 if USE_TAPROOT else 2 + assert "missing" in lianad.rpc.rbfpsbt(spend_txid_1, False, feerate) # We can still cancel it as the coin has enough value to create a single # output at a higher feerate. assert "psbt" in lianad.rpc.rbfpsbt(spend_txid_1, True) @@ -1273,8 +1284,8 @@ def test_rbfpsbt_cancel(lianad, bitcoind): # Replacement only has a single input. assert len(rbf_1_psbt.i) == 1 # This input is one of the two from the previous transaction. - assert rbf_1_psbt.i[0].map[PSBT_IN_NON_WITNESS_UTXO] in [ - psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_1_psbt.i + assert rbf_1_psbt.tx.vin[0].prevout.serialize() in [ + i.prevout.serialize() for i in first_psbt.tx.vin ] # The replacement only has a change output. assert len(rbf_1_psbt.tx.vout) == 1 @@ -1340,13 +1351,12 @@ def test_rbfpsbt_cancel(lianad, bitcoind): # Now cancel the first RBF, which will also remove its descendants. rbf_2_res = lianad.rpc.rbfpsbt(rbf_1_txid, True) rbf_2_psbt = PSBT.from_base64(rbf_2_res["psbt"]) - # - assert len(rbf_2_psbt.i) == 1 - assert ( - rbf_1_psbt.i[0].map[PSBT_IN_NON_WITNESS_UTXO] - == rbf_2_psbt.i[0].map[PSBT_IN_NON_WITNESS_UTXO] - ) # The inputs are the same in both (no new inputs needed in the replacement). + assert len(rbf_2_psbt.tx.vin) == 1 + assert ( + rbf_1_psbt.tx.vin[0].prevout.serialize() + == rbf_2_psbt.tx.vin[0].prevout.serialize() + ) # Only a single output (change) in the replacement. assert len(rbf_2_psbt.tx.vout) == 1 diff --git a/tests/test_spend.py b/tests/test_spend.py index 0cf4ab12..9c496386 100644 --- a/tests/test_spend.py +++ b/tests/test_spend.py @@ -1,6 +1,6 @@ from fixtures import * from test_framework.serializations import PSBT, uint256_from_str -from test_framework.utils import sign_and_broadcast_psbt, wait_for, COIN, RpcError +from test_framework.utils import sign_and_broadcast_psbt, wait_for, COIN, RpcError, USE_TAPROOT def test_spend_change(lianad, bitcoind): @@ -121,11 +121,12 @@ def test_coin_marked_spent(lianad, bitcoind): res = lianad.rpc.createspend(destinations, [outpoint], 1) psbt = PSBT.from_base64(res["psbt"]) sign_and_broadcast(psbt) + change_amount = 840 if USE_TAPROOT else 830 assert len(psbt.o) == 1 assert len(res["warnings"]) == 1 assert ( res["warnings"][0] - == "Change amount of 830 sats added to fee as it was too small to create a transaction output." + == f"Change amount of {change_amount} sats added to fee as it was too small to create a transaction output." ) # Spend the third coin to an address of ours, no change @@ -137,11 +138,12 @@ def test_coin_marked_spent(lianad, bitcoind): res = lianad.rpc.createspend(destinations, [outpoint_3], 1) psbt = PSBT.from_base64(res["psbt"]) sign_and_broadcast(psbt) + change_amount = 828 if USE_TAPROOT else 818 assert len(psbt.o) == 1 assert len(res["warnings"]) == 1 assert ( res["warnings"][0] - == "Change amount of 818 sats added to fee as it was too small to create a transaction output." + == f"Change amount of {change_amount} sats added to fee as it was too small to create a transaction output." ) # Spend the fourth coin to an address of ours, with change @@ -223,7 +225,8 @@ def test_send_to_self(lianad, bitcoind): assert len(spend_psbt.o) == len(spend_psbt.tx.vout) == 1 # Note they may ask for an impossible send-to-self. In this case we'll report missing amount. - assert "missing" in lianad.rpc.createspend({}, outpoints, 40500) + huge_feerate = 50_000 if USE_TAPROOT else 40_500 + assert "missing" in lianad.rpc.createspend({}, outpoints, huge_feerate) # Sign and broadcast the send-to-self transaction created above. signed_psbt = lianad.signer.sign_psbt(spend_psbt) @@ -237,7 +240,12 @@ def test_send_to_self(lianad, bitcoind): # FIXME: a 15% increase is huge. res = bitcoind.rpc.getmempoolentry(spend_txid) spend_feerate = int(res["fees"]["base"] * COIN / res["vsize"]) - assert specified_feerate <= spend_feerate <= int(specified_feerate * 115 / 100) + if not USE_TAPROOT: + assert specified_feerate <= spend_feerate <= int(specified_feerate * 115 / 100) + else: + # FIXME: under Taproot we should not consider the max feerate of all leaves if there + # is a spendable internal key. + assert specified_feerate <= spend_feerate <= int(specified_feerate * 125 / 100) # We should by now only have one coin. bitcoind.generate_block(1, wait_for_mempool=spend_txid) @@ -250,7 +258,7 @@ def test_send_to_self(lianad, bitcoind): assert len(lianad.rpc.listaddresses()["addresses"]) == 3 # Create a new spend to the receive address with index 3. recv_addr = lianad.rpc.listaddresses(3, 1)["addresses"][0]["receive"] - res = lianad.rpc.createspend({recv_addr: 11_955_000}, [], 1) + res = lianad.rpc.createspend({recv_addr: 11_955_000}, [], 2) assert "psbt" in res # Max(receive_index, change_index) is now 4: assert len(lianad.rpc.listaddresses()["addresses"]) == 4 @@ -308,42 +316,48 @@ def test_coin_selection(lianad, bitcoind): dest_addr_2 = bitcoind.rpc.getnewaddress() # If feerate is higher than ancestor, we'll need to pay extra. + def additional_fees(anc_vsize, anc_fee, target_feerate): + """The additional fee which must have been computed by lianad.""" + computed_anc_vsize = int(anc_fee / target_feerate) + print(f"c ", computed_anc_vsize) + extra_vsize = anc_vsize - computed_anc_vsize + print("e ", extra_vsize) + return extra_vsize * target_feerate + # Try 10 sat/vb: - spend_res_2 = lianad.rpc.createspend({dest_addr_2: 10_000}, [], 10) + feerate = 10 + spend_res_2 = lianad.rpc.createspend({dest_addr_2: 10_000}, [], feerate) assert "psbt" in spend_res_2 spend_psbt_2 = PSBT.from_base64(spend_res_2["psbt"]) # The spend is using the unconfirmed change. assert spend_psbt_2.tx.vin[0].prevout.hash == uint256_from_str( bytes.fromhex(spend_txid_1)[::-1] ) - assert bitcoind.rpc.getmempoolentry(spend_txid_1)["ancestorsize"] == 161 - assert bitcoind.rpc.getmempoolentry(spend_txid_1)["fees"]["ancestor"] * COIN == 339 - # ancestor vsize at feerate 10 sat/vb = ancestor_fee / 10 = 339 / 10 = 33 - # extra_weight <= (extra vsize * witness factor) = (161 - 33) * 4 = 512 - # additional fee at 10 sat/vb (2.5 sat/wu) = 512 * 2.5 = 1280 + anc_vsize = bitcoind.rpc.getmempoolentry(spend_txid_1)["ancestorsize"] + anc_fees = int(bitcoind.rpc.getmempoolentry(spend_txid_1)["fees"]["ancestor"] * COIN) + additional_fee = additional_fees(anc_vsize, anc_fees, feerate) assert len(spend_res_2["warnings"]) == 1 assert ( spend_res_2["warnings"][0] - == "An additional fee of 1280 sats has been added to pay for ancestors at the target feerate." + == f"An additional fee of {additional_fee} sats has been added to pay for ancestors at the target feerate." ) # Try 3 sat/vb: - spend_res_2 = lianad.rpc.createspend({dest_addr_2: 10_000}, [], 3) + feerate = 3 + spend_res_2 = lianad.rpc.createspend({dest_addr_2: 10_000}, [], feerate) assert "psbt" in spend_res_2 spend_psbt_2 = PSBT.from_base64(spend_res_2["psbt"]) # The spend is using the unconfirmed change. assert spend_psbt_2.tx.vin[0].prevout.hash == uint256_from_str( bytes.fromhex(spend_txid_1)[::-1] ) - assert bitcoind.rpc.getmempoolentry(spend_txid_1)["ancestorsize"] == 161 - assert bitcoind.rpc.getmempoolentry(spend_txid_1)["fees"]["ancestor"] * COIN == 339 - # ancestor vsize at feerate 3 sat/vb = ancestor_fee / 3 = 339 / 3 = 113 - # extra_weight <= (extra vsize * witness factor) = (161 - 113) * 4 = 192 - # additional fee at 3 sat/vb (0.75 sat/wu) = 192 * 0.75 = 144 + anc_vsize = bitcoind.rpc.getmempoolentry(spend_txid_1)["ancestorsize"] + anc_fees = int(bitcoind.rpc.getmempoolentry(spend_txid_1)["fees"]["ancestor"] * COIN) + additional_fee = additional_fees(anc_vsize, anc_fees, feerate) assert len(spend_res_2["warnings"]) == 1 assert ( spend_res_2["warnings"][0] - == "An additional fee of 144 sats has been added to pay for ancestors at the target feerate." + == f"An additional fee of {additional_fee} sats has been added to pay for ancestors at the target feerate." ) # 2 sat/vb is same feerate as ancestor and we have no warnings: @@ -378,20 +392,18 @@ def test_coin_selection(lianad, bitcoind): # We'll need to pay extra for each unconfirmed coin's ancestors. outpoints = [c["outpoint"] for c in lianad.rpc.listcoins(["unconfirmed"])["coins"]] - spend_res_3 = lianad.rpc.createspend({dest_addr_3: 20_000}, outpoints, 10) + feerate = 10 + spend_res_3 = lianad.rpc.createspend({dest_addr_3: 20_000}, outpoints, feerate) assert "psbt" in spend_res_3 - assert bitcoind.rpc.getmempoolentry(deposit_2)["ancestorsize"] == 165 - assert bitcoind.rpc.getmempoolentry(deposit_2)["fees"]["ancestor"] * COIN == 165 - # From above, extra fee for unconfirmed change at 10 sat/vb = 1280. - # For unconfirmed non-change: - # ancestor vsize at feerate 10 sat/vb = ancestor_fee / 10 = 165 / 10 = 16 - # extra_weight <= (extra vsize * witness factor) = (165 - 16) * 4 = 596 - # additional fee at 10 sat/vb (2.5 sat/wu) = 596 * 2.5 = 1490 - # Sum of extra ancestor fees = 1280 + 1490 = 2770. + anc_vsize = bitcoind.rpc.getmempoolentry(deposit_2)["ancestorsize"] + anc_fees = int(bitcoind.rpc.getmempoolentry(deposit_2)["fees"]["ancestor"] * COIN) + prev_anc_vsize = bitcoind.rpc.getmempoolentry(spend_txid_1)["ancestorsize"] + prev_anc_fees = int(bitcoind.rpc.getmempoolentry(spend_txid_1)["fees"]["ancestor"] * COIN) + additional_fee = additional_fees(anc_vsize, anc_fees, feerate) + additional_fees(prev_anc_vsize, prev_anc_fees, feerate) assert len(spend_res_3["warnings"]) == 1 assert ( spend_res_3["warnings"][0] - == "An additional fee of 2770 sats has been added to pay for ancestors at the target feerate." + == f"An additional fee of {additional_fee} sats has been added to pay for ancestors at the target feerate." ) spend_psbt_3 = PSBT.from_base64(spend_res_3["psbt"]) spend_txid_3 = sign_and_broadcast_psbt(lianad, spend_psbt_3) diff --git a/tests/tools/taproot_signer/Cargo.lock b/tests/tools/taproot_signer/Cargo.lock new file mode 100644 index 00000000..cab0bff6 --- /dev/null +++ b/tests/tools/taproot_signer/Cargo.lock @@ -0,0 +1,90 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bech32" +version = "0.10.0-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98f7eed2b2781a6f0b5c903471d48e15f56fb4e1165df8a9a2337fd1a59d45ea" + +[[package]] +name = "bitcoin" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd00f3c09b5f21fb357abe32d29946eb8bb7a0862bae62c0b5e4a692acbbe73c" +dependencies = [ + "base64", + "bech32", + "bitcoin-internals", + "bitcoin_hashes", + "hex-conservative", + "hex_lit", + "secp256k1", +] + +[[package]] +name = "bitcoin-internals" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" + +[[package]] +name = "bitcoin_hashes" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" +dependencies = [ + "bitcoin-internals", + "hex-conservative", +] + +[[package]] +name = "cc" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9fa1897e4325be0d68d48df6aa1a71ac2ed4d27723887e7754192705350730" + +[[package]] +name = "hex-conservative" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ed443af458ccb6d81c1e7e661545f94d3176752fb1df2f543b902a1e0f51e2" + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "secp256k1" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10" +dependencies = [ + "bitcoin_hashes", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d1746aae42c19d583c3c1a8c646bfad910498e2051c551a7f2e3c0c9fbb7eb" +dependencies = [ + "cc", +] + +[[package]] +name = "taproot_signer" +version = "0.1.0" +dependencies = [ + "bitcoin", +] diff --git a/tests/tools/taproot_signer/Cargo.toml b/tests/tools/taproot_signer/Cargo.toml new file mode 100644 index 00000000..c7554761 --- /dev/null +++ b/tests/tools/taproot_signer/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "taproot_signer" +version = "0.1.0" +edition = "2021" + +[dependencies] +bitcoin = { version = "0.31", features = ["base64"] } + +# Avoid it being built under Liana's target dir. +[workspace] diff --git a/tests/tools/taproot_signer/README.md b/tests/tools/taproot_signer/README.md new file mode 100644 index 00000000..00638e28 --- /dev/null +++ b/tests/tools/taproot_signer/README.md @@ -0,0 +1,13 @@ +A quick and dirty program to sign Taproot PSBTs with a given xpriv. This reuses the hot signer Rust +code so i don't have to reimplement everything in Python. + +Usage: +``` +cargo build --release +./target/release/taproot_signer "cHNidP8BAFICAAAAASLJVdEybEXc7wUWShMaYhEOjwJL5MYbzg+7zeR44BNGEwAAAAD9////AfRHAAAAAAAAFgAUZb+ZwCt1P282vnthp4cBgN4YNCgAAAAAAAEBKzhKAAAAAAAAIlEggmdBglq+Jt7mG8eFAZJPZ9TNEoqsfUXX+e0tRR4on0pCFcHpCSnxiCBSU6Ddvmwxv9nF+529tCGtEuCM1/zRYlksP2wAEs3HghglLpv2X3mSwNNzlaHY8BKNNas/13WibyuQaSCNnIknSSMfLln9wxqrj+HdoimEWq8EF48QCVphtE8of6wgQC+hw/bp6Z3LosvoFKmVGbi5BhTFxhJ88YlUkHwkyx26IFRPnR3hiTo2F1MW+PzS6hKLjdiQA+aD3DlMZkolBdKAulKcwEIVwekJKfGIIFJToN2+bDG/2cX7nb20Ia0S4IzX/NFiWSw/2HFGAyQNqMHzx0CejOgXB8dPeu9cm47MEKqCRr08ntsmIEkh6Q/Tvcb69Lv4z1KGhY5rH2SVgNb+0L6jjg9jTCA7rQEussAhFkAvocP26emdy6LL6BSplRm4uQYUxcYSfPGJVJB8JMsdOQHYcUYDJA2owfPHQJ6M6BcHx09671ybjswQqoJGvTye29gS2ksSAACAGAAAgB8AAAAAAAAAKgAAACEWSSHpD9O9xvr0u/jPUoaFjmsfZJWA1v7QvqOOD2NMIDtFAWwAEs3HghglLpv2X3mSwNNzlaHY8BKNNas/13WibyuQsLhRtwEAAAACAACAAwAAAAQAAIAFAAAABgAAAAAAAAAqAAAAIRZUT50d4Yk6NhdTFvj80uoSi43YkAPmg9w5TGZKJQXSgDUB2HFGAyQNqMHzx0CejOgXB8dPeu9cm47MEKqCRr08ntuwuFG3EgAAgBkAAIAAAAAAKgAAACEWjZyJJ0kjHy5Z/cMaq4/h3aIphFqvBBePEAlaYbRPKH9BAdhxRgMkDajB88dAnozoFwfHT3rvXJuOzBCqgka9PJ7bJxAoeAAAAIAMAACAKgAAAKQBAAA4AAAAAAAAACoAAAAhFukJKfGIIFJToN2+bDG/2cX7nb20Ia0S4IzX/NFiWSw/DQB8Rh5dAAAAACoAAAABFyDpCSnxiCBSU6Ddvmwxv9nF+529tCGtEuCM1/zRYlksPwEYIKKzzcTwLrwB4DXcLXJYeZVMlpkb1zTIpFzsTWdZJU7nAAA=" xprv9s21ZrQH143K3fRVbj9JuCc3Eaph4F7AKXUtLGs9AWxWmhyMGq5ur5NqH3fDuiQZ6GamXbW7L3msaCwRbstxVqc3eP2FX3oa9wABxVzHr1k +``` + +Output: +``` +cHNidP8BAFICAAAAASLJVdEybEXc7wUWShMaYhEOjwJL5MYbzg+7zeR44BNGEwAAAAD9////AfRHAAAAAAAAFgAUZb+ZwCt1P282vnthp4cBgN4YNCgAAAAAAAEBKzhKAAAAAAAAIlEggmdBglq+Jt7mG8eFAZJPZ9TNEoqsfUXX+e0tRR4on0pBFI2ciSdJIx8uWf3DGquP4d2iKYRarwQXjxAJWmG0Tyh/2HFGAyQNqMHzx0CejOgXB8dPeu9cm47MEKqCRr08nttAJ148PhMM/k/GnKCQ09RkpEKYCAHwVh3UMueWbq0TcIMW8s3XKNk1vyFy3LrXdnGghS8Up/i4bdNR2ikX1XdlYEIVwekJKfGIIFJToN2+bDG/2cX7nb20Ia0S4IzX/NFiWSw/bAASzceCGCUum/ZfeZLA03OVodjwEo01qz/XdaJvK5BpII2ciSdJIx8uWf3DGquP4d2iKYRarwQXjxAJWmG0Tyh/rCBAL6HD9unpncuiy+gUqZUZuLkGFMXGEnzxiVSQfCTLHbogVE+dHeGJOjYXUxb4/NLqEouN2JAD5oPcOUxmSiUF0oC6UpzAQhXB6Qkp8YggUlOg3b5sMb/ZxfudvbQhrRLgjNf80WJZLD/YcUYDJA2owfPHQJ6M6BcHx09671ybjswQqoJGvTye2yYgSSHpD9O9xvr0u/jPUoaFjmsfZJWA1v7QvqOOD2NMIDutAS6ywCEWQC+hw/bp6Z3LosvoFKmVGbi5BhTFxhJ88YlUkHwkyx05AdhxRgMkDajB88dAnozoFwfHT3rvXJuOzBCqgka9PJ7b2BLaSxIAAIAYAACAHwAAAAAAAAAqAAAAIRZJIekP073G+vS7+M9ShoWOax9klYDW/tC+o44PY0wgO0UBbAASzceCGCUum/ZfeZLA03OVodjwEo01qz/XdaJvK5CwuFG3AQAAAAIAAIADAAAABAAAgAUAAAAGAAAAAAAAACoAAAAhFlRPnR3hiTo2F1MW+PzS6hKLjdiQA+aD3DlMZkolBdKANQHYcUYDJA2owfPHQJ6M6BcHx09671ybjswQqoJGvTye27C4UbcSAACAGQAAgAAAAAAqAAAAIRaNnIknSSMfLln9wxqrj+HdoimEWq8EF48QCVphtE8of0EB2HFGAyQNqMHzx0CejOgXB8dPeu9cm47MEKqCRr08ntsnECh4AAAAgAwAAIAqAAAApAEAADgAAAAAAAAAKgAAACEW6Qkp8YggUlOg3b5sMb/ZxfudvbQhrRLgjNf80WJZLD8NAHxGHl0AAAAAKgAAAAEXIOkJKfGIIFJToN2+bDG/2cX7nb20Ia0S4IzX/NFiWSw/ARggorPNxPAuvAHgNdwtclh5lUyWmRvXNMikXOxNZ1klTucAAA== +``` diff --git a/tests/tools/taproot_signer/src/main.rs b/tests/tools/taproot_signer/src/main.rs new file mode 100644 index 00000000..bddd4b28 --- /dev/null +++ b/tests/tools/taproot_signer/src/main.rs @@ -0,0 +1,119 @@ +//! A quick and dirty program which reads a PSBT and an xpriv from stdin and outputs the signed +//! PSBT to stdout. Uses function copied from Liana's hot signer and adapted. + +use std::{ + env, + io::{self, Write}, + str::FromStr, +}; + +use bitcoin::{ + self, + bip32::{self, Xpriv}, + hashes::Hash, + key::TapTweak, + psbt::{Input as PsbtIn, Psbt}, + secp256k1, sighash, +}; + +fn sign_taproot( + secp: &secp256k1::Secp256k1, + sighash_cache: &mut sighash::SighashCache<&bitcoin::Transaction>, + master_xpriv: Xpriv, + master_fingerprint: bip32::Fingerprint, + prevouts: &[bitcoin::TxOut], + psbt_in: &mut PsbtIn, + input_index: usize, +) { + let sig_type = sighash::TapSighashType::Default; + let prevouts = sighash::Prevouts::All(prevouts); + + // If the details of the internal key are filled, provide a keypath signature. + if let Some(ref int_key) = psbt_in.tap_internal_key { + // NB: we don't check for empty leaf hashes on purpose, in case the internal key also + // appears in a leaf. + if let Some((_, (fg, der_path))) = psbt_in.tap_key_origins.get(int_key) { + if *fg == master_fingerprint { + let privkey = master_xpriv.derive_priv(secp, der_path).unwrap().to_priv(); + let keypair = secp256k1::Keypair::from_secret_key(secp, &privkey.inner); + assert_eq!(keypair.x_only_public_key().0, *int_key); + let keypair = keypair.tap_tweak(secp, psbt_in.tap_merkle_root).to_inner(); + let sighash = sighash_cache + .taproot_key_spend_signature_hash(input_index, &prevouts, sig_type) + .unwrap(); + let sighash = secp256k1::Message::from_digest_slice(sighash.as_byte_array()) + .expect("Sighash is always 32 bytes."); + let sig = secp.sign_schnorr_no_aux_rand(&sighash, &keypair); + let sig = bitcoin::taproot::Signature { + sig, + hash_ty: sig_type, + }; + psbt_in.tap_key_sig = Some(sig); + } + } + } + + // Now sign for all the public keys derived from our master secret, in all the leaves where + // they are present. + for (pubkey, (leaf_hashes, (fg, der_path))) in &psbt_in.tap_key_origins { + if *fg != master_fingerprint { + continue; + } + + for leaf_hash in leaf_hashes { + let privkey = master_xpriv.derive_priv(secp, der_path).unwrap().to_priv(); + let keypair = secp256k1::Keypair::from_secret_key(secp, &privkey.inner); + let sighash = sighash_cache + .taproot_script_spend_signature_hash(input_index, &prevouts, *leaf_hash, sig_type) + .unwrap(); + let sighash = secp256k1::Message::from_digest_slice(sighash.as_byte_array()) + .expect("Sighash is always 32 bytes."); + let sig = secp.sign_schnorr_no_aux_rand(&sighash, &keypair); + let sig = bitcoin::taproot::Signature { + sig, + hash_ty: sig_type, + }; + psbt_in.tap_script_sigs.insert((*pubkey, *leaf_hash), sig); + } + } +} + +fn sign_psbt(psbt: &mut Psbt, master_xpriv: Xpriv, secp: &secp256k1::Secp256k1) { + let mut sighash_cache = sighash::SighashCache::new(&psbt.unsigned_tx); + let master_fingerprint = master_xpriv.fingerprint(secp); + + let prevouts: Vec<_> = psbt + .inputs + .iter() + .filter_map(|psbt_in| psbt_in.witness_utxo.clone()) + .collect(); + assert_eq!(prevouts.len(), psbt.inputs.len()); + + // Sign each input in the PSBT. + for i in 0..psbt.inputs.len() { + sign_taproot( + secp, + &mut sighash_cache, + master_xpriv, + master_fingerprint, + &prevouts, + &mut psbt.inputs[i], + i, + ); + } +} + +fn main() { + let args: Vec = env::args().collect(); + assert_eq!(args.len(), 3); + + let mut psbt = Psbt::from_str(&args[1]).unwrap(); + let xprv = Xpriv::from_str(&args[2]).unwrap(); + + let secp = secp256k1::Secp256k1::new(); + sign_psbt(&mut psbt, xprv, &secp); + + let psbt_str = psbt.to_string(); + print!("{}", psbt_str); + io::stdout().flush().unwrap(); +}