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::{DescriptorPublicKey, ShInner, WshInner};
use miniscript::{Descriptor, ScriptContext, Terminal};
use crate::types::KeychainKind;
use crate::wallet::Wallet;
#[deprecated(since = "0.18.0", note = "Please use [`FullyNodedExport`] instead")]
pub type WalletExport = FullyNodedExport;
#[derive(Debug, Serialize, Deserialize)]
pub struct FullyNodedExport {
descriptor: String,
pub blockheight: u32,
pub label: String,
}
impl fmt::Display for FullyNodedExport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", serde_json::to_string(self).unwrap())
}
}
impl FromStr for FullyNodedExport {
type Err = serde_json::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
serde_json::from_str(s)
}
}
fn remove_checksum(s: String) -> String {
s.split_once('#').map(|(a, _)| String::from(a)).unwrap()
}
impl FullyNodedExport {
pub fn export_wallet(
wallet: &Wallet,
label: &str,
include_blockheight: bool,
) -> Result<Self, &'static str> {
let descriptor = wallet
.public_descriptor(KeychainKind::External)
.to_string_with_secret(
&wallet
.get_signers(KeychainKind::External)
.as_key_map(wallet.secp_ctx()),
);
let descriptor = remove_checksum(descriptor);
Self::is_compatible_with_core(&descriptor)?;
let blockheight = if include_blockheight {
wallet.transactions().next().map_or(0, |canonical_tx| {
canonical_tx
.chain_position
.confirmation_height_upper_bound()
.unwrap_or(0)
})
} else {
0
};
let export = FullyNodedExport {
descriptor,
label: label.into(),
blockheight,
};
let change_descriptor = {
let descriptor = wallet
.public_descriptor(KeychainKind::Internal)
.to_string_with_secret(
&wallet
.get_signers(KeychainKind::Internal)
.as_key_map(wallet.secp_ctx()),
);
Some(remove_checksum(descriptor))
};
if export.change_descriptor() != change_descriptor {
return Err("Incompatible change descriptor");
}
Ok(export)
}
fn is_compatible_with_core(descriptor: &str) -> Result<(), &'static str> {
fn check_ms<Ctx: ScriptContext>(
terminal: &Terminal<String, Ctx>,
) -> Result<(), &'static str> {
if let Terminal::Multi(_) = terminal {
Ok(())
} else {
Err("The descriptor contains operators not supported by Bitcoin Core")
}
}
match Descriptor::<String>::from_str(descriptor).map_err(|_| "Invalid descriptor")? {
Descriptor::Pkh(_) | Descriptor::Wpkh(_) => Ok(()),
Descriptor::Sh(sh) => match sh.as_inner() {
ShInner::Wpkh(_) => Ok(()),
ShInner::SortedMulti(_) => Ok(()),
ShInner::Wsh(wsh) => match wsh.as_inner() {
WshInner::SortedMulti(_) => Ok(()),
WshInner::Ms(ms) => check_ms(&ms.node),
},
ShInner::Ms(ms) => check_ms(&ms.node),
},
Descriptor::Wsh(wsh) => match wsh.as_inner() {
WshInner::SortedMulti(_) => Ok(()),
WshInner::Ms(ms) => check_ms(&ms.node),
},
Descriptor::Tr(_) => Ok(()),
_ => Err("The descriptor is not compatible with Bitcoin Core"),
}
}
pub fn descriptor(&self) -> String {
self.descriptor.clone()
}
pub fn change_descriptor(&self) -> Option<String> {
let replaced = self.descriptor.replace("/0/*", "/1/*");
if replaced != self.descriptor {
Some(replaced)
} else {
None
}
}
}
#[derive(Debug, Clone)]
pub struct CaravanExtendedPublicKey {
pub name: String,
pub bip32_path: DerivationPath,
pub xpub: Xpub,
pub xfp: Fingerprint,
}
impl Serialize for CaravanExtendedPublicKey {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("CaravanExtendedPublicKey", 4)?;
state.serialize_field("name", &self.name)?;
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<D>(deserializer: D) -> Result<Self, D::Error>
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<V>(self, mut map: V) -> Result<CaravanExtendedPublicKey, V::Error>
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()?;
bip32_path = Some(
DerivationPath::from_str(&path_str).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)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CaravanAddressType {
#[serde(rename = "P2SH")]
P2SH,
#[serde(rename = "P2WSH")]
P2WSH,
#[serde(rename = "P2SH-P2WSH")]
P2SHWrappedP2WSH,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CaravanNetwork {
Mainnet,
Testnet,
}
use bitcoin::NetworkKind;
impl From<bitcoin::Network> for CaravanNetwork {
fn from(network: bitcoin::Network) -> Self {
match network.into() {
NetworkKind::Main => Self::Mainnet,
NetworkKind::Test => Self::Testnet,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CaravanExport {
pub name: String,
#[serde(rename = "addressType")]
pub address_type: CaravanAddressType,
pub network: CaravanNetwork,
pub client: serde_json::Value,
pub quorum: CaravanQuorum,
#[serde(rename = "extendedPublicKeys")]
pub extended_public_keys: Vec<CaravanExtendedPublicKey>,
#[serde(rename = "startingAddressIndex")]
pub starting_address_index: u32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CaravanQuorum {
#[serde(rename = "requiredSigners")]
pub required_signers: u32,
#[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 {
pub fn export_wallet(wallet: &Wallet, name: &str) -> Result<Self, &'static str> {
let descriptor = wallet.public_descriptor(KeychainKind::External);
let (address_type, quorum, keys) = Self::extract_descriptor_info(descriptor)?;
let export = CaravanExport {
name: name.into(),
address_type,
network: wallet.network().into(),
client: serde_json::json!({"type": "public"}),
quorum,
extended_public_keys: keys,
starting_address_index: 0,
};
Ok(export)
}
fn extract_descriptor_info(
descriptor: &Descriptor<DescriptorPublicKey>,
) -> Result<
(
CaravanAddressType,
CaravanQuorum,
Vec<CaravanExtendedPublicKey>,
),
&'static str,
> {
match descriptor {
Descriptor::Sh(sh) => {
match sh.as_inner() {
ShInner::Wsh(wsh) => {
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((CaravanAddressType::P2SHWrappedP2WSH, quorum, keys))
}
_ => Err("Only sortedmulti is supported for P2SH-P2WSH in Caravan"),
}
}
ShInner::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((CaravanAddressType::P2SH, quorum, keys))
}
_ => Err("Only sortedmulti is supported for P2SH in Caravan"),
}
}
Descriptor::Wsh(wsh) => {
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((CaravanAddressType::P2WSH, quorum, keys))
}
_ => Err("Only sortedmulti is supported for P2WSH in Caravan"),
}
}
_ => {
Err("Only P2SH, P2WSH, or P2SH-P2WSH multisig descriptors are supported by Caravan")
}
}
}
fn extract_xpubs_from_multi<Ctx: ScriptContext>(
multi: &miniscript::descriptor::SortedMultiVec<DescriptorPublicKey, Ctx>,
) -> Result<Vec<CaravanExtendedPublicKey>, &'static str> {
let mut keys = Vec::new();
for (i, desc_key) in multi.pks().iter().enumerate() {
match desc_key {
DescriptorPublicKey::XPub(xpub_key) => {
let fingerprint = match &xpub_key.origin {
Some((fp, _)) => *fp,
None => {
return Err("Missing origin information for key");
}
};
let base_path = if !xpub_key.derivation_path.is_empty() {
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();
match &xpub_key.origin {
Some((_, origin_path)) => origin_path.extend(&base),
None => base,
}
} else {
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)
}
pub fn to_descriptors(&self) -> Result<(String, String), &'static str> {
if self.extended_public_keys.is_empty() {
return Err("No extended public keys found");
}
let external_keys: Result<Vec<DescriptorPublicKey>, _> = 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<Vec<DescriptorPublicKey>, _> = 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;
let external_desc: Descriptor<DescriptorPublicKey> = 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 internal_desc: Descriptor<DescriptorPublicKey> = 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")?
}
};
Ok((external_desc.to_string(), internal_desc.to_string()))
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod test {
use alloc::string::ToString;
use bitcoin::Amount;
use core::str::FromStr;
use bdk_chain::BlockId;
use bitcoin::{hashes::Hash, BlockHash, Network};
use super::*;
use crate::test_utils::*;
use crate::Wallet;
fn get_test_wallet(descriptor: &str, change_descriptor: &str, network: Network) -> Wallet {
let mut wallet = Wallet::create(descriptor.to_string(), change_descriptor.to_string())
.network(network)
.create_wallet_no_persist()
.expect("must create wallet");
let block = BlockId {
height: 5000,
hash: BlockHash::all_zeros(),
};
insert_checkpoint(&mut wallet, block);
receive_output_in_latest_block(&mut wallet, Amount::from_sat(10_000));
wallet
}
#[test]
fn test_export_bip44() {
let descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44'/0'/0'/0/*)";
let change_descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44'/0'/0'/1/*)";
let wallet = get_test_wallet(descriptor, change_descriptor, Network::Bitcoin);
let export = FullyNodedExport::export_wallet(&wallet, "Test Label", true).unwrap();
assert_eq!(export.descriptor(), descriptor);
assert_eq!(export.change_descriptor(), Some(change_descriptor.into()));
assert_eq!(export.blockheight, 5000);
assert_eq!(export.label, "Test Label");
}
#[test]
#[should_panic(expected = "Incompatible change descriptor")]
fn test_export_no_change() {
let descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44'/0'/0'/0/*)";
let change_descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44'/0'/0'/1/0)";
let wallet = get_test_wallet(descriptor, change_descriptor, Network::Bitcoin);
FullyNodedExport::export_wallet(&wallet, "Test Label", true).unwrap();
}
#[test]
#[should_panic(expected = "Incompatible change descriptor")]
fn test_export_incompatible_change() {
let descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44'/0'/0'/0/*)";
let change_descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/50'/0'/1/*)";
let wallet = get_test_wallet(descriptor, change_descriptor, Network::Bitcoin);
FullyNodedExport::export_wallet(&wallet, "Test Label", true).unwrap();
}
#[test]
fn test_export_multi() {
let descriptor = "wsh(multi(2,\
[73756c7f/48'/0'/0'/2']tpubDCKxNyM3bLgbEX13Mcd8mYxbVg9ajDkWXMh29hMWBurKfVmBfWAM96QVP3zaUcN51HvkZ3ar4VwP82kC8JZhhux8vFQoJintSpVBwpFvyU3/0/*,\
[f9f62194/48'/0'/0'/2']tpubDDp3ZSH1yCwusRppH7zgSxq2t1VEUyXSeEp8E5aFS8m43MknUjiF1bSLo3CGWAxbDyhF1XowA5ukPzyJZjznYk3kYi6oe7QxtX2euvKWsk4/0/*,\
[c98b1535/48'/0'/0'/2']tpubDCDi5W4sP6zSnzJeowy8rQDVhBdRARaPhK1axABi8V1661wEPeanpEXj4ZLAUEoikVtoWcyK26TKKJSecSfeKxwHCcRrge9k1ybuiL71z4a/0/*\
))";
let change_descriptor = "wsh(multi(2,\
[73756c7f/48'/0'/0'/2']tpubDCKxNyM3bLgbEX13Mcd8mYxbVg9ajDkWXMh29hMWBurKfVmBfWAM96QVP3zaUcN51HvkZ3ar4VwP82kC8JZhhux8vFQoJintSpVBwpFvyU3/1/*,\
[f9f62194/48'/0'/0'/2']tpubDDp3ZSH1yCwusRppH7zgSxq2t1VEUyXSeEp8E5aFS8m43MknUjiF1bSLo3CGWAxbDyhF1XowA5ukPzyJZjznYk3kYi6oe7QxtX2euvKWsk4/1/*,\
[c98b1535/48'/0'/0'/2']tpubDCDi5W4sP6zSnzJeowy8rQDVhBdRARaPhK1axABi8V1661wEPeanpEXj4ZLAUEoikVtoWcyK26TKKJSecSfeKxwHCcRrge9k1ybuiL71z4a/1/*\
))";
let wallet = get_test_wallet(descriptor, change_descriptor, Network::Testnet);
let export = FullyNodedExport::export_wallet(&wallet, "Test Label", true).unwrap();
assert_eq!(export.descriptor(), descriptor);
assert_eq!(export.change_descriptor(), Some(change_descriptor.into()));
assert_eq!(export.blockheight, 5000);
assert_eq!(export.label, "Test Label");
}
#[test]
fn test_export_tr() {
let descriptor = "tr([73c5da0a/86'/0'/0']tprv8fMn4hSKPRC1oaCPqxDb1JWtgkpeiQvZhsr8W2xuy3GEMkzoArcAWTfJxYb6Wj8XNNDWEjfYKK4wGQXh3ZUXhDF2NcnsALpWTeSwarJt7Vc/0/*)";
let change_descriptor = "tr([73c5da0a/86'/0'/0']tprv8fMn4hSKPRC1oaCPqxDb1JWtgkpeiQvZhsr8W2xuy3GEMkzoArcAWTfJxYb6Wj8XNNDWEjfYKK4wGQXh3ZUXhDF2NcnsALpWTeSwarJt7Vc/1/*)";
let wallet = get_test_wallet(descriptor, change_descriptor, Network::Testnet);
let export = FullyNodedExport::export_wallet(&wallet, "Test Label", true).unwrap();
assert_eq!(export.descriptor(), descriptor);
assert_eq!(export.change_descriptor(), Some(change_descriptor.into()));
assert_eq!(export.blockheight, 5000);
assert_eq!(export.label, "Test Label");
}
#[test]
fn test_export_to_json() {
let descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44'/0'/0'/0/*)";
let change_descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44'/0'/0'/1/*)";
let wallet = get_test_wallet(descriptor, change_descriptor, Network::Bitcoin);
let export = FullyNodedExport::export_wallet(&wallet, "Test Label", true).unwrap();
assert_eq!(export.to_string(), "{\"descriptor\":\"wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44\'/0\'/0\'/0/*)\",\"blockheight\":5000,\"label\":\"Test Label\"}");
}
#[test]
fn test_export_from_json() {
let descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44'/0'/0'/0/*)";
let change_descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44'/0'/0'/1/*)";
let import_str = "{\"descriptor\":\"wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44\'/0\'/0\'/0/*)\",\"blockheight\":5000,\"label\":\"Test Label\"}";
let export = FullyNodedExport::from_str(import_str).unwrap();
assert_eq!(export.descriptor(), descriptor);
assert_eq!(export.change_descriptor(), Some(change_descriptor.into()));
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();
assert_eq!(export.name, "Test P2WSH Wallet");
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);
assert_eq!(export.extended_public_keys.len(), 2);
assert_eq!(
export.extended_public_keys[0].xfp,
Fingerprint::from_str("119dbcab").unwrap()
);
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()
);
let (external, internal) = export.to_descriptors().unwrap();
assert!(external.contains("wsh(sortedmulti("));
assert!(internal.contains("/1/*"));
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, CaravanAddressType::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, CaravanAddressType::P2SHWrappedP2WSH);
assert_eq!(export.quorum.required_signers, 2);
assert_eq!(export.quorum.total_signers, 2);
}
#[test]
fn test_caravan_network_conversion() {
assert_eq!(
serde_json::to_string(&CaravanNetwork::from(bitcoin::Network::Bitcoin)).unwrap(),
"\"mainnet\""
);
assert_eq!(
serde_json::to_string(&CaravanNetwork::from(bitcoin::Network::Testnet)).unwrap(),
"\"testnet\""
);
assert_eq!(
serde_json::to_string(&CaravanNetwork::from(bitcoin::Network::Signet)).unwrap(),
"\"testnet\""
);
assert_eq!(
serde_json::to_string(&CaravanNetwork::from(bitcoin::Network::Regtest)).unwrap(),
"\"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/48'/0'/0'/2'",
"xpub": "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL",
"xfp": "119dbcab"
},
{
"name": "key2",
"bip32Path": "m/48'/0'/0'/2'",
"xpub": "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL",
"xfp": "e650dc93"
},
{
"name": "key3",
"bip32Path": "m/48'/0'/0'/2'",
"xpub": "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL",
"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);
assert!(internal.contains("/1/*"));
assert!(external.contains("/0/*"));
}
#[test]
fn test_caravan_descriptors_create_functional_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();
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();
assert_eq!(wallet.network(), bitcoin::Network::Testnet);
let address = wallet.reveal_next_address(crate::types::KeychainKind::External);
assert_eq!(address.index, 0);
let addr_str = address.address.to_string();
assert!(
addr_str.starts_with("tb1"),
"Expected testnet bech32 address, got: {}",
addr_str
);
}
#[test]
fn test_extract_xpubs_complex_derivation() {
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::<DescriptorPublicKey>::from_str(descriptor_str).unwrap();
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"),
};
let keys = CaravanExport::extract_xpubs_from_multi(&multi).unwrap();
assert_eq!(keys.len(), 2);
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"
);
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(),
};
let json = serde_json::to_string(&key).unwrap();
let deserialized: CaravanExtendedPublicKey = serde_json::from_str(&json).unwrap();
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);
}
}