From 101273f50104837e5e155bf51c68d08a386168e1 Mon Sep 17 00:00:00 2001 From: Noah Joeris Date: Sat, 9 May 2026 13:04:18 +0300 Subject: [PATCH 1/2] feat(wallet): WalletTx now supports non-canonical txs --- examples/bitcoind_rpc.rs | 9 +- src/wallet/error.rs | 12 ++ src/wallet/export.rs | 18 ++- src/wallet/mod.rs | 299 ++++++++++++++++++++++++++------------ src/wallet/tx_builder.rs | 2 +- src/wallet/utils.rs | 11 +- tests/add_foreign_utxo.rs | 8 +- tests/build_fee_bump.rs | 27 +++- tests/persisted_wallet.rs | 5 +- tests/wallet.rs | 48 +++--- 10 files changed, 302 insertions(+), 137 deletions(-) diff --git a/examples/bitcoind_rpc.rs b/examples/bitcoind_rpc.rs index f0bbd7290..a29ab7705 100644 --- a/examples/bitcoind_rpc.rs +++ b/examples/bitcoind_rpc.rs @@ -5,6 +5,7 @@ use bdk_bitcoind_rpc::{ use bdk_wallet::rusqlite::Connection; use bdk_wallet::{ bitcoin::{Block, Network}, + chain::ChainPosition, KeychainKind, Wallet, }; use clap::{self, Parser}; @@ -144,7 +145,13 @@ fn main() -> anyhow::Result<()> { args.start_height, wallet .transactions() - .filter(|tx| tx.chain_position.is_unconfirmed()), + .filter(|tx| { + matches!( + tx.details.chain_position, + Some(ChainPosition::Unconfirmed { .. }) + ) + }) + .map(|tx| tx.details.tx), ); spawn(move || -> Result<(), anyhow::Error> { while let Some(emission) = emitter.next_block()? { diff --git a/src/wallet/error.rs b/src/wallet/error.rs index ddd074785..f5acca309 100644 --- a/src/wallet/error.rs +++ b/src/wallet/error.rs @@ -365,3 +365,15 @@ impl fmt::Display for BuildFeeBumpError { } impl core::error::Error for BuildFeeBumpError {} + +/// The transaction is unknown or not relevant to this wallet. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnknownTransaction; + +impl fmt::Display for UnknownTransaction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "transaction is unknown or not wallet-relevant") + } +} + +impl core::error::Error for UnknownTransaction {} diff --git a/src/wallet/export.rs b/src/wallet/export.rs index b169bba7d..6fa796f80 100644 --- a/src/wallet/export.rs +++ b/src/wallet/export.rs @@ -137,6 +137,7 @@ use alloc::string::String; use alloc::string::ToString; use alloc::vec::Vec; +use bdk_chain::{CanonicalizationParams, Indexer}; use bitcoin::bip32::{DerivationPath, Fingerprint, Xpub}; use core::fmt; use core::str::FromStr; @@ -210,12 +211,17 @@ impl FullyNodedExport { Self::is_compatible_with_core(&descriptor)?; let blockheight = if include_blockheight { - wallet.transactions().next().map_or(0, |canonical_tx| { - canonical_tx - .chain_position - .confirmation_height_upper_bound() - .unwrap_or(0) - }) + wallet + .tx_graph() + .list_canonical_txs( + wallet.local_chain(), + wallet.local_chain().tip().block_id(), + CanonicalizationParams::default(), + ) + .filter(|tx| wallet.spk_index().is_tx_relevant(&tx.tx_node.tx)) + .filter_map(|tx| tx.chain_position.confirmation_height_upper_bound()) + .min() + .unwrap_or(0) } else { 0 }; diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 5bd42ba75..d18a09b15 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -30,7 +30,7 @@ use bdk_chain::{ FullScanRequest, FullScanRequestBuilder, FullScanResponse, SyncRequest, SyncRequestBuilder, SyncResponse, }, - tx_graph::{CalculateFeeError, CanonicalTx, TxGraph, TxUpdate}, + tx_graph::{CalculateFeeError, TxGraph, TxNode, TxUpdate}, BlockId, CanonicalizationParams, ChainPosition, ConfirmationBlockTime, DescriptorExt, FullTxOut, Indexed, IndexedTxGraph, Indexer, Merge, }; @@ -42,7 +42,7 @@ use bitcoin::{ secp256k1::Secp256k1, sighash::{EcdsaSighashType, TapSighashType}, transaction, Address, Amount, Block, FeeRate, Network, NetworkKind, OutPoint, Psbt, ScriptBuf, - Sequence, SignedAmount, Transaction, TxOut, Txid, Weight, Witness, + Sequence, Transaction, TxOut, Txid, Weight, Witness, }; use miniscript::{ descriptor::KeyMap, @@ -64,7 +64,7 @@ pub mod signer; pub mod tx_builder; pub(crate) mod utils; -use crate::collections::{BTreeMap, HashMap, HashSet}; +use crate::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use crate::descriptor::{ check_wallet_descriptor, checksum::calc_checksum, error::Error as DescriptorError, policy::BuildSatisfaction, DerivedDescriptor, DescriptorMeta, ExtendedDescriptor, @@ -83,7 +83,7 @@ use crate::wallet::{ // re-exports pub use bdk_chain::Balance; pub use changeset::ChangeSet; -pub use error::{LoadError, LoadMismatch}; +pub use error::{LoadError, LoadMismatch, UnknownTransaction}; pub use event::*; pub use params::*; pub use persisted::*; @@ -181,8 +181,24 @@ impl fmt::Display for AddressInfo { } } -/// A `CanonicalTx` managed by a `Wallet`. -pub type WalletTx<'a> = CanonicalTx<'a, Arc, ConfirmationBlockTime>; +/// A wallet-relevant transaction and its metadata. +#[derive(Clone, Debug)] +pub struct TransactionInfo { + /// Wallet-specific amounts, fees, and chain position. + pub details: TxDetails, + /// Blocks that the transaction is anchored in. + pub anchors: BTreeSet, + /// The first-seen unix timestamp of the transaction as unconfirmed. + pub first_seen: Option, + /// The last-seen unix timestamp of the transaction as unconfirmed. + pub last_seen: Option, + /// Latest backend observation that this tx was absent from the mempool. + pub evicted_at: Option, + /// Direct conflicts spending the same inputs. + /// + /// See [`Wallet::conflicts`] for ancestor-aware conflicts with chain positions. + pub conflicts: Vec, +} impl Wallet { /// Build a new single descriptor [`Wallet`]. @@ -789,33 +805,6 @@ impl Wallet { .map(|((k, i), full_txo)| new_local_utxo(k, i, full_txo)) } - /// Get the [`TxDetails`] of a wallet transaction. - /// - /// If the transaction with txid [`Txid`] cannot be found in the wallet's transactions, `None` - /// is returned. - pub fn tx_details(&self, txid: Txid) -> Option { - let tx: WalletTx = self.transactions().find(|c| c.tx_node.txid == txid)?; - - let (sent, received) = self.sent_and_received(&tx.tx_node.tx); - let fee: Option = self.calculate_fee(&tx.tx_node.tx).ok(); - let fee_rate: Option = self.calculate_fee_rate(&tx.tx_node.tx).ok(); - let balance_delta: SignedAmount = self.tx_graph.index.net_value(&tx.tx_node.tx, ..); - let chain_position = tx.chain_position; - - let tx_details: TxDetails = TxDetails { - txid, - received, - sent, - fee, - fee_rate, - balance_delta, - chain_position, - tx: tx.tx_node.tx, - }; - - Some(tx_details) - } - /// List all relevant outputs (includes both spent and unspent, confirmed and unconfirmed). /// /// To list only unspent outputs (UTXOs), use [`Wallet::list_unspent`] instead. @@ -923,7 +912,7 @@ impl Wallet { /// # use bdk_wallet::Wallet; /// # let mut wallet: Wallet = todo!(); /// # let txid:Txid = todo!(); - /// let tx = wallet.get_tx(txid).expect("transaction").tx_node.tx; + /// let tx = wallet.get_tx(txid).expect("transaction").details.tx; /// let fee = wallet.calculate_fee(&tx).expect("fee"); /// ``` /// @@ -954,7 +943,7 @@ impl Wallet { /// # use bdk_wallet::Wallet; /// # let mut wallet: Wallet = todo!(); /// # let txid:Txid = todo!(); - /// let tx = wallet.get_tx(txid).expect("transaction").tx_node.tx; + /// let tx = wallet.get_tx(txid).expect("transaction").details.tx; /// let fee_rate = wallet.calculate_fee_rate(&tx).expect("fee rate"); /// ``` /// @@ -984,7 +973,7 @@ impl Wallet { /// # use bdk_wallet::Wallet; /// # let mut wallet: Wallet = todo!(); /// # let txid:Txid = todo!(); - /// let tx = wallet.get_tx(txid).expect("tx exists").tx_node.tx; + /// let tx = wallet.get_tx(txid).expect("tx exists").details.tx; /// let (sent, received) = wallet.sent_and_received(&tx); /// ``` /// @@ -1000,95 +989,208 @@ impl Wallet { self.tx_graph.index.sent_and_received(tx, ..) } - /// Get a single transaction from the wallet as a [`WalletTx`] (if the transaction exists). + /// Create transaction info metadata. + fn build_transaction_info( + &self, + tx_node: TxNode<'_, Arc, ConfirmationBlockTime>, + chain_position: Option>, + ) -> TransactionInfo { + let txid = tx_node.txid; + let (sent, received) = self.sent_and_received(&tx_node.tx); + let fee = self.calculate_fee(&tx_node.tx).ok(); + let fee_rate = self.calculate_fee_rate(&tx_node.tx).ok(); + let balance_delta = self.tx_graph.index.net_value(&tx_node.tx, ..); + let conflicts = self + .tx_graph + .graph() + .direct_conflicts(&tx_node.tx) + .map(|(_, conflict_txid)| conflict_txid) + .collect::>() + .into_iter() + .collect(); + let evicted_at = self.tx_graph.graph().get_last_evicted(txid); + + TransactionInfo { + details: TxDetails { + txid, + received, + sent, + fee, + fee_rate, + balance_delta, + chain_position, + tx: tx_node.tx.clone(), + }, + anchors: tx_node.anchors.clone(), + first_seen: tx_node.first_seen, + last_seen: tx_node.last_seen, + evicted_at, + conflicts, + } + } + + /// Get a single wallet-relevant transaction as [`TransactionInfo`] if it is known. /// - /// `WalletTx` contains the full transaction alongside meta-data such as: + /// [`TransactionInfo`] contains the full transaction alongside metadata such as: /// * Blocks that the transaction is [`Anchor`]ed in. These may or may not be blocks that exist /// in the best chain. - /// * The [`ChainPosition`] of the transaction in the best chain - whether the transaction is - /// confirmed or unconfirmed. If the transaction is confirmed, the anchor which proves the - /// confirmation is provided. If the transaction is unconfirmed, the unix timestamp of when - /// the transaction was last seen in the mempool is provided. + /// * Wallet-specific amounts, fees, and chain position. + /// * Direct conflicts spending the same inputs. /// /// ```rust, no_run /// use bdk_chain::Anchor; - /// use bdk_wallet::{chain::ChainPosition, Wallet}; + /// use bdk_wallet::Wallet; /// # let wallet: Wallet = todo!(); /// # let my_txid: bitcoin::Txid = todo!(); /// - /// let wallet_tx = wallet.get_tx(my_txid).expect("panic if tx does not exist"); + /// let tx_info = wallet.get_tx(my_txid).expect("panic if tx does not exist"); /// /// // get reference to full transaction - /// println!("my tx: {:#?}", wallet_tx.tx_node.tx); + /// println!("my tx: {:#?}", tx_info.details.tx); /// /// // list all transaction anchors - /// for anchor in wallet_tx.tx_node.anchors { + /// for anchor in &tx_info.anchors { /// println!( /// "tx is anchored by block of hash {}", /// anchor.anchor_block().hash /// ); /// } - /// - /// // get confirmation status of transaction - /// match wallet_tx.chain_position { - /// ChainPosition::Confirmed { - /// anchor, - /// transitively: None, - /// } => println!( - /// "tx is confirmed at height {}, we know this since {}:{} is in the best chain", - /// anchor.block_id.height, anchor.block_id.height, anchor.block_id.hash, - /// ), - /// ChainPosition::Confirmed { - /// anchor, - /// transitively: Some(_), - /// } => println!( - /// "tx is an ancestor of a tx anchored in {}:{}", - /// anchor.block_id.height, anchor.block_id.hash, - /// ), - /// ChainPosition::Unconfirmed { first_seen, last_seen } => println!( - /// "tx is first seen at {:?}, last seen at {:?}, it is unconfirmed as it is not anchored in the best chain", - /// first_seen, last_seen - /// ), - /// } /// ``` /// /// [`Anchor`]: bdk_chain::Anchor - pub fn get_tx(&self, txid: Txid) -> Option> { + pub fn get_tx(&self, txid: Txid) -> Option { + let graph = self.tx_graph.graph(); + let tx_node = graph.get_tx_node(txid)?; + + if !self.tx_graph.index.is_tx_relevant(&tx_node.tx) { + return None; + } + + let maybe_chain_position = graph + .list_canonical_txs( + &self.chain, + self.chain.tip().block_id(), + CanonicalizationParams::default(), + ) + .find(|canonical_tx| canonical_tx.tx_node.txid == txid) + .map(|canonical_tx| canonical_tx.chain_position); + + Some(self.build_transaction_info(tx_node, maybe_chain_position)) + } + + /// Get the [`TxDetails`] of a wallet transaction. + /// + /// If the transaction with txid [`Txid`] cannot be found in the wallet's transactions, `None` + /// is returned. + /// + /// Convenience for [`Wallet::get_tx`]'s `details` field. + pub fn tx_details(&self, txid: Txid) -> Option { + self.get_tx(txid).map(|info| info.details) + } + + /// Return conflicts of a wallet-relevant transaction, including conflicts of its non-canonical + /// ancestors. + /// + /// Direct conflicts of `txid` are included, as well as direct conflicts of any non-canonical + /// ancestor of `txid`. Ancestors are checked because a transaction can be non-canonical when + /// one of its ancestors was replaced. + /// + /// Returns [`UnknownTransaction`] if `txid` is unknown or not wallet-relevant. Returns + /// an empty list if `txid` is canonical or no conflicts are known. + #[allow(clippy::type_complexity)] + pub fn conflicts( + &self, + txid: Txid, + ) -> Result>)>, UnknownTransaction> { let graph = self.tx_graph.graph(); - graph + let tx_node = graph.get_tx_node(txid).ok_or(UnknownTransaction)?; + + if !self.tx_graph.index.is_tx_relevant(&tx_node.tx) { + return Err(UnknownTransaction); + } + + let canonical_positions: HashMap> = graph .list_canonical_txs( &self.chain, self.chain.tip().block_id(), CanonicalizationParams::default(), ) - .find(|tx| tx.tx_node.txid == txid) + .map(|canonical_tx| (canonical_tx.tx_node.txid, canonical_tx.chain_position)) + .collect(); + + if canonical_positions.contains_key(&txid) { + return Ok(Vec::new()); + } + + let tx = tx_node.tx.clone(); + let candidates = core::iter::once(tx.clone()) + .chain(graph.walk_ancestors(tx, |_, ancestor| Some(ancestor))) + .filter(|tx| !canonical_positions.contains_key(&tx.compute_txid())); + + let conflicts = candidates + .flat_map(|candidate_tx| { + graph + .direct_conflicts(&candidate_tx) + .map(|(_, conflict_txid)| conflict_txid) + .collect::>() + }) + .collect::>() + .into_iter() + .map(|conflict_txid| { + ( + conflict_txid, + canonical_positions.get(&conflict_txid).cloned(), + ) + }) + .collect(); + + Ok(conflicts) } - /// Iterate over relevant and canonical transactions in the wallet. + /// Iterate over relevant transactions in the wallet. /// /// A transaction is relevant when it spends from or spends to at least one tracked output. A /// transaction is canonical when it is confirmed in the best chain, or does not conflict - /// with any transaction confirmed in the best chain. + /// with any transaction confirmed in the best chain. Non-canonical transactions are included + /// too if they remain wallet-relevant. /// - /// To iterate over all transactions, including those that are irrelevant and not canonical, use + /// To iterate over all transactions, including those that are irrelevant, use /// [`TxGraph::full_txs`]. /// /// To iterate over all canonical transactions, including those that are irrelevant, use /// [`TxGraph::list_canonical_txs`]. - pub fn transactions(&self) -> impl Iterator> + '_ { + pub fn transactions(&self) -> impl Iterator + '_ { let tx_graph = self.tx_graph.graph(); let tx_index = &self.tx_graph.index; - tx_graph + + let canonical_txs: Vec<_> = tx_graph .list_canonical_txs( &self.chain, self.chain.tip().block_id(), CanonicalizationParams::default(), ) + .collect(); + + let canonical_txids: HashSet<_> = canonical_txs + .iter() + .map(|canonical_tx| canonical_tx.tx_node.txid) + .collect(); + + let canonical = canonical_txs + .into_iter() .filter(|c_tx| tx_index.is_tx_relevant(&c_tx.tx_node.tx)) + .map(|c_tx| self.build_transaction_info(c_tx.tx_node, Some(c_tx.chain_position))); + + let non_canonical = tx_graph + .full_txs() + .filter(move |tx_node| !canonical_txids.contains(&tx_node.txid)) + .filter(|tx_node| tx_index.is_tx_relevant(&tx_node.tx)) + .map(|tx_node| self.build_transaction_info(tx_node, None)); + + canonical.chain(non_canonical) } - /// Array of relevant and canonical transactions in the wallet sorted with a comparator - /// function. + /// Array of relevant transactions in the wallet sorted with a comparator function. /// /// This is a helper method equivalent to collecting the result of [`Wallet::transactions`] /// into a [`Vec`] and then sorting it. @@ -1096,18 +1198,19 @@ impl Wallet { /// # Example /// /// ```rust,no_run - /// # use bdk_wallet::{LoadParams, Wallet, WalletTx}; + /// # use bdk_wallet::{LoadParams, TransactionInfo, Wallet}; /// # let mut wallet:Wallet = todo!(); /// // Transactions by chain position: first unconfirmed then descending by confirmed height. - /// let sorted_txs: Vec = - /// wallet.transactions_sort_by(|tx1, tx2| tx2.chain_position.cmp(&tx1.chain_position)); + /// let sorted_txs: Vec = wallet.transactions_sort_by(|tx1, tx2| { + /// tx2.details.chain_position.cmp(&tx1.details.chain_position) + /// }); /// # Ok::<(), anyhow::Error>(()) /// ``` - pub fn transactions_sort_by(&self, compare: F) -> Vec> + pub fn transactions_sort_by(&self, compare: F) -> Vec where - F: FnMut(&WalletTx, &WalletTx) -> Ordering, + F: FnMut(&TransactionInfo, &TransactionInfo) -> Ordering, { - let mut txs: Vec = self.transactions().collect(); + let mut txs: Vec = self.transactions().collect(); txs.sort_unstable_by(compare); txs } @@ -2652,10 +2755,9 @@ impl Wallet { /// Apply evictions of the given transaction IDs with their associated timestamps. /// /// This function is used to mark specific unconfirmed transactions as evicted from the mempool. - /// Eviction means that these transactions are not considered canonical by default, and will - /// no longer be part of the wallet's [`transactions`] set. This can happen for example when - /// a transaction is dropped from the mempool due to low fees or conflicts with another - /// transaction. + /// Eviction means that these transactions are not considered canonical by default. This can + /// happen for example when a transaction is dropped from the mempool due to low fees or + /// conflicts with another transaction. /// /// Only transactions that are currently unconfirmed and canonical are considered for eviction. /// Transactions that are not relevant to the wallet are ignored. Note that an evicted @@ -2773,14 +2875,14 @@ impl Wallet { { // Snapshot of chain tip and transactions before let chain_tip1 = self.chain.tip().block_id(); - let wallet_txs1 = self.map_transactions(); + let wallet_txs1 = self.map_canonical_transactions(); // Call `f` on self f(self)?; // Chain tip and transactions after let chain_tip2 = self.chain.tip().block_id(); - let wallet_txs2 = self.map_transactions(); + let wallet_txs2 = self.map_canonical_transactions(); Ok(wallet_events( self, @@ -2806,14 +2908,21 @@ impl Wallet { /// Returns a map of canonical transactions keyed by txid. /// /// This is used internally to help generate [`WalletEvent`]s. - fn map_transactions( + fn map_canonical_transactions( &self, ) -> BTreeMap, ChainPosition)> { - self.transactions() - .map(|wtx| { + self.tx_graph + .graph() + .list_canonical_txs( + &self.chain, + self.chain.tip().block_id(), + CanonicalizationParams::default(), + ) + .filter(|c_tx| self.tx_graph.index.is_tx_relevant(&c_tx.tx_node.tx)) + .map(|c_tx| { ( - wtx.tx_node.txid, - (wtx.tx_node.tx.clone(), wtx.chain_position), + c_tx.tx_node.txid, + (c_tx.tx_node.tx.clone(), c_tx.chain_position), ) }) .collect() diff --git a/src/wallet/tx_builder.rs b/src/wallet/tx_builder.rs index ec50be9ac..68088cc0b 100644 --- a/src/wallet/tx_builder.rs +++ b/src/wallet/tx_builder.rs @@ -1369,7 +1369,7 @@ mod test { assert_ne!(txid1, txid2); let utxo1 = wallet1.list_unspent().next().unwrap(); - let tx1 = wallet1.get_tx(txid1).unwrap().tx_node.tx.clone(); + let tx1 = wallet1.get_tx(txid1).unwrap().details.tx.clone(); let satisfaction_weight = wallet1 .public_descriptor(KeychainKind::External) diff --git a/src/wallet/utils.rs b/src/wallet/utils.rs index adf239f9b..503a6a838 100644 --- a/src/wallet/utils.rs +++ b/src/wallet/utils.rs @@ -14,9 +14,10 @@ use bitcoin::secp256k1::{All, Secp256k1}; use bitcoin::{ absolute, relative, Amount, FeeRate, Script, Sequence, SignedAmount, Transaction, Txid, }; -use chain::{ChainPosition, ConfirmationBlockTime}; use miniscript::{MiniscriptKey, Satisfier, ToPublicKey}; +use bdk_chain::{ChainPosition, ConfirmationBlockTime}; + use rand_core::RngCore; /// Trait to check if a value is below the dust limit. @@ -136,8 +137,8 @@ pub(crate) fn shuffle_slice(list: &mut [T], rng: &mut impl RngCore) { pub(crate) type SecpCtx = Secp256k1; -/// Details about a transaction affecting the wallet (relevant and canonical). -#[derive(Debug)] +/// Details about a transaction affecting the wallet. +#[derive(Clone, Debug)] pub struct TxDetails { /// The transaction id. pub txid: Txid, @@ -157,8 +158,8 @@ pub struct TxDetails { pub fee_rate: Option, /// The net effect of the transaction on the balance of the wallet. pub balance_delta: SignedAmount, - /// The position of the transaction in the chain. - pub chain_position: ChainPosition, + /// Chain position of the transaction, if currently canonical. + pub chain_position: Option>, /// The complete [`Transaction`]. pub tx: Arc, } diff --git a/tests/add_foreign_utxo.rs b/tests/add_foreign_utxo.rs index 409d71fd6..a89c1a6a9 100644 --- a/tests/add_foreign_utxo.rs +++ b/tests/add_foreign_utxo.rs @@ -139,8 +139,8 @@ fn test_add_foreign_utxo_where_outpoint_doesnt_match_psbt_input() { get_funded_wallet_single("wpkh(cVbZ8ovhye9AoAHFsqobCf7LxbXDAECy9Kb8TZdfsDYMZGBUyCnm)"); let utxo2 = wallet2.list_unspent().next().unwrap(); - let tx1 = wallet1.get_tx(txid1).unwrap().tx_node.tx.clone(); - let tx2 = wallet2.get_tx(txid2).unwrap().tx_node.tx.clone(); + let tx1 = wallet1.get_tx(txid1).unwrap().details.tx.clone(); + let tx2 = wallet2.get_tx(txid2).unwrap().details.tx.clone(); let satisfaction_weight = wallet2 .public_descriptor(KeychainKind::External) @@ -230,7 +230,7 @@ fn test_add_foreign_utxo_only_witness_utxo() { let mut builder = wallet1.build_tx(); builder.add_recipient(addr.script_pubkey(), Amount::from_sat(60_000)); - let tx2 = wallet2.get_tx(txid2).unwrap().tx_node.tx; + let tx2 = wallet2.get_tx(txid2).unwrap().details.tx; let psbt_input = psbt::Input { non_witness_utxo: Some(tx2.as_ref().clone()), ..Default::default() @@ -298,7 +298,7 @@ fn test_add_foreign_utxo_rejects_wrong_non_witness_utxo_even_with_witness_utxo() get_funded_wallet_single("wpkh(cVbZ8ovhye9AoAHFsqobCf7LxbXDAECy9Kb8TZdfsDYMZGBUyCnm)"); let utxo2 = wallet2.list_unspent().next().unwrap(); - let tx1 = wallet1.get_tx(txid1).unwrap().tx_node.tx.clone(); + let tx1 = wallet1.get_tx(txid1).unwrap().details.tx.clone(); let satisfaction_weight = wallet2 .public_descriptor(KeychainKind::External) diff --git a/tests/build_fee_bump.rs b/tests/build_fee_bump.rs index 5b7befee0..7d5cfff85 100644 --- a/tests/build_fee_bump.rs +++ b/tests/build_fee_bump.rs @@ -358,8 +358,13 @@ fn test_bump_fee_remove_output_manually_selected_only() { }], }; - let position: ChainPosition = - wallet.transactions().last().unwrap().chain_position; + let position: ChainPosition = wallet + .transactions() + .last() + .unwrap() + .details + .chain_position + .unwrap(); insert_tx(&mut wallet, init_tx.clone()); match position { ChainPosition::Confirmed { anchor, .. } => { @@ -410,8 +415,13 @@ fn test_bump_fee_add_input() { }], }; let txid = init_tx.compute_txid(); - let pos: ChainPosition = - wallet.transactions().last().unwrap().chain_position; + let pos: ChainPosition = wallet + .transactions() + .last() + .unwrap() + .details + .chain_position + .unwrap(); insert_tx(&mut wallet, init_tx); match pos { ChainPosition::Confirmed { anchor, .. } => insert_anchor(&mut wallet, txid, anchor), @@ -846,8 +856,13 @@ fn test_legacy_bump_fee_add_input() { }], }; let txid = init_tx.compute_txid(); - let pos: ChainPosition = - wallet.transactions().last().unwrap().chain_position; + let pos: ChainPosition = wallet + .transactions() + .last() + .unwrap() + .details + .chain_position + .unwrap(); insert_tx(&mut wallet, init_tx); match pos { ChainPosition::Confirmed { anchor, .. } => insert_anchor(&mut wallet, txid, anchor), diff --git a/tests/persisted_wallet.rs b/tests/persisted_wallet.rs index bc1375948..a33ceaba2 100644 --- a/tests/persisted_wallet.rs +++ b/tests/persisted_wallet.rs @@ -370,9 +370,12 @@ fn wallet_should_persist_anchors_and_recover() { } = wallet .get_tx(txid) .expect("should retrieve stored tx") + .details .chain_position + .as_ref() + .expect("stored tx should be canonical") { - assert_eq!(obtained_anchor, expected_anchor) + assert_eq!(obtained_anchor, &expected_anchor) } else { panic!("Should have got ChainPosition::Confirmed)"); } diff --git a/tests/wallet.rs b/tests/wallet.rs index 0204cfd5a..f53315a6d 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -11,8 +11,8 @@ use bdk_wallet::signer::{SignOptions, SignerError, SignersContainer}; use bdk_wallet::test_utils::*; use bdk_wallet::KeychainKind; use bdk_wallet::{ - AddressInfo, Balance, FinalizeInputOutcome, IndexOutOfBoundsError, PersistedWallet, Update, - Wallet, WalletTx, + AddressInfo, Balance, FinalizeInputOutcome, IndexOutOfBoundsError, PersistedWallet, + TransactionInfo, Update, Wallet, }; use bitcoin::constants::COINBASE_MATURITY; use bitcoin::hashes::Hash; @@ -85,7 +85,7 @@ fn test_get_funded_wallet_balance() { fn test_get_funded_wallet_sent_and_received() { let (wallet, txid) = get_funded_wallet_wpkh(); - let tx = wallet.get_tx(txid).expect("transaction").tx_node.tx; + let tx = wallet.get_tx(txid).expect("transaction").details.tx; let (sent, received) = wallet.sent_and_received(&tx); // The funded wallet contains a tx with a 76_000 sats input and two outputs, one spending 25_000 @@ -99,7 +99,7 @@ fn test_get_funded_wallet_sent_and_received() { fn test_get_funded_wallet_tx_fees() { let (wallet, txid) = get_funded_wallet_wpkh(); - let tx = wallet.get_tx(txid).expect("transaction").tx_node.tx; + let tx = wallet.get_tx(txid).expect("transaction").details.tx; let tx_fee = wallet.calculate_fee(&tx).expect("transaction fee"); // The funded wallet contains a tx with a 76_000 sats input and two outputs, one spending 25_000 @@ -112,7 +112,7 @@ fn test_get_funded_wallet_tx_fees() { fn test_get_funded_wallet_tx_fee_rate() { let (wallet, txid) = get_funded_wallet_wpkh(); - let tx = wallet.get_tx(txid).expect("transaction").tx_node.tx; + let tx = wallet.get_tx(txid).expect("transaction").details.tx; let tx_fee_rate = wallet .calculate_fee_rate(&tx) .expect("transaction fee rate"); @@ -132,7 +132,7 @@ fn test_get_funded_wallet_tx_fee_rate() { fn test_legacy_get_funded_wallet_tx_fee_rate() { let (wallet, txid) = get_funded_wallet_single(get_test_pkh()); - let tx = wallet.get_tx(txid).expect("transaction").tx_node.tx; + let tx = wallet.get_tx(txid).expect("transaction").details.tx; let tx_fee_rate = wallet .calculate_fee_rate(&tx) .expect("transaction fee rate"); @@ -2463,8 +2463,15 @@ fn test_taproot_sign_using_non_witness_utxo() { let mut psbt = builder.finish().unwrap(); psbt.inputs[0].witness_utxo = None; - psbt.inputs[0].non_witness_utxo = - Some(wallet.get_tx(prev_txid).unwrap().tx_node.as_ref().clone()); + psbt.inputs[0].non_witness_utxo = Some( + wallet + .get_tx(prev_txid) + .unwrap() + .details + .tx + .as_ref() + .clone(), + ); assert!( psbt.inputs[0].non_witness_utxo.is_some(), "Previous tx should be present in the database" @@ -3131,11 +3138,16 @@ fn test_transactions_sort_by() { receive_output(&mut wallet, Amount::from_sat(25_000), ReceiveTo::Mempool(0)); // sort by chain position, unconfirmed then confirmed by descending block height - let sorted_txs: Vec = - wallet.transactions_sort_by(|t1, t2| t2.chain_position.cmp(&t1.chain_position)); + let sorted_txs: Vec = wallet + .transactions_sort_by(|t1, t2| t2.details.chain_position.cmp(&t1.details.chain_position)); let conf_heights: Vec> = sorted_txs .iter() - .map(|tx| tx.chain_position.confirmation_height_upper_bound()) + .map(|tx| { + tx.details + .chain_position + .as_ref() + .and_then(|position| position.confirmation_height_upper_bound()) + }) .collect(); assert_eq!([None, Some(2000), Some(1000)], conf_heights.as_slice()); } @@ -3185,7 +3197,7 @@ fn test_wallet_transactions_relevant() { assert_eq!(relevant_tx_count_before, relevant_tx_count_after); assert!(!test_wallet .transactions() - .any(|wallet_tx| wallet_tx.tx_node.txid == other_txid)); + .any(|wallet_tx| wallet_tx.details.txid == other_txid)); assert!(test_wallet .tx_graph() .list_canonical_txs( @@ -3199,12 +3211,12 @@ fn test_wallet_transactions_relevant() { } #[test] -fn test_tx_details_method() { +fn test_transaction_info_details() { let (test_wallet, txid_1) = get_funded_wallet_wpkh(); - let tx_details_1_option = test_wallet.tx_details(txid_1); + let tx_info_1_option = test_wallet.get_tx(txid_1); - assert!(tx_details_1_option.is_some()); - let tx_details_1 = tx_details_1_option.unwrap(); + assert!(tx_info_1_option.is_some()); + let tx_details_1 = tx_info_1_option.unwrap().details; assert_eq!( tx_details_1.txid.to_string(), @@ -3217,8 +3229,8 @@ fn test_tx_details_method() { // Transaction id not part of the TxGraph let txid_2 = Txid::from_raw_hash(Hash::all_zeros()); - let tx_details_2_option = test_wallet.tx_details(txid_2); - assert!(tx_details_2_option.is_none()); + let tx_info_2_option = test_wallet.get_tx(txid_2); + assert!(tx_info_2_option.is_none()); } #[test] From 404f0fabdbe957859794b003fef232e67e10f021 Mon Sep 17 00:00:00 2001 From: Noah Joeris Date: Wed, 13 May 2026 15:50:16 +0300 Subject: [PATCH 2/2] test(wallet): cover non-canonical transaction APIs --- src/test_utils.rs | 12 ++++ tests/wallet.rs | 179 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 184 insertions(+), 7 deletions(-) diff --git a/src/test_utils.rs b/src/test_utils.rs index c0a514647..d7c6fe54d 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -361,3 +361,15 @@ pub fn insert_seen_at(wallet: &mut Wallet, txid: Txid, seen_at: u64) { }) .expect("failed to apply update"); } + +/// Marks the given `txid` evicted from the mempool at `evicted_at` +pub fn insert_evicted_at(wallet: &mut Wallet, txid: Txid, evicted_at: u64) { + let mut tx_update = TxUpdate::default(); + tx_update.evicted_ats = [(txid, evicted_at)].into(); + wallet + .apply_update(Update { + tx_update, + ..Default::default() + }) + .expect("failed to apply update"); +} diff --git a/tests/wallet.rs b/tests/wallet.rs index f53315a6d..04985420b 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -12,7 +12,7 @@ use bdk_wallet::test_utils::*; use bdk_wallet::KeychainKind; use bdk_wallet::{ AddressInfo, Balance, FinalizeInputOutcome, IndexOutOfBoundsError, PersistedWallet, - TransactionInfo, Update, Wallet, + TransactionInfo, UnknownTransaction, Update, Wallet, }; use bitcoin::constants::COINBASE_MATURITY; use bitcoin::hashes::Hash; @@ -3208,15 +3208,35 @@ fn test_wallet_transactions_relevant() { .any(|wallet_tx| wallet_tx.tx_node.txid == other_txid)); assert!(full_tx_count_before < full_tx_count_after); assert!(canonical_tx_count_before < canonical_tx_count_after); + + // A wallet-relevant non-canonical tx is included. + let sendto = Address::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5") + .unwrap() + .require_network(Network::Regtest) + .unwrap(); + let mut builder = test_wallet.build_tx(); + builder.add_recipient(sendto.script_pubkey(), Amount::from_sat(10_000)); + let send_tx = builder.finish().unwrap().extract_tx().unwrap(); + let send_txid = send_tx.compute_txid(); + let mut update = Update::default(); + update.tx_update.txs = vec![Arc::new(send_tx)]; + update.tx_update.seen_ats = [(send_txid, 100)].into(); + update.tx_update.evicted_ats = [(send_txid, 200)].into(); + test_wallet.apply_update(update).unwrap(); + let info = test_wallet + .transactions() + .find(|tx| tx.details.txid == send_txid) + .expect("non-canonical send_tx should be included"); + assert!(info.details.chain_position.is_none()); } #[test] -fn test_transaction_info_details() { +fn test_tx_details_method() { let (test_wallet, txid_1) = get_funded_wallet_wpkh(); - let tx_info_1_option = test_wallet.get_tx(txid_1); + let tx_details_1_option = test_wallet.tx_details(txid_1); - assert!(tx_info_1_option.is_some()); - let tx_details_1 = tx_info_1_option.unwrap().details; + assert!(tx_details_1_option.is_some()); + let tx_details_1 = tx_details_1_option.unwrap(); assert_eq!( tx_details_1.txid.to_string(), @@ -3229,8 +3249,153 @@ fn test_transaction_info_details() { // Transaction id not part of the TxGraph let txid_2 = Txid::from_raw_hash(Hash::all_zeros()); - let tx_info_2_option = test_wallet.get_tx(txid_2); - assert!(tx_info_2_option.is_none()); + let tx_details_2_option = test_wallet.tx_details(txid_2); + assert!(tx_details_2_option.is_none()); +} + +#[test] +fn test_get_tx() { + let (mut wallet, funding_txid) = get_funded_wallet_wpkh(); + + // Canonical case: the confirmed funding tx. + let info = wallet.get_tx(funding_txid).expect("funded tx should exist"); + let details = &info.details; + assert_eq!( + details.txid.to_string(), + "f2a03cdfe1bb6a295b0a4bb4385ca42f95e4b2c6d9a7a59355d32911f957a5b3" + ); + assert_eq!(details.received, Amount::from_sat(50000)); + assert_eq!(details.sent, Amount::from_sat(76000)); + assert_eq!(details.fee.unwrap(), Amount::from_sat(1000)); + assert_eq!(details.balance_delta, SignedAmount::from_sat(-26000)); + assert!(details.chain_position.is_some()); + assert!(info.evicted_at.is_none()); + assert!(info.conflicts.is_empty()); + + // Build an unconfirmed tx and replace it via RBF. + let sendto = Address::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5") + .unwrap() + .require_network(Network::Regtest) + .unwrap(); + let mut builder = wallet.build_tx(); + builder.add_recipient(sendto.script_pubkey(), Amount::from_sat(10_000)); + let orig_tx = builder.finish().unwrap().extract_tx().unwrap(); + let orig_txid = orig_tx.compute_txid(); + insert_tx(&mut wallet, orig_tx); + + let mut builder = wallet.build_fee_bump(orig_txid).unwrap(); + builder.fee_rate(FeeRate::from_sat_per_vb(10).unwrap()); + let rbf_tx = builder.finish().unwrap().extract_tx().unwrap(); + let rbf_txid = rbf_tx.compute_txid(); + insert_tx(&mut wallet, rbf_tx); + insert_evicted_at(&mut wallet, orig_txid, 220); + + // Non-canonical (evicted) side. + let orig_info = wallet.get_tx(orig_txid).expect("evicted tx still visible"); + assert!(orig_info.details.chain_position.is_none()); + assert_eq!(orig_info.evicted_at, Some(220)); + assert!(orig_info.conflicts.contains(&rbf_txid)); + + // Canonical replacement side. + let rbf_info = wallet + .get_tx(rbf_txid) + .expect("replacement tx should exist"); + assert!(rbf_info.details.chain_position.is_some()); + assert!(rbf_info.evicted_at.is_none()); + assert!(rbf_info.conflicts.contains(&orig_txid)); + + // Unknown txid returns None. + let unknown = Txid::from_raw_hash(Hash::all_zeros()); + assert!(wallet.get_tx(unknown).is_none()); + + // Known to the tx_graph but not wallet-relevant. + let (other_external, other_internal) = get_test_tr_single_sig_xprv_and_change_desc(); + let (other_wallet, other_txid) = get_funded_wallet(other_internal, other_external); + wallet + .apply_update(Update { + tx_update: other_wallet.tx_graph().clone().into(), + ..Default::default() + }) + .unwrap(); + assert!(wallet.get_tx(other_txid).is_none()); +} + +#[test] +fn test_conflicts() { + let (mut wallet, _funding_txid) = get_funded_wallet_wpkh(); + + let sendto = Address::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5") + .unwrap() + .require_network(Network::Regtest) + .unwrap(); + let mut builder = wallet.build_tx(); + builder.add_recipient(sendto.script_pubkey(), Amount::from_sat(10_000)); + let orig_tx = builder.finish().unwrap().extract_tx().unwrap(); + let orig_txid = orig_tx.compute_txid(); + + wallet.apply_unconfirmed_txs([(orig_tx.clone(), 100)]); + + // Canonical transactions have no conflicts. + assert_eq!(wallet.conflicts(orig_txid), Ok(Vec::new())); + + let orig_change_vout = orig_tx + .output + .iter() + .position(|txout| wallet.is_mine(txout.script_pubkey.clone())) + .expect("orig tx should have wallet change") as u32; + let child_addr = wallet.next_unused_address(KeychainKind::External).address; + let child_tx = Transaction { + input: vec![TxIn { + previous_output: OutPoint { + txid: orig_txid, + vout: orig_change_vout, + }, + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: child_addr.script_pubkey(), + }], + ..new_tx(0) + }; + let child_txid = child_tx.compute_txid(); + + let mut builder = wallet.build_fee_bump(orig_txid).unwrap(); + builder.fee_rate(FeeRate::from_sat_per_vb(10).unwrap()); + let replacement_tx = builder.finish().unwrap().extract_tx().unwrap(); + let replacement_txid = replacement_tx.compute_txid(); + + wallet.apply_unconfirmed_txs([(child_tx, 200), (replacement_tx, 300)]); + + // canonical replacement should have no conflicts + assert_eq!(wallet.conflicts(replacement_txid), Ok(Vec::new())); + + // check direct conflicts + let orig_conflicts = wallet.conflicts(orig_txid).unwrap(); + assert_eq!(orig_conflicts.len(), 1); + assert_eq!(orig_conflicts[0].0, replacement_txid); + assert!(orig_conflicts[0].1.is_some()); + + // check ancestor conflicts + let child_conflicts = wallet.conflicts(child_txid).unwrap(); + assert_eq!(child_conflicts.len(), 1); + assert_eq!(child_conflicts[0].0, replacement_txid); + assert!(child_conflicts[0].1.is_some()); + + // Unknown transactions return UnknownTransaction. + let unknown = Txid::from_raw_hash(Hash::all_zeros()); + assert_eq!(wallet.conflicts(unknown), Err(UnknownTransaction)); + + // Known but wallet-irrelevant transactions return UnknownTransaction. + let (other_external, other_internal) = get_test_tr_single_sig_xprv_and_change_desc(); + let (other_wallet, other_txid) = get_funded_wallet(other_internal, other_external); + wallet + .apply_update(Update { + tx_update: other_wallet.tx_graph().clone().into(), + ..Default::default() + }) + .unwrap(); + assert_eq!(wallet.conflicts(other_txid), Err(UnknownTransaction)); } #[test]