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
8 changes: 8 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 Expand Up @@ -46,6 +49,11 @@ pub enum BDKCliError {
#[error("LocalChain error: {0}")]
LocalChainError(#[from] bdk_wallet::chain::local_chain::ApplyHeaderError),

#[error(
"The internal descriptor cannot be a multipath descriptor. Provide it as the external descriptor instead."
)]
MultipathInternalDescriptor,

#[error("Miniscript error: {0}")]
MiniscriptError(#[from] bdk_wallet::miniscript::Error),

Expand Down
3 changes: 3 additions & 0 deletions src/handlers/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use crate::handlers::Init;
use crate::handlers::{AppCommand, AppContext};
#[cfg(any(feature = "sqlite", feature = "redb"))]
use crate::persister::DatabaseType;
use crate::utils::descriptors::validate_descriptor_pair;
use crate::utils::types::{StatusResult, WalletsListResult};
use bdk_wallet::bitcoin::Network;
use clap::Args;
Expand Down Expand Up @@ -44,6 +45,8 @@ impl AppCommand<AppContext<Init>> for SaveConfigCommand {
let ext_descriptor = self.wallet_opts.ext_descriptor.clone();
let int_descriptor = self.wallet_opts.int_descriptor.clone();

validate_descriptor_pair(&ext_descriptor, int_descriptor.as_deref(), ctx.network)?;

if ext_descriptor.contains("xprv") || ext_descriptor.contains("tprv") {
eprintln!(
"WARNING: Your external descriptor contains PRIVATE KEYS.
Expand Down
1 change: 0 additions & 1 deletion src/handlers/payjoin/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,6 @@ mod tests {
use std::time::{SystemTime, UNIX_EPOCH};

use payjoin::HpkeKeyPair;
use payjoin::persist::SessionPersister as _;
use payjoin::receive::v2::SessionOutcome as ReceiverSessionOutcome;
use payjoin::send::v2::SessionOutcome as SenderSessionOutcome;

Expand Down
69 changes: 40 additions & 29 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::validate_descriptor_pair;
use bdk_wallet::Wallet;
use bdk_wallet::bitcoin::Network;
#[cfg(any(feature = "sqlite", feature = "redb"))]
Expand Down Expand Up @@ -68,14 +69,21 @@ 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()));
let ext_is_multipath =
validate_descriptor_pair(&ext_descriptor, int_descriptor.as_deref(), network)?;

if int_descriptor.is_some() {
wallet_load_params =
wallet_load_params.descriptor(KeychainKind::Internal, int_descriptor.clone());
}
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 +93,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 +116,17 @@ 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 =
validate_descriptor_pair(&ext_descriptor, int_descriptor.as_deref(), network)?;

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)
}
64 changes: 64 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,65 @@ pub fn generate_descriptor_from_mnemonic(
result.mnemonic = Some(mnemonic_str.to_string());
Ok(result)
}

/// Returns the number of derivation paths in `descriptor`.
///
/// Errors if the descriptor is unparseable or its keys don't match `network`.
fn descriptor_path_count(descriptor: &str, network: Network) -> Result<usize, Error> {
let secp = Secp256k1::new();
let (descriptor, _) = descriptor.into_wallet_descriptor(&secp, network.into())?;
Ok(descriptor.into_single_descriptors()?.len())
}

/// Validates the external/internal descriptor pair, returning `true` if `ext_descriptor` is a
/// supported two-path BIP-389 multipath descriptor.
///
/// Errors if either descriptor is unparseable or doesn't match `network`, if `ext_descriptor` is
/// a multipath descriptor with a number of paths other than two (only external/internal two-path
/// multipath is supported), if a multipath `ext_descriptor` is paired with a separate
/// `int_descriptor`, or if `int_descriptor` is itself a multipath descriptor.
pub fn validate_descriptor_pair(
ext_descriptor: &str,
int_descriptor: Option<&str>,
network: Network,
) -> Result<bool, Error> {
let ext_paths = descriptor_path_count(ext_descriptor, network)?;
if ext_paths > 2 {
return Err(Error::Generic(format!(
"Unsupported multipath descriptor: expected exactly 2 paths (external/internal), found {ext_paths}."
)));
}
let ext_is_multipath = ext_paths == 2;

if let Some(int_descriptor) = int_descriptor {
if ext_is_multipath {
return Err(Error::AmbiguousDescriptors);
}
if descriptor_path_count(int_descriptor, network)? > 1 {
return Err(Error::MultipathInternalDescriptor);
}
}

Ok(ext_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 rejects_multipath_with_internal_descriptor() {
assert!(matches!(
validate_descriptor_pair(MULTIPATH, Some(SINGLE), Network::Testnet),
Err(Error::AmbiguousDescriptors)
));
}

#[test]
fn accepts_single_path_with_internal_descriptor() {
assert!(!validate_descriptor_pair(SINGLE, Some(SINGLE), Network::Testnet).unwrap());
}
}
84 changes: 84 additions & 0 deletions tests/integration/offline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,3 +349,87 @@ 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_rejected_at_config_time() {
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()
.failure()
.stderr(predicate::str::contains(
"multipath descriptor and a separate internal descriptor",
));

// Nothing was written, so the wallet does not exist.
cli.wallet_cmd(&["--wallet", "multipath_wallet", "new_address"])
.assert()
.failure();
}
}
Loading