]> Untitled Git - bdk/commitdiff
feat(testenv): add `persist_test_utils` for testing persistence backends
authorcodingp110 <codingp110@gmail.com>
Sun, 18 Oct 2026 14:21:06 +0000 (01:21 +1100)
committer志宇 <hello@evanlinjin.me>
Sun, 18 Oct 2026 14:21:06 +0000 (01:21 +1100)
`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: 志宇 <hello@evanlinjin.me>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
crates/testenv/Cargo.toml
crates/testenv/src/lib.rs
crates/testenv/src/persist_test_utils.rs [new file with mode: 0644]
crates/testenv/src/utils.rs

index 6cedcf4d30a7860f86b5b0bdfbb1fd6b7a322873..425e6b38343b8e74bcd7f522a10b1b52344641ac 100644 (file)
@@ -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
index 1663c21f3755d1c9bb2f2927d6cf53fc8c454ef9..1b4283e1cf36cf20e9fa7ee05984d0d9891f1533 100644 (file)
@@ -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 (file)
index 0000000..d2520f3
--- /dev/null
@@ -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<C, Store, Init, Load, Persist>(
+    init: Init,
+    load: Load,
+    persist: Persist,
+    changesets: &[C],
+) where
+    C: Debug + PartialEq + Default + Merge + Clone,
+    Init: Fn() -> Result<Store, Box<dyn Error>>,
+    Load: Fn(&mut Store) -> Result<C, Box<dyn Error>>,
+    Persist: Fn(&mut Store, &C) -> Result<(), Box<dyn Error>>,
+{
+    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<ConfirmationBlockTime>; 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::<ConfirmationBlockTime> {
+        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::<ConfirmationBlockTime> {
+        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]
+}
index 93ca1f217f714975b4b3da58d2e04dbb54054685..fdd9dbaa2dcfec39bce67cb3599e6d414a49cd0b 100644 (file)
@@ -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<Item = Txid>,
+    vouts: impl IntoIterator<Item = u32>,
+    amounts: impl IntoIterator<Item = u64>,
+    addrs: impl IntoIterator<Item = &'a str>,
+    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<DescriptorPublicKey>,
+    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<DescriptorPublicKey>, KeyMap) {
+    use bdk_chain::bitcoin::key::Secp256k1;
+    let secp = Secp256k1::signing_only();
+    Descriptor::<DescriptorPublicKey>::parse_descriptor(&secp, descriptor).unwrap()
+}
+
 #[allow(unused)]
 pub const DESCRIPTORS: [&str; 7] = [
     "tr([73c5da0a/86'/0'/0']xprv9xgqHN7yz9MwCkxsBPN5qetuNdQSUttZNKw1dcYTV4mkaAFiBVGQziHs3NRSWMkCzvgjEe3n9xV8oYywvM8at9yRqyaZVz6TYYhX98VjsUk/0/*)",