From e75f40783c71d03072c87921426cd09816b1434d Mon Sep 17 00:00:00 2001 From: dorinengabdoh Date: Fri, 14 Aug 2026 11:39:39 +0500 Subject: [PATCH] feat: Add lock_utxos option to prevent double-spending unbroadcasted txs --- src/psbt/params.rs | 9 +++++++++ src/wallet/mod.rs | 13 +++++++++++++ src/wallet/tx_builder.rs | 7 +++++++ tests/create_psbt.rs | 30 ++++++++++++++++++++++++++++++ tests/wallet.rs | 27 +++++++++++++++++++++++++++ 5 files changed, 86 insertions(+) diff --git a/src/psbt/params.rs b/src/psbt/params.rs index cba8b7e5..e79f14eb 100644 --- a/src/psbt/params.rs +++ b/src/psbt/params.rs @@ -75,6 +75,8 @@ pub struct PsbtParams { pub(crate) fallback_sequence: Option, /// The context in which the params are used. pub(crate) marker: core::marker::PhantomData, + /// Set whether to automatically lock the selected UTXOs (inputs) of the created transaction. + pub(crate) lock_utxos: bool, } impl Default for PsbtParams { @@ -101,6 +103,7 @@ impl Default for PsbtParams { sequence_overrides: Default::default(), fallback_sequence: Default::default(), marker: core::marker::PhantomData, + lock_utxos: false, } } } @@ -276,6 +279,12 @@ impl PsbtParams { self } + /// Set whether to automatically lock the selected UTXOs (inputs) of the created transaction in the wallet. + pub fn lock_utxos(&mut self, lock: bool) -> &mut Self { + self.lock_utxos = lock; + self + } + /// Add the spend [`Assets`]. /// /// Assets are required to create a spending plan for an output controlled by the wallet's diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 3a5e7fde..8fd9ad22 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1572,6 +1572,7 @@ impl Wallet { // Sort inputs/outputs according to the chosen algorithm. params.ordering.sort_tx_with_aux_rand(&mut tx, rng); + let lock_utxos = params.lock_utxos; let psbt = self.complete_transaction(tx, coin_selection.selected, params)?; // Recording changes to the change keychain. @@ -1584,6 +1585,12 @@ impl Wallet { } } + if lock_utxos { + for input in &psbt.unsigned_tx.input { + self.lock_outpoint(input.previous_output); + } + } + Ok(psbt) } @@ -3270,6 +3277,12 @@ impl Wallet { } } + if params.lock_utxos { + for input in &psbt.unsigned_tx.input { + self.lock_outpoint(input.previous_output); + } + } + Ok((psbt, finalizer)) } diff --git a/src/wallet/tx_builder.rs b/src/wallet/tx_builder.rs index 6a5e1eea..01ebd8ad 100644 --- a/src/wallet/tx_builder.rs +++ b/src/wallet/tx_builder.rs @@ -140,6 +140,7 @@ pub(crate) struct TxParams { pub(crate) bumping_fee: Option, pub(crate) current_height: Option, pub(crate) allow_dust: bool, + pub(crate) lock_utxos: bool, } #[derive(Clone, Copy, Debug)] @@ -668,6 +669,12 @@ impl<'a, Cs> TxBuilder<'a, Cs> { self } + /// Set whether to automatically lock the selected UTXOs (inputs) of the created transaction in the wallet. + pub fn lock_utxos(&mut self, lock: bool) -> &mut Self { + self.params.lock_utxos = lock; + self + } + /// Replace the recipients already added with a new list pub fn set_recipients(&mut self, recipients: Vec<(ScriptBuf, Amount)>) -> &mut Self { self.params.recipients = recipients; diff --git a/tests/create_psbt.rs b/tests/create_psbt.rs index cd881d82..975de609 100644 --- a/tests/create_psbt.rs +++ b/tests/create_psbt.rs @@ -1211,3 +1211,33 @@ fn test_add_planned_psbt_input() -> anyhow::Result<()> { Ok(()) } + +#[test] +fn test_create_psbt_lock_utxos() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + + // Check that we have a spendable UTXO and it's not locked. + let utxo = wallet.list_unspent().next().unwrap().outpoint; + assert!(!wallet.is_outpoint_locked(utxo)); + + // Create a PSBT with lock_utxos(true) + let send_to = wallet.reveal_next_address(KeychainKind::External).address; + let mut params = PsbtParams::default(); + params + .add_recipients([(send_to.script_pubkey(), Amount::from_sat(20_000))]) + .lock_utxos(true); + let (psbt, _) = wallet.create_psbt(params).unwrap(); + + // Verify that the UTXO spent by this PSBT is now locked + let input_op = psbt.unsigned_tx.input[0].previous_output; + assert!(wallet.is_outpoint_locked(input_op)); + + // Creating a second PSBT should fail with InsufficientFunds because the only UTXO is locked + let send_to2 = wallet.reveal_next_address(KeychainKind::External).address; + let mut params2 = PsbtParams::default(); + params2 + .add_recipients([(send_to2.script_pubkey(), Amount::from_sat(20_000))]); + let result = wallet.create_psbt(params2); + assert!(result.is_err()); +} + diff --git a/tests/wallet.rs b/tests/wallet.rs index a27d21d8..18e814f0 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -3506,3 +3506,30 @@ fn test_create_and_spend_from_truc_tx() -> anyhow::Result<()> { Ok(()) } + +#[test] +fn test_tx_builder_lock_utxos() { + let (mut wallet, _) = get_funded_wallet_wpkh(); + + let utxo = wallet.list_unspent().next().unwrap().outpoint; + assert!(!wallet.is_outpoint_locked(utxo)); + + let send_to = wallet.reveal_next_address(KeychainKind::External).address; + let mut builder = wallet.build_tx(); + builder + .add_recipient(send_to.script_pubkey(), Amount::from_sat(20_000)) + .lock_utxos(true); + let psbt = builder.finish().unwrap(); + + let input_op = psbt.unsigned_tx.input[0].previous_output; + assert!(wallet.is_outpoint_locked(input_op)); + + // Subsequent build should fail with InsufficientFunds + let send_to2 = wallet.reveal_next_address(KeychainKind::External).address; + let mut builder2 = wallet.build_tx(); + builder2 + .add_recipient(send_to2.script_pubkey(), Amount::from_sat(20_000)); + let result = builder2.finish(); + assert!(result.is_err()); +} +