diff --git a/wallet/src/descriptor/error.rs b/wallet/src/descriptor/error.rs index e018b535..8a5e47bd 100644 --- a/wallet/src/descriptor/error.rs +++ b/wallet/src/descriptor/error.rs @@ -68,7 +68,7 @@ impl fmt::Display for Error { ), Self::MultiPath => write!( f, - "The descriptor contains multipath keys, which are not supported yet" + "The descriptor contains multipath keys with invalid number of paths (must have exactly 2 paths for receive and change)" ), Self::Key(err) => write!(f, "Key error: {}", err), Self::Policy(err) => write!(f, "Policy error: {}", err), diff --git a/wallet/src/descriptor/mod.rs b/wallet/src/descriptor/mod.rs index 4db28c61..49a54b17 100644 --- a/wallet/src/descriptor/mod.rs +++ b/wallet/src/descriptor/mod.rs @@ -312,7 +312,14 @@ pub(crate) fn check_wallet_descriptor( } if descriptor.is_multipath() { - return Err(DescriptorError::MultiPath); + // Only allow multipath descriptors with exactly 2 paths (receive and change) + let paths = descriptor + .clone() + .into_single_descriptors() + .map_err(|_| DescriptorError::MultiPath)?; + if paths.len() != 2 { + return Err(DescriptorError::MultiPath); + } } // Run miniscript's sanity check, which will look for duplicated keys and other potential @@ -875,12 +882,22 @@ mod test { assert_matches!(result, Err(DescriptorError::HardenedDerivationXpub)); + // Valid 2-path multipath descriptor should now pass let descriptor = "wpkh(tpubD6NzVbkrYhZ4XHndKkuB8FifXm8r5FQHwrN6oZuWCz13qb93rtgKvD4PQsqC4HP4yhV3tA2fqr2RbY5mNXfM7RxXUoeABoDtsFUq2zJq6YK/<0;1>/*)"; let (descriptor, _) = descriptor .into_wallet_descriptor(&secp, Network::Testnet) .expect("must parse"); let result = check_wallet_descriptor(&descriptor); + assert!(result.is_ok()); + + // Invalid 3-path multipath descriptor should fail + let descriptor = "wpkh(tpubD6NzVbkrYhZ4XHndKkuB8FifXm8r5FQHwrN6oZuWCz13qb93rtgKvD4PQsqC4HP4yhV3tA2fqr2RbY5mNXfM7RxXUoeABoDtsFUq2zJq6YK/<0;1;2>/*)"; + let (descriptor, _) = descriptor + .into_wallet_descriptor(&secp, Network::Testnet) + .expect("must parse"); + let result = check_wallet_descriptor(&descriptor); + assert_matches!(result, Err(DescriptorError::MultiPath)); // repeated pubkeys @@ -961,4 +978,36 @@ mod test { Ok(()) } + + #[test] + fn test_multipath_descriptor_validation() { + let secp = Secp256k1::new(); + + // Test that 2-path multipath descriptor passes validation + let descriptor_str = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1>/*)"; + let (descriptor, _) = descriptor_str + .into_wallet_descriptor(&secp, Network::Testnet) + .expect("should parse 2-path multipath descriptor"); + + let result = check_wallet_descriptor(&descriptor); + assert!(result.is_ok()); + + // Test that 1-path descriptor (non-multipath) still works + let descriptor_str = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/0/*)"; + let (descriptor, _) = descriptor_str + .into_wallet_descriptor(&secp, Network::Testnet) + .expect("should parse single-path descriptor"); + + let result = check_wallet_descriptor(&descriptor); + assert!(result.is_ok()); + + // Test that 3-path multipath descriptor fails validation + let descriptor_str = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1;2>/*)"; + let (descriptor, _) = descriptor_str + .into_wallet_descriptor(&secp, Network::Testnet) + .expect("should parse 3-path multipath descriptor"); + + let result = check_wallet_descriptor(&descriptor); + assert!(matches!(result, Err(DescriptorError::MultiPath))); + } } diff --git a/wallet/src/wallet/mod.rs b/wallet/src/wallet/mod.rs index ed6cb3f9..afe8e5e6 100644 --- a/wallet/src/wallet/mod.rs +++ b/wallet/src/wallet/mod.rs @@ -402,6 +402,45 @@ impl Wallet { CreateParams::new(descriptor, change_descriptor) } + /// Build a new [`Wallet`] from a multipath descriptor. + /// + /// This function parses a multipath descriptor with exactly 2 paths (receive and change) + /// and creates a wallet using the existing receive and change wallet creation logic. + /// + /// Multipath descriptors follow [BIP 389] and allow defining both receive and change + /// derivation paths in a single descriptor using the `<0;1>` syntax. + /// + /// If you have previously created a wallet, use [`load`](Self::load) instead. + /// + /// # Errors + /// Returns an error if the descriptor is invalid or not a 2-path multipath descriptor. + /// + /// # Synopsis + /// + /// ```rust + /// # use bdk_wallet::Wallet; + /// # use bitcoin::Network; + /// # use bdk_wallet::KeychainKind; + /// # const MULTIPATH_DESC: &str = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1>/*)"; + /// let wallet = Wallet::create_multipath(MULTIPATH_DESC) + /// .network(Network::Testnet) + /// .create_wallet_no_persist() + /// .unwrap(); + /// + /// // The multipath descriptor automatically creates separate receive and change descriptors + /// let receive_addr = wallet.peek_address(KeychainKind::External, 0); // Uses path /0/* + /// let change_addr = wallet.peek_address(KeychainKind::Internal, 0); // Uses path /1/* + /// assert_ne!(receive_addr.address, change_addr.address); + /// ``` + /// + /// [BIP 389]: https://github.com/bitcoin/bips/blob/master/bip-0389.mediawiki + pub fn create_multipath(multipath_descriptor: D) -> CreateParams + where + D: IntoWalletDescriptor + Send + Clone + 'static, + { + CreateParams::new_multipath(multipath_descriptor) + } + /// Create a new [`Wallet`] with given `params`. /// /// Refer to [`Wallet::create`] for more. @@ -2765,4 +2804,59 @@ mod test { assert_eq!(expected, received); } + + #[test] + fn test_create_multipath_wallet() { + let multipath_descriptor = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1>/*)"; + + // Test successful creation of multipath wallet + let params = Wallet::create_multipath(multipath_descriptor); + let wallet = params.network(Network::Testnet).create_wallet_no_persist(); + assert!(wallet.is_ok()); + + let wallet = wallet.unwrap(); + + // Verify that the wallet has both external and internal keychains + let keychains: Vec<_> = wallet.keychains().collect(); + assert_eq!(keychains.len(), 2); + + // Verify that the descriptors are different (receive vs change) + let external_desc = keychains + .iter() + .find(|(k, _)| *k == KeychainKind::External) + .unwrap() + .1; + let internal_desc = keychains + .iter() + .find(|(k, _)| *k == KeychainKind::Internal) + .unwrap() + .1; + assert_ne!(external_desc.to_string(), internal_desc.to_string()); + + // Verify that addresses can be generated + let external_addr = wallet.peek_address(KeychainKind::External, 0); + let internal_addr = wallet.peek_address(KeychainKind::Internal, 0); + assert_ne!(external_addr.address, internal_addr.address); + } + + #[test] + fn test_create_multipath_wallet_invalid_descriptor() { + // Test with invalid single-path descriptor + let single_path_descriptor = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/0/*)"; + let params = Wallet::create_multipath(single_path_descriptor); + let wallet = params.network(Network::Testnet).create_wallet_no_persist(); + assert!(matches!(wallet, Err(DescriptorError::MultiPath))); + + // Test with invalid 3-path multipath descriptor + let three_path_descriptor = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1;2>/*)"; + let params = Wallet::create_multipath(three_path_descriptor); + let wallet = params.network(Network::Testnet).create_wallet_no_persist(); + assert!(matches!(wallet, Err(DescriptorError::MultiPath))); + + // Test with completely invalid descriptor + let invalid_descriptor = "invalid_descriptor"; + let params = Wallet::create_multipath(invalid_descriptor); + let wallet = params.network(Network::Testnet).create_wallet_no_persist(); + assert!(wallet.is_err()); + } } diff --git a/wallet/src/wallet/params.rs b/wallet/src/wallet/params.rs index aa608554..8b005918 100644 --- a/wallet/src/wallet/params.rs +++ b/wallet/src/wallet/params.rs @@ -12,6 +12,32 @@ use crate::{ use super::{ChangeSet, LoadError, PersistedWallet}; +fn make_multipath_descriptor_to_extract( + multipath_descriptor: D, + index: usize, +) -> DescriptorToExtract +where + D: IntoWalletDescriptor + Send + 'static, +{ + Box::new(move |secp, network| { + let (desc, keymap) = multipath_descriptor.into_wallet_descriptor(secp, network)?; + + if !desc.is_multipath() { + return Err(DescriptorError::MultiPath); + } + + let descriptors = desc + .into_single_descriptors() + .map_err(|_| DescriptorError::MultiPath)?; + + if descriptors.len() != 2 { + return Err(DescriptorError::MultiPath); + } + + Ok((descriptors[index].clone(), keymap)) + }) +} + /// This atrocity is to avoid having type parameters on [`CreateParams`] and [`LoadParams`]. /// /// The better option would be to do `Box`, but we cannot due to Rust's @@ -88,6 +114,34 @@ impl CreateParams { } } + /// Construct parameters with a multipath descriptor that will be parsed into receive and change + /// descriptors. + /// + /// This function parses a multipath descriptor with exactly 2 paths (receive and change) + /// and creates parameters using the existing receive and change wallet creation logic. + /// + /// Default values: + /// * `network` = [`Network::Bitcoin`] + /// * `genesis_hash` = `None` + /// * `lookahead` = [`DEFAULT_LOOKAHEAD`] + pub fn new_multipath( + multipath_descriptor: D, + ) -> Self { + Self { + descriptor: make_multipath_descriptor_to_extract(multipath_descriptor.clone(), 0), + descriptor_keymap: KeyMap::default(), + change_descriptor: Some(make_multipath_descriptor_to_extract( + multipath_descriptor, + 1, + )), + change_descriptor_keymap: KeyMap::default(), + network: Network::Bitcoin, + genesis_hash: None, + lookahead: DEFAULT_LOOKAHEAD, + use_spk_cache: false, + } + } + /// Extend the given `keychain`'s `keymap`. pub fn keymap(mut self, keychain: KeychainKind, keymap: KeyMap) -> Self { match keychain {