]> Untitled Git - bdk-cli/commitdiff
feat(multipath): add multipath desc support
authorVihiga Tyonum <withtvpeter@gmail.com>
Wed, 29 Jul 2026 22:16:22 +0000 (23:16 +0100)
committerVihiga Tyonum <withtvpeter@gmail.com>
Wed, 29 Jul 2026 22:16:22 +0000 (23:16 +0100)
- Enable creating wallets and wallet operations
using multipath descriptors.

src/commands.rs
src/error.rs
src/persister.rs
src/utils/descriptors.rs

index 975f33be6d6fc84d13f4e223abba0022b0474140..37f80523fd28d8506d8852c60b66d549ea8bd6ea 100644 (file)
@@ -231,10 +231,12 @@ pub struct WalletOpts {
     /// Selects the wallet to use.
     #[arg(skip)]
     pub wallet: Option<String>,
+    /// 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<String>,
     #[cfg(any(
index bee8f191c83a00b07ff98362d8feed781f08014b..32fe765d1e34221ba0260ae57c217f651e0aa499 100644 (file)
@@ -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<bdk_wallet::bip39::Error>),
 
index e49b4aeaf0a95f244795a02fd5d90fed61aee28e..4ff61f1a68f0cd3765f922bcdeeb90480b9b858b 100644 (file)
@@ -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<W
     let ext_descriptor = wallet_opts.ext_descriptor.clone();
     let int_descriptor = wallet_opts.int_descriptor.clone();
 
-    match int_descriptor {
-        Some(int_descriptor) => {
-            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)
 }
index 9b0f5b7cc83ac1e69077ffe5854edb30b79c47f1..d6c5e40115421f570922ff181c5aecdee73f9d85 100644 (file)
@@ -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<bool, Error> {
+    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());
+    }
+}