From 53161348b18c6a0f30dec67021ede2b25a55486a Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Wed, 29 Jul 2026 23:16:22 +0100 Subject: [PATCH 1/3] feat(multipath): add multipath desc support - Enable creating wallets and wallet operations using multipath descriptors. --- src/commands.rs | 4 ++- src/error.rs | 3 ++ src/persister.rs | 71 ++++++++++++++++++++++++---------------- src/utils/descriptors.rs | 35 ++++++++++++++++++++ 4 files changed, 84 insertions(+), 29 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index 975f33be..37f80523 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -231,10 +231,12 @@ pub struct WalletOpts { /// Selects the wallet to use. #[arg(skip)] pub wallet: Option, + /// A single external descriptor, or a BIP-389 multipath descriptor. /// Sets the descriptor to use for the external addresses. #[arg(env = "EXT_DESCRIPTOR", short = 'e', long, required = true)] pub ext_descriptor: String, - /// Sets the descriptor to use for internal/change addresses. + /// Optional internal/change descriptor. Omit when `ext_descriptor` is a + /// multipath descriptor. Sets the descriptor to use for internal/change addresses. #[arg(env = "INT_DESCRIPTOR", short = 'i', long)] pub int_descriptor: Option, #[cfg(any( diff --git a/src/error.rs b/src/error.rs index bee8f191..32fe765d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,6 +5,9 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum BDKCliError { + #[error("Cannot provide both a multipath descriptor and a separate internal descriptor.")] + AmbiguousDescriptors, + #[error("BIP39 error: {0:?}")] BIP39Error(#[from] Option), diff --git a/src/persister.rs b/src/persister.rs index e49b4aea..4ff61f1a 100644 --- a/src/persister.rs +++ b/src/persister.rs @@ -1,5 +1,6 @@ use crate::commands::WalletOpts; use crate::error::BDKCliError as Error; +use crate::utils::descriptors::is_multipath_descriptor; use bdk_wallet::Wallet; use bdk_wallet::bitcoin::Network; #[cfg(any(feature = "sqlite", feature = "redb"))] @@ -68,14 +69,23 @@ where let ext_descriptor = wallet_opts.ext_descriptor.clone(); let int_descriptor = wallet_opts.int_descriptor.clone(); - let mut wallet_load_params = Wallet::load(); - wallet_load_params = - wallet_load_params.descriptor(KeychainKind::External, Some(ext_descriptor.clone())); - - if int_descriptor.is_some() { - wallet_load_params = - wallet_load_params.descriptor(KeychainKind::Internal, int_descriptor.clone()); + let ext_is_multipath = is_multipath_descriptor(&ext_descriptor, network)?; + if ext_is_multipath && int_descriptor.is_some() { + return Err(Error::AmbiguousDescriptors); } + + let mut wallet_load_params = Wallet::load(); + wallet_load_params = if ext_is_multipath { + // Load a wallet created from a two-path (BIP-389) descriptor. + wallet_load_params.two_path_descriptor(ext_descriptor.clone()) + } else { + let mut params = + wallet_load_params.descriptor(KeychainKind::External, Some(ext_descriptor.clone())); + if int_descriptor.is_some() { + params = params.descriptor(KeychainKind::Internal, int_descriptor.clone()); + } + params + }; wallet_load_params = wallet_load_params.extract_keys(); let wallet_opt = wallet_load_params @@ -85,16 +95,20 @@ where let wallet = match wallet_opt { Some(wallet) => wallet, - None => match int_descriptor { - Some(int_descriptor) => Wallet::create(ext_descriptor, int_descriptor) - .network(network) - .create_wallet(persister) - .map_err(|e| Error::Generic(e.to_string()))?, - None => Wallet::create_single(ext_descriptor) + None => { + let builder = if let Some(int_descriptor) = int_descriptor { + Wallet::create(ext_descriptor, int_descriptor) + } else if ext_is_multipath { + Wallet::create_from_two_path_descriptor(ext_descriptor) + } else { + Wallet::create_single(ext_descriptor) + }; + + builder .network(network) .create_wallet(persister) - .map_err(|e| Error::Generic(e.to_string()))?, - }, + .map_err(|e| Error::Generic(e.to_string()))? + } }; Ok(wallet) @@ -104,18 +118,19 @@ pub(crate) fn new_wallet(network: Network, wallet_opts: &WalletOpts) -> Result { - let wallet = Wallet::create(ext_descriptor, int_descriptor) - .network(network) - .create_wallet_no_persist()?; - Ok(wallet) - } - None => { - let wallet = Wallet::create_single(ext_descriptor) - .network(network) - .create_wallet_no_persist()?; - Ok(wallet) - } + let ext_is_multipath = is_multipath_descriptor(&ext_descriptor, network)?; + if ext_is_multipath && int_descriptor.is_some() { + return Err(Error::AmbiguousDescriptors); } + + let builder = if let Some(int_descriptor) = int_descriptor { + Wallet::create(ext_descriptor, int_descriptor) + } else if ext_is_multipath { + Wallet::create_from_two_path_descriptor(ext_descriptor) + } else { + Wallet::create_single(ext_descriptor) + }; + + let wallet = builder.network(network).create_wallet_no_persist()?; + Ok(wallet) } diff --git a/src/utils/descriptors.rs b/src/utils/descriptors.rs index 9b0f5b7c..d6c5e401 100644 --- a/src/utils/descriptors.rs +++ b/src/utils/descriptors.rs @@ -1,3 +1,5 @@ +use bdk_wallet::bitcoin::Network; +use bdk_wallet::descriptor::IntoWalletDescriptor; use bdk_wallet::keys::GeneratableKey; use std::{str::FromStr, sync::Arc}; @@ -196,3 +198,36 @@ pub fn generate_descriptor_from_mnemonic( result.mnemonic = Some(mnemonic_str.to_string()); Ok(result) } + +/// Returns `true` if `descriptor` is a BIP-389 multipath descriptor (e.g. `.../<0;1>/*`). +/// +/// Parses via IntoWalletDescriptor and uses miniscript's `is_multipath`. +/// Returns an error if the descriptor is unparseable. +pub fn is_multipath_descriptor(descriptor: &str, network: Network) -> Result { + let secp = Secp256k1::new(); + let (descriptor, _) = descriptor.into_wallet_descriptor(&secp, network.into())?; + Ok(descriptor.is_multipath()) +} + +#[cfg(test)] +mod multipath_tests { + use super::*; + + const MULTIPATH: &str = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1>/*)"; + const SINGLE: &str = "wpkh([07234a14/84'/1'/0']tpubDCSgT6PaVLQH9h2TAxKryhvkEurUBcYRJc9dhTcMDyahhWiMWfEWvQQX89yaw7w7XU8bcVujoALfxq59VkFATri3Cxm5mkp9kfHfRFDckEh/0/*)#429nsxmg"; + + #[test] + fn detects_multipath() { + assert!(is_multipath_descriptor(MULTIPATH, Network::Testnet).unwrap()); + } + + #[test] + fn detects_single_path() { + assert!(!is_multipath_descriptor(SINGLE, Network::Testnet).unwrap()); + } + + #[test] + fn rejects_unparseable() { + assert!(is_multipath_descriptor("not a descriptor", Network::Testnet).is_err()); + } +} From 697fe0b1eb97b8b461ec5096697bbb66b8bbeae1 Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Wed, 29 Jul 2026 23:24:19 +0100 Subject: [PATCH 2/3] feat(multipath): limit multipath desc to two paths - Add a check for a two-paths multipath descriptors as supported by the wallet api. --- src/utils/descriptors.rs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/utils/descriptors.rs b/src/utils/descriptors.rs index d6c5e401..924041dd 100644 --- a/src/utils/descriptors.rs +++ b/src/utils/descriptors.rs @@ -199,14 +199,26 @@ pub fn generate_descriptor_from_mnemonic( Ok(result) } -/// Returns `true` if `descriptor` is a BIP-389 multipath descriptor (e.g. `.../<0;1>/*`). +/// Returns `true` if `descriptor` is a supported two-path BIP-389 multipath descriptor +/// (external and internal), `false` for a normal single-path descriptor. /// -/// Parses via IntoWalletDescriptor and uses miniscript's `is_multipath`. -/// Returns an error if the descriptor is unparseable. +/// Errors if the descriptor is unparseable, or if it's a multipath descriptor with a number +/// of paths other than two (supports only external/internal two-path multipath). pub fn is_multipath_descriptor(descriptor: &str, network: Network) -> Result { let secp = Secp256k1::new(); let (descriptor, _) = descriptor.into_wallet_descriptor(&secp, network.into())?; - Ok(descriptor.is_multipath()) + + if !descriptor.is_multipath() { + return Ok(false); + } + + let paths = descriptor.into_single_descriptors()?.len(); + if paths != 2 { + return Err(Error::Generic(format!( + "Unsupported multipath descriptor: expected exactly 2 paths (external/internal), found {paths}." + ))); + } + Ok(true) } #[cfg(test)] @@ -214,6 +226,7 @@ mod multipath_tests { use super::*; const MULTIPATH: &str = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1>/*)"; + const THREE_PATH: &str = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1;2>/*)"; const SINGLE: &str = "wpkh([07234a14/84'/1'/0']tpubDCSgT6PaVLQH9h2TAxKryhvkEurUBcYRJc9dhTcMDyahhWiMWfEWvQQX89yaw7w7XU8bcVujoALfxq59VkFATri3Cxm5mkp9kfHfRFDckEh/0/*)#429nsxmg"; #[test] @@ -230,4 +243,9 @@ mod multipath_tests { fn rejects_unparseable() { assert!(is_multipath_descriptor("not a descriptor", Network::Testnet).is_err()); } + + #[test] + fn rejects_more_than_two_paths() { + assert!(is_multipath_descriptor(THREE_PATH, Network::Testnet).is_err()); + } } From 48b331423469d5a60a29e098f07d1c4574178552 Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Wed, 29 Jul 2026 23:34:04 +0100 Subject: [PATCH 3/3] feat(multipath): Add tests for multipath desc -Add tests for multipath descriptors. - Update CHANGELOG --- CHANGELOG.md | 3 ++ tests/integration/offline.rs | 83 ++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79da994d..098eee19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ page. See [DEVELOPMENT_CYCLE.md](DEVELOPMENT_CYCLE.md) for more details. ## [Unreleased] +- Added support for Multipath (two-paths) descriptors. + + ## [4.0.0] - Added persistance to existing async payjoin integration diff --git a/tests/integration/offline.rs b/tests/integration/offline.rs index 780761a0..2fceefc3 100644 --- a/tests/integration/offline.rs +++ b/tests/integration/offline.rs @@ -349,3 +349,86 @@ mod repl_tests { ); } } + +#[cfg(all( + feature = "sqlite", + not(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + )) +))] +mod multipath_tests { + use crate::common::BdkCli; + use assert_cmd::Command; + use predicates::prelude::*; + use tempfile::TempDir; + + // A public BIP-389 two-path (multipath) descriptor + const MULTIPATH_DESC: &str = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1>/*)"; + const INT_DESC: &str = "wpkh([07234a14/84'/1'/0']tpubDCSgT6PaVLQH9h2TAxKryhvkEurUBcYRJc9dhTcMDyahhWiMWfEWvQQX89yaw7w7XU8bcVujoALfxq59VkFATri3Cxm5mkp9kfHfRFDckEh/1/*)"; + + fn save_config(cli: &BdkCli, wallet: &str, ext: &str, int: Option<&str>) -> Command { + let mut cmd = cli.build_base_cmd(); + cmd.arg("wallet") + .arg("--wallet") + .arg(wallet) + .arg("config") + .arg("--ext-descriptor") + .arg(ext) + .arg("--database-type") + .arg("sqlite"); + if let Some(int) = int { + cmd.arg("--int-descriptor").arg(int); + } + cmd + } + + #[test] + fn multipath_descriptor_creates_split_keychains() { + let tmp = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(tmp.path().to_path_buf())); + save_config(&cli, "multipath_wallet", MULTIPATH_DESC, None) + .assert() + .success(); + + cli.wallet_cmd(&["--wallet", "multipath_wallet", "public_descriptor"]) + .assert() + .success() + .stdout(predicate::str::contains("/0/*")) + .stdout(predicate::str::contains("/1/*")); + } + + #[test] + fn multipath_wallet_reloads() { + let tmp = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(tmp.path().to_path_buf())); + save_config(&cli, "multipath_wallet", MULTIPATH_DESC, None) + .assert() + .success(); + + cli.wallet_cmd(&["--wallet", "multipath_wallet", "new_address"]) + .assert() + .success(); + cli.wallet_cmd(&["--wallet", "multipath_wallet", "new_address"]) + .assert() + .success(); + } + + #[test] + fn multipath_with_internal_is_ambiguous() { + let tmp = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(tmp.path().to_path_buf())); + save_config(&cli, "multipath_wallet", MULTIPATH_DESC, Some(INT_DESC)) + .assert() + .success(); + + cli.wallet_cmd(&["--wallet", "multipath_wallet", "new_address"]) + .assert() + .failure() + .stderr(predicate::str::contains( + "multipath descriptor and a separate internal descriptor", + )); + } +}