Skip to content

Commit 2692684

Browse files
committed
Merge #275: Multipath descriptor support (BIP 389)
da0b577 Update wallet/src/wallet/params.rs (Andreas Schjønhaug) 9e3adf2 feat(wallet): add multipath descriptor support for two-path descriptors (Andreas Schjønhaug) Pull request description: ### Description #### Key Features: - New API: `Wallet::create_multipath(descriptor)` following the same pattern as `create()` and `create_single()` - [BIP 389](https://github.com/bitcoin/bips/blob/master/bip-0389.mediawiki) compliance with exactly 2-path validation (receive and change) - Robust validation with clear error messages - Pattern consistency with existing wallet creation methods #### Usage Example: ```rust let multipath_desc = "wpkh([9a6a2580/84'/1'/0']tpub.../‹0;1›/*)"; let wallet = Wallet::create_multipath(multipath_desc) .network(Network::Testnet) .create_wallet_no_persist()?; // Automatically creates separate receive and change descriptors let receive_addr = wallet.peek_address(KeychainKind::External, 0); // Uses path /0/* let change_addr = wallet.peek_address(KeychainKind::Internal, 0); // Uses path /1/* ``` ### Notes to the reviewers #### Design Decisions: 1. Pattern Consistency: Uses `make_multipath_descriptor_to_extract()` helper following the same pattern as existing `make_descriptor_to_extract()` function 2. Lazy Evaluation: Descriptor parsing only happens when needed during wallet creation, not during parameter setup - this maintains performance and follows Rust's lazy evaluation patterns 3. Strict Validation: Only allows exactly 2-path multipath descriptors to ensure proper receive/change separation 4. API Consistency: The `create_multipath()` method returns `CreateParams` just like `create()` and `create_single()`, maintaining the fluent builder pattern #### Implementation Notes: - The function calls `make_multipath_descriptor_to_extract()` twice (once for receive, once for change) which is intentional and follows the established pattern of lazy evaluation - Validation occurs in the descriptor extraction closures, providing clear error messages when descriptors are actually processed - All existing functionality remains unchanged - this is a purely additive feature ### Changelog notice Added: - `Wallet::create_multipath()` method for creating wallets from BIP 389 multipath descriptors - `CreateParams::new_multipath()` for multipath descriptor parameter creation - Support for 2-path multipath descriptors with automatic receive/change separation - Enhanced validation for multipath descriptors with descriptive error messages ### Checklists #### All Submissions: * [x] I've signed all my commits * [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md) * [x] I ran `just p` before pushing #### New Features: * [x] I've added tests for the new feature * [x] I've added docs for the new feature #### Bugfixes: * [ ] This pull request breaks the existing API * [ ] I've added tests to reproduce the issue which are now passing * [x] I'm linking the issue being fixed by this PR Closes #11 ACKs for top commit: notmandatory: ACK da0b577 Tree-SHA512: 5d714e8f44a5ba1c3d956a5b2d93abba363ca1eb965dcc6d9492f1fef2239a10b560929af2bba221a49cb97e611253f2f1be5f41441122047f361848ec4e13f1
2 parents 27899da + da0b577 commit 2692684

File tree

4 files changed

+161
-4
lines changed

4 files changed

+161
-4
lines changed

wallet/src/descriptor/error.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ pub enum Error {
2121
InvalidDescriptorChecksum,
2222
/// The descriptor contains hardened derivation steps on public extended keys
2323
HardenedDerivationXpub,
24-
/// The descriptor contains multipath keys
24+
/// The descriptor contains multipath keys with an invalid number of paths (must have exactly 2
25+
/// paths for receive and change)
2526
MultiPath,
2627
/// Error thrown while working with [`keys`](crate::keys)
2728
Key(crate::keys::KeyError),
@@ -68,7 +69,7 @@ impl fmt::Display for Error {
6869
),
6970
Self::MultiPath => write!(
7071
f,
71-
"The descriptor contains multipath keys, which are not supported yet"
72+
"The descriptor contains multipath keys with invalid number of paths (must have exactly 2 paths for receive and change)"
7273
),
7374
Self::Key(err) => write!(f, "Key error: {}", err),
7475
Self::Policy(err) => write!(f, "Policy error: {}", err),

wallet/src/descriptor/mod.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
//! This module contains generic utilities to work with descriptors, plus some re-exported types
1515
//! from [`miniscript`].
1616
17+
use crate::alloc::string::ToString;
1718
use crate::collections::BTreeMap;
1819
use alloc::string::String;
1920
use alloc::vec::Vec;
@@ -312,7 +313,11 @@ pub(crate) fn check_wallet_descriptor(
312313
}
313314

314315
if descriptor.is_multipath() {
315-
return Err(DescriptorError::MultiPath);
316+
return Err(DescriptorError::Miniscript(
317+
miniscript::Error::BadDescriptor(
318+
"`check_wallet_descriptor` must not contain multipath keys".to_string(),
319+
),
320+
));
316321
}
317322

318323
// Run miniscript's sanity check, which will look for duplicated keys and other potential
@@ -875,13 +880,19 @@ mod test {
875880

876881
assert_matches!(result, Err(DescriptorError::HardenedDerivationXpub));
877882

883+
// Any multipath descriptor should fail
878884
let descriptor = "wpkh(tpubD6NzVbkrYhZ4XHndKkuB8FifXm8r5FQHwrN6oZuWCz13qb93rtgKvD4PQsqC4HP4yhV3tA2fqr2RbY5mNXfM7RxXUoeABoDtsFUq2zJq6YK/<0;1>/*)";
879885
let (descriptor, _) = descriptor
880886
.into_wallet_descriptor(&secp, Network::Testnet)
881887
.expect("must parse");
882888
let result = check_wallet_descriptor(&descriptor);
883889

884-
assert_matches!(result, Err(DescriptorError::MultiPath));
890+
assert_matches!(
891+
result,
892+
Err(DescriptorError::Miniscript(
893+
miniscript::Error::BadDescriptor(_)
894+
))
895+
);
885896

886897
// repeated pubkeys
887898
let descriptor = "wsh(multi(2,tpubD6NzVbkrYhZ4XHndKkuB8FifXm8r5FQHwrN6oZuWCz13qb93rtgKvD4PQsqC4HP4yhV3tA2fqr2RbY5mNXfM7RxXUoeABoDtsFUq2zJq6YK/0/*,tpubD6NzVbkrYhZ4XHndKkuB8FifXm8r5FQHwrN6oZuWCz13qb93rtgKvD4PQsqC4HP4yhV3tA2fqr2RbY5mNXfM7RxXUoeABoDtsFUq2zJq6YK/0/*))";

wallet/src/wallet/mod.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,45 @@ impl Wallet {
402402
CreateParams::new(descriptor, change_descriptor)
403403
}
404404

405+
/// Build a new [`Wallet`] from a two-path descriptor.
406+
///
407+
/// This function parses a multipath descriptor with exactly 2 paths and creates a wallet
408+
/// using the existing receive and change wallet creation logic.
409+
///
410+
/// Multipath descriptors follow [BIP 389] and allow defining both receive and change
411+
/// derivation paths in a single descriptor using the `<0;1>` syntax.
412+
///
413+
/// If you have previously created a wallet, use [`load`](Self::load) instead.
414+
///
415+
/// # Errors
416+
/// Returns an error if the descriptor is invalid or not a 2-path multipath descriptor.
417+
///
418+
/// # Synopsis
419+
///
420+
/// ```rust
421+
/// # use bdk_wallet::Wallet;
422+
/// # use bitcoin::Network;
423+
/// # use bdk_wallet::KeychainKind;
424+
/// # const TWO_PATH_DESC: &str = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1>/*)";
425+
/// let wallet = Wallet::create_from_two_path_descriptor(TWO_PATH_DESC)
426+
/// .network(Network::Testnet)
427+
/// .create_wallet_no_persist()
428+
/// .unwrap();
429+
///
430+
/// // The multipath descriptor automatically creates separate receive and change descriptors
431+
/// let receive_addr = wallet.peek_address(KeychainKind::External, 0); // Uses path /0/*
432+
/// let change_addr = wallet.peek_address(KeychainKind::Internal, 0); // Uses path /1/*
433+
/// assert_ne!(receive_addr.address, change_addr.address);
434+
/// ```
435+
///
436+
/// [BIP 389]: https://github.com/bitcoin/bips/blob/master/bip-0389.mediawiki
437+
pub fn create_from_two_path_descriptor<D>(two_path_descriptor: D) -> CreateParams
438+
where
439+
D: IntoWalletDescriptor + Send + Clone + 'static,
440+
{
441+
CreateParams::new_two_path(two_path_descriptor)
442+
}
443+
405444
/// Create a new [`Wallet`] with given `params`.
406445
///
407446
/// Refer to [`Wallet::create`] for more.
@@ -2765,4 +2804,59 @@ mod test {
27652804

27662805
assert_eq!(expected, received);
27672806
}
2807+
2808+
#[test]
2809+
fn test_create_two_path_wallet() {
2810+
let two_path_descriptor = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1>/*)";
2811+
2812+
// Test successful creation of a two-path wallet
2813+
let params = Wallet::create_from_two_path_descriptor(two_path_descriptor);
2814+
let wallet = params.network(Network::Testnet).create_wallet_no_persist();
2815+
assert!(wallet.is_ok());
2816+
2817+
let wallet = wallet.unwrap();
2818+
2819+
// Verify that the wallet has both external and internal keychains
2820+
let keychains: Vec<_> = wallet.keychains().collect();
2821+
assert_eq!(keychains.len(), 2);
2822+
2823+
// Verify that the descriptors are different (receive vs change)
2824+
let external_desc = keychains
2825+
.iter()
2826+
.find(|(k, _)| *k == KeychainKind::External)
2827+
.unwrap()
2828+
.1;
2829+
let internal_desc = keychains
2830+
.iter()
2831+
.find(|(k, _)| *k == KeychainKind::Internal)
2832+
.unwrap()
2833+
.1;
2834+
assert_ne!(external_desc.to_string(), internal_desc.to_string());
2835+
2836+
// Verify that addresses can be generated
2837+
let external_addr = wallet.peek_address(KeychainKind::External, 0);
2838+
let internal_addr = wallet.peek_address(KeychainKind::Internal, 0);
2839+
assert_ne!(external_addr.address, internal_addr.address);
2840+
}
2841+
2842+
#[test]
2843+
fn test_create_two_path_wallet_invalid_descriptor() {
2844+
// Test with invalid single-path descriptor
2845+
let single_path_descriptor = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/0/*)";
2846+
let params = Wallet::create_from_two_path_descriptor(single_path_descriptor);
2847+
let wallet = params.network(Network::Testnet).create_wallet_no_persist();
2848+
assert!(matches!(wallet, Err(DescriptorError::MultiPath)));
2849+
2850+
// Test with invalid 3-path multipath descriptor
2851+
let three_path_descriptor = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1;2>/*)";
2852+
let params = Wallet::create_from_two_path_descriptor(three_path_descriptor);
2853+
let wallet = params.network(Network::Testnet).create_wallet_no_persist();
2854+
assert!(matches!(wallet, Err(DescriptorError::MultiPath)));
2855+
2856+
// Test with completely invalid descriptor
2857+
let invalid_descriptor = "invalid_descriptor";
2858+
let params = Wallet::create_from_two_path_descriptor(invalid_descriptor);
2859+
let wallet = params.network(Network::Testnet).create_wallet_no_persist();
2860+
assert!(wallet.is_err());
2861+
}
27682862
}

wallet/src/wallet/params.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,32 @@ use crate::{
1212

1313
use super::{ChangeSet, LoadError, PersistedWallet};
1414

15+
fn make_two_path_descriptor_to_extract<D>(
16+
two_path_descriptor: D,
17+
index: usize,
18+
) -> DescriptorToExtract
19+
where
20+
D: IntoWalletDescriptor + Send + 'static,
21+
{
22+
Box::new(move |secp, network| {
23+
let (desc, keymap) = two_path_descriptor.into_wallet_descriptor(secp, network)?;
24+
25+
if !desc.is_multipath() {
26+
return Err(DescriptorError::MultiPath);
27+
}
28+
29+
let descriptors = desc
30+
.into_single_descriptors()
31+
.map_err(DescriptorError::Miniscript)?;
32+
33+
if descriptors.len() != 2 {
34+
return Err(DescriptorError::MultiPath);
35+
}
36+
37+
Ok((descriptors[index].clone(), keymap))
38+
})
39+
}
40+
1541
/// This atrocity is to avoid having type parameters on [`CreateParams`] and [`LoadParams`].
1642
///
1743
/// The better option would be to do `Box<dyn IntoWalletDescriptor>`, but we cannot due to Rust's
@@ -88,6 +114,31 @@ impl CreateParams {
88114
}
89115
}
90116

117+
/// Construct parameters with a two-path descriptor that will be parsed into receive and change
118+
/// descriptors.
119+
///
120+
/// This function parses a two-path descriptor (receive and change) and creates parameters
121+
/// using the existing receive and change wallet creation logic.
122+
///
123+
/// Default values:
124+
/// * `network` = [`Network::Bitcoin`]
125+
/// * `genesis_hash` = `None`
126+
/// * `lookahead` = [`DEFAULT_LOOKAHEAD`]
127+
pub fn new_two_path<D: IntoWalletDescriptor + Send + Clone + 'static>(
128+
two_path_descriptor: D,
129+
) -> Self {
130+
Self {
131+
descriptor: make_two_path_descriptor_to_extract(two_path_descriptor.clone(), 0),
132+
descriptor_keymap: KeyMap::default(),
133+
change_descriptor: Some(make_two_path_descriptor_to_extract(two_path_descriptor, 1)),
134+
change_descriptor_keymap: KeyMap::default(),
135+
network: Network::Bitcoin,
136+
genesis_hash: None,
137+
lookahead: DEFAULT_LOOKAHEAD,
138+
use_spk_cache: false,
139+
}
140+
}
141+
91142
/// Extend the given `keychain`'s `keymap`.
92143
pub fn keymap(mut self, keychain: KeychainKind, keymap: KeyMap) -> Self {
93144
match keychain {

0 commit comments

Comments
 (0)