diff --git a/Cargo.toml b/Cargo.toml index eed208f9..9cb0b230 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,3 +74,6 @@ name = "esplora_blocking" [[example]] name = "bitcoind_rpc" + +[[example]] +name = "bitcoind_rpc_filter" diff --git a/docs/adr/0004_birthday.md b/docs/adr/0004_birthday.md new file mode 100644 index 00000000..91a7ed4c --- /dev/null +++ b/docs/adr/0004_birthday.md @@ -0,0 +1,56 @@ +# Introduce birthday information to the wallet + +* Status: TBD +* Authors: @luisschwab +* Date: 2026-02-XX +* Targeted modules: wallet +* Associated tickets/PRs: [#368](https://github.com/bitcoindevkit/bdk_wallet/pull/368) + +## Context and Problem Statement + +[Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question.] + +## Decision Drivers + +* [driver 1, e.g., a force, facing concern, …] +* [driver 2, e.g., a force, facing concern, …] +* … + +## Considered Options + +#### [Option 1] + +[example | description | pointer to more information | …] + +**Pros:** + +* Good, because [argument …] + +**Cons:** + +* Bad, because [argument …] + +#### [Option 2] +... + +#### [Option 3] +... + +## Decision Outcome + +Chosen option: "[option 1]", because [justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force force | … | comes out best (see below)]. + +### Positive Consequences + +* [e.g., improvement of quality attribute satisfaction, follow-up decisions required, …] +* … + +### Negative Consequences + +* [e.g., compromising quality attribute, follow-up decisions required, …] +* … + +## Links + +* [Link type] [Link to ADR] +* … diff --git a/examples/bitcoind_rpc_filter.rs b/examples/bitcoind_rpc_filter.rs new file mode 100644 index 00000000..17f37f2b --- /dev/null +++ b/examples/bitcoind_rpc_filter.rs @@ -0,0 +1,20 @@ +#![allow(unused)] + +use bdk_chain::BlockId; +use bdk_wallet::rusqlite::Connection; +use bdk_wallet::{ + bitcoin::{Block, Network}, + KeychainKind, Wallet, +}; +use bitcoin::{hashes::Hash, BlockHash}; +use clap::{self, Parser}; +use std::{ + path::PathBuf, + sync::{mpsc::sync_channel, Arc}, + thread::spawn, + time::Instant, +}; + +fn main() -> anyhow::Result<()> { + Ok(()) +} diff --git a/src/persist_test_utils.rs b/src/persist_test_utils.rs index 729d7ef4..d0ef9681 100644 --- a/src/persist_test_utils.rs +++ b/src/persist_test_utils.rs @@ -1,5 +1,8 @@ //! Utilities for testing custom persistence backends for `bdk_wallet` +use bitcoin::{hashes::Hash, BlockHash}; +use chain::BlockId; + use crate::{ bitcoin::{ absolute, key::Secp256k1, transaction, Address, Amount, Network, OutPoint, ScriptBuf, @@ -168,6 +171,7 @@ where descriptor: Some(descriptor.clone()), change_descriptor: Some(change_descriptor.clone()), network: Some(Network::Testnet), + birthday: None, local_chain: local_chain_changeset, tx_graph: tx_graph_changeset, indexer: keychain_txout_changeset, @@ -227,6 +231,7 @@ where descriptor: None, change_descriptor: None, network: None, + birthday: None, local_chain: local_chain_changeset, tx_graph: tx_graph_changeset, indexer: keychain_txout_changeset, @@ -351,6 +356,41 @@ where assert_eq!(changeset_read.network, Some(Network::Bitcoin)); } +/// Test whether the `birthday` is persisted correctly. +pub fn persist_birthday(filename: &str, create_store: CreateStore) +where + CreateStore: Fn(&Path) -> anyhow::Result, + Store: WalletPersister, + Store::Error: Debug, +{ + // Create store + let temp_dir = tempfile::tempdir().expect("must create tempdir"); + let file_path = temp_dir.path().join(filename); + let mut store = create_store(&file_path).expect("store should get created"); + + // Initialize store + let changeset = WalletPersister::initialize(&mut store) + .expect("should initialize and load empty changeset"); + assert_eq!(changeset, ChangeSet::default()); + + let birthday = BlockId { + height: 42, + hash: BlockHash::all_zeros(), + }; + let changeset = ChangeSet { + birthday: Some(birthday), + ..Default::default() + }; + + WalletPersister::persist(&mut store, &changeset).expect("should persist birthday"); + + // Load the birthday + let changeset_read = + WalletPersister::initialize(&mut store).expect("should read persisted changeset"); + + assert_eq!(changeset_read.birthday, Some(birthday)); +} + /// tests if descriptors are being persisted correctly /// /// [`ChangeSet`]: diff --git a/src/wallet/changeset.rs b/src/wallet/changeset.rs index 0945d7a1..ecaf139f 100644 --- a/src/wallet/changeset.rs +++ b/src/wallet/changeset.rs @@ -1,5 +1,5 @@ use bdk_chain::{ - indexed_tx_graph, keychain_txout, local_chain, tx_graph, ConfirmationBlockTime, Merge, + indexed_tx_graph, keychain_txout, local_chain, tx_graph, BlockId, ConfirmationBlockTime, Merge, }; use miniscript::{Descriptor, DescriptorPublicKey}; use serde::{Deserialize, Serialize}; @@ -32,7 +32,7 @@ type IndexedTxGraphChangeSet = /// instance. /// * A change set is composed of a number of individual "sub-change sets" that adhere to the same /// rules as above. This is for increased modularity and portability. For example the core modules -/// each have their own change set (`tx_graph`, `local_chain`, etc). +/// each have their own change set ([`tx_graph`], [`local_chain`], etc). /// /// ## Members and required fields /// @@ -110,6 +110,8 @@ pub struct ChangeSet { pub change_descriptor: Option>, /// Stores the network type of the transaction data. pub network: Option, + /// Set the wallet's birthday. + pub birthday: Option, /// Changes to the [`LocalChain`](local_chain::LocalChain). pub local_chain: local_chain::ChangeSet, /// Changes to [`TxGraph`](tx_graph::TxGraph). @@ -146,6 +148,14 @@ impl Merge for ChangeSet { self.network = other.network; } + // Merging birthdays should yield the earliest birthday. + if other.birthday.is_some() { + self.birthday = match (self.birthday, other.birthday) { + (Some(a), Some(b)) => Some(if a.height <= b.height { a } else { b }), + (None, some) | (some, None) => some, + }; + } + // merge locked outpoints self.locked_outpoints.merge(other.locked_outpoints); @@ -158,6 +168,7 @@ impl Merge for ChangeSet { self.descriptor.is_none() && self.change_descriptor.is_none() && self.network.is_none() + && self.birthday.is_none() && self.local_chain.is_empty() && self.tx_graph.is_empty() && self.indexer.is_empty() @@ -199,12 +210,26 @@ impl ChangeSet { ) } + /// Get the `v2` sqlite [`ChangeSet`] schema. + /// + /// Adds two columns to the wallet table to perist the wallet's birthday from a [`BlockId`]: + /// * `birth_height`: the height of the birthday block, as a `u32` + /// * `birth_hash`: the hash of the birthday block, as a [`BlockHash`] + pub fn schema_v2() -> alloc::string::String { + format!( + "ALTER TABLE {} ADD COLUMN birth_height INTEGER; \ + ALTER TABLE {} ADD COLUMN birth_hash TEXT;", + Self::WALLET_TABLE_NAME, + Self::WALLET_TABLE_NAME, + ) + } + /// Initialize sqlite tables for wallet tables. pub fn init_sqlite_tables(db_tx: &chain::rusqlite::Transaction) -> chain::rusqlite::Result<()> { crate::rusqlite_impl::migrate_schema( db_tx, Self::WALLET_SCHEMA_NAME, - &[&Self::schema_v0(), &Self::schema_v1()], + &[&Self::schema_v0(), &Self::schema_v1(), &Self::schema_v2()], )?; bdk_chain::local_chain::ChangeSet::init_sqlite_tables(db_tx)?; @@ -223,7 +248,7 @@ impl ChangeSet { let mut changeset = Self::default(); let mut wallet_statement = db_tx.prepare(&format!( - "SELECT descriptor, change_descriptor, network FROM {}", + "SELECT descriptor, change_descriptor, network, birth_height, birth_hash FROM {}", Self::WALLET_TABLE_NAME, ))?; let row = wallet_statement @@ -234,13 +259,22 @@ impl ChangeSet { "change_descriptor", )?, row.get::<_, Option>>("network")?, + row.get::<_, Option>("birth_height")?, + row.get::<_, Option>>("birth_hash")?, )) }) .optional()?; - if let Some((desc, change_desc, network)) = row { + if let Some((desc, change_desc, network, birth_height, birth_hash)) = row { changeset.descriptor = desc.map(Impl::into_inner); changeset.change_descriptor = change_desc.map(Impl::into_inner); changeset.network = network.map(Impl::into_inner); + changeset.birthday = match (birth_height, birth_hash) { + (Some(height), Some(hash)) => Some(BlockId { + height, + hash: hash.into_inner(), + }), + _ => None, + } } // Select locked outpoints. @@ -309,6 +343,26 @@ impl ChangeSet { })?; } + if let Some(birthday) = self.birthday { + let mut birth_height_statement = db_tx.prepare_cached(&format!( + "INSERT INTO {}(id, birth_height) VALUES(:id, :birth_height) ON CONFLICT(id) DO UPDATE SET birth_height=:birth_height", + Self::WALLET_TABLE_NAME, + ))?; + birth_height_statement.execute(named_params! { + ":id": 0, + ":birth_height": birthday.height, + })?; + + let mut birth_hash_statement = db_tx.prepare_cached(&format!( + "INSERT INTO {}(id, birth_hash) VALUES(:id, :birth_hash) ON CONFLICT(id) DO UPDATE SET birth_hash=:birth_hash", + Self::WALLET_TABLE_NAME, + ))?; + birth_hash_statement.execute(named_params! { + ":id": 0, + ":birth_hash": Impl(birthday.hash), + })?; + } + // Insert or delete locked outpoints. let mut insert_stmt = db_tx.prepare_cached(&format!( "INSERT OR IGNORE INTO {}(txid, vout) VALUES(:txid, :vout)", diff --git a/src/wallet/error.rs b/src/wallet/error.rs index d88b64b4..3ea61b26 100644 --- a/src/wallet/error.rs +++ b/src/wallet/error.rs @@ -20,6 +20,8 @@ use alloc::{ string::{String, ToString}, }; use bitcoin::{absolute, psbt, Amount, BlockHash, Network, OutPoint, Sequence, Txid}; + +use chain::BlockId; use core::fmt; /// The error type when loading a [`Wallet`] from a [`ChangeSet`]. @@ -64,6 +66,13 @@ pub enum LoadMismatch { /// The expected network. expected: Network, }, + /// Birthday does not match. + Birthday { + /// The birthday that is loaded. + loaded: Option, + /// The expected birthday. + expected: Option, + }, /// Genesis hash does not match. Genesis { /// The genesis hash that is loaded. @@ -88,6 +97,17 @@ impl fmt::Display for LoadMismatch { LoadMismatch::Network { loaded, expected } => { write!(f, "Network mismatch: loaded {loaded}, expected {expected}") } + LoadMismatch::Birthday { loaded, expected } => { + let loaded = match loaded { + Some(loaded) => format!("{}:{}", loaded.height, loaded.hash), + None => "None".to_string(), + }; + let expected = match expected { + Some(expected) => format!("{}:{}", expected.height, expected.hash), + None => "None".to_string(), + }; + write!(f, "Birthday mismatch: loaded {loaded}, expected {expected}") + } LoadMismatch::Genesis { loaded, expected } => { write!( f, diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 484702fc..cc05eaaf 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -312,10 +312,18 @@ impl Wallet { let secp = SecpCtx::new(); let network = params.network; let network_kind = NetworkKind::from(network); + let birthday = params.birthday; let genesis_hash = params .genesis_hash .unwrap_or(genesis_block(network).block_hash()); - let (chain, chain_changeset) = LocalChain::from_genesis_hash(genesis_hash); + let (mut chain, chain_changeset) = LocalChain::from_genesis_hash(genesis_hash); + + // Add the `birthday` block to the chain, if provided. + if let Some(birthday_block) = birthday { + chain + .insert_block(birthday_block) + .expect("cannot replace genesis"); + } let (descriptor, mut descriptor_keymap) = (params.descriptor)(&secp, network_kind)?; check_wallet_descriptor(&descriptor)?; @@ -349,6 +357,7 @@ impl Wallet { change_descriptor: change_descriptor.clone(), local_chain: chain_changeset, network: Some(network), + birthday, ..Default::default() }; @@ -437,6 +446,7 @@ impl Wallet { let secp = Secp256k1::new(); let network = changeset.network.ok_or(LoadError::MissingNetwork)?; let network_kind = NetworkKind::from(network); + let birthday = changeset.birthday; let chain = LocalChain::from_changeset(changeset.local_chain) .map_err(|_| LoadError::MissingGenesis)?; @@ -448,6 +458,14 @@ impl Wallet { })); } } + if let Some(exp_birthday) = params.check_birthday { + if birthday != Some(exp_birthday) { + return Err(LoadError::Mismatch(LoadMismatch::Birthday { + loaded: birthday, + expected: Some(exp_birthday), + })); + } + } if let Some(exp_genesis_hash) = params.check_genesis_hash { if chain.genesis_hash() != exp_genesis_hash { return Err(LoadError::Mismatch(LoadMismatch::Genesis { @@ -586,6 +604,25 @@ impl Wallet { self.network } + /// Get the wallet's birthday. + /// + /// If the wallet's local chain only has a single checkpoint, then the birthay is genesis. If it + /// has more than one checkpoint, then the birthday is the checkpoint immediately after genesis. + pub fn birthday(&self) -> CheckPoint { + let mut cp_iter = self.local_chain().iter_checkpoints(); + let mut prev_cp = cp_iter + .next() + .expect("genesis checkpoint is always present"); + for cp in cp_iter { + // A single checkpoint in the chain implies that the birthday is genesis. + if cp.prev().is_none() { + return prev_cp; + } + prev_cp = cp; + } + prev_cp + } + /// Iterator over all keychains in this wallet pub fn keychains(&self) -> impl Iterator { self.tx_graph.index.keychains() diff --git a/src/wallet/params.rs b/src/wallet/params.rs index 4868074b..e40c6b18 100644 --- a/src/wallet/params.rs +++ b/src/wallet/params.rs @@ -1,6 +1,6 @@ use alloc::boxed::Box; -use bdk_chain::keychain_txout::DEFAULT_LOOKAHEAD; +use bdk_chain::{keychain_txout::DEFAULT_LOOKAHEAD, BlockId}; use bitcoin::{BlockHash, Network, NetworkKind}; use miniscript::descriptor::KeyMap; @@ -64,6 +64,7 @@ pub struct CreateParams { pub(crate) change_descriptor: Option, pub(crate) change_descriptor_keymap: KeyMap, pub(crate) network: Network, + pub(crate) birthday: Option, pub(crate) genesis_hash: Option, pub(crate) lookahead: u32, pub(crate) use_spk_cache: bool, @@ -75,6 +76,7 @@ impl CreateParams { /// Default values: /// * `change_descriptor` = `None` /// * `network` = [`Network::Bitcoin`] + /// * `birthday` = `None` /// * `genesis_hash` = `None` /// * `lookahead` = [`DEFAULT_LOOKAHEAD`] /// @@ -87,6 +89,7 @@ impl CreateParams { change_descriptor: None, change_descriptor_keymap: KeyMap::default(), network: Network::Bitcoin, + birthday: None, genesis_hash: None, lookahead: DEFAULT_LOOKAHEAD, use_spk_cache: false, @@ -97,6 +100,7 @@ impl CreateParams { /// /// Default values: /// * `network` = [`Network::Bitcoin`] + /// * `birthday` = `None` /// * `genesis_hash` = `None` /// * `lookahead` = [`DEFAULT_LOOKAHEAD`] pub fn new( @@ -109,6 +113,7 @@ impl CreateParams { change_descriptor: Some(make_descriptor_to_extract(change_descriptor)), change_descriptor_keymap: KeyMap::default(), network: Network::Bitcoin, + birthday: None, genesis_hash: None, lookahead: DEFAULT_LOOKAHEAD, use_spk_cache: false, @@ -123,6 +128,7 @@ impl CreateParams { /// /// Default values: /// * `network` = [`Network::Bitcoin`] + /// * `birthday` = `None` /// * `genesis_hash` = `None` /// * `lookahead` = [`DEFAULT_LOOKAHEAD`] pub fn new_two_path( @@ -134,6 +140,7 @@ impl CreateParams { change_descriptor: Some(make_two_path_descriptor_to_extract(two_path_descriptor, 1)), change_descriptor_keymap: KeyMap::default(), network: Network::Bitcoin, + birthday: None, genesis_hash: None, lookahead: DEFAULT_LOOKAHEAD, use_spk_cache: false, @@ -150,12 +157,21 @@ impl CreateParams { self } - /// Set [`Self::network`]. + /// Set the wallet's network. pub fn network(mut self, network: Network) -> Self { self.network = network; self } + /// Set the wallet's `birthday`. + /// + /// The `birthday` can be used to limit how far back a block-based chain-source is queried for + /// wallet information. Synching will begin from the birthday onwards, excluding older blocks. + pub fn birthday(mut self, birthday: BlockId) -> Self { + self.birthday = Some(birthday); + self + } + /// Use a custom `genesis_hash`. pub fn genesis_hash(mut self, genesis_hash: BlockHash) -> Self { self.genesis_hash = Some(genesis_hash); @@ -217,6 +233,7 @@ pub struct LoadParams { pub(crate) change_descriptor_keymap: KeyMap, pub(crate) lookahead: u32, pub(crate) check_network: Option, + pub(crate) check_birthday: Option, pub(crate) check_genesis_hash: Option, pub(crate) check_descriptor: Option>, pub(crate) check_change_descriptor: Option>, @@ -234,6 +251,7 @@ impl LoadParams { change_descriptor_keymap: KeyMap::default(), lookahead: DEFAULT_LOOKAHEAD, check_network: None, + check_birthday: None, check_genesis_hash: None, check_descriptor: None, check_change_descriptor: None, @@ -276,6 +294,12 @@ impl LoadParams { self } + /// Checks that the given `birthday matches` the one loaded from persistence. + pub fn check_birthday(mut self, birthday: BlockId) -> Self { + self.check_birthday = Some(birthday); + self + } + /// Checks that the given `genesis_hash` matches the one loaded from persistence. pub fn check_genesis_hash(mut self, genesis_hash: BlockHash) -> Self { self.check_genesis_hash = Some(genesis_hash); diff --git a/src/wallet/persisted.rs b/src/wallet/persisted.rs index 003dca13..c2e89c98 100644 --- a/src/wallet/persisted.rs +++ b/src/wallet/persisted.rs @@ -399,6 +399,11 @@ fn changeset_info(f: &mut fmt::Formatter<'_>, changeset: &ChangeSet) -> fmt::Res .as_ref() .map_or("None".to_string(), |n| n.to_string()); + let birthday = match changeset.birthday.as_ref() { + Some(birthday) => format!("{}:{}", birthday.height, birthday.hash), + None => "None".to_string(), + }; + let descriptor_checksum = changeset .descriptor .as_ref() @@ -422,6 +427,7 @@ fn changeset_info(f: &mut fmt::Formatter<'_>, changeset: &ChangeSet) -> fmt::Res }; writeln!(f, " Network: {network}")?; + writeln!(f, " Birthday: {birthday}")?; writeln!(f, " Descriptor Checksum: {descriptor_checksum}")?; writeln!( f, diff --git a/tests/persisted_wallet.rs b/tests/persisted_wallet.rs index 0b9076bc..8084e2a2 100644 --- a/tests/persisted_wallet.rs +++ b/tests/persisted_wallet.rs @@ -3,10 +3,10 @@ use std::path::Path; use anyhow::Context; use assert_matches::assert_matches; -use bdk_chain::DescriptorId; use bdk_chain::{ keychain_txout::DEFAULT_LOOKAHEAD, ChainPosition, ConfirmationBlockTime, DescriptorExt, }; +use bdk_chain::{BlockId, DescriptorId}; use bdk_wallet::coin_selection::InsufficientFunds; use bdk_wallet::descriptor::IntoWalletDescriptor; use bdk_wallet::error::CreateTxError; @@ -24,7 +24,8 @@ use bitcoin::{ use miniscript::{Descriptor, DescriptorPublicKey}; use bdk_wallet::persist_test_utils::{ - persist_keychains, persist_network, persist_single_keychain, persist_wallet_changeset, + persist_birthday, persist_keychains, persist_network, persist_single_keychain, + persist_wallet_changeset, }; mod common; @@ -244,14 +245,20 @@ fn wallet_load_checks() -> anyhow::Result<()> { { let temp_dir = tempfile::tempdir().expect("must create tempdir"); let file_path = temp_dir.path().join(filename); - let network = Network::Testnet; let (external_desc, internal_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let network = Network::Testnet; + let birthday = BlockId { + height: 42, + hash: BlockHash::all_zeros(), + }; - // create new wallet + // Create a new wallet let _ = Wallet::create(external_desc, internal_desc) .network(network) + .birthday(birthday) .create_wallet(&mut create_db(&file_path)?)?; + // Check network persistence. assert_matches!( Wallet::load() .check_network(Network::Regtest) @@ -264,12 +271,34 @@ fn wallet_load_checks() -> anyhow::Result<()> { ))), "unexpected network check result: Regtest (check) is not Testnet (loaded)", ); + + // Check birthday persistence. + let wrong_birthday = BlockId { + height: 99, + hash: BlockHash::all_zeros(), + }; + assert_matches!( + Wallet::load() + .check_birthday(wrong_birthday) + .load_wallet(&mut open_db(&file_path)?), + Err(LoadWithPersistError::InvalidChangeSet(LoadError::Mismatch( + LoadMismatch::Birthday { + loaded: Some(loaded), + expected: Some(expected), + } + ))) if loaded == birthday && expected == wrong_birthday, + "unexpected birthday check result: wrong_birthday (check) does not match loaded birthday", + ); + + // Check genesis hash persistence. let mainnet_hash = BlockHash::from_byte_array(ChainHash::BITCOIN.to_bytes()); assert_matches!( Wallet::load().check_genesis_hash(mainnet_hash).load_wallet(&mut open_db(&file_path)?), Err(LoadWithPersistError::InvalidChangeSet(LoadError::Mismatch(LoadMismatch::Genesis { .. }))), "unexpected genesis hash check result: mainnet hash (check) is not testnet hash (loaded)", ); + + // Check descriptor persistence. assert_matches!( Wallet::load() .descriptor(KeychainKind::External, Some(internal_desc)) @@ -288,7 +317,7 @@ fn wallet_load_checks() -> anyhow::Result<()> { ))), "unexpected descriptors check result", ); - // check setting keymaps + // Check keymap persistence. let (_, external_keymap) = parse_descriptor(external_desc); let (_, internal_keymap) = parse_descriptor(internal_desc); let wallet = Wallet::load() @@ -467,6 +496,16 @@ fn network_is_persisted() { }); } +#[test] +fn birthday_is_persisted() { + persist_birthday("store.db", |path| { + Ok(bdk_file_store::Store::create(DB_MAGIC, path)?) + }); + persist_birthday::("store.sqlite", |path| { + Ok(bdk_chain::rusqlite::Connection::open(path)?) + }); +} + #[test] fn test_lock_outpoint_persist() -> anyhow::Result<()> { use bdk_chain::rusqlite; diff --git a/tests/wallet.rs b/tests/wallet.rs index c779c0a4..9b83f2a1 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -170,6 +170,71 @@ fn test_list_output() { } } +#[test] +fn test_birthday_genesis_only() { + let desc = get_test_wpkh(); + let wallet = Wallet::create_single(desc) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + + // Assert that the wallet's birthday is genesis, since no `birthday` was set. + assert_eq!(wallet.birthday().height(), 0); +} + +#[test] +fn test_birthday_single_checkpoint() { + let desc = get_test_wpkh(); + let birthday = BlockId { + height: 42, + hash: BlockHash::all_zeros(), + }; + let wallet = Wallet::create_single(desc) + .network(Network::Regtest) + .birthday(birthday) + .create_wallet_no_persist() + .unwrap(); + + // Assert that the wallet's birthday is the checkpoint of height `42`. + assert_eq!(wallet.birthday().height(), 42); +} + +#[test] +fn test_birthday_multiple_checkpoints() { + let desc = get_test_wpkh(); + let birthday = BlockId { + height: 42, + hash: BlockHash::all_zeros(), + }; + let mut wallet = Wallet::create_single(desc) + .network(Network::Regtest) + .birthday(birthday) + .create_wallet_no_persist() + .unwrap(); + + let update_a = Update { + chain: Some(wallet.latest_checkpoint().insert(BlockId { + height: 43, + hash: BlockHash::all_zeros(), + })), + ..Default::default() + }; + wallet.apply_update(update_a).unwrap(); + + let update_b = Update { + chain: Some(wallet.latest_checkpoint().insert(BlockId { + height: 77, + hash: BlockHash::all_zeros(), + })), + ..Default::default() + }; + wallet.apply_update(update_b).unwrap(); + + // Assert that the wallet's birthday is still the checkpoint + // of height `42` if more checkpoints were added after it. + assert_eq!(wallet.birthday().height(), 42); +} + macro_rules! from_str { ($e:expr, $t:ty) => {{ use core::str::FromStr;