From: codingp110 Date: Sun, 18 Oct 2026 14:21:06 +0000 (+1100) Subject: feat(testenv): add `persist_test_utils` for testing persistence backends X-Git-Url: http://internal-gitweb-vhost/-script/src/example_cli/database/encode/struct.ScriptHash.html?a=commitdiff_plain;h=73dc5cd3815c57aa80773d48131e2f88143adc68;p=bdk feat(testenv): add `persist_test_utils` for testing persistence backends `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 --- diff --git a/crates/testenv/Cargo.toml b/crates/testenv/Cargo.toml index 6cedcf4d..425e6b38 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 1663c21f..1b4283e1 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 00000000..d2520f35 --- /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 93ca1f21..fdd9dbaa 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/*)",