diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 60d1ccef..a48c23a1 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. @@ -1114,12 +1132,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, ) } @@ -2052,6 +2090,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 f4ed438a..c560aa4a 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,22 @@ impl CreateParams { self } + /// Use a custom minimum output value for the wallet. + /// + /// Outputs with a value below this threshold are ignored by operational wallet behavior, + /// similar to a custom dust limit. + /// + /// **WARNING:** This affects [`balance`], [`list_unspent`], and coin selection. Outputs + /// below this value will not appear in these views and will not be selected + /// for spending. + /// + /// [`balance`]: Wallet::balance + /// [`list_unspent`]: Wallet::list_unspent + 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 +242,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 +260,7 @@ impl LoadParams { check_change_descriptor: None, extract_keys: false, use_spk_cache: false, + min_output_value: None, } } @@ -331,6 +353,22 @@ impl LoadParams { self } + /// Use a custom minimum output value for the wallet. + /// + /// Outputs with a value below this threshold are ignored by operational wallet behavior, + /// similar to a custom dust limit. + /// + /// **WARNING:** This affects [`balance`], [`list_unspent`], and coin selection. Outputs + /// below this value will not appear in these views and will not be selected + /// for spending. + /// + /// [`balance`]: Wallet::balance + /// [`list_unspent`]: Wallet::list_unspent + 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..0a3c6d20 --- /dev/null +++ b/tests/min_output_value.rs @@ -0,0 +1,164 @@ +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::Wallet; +use bitcoin::{Address, Amount}; + +mod common; + +#[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_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() { + 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() { + 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_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" + ); +}