From 73dc5cd3815c57aa80773d48131e2f88143adc68 Mon Sep 17 00:00:00 2001 From: codingp110 Date: Mon, 19 Oct 2026 01:21:06 +1100 Subject: [PATCH 1/3] feat(testenv): add `persist_test_utils` for testing persistence backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `assert_persist_changesets` persists each changeset in turn, checks that loading returns their aggregate, then reopens the store and checks again. It panics on any mismatch or backend error. Also add `utils` helpers that build non-empty `tx_graph`, `keychain_txout` and `local_chain` changesets, and a `miniscript` feature (on by default) that the keychain changeset needs. Co-Authored-By: 志宇 Co-Authored-By: Claude Opus 5.5 --- crates/testenv/Cargo.toml | 3 +- crates/testenv/src/lib.rs | 1 + crates/testenv/src/persist_test_utils.rs | 197 +++++++++++++++++++++++ crates/testenv/src/utils.rs | 70 +++++++- 4 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 crates/testenv/src/persist_test_utils.rs diff --git a/crates/testenv/Cargo.toml b/crates/testenv/Cargo.toml index 6cedcf4d30..425e6b3834 100644 --- a/crates/testenv/Cargo.toml +++ b/crates/testenv/Cargo.toml @@ -24,10 +24,11 @@ bitcoin = { version = "0.32.0", default-features = false } bdk_testenv = { path = "." } [features] -default = ["std", "download"] +default = ["std", "download", "miniscript"] download = ["electrsd/bitcoind_download", "electrsd/bitcoind_28_2", "electrsd/esplora_a33e97e1"] std = ["bdk_chain/std", "bitcoin/rand-std"] serde = ["bdk_chain/serde"] +miniscript = ["bdk_chain/miniscript"] [package.metadata.docs.rs] no-default-features = true diff --git a/crates/testenv/src/lib.rs b/crates/testenv/src/lib.rs index 1663c21f37..1b4283e1cf 100644 --- a/crates/testenv/src/lib.rs +++ b/crates/testenv/src/lib.rs @@ -1,5 +1,6 @@ #![cfg_attr(coverage_nightly, feature(coverage_attribute))] +pub mod persist_test_utils; pub mod utils; use anyhow::Context; diff --git a/crates/testenv/src/persist_test_utils.rs b/crates/testenv/src/persist_test_utils.rs new file mode 100644 index 0000000000..d2520f3597 --- /dev/null +++ b/crates/testenv/src/persist_test_utils.rs @@ -0,0 +1,197 @@ +//! This module provides utility functions for testing custom persistence backends. +use crate::{block_id, hash}; +use bdk_chain::{ + bitcoin::{self, transaction::Version, OutPoint}, + local_chain, tx_graph, ConfirmationBlockTime, Merge, +}; +use std::sync::Arc; + +#[cfg(feature = "miniscript")] +use bdk_chain::{indexer::keychain_txout, DescriptorExt, SpkIterator}; +use core::{error::Error, fmt::Debug}; + +use crate::utils::{create_test_tx, create_txout}; + +#[cfg(feature = "miniscript")] +use crate::utils::{parse_descriptor, spk_at_index}; + +const ADDRS: [&str; 2] = [ + "bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5", + "bcrt1q8an5jfmpq8w2hr648nn34ecf9zdtxk0qyqtrfl", +]; + +/// Asserts a persistence backend correctly stores and reloads a sequence of `ChangeSet`s. +/// +/// Persists every changeset, drops and reopens the store, then checks that `load` returns the merge +/// of everything `persist`ed. The backend is supplied as three closures: +/// +/// - `init`: open-or-create a handle. This must never wipe existing data. +/// - `load`: return the full persisted changeset (`C::default()` on a fresh store). +/// - `persist`: durably write one changeset on top of what's stored; must survive the handle being +/// dropped. +/// +/// # Panics +/// +/// Panics naming the failing step if a closure errors or a load doesn't match the expected merge. +#[track_caller] +pub fn assert_persist_changesets( + init: Init, + load: Load, + persist: Persist, + changesets: &[C], +) where + C: Debug + PartialEq + Default + Merge + Clone, + Init: Fn() -> Result>, + Load: Fn(&mut Store) -> Result>, + Persist: Fn(&mut Store, &C) -> Result<(), Box>, +{ + let mut store = match init() { + Ok(store) => store, + Err(err) => panic!("Failed to initialize the store: {err}"), + }; + let init_changeset = match load(&mut store) { + Ok(changeset) => changeset, + Err(err) => panic!("Failed to load from the new store: {err}"), + }; + assert!( + init_changeset.is_empty(), + "Loading from a new store should return an empty changeset." + ); + + let mut merged_changeset = C::default(); + for (i, changeset) in changesets.iter().enumerate() { + if let Err(err) = persist(&mut store, changeset) { + panic!("Failed to persist changeset no. {}: {err}", i + 1); + } + merged_changeset.merge(changeset.clone()); + } + + drop(store); + let mut store = match init() { + Ok(store) => store, + Err(err) => panic!("Failed to reopen the store: {err}"), + }; + let persisted_changeset = match load(&mut store) { + Ok(changeset) => changeset, + Err(err) => panic!("Failed to load after reopening the store: {err}"), + }; + assert_eq!( + persisted_changeset, merged_changeset, + "Loaded changeset after reopening doesn't match what was persisted." + ); +} + +/// Get two [`tx_graph::ChangeSet`]s. +pub fn tx_graph_changesets() -> [tx_graph::ChangeSet; 2] { + use tx_graph::ChangeSet; + + let tx1 = Arc::new(create_test_tx( + [hash!("BTC")], + [0], + [30_000], + [ADDRS[0]], + Version::ONE, + 0, + )); + + let conf_anchor: ConfirmationBlockTime = ConfirmationBlockTime { + block_id: block_id!(910425, "Rust"), + confirmation_time: 1755416660, + }; + + let tx_graph_changeset1 = ChangeSet:: { + txs: [tx1.clone()].into(), + txouts: [ + (OutPoint::new(hash!("BDK"), 0), create_txout(1300, ADDRS[1])), + ( + OutPoint::new(hash!("Bitcoin_fixes_things"), 0), + create_txout(1400, ADDRS[1]), + ), + ] + .into(), + anchors: [(conf_anchor, tx1.compute_txid())].into(), + last_seen: [(tx1.compute_txid(), 1755416650)].into(), + first_seen: [(tx1.compute_txid(), 1755416655)].into(), + last_evicted: [(tx1.compute_txid(), 1755416660)].into(), + }; + + let tx2 = Arc::new(create_test_tx( + [tx1.compute_txid()], + [0], + [20_000], + [ADDRS[0]], + Version::ONE, + 0, + )); + + let conf_anchor: ConfirmationBlockTime = ConfirmationBlockTime { + block_id: block_id!(910426, "BOSS"), + confirmation_time: 1755416700, + }; + + let tx_graph_changeset2 = ChangeSet:: { + txs: [tx2.clone()].into(), + txouts: [( + OutPoint::new(hash!("Magical_Bitcoin"), 0), + create_txout(10000, ADDRS[1]), + )] + .into(), + anchors: [(conf_anchor, tx2.compute_txid())].into(), + last_seen: [(tx2.compute_txid(), 1755416700)].into(), + first_seen: [(tx2.compute_txid(), 1755416670)].into(), + last_evicted: [(tx2.compute_txid(), 1755416760)].into(), + }; + + [tx_graph_changeset1, tx_graph_changeset2] +} + +/// Get two [`keychain_txout::ChangeSet`]s. +#[cfg(feature = "miniscript")] +pub fn keychain_txout_changesets() -> [keychain_txout::ChangeSet; 2] { + use crate::utils::DESCRIPTORS; + use keychain_txout::ChangeSet; + + let descs = DESCRIPTORS.map(|desc| parse_descriptor(desc).0); + let descriptor_ids = descs.each_ref().map(|d| d.descriptor_id()); + + let changeset = ChangeSet { + last_revealed: [(descriptor_ids[0], 1), (descriptor_ids[1], 100)].into(), + spk_cache: [ + ( + descriptor_ids[0], + SpkIterator::new_with_range(&descs[0], 0..=26).collect(), + ), + ( + descriptor_ids[1], + SpkIterator::new_with_range(&descs[1], 0..=125).collect(), + ), + ] + .into(), + }; + + let changeset_new = ChangeSet { + last_revealed: [(descriptor_ids[0], 2)].into(), + spk_cache: [( + descriptor_ids[0], + [(27, spk_at_index(&descs[0], 27))].into(), + )] + .into(), + }; + + [changeset, changeset_new] +} + +/// Get two [`local_chain::ChangeSet`]s. +pub fn local_chain_changesets() -> [local_chain::ChangeSet; 2] { + use local_chain::ChangeSet; + + let changeset = ChangeSet { + blocks: [(910425, Some(hash!("B"))), (910426, Some(hash!("D")))].into(), + }; + + let changeset_new = ChangeSet { + blocks: [(910427, Some(hash!("K")))].into(), + }; + + [changeset, changeset_new] +} diff --git a/crates/testenv/src/utils.rs b/crates/testenv/src/utils.rs index 93ca1f217f..fdd9dbaa2d 100644 --- a/crates/testenv/src/utils.rs +++ b/crates/testenv/src/utils.rs @@ -1,4 +1,10 @@ -use bdk_chain::bitcoin; +use bdk_chain::bitcoin::{ + self, absolute, transaction, Address, Amount, OutPoint, Transaction, TxIn, TxOut, Txid, +}; +use core::str::FromStr; + +#[cfg(feature = "miniscript")] +use bdk_chain::miniscript::{descriptor::KeyMap, Descriptor, DescriptorPublicKey}; #[allow(unused_macros)] #[macro_export] @@ -77,6 +83,68 @@ pub fn new_tx(lt: u32) -> bitcoin::Transaction { } } +/// Utility function to create a [`TxOut`] given amount (in satoshis) and address. +pub fn create_txout(sats: u64, addr: &str) -> TxOut { + TxOut { + value: Amount::from_sat(sats), + script_pubkey: Address::from_str(addr) + .unwrap() + .assume_checked() + .script_pubkey(), + } +} + +/// Utility function to create a transaction given txids, vouts of inputs and amounts (in satoshis), +/// addresses of outputs. +/// +/// The locktime should be in the form given to `OP_CHECKLOCKTIMEVERIFY`. +pub fn create_test_tx<'a>( + txids: impl IntoIterator, + vouts: impl IntoIterator, + amounts: impl IntoIterator, + addrs: impl IntoIterator, + version: transaction::Version, + locktime: u32, +) -> Transaction { + let input_vec = core::iter::zip(txids, vouts) + .map(|(txid, vout)| TxIn { + previous_output: OutPoint::new(txid, vout), + ..TxIn::default() + }) + .collect(); + let output_vec = core::iter::zip(amounts, addrs) + .map(|(amount, addr)| create_txout(amount, addr)) + .collect(); + let lock_time = absolute::LockTime::from_consensus(locktime); + Transaction { + version, + lock_time, + input: input_vec, + output: output_vec, + } +} + +/// Generates `script_pubkey` corresponding to `index` on keychain of `descriptor`. +#[cfg(feature = "miniscript")] +pub fn spk_at_index( + descriptor: &Descriptor, + index: u32, +) -> bdk_chain::bitcoin::ScriptBuf { + use bdk_chain::bitcoin::key::Secp256k1; + descriptor + .derived_descriptor(&Secp256k1::verification_only(), index) + .expect("must derive") + .script_pubkey() +} + +/// Parses a descriptor string. +#[cfg(feature = "miniscript")] +pub fn parse_descriptor(descriptor: &str) -> (Descriptor, KeyMap) { + use bdk_chain::bitcoin::key::Secp256k1; + let secp = Secp256k1::signing_only(); + Descriptor::::parse_descriptor(&secp, descriptor).unwrap() +} + #[allow(unused)] pub const DESCRIPTORS: [&str; 7] = [ "tr([73c5da0a/86'/0'/0']xprv9xgqHN7yz9MwCkxsBPN5qetuNdQSUttZNKw1dcYTV4mkaAFiBVGQziHs3NRSWMkCzvgjEe3n9xV8oYywvM8at9yRqyaZVz6TYYhX98VjsUk/0/*)", From 206f9d01a40f5e4509bfcce57da219a7246727a1 Mon Sep 17 00:00:00 2001 From: codingp110 Date: Mon, 19 Oct 2026 01:21:23 +1100 Subject: [PATCH 2/3] test(chain): test rusqlite persistence with `assert_persist_changesets` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: 志宇 Co-Authored-By: Claude Opus 5.5 --- crates/chain/Cargo.toml | 3 +- crates/chain/tests/test_rusqlite_impl.rs | 62 ++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 crates/chain/tests/test_rusqlite_impl.rs diff --git a/crates/chain/Cargo.toml b/crates/chain/Cargo.toml index e474f99714..36ff3847db 100644 --- a/crates/chain/Cargo.toml +++ b/crates/chain/Cargo.toml @@ -27,8 +27,9 @@ rusqlite = { version = "0.31.0", features = ["bundled"], optional = true } [dev-dependencies] rand = "0.8" proptest = "1.2.0" -bdk_testenv = { path = "../testenv" } +bdk_testenv = { path = "../testenv" } # `miniscript` is active by default criterion = { version = "0.7" } +tempfile = "3" [features] default = ["std", "miniscript"] diff --git a/crates/chain/tests/test_rusqlite_impl.rs b/crates/chain/tests/test_rusqlite_impl.rs new file mode 100644 index 0000000000..05a22ce504 --- /dev/null +++ b/crates/chain/tests/test_rusqlite_impl.rs @@ -0,0 +1,62 @@ +#![cfg(feature = "rusqlite")] +#[cfg(feature = "miniscript")] +use bdk_chain::keychain_txout; +use bdk_chain::{local_chain, tx_graph, Merge}; +use bdk_testenv::persist_test_utils::*; +use core::fmt::Debug; +use rusqlite::Transaction; + +#[test] +fn changesets_are_persisted() { + #[track_caller] + fn assert_sqlite_persists( + changesets: &[C], + init_tables: fn(&Transaction) -> rusqlite::Result<()>, + from_sqlite: fn(&Transaction) -> rusqlite::Result, + persist_to_sqlite: fn(&C, &Transaction) -> rusqlite::Result<()>, + ) where + C: Debug + PartialEq + Default + Merge + Clone, + { + let temp_dir = tempfile::tempdir().unwrap(); + assert_persist_changesets( + || { + let mut db = rusqlite::Connection::open(temp_dir.path().join("wallet.sqlite"))?; + let db_tx = db.transaction()?; + init_tables(&db_tx)?; + db_tx.commit()?; + Ok(db) + }, + |db| { + let db_tx = db.transaction()?; + Ok(from_sqlite(&db_tx)?) + }, + |db, changeset| { + let db_tx = db.transaction()?; + persist_to_sqlite(changeset, &db_tx)?; + db_tx.commit()?; + Ok(()) + }, + changesets, + ); + } + + assert_sqlite_persists( + &tx_graph_changesets(), + tx_graph::ChangeSet::init_sqlite_tables, + tx_graph::ChangeSet::from_sqlite, + tx_graph::ChangeSet::persist_to_sqlite, + ); + #[cfg(feature = "miniscript")] + assert_sqlite_persists( + &keychain_txout_changesets(), + keychain_txout::ChangeSet::init_sqlite_tables, + keychain_txout::ChangeSet::from_sqlite, + keychain_txout::ChangeSet::persist_to_sqlite, + ); + assert_sqlite_persists( + &local_chain_changesets(), + local_chain::ChangeSet::init_sqlite_tables, + local_chain::ChangeSet::from_sqlite, + local_chain::ChangeSet::persist_to_sqlite, + ); +} From c537631be11b731f6833c0523bd2c81699edd908 Mon Sep 17 00:00:00 2001 From: codingp110 Date: Mon, 19 Oct 2026 01:21:23 +1100 Subject: [PATCH 3/3] test(file_store): test `Store` persistence with `assert_persist_changesets` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: 志宇 Co-Authored-By: Claude Opus 5.5 --- crates/file_store/Cargo.toml | 1 + crates/file_store/src/store.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/crates/file_store/Cargo.toml b/crates/file_store/Cargo.toml index 8fbdc358de..d4f568df85 100644 --- a/crates/file_store/Cargo.toml +++ b/crates/file_store/Cargo.toml @@ -21,3 +21,4 @@ serde = { version = "1", features = ["derive"] } [dev-dependencies] tempfile = "3" +bdk_testenv = { path = "../testenv", features = ["serde"] } # `miniscript` is active by default. diff --git a/crates/file_store/src/store.rs b/crates/file_store/src/store.rs index 858b9d2cdf..31fb6066a5 100644 --- a/crates/file_store/src/store.rs +++ b/crates/file_store/src/store.rs @@ -295,6 +295,8 @@ mod test { const TEST_MAGIC_BYTES: [u8; TEST_MAGIC_BYTES_LEN] = [98, 100, 107, 102, 115, 49, 49, 49, 49, 49, 49, 49]; + use bdk_testenv::persist_test_utils::*; + type TestChangeSet = BTreeSet; /// Check behavior of [`Store::create`] and [`Store::load`]. @@ -599,4 +601,33 @@ mod test { // current position matches EOF assert_eq!(current_pointer, expected_pointer); } + + #[test] + fn changesets_are_persisted() { + #[track_caller] + fn assert_store_persists(changesets: &[C]) + where + C: Merge + + serde::Serialize + + serde::de::DeserializeOwned + + Debug + + PartialEq + + Default + + Clone + + 'static, + { + let temp_dir = tempfile::tempdir().unwrap(); + let path = temp_dir.path().join("store.db"); + assert_persist_changesets( + || Ok(Store::load_or_create(&TEST_MAGIC_BYTES, &path)?.0), + |db| Ok(db.dump().map(Option::unwrap_or_default)?), + |db, changeset| Ok(db.append(changeset)?), + changesets, + ); + } + + assert_store_persists(&tx_graph_changesets()); + assert_store_persists(&keychain_txout_changesets()); + assert_store_persists(&local_chain_changesets()); + } }