diff --git a/Cargo.toml b/Cargo.toml index 9b42de68..bdd5f5ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ assert_matches = "1.5.0" bdk_bitcoind_rpc = { version = "0.21.0" } bdk_electrum = { version = "0.23.1" } bdk_esplora = { version = "0.22.1", features = ["async-https", "blocking-https", "tokio"] } +bdk_wallet_2_3_0 = { package = "bdk_wallet", version = "2.3.0", default-features = false } bdk_wallet = { path = ".", features = ["rusqlite", "file_store", "test-utils"] } clap = { version = "4.5.17", features = ["derive", "env"] } ctrlc = "3.4.6" diff --git a/src/persist_test_utils.rs b/src/persist_test_utils.rs index 729d7ef4..20a8a810 100644 --- a/src/persist_test_utils.rs +++ b/src/persist_test_utils.rs @@ -34,14 +34,16 @@ use std::path::Path; use std::str::FromStr; use std::sync::Arc; -const DESCRIPTORS: [&str; 4] = [ +/// Test descriptors +pub const DESCRIPTORS: [&str; 4] = [ "tr([5940b9b9/86'/0'/0']tpubDDVNqmq75GNPWQ9UNKfP43UwjaHU4GYfoPavojQbfpyfZp2KetWgjGBRRAy4tYCrAA6SB11mhQAkqxjh1VtQHyKwT4oYxpwLaGHvoKmtxZf/0/*)#44aqnlam", "tr([5940b9b9/86'/0'/0']tpubDDVNqmq75GNPWQ9UNKfP43UwjaHU4GYfoPavojQbfpyfZp2KetWgjGBRRAy4tYCrAA6SB11mhQAkqxjh1VtQHyKwT4oYxpwLaGHvoKmtxZf/1/*)#ypcpw2dr", "wpkh([41f2aed0/84h/1h/0h]tpubDDFSdQWw75hk1ewbwnNpPp5DvXFRKt68ioPoyJDY752cNHKkFxPWqkqCyCf4hxrEfpuxh46QisehL3m8Bi6MsAv394QVLopwbtfvryFQNUH/0/*)#g0w0ymmw", "wpkh([41f2aed0/84h/1h/0h]tpubDDFSdQWw75hk1ewbwnNpPp5DvXFRKt68ioPoyJDY752cNHKkFxPWqkqCyCf4hxrEfpuxh46QisehL3m8Bi6MsAv394QVLopwbtfvryFQNUH/1/*)#emtwewtk", ]; -fn create_one_inp_one_out_tx(txid: Txid, amount: u64) -> Transaction { +/// Create 1-input, 1-output transaction. +pub fn create_one_inp_one_out_tx(txid: Txid, amount: u64) -> Transaction { Transaction { version: transaction::Version::ONE, lock_time: absolute::LockTime::ZERO, @@ -59,7 +61,8 @@ fn create_one_inp_one_out_tx(txid: Txid, amount: u64) -> Transaction { } } -fn spk_at_index(descriptor: &Descriptor, index: u32) -> ScriptBuf { +/// Derives the script pubkey of a `descriptor` at a derivation `index`. +pub fn spk_at_index(descriptor: &Descriptor, index: u32) -> ScriptBuf { descriptor .derived_descriptor(&Secp256k1::verification_only(), index) .expect("must derive") diff --git a/src/wallet/changeset.rs b/src/wallet/changeset.rs index 0945d7a1..779e880f 100644 --- a/src/wallet/changeset.rs +++ b/src/wallet/changeset.rs @@ -76,12 +76,42 @@ type IndexedTxGraphChangeSet = /// /// Existing fields may be extended in the future with additional sub-fields. New top-level fields /// are likely to be added as new features and core components are implemented. Existing fields may -/// be removed in future versions of the library. +/// be removed in future versions of the library following the deprecation policy below. /// -/// The authors reserve the right to make breaking changes to the [`ChangeSet`] structure in -/// a major version release. API changes affecting the types of data persisted will display -/// prominently in the release notes. Users are advised to look for such changes and update their -/// application accordingly. +/// ## Version Compatibility +/// +/// Any change to the [`ChangeSet`] data structure MUST correlate with a major version bump per +/// [Semantic Versioning](https://semver.org/). We guarantee that version N can read and +/// deserialize [`ChangeSet`] data written by version N-1 (one major version back), but this +/// guarantee does NOT extend to version N-2 or earlier. New fields added in version N must +/// implement [`Default`] so that when reading N-1 data, absent fields are populated with default +/// values. +/// +/// Limited forward compatibility is provided for downgrades: version N-1 will successfully +/// deserialize version N data without errors by ignoring unknown fields. Users should be aware that +/// features introduced in version N will not be available when downgrading to N-1, and that +/// downgrading can result in loss of data if not backed up. For this reason we recommend carefully +/// planning major upgrades and backing up necessary data to avoid compatibility issues. +/// +/// Fields can be removed using a 3-version deprecation cycle: fields are marked deprecated in +/// version N with a reason and instructions for migrating, the field is retained in version N+1 +/// for compatibility where it deserializes but may not be used, and finally removed in version +/// N+2. This ensures the standard backwards compatibility guarantees while allowing the removal of +/// deprecated fields. +/// +/// ### Responsibilities +/// +/// Library authors SHOULD test all upgrade paths using the persistence test suite and in CI. +/// Library authors MUST document API changes prominently in the release notes and CHANGELOG, +/// clearly mark deprecated fields including migration instructions, and follow the 3-version +/// deprecation cycle before removing fields. +/// +/// Users SHOULD back up wallet data before major version upgrades, test upgrades in non-production +/// environments first, and monitor the release notes for warnings and updates. Users MUST complete +/// migrations within the compatibility window, and not skip major versions (i.e. upgrade major +/// versions sequentially). +/// +/// ### Custom Persistence Implementations /// /// The resulting interface is designed to give the user more control of what to persist and when /// to persist it. Custom implementations should consider and account for the possibility of @@ -117,6 +147,7 @@ pub struct ChangeSet { /// Changes to [`KeychainTxOutIndex`](keychain_txout::KeychainTxOutIndex). pub indexer: keychain_txout::ChangeSet, /// Changes to locked outpoints. + #[serde(default)] pub locked_outpoints: locked_outpoints::ChangeSet, } diff --git a/tests/changeset.rs b/tests/changeset.rs new file mode 100644 index 00000000..b09d5354 --- /dev/null +++ b/tests/changeset.rs @@ -0,0 +1,155 @@ +use bdk_chain::{ConfirmationBlockTime, DescriptorExt, SpkIterator}; +use bdk_wallet::persist_test_utils::*; +use bdk_wallet::ChangeSet; +use bitcoin::{Amount, Network, OutPoint, TxOut}; +use miniscript::{Descriptor, DescriptorPublicKey}; +use std::sync::Arc; + +mod common; + +// What this test validates: +// - v3 can deserialize v2 JSON (backwards compat) +// - v2 can deserialize v3 JSON and ignore new fields (forwards compat) +// - New fields added in v3 implement Default correctly +// - For simplicity JSON is chosen as the serialization format +#[test] +fn test_changeset_compatibility_v2_to_v3() { + let v2_change_set = get_changeset_v2(); + let v2_json = serde_json::to_string(&v2_change_set).expect("failed to serialize v2_change_set"); + + // Test deserialize v2_change_set with the current version (backwards compatibility) + let v3_change_set: ChangeSet = + serde_json::from_str(&v2_json).expect("failed to deserialize v2_change_set"); + + // v3 added locked_outpoints - verify Default was applied + assert!( + v3_change_set.locked_outpoints.outpoints.is_empty(), + "Failed to populate new default field `locked_outpoints`" + ); + + let v3_change_set = get_changeset_v3(); + assert!(!v3_change_set.locked_outpoints.outpoints.is_empty()); + let v3_json = serde_json::to_string(&v3_change_set).expect("failed to serialize v3_change_set"); + + // v2 should ignore unknown fields when reading v3 data + let _: bdk_wallet_2_3_0::ChangeSet = + serde_json::from_str(&v3_json).expect("failed to deserialize v3_change_set"); +} + +#[test] +fn test_changeset_v2_roundtrip_through_v3() { + // Ensure v2 data survives a write/read cycle through v3 code + let v2_change_set = get_changeset_v2(); + let v2_json = serde_json::to_string(&v2_change_set).unwrap(); + + // Deserialize into v3 + let v3_change_set: ChangeSet = serde_json::from_str(&v2_json).unwrap(); + + // Re-serialize from v3 + let v3_json = serde_json::to_string(&v3_change_set).unwrap(); + + // Deserialize back into v2 - should still work + let v2_roundtrip: bdk_wallet_2_3_0::ChangeSet = serde_json::from_str(&v3_json) + .expect("v2 must still deserialize after roundtrip through v3"); + + // Verify data is preserved + assert_eq!(v2_roundtrip, v2_change_set); +} + +/// Get v3 change set. +pub fn get_changeset_v3() -> ChangeSet { + let change_set = get_changeset_v2(); + + ChangeSet { + descriptor: change_set.descriptor, + change_descriptor: change_set.change_descriptor, + network: change_set.network, + local_chain: change_set.local_chain, + tx_graph: change_set.tx_graph, + indexer: change_set.indexer, + locked_outpoints: bdk_wallet::locked_outpoints::ChangeSet { + outpoints: [(OutPoint::new(hash!("Rust"), 0), true)].into(), + }, + } +} + +/// Get v2 change set. +pub fn get_changeset_v2() -> bdk_wallet_2_3_0::ChangeSet { + use bdk_wallet_2_3_0::chain::{keychain_txout, local_chain, tx_graph}; + use bdk_wallet_2_3_0::ChangeSet; + + let descriptor: Descriptor = DESCRIPTORS[0].parse().unwrap(); + let change_descriptor: Descriptor = DESCRIPTORS[1].parse().unwrap(); + + let local_chain_changeset = local_chain::ChangeSet { + blocks: [ + (0, Some(hash!("0"))), + (910233, Some(hash!("B"))), + (910234, Some(hash!("T"))), + (910235, Some(hash!("C"))), + ] + .into(), + }; + + let tx = Arc::new(create_one_inp_one_out_tx(hash!("prev_txid"), 30_000)); + + let txid = tx.compute_txid(); + + let conf_anchor: ConfirmationBlockTime = ConfirmationBlockTime { + block_id: block_id!(910233, "B"), + confirmation_time: 1755317160, + }; + + let tx_graph_changeset = tx_graph::ChangeSet { + txs: [tx].into(), + txouts: [ + ( + OutPoint::new(hash!("Rust"), 0), + TxOut { + value: Amount::from_sat(1300), + script_pubkey: spk_at_index(&descriptor, 4), + }, + ), + ( + OutPoint::new(hash!("REDB"), 0), + TxOut { + value: Amount::from_sat(1400), + script_pubkey: spk_at_index(&descriptor, 10), + }, + ), + ] + .into(), + anchors: [(conf_anchor, txid)].into(), + last_seen: [(txid, 1755317760)].into(), + first_seen: [(txid, 1755317750)].into(), + last_evicted: [(txid, 1755317760)].into(), + }; + + let keychain_txout_changeset = keychain_txout::ChangeSet { + last_revealed: [ + (descriptor.descriptor_id(), 3), + (change_descriptor.descriptor_id(), 5), + ] + .into(), + spk_cache: [ + ( + descriptor.descriptor_id(), + SpkIterator::new_with_range(&descriptor, 0..=10).collect(), + ), + ( + change_descriptor.descriptor_id(), + SpkIterator::new_with_range(&change_descriptor, 0..=10).collect(), + ), + ] + .into(), + }; + + ChangeSet { + descriptor: Some(descriptor), + change_descriptor: Some(change_descriptor), + network: Some(Network::Regtest), + local_chain: local_chain_changeset, + tx_graph: tx_graph_changeset, + indexer: keychain_txout_changeset, + } +} diff --git a/tests/common.rs b/tests/common.rs index c7a9b9c5..220dc3ba 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -17,6 +17,23 @@ pub fn parse_descriptor(s: &str) -> (Descriptor, KeyMap) { .expect("failed to parse descriptor") } +#[macro_export] +macro_rules! block_id { + ($height:expr, $hash:literal) => {{ + bdk_chain::BlockId { + height: $height, + hash: bitcoin::hashes::Hash::hash($hash.as_bytes()), + } + }}; +} + +#[macro_export] +macro_rules! hash { + ($index:literal) => {{ + bitcoin::hashes::Hash::hash($index.as_bytes()) + }}; +} + /// Validate and return the transaction fee from a PSBT. /// Panics if extraction fails, fee calculation fails, or if calculated fee doesn't match PSBT's /// fee.