diff --git a/src/config.rs b/src/config.rs index 69c0238..3f1ba77 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,7 +27,9 @@ pub struct WalletConfig { pub struct WalletConfigInner { pub wallet: String, pub network: String, + /// This can be a single multipath descriptor (BIP389) or a standard external descriptor. pub ext_descriptor: String, + /// optional; Do not use if external descriptor is a multipath (BIP389) string pub int_descriptor: Option, #[cfg(any(feature = "sqlite", feature = "redb"))] pub database_type: String, diff --git a/src/error.rs b/src/error.rs index 064a928..74c621f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,6 +5,11 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum BDKCliError { + #[error( + "Ambiguous descriptors: cannot provide both a multipath descriptor and a separate internal descriptor." + )] + AmbiguousDescriptors, + #[error("BIP39 error: {0:?}")] BIP39Error(#[from] Option), diff --git a/src/handlers.rs b/src/handlers.rs index 1f867b4..ea8b0bb 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -480,25 +480,32 @@ pub fn handle_offline_wallet_subcommand( let internal = wallet.public_descriptor(KeychainKind::Internal).to_string(); if cli_opts.pretty { - let table = vec![ - vec![ - "External Descriptor".cell().bold(true), - external.to_string().cell(), - ], + let rows = if external == internal { + vec![vec![ + "Multipath Descriptor".cell().bold(true), + external.cell(), + ]] + } else { vec![ - "Internal Descriptor".cell().bold(true), - internal.to_string().cell(), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; + vec!["External Descriptor".cell().bold(true), external.cell()], + vec!["Internal Descriptor".cell().bold(true), internal.cell()], + ] + }; + let table = rows + .table() + .display() + .map_err(|e| Error::Generic(e.to_string()))?; Ok(format!("{table}")) } else { - Ok(serde_json::to_string_pretty(&json!({ - "external": external.to_string(), - "internal": internal.to_string(), - }))?) + let desc = if external == internal { + json!({"multipath_descriptor": external.to_string()}) + } else { + json!({ + "external": external.to_string(), + "internal": internal.to_string(), + }) + }; + Ok(serde_json::to_string_pretty(&desc)?) } } Sign { diff --git a/src/utils.rs b/src/utils.rs index 73d3453..520825f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -241,6 +241,10 @@ where let ext_descriptor = wallet_opts.ext_descriptor.clone(); let int_descriptor = wallet_opts.int_descriptor.clone(); + if is_multipath_desc(&ext_descriptor) && int_descriptor.is_some() { + return Err(Error::AmbiguousDescriptors); + }; + let mut wallet_load_params = Wallet::load(); wallet_load_params = wallet_load_params.descriptor(KeychainKind::External, Some(ext_descriptor.clone())); @@ -258,16 +262,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 is_multipath_desc(&ext_descriptor) { + 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) @@ -279,20 +287,20 @@ 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) - } + if is_multipath_desc(&ext_descriptor) && 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 is_multipath_desc(&ext_descriptor) { + 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) } #[cfg(feature = "cbf")] @@ -648,3 +656,112 @@ pub fn load_wallet_config( Ok((wallet_opts, network)) } + +/// Helper to check if a descriptor string contains a BIP389 multipath expression. +fn is_multipath_desc(desc_str: &str) -> bool { + let desc_str = desc_str.split('#').next().unwrap_or(desc_str).trim(); + + desc_str.contains('<') && desc_str.contains(';') && desc_str.contains('>') +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::WalletOpts; + use bdk_wallet::{bitcoin::Network, rusqlite::Connection}; + + #[test] + fn test_is_multipath_descriptor() { + let multipath_desc = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1>/*)"; + let desc = "wpkh([07234a14/84'/1'/0']tpubDCSgT6PaVLQH9h2TAxKryhvkEurUBcYRJc9dhTcMDyahhWiMWfEWvQQX89yaw7w7XU8bcVujoALfxq59VkFATri3Cxm5mkp9kfHfRFDckEh/0/*)#429nsxmg"; + let multi_path = is_multipath_desc(multipath_desc); + let result = is_multipath_desc(desc); + assert!(multi_path); + assert!(!result); + } + + #[cfg(any(feature = "sqlite", feature = "redb"))] + #[test] + fn test_multipath_detection_and_initialization() { + let mut db = Connection::open_in_memory().expect("should open in memory db"); + let wallet_config = crate::config::WalletConfigInner { + wallet: "test_wallet".to_string(), + network: "testnet4".to_string(), + ext_descriptor: "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1>/*)".to_string(), + int_descriptor: None, + #[cfg(any(feature = "sqlite", feature = "redb"))] + database_type: "sqlite".to_string(), + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc", feature = "cbf"))] + client_type: Some("esplora".to_string()), + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] + server_url: Some(" https://blockstream.info/testnet4/api".to_string()), + #[cfg(feature = "electrum")] + batch_size: None, + #[cfg(feature = "esplora")] + parallel_requests: None, + #[cfg(feature = "rpc")] + rpc_user: None, + #[cfg(feature = "rpc")] + rpc_password: None, + #[cfg(feature = "rpc")] + cookie: None, + }; + + let opts: WalletOpts = (&wallet_config) + .try_into() + .expect("Conversion should succeed"); + + let result = new_persisted_wallet(Network::Testnet, &mut db, &opts); + assert!(result.is_ok(), "Multipath initialization should succeed"); + + let wallet = result.unwrap(); + let ext_desc = wallet.public_descriptor(KeychainKind::External).to_string(); + let int_desc = wallet.public_descriptor(KeychainKind::Internal).to_string(); + + assert!(ext_desc.contains("/0/*"), "External should use index 0"); + assert!(int_desc.contains("/1/*"), "Internal should use index 1"); + + assert!(ext_desc.contains("9a6a2580")); + assert!(int_desc.contains("9a6a2580")); + } + + #[cfg(any(feature = "sqlite", feature = "redb"))] + #[test] + fn test_error_on_ambiguous_descriptors() { + let network = Network::Testnet; + let mut db = Connection::open_in_memory().expect("should open in memory db"); + let wallet_config = crate::config::WalletConfigInner { + wallet: "test_wallet".to_string(), + network: "testnet4".to_string(), + ext_descriptor: "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1>/*)".to_string(), + int_descriptor: Some("wpkh([07234a14/84'/1'/0']tpubDCSgT6PaVLQH9h2TAxKryhvkEurUBcYRJc9dhTcMDyahhWiMWfEWvQQX89yaw7w7XU8bcVujoALfxq59VkFATri3Cxm5mkp9kfHfRFDckEh/1/*)#y7qjdnts".to_string()), + #[cfg(any(feature = "sqlite", feature = "redb"))] + database_type: "sqlite".to_string(), + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc", feature = "cbf"))] + client_type: Some("esplora".to_string()), + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] + server_url: Some(" https://blockstream.info/testnet4/api".to_string()), + #[cfg(feature = "electrum")] + batch_size: None, + #[cfg(feature = "esplora")] + parallel_requests: None, + #[cfg(feature = "rpc")] + rpc_user: None, + #[cfg(feature = "rpc")] + rpc_password: None, + #[cfg(feature = "rpc")] + cookie: None, + }; + + let opts: WalletOpts = (&wallet_config) + .try_into() + .expect("Conversion should succeed"); + + let result = new_persisted_wallet(network, &mut db, &opts); + + match result { + Err(Error::AmbiguousDescriptors) => (), + _ => panic!("Should have returned AmbiguousDescriptors error"), + } + } +}