diff --git a/src/bitcoin/d/mod.rs b/src/bitcoin/d/mod.rs index 4c010c1d..5e21472e 100644 --- a/src/bitcoin/d/mod.rs +++ b/src/bitcoin/d/mod.rs @@ -351,13 +351,19 @@ impl BitcoinD { None } - // TODO: rescan feature will probably need another timestamp than 'now' - fn import_descriptor(&self, descriptor: &InheritanceDescriptor) -> Option { - let descriptors = vec![serde_json::json!({ - "desc": descriptor.to_string(), - "timestamp": "now", - "active": false, - })]; + // Import the receive and change descriptors from the multipath descriptor to bitcoind. + fn import_descriptor(&self, desc: &InheritanceDescriptor) -> Option { + let descriptors = [desc.receive_descriptor(), desc.change_descriptor()] + .iter() + .map(|desc| { + // TODO: rescan feature will probably need another timestamp than 'now' + serde_json::json!({ + "desc": desc.to_string(), + "timestamp": "now", + "active": false, + }) + }) + .collect(); let res = self.make_wallet_request("importdescriptors", ¶ms!(Json::Array(descriptors))); let all_succeeded = res @@ -471,9 +477,11 @@ impl BitcoinD { } // Check our main descriptor is imported in this wallet. - if !self - .list_descriptors() - .contains(&main_descriptor.to_string()) + let receive_desc = main_descriptor.receive_descriptor(); + let change_desc = main_descriptor.change_descriptor(); + let desc_list = self.list_descriptors(); + if !desc_list.contains(&receive_desc.to_string()) + || !desc_list.contains(&change_desc.to_string()) { return Err(BitcoindError::MissingDescriptor); } diff --git a/src/bitcoin/mod.rs b/src/bitcoin/mod.rs index a95de2d8..ae7b2257 100644 --- a/src/bitcoin/mod.rs +++ b/src/bitcoin/mod.rs @@ -62,7 +62,7 @@ pub trait BitcoinInterface: Send { fn received_coins( &self, tip: &BlockChainTip, - desc: &descriptors::InheritanceDescriptor, + descs: &[descriptors::InheritanceDescriptor], ) -> Vec; /// Get all coins that were confirmed, and at what height and time. @@ -116,7 +116,7 @@ impl BitcoinInterface for d::BitcoinD { fn received_coins( &self, tip: &BlockChainTip, - desc: &descriptors::InheritanceDescriptor, + descs: &[descriptors::InheritanceDescriptor], ) -> Vec { // TODO: don't assume only a single descriptor is loaded on the wo wallet let lsb_res = self.list_since_block(&tip.hash); @@ -132,7 +132,10 @@ impl BitcoinInterface for d::BitcoinD { address, parent_descs, } = entry; - if parent_descs.iter().any(|parent_desc| desc == parent_desc) { + if parent_descs + .iter() + .any(|parent_desc| descs.iter().any(|desc| desc == parent_desc)) + { Some(UTxO { outpoint, amount, @@ -306,9 +309,9 @@ impl BitcoinInterface for sync::Arc> fn received_coins( &self, tip: &BlockChainTip, - desc: &descriptors::InheritanceDescriptor, + descs: &[descriptors::InheritanceDescriptor], ) -> Vec { - self.lock().unwrap().received_coins(tip, desc) + self.lock().unwrap().received_coins(tip, descs) } fn confirmed_coins( diff --git a/src/bitcoin/poller/looper.rs b/src/bitcoin/poller/looper.rs index 77751092..64904d5f 100644 --- a/src/bitcoin/poller/looper.rs +++ b/src/bitcoin/poller/looper.rs @@ -27,14 +27,14 @@ fn update_coins( bit: &impl BitcoinInterface, db_conn: &mut Box, previous_tip: &BlockChainTip, - desc: &descriptors::InheritanceDescriptor, + descs: &[descriptors::InheritanceDescriptor], ) -> UpdatedCoins { let curr_coins = db_conn.coins(); log::debug!("Current coins: {:?}", curr_coins); // Start by fetching newly received coins. let mut received = Vec::new(); - for utxo in bit.received_coins(previous_tip, desc) { + for utxo in bit.received_coins(previous_tip, descs) { if let Some(derivation_index) = db_conn.derivation_index_by_address(&utxo.address) { if !curr_coins.contains_key(&utxo.outpoint) { let UTxO { @@ -159,7 +159,7 @@ fn new_tip(bit: &impl BitcoinInterface, current_tip: &BlockChainTip) -> TipUpdat fn updates( bit: &impl BitcoinInterface, db: &impl DatabaseInterface, - desc: &descriptors::InheritanceDescriptor, + descs: &[descriptors::InheritanceDescriptor], ) { let mut db_conn = db.connection(); @@ -173,18 +173,18 @@ fn updates( // between our former chain and the new one, then restart fresh. db_conn.rollback_tip(&new_tip); log::info!("Tip was rolled back to '{}'.", new_tip); - return updates(bit, db, desc); + return updates(bit, db, descs); } }; // Then check the state of our coins. Do it even if the tip did not change since last poll, as // we may have unconfirmed transactions. - let updated_coins = update_coins(bit, &mut db_conn, ¤t_tip, desc); + let updated_coins = update_coins(bit, &mut db_conn, ¤t_tip, descs); // If the tip changed while we were polling our Bitcoin interface, start over. if bit.chain_tip() != latest_tip { log::info!("Chain tip changed while we were updating our state. Starting over."); - return updates(bit, db, desc); + return updates(bit, db, descs); } // The chain tip did not change since we started our updates. Record them and the latest tip. @@ -223,6 +223,7 @@ pub fn looper( ) { let mut last_poll = None; let mut synced = false; + let descs = [desc.receive_descriptor(), desc.change_descriptor()]; maybe_initialize_tip(&bit, &db); @@ -254,6 +255,6 @@ pub fn looper( } } - updates(&bit, &db, &desc); + updates(&bit, &db, &descs); } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9805331a..e5704daa 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -572,7 +572,7 @@ mod tests { assert_eq!( addr, bitcoin::Address::from_str( - "bc1qgudekhcrejgtlx3yhlvdul7t4q76e5lhm0vtcsndxs6aslh4r9jsqkqhwu" + "bc1q9ksrc647hx8zp2cewl8p5f487dgux3777yees8rjcx46t4daqzzqt7yga8" ) .unwrap() ); diff --git a/src/config.rs b/src/config.rs index a873abf4..ac532b55 100644 --- a/src/config.rs +++ b/src/config.rs @@ -228,7 +228,7 @@ mod tests { data_dir = "/home/wizardsardine/custom/folder/" daemon = false log_level = "debug" - main_descriptor = "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/*)))#y5wcna2d" + main_descriptor = "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/<0;1>/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/<0;1>/*)))#5f6qd0d9" [bitcoin_config] network = "bitcoin" @@ -245,7 +245,7 @@ mod tests { data_dir = '/home/wizardsardine/custom/folder/' daemon = false log_level = 'TRACE' - main_descriptor = 'wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/*)))#y5wcna2d' + main_descriptor = 'wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/<0;1>/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/<0;1>/*)))#5f6qd0d9' [bitcoin_config] network = 'bitcoin' @@ -266,7 +266,7 @@ mod tests { log_level = "trace" data_dir = "/home/wizardsardine/custom/folder/" - main_descriptor = "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/*)))#y5wcna2e" + main_descriptor = "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/<0;1>/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/<0;1>/*)))#y5wcna2e" [bitcoin_config] network = "bitcoin" diff --git a/src/database/sqlite/mod.rs b/src/database/sqlite/mod.rs index c0178fd2..cd0ec795 100644 --- a/src/database/sqlite/mod.rs +++ b/src/database/sqlite/mod.rs @@ -484,7 +484,7 @@ mod tests { use bitcoin::{hashes::Hash, util::bip32}; fn dummy_options() -> FreshDbOptions { - let desc_str = "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/*)))#y5wcna2d"; + let desc_str = "wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/<0;1>/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/<0;1>/*)))#5f6qd0d9"; let main_descriptor = InheritanceDescriptor::from_str(desc_str).unwrap(); FreshDbOptions { bitcoind_network: bitcoin::Network::Bitcoin, @@ -533,7 +533,7 @@ mod tests { .to_string() .contains("Database was created for network"); fs::remove_file(&db_path).unwrap(); - let other_desc_str = "wsh(andor(pk(tpubDExU4YLJkyQ9RRbVScQq2brFxWWha7WmAUByPWyaWYwmcTv3Shx8aHp6mVwuE5n4TeM4z5DTWGf2YhNPmXtfvyr8cUDVvA3txdrFnFgNdF7/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/*)))"; + let other_desc_str = "wsh(andor(pk(tpubDExU4YLJkyQ9RRbVScQq2brFxWWha7WmAUByPWyaWYwmcTv3Shx8aHp6mVwuE5n4TeM4z5DTWGf2YhNPmXtfvyr8cUDVvA3txdrFnFgNdF7/<0;1>/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/<0;1>/*)))"; let other_desc = InheritanceDescriptor::from_str(other_desc_str).unwrap(); let db = SqliteDb::new(db_path.clone(), Some(options.clone()), &secp).unwrap(); db.sanity_check(bitcoin::Network::Bitcoin, &other_desc) diff --git a/src/descriptors.rs b/src/descriptors.rs index 46b44009..87a5d6ab 100644 --- a/src/descriptors.rs +++ b/src/descriptors.rs @@ -31,7 +31,11 @@ impl std::fmt::Display for DescCreationError { match self { Self::InsaneTimelock(tl) => write!(f, "Timelock value '{}' isn't safe to use", tl), Self::InvalidKey(key) => { - write!(f, "Invalid key '{}'. Need a wildcard ('ranged') xpub", key) + write!( + f, + "Invalid key '{}'. Need a wildcard ('ranged') xpub with a multipath for (and only for) deriving change addresses. That is, an xpub of the form 'xpub.../<0;1>/*'.", + key + ) } Self::Miniscript(e) => write!(f, "Miniscript error: '{}'.", e), Self::IncompatibleDesc => write!(f, "Descriptor is not compatible."), @@ -167,12 +171,22 @@ fn csv_check(csv_value: u32) -> Result<(), DescCreationError> { .map_err(|_| DescCreationError::InsaneTimelock(csv_value)) } -fn is_unhardened_deriv(key: &descriptor::DescriptorPublicKey) -> bool { +// We require the descriptor key to: +// - Be deriveable (to contain a wildcard) +// - Be multipath (to contain a step in the derivation path with multiple indexes) +// - The multipath step to only contain two indexes, 0 and 1. +fn is_valid_desc_key(key: &descriptor::DescriptorPublicKey) -> bool { match *key { - descriptor::DescriptorPublicKey::Single(..) - | descriptor::DescriptorPublicKey::MultiXPub(..) => false, - descriptor::DescriptorPublicKey::XPub(ref xpub) => { + descriptor::DescriptorPublicKey::Single(..) | descriptor::DescriptorPublicKey::XPub(..) => { + false + } + descriptor::DescriptorPublicKey::MultiXPub(ref xpub) => { + // Rust-miniscript enforces BIP389 which states that all paths must have the same len. + let len = xpub.derivation_paths.get(0).expect("Cannot be empty").len(); xpub.wildcard == descriptor::Wildcard::Unhardened + && xpub.derivation_paths.len() == 2 + && xpub.derivation_paths[0][len - 1] == 0.into() + && xpub.derivation_paths[1][len - 1] == 1.into() } } } @@ -203,7 +217,7 @@ impl str::FromStr for InheritanceDescriptor { _ => return Err(DescCreationError::IncompatibleDesc), }; let invalid_key = ms.iter_pk().find_map(|pk| { - if is_unhardened_deriv(&pk) { + if is_valid_desc_key(&pk) { None } else { Some(pk) @@ -269,6 +283,53 @@ impl PartialEq> for Inhe } } +// Derive a **single-path** descriptor at a **unhardened** derivation index. Will panic if either +// of these isn't true. +fn derive_desc( + desc: &descriptor::Descriptor, + index: bip32::ChildNumber, + secp: &secp256k1::Secp256k1, +) -> descriptor::Descriptor { + assert!(index.is_normal()); + + // Unfortunately we can't just use `self.0.at_derivation_index().derived_descriptor()` + // since it would return a raw public key, but we need the origin too. + // TODO: upstream our DerivedPublicKey stuff to rust-miniscript. + // + // So we roll our own translation. + struct Derivator<'a, C: secp256k1::Verification>(u32, &'a secp256k1::Secp256k1); + impl<'a, C: secp256k1::Verification> + Translator + for Derivator<'a, C> + { + fn pk( + &mut self, + pk: &descriptor::DescriptorPublicKey, + ) -> Result { + let definite_key = pk + .clone() + .at_derivation_index(self.0) + .expect("We disallow multipath keys."); + let origin = ( + definite_key.master_fingerprint(), + definite_key + .full_derivation_path() + .expect("We disallow multipath keys."), + ); + let key = definite_key.derive_public_key(self.1)?; + Ok(DerivedPublicKey { origin, key }) + } + translate_hash_clone!( + descriptor::DescriptorPublicKey, + DerivedPublicKey, + descriptor::ConversionError + ); + } + + desc.translate_pk(&mut Derivator(index.into(), secp)) + .expect("May only fail on hardened derivation indexes, but we ruled out this case.") +} + impl InheritanceDescriptor { pub fn new( owner_key: descriptor::DescriptorPublicKey, @@ -285,7 +346,7 @@ impl InheritanceDescriptor { if let Some(key) = vec![&owner_key, &heir_key] .iter() - .find(|k| !is_unhardened_deriv(k)) + .find(|k| !is_valid_desc_key(k)) { return Err(DescCreationError::InvalidKey((**key).clone())); } @@ -325,7 +386,7 @@ impl InheritanceDescriptor { /// Whether all xpubs contained in this descriptor are for the passed expected network. pub fn all_xpubs_net_is(&self, expected_net: bitcoin::Network) -> bool { self.0.for_each_key(|xpub| { - if let descriptor::DescriptorPublicKey::XPub(xpub) = xpub { + if let descriptor::DescriptorPublicKey::MultiXPub(xpub) = xpub { xpub.xkey.network == expected_net } else { false @@ -333,6 +394,46 @@ impl InheritanceDescriptor { }) } + // TODO: Use a newtype to differentiate single and multi path descriptors. + // TODO: Cache it inside the struct, it's very inefficient to use into_single_descriptors() for + // every single derivation. + pub fn receive_descriptor(&self) -> InheritanceDescriptor { + let singlepath_descs = self + .0 + .clone() + .into_single_descriptors() + .expect("Can't error, all paths have the same length"); + assert_eq!(singlepath_descs.len(), 2); + + // We use /0/* for receiving, so it's the first descriptor between <0;1>. + // FIXME: don't rely on ordering. + InheritanceDescriptor( + singlepath_descs + .into_iter() + .next() + .expect("Just checked the length"), + ) + } + + pub fn change_descriptor(&self) -> InheritanceDescriptor { + let singlepath_descs = self + .0 + .clone() + .into_single_descriptors() + .expect("Can't error, all paths have the same length"); + assert_eq!(singlepath_descs.len(), 2); + + // We use /1/* for change, so it's the second descriptor between <0;1>. + // FIXME: don't rely on ordering. + InheritanceDescriptor( + singlepath_descs + .into_iter() + .rev() + .next() + .expect("Just checked the length"), + ) + } + /// Derive this descriptor at a given index for a receiving address. /// /// # Panics @@ -344,48 +445,7 @@ impl InheritanceDescriptor { ) -> DerivedInheritanceDescriptor { assert!(index.is_normal()); - // Unfortunately we can't just use `self.0.at_derivation_index().derived_descriptor()` - // since it would return a raw public key, but we need the origin too. - // TODO: upstream our DerivedPublicKey stuff to rust-miniscript. - // - // So we roll our own translation. - struct Derivator<'a, C: secp256k1::Verification>(u32, &'a secp256k1::Secp256k1); - impl<'a, C: secp256k1::Verification> - Translator< - descriptor::DescriptorPublicKey, - DerivedPublicKey, - descriptor::ConversionError, - > for Derivator<'a, C> - { - fn pk( - &mut self, - pk: &descriptor::DescriptorPublicKey, - ) -> Result { - let definite_key = pk - .clone() - .at_derivation_index(self.0) - .expect("We disallow multipath keys."); - let origin = ( - definite_key.master_fingerprint(), - definite_key - .full_derivation_path() - .expect("We disallow multipath keys."), - ); - let key = definite_key.derive_public_key(self.1)?; - Ok(DerivedPublicKey { origin, key }) - } - translate_hash_clone!( - descriptor::DescriptorPublicKey, - DerivedPublicKey, - descriptor::ConversionError - ); - } - - let desc = self - .0 - .translate_pk(&mut Derivator(index.into(), secp)) - .expect("May only fail on hardened derivation indexes, but we ruled out this case."); - DerivedInheritanceDescriptor(desc) + DerivedInheritanceDescriptor(derive_desc(&self.receive_descriptor().0, index, secp)) } /// Get the value (in blocks) of the relative timelock for the heir's spending path. @@ -478,10 +538,10 @@ mod tests { #[test] fn inheritance_descriptor_creation() { - let owner_key = descriptor::DescriptorPublicKey::from_str("xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/*").unwrap(); - let heir_key = descriptor::DescriptorPublicKey::from_str("xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/*").unwrap(); + let owner_key = descriptor::DescriptorPublicKey::from_str("xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap(); + let heir_key = descriptor::DescriptorPublicKey::from_str("xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*").unwrap(); let timelock = 52560; - assert_eq!(InheritanceDescriptor::new(owner_key, heir_key, timelock).unwrap().to_string(), "wsh(or_d(pk(xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/*),and_v(v:pkh(xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/*),older(52560))))#eeyujkt7"); + assert_eq!(InheritanceDescriptor::new(owner_key, heir_key, timelock).unwrap().to_string(), "wsh(or_d(pk(xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*),and_v(v:pkh(xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*),older(52560))))#8n2ydpkt"); // We prevent footguns with timelocks by requiring a u16. Note how the following wouldn't // compile: @@ -489,30 +549,36 @@ mod tests { //InheritanceDescriptor::new(owner_key.clone(), heir_key.clone(), (1 << 31) + 1).unwrap_err(); //InheritanceDescriptor::new(owner_key, heir_key, (1 << 22) + 1).unwrap_err(); - let owner_key = descriptor::DescriptorPublicKey::from_str("[aabb0011/10/4893]xpub661MyMwAqRbcFG59fiikD8UV762quhruT8K8bdjqy6N2o3LG7yohoCdLg1m2HAY1W6rfBrtauHkBhbfA4AQ3iazaJj5wVPhwgaRCHBW2DBg/*").unwrap(); - let heir_key = descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/24/32/*").unwrap(); + let owner_key = descriptor::DescriptorPublicKey::from_str("[aabb0011/10/4893]xpub661MyMwAqRbcFG59fiikD8UV762quhruT8K8bdjqy6N2o3LG7yohoCdLg1m2HAY1W6rfBrtauHkBhbfA4AQ3iazaJj5wVPhwgaRCHBW2DBg/<0;1>/*").unwrap(); + let heir_key = descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/24/32/<0;1>/*").unwrap(); let timelock = 57600; - assert_eq!(InheritanceDescriptor::new(owner_key.clone(), heir_key, timelock).unwrap().to_string(), "wsh(or_d(pk([aabb0011/10/4893]xpub661MyMwAqRbcFG59fiikD8UV762quhruT8K8bdjqy6N2o3LG7yohoCdLg1m2HAY1W6rfBrtauHkBhbfA4AQ3iazaJj5wVPhwgaRCHBW2DBg/*),and_v(v:pkh(xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/24/32/*),older(57600))))#8kamh6y8"); + assert_eq!(InheritanceDescriptor::new(owner_key.clone(), heir_key, timelock).unwrap().to_string(), "wsh(or_d(pk([aabb0011/10/4893]xpub661MyMwAqRbcFG59fiikD8UV762quhruT8K8bdjqy6N2o3LG7yohoCdLg1m2HAY1W6rfBrtauHkBhbfA4AQ3iazaJj5wVPhwgaRCHBW2DBg/<0;1>/*),and_v(v:pkh(xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/24/32/<0;1>/*),older(57600))))#l6dlpc2l"); - // We can't pass a raw key, an xpub that is not deriveable, or only hardened derivable - let heir_key = descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/354").unwrap(); + // We can't pass a raw key, an xpub that is not deriveable, only hardened derivable, + // without both the change and receive derivation paths, or with more than 2 different + // derivation paths. + let heir_key = descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/<0;1>/354").unwrap(); InheritanceDescriptor::new(owner_key.clone(), heir_key, timelock).unwrap_err(); - let heir_key = descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/*'").unwrap(); + let heir_key = descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/<0;1>/*'").unwrap(); InheritanceDescriptor::new(owner_key.clone(), heir_key, timelock).unwrap_err(); let heir_key = descriptor::DescriptorPublicKey::from_str( "02e24913be26dbcfdf8e8e94870b28725cdae09b448b6c127767bf0154e3a3c8e5", ) .unwrap(); + InheritanceDescriptor::new(owner_key.clone(), heir_key, timelock).unwrap_err(); + let heir_key = descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/*'").unwrap(); + InheritanceDescriptor::new(owner_key.clone(), heir_key, timelock).unwrap_err(); + let heir_key = descriptor::DescriptorPublicKey::from_str("xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/<0;1;2>/*'").unwrap(); InheritanceDescriptor::new(owner_key, heir_key, timelock).unwrap_err(); } #[test] fn inheritance_descriptor_derivation() { let secp = secp256k1::Secp256k1::verification_only(); - let desc = InheritanceDescriptor::from_str("wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/*)))#y5wcna2d").unwrap(); + let desc = InheritanceDescriptor::from_str("wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/<0;1>/*),older(10000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/<0;1>/*)))#5f6qd0d9").unwrap(); let der_desc = desc.derive_receive(11.into(), &secp); assert_eq!( - "bc1qvjzcg25nsxmfccct0txjvljxjwn68htkrw57jqmjhfzvhyd2z4msc74w65", + "bc1q26gtczlz03u6juf5cxppapk4sr4fyz53s3g4zs2cgactcahqv6yqc2t8e6", der_desc.address(bitcoin::Network::Bitcoin).to_string() ); @@ -525,13 +591,13 @@ mod tests { #[test] fn inheritance_descriptor_tl_value() { - let desc = InheritanceDescriptor::from_str("wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/*),older(1),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/*)))").unwrap(); + let desc = InheritanceDescriptor::from_str("wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/<0;1>/*),older(1),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/<0;1>/*)))").unwrap(); assert_eq!(desc.timelock_value(), 1); - let desc = InheritanceDescriptor::from_str("wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/*),older(42000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/*)))").unwrap(); + let desc = InheritanceDescriptor::from_str("wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/<0;1>/*),older(42000),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/<0;1>/*)))").unwrap(); assert_eq!(desc.timelock_value(), 42000); - let desc = InheritanceDescriptor::from_str("wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/*),older(65535),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/*)))").unwrap(); + let desc = InheritanceDescriptor::from_str("wsh(andor(pk(tpubDEN9WSToTyy9ZQfaYqSKfmVqmq1VVLNtYfj3Vkqh67et57eJ5sTKZQBkHqSwPUsoSskJeaYnPttHe2VrkCsKA27kUaN9SDc5zhqeLzKa1rr/<0;1>/*),older(65535),pk(tpubD8LYfn6njiA2inCoxwM7EuN3cuLVcaHAwLYeups13dpevd3nHLRdK9NdQksWXrhLQVxcUZRpnp5CkJ1FhE61WRAsHxDNAkvGkoQkAeWDYjV/<0;1>/*)))").unwrap(); assert_eq!(desc.timelock_value(), 0xffff); } diff --git a/src/lib.rs b/src/lib.rs index a8a6cfe2..168fc199 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -511,11 +511,14 @@ mod tests { stream.flush().unwrap(); } - // Send them a response to 'listdescriptors' with the main descriptor - fn complete_desc_check(server: &net::TcpListener, desc: &str) { + // Send them a response to 'listdescriptors' with the receive and change descriptors + fn complete_desc_check(server: &net::TcpListener, receive_desc: &str, change_desc: &str) { let net_resp = [ "HTTP/1.1 200\n\r\n{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"descriptors\":[{\"desc\":\"".as_bytes(), - desc.as_bytes(), + receive_desc.as_bytes(), + "\"},".as_bytes(), + "{\"desc\":\"".as_bytes(), + change_desc.as_bytes(), "\"}]}}\n".as_bytes(), ] .concat(); @@ -595,8 +598,10 @@ mod tests { }; // Create a dummy config with this bitcoind - let desc_str = "wsh(andor(pk(xpub68JJTXc1MWK8KLW4HGLXZBJknja7kDUJuFHnM424LbziEXsfkh1WQCiEjjHw4zLqSUm4rvhgyGkkuRowE9tCJSgt3TQB5J3SKAbZ2SdcKST/*),older(10000),pk(xpub68JJTXc1MWK8PEQozKsRatrUHXKFNkD1Cb1BuQU9Xr5moCv87anqGyXLyUd4KpnDyZgo3gz4aN1r3NiaoweFW8UutBsBbgKHzaD5HkTkifK/*)))#tk6wzexy"; + let desc_str = "wsh(andor(pk(xpub68JJTXc1MWK8KLW4HGLXZBJknja7kDUJuFHnM424LbziEXsfkh1WQCiEjjHw4zLqSUm4rvhgyGkkuRowE9tCJSgt3TQB5J3SKAbZ2SdcKST/<0;1>/*),older(10000),pk(xpub68JJTXc1MWK8PEQozKsRatrUHXKFNkD1Cb1BuQU9Xr5moCv87anqGyXLyUd4KpnDyZgo3gz4aN1r3NiaoweFW8UutBsBbgKHzaD5HkTkifK/<0;1>/*)))#yudtr0k5"; let desc = InheritanceDescriptor::from_str(desc_str).unwrap(); + let receive_desc = desc.receive_descriptor(); + let change_desc = desc.change_descriptor(); let config = Config { bitcoin_config, bitcoind_config: Some(bitcoind_config), @@ -621,7 +626,7 @@ mod tests { complete_version_check(&server); complete_network_check(&server); complete_wallet_check(&server, &wo_path); - complete_desc_check(&server, desc_str); + complete_desc_check(&server, &receive_desc.to_string(), &change_desc.to_string()); complete_tip_init(&server); complete_sync_check(&server); daemon_thread.join().unwrap(); @@ -636,7 +641,7 @@ mod tests { complete_version_check(&server); complete_network_check(&server); complete_wallet_check(&server, &wo_path); - complete_desc_check(&server, desc_str); + complete_desc_check(&server, &receive_desc.to_string(), &change_desc.to_string()); complete_sync_check(&server); daemon_thread.join().unwrap(); diff --git a/src/testutils.rs b/src/testutils.rs index 7b11ab9a..bf3282f7 100644 --- a/src/testutils.rs +++ b/src/testutils.rs @@ -47,7 +47,7 @@ impl BitcoinInterface for DummyBitcoind { fn received_coins( &self, _: &BlockChainTip, - _: &descriptors::InheritanceDescriptor, + _: &[descriptors::InheritanceDescriptor], ) -> Vec { Vec::new() } @@ -274,8 +274,8 @@ impl DummyMinisafe { poll_interval_secs: time::Duration::from_secs(2), }; - let owner_key = descriptor::DescriptorPublicKey::from_str("xpub68JJTXc1MWK8KLW4HGLXZBJknja7kDUJuFHnM424LbziEXsfkh1WQCiEjjHw4zLqSUm4rvhgyGkkuRowE9tCJSgt3TQB5J3SKAbZ2SdcKST/*").unwrap(); - let heir_key = descriptor::DescriptorPublicKey::from_str("xpub68JJTXc1MWK8PEQozKsRatrUHXKFNkD1Cb1BuQU9Xr5moCv87anqGyXLyUd4KpnDyZgo3gz4aN1r3NiaoweFW8UutBsBbgKHzaD5HkTkifK/*").unwrap(); + let owner_key = descriptor::DescriptorPublicKey::from_str("xpub68JJTXc1MWK8KLW4HGLXZBJknja7kDUJuFHnM424LbziEXsfkh1WQCiEjjHw4zLqSUm4rvhgyGkkuRowE9tCJSgt3TQB5J3SKAbZ2SdcKST/<0;1>/*").unwrap(); + let heir_key = descriptor::DescriptorPublicKey::from_str("xpub68JJTXc1MWK8PEQozKsRatrUHXKFNkD1Cb1BuQU9Xr5moCv87anqGyXLyUd4KpnDyZgo3gz4aN1r3NiaoweFW8UutBsBbgKHzaD5HkTkifK/<0;1>/*").unwrap(); let desc = crate::descriptors::InheritanceDescriptor::new(owner_key, heir_key, 10_000).unwrap(); let config = Config { diff --git a/tests/fixtures.py b/tests/fixtures.py index c94707ea..2a7bbe5b 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -121,7 +121,7 @@ def minisafed(bitcoind, directory): owner_hd = BIP32.from_seed(os.urandom(32), network="test") owner_xpub = owner_hd.get_xpub() - main_desc = Descriptor.from_str(f"wsh(or_d(pk({owner_xpub}/*),and_v(v:pkh(tpubD9vQiBdDxYzU4cVFtApWj4devZrvcfWaPXX1zHdDc7GPfUsDKqGnbhraccfm7BAXgRgUbVQUV2v2o4NitjGEk7hpbuP85kvBrD4ahFDtNBJ/*),older(65000))))") + main_desc = Descriptor.from_str(f"wsh(or_d(pk({owner_xpub}/<0;1>/*),and_v(v:pkh(tpubD9vQiBdDxYzU4cVFtApWj4devZrvcfWaPXX1zHdDc7GPfUsDKqGnbhraccfm7BAXgRgUbVQUV2v2o4NitjGEk7hpbuP85kvBrD4ahFDtNBJ/<0;1>/*),older(65000))))") minisafed = Minisafed( datadir, diff --git a/tests/test_framework/minisafed.py b/tests/test_framework/minisafed.py index 8600a7de..085e07e6 100644 --- a/tests/test_framework/minisafed.py +++ b/tests/test_framework/minisafed.py @@ -28,7 +28,7 @@ class Minisafed(TailableProc): self, datadir, owner_hd, - main_desc, + multi_desc, bitcoind_rpc_port, bitcoind_cookie_path, ): @@ -37,7 +37,8 @@ class Minisafed(TailableProc): self.prefix = os.path.split(datadir)[-1] self.owner_hd = owner_hd - self.main_desc = main_desc + self.multi_desc = multi_desc + self.receive_desc, self.change_desc = multi_desc.singlepath_descriptors() self.conf_file = os.path.join(datadir, "config.toml") self.cmd_line = [MINISAFED_PATH, "--conf", f"{self.conf_file}"] @@ -49,7 +50,7 @@ class Minisafed(TailableProc): f.write("daemon = false\n") f.write(f"log_level = '{LOG_LEVEL}'\n") - f.write(f'main_descriptor = "{main_desc}"\n') + f.write(f'main_descriptor = "{multi_desc}"\n') f.write("[bitcoin_config]\n") f.write('network = "regtest"\n') @@ -71,24 +72,32 @@ class Minisafed(TailableProc): # Sign each input. for i, psbt_in in enumerate(psbt.i): # First, gather the needed information from the PSBT input. - # 'hd_keypaths' is of the form {pubkey: (fingerprint (4 bytes), derivation index (4 bytes))} + # 'hd_keypaths' is of the form {pubkey: (fingerprint (4 bytes), derivation path (n * 4 bytes))} fing_der = next(iter(psbt_in.map[PSBT_IN_BIP32_DERIVATION].values())) - der_index = int.from_bytes(fing_der[4:], byteorder="little", signed=True) + raw_der_path = fing_der[4:] + der_path = [ + int.from_bytes(raw_der_path[i : i + 4], byteorder="little", signed=True) + for i in range(0, len(raw_der_path), 4) + ] script_code = psbt_in.map[PSBT_IN_WITNESS_SCRIPT] # Now sign the transaction with the key of the "owner" (the participant that # can sign immediately without a timelock) sighash = sighash_all_witness(script_code, psbt, i) privkey = coincurve.PrivateKey( - self.owner_hd.get_privkey_from_path([der_index]) + self.owner_hd.get_privkey_from_path(der_path) ) pubkey = privkey.public_key.format() assert pubkey in psbt_in.map[PSBT_IN_BIP32_DERIVATION].keys(), ( + der_path, + fing_der, pubkey, psbt_in.map[PSBT_IN_BIP32_DERIVATION].keys(), ) sig = privkey.sign(sighash, hasher=None) + b"\x01" - logging.debug(f"Adding signature {sig.hex()} for pubkey {pubkey.hex()}") + logging.debug( + f"Adding signature {sig.hex()} for pubkey {pubkey.hex()} (path {der_path})" + ) assert PSBT_IN_PARTIAL_SIG not in psbt_in.map psbt_in.map[PSBT_IN_PARTIAL_SIG] = {pubkey: sig} @@ -108,12 +117,19 @@ class Minisafed(TailableProc): # First, gather the needed information from the PSBT input. # 'hd_keypaths' is of the form {pubkey: (fingerprint, derivation index)} fing_der = next(iter(psbt_in.map[PSBT_IN_BIP32_DERIVATION].values())) - der_index = int.from_bytes(fing_der[4:], byteorder="little", signed=True) + raw_der_path = fing_der[4:] + der_path = [ + int.from_bytes(raw_der_path[i : i + 4], byteorder="little", signed=True) + for i in range(0, len(raw_der_path), 4) + ] + assert len(der_path) == 2 # Create a copy of the descriptor to derive it at the index used in this input. # Then create a satisfaction for it using the signature we just created. - desc = Descriptor.from_str(str(self.main_desc)) - desc.derive(der_index) + desc = Descriptor.from_str( + str(self.receive_desc if der_path[0] == 0 else self.change_desc) + ) + desc.derive(der_path[1]) sat_material = SatisfactionMaterial( signatures=psbt_in.map[PSBT_IN_PARTIAL_SIG], )