diff --git a/node/src/wallets.rs b/node/src/wallets.rs index 39846f6..0dbf5a2 100644 --- a/node/src/wallets.rs +++ b/node/src/wallets.rs @@ -154,7 +154,8 @@ impl RpcWallet { balance, dust: unspent .into_iter() - .filter(|output| output.is_spaceout) + .filter(|output| output.is_spaceout || + output.output.txout.value <= SpacesAwareCoinSelection::DUST_THRESHOLD) .map(|output| output.output.txout.value) .sum(), }; @@ -361,13 +362,17 @@ impl RpcWallet { wallet: &mut SpacesWallet, state: &mut LiveSnapshot, ) -> anyhow::Result { - // exclude all space outs + // Filters out all "space outs" from the selection. + // Note: This exclusion only applies to confirmed space outs; unconfirmed ones are not excluded. + // In practice, this should be fine since Spaces coin selection skips dust by default, + // so explicitly excluding space outs may be redundant. let excluded = Self::list_unspent(wallet, state)? .into_iter() .filter(|out| out.is_spaceout) .map(|out| out.output.outpoint) .collect::>(); - Ok(SpacesAwareCoinSelection::new(Vec::new(), excluded)) + + Ok(SpacesAwareCoinSelection::new(excluded)) } fn list_unspent( @@ -437,6 +442,15 @@ impl RpcWallet { store: &mut LiveSnapshot, tx: RpcWalletTxBuilder, ) -> anyhow::Result { + if let Some(dust) = tx.dust { + if dust > SpacesAwareCoinSelection::DUST_THRESHOLD { + // Allowing higher dust may space outs to be accidentally + // spent during coin selection + return Err(anyhow!("dust cannot be higher than {}", + SpacesAwareCoinSelection::DUST_THRESHOLD)); + } + } + let fee_rate = match tx.fee_rate.as_ref() { None => match Self::estimate_fee_rate(source) { None => return Err(anyhow!("could not estimate fee rate")), diff --git a/wallet/src/builder.rs b/wallet/src/builder.rs index 59e0425..483439e 100644 --- a/wallet/src/builder.rs +++ b/wallet/src/builder.rs @@ -1,5 +1,4 @@ use std::{ - cell::RefCell, cmp::min, collections::BTreeMap, default::Default, @@ -235,7 +234,7 @@ impl<'a, Cs: CoinSelectionAlgorithm> TxBuilderSpacesUtils<'a, Cs> for TxBuilder< placeholder.auction.outpoint.vout as u8, &offer, )?) - .expect("compressed psbt script bytes"); + .expect("compressed psbt script bytes"); let carrier = ScriptBuf::new_op_return(&compressed_psbt); @@ -895,23 +894,25 @@ impl Builder { } } -/// A coin selection algorithm that guarantees required utxos are ordered first -/// appends any funding/change outputs to the end of the selected utxos -/// also enables adding additional optional foreign utxos for funding +/// A coin selection algorithm that : +/// 1. Guarantees required utxos are ordered first appending +/// any funding/change outputs to the end of the selected utxos. +/// 2. Excludes all dust outputs to avoid accidentally spending space utxos +/// 3. Enables adding additional output exclusions #[derive(Debug, Clone)] pub struct SpacesAwareCoinSelection { pub default_algorithm: DefaultCoinSelectionAlgorithm, - // Additional UTXOs to fund the transaction - pub other_optional_utxos: RefCell>, // Exclude outputs pub exclude_outputs: Vec, } impl SpacesAwareCoinSelection { - pub fn new(optional_utxos: Vec, excluded: Vec) -> Self { + // Will skip any outputs with value less than the dust threshold + // to avoid accidentally spending space outputs + pub const DUST_THRESHOLD: Amount = Amount::from_sat(1200); + pub fn new(excluded: Vec) -> Self { Self { default_algorithm: DefaultCoinSelectionAlgorithm::default(), - other_optional_utxos: RefCell::new(optional_utxos), exclude_outputs: excluded, } } @@ -931,12 +932,10 @@ impl CoinSelectionAlgorithm for SpacesAwareCoinSelection { .map(|w| w.utxo.clone()) .collect::>(); - // Extend optional outputs - optional_utxos.extend(self.other_optional_utxos.borrow().iter().cloned()); - - // Filter out excluded outputs + // Filter out UTXOs that are either explicitly excluded or below the dust threshold optional_utxos.retain(|weighted_utxo| { - !self + weighted_utxo.utxo.txout().value > SpacesAwareCoinSelection::DUST_THRESHOLD + && !self .exclude_outputs .contains(&weighted_utxo.utxo.outpoint()) }); @@ -952,9 +951,6 @@ impl CoinSelectionAlgorithm for SpacesAwareCoinSelection { let mut optional = Vec::with_capacity(result.selected.len() - required.len()); for utxo in result.selected.drain(..) { if !required.iter().any(|u| u == &utxo) { - self.other_optional_utxos - .borrow_mut() - .retain(|x| x.utxo != utxo); optional.push(utxo); } }