From 3afb324e48126f26f96485a346599b233160f0af Mon Sep 17 00:00:00 2001 From: valued mammal Date: Fri, 20 Jun 2025 13:51:52 -0400 Subject: [PATCH 1/2] feat: add multi_keychain module - Add rusqlite support for `Wallet`. --- wallet/src/lib.rs | 2 + wallet/src/multi_keychain.rs | 12 ++ wallet/src/multi_keychain/changeset.rs | 227 ++++++++++++++++++++ wallet/src/multi_keychain/keyring.rs | 121 +++++++++++ wallet/src/multi_keychain/wallet.rs | 279 +++++++++++++++++++++++++ 5 files changed, 641 insertions(+) create mode 100644 wallet/src/multi_keychain.rs create mode 100644 wallet/src/multi_keychain/changeset.rs create mode 100644 wallet/src/multi_keychain/keyring.rs create mode 100644 wallet/src/multi_keychain/wallet.rs diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index d17cc468..ca3575a2 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -33,6 +33,8 @@ pub mod test_utils; mod types; mod wallet; +pub mod multi_keychain; + pub(crate) use bdk_chain::collections; #[cfg(feature = "rusqlite")] pub use bdk_chain::rusqlite; diff --git a/wallet/src/multi_keychain.rs b/wallet/src/multi_keychain.rs new file mode 100644 index 00000000..7ecdfdf9 --- /dev/null +++ b/wallet/src/multi_keychain.rs @@ -0,0 +1,12 @@ +//! Module containing the multi-keychain [`Wallet`]. + +mod changeset; +pub mod keyring; +mod wallet; + +pub use changeset::*; +pub use keyring::KeyRing; +pub use wallet::*; + +/// Alias for [`DescriptorId`](bdk_chain::DescriptorId). +pub(crate) type Did = bdk_chain::DescriptorId; diff --git a/wallet/src/multi_keychain/changeset.rs b/wallet/src/multi_keychain/changeset.rs new file mode 100644 index 00000000..91aa6789 --- /dev/null +++ b/wallet/src/multi_keychain/changeset.rs @@ -0,0 +1,227 @@ +use bdk_chain::{ + indexed_tx_graph, keychain_txout, local_chain, tx_graph, ConfirmationBlockTime, DescriptorId, + Merge, +}; +use bitcoin::Network; +use miniscript::{Descriptor, DescriptorPublicKey}; +use serde::{Deserialize, Serialize}; + +use crate::multi_keychain::keyring; + +/// Change set. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ChangeSet { + /// Keyring changeset. + pub keyring: keyring::ChangeSet, + /// Changes to the [`LocalChain`](local_chain::LocalChain). + pub local_chain: local_chain::ChangeSet, + /// Changes to [`TxGraph`](tx_graph::TxGraph). + pub tx_graph: tx_graph::ChangeSet, + /// Changes to [`KeychainTxOutIndex`](keychain_txout::KeychainTxOutIndex). + pub indexer: keychain_txout::ChangeSet, +} + +impl Default for ChangeSet { + fn default() -> Self { + Self { + keyring: Default::default(), + local_chain: Default::default(), + tx_graph: Default::default(), + indexer: Default::default(), + } + } +} + +impl Merge for ChangeSet { + fn merge(&mut self, other: Self) { + // merge keyring + self.keyring.merge(other.keyring); + + // merge local chain, tx-graph, indexer + Merge::merge(&mut self.local_chain, other.local_chain); + Merge::merge(&mut self.tx_graph, other.tx_graph); + Merge::merge(&mut self.indexer, other.indexer); + } + + fn is_empty(&self) -> bool { + self.keyring.is_empty() + && self.local_chain.is_empty() + && self.tx_graph.is_empty() + && self.indexer.is_empty() + } +} + +#[cfg(feature = "rusqlite")] +use bdk_chain::rusqlite; + +#[cfg(feature = "rusqlite")] +impl ChangeSet { + /// Schema name for wallet. + pub const WALLET_SCHEMA_NAME: &'static str = "bdk_wallet"; + /// Name of table to store wallet metainformation. + pub const WALLET_TABLE_NAME: &'static str = "bdk_wallet"; + /// Name of table to store wallet descriptors. + pub const DESCRIPTORS_TABLE_NAME: &'static str = "bdk_descriptor"; + + /// Get v0 sqlite [ChangeSet] schema. + pub fn schema_v0() -> alloc::string::String { + format!( + "CREATE TABLE {} ( \ + id INTEGER PRIMARY KEY NOT NULL, \ + network TEXT NOT NULL \ + ); \ + CREATE TABLE {} ( \ + descriptor_id TEXT PRIMARY KEY NOT NULL, \ + descriptor BLOB NOT NULL \ + );", + Self::WALLET_TABLE_NAME, + Self::DESCRIPTORS_TABLE_NAME, + ) + } + + /// Initializes tables and returns the aggregate data if the database is non-empty + /// otherwise returns `Ok(None)`. + pub fn initialize(db_tx: &rusqlite::Transaction) -> rusqlite::Result> { + Self::init_sqlite_tables(db_tx)?; + let changeset = Self::from_sqlite(db_tx)?; + + if changeset.is_empty() { + Ok(None) + } else { + Ok(Some(changeset)) + } + } + + /// Initialize SQLite tables. + fn init_sqlite_tables(db_tx: &rusqlite::Transaction) -> rusqlite::Result<()> { + crate::rusqlite_impl::migrate_schema( + db_tx, + Self::WALLET_SCHEMA_NAME, + &[&Self::schema_v0()], + )?; + + local_chain::ChangeSet::init_sqlite_tables(db_tx)?; + tx_graph::ChangeSet::::init_sqlite_tables(db_tx)?; + keychain_txout::ChangeSet::init_sqlite_tables(db_tx)?; + + Ok(()) + } + + /// Construct self by reading all of the SQLite data. This should succeed + /// even if attempting to read an empty database. + fn from_sqlite(db_tx: &rusqlite::Transaction) -> rusqlite::Result { + use bdk_chain::Impl; + use rusqlite::OptionalExtension; + let mut changeset = Self::default(); + + let mut keyring = keyring::ChangeSet::default(); + + // Read network + let mut network_stmt = db_tx.prepare(&format!( + "SELECT network FROM {} WHERE id = 0", + Self::WALLET_TABLE_NAME, + ))?; + let row = network_stmt + .query_row([], |row| row.get::<_, Impl>("network")) + .optional()?; + if let Some(Impl(network)) = row { + keyring.network = Some(network); + } + + // Read descriptors + let mut descriptor_stmt = db_tx.prepare(&format!( + "SELECT descriptor_id, descriptor FROM {}", + Self::DESCRIPTORS_TABLE_NAME + ))?; + let rows = descriptor_stmt.query_map([], |row| { + Ok(( + row.get::<_, Impl>("descriptor_id")?, + row.get::<_, Impl>>("descriptor")?, + )) + })?; + for row in rows { + let (Impl(did), Impl(descriptor)) = row?; + keyring.descriptors.insert(did, descriptor); + } + + changeset.keyring = keyring; + changeset.local_chain = local_chain::ChangeSet::from_sqlite(db_tx)?; + changeset.tx_graph = tx_graph::ChangeSet::from_sqlite(db_tx)?; + changeset.indexer = keychain_txout::ChangeSet::from_sqlite(db_tx)?; + + Ok(changeset) + } + + /// Persist self to SQLite. + pub fn persist_to_sqlite(&self, db_tx: &rusqlite::Transaction) -> rusqlite::Result<()> { + use chain::rusqlite::named_params; + use chain::Impl; + + let keyring = &self.keyring; + + // Write network + let mut network_stmt = db_tx.prepare_cached(&format!( + "REPLACE INTO {}(id, network) VALUES(:id, :network)", + Self::WALLET_TABLE_NAME, + ))?; + if let Some(network) = keyring.network { + network_stmt.execute(named_params! { + ":id": 0, + ":network": Impl(network), + })?; + } + + // Write descriptors + let mut descriptor_stmt = db_tx.prepare_cached(&format!( + "INSERT OR IGNORE INTO {}(descriptor_id, descriptor) VALUES(:descriptor_id, :descriptor)", + Self::DESCRIPTORS_TABLE_NAME, + ))?; + for (&did, descriptor) in &keyring.descriptors { + descriptor_stmt.execute(named_params! { + ":descriptor_id": Impl(did), + ":descriptor": Impl(descriptor.clone()), + })?; + } + + self.local_chain.persist_to_sqlite(db_tx)?; + self.tx_graph.persist_to_sqlite(db_tx)?; + self.indexer.persist_to_sqlite(db_tx)?; + + Ok(()) + } +} + +impl From for ChangeSet { + fn from(local_chain: local_chain::ChangeSet) -> Self { + Self { + local_chain, + ..Default::default() + } + } +} + +impl From> + for ChangeSet +{ + fn from( + indexed_tx_graph: indexed_tx_graph::ChangeSet< + ConfirmationBlockTime, + keychain_txout::ChangeSet, + >, + ) -> Self { + Self { + tx_graph: indexed_tx_graph.tx_graph, + indexer: indexed_tx_graph.indexer, + ..Default::default() + } + } +} + +impl From for ChangeSet { + fn from(indexer: keychain_txout::ChangeSet) -> Self { + Self { + indexer, + ..Default::default() + } + } +} diff --git a/wallet/src/multi_keychain/keyring.rs b/wallet/src/multi_keychain/keyring.rs new file mode 100644 index 00000000..f86fa0f1 --- /dev/null +++ b/wallet/src/multi_keychain/keyring.rs @@ -0,0 +1,121 @@ +//! [`KeyRing`]. + +use bdk_chain::{DescriptorExt, Merge}; +use bitcoin::{ + secp256k1::{All, Secp256k1}, + Network, +}; +use miniscript::{Descriptor, DescriptorPublicKey}; +use serde::{Deserialize, Serialize}; + +use crate::collections::BTreeMap; +use crate::descriptor::{check_wallet_descriptor, IntoWalletDescriptor}; +use crate::multi_keychain::Did; + +/// KeyRing. +#[derive(Debug, Clone)] +pub struct KeyRing { + pub(crate) secp: Secp256k1, + pub(crate) network: Network, + pub(crate) descriptors: BTreeMap>, +} + +impl KeyRing +where + K: Ord + Clone, +{ + /// Construct new [`KeyRing`] with the provided `network`. + pub fn new(network: Network) -> Self { + Self { + secp: Secp256k1::new(), + network, + descriptors: BTreeMap::default(), + } + } + + /// Add descriptor, must not be [multipath](miniscript::Descriptor::is_multipath). + pub fn add_descriptor(&mut self, keychain: K, descriptor: impl IntoWalletDescriptor) { + let descriptor = descriptor + .into_wallet_descriptor(&self.secp, self.network) + .expect("err: invalid descriptor") + .0; + assert!( + !descriptor.is_multipath(), + "err: Use `add_multipath_descriptor` instead" + ); + check_wallet_descriptor(&descriptor).expect("err: failed `check_wallet_descriptor`"); + + self.descriptors.insert(keychain, descriptor); + } + + /// Initial changeset. + pub fn initial_changeset(&self) -> ChangeSet { + ChangeSet { + network: Some(self.network), + descriptors: self.descriptors.clone(), + } + } + + /// Construct from changeset. + pub fn from_changeset(changeset: ChangeSet) -> Option { + Some(Self { + secp: Secp256k1::new(), + network: changeset.network?, + descriptors: changeset.descriptors, + }) + } +} + +impl KeyRing { + /// Add multipath descriptor. + pub fn add_multipath_descriptor(&mut self, descriptor: impl IntoWalletDescriptor) { + let descriptor = descriptor + .into_wallet_descriptor(&self.secp, self.network) + .expect("err: invalid descriptor") + .0; + assert!( + descriptor.is_multipath(), + "err: Use `add_descriptor` instead" + ); + let descriptors = descriptor + .into_single_descriptors() + .expect("err: invalid descriptor"); + for descriptor in descriptors { + let did = descriptor.descriptor_id(); + self.descriptors.insert(did, descriptor); + } + } +} + +/// Represents changes to the `KeyRing`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChangeSet { + /// Network. + pub network: Option, + /// Added descriptors. + pub descriptors: BTreeMap>, +} + +impl Default for ChangeSet { + fn default() -> Self { + Self { + network: None, + descriptors: Default::default(), + } + } +} + +impl Merge for ChangeSet { + fn merge(&mut self, other: Self) { + // merge network + if other.network.is_some() && self.network.is_none() { + self.network = other.network; + } + // merge descriptors + self.descriptors.extend(other.descriptors); + } + + fn is_empty(&self) -> bool { + self.network.is_none() && self.descriptors.is_empty() + } +} diff --git a/wallet/src/multi_keychain/wallet.rs b/wallet/src/multi_keychain/wallet.rs new file mode 100644 index 00000000..74d99095 --- /dev/null +++ b/wallet/src/multi_keychain/wallet.rs @@ -0,0 +1,279 @@ +use core::fmt; + +use bitcoin::Address; +use miniscript::{Descriptor, DescriptorPublicKey}; + +#[cfg(feature = "rusqlite")] +use bdk_chain::rusqlite; +use bdk_chain::{ + keychain_txout::{KeychainTxOutIndex, DEFAULT_LOOKAHEAD}, + local_chain::LocalChain, + CheckPoint, ConfirmationBlockTime, IndexedTxGraph, KeychainIndexed, Merge, +}; + +use crate::collections::BTreeMap; +use crate::multi_keychain::{ChangeSet, Did, KeyRing}; + +/// Alias for a [`IndexedTxGraph`]. +type KeychainTxGraph = IndexedTxGraph>; + +// This is here for dev purposes and can be made a configurable option as part of the final API. +const USE_SPK_CACHE: bool = false; + +/// [`Wallet`] is a structure that stores transaction data that can be indexed by multiple +/// keychains. +#[derive(Debug)] +pub struct Wallet { + keyring: KeyRing, + chain: LocalChain, + tx_graph: KeychainTxGraph, + stage: ChangeSet, +} + +impl Wallet +where + K: fmt::Debug + Clone + Ord, +{ + /// Construct a new [`Wallet`] with the given `keyring`. + pub fn new(mut keyring: KeyRing) -> Self { + let network = keyring.network; + + let genesis_hash = bitcoin::constants::genesis_block(network).block_hash(); + let (chain, chain_changeset) = LocalChain::from_genesis_hash(genesis_hash); + + let keyring_changeset = keyring.initial_changeset(); + + let mut index = KeychainTxOutIndex::new(DEFAULT_LOOKAHEAD, USE_SPK_CACHE); + let descriptors = core::mem::take(&mut keyring.descriptors); + for (keychain, desc) in descriptors { + let _inserted = index + .insert_descriptor(keychain, desc) + .expect("err: failed to insert descriptor"); + assert!(_inserted); + } + + let tx_graph = KeychainTxGraph::new(index); + + let stage = ChangeSet { + keyring: keyring_changeset, + local_chain: chain_changeset, + tx_graph: bdk_chain::tx_graph::ChangeSet::default(), + indexer: bdk_chain::keychain_txout::ChangeSet::default(), + }; + + Self { + keyring, + chain, + tx_graph, + stage, + } + } + + /// Construct [`Wallet`] from the provided `changeset`. + /// + /// Will be `None` if the changeset is empty. + pub fn from_changeset(changeset: ChangeSet) -> Option { + if changeset.is_empty() { + return None; + } + + // chain + let chain = + LocalChain::from_changeset(changeset.local_chain).expect("err: Missing genesis"); + + // keyring + let mut keyring = KeyRing::from_changeset(changeset.keyring)?; + + // index + let mut index = KeychainTxOutIndex::new(DEFAULT_LOOKAHEAD, USE_SPK_CACHE); + index.apply_changeset(changeset.indexer); + for (keychain, descriptor) in core::mem::take(&mut keyring.descriptors) { + let _inserted = index + .insert_descriptor(keychain, descriptor) + .expect("failed to insert descriptor"); + assert!(_inserted); + } + + // txgraph + let mut tx_graph = KeychainTxGraph::new(index); + tx_graph.apply_changeset(changeset.tx_graph.into()); + + let stage = ChangeSet::default(); + + Some(Self { + tx_graph, + stage, + chain, + keyring, + }) + } + + /// Reveal next default address. Panics if the default implementation of `K` does not match + /// a keychain contained in this wallet. + pub fn reveal_next_default_address_unwrap(&mut self) -> KeychainIndexed + where + K: Default, + { + self.reveal_next_address(K::default()) + .expect("invalid keychain") + } + + /// Reveal next address from the given `keychain`. + /// + /// This may return the last revealed address in case there are none left to reveal. + pub fn reveal_next_address(&mut self, keychain: K) -> Option> { + let ((index, spk), index_changeset) = + self.tx_graph.index.reveal_next_spk(keychain.clone())?; + let address = Address::from_script(&spk, self.keyring.network) + .expect("script should have address form"); + + self.stage(index_changeset); + + Some(((keychain, index), address)) + } + + /// Iterate over `(keychain descriptor)` pairs contained in this wallet. + pub fn keychains( + &self, + ) -> impl DoubleEndedIterator)> { + self.tx_graph.index.keychains() + } + + /// Compute the balance. + pub fn balance(&self) -> bdk_chain::Balance { + use bdk_chain::CanonicalizationParams; + let chain = &self.chain; + let outpoints = self.tx_graph.index.outpoints().clone(); + self.tx_graph.graph().balance( + chain, + chain.tip().block_id(), + CanonicalizationParams::default(), + outpoints, + |_, _| false, + ) + } + + /// Obtain a reference to the indexed transaction graph. + pub fn tx_graph(&self) -> &KeychainTxGraph { + &self.tx_graph + } + + /// Obtain a reference to the keychain indexer. + pub fn index(&self) -> &KeychainTxOutIndex { + &self.tx_graph.index + } + + /// Obtain a reference to the local chain. + pub fn local_chain(&self) -> &LocalChain { + &self.chain + } + + /// Apply update. + pub fn apply_update(&mut self, update: impl Into>) { + let Update { + chain, + tx_update, + last_active_indices, + } = update.into(); + + let mut changeset = ChangeSet::default(); + + // chain + if let Some(tip) = chain { + changeset.merge( + self.chain + .apply_update(tip) + .expect("err: failed to apply update to chain") + .into(), + ); + } + // index + changeset.merge( + self.tx_graph + .index + .reveal_to_target_multi(&last_active_indices) + .into(), + ); + // tx graph + changeset.merge(self.tx_graph.apply_update(tx_update).into()); + + self.stage(changeset); + } + + /// Stages anything that can be converted directly into a [`ChangeSet`]. + fn stage(&mut self, changeset: impl Into>) { + self.stage.merge(changeset.into()); + } + + /// See the staged changes if any. + pub fn staged(&self) -> Option<&ChangeSet> { + if self.stage.is_empty() { + None + } else { + Some(&self.stage) + } + } +} + +// TODO: This should probably be handled by `PersistedWallet` or similar +#[cfg(feature = "rusqlite")] +impl Wallet { + /// Construct [`Wallet`] from SQLite. + pub fn from_sqlite(conn: &mut rusqlite::Connection) -> rusqlite::Result> { + let tx = conn.transaction()?; + + let changeset = ChangeSet::initialize(&tx)?; + tx.commit()?; + + Ok(changeset.and_then(Self::from_changeset)) + } + + /// Persist to SQLite. Returns the newly committed changeset if successful, or `None` + /// if the stage is currently empty. + pub fn persist_to_sqlite( + &mut self, + conn: &mut rusqlite::Connection, + ) -> rusqlite::Result>> { + let mut ret = None; + + let tx = conn.transaction()?; + + if let Some(changeset) = self.staged_changeset() { + changeset.persist_to_sqlite(&tx)?; + tx.commit()?; + ret = self.stage.take(); + } + + Ok(ret) + } + + /// See the staged changes if any. + pub fn staged_changeset(&self) -> Option<&ChangeSet> { + if self.stage.is_empty() { + None + } else { + Some(&self.stage) + } + } +} + +/// Contains structures for updating a multi-keychain wallet. +#[derive(Debug)] +pub struct Update { + /// chain + pub chain: Option, + /// tx update + pub tx_update: bdk_chain::TxUpdate, + /// last active keychain indices + pub last_active_indices: BTreeMap, +} + +impl From> for Update { + fn from(resp: bdk_chain::spk_client::FullScanResponse) -> Self { + Self { + chain: resp.chain_update, + tx_update: resp.tx_update, + last_active_indices: resp.last_active_indices, + } + } +} From 974f31fced35fe131595dec0bdb79c6b7e476156 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Fri, 20 Jun 2025 13:56:33 -0400 Subject: [PATCH 2/2] example: Add `keyring` example --- wallet/Cargo.toml | 4 +++ wallet/examples/keyring.rs | 63 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 wallet/examples/keyring.rs diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml index 7b826270..71f991da 100644 --- a/wallet/Cargo.toml +++ b/wallet/Cargo.toml @@ -58,3 +58,7 @@ required-features = ["all-keys"] name = "miniscriptc" path = "examples/compiler.rs" required-features = ["compiler"] + +[[example]] +name = "keyring" +required-features = ["rusqlite"] diff --git a/wallet/examples/keyring.rs b/wallet/examples/keyring.rs new file mode 100644 index 00000000..54a72abd --- /dev/null +++ b/wallet/examples/keyring.rs @@ -0,0 +1,63 @@ +#![allow(unused)] +#![allow(clippy::print_stdout)] + +use bdk_chain::DescriptorExt; +use bdk_chain::DescriptorId; +use bitcoin::secp256k1::Secp256k1; +use bitcoin::Network; +use miniscript::{Descriptor, DescriptorPublicKey}; + +use bdk_wallet::chain as bdk_chain; +use bdk_wallet::multi_keychain::{KeyRing, Wallet}; +use bdk_wallet::rusqlite; + +// This example shows how to create a BDK wallet from a `KeyRing`. + +fn main() -> anyhow::Result<()> { + let path = ".bdk_example_keyring.sqlite"; + let mut conn = rusqlite::Connection::open(path)?; + + let network = Network::Signet; + + let desc = "wpkh([83737d5e/84'/1'/1']tpubDCzuCBKnZA5TNKhiJnASku7kq8Q4iqcVF82JV7mHo2NxWpXkLRbrJaGA5ToE7LCuWpcPErBbpDzbdWKN8aTdJzmRy1jQPmZvnqpwwDwCdy7/<0;1>/*)"; + let desc2 = "tr([83737d5e/86'/1'/1']tpubDDR5GgtoxS8fNuSTJU6huqQKGzWshPaemb3UwFDoAXCsyakcQoRcFDMiGUVRX43Lofd7ZB82RcUvu1xnZ5oGZhbr43dRkY8xm2KGhpcq93o/<0;1>/*)"; + + let default_did: DescriptorId = + "6f3ba87443e825675b2b1cb8da505831422a7d214c515070570885180a1b2733".parse()?; + + let mut wallet = match Wallet::from_sqlite(&mut conn)? { + Some(w) => w, + None => { + let mut keyring = KeyRing::new(network); + for multipath_desc in [desc, desc2] { + for (did, desc) in label_descriptors(multipath_desc) { + keyring.add_descriptor(did, desc); + } + } + let mut wallet = Wallet::new(keyring); + wallet.persist_to_sqlite(&mut conn)?; + wallet + } + }; + + let (indexed, addr) = wallet.reveal_next_address(default_did).unwrap(); + println!("Address: {:?} {}", indexed, addr); + + let changeset = wallet.persist_to_sqlite(&mut conn)?; + println!("Change persisted: {}", changeset.is_some()); + + Ok(()) +} + +/// Helper method to label descriptors by descriptor ID! +fn label_descriptors( + s: &str, +) -> impl Iterator)> { + let desc = Descriptor::parse_descriptor(&Secp256k1::new(), s) + .expect("failed to parse descriptor") + .0; + desc.into_single_descriptors() + .expect("inavlid descriptor") + .into_iter() + .map(|desc| (desc.descriptor_id(), desc)) +}