diff --git a/docs/adr/0003_events.md b/docs/adr/0003_events.md new file mode 100644 index 00000000..0ea4185d --- /dev/null +++ b/docs/adr/0003_events.md @@ -0,0 +1,124 @@ +# 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, #319 + +## 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`. + +#### 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" 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 + +* 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. + 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/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..22e9adbd --- /dev/null +++ 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 137aecc3..f4c60c66 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; @@ -74,10 +75,12 @@ use crate::wallet::{ tx_builder::{FeePolicy, TxBuilder, TxParams}, utils::{check_nsequence_rbf, After, Older, SecpCtx}, }; +use event::wallet_events; // re-exports pub use bdk_chain::Balance; pub use changeset::ChangeSet; +pub use event::WalletEvent; pub use params::*; pub use persisted::*; pub use utils::IsDust; @@ -2374,6 +2377,126 @@ 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`]. + /// + /// ```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, + ) -> 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() { @@ -2439,6 +2562,57 @@ 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> { + // 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 /// block to the internal chain. /// @@ -2470,6 +2644,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. diff --git a/tests/wallet_event.rs b/tests/wallet_event.rs new file mode 100644 index 00000000..2cc7cc50 --- /dev/null +++ b/tests/wallet_event.rs @@ -0,0 +1,466 @@ +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, 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(); + 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 {tx, block_time, ..} if block_time.block_id.height == 2000 && 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 psbt = builder.finish().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 {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 psbt = builder.finish().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, conflicts, ..} if *txid == orig_txid && 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 psbt = builder.finish().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 {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 psbt = builder.finish().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 {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 psbt = builder.finish().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 {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)); +} + +// 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()) + ); +}