diff --git a/ci/pin-msrv.sh b/ci/pin-msrv.sh index 2018064c..6cb0b4f4 100755 --- a/ci/pin-msrv.sh +++ b/ci/pin-msrv.sh @@ -13,3 +13,10 @@ set -euo pipefail cargo update -p once_cell --precise "1.20.3" cargo update -p syn --precise "2.0.106" cargo update -p quote --precise "1.0.41" +cargo update -p serde_json --precise "1.0.145" +cargo update -p anyhow --precise "1.0.100" +cargo update -p tempfile --precise "3.25.0" +cargo update -p proc-macro2 --precise "1.0.103" +cargo update -p ryu --precise "1.0.20" +cargo update -p itoa --precise "1.0.15" +cargo update -p unicode-ident --precise "1.0.22" diff --git a/wallet/src/wallet/event.rs b/wallet/src/wallet/event.rs index 4785f59c..0f46568d 100644 --- a/wallet/src/wallet/event.rs +++ b/wallet/src/wallet/event.rs @@ -82,8 +82,10 @@ pub enum WalletEvent { }, } +/// Generate `WalletEvent`s by comparing the chain tip and wallet transactions before and after +/// updating the state of the `Wallet`. pub(crate) fn wallet_events( - wallet: &mut Wallet, + wallet: &Wallet, chain_tip1: BlockId, chain_tip2: BlockId, wallet_txs1: BTreeMap, ChainPosition)>, @@ -91,6 +93,7 @@ pub(crate) fn wallet_events( ) -> Vec { let mut events: Vec = Vec::new(); + // find chain tip change if chain_tip1 != chain_tip2 { events.push(WalletEvent::ChainTipChanged { old_tip: chain_tip1, @@ -98,10 +101,11 @@ pub(crate) fn wallet_events( }); } - wallet_txs2.iter().for_each(|(txid2, (tx2, cp2))| { - if let Some((tx1, cp1)) = wallet_txs1.get(txid2) { - assert_eq!(tx1.compute_txid(), *txid2); - match (cp1, cp2) { + // find transaction canonical status changes + wallet_txs2.iter().for_each(|(txid2, (tx2, pos2))| { + if let Some((tx1, pos1)) = wallet_txs1.get(txid2) { + debug_assert_eq!(tx1.compute_txid(), *txid2); + match (pos1, pos2) { (Unconfirmed { .. }, Confirmed { anchor, .. }) => { events.push(WalletEvent::TxConfirmed { txid: *txid2, @@ -139,7 +143,7 @@ pub(crate) fn wallet_events( } } } else { - match cp2 { + match pos2 { Confirmed { anchor, .. } => { events.push(WalletEvent::TxConfirmed { txid: *txid2, diff --git a/wallet/src/wallet/mod.rs b/wallet/src/wallet/mod.rs index b05d9718..617c4536 100644 --- a/wallet/src/wallet/mod.rs +++ b/wallet/src/wallet/mod.rs @@ -2448,40 +2448,7 @@ impl Wallet { &mut self, update: impl Into, ) -> Result, CannotConnectError> { - // snapshot of chain tip and transactions before update - let chain_tip1 = self.chain.tip().block_id(); - let wallet_txs1 = self - .transactions() - .map(|wtx| { - ( - wtx.tx_node.txid, - (wtx.tx_node.tx.clone(), wtx.chain_position), - ) - }) - .collect::, ChainPosition)>>(); - - // apply update - self.apply_update(update)?; - - // chain tip and transactions after update - let chain_tip2 = self.chain.tip().block_id(); - let wallet_txs2 = self - .transactions() - .map(|wtx| { - ( - wtx.tx_node.txid, - (wtx.tx_node.tx.clone(), wtx.chain_position), - ) - }) - .collect::, ChainPosition)>>(); - - Ok(wallet_events( - self, - chain_tip1, - chain_tip2, - wallet_txs1, - wallet_txs2, - )) + self.events_helper(|wallet| wallet.apply_update(update)) } /// Get a reference of the staged [`ChangeSet`] that is yet to be committed (if any). @@ -2550,7 +2517,7 @@ impl Wallet { } /// Introduces a `block` of `height` to the wallet, and tries to connect it to the - /// `prev_blockhash` of the block's header. + /// `prev_blockhash` of the block's header and returns events. /// /// This is a convenience method that is equivalent to calling /// [`apply_block_connected_to_events`] with `prev_blockhash` and `height-1` as the @@ -2565,39 +2532,7 @@ impl Wallet { block: &Block, height: u32, ) -> Result, CannotConnectError> { - // snapshot of chain tip and transactions before update - let chain_tip1 = self.chain.tip().block_id(); - let wallet_txs1 = self - .transactions() - .map(|wtx| { - ( - wtx.tx_node.txid, - (wtx.tx_node.tx.clone(), wtx.chain_position), - ) - }) - .collect::, ChainPosition)>>(); - - self.apply_block(block, height)?; - - // chain tip and transactions after update - let chain_tip2 = self.chain.tip().block_id(); - let wallet_txs2 = self - .transactions() - .map(|wtx| { - ( - wtx.tx_node.txid, - (wtx.tx_node.tx.clone(), wtx.chain_position), - ) - }) - .collect::, ChainPosition)>>(); - - Ok(wallet_events( - self, - chain_tip1, - chain_tip2, - wallet_txs1, - wallet_txs2, - )) + self.events_helper(|wallet| wallet.apply_block(block, height)) } /// Applies relevant transactions from `block` of `height` to the wallet, and connects the @@ -2631,8 +2566,8 @@ impl Wallet { Ok(()) } - /// Applies relevant transactions from `block` of `height` to the wallet, and connects the - /// block to the internal chain. + /// Applies relevant transactions from `block` of `height` to the wallet, connects the + /// block to the internal chain and returns events. /// /// See [`apply_block_connected_to`] for more information. /// @@ -2646,39 +2581,7 @@ impl Wallet { height: u32, connected_to: BlockId, ) -> Result, ApplyHeaderError> { - // snapshot of chain tip and transactions before update - let chain_tip1 = self.chain.tip().block_id(); - let wallet_txs1 = self - .transactions() - .map(|wtx| { - ( - wtx.tx_node.txid, - (wtx.tx_node.tx.clone(), wtx.chain_position), - ) - }) - .collect::, ChainPosition)>>(); - - self.apply_block_connected_to(block, height, connected_to)?; - - // chain tip and transactions after update - let chain_tip2 = self.chain.tip().block_id(); - let wallet_txs2 = self - .transactions() - .map(|wtx| { - ( - wtx.tx_node.txid, - (wtx.tx_node.tx.clone(), wtx.chain_position), - ) - }) - .collect::, ChainPosition)>>(); - - Ok(wallet_events( - self, - chain_tip1, - chain_tip2, - wallet_txs1, - wallet_txs2, - )) + self.events_helper(|wallet| wallet.apply_block_connected_to(block, height, connected_to)) } /// Apply relevant unconfirmed transactions to the wallet. @@ -2687,8 +2590,8 @@ impl Wallet { /// /// This method takes in an iterator of `(tx, last_seen)` where `last_seen` is the timestamp of /// when the transaction was last seen in the mempool. This is used for conflict resolution - /// when there is conflicting unconfirmed transactions. The transaction with the later - /// `last_seen` is prioritized. + /// when there are conflicting unconfirmed transactions in the mempool. The transaction with the + /// later `last_seen` is prioritized. /// /// **WARNING**: You must persist the changes resulting from one or more calls to this method /// if you need the applied unconfirmed transactions to be reloaded after closing the wallet. @@ -2703,6 +2606,25 @@ impl Wallet { self.stage.merge(indexed_graph_changeset.into()); } + /// Apply relevant unconfirmed transactions to the wallet and returns events. + /// + /// See [`apply_unconfirmed_txs`] for more information. + /// + /// See [`apply_update_events`] for more information on the returned [`WalletEvent`]s. + /// + /// [`apply_unconfirmed_txs`]: Self::apply_unconfirmed_txs + /// [`apply_update_events`]: Self::apply_update_events + pub fn apply_unconfirmed_txs_events>>( + &mut self, + unconfirmed_txs: impl IntoIterator, + ) -> Vec { + self.events_helper::<_, _, core::convert::Infallible>(|wallet| { + wallet.apply_unconfirmed_txs(unconfirmed_txs); + Ok(()) + }) + .expect("`apply_unconfirmed_txs` should not fail") + } + /// Apply evictions of the given transaction IDs with their associated timestamps. /// /// This function is used to mark specific unconfirmed transactions as evicted from the mempool. @@ -2763,6 +2685,88 @@ impl Wallet { self.stage.merge(changeset.into()); } + /// Apply evictions of the given transaction IDs with their associated timestamps and returns + /// events. + /// + /// See [`apply_evicted_txs`] for more information. + /// + /// See [`apply_update_events`] for more information on the returned [`WalletEvent`]s. + /// + /// [`apply_evicted_txs`]: Self::apply_evicted_txs + /// [`apply_update_events`]: Self::apply_update_events + pub fn apply_evicted_txs_events( + &mut self, + evicted_txs: impl IntoIterator, + ) -> Vec { + self.events_helper::<_, _, core::convert::Infallible>(|wallet| { + wallet.apply_evicted_txs(evicted_txs); + Ok(()) + }) + .expect("`apply_evicted_txs` should not fail") + } + + /// Generates wallet events by executing a wallet-mutating function and surfacing internal + /// state changes. + /// + /// It works by taking some wallet operation that modifies state, capturing "before" and "after" + /// snapshots of the wallet's chain tip and transactions and comparing them in order to + /// generate a list of [`WalletEvent`]s representing what changed. + /// + /// Common kinds of events include: + /// + /// - [`WalletEvent::ChainTipChanged`]: The blockchain tip changed + /// - [`WalletEvent::TxConfirmed`]: A transaction was confirmed in a block + /// - [`WalletEvent::TxUnconfirmed`]: A transaction was newly unconfirmed + /// - [`WalletEvent::TxReplaced`]: An unconfirmed transaction was replaced (e.g., via RBF) + /// - [`WalletEvent::TxDropped`]: An unconfirmed transaction was dropped from the mempool + /// + /// This is useful when you need to track specific changes to your wallet state, such + /// as updating a UI to reflect transaction status changes, triggering notifications when + /// transactions confirm, logging state changes for debugging or auditing, or responding to + /// chain reorganizations. + /// + /// # Example + /// + /// ```rust,no_run + /// # use bdk_chain::local_chain::CannotConnectError; + /// # use bdk_wallet::{Wallet, Update, event::WalletEvent}; + /// # let mut wallet: Wallet = todo!(); + /// // Apply an update and get events describing what changed + /// let update = Update::default(); + /// let func = |wallet: &mut Wallet| wallet.apply_update(update); + /// let events = wallet.events_helper(func)?; + /// # Ok::<(), anyhow::Error>(()) + /// ``` + /// + /// # Errors + /// + /// If `f` returns an error, then returns `E` of a type defined by the function + /// passed in. + pub fn events_helper(&mut self, f: F) -> Result, E> + where + F: FnOnce(&mut Self) -> Result, + E: fmt::Debug + fmt::Display, + { + // Snapshot of chain tip and transactions before + let chain_tip1 = self.chain.tip().block_id(); + let wallet_txs1 = self.map_transactions(); + + // Call `f` on self + f(self)?; + + // Chain tip and transactions after + let chain_tip2 = self.chain.tip().block_id(); + let wallet_txs2 = self.map_transactions(); + + Ok(wallet_events( + self, + chain_tip1, + chain_tip2, + wallet_txs1, + wallet_txs2, + )) + } + /// Used internally to ensure that all methods requiring a [`KeychainKind`] will use a /// keychain with an associated descriptor. For example in case the wallet was created /// with only one keychain, passing [`KeychainKind::Internal`] here will instead return @@ -2774,6 +2778,22 @@ impl Wallet { keychain } } + + /// Returns a map of canonical transactions keyed by txid. + /// + /// This is used internally to help generate [`WalletEvent`]s. + fn map_transactions( + &self, + ) -> BTreeMap, ChainPosition)> { + self.transactions() + .map(|wtx| { + ( + wtx.tx_node.txid, + (wtx.tx_node.tx.clone(), wtx.chain_position), + ) + }) + .collect() + } } /// Methods to construct sync/full-scan requests for spk-based chain sources. diff --git a/wallet/tests/wallet_event.rs b/wallet/tests/wallet_event.rs index bf72a35e..82828880 100644 --- a/wallet/tests/wallet_event.rs +++ b/wallet/tests/wallet_event.rs @@ -10,6 +10,17 @@ use core::str::FromStr; use std::sync::Arc; /// apply_update_events tests. +#[test] +fn test_empty_update_should_return_no_events() { + let (mut wallet, _) = bdk_wallet::test_utils::get_funded_wallet_wpkh(); + assert!( + wallet + .apply_update_events(Update::default()) + .map_or(false, |vec| vec.is_empty()), + "Empty update should return no events" + ); +} + #[test] fn test_new_confirmed_tx_event() { let (desc, change_desc) = get_test_wpkh_and_change_desc(); @@ -19,7 +30,7 @@ fn test_new_confirmed_tx_event() { height: 0, hash: wallet.local_chain().genesis_hash(), }; - let events = wallet.apply_update_events(update).unwrap(); + let events = wallet.apply_update_events(update.clone()).unwrap(); let new_tip1 = wallet.local_chain().tip().block_id(); assert_eq!(events.len(), 3); assert!( @@ -32,6 +43,11 @@ fn test_new_confirmed_tx_event() { assert!( matches!(&events[2], WalletEvent::TxConfirmed {tx, block_time, ..} if block_time.block_id.height == 2000 && tx.output.len() == 2) ); + // Repeatedly applying an update should have no effect + assert!( + wallet.apply_update_events(update).unwrap().is_empty(), + "Applying repeat Update should return no events" + ); } #[test] @@ -470,3 +486,33 @@ fn test_apply_block_tx_confirmed_new_block_event() { txid == spending_tx.compute_txid() && block_time.block_id == (2, reorg_block2.block_hash()).into() && old_block_time.is_some()) ); } + +#[test] +fn test_apply_unconfirmed_txs_tx_unconfirmed_event() { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let (mut wallet, _, update) = new_wallet_and_funding_update(desc, Some(change_desc)); + let unconfirmed_tx = update.tx_update.txs[1].clone(); + let events = wallet.apply_unconfirmed_txs_events([(unconfirmed_tx.clone(), 1010)]); + assert_eq!(events.len(), 1); + assert!(matches!( + &events[0], + WalletEvent::TxUnconfirmed {tx, old_block_time, ..} + if *tx == unconfirmed_tx + && old_block_time.is_none() + )); +} + +#[test] +fn test_apply_evicted_txs_tx_dropped_event() { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let (mut wallet, _, update) = new_wallet_and_funding_update(desc, Some(change_desc)); + let unconfirmed_tx = update.tx_update.txs[1].clone(); + let _events = wallet.apply_unconfirmed_txs_events([(unconfirmed_tx.clone(), 1010)]); + let events = wallet.apply_evicted_txs_events([(unconfirmed_tx.compute_txid(), 1010)]); + assert_eq!(events.len(), 1); + assert!(matches!( + &events[0], + WalletEvent::TxDropped {txid, tx} + if *txid == tx.compute_txid() && *tx == unconfirmed_tx + )); +}