Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 46 additions & 3 deletions src/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ pub struct Wallet {
network: Network,
secp: SecpCtx,
locked_outpoints: HashSet<OutPoint>,
min_output_value: Option<Amount>,
}

/// An update to [`Wallet`].
Expand Down Expand Up @@ -374,6 +375,7 @@ impl Wallet {
stage,
secp,
locked_outpoints,
min_output_value: params.min_output_value,
})
}

Expand Down Expand Up @@ -581,6 +583,7 @@ impl Wallet {
network,
secp,
locked_outpoints,
min_output_value: params.min_output_value,
}))
}

Expand All @@ -589,6 +592,16 @@ impl Wallet {
self.network
}

/// Get the minimum output value, if set.
pub fn min_output_value(&self) -> Option<Amount> {
self.min_output_value
}

/// Set or clear the minimum output value.
pub fn set_min_output_value(&mut self, min_output_value: Option<Amount>) {
self.min_output_value = min_output_value;
}

/// Iterator over all keychains in this wallet
pub fn keychains(&self) -> impl Iterator<Item = (KeychainKind, &ExtendedDescriptor)> {
self.tx_graph.index.keychains()
Expand Down Expand Up @@ -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<Item = LocalOutput> + '_ {
let min_value = self.min_output_value;
self.tx_graph
.graph()
.filter_chain_unspents(
Expand All @@ -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.
Expand Down Expand Up @@ -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,
)
}
Expand Down Expand Up @@ -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
Expand Down
40 changes: 39 additions & 1 deletion src/wallet/params.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down Expand Up @@ -67,6 +67,7 @@ pub struct CreateParams {
pub(crate) genesis_hash: Option<BlockHash>,
pub(crate) lookahead: u32,
pub(crate) use_spk_cache: bool,
pub(crate) min_output_value: Option<Amount>,
}

impl CreateParams {
Expand All @@ -90,6 +91,7 @@ impl CreateParams {
genesis_hash: None,
lookahead: DEFAULT_LOOKAHEAD,
use_spk_cache: false,
min_output_value: None,
}
}

Expand All @@ -112,6 +114,7 @@ impl CreateParams {
genesis_hash: None,
lookahead: DEFAULT_LOOKAHEAD,
use_spk_cache: false,
min_output_value: None,
}
}

Expand All @@ -137,6 +140,7 @@ impl CreateParams {
genesis_hash: None,
lookahead: DEFAULT_LOOKAHEAD,
use_spk_cache: false,
min_output_value: None,
}
}

Expand Down Expand Up @@ -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<P>(
self,
Expand Down Expand Up @@ -222,6 +242,7 @@ pub struct LoadParams {
pub(crate) check_change_descriptor: Option<Option<DescriptorToExtract>>,
pub(crate) extract_keys: bool,
pub(crate) use_spk_cache: bool,
pub(crate) min_output_value: Option<Amount>,
}

impl LoadParams {
Expand All @@ -239,6 +260,7 @@ impl LoadParams {
check_change_descriptor: None,
extract_keys: false,
use_spk_cache: false,
min_output_value: None,
}
}

Expand Down Expand Up @@ -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<P>(
self,
Expand Down
164 changes: 164 additions & 0 deletions tests/min_output_value.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}