Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[cfg(any(feature = "sqlite", feature = "redb"))]
pub database_type: String,
Expand Down
5 changes: 5 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bdk_wallet::bip39::Error>),

Expand Down
39 changes: 23 additions & 16 deletions src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
159 changes: 138 additions & 21 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand All @@ -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)
Expand All @@ -279,20 +287,20 @@ 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)
}
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")]
Expand Down Expand Up @@ -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"),
}
}
}
Loading