Skip to content

Commit 7aa2746

Browse files
committed
Merge #569: [blockchain] Add traits to reuse Blockchains across multiple wallets
8795da4 wallet: Move `wallet_name_from_descriptor` above the tests (Alekos Filini) 9c405e9 [blockchain] Add traits to reuse `Blockchain`s across multiple wallets (Alekos Filini) 2d83af4 Move testutils macro module before the others (Alekos Filini) Pull request description: ### Description Add three new traits: - `StatelessBlockchain` is used to tag `Blockchain`s that don't have any wallet-specic state, i.e. they can be used as-is to sync multiple wallets. - `StatefulBlockchain` is the opposite of `StatelessBlockchain`: it provides a method to "clone" a `Blockchain` with an updated internal state (a new wallet checksum and, optionally, a different number of blocks to skip from genesis). Potentially this also allows reusing the underlying connection on `Blockchain` types that support it. - `MultiBlockchain` is a generalization of this concept: it's implemented automatically for every type that implements `StatefulBlockchain` and for every `Arc<T>` where `T` is a `StatelessBlockchain`. This allows a piece of code that deals with multiple sub-wallets to just get a `&B: MultiBlockchain` without having to deal with stateful and statless blockchains individually. These new traits have been implemented for Electrum, Esplora and RPC (the first two being stateless and the latter stateful). It hasn't been implemented on the CBF blockchain, because I don't think it would work in its current form (it throws away old block filters, so it's hard to go back and rescan). This is the first step for #549, as BIP47 needs to sync many different descriptors internally. It's also very useful for #486. ### Notes to the reviewers This is still a draft because: - I'm still wondering if these traits should "inherit" from `Blockchain` instead of the less-restrictive `WalletSync` + `GetHeight` which is the bare minimum to sync a wallet - I need to write tests, at least for rpc which is stateful - I need to add examples ### Checklists #### All Submissions: * [x] I've signed all my commits * [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md) * [x] I ran `cargo fmt` and `cargo clippy` before committing #### New Features: * [x] I've added tests for the new feature * [x] I've added docs for the new feature * [x] I've updated `CHANGELOG.md` ACKs for top commit: rajarshimaitra: tACK 8795da4 Tree-SHA512: 03f3b63d51199b26a20d58cf64929fd690e2530f873120291a7ffea14a6237334845ceb37bff20d6c5466fca961699460af42134d561935d77b830e2e131df9d
2 parents 616aa82 + 8795da4 commit 7aa2746

File tree

8 files changed

+326
-47
lines changed

8 files changed

+326
-47
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88

99
- added `OldestFirstCoinSelection` impl to `CoinSelectionAlgorithm`
1010
- New MSRV set to `1.56`
11+
- Add traits to reuse `Blockchain`s across multiple wallets (`BlockchainFactory` and `StatelessBlockchain`).
1112

1213

1314
## [v0.18.0] - [v0.17.0]

src/blockchain/electrum.rs

+64-3
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ impl Blockchain for ElectrumBlockchain {
7979
}
8080
}
8181

82+
impl StatelessBlockchain for ElectrumBlockchain {}
83+
8284
impl GetHeight for ElectrumBlockchain {
8385
fn get_height(&self) -> Result<u32, Error> {
8486
// TODO: unsubscribe when added to the client, or is there a better call to use here?
@@ -320,8 +322,67 @@ impl ConfigurableBlockchain for ElectrumBlockchain {
320322

321323
#[cfg(test)]
322324
#[cfg(feature = "test-electrum")]
323-
crate::bdk_blockchain_tests! {
324-
fn test_instance(test_client: &TestClient) -> ElectrumBlockchain {
325-
ElectrumBlockchain::from(Client::new(&test_client.electrsd.electrum_url).unwrap())
325+
mod test {
326+
use std::sync::Arc;
327+
328+
use super::*;
329+
use crate::database::MemoryDatabase;
330+
use crate::testutils::blockchain_tests::TestClient;
331+
use crate::wallet::{AddressIndex, Wallet};
332+
333+
crate::bdk_blockchain_tests! {
334+
fn test_instance(test_client: &TestClient) -> ElectrumBlockchain {
335+
ElectrumBlockchain::from(Client::new(&test_client.electrsd.electrum_url).unwrap())
336+
}
337+
}
338+
339+
fn get_factory() -> (TestClient, Arc<ElectrumBlockchain>) {
340+
let test_client = TestClient::default();
341+
342+
let factory = Arc::new(ElectrumBlockchain::from(
343+
Client::new(&test_client.electrsd.electrum_url).unwrap(),
344+
));
345+
346+
(test_client, factory)
347+
}
348+
349+
#[test]
350+
fn test_electrum_blockchain_factory() {
351+
let (_test_client, factory) = get_factory();
352+
353+
let a = factory.build("aaaaaa", None).unwrap();
354+
let b = factory.build("bbbbbb", None).unwrap();
355+
356+
assert_eq!(
357+
a.client.block_headers_subscribe().unwrap().height,
358+
b.client.block_headers_subscribe().unwrap().height
359+
);
360+
}
361+
362+
#[test]
363+
fn test_electrum_blockchain_factory_sync_wallet() {
364+
let (mut test_client, factory) = get_factory();
365+
366+
let db = MemoryDatabase::new();
367+
let wallet = Wallet::new(
368+
"wpkh(L5EZftvrYaSudiozVRzTqLcHLNDoVn7H5HSfM9BAN6tMJX8oTWz6)",
369+
None,
370+
bitcoin::Network::Regtest,
371+
db,
372+
)
373+
.unwrap();
374+
375+
let address = wallet.get_address(AddressIndex::New).unwrap();
376+
377+
let tx = testutils! {
378+
@tx ( (@addr address.address) => 50_000 )
379+
};
380+
test_client.receive(tx);
381+
382+
factory
383+
.sync_wallet(&wallet, None, Default::default())
384+
.unwrap();
385+
386+
assert_eq!(wallet.get_balance().unwrap(), 50_000);
326387
}
327388
}

src/blockchain/esplora/reqwest.rs

+2
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ impl Blockchain for EsploraBlockchain {
101101
}
102102
}
103103

104+
impl StatelessBlockchain for EsploraBlockchain {}
105+
104106
#[maybe_async]
105107
impl GetHeight for EsploraBlockchain {
106108
fn get_height(&self) -> Result<u32, Error> {

src/blockchain/esplora/ureq.rs

+2
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ impl Blockchain for EsploraBlockchain {
9898
}
9999
}
100100

101+
impl StatelessBlockchain for EsploraBlockchain {}
102+
101103
impl GetHeight for EsploraBlockchain {
102104
fn get_height(&self) -> Result<u32, Error> {
103105
Ok(self.url_client._get_height()?)

src/blockchain/mod.rs

+102-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ use bitcoin::{Transaction, Txid};
2525

2626
use crate::database::BatchDatabase;
2727
use crate::error::Error;
28-
use crate::FeeRate;
28+
use crate::wallet::{wallet_name_from_descriptor, Wallet};
29+
use crate::{FeeRate, KeychainKind};
2930

3031
#[cfg(any(
3132
feature = "electrum",
@@ -164,6 +165,106 @@ pub trait ConfigurableBlockchain: Blockchain + Sized {
164165
fn from_config(config: &Self::Config) -> Result<Self, Error>;
165166
}
166167

168+
/// Trait for blockchains that don't contain any state
169+
///
170+
/// Statless blockchains can be used to sync multiple wallets with different descriptors.
171+
///
172+
/// [`BlockchainFactory`] is automatically implemented for `Arc<T>` where `T` is a stateless
173+
/// blockchain.
174+
pub trait StatelessBlockchain: Blockchain {}
175+
176+
/// Trait for a factory of blockchains that share the underlying connection or configuration
177+
#[cfg_attr(
178+
not(feature = "async-interface"),
179+
doc = r##"
180+
## Example
181+
182+
This example shows how to sync multiple walles and return the sum of their balances
183+
184+
```no_run
185+
# use bdk::Error;
186+
# use bdk::blockchain::*;
187+
# use bdk::database::*;
188+
# use bdk::wallet::*;
189+
# use bdk::*;
190+
fn sum_of_balances<B: BlockchainFactory>(blockchain_factory: B, wallets: &[Wallet<MemoryDatabase>]) -> Result<u64, Error> {
191+
Ok(wallets
192+
.iter()
193+
.map(|w| -> Result<_, Error> {
194+
blockchain_factory.sync_wallet(&w, None, SyncOptions::default())?;
195+
w.get_balance()
196+
})
197+
.collect::<Result<Vec<_>, _>>()?
198+
.into_iter()
199+
.sum())
200+
}
201+
```
202+
"##
203+
)]
204+
pub trait BlockchainFactory {
205+
/// The type returned when building a blockchain from this factory
206+
type Inner: Blockchain;
207+
208+
/// Build a new blockchain for the given descriptor wallet_name
209+
///
210+
/// If `override_skip_blocks` is `None`, the returned blockchain will inherit the number of blocks
211+
/// from the factory. Since it's not possible to override the value to `None`, set it to
212+
/// `Some(0)` to rescan from the genesis.
213+
fn build(
214+
&self,
215+
wallet_name: &str,
216+
override_skip_blocks: Option<u32>,
217+
) -> Result<Self::Inner, Error>;
218+
219+
/// Build a new blockchain for a given wallet
220+
///
221+
/// Internally uses [`wallet_name_from_descriptor`] to derive the name, and then calls
222+
/// [`BlockchainFactory::build`] to create the blockchain instance.
223+
fn build_for_wallet<D: BatchDatabase>(
224+
&self,
225+
wallet: &Wallet<D>,
226+
override_skip_blocks: Option<u32>,
227+
) -> Result<Self::Inner, Error> {
228+
let wallet_name = wallet_name_from_descriptor(
229+
wallet.public_descriptor(KeychainKind::External)?.unwrap(),
230+
wallet.public_descriptor(KeychainKind::Internal)?,
231+
wallet.network(),
232+
wallet.secp_ctx(),
233+
)?;
234+
self.build(&wallet_name, override_skip_blocks)
235+
}
236+
237+
/// Use [`BlockchainFactory::build_for_wallet`] to get a blockchain, then sync the wallet
238+
///
239+
/// This can be used when a new blockchain would only be used to sync a wallet and then
240+
/// immediately dropped. Keep in mind that specific blockchain factories may perform slow
241+
/// operations to build a blockchain for a given wallet, so if a wallet needs to be synced
242+
/// often it's recommended to use [`BlockchainFactory::build_for_wallet`] to reuse the same
243+
/// blockchain multiple times.
244+
#[cfg(not(any(target_arch = "wasm32", feature = "async-interface")))]
245+
#[cfg_attr(
246+
docsrs,
247+
doc(cfg(not(any(target_arch = "wasm32", feature = "async-interface"))))
248+
)]
249+
fn sync_wallet<D: BatchDatabase>(
250+
&self,
251+
wallet: &Wallet<D>,
252+
override_skip_blocks: Option<u32>,
253+
sync_options: crate::wallet::SyncOptions,
254+
) -> Result<(), Error> {
255+
let blockchain = self.build_for_wallet(wallet, override_skip_blocks)?;
256+
wallet.sync(&blockchain, sync_options)
257+
}
258+
}
259+
260+
impl<T: StatelessBlockchain> BlockchainFactory for Arc<T> {
261+
type Inner = Self;
262+
263+
fn build(&self, _wallet_name: &str, _override_skip_blocks: Option<u32>) -> Result<Self, Error> {
264+
Ok(Arc::clone(self))
265+
}
266+
}
267+
167268
/// Data sent with a progress update over a [`channel`]
168269
pub type ProgressData = (f32, Option<String>);
169270

src/blockchain/rpc.rs

+116-7
Original file line numberDiff line numberDiff line change
@@ -438,18 +438,127 @@ fn list_wallet_dir(client: &Client) -> Result<Vec<String>, Error> {
438438
Ok(result.wallets.into_iter().map(|n| n.name).collect())
439439
}
440440

441+
/// Factory of [`RpcBlockchain`] instances, implements [`BlockchainFactory`]
442+
///
443+
/// Internally caches the node url and authentication params and allows getting many different [`RpcBlockchain`]
444+
/// objects for different wallet names and with different rescan heights.
445+
///
446+
/// ## Example
447+
///
448+
/// ```no_run
449+
/// # use bdk::bitcoin::Network;
450+
/// # use bdk::blockchain::BlockchainFactory;
451+
/// # use bdk::blockchain::rpc::{Auth, RpcBlockchainFactory};
452+
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
453+
/// let factory = RpcBlockchainFactory {
454+
/// url: "http://127.0.0.1:18332".to_string(),
455+
/// auth: Auth::Cookie {
456+
/// file: "/home/user/.bitcoin/.cookie".into(),
457+
/// },
458+
/// network: Network::Testnet,
459+
/// wallet_name_prefix: Some("prefix-".to_string()),
460+
/// default_skip_blocks: 100_000,
461+
/// };
462+
/// let main_wallet_blockchain = factory.build("main_wallet", Some(200_000))?;
463+
/// # Ok(())
464+
/// # }
465+
/// ```
466+
#[derive(Debug, Clone)]
467+
pub struct RpcBlockchainFactory {
468+
/// The bitcoin node url
469+
pub url: String,
470+
/// The bitcoin node authentication mechanism
471+
pub auth: Auth,
472+
/// The network we are using (it will be checked the bitcoin node network matches this)
473+
pub network: Network,
474+
/// The optional prefix used to build the full wallet name for blockchains
475+
pub wallet_name_prefix: Option<String>,
476+
/// Default number of blocks to skip which will be inherited by blockchain unless overridden
477+
pub default_skip_blocks: u32,
478+
}
479+
480+
impl BlockchainFactory for RpcBlockchainFactory {
481+
type Inner = RpcBlockchain;
482+
483+
fn build(
484+
&self,
485+
checksum: &str,
486+
override_skip_blocks: Option<u32>,
487+
) -> Result<Self::Inner, Error> {
488+
RpcBlockchain::from_config(&RpcConfig {
489+
url: self.url.clone(),
490+
auth: self.auth.clone(),
491+
network: self.network,
492+
wallet_name: format!(
493+
"{}{}",
494+
self.wallet_name_prefix.as_ref().unwrap_or(&String::new()),
495+
checksum
496+
),
497+
skip_blocks: Some(override_skip_blocks.unwrap_or(self.default_skip_blocks)),
498+
})
499+
}
500+
}
501+
441502
#[cfg(test)]
442503
#[cfg(feature = "test-rpc")]
443-
crate::bdk_blockchain_tests! {
504+
mod test {
505+
use super::*;
506+
use crate::testutils::blockchain_tests::TestClient;
507+
508+
use bitcoin::Network;
509+
use bitcoincore_rpc::RpcApi;
510+
511+
crate::bdk_blockchain_tests! {
512+
fn test_instance(test_client: &TestClient) -> RpcBlockchain {
513+
let config = RpcConfig {
514+
url: test_client.bitcoind.rpc_url(),
515+
auth: Auth::Cookie { file: test_client.bitcoind.params.cookie_file.clone() },
516+
network: Network::Regtest,
517+
wallet_name: format!("client-wallet-test-{:?}", std::time::SystemTime::now() ),
518+
skip_blocks: None,
519+
};
520+
RpcBlockchain::from_config(&config).unwrap()
521+
}
522+
}
523+
524+
fn get_factory() -> (TestClient, RpcBlockchainFactory) {
525+
let test_client = TestClient::default();
444526

445-
fn test_instance(test_client: &TestClient) -> RpcBlockchain {
446-
let config = RpcConfig {
527+
let factory = RpcBlockchainFactory {
447528
url: test_client.bitcoind.rpc_url(),
448-
auth: Auth::Cookie { file: test_client.bitcoind.params.cookie_file.clone() },
529+
auth: Auth::Cookie {
530+
file: test_client.bitcoind.params.cookie_file.clone(),
531+
},
449532
network: Network::Regtest,
450-
wallet_name: format!("client-wallet-test-{:?}", std::time::SystemTime::now() ),
451-
skip_blocks: None,
533+
wallet_name_prefix: Some("prefix-".into()),
534+
default_skip_blocks: 0,
452535
};
453-
RpcBlockchain::from_config(&config).unwrap()
536+
537+
(test_client, factory)
538+
}
539+
540+
#[test]
541+
fn test_rpc_blockchain_factory() {
542+
let (_test_client, factory) = get_factory();
543+
544+
let a = factory.build("aaaaaa", None).unwrap();
545+
assert_eq!(a.skip_blocks, Some(0));
546+
assert_eq!(
547+
a.client
548+
.get_wallet_info()
549+
.expect("Node connection isn't working")
550+
.wallet_name,
551+
"prefix-aaaaaa"
552+
);
553+
554+
let b = factory.build("bbbbbb", Some(100)).unwrap();
555+
assert_eq!(b.skip_blocks, Some(100));
556+
assert_eq!(
557+
b.client
558+
.get_wallet_info()
559+
.expect("Node connection isn't working")
560+
.wallet_name,
561+
"prefix-bbbbbb"
562+
);
454563
}
455564
}

src/lib.rs

+8-7
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,14 @@ pub extern crate sled;
249249
#[cfg(feature = "sqlite")]
250250
pub extern crate rusqlite;
251251

252+
// We should consider putting this under a feature flag but we need the macro in doctests so we need
253+
// to wait until https://github.com/rust-lang/rust/issues/67295 is fixed.
254+
//
255+
// Stuff in here is too rough to document atm
256+
#[doc(hidden)]
257+
#[macro_use]
258+
pub mod testutils;
259+
252260
#[allow(unused_imports)]
253261
#[macro_use]
254262
pub(crate) mod error;
@@ -277,10 +285,3 @@ pub use wallet::Wallet;
277285
pub fn version() -> &'static str {
278286
env!("CARGO_PKG_VERSION", "unknown")
279287
}
280-
281-
// We should consider putting this under a feature flag but we need the macro in doctests so we need
282-
// to wait until https://github.com/rust-lang/rust/issues/67295 is fixed.
283-
//
284-
// Stuff in here is too rough to document atm
285-
#[doc(hidden)]
286-
pub mod testutils;

0 commit comments

Comments
 (0)