--- /dev/null
+//! 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]
+}
-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]
}
}
+/// 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/*)",