Skip to content

Commit 5893601

Browse files
committed
Multipath descriptor support
1 parent b925402 commit 5893601

File tree

4 files changed

+199
-2
lines changed

4 files changed

+199
-2
lines changed

wallet/src/descriptor/error.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ impl fmt::Display for Error {
6868
),
6969
Self::MultiPath => write!(
7070
f,
71-
"The descriptor contains multipath keys, which are not supported yet"
71+
"The descriptor contains multipath keys with invalid number of paths (must have exactly 2 paths for receive and change)"
7272
),
7373
Self::Key(err) => write!(f, "Key error: {}", err),
7474
Self::Policy(err) => write!(f, "Policy error: {}", err),

wallet/src/descriptor/mod.rs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,14 @@ pub(crate) fn check_wallet_descriptor(
312312
}
313313

314314
if descriptor.is_multipath() {
315-
return Err(DescriptorError::MultiPath);
315+
// Only allow multipath descriptors with exactly 2 paths (receive and change)
316+
let paths = descriptor
317+
.clone()
318+
.into_single_descriptors()
319+
.map_err(|_| DescriptorError::MultiPath)?;
320+
if paths.len() != 2 {
321+
return Err(DescriptorError::MultiPath);
322+
}
316323
}
317324

318325
// Run miniscript's sanity check, which will look for duplicated keys and other potential
@@ -875,12 +882,22 @@ mod test {
875882

876883
assert_matches!(result, Err(DescriptorError::HardenedDerivationXpub));
877884

885+
// Valid 2-path multipath descriptor should now pass
878886
let descriptor = "wpkh(tpubD6NzVbkrYhZ4XHndKkuB8FifXm8r5FQHwrN6oZuWCz13qb93rtgKvD4PQsqC4HP4yhV3tA2fqr2RbY5mNXfM7RxXUoeABoDtsFUq2zJq6YK/<0;1>/*)";
879887
let (descriptor, _) = descriptor
880888
.into_wallet_descriptor(&secp, Network::Testnet)
881889
.expect("must parse");
882890
let result = check_wallet_descriptor(&descriptor);
883891

892+
assert!(result.is_ok());
893+
894+
// Invalid 3-path multipath descriptor should fail
895+
let descriptor = "wpkh(tpubD6NzVbkrYhZ4XHndKkuB8FifXm8r5FQHwrN6oZuWCz13qb93rtgKvD4PQsqC4HP4yhV3tA2fqr2RbY5mNXfM7RxXUoeABoDtsFUq2zJq6YK/<0;1;2>/*)";
896+
let (descriptor, _) = descriptor
897+
.into_wallet_descriptor(&secp, Network::Testnet)
898+
.expect("must parse");
899+
let result = check_wallet_descriptor(&descriptor);
900+
884901
assert_matches!(result, Err(DescriptorError::MultiPath));
885902

886903
// repeated pubkeys
@@ -961,4 +978,36 @@ mod test {
961978

962979
Ok(())
963980
}
981+
982+
#[test]
983+
fn test_multipath_descriptor_validation() {
984+
let secp = Secp256k1::new();
985+
986+
// Test that 2-path multipath descriptor passes validation
987+
let descriptor_str = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1>/*)";
988+
let (descriptor, _) = descriptor_str
989+
.into_wallet_descriptor(&secp, Network::Testnet)
990+
.expect("should parse 2-path multipath descriptor");
991+
992+
let result = check_wallet_descriptor(&descriptor);
993+
assert!(result.is_ok());
994+
995+
// Test that 1-path descriptor (non-multipath) still works
996+
let descriptor_str = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/0/*)";
997+
let (descriptor, _) = descriptor_str
998+
.into_wallet_descriptor(&secp, Network::Testnet)
999+
.expect("should parse single-path descriptor");
1000+
1001+
let result = check_wallet_descriptor(&descriptor);
1002+
assert!(result.is_ok());
1003+
1004+
// Test that 3-path multipath descriptor fails validation
1005+
let descriptor_str = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1;2>/*)";
1006+
let (descriptor, _) = descriptor_str
1007+
.into_wallet_descriptor(&secp, Network::Testnet)
1008+
.expect("should parse 3-path multipath descriptor");
1009+
1010+
let result = check_wallet_descriptor(&descriptor);
1011+
assert!(matches!(result, Err(DescriptorError::MultiPath)));
1012+
}
9641013
}

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 multipath descriptor.
406+
///
407+
/// This function parses a multipath descriptor with exactly 2 paths (receive and change)
408+
/// and creates a wallet 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 MULTIPATH_DESC: &str = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1>/*)";
425+
/// let wallet = Wallet::create_multipath(MULTIPATH_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_multipath<D>(multipath_descriptor: D) -> CreateParams
438+
where
439+
D: IntoWalletDescriptor + Send + Clone + 'static,
440+
{
441+
CreateParams::new_multipath(multipath_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_multipath_wallet() {
2810+
let multipath_descriptor = "wpkh([9a6a2580/84'/1'/0']tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks/<0;1>/*)";
2811+
2812+
// Test successful creation of multipath wallet
2813+
let params = Wallet::create_multipath(multipath_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_multipath_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_multipath(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_multipath(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_multipath(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: 54 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_multipath_descriptor_to_extract<D>(
16+
multipath_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) = multipath_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::MultiPath)?;
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,34 @@ impl CreateParams {
88114
}
89115
}
90116

117+
/// Construct parameters with a multipath descriptor that will be parsed into receive and change
118+
/// descriptors.
119+
///
120+
/// This function parses a multipath descriptor with exactly 2 paths (receive and change)
121+
/// and creates parameters 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_multipath<D: IntoWalletDescriptor + Send + Clone + 'static>(
128+
multipath_descriptor: D,
129+
) -> Self {
130+
Self {
131+
descriptor: make_multipath_descriptor_to_extract(multipath_descriptor.clone(), 0),
132+
descriptor_keymap: KeyMap::default(),
133+
change_descriptor: Some(make_multipath_descriptor_to_extract(
134+
multipath_descriptor,
135+
1,
136+
)),
137+
change_descriptor_keymap: KeyMap::default(),
138+
network: Network::Bitcoin,
139+
genesis_hash: None,
140+
lookahead: DEFAULT_LOOKAHEAD,
141+
use_spk_cache: false,
142+
}
143+
}
144+
91145
/// Extend the given `keychain`'s `keymap`.
92146
pub fn keymap(mut self, keychain: KeychainKind, keymap: KeyMap) -> Self {
93147
match keychain {

0 commit comments

Comments
 (0)