From a55a2a8913750df509a4a3f4ea88754cf61fcff4 Mon Sep 17 00:00:00 2001 From: vkprogrammer-001 Date: Wed, 9 Apr 2025 19:05:38 +0530 Subject: [PATCH 1/4] Add Caravan wallet format support --- src/wallet/export.rs | 508 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 504 insertions(+), 4 deletions(-) diff --git a/src/wallet/export.rs b/src/wallet/export.rs index c07bdf48..62ba2131 100644 --- a/src/wallet/export.rs +++ b/src/wallet/export.rs @@ -11,11 +11,14 @@ //! Wallet export //! -//! This modules implements the wallet export format used by [FullyNoded](https://github.com/Fonta1n3/FullyNoded/blob/10b7808c8b929b171cca537fb50522d015168ac9/Docs/Wallets/Wallet-Export-Spec.md). +//! This modules implements wallet export formats for different Bitcoin wallet applications: +//! +//! 1. [FullyNoded](https://github.com/Fonta1n3/FullyNoded/blob/10b7808c8b929b171cca537fb50522d015168ac9/Docs/Wallets/Wallet-Export-Spec.md) +//! 2. [Caravan](https://github.com/unchained-capital/caravan) //! //! ## Examples //! -//! ### Import from JSON +//! ### Import from FullyNoded JSON //! //! ``` //! # use std::str::FromStr; @@ -38,7 +41,7 @@ //! # Ok::<_, Box>(()) //! ``` //! -//! ### Export a `Wallet` +//! ### Export a `Wallet` to FullyNoded format //! ``` //! # use bitcoin::*; //! # use bdk_wallet::export::*; @@ -54,8 +57,68 @@ //! println!("Exported: {}", export.to_string()); //! # Ok::<_, Box>(()) //! ``` +//! +//! ### Export a `Wallet` to Caravan format +//! ``` +//! # use bitcoin::*; +//! # use bdk_wallet::export::*; +//! # use bdk_wallet::*; +//! let wallet = Wallet::create( +//! "wsh(sortedmulti(2,[73756c7f/48h/0h/0h/2h]tpubDCKxNyM3bLgbEX13Mcd8mYxbVg9ajDkWXMh29hMWBurKfVmBfWAM96QVP3zaUcN51HvkZ3ar4VwP82kC8JZhhux8vFQoJintSpVBwpFvyU3/0/*,[f9f62194/48h/0h/0h/2h]tpubDDp3ZSH1yCwusRppH7zgSxq2t1VEUyXSeEp8E5aFS8m43MknUjiF1bSLo3CGWAxbDyhF1XowA5ukPzyJZjznYk3kYi6oe7QxtX2euvKWsk4/0/*))", +//! "wsh(sortedmulti(2,[73756c7f/48h/0h/0h/2h]tpubDCKxNyM3bLgbEX13Mcd8mYxbVg9ajDkWXMh29hMWBurKfVmBfWAM96QVP3zaUcN51HvkZ3ar4VwP82kC8JZhhux8vFQoJintSpVBwpFvyU3/1/*,[f9f62194/48h/0h/0h/2h]tpubDDp3ZSH1yCwusRppH7zgSxq2t1VEUyXSeEp8E5aFS8m43MknUjiF1bSLo3CGWAxbDyhF1XowA5ukPzyJZjznYk3kYi6oe7QxtX2euvKWsk4/1/*))", +//! ) +//! .network(Network::Testnet) +//! .create_wallet_no_persist()?; +//! let export = CaravanExport::export_wallet(&wallet, "My Multisig Wallet").unwrap(); +//! +//! println!("Exported: {}", export.to_string()); +//! # Ok::<_, Box>(()) +//! ``` +//! +//! ### Import from Caravan format +//! ``` +//! # use std::str::FromStr; +//! # use bitcoin::*; +//! # use bdk_wallet::export::*; +//! # use bdk_wallet::*; +//! let import = r#"{ +//! "name": "My Multisig Wallet", +//! "addressType": "P2WSH", +//! "network": "mainnet", +//! "client": { +//! "type": "public" +//! }, +//! "quorum": { +//! "requiredSigners": 2, +//! "totalSigners": 2 +//! }, +//! "extendedPublicKeys": [ +//! { +//! "name": "key1", +//! "bip32Path": "m/48'/0'/0'/2'", +//! "xpub": "tpubDCKxNyM3bLgbEX13Mcd8mYxbVg9ajDkWXMh29hMWBurKfVmBfWAM96QVP3zaUcN51HvkZ3ar4VwP82kC8JZhhux8vFQoJintSpVBwpFvyU3", +//! "xfp": "73756c7f" +//! }, +//! { +//! "name": "key2", +//! "bip32Path": "m/48'/0'/0'/2'", +//! "xpub": "tpubDDp3ZSH1yCwusRppH7zgSxq2t1VEUyXSeEp8E5aFS8m43MknUjiF1bSLo3CGWAxbDyhF1XowA5ukPzyJZjznYk3kYi6oe7QxtX2euvKWsk4", +//! "xfp": "f9f62194" +//! } +//! ], +//! "startingAddressIndex": 0 +//! }"#; +//! +//! let import = CaravanExport::from_str(import)?; +//! let (external, internal) = import.to_descriptors()?; +//! # assert!(external.contains("sortedmulti")); +//! # assert!(internal.contains("sortedmulti")); +//! # Ok::<_, Box>(()) +//! ``` use alloc::string::String; +use alloc::string::ToString; +use alloc::vec::Vec; use core::fmt; use core::str::FromStr; use serde::{Deserialize, Serialize}; @@ -70,7 +133,7 @@ use crate::wallet::Wallet; #[deprecated(since = "0.18.0", note = "Please use [`FullyNodedExport`] instead")] pub type WalletExport = FullyNodedExport; -/// Structure that contains the export of a wallet +/// Structure that contains the export of a wallet in FullyNoded format /// /// For a usage example see [this module](crate::wallet::export)'s documentation. #[derive(Debug, Serialize, Deserialize)] @@ -211,6 +274,281 @@ impl FullyNodedExport { } } +/// ExtendedPublicKey structure for Caravan wallet format +#[derive(Debug, Serialize, Deserialize)] +pub struct CaravanExtendedPublicKey { + /// Name of the signer + pub name: String, + /// BIP32 derivation path + #[serde(rename = "bip32Path")] + pub bip32_path: String, + /// Extended public key + pub xpub: String, + /// Fingerprint of the master key + pub xfp: String, +} + +/// Structure that contains the export of a wallet in Caravan wallet format +/// +/// Caravan is a Bitcoin multisig coordinator by Unchained Capital. +/// This format supports P2SH, P2WSH, and P2SH-P2WSH multisig wallet types. +/// +/// For a usage example see [this module](crate::wallet::export)'s documentation. +#[derive(Debug, Serialize, Deserialize)] +pub struct CaravanExport { + /// Name of the wallet + pub name: String, + /// Address type (P2SH, P2WSH, P2SH-P2WSH) + #[serde(rename = "addressType")] + pub address_type: String, + /// Network (mainnet, testnet) + pub network: String, + /// Client configuration + pub client: serde_json::Value, + /// Quorum information + pub quorum: CaravanQuorum, + /// List of extended public keys + #[serde(rename = "extendedPublicKeys")] + pub extended_public_keys: Vec, + /// Starting address index + #[serde(rename = "startingAddressIndex")] + pub starting_address_index: u32, +} + +/// Quorum information for Caravan wallet format +#[derive(Debug, Serialize, Deserialize)] +pub struct CaravanQuorum { + /// Number of required signers + #[serde(rename = "requiredSigners")] + pub required_signers: u32, + /// Total number of signers + #[serde(rename = "totalSigners")] + pub total_signers: u32, +} + +impl fmt::Display for CaravanExport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", serde_json::to_string_pretty(self).unwrap()) + } +} + +impl FromStr for CaravanExport { + type Err = serde_json::Error; + + fn from_str(s: &str) -> Result { + serde_json::from_str(s) + } +} + +impl CaravanExport { + /// Export a wallet to Caravan format + /// + /// This function returns an error if it determines that the `wallet`'s descriptor(s) are not + /// supported by Caravan or if the descriptor is not a multisig descriptor. + /// + /// Caravan supports P2SH, P2WSH, and P2SH-P2WSH multisig wallets. + pub fn export_wallet(wallet: &Wallet, name: &str) -> Result { + // Get the descriptor and extract information + let descriptor_str = wallet + .public_descriptor(KeychainKind::External) + .to_string_with_secret( + &wallet + .get_signers(KeychainKind::External) + .as_key_map(wallet.secp_ctx()), + ); + let descriptor_str = remove_checksum(descriptor_str); + + // Parse the descriptor to extract required information + let descriptor = + Descriptor::::from_str(&descriptor_str).map_err(|_| "Invalid descriptor")?; + + // Determine the address type and multisig information + let (address_type, quorum, keys) = Self::extract_descriptor_info(&descriptor)?; + + // Network + let network = match wallet.network() { + bitcoin::Network::Bitcoin => "mainnet", + _ => "testnet", + }; + + // Create the Caravan export + let export = CaravanExport { + name: name.into(), + address_type, + network: network.into(), + client: serde_json::json!({"type": "public"}), + quorum, + extended_public_keys: keys, + starting_address_index: 0, + }; + + Ok(export) + } + + /// Extract information from a descriptor + fn extract_descriptor_info( + descriptor: &Descriptor, + ) -> Result<(String, CaravanQuorum, Vec), &'static str> { + // Extract address type, quorum, and keys based on descriptor type + match descriptor { + Descriptor::Sh(sh) => { + match sh.as_inner() { + ShInner::Wsh(wsh) => { + // P2SH-P2WSH multisig + match wsh.as_inner() { + WshInner::SortedMulti(multi) => { + let keys = Self::extract_xpubs_from_multi(multi)?; + let quorum = CaravanQuorum { + required_signers: multi.k() as u32, + total_signers: multi.pks().len() as u32, + }; + Ok(("P2SH-P2WSH".into(), quorum, keys)) + } + _ => Err("Only sortedmulti is supported for P2SH-P2WSH in Caravan"), + } + } + ShInner::SortedMulti(multi) => { + // P2SH multisig + let keys = Self::extract_xpubs_from_multi(multi)?; + let quorum = CaravanQuorum { + required_signers: multi.k() as u32, + total_signers: multi.pks().len() as u32, + }; + Ok(("P2SH".into(), quorum, keys)) + } + _ => Err("Only sortedmulti is supported for P2SH in Caravan"), + } + } + Descriptor::Wsh(wsh) => { + match wsh.as_inner() { + WshInner::SortedMulti(multi) => { + // P2WSH multisig + let keys = Self::extract_xpubs_from_multi(multi)?; + let quorum = CaravanQuorum { + required_signers: multi.k() as u32, + total_signers: multi.pks().len() as u32, + }; + Ok(("P2WSH".into(), quorum, keys)) + } + _ => Err("Only sortedmulti is supported for P2WSH in Caravan"), + } + } + _ => { + Err("Only P2SH, P2WSH, or P2SH-P2WSH multisig descriptors are supported by Caravan") + } + } + } + + /// Extract xpubs and fingerprints from multi descriptor + fn extract_xpubs_from_multi( + multi: &miniscript::descriptor::SortedMultiVec, + ) -> Result, &'static str> { + let mut keys = Vec::new(); + + for (i, key) in multi.pks().iter().enumerate() { + // Parse the key string to extract origin fingerprint, path, and xpub + // Format example: [c258d2e4/48h/0h/0h/2h]xpub.../0/* + let key_str = key.clone(); + + // Check if the key has origin information + if !key_str.starts_with('[') { + return Err("Keys must include origin information for Caravan export"); + } + + // Extract origin fingerprint + let origin_end = key_str.find(']').ok_or("Invalid key format")?; + let origin = &key_str[1..origin_end]; + let parts: Vec<&str> = origin.split('/').collect(); + if parts.is_empty() { + return Err("Invalid key origin format"); + } + + let fingerprint = parts[0].to_string(); + + // Extract derivation path and convert 'h' to "'" + let path_parts: Vec = parts[1..] + .iter() + .map(|part| { + if part.ends_with('h') { + let p = &part[0..part.len() - 1]; + format!("{}'", p) + } else { + part.to_string() + } + }) + .collect(); + let path = format!("m/{}", path_parts.join("/")); + + // Extract xpub + let xpub_part = &key_str[origin_end + 1..]; + let xpub_end = xpub_part.find('/').unwrap_or(xpub_part.len()); + let xpub = xpub_part[..xpub_end].to_string(); + + keys.push(CaravanExtendedPublicKey { + name: format!("key{}", i + 1), + bip32_path: path, + xpub, + xfp: fingerprint, + }); + } + + Ok(keys) + } + + /// Import a wallet from Caravan format + pub fn to_descriptors(&self) -> Result<(String, String), &'static str> { + if self.extended_public_keys.is_empty() { + return Err("No extended public keys found"); + } + + // Build key expressions for the descriptor + let mut key_exprs = Vec::new(); + for key in &self.extended_public_keys { + // Remove 'm/' prefix from bip32Path if present + let path = if key.bip32_path.starts_with("m/") { + &key.bip32_path[2..] + } else { + &key.bip32_path + }; + + // Convert "'" to "h" in the path + let descriptor_path = path.replace("'", "h"); + + // Format key with origin fingerprint and path + let key_expr = format!("[{}/{}]{}/0/*", key.xfp, descriptor_path, key.xpub); + key_exprs.push(key_expr); + } + + // Build descriptor based on address type + let descriptor_prefix = match self.address_type.as_str() { + "P2SH" => "sh(sortedmulti(", + "P2WSH" => "wsh(sortedmulti(", + "P2SH-P2WSH" => "sh(wsh(sortedmulti(", + _ => return Err("Unsupported address type"), + }; + + let descriptor_suffix = match self.address_type.as_str() { + "P2SH" | "P2WSH" => "))", + "P2SH-P2WSH" => ")))", + _ => return Err("Unsupported address type"), + }; + + // Construct the external descriptor + let external_descriptor = format!( + "{}{},({})){}", + descriptor_prefix, + self.quorum.required_signers, + key_exprs.join(","), + descriptor_suffix + ); + + // Create change descriptor by replacing /0/* with /1/* + let change_descriptor = external_descriptor.replace("/0/*", "/1/*"); + + Ok((external_descriptor, change_descriptor)) + } +} + #[cfg(test)] mod test { use alloc::string::ToString; @@ -338,4 +676,166 @@ mod test { assert_eq!(export.blockheight, 5000); assert_eq!(export.label, "Test Label"); } + + #[test] + fn test_caravan_export_p2wsh() { + let descriptor = "wsh(sortedmulti(2,[119dbcab/48h/0h/0h/2h]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/0/*,[e650dc93/48h/0h/0h/2h]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/0/*))"; + let change_descriptor = "wsh(sortedmulti(2,[119dbcab/48h/0h/0h/2h]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1/*,[e650dc93/48h/0h/0h/2h]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1/*))"; + let network = Network::Bitcoin; + + let wallet = get_test_wallet(descriptor, change_descriptor, network); + let export = CaravanExport::export_wallet(&wallet, "Test P2WSH Wallet").unwrap(); + + // Check basic fields + assert_eq!(export.name, "Test P2WSH Wallet"); + assert_eq!(export.address_type, "P2WSH"); + assert_eq!(export.network, "mainnet"); + assert_eq!(export.quorum.required_signers, 2); + assert_eq!(export.quorum.total_signers, 2); + assert_eq!(export.starting_address_index, 0); + + // Check extended public keys + assert_eq!(export.extended_public_keys.len(), 2); + assert_eq!(export.extended_public_keys[0].xfp, "119dbcab"); + + // Use the path format with apostrophes in the test expectation + assert_eq!(export.extended_public_keys[0].bip32_path, "m/48'/0'/0'/2'"); + assert_eq!(export.extended_public_keys[1].xfp, "e650dc93"); + assert_eq!(export.extended_public_keys[1].bip32_path, "m/48'/0'/0'/2'"); + + // Test to_descriptors functionality + let (external, internal) = export.to_descriptors().unwrap(); + assert!(external.contains("wsh(sortedmulti(")); + assert!(internal.contains("/1/*")); + + // Test JSON serialization + let json = export.to_string(); + assert!(json.contains("\"name\":")); + assert!(json.contains("\"addressType\":")); + assert!(json.contains("\"extendedPublicKeys\":")); + } + + #[test] + fn test_caravan_export_p2sh() { + let descriptor = "sh(sortedmulti(2,[119dbcab/48h/0h/0h/1h]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/0/*,[e650dc93/48h/0h/0h/1h]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/0/*))"; + let change_descriptor = "sh(sortedmulti(2,[119dbcab/48h/0h/0h/1h]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1/*,[e650dc93/48h/0h/0h/1h]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1/*))"; + let network = Network::Bitcoin; + + let wallet = get_test_wallet(descriptor, change_descriptor, network); + let export = CaravanExport::export_wallet(&wallet, "Test P2SH Wallet").unwrap(); + + assert_eq!(export.address_type, "P2SH"); + assert_eq!(export.quorum.required_signers, 2); + assert_eq!(export.quorum.total_signers, 2); + } + + #[test] + fn test_caravan_export_p2sh_p2wsh() { + let descriptor = "sh(wsh(sortedmulti(2,[119dbcab/48h/0h/0h/3h]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/0/*,[e650dc93/48h/0h/0h/3h]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/0/*)))"; + let change_descriptor = "sh(wsh(sortedmulti(2,[119dbcab/48h/0h/0h/3h]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1/*,[e650dc93/48h/0h/0h/3h]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1/*)))"; + let network = Network::Bitcoin; + + let wallet = get_test_wallet(descriptor, change_descriptor, network); + let export = CaravanExport::export_wallet(&wallet, "Test P2SH-P2WSH Wallet").unwrap(); + + assert_eq!(export.address_type, "P2SH-P2WSH"); + assert_eq!(export.quorum.required_signers, 2); + assert_eq!(export.quorum.total_signers, 2); + } + + #[test] + fn test_network_detection_for_caravan() { + // Test the network detection logic directly + assert_eq!( + match bitcoin::Network::Bitcoin { + bitcoin::Network::Bitcoin => "mainnet", + _ => "testnet", + }, + "mainnet" + ); + + assert_eq!( + match bitcoin::Network::Testnet { + bitcoin::Network::Bitcoin => "mainnet", + _ => "testnet", + }, + "testnet" + ); + + assert_eq!( + match bitcoin::Network::Signet { + bitcoin::Network::Bitcoin => "mainnet", + _ => "testnet", + }, + "testnet" + ); + + assert_eq!( + match bitcoin::Network::Regtest { + bitcoin::Network::Bitcoin => "mainnet", + _ => "testnet", + }, + "testnet" + ); + + // This tests the exact same logic used in the CaravanExport::export_wallet method + let network_mapping = |network: bitcoin::Network| -> &'static str { + match network { + bitcoin::Network::Bitcoin => "mainnet", + _ => "testnet", + } + }; + + assert_eq!(network_mapping(bitcoin::Network::Bitcoin), "mainnet"); + assert_eq!(network_mapping(bitcoin::Network::Testnet), "testnet"); + } + + #[test] + fn test_caravan_import() { + let json = r#"{ + "name": "Test Wallet", + "addressType": "P2WSH", + "network": "mainnet", + "client": { + "type": "public" + }, + "quorum": { + "requiredSigners": 2, + "totalSigners": 3 + }, + "extendedPublicKeys": [ + { + "name": "key1", + "bip32Path": "m/48h/0h/0h/2h", + "xpub": "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL", + "xfp": "119dbcab" + }, + { + "name": "key2", + "bip32Path": "m/48h/0h/0h/2h", + "xpub": "xpub6FKY2Zpu9dFmKZwLkRwt6XK3gcQuJDCz7rBzSWRU4TsUfGgfLdBMK6nVztnz6oSQjSiy2muFnxT5hc4CtYJzr4cLZcmCVeiUxCRGeTqVMuQ", + "xfp": "e650dc93" + }, + { + "name": "key3", + "bip32Path": "m/48h/0h/0h/2h", + "xpub": "xpub6FPZdGBiQAu3FJjWAjeu6YBCCeUSnpm98y5tQU3AvBXRjQU8H2Su8QkcQZrAL8Wv8hy7G44JzBdNWvjXm1bdHhQDfg4JBzPQshqMfQLt1Bj", + "xfp": "bcc3df08" + } + ], + "startingAddressIndex": 0 + }"#; + + let import = CaravanExport::from_str(json).unwrap(); + let (external, internal) = import.to_descriptors().unwrap(); + + assert!(external.contains("wsh(sortedmulti(2,")); + assert_eq!(import.quorum.required_signers, 2); + assert_eq!(import.quorum.total_signers, 3); + assert_eq!(import.extended_public_keys.len(), 3); + + // Check that the change descriptor is correctly generated + assert!(internal.contains("/1/*")); + assert!(external.contains("/0/*")); + } } From e0dafd6273e818dc03e565225d352f4798f3f604 Mon Sep 17 00:00:00 2001 From: vkprogrammer-001 Date: Sun, 21 Sep 2025 18:50:55 +0530 Subject: [PATCH 2/4] Fix: Replace string manipulation with stronger Bitcoin types in Caravan export This commit addresses @ValuedMammal feedback by replacing extensive string manipulation with proper Bitcoin types throughout the Caravan wallet export functionality, making the code more type-safe and less error-prone. Key Changes: - Type-safe BIP32 handling: Updated CaravanExtendedPublicKey to use bitcoin::bip32::{DerivationPath, Fingerprint, Xpub} instead of strings. Added custom serde implementation that properly handles "m/" prefix for JSON compatibility while using proper types internally. - Enhanced descriptor parsing: Changed extract_xpubs_from_multi() to accept SortedMultiVec instead of SortedMultiVec, eliminating string parsing errors and making the code more robust. - Enum-based address types: Replaced string-based address type handling with CaravanAddressType enum (P2SH, P2WSH, P2SHWrappedP2WSH) for better type safety and validation. - Proper descriptor construction: Updated to_descriptors() to use Descriptor::new_sh_sortedmulti(), new_wsh_sortedmulti(), and new_sh_wsh_sortedmulti() construction methods instead of string building, ensuring standard format with checksums. - Network type conversion: Replaced string-based network handling with CaravanNetwork enum and From implementation using NetworkKind pattern. - Comprehensive validation: Enhanced test suite to verify that exported descriptors can create functional BDK wallets with proper address generation, ensuring practical usability. --- src/wallet/export.rs | 510 ++++++++++++++++++++++++++++++------------- src/wallet/mod.rs | 2 +- 2 files changed, 354 insertions(+), 158 deletions(-) diff --git a/src/wallet/export.rs b/src/wallet/export.rs index 62ba2131..ba783f62 100644 --- a/src/wallet/export.rs +++ b/src/wallet/export.rs @@ -111,19 +111,20 @@ //! //! let import = CaravanExport::from_str(import)?; //! let (external, internal) = import.to_descriptors()?; -//! # assert!(external.contains("sortedmulti")); -//! # assert!(internal.contains("sortedmulti")); +//! # assert_eq!(external, "wsh(sortedmulti(2,[73756c7f/48'/0'/0'/2']tpubDCKxNyM3bLgbEX13Mcd8mYxbVg9ajDkWXMh29hMWBurKfVmBfWAM96QVP3zaUcN51HvkZ3ar4VwP82kC8JZhhux8vFQoJintSpVBwpFvyU3/0/*,[f9f62194/48'/0'/0'/2']tpubDDp3ZSH1yCwusRppH7zgSxq2t1VEUyXSeEp8E5aFS8m43MknUjiF1bSLo3CGWAxbDyhF1XowA5ukPzyJZjznYk3kYi6oe7QxtX2euvKWsk4/0/*))#pgthjwtg"); +//! # assert_eq!(internal, "wsh(sortedmulti(2,[73756c7f/48'/0'/0'/2']tpubDCKxNyM3bLgbEX13Mcd8mYxbVg9ajDkWXMh29hMWBurKfVmBfWAM96QVP3zaUcN51HvkZ3ar4VwP82kC8JZhhux8vFQoJintSpVBwpFvyU3/1/*,[f9f62194/48'/0'/0'/2']tpubDDp3ZSH1yCwusRppH7zgSxq2t1VEUyXSeEp8E5aFS8m43MknUjiF1bSLo3CGWAxbDyhF1XowA5ukPzyJZjznYk3kYi6oe7QxtX2euvKWsk4/1/*))#cmcnua7a"); //! # Ok::<_, Box>(()) //! ``` use alloc::string::String; use alloc::string::ToString; use alloc::vec::Vec; +use bitcoin::bip32::{DerivationPath, Fingerprint, Xpub}; use core::fmt; use core::str::FromStr; use serde::{Deserialize, Serialize}; -use miniscript::descriptor::{ShInner, WshInner}; +use miniscript::descriptor::{DescriptorPublicKey, ShInner, WshInner}; use miniscript::{Descriptor, ScriptContext, Terminal}; use crate::types::KeychainKind; @@ -275,17 +276,154 @@ impl FullyNodedExport { } /// ExtendedPublicKey structure for Caravan wallet format -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub struct CaravanExtendedPublicKey { /// Name of the signer pub name: String, /// BIP32 derivation path - #[serde(rename = "bip32Path")] - pub bip32_path: String, + pub bip32_path: DerivationPath, /// Extended public key - pub xpub: String, + pub xpub: Xpub, /// Fingerprint of the master key - pub xfp: String, + pub xfp: Fingerprint, +} + +// Custom serde implementation to maintain JSON compatibility +impl Serialize for CaravanExtendedPublicKey { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut state = serializer.serialize_struct("CaravanExtendedPublicKey", 4)?; + state.serialize_field("name", &self.name)?; + // Add m/ prefix for JSON compatibility + let path_with_prefix = format!("m/{}", self.bip32_path); + state.serialize_field("bip32Path", &path_with_prefix)?; + state.serialize_field("xpub", &self.xpub.to_string())?; + state.serialize_field("xfp", &self.xfp.to_string())?; + state.end() + } +} + +impl<'de> Deserialize<'de> for CaravanExtendedPublicKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::{self, MapAccess, Visitor}; + + #[derive(Deserialize)] + #[serde(field_identifier, rename_all = "camelCase")] + enum Field { + Name, + Bip32Path, + Xpub, + Xfp, + } + + struct CaravanKeyVisitor; + + impl<'de> Visitor<'de> for CaravanKeyVisitor { + type Value = CaravanExtendedPublicKey; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("struct CaravanExtendedPublicKey") + } + + fn visit_map(self, mut map: V) -> Result + where + V: MapAccess<'de>, + { + let mut name = None; + let mut bip32_path = None; + let mut xpub = None; + let mut xfp = None; + + while let Some(key) = map.next_key()? { + match key { + Field::Name => { + if name.is_some() { + return Err(de::Error::duplicate_field("name")); + } + name = Some(map.next_value()?); + } + Field::Bip32Path => { + if bip32_path.is_some() { + return Err(de::Error::duplicate_field("bip32Path")); + } + let path_str: String = map.next_value()?; + // Strip m/ prefix if present for DerivationPath parsing + let cleaned_path = path_str.strip_prefix("m/").unwrap_or(&path_str); + bip32_path = Some( + DerivationPath::from_str(cleaned_path) + .map_err(de::Error::custom)?, + ); + } + Field::Xpub => { + if xpub.is_some() { + return Err(de::Error::duplicate_field("xpub")); + } + let xpub_str: String = map.next_value()?; + xpub = Some(Xpub::from_str(&xpub_str).map_err(de::Error::custom)?); + } + Field::Xfp => { + if xfp.is_some() { + return Err(de::Error::duplicate_field("xfp")); + } + let xfp_str: String = map.next_value()?; + xfp = Some(Fingerprint::from_str(&xfp_str).map_err(de::Error::custom)?); + } + } + } + + Ok(CaravanExtendedPublicKey { + name: name.ok_or_else(|| de::Error::missing_field("name"))?, + bip32_path: bip32_path.ok_or_else(|| de::Error::missing_field("bip32Path"))?, + xpub: xpub.ok_or_else(|| de::Error::missing_field("xpub"))?, + xfp: xfp.ok_or_else(|| de::Error::missing_field("xfp"))?, + }) + } + } + + const FIELDS: &[&str] = &["name", "bip32Path", "xpub", "xfp"]; + deserializer.deserialize_struct("CaravanExtendedPublicKey", FIELDS, CaravanKeyVisitor) + } +} + +/// Address type for Caravan wallet format +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum CaravanAddressType { + /// P2SH multisig + #[serde(rename = "P2SH")] + P2SH, + /// P2WSH multisig (native SegWit) + #[serde(rename = "P2WSH")] + P2WSH, + /// P2SH-P2WSH multisig (nested SegWit) + #[serde(rename = "P2SH-P2WSH")] + P2SHWrappedP2WSH, +} + +/// Caravan network. +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CaravanNetwork { + /// mainnet + Mainnet, + /// testnet + Testnet, +} + +use bitcoin::NetworkKind; + +impl From for CaravanNetwork { + fn from(network: bitcoin::Network) -> Self { + match network.into() { + NetworkKind::Main => Self::Mainnet, + NetworkKind::Test => Self::Testnet, + } + } } /// Structure that contains the export of a wallet in Caravan wallet format @@ -300,9 +438,9 @@ pub struct CaravanExport { pub name: String, /// Address type (P2SH, P2WSH, P2SH-P2WSH) #[serde(rename = "addressType")] - pub address_type: String, + pub address_type: CaravanAddressType, /// Network (mainnet, testnet) - pub network: String, + pub network: CaravanNetwork, /// Client configuration pub client: serde_json::Value, /// Quorum information @@ -359,23 +497,17 @@ impl CaravanExport { let descriptor_str = remove_checksum(descriptor_str); // Parse the descriptor to extract required information - let descriptor = - Descriptor::::from_str(&descriptor_str).map_err(|_| "Invalid descriptor")?; + let descriptor = Descriptor::::from_str(&descriptor_str) + .map_err(|_| "Invalid descriptor")?; // Determine the address type and multisig information let (address_type, quorum, keys) = Self::extract_descriptor_info(&descriptor)?; - // Network - let network = match wallet.network() { - bitcoin::Network::Bitcoin => "mainnet", - _ => "testnet", - }; - // Create the Caravan export let export = CaravanExport { name: name.into(), address_type, - network: network.into(), + network: wallet.network().into(), client: serde_json::json!({"type": "public"}), quorum, extended_public_keys: keys, @@ -387,8 +519,15 @@ impl CaravanExport { /// Extract information from a descriptor fn extract_descriptor_info( - descriptor: &Descriptor, - ) -> Result<(String, CaravanQuorum, Vec), &'static str> { + descriptor: &Descriptor, + ) -> Result< + ( + CaravanAddressType, + CaravanQuorum, + Vec, + ), + &'static str, + > { // Extract address type, quorum, and keys based on descriptor type match descriptor { Descriptor::Sh(sh) => { @@ -402,7 +541,7 @@ impl CaravanExport { required_signers: multi.k() as u32, total_signers: multi.pks().len() as u32, }; - Ok(("P2SH-P2WSH".into(), quorum, keys)) + Ok((CaravanAddressType::P2SHWrappedP2WSH, quorum, keys)) } _ => Err("Only sortedmulti is supported for P2SH-P2WSH in Caravan"), } @@ -414,7 +553,7 @@ impl CaravanExport { required_signers: multi.k() as u32, total_signers: multi.pks().len() as u32, }; - Ok(("P2SH".into(), quorum, keys)) + Ok((CaravanAddressType::P2SH, quorum, keys)) } _ => Err("Only sortedmulti is supported for P2SH in Caravan"), } @@ -428,7 +567,7 @@ impl CaravanExport { required_signers: multi.k() as u32, total_signers: multi.pks().len() as u32, }; - Ok(("P2WSH".into(), quorum, keys)) + Ok((CaravanAddressType::P2WSH, quorum, keys)) } _ => Err("Only sortedmulti is supported for P2WSH in Caravan"), } @@ -441,111 +580,116 @@ impl CaravanExport { /// Extract xpubs and fingerprints from multi descriptor fn extract_xpubs_from_multi( - multi: &miniscript::descriptor::SortedMultiVec, + multi: &miniscript::descriptor::SortedMultiVec, ) -> Result, &'static str> { let mut keys = Vec::new(); - for (i, key) in multi.pks().iter().enumerate() { - // Parse the key string to extract origin fingerprint, path, and xpub - // Format example: [c258d2e4/48h/0h/0h/2h]xpub.../0/* - let key_str = key.clone(); - - // Check if the key has origin information - if !key_str.starts_with('[') { - return Err("Keys must include origin information for Caravan export"); - } - - // Extract origin fingerprint - let origin_end = key_str.find(']').ok_or("Invalid key format")?; - let origin = &key_str[1..origin_end]; - let parts: Vec<&str> = origin.split('/').collect(); - if parts.is_empty() { - return Err("Invalid key origin format"); - } - - let fingerprint = parts[0].to_string(); - - // Extract derivation path and convert 'h' to "'" - let path_parts: Vec = parts[1..] - .iter() - .map(|part| { - if part.ends_with('h') { - let p = &part[0..part.len() - 1]; - format!("{}'", p) + for (i, desc_key) in multi.pks().iter().enumerate() { + match desc_key { + DescriptorPublicKey::XPub(xpub_key) => { + // Extract fingerprint from origin or derive from xpub + let fingerprint = match &xpub_key.origin { + Some((fp, _)) => *fp, + None => { + // If no origin, we need the secp context to derive fingerprint + // This should ideally be passed as a parameter + return Err("Missing origin information for key"); + } + }; + + // Extract the base derivation path (without the final /0/* or /1/*) + let base_path = if !xpub_key.derivation_path.is_empty() { + // Remove the final derivation step (0 or 1) to get the base path + // Convert to vector, remove last element, then back to DerivationPath + let base_vec: Vec<_> = xpub_key + .derivation_path + .into_iter() + .take(xpub_key.derivation_path.len() - 1) + .cloned() + .collect(); + let base: DerivationPath = base_vec.into(); + + // Combine with origin path if present + match &xpub_key.origin { + Some((_, origin_path)) => origin_path.extend(&base), + None => base, + } } else { - part.to_string() - } - }) - .collect(); - let path = format!("m/{}", path_parts.join("/")); - - // Extract xpub - let xpub_part = &key_str[origin_end + 1..]; - let xpub_end = xpub_part.find('/').unwrap_or(xpub_part.len()); - let xpub = xpub_part[..xpub_end].to_string(); - - keys.push(CaravanExtendedPublicKey { - name: format!("key{}", i + 1), - bip32_path: path, - xpub, - xfp: fingerprint, - }); + match &xpub_key.origin { + Some((_, origin_path)) => origin_path.clone(), + None => DerivationPath::default(), + } + }; + + keys.push(CaravanExtendedPublicKey { + name: format!("key{}", i + 1), + bip32_path: base_path, + xpub: xpub_key.xkey, + xfp: fingerprint, + }); + } + _ => return Err("Only extended public keys are supported"), + } } Ok(keys) } - /// Import a wallet from Caravan format + /// Import a wallet from Caravan format using proper Descriptor construction pub fn to_descriptors(&self) -> Result<(String, String), &'static str> { if self.extended_public_keys.is_empty() { return Err("No extended public keys found"); } - // Build key expressions for the descriptor - let mut key_exprs = Vec::new(); - for key in &self.extended_public_keys { - // Remove 'm/' prefix from bip32Path if present - let path = if key.bip32_path.starts_with("m/") { - &key.bip32_path[2..] - } else { - &key.bip32_path - }; - - // Convert "'" to "h" in the path - let descriptor_path = path.replace("'", "h"); - - // Format key with origin fingerprint and path - let key_expr = format!("[{}/{}]{}/0/*", key.xfp, descriptor_path, key.xpub); - key_exprs.push(key_expr); - } - - // Build descriptor based on address type - let descriptor_prefix = match self.address_type.as_str() { - "P2SH" => "sh(sortedmulti(", - "P2WSH" => "wsh(sortedmulti(", - "P2SH-P2WSH" => "sh(wsh(sortedmulti(", - _ => return Err("Unsupported address type"), + // Create DescriptorPublicKey objects for external and internal chains + let external_keys: Result, _> = self + .extended_public_keys + .iter() + .map(|key| { + let key_str = format!("[{}/{}]{}/0/*", key.xfp, key.bip32_path, key.xpub); + DescriptorPublicKey::from_str(&key_str) + .map_err(|_| "Failed to create DescriptorPublicKey") + }) + .collect(); + let external_keys = external_keys?; + + let internal_keys: Result, _> = self + .extended_public_keys + .iter() + .map(|key| { + let key_str = format!("[{}/{}]{}/1/*", key.xfp, key.bip32_path, key.xpub); + DescriptorPublicKey::from_str(&key_str) + .map_err(|_| "Failed to create DescriptorPublicKey") + }) + .collect(); + let internal_keys = internal_keys?; + + let k = self.quorum.required_signers as usize; + + // Use proper Descriptor construction methods + let external_desc: Descriptor = match self.address_type { + CaravanAddressType::P2SH => Descriptor::new_sh_sortedmulti(k, external_keys) + .map_err(|_| "Failed to create P2SH sortedmulti descriptor")?, + CaravanAddressType::P2WSH => Descriptor::new_wsh_sortedmulti(k, external_keys) + .map_err(|_| "Failed to create P2WSH sortedmulti descriptor")?, + CaravanAddressType::P2SHWrappedP2WSH => { + Descriptor::new_sh_wsh_sortedmulti(k, external_keys) + .map_err(|_| "Failed to create P2SH-P2WSH sortedmulti descriptor")? + } }; - let descriptor_suffix = match self.address_type.as_str() { - "P2SH" | "P2WSH" => "))", - "P2SH-P2WSH" => ")))", - _ => return Err("Unsupported address type"), + let internal_desc: Descriptor = match self.address_type { + CaravanAddressType::P2SH => Descriptor::new_sh_sortedmulti(k, internal_keys) + .map_err(|_| "Failed to create P2SH sortedmulti descriptor")?, + CaravanAddressType::P2WSH => Descriptor::new_wsh_sortedmulti(k, internal_keys) + .map_err(|_| "Failed to create P2WSH sortedmulti descriptor")?, + CaravanAddressType::P2SHWrappedP2WSH => { + Descriptor::new_sh_wsh_sortedmulti(k, internal_keys) + .map_err(|_| "Failed to create P2SH-P2WSH sortedmulti descriptor")? + } }; - // Construct the external descriptor - let external_descriptor = format!( - "{}{},({})){}", - descriptor_prefix, - self.quorum.required_signers, - key_exprs.join(","), - descriptor_suffix - ); - - // Create change descriptor by replacing /0/* with /1/* - let change_descriptor = external_descriptor.replace("/0/*", "/1/*"); - - Ok((external_descriptor, change_descriptor)) + Ok((external_desc.to_string(), internal_desc.to_string())) } } @@ -688,20 +832,32 @@ mod test { // Check basic fields assert_eq!(export.name, "Test P2WSH Wallet"); - assert_eq!(export.address_type, "P2WSH"); - assert_eq!(export.network, "mainnet"); + assert_eq!(export.address_type, CaravanAddressType::P2WSH); + assert_eq!(export.network, CaravanNetwork::Mainnet); assert_eq!(export.quorum.required_signers, 2); assert_eq!(export.quorum.total_signers, 2); assert_eq!(export.starting_address_index, 0); // Check extended public keys assert_eq!(export.extended_public_keys.len(), 2); - assert_eq!(export.extended_public_keys[0].xfp, "119dbcab"); + assert_eq!( + export.extended_public_keys[0].xfp, + Fingerprint::from_str("119dbcab").unwrap() + ); // Use the path format with apostrophes in the test expectation - assert_eq!(export.extended_public_keys[0].bip32_path, "m/48'/0'/0'/2'"); - assert_eq!(export.extended_public_keys[1].xfp, "e650dc93"); - assert_eq!(export.extended_public_keys[1].bip32_path, "m/48'/0'/0'/2'"); + assert_eq!( + export.extended_public_keys[0].bip32_path, + DerivationPath::from_str("m/48'/0'/0'/2'").unwrap() + ); + assert_eq!( + export.extended_public_keys[1].xfp, + Fingerprint::from_str("e650dc93").unwrap() + ); + assert_eq!( + export.extended_public_keys[1].bip32_path, + DerivationPath::from_str("m/48'/0'/0'/2'").unwrap() + ); // Test to_descriptors functionality let (external, internal) = export.to_descriptors().unwrap(); @@ -724,7 +880,7 @@ mod test { let wallet = get_test_wallet(descriptor, change_descriptor, network); let export = CaravanExport::export_wallet(&wallet, "Test P2SH Wallet").unwrap(); - assert_eq!(export.address_type, "P2SH"); + assert_eq!(export.address_type, CaravanAddressType::P2SH); assert_eq!(export.quorum.required_signers, 2); assert_eq!(export.quorum.total_signers, 2); } @@ -738,56 +894,33 @@ mod test { let wallet = get_test_wallet(descriptor, change_descriptor, network); let export = CaravanExport::export_wallet(&wallet, "Test P2SH-P2WSH Wallet").unwrap(); - assert_eq!(export.address_type, "P2SH-P2WSH"); + assert_eq!(export.address_type, CaravanAddressType::P2SHWrappedP2WSH); assert_eq!(export.quorum.required_signers, 2); assert_eq!(export.quorum.total_signers, 2); } #[test] - fn test_network_detection_for_caravan() { - // Test the network detection logic directly + fn test_caravan_network_conversion() { + // Test CaravanNetwork enum with From implementation assert_eq!( - match bitcoin::Network::Bitcoin { - bitcoin::Network::Bitcoin => "mainnet", - _ => "testnet", - }, - "mainnet" + serde_json::to_string(&CaravanNetwork::from(bitcoin::Network::Bitcoin)).unwrap(), + "\"mainnet\"" ); assert_eq!( - match bitcoin::Network::Testnet { - bitcoin::Network::Bitcoin => "mainnet", - _ => "testnet", - }, - "testnet" + serde_json::to_string(&CaravanNetwork::from(bitcoin::Network::Testnet)).unwrap(), + "\"testnet\"" ); assert_eq!( - match bitcoin::Network::Signet { - bitcoin::Network::Bitcoin => "mainnet", - _ => "testnet", - }, - "testnet" + serde_json::to_string(&CaravanNetwork::from(bitcoin::Network::Signet)).unwrap(), + "\"testnet\"" ); assert_eq!( - match bitcoin::Network::Regtest { - bitcoin::Network::Bitcoin => "mainnet", - _ => "testnet", - }, - "testnet" + serde_json::to_string(&CaravanNetwork::from(bitcoin::Network::Regtest)).unwrap(), + "\"testnet\"" ); - - // This tests the exact same logic used in the CaravanExport::export_wallet method - let network_mapping = |network: bitcoin::Network| -> &'static str { - match network { - bitcoin::Network::Bitcoin => "mainnet", - _ => "testnet", - } - }; - - assert_eq!(network_mapping(bitcoin::Network::Bitcoin), "mainnet"); - assert_eq!(network_mapping(bitcoin::Network::Testnet), "testnet"); } #[test] @@ -806,20 +939,20 @@ mod test { "extendedPublicKeys": [ { "name": "key1", - "bip32Path": "m/48h/0h/0h/2h", + "bip32Path": "m/48'/0'/0'/2'", "xpub": "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL", "xfp": "119dbcab" }, { "name": "key2", - "bip32Path": "m/48h/0h/0h/2h", - "xpub": "xpub6FKY2Zpu9dFmKZwLkRwt6XK3gcQuJDCz7rBzSWRU4TsUfGgfLdBMK6nVztnz6oSQjSiy2muFnxT5hc4CtYJzr4cLZcmCVeiUxCRGeTqVMuQ", + "bip32Path": "m/48'/0'/0'/2'", + "xpub": "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL", "xfp": "e650dc93" }, { "name": "key3", - "bip32Path": "m/48h/0h/0h/2h", - "xpub": "xpub6FPZdGBiQAu3FJjWAjeu6YBCCeUSnpm98y5tQU3AvBXRjQU8H2Su8QkcQZrAL8Wv8hy7G44JzBdNWvjXm1bdHhQDfg4JBzPQshqMfQLt1Bj", + "bip32Path": "m/48'/0'/0'/2'", + "xpub": "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL", "xfp": "bcc3df08" } ], @@ -838,4 +971,67 @@ mod test { assert!(internal.contains("/1/*")); assert!(external.contains("/0/*")); } + + #[test] + fn test_caravan_descriptors_create_functional_wallet() { + // Test that resulting descriptor strings can create a functional BDK Wallet + let json = r#"{ + "name": "Test Wallet", + "addressType": "P2WSH", + "network": "testnet", + "client": { + "type": "public" + }, + "quorum": { + "requiredSigners": 2, + "totalSigners": 2 + }, + "extendedPublicKeys": [ + { + "name": "key1", + "bip32Path": "m/48'/1'/0'/2'", + "xpub": "tpubDCKxNyM3bLgbEX13Mcd8mYxbVg9ajDkWXMh29hMWBurKfVmBfWAM96QVP3zaUcN51HvkZ3ar4VwP82kC8JZhhux8vFQoJintSpVBwpFvyU3", + "xfp": "73756c7f" + }, + { + "name": "key2", + "bip32Path": "m/48'/1'/0'/2'", + "xpub": "tpubDDp3ZSH1yCwusRppH7zgSxq2t1VEUyXSeEp8E5aFS8m43MknUjiF1bSLo3CGWAxbDyhF1XowA5ukPzyJZjznYk3kYi6oe7QxtX2euvKWsk4", + "xfp": "f9f62194" + } + ], + "startingAddressIndex": 0 + }"#; + + let import = CaravanExport::from_str(json).unwrap(); + let (external_desc, internal_desc) = import.to_descriptors().unwrap(); + + // Verify the descriptors can create a functional BDK Wallet + let wallet_result = Wallet::create(external_desc, internal_desc) + .network(bitcoin::Network::Testnet) + .create_wallet_no_persist(); + + assert!( + wallet_result.is_ok(), + "Failed to create wallet from Caravan export descriptors: {:?}", + wallet_result.err() + ); + + let mut wallet = wallet_result.unwrap(); + + // Verify basic wallet functionality + assert_eq!(wallet.network(), bitcoin::Network::Testnet); + + // Test address generation to verify the wallet is functional + let address = wallet.reveal_next_address(crate::types::KeychainKind::External); + assert_eq!(address.index, 0); + + // Verify it's a proper script hash address (P2WSH) + let addr_str = address.address.to_string(); + assert!( + addr_str.starts_with("tb1"), + "Expected testnet bech32 address, got: {}", + addr_str + ); + } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 273b5f6f..7d908e7d 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1189,7 +1189,7 @@ impl Wallet { /// /// To iterate over all canonical transactions, including those that are irrelevant, use /// [`TxGraph::list_canonical_txs`]. - pub fn transactions<'a>(&'a self) -> impl Iterator> + 'a { + pub fn transactions(&self) -> impl Iterator> + '_ { let tx_graph = self.indexed_graph.graph(); let tx_index = &self.indexed_graph.index; tx_graph From 0b67cd1d648eff76d158f8d053a07ac98ffa2a98 Mon Sep 17 00:00:00 2001 From: vkprogrammer-001 Date: Sat, 27 Sep 2025 23:46:12 +0530 Subject: [PATCH 3/4] Fix: improve derivation path handling and add tests - Simplified DerivationPath parsing by relying on rust-bitcoin's native support for m/ prefix. - Used wallet.public_descriptor directly to avoid unnecessary descriptor re-parsing. - Clarified and documented logic for combining origin and derivation paths for Caravan compatibility. - Added tests to verify correct handling of complex derivation paths and serialization/deserialization of CaravanExtendedPublicKey. - Ensured all changes are backward compatible and maintain expected Caravan export behavior. --- src/wallet/export.rs | 103 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 84 insertions(+), 19 deletions(-) diff --git a/src/wallet/export.rs b/src/wallet/export.rs index ba783f62..597c8267 100644 --- a/src/wallet/export.rs +++ b/src/wallet/export.rs @@ -281,6 +281,12 @@ pub struct CaravanExtendedPublicKey { /// Name of the signer pub name: String, /// BIP32 derivation path + /// This contains the combined path (origin path + derivation path) + /// Note: In the future, we might consider separating this into: + /// - origin_path: for the path from master to xpub + /// - derivation_path: for the additional derivation steps in the descriptor + /// + /// But for now, Caravan expects a single combined path. pub bip32_path: DerivationPath, /// Extended public key pub xpub: Xpub, @@ -353,11 +359,9 @@ impl<'de> Deserialize<'de> for CaravanExtendedPublicKey { return Err(de::Error::duplicate_field("bip32Path")); } let path_str: String = map.next_value()?; - // Strip m/ prefix if present for DerivationPath parsing - let cleaned_path = path_str.strip_prefix("m/").unwrap_or(&path_str); + // DerivationPath can handle m/ prefix natively bip32_path = Some( - DerivationPath::from_str(cleaned_path) - .map_err(de::Error::custom)?, + DerivationPath::from_str(&path_str).map_err(de::Error::custom)?, ); } Field::Xpub => { @@ -486,22 +490,11 @@ impl CaravanExport { /// /// Caravan supports P2SH, P2WSH, and P2SH-P2WSH multisig wallets. pub fn export_wallet(wallet: &Wallet, name: &str) -> Result { - // Get the descriptor and extract information - let descriptor_str = wallet - .public_descriptor(KeychainKind::External) - .to_string_with_secret( - &wallet - .get_signers(KeychainKind::External) - .as_key_map(wallet.secp_ctx()), - ); - let descriptor_str = remove_checksum(descriptor_str); - - // Parse the descriptor to extract required information - let descriptor = Descriptor::::from_str(&descriptor_str) - .map_err(|_| "Invalid descriptor")?; + // Get the descriptor directly from the wallet + let descriptor = wallet.public_descriptor(KeychainKind::External); // Determine the address type and multisig information - let (address_type, quorum, keys) = Self::extract_descriptor_info(&descriptor)?; + let (address_type, quorum, keys) = Self::extract_descriptor_info(descriptor)?; // Create the Caravan export let export = CaravanExport { @@ -598,6 +591,9 @@ impl CaravanExport { }; // Extract the base derivation path (without the final /0/* or /1/*) + // Caravan expects the complete derivation path excluding the final step + // This means combining the origin path with the descriptor's derivation path + // but excluding the last step (which will be added by Caravan later) let base_path = if !xpub_key.derivation_path.is_empty() { // Remove the final derivation step (0 or 1) to get the base path // Convert to vector, remove last element, then back to DerivationPath @@ -609,7 +605,8 @@ impl CaravanExport { .collect(); let base: DerivationPath = base_vec.into(); - // Combine with origin path if present + // Combine with origin path if present, as Caravan expects the complete path + // from master to just before the final step match &xpub_key.origin { Some((_, origin_path)) => origin_path.extend(&base), None => base, @@ -1034,4 +1031,72 @@ mod test { addr_str ); } + + #[test] + fn test_extract_xpubs_complex_derivation() { + // Create a descriptor with complex derivation paths (multiple steps) + // This simulates a case with [fingerprint/path]xpub/additional/path + let descriptor_str = "wsh(sortedmulti(2,[3f3b5353/48'/0'/0'/2']tpubDCKxNyM3bLgbEX13Mcd8mYxbVg9ajDkWXMh29hMWBurKfVmBfWAM96QVP3zaUcN51HvkZ3ar4VwP82kC8JZhhux8vFQoJintSpVBwpFvyU3/0/0,[f9f62194/48'/0'/0'/2']tpubDDp3ZSH1yCwusRppH7zgSxq2t1VEUyXSeEp8E5aFS8m43MknUjiF1bSLo3CGWAxbDyhF1XowA5ukPzyJZjznYk3kYi6oe7QxtX2euvKWsk4/0/0))"; + let descriptor = Descriptor::::from_str(descriptor_str).unwrap(); + + // Get the multi object from the descriptor + let multi = match descriptor { + Descriptor::Wsh(wsh) => { + if let miniscript::descriptor::WshInner::SortedMulti(multi) = wsh.into_inner() { + multi + } else { + panic!("Expected a SortedMulti in the descriptor") + } + } + _ => panic!("Expected a WSH descriptor"), + }; + + // Extract the keys + let keys = CaravanExport::extract_xpubs_from_multi(&multi).unwrap(); + + // Verify we have 2 keys + assert_eq!(keys.len(), 2); + + // Check the first key + let key1 = &keys[0]; + assert_eq!(key1.name, "key1"); + assert_eq!(key1.xfp, Fingerprint::from_str("3f3b5353").unwrap()); + assert_eq!( + key1.bip32_path, + DerivationPath::from_str("m/48'/0'/0'/2'/0").unwrap(), + "First key should have combined path from origin and derivation" + ); + + // Check the second key + let key2 = &keys[1]; + assert_eq!(key2.name, "key2"); + assert_eq!(key2.xfp, Fingerprint::from_str("f9f62194").unwrap()); + assert_eq!( + key2.bip32_path, + DerivationPath::from_str("m/48'/0'/0'/2'/0").unwrap(), + "Second key should have combined path from origin and derivation" + ); + } + + #[test] + fn test_caravan_extended_public_key_serialize_deserialize() { + let key = CaravanExtendedPublicKey { + name: "test_key".to_string(), + bip32_path: DerivationPath::from_str("m/48'/0'/0'/2'/0").unwrap(), + xpub: bitcoin::bip32::Xpub::from_str("tpubDCKxNyM3bLgbEX13Mcd8mYxbVg9ajDkWXMh29hMWBurKfVmBfWAM96QVP3zaUcN51HvkZ3ar4VwP82kC8JZhhux8vFQoJintSpVBwpFvyU3").unwrap(), + xfp: Fingerprint::from_str("3f3b5353").unwrap(), + }; + + // Serialize to JSON + let json = serde_json::to_string(&key).unwrap(); + + // Deserialize from JSON + let deserialized: CaravanExtendedPublicKey = serde_json::from_str(&json).unwrap(); + + // Verify fields match + assert_eq!(key.name, deserialized.name); + assert_eq!(key.bip32_path, deserialized.bip32_path); + assert_eq!(key.xpub.to_string(), deserialized.xpub.to_string()); + assert_eq!(key.xfp, deserialized.xfp); + } } From c624e851a631a7855f378cec2952a6aed573516a Mon Sep 17 00:00:00 2001 From: vkprogrammer-001 Date: Mon, 13 Oct 2025 10:53:05 +0530 Subject: [PATCH 4/4] docs: add round-trip limitation notice for Caravan export/import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add documentation explaining that Caravan export/import operations are designed as one-way operations and that full round-trip conversions (Descriptor → Wallet → CaravanExport → to_descriptors) will not produce identical descriptors due to derivation path processing for Caravan compatibility. --- src/wallet/export.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/wallet/export.rs b/src/wallet/export.rs index 597c8267..7691bc96 100644 --- a/src/wallet/export.rs +++ b/src/wallet/export.rs @@ -115,6 +115,24 @@ //! # assert_eq!(internal, "wsh(sortedmulti(2,[73756c7f/48'/0'/0'/2']tpubDCKxNyM3bLgbEX13Mcd8mYxbVg9ajDkWXMh29hMWBurKfVmBfWAM96QVP3zaUcN51HvkZ3ar4VwP82kC8JZhhux8vFQoJintSpVBwpFvyU3/1/*,[f9f62194/48'/0'/0'/2']tpubDDp3ZSH1yCwusRppH7zgSxq2t1VEUyXSeEp8E5aFS8m43MknUjiF1bSLo3CGWAxbDyhF1XowA5ukPzyJZjznYk3kYi6oe7QxtX2euvKWsk4/1/*))#cmcnua7a"); //! # Ok::<_, Box>(()) //! ``` +//! +//! ## Important Notes on Import/Export Operations +//! +//! ### One-Way Operations +//! +//! The Caravan export/import functionality is designed for **one-way operations**: +//! +//! 1. **Export**: `Wallet → CaravanExport` (for sharing with Caravan) +//! 2. **Import**: `JSON → CaravanExport → to_descriptors` (for importing from Caravan) +//! +//! ### Round-Trip Limitation +//! +//! **Note**: Full round-trip operations (`Descriptor → Wallet → CaravanExport → to_descriptors`) +//! will **not** produce identical descriptors due to how derivation paths are processed for +//! Caravan compatibility. This is expected behavior and not a bug. +//! +//! If you need to preserve the exact original descriptor format, store it separately +//! rather than relying on round-trip conversion. use alloc::string::String; use alloc::string::ToString;