From cd02d3c14f14cada3e9e901a9861c7f168995970 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Fri, 12 Sep 2025 22:12:25 -0500 Subject: [PATCH 01/10] docs: fix small typos # Conflicts: # src/wallet/event.rs --- src/wallet/changeset.rs | 4 ++-- src/wallet/event.rs | 0 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 src/wallet/event.rs diff --git a/src/wallet/changeset.rs b/src/wallet/changeset.rs index d3d1ba93..0527b916 100644 --- a/src/wallet/changeset.rs +++ b/src/wallet/changeset.rs @@ -11,7 +11,7 @@ type IndexedTxGraphChangeSet = /// /// ## Definition /// -/// The change set is responsible for transmiting data between the persistent storage layer and the +/// The change set is responsible for transmitting data between the persistent storage layer and the /// core library components. Specifically, it serves two primary functions: /// /// 1) Recording incremental changes to the in-memory representation that need to be persisted to @@ -46,7 +46,7 @@ type IndexedTxGraphChangeSet = /// to change at any point thereafter. /// /// Other fields of the change set are not required to be non-empty, that is they may be empty even -/// in the aggregate. However in practice they should contain the data needed to recover a wallet +/// in the aggregate. However, in practice they should contain the data needed to recover a wallet /// state between sessions. These include: /// * [`tx_graph`](Self::tx_graph) /// * [`indexer`](Self::indexer) diff --git a/src/wallet/event.rs b/src/wallet/event.rs new file mode 100644 index 00000000..e69de29b From a100567821b1f1f8bb51494d81c28b658b6ac956 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Fri, 12 Sep 2025 22:12:49 -0500 Subject: [PATCH 02/10] feat(wallet): add WalletEvent and Wallet::apply_update_events WalletEvent is a enum of user facing events that are generated when a sync update is applied to a wallet using the Wallet::apply_update_events function. --- src/test_utils.rs | 127 ++++++++-------- src/wallet/mod.rs | 50 +++++++ tests/wallet_event.rs | 300 +++++++++++++++++++++++++++++++++++++ wallet/src/wallet/event.rs | 181 ++++++++++++++++++++++ 4 files changed, 593 insertions(+), 65 deletions(-) create mode 100644 tests/wallet_event.rs create mode 100644 wallet/src/wallet/event.rs diff --git a/src/test_utils.rs b/src/test_utils.rs index 11fd13b1..ff288ac8 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -4,7 +4,7 @@ use alloc::string::ToString; use alloc::sync::Arc; use core::str::FromStr; -use bdk_chain::{BlockId, ConfirmationBlockTime, TxUpdate}; +use bdk_chain::{BlockId, CheckPoint, ConfirmationBlockTime, TxUpdate}; use bitcoin::{ absolute, hashes::Hash, transaction, Address, Amount, BlockHash, FeeRate, Network, OutPoint, Transaction, TxIn, TxOut, Txid, @@ -22,13 +22,42 @@ pub fn get_funded_wallet(descriptor: &str, change_descriptor: &str) -> (Wallet, } fn new_funded_wallet(descriptor: &str, change_descriptor: Option<&str>) -> (Wallet, Txid) { + let (mut wallet, txid, update) = new_wallet_and_funding_update(descriptor, change_descriptor); + wallet.apply_update(update).unwrap(); + (wallet, txid) +} + +/// Return a fake wallet that appears to be funded for testing. +/// +/// The funded wallet contains a tx with a 76_000 sats input and two outputs, one spending 25_000 +/// to a foreign address and one returning 50_000 back to the wallet. The remaining 1000 +/// sats are the transaction fee. +pub fn get_funded_wallet_single(descriptor: &str) -> (Wallet, Txid) { + new_funded_wallet(descriptor, None) +} + +/// Get funded segwit wallet +pub fn get_funded_wallet_wpkh() -> (Wallet, Txid) { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + get_funded_wallet(desc, change_desc) +} + +/// Get unfunded wallet and wallet update that funds it +/// +/// The funding update contains a tx with a 76_000 sats input and two outputs, one spending +/// 25_000 to a foreign address and one returning 50_000 back to the wallet as +/// change. The remaining 1000 sats are the transaction fee. +pub fn new_wallet_and_funding_update( + descriptor: &str, + change_descriptor: Option<&str>, +) -> (Wallet, Txid, Update) { let params = if let Some(change_desc) = change_descriptor { Wallet::create(descriptor.to_string(), change_desc.to_string()) } else { Wallet::create_single(descriptor.to_string()) }; - let mut wallet = params + let wallet = params .network(Network::Regtest) .create_wallet_no_persist() .expect("descriptors must be valid"); @@ -39,6 +68,8 @@ fn new_funded_wallet(descriptor: &str, change_descriptor: Option<&str>) -> (Wall .require_network(Network::Regtest) .unwrap(); + let mut update = Update::default(); + let tx0 = Transaction { output: vec![TxOut { value: Amount::from_sat(76_000), @@ -67,71 +98,37 @@ fn new_funded_wallet(descriptor: &str, change_descriptor: Option<&str>) -> (Wall ], ..new_tx(0) }; + let txid1 = tx1.compute_txid(); - insert_checkpoint( - &mut wallet, - BlockId { - height: 42, - hash: BlockHash::all_zeros(), - }, - ); - insert_checkpoint( - &mut wallet, - BlockId { - height: 1_000, - hash: BlockHash::all_zeros(), - }, - ); - insert_checkpoint( - &mut wallet, - BlockId { - height: 2_000, - hash: BlockHash::all_zeros(), - }, - ); - - insert_tx(&mut wallet, tx0.clone()); - insert_anchor( - &mut wallet, - tx0.compute_txid(), - ConfirmationBlockTime { - block_id: BlockId { - height: 1_000, - hash: BlockHash::all_zeros(), - }, - confirmation_time: 100, - }, - ); - - insert_tx(&mut wallet, tx1.clone()); - insert_anchor( - &mut wallet, - tx1.compute_txid(), - ConfirmationBlockTime { - block_id: BlockId { - height: 2_000, - hash: BlockHash::all_zeros(), - }, - confirmation_time: 200, - }, - ); - - (wallet, tx1.compute_txid()) -} - -/// Return a fake wallet that appears to be funded for testing. -/// -/// The funded wallet contains a tx with a 76_000 sats input and two outputs, one spending 25_000 -/// to a foreign address and one returning 50_000 back to the wallet. The remaining 1000 -/// sats are the transaction fee. -pub fn get_funded_wallet_single(descriptor: &str) -> (Wallet, Txid) { - new_funded_wallet(descriptor, None) -} + let b0 = BlockId { + height: 0, + hash: BlockHash::from_slice(wallet.network().chain_hash().as_bytes()).unwrap(), + }; + let b1 = BlockId { + height: 42, + hash: BlockHash::all_zeros(), + }; + let b2 = BlockId { + height: 1000, + hash: BlockHash::all_zeros(), + }; + let a2 = ConfirmationBlockTime { + block_id: b2, + confirmation_time: 100, + }; + let b3 = BlockId { + height: 2000, + hash: BlockHash::all_zeros(), + }; + let a3 = ConfirmationBlockTime { + block_id: b3, + confirmation_time: 200, + }; + update.chain = CheckPoint::from_block_ids([b0, b1, b2, b3]).ok(); + update.tx_update.anchors = [(a2, tx0.compute_txid()), (a3, tx1.compute_txid())].into(); + update.tx_update.txs = [Arc::new(tx0), Arc::new(tx1)].into(); -/// Get funded segwit wallet -pub fn get_funded_wallet_wpkh() -> (Wallet, Txid) { - let (desc, change_desc) = get_test_wpkh_and_change_desc(); - get_funded_wallet(desc, change_desc) + (wallet, txid1, update) } /// `pkh` single key descriptor diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index bc814589..3a0d58f4 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -52,6 +52,7 @@ use rand_core::RngCore; mod changeset; pub mod coin_selection; pub mod error; +pub mod event; pub mod export; mod params; mod persisted; @@ -76,6 +77,7 @@ use crate::wallet::{ }; // re-exports +use crate::event::{wallet_events, WalletEvent}; pub use bdk_chain::Balance; pub use changeset::ChangeSet; pub use params::*; @@ -2362,6 +2364,54 @@ impl Wallet { Ok(()) } + /// Applies an update to the wallet, stages the changes, and returns events. + /// + /// Usually you create an `update` by interacting with some blockchain data source and inserting + /// transactions related to your wallet into it. Staged changes are NOT persisted. + /// + /// After applying updates you should process the events in your app before persisting the + /// staged wallet changes. For an example of how to persist staged wallet changes see + /// [`Wallet::reveal_next_address`]. + pub fn apply_update_events( + &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, + )) + } + /// Get a reference of the staged [`ChangeSet`] that is yet to be committed (if any). pub fn staged(&self) -> Option<&ChangeSet> { if self.stage.is_empty() { diff --git a/tests/wallet_event.rs b/tests/wallet_event.rs new file mode 100644 index 00000000..a4559405 --- /dev/null +++ b/tests/wallet_event.rs @@ -0,0 +1,300 @@ +use bdk_chain::{BlockId, CheckPoint, ConfirmationBlockTime}; +use bdk_wallet::event::WalletEvent; +use bdk_wallet::test_utils::{get_test_wpkh_and_change_desc, new_wallet_and_funding_update}; +use bdk_wallet::{SignOptions, Update}; +use bitcoin::hashes::Hash; +use bitcoin::{Address, Amount, BlockHash, FeeRate}; +use core::str::FromStr; +use std::sync::Arc; + +#[test] +fn test_new_confirmed_tx_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 genesis = BlockId { + height: 0, + hash: wallet.local_chain().genesis_hash(), + }; + let events = wallet.apply_update_events(update).unwrap(); + let new_tip1 = wallet.local_chain().tip().block_id(); + assert_eq!(events.len(), 3); + assert!( + matches!(events[0], WalletEvent::ChainTipChanged { old_tip, new_tip } if old_tip == genesis && new_tip == new_tip1) + ); + assert!( + matches!(events[1], WalletEvent::TxConfirmed { block_time, ..} if block_time.block_id.height == 1000) + ); + assert!(matches!(&events[1], WalletEvent::TxConfirmed {tx, ..} if tx.output.len() == 1)); + assert!( + matches!(events[2], WalletEvent::TxConfirmed {block_time, ..} if block_time.block_id.height == 2000) + ); + assert!(matches!(&events[2], WalletEvent::TxConfirmed {tx, ..} if tx.output.len() == 2)); +} + +#[test] +fn test_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)); + // ignore funding events + let _events = wallet.apply_update_events(update).unwrap(); + + let reorg_block = BlockId { + height: 2_000, + hash: BlockHash::from_slice(&[1; 32]).unwrap(), + }; + let mut cp = wallet.latest_checkpoint(); + cp = cp.insert(reorg_block); + let reorg_update = Update { + chain: Some(cp), + ..Default::default() + }; + let old_tip1 = wallet.local_chain().tip().block_id(); + let events = wallet.apply_update_events(reorg_update).unwrap(); + let new_tip1 = wallet.local_chain().tip().block_id(); + assert_eq!(events.len(), 2); + assert!( + matches!(events[0], WalletEvent::ChainTipChanged { old_tip, new_tip } if old_tip == old_tip1 && new_tip == new_tip1) + ); + assert!( + matches!(&events[1], WalletEvent::TxUnconfirmed {tx, old_block_time, ..} if tx.output.len() == 2 && old_block_time.is_some()) + ); +} + +#[test] +fn test_tx_replaced_event() { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let (mut wallet, _, update) = new_wallet_and_funding_update(desc, Some(change_desc)); + // ignore funding events + let _events = wallet.apply_update_events(update).unwrap(); + + // create original tx + let mut builder = wallet.build_tx(); + builder.add_recipient( + Address::from_str("tb1q6yn66vajcctph75pvylgkksgpp6nq04ppwct9a") + .unwrap() + .assume_checked(), + Amount::from_sat(10_000), + ); + let mut psbt = builder.finish().unwrap(); + wallet.sign(&mut psbt, SignOptions::default()).unwrap(); + let orig_tx = Arc::new(psbt.extract_tx().unwrap()); + let orig_txid = orig_tx.compute_txid(); + + // update wallet with original tx + let mut update = Update::default(); + update.tx_update.txs = vec![orig_tx.clone()]; + update.tx_update.seen_ats = [(orig_txid, 210)].into(); + let events = wallet.apply_update_events(update).unwrap(); + assert_eq!(events.len(), 1); + assert!(matches!(events[0], WalletEvent::TxUnconfirmed { .. })); + assert!( + matches!(&events[0], WalletEvent::TxUnconfirmed {tx, ..} if tx.compute_txid() == orig_txid) + ); + + // create rbf tx + let mut builder = wallet.build_fee_bump(orig_txid).unwrap(); + builder.fee_rate(FeeRate::from_sat_per_vb(10).unwrap()); + let mut psbt = builder.finish().unwrap(); + wallet.sign(&mut psbt, SignOptions::default()).unwrap(); + let rbf_tx = Arc::new(psbt.extract_tx().unwrap()); + let rbf_txid = rbf_tx.compute_txid(); + + // update wallet with rbf tx + let mut update = Update::default(); + update.tx_update.txs = vec![rbf_tx.clone()]; + update.tx_update.evicted_ats = [(orig_txid, 220)].into(); + update.tx_update.seen_ats = [(rbf_txid, 220)].into(); + + let events = wallet.apply_update_events(update).unwrap(); + assert_eq!(events.len(), 2); + assert!(matches!(events[0], WalletEvent::TxUnconfirmed { txid, .. } if txid == rbf_txid)); + assert!(matches!(events[1], WalletEvent::TxReplaced { txid, ..} if txid == orig_txid)); + assert!( + matches!(&events[1], WalletEvent::TxReplaced {conflicts, ..} if conflicts.len() == 1 && + conflicts.contains(&(0, rbf_txid))) + ); +} + +#[test] +fn test_tx_confirmed_event() { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let (mut wallet, _, update) = new_wallet_and_funding_update(desc, Some(change_desc)); + // ignore funding events + let _events = wallet.apply_update_events(update).unwrap(); + + // create new tx + let mut builder = wallet.build_tx(); + builder.add_recipient( + Address::from_str("tb1q6yn66vajcctph75pvylgkksgpp6nq04ppwct9a") + .unwrap() + .assume_checked(), + Amount::from_sat(10_000), + ); + let mut psbt = builder.finish().unwrap(); + wallet.sign(&mut psbt, SignOptions::default()).unwrap(); + let new_tx = Arc::new(psbt.extract_tx().unwrap()); + let new_txid = new_tx.compute_txid(); + + // update wallet with original tx + let mut update = Update::default(); + update.tx_update.txs = vec![new_tx.clone()]; + update.tx_update.seen_ats = [(new_txid, 210)].into(); + let events = wallet.apply_update_events(update).unwrap(); + assert_eq!(events.len(), 1); + assert!(matches!(events[0], WalletEvent::TxUnconfirmed { .. })); + assert!( + matches!(&events[0], WalletEvent::TxUnconfirmed {tx, ..} if tx.compute_txid() == new_txid) + ); + + // confirm tx + let mut update = Update::default(); + let parent_block = BlockId { + height: 2000, + hash: BlockHash::all_zeros(), + }; + let new_block = BlockId { + height: 2100, + hash: BlockHash::all_zeros(), + }; + + let new_anchor = ConfirmationBlockTime { + block_id: new_block, + confirmation_time: 300, + }; + update.chain = CheckPoint::from_block_ids([parent_block, new_block]).ok(); + update.tx_update.anchors = [(new_anchor, new_txid)].into(); + + let orig_tip = wallet.local_chain().tip().block_id(); + let events = wallet.apply_update_events(update).unwrap(); + assert_eq!(events.len(), 2); + assert!( + matches!(events[0], WalletEvent::ChainTipChanged { old_tip, new_tip } if old_tip == orig_tip && new_tip == new_block) + ); + assert!(matches!(events[1], WalletEvent::TxConfirmed { txid, .. } if txid == new_txid)); +} + +#[test] +fn test_tx_confirmed_new_block_event() { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let (mut wallet, _, update) = new_wallet_and_funding_update(desc, Some(change_desc)); + // ignore funding events + let _events = wallet.apply_update_events(update).unwrap(); + + // create new tx + let mut builder = wallet.build_tx(); + builder.add_recipient( + Address::from_str("tb1q6yn66vajcctph75pvylgkksgpp6nq04ppwct9a") + .unwrap() + .assume_checked(), + Amount::from_sat(10_000), + ); + let mut psbt = builder.finish().unwrap(); + wallet.sign(&mut psbt, SignOptions::default()).unwrap(); + let new_tx = Arc::new(psbt.extract_tx().unwrap()); + let new_txid = new_tx.compute_txid(); + + // update wallet with original tx + let mut update = Update::default(); + update.tx_update.txs = vec![new_tx.clone()]; + update.tx_update.seen_ats = [(new_txid, 210)].into(); + let events = wallet.apply_update_events(update).unwrap(); + assert_eq!(events.len(), 1); + assert!(matches!(events[0], WalletEvent::TxUnconfirmed { .. })); + assert!( + matches!(&events[0], WalletEvent::TxUnconfirmed {tx, ..} if tx.compute_txid() == new_txid) + ); + + // confirm tx + let mut update = Update::default(); + let parent_block = BlockId { + height: 2000, + hash: BlockHash::all_zeros(), + }; + let new_block = BlockId { + height: 2100, + hash: BlockHash::all_zeros(), + }; + + let new_anchor = ConfirmationBlockTime { + block_id: new_block, + confirmation_time: 300, + }; + update.chain = CheckPoint::from_block_ids([parent_block, new_block]).ok(); + update.tx_update.anchors = [(new_anchor, new_txid)].into(); + + let orig_tip = wallet.local_chain().tip().block_id(); + let events = wallet.apply_update_events(update).unwrap(); + assert_eq!(events.len(), 2); + assert!( + matches!(events[0], WalletEvent::ChainTipChanged { old_tip, new_tip } if old_tip == orig_tip && new_tip == new_block) + ); + assert!(matches!(events[1], WalletEvent::TxConfirmed { txid, .. } if txid == new_txid)); + + // confirm reorged tx + let mut update = Update::default(); + let parent_block = BlockId { + height: 2000, + hash: BlockHash::all_zeros(), + }; + let reorg_block = BlockId { + height: 2100, + hash: BlockHash::from_slice(&[1; 32]).unwrap(), + }; + + let reorg_anchor = ConfirmationBlockTime { + block_id: reorg_block, + confirmation_time: 310, + }; + update.chain = CheckPoint::from_block_ids([parent_block, reorg_block]).ok(); + update.tx_update.anchors = [(reorg_anchor, new_txid)].into(); + + let events = wallet.apply_update_events(update).unwrap(); + assert_eq!(events.len(), 2); + assert!( + matches!(events[0], WalletEvent::ChainTipChanged { old_tip, new_tip } if old_tip == new_block && new_tip == reorg_block) + ); + assert!( + matches!(events[1], WalletEvent::TxConfirmed { txid, block_time, old_block_time, .. } if txid == new_txid && block_time.block_id == reorg_block && old_block_time.is_some()) + ); +} + +#[test] +fn test_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)); + // ignore funding events + let _events = wallet.apply_update_events(update).unwrap(); + + // create new tx + let mut builder = wallet.build_tx(); + builder.add_recipient( + Address::from_str("tb1q6yn66vajcctph75pvylgkksgpp6nq04ppwct9a") + .unwrap() + .assume_checked(), + Amount::from_sat(10_000), + ); + let mut psbt = builder.finish().unwrap(); + wallet.sign(&mut psbt, SignOptions::default()).unwrap(); + let new_tx = Arc::new(psbt.extract_tx().unwrap()); + let new_txid = new_tx.compute_txid(); + + // update wallet with original tx + let mut update = Update::default(); + update.tx_update.txs = vec![new_tx.clone()]; + update.tx_update.seen_ats = [(new_txid, 210)].into(); + let events = wallet.apply_update_events(update).unwrap(); + assert_eq!(events.len(), 1); + assert!(matches!(events[0], WalletEvent::TxUnconfirmed { .. })); + assert!( + matches!(&events[0], WalletEvent::TxUnconfirmed {tx, ..} if tx.compute_txid() == new_txid) + ); + + // drop tx + let mut update = Update::default(); + update.tx_update.evicted_ats = [(new_txid, 220)].into(); + let events = wallet.apply_update_events(update).unwrap(); + + assert_eq!(events.len(), 1); + assert!(matches!(events[0], WalletEvent::TxDropped { txid, .. } if txid == new_txid)); +} diff --git a/wallet/src/wallet/event.rs b/wallet/src/wallet/event.rs new file mode 100644 index 00000000..562fb1a6 --- /dev/null +++ b/wallet/src/wallet/event.rs @@ -0,0 +1,181 @@ +//! User facing wallet events. + +use crate::collections::BTreeMap; +use crate::wallet::ChainPosition::{Confirmed, Unconfirmed}; +use crate::Wallet; +use alloc::sync::Arc; +use alloc::vec::Vec; +use bitcoin::{Transaction, Txid}; +use chain::{BlockId, ChainPosition, ConfirmationBlockTime}; + +/// Events representing changes to wallet transactions. +/// +/// Returned after calling [`Wallet::apply_update`](crate::wallet::Wallet::apply_update). +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum WalletEvent { + /// The latest chain tip known to the wallet changed. + ChainTipChanged { + /// Previous chain tip. + old_tip: BlockId, + /// New chain tip. + new_tip: BlockId, + }, + /// A transaction is now confirmed. + /// + /// If the transaction was previously unconfirmed `old_block_time` will be `None`. + /// + /// If a confirmed transaction is now re-confirmed in a new block `old_block_time` will contain + /// the block id and the time it was previously confirmed. This can happen after a chain + /// reorg. + TxConfirmed { + /// Transaction id. + txid: Txid, + /// Transaction. + tx: Arc, + /// Confirmation block time. + block_time: ConfirmationBlockTime, + /// Old confirmation block and time if previously confirmed in a different block. + old_block_time: Option, + }, + /// A transaction is now unconfirmed. + /// + /// If the transaction is first seen in the mempool `old_block_time` will be `None`. + /// + /// If a previously confirmed transaction is now seen in the mempool `old_block_time` will + /// contain the block id and the time it was previously confirmed. This can happen after a + /// chain reorg. + TxUnconfirmed { + /// Transaction id. + txid: Txid, + /// Transaction. + tx: Arc, + /// Old confirmation block and time, if previously confirmed. + old_block_time: Option, + }, + /// An unconfirmed transaction was replaced. + /// + /// This can happen after an RBF is broadcast or if a third party double spends an input of + /// a received payment transaction before it is confirmed. + /// + /// The conflicts field contains the txid and vin (in which it conflicts) of the conflicting + /// transactions. + TxReplaced { + /// Transaction id. + txid: Txid, + /// Transaction. + tx: Arc, + /// Conflicting transaction ids. + conflicts: Vec<(usize, Txid)>, + }, + /// Unconfirmed transaction dropped. + /// + /// The transaction was dropped from the local mempool. This is generally due to the fee rate + /// being too low. The transaction can still reappear in the mempool in the future resulting in + /// a [`WalletEvent::TxUnconfirmed`] event. + TxDropped { + /// Transaction id. + txid: Txid, + /// Transaction. + tx: Arc, + }, +} + +pub(crate) fn wallet_events( + wallet: &mut Wallet, + chain_tip1: BlockId, + chain_tip2: BlockId, + wallet_txs1: BTreeMap, ChainPosition)>, + wallet_txs2: BTreeMap, ChainPosition)>, +) -> Vec { + let mut events: Vec = Vec::new(); + + if chain_tip1 != chain_tip2 { + events.push(WalletEvent::ChainTipChanged { + old_tip: chain_tip1, + new_tip: chain_tip2, + }); + } + + 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) { + (Unconfirmed { .. }, Confirmed { anchor, .. }) => { + events.push(WalletEvent::TxConfirmed { + txid: *txid2, + tx: tx2.clone(), + block_time: *anchor, + old_block_time: None, + }); + } + (Confirmed { anchor, .. }, Unconfirmed { .. }) => { + events.push(WalletEvent::TxUnconfirmed { + txid: *txid2, + tx: tx2.clone(), + old_block_time: Some(*anchor), + }); + } + ( + Confirmed { + anchor: anchor1, .. + }, + Confirmed { + anchor: anchor2, .. + }, + ) => { + if *anchor1 != *anchor2 { + events.push(WalletEvent::TxConfirmed { + txid: *txid2, + tx: tx2.clone(), + block_time: *anchor2, + old_block_time: Some(*anchor1), + }); + } + } + (Unconfirmed { .. }, Unconfirmed { .. }) => { + // do nothing if still unconfirmed + } + } + } else { + match cp2 { + Confirmed { anchor, .. } => { + events.push(WalletEvent::TxConfirmed { + txid: *txid2, + tx: tx2.clone(), + block_time: *anchor, + old_block_time: None, + }); + } + Unconfirmed { .. } => { + events.push(WalletEvent::TxUnconfirmed { + txid: *txid2, + tx: tx2.clone(), + old_block_time: None, + }); + } + } + } + }); + + // find tx that are no longer canonical + wallet_txs1.iter().for_each(|(txid1, (tx1, _))| { + if !wallet_txs2.contains_key(txid1) { + let conflicts = wallet.tx_graph().direct_conflicts(tx1).collect::>(); + if !conflicts.is_empty() { + events.push(WalletEvent::TxReplaced { + txid: *txid1, + tx: tx1.clone(), + conflicts, + }); + } else { + events.push(WalletEvent::TxDropped { + txid: *txid1, + tx: tx1.clone(), + }); + } + } + }); + + events +} From aa964799fd32b89137bf5a83553db66d8c383eaa Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Sun, 21 Sep 2025 18:58:07 -0500 Subject: [PATCH 03/10] docs(wallet): add example to appl_update_events --- src/wallet/mod.rs | 72 ++++++++++++++++++++++++++++++++++++++ wallet/src/wallet/event.rs | 3 +- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 3a0d58f4..d0a8afaf 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2372,6 +2372,78 @@ impl Wallet { /// After applying updates you should process the events in your app before persisting the /// staged wallet changes. For an example of how to persist staged wallet changes see /// [`Wallet::reveal_next_address`]. + /// + /// ```rust,no_run + /// # use bitcoin::*; + /// # use bdk_wallet::*; + /// use bdk_wallet::event::WalletEvent; + /// # let wallet_update = Update::default(); + /// # let mut wallet = doctest_wallet!(); + /// let events = wallet.apply_update_events(wallet_update)?; + /// // Handle wallet relevant events from this update. + /// events.iter().for_each(|event| { + /// match event { + /// // The chain tip changed. + /// WalletEvent::ChainTipChanged { old_tip, new_tip } => { + /// todo!() // handle event + /// } + /// // An unconfirmed tx is now confirmed in a block. + /// WalletEvent::TxConfirmed { + /// txid, + /// tx, + /// block_time, + /// old_block_time: None, + /// } => { + /// todo!() // handle event + /// } + /// // A confirmed tx is now confirmed in a new block (reorg). + /// WalletEvent::TxConfirmed { + /// txid, + /// tx, + /// block_time, + /// old_block_time: Some(old_block_time), + /// } => { + /// todo!() // handle event + /// } + /// // A new unconfirmed tx was seen in the mempool. + /// WalletEvent::TxUnconfirmed { + /// txid, + /// tx, + /// old_block_time: None, + /// } => { + /// todo!() // handle event + /// } + /// // A previously confirmed tx in now unconfirmed in the mempool (reorg). + /// WalletEvent::TxUnconfirmed { + /// txid, + /// tx, + /// old_block_time: Some(old_block_time), + /// } => { + /// todo!() // handle event + /// } + /// // An unconfirmed tx was replaced in the mempool (RBF or double spent input). + /// WalletEvent::TxReplaced { + /// txid, + /// tx, + /// conflicts, + /// } => { + /// todo!() // handle event + /// } + /// // An unconfirmed tx was dropped from the mempool (fee too low). + /// WalletEvent::TxDropped { txid, tx } => { + /// todo!() // handle event + /// } + /// _ => { + /// // unexpected event, do nothing + /// } + /// } + /// // take staged wallet changes + /// let staged = wallet.take_staged(); + /// // persist staged changes + /// }); + /// # Ok::<(), anyhow::Error>(()) + /// ``` + /// [`TxBuilder`]: crate::TxBuilder pub fn apply_update_events( &mut self, update: impl Into, diff --git a/wallet/src/wallet/event.rs b/wallet/src/wallet/event.rs index 562fb1a6..4785f59c 100644 --- a/wallet/src/wallet/event.rs +++ b/wallet/src/wallet/event.rs @@ -10,7 +10,8 @@ use chain::{BlockId, ChainPosition, ConfirmationBlockTime}; /// Events representing changes to wallet transactions. /// -/// Returned after calling [`Wallet::apply_update`](crate::wallet::Wallet::apply_update). +/// Returned after calling +/// [`Wallet::apply_update_events`](crate::wallet::Wallet::apply_update_events). #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum WalletEvent { From e632fcc48ac7161ecda7e2f71885f252374bc7c8 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Sun, 21 Sep 2025 18:58:32 -0500 Subject: [PATCH 04/10] docs: add events ADR 0003 --- docs/adr/0003_events.md | 104 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/adr/0003_events.md diff --git a/docs/adr/0003_events.md b/docs/adr/0003_events.md new file mode 100644 index 00000000..a8019369 --- /dev/null +++ b/docs/adr/0003_events.md @@ -0,0 +1,104 @@ +# Return user-facing events when applying updates after syncing + +* Status: accepted +* Authors: @notmandatory +* Date: 2025-09-21 +* Targeted modules: wallet +* Associated tickets/PRs: #6, #310 + +## Context and Problem Statement + +When syncing a `Wallet` with new blockchain data using `Wallet::apply_update` it does not return any value on success, +only a `CannotConnectError` if it fails. + +Users have asked for a concise list of events that reflect if or how new blockchain data has changed the +blockchain tip and the status of transactions relevant to the wallet's bitcoin balance. This information should also +be useful for on-chain apps who want to notify users of wallet changes after syncing. + +If the end user app ends for some reason before handling the wallet events, the same wallet events should be +regenerated when the same blockchain sync data is re-downloaded and reapplied to the wallet. + +## Decision Drivers + +* Currently `Wallet::apply_update` does not return any value except a `CannotConnectError` if it fails. +* Downstream users need updates on chain tip, new transactions and transaction status changes. +* If the app doesn't process all the events before it ends the same events should be returned on a subsequent sync. +* Current downstream users requesting this feature are: LDK node (@tnull) and Bitkit (@ovitrif). +* This feature was requested in May 2024, over a year and a half ago. + +## Considered Options + +#### Option 1: Do nothing + +Do not change anything since all the data the users require is available with the current API by comparing the +wallet's canonical transaction list before and after applying a sync update. + +**Pros:** + +* No API changes are needed and user can customize the events to exactly what they need. + +**Cons:** + +* Users will need to duplicate the work to add this feature on every project. +* It's easier for the core BDK team to add this feature once in a way that meets most users needs. + +#### Option 2: Modify the `Wallet::apply_update` to return a list of `WalletEvent` + +Adds `WalletEvent` enum of user facing events that are generated when a sync update is applied to a wallet using the +existing `Wallet::apply_update` function. The `WalletEvent` enum includes an event for changes in blockchain tip and +events for changes to the status of transactions that are relevant to the wallet, including: + +1. newly seen in the mempool +2. replaced in the mempool +3. dropped from the mempool +4. confirmed in a block +5. confirmed in a new block due to a reorg +6. unconfirmed due to a reorg + +Chain tip change events are generated by comparing the wallet's chain tip before and after applying an update. Wallet +transaction events are generated by comparing a snapshot of canonical transactions. + +As long as updates to the wallet are not persisted until after all events are processed by the caller then if the app +crashes for some reason and the wallet is re-sync'd a new update will re-return the same events. + +The `WalletEvent` enum is non-exhaustive. + +**Pros:** + +* Events are always generated when a wallet update is applied. +* The user doesn't need to add this functionality themselves. +* New events can be added without a breaking change. + +**Cons:** + +* This can not be rolled out except as a breaking release since it changes the `Wallet::apply_update` function signature. +* If an app doesn't care about these events they must still generate them. + +#### Option 3: Same as option 2 but add a new function + +This option is the same as option 2 but adds a new `Wallet::apply_update_events` function to update the wallet and +return the list of `WalletEvent` enums. + +**Pros:** + +* Same reasons as above and does not require an API breaking release. +* Keeps option for users to update the wallet with original `Wallet::apply_update` and not get events. + +**Cons:** + +* Could be confusing to users which function to use, the original or new one. +* If in a future breaking release we decide to always return events we'll need to deprecate `Wallet::apply_update_events`. + +## Decision Outcome + +Chosen option: "Option 3", because it can be delivered to users in the next minor release. This option also lets us +get user feedback and see how the events are used before forcing all users to generate them during an update. + +### Positive Consequences + +* The new wallet events can be used for more responsive on chain wallet UIs. + +### Negative Consequences + +* The down stream `bdk-ffi` and book of bdk projects will need to be updated for this new feature. + From fc9d20eaf8fa3df92ed43bffd6cad97225ed4669 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Tue, 28 Oct 2025 15:47:22 -0500 Subject: [PATCH 05/10] docs(event): improve wallet event docs and tests per suggestions from ValuedMammal: 1. re-export WalletEvent type 2. add comments to wallet_events function 3. rename ambiguous variable names in wallet_events from cp to pos 4. remove signing from wallet_event tests 5. change wallet_events function assert_eq to debug_asset_eq 6. update ADR 0003 decision outcome and add option 4 re: creating events only from Update --- docs/adr/0003_events.md | 26 +++++- src/wallet/event.rs | 186 +++++++++++++++++++++++++++++++++++++ src/wallet/mod.rs | 3 +- tests/wallet_event.rs | 17 ++-- wallet/src/wallet/event.rs | 182 ------------------------------------ 5 files changed, 217 insertions(+), 197 deletions(-) delete mode 100644 wallet/src/wallet/event.rs diff --git a/docs/adr/0003_events.md b/docs/adr/0003_events.md index a8019369..0ea4185d 100644 --- a/docs/adr/0003_events.md +++ b/docs/adr/0003_events.md @@ -4,7 +4,7 @@ * Authors: @notmandatory * Date: 2025-09-21 * Targeted modules: wallet -* Associated tickets/PRs: #6, #310 +* Associated tickets/PRs: #6, #310, #319 ## Context and Problem Statement @@ -89,10 +89,30 @@ return the list of `WalletEvent` enums. * Could be confusing to users which function to use, the original or new one. * If in a future breaking release we decide to always return events we'll need to deprecate `Wallet::apply_update_events`. +#### Option 4: Create events directly from Wallet::Update + +The `wallet::Update` structure passed into the `Wallet::apply_update` function contains any new transaction or +blockchain data found in a `FullScanResponse` or `SyncResponse`. Events could be generated from only this data. + +**Pros:** + +* No further wallet lookups is required to create events, it would be more efficient. +* Could be implemented as a function directly on the `wallet::Update` structure, a non-breaking API change. + +**Cons:** + +* A `wallet::Update` only contains the blocks, tx, and anchors found during a sync or full scan. It does not show how + this data changes the canonical status of already known blocks and tx. + ## Decision Outcome -Chosen option: "Option 3", because it can be delivered to users in the next minor release. This option also lets us -get user feedback and see how the events are used before forcing all users to generate them during an update. +Chosen option: + +"Option 3" for the 2.2 release because it can be delivered to users as a minor release. This option also +lets us get user feedback and see how the events are used before forcing all users to generate them during an update. + +"Option 2" for the 3.0 release to simplify the API by only using one function `apply_update` that will now return +events. ### Positive Consequences diff --git a/src/wallet/event.rs b/src/wallet/event.rs index e69de29b..22e9adbd 100644 --- a/src/wallet/event.rs +++ b/src/wallet/event.rs @@ -0,0 +1,186 @@ +//! User facing wallet events. + +use crate::collections::BTreeMap; +use crate::wallet::ChainPosition::{Confirmed, Unconfirmed}; +use crate::Wallet; +use alloc::sync::Arc; +use alloc::vec::Vec; +use bitcoin::{Transaction, Txid}; +use chain::{BlockId, ChainPosition, ConfirmationBlockTime}; + +/// Events representing changes to wallet transactions. +/// +/// Returned after calling +/// [`Wallet::apply_update_events`](crate::wallet::Wallet::apply_update_events). +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum WalletEvent { + /// The latest chain tip known to the wallet changed. + ChainTipChanged { + /// Previous chain tip. + old_tip: BlockId, + /// New chain tip. + new_tip: BlockId, + }, + /// A transaction is now confirmed. + /// + /// If the transaction was previously unconfirmed `old_block_time` will be `None`. + /// + /// If a confirmed transaction is now re-confirmed in a new block `old_block_time` will contain + /// the block id and the time it was previously confirmed. This can happen after a chain + /// reorg. + TxConfirmed { + /// Transaction id. + txid: Txid, + /// Transaction. + tx: Arc, + /// Confirmation block time. + block_time: ConfirmationBlockTime, + /// Old confirmation block and time if previously confirmed in a different block. + old_block_time: Option, + }, + /// A transaction is now unconfirmed. + /// + /// If the transaction is first seen in the mempool `old_block_time` will be `None`. + /// + /// If a previously confirmed transaction is now seen in the mempool `old_block_time` will + /// contain the block id and the time it was previously confirmed. This can happen after a + /// chain reorg. + TxUnconfirmed { + /// Transaction id. + txid: Txid, + /// Transaction. + tx: Arc, + /// Old confirmation block and time, if previously confirmed. + old_block_time: Option, + }, + /// An unconfirmed transaction was replaced. + /// + /// This can happen after an RBF is broadcast or if a third party double spends an input of + /// a received payment transaction before it is confirmed. + /// + /// The conflicts field contains the txid and vin (in which it conflicts) of the conflicting + /// transactions. + TxReplaced { + /// Transaction id. + txid: Txid, + /// Transaction. + tx: Arc, + /// Conflicting transaction ids. + conflicts: Vec<(usize, Txid)>, + }, + /// Unconfirmed transaction dropped. + /// + /// The transaction was dropped from the local mempool. This is generally due to the fee rate + /// being too low. The transaction can still reappear in the mempool in the future resulting in + /// a [`WalletEvent::TxUnconfirmed`] event. + TxDropped { + /// Transaction id. + txid: Txid, + /// Transaction. + tx: Arc, + }, +} + +/// 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. +pub(crate) fn wallet_events( + wallet: &mut Wallet, + chain_tip1: BlockId, + chain_tip2: BlockId, + wallet_txs1: BTreeMap, ChainPosition)>, + wallet_txs2: BTreeMap, ChainPosition)>, +) -> Vec { + let mut events: Vec = Vec::new(); + + // find chain tip change + if chain_tip1 != chain_tip2 { + events.push(WalletEvent::ChainTipChanged { + old_tip: chain_tip1, + new_tip: chain_tip2, + }); + } + + // 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, + tx: tx2.clone(), + block_time: *anchor, + old_block_time: None, + }); + } + (Confirmed { anchor, .. }, Unconfirmed { .. }) => { + events.push(WalletEvent::TxUnconfirmed { + txid: *txid2, + tx: tx2.clone(), + old_block_time: Some(*anchor), + }); + } + ( + Confirmed { + anchor: anchor1, .. + }, + Confirmed { + anchor: anchor2, .. + }, + ) => { + if *anchor1 != *anchor2 { + events.push(WalletEvent::TxConfirmed { + txid: *txid2, + tx: tx2.clone(), + block_time: *anchor2, + old_block_time: Some(*anchor1), + }); + } + } + (Unconfirmed { .. }, Unconfirmed { .. }) => { + // do nothing if still unconfirmed + } + } + } else { + match pos2 { + Confirmed { anchor, .. } => { + events.push(WalletEvent::TxConfirmed { + txid: *txid2, + tx: tx2.clone(), + block_time: *anchor, + old_block_time: None, + }); + } + Unconfirmed { .. } => { + events.push(WalletEvent::TxUnconfirmed { + txid: *txid2, + tx: tx2.clone(), + old_block_time: None, + }); + } + } + } + }); + + // find tx that are no longer canonical + wallet_txs1.iter().for_each(|(txid1, (tx1, _))| { + if !wallet_txs2.contains_key(txid1) { + let conflicts = wallet.tx_graph().direct_conflicts(tx1).collect::>(); + if !conflicts.is_empty() { + events.push(WalletEvent::TxReplaced { + txid: *txid1, + tx: tx1.clone(), + conflicts, + }); + } else { + events.push(WalletEvent::TxDropped { + txid: *txid1, + tx: tx1.clone(), + }); + } + } + }); + + events +} diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index d0a8afaf..54d7dfae 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -75,11 +75,12 @@ use crate::wallet::{ tx_builder::{FeePolicy, TxBuilder, TxParams}, utils::{check_nsequence_rbf, After, Older, SecpCtx}, }; +use event::wallet_events; // re-exports -use crate::event::{wallet_events, WalletEvent}; pub use bdk_chain::Balance; pub use changeset::ChangeSet; +pub use event::WalletEvent; pub use params::*; pub use persisted::*; pub use utils::IsDust; diff --git a/tests/wallet_event.rs b/tests/wallet_event.rs index a4559405..e269bab6 100644 --- a/tests/wallet_event.rs +++ b/tests/wallet_event.rs @@ -1,7 +1,7 @@ use bdk_chain::{BlockId, CheckPoint, ConfirmationBlockTime}; use bdk_wallet::event::WalletEvent; use bdk_wallet::test_utils::{get_test_wpkh_and_change_desc, new_wallet_and_funding_update}; -use bdk_wallet::{SignOptions, Update}; +use bdk_wallet::Update; use bitcoin::hashes::Hash; use bitcoin::{Address, Amount, BlockHash, FeeRate}; use core::str::FromStr; @@ -76,8 +76,7 @@ fn test_tx_replaced_event() { .assume_checked(), Amount::from_sat(10_000), ); - let mut psbt = builder.finish().unwrap(); - wallet.sign(&mut psbt, SignOptions::default()).unwrap(); + let psbt = builder.finish().unwrap(); let orig_tx = Arc::new(psbt.extract_tx().unwrap()); let orig_txid = orig_tx.compute_txid(); @@ -95,8 +94,7 @@ fn test_tx_replaced_event() { // create rbf tx let mut builder = wallet.build_fee_bump(orig_txid).unwrap(); builder.fee_rate(FeeRate::from_sat_per_vb(10).unwrap()); - let mut psbt = builder.finish().unwrap(); - wallet.sign(&mut psbt, SignOptions::default()).unwrap(); + let psbt = builder.finish().unwrap(); let rbf_tx = Arc::new(psbt.extract_tx().unwrap()); let rbf_txid = rbf_tx.compute_txid(); @@ -131,8 +129,7 @@ fn test_tx_confirmed_event() { .assume_checked(), Amount::from_sat(10_000), ); - let mut psbt = builder.finish().unwrap(); - wallet.sign(&mut psbt, SignOptions::default()).unwrap(); + let psbt = builder.finish().unwrap(); let new_tx = Arc::new(psbt.extract_tx().unwrap()); let new_txid = new_tx.compute_txid(); @@ -189,8 +186,7 @@ fn test_tx_confirmed_new_block_event() { .assume_checked(), Amount::from_sat(10_000), ); - let mut psbt = builder.finish().unwrap(); - wallet.sign(&mut psbt, SignOptions::default()).unwrap(); + let psbt = builder.finish().unwrap(); let new_tx = Arc::new(psbt.extract_tx().unwrap()); let new_txid = new_tx.compute_txid(); @@ -274,8 +270,7 @@ fn test_tx_dropped_event() { .assume_checked(), Amount::from_sat(10_000), ); - let mut psbt = builder.finish().unwrap(); - wallet.sign(&mut psbt, SignOptions::default()).unwrap(); + let psbt = builder.finish().unwrap(); let new_tx = Arc::new(psbt.extract_tx().unwrap()); let new_txid = new_tx.compute_txid(); diff --git a/wallet/src/wallet/event.rs b/wallet/src/wallet/event.rs deleted file mode 100644 index 4785f59c..00000000 --- a/wallet/src/wallet/event.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! User facing wallet events. - -use crate::collections::BTreeMap; -use crate::wallet::ChainPosition::{Confirmed, Unconfirmed}; -use crate::Wallet; -use alloc::sync::Arc; -use alloc::vec::Vec; -use bitcoin::{Transaction, Txid}; -use chain::{BlockId, ChainPosition, ConfirmationBlockTime}; - -/// Events representing changes to wallet transactions. -/// -/// Returned after calling -/// [`Wallet::apply_update_events`](crate::wallet::Wallet::apply_update_events). -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum WalletEvent { - /// The latest chain tip known to the wallet changed. - ChainTipChanged { - /// Previous chain tip. - old_tip: BlockId, - /// New chain tip. - new_tip: BlockId, - }, - /// A transaction is now confirmed. - /// - /// If the transaction was previously unconfirmed `old_block_time` will be `None`. - /// - /// If a confirmed transaction is now re-confirmed in a new block `old_block_time` will contain - /// the block id and the time it was previously confirmed. This can happen after a chain - /// reorg. - TxConfirmed { - /// Transaction id. - txid: Txid, - /// Transaction. - tx: Arc, - /// Confirmation block time. - block_time: ConfirmationBlockTime, - /// Old confirmation block and time if previously confirmed in a different block. - old_block_time: Option, - }, - /// A transaction is now unconfirmed. - /// - /// If the transaction is first seen in the mempool `old_block_time` will be `None`. - /// - /// If a previously confirmed transaction is now seen in the mempool `old_block_time` will - /// contain the block id and the time it was previously confirmed. This can happen after a - /// chain reorg. - TxUnconfirmed { - /// Transaction id. - txid: Txid, - /// Transaction. - tx: Arc, - /// Old confirmation block and time, if previously confirmed. - old_block_time: Option, - }, - /// An unconfirmed transaction was replaced. - /// - /// This can happen after an RBF is broadcast or if a third party double spends an input of - /// a received payment transaction before it is confirmed. - /// - /// The conflicts field contains the txid and vin (in which it conflicts) of the conflicting - /// transactions. - TxReplaced { - /// Transaction id. - txid: Txid, - /// Transaction. - tx: Arc, - /// Conflicting transaction ids. - conflicts: Vec<(usize, Txid)>, - }, - /// Unconfirmed transaction dropped. - /// - /// The transaction was dropped from the local mempool. This is generally due to the fee rate - /// being too low. The transaction can still reappear in the mempool in the future resulting in - /// a [`WalletEvent::TxUnconfirmed`] event. - TxDropped { - /// Transaction id. - txid: Txid, - /// Transaction. - tx: Arc, - }, -} - -pub(crate) fn wallet_events( - wallet: &mut Wallet, - chain_tip1: BlockId, - chain_tip2: BlockId, - wallet_txs1: BTreeMap, ChainPosition)>, - wallet_txs2: BTreeMap, ChainPosition)>, -) -> Vec { - let mut events: Vec = Vec::new(); - - if chain_tip1 != chain_tip2 { - events.push(WalletEvent::ChainTipChanged { - old_tip: chain_tip1, - new_tip: chain_tip2, - }); - } - - 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) { - (Unconfirmed { .. }, Confirmed { anchor, .. }) => { - events.push(WalletEvent::TxConfirmed { - txid: *txid2, - tx: tx2.clone(), - block_time: *anchor, - old_block_time: None, - }); - } - (Confirmed { anchor, .. }, Unconfirmed { .. }) => { - events.push(WalletEvent::TxUnconfirmed { - txid: *txid2, - tx: tx2.clone(), - old_block_time: Some(*anchor), - }); - } - ( - Confirmed { - anchor: anchor1, .. - }, - Confirmed { - anchor: anchor2, .. - }, - ) => { - if *anchor1 != *anchor2 { - events.push(WalletEvent::TxConfirmed { - txid: *txid2, - tx: tx2.clone(), - block_time: *anchor2, - old_block_time: Some(*anchor1), - }); - } - } - (Unconfirmed { .. }, Unconfirmed { .. }) => { - // do nothing if still unconfirmed - } - } - } else { - match cp2 { - Confirmed { anchor, .. } => { - events.push(WalletEvent::TxConfirmed { - txid: *txid2, - tx: tx2.clone(), - block_time: *anchor, - old_block_time: None, - }); - } - Unconfirmed { .. } => { - events.push(WalletEvent::TxUnconfirmed { - txid: *txid2, - tx: tx2.clone(), - old_block_time: None, - }); - } - } - } - }); - - // find tx that are no longer canonical - wallet_txs1.iter().for_each(|(txid1, (tx1, _))| { - if !wallet_txs2.contains_key(txid1) { - let conflicts = wallet.tx_graph().direct_conflicts(tx1).collect::>(); - if !conflicts.is_empty() { - events.push(WalletEvent::TxReplaced { - txid: *txid1, - tx: tx1.clone(), - conflicts, - }); - } else { - events.push(WalletEvent::TxDropped { - txid: *txid1, - tx: tx1.clone(), - }); - } - } - }); - - events -} From 94dc5f4fb6b7763503f3b0a23099b1699cf3cdd3 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 29 Oct 2025 12:53:12 +0100 Subject: [PATCH 06/10] Add `apply_block_events` and `apply_block_connected_to_events` Previously, we added a new `Wallet::apply_update_events` method that returned `WalletEvent`s. Unfortunately, no corresponding APIs were added for the `apply_block` counterparts. Here we fix this omission. --- src/wallet/mod.rs | 85 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 54d7dfae..c490e159 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2550,6 +2550,41 @@ impl Wallet { }) } + /// Introduces a `block` of `height` to the wallet, and tries to connect it to the + /// `prev_blockhash` of the block's header. + /// + /// This is a convenience method that is equivalent to calling + /// [`apply_block_connected_to_events`] with `prev_blockhash` and `height-1` as the + /// `connected_to` parameter. + /// + /// See [`apply_update_events`] for more information on the returned [`WalletEvent`]s. + /// + /// [`apply_block_connected_to_events`]: Self::apply_block_connected_to_events + /// [`apply_update_events`]: Self::apply_update_events + pub fn apply_block_events( + &mut self, + block: &Block, + height: u32, + ) -> Result, CannotConnectError> { + let connected_to = match height.checked_sub(1) { + Some(prev_height) => BlockId { + height: prev_height, + hash: block.header.prev_blockhash, + }, + None => BlockId { + height, + hash: block.block_hash(), + }, + }; + self.apply_block_connected_to_events(block, height, connected_to) + .map_err(|err| match err { + ApplyHeaderError::InconsistentBlocks => { + unreachable!("connected_to is derived from the block so must be consistent") + } + ApplyHeaderError::CannotConnect(err) => err, + }) + } + /// Applies relevant transactions from `block` of `height` to the wallet, and connects the /// block to the internal chain. /// @@ -2581,6 +2616,56 @@ impl Wallet { Ok(()) } + /// Applies relevant transactions from `block` of `height` to the wallet, and connects the + /// block to the internal chain. + /// + /// See [`apply_block_connected_to`] for more information. + /// + /// See [`apply_update_events`] for more information on the returned [`WalletEvent`]s. + /// + /// [`apply_block_connected_to`]: Self::apply_block_connected_to + /// [`apply_update_events`]: Self::apply_update_events + pub fn apply_block_connected_to_events( + &mut self, + block: &Block, + 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, + )) + } + /// Apply relevant unconfirmed transactions to the wallet. /// /// Transactions that are not relevant are filtered out. From 95e681a9ef576fd583db8e3057973bafefcb0db5 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 31 Oct 2025 13:16:47 +0100 Subject: [PATCH 07/10] f Duplicate event logic rather than business logic Co-authored-by: Steve Myers --- src/wallet/mod.rs | 48 +++++++++++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index c490e159..d98b87a4 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2566,23 +2566,39 @@ impl Wallet { block: &Block, height: u32, ) -> Result, CannotConnectError> { - let connected_to = match height.checked_sub(1) { - Some(prev_height) => BlockId { - height: prev_height, - hash: block.header.prev_blockhash, - }, - None => BlockId { - height, - hash: block.block_hash(), - }, - }; - self.apply_block_connected_to_events(block, height, connected_to) - .map_err(|err| match err { - ApplyHeaderError::InconsistentBlocks => { - unreachable!("connected_to is derived from the block so must be consistent") - } - ApplyHeaderError::CannotConnect(err) => err, + // 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, + )) } /// Applies relevant transactions from `block` of `height` to the wallet, and connects the From e0bef3b248933564d166a8ea05159b276aedfa10 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Fri, 31 Oct 2025 18:57:31 -0500 Subject: [PATCH 08/10] test(wallet): add tests for apply_block_events Also did minor cleanup of apply_update_events tests. --- tests/wallet_event.rs | 189 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 180 insertions(+), 9 deletions(-) diff --git a/tests/wallet_event.rs b/tests/wallet_event.rs index e269bab6..2cc7cc50 100644 --- a/tests/wallet_event.rs +++ b/tests/wallet_event.rs @@ -2,11 +2,13 @@ use bdk_chain::{BlockId, CheckPoint, ConfirmationBlockTime}; use bdk_wallet::event::WalletEvent; use bdk_wallet::test_utils::{get_test_wpkh_and_change_desc, new_wallet_and_funding_update}; use bdk_wallet::Update; +use bitcoin::block::Header; use bitcoin::hashes::Hash; -use bitcoin::{Address, Amount, BlockHash, FeeRate}; +use bitcoin::{Address, Amount, Block, BlockHash, FeeRate, Transaction, TxMerkleNode}; use core::str::FromStr; use std::sync::Arc; +/// apply_update_events tests. #[test] fn test_new_confirmed_tx_event() { let (desc, change_desc) = get_test_wpkh_and_change_desc(); @@ -27,9 +29,8 @@ fn test_new_confirmed_tx_event() { ); assert!(matches!(&events[1], WalletEvent::TxConfirmed {tx, ..} if tx.output.len() == 1)); assert!( - matches!(events[2], WalletEvent::TxConfirmed {block_time, ..} if block_time.block_id.height == 2000) + matches!(&events[2], WalletEvent::TxConfirmed {tx, block_time, ..} if block_time.block_id.height == 2000 && tx.output.len() == 2) ); - assert!(matches!(&events[2], WalletEvent::TxConfirmed {tx, ..} if tx.output.len() == 2)); } #[test] @@ -86,7 +87,6 @@ fn test_tx_replaced_event() { update.tx_update.seen_ats = [(orig_txid, 210)].into(); let events = wallet.apply_update_events(update).unwrap(); assert_eq!(events.len(), 1); - assert!(matches!(events[0], WalletEvent::TxUnconfirmed { .. })); assert!( matches!(&events[0], WalletEvent::TxUnconfirmed {tx, ..} if tx.compute_txid() == orig_txid) ); @@ -107,9 +107,8 @@ fn test_tx_replaced_event() { let events = wallet.apply_update_events(update).unwrap(); assert_eq!(events.len(), 2); assert!(matches!(events[0], WalletEvent::TxUnconfirmed { txid, .. } if txid == rbf_txid)); - assert!(matches!(events[1], WalletEvent::TxReplaced { txid, ..} if txid == orig_txid)); assert!( - matches!(&events[1], WalletEvent::TxReplaced {conflicts, ..} if conflicts.len() == 1 && + matches!(&events[1], WalletEvent::TxReplaced {txid, conflicts, ..} if *txid == orig_txid && conflicts.len() == 1 && conflicts.contains(&(0, rbf_txid))) ); } @@ -139,7 +138,6 @@ fn test_tx_confirmed_event() { update.tx_update.seen_ats = [(new_txid, 210)].into(); let events = wallet.apply_update_events(update).unwrap(); assert_eq!(events.len(), 1); - assert!(matches!(events[0], WalletEvent::TxUnconfirmed { .. })); assert!( matches!(&events[0], WalletEvent::TxUnconfirmed {tx, ..} if tx.compute_txid() == new_txid) ); @@ -196,7 +194,6 @@ fn test_tx_confirmed_new_block_event() { update.tx_update.seen_ats = [(new_txid, 210)].into(); let events = wallet.apply_update_events(update).unwrap(); assert_eq!(events.len(), 1); - assert!(matches!(events[0], WalletEvent::TxUnconfirmed { .. })); assert!( matches!(&events[0], WalletEvent::TxUnconfirmed {tx, ..} if tx.compute_txid() == new_txid) ); @@ -280,7 +277,6 @@ fn test_tx_dropped_event() { update.tx_update.seen_ats = [(new_txid, 210)].into(); let events = wallet.apply_update_events(update).unwrap(); assert_eq!(events.len(), 1); - assert!(matches!(events[0], WalletEvent::TxUnconfirmed { .. })); assert!( matches!(&events[0], WalletEvent::TxUnconfirmed {tx, ..} if tx.compute_txid() == new_txid) ); @@ -293,3 +289,178 @@ fn test_tx_dropped_event() { assert_eq!(events.len(), 1); assert!(matches!(events[0], WalletEvent::TxDropped { txid, .. } if txid == new_txid)); } + +// apply_block_events tests. + +fn test_block(prev_blockhash: BlockHash, time: u32, txdata: Vec) -> Block { + Block { + header: Header { + version: Default::default(), + prev_blockhash, + merkle_root: TxMerkleNode::all_zeros(), + time, + bits: Default::default(), + nonce: time, + }, + txdata, + } +} + +#[test] +fn test_apply_block_new_confirmed_tx_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 genesis = BlockId { + height: 0, + hash: wallet.local_chain().genesis_hash(), + }; + // apply empty block + let block1 = test_block(genesis.hash, 1000, vec![]); + let events = wallet.apply_block_events(&block1, 1).unwrap(); + assert_eq!(events.len(), 1); + + // apply funding block + let block2 = test_block( + block1.block_hash(), + 2000, + update.tx_update.txs[..1] + .iter() + .map(|tx| (**tx).clone()) + .collect(), + ); + let events = wallet.apply_block_events(&block2, 2).unwrap(); + assert_eq!(events.len(), 2); + let new_tip2 = wallet.local_chain().tip().block_id(); + assert!( + matches!(events[0], WalletEvent::ChainTipChanged { old_tip, new_tip } if old_tip == (1, block1.block_hash()).into() && new_tip == new_tip2) + ); + assert!( + matches!(&events[1], WalletEvent::TxConfirmed { tx, block_time, ..} if block_time.block_id.height == 2 && tx.output.len() == 1) + ); + + // apply empty block + let block3 = test_block(block2.block_hash(), 3000, vec![]); + let events = wallet.apply_block_events(&block3, 3).unwrap(); + assert_eq!(events.len(), 1); + + // apply spending block + let block4 = test_block( + block3.block_hash(), + 4000, + update.tx_update.txs[1..] + .iter() + .map(|tx| (**tx).clone()) + .collect(), + ); + let events = wallet.apply_block_events(&block4, 4).unwrap(); + let new_tip3 = wallet.local_chain().tip().block_id(); + assert_eq!(events.len(), 2); + assert!( + matches!(events[0], WalletEvent::ChainTipChanged { old_tip, new_tip } if old_tip == (3, block3.block_hash()).into() && new_tip == new_tip3) + ); + assert!( + matches!(&events[1], WalletEvent::TxConfirmed {tx, block_time, ..} if block_time.block_id.height == 4 && tx.output.len() == 2) + ); +} + +#[test] +fn test_apply_block_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)); + // apply funding block + let genesis = BlockId { + height: 0, + hash: wallet.local_chain().genesis_hash(), + }; + let block1 = test_block( + genesis.hash, + 1000, + update.tx_update.txs[..1] + .iter() + .map(|tx| (**tx).clone()) + .collect(), + ); + let events = wallet.apply_block_events(&block1, 1).unwrap(); + assert_eq!(events.len(), 2); + + // apply spending block + let block2 = test_block( + block1.block_hash(), + 2000, + update.tx_update.txs[1..] + .iter() + .map(|tx| (**tx).clone()) + .collect(), + ); + let events = wallet.apply_block_events(&block2, 2).unwrap(); + assert_eq!(events.len(), 2); + let new_tip2 = wallet.local_chain().tip().block_id(); + assert!( + matches!(events[0], WalletEvent::ChainTipChanged { old_tip, new_tip } if old_tip == (1, block1.block_hash()).into() && new_tip == new_tip2) + ); + assert!( + matches!(&events[1], WalletEvent::TxConfirmed {block_time, tx, ..} if block_time.block_id.height == 2 && tx.output.len() == 2) + ); + + // apply reorg of spending block without previously confirmed tx + let reorg_block2 = test_block(block1.block_hash(), 2100, vec![]); + let events = wallet.apply_block_events(&reorg_block2, 2).unwrap(); + assert_eq!(events.len(), 2); + assert!( + matches!(events[0], WalletEvent::ChainTipChanged { old_tip, new_tip } if old_tip == +(2, block2.block_hash()).into() && new_tip == (2, reorg_block2.block_hash()).into()) + ); + assert!( + matches!(&events[1], WalletEvent::TxUnconfirmed {tx, old_block_time, ..} if +tx.output.len() == 2 && old_block_time.is_some()) + ); +} + +#[test] +fn test_apply_block_tx_confirmed_new_block_event() { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let (mut wallet, _, update) = new_wallet_and_funding_update(desc, Some(change_desc)); + // apply funding block + let genesis = BlockId { + height: 0, + hash: wallet.local_chain().genesis_hash(), + }; + let block1 = test_block( + genesis.hash, + 1000, + update.tx_update.txs[..1] + .iter() + .map(|tx| (**tx).clone()) + .collect(), + ); + let events = wallet.apply_block_events(&block1, 1).unwrap(); + assert_eq!(events.len(), 2); + + // apply spending block + let spending_tx: Transaction = (*update.tx_update.txs[1].clone()).clone(); + let block2 = test_block(block1.block_hash(), 2000, vec![spending_tx.clone()]); + let events = wallet.apply_block_events(&block2, 2).unwrap(); + assert_eq!(events.len(), 2); + let new_tip2 = wallet.local_chain().tip().block_id(); + assert!( + matches!(events[0], WalletEvent::ChainTipChanged { old_tip, new_tip } if old_tip == (1, block1.block_hash()).into() && new_tip == new_tip2) + ); + assert!( + matches!(events[1], WalletEvent::TxConfirmed { txid, block_time, old_block_time, .. } if + txid == spending_tx.compute_txid() && block_time.block_id == (2, block2.block_hash()).into() && old_block_time.is_none()) + ); + + // apply reorg of spending block including the original spending tx + let reorg_block2 = test_block(block1.block_hash(), 2100, vec![spending_tx.clone()]); + let events = wallet.apply_block_events(&reorg_block2, 2).unwrap(); + assert_eq!(events.len(), 2); + assert!( + matches!(events[0], WalletEvent::ChainTipChanged { old_tip, new_tip } if old_tip == +(2, block2.block_hash()).into() && new_tip == (2, reorg_block2.block_hash()).into()) + ); + assert!( + matches!(events[1], WalletEvent::TxConfirmed { txid, block_time, old_block_time, .. } if +txid == spending_tx.compute_txid() && block_time.block_id == (2, reorg_block2.block_hash()).into() && old_block_time.is_some()) + ); +} From 66cedb47425a255100b0102fb33c471928c71459 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Fri, 5 Dec 2025 18:03:01 -0600 Subject: [PATCH 09/10] refactor: change Wallet apply updates or blocks to return events BREAKING CHANGE: 1. changed functions to return WalletEvents: - Wallet::apply_update - Wallet::apply_block - Wallet::apply_block_connected_to 2. removed functions: - Wallet::apply_update_events - Wallet::apply_block_events - Wallet::apply_block_connected_to_events --- src/wallet/event.rs | 6 +- src/wallet/mod.rs | 155 ++++++++++++------------------------------ tests/wallet_event.rs | 52 +++++++------- 3 files changed, 71 insertions(+), 142 deletions(-) diff --git a/src/wallet/event.rs b/src/wallet/event.rs index 22e9adbd..67cbb5bc 100644 --- a/src/wallet/event.rs +++ b/src/wallet/event.rs @@ -59,7 +59,7 @@ pub enum WalletEvent { /// This can happen after an RBF is broadcast or if a third party double spends an input of /// a received payment transaction before it is confirmed. /// - /// The conflicts field contains the txid and vin (in which it conflicts) of the conflicting + /// The 'conflicts' field contains the txid and vin (in which it conflicts) of the conflicting /// transactions. TxReplaced { /// Transaction id. @@ -83,7 +83,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. +/// `wallet::Update` or a `bitcoin::Block` to `Wallet`. Any changes are added to the list of +/// returned `WalletEvent`s. pub(crate) fn wallet_events( wallet: &mut Wallet, chain_tip1: BlockId, @@ -92,7 +93,6 @@ pub(crate) fn wallet_events( wallet_txs2: BTreeMap, ChainPosition)>, ) -> Vec { let mut events: Vec = Vec::new(); - // find chain tip change if chain_tip1 != chain_tip2 { events.push(WalletEvent::ChainTipChanged { diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index d98b87a4..91505619 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2341,37 +2341,13 @@ impl Wallet { .to_string() } - /// Applies an update to the wallet and stages the changes (but does not persist them). - /// - /// Usually you create an `update` by interacting with some blockchain data source and inserting - /// transactions related to your wallet into it. - /// - /// After applying updates you should persist the staged wallet changes. For an example of how - /// to persist staged wallet changes see [`Wallet::reveal_next_address`]. - pub fn apply_update(&mut self, update: impl Into) -> Result<(), CannotConnectError> { - let update = update.into(); - let mut changeset = match update.chain { - Some(chain_update) => ChangeSet::from(self.chain.apply_update(chain_update)?), - None => ChangeSet::default(), - }; - - let index_changeset = self - .indexed_graph - .index - .reveal_to_target_multi(&update.last_active_indices); - changeset.merge(index_changeset.into()); - changeset.merge(self.indexed_graph.apply_update(update.tx_update).into()); - self.stage.merge(changeset); - Ok(()) - } - /// Applies an update to the wallet, stages the changes, and returns events. /// /// Usually you create an `update` by interacting with some blockchain data source and inserting /// transactions related to your wallet into it. Staged changes are NOT persisted. /// - /// After applying updates you should process the events in your app before persisting the - /// staged wallet changes. For an example of how to persist staged wallet changes see + /// After applying updates, you should process the events in your app before persisting the + /// staged wallet changes. For an example of how to persist staged wallet changes, see /// [`Wallet::reveal_next_address`]. /// /// ```rust,no_run @@ -2380,7 +2356,7 @@ impl Wallet { /// use bdk_wallet::event::WalletEvent; /// # let wallet_update = Update::default(); /// # let mut wallet = doctest_wallet!(); - /// let events = wallet.apply_update_events(wallet_update)?; + /// let events = wallet.apply_update(wallet_update)?; /// // Handle wallet relevant events from this update. /// events.iter().for_each(|event| { /// match event { @@ -2445,7 +2421,7 @@ impl Wallet { /// # Ok::<(), anyhow::Error>(()) /// ``` /// [`TxBuilder`]: crate::TxBuilder - pub fn apply_update_events( + pub fn apply_update( &mut self, update: impl Into, ) -> Result, CannotConnectError> { @@ -2462,7 +2438,19 @@ impl Wallet { .collect::, ChainPosition)>>(); // apply update - self.apply_update(update)?; + let update = update.into(); + let mut changeset = match update.chain { + Some(chain_update) => ChangeSet::from(self.chain.apply_update(chain_update)?), + None => ChangeSet::default(), + }; + + let index_changeset = self + .indexed_graph + .index + .reveal_to_target_multi(&update.last_active_indices); + changeset.merge(index_changeset.into()); + changeset.merge(self.indexed_graph.apply_update(update.tx_update).into()); + self.stage.merge(changeset); // chain tip and transactions after update let chain_tip2 = self.chain.tip().block_id(); @@ -2523,14 +2511,22 @@ impl Wallet { &self.chain } - /// Introduces a `block` of `height` to the wallet, and tries to connect it to the + /// Introduces a `block` of `height` to the wallet and tries to connect it to the /// `prev_blockhash` of the block's header. /// - /// This is a convenience method that is equivalent to calling [`apply_block_connected_to`] - /// with `prev_blockhash` and `height-1` as the `connected_to` parameter. + /// This is a convenience method that is equivalent to calling + /// [`apply_block_connected_to`] with `prev_blockhash` and `height-1` as the + /// `connected_to` parameter. + /// + /// See [`apply_update`] for more information on the returned [`WalletEvent`]s. /// /// [`apply_block_connected_to`]: Self::apply_block_connected_to - pub fn apply_block(&mut self, block: &Block, height: u32) -> Result<(), CannotConnectError> { + /// [`apply_update`]: Self::apply_update + pub fn apply_block( + &mut self, + block: &Block, + height: u32, + ) -> Result, CannotConnectError> { let connected_to = match height.checked_sub(1) { Some(prev_height) => BlockId { height: prev_height, @@ -2550,22 +2546,24 @@ impl Wallet { }) } - /// Introduces a `block` of `height` to the wallet, and tries to connect it to the - /// `prev_blockhash` of the block's header. + /// Applies relevant transactions from `block` of `height` to the wallet and connects the + /// block to the internal chain. /// - /// This is a convenience method that is equivalent to calling - /// [`apply_block_connected_to_events`] with `prev_blockhash` and `height-1` as the - /// `connected_to` parameter. + /// The `connected_to` parameter informs the wallet how this block connects to the internal + /// [`LocalChain`]. Relevant transactions are filtered from the `block` and inserted into the + /// internal [`TxGraph`]. /// - /// See [`apply_update_events`] for more information on the returned [`WalletEvent`]s. + /// **WARNING**: You must persist the changes resulting from one or more calls to this method + /// if you need the inserted block data to be reloaded after closing the wallet. + /// See [`Wallet::reveal_next_address`]. /// - /// [`apply_block_connected_to_events`]: Self::apply_block_connected_to_events - /// [`apply_update_events`]: Self::apply_update_events - pub fn apply_block_events( + /// See [`apply_update_events`] for more information on the returned [`WalletEvent`]s. + pub fn apply_block_connected_to( &mut self, block: &Block, height: u32, - ) -> Result, CannotConnectError> { + 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 @@ -2578,45 +2576,7 @@ impl Wallet { }) .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, - )) - } - - /// Applies relevant transactions from `block` of `height` to the wallet, and connects the - /// block to the internal chain. - /// - /// The `connected_to` parameter informs the wallet how this block connects to the internal - /// [`LocalChain`]. Relevant transactions are filtered from the `block` and inserted into the - /// internal [`TxGraph`]. - /// - /// **WARNING**: You must persist the changes resulting from one or more calls to this method - /// if you need the inserted block data to be reloaded after closing the wallet. - /// See [`Wallet::reveal_next_address`]. - pub fn apply_block_connected_to( - &mut self, - block: &Block, - height: u32, - connected_to: BlockId, - ) -> Result<(), ApplyHeaderError> { + // apply block to wallet let mut changeset = ChangeSet::default(); changeset.merge( self.chain @@ -2629,37 +2589,6 @@ impl Wallet { .into(), ); self.stage.merge(changeset); - Ok(()) - } - - /// Applies relevant transactions from `block` of `height` to the wallet, and connects the - /// block to the internal chain. - /// - /// See [`apply_block_connected_to`] for more information. - /// - /// See [`apply_update_events`] for more information on the returned [`WalletEvent`]s. - /// - /// [`apply_block_connected_to`]: Self::apply_block_connected_to - /// [`apply_update_events`]: Self::apply_update_events - pub fn apply_block_connected_to_events( - &mut self, - block: &Block, - 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(); diff --git a/tests/wallet_event.rs b/tests/wallet_event.rs index 2cc7cc50..75d75cb0 100644 --- a/tests/wallet_event.rs +++ b/tests/wallet_event.rs @@ -18,7 +18,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(update).unwrap(); let new_tip1 = wallet.local_chain().tip().block_id(); assert_eq!(events.len(), 3); assert!( @@ -38,7 +38,7 @@ fn test_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)); // ignore funding events - let _events = wallet.apply_update_events(update).unwrap(); + let _events = wallet.apply_update(update).unwrap(); let reorg_block = BlockId { height: 2_000, @@ -51,7 +51,7 @@ fn test_tx_unconfirmed_event() { ..Default::default() }; let old_tip1 = wallet.local_chain().tip().block_id(); - let events = wallet.apply_update_events(reorg_update).unwrap(); + let events = wallet.apply_update(reorg_update).unwrap(); let new_tip1 = wallet.local_chain().tip().block_id(); assert_eq!(events.len(), 2); assert!( @@ -67,7 +67,7 @@ fn test_tx_replaced_event() { let (desc, change_desc) = get_test_wpkh_and_change_desc(); let (mut wallet, _, update) = new_wallet_and_funding_update(desc, Some(change_desc)); // ignore funding events - let _events = wallet.apply_update_events(update).unwrap(); + let _events = wallet.apply_update(update).unwrap(); // create original tx let mut builder = wallet.build_tx(); @@ -85,7 +85,7 @@ fn test_tx_replaced_event() { let mut update = Update::default(); update.tx_update.txs = vec![orig_tx.clone()]; update.tx_update.seen_ats = [(orig_txid, 210)].into(); - let events = wallet.apply_update_events(update).unwrap(); + let events = wallet.apply_update(update).unwrap(); assert_eq!(events.len(), 1); assert!( matches!(&events[0], WalletEvent::TxUnconfirmed {tx, ..} if tx.compute_txid() == orig_txid) @@ -104,7 +104,7 @@ fn test_tx_replaced_event() { update.tx_update.evicted_ats = [(orig_txid, 220)].into(); update.tx_update.seen_ats = [(rbf_txid, 220)].into(); - let events = wallet.apply_update_events(update).unwrap(); + let events = wallet.apply_update(update).unwrap(); assert_eq!(events.len(), 2); assert!(matches!(events[0], WalletEvent::TxUnconfirmed { txid, .. } if txid == rbf_txid)); assert!( @@ -118,7 +118,7 @@ fn test_tx_confirmed_event() { let (desc, change_desc) = get_test_wpkh_and_change_desc(); let (mut wallet, _, update) = new_wallet_and_funding_update(desc, Some(change_desc)); // ignore funding events - let _events = wallet.apply_update_events(update).unwrap(); + let _events = wallet.apply_update(update).unwrap(); // create new tx let mut builder = wallet.build_tx(); @@ -136,7 +136,7 @@ fn test_tx_confirmed_event() { let mut update = Update::default(); update.tx_update.txs = vec![new_tx.clone()]; update.tx_update.seen_ats = [(new_txid, 210)].into(); - let events = wallet.apply_update_events(update).unwrap(); + let events = wallet.apply_update(update).unwrap(); assert_eq!(events.len(), 1); assert!( matches!(&events[0], WalletEvent::TxUnconfirmed {tx, ..} if tx.compute_txid() == new_txid) @@ -161,7 +161,7 @@ fn test_tx_confirmed_event() { update.tx_update.anchors = [(new_anchor, new_txid)].into(); let orig_tip = wallet.local_chain().tip().block_id(); - let events = wallet.apply_update_events(update).unwrap(); + let events = wallet.apply_update(update).unwrap(); assert_eq!(events.len(), 2); assert!( matches!(events[0], WalletEvent::ChainTipChanged { old_tip, new_tip } if old_tip == orig_tip && new_tip == new_block) @@ -174,7 +174,7 @@ fn test_tx_confirmed_new_block_event() { let (desc, change_desc) = get_test_wpkh_and_change_desc(); let (mut wallet, _, update) = new_wallet_and_funding_update(desc, Some(change_desc)); // ignore funding events - let _events = wallet.apply_update_events(update).unwrap(); + let _events = wallet.apply_update(update).unwrap(); // create new tx let mut builder = wallet.build_tx(); @@ -192,7 +192,7 @@ fn test_tx_confirmed_new_block_event() { let mut update = Update::default(); update.tx_update.txs = vec![new_tx.clone()]; update.tx_update.seen_ats = [(new_txid, 210)].into(); - let events = wallet.apply_update_events(update).unwrap(); + let events = wallet.apply_update(update).unwrap(); assert_eq!(events.len(), 1); assert!( matches!(&events[0], WalletEvent::TxUnconfirmed {tx, ..} if tx.compute_txid() == new_txid) @@ -217,7 +217,7 @@ fn test_tx_confirmed_new_block_event() { update.tx_update.anchors = [(new_anchor, new_txid)].into(); let orig_tip = wallet.local_chain().tip().block_id(); - let events = wallet.apply_update_events(update).unwrap(); + let events = wallet.apply_update(update).unwrap(); assert_eq!(events.len(), 2); assert!( matches!(events[0], WalletEvent::ChainTipChanged { old_tip, new_tip } if old_tip == orig_tip && new_tip == new_block) @@ -242,7 +242,7 @@ fn test_tx_confirmed_new_block_event() { update.chain = CheckPoint::from_block_ids([parent_block, reorg_block]).ok(); update.tx_update.anchors = [(reorg_anchor, new_txid)].into(); - let events = wallet.apply_update_events(update).unwrap(); + let events = wallet.apply_update(update).unwrap(); assert_eq!(events.len(), 2); assert!( matches!(events[0], WalletEvent::ChainTipChanged { old_tip, new_tip } if old_tip == new_block && new_tip == reorg_block) @@ -257,7 +257,7 @@ fn test_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)); // ignore funding events - let _events = wallet.apply_update_events(update).unwrap(); + let _events = wallet.apply_update(update).unwrap(); // create new tx let mut builder = wallet.build_tx(); @@ -275,7 +275,7 @@ fn test_tx_dropped_event() { let mut update = Update::default(); update.tx_update.txs = vec![new_tx.clone()]; update.tx_update.seen_ats = [(new_txid, 210)].into(); - let events = wallet.apply_update_events(update).unwrap(); + let events = wallet.apply_update(update).unwrap(); assert_eq!(events.len(), 1); assert!( matches!(&events[0], WalletEvent::TxUnconfirmed {tx, ..} if tx.compute_txid() == new_txid) @@ -284,7 +284,7 @@ fn test_tx_dropped_event() { // drop tx let mut update = Update::default(); update.tx_update.evicted_ats = [(new_txid, 220)].into(); - let events = wallet.apply_update_events(update).unwrap(); + let events = wallet.apply_update(update).unwrap(); assert_eq!(events.len(), 1); assert!(matches!(events[0], WalletEvent::TxDropped { txid, .. } if txid == new_txid)); @@ -317,7 +317,7 @@ fn test_apply_block_new_confirmed_tx_event() { }; // apply empty block let block1 = test_block(genesis.hash, 1000, vec![]); - let events = wallet.apply_block_events(&block1, 1).unwrap(); + let events = wallet.apply_block(&block1, 1).unwrap(); assert_eq!(events.len(), 1); // apply funding block @@ -329,7 +329,7 @@ fn test_apply_block_new_confirmed_tx_event() { .map(|tx| (**tx).clone()) .collect(), ); - let events = wallet.apply_block_events(&block2, 2).unwrap(); + let events = wallet.apply_block(&block2, 2).unwrap(); assert_eq!(events.len(), 2); let new_tip2 = wallet.local_chain().tip().block_id(); assert!( @@ -341,7 +341,7 @@ fn test_apply_block_new_confirmed_tx_event() { // apply empty block let block3 = test_block(block2.block_hash(), 3000, vec![]); - let events = wallet.apply_block_events(&block3, 3).unwrap(); + let events = wallet.apply_block(&block3, 3).unwrap(); assert_eq!(events.len(), 1); // apply spending block @@ -353,7 +353,7 @@ fn test_apply_block_new_confirmed_tx_event() { .map(|tx| (**tx).clone()) .collect(), ); - let events = wallet.apply_block_events(&block4, 4).unwrap(); + let events = wallet.apply_block(&block4, 4).unwrap(); let new_tip3 = wallet.local_chain().tip().block_id(); assert_eq!(events.len(), 2); assert!( @@ -381,7 +381,7 @@ fn test_apply_block_tx_unconfirmed_event() { .map(|tx| (**tx).clone()) .collect(), ); - let events = wallet.apply_block_events(&block1, 1).unwrap(); + let events = wallet.apply_block(&block1, 1).unwrap(); assert_eq!(events.len(), 2); // apply spending block @@ -393,7 +393,7 @@ fn test_apply_block_tx_unconfirmed_event() { .map(|tx| (**tx).clone()) .collect(), ); - let events = wallet.apply_block_events(&block2, 2).unwrap(); + let events = wallet.apply_block(&block2, 2).unwrap(); assert_eq!(events.len(), 2); let new_tip2 = wallet.local_chain().tip().block_id(); assert!( @@ -405,7 +405,7 @@ fn test_apply_block_tx_unconfirmed_event() { // apply reorg of spending block without previously confirmed tx let reorg_block2 = test_block(block1.block_hash(), 2100, vec![]); - let events = wallet.apply_block_events(&reorg_block2, 2).unwrap(); + let events = wallet.apply_block(&reorg_block2, 2).unwrap(); assert_eq!(events.len(), 2); assert!( matches!(events[0], WalletEvent::ChainTipChanged { old_tip, new_tip } if old_tip == @@ -434,13 +434,13 @@ fn test_apply_block_tx_confirmed_new_block_event() { .map(|tx| (**tx).clone()) .collect(), ); - let events = wallet.apply_block_events(&block1, 1).unwrap(); + let events = wallet.apply_block(&block1, 1).unwrap(); assert_eq!(events.len(), 2); // apply spending block let spending_tx: Transaction = (*update.tx_update.txs[1].clone()).clone(); let block2 = test_block(block1.block_hash(), 2000, vec![spending_tx.clone()]); - let events = wallet.apply_block_events(&block2, 2).unwrap(); + let events = wallet.apply_block(&block2, 2).unwrap(); assert_eq!(events.len(), 2); let new_tip2 = wallet.local_chain().tip().block_id(); assert!( @@ -453,7 +453,7 @@ fn test_apply_block_tx_confirmed_new_block_event() { // apply reorg of spending block including the original spending tx let reorg_block2 = test_block(block1.block_hash(), 2100, vec![spending_tx.clone()]); - let events = wallet.apply_block_events(&reorg_block2, 2).unwrap(); + let events = wallet.apply_block(&reorg_block2, 2).unwrap(); assert_eq!(events.len(), 2); assert!( matches!(events[0], WalletEvent::ChainTipChanged { old_tip, new_tip } if old_tip == From 45b3cd0840e6788ce86dec9bdc836b52ef5dc1cf Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Mon, 8 Dec 2025 20:49:04 -0600 Subject: [PATCH 10/10] refactor(wallet): improve wallet event docs and extract helper method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enhanced WalletEvent documentation with clearer explanations - Expanded apply_update() docs with examples and persistence warnings - Extracted collect_wallet_txs() to reduce code duplication - Fixed minor typos and improved code clarity 🤖 LLM-assisted changes --- src/wallet/event.rs | 84 +++++++++++----------- src/wallet/mod.rs | 167 ++++++++++++++++++++++++-------------------- 2 files changed, 136 insertions(+), 115 deletions(-) diff --git a/src/wallet/event.rs b/src/wallet/event.rs index 67cbb5bc..ba50710c 100644 --- a/src/wallet/event.rs +++ b/src/wallet/event.rs @@ -8,76 +8,82 @@ use alloc::vec::Vec; use bitcoin::{Transaction, Txid}; use chain::{BlockId, ChainPosition, ConfirmationBlockTime}; -/// Events representing changes to wallet transactions. +/// Events representing changes to the wallet state. /// -/// Returned after calling -/// [`Wallet::apply_update_events`](crate::wallet::Wallet::apply_update_events). +/// Returned by [`Wallet::apply_update`], [`Wallet::apply_block`], and +/// [`Wallet::apply_block_connected_to`] to track transaction status changes and chain +/// tip changes due to new blocks or reorganizations. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum WalletEvent { - /// The latest chain tip known to the wallet changed. + /// The blockchain tip known to the wallet has changed. + /// + /// Emitted when the blockchain is extended or a chain reorganization occurs. ChainTipChanged { - /// Previous chain tip. + /// The previous blockchain tip. old_tip: BlockId, - /// New chain tip. + /// The new blockchain tip. new_tip: BlockId, }, - /// A transaction is now confirmed. - /// - /// If the transaction was previously unconfirmed `old_block_time` will be `None`. + + /// A transaction has been confirmed in a block. /// - /// If a confirmed transaction is now re-confirmed in a new block `old_block_time` will contain - /// the block id and the time it was previously confirmed. This can happen after a chain - /// reorg. + /// Emitted when a transaction is first confirmed or re-confirmed in a different block after + /// a chain reorganization. When `old_block_time` is `Some`, the transaction was previously + /// confirmed in a different block. TxConfirmed { - /// Transaction id. + /// The transaction id. txid: Txid, - /// Transaction. + /// The full transaction. tx: Arc, - /// Confirmation block time. + /// The block and timestamp where the transaction is confirmed. block_time: ConfirmationBlockTime, - /// Old confirmation block and time if previously confirmed in a different block. + /// Previous confirmation details if re-confirmed after a reorg, `None` for first + /// confirmation. old_block_time: Option, }, - /// A transaction is now unconfirmed. - /// - /// If the transaction is first seen in the mempool `old_block_time` will be `None`. + + /// A transaction is now unconfirmed (in the mempool). /// - /// If a previously confirmed transaction is now seen in the mempool `old_block_time` will - /// contain the block id and the time it was previously confirmed. This can happen after a - /// chain reorg. + /// Emitted when a transaction first appears in the mempool or when a confirmed transaction + /// becomes unconfirmed due to a chain reorganization. When `old_block_time` is `Some`, the + /// transaction was previously confirmed but is now unconfirmed due to a reorg. TxUnconfirmed { - /// Transaction id. + /// The transaction id. txid: Txid, - /// Transaction. + /// The full transaction. tx: Arc, - /// Old confirmation block and time, if previously confirmed. + /// Previous confirmation details if unconfirmed due to reorg, `None` if first seen. old_block_time: Option, }, - /// An unconfirmed transaction was replaced. + + /// One or more unconfirmed transactions were replaced. /// - /// This can happen after an RBF is broadcast or if a third party double spends an input of - /// a received payment transaction before it is confirmed. + /// Occurs when a transaction's inputs are spent by the replacement transaction, typically due + /// to Replace-By-Fee (RBF) or a double-spend attempt. /// - /// The 'conflicts' field contains the txid and vin (in which it conflicts) of the conflicting - /// transactions. + /// The `conflicts` field contains `(input_index, conflicting_txid)` pairs indicating which + /// inputs conflict. Only conflicting transactions known to the wallet are reported. + /// Conflicting transactions are usually added during a sync because they spend a UTXO tracked + /// by the wallet. TxReplaced { - /// Transaction id. + /// The replacement transaction id. txid: Txid, - /// Transaction. + /// The full replacement transaction. tx: Arc, - /// Conflicting transaction ids. + /// List of `(input_index, conflicting_txid)` pairs showing which inputs were double-spent. conflicts: Vec<(usize, Txid)>, }, - /// Unconfirmed transaction dropped. + + /// An unconfirmed transaction was dropped from the mempool. /// - /// The transaction was dropped from the local mempool. This is generally due to the fee rate - /// being too low. The transaction can still reappear in the mempool in the future resulting in - /// a [`WalletEvent::TxUnconfirmed`] event. + /// This typically occurs when a transaction's fee rate is too low and/or the mempool is full. + /// The transaction may reappear later if conditions change, which will emit a + /// [`WalletEvent::TxUnconfirmed`] event. TxDropped { - /// Transaction id. + /// The dropped transaction id. txid: Txid, - /// Transaction. + /// The full dropped transaction. tx: Arc, }, } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 91505619..0cfa8bd1 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2341,14 +2341,22 @@ impl Wallet { .to_string() } - /// Applies an update to the wallet, stages the changes, and returns events. + /// Applies an update to the wallet, stages changes, and returns events describing what changed. /// - /// Usually you create an `update` by interacting with some blockchain data source and inserting - /// transactions related to your wallet into it. Staged changes are NOT persisted. + /// This is the primary method for updating wallet state with new blockchain data. It accepts + /// an [`Update`] /// - /// After applying updates, you should process the events in your app before persisting the - /// staged wallet changes. For an example of how to persist staged wallet changes, see - /// [`Wallet::reveal_next_address`]. + /// **IMPORTANT**: Changes are staged but **NOT automatically persisted**. You must: + /// + /// 1. Process the returned events in your application + /// 2. Call [`take_staged`] to retrieve the [`ChangeSet`] + /// 3. Persist the changeset to your database/storage + /// + /// Failing to persist changes means they will be lost when the wallet is reloaded. + /// + /// # Example + /// + /// ## Basic usage with event handling /// /// ```rust,no_run /// # use bitcoin::*; @@ -2356,86 +2364,98 @@ impl Wallet { /// use bdk_wallet::event::WalletEvent; /// # let wallet_update = Update::default(); /// # let mut wallet = doctest_wallet!(); + /// + /// // Apply the update and get events /// let events = wallet.apply_update(wallet_update)?; - /// // Handle wallet relevant events from this update. - /// events.iter().for_each(|event| { + /// + /// // Handle each event + /// for event in events { /// match event { - /// // The chain tip changed. /// WalletEvent::ChainTipChanged { old_tip, new_tip } => { - /// todo!() // handle event + /// println!( + /// "Chain advanced from {} to {}", + /// old_tip.height, new_tip.height + /// ); /// } - /// // An unconfirmed tx is now confirmed in a block. /// WalletEvent::TxConfirmed { /// txid, - /// tx, /// block_time, /// old_block_time: None, + /// .. /// } => { - /// todo!() // handle event + /// println!( + /// "Transaction {} confirmed at height {}", + /// txid, block_time.block_id.height + /// ); /// } - /// // A confirmed tx is now confirmed in a new block (reorg). /// WalletEvent::TxConfirmed { /// txid, - /// tx, /// block_time, - /// old_block_time: Some(old_block_time), + /// old_block_time: Some(old), + /// .. /// } => { - /// todo!() // handle event + /// println!( + /// "Transaction {} re-confirmed due to reorg: {} -> {}", + /// txid, old.block_id.height, block_time.block_id.height + /// ); /// } - /// // A new unconfirmed tx was seen in the mempool. /// WalletEvent::TxUnconfirmed { /// txid, - /// tx, /// old_block_time: None, + /// .. /// } => { - /// todo!() // handle event + /// println!("New mempool transaction: {}", txid); /// } - /// // A previously confirmed tx in now unconfirmed in the mempool (reorg). /// WalletEvent::TxUnconfirmed { /// txid, - /// tx, - /// old_block_time: Some(old_block_time), + /// old_block_time: Some(old), + /// .. /// } => { - /// todo!() // handle event + /// println!( + /// "Transaction {} unconfirmed due to reorg from height {}", + /// txid, old.block_id.height + /// ); /// } - /// // An unconfirmed tx was replaced in the mempool (RBF or double spent input). /// WalletEvent::TxReplaced { - /// txid, - /// tx, - /// conflicts, + /// txid, conflicts, .. /// } => { - /// todo!() // handle event + /// println!("Transaction {} replaced: {:?}", txid, conflicts); /// } - /// // An unconfirmed tx was dropped from the mempool (fee too low). - /// WalletEvent::TxDropped { txid, tx } => { - /// todo!() // handle event + /// WalletEvent::TxDropped { txid, .. } => { + /// println!("Transaction {} dropped from mempool", txid); /// } /// _ => { - /// // unexpected event, do nothing + /// debug_assert!(false, "unexpected event") /// } /// } - /// // take staged wallet changes - /// let staged = wallet.take_staged(); - /// // persist staged changes - /// }); + /// } + /// + /// // IMPORTANT: Persist the changes + /// if let Some(changeset) = wallet.take_staged() { + /// // Save changeset to your database + /// // e.g., db.persist(&changeset)?; + /// } /// # Ok::<(), anyhow::Error>(()) /// ``` - /// [`TxBuilder`]: crate::TxBuilder + /// + /// # See Also + /// + /// - [`apply_block`] - Apply a single block to the wallet + /// - [`apply_block_connected_to`] - Apply a block with explicit connection point + /// - [`take_staged`] - Retrieve staged changes for persistence + /// - [`WalletEvent`] - Documentation for all event types + /// - [`Update`] - The update structure accepted by this method + /// + /// [`apply_block`]: Wallet::apply_block + /// [`apply_block_connected_to`]: Wallet::apply_block_connected_to + /// [`take_staged`]: Wallet::take_staged pub fn apply_update( &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)>>(); + let wallet_txs1 = self.collect_wallet_txs(); // apply update let update = update.into(); @@ -2454,15 +2474,7 @@ impl Wallet { // 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)>>(); + let wallet_txs2 = self.collect_wallet_txs(); Ok(wallet_events( self, @@ -2557,7 +2569,9 @@ impl Wallet { /// if you need the inserted block data to be reloaded after closing the wallet. /// See [`Wallet::reveal_next_address`]. /// - /// See [`apply_update_events`] for more information on the returned [`WalletEvent`]s. + /// See [`apply_update`] for more information on the returned [`WalletEvent`]s. + /// + /// [`apply_update`]: Wallet::apply_update pub fn apply_block_connected_to( &mut self, block: &Block, @@ -2566,15 +2580,7 @@ impl Wallet { ) -> 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)>>(); + let wallet_txs1 = self.collect_wallet_txs(); // apply block to wallet let mut changeset = ChangeSet::default(); @@ -2592,15 +2598,7 @@ impl Wallet { // 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)>>(); + let wallet_txs2 = self.collect_wallet_txs(); Ok(wallet_events( self, @@ -2611,13 +2609,30 @@ impl Wallet { )) } + /// Collects all canonical wallet transactions into a map. + /// + /// This method is primarily used internally to create before/after snapshots when applying + /// updates or blocks to the wallet. + fn collect_wallet_txs( + &self, + ) -> BTreeMap, ChainPosition)> { + self.transactions() + .map(|wtx| { + ( + wtx.tx_node.txid, + (wtx.tx_node.tx.clone(), wtx.chain_position), + ) + }) + .collect() + } + /// Apply relevant unconfirmed transactions to the wallet. /// /// Transactions that are not relevant are filtered out. /// /// 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 + /// when there are conflicting unconfirmed transactions. The transaction with the later /// `last_seen` is prioritized. /// /// **WARNING**: You must persist the changes resulting from one or more calls to this method