Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 `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,
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
));
}