Skip to content

feature: Add Caravan wallet format import/export support #205

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
508 changes: 504 additions & 4 deletions wallet/src/wallet/export.rs
Original file line number Diff line number Diff line change
@@ -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<dyn std::error::Error>>(())
//! ```
//!
//! ### 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<dyn std::error::Error>>(())
//! ```
//!
//! ### 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<dyn std::error::Error>>(())
//! ```
//!
//! ### 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<dyn std::error::Error>>(())
//! ```
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<CaravanExtendedPublicKey>,
/// 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<Self, Self::Err> {
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<Self, &'static str> {
// 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::<String>::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<String>,
) -> Result<(String, CaravanQuorum, Vec<CaravanExtendedPublicKey>), &'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<Ctx: ScriptContext>(
multi: &miniscript::descriptor::SortedMultiVec<String, Ctx>,
) -> Result<Vec<CaravanExtendedPublicKey>, &'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<String> = 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;
@@ -337,4 +675,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/*"));
}
}