Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/wallet/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 events 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,
Expand Down
231 changes: 126 additions & 105 deletions src/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -2327,40 +2328,7 @@ impl Wallet {
&mut self,
update: impl Into<Update>,
) -> Result<Vec<WalletEvent>, 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::<BTreeMap<Txid, (Arc<Transaction>, ChainPosition<ConfirmationBlockTime>)>>();

// 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::<BTreeMap<Txid, (Arc<Transaction>, ChainPosition<ConfirmationBlockTime>)>>();

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).
Expand Down Expand Up @@ -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
Expand All @@ -2489,39 +2457,7 @@ impl Wallet {
block: &Block,
height: u32,
) -> Result<Vec<WalletEvent>, 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::<BTreeMap<Txid, (Arc<Transaction>, ChainPosition<ConfirmationBlockTime>)>>();

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::<BTreeMap<Txid, (Arc<Transaction>, ChainPosition<ConfirmationBlockTime>)>>();

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
Expand Down Expand Up @@ -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.
///
Expand All @@ -2566,39 +2502,7 @@ impl Wallet {
height: u32,
connected_to: BlockId,
) -> Result<Vec<WalletEvent>, 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::<BTreeMap<Txid, (Arc<Transaction>, ChainPosition<ConfirmationBlockTime>)>>();

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::<BTreeMap<Txid, (Arc<Transaction>, ChainPosition<ConfirmationBlockTime>)>>();

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.
Expand All @@ -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.
Expand All @@ -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<T: Into<Arc<Transaction>>>(
&mut self,
unconfirmed_txs: impl IntoIterator<Item = (T, u64)>,
) -> Vec<WalletEvent> {
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.
Expand Down Expand Up @@ -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<Item = (Txid, u64)>,
) -> Vec<WalletEvent> {
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<F, T, E>(&mut self, f: F) -> Result<Vec<WalletEvent>, E>
where
F: FnOnce(&mut Self) -> Result<T, E>,
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
Expand All @@ -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<Txid, (Arc<Transaction>, ChainPosition<ConfirmationBlockTime>)> {
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.
Expand Down
48 changes: 47 additions & 1 deletion tests/wallet_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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!(
Expand All @@ -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]
Expand Down Expand Up @@ -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
));
}