diff --git a/src/wallet/event.rs b/src/wallet/event.rs index d0a62459..0f46568d 100644 --- a/src/wallet/event.rs +++ b/src/wallet/event.rs @@ -82,8 +82,8 @@ pub enum WalletEvent { }, } -/// Generate events by comparing the chain tip and wallet transactions before and after applying -/// `wallet::Update` to `Wallet`. Any changes are added to the list of returned `WalletEvent`s. +/// 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: &Wallet, chain_tip1: BlockId, diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 886dea5d..e644f65c 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -19,6 +19,7 @@ use alloc::{ sync::Arc, vec::Vec, }; +use core::fmt::{Debug, Display}; use core::{cmp::Ordering, fmt, mem, ops::Deref}; use bdk_chain::{ @@ -2327,40 +2328,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). @@ -2474,7 +2442,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 @@ -2489,39 +2457,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 @@ -2551,8 +2487,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. /// @@ -2566,39 +2502,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. @@ -2607,8 +2511,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. @@ -2623,6 +2527,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. @@ -2683,6 +2606,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, 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: Debug + 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 @@ -2694,6 +2699,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/tests/wallet_event.rs b/tests/wallet_event.rs index 3dfa688e..e19db201 100644 --- a/tests/wallet_event.rs +++ b/tests/wallet_event.rs @@ -9,6 +9,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()) + .is_ok_and(|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(); @@ -18,7 +29,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!( @@ -31,6 +42,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] @@ -476,3 +492,33 @@ fn test_apply_block_tx_confirmed_new_block_event() { && 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 + )); +}