From df444d06bda972819541d75f120cf8556a6a61e9 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 29 Oct 2025 12:53:12 +0100 Subject: [PATCH 1/3] 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. --- wallet/src/wallet/mod.rs | 85 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/wallet/src/wallet/mod.rs b/wallet/src/wallet/mod.rs index 9f276e9d..f5dd583e 100644 --- a/wallet/src/wallet/mod.rs +++ b/wallet/src/wallet/mod.rs @@ -2560,6 +2560,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. /// @@ -2591,6 +2626,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 3f0664e9485f531f6ba98e4a3a8bf75e69b0a3e5 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 31 Oct 2025 13:16:47 +0100 Subject: [PATCH 2/3] f Duplicate event logic rather than business logic Co-authored-by: Steve Myers --- wallet/src/wallet/mod.rs | 48 ++++++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/wallet/src/wallet/mod.rs b/wallet/src/wallet/mod.rs index f5dd583e..4939d01c 100644 --- a/wallet/src/wallet/mod.rs +++ b/wallet/src/wallet/mod.rs @@ -2576,23 +2576,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 e9a303436b8d22d6a73552316e74da021b67977c Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Fri, 31 Oct 2025 18:57:31 -0500 Subject: [PATCH 3/3] test(wallet): add tests for apply_block_events Also did minor cleanup of apply_update_events tests. --- wallet/tests/wallet_event.rs | 189 +++++++++++++++++++++++++++++++++-- 1 file changed, 180 insertions(+), 9 deletions(-) diff --git a/wallet/tests/wallet_event.rs b/wallet/tests/wallet_event.rs index 335f3e5e..bf72a35e 100644 --- a/wallet/tests/wallet_event.rs +++ b/wallet/tests/wallet_event.rs @@ -3,11 +3,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::{SignOptions, 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(); @@ -28,9 +30,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] @@ -88,7 +89,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) ); @@ -110,9 +110,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))) ); } @@ -143,7 +142,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) ); @@ -201,7 +199,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) ); @@ -286,7 +283,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) ); @@ -299,3 +295,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()) + ); +}