From dc823307306e1926b643c5245f987817e6baff46 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Fri, 12 Sep 2025 22:12:25 -0500 Subject: [PATCH 1/4] docs: fix small typos --- wallet/src/wallet/changeset.rs | 4 ++-- wallet/src/wallet/event.rs | 0 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 wallet/src/wallet/event.rs diff --git a/wallet/src/wallet/changeset.rs b/wallet/src/wallet/changeset.rs index ebfdb9fb..ebf8f6d4 100644 --- a/wallet/src/wallet/changeset.rs +++ b/wallet/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 = /// 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/wallet/src/wallet/event.rs b/wallet/src/wallet/event.rs new file mode 100644 index 00000000..e69de29b From 6921b2bc0ca1fe6d457b14194119cbda4e8dbfbb Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Fri, 12 Sep 2025 22:12:49 -0500 Subject: [PATCH 2/4] 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. --- wallet/src/test_utils.rs | 127 ++++++++------- wallet/src/wallet/event.rs | 181 +++++++++++++++++++++ wallet/src/wallet/mod.rs | 50 ++++++ wallet/tests/wallet_event.rs | 300 +++++++++++++++++++++++++++++++++++ 4 files changed, 593 insertions(+), 65 deletions(-) create mode 100644 wallet/tests/wallet_event.rs diff --git a/wallet/src/test_utils.rs b/wallet/src/test_utils.rs index 11fd13b1..ff288ac8 100644 --- a/wallet/src/test_utils.rs +++ b/wallet/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/wallet/src/wallet/event.rs b/wallet/src/wallet/event.rs index e69de29b..562fb1a6 100644 --- a/wallet/src/wallet/event.rs +++ 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 +} diff --git a/wallet/src/wallet/mod.rs b/wallet/src/wallet/mod.rs index 273b5f6f..92a882fe 100644 --- a/wallet/src/wallet/mod.rs +++ b/wallet/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::*; @@ -2370,6 +2372,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/wallet/tests/wallet_event.rs b/wallet/tests/wallet_event.rs new file mode 100644 index 00000000..a4559405 --- /dev/null +++ b/wallet/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)); +} From df9e2100bb210dfceee1607795ed7ffe306547fc Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Sun, 21 Sep 2025 18:58:07 -0500 Subject: [PATCH 3/4] docs(wallet): add example to appl_update_events --- wallet/src/wallet/event.rs | 3 +- wallet/src/wallet/mod.rs | 72 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) 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 { diff --git a/wallet/src/wallet/mod.rs b/wallet/src/wallet/mod.rs index 92a882fe..8976de42 100644 --- a/wallet/src/wallet/mod.rs +++ b/wallet/src/wallet/mod.rs @@ -2380,6 +2380,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, From 9e766ddb1d9e70b8d56deeb4d791b80e825ad406 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Sun, 21 Sep 2025 18:58:32 -0500 Subject: [PATCH 4/4] 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. +