From 5f8095097166873b45ffef19d15a19cd22618503 Mon Sep 17 00:00:00 2001 From: Alekos Filini Date: Fri, 7 Aug 2020 10:19:06 +0200 Subject: [PATCH] [export] Implement the wallet import/export format from FullyNoded This commit closes #31 --- src/wallet/export.rs | 236 +++++++++++++++++++++++++++++++++++++++++++ src/wallet/mod.rs | 1 + 2 files changed, 237 insertions(+) create mode 100644 src/wallet/export.rs diff --git a/src/wallet/export.rs b/src/wallet/export.rs new file mode 100644 index 00000000..dac3cfd9 --- /dev/null +++ b/src/wallet/export.rs @@ -0,0 +1,236 @@ +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +use miniscript::{Descriptor, ScriptContext, Terminal}; + +use crate::blockchain::Blockchain; +use crate::database::BatchDatabase; +use crate::wallet::Wallet; + +#[derive(Debug, Serialize, Deserialize)] +pub struct WalletExport { + descriptor: String, + pub blockheight: u32, + pub label: String, +} + +impl WalletExport { + pub fn export_wallet( + wallet: &Wallet, + label: &str, + include_blockheight: bool, + ) -> Result { + let descriptor = wallet.descriptor.as_ref().to_string(); + 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.height.unwrap_or(0)) + .collect::>(); + heights.sort(); + + *heights.last().unwrap_or(&0) + } + }; + + let export = WalletExport { + descriptor, + label: label.into(), + blockheight, + }; + + if export.change_descriptor() + != wallet + .change_descriptor + .as_ref() + .map(|d| d.as_ref().to_string()) + { + 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") + } + } + + match Descriptor::::from_str(descriptor).map_err(|_| "Invalid descriptor")? { + Descriptor::Pk(_) + | Descriptor::Pkh(_) + | Descriptor::Wpkh(_) + | Descriptor::ShWpkh(_) => Ok(()), + Descriptor::Sh(ms) => check_ms(ms.node), + Descriptor::Wsh(ms) | Descriptor::ShWsh(ms) => check_ms(ms.node), + _ => Err("The descriptor is not compatible with Bitcoin Core"), + } + } + + pub fn descriptor(&self) -> String { + self.descriptor.clone() + } + + 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::{OfflineWallet, Wallet}; + + fn get_test_db() -> MemoryDatabase { + let mut db = MemoryDatabase::new(); + db.set_tx(&TransactionDetails { + transaction: None, + txid: Txid::from_str( + "4ddff1fa33af17f377f62b72357b43107c19110a8009b36fb832af505efed98a", + ) + .unwrap(), + timestamp: 12345678, + received: 100_000, + sent: 0, + height: Some(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: OfflineWallet<_> = Wallet::new_offline( + descriptor, + Some(change_descriptor), + Network::Testnet, + get_test_db(), + ) + .unwrap(); + let export = WalletExport::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: OfflineWallet<_> = + Wallet::new_offline(descriptor, None, Network::Testnet, get_test_db()).unwrap(); + WalletExport::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: OfflineWallet<_> = Wallet::new_offline( + descriptor, + Some(change_descriptor), + Network::Testnet, + get_test_db(), + ) + .unwrap(); + WalletExport::export_wallet(&wallet, "Test Label", true).unwrap(); + } + + #[test] + fn test_export_multi() { + let descriptor = "wsh(multi(2,\ + [73756c7f/48h/0h/0h/2h]tpubDCKxNyM3bLgbEX13Mcd8mYxbVg9ajDkWXMh29hMWBurKfVmBfWAM96QVP3zaUcN51HvkZ3ar4VwP82kC8JZhhux8vFQoJintSpVBwpFvyU3/0/*,\ + [f9f62194/48h/0h/0h/2h]tpubDDp3ZSH1yCwusRppH7zgSxq2t1VEUyXSeEp8E5aFS8m43MknUjiF1bSLo3CGWAxbDyhF1XowA5ukPzyJZjznYk3kYi6oe7QxtX2euvKWsk4/0/*,\ + [c98b1535/48h/0h/0h/2h]tpubDCDi5W4sP6zSnzJeowy8rQDVhBdRARaPhK1axABi8V1661wEPeanpEXj4ZLAUEoikVtoWcyK26TKKJSecSfeKxwHCcRrge9k1ybuiL71z4a/0/*\ + ))"; + let change_descriptor = "wsh(multi(2,\ + [73756c7f/48h/0h/0h/2h]tpubDCKxNyM3bLgbEX13Mcd8mYxbVg9ajDkWXMh29hMWBurKfVmBfWAM96QVP3zaUcN51HvkZ3ar4VwP82kC8JZhhux8vFQoJintSpVBwpFvyU3/1/*,\ + [f9f62194/48h/0h/0h/2h]tpubDDp3ZSH1yCwusRppH7zgSxq2t1VEUyXSeEp8E5aFS8m43MknUjiF1bSLo3CGWAxbDyhF1XowA5ukPzyJZjznYk3kYi6oe7QxtX2euvKWsk4/1/*,\ + [c98b1535/48h/0h/0h/2h]tpubDCDi5W4sP6zSnzJeowy8rQDVhBdRARaPhK1axABi8V1661wEPeanpEXj4ZLAUEoikVtoWcyK26TKKJSecSfeKxwHCcRrge9k1ybuiL71z4a/1/*\ + ))"; + + let wallet: OfflineWallet<_> = Wallet::new_offline( + descriptor, + Some(change_descriptor), + Network::Testnet, + get_test_db(), + ) + .unwrap(); + let export = WalletExport::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: OfflineWallet<_> = Wallet::new_offline( + descriptor, + Some(change_descriptor), + Network::Testnet, + get_test_db(), + ) + .unwrap(); + let export = WalletExport::export_wallet(&wallet, "Test Label", true).unwrap(); + + assert_eq!(serde_json::to_string(&export).unwrap(), "{\"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: WalletExport = serde_json::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/mod.rs b/src/wallet/mod.rs index 7948a672..6dd5ad59 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -17,6 +17,7 @@ use miniscript::BitcoinSig; use log::{debug, error, info, trace}; pub mod coin_selection; +pub mod export; pub mod time; pub mod tx_builder; pub mod utils;