From fe2274ad30ea39fc1e6e56cea17236e462767b61 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Fri, 12 Sep 2025 22:12:25 -0500 Subject: [PATCH 1/8] 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 cd858291fc664e0bc9d1499e5055895e1c957309 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Fri, 12 Sep 2025 22:12:49 -0500 Subject: [PATCH 2/8] 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 137aecc3..d22f6bff 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::*; @@ -2374,6 +2376,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 74b59757a5565f6a4213be334296f0ac610d6e25 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Sun, 21 Sep 2025 18:58:07 -0500 Subject: [PATCH 3/8] 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 d22f6bff..560d4168 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2384,6 +2384,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 7aaa05966b053fd1ebf3b9060a9bfb531d0ded1a Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Sun, 21 Sep 2025 18:58:32 -0500 Subject: [PATCH 4/8] 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 d0b26fa63ab6b9d8a508804cacc298c171a8b0d6 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Tue, 28 Oct 2025 15:47:22 -0500 Subject: [PATCH 5/8] 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 560d4168..86ad409c 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 80be94df90dd76be814d315f1acebc6aceac2386 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 29 Oct 2025 12:53:12 +0100 Subject: [PATCH 6/8] 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 86ad409c..eaf861c3 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2562,6 +2562,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. /// @@ -2593,6 +2628,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 193609fc6603fe2462ed4508f3f2db3824fde796 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 31 Oct 2025 13:16:47 +0100 Subject: [PATCH 7/8] 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 eaf861c3..f4c60c66 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2578,23 +2578,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 6c5c755227f066fb582356f39ddd33df6f104539 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Fri, 31 Oct 2025 18:57:31 -0500 Subject: [PATCH 8/8] 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()) + ); +}