diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index b4eb7709..a3b88114 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -117,6 +117,7 @@ pub struct Wallet { network: Network, secp: SecpCtx, locked_outpoints: HashSet, + min_output_value: Option, } /// An update to [`Wallet`]. @@ -374,6 +375,7 @@ impl Wallet { stage, secp, locked_outpoints, + min_output_value: params.min_output_value, }) } @@ -581,6 +583,7 @@ impl Wallet { network, secp, locked_outpoints, + min_output_value: params.min_output_value, })) } @@ -589,6 +592,16 @@ impl Wallet { self.network } + /// Get the minimum output value, if set. + pub fn min_output_value(&self) -> Option { + self.min_output_value + } + + /// Set or clear the minimum output value. + pub fn set_min_output_value(&mut self, min_output_value: Option) { + self.min_output_value = min_output_value; + } + /// Iterator over all keychains in this wallet pub fn keychains(&self) -> impl Iterator { self.tx_graph.index.keychains() @@ -777,7 +790,11 @@ impl Wallet { } /// Return the list of unspent outputs of this wallet + /// + /// If [`min_output_value`](Wallet::min_output_value) is set, outputs with a value below + /// that threshold are excluded. pub fn list_unspent(&self) -> impl Iterator + '_ { + let min_value = self.min_output_value; self.tx_graph .graph() .filter_chain_unspents( @@ -787,6 +804,7 @@ impl Wallet { self.tx_graph.index.outpoints().iter().cloned(), ) .map(|((k, i), full_txo)| new_local_utxo(k, i, full_txo)) + .filter(move |utxo| min_value.is_none_or(|threshold| utxo.txout.value >= threshold)) } /// Get the [`TxDetails`] of a wallet transaction. @@ -799,7 +817,7 @@ impl Wallet { 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 balance_delta: SignedAmount = self.net_value(&tx.tx_node.tx); let chain_position = tx.chain_position; let tx_details: TxDetails = TxDetails { @@ -818,8 +836,11 @@ impl Wallet { /// List all relevant outputs (includes both spent and unspent, confirmed and unconfirmed). /// + /// If [`min_output_value`](Wallet::min_output_value) is set, outputs with a value below + /// that threshold are excluded. /// To list only unspent outputs (UTXOs), use [`Wallet::list_unspent`] instead. pub fn list_output(&self) -> impl Iterator + '_ { + let min_value = self.min_output_value; self.tx_graph .graph() .filter_chain_txouts( @@ -829,6 +850,7 @@ impl Wallet { self.tx_graph.index.outpoints().iter().cloned(), ) .map(|((k, i), full_txo)| new_local_utxo(k, i, full_txo)) + .filter(move |output| min_value.is_none_or(|threshold| output.txout.value >= threshold)) } /// Get all the checkpoints the wallet is currently storing indexed by height. @@ -976,6 +998,8 @@ impl Wallet { /// This method returns a tuple `(sent, received)`. Sent is the sum of the txin amounts /// that spend from previous txouts tracked by this wallet. Received is the summation /// of this tx's outputs that send to script pubkeys tracked by this wallet. + /// If [`min_output_value`](Wallet::min_output_value) is set, outputs with a value below + /// that threshold are excluded from both `sent` and `received`. /// /// # Examples /// @@ -997,7 +1021,36 @@ impl Wallet { /// let (sent, received) = wallet.sent_and_received(tx); /// ``` pub fn sent_and_received(&self, tx: &Transaction) -> (Amount, Amount) { - self.tx_graph.index.sent_and_received(tx, ..) + let (mut sent, mut received) = self.tx_graph.index.sent_and_received(tx, ..); + + // filter out outputs below the min_output_value threshold + if let Some(threshold) = self.min_output_value { + for spent in self.tx_graph.index.spent_txouts(tx) { + if spent.txout.value < threshold { + sent -= spent.txout.value; + } + } + for created in self.tx_graph.index.created_txouts(tx) { + if created.txout.value < threshold { + received -= created.txout.value; + } + } + } + + (sent, received) + } + + /// Compute the net value transferred to/from this wallet by `tx`. + /// + /// This is equivalent to `received - sent` from [`sent_and_received`], + /// and respects [`min_output_value`] if set. + /// + /// [`sent_and_received`]: Wallet::sent_and_received + /// [`min_output_value`]: Wallet::min_output_value + pub fn net_value(&self, tx: &Transaction) -> SignedAmount { + let (sent, received) = self.sent_and_received(tx); + received.to_signed().expect("valid `SignedAmount`") + - sent.to_signed().expect("valid `SignedAmount`") } /// Get a single transaction from the wallet as a [`WalletTx`] (if the transaction exists). @@ -1114,12 +1167,32 @@ impl Wallet { /// Return the balance, separated into available, trusted-pending, untrusted-pending, and /// immature values. + /// + /// If [`min_output_value`](Wallet::min_output_value) is set, outputs with a value below + /// that threshold are excluded from the balance. pub fn balance(&self) -> Balance { - self.tx_graph.graph().balance( + let graph = self.tx_graph.graph(); + let chain_tip = self.chain.tip().block_id(); + let min_value = self.min_output_value; + + let outpoints = self + .tx_graph + .index + .outpoints() + .iter() + .filter(|(_, op)| match min_value { + None => true, + Some(threshold) => graph + .get_txout(*op) + .is_none_or(|txout| txout.value >= threshold), + }) + .cloned(); + + graph.balance( &self.chain, - self.chain.tip().block_id(), + chain_tip, CanonicalizationParams::default(), - self.tx_graph.index.outpoints().iter().cloned(), + outpoints, |&(k, _), _| k == KeychainKind::Internal, ) } @@ -2001,6 +2074,11 @@ impl Wallet { ) // Filter out locked outpoints. .filter(|(_, txo)| !self.is_outpoint_locked(txo.outpoint)) + // Filter out outputs below `min_output_value`, if set. + .filter(|(_, txo)| { + self.min_output_value + .is_none_or(|threshold| txo.txout.value >= threshold) + }) // Only create LocalOutput if UTxO is mature. .filter_map(move |((k, i), full_txo)| { full_txo diff --git a/src/wallet/params.rs b/src/wallet/params.rs index 4868074b..14b07cbb 100644 --- a/src/wallet/params.rs +++ b/src/wallet/params.rs @@ -1,7 +1,7 @@ use alloc::boxed::Box; use bdk_chain::keychain_txout::DEFAULT_LOOKAHEAD; -use bitcoin::{BlockHash, Network, NetworkKind}; +use bitcoin::{Amount, BlockHash, Network, NetworkKind}; use miniscript::descriptor::KeyMap; use crate::{ @@ -67,6 +67,7 @@ pub struct CreateParams { pub(crate) genesis_hash: Option, pub(crate) lookahead: u32, pub(crate) use_spk_cache: bool, + pub(crate) min_output_value: Option, } impl CreateParams { @@ -90,6 +91,7 @@ impl CreateParams { genesis_hash: None, lookahead: DEFAULT_LOOKAHEAD, use_spk_cache: false, + min_output_value: None, } } @@ -112,6 +114,7 @@ impl CreateParams { genesis_hash: None, lookahead: DEFAULT_LOOKAHEAD, use_spk_cache: false, + min_output_value: None, } } @@ -137,6 +140,7 @@ impl CreateParams { genesis_hash: None, lookahead: DEFAULT_LOOKAHEAD, use_spk_cache: false, + min_output_value: None, } } @@ -182,6 +186,25 @@ impl CreateParams { self } + /// Use a minimum output value for the wallet. + /// + /// Outputs with a value below this threshold are ignored by the wallet, similar to + /// a custom dust limit. + /// + /// **WARNING:** This affects [`balance`], [`list_unspent`], [`list_output`], + /// [`sent_and_received`], [`net_value`], and coin selection. Outputs below this value + /// will not appear in these methods and will not be selected for spending. + /// + /// [`balance`]: Wallet::balance + /// [`list_unspent`]: Wallet::list_unspent + /// [`list_output`]: Wallet::list_output + /// [`sent_and_received`]: Wallet::sent_and_received + /// [`net_value`]: Wallet::net_value + pub fn min_output_value(mut self, value: Amount) -> Self { + self.min_output_value = Some(value); + self + } + /// Create [`PersistedWallet`] with the given [`WalletPersister`]. pub fn create_wallet

( self, @@ -222,6 +245,7 @@ pub struct LoadParams { pub(crate) check_change_descriptor: Option>, pub(crate) extract_keys: bool, pub(crate) use_spk_cache: bool, + pub(crate) min_output_value: Option, } impl LoadParams { @@ -239,6 +263,7 @@ impl LoadParams { check_change_descriptor: None, extract_keys: false, use_spk_cache: false, + min_output_value: None, } } @@ -309,6 +334,28 @@ impl LoadParams { self } + /// Use a custom minimum output value for the wallet. + /// + /// Outputs with a value below this threshold are ignored by the wallet, similar to + /// a custom dust limit. Unlike the standard dust limit, this is a + /// user-defined threshold that applies to balance, output listing, and tx construction. + /// + /// **WARNING:** This affects [`balance`], [`list_unspent`], [`list_output`], + /// [`sent_and_received`], [`net_value`], and coin selection. Outputs below this value + /// will not appear in these methods and will not be selected for spending. + /// [`get_utxo`] and fee calculations are not affected. + /// + /// [`balance`]: Wallet::balance + /// [`list_unspent`]: Wallet::list_unspent + /// [`list_output`]: Wallet::list_output + /// [`sent_and_received`]: Wallet::sent_and_received + /// [`net_value`]: Wallet::net_value + /// [`get_utxo`]: Wallet::get_utxo + pub fn min_output_value(mut self, value: Amount) -> Self { + self.min_output_value = Some(value); + self + } + /// Load [`PersistedWallet`] with the given [`WalletPersister`]. pub fn load_wallet

( self, diff --git a/tests/min_output_value.rs b/tests/min_output_value.rs new file mode 100644 index 00000000..bec6f7a9 --- /dev/null +++ b/tests/min_output_value.rs @@ -0,0 +1,245 @@ +use std::str::FromStr; + +use assert_matches::assert_matches; +use bdk_wallet::coin_selection::InsufficientFunds; +use bdk_wallet::error::CreateTxError; +use bdk_wallet::test_utils::*; +use bdk_wallet::{KeychainKind, Wallet}; +use bitcoin::{ + absolute, transaction, Address, Amount, OutPoint, SignedAmount, Transaction, TxIn, TxOut, Txid, +}; + +mod common; + +fn get_tx_for_min_output_value_test(wallet: &mut Wallet, funded_txid: Txid) -> Transaction { + let small_outpoint = receive_output_in_latest_block(wallet, Amount::from_sat(600)); + let wallet_change = wallet.next_unused_address(KeychainKind::External); + let recipient = Address::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5") + .expect("address") + .assume_checked(); + + Transaction { + version: transaction::Version::ONE, + lock_time: absolute::LockTime::ZERO, + input: vec![ + TxIn { + previous_output: OutPoint::new(funded_txid, 0), + ..Default::default() + }, + TxIn { + previous_output: small_outpoint, + ..Default::default() + }, + ], + output: vec![ + TxOut { + script_pubkey: wallet_change.script_pubkey(), + value: Amount::from_sat(700), + }, + TxOut { + script_pubkey: recipient.script_pubkey(), + value: Amount::from_sat(50_000), + }, + ], + } +} + +#[test] +fn test_min_output_value_list_unspent() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + let initial_count = wallet.list_unspent().count(); + + receive_output_in_latest_block(&mut wallet, Amount::from_sat(600)); + assert_eq!(wallet.list_unspent().count(), initial_count + 1); + + wallet.set_min_output_value(Some(Amount::from_sat(1_000))); + + let unspent: Vec<_> = wallet.list_unspent().collect(); + assert_eq!(unspent.len(), initial_count); + assert!(unspent + .iter() + .all(|u| u.txout.value >= Amount::from_sat(1_000))); +} + +#[test] +fn test_min_output_value_list_output() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + receive_output_in_latest_block(&mut wallet, Amount::from_sat(600)); + + let all_count = wallet.list_output().count(); + + wallet.set_min_output_value(Some(Amount::from_sat(1_000))); + + let filtered: Vec<_> = wallet.list_output().collect(); + assert!(filtered.len() < all_count); + assert!(filtered + .iter() + .all(|o| o.txout.value >= Amount::from_sat(1_000))); +} + +#[test] +fn test_min_output_value_balance() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + let initial_balance = wallet.balance().confirmed; + + receive_output_in_latest_block(&mut wallet, Amount::from_sat(600)); + assert_eq!( + wallet.balance().confirmed, + initial_balance + Amount::from_sat(600) + ); + + // Set threshold + wallet.set_min_output_value(Some(Amount::from_sat(1_000))); + + assert_eq!(wallet.balance().confirmed, initial_balance); +} + +#[test] +fn test_create_params_min_output_value_sets_wallet_value() { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let wallet = Wallet::create(desc, change_desc) + .network(bitcoin::Network::Regtest) + .min_output_value(Amount::from_sat(1_000)) + .create_wallet_no_persist() + .expect("descriptors must be valid"); + + assert_eq!(wallet.min_output_value(), Some(Amount::from_sat(1_000))); +} + +#[test] +fn test_load_params_min_output_value_sets_wallet_value() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + receive_output_in_latest_block(&mut wallet, Amount::from_sat(600)); + let changeset = wallet + .take_staged() + .expect("wallet should have a staged changeset"); + + let wallet = Wallet::load() + .min_output_value(Amount::from_sat(1_000)) + .load_wallet_no_persist(changeset) + .expect("changeset should be valid") + .expect("changeset should load a wallet"); + + assert_eq!(wallet.min_output_value(), Some(Amount::from_sat(1_000))); +} + +#[test] +fn test_set_min_output_value_can_be_cleared() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + let initial_balance = wallet.balance().confirmed; + receive_output_in_latest_block(&mut wallet, Amount::from_sat(600)); + + wallet.set_min_output_value(Some(Amount::from_sat(1_000))); + assert_eq!(wallet.min_output_value(), Some(Amount::from_sat(1_000))); + assert_eq!(wallet.balance().confirmed, initial_balance); + + wallet.set_min_output_value(None); + + assert_eq!(wallet.min_output_value(), None); + assert_eq!( + wallet.balance().confirmed, + initial_balance + Amount::from_sat(600) + ); +} + +#[test] +fn test_min_output_value_sent_and_received() { + let (mut wallet, funded_txid) = get_funded_wallet_wpkh(); + let tx = get_tx_for_min_output_value_test(&mut wallet, funded_txid); + + assert_eq!( + wallet.sent_and_received(&tx), + (Amount::from_sat(50_600), Amount::from_sat(700)) + ); + + wallet.set_min_output_value(Some(Amount::from_sat(1_000))); + + assert_eq!( + wallet.sent_and_received(&tx), + (Amount::from_sat(50_000), Amount::ZERO) + ); +} + +#[test] +fn test_min_output_value_net_value() { + let (mut wallet, funded_txid) = get_funded_wallet_wpkh(); + let tx = get_tx_for_min_output_value_test(&mut wallet, funded_txid); + + assert_eq!(wallet.net_value(&tx), SignedAmount::from_sat(-49_900)); + + wallet.set_min_output_value(Some(Amount::from_sat(1_000))); + + assert_eq!(wallet.net_value(&tx), SignedAmount::from_sat(-50_000)); +} + +#[test] +fn test_min_output_value_coin_selection() { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let (mut wallet, _, _) = new_wallet_and_funding_update(desc, Some(change_desc)); + receive_output(&mut wallet, Amount::from_sat(1_500), ReceiveTo::Mempool(1)); + + let recipient = Address::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5") + .expect("address") + .assume_checked(); + + let mut baseline_builder = wallet.build_tx(); + baseline_builder.add_recipient(recipient.script_pubkey(), Amount::from_sat(1_000)); + assert!( + baseline_builder.finish().is_ok(), + "wallet should spend the small UTXO before the threshold is set" + ); + + wallet.set_min_output_value(Some(Amount::from_sat(2_000))); + + let mut filtered_builder = wallet.build_tx(); + filtered_builder.add_recipient(recipient.script_pubkey(), Amount::from_sat(1_000)); + + assert_matches!( + filtered_builder.finish(), + Err(CreateTxError::CoinSelection(InsufficientFunds { + available: Amount::ZERO, + .. + })) + ); +} + +#[test] +fn test_min_output_value_coin_selection_ignores_small_utxos_when_large_utxos_exist() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + let small_outpoint = receive_output_in_latest_block(&mut wallet, Amount::from_sat(600)); + let recipient = Address::from_str("bcrt1q3qtze4ys45tgdvguj66zrk4fu6hq3a3v9pfly5") + .expect("address") + .assume_checked(); + + let mut baseline_builder = wallet.build_tx(); + baseline_builder + .drain_to(recipient.script_pubkey()) + .drain_wallet(); + let baseline_psbt = baseline_builder.finish().unwrap(); + + assert!( + baseline_psbt + .unsigned_tx + .input + .iter() + .any(|txin| txin.previous_output == small_outpoint), + "drain_wallet should include the small UTXO before the threshold is set" + ); + + wallet.set_min_output_value(Some(Amount::from_sat(1_000))); + + let mut filtered_builder = wallet.build_tx(); + filtered_builder + .drain_to(recipient.script_pubkey()) + .drain_wallet(); + let filtered_psbt = filtered_builder.finish().unwrap(); + + assert!( + filtered_psbt + .unsigned_tx + .input + .iter() + .all(|txin| txin.previous_output != small_outpoint), + "coin selection should ignore the small UTXO after applying the threshold" + ); +}