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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>),

Expand Down
71 changes: 43 additions & 28 deletions src/persister.rs
Original file line number Diff line number Diff line change
@@ -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"))]
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
}
53 changes: 53 additions & 0 deletions src/utils/descriptors.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -196,3 +198,54 @@ pub fn generate_descriptor_from_mnemonic(
result.mnemonic = Some(mnemonic_str.to_string());
Ok(result)
}

/// Returns `true` if `descriptor` is a supported two-path BIP-389 multipath descriptor
/// (external and internal), `false` for a normal single-path descriptor.
///
/// 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<bool, Error> {
let secp = Secp256k1::new();
let (descriptor, _) = descriptor.into_wallet_descriptor(&secp, network.into())?;

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)]
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]
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());
}

#[test]
fn rejects_more_than_two_paths() {
assert!(is_multipath_descriptor(THREE_PATH, Network::Testnet).is_err());
}
}
83 changes: 83 additions & 0 deletions tests/integration/offline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
));
}
}
Loading