diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cb9f31c..22152cf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - added `OldestFirstCoinSelection` impl to `CoinSelectionAlgorithm` - New MSRV set to `1.56` +- Add traits to reuse `Blockchain`s across multiple wallets (`BlockchainFactory` and `StatelessBlockchain`). ## [v0.18.0] - [v0.17.0] diff --git a/src/blockchain/electrum.rs b/src/blockchain/electrum.rs index 0b8691bc..f8ac758c 100644 --- a/src/blockchain/electrum.rs +++ b/src/blockchain/electrum.rs @@ -79,6 +79,8 @@ impl Blockchain for ElectrumBlockchain { } } +impl StatelessBlockchain for ElectrumBlockchain {} + impl GetHeight for ElectrumBlockchain { fn get_height(&self) -> Result { // TODO: unsubscribe when added to the client, or is there a better call to use here? @@ -320,8 +322,67 @@ impl ConfigurableBlockchain for ElectrumBlockchain { #[cfg(test)] #[cfg(feature = "test-electrum")] -crate::bdk_blockchain_tests! { - fn test_instance(test_client: &TestClient) -> ElectrumBlockchain { - ElectrumBlockchain::from(Client::new(&test_client.electrsd.electrum_url).unwrap()) +mod test { + use std::sync::Arc; + + use super::*; + use crate::database::MemoryDatabase; + use crate::testutils::blockchain_tests::TestClient; + use crate::wallet::{AddressIndex, Wallet}; + + crate::bdk_blockchain_tests! { + fn test_instance(test_client: &TestClient) -> ElectrumBlockchain { + ElectrumBlockchain::from(Client::new(&test_client.electrsd.electrum_url).unwrap()) + } + } + + fn get_factory() -> (TestClient, Arc) { + let test_client = TestClient::default(); + + let factory = Arc::new(ElectrumBlockchain::from( + Client::new(&test_client.electrsd.electrum_url).unwrap(), + )); + + (test_client, factory) + } + + #[test] + fn test_electrum_blockchain_factory() { + let (_test_client, factory) = get_factory(); + + let a = factory.build("aaaaaa", None).unwrap(); + let b = factory.build("bbbbbb", None).unwrap(); + + assert_eq!( + a.client.block_headers_subscribe().unwrap().height, + b.client.block_headers_subscribe().unwrap().height + ); + } + + #[test] + fn test_electrum_blockchain_factory_sync_wallet() { + let (mut test_client, factory) = get_factory(); + + let db = MemoryDatabase::new(); + let wallet = Wallet::new( + "wpkh(L5EZftvrYaSudiozVRzTqLcHLNDoVn7H5HSfM9BAN6tMJX8oTWz6)", + None, + bitcoin::Network::Regtest, + db, + ) + .unwrap(); + + let address = wallet.get_address(AddressIndex::New).unwrap(); + + let tx = testutils! { + @tx ( (@addr address.address) => 50_000 ) + }; + test_client.receive(tx); + + factory + .sync_wallet(&wallet, None, Default::default()) + .unwrap(); + + assert_eq!(wallet.get_balance().unwrap(), 50_000); } } diff --git a/src/blockchain/esplora/reqwest.rs b/src/blockchain/esplora/reqwest.rs index 2141b8e6..f68bdd8a 100644 --- a/src/blockchain/esplora/reqwest.rs +++ b/src/blockchain/esplora/reqwest.rs @@ -101,6 +101,8 @@ impl Blockchain for EsploraBlockchain { } } +impl StatelessBlockchain for EsploraBlockchain {} + #[maybe_async] impl GetHeight for EsploraBlockchain { fn get_height(&self) -> Result { diff --git a/src/blockchain/esplora/ureq.rs b/src/blockchain/esplora/ureq.rs index 55f1cf76..50493f9c 100644 --- a/src/blockchain/esplora/ureq.rs +++ b/src/blockchain/esplora/ureq.rs @@ -98,6 +98,8 @@ impl Blockchain for EsploraBlockchain { } } +impl StatelessBlockchain for EsploraBlockchain {} + impl GetHeight for EsploraBlockchain { fn get_height(&self) -> Result { Ok(self.url_client._get_height()?) diff --git a/src/blockchain/mod.rs b/src/blockchain/mod.rs index 714fdf6a..cf593c3c 100644 --- a/src/blockchain/mod.rs +++ b/src/blockchain/mod.rs @@ -25,7 +25,8 @@ use bitcoin::{Transaction, Txid}; use crate::database::BatchDatabase; use crate::error::Error; -use crate::FeeRate; +use crate::wallet::{wallet_name_from_descriptor, Wallet}; +use crate::{FeeRate, KeychainKind}; #[cfg(any( feature = "electrum", @@ -164,6 +165,106 @@ pub trait ConfigurableBlockchain: Blockchain + Sized { fn from_config(config: &Self::Config) -> Result; } +/// Trait for blockchains that don't contain any state +/// +/// Statless blockchains can be used to sync multiple wallets with different descriptors. +/// +/// [`BlockchainFactory`] is automatically implemented for `Arc` where `T` is a stateless +/// blockchain. +pub trait StatelessBlockchain: Blockchain {} + +/// Trait for a factory of blockchains that share the underlying connection or configuration +#[cfg_attr( + not(feature = "async-interface"), + doc = r##" +## Example + +This example shows how to sync multiple walles and return the sum of their balances + +```no_run +# use bdk::Error; +# use bdk::blockchain::*; +# use bdk::database::*; +# use bdk::wallet::*; +# use bdk::*; +fn sum_of_balances(blockchain_factory: B, wallets: &[Wallet]) -> Result { + Ok(wallets + .iter() + .map(|w| -> Result<_, Error> { + blockchain_factory.sync_wallet(&w, None, SyncOptions::default())?; + w.get_balance() + }) + .collect::, _>>()? + .into_iter() + .sum()) +} +``` +"## +)] +pub trait BlockchainFactory { + /// The type returned when building a blockchain from this factory + type Inner: Blockchain; + + /// Build a new blockchain for the given descriptor wallet_name + /// + /// If `override_skip_blocks` is `None`, the returned blockchain will inherit the number of blocks + /// from the factory. Since it's not possible to override the value to `None`, set it to + /// `Some(0)` to rescan from the genesis. + fn build( + &self, + wallet_name: &str, + override_skip_blocks: Option, + ) -> Result; + + /// Build a new blockchain for a given wallet + /// + /// Internally uses [`wallet_name_from_descriptor`] to derive the name, and then calls + /// [`BlockchainFactory::build`] to create the blockchain instance. + fn build_for_wallet( + &self, + wallet: &Wallet, + override_skip_blocks: Option, + ) -> Result { + let wallet_name = wallet_name_from_descriptor( + wallet.public_descriptor(KeychainKind::External)?.unwrap(), + wallet.public_descriptor(KeychainKind::Internal)?, + wallet.network(), + wallet.secp_ctx(), + )?; + self.build(&wallet_name, override_skip_blocks) + } + + /// Use [`BlockchainFactory::build_for_wallet`] to get a blockchain, then sync the wallet + /// + /// This can be used when a new blockchain would only be used to sync a wallet and then + /// immediately dropped. Keep in mind that specific blockchain factories may perform slow + /// operations to build a blockchain for a given wallet, so if a wallet needs to be synced + /// often it's recommended to use [`BlockchainFactory::build_for_wallet`] to reuse the same + /// blockchain multiple times. + #[cfg(not(any(target_arch = "wasm32", feature = "async-interface")))] + #[cfg_attr( + docsrs, + doc(cfg(not(any(target_arch = "wasm32", feature = "async-interface")))) + )] + fn sync_wallet( + &self, + wallet: &Wallet, + override_skip_blocks: Option, + sync_options: crate::wallet::SyncOptions, + ) -> Result<(), Error> { + let blockchain = self.build_for_wallet(wallet, override_skip_blocks)?; + wallet.sync(&blockchain, sync_options) + } +} + +impl BlockchainFactory for Arc { + type Inner = Self; + + fn build(&self, _wallet_name: &str, _override_skip_blocks: Option) -> Result { + Ok(Arc::clone(self)) + } +} + /// Data sent with a progress update over a [`channel`] pub type ProgressData = (f32, Option); diff --git a/src/blockchain/rpc.rs b/src/blockchain/rpc.rs index 78d166e3..7eb05920 100644 --- a/src/blockchain/rpc.rs +++ b/src/blockchain/rpc.rs @@ -438,18 +438,127 @@ fn list_wallet_dir(client: &Client) -> Result, Error> { Ok(result.wallets.into_iter().map(|n| n.name).collect()) } -#[cfg(test)] -#[cfg(feature = "test-rpc")] -crate::bdk_blockchain_tests! { +/// Factory of [`RpcBlockchain`] instances, implements [`BlockchainFactory`] +/// +/// Internally caches the node url and authentication params and allows getting many different [`RpcBlockchain`] +/// objects for different wallet names and with different rescan heights. +/// +/// ## Example +/// +/// ```no_run +/// # use bdk::bitcoin::Network; +/// # use bdk::blockchain::BlockchainFactory; +/// # use bdk::blockchain::rpc::{Auth, RpcBlockchainFactory}; +/// # fn main() -> Result<(), Box> { +/// let factory = RpcBlockchainFactory { +/// url: "http://127.0.0.1:18332".to_string(), +/// auth: Auth::Cookie { +/// file: "/home/user/.bitcoin/.cookie".into(), +/// }, +/// network: Network::Testnet, +/// wallet_name_prefix: Some("prefix-".to_string()), +/// default_skip_blocks: 100_000, +/// }; +/// let main_wallet_blockchain = factory.build("main_wallet", Some(200_000))?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug, Clone)] +pub struct RpcBlockchainFactory { + /// The bitcoin node url + pub url: String, + /// The bitcoin node authentication mechanism + pub auth: Auth, + /// The network we are using (it will be checked the bitcoin node network matches this) + pub network: Network, + /// The optional prefix used to build the full wallet name for blockchains + pub wallet_name_prefix: Option, + /// Default number of blocks to skip which will be inherited by blockchain unless overridden + pub default_skip_blocks: u32, +} - fn test_instance(test_client: &TestClient) -> RpcBlockchain { - let config = RpcConfig { - url: test_client.bitcoind.rpc_url(), - auth: Auth::Cookie { file: test_client.bitcoind.params.cookie_file.clone() }, - network: Network::Regtest, - wallet_name: format!("client-wallet-test-{:?}", std::time::SystemTime::now() ), - skip_blocks: None, - }; - RpcBlockchain::from_config(&config).unwrap() +impl BlockchainFactory for RpcBlockchainFactory { + type Inner = RpcBlockchain; + + fn build( + &self, + checksum: &str, + override_skip_blocks: Option, + ) -> Result { + RpcBlockchain::from_config(&RpcConfig { + url: self.url.clone(), + auth: self.auth.clone(), + network: self.network, + wallet_name: format!( + "{}{}", + self.wallet_name_prefix.as_ref().unwrap_or(&String::new()), + checksum + ), + skip_blocks: Some(override_skip_blocks.unwrap_or(self.default_skip_blocks)), + }) + } +} + +#[cfg(test)] +#[cfg(feature = "test-rpc")] +mod test { + use super::*; + use crate::testutils::blockchain_tests::TestClient; + + use bitcoin::Network; + use bitcoincore_rpc::RpcApi; + + crate::bdk_blockchain_tests! { + fn test_instance(test_client: &TestClient) -> RpcBlockchain { + let config = RpcConfig { + url: test_client.bitcoind.rpc_url(), + auth: Auth::Cookie { file: test_client.bitcoind.params.cookie_file.clone() }, + network: Network::Regtest, + wallet_name: format!("client-wallet-test-{:?}", std::time::SystemTime::now() ), + skip_blocks: None, + }; + RpcBlockchain::from_config(&config).unwrap() + } + } + + fn get_factory() -> (TestClient, RpcBlockchainFactory) { + let test_client = TestClient::default(); + + let factory = RpcBlockchainFactory { + url: test_client.bitcoind.rpc_url(), + auth: Auth::Cookie { + file: test_client.bitcoind.params.cookie_file.clone(), + }, + network: Network::Regtest, + wallet_name_prefix: Some("prefix-".into()), + default_skip_blocks: 0, + }; + + (test_client, factory) + } + + #[test] + fn test_rpc_blockchain_factory() { + let (_test_client, factory) = get_factory(); + + let a = factory.build("aaaaaa", None).unwrap(); + assert_eq!(a.skip_blocks, Some(0)); + assert_eq!( + a.client + .get_wallet_info() + .expect("Node connection isn't working") + .wallet_name, + "prefix-aaaaaa" + ); + + let b = factory.build("bbbbbb", Some(100)).unwrap(); + assert_eq!(b.skip_blocks, Some(100)); + assert_eq!( + b.client + .get_wallet_info() + .expect("Node connection isn't working") + .wallet_name, + "prefix-bbbbbb" + ); } } diff --git a/src/testutils/mod.rs b/src/testutils/mod.rs index b10f1a3b..f05c9df4 100644 --- a/src/testutils/mod.rs +++ b/src/testutils/mod.rs @@ -267,5 +267,3 @@ macro_rules! testutils { (external, internal) }) } - -pub use testutils; diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 914d1089..071b16e0 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -4089,6 +4089,8 @@ pub(crate) mod test { } /// Deterministically generate a unique name given the descriptors defining the wallet +/// +/// Compatible with [`wallet_name_from_descriptor`] pub fn wallet_name_from_descriptor( descriptor: T, change_descriptor: Option,