From 5968606e66d8337270414ed3d7e2bff5842535f2 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 | 11 +- src/wallet/export.rs | 18 ++- src/wallet/mod.rs | 242 ++++++++++++++++++++++++++------------ src/wallet/utils.rs | 11 +- tests/build_fee_bump.rs | 27 ++++- tests/persisted_wallet.rs | 5 +- tests/wallet.rs | 23 ++-- 7 files changed, 230 insertions(+), 107 deletions(-) diff --git a/examples/bitcoind_rpc.rs b/examples/bitcoind_rpc.rs index f0bbd7290..34cabc416 100644 --- a/examples/bitcoind_rpc.rs +++ b/examples/bitcoind_rpc.rs @@ -5,7 +5,8 @@ use bdk_bitcoind_rpc::{ use bdk_wallet::rusqlite::Connection; use bdk_wallet::{ bitcoin::{Block, Network}, - KeychainKind, Wallet, + chain::ChainPosition, + KeychainKind, TxCanonicality, Wallet, }; use clap::{self, Parser}; use std::{ @@ -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.canonicality, + TxCanonicality::Canonical(ChainPosition::Unconfirmed { .. }) + ) + }) + .map(|tx| tx.tx_node.tx), ); spawn(move || -> Result<(), anyhow::Error> { while let Some(emission) = emitter.next_block()? { 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 1cdd1cc7b..26fb1b847 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, error::Error as DescriptorError, policy::BuildSatisfaction, DerivedDescriptor, DescriptorMeta, ExtendedDescriptor, ExtractPolicy, IntoWalletDescriptor, @@ -181,8 +181,46 @@ 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<'a> { + /// Wallet-specific amounts, fees, and canonicality. + pub details: TxDetails, + /// Graph metadata such as anchors, first_seen, and last_seen. + pub tx_node: TxNode<'a, Arc, ConfirmationBlockTime>, + /// Latest backend observation that this tx was absent from the mempool. + pub last_evicted: Option, + /// Direct conflicts spending the same inputs. + pub conflicts: Vec, +} + +/// The canonicality of a wallet-relevant transaction. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TxCanonicality { + /// The transaction is currently canonical. + Canonical(ChainPosition), + /// The transaction is currently not canonical. + NonCanonical, +} + +impl From>> for TxCanonicality { + fn from(chain_position: Option>) -> Self { + match chain_position { + Some(chain_position) => Self::Canonical(chain_position), + None => Self::NonCanonical, + } + } +} + +impl TransactionInfo<'_> { + /// Returns the chain position if the transaction is currently canonical. + pub fn chain_position(&self) -> Option<&ChainPosition> { + match &self.details.canonicality { + TxCanonicality::Canonical(chain_position) => Some(chain_position), + TxCanonicality::NonCanonical => None, + } + } +} impl Wallet { /// Build a new single descriptor [`Wallet`]. @@ -789,33 +827,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. @@ -1000,95 +1011,168 @@ 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<'a>( + &self, + tx_node: TxNode<'a, Arc, ConfirmationBlockTime>, + chain_position: Option>, + ) -> TransactionInfo<'a> { + 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 canonicality = chain_position.into(); + let conflicts = self + .tx_graph + .graph() + .direct_conflicts(&tx_node.tx) + .map(|(_, conflict_txid)| conflict_txid) + .collect::>() + .into_iter() + .collect(); + let last_evicted = self.tx_graph.graph().get_last_evicted(txid); + + TransactionInfo { + details: TxDetails { + txid, + received, + sent, + fee, + fee_rate, + balance_delta, + canonicality, + tx: tx_node.tx.clone(), + }, + tx_node, + last_evicted, + 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 canonicality. + /// * Direct conflicts spending the same inputs. /// /// ```rust, no_run /// use bdk_chain::Anchor; - /// use bdk_wallet::{chain::ChainPosition, Wallet}; + /// use bdk_wallet::{chain::ChainPosition, TxCanonicality, 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.tx_node.tx); /// /// // list all transaction anchors - /// for anchor in wallet_tx.tx_node.anchors { + /// for anchor in tx_info.tx_node.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 { + /// // get canonicality of transaction + /// match tx_info.details.canonicality { + /// TxCanonicality::Canonical(ChainPosition::Confirmed { /// anchor, /// transitively: None, - /// } => println!( + /// }) => 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 { + /// TxCanonicality::Canonical(ChainPosition::Confirmed { /// anchor, /// transitively: Some(_), - /// } => println!( + /// }) => 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!( + /// TxCanonicality::Canonical(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 /// ), + /// TxCanonicality::NonCanonical => println!( + /// "tx is not canonical, last evicted at {:?}", + /// tx_info.last_evicted + /// ), /// } /// ``` /// /// [`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(); - 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(|tx| tx.tx_node.txid == txid) + .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)) } - /// 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 mut wallet_txs = 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))) + .collect::>(); + + let mut non_canonical_txs = tx_graph + .full_txs() + .filter(|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)) + .collect::>(); + non_canonical_txs.sort_unstable_by_key(|tx_info| tx_info.tx_node.txid); + wallet_txs.extend(non_canonical_txs); + + wallet_txs.into_iter() } - /// 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 +1180,18 @@ 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.chain_position().cmp(&tx1.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 } @@ -2535,10 +2619,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 @@ -2656,14 +2739,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, @@ -2689,14 +2772,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/utils.rs b/src/wallet/utils.rs index adf239f9b..9232bb49a 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 super::TxCanonicality; + 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, + /// The canonicality of the transaction. + pub canonicality: TxCanonicality, /// The complete [`Transaction`]. pub tx: Arc, } diff --git a/tests/build_fee_bump.rs b/tests/build_fee_bump.rs index 5b7befee0..e68203219 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() + .chain_position() + .cloned() + .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() + .chain_position() + .cloned() + .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() + .chain_position() + .cloned() + .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..bd08b1969 100644 --- a/tests/persisted_wallet.rs +++ b/tests/persisted_wallet.rs @@ -370,9 +370,10 @@ fn wallet_should_persist_anchors_and_recover() { } = wallet .get_tx(txid) .expect("should retrieve stored tx") - .chain_position + .chain_position() + .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 268c66f8a..0526692db 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -10,7 +10,7 @@ use bdk_wallet::psbt::PsbtUtils; use bdk_wallet::signer::{SignOptions, SignerError}; use bdk_wallet::test_utils::*; use bdk_wallet::KeychainKind; -use bdk_wallet::{AddressInfo, Balance, PersistedWallet, Update, Wallet, WalletTx}; +use bdk_wallet::{AddressInfo, Balance, PersistedWallet, TransactionInfo, Update, Wallet}; use bitcoin::constants::COINBASE_MATURITY; use bitcoin::hashes::Hash; use bitcoin::script::PushBytesBuf; @@ -2807,11 +2807,14 @@ 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.chain_position().cmp(&t1.chain_position())); let conf_heights: Vec> = sorted_txs .iter() - .map(|tx| tx.chain_position.confirmation_height_upper_bound()) + .map(|tx| { + tx.chain_position() + .and_then(|position| position.confirmation_height_upper_bound()) + }) .collect(); assert_eq!([None, Some(2000), Some(1000)], conf_heights.as_slice()); } @@ -2875,12 +2878,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(), @@ -2893,8 +2896,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 cd09dc550a3b51ebba514eaad4c8b7ef232fb7ef 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 txs in Wallet::{get_tx, transactions} --- src/test_utils.rs | 12 ++++++ tests/wallet.rs | 107 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 103 insertions(+), 16 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 0526692db..5dc7dee2f 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -10,7 +10,9 @@ use bdk_wallet::psbt::PsbtUtils; use bdk_wallet::signer::{SignOptions, SignerError}; use bdk_wallet::test_utils::*; use bdk_wallet::KeychainKind; -use bdk_wallet::{AddressInfo, Balance, PersistedWallet, TransactionInfo, Update, Wallet}; +use bdk_wallet::{ + AddressInfo, Balance, PersistedWallet, TransactionInfo, TxCanonicality, Update, Wallet, +}; use bitcoin::constants::COINBASE_MATURITY; use bitcoin::hashes::Hash; use bitcoin::script::PushBytesBuf; @@ -2875,29 +2877,102 @@ 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.tx_node.txid == send_txid) + .expect("non-canonical send_tx should be included"); + assert!(info.chain_position().is_none()); } #[test] -fn test_transaction_info_details() { - let (test_wallet, txid_1) = get_funded_wallet_wpkh(); - let tx_info_1_option = test_wallet.get_tx(txid_1); - - assert!(tx_info_1_option.is_some()); - let tx_details_1 = tx_info_1_option.unwrap().details; +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!( - tx_details_1.txid.to_string(), + details.txid.to_string(), "f2a03cdfe1bb6a295b0a4bb4385ca42f95e4b2c6d9a7a59355d32911f957a5b3" ); - assert_eq!(tx_details_1.received, Amount::from_sat(50000)); - assert_eq!(tx_details_1.sent, Amount::from_sat(76000)); - assert_eq!(tx_details_1.fee.unwrap(), Amount::from_sat(1000)); - assert_eq!(tx_details_1.balance_delta, SignedAmount::from_sat(-26000)); + 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!(matches!(details.canonicality, TxCanonicality::Canonical(_))); + assert!(info.chain_position().is_some()); + assert!(info.last_evicted.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); - // 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 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!(matches!( + orig_info.details.canonicality, + TxCanonicality::NonCanonical + )); + assert!(orig_info.chain_position().is_none()); + assert_eq!(orig_info.last_evicted, 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!(matches!( + rbf_info.details.canonicality, + TxCanonicality::Canonical(_) + )); + assert!(rbf_info.chain_position().is_some()); + assert!(rbf_info.last_evicted.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]