]> Untitled Git - bdk/commitdiff
[export] Implement the wallet import/export format from FullyNoded
authorAlekos Filini <alekos.filini@gmail.com>
Fri, 7 Aug 2020 08:19:06 +0000 (10:19 +0200)
committerAlekos Filini <alekos.filini@gmail.com>
Fri, 7 Aug 2020 08:19:06 +0000 (10:19 +0200)
This commit closes #31

src/wallet/export.rs [new file with mode: 0644]
src/wallet/mod.rs

diff --git a/src/wallet/export.rs b/src/wallet/export.rs
new file mode 100644 (file)
index 0000000..dac3cfd
--- /dev/null
@@ -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<B: Blockchain, D: BatchDatabase>(
+        wallet: &Wallet<B, D>,
+        label: &str,
+        include_blockheight: bool,
+    ) -> Result<Self, &'static str> {
+        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::<Vec<_>>();
+                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<Ctx: ScriptContext>(
+            terminal: Terminal<String, Ctx>,
+        ) -> Result<(), &'static str> {
+            if let Terminal::Multi(_, _) = terminal {
+                Ok(())
+            } else {
+                Err("The descriptor contains operators not supported by Bitcoin Core")
+            }
+        }
+
+        match Descriptor::<String>::from_str(descriptor).map_err(|_| "Invalid descriptor")? {
+            Descriptor::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<String> {
+        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");
+    }
+}
index 7948a672aa7c78c07a02d392ca89133a524e52bb..6dd5ad5983c9e092f0d114aa9f54cfac36cb753a 100644 (file)
@@ -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;