From 4cd9f036e5961ea7e8d1619d94e9aa207980fd52 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Wed, 27 Apr 2022 21:52:26 -0700 Subject: [PATCH 1/6] Reorganize wallet::export into wallet::export::fully_noded submodule --- src/wallet/{export.rs => export/caravan.rs} | 4 +- src/wallet/export/fully_noded.rs | 370 ++++++++++++++++++++ src/wallet/export/mod.rs | 13 + 3 files changed, 385 insertions(+), 2 deletions(-) rename src/wallet/{export.rs => export/caravan.rs} (99%) create mode 100644 src/wallet/export/fully_noded.rs create mode 100644 src/wallet/export/mod.rs diff --git a/src/wallet/export.rs b/src/wallet/export/caravan.rs similarity index 99% rename from src/wallet/export.rs rename to src/wallet/export/caravan.rs index 9c7532119..c9cfc765f 100644 --- a/src/wallet/export.rs +++ b/src/wallet/export/caravan.rs @@ -21,7 +21,7 @@ //! # use std::str::FromStr; //! # use bitcoin::*; //! # use bdk::database::*; -//! # use bdk::wallet::export::*; +//! # use bdk::wallet::fully_noded::*; //! # use bdk::*; //! let import = r#"{ //! "descriptor": "wpkh([c258d2e4\/84h\/1h\/0h]tpubDD3ynpHgJQW8VvWRzQ5WFDCrs4jqVFGHB3vLC3r49XHJSqP8bHKdK4AriuUKLccK68zfzowx7YhmDN8SiSkgCDENUFx9qVw65YyqM78vyVe\/0\/*)", @@ -43,7 +43,7 @@ //! ``` //! # use bitcoin::*; //! # use bdk::database::*; -//! # use bdk::wallet::export::*; +//! # use bdk::wallet::fully_noded::*; //! # use bdk::*; //! let wallet = Wallet::new( //! "wpkh([c258d2e4/84h/1h/0h]tpubDD3ynpHgJQW8VvWRzQ5WFDCrs4jqVFGHB3vLC3r49XHJSqP8bHKdK4AriuUKLccK68zfzowx7YhmDN8SiSkgCDENUFx9qVw65YyqM78vyVe/0/*)", diff --git a/src/wallet/export/fully_noded.rs b/src/wallet/export/fully_noded.rs new file mode 100644 index 000000000..1fd125cb4 --- /dev/null +++ b/src/wallet/export/fully_noded.rs @@ -0,0 +1,370 @@ +// Bitcoin Dev Kit +// Written in 2020 by Alekos Filini +// +// Copyright (c) 2020-2021 Bitcoin Dev Kit Developers +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Fully Noded 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). +//! +//! ## Examples +//! +//! ### Import from JSON +//! +//! ``` +//! # use std::str::FromStr; +//! # use bitcoin::*; +//! # use bdk::database::*; +//! # use bdk::wallet::fully_noded::*; +//! # use bdk::*; +//! let import = r#"{ +//! "descriptor": "wpkh([c258d2e4\/84h\/1h\/0h]tpubDD3ynpHgJQW8VvWRzQ5WFDCrs4jqVFGHB3vLC3r49XHJSqP8bHKdK4AriuUKLccK68zfzowx7YhmDN8SiSkgCDENUFx9qVw65YyqM78vyVe\/0\/*)", +//! "blockheight":1782088, +//! "label":"testnet" +//! }"#; +//! +//! let import = FullyNodedExport::from_str(import)?; +//! let wallet = Wallet::new( +//! &import.descriptor(), +//! import.change_descriptor().as_ref(), +//! Network::Testnet, +//! MemoryDatabase::default(), +//! )?; +//! # Ok::<_, bdk::Error>(()) +//! ``` +//! +//! ### Export a `Wallet` +//! ``` +//! # use bitcoin::*; +//! # use bdk::database::*; +//! # use bdk::wallet::fully_noded::*; +//! # use bdk::*; +//! let wallet = Wallet::new( +//! "wpkh([c258d2e4/84h/1h/0h]tpubDD3ynpHgJQW8VvWRzQ5WFDCrs4jqVFGHB3vLC3r49XHJSqP8bHKdK4AriuUKLccK68zfzowx7YhmDN8SiSkgCDENUFx9qVw65YyqM78vyVe/0/*)", +//! Some("wpkh([c258d2e4/84h/1h/0h]tpubDD3ynpHgJQW8VvWRzQ5WFDCrs4jqVFGHB3vLC3r49XHJSqP8bHKdK4AriuUKLccK68zfzowx7YhmDN8SiSkgCDENUFx9qVw65YyqM78vyVe/1/*)"), +//! Network::Testnet, +//! MemoryDatabase::default() +//! )?; +//! let export = FullyNodedExport::export_wallet(&wallet, "exported wallet", true) +//! .map_err(ToString::to_string) +//! .map_err(bdk::Error::Generic)?; +//! +//! println!("Exported: {}", export.to_string()); +//! # Ok::<_, bdk::Error>(()) +//! ``` + +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +use miniscript::descriptor::{ShInner, WshInner}; +use miniscript::{Descriptor, ScriptContext, Terminal}; + +use crate::database::BatchDatabase; +use crate::types::KeychainKind; +use crate::wallet::Wallet; + +/// Alias for [`FullyNodedExport`] +#[deprecated(since = "0.18.0", note = "Please use [`FullyNodedExport`] instead")] +pub type WalletExport = FullyNodedExport; + +/// Structure that contains the export of a wallet +/// +/// For a usage example see [this module](crate::wallet::export::fully_noded)'s documentation. +#[derive(Debug, Serialize, Deserialize)] +pub struct FullyNodedExport { + descriptor: String, + /// Earliest block to rescan when looking for the wallet's transactions + pub blockheight: u32, + /// Arbitrary label for the wallet + pub label: String, +} + +impl ToString for FullyNodedExport { + fn to_string(&self) -> String { + serde_json::to_string(self).unwrap() + } +} + +impl FromStr for FullyNodedExport { + type Err = serde_json::Error; + + fn from_str(s: &str) -> Result { + serde_json::from_str(s) + } +} + +fn remove_checksum(s: String) -> String { + s.split_once('#').map(|(a, _)| String::from(a)).unwrap() +} + +impl FullyNodedExport { + /// Export a wallet + /// + /// This function returns an error if it determines that the `wallet`'s descriptor(s) are not + /// supported by Bitcoin Core or don't follow the standard derivation paths defined by BIP44 + /// and others. + /// + /// If `include_blockheight` is `true`, this function will look into the `wallet`'s database + /// for the oldest transaction it knows and use that as the earliest block to rescan. + /// + /// If the database is empty or `include_blockheight` is false, the `blockheight` field + /// returned will be `0`. + pub fn export_wallet( + wallet: &Wallet, + label: &str, + include_blockheight: bool, + ) -> Result { + let descriptor = wallet + .get_descriptor_for_keychain(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 = match wallet.database.borrow().iter_txs(false) { + _ if !include_blockheight => 0, + Err(_) => 0, + Ok(txs) => { + let mut heights = txs + .into_iter() + .map(|tx| tx.confirmation_time.map(|c| c.height).unwrap_or(0)) + .collect::>(); + heights.sort_unstable(); + + *heights.last().unwrap_or(&0) + } + }; + + let export = FullyNodedExport { + descriptor, + label: label.into(), + blockheight, + }; + + let change_descriptor = match wallet + .public_descriptor(KeychainKind::Internal) + .map_err(|_| "Invalid change descriptor")? + .is_some() + { + false => None, + true => { + let descriptor = wallet + .get_descriptor_for_keychain(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( + terminal: &Terminal, + ) -> Result<(), &'static str> { + if let Terminal::Multi(_, _) = terminal { + Ok(()) + } else { + Err("The descriptor contains operators not supported by Bitcoin Core") + } + } + + // pkh(), wpkh(), sh(wpkh()) are always fine, as well as multi() and sortedmulti() + match Descriptor::::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), + }, + _ => Err("The descriptor is not compatible with Bitcoin Core"), + } + } + + /// Return the external descriptor + pub fn descriptor(&self) -> String { + self.descriptor.clone() + } + + /// Return the internal descriptor, if present + pub fn change_descriptor(&self) -> Option { + let replaced = self.descriptor.replace("/0/*", "/1/*"); + + if replaced != self.descriptor { + Some(replaced) + } else { + None + } + } +} + +#[cfg(test)] +mod test { + use std::str::FromStr; + + use bitcoin::{Network, Txid}; + + use super::*; + use crate::database::{memory::MemoryDatabase, BatchOperations}; + use crate::types::TransactionDetails; + use crate::wallet::Wallet; + use crate::BlockTime; + + fn get_test_db() -> MemoryDatabase { + let mut db = MemoryDatabase::new(); + db.set_tx(&TransactionDetails { + transaction: None, + txid: Txid::from_str( + "4ddff1fa33af17f377f62b72357b43107c19110a8009b36fb832af505efed98a", + ) + .unwrap(), + + received: 100_000, + sent: 0, + fee: Some(500), + confirmation_time: Some(BlockTime { + timestamp: 12345678, + height: 5000, + }), + }) + .unwrap(); + + db + } + + #[test] + fn test_export_bip44() { + let descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44'/0'/0'/0/*)"; + let change_descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44'/0'/0'/1/*)"; + + let wallet = Wallet::new( + descriptor, + Some(change_descriptor), + Network::Bitcoin, + get_test_db(), + ) + .unwrap(); + 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() { + // This wallet explicitly doesn't have a change descriptor. It should be impossible to + // export, because exporting this kind of external descriptor normally implies the + // existence of an internal descriptor + + let descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44'/0'/0'/0/*)"; + + let wallet = Wallet::new(descriptor, None, Network::Bitcoin, get_test_db()).unwrap(); + FullyNodedExport::export_wallet(&wallet, "Test Label", true).unwrap(); + } + + #[test] + #[should_panic(expected = "Incompatible change descriptor")] + fn test_export_incompatible_change() { + // This wallet has a change descriptor, but the derivation path is not in the "standard" + // bip44/49/etc format + + let descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44'/0'/0'/0/*)"; + let change_descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/50'/0'/1/*)"; + + let wallet = Wallet::new( + descriptor, + Some(change_descriptor), + Network::Bitcoin, + get_test_db(), + ) + .unwrap(); + 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 = Wallet::new( + descriptor, + Some(change_descriptor), + Network::Testnet, + get_test_db(), + ) + .unwrap(); + 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 = Wallet::new( + descriptor, + Some(change_descriptor), + Network::Bitcoin, + get_test_db(), + ) + .unwrap(); + 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"); + } +} diff --git a/src/wallet/export/mod.rs b/src/wallet/export/mod.rs new file mode 100644 index 000000000..cb701ffc4 --- /dev/null +++ b/src/wallet/export/mod.rs @@ -0,0 +1,13 @@ +// Bitcoin Dev Kit +// +// Copyright (c) 2020-2022 Bitcoin Dev Kit Developers +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! This module contains submodules that implement various wallet export formats. + +pub mod fully_noded; \ No newline at end of file From 3052b654cdf800f3292bc43759e316c41df1ed9a Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Thu, 28 Apr 2022 17:43:19 -0700 Subject: [PATCH 2/6] Add wallet::export::caravan module --- Cargo.toml | 1 + src/wallet/export/caravan.rs | 617 +++++++++++++++++-------------- src/wallet/export/fully_noded.rs | 6 +- src/wallet/export/mod.rs | 3 +- 4 files changed, 353 insertions(+), 274 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 49d5359e5..dde7122bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,6 +99,7 @@ lazy_static = "1.4" env_logger = "0.7" clap = "2.33" electrsd = "0.19.1" +assert-json-diff = "2.0" [[example]] name = "address_validator" diff --git a/src/wallet/export/caravan.rs b/src/wallet/export/caravan.rs index c9cfc765f..56f4464e5 100644 --- a/src/wallet/export/caravan.rs +++ b/src/wallet/export/caravan.rs @@ -11,7 +11,7 @@ //! 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 the wallet export format used by Unchained Capitals's [Caravan](https://github.com/unchained-capital/caravan). //! //! ## Examples //! @@ -21,19 +21,41 @@ //! # use std::str::FromStr; //! # use bitcoin::*; //! # use bdk::database::*; -//! # use bdk::wallet::fully_noded::*; +//! # use bdk::wallet::export::caravan::*; //! # use bdk::*; //! let import = r#"{ -//! "descriptor": "wpkh([c258d2e4\/84h\/1h\/0h]tpubDD3ynpHgJQW8VvWRzQ5WFDCrs4jqVFGHB3vLC3r49XHJSqP8bHKdK4AriuUKLccK68zfzowx7YhmDN8SiSkgCDENUFx9qVw65YyqM78vyVe\/0\/*)", -//! "blockheight":1782088, -//! "label":"testnet" +//! "name": "P2WSH-T", +//! "addressType": "P2WSH", +//! "network": "testnet", +//! "client": { +//! "type": "public" +//! }, +//! "quorum": { +//! "requiredSigners": 2, +//! "totalSigners": 2 +//! }, +//! "extendedPublicKeys": [ +//! { +//! "name": "osw", +//! "bip32Path": "m/48'/1'/100'/2'", +//! "xpub": "tpubDFc9Mm4tw6EkgR4YTC1GrU6CGEd9yw7KSBnSssL4LXAXh89D4uMZigRyv3csdXbeU3BhLQc4vWKTLewboA1Pt8Fu6fbHKu81MZ6VGdc32eM", +//! "xfp" : "f57ec65d" +//! }, +//! { +//! "name": "d", +//! "bip32Path": "m/48'/1'/100'/2'", +//! "xpub": "tpubDErWN5qfdLwYE94mh12oWr4uURDDNKCjKVhCEcAgZ7jKnnAwq5tcTF2iEk3VuznkJuk2G8SCHft9gS6aKbBd18ptYWPqKLRSTRQY7e2rrDj", +//! "xfp" : "efa5d916" +//! } +//! ], +//! "startingAddressIndex": 0 //! }"#; //! -//! let import = FullyNodedExport::from_str(import)?; +//! let import = CaravanExport::from_str(import)?; //! let wallet = Wallet::new( -//! &import.descriptor(), -//! import.change_descriptor().as_ref(), -//! Network::Testnet, +//! import.descriptor()?, +//! None, +//! import.network(), //! MemoryDatabase::default(), //! )?; //! # Ok::<_, bdk::Error>(()) @@ -43,17 +65,21 @@ //! ``` //! # use bitcoin::*; //! # use bdk::database::*; -//! # use bdk::wallet::fully_noded::*; +//! # use bdk::wallet::export::caravan::*; //! # use bdk::*; //! let wallet = Wallet::new( -//! "wpkh([c258d2e4/84h/1h/0h]tpubDD3ynpHgJQW8VvWRzQ5WFDCrs4jqVFGHB3vLC3r49XHJSqP8bHKdK4AriuUKLccK68zfzowx7YhmDN8SiSkgCDENUFx9qVw65YyqM78vyVe/0/*)", -//! Some("wpkh([c258d2e4/84h/1h/0h]tpubDD3ynpHgJQW8VvWRzQ5WFDCrs4jqVFGHB3vLC3r49XHJSqP8bHKdK4AriuUKLccK68zfzowx7YhmDN8SiSkgCDENUFx9qVw65YyqM78vyVe/1/*)"), +//! "wsh(sortedmulti(2,[f57ec65d/48'/1'/100'/2']tpubDFc9Mm4tw6EkgR4YTC1GrU6CGEd9yw7KSBnSssL4LXAXh89D4uMZigRyv3csdXbeU3BhLQc4vWKTLewboA1Pt8Fu6fbHKu81MZ6VGdc32eM/0/*,[efa5d916/48'/1'/100'/2']tpubDErWN5qfdLwYE94mh12oWr4uURDDNKCjKVhCEcAgZ7jKnnAwq5tcTF2iEk3VuznkJuk2G8SCHft9gS6aKbBd18ptYWPqKLRSTRQY7e2rrDj/0/*))#nv5k65uf", +//! None, //! Network::Testnet, //! MemoryDatabase::default() //! )?; -//! let export = FullyNodedExport::export_wallet(&wallet, "exported wallet", true) -//! .map_err(ToString::to_string) -//! .map_err(bdk::Error::Generic)?; +//! +//! let name = "P2WSH-T".to_string(); +//! let client = "public".to_string(); +//! let network = wallet.network(); +//! let descriptor = wallet.get_descriptor_for_keychain(KeychainKind::External); +//! +//! let export = CaravanExport::export(name, client, network, &descriptor)?; //! //! println!("Exported: {}", export.to_string()); //! # Ok::<_, bdk::Error>(()) @@ -63,12 +89,16 @@ use std::str::FromStr; use serde::{Deserialize, Serialize}; -use miniscript::descriptor::{ShInner, WshInner}; -use miniscript::{Descriptor, ScriptContext, Terminal}; +use crate::bitcoin::util::bip32::{ChildNumber, DerivationPath, ExtendedPubKey, Fingerprint}; +use crate::bitcoin::Network; +use miniscript::{Descriptor, ScriptContext}; -use crate::database::BatchDatabase; -use crate::types::KeychainKind; -use crate::wallet::Wallet; +use crate::descriptor; +use crate::descriptor::{DescriptorError, DescriptorPublicKey, Legacy, Segwitv0}; +use crate::error::Error; +use crate::keys::{DerivableKey, DescriptorKey, SortedMultiVec}; +use crate::miniscript::descriptor::{ShInner, WshInner}; +use crate::miniscript::MiniscriptKey; /// Alias for [`FullyNodedExport`] #[deprecated(since = "0.18.0", note = "Please use [`FullyNodedExport`] instead")] @@ -76,295 +106,342 @@ pub type WalletExport = FullyNodedExport; /// Structure that contains the export of a wallet /// -/// For a usage example see [this module](crate::wallet::export)'s documentation. +/// For a usage example see [this module](crate::wallet::export::caravan)'s documentation. #[derive(Debug, Serialize, Deserialize)] -pub struct FullyNodedExport { - descriptor: String, - /// Earliest block to rescan when looking for the wallet's transactions - pub blockheight: u32, - /// Arbitrary label for the wallet - pub label: String, -} - -impl ToString for FullyNodedExport { - fn to_string(&self) -> String { - serde_json::to_string(self).unwrap() - } +pub struct CaravanExport { + pub name: String, + #[serde(rename = "addressType")] + pub address_type: CaravanAddressType, + network: CaravanNetwork, + pub client: CaravanClient, + pub quorum: Quorum, + #[serde(rename = "extendedPublicKeys")] + pub extended_public_keys: Vec, + #[serde(rename = "startingAddressIndex")] + pub starting_address_index: u32, } -impl FromStr for FullyNodedExport { - type Err = serde_json::Error; - - fn from_str(s: &str) -> Result { - serde_json::from_str(s) +impl CaravanExport { + pub fn network(&self) -> Network { + match self.network { + CaravanNetwork::Mainnet => Network::Bitcoin, + CaravanNetwork::Testnet => Network::Testnet, + } } -} -fn remove_checksum(s: String) -> String { - s.split_once('#').map(|(a, _)| String::from(a)).unwrap() -} + pub fn descriptor(&self) -> Result, Error> { + let required = self.quorum.required_signers; + let network: Network = self.network(); -impl FullyNodedExport { - /// Export a wallet - /// - /// This function returns an error if it determines that the `wallet`'s descriptor(s) are not - /// supported by Bitcoin Core or don't follow the standard derivation paths defined by BIP44 - /// and others. - /// - /// If `include_blockheight` is `true`, this function will look into the `wallet`'s database - /// for the oldest transaction it knows and use that as the earliest block to rescan. - /// - /// If the database is empty or `include_blockheight` is false, the `blockheight` field - /// returned will be `0`. - pub fn export_wallet( - wallet: &Wallet, - label: &str, - include_blockheight: bool, - ) -> Result { - let descriptor = wallet - .get_descriptor_for_keychain(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 = match wallet.database.borrow().iter_txs(false) { - _ if !include_blockheight => 0, - Err(_) => 0, - Ok(txs) => { - let mut heights = txs - .into_iter() - .map(|tx| tx.confirmation_time.map(|c| c.height).unwrap_or(0)) - .collect::>(); - heights.sort_unstable(); - - *heights.last().unwrap_or(&0) + let result = match self.address_type { + CaravanAddressType::P2sh => { + let keys: Vec> = self.descriptor_keys()?; + descriptor! { sh ( sortedmulti_vec(required, keys) ) } } - }; - - let export = FullyNodedExport { - descriptor, - label: label.into(), - blockheight, - }; - - let change_descriptor = match wallet - .public_descriptor(KeychainKind::Internal) - .map_err(|_| "Invalid change descriptor")? - .is_some() - { - false => None, - true => { - let descriptor = wallet - .get_descriptor_for_keychain(KeychainKind::Internal) - .to_string_with_secret( - &wallet - .get_signers(KeychainKind::Internal) - .as_key_map(wallet.secp_ctx()), - ); - Some(remove_checksum(descriptor)) + CaravanAddressType::P2shP2wsh => { + let keys: Vec> = self.descriptor_keys()?; + descriptor! { sh ( wsh ( sortedmulti_vec(required, keys) ) ) } } - }; - if export.change_descriptor() != change_descriptor { - return Err("Incompatible change descriptor"); + CaravanAddressType::P2wsh => { + let keys: Vec> = self.descriptor_keys()?; + descriptor! { wsh ( sortedmulti_vec(required, keys) ) } + } + } + .map_err(|e| Error::Descriptor(e)); + + match result { + Ok((d, _, n)) => { + if n.contains(&network) { + Ok(d) + } else { + Err(Error::InvalidNetwork { + requested: network, + found: *n.iter().last().expect("network"), + }) + } + } + Err(e) => Err(e), } + } - Ok(export) + fn descriptor_keys( + &self, + ) -> Result>, DescriptorError> { + let result = self + .extended_public_keys + .iter() + .map(|k| { + let fingerprint = k.xfp; + let key_path = k.clone().bip32_path; + let mut key_source = None; + if let (Some(fp), Some(kp)) = (fingerprint, key_path) { + key_source = Some((fp, kp)) + }; + let derivation_path = + DerivationPath::master().child(ChildNumber::Normal { index: 0 }); + k.xpub + .into_descriptor_key(key_source, derivation_path) + .map_err(|e| DescriptorError::Key(e)) + }) + .collect(); + result } - fn is_compatible_with_core(descriptor: &str) -> Result<(), &'static str> { - fn check_ms( - terminal: &Terminal, - ) -> Result<(), &'static str> { - if let Terminal::Multi(_, _) = terminal { - Ok(()) - } else { - Err("The descriptor contains operators not supported by Bitcoin Core") - } - } + fn parse_sorted_multi( + sorted_multi: &SortedMultiVec, + ) -> (Quorum, Vec) { + let quorum = Quorum { + required_signers: sorted_multi.k, + total_signers: sorted_multi.pks.len(), + }; + let extended_public_keys = sorted_multi.pks.clone(); + (quorum, extended_public_keys) + } - // pkh(), wpkh(), sh(wpkh()) are always fine, as well as multi() and sortedmulti() - match Descriptor::::from_str(descriptor).map_err(|_| "Invalid descriptor")? { - Descriptor::Pkh(_) | Descriptor::Wpkh(_) => Ok(()), + pub fn export( + name: String, + client_type: String, + network: Network, + descriptor: &Descriptor, + ) -> Result { + let (address_type, quorum, descriptor_public_keys) = match descriptor { Descriptor::Sh(sh) => match sh.as_inner() { - ShInner::Wpkh(_) => Ok(()), - ShInner::SortedMulti(_) => Ok(()), + ShInner::SortedMulti(smv) => { + let (quorum, extended_public_keys) = CaravanExport::parse_sorted_multi(smv); + Ok((CaravanAddressType::P2sh, quorum, extended_public_keys)) + } ShInner::Wsh(wsh) => match wsh.as_inner() { - WshInner::SortedMulti(_) => Ok(()), - WshInner::Ms(ms) => check_ms(&ms.node), + WshInner::SortedMulti(smv) => { + let (quorum, extended_public_keys) = CaravanExport::parse_sorted_multi(smv); + Ok((CaravanAddressType::P2shP2wsh, quorum, extended_public_keys)) + } + _ => Err(Error::Generic( + "Unsupported sh(wsh()) inner descriptor.".to_string(), + )), }, - ShInner::Ms(ms) => check_ms(&ms.node), + _ => Err(Error::Generic( + "Unsupported sh() inner descriptor.".to_string(), + )), }, - Descriptor::Wsh(wsh) => match wsh.as_inner() { - WshInner::SortedMulti(_) => Ok(()), - WshInner::Ms(ms) => check_ms(&ms.node), + Descriptor::Wsh(sh) => match sh.as_inner() { + WshInner::SortedMulti(smv) => { + let (quorum, extended_public_keys) = CaravanExport::parse_sorted_multi(smv); + Ok((CaravanAddressType::P2wsh, quorum, extended_public_keys)) + } + _ => Err(Error::Generic( + "Unsupported wsh() inner descriptor.".to_string(), + )), }, - _ => Err("The descriptor is not compatible with Bitcoin Core"), - } - } - - /// Return the external descriptor - pub fn descriptor(&self) -> String { - self.descriptor.clone() - } - - /// Return the internal descriptor, if present - pub fn change_descriptor(&self) -> Option { - let replaced = self.descriptor.replace("/0/*", "/1/*"); - - if replaced != self.descriptor { - Some(replaced) - } else { - None - } + _ => Err(Error::Generic( + "Unsupported top level descriptor.".to_string(), + )), + }?; + + let network = match network { + Network::Bitcoin => CaravanNetwork::Mainnet, + _ => CaravanNetwork::Testnet, + }; + let client = CaravanClient { value: client_type }; + let extended_public_keys: Vec = descriptor_public_keys + .iter() + .map(|k| match k { + DescriptorPublicKey::SinglePub(_) => { + Err(Error::Generic("Unsupported single pub key.".to_string())) + } + DescriptorPublicKey::XPub(xpub) => { + let mut xfp = None; + let mut bip32_path = None; + if let Some((s_xfp, s_bip32_path)) = xpub.clone().origin { + xfp = Some(s_xfp); + bip32_path = Some(s_bip32_path); + } + Ok(CaravanExtendedPublicKey { + name: xpub.xkey.fingerprint().to_string(), + bip32_path, + xpub: xpub.xkey, + xfp, + }) + } + }) + .flatten() + .collect::>(); + + Ok(Self { + name, + address_type, + network, + client, + quorum, + extended_public_keys, + starting_address_index: 0, + }) } } -#[cfg(test)] -mod test { - use std::str::FromStr; +#[derive(Debug, Serialize, Deserialize)] +pub enum CaravanAddressType { + #[serde(rename = "P2SH")] + P2sh, + #[serde(rename = "P2SH-P2WSH")] + P2shP2wsh, + #[serde(rename = "P2WSH")] + P2wsh, +} - use bitcoin::{Network, Txid}; +#[derive(Debug, Serialize, Deserialize)] +enum CaravanNetwork { + #[serde(rename = "mainnet")] + Mainnet, + #[serde(rename = "testnet")] + Testnet, +} - use super::*; - use crate::database::{memory::MemoryDatabase, BatchOperations}; - use crate::types::TransactionDetails; - use crate::wallet::Wallet; - use crate::BlockTime; - - fn get_test_db() -> MemoryDatabase { - let mut db = MemoryDatabase::new(); - db.set_tx(&TransactionDetails { - transaction: None, - txid: Txid::from_str( - "4ddff1fa33af17f377f62b72357b43107c19110a8009b36fb832af505efed98a", - ) - .unwrap(), - - received: 100_000, - sent: 0, - fee: Some(500), - confirmation_time: Some(BlockTime { - timestamp: 12345678, - height: 5000, - }), - }) - .unwrap(); +#[derive(Debug, Serialize, Deserialize)] +pub struct CaravanClient { + #[serde(rename = "type")] + value: String, +} - db - } +#[derive(Debug, Serialize, Deserialize)] +pub struct Quorum { + #[serde(rename = "requiredSigners")] + required_signers: usize, + #[serde(rename = "totalSigners")] + total_signers: usize, +} - #[test] - fn test_export_bip44() { - let descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44'/0'/0'/0/*)"; - let change_descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44'/0'/0'/1/*)"; +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CaravanExtendedPublicKey { + name: String, + #[serde(rename = "bip32Path")] + bip32_path: Option, + xpub: ExtendedPubKey, + xfp: Option, +} - let wallet = Wallet::new( - descriptor, - Some(change_descriptor), - Network::Bitcoin, - get_test_db(), - ) - .unwrap(); - 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"); +impl ToString for CaravanExport { + fn to_string(&self) -> String { + serde_json::to_string(self).unwrap() } +} - #[test] - #[should_panic(expected = "Incompatible change descriptor")] - fn test_export_no_change() { - // This wallet explicitly doesn't have a change descriptor. It should be impossible to - // export, because exporting this kind of external descriptor normally implies the - // existence of an internal descriptor - - let descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44'/0'/0'/0/*)"; +impl FromStr for CaravanExport { + type Err = serde_json::Error; - let wallet = Wallet::new(descriptor, None, Network::Bitcoin, get_test_db()).unwrap(); - FullyNodedExport::export_wallet(&wallet, "Test Label", true).unwrap(); + fn from_str(s: &str) -> Result { + serde_json::from_str(s) } +} - #[test] - #[should_panic(expected = "Incompatible change descriptor")] - fn test_export_incompatible_change() { - // This wallet has a change descriptor, but the derivation path is not in the "standard" - // bip44/49/etc format +#[cfg(test)] +mod test { + use std::str::FromStr; - let descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/44'/0'/0'/0/*)"; - let change_descriptor = "wpkh(xprv9s21ZrQH143K4CTb63EaMxja1YiTnSEWKMbn23uoEnAzxjdUJRQkazCAtzxGm4LSoTSVTptoV9RbchnKPW9HxKtZumdyxyikZFDLhogJ5Uj/50'/0'/1/*)"; + use crate::bitcoin::Address; - let wallet = Wallet::new( - descriptor, - Some(change_descriptor), - Network::Bitcoin, - get_test_db(), - ) - .unwrap(); - FullyNodedExport::export_wallet(&wallet, "Test Label", true).unwrap(); - } + use super::*; + use crate::database::memory::MemoryDatabase; + use crate::wallet::{AddressIndex, Wallet}; + use crate::KeychainKind; #[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 = Wallet::new( - descriptor, - Some(change_descriptor), - Network::Testnet, - get_test_db(), - ) - .unwrap(); - 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"); + fn test_import_from_json() { + let import_json = r#"{ + "name": "P2WSH-T", + "addressType": "P2WSH", + "network": "testnet", + "client": { + "type": "public" + }, + "quorum": { + "requiredSigners": 2, + "totalSigners": 2 + }, + "extendedPublicKeys": [ + { + "name": "osw", + "bip32Path": "m/48'/1'/100'/2'", + "xpub": "tpubDFc9Mm4tw6EkgR4YTC1GrU6CGEd9yw7KSBnSssL4LXAXh89D4uMZigRyv3csdXbeU3BhLQc4vWKTLewboA1Pt8Fu6fbHKu81MZ6VGdc32eM", + "xfp" : "f57ec65d" + }, + { + "name": "d", + "bip32Path": "m/48'/1'/100'/2'", + "xpub": "tpubDErWN5qfdLwYE94mh12oWr4uURDDNKCjKVhCEcAgZ7jKnnAwq5tcTF2iEk3VuznkJuk2G8SCHft9gS6aKbBd18ptYWPqKLRSTRQY7e2rrDj", + "xfp" : "efa5d916" + } + ], + "startingAddressIndex": 0 + }"#; + + let import = CaravanExport::from_str(import_json).expect("import"); + let descriptor = import.descriptor().expect("descriptor"); + + println!("descriptor: {}", descriptor); + + let wallet = + Wallet::new(descriptor, None, import.network(), MemoryDatabase::new()).expect("wallet"); + let expected_address_0 = + Address::from_str("tb1qhgj3fnwn50pq966rjnj4pg8uz9ktsd8nge32qxd73ffvvg636p5q54g7m0") + .expect("address[0]"); + assert_eq!( + wallet.get_address(AddressIndex::Peek(0)).unwrap().address, + expected_address_0 + ); + let expected_address_9 = + Address::from_str("tb1qxzw9q520f3ee6hjpc6wc7jrh7wxfgvundhfclqv8w7gtdd2srwns4krnc0") + .expect("address[9]"); + assert_eq!( + wallet.get_address(AddressIndex::Peek(9)).unwrap().address, + expected_address_9 + ); } #[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 = Wallet::new( - descriptor, - Some(change_descriptor), - Network::Bitcoin, - get_test_db(), - ) - .unwrap(); - 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"); + "wsh(sortedmulti(2,[f57ec65d/48'/1'/100'/2']tpubDFc9Mm4tw6EkgR4YTC1GrU6CGEd9yw7KSBnSssL4LXAXh89D4uMZigRyv3csdXbeU3BhLQc4vWKTLewboA1Pt8Fu6fbHKu81MZ6VGdc32eM/0/*,[efa5d916/48'/1'/100'/2']tpubDErWN5qfdLwYE94mh12oWr4uURDDNKCjKVhCEcAgZ7jKnnAwq5tcTF2iEk3VuznkJuk2G8SCHft9gS6aKbBd18ptYWPqKLRSTRQY7e2rrDj/0/*))#nv5k65uf", + None, + Network::Testnet, + MemoryDatabase::default() + ).expect("wallet"); + + let name = "P2WSH-T".to_string(); + let client = "public".to_string(); + let network = wallet.network(); + let descriptor = wallet.get_descriptor_for_keychain(KeychainKind::External); + + let export = CaravanExport::export(name, client, network, &descriptor).expect("export"); + + println!("Exported: {}", export.to_string()); + + // NOTE: .extendedPublicKeys[].name fields are set to key hash and are not expected + let expected_export = json!({ + "name": "P2WSH-T", + "addressType": "P2WSH", + "network": "testnet", + "client": { + "type": "public" + }, + "quorum": { + "requiredSigners": 2, + "totalSigners": 2 + }, + "extendedPublicKeys": [ + { + "bip32Path": "m/48'/1'/100'/2'", + "xpub": "tpubDFc9Mm4tw6EkgR4YTC1GrU6CGEd9yw7KSBnSssL4LXAXh89D4uMZigRyv3csdXbeU3BhLQc4vWKTLewboA1Pt8Fu6fbHKu81MZ6VGdc32eM", + "xfp" : "f57ec65d" + }, + { + "bip32Path": "m/48'/1'/100'/2'", + "xpub": "tpubDErWN5qfdLwYE94mh12oWr4uURDDNKCjKVhCEcAgZ7jKnnAwq5tcTF2iEk3VuznkJuk2G8SCHft9gS6aKbBd18ptYWPqKLRSTRQY7e2rrDj", + "xfp" : "efa5d916" + } + ], + "startingAddressIndex": 0 + }); + use assert_json_diff::assert_json_include; + assert_json_include!(actual: &export, expected: expected_export); } } diff --git a/src/wallet/export/fully_noded.rs b/src/wallet/export/fully_noded.rs index 1fd125cb4..f346fe084 100644 --- a/src/wallet/export/fully_noded.rs +++ b/src/wallet/export/fully_noded.rs @@ -1,7 +1,7 @@ // Bitcoin Dev Kit // Written in 2020 by Alekos Filini // -// Copyright (c) 2020-2021 Bitcoin Dev Kit Developers +// Copyright (c) 2020-2022 Bitcoin Dev Kit Developers // // This file is licensed under the Apache License, Version 2.0 or the MIT license @@ -21,7 +21,7 @@ //! # use std::str::FromStr; //! # use bitcoin::*; //! # use bdk::database::*; -//! # use bdk::wallet::fully_noded::*; +//! # use bdk::wallet::export::fully_noded::*; //! # use bdk::*; //! let import = r#"{ //! "descriptor": "wpkh([c258d2e4\/84h\/1h\/0h]tpubDD3ynpHgJQW8VvWRzQ5WFDCrs4jqVFGHB3vLC3r49XHJSqP8bHKdK4AriuUKLccK68zfzowx7YhmDN8SiSkgCDENUFx9qVw65YyqM78vyVe\/0\/*)", @@ -43,7 +43,7 @@ //! ``` //! # use bitcoin::*; //! # use bdk::database::*; -//! # use bdk::wallet::fully_noded::*; +//! # use bdk::wallet::export::fully_noded::*; //! # use bdk::*; //! let wallet = Wallet::new( //! "wpkh([c258d2e4/84h/1h/0h]tpubDD3ynpHgJQW8VvWRzQ5WFDCrs4jqVFGHB3vLC3r49XHJSqP8bHKdK4AriuUKLccK68zfzowx7YhmDN8SiSkgCDENUFx9qVw65YyqM78vyVe/0/*)", diff --git a/src/wallet/export/mod.rs b/src/wallet/export/mod.rs index cb701ffc4..c8946b2b1 100644 --- a/src/wallet/export/mod.rs +++ b/src/wallet/export/mod.rs @@ -10,4 +10,5 @@ //! This module contains submodules that implement various wallet export formats. -pub mod fully_noded; \ No newline at end of file +pub mod caravan; +pub mod fully_noded; From 7bb16720667a9310802aef22f15da6f3ad3822b1 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Thu, 28 Apr 2022 22:24:21 -0700 Subject: [PATCH 3/6] Add basic docs for wallet::export::caravan module --- CHANGELOG.md | 1 + src/wallet/export/caravan.rs | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8bf8a04d..b50b2d2cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix hang when `ElectrumBlockchainConfig::stop_gap` is zero. - Set coin type in BIP44, BIP49, and BIP84 templates - Get block hash given a block height - A `get_block_hash` method is now defined on the `GetBlockHash` trait and implemented on every blockchain backend. This method expects a block height and returns the corresponding block hash. +- Add wallet::export::caravan module for importing and exporting Caravan configurations ## [v0.19.0] - [v0.18.0] diff --git a/src/wallet/export/caravan.rs b/src/wallet/export/caravan.rs index 56f4464e5..0759816f0 100644 --- a/src/wallet/export/caravan.rs +++ b/src/wallet/export/caravan.rs @@ -109,26 +109,34 @@ pub type WalletExport = FullyNodedExport; /// For a usage example see [this module](crate::wallet::export::caravan)'s documentation. #[derive(Debug, Serialize, Deserialize)] pub struct CaravanExport { + /// Vault name pub name: String, + /// Caravan address type, #[serde(rename = "addressType")] pub address_type: CaravanAddressType, + /// Caravan network network: CaravanNetwork, + /// Caravan client pub client: CaravanClient, + /// Signing quorum pub quorum: Quorum, + /// Extended public keys #[serde(rename = "extendedPublicKeys")] pub extended_public_keys: Vec, + /// Starting address index, always 0 when exporting #[serde(rename = "startingAddressIndex")] pub starting_address_index: u32, } impl CaravanExport { + /// Get the bitcoin network value pub fn network(&self) -> Network { match self.network { CaravanNetwork::Mainnet => Network::Bitcoin, CaravanNetwork::Testnet => Network::Testnet, } } - + /// Get the descriptor value pub fn descriptor(&self) -> Result, Error> { let required = self.quorum.required_signers; let network: Network = self.network(); @@ -198,6 +206,7 @@ impl CaravanExport { (quorum, extended_public_keys) } + /// Export BDK wallet configuration as a Caravan configuration pub fn export( name: String, client_type: String, @@ -278,16 +287,21 @@ impl CaravanExport { } } +/// The address types supported by Caravan #[derive(Debug, Serialize, Deserialize)] pub enum CaravanAddressType { + /// P2SH #[serde(rename = "P2SH")] P2sh, + /// P2SH-P2WSH #[serde(rename = "P2SH-P2WSH")] P2shP2wsh, + /// P2WSH #[serde(rename = "P2WSH")] P2wsh, } +/// The networks supported by Caravan #[derive(Debug, Serialize, Deserialize)] enum CaravanNetwork { #[serde(rename = "mainnet")] @@ -296,12 +310,15 @@ enum CaravanNetwork { Testnet, } +/// A caravan client #[derive(Debug, Serialize, Deserialize)] pub struct CaravanClient { + /// The client type value #[serde(rename = "type")] value: String, } +/// The quorum of signers required and total signers #[derive(Debug, Serialize, Deserialize)] pub struct Quorum { #[serde(rename = "requiredSigners")] @@ -310,6 +327,7 @@ pub struct Quorum { total_signers: usize, } +/// The Caravan extended public key information #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CaravanExtendedPublicKey { name: String, From 04a9b05bf7823ab7d757776ca984f964b9d688e9 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Fri, 29 Apr 2022 14:48:44 -0700 Subject: [PATCH 4/6] Add suggested changes from code review --- src/wallet/export/caravan.rs | 48 ++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/src/wallet/export/caravan.rs b/src/wallet/export/caravan.rs index 0759816f0..03c3d0cc9 100644 --- a/src/wallet/export/caravan.rs +++ b/src/wallet/export/caravan.rs @@ -79,7 +79,7 @@ //! let network = wallet.network(); //! let descriptor = wallet.get_descriptor_for_keychain(KeychainKind::External); //! -//! let export = CaravanExport::export(name, client, network, &descriptor)?; +//! let export = CaravanExport::export_wallet(&wallet, name, client)?; //! //! println!("Exported: {}", export.to_string()); //! # Ok::<_, bdk::Error>(()) @@ -93,12 +93,13 @@ use crate::bitcoin::util::bip32::{ChildNumber, DerivationPath, ExtendedPubKey, F use crate::bitcoin::Network; use miniscript::{Descriptor, ScriptContext}; -use crate::descriptor; +use crate::database::BatchDatabase; use crate::descriptor::{DescriptorError, DescriptorPublicKey, Legacy, Segwitv0}; use crate::error::Error; use crate::keys::{DerivableKey, DescriptorKey, SortedMultiVec}; use crate::miniscript::descriptor::{ShInner, WshInner}; use crate::miniscript::MiniscriptKey; +use crate::{descriptor, KeychainKind, Wallet}; /// Alias for [`FullyNodedExport`] #[deprecated(since = "0.18.0", note = "Please use [`FullyNodedExport`] instead")] @@ -108,11 +109,11 @@ pub type WalletExport = FullyNodedExport; /// /// For a usage example see [this module](crate::wallet::export::caravan)'s documentation. #[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct CaravanExport { /// Vault name pub name: String, /// Caravan address type, - #[serde(rename = "addressType")] pub address_type: CaravanAddressType, /// Caravan network network: CaravanNetwork, @@ -121,10 +122,8 @@ pub struct CaravanExport { /// Signing quorum pub quorum: Quorum, /// Extended public keys - #[serde(rename = "extendedPublicKeys")] pub extended_public_keys: Vec, /// Starting address index, always 0 when exporting - #[serde(rename = "startingAddressIndex")] pub starting_address_index: u32, } @@ -180,11 +179,8 @@ impl CaravanExport { .iter() .map(|k| { let fingerprint = k.xfp; - let key_path = k.clone().bip32_path; - let mut key_source = None; - if let (Some(fp), Some(kp)) = (fingerprint, key_path) { - key_source = Some((fp, kp)) - }; + let key_path = k.bip32_path.clone(); + let key_source = fingerprint.zip(key_path); let derivation_path = DerivationPath::master().child(ChildNumber::Normal { index: 0 }); k.xpub @@ -197,21 +193,38 @@ impl CaravanExport { fn parse_sorted_multi( sorted_multi: &SortedMultiVec, - ) -> (Quorum, Vec) { + ) -> (Quorum, &[Pk]) { let quorum = Quorum { required_signers: sorted_multi.k, total_signers: sorted_multi.pks.len(), }; - let extended_public_keys = sorted_multi.pks.clone(); + let extended_public_keys = sorted_multi.pks.as_slice(); (quorum, extended_public_keys) } /// Export BDK wallet configuration as a Caravan configuration - pub fn export( + pub fn export_wallet( + wallet: &Wallet, name: String, client_type: String, + ) -> Result { + let network = wallet.network; + let descriptor = wallet.get_descriptor_for_keychain(KeychainKind::External); + if wallet.change_descriptor.is_none() { + Self::export(network, descriptor, name, client_type) + } else { + Err(Error::Generic( + "Can not export a wallet with a change descriptor to Caravan.".to_string(), + )) + } + } + + /// Export BDK wallet network and descriptor as a Caravan configuration + pub fn export( network: Network, descriptor: &Descriptor, + name: String, + client_type: String, ) -> Result { let (address_type, quorum, descriptor_public_keys) = match descriptor { Descriptor::Sh(sh) => match sh.as_inner() { @@ -251,7 +264,7 @@ impl CaravanExport { _ => CaravanNetwork::Testnet, }; let client = CaravanClient { value: client_type }; - let extended_public_keys: Vec = descriptor_public_keys + let extended_public_keys = descriptor_public_keys .iter() .map(|k| match k { DescriptorPublicKey::SinglePub(_) => { @@ -273,7 +286,7 @@ impl CaravanExport { } }) .flatten() - .collect::>(); + .collect(); Ok(Self { name, @@ -303,10 +316,9 @@ pub enum CaravanAddressType { /// The networks supported by Caravan #[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] enum CaravanNetwork { - #[serde(rename = "mainnet")] Mainnet, - #[serde(rename = "testnet")] Testnet, } @@ -429,7 +441,7 @@ mod test { let network = wallet.network(); let descriptor = wallet.get_descriptor_for_keychain(KeychainKind::External); - let export = CaravanExport::export(name, client, network, &descriptor).expect("export"); + let export = CaravanExport::export(network, &descriptor, name, client).expect("export"); println!("Exported: {}", export.to_string()); From 5b33a59e1bf713574cd5747b7906abc48ae13469 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Fri, 29 Apr 2022 16:55:44 -0700 Subject: [PATCH 5/6] Cleanup and add more wallet::export::caravan tests --- src/wallet/export/caravan.rs | 470 +++++++++++++++++++++++++++++++---- 1 file changed, 428 insertions(+), 42 deletions(-) diff --git a/src/wallet/export/caravan.rs b/src/wallet/export/caravan.rs index 03c3d0cc9..29039d15c 100644 --- a/src/wallet/export/caravan.rs +++ b/src/wallet/export/caravan.rs @@ -372,10 +372,422 @@ mod test { use super::*; use crate::database::memory::MemoryDatabase; use crate::wallet::{AddressIndex, Wallet}; - use crate::KeychainKind; + use assert_json_diff::assert_json_include; + use serde_json::Value; + + fn test_import(import_json: &str, expected_addresses: Vec<&str>) { + let import = CaravanExport::from_str(import_json).expect("import"); + let descriptor = import.descriptor().expect("descriptor"); + + println!("descriptor: {}", descriptor); + + let wallet = + Wallet::new(descriptor, None, import.network(), MemoryDatabase::new()).expect("wallet"); + + for (index, expected_address) in expected_addresses.iter().enumerate() { + let expected_address = Address::from_str(expected_address).expect("address"); + assert_eq!( + wallet + .get_address(AddressIndex::Peek(index as u32)) + .unwrap() + .address, + expected_address + ); + } + } + + fn test_export(network: Network, descriptor: &str, name: &str, expected_export_json: &str) { + let wallet = + Wallet::new(descriptor, None, network, MemoryDatabase::default()).expect("wallet"); + + let export = CaravanExport::export_wallet(&wallet, name.to_string(), "public".to_string()) + .expect("export"); + + println!("Exported: {}", export.to_string()); + + // NOTE: .extendedPublicKeys[].name fields are set to key hash and are not expected + let expected_export: Value = + serde_json::from_str(expected_export_json).expect("expected export"); + assert_json_include!(actual: export, expected: expected_export); + } + + #[test] + fn test_import_p2sh_m() { + let import_json = r#"{ + "name": "P2SH-M", + "addressType": "P2SH", + "network": "mainnet", + "client": { + "type": "public" + }, + "quorum": { + "requiredSigners": 2, + "totalSigners": 2 + }, + "extendedPublicKeys": [ + { + "name": "osw", + "bip32Path": "m/45'/0'/100'", + "xpub": "xpub6CCHViYn5VzPfSR7baop9FtGcbm3UnqHwa54Z2eNvJnRFCJCdo9HtCYoLJKZCoATMLUowDDA1BMGfQGauY3fDYU3HyMzX4NDkoLYCSkLpbH", + "xfp" : "f57ec65d" + }, + { + "name": "d", + "bip32Path": "m/45'/0'/100'", + "xpub": "xpub6Ca5CwTgRASgkXbXE5TeddTP9mPCbYHreCpmGt9dhz9y6femstHGCoFESHHKKRcm414xMKnuLjP9LDS7TwaJC9n5gxua6XB1rwPcC6hqDub", + "xfp" : "efa5d916" + } + ], + "startingAddressIndex": 0 + }"#; + + test_import( + import_json, + vec![ + "3PiCF26aq57Wo5DJEbFNTVwD1bLCUEpAYZ", + "3EvHiVyDVoLjeZNMt3v1QTQfs2P4ohVwmg", + "3PSAx42y6hzWvx2QxQon7CymauWs2SZXuA", + ], + ); + } + + #[test] + fn test_export_p2sh_m() { + let descriptor = "sh(sortedmulti(2,[f57ec65d/45'/0'/100']xpub6CCHViYn5VzPfSR7baop9FtGcbm3UnqHwa54Z2eNvJnRFCJCdo9HtCYoLJKZCoATMLUowDDA1BMGfQGauY3fDYU3HyMzX4NDkoLYCSkLpbH/0/*,[efa5d916/45'/0'/100']xpub6Ca5CwTgRASgkXbXE5TeddTP9mPCbYHreCpmGt9dhz9y6femstHGCoFESHHKKRcm414xMKnuLjP9LDS7TwaJC9n5gxua6XB1rwPcC6hqDub/0/*))#uxj9xxul"; + let name = "P2SH-M"; + + // NOTE: .extendedPublicKeys[].name fields are set to key hash and are not expected + let expected_export_json = r#"{ + "name": "P2SH-M", + "addressType": "P2SH", + "network": "mainnet", + "client": { + "type": "public" + }, + "quorum": { + "requiredSigners": 2, + "totalSigners": 2 + }, + "extendedPublicKeys": [ + { + "bip32Path": "m/45'/0'/100'", + "xpub": "xpub6CCHViYn5VzPfSR7baop9FtGcbm3UnqHwa54Z2eNvJnRFCJCdo9HtCYoLJKZCoATMLUowDDA1BMGfQGauY3fDYU3HyMzX4NDkoLYCSkLpbH", + "xfp" : "f57ec65d" + }, + { + "bip32Path": "m/45'/0'/100'", + "xpub": "xpub6Ca5CwTgRASgkXbXE5TeddTP9mPCbYHreCpmGt9dhz9y6femstHGCoFESHHKKRcm414xMKnuLjP9LDS7TwaJC9n5gxua6XB1rwPcC6hqDub", + "xfp" : "efa5d916" + } + ], + "startingAddressIndex": 0 + }"#; + + test_export(Network::Bitcoin, descriptor, name, expected_export_json); + } + + #[test] + fn test_import_p2sh_t() { + let import_json = r#"{ + "name": "P2SH-T", + "addressType": "P2SH", + "network": "testnet", + "client": { + "type": "public" + }, + "quorum": { + "requiredSigners": 2, + "totalSigners": 2 + }, + "extendedPublicKeys": [ + { + "name": "dev", + "bip32Path": "m/45'/1'/100'", + "xpub": "tpubDDinbKDXyddTUKcX6mv936Ux5utCJteq5S6EEKhfpM8CqN2rMAcccv6GecsB3cPt8eGL4e4K2eaZ9Jis9TGf7mbwBsRTN7ngnFR7yJZxBKC", + "xfp" : "efa5d916" + }, + { + "name": "osw", + "bip32Path": "m/45'/1'/100'", + "xpub": "tpubDDQubdBx9cbwQtdcRTisKF7wVCwHgHewhU7wh77VzCi62Q9q81qyQeLoZjKWZ62FnQbWU8k7CuKo2A21pAWaFtPGDHP9WuhtAx4smcCxqn1", + "xfp" : "f57ec65d" + } + ], + "startingAddressIndex": 0 + }"#; + + test_import( + import_json, + vec![ + "2N5KgAnFFpmk5TRMiCicRZDQS8FFNCKqKf1", + "2N5hHeNeqk72xkQiHWTHvmpVTpyuKynGrcH", + "2NC1zVgtFLBfc3UZvnhhjNAF15NmksNCZXe", + ], + ); + } + + #[test] + fn test_export_p2sh_t() { + let descriptor = "sh(sortedmulti(2,[efa5d916/45'/1'/100']tpubDDinbKDXyddTUKcX6mv936Ux5utCJteq5S6EEKhfpM8CqN2rMAcccv6GecsB3cPt8eGL4e4K2eaZ9Jis9TGf7mbwBsRTN7ngnFR7yJZxBKC/0/*,[f57ec65d/45'/1'/100']tpubDDQubdBx9cbwQtdcRTisKF7wVCwHgHewhU7wh77VzCi62Q9q81qyQeLoZjKWZ62FnQbWU8k7CuKo2A21pAWaFtPGDHP9WuhtAx4smcCxqn1/0/*))#e4qrgzdy"; + let name = "P2SH-T"; + + // NOTE: .extendedPublicKeys[].name fields are set to key hash and are not expected + let expected_export_json = r#"{ + "name": "P2SH-T", + "addressType": "P2SH", + "network": "testnet", + "client": { + "type": "public" + }, + "quorum": { + "requiredSigners": 2, + "totalSigners": 2 + }, + "extendedPublicKeys": [ + { + "bip32Path": "m/45'/1'/100'", + "xpub": "tpubDDinbKDXyddTUKcX6mv936Ux5utCJteq5S6EEKhfpM8CqN2rMAcccv6GecsB3cPt8eGL4e4K2eaZ9Jis9TGf7mbwBsRTN7ngnFR7yJZxBKC", + "xfp" : "efa5d916" + }, + { + "bip32Path": "m/45'/1'/100'", + "xpub": "tpubDDQubdBx9cbwQtdcRTisKF7wVCwHgHewhU7wh77VzCi62Q9q81qyQeLoZjKWZ62FnQbWU8k7CuKo2A21pAWaFtPGDHP9WuhtAx4smcCxqn1", + "xfp" : "f57ec65d" + } + ], + "startingAddressIndex": 0 + }"#; + + test_export(Network::Testnet, descriptor, name, expected_export_json); + } + + #[test] + fn test_import_p2sh_p2wsh_m() { + let import_json = r#"{ + "name": "P2SH-P2WSH-M", + "addressType": "P2SH-P2WSH", + "network": "mainnet", + "client": { + "type": "public" + }, + "quorum": { + "requiredSigners": 2, + "totalSigners": 2 + }, + "extendedPublicKeys": [ + { + "name": "d", + "bip32Path": "m/48'/0'/100'/1'", + "xpub": "xpub6EwJjKaiocGvo9f7XSGXGwzo1GLB1URxSZ5Ccp1wqdxNkhrSoqNQkC2CeMsU675urdmFJLHSX62xz56HGcnn6u21wRy6uipovmzaE65PfBp", + "xfp" : "efa5d916" + }, + { + "name": "osw", + "bip32Path": "m/48'/0'/100'/1'", + "xpub": "xpub6DcqYQxnbefzEBJF6osEuT5yXoHVZu1YCCsS5YkATvqD2h7tdMBgdBrUXk26FrJwawDGX6fHKPvhhZxKc5b8dPAPb8uANDhsjAPMJqTFDjH", + "xfp" : "f57ec65d" + } + ], + "startingAddressIndex": 0 + }"#; + + test_import( + import_json, + vec![ + "348PsXezZAHcW7RjmCoMJ8PHWx1QBTXJvm", + "3GFHyS5GGzTLJaJz6qeSjMrtQGLsbFG4Z8", + "34Gam7P9rrWwZTeF74WceJ2PGH9XCZTEi6", + ], + ); + } #[test] - fn test_import_from_json() { + fn test_export_p2sh_p2wsh_m() { + let descriptor = "sh(wsh(sortedmulti(2,[efa5d916/48'/0'/100'/1']xpub6EwJjKaiocGvo9f7XSGXGwzo1GLB1URxSZ5Ccp1wqdxNkhrSoqNQkC2CeMsU675urdmFJLHSX62xz56HGcnn6u21wRy6uipovmzaE65PfBp/0/*,[f57ec65d/48'/0'/100'/1']xpub6DcqYQxnbefzEBJF6osEuT5yXoHVZu1YCCsS5YkATvqD2h7tdMBgdBrUXk26FrJwawDGX6fHKPvhhZxKc5b8dPAPb8uANDhsjAPMJqTFDjH/0/*)))#jeqfd8lr"; + let name = "P2SH-P2WSH-M"; + + // NOTE: .extendedPublicKeys[].name fields are set to key hash and are not expected + let expected_export_json = r#"{ + "name": "P2SH-P2WSH-M", + "addressType": "P2SH-P2WSH", + "network": "mainnet", + "client": { + "type": "public" + }, + "quorum": { + "requiredSigners": 2, + "totalSigners": 2 + }, + "extendedPublicKeys": [ + { + "bip32Path": "m/48'/0'/100'/1'", + "xpub": "xpub6EwJjKaiocGvo9f7XSGXGwzo1GLB1URxSZ5Ccp1wqdxNkhrSoqNQkC2CeMsU675urdmFJLHSX62xz56HGcnn6u21wRy6uipovmzaE65PfBp", + "xfp" : "efa5d916" + }, + { + "bip32Path": "m/48'/0'/100'/1'", + "xpub": "xpub6DcqYQxnbefzEBJF6osEuT5yXoHVZu1YCCsS5YkATvqD2h7tdMBgdBrUXk26FrJwawDGX6fHKPvhhZxKc5b8dPAPb8uANDhsjAPMJqTFDjH", + "xfp" : "f57ec65d" + } + ], + "startingAddressIndex": 0 + }"#; + + test_export(Network::Bitcoin, descriptor, name, expected_export_json); + } + + #[test] + fn test_import_p2sh_p2wsh_t() { + let import_json = r#"{ + "name": "P2SH-P2WSH-T", + "addressType": "P2SH-P2WSH", + "network": "testnet", + "client": { + "type": "public" + }, + "quorum": { + "requiredSigners": 2, + "totalSigners": 2 + }, + "extendedPublicKeys": [ + { + "name": "osw", + "bip32Path": "m/48'/1'/100'/1'", + "xpub": "tpubDFc9Mm4tw6EkdXuk24MnQYRrDsdKEFh498vFffqa2KJmxytpcHbWrcFYwTKAdLxkSWpadzb5M5VVZ7PDAUjDjymvUmQ7pBbRecz2FM952Am", + "xfp" : "f57ec65d" + }, + { + "name": "d", + "bip32Path": "m/48'/1'/100'/1'", + "xpub": "tpubDErWN5qfdLwY9ZJo9HWpxjcuEFuEBVHSbQbPqF35LQr3etWNGirKcgAa93DZ4DmtHm36p2gTf4aj6KybLqHaS3UePM5LtPqtb3d3dYVDs2F", + "xfp" : "efa5d916" + } + ], + "startingAddressIndex": 0 + }"#; + + test_import( + import_json, + vec![ + "2NDBsV6VBe4d2Ukp2XB644dg2xZ2SuWGkyG", + "2N2HfmoavC1zjYKxU71Lp1YwCECHXPVKb2Y", + "2N9g9FZRJ1KUbEvdQ6Mpm5cMGxR3fpM8h5h", + ], + ); + } + + #[test] + fn test_export_p2sh_p2wsh_t() { + let descriptor = "sh(wsh(sortedmulti(2,[f57ec65d/48'/1'/100'/1']tpubDFc9Mm4tw6EkdXuk24MnQYRrDsdKEFh498vFffqa2KJmxytpcHbWrcFYwTKAdLxkSWpadzb5M5VVZ7PDAUjDjymvUmQ7pBbRecz2FM952Am/0/*,[efa5d916/48'/1'/100'/1']tpubDErWN5qfdLwY9ZJo9HWpxjcuEFuEBVHSbQbPqF35LQr3etWNGirKcgAa93DZ4DmtHm36p2gTf4aj6KybLqHaS3UePM5LtPqtb3d3dYVDs2F/0/*)))#j7jzgtur"; + let name = "P2SH-P2WSH-T"; + + // NOTE: .extendedPublicKeys[].name fields are set to key hash and are not expected + let expected_export_json = r#"{ + "name": "P2SH-P2WSH-T", + "addressType": "P2SH-P2WSH", + "network": "testnet", + "client": { + "type": "public" + }, + "quorum": { + "requiredSigners": 2, + "totalSigners": 2 + }, + "extendedPublicKeys": [ + { + "bip32Path": "m/48'/1'/100'/1'", + "xpub": "tpubDFc9Mm4tw6EkdXuk24MnQYRrDsdKEFh498vFffqa2KJmxytpcHbWrcFYwTKAdLxkSWpadzb5M5VVZ7PDAUjDjymvUmQ7pBbRecz2FM952Am", + "xfp" : "f57ec65d" + }, + { + "bip32Path": "m/48'/1'/100'/1'", + "xpub": "tpubDErWN5qfdLwY9ZJo9HWpxjcuEFuEBVHSbQbPqF35LQr3etWNGirKcgAa93DZ4DmtHm36p2gTf4aj6KybLqHaS3UePM5LtPqtb3d3dYVDs2F", + "xfp" : "efa5d916" + } + ], + "startingAddressIndex": 0 + }"#; + + test_export(Network::Testnet, descriptor, name, expected_export_json); + } + + #[test] + fn test_import_p2wsh_m() { + let import_json = r#"{ + "name": "P2WSH-M", + "addressType": "P2WSH", + "network": "mainnet", + "client": { + "type": "public" + }, + "quorum": { + "requiredSigners": 2, + "totalSigners": 2 + }, + "extendedPublicKeys": [ + { + "name": "d", + "bip32Path": "m/48'/0'/100'/2'", + "xpub": "xpub6EwJjKaiocGvqSuM2jRZSuQ9HEddiFUFu9RdjE47zG7kXVNDQpJ3GyvskwYiLmvU4SBTNZyv8UH53QcmFEE23YwozE61V3dwzZJEFQr6H2b", + "xfp" : "efa5d916" + }, + { + "name": "osw", + "bip32Path": "m/48'/0'/100'/2'", + "xpub": "xpub6DcqYQxnbefzFkaRBK63FSE2GzNuNnNhFGw1xV9RioVG7av6r3JDf1aELqBSq5gt5487CtNxvVtaiJjQU2HQWzgG5NzLyTPbYav6otW8qEc", + "xfp" : "f57ec65d" + } + ], + "startingAddressIndex": 0 + }"#; + + test_import( + import_json, + vec![ + "bc1qf9asympax4r6xrndsqrw8p0qxe40tm9zkk69tkrc8p6eg8ju075sjeekkt", + "bc1q2dexslsgvj4w2adf2lltthglkchmh3d2qvyrtdrece6lfr5tl4cq382unz", + "bc1q3kwd3zfaa90r20nvm2u3zxtw9c8cf5x4a4ecgw2y7pf59pnpmxns9keq9w", + ], + ); + } + + #[test] + fn test_export_p2wsh_m() { + let descriptor = "wsh(sortedmulti(2,[efa5d916/48'/0'/100'/2']xpub6EwJjKaiocGvqSuM2jRZSuQ9HEddiFUFu9RdjE47zG7kXVNDQpJ3GyvskwYiLmvU4SBTNZyv8UH53QcmFEE23YwozE61V3dwzZJEFQr6H2b/0/*,[f57ec65d/48'/0'/100'/2']xpub6DcqYQxnbefzFkaRBK63FSE2GzNuNnNhFGw1xV9RioVG7av6r3JDf1aELqBSq5gt5487CtNxvVtaiJjQU2HQWzgG5NzLyTPbYav6otW8qEc/0/*))#decr929e"; + let name = "P2WSH-M"; + + // NOTE: .extendedPublicKeys[].name fields are set to key hash and are not expected + let expected_export_json = r#"{ + "name": "P2WSH-M", + "addressType": "P2WSH", + "network": "mainnet", + "client": { + "type": "public" + }, + "quorum": { + "requiredSigners": 2, + "totalSigners": 2 + }, + "extendedPublicKeys": [ + { + "bip32Path": "m/48'/0'/100'/2'", + "xpub": "xpub6EwJjKaiocGvqSuM2jRZSuQ9HEddiFUFu9RdjE47zG7kXVNDQpJ3GyvskwYiLmvU4SBTNZyv8UH53QcmFEE23YwozE61V3dwzZJEFQr6H2b", + "xfp" : "efa5d916" + }, + { + "bip32Path": "m/48'/0'/100'/2'", + "xpub": "xpub6DcqYQxnbefzFkaRBK63FSE2GzNuNnNhFGw1xV9RioVG7av6r3JDf1aELqBSq5gt5487CtNxvVtaiJjQU2HQWzgG5NzLyTPbYav6otW8qEc", + "xfp" : "f57ec65d" + } + ], + "startingAddressIndex": 0 + }"#; + + test_export(Network::Bitcoin, descriptor, name, expected_export_json); + } + + #[test] + fn test_import_p2wsh_t() { let import_json = r#"{ "name": "P2WSH-T", "addressType": "P2WSH", @@ -404,49 +816,23 @@ mod test { "startingAddressIndex": 0 }"#; - let import = CaravanExport::from_str(import_json).expect("import"); - let descriptor = import.descriptor().expect("descriptor"); - - println!("descriptor: {}", descriptor); - - let wallet = - Wallet::new(descriptor, None, import.network(), MemoryDatabase::new()).expect("wallet"); - let expected_address_0 = - Address::from_str("tb1qhgj3fnwn50pq966rjnj4pg8uz9ktsd8nge32qxd73ffvvg636p5q54g7m0") - .expect("address[0]"); - assert_eq!( - wallet.get_address(AddressIndex::Peek(0)).unwrap().address, - expected_address_0 - ); - let expected_address_9 = - Address::from_str("tb1qxzw9q520f3ee6hjpc6wc7jrh7wxfgvundhfclqv8w7gtdd2srwns4krnc0") - .expect("address[9]"); - assert_eq!( - wallet.get_address(AddressIndex::Peek(9)).unwrap().address, - expected_address_9 + test_import( + import_json, + vec![ + "tb1qhgj3fnwn50pq966rjnj4pg8uz9ktsd8nge32qxd73ffvvg636p5q54g7m0", + "tb1q4ka64s7fcdv8ms7xs6j2w35dz8t7n0zd450lgsny73jvg8lpyqfqr9n037", + "tb1q8fglyvwtlr5t427cqn898jc9vrqxkc43522tpxjaupmn8ewu9sushz86gf", + ], ); } #[test] - fn test_export_to_json() { - let wallet = Wallet::new( - "wsh(sortedmulti(2,[f57ec65d/48'/1'/100'/2']tpubDFc9Mm4tw6EkgR4YTC1GrU6CGEd9yw7KSBnSssL4LXAXh89D4uMZigRyv3csdXbeU3BhLQc4vWKTLewboA1Pt8Fu6fbHKu81MZ6VGdc32eM/0/*,[efa5d916/48'/1'/100'/2']tpubDErWN5qfdLwYE94mh12oWr4uURDDNKCjKVhCEcAgZ7jKnnAwq5tcTF2iEk3VuznkJuk2G8SCHft9gS6aKbBd18ptYWPqKLRSTRQY7e2rrDj/0/*))#nv5k65uf", - None, - Network::Testnet, - MemoryDatabase::default() - ).expect("wallet"); - - let name = "P2WSH-T".to_string(); - let client = "public".to_string(); - let network = wallet.network(); - let descriptor = wallet.get_descriptor_for_keychain(KeychainKind::External); - - let export = CaravanExport::export(network, &descriptor, name, client).expect("export"); - - println!("Exported: {}", export.to_string()); + fn test_export_p2wsh_t() { + let descriptor = "wsh(sortedmulti(2,[f57ec65d/48'/1'/100'/2']tpubDFc9Mm4tw6EkgR4YTC1GrU6CGEd9yw7KSBnSssL4LXAXh89D4uMZigRyv3csdXbeU3BhLQc4vWKTLewboA1Pt8Fu6fbHKu81MZ6VGdc32eM/0/*,[efa5d916/48'/1'/100'/2']tpubDErWN5qfdLwYE94mh12oWr4uURDDNKCjKVhCEcAgZ7jKnnAwq5tcTF2iEk3VuznkJuk2G8SCHft9gS6aKbBd18ptYWPqKLRSTRQY7e2rrDj/0/*))#nv5k65uf"; + let name = "P2WSH-T"; // NOTE: .extendedPublicKeys[].name fields are set to key hash and are not expected - let expected_export = json!({ + let expected_export_json = r#"{ "name": "P2WSH-T", "addressType": "P2WSH", "network": "testnet", @@ -470,8 +856,8 @@ mod test { } ], "startingAddressIndex": 0 - }); - use assert_json_diff::assert_json_include; - assert_json_include!(actual: &export, expected: expected_export); + }"#; + + test_export(Network::Testnet, descriptor, name, expected_export_json); } } From 8a393878d9498d1610feaa5b88297eb1e0a4f9f2 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Sat, 30 Apr 2022 00:21:07 -0700 Subject: [PATCH 6/6] Add internal descriptor and validation to wallet::export::caravan --- src/wallet/export/caravan.rs | 358 ++++++++++++++++++++++++----------- 1 file changed, 251 insertions(+), 107 deletions(-) diff --git a/src/wallet/export/caravan.rs b/src/wallet/export/caravan.rs index 29039d15c..1e5d3f4e5 100644 --- a/src/wallet/export/caravan.rs +++ b/src/wallet/export/caravan.rs @@ -1,7 +1,7 @@ // Bitcoin Dev Kit // Written in 2020 by Alekos Filini // -// Copyright (c) 2020-2021 Bitcoin Dev Kit Developers +// Copyright (c) 2020-2022 Bitcoin Dev Kit Developers // // This file is licensed under the Apache License, Version 2.0 or the MIT license @@ -9,7 +9,7 @@ // You may not use this file except in accordance with one or both of these // licenses. -//! Wallet export +//! Caravan Wallet export //! //! This modules implements the wallet export format used by Unchained Capitals's [Caravan](https://github.com/unchained-capital/caravan). //! @@ -53,8 +53,8 @@ //! //! let import = CaravanExport::from_str(import)?; //! let wallet = Wallet::new( -//! import.descriptor()?, -//! None, +//! import.descriptor(KeychainKind::External)?, +//! Some(import.descriptor(KeychainKind::Internal)?), //! import.network(), //! MemoryDatabase::default(), //! )?; @@ -69,7 +69,7 @@ //! # use bdk::*; //! let wallet = Wallet::new( //! "wsh(sortedmulti(2,[f57ec65d/48'/1'/100'/2']tpubDFc9Mm4tw6EkgR4YTC1GrU6CGEd9yw7KSBnSssL4LXAXh89D4uMZigRyv3csdXbeU3BhLQc4vWKTLewboA1Pt8Fu6fbHKu81MZ6VGdc32eM/0/*,[efa5d916/48'/1'/100'/2']tpubDErWN5qfdLwYE94mh12oWr4uURDDNKCjKVhCEcAgZ7jKnnAwq5tcTF2iEk3VuznkJuk2G8SCHft9gS6aKbBd18ptYWPqKLRSTRQY7e2rrDj/0/*))#nv5k65uf", -//! None, +//! Some("wsh(sortedmulti(2,[f57ec65d/48'/1'/100'/2']tpubDFc9Mm4tw6EkgR4YTC1GrU6CGEd9yw7KSBnSssL4LXAXh89D4uMZigRyv3csdXbeU3BhLQc4vWKTLewboA1Pt8Fu6fbHKu81MZ6VGdc32eM/1/*,[efa5d916/48'/1'/100'/2']tpubDErWN5qfdLwYE94mh12oWr4uURDDNKCjKVhCEcAgZ7jKnnAwq5tcTF2iEk3VuznkJuk2G8SCHft9gS6aKbBd18ptYWPqKLRSTRQY7e2rrDj/1/*))"), //! Network::Testnet, //! MemoryDatabase::default() //! )?; @@ -136,21 +136,24 @@ impl CaravanExport { } } /// Get the descriptor value - pub fn descriptor(&self) -> Result, Error> { + pub fn descriptor( + &self, + keychain: KeychainKind, + ) -> Result, Error> { let required = self.quorum.required_signers; let network: Network = self.network(); let result = match self.address_type { CaravanAddressType::P2sh => { - let keys: Vec> = self.descriptor_keys()?; + let keys: Vec> = self.descriptor_keys(keychain)?; descriptor! { sh ( sortedmulti_vec(required, keys) ) } } CaravanAddressType::P2shP2wsh => { - let keys: Vec> = self.descriptor_keys()?; + let keys: Vec> = self.descriptor_keys(keychain)?; descriptor! { sh ( wsh ( sortedmulti_vec(required, keys) ) ) } } CaravanAddressType::P2wsh => { - let keys: Vec> = self.descriptor_keys()?; + let keys: Vec> = self.descriptor_keys(keychain)?; descriptor! { wsh ( sortedmulti_vec(required, keys) ) } } } @@ -173,6 +176,7 @@ impl CaravanExport { fn descriptor_keys( &self, + keychain: KeychainKind, ) -> Result>, DescriptorError> { let result = self .extended_public_keys @@ -181,8 +185,10 @@ impl CaravanExport { let fingerprint = k.xfp; let key_path = k.bip32_path.clone(); let key_source = fingerprint.zip(key_path); - let derivation_path = - DerivationPath::master().child(ChildNumber::Normal { index: 0 }); + let keychain_index = keychain as u32; + let derivation_path = DerivationPath::master().child(ChildNumber::Normal { + index: keychain_index, + }); k.xpub .into_descriptor_key(key_source, derivation_path) .map_err(|e| DescriptorError::Key(e)) @@ -191,17 +197,6 @@ impl CaravanExport { result } - fn parse_sorted_multi( - sorted_multi: &SortedMultiVec, - ) -> (Quorum, &[Pk]) { - let quorum = Quorum { - required_signers: sorted_multi.k, - total_signers: sorted_multi.pks.len(), - }; - let extended_public_keys = sorted_multi.pks.as_slice(); - (quorum, extended_public_keys) - } - /// Export BDK wallet configuration as a Caravan configuration pub fn export_wallet( wallet: &Wallet, @@ -209,99 +204,182 @@ impl CaravanExport { client_type: String, ) -> Result { let network = wallet.network; - let descriptor = wallet.get_descriptor_for_keychain(KeychainKind::External); - if wallet.change_descriptor.is_none() { - Self::export(network, descriptor, name, client_type) - } else { - Err(Error::Generic( - "Can not export a wallet with a change descriptor to Caravan.".to_string(), - )) + let external_descriptor = wallet.get_descriptor_for_keychain(KeychainKind::External); + match &wallet.change_descriptor { + None => Err(Error::Generic( + "Wallet must have an internal descriptor".to_string(), + )), + Some(internal_descriptor) => { + Self::export( + network, + external_descriptor, + internal_descriptor, + name, + client_type, + ) + } } } /// Export BDK wallet network and descriptor as a Caravan configuration pub fn export( network: Network, - descriptor: &Descriptor, + external_descriptor: &Descriptor, + internal_descriptor: &Descriptor, name: String, client_type: String, ) -> Result { - let (address_type, quorum, descriptor_public_keys) = match descriptor { - Descriptor::Sh(sh) => match sh.as_inner() { - ShInner::SortedMulti(smv) => { - let (quorum, extended_public_keys) = CaravanExport::parse_sorted_multi(smv); - Ok((CaravanAddressType::P2sh, quorum, extended_public_keys)) - } - ShInner::Wsh(wsh) => match wsh.as_inner() { - WshInner::SortedMulti(smv) => { - let (quorum, extended_public_keys) = CaravanExport::parse_sorted_multi(smv); - Ok((CaravanAddressType::P2shP2wsh, quorum, extended_public_keys)) - } - _ => Err(Error::Generic( - "Unsupported sh(wsh()) inner descriptor.".to_string(), - )), - }, - _ => Err(Error::Generic( - "Unsupported sh() inner descriptor.".to_string(), - )), - }, - Descriptor::Wsh(sh) => match sh.as_inner() { - WshInner::SortedMulti(smv) => { - let (quorum, extended_public_keys) = CaravanExport::parse_sorted_multi(smv); - Ok((CaravanAddressType::P2wsh, quorum, extended_public_keys)) - } - _ => Err(Error::Generic( - "Unsupported wsh() inner descriptor.".to_string(), - )), - }, - _ => Err(Error::Generic( - "Unsupported top level descriptor.".to_string(), - )), - }?; + let (external_address_type, external_quorum, external_public_keys) = + parse_descriptor(external_descriptor)?; + let (internal_address_type, internal_quorum, internal_public_keys) = + parse_descriptor(internal_descriptor)?; + + // verify external and internal address types match + if external_address_type != internal_address_type { + return Err(Error::Generic( + "External and internal descriptor address type configs don't match.".to_string(), + )); + } + + // verify external and internal descriptor configs match + if external_quorum != internal_quorum { + return Err(Error::Generic( + "External and internal descriptor quorum configs don't match.".to_string(), + )); + } + + // verify internal and external descriptor keys match except ends with m/(0|1)/* + for (external_key, internal_key) in + external_public_keys.iter().zip(internal_public_keys.iter()) + { + let ex_caravan_key = parse_key(external_key)?; + let in_caravan_key = parse_key(internal_key)?; + if ex_caravan_key.bip32_path != in_caravan_key.bip32_path { + return Err(Error::Generic( + "External and internal keys have different bip32_path".to_string(), + )); + } + if ex_caravan_key.xfp != in_caravan_key.xfp { + return Err(Error::Generic( + "External and internal keys have different xfp".to_string(), + )); + } + if ex_caravan_key.xpub_last_index != 0 { + return Err(Error::Generic( + "External keys last normal index must be 0".to_string(), + )); + } + if in_caravan_key.xpub_last_index != 1 { + return Err(Error::Generic( + "Internal keys last normal index must be 0".to_string(), + )); + } + } let network = match network { Network::Bitcoin => CaravanNetwork::Mainnet, _ => CaravanNetwork::Testnet, }; let client = CaravanClient { value: client_type }; - let extended_public_keys = descriptor_public_keys + let extended_public_keys = external_public_keys .iter() - .map(|k| match k { - DescriptorPublicKey::SinglePub(_) => { - Err(Error::Generic("Unsupported single pub key.".to_string())) - } - DescriptorPublicKey::XPub(xpub) => { - let mut xfp = None; - let mut bip32_path = None; - if let Some((s_xfp, s_bip32_path)) = xpub.clone().origin { - xfp = Some(s_xfp); - bip32_path = Some(s_bip32_path); - } - Ok(CaravanExtendedPublicKey { - name: xpub.xkey.fingerprint().to_string(), - bip32_path, - xpub: xpub.xkey, - xfp, - }) - } - }) + .map(|pubkey| parse_key(pubkey)) .flatten() .collect(); Ok(Self { name, - address_type, + address_type: external_address_type, network, client, - quorum, + quorum: external_quorum, extended_public_keys, starting_address_index: 0, }) } } +fn parse_sorted_multi( + sorted_multi: &SortedMultiVec, +) -> (Quorum, &[Pk]) { + let quorum = Quorum { + required_signers: sorted_multi.k, + total_signers: sorted_multi.pks.len(), + }; + let extended_public_keys = sorted_multi.pks.as_slice(); + (quorum, extended_public_keys) +} + +fn parse_descriptor( + descriptor: &Descriptor, +) -> Result<(CaravanAddressType, Quorum, &[DescriptorPublicKey]), Error> { + match descriptor { + Descriptor::Sh(sh) => match sh.as_inner() { + ShInner::SortedMulti(sorted_multi) => { + let (quorum, extended_public_keys) = parse_sorted_multi(sorted_multi); + Ok((CaravanAddressType::P2sh, quorum, extended_public_keys)) + } + ShInner::Wsh(wsh) => match wsh.as_inner() { + WshInner::SortedMulti(sorted_multi) => { + let (quorum, extended_public_keys) = parse_sorted_multi(sorted_multi); + Ok((CaravanAddressType::P2shP2wsh, quorum, extended_public_keys)) + } + _ => Err(Error::Generic( + "Unsupported sh(wsh()) inner descriptor.".to_string(), + )), + }, + _ => Err(Error::Generic( + "Unsupported sh() inner descriptor.".to_string(), + )), + }, + Descriptor::Wsh(sh) => match sh.as_inner() { + WshInner::SortedMulti(smv) => { + let (quorum, extended_public_keys) = parse_sorted_multi(smv); + Ok((CaravanAddressType::P2wsh, quorum, extended_public_keys)) + } + _ => Err(Error::Generic( + "Unsupported wsh() inner descriptor.".to_string(), + )), + }, + _ => Err(Error::Generic( + "Unsupported top level descriptor.".to_string(), + )), + } +} + +fn parse_key(pubkey: &DescriptorPublicKey) -> Result { + match pubkey { + DescriptorPublicKey::SinglePub(_) => { + Err(Error::Generic("Unsupported single pub key.".to_string())) + } + DescriptorPublicKey::XPub(xpub) => { + let mut xfp = None; + let mut bip32_path = None; + if let Some((s_xfp, s_bip32_path)) = xpub.origin.clone() { + xfp = Some(s_xfp); + bip32_path = Some(s_bip32_path); + } + let xpub_index = xpub.derivation_path.len() - 1; + let xpub_last_child = xpub.derivation_path[xpub_index]; + let xpub_last_index = match xpub_last_child { + ChildNumber::Normal { index } => index, + ChildNumber::Hardened { .. } => { + return Err(Error::Generic("Last key index must be normal.".to_string())) + } + }; + Ok(CaravanExtendedPublicKey { + name: xpub.xkey.fingerprint().to_string(), + bip32_path, + xpub: xpub.xkey, + xfp, + xpub_last_index, + }) + } + } +} + /// The address types supported by Caravan -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] pub enum CaravanAddressType { /// P2SH #[serde(rename = "P2SH")] @@ -331,7 +409,7 @@ pub struct CaravanClient { } /// The quorum of signers required and total signers -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] pub struct Quorum { #[serde(rename = "requiredSigners")] required_signers: usize, @@ -347,6 +425,8 @@ pub struct CaravanExtendedPublicKey { bip32_path: Option, xpub: ExtendedPubKey, xfp: Option, + #[serde(skip, default)] + xpub_last_index: u32, } impl ToString for CaravanExport { @@ -377,12 +457,22 @@ mod test { fn test_import(import_json: &str, expected_addresses: Vec<&str>) { let import = CaravanExport::from_str(import_json).expect("import"); - let descriptor = import.descriptor().expect("descriptor"); - - println!("descriptor: {}", descriptor); - - let wallet = - Wallet::new(descriptor, None, import.network(), MemoryDatabase::new()).expect("wallet"); + let external_descriptor = import + .descriptor(KeychainKind::External) + .expect("external descriptor"); + println!("external descriptor: {}", external_descriptor); + let internal_descriptor = import + .descriptor(KeychainKind::Internal) + .expect("internal descriptor"); + println!("internal descriptor: {}", internal_descriptor); + + let wallet = Wallet::new( + external_descriptor, + Some(internal_descriptor), + import.network(), + MemoryDatabase::new(), + ) + .expect("wallet"); for (index, expected_address) in expected_addresses.iter().enumerate() { let expected_address = Address::from_str(expected_address).expect("address"); @@ -396,9 +486,20 @@ mod test { } } - fn test_export(network: Network, descriptor: &str, name: &str, expected_export_json: &str) { - let wallet = - Wallet::new(descriptor, None, network, MemoryDatabase::default()).expect("wallet"); + fn test_export( + network: Network, + external_descriptor: &str, + internal_descriptor: &str, + name: &str, + expected_export_json: &str, + ) { + let wallet = Wallet::new( + external_descriptor, + Some(internal_descriptor), + network, + MemoryDatabase::default(), + ) + .expect("wallet"); let export = CaravanExport::export_wallet(&wallet, name.to_string(), "public".to_string()) .expect("export"); @@ -453,7 +554,9 @@ mod test { #[test] fn test_export_p2sh_m() { - let descriptor = "sh(sortedmulti(2,[f57ec65d/45'/0'/100']xpub6CCHViYn5VzPfSR7baop9FtGcbm3UnqHwa54Z2eNvJnRFCJCdo9HtCYoLJKZCoATMLUowDDA1BMGfQGauY3fDYU3HyMzX4NDkoLYCSkLpbH/0/*,[efa5d916/45'/0'/100']xpub6Ca5CwTgRASgkXbXE5TeddTP9mPCbYHreCpmGt9dhz9y6femstHGCoFESHHKKRcm414xMKnuLjP9LDS7TwaJC9n5gxua6XB1rwPcC6hqDub/0/*))#uxj9xxul"; + let external_descriptor = "sh(sortedmulti(2,[f57ec65d/45'/0'/100']xpub6CCHViYn5VzPfSR7baop9FtGcbm3UnqHwa54Z2eNvJnRFCJCdo9HtCYoLJKZCoATMLUowDDA1BMGfQGauY3fDYU3HyMzX4NDkoLYCSkLpbH/0/*,[efa5d916/45'/0'/100']xpub6Ca5CwTgRASgkXbXE5TeddTP9mPCbYHreCpmGt9dhz9y6femstHGCoFESHHKKRcm414xMKnuLjP9LDS7TwaJC9n5gxua6XB1rwPcC6hqDub/0/*))#uxj9xxul"; + let internal_descriptor = "sh(sortedmulti(2,[f57ec65d/45'/0'/100']xpub6CCHViYn5VzPfSR7baop9FtGcbm3UnqHwa54Z2eNvJnRFCJCdo9HtCYoLJKZCoATMLUowDDA1BMGfQGauY3fDYU3HyMzX4NDkoLYCSkLpbH/1/*,[efa5d916/45'/0'/100']xpub6Ca5CwTgRASgkXbXE5TeddTP9mPCbYHreCpmGt9dhz9y6femstHGCoFESHHKKRcm414xMKnuLjP9LDS7TwaJC9n5gxua6XB1rwPcC6hqDub/1/*))#3hxf9z66"; + let name = "P2SH-M"; // NOTE: .extendedPublicKeys[].name fields are set to key hash and are not expected @@ -483,7 +586,13 @@ mod test { "startingAddressIndex": 0 }"#; - test_export(Network::Bitcoin, descriptor, name, expected_export_json); + test_export( + Network::Bitcoin, + external_descriptor, + internal_descriptor, + name, + expected_export_json, + ); } #[test] @@ -528,7 +637,8 @@ mod test { #[test] fn test_export_p2sh_t() { - let descriptor = "sh(sortedmulti(2,[efa5d916/45'/1'/100']tpubDDinbKDXyddTUKcX6mv936Ux5utCJteq5S6EEKhfpM8CqN2rMAcccv6GecsB3cPt8eGL4e4K2eaZ9Jis9TGf7mbwBsRTN7ngnFR7yJZxBKC/0/*,[f57ec65d/45'/1'/100']tpubDDQubdBx9cbwQtdcRTisKF7wVCwHgHewhU7wh77VzCi62Q9q81qyQeLoZjKWZ62FnQbWU8k7CuKo2A21pAWaFtPGDHP9WuhtAx4smcCxqn1/0/*))#e4qrgzdy"; + let external_descriptor = "sh(sortedmulti(2,[efa5d916/45'/1'/100']tpubDDinbKDXyddTUKcX6mv936Ux5utCJteq5S6EEKhfpM8CqN2rMAcccv6GecsB3cPt8eGL4e4K2eaZ9Jis9TGf7mbwBsRTN7ngnFR7yJZxBKC/0/*,[f57ec65d/45'/1'/100']tpubDDQubdBx9cbwQtdcRTisKF7wVCwHgHewhU7wh77VzCi62Q9q81qyQeLoZjKWZ62FnQbWU8k7CuKo2A21pAWaFtPGDHP9WuhtAx4smcCxqn1/0/*))#e4qrgzdy"; + let internal_descriptor = "sh(sortedmulti(2,[efa5d916/45'/1'/100']tpubDDinbKDXyddTUKcX6mv936Ux5utCJteq5S6EEKhfpM8CqN2rMAcccv6GecsB3cPt8eGL4e4K2eaZ9Jis9TGf7mbwBsRTN7ngnFR7yJZxBKC/1/*,[f57ec65d/45'/1'/100']tpubDDQubdBx9cbwQtdcRTisKF7wVCwHgHewhU7wh77VzCi62Q9q81qyQeLoZjKWZ62FnQbWU8k7CuKo2A21pAWaFtPGDHP9WuhtAx4smcCxqn1/1/*))#5y50txtp"; let name = "P2SH-T"; // NOTE: .extendedPublicKeys[].name fields are set to key hash and are not expected @@ -558,7 +668,13 @@ mod test { "startingAddressIndex": 0 }"#; - test_export(Network::Testnet, descriptor, name, expected_export_json); + test_export( + Network::Testnet, + external_descriptor, + internal_descriptor, + name, + expected_export_json, + ); } #[test] @@ -603,7 +719,8 @@ mod test { #[test] fn test_export_p2sh_p2wsh_m() { - let descriptor = "sh(wsh(sortedmulti(2,[efa5d916/48'/0'/100'/1']xpub6EwJjKaiocGvo9f7XSGXGwzo1GLB1URxSZ5Ccp1wqdxNkhrSoqNQkC2CeMsU675urdmFJLHSX62xz56HGcnn6u21wRy6uipovmzaE65PfBp/0/*,[f57ec65d/48'/0'/100'/1']xpub6DcqYQxnbefzEBJF6osEuT5yXoHVZu1YCCsS5YkATvqD2h7tdMBgdBrUXk26FrJwawDGX6fHKPvhhZxKc5b8dPAPb8uANDhsjAPMJqTFDjH/0/*)))#jeqfd8lr"; + let external_descriptor = "sh(wsh(sortedmulti(2,[efa5d916/48'/0'/100'/1']xpub6EwJjKaiocGvo9f7XSGXGwzo1GLB1URxSZ5Ccp1wqdxNkhrSoqNQkC2CeMsU675urdmFJLHSX62xz56HGcnn6u21wRy6uipovmzaE65PfBp/0/*,[f57ec65d/48'/0'/100'/1']xpub6DcqYQxnbefzEBJF6osEuT5yXoHVZu1YCCsS5YkATvqD2h7tdMBgdBrUXk26FrJwawDGX6fHKPvhhZxKc5b8dPAPb8uANDhsjAPMJqTFDjH/0/*)))#jeqfd8lr"; + let internal_descriptor = "sh(wsh(sortedmulti(2,[efa5d916/48'/0'/100'/1']xpub6EwJjKaiocGvo9f7XSGXGwzo1GLB1URxSZ5Ccp1wqdxNkhrSoqNQkC2CeMsU675urdmFJLHSX62xz56HGcnn6u21wRy6uipovmzaE65PfBp/1/*,[f57ec65d/48'/0'/100'/1']xpub6DcqYQxnbefzEBJF6osEuT5yXoHVZu1YCCsS5YkATvqD2h7tdMBgdBrUXk26FrJwawDGX6fHKPvhhZxKc5b8dPAPb8uANDhsjAPMJqTFDjH/1/*)))#j58fg4ec"; let name = "P2SH-P2WSH-M"; // NOTE: .extendedPublicKeys[].name fields are set to key hash and are not expected @@ -633,7 +750,13 @@ mod test { "startingAddressIndex": 0 }"#; - test_export(Network::Bitcoin, descriptor, name, expected_export_json); + test_export( + Network::Bitcoin, + external_descriptor, + internal_descriptor, + name, + expected_export_json, + ); } #[test] @@ -678,7 +801,8 @@ mod test { #[test] fn test_export_p2sh_p2wsh_t() { - let descriptor = "sh(wsh(sortedmulti(2,[f57ec65d/48'/1'/100'/1']tpubDFc9Mm4tw6EkdXuk24MnQYRrDsdKEFh498vFffqa2KJmxytpcHbWrcFYwTKAdLxkSWpadzb5M5VVZ7PDAUjDjymvUmQ7pBbRecz2FM952Am/0/*,[efa5d916/48'/1'/100'/1']tpubDErWN5qfdLwY9ZJo9HWpxjcuEFuEBVHSbQbPqF35LQr3etWNGirKcgAa93DZ4DmtHm36p2gTf4aj6KybLqHaS3UePM5LtPqtb3d3dYVDs2F/0/*)))#j7jzgtur"; + let external_descriptor = "sh(wsh(sortedmulti(2,[f57ec65d/48'/1'/100'/1']tpubDFc9Mm4tw6EkdXuk24MnQYRrDsdKEFh498vFffqa2KJmxytpcHbWrcFYwTKAdLxkSWpadzb5M5VVZ7PDAUjDjymvUmQ7pBbRecz2FM952Am/0/*,[efa5d916/48'/1'/100'/1']tpubDErWN5qfdLwY9ZJo9HWpxjcuEFuEBVHSbQbPqF35LQr3etWNGirKcgAa93DZ4DmtHm36p2gTf4aj6KybLqHaS3UePM5LtPqtb3d3dYVDs2F/0/*)))#j7jzgtur"; + let internal_descriptor = "sh(wsh(sortedmulti(2,[f57ec65d/48'/1'/100'/1']tpubDFc9Mm4tw6EkdXuk24MnQYRrDsdKEFh498vFffqa2KJmxytpcHbWrcFYwTKAdLxkSWpadzb5M5VVZ7PDAUjDjymvUmQ7pBbRecz2FM952Am/1/*,[efa5d916/48'/1'/100'/1']tpubDErWN5qfdLwY9ZJo9HWpxjcuEFuEBVHSbQbPqF35LQr3etWNGirKcgAa93DZ4DmtHm36p2gTf4aj6KybLqHaS3UePM5LtPqtb3d3dYVDs2F/1/*)))#jn4zde6c"; let name = "P2SH-P2WSH-T"; // NOTE: .extendedPublicKeys[].name fields are set to key hash and are not expected @@ -708,7 +832,13 @@ mod test { "startingAddressIndex": 0 }"#; - test_export(Network::Testnet, descriptor, name, expected_export_json); + test_export( + Network::Testnet, + external_descriptor, + internal_descriptor, + name, + expected_export_json, + ); } #[test] @@ -753,7 +883,8 @@ mod test { #[test] fn test_export_p2wsh_m() { - let descriptor = "wsh(sortedmulti(2,[efa5d916/48'/0'/100'/2']xpub6EwJjKaiocGvqSuM2jRZSuQ9HEddiFUFu9RdjE47zG7kXVNDQpJ3GyvskwYiLmvU4SBTNZyv8UH53QcmFEE23YwozE61V3dwzZJEFQr6H2b/0/*,[f57ec65d/48'/0'/100'/2']xpub6DcqYQxnbefzFkaRBK63FSE2GzNuNnNhFGw1xV9RioVG7av6r3JDf1aELqBSq5gt5487CtNxvVtaiJjQU2HQWzgG5NzLyTPbYav6otW8qEc/0/*))#decr929e"; + let external_descriptor = "wsh(sortedmulti(2,[efa5d916/48'/0'/100'/2']xpub6EwJjKaiocGvqSuM2jRZSuQ9HEddiFUFu9RdjE47zG7kXVNDQpJ3GyvskwYiLmvU4SBTNZyv8UH53QcmFEE23YwozE61V3dwzZJEFQr6H2b/0/*,[f57ec65d/48'/0'/100'/2']xpub6DcqYQxnbefzFkaRBK63FSE2GzNuNnNhFGw1xV9RioVG7av6r3JDf1aELqBSq5gt5487CtNxvVtaiJjQU2HQWzgG5NzLyTPbYav6otW8qEc/0/*))#decr929e"; + let internal_descriptor = "wsh(sortedmulti(2,[efa5d916/48'/0'/100'/2']xpub6EwJjKaiocGvqSuM2jRZSuQ9HEddiFUFu9RdjE47zG7kXVNDQpJ3GyvskwYiLmvU4SBTNZyv8UH53QcmFEE23YwozE61V3dwzZJEFQr6H2b/1/*,[f57ec65d/48'/0'/100'/2']xpub6DcqYQxnbefzFkaRBK63FSE2GzNuNnNhFGw1xV9RioVG7av6r3JDf1aELqBSq5gt5487CtNxvVtaiJjQU2HQWzgG5NzLyTPbYav6otW8qEc/1/*))#wj94h3at"; let name = "P2WSH-M"; // NOTE: .extendedPublicKeys[].name fields are set to key hash and are not expected @@ -783,7 +914,13 @@ mod test { "startingAddressIndex": 0 }"#; - test_export(Network::Bitcoin, descriptor, name, expected_export_json); + test_export( + Network::Bitcoin, + external_descriptor, + internal_descriptor, + name, + expected_export_json, + ); } #[test] @@ -828,7 +965,8 @@ mod test { #[test] fn test_export_p2wsh_t() { - let descriptor = "wsh(sortedmulti(2,[f57ec65d/48'/1'/100'/2']tpubDFc9Mm4tw6EkgR4YTC1GrU6CGEd9yw7KSBnSssL4LXAXh89D4uMZigRyv3csdXbeU3BhLQc4vWKTLewboA1Pt8Fu6fbHKu81MZ6VGdc32eM/0/*,[efa5d916/48'/1'/100'/2']tpubDErWN5qfdLwYE94mh12oWr4uURDDNKCjKVhCEcAgZ7jKnnAwq5tcTF2iEk3VuznkJuk2G8SCHft9gS6aKbBd18ptYWPqKLRSTRQY7e2rrDj/0/*))#nv5k65uf"; + let external_descriptor = "wsh(sortedmulti(2,[f57ec65d/48'/1'/100'/2']tpubDFc9Mm4tw6EkgR4YTC1GrU6CGEd9yw7KSBnSssL4LXAXh89D4uMZigRyv3csdXbeU3BhLQc4vWKTLewboA1Pt8Fu6fbHKu81MZ6VGdc32eM/0/*,[efa5d916/48'/1'/100'/2']tpubDErWN5qfdLwYE94mh12oWr4uURDDNKCjKVhCEcAgZ7jKnnAwq5tcTF2iEk3VuznkJuk2G8SCHft9gS6aKbBd18ptYWPqKLRSTRQY7e2rrDj/0/*))#nv5k65uf"; + let internal_descriptor = "wsh(sortedmulti(2,[f57ec65d/48'/1'/100'/2']tpubDFc9Mm4tw6EkgR4YTC1GrU6CGEd9yw7KSBnSssL4LXAXh89D4uMZigRyv3csdXbeU3BhLQc4vWKTLewboA1Pt8Fu6fbHKu81MZ6VGdc32eM/1/*,[efa5d916/48'/1'/100'/2']tpubDErWN5qfdLwYE94mh12oWr4uURDDNKCjKVhCEcAgZ7jKnnAwq5tcTF2iEk3VuznkJuk2G8SCHft9gS6aKbBd18ptYWPqKLRSTRQY7e2rrDj/1/*))#s8fqg0ym"; let name = "P2WSH-T"; // NOTE: .extendedPublicKeys[].name fields are set to key hash and are not expected @@ -858,6 +996,12 @@ mod test { "startingAddressIndex": 0 }"#; - test_export(Network::Testnet, descriptor, name, expected_export_json); + test_export( + Network::Testnet, + external_descriptor, + internal_descriptor, + name, + expected_export_json, + ); } }