diff --git a/crates/wallet/src/wallet/mod.rs b/crates/wallet/src/wallet/mod.rs index e4dc6d056..92aecf74f 100644 --- a/crates/wallet/src/wallet/mod.rs +++ b/crates/wallet/src/wallet/mod.rs @@ -2026,6 +2026,8 @@ impl Wallet { self.keychains().count() == 1 || params.change_policy.is_satisfied_by(local_output) }) + // only add utxos that match the confirmation policy + .filter(|local_output| params.confirmation_policy.is_satisfied_by(local_output)) // only add to optional UTxOs those marked as spendable .filter(|local_output| !params.unspendable.contains(&local_output.outpoint)) // if bumping fees only add to optional UTxOs those confirmed diff --git a/crates/wallet/src/wallet/tx_builder.rs b/crates/wallet/src/wallet/tx_builder.rs index 235987fc0..06cc425ca 100644 --- a/crates/wallet/src/wallet/tx_builder.rs +++ b/crates/wallet/src/wallet/tx_builder.rs @@ -134,6 +134,7 @@ pub(crate) struct TxParams { pub(crate) sequence: Option, pub(crate) version: Option, pub(crate) change_policy: ChangeSpendPolicy, + pub(crate) confirmation_policy: ConfirmationSpendPolicy, pub(crate) only_witness_utxo: bool, pub(crate) add_global_xpubs: bool, pub(crate) include_output_redeem_witness_script: bool, @@ -513,6 +514,43 @@ impl<'a, Cs> TxBuilder<'a, Cs> { self } + /// Only spend confirmed outputs + /// + /// This effectively adds all the unconfirmed outputs to the "unspendable" list. See + /// [`TxBuilder::unspendable`]. + pub fn only_spend_confirmed(&mut self) -> &mut Self { + self.params.confirmation_policy = ConfirmationSpendPolicy::OnlyConfirmed; + self + } + + /// Only spend outputs confirmed before or on the given block height + /// + /// This effectively adds all the outputs not confirmed before or on the + /// given height to the "unspendable" list. See [`TxBuilder::unspendable`]. + pub fn only_spend_confirmed_since(&mut self, height: u32) -> &mut Self { + self.params.confirmation_policy = ConfirmationSpendPolicy::OnlyConfirmedSince { height }; + self + } + + /// Only spend unconfirmed outputs + /// + /// This effectively adds all the confirmed outputs to the "unspendable" list. See + /// [`TxBuilder::unspendable`]. + pub fn only_spend_unconfirmed(&mut self) -> &mut Self { + self.params.confirmation_policy = ConfirmationSpendPolicy::OnlyUnconfirmed; + self + } + + /// Set a specific [`ConfirmationSpendPolicy`]. See [`TxBuilder::only_spend_confirmed`] and + /// [`TxBuilder::only_spend_unconfirmed`] for some shortcuts. + pub fn confirmation_policy( + &mut self, + confirmation_policy: ConfirmationSpendPolicy, + ) -> &mut Self { + self.params.confirmation_policy = confirmation_policy; + self + } + /// Only Fill-in the [`psbt::Input::witness_utxo`](bitcoin::psbt::Input::witness_utxo) field when spending from /// SegWit descriptors. /// @@ -838,6 +876,38 @@ impl ChangeSpendPolicy { } } +/// Policy regarding the use of unconfirmed outputs when creating a transaction +#[derive(Default, Debug, Ord, PartialOrd, Eq, PartialEq, Hash, Clone, Copy)] +pub enum ConfirmationSpendPolicy { + /// Use both confirmed and unconfirmed outputs (default) + #[default] + UnconfirmedAllowed, + /// Only use confirmed outputs (see [`TxBuilder::only_spend_confirmed`]) + OnlyConfirmed, + /// Only use outputs confirmed since `height` (see [`TxBuilder::only_spend_confirmed`]) + OnlyConfirmedSince { + /// The height at which the outputs should be confirmed + height: u32, + }, + /// Only use unconfirmed outputs (see [`TxBuilder::only_spend_unconfirmed`]) + OnlyUnconfirmed, +} + +impl ConfirmationSpendPolicy { + pub(crate) fn is_satisfied_by(&self, utxo: &LocalOutput) -> bool { + match self { + ConfirmationSpendPolicy::UnconfirmedAllowed => true, + ConfirmationSpendPolicy::OnlyConfirmed => utxo.chain_position.is_confirmed(), + ConfirmationSpendPolicy::OnlyConfirmedSince { height } => utxo + .chain_position + .confirmation_height_upper_bound() + .map(|h| h <= *height) + .unwrap_or(false), + ConfirmationSpendPolicy::OnlyUnconfirmed => !utxo.chain_position.is_confirmed(), + } + } +} + #[cfg(test)] mod test { const ORDERING_TEST_TX: &str = "0200000003c26f3eb7932f7acddc5ddd26602b77e7516079b03090a16e2c2f54\ diff --git a/crates/wallet/tests/wallet.rs b/crates/wallet/tests/wallet.rs index f42f0bcd5..1e888a014 100644 --- a/crates/wallet/tests/wallet.rs +++ b/crates/wallet/tests/wallet.rs @@ -766,6 +766,133 @@ fn test_create_tx_change_policy() { )); } +#[test] +fn test_create_tx_confirmation_policy() { + let (mut wallet, funding_txid) = get_funded_wallet_wpkh(); + assert_eq!(wallet.balance().confirmed, Amount::from_sat(50_000)); + insert_checkpoint( + &mut wallet, + BlockId { + height: 3_000, + hash: BlockHash::all_zeros(), + }, + ); + + let confirmed_tx = Transaction { + input: vec![], + output: vec![TxOut { + script_pubkey: wallet + .next_unused_address(KeychainKind::External) + .script_pubkey(), + value: Amount::from_sat(25_000), + }], + version: transaction::Version::non_standard(0), + lock_time: absolute::LockTime::ZERO, + }; + let confirmed_txid = confirmed_tx.compute_txid(); + insert_tx(&mut wallet, confirmed_tx); + let anchor = ConfirmationBlockTime { + block_id: wallet.latest_checkpoint().get(3_000).unwrap().block_id(), + confirmation_time: 200, + }; + insert_anchor(&mut wallet, confirmed_txid, anchor); + let unconfirmed_tx = Transaction { + input: vec![], + output: vec![TxOut { + script_pubkey: wallet + .next_unused_address(KeychainKind::External) + .script_pubkey(), + value: Amount::from_sat(25_000), + }], + version: transaction::Version::non_standard(0), + lock_time: absolute::LockTime::ZERO, + }; + let unconfirmed_txid = unconfirmed_tx.compute_txid(); + insert_tx(&mut wallet, unconfirmed_tx); + + let addr = wallet.next_unused_address(KeychainKind::External); + + let mut builder = wallet.build_tx(); + builder + .add_recipient(addr.script_pubkey(), Amount::from_sat(51_000)) + .only_spend_confirmed(); + let ret = builder.finish().unwrap(); + assert_eq!(ret.unsigned_tx.input.len(), 2); + assert!(ret + .unsigned_tx + .input + .iter() + .any(|i| i.previous_output.txid == funding_txid)); + assert!(ret + .unsigned_tx + .input + .iter() + .any(|i| i.previous_output.txid == confirmed_txid)); + + let mut builder = wallet.build_tx(); + builder + .add_recipient(addr.script_pubkey(), Amount::from_sat(51_000)) + .only_spend_confirmed_since(3_000); + let ret = builder.finish().unwrap(); + assert_eq!(ret.unsigned_tx.input.len(), 2); + assert!(ret + .unsigned_tx + .input + .iter() + .any(|i| i.previous_output.txid == funding_txid)); + assert!(ret + .unsigned_tx + .input + .iter() + .any(|i| i.previous_output.txid == confirmed_txid)); + + let mut builder = wallet.build_tx(); + builder + .add_recipient(addr.script_pubkey(), Amount::from_sat(25_000)) + .only_spend_confirmed_since(2_500); + let ret = builder.finish().unwrap(); + assert_eq!(ret.unsigned_tx.input.len(), 1); + assert!(ret + .unsigned_tx + .input + .iter() + .any(|i| i.previous_output.txid == funding_txid)); + + let mut builder = wallet.build_tx(); + builder + .add_recipient(addr.script_pubkey(), Amount::from_sat(24_000)) + .only_spend_unconfirmed(); + let ret = builder.finish().unwrap(); + assert_eq!(ret.unsigned_tx.input.len(), 1); + assert!(ret + .unsigned_tx + .input + .iter() + .any(|i| i.previous_output.txid == unconfirmed_txid)); + + let mut builder = wallet.build_tx(); + builder + .add_recipient(addr.script_pubkey(), Amount::from_sat(76_000)) + .only_spend_confirmed(); + assert!(matches!( + builder.finish(), + Err(CreateTxError::CoinSelection( + coin_selection::InsufficientFunds { .. } + )), + )); + + let mut builder = wallet.build_tx(); + builder + .add_recipient(addr.script_pubkey(), Amount::from_sat(76_000)) + .only_spend_unconfirmed(); + assert!(matches!( + builder.finish(), + Err(CreateTxError::CoinSelection( + coin_selection::InsufficientFunds { .. } + )), + )); +} + #[test] fn test_create_tx_default_sequence() { let (mut wallet, _) = get_funded_wallet_wpkh();