]> Untitled Git - bdk-cli/commitdiff
feat(utxo-locking): Add wallet locking commands
authorVihiga Tyonum <withtvpeter@gmail.com>
Tue, 30 Jun 2026 14:19:29 +0000 (15:19 +0100)
committerVihiga Tyonum <withtvpeter@gmail.com>
Tue, 30 Jun 2026 14:19:29 +0000 (15:19 +0100)
- add wallet commands to lock, unlock and list
locked utxos
- update unspent command to show status of each
outpoint

src/client.rs
src/commands.rs
src/handlers/offline.rs
src/utils/common.rs
src/utils/types.rs

index 9aed57fec89ebfe5b16a29259c5904431c076632..53b3ecf7c684da5ea7c6209f29f9b531c9f471b1 100644 (file)
@@ -98,14 +98,10 @@ impl BlockchainClient {
             #[cfg(feature = "cbf")]
             Self::KyotoClient { client } => {
                 let txid = tx.compute_txid();
-                let wtxid = client
-                    .requester
-                    .submit_package(tx)
-                    .await
-                    .map_err(|_| {
-                        tracing::warn!("Broadcast was unsuccessful");
-                        Error::Generic("Transaction broadcast timed out after 30 seconds".into())
-                    })?;
+                let wtxid = client.requester.submit_package(tx).await.map_err(|_| {
+                    tracing::warn!("Broadcast was unsuccessful");
+                    Error::Generic("Transaction broadcast timed out after 30 seconds".into())
+                })?;
                 tracing::info!("Successfully broadcast WTXID: {wtxid}");
                 Ok(txid)
             }
index 7559d5d3a415aa0bc3bb621e1476040f66f82c40..5ed726ed9245d64555ece5b6c1b1c563b001f6e8 100644 (file)
@@ -21,8 +21,9 @@ use crate::handlers::{
     key::{DeriveKeyCommand, GenerateKeyCommand, RestoreKeyCommand},
     offline::{
         BalanceCommand, BumpFeeCommand, CombinePsbtCommand, CreateTxCommand, ExtractPsbtCommand,
-        FinalizePsbtCommand, NewAddressCommand, PoliciesCommand, PublicDescriptorCommand,
-        SignCommand, TransactionsCommand, UnspentCommand, UnusedAddressCommand,
+        FinalizePsbtCommand, LockUtxoCommand, LockedUtxosCommand, NewAddressCommand,
+        PoliciesCommand, PublicDescriptorCommand, SignCommand, TransactionsCommand,
+        UnlockUtxoCommand, UnspentCommand, UnusedAddressCommand,
     },
 };
 
@@ -359,6 +360,12 @@ pub enum OfflineWalletSubCommand {
     /// Verify a BIP322 signature
     #[cfg(feature = "bip322")]
     VerifyMessage(VerifyMessageCommand),
+    /// Lock UTXO(s) so they're excluded from coin selection.
+    LockUtxo(LockUtxoCommand),
+    /// Unlock previously locked UTXO(s).
+    UnlockUtxo(UnlockUtxoCommand),
+    /// List currently locked UTXOs.
+    LockedUtxos(LockedUtxosCommand),
 }
 
 /// Wallet subcommands that needs a blockchain backend.
index 5af69b599780781ae4d9e5648f2548b653701707..e0ee2847ab959185ee5cdd5df060ccd43568119d 100644 (file)
@@ -81,6 +81,11 @@ impl OfflineWalletSubCommand {
             Self::VerifyMessage(verify_message_command) => verify_message_command
                 .execute(ctx)?
                 .write_out(std::io::stdout()),
+            Self::LockUtxo(lock_utxo) => lock_utxo.execute(ctx)?.write_out(std::io::stdout()),
+            Self::UnlockUtxo(unlock_utxo) => unlock_utxo.execute(ctx)?.write_out(std::io::stdout()),
+            Self::LockedUtxos(locked_utxos) => {
+                locked_utxos.execute(ctx)?.write_out(std::io::stdout())
+            }
         }
     }
 }
@@ -119,9 +124,13 @@ impl AppCommand<AppContext<OfflineOperations<'_>>> for UnspentCommand {
 
     fn execute(&self, ctx: &mut AppContext<OfflineOperations<'_>>) -> Result<Self::Output, Error> {
         let wallet = &mut ctx.state.wallet;
-        let utxos = wallet
-            .list_unspent()
-            .map(|utxo| UnspentDetails::from_local_output(&utxo, ctx.network))
+        let outputs: Vec<_> = wallet.list_unspent().collect();
+        let utxos = outputs
+            .into_iter()
+            .map(|utxo| {
+                let is_locked = wallet.is_outpoint_locked(utxo.outpoint);
+                UnspentDetails::from_local_output(&utxo, ctx.network, is_locked)
+            })
             .collect();
 
         Ok(ListResult::new(utxos))
@@ -841,3 +850,65 @@ impl AppCommand<AppContext<OfflineOperations<'_>>> for VerifyMessageCommand {
         })
     }
 }
+
+#[derive(Parser, Debug, Clone, PartialEq)]
+pub struct LockUtxoCommand {
+    /// Outpoint(s) to lock, format TXID:VOUT.
+    #[arg(env = "TXID:VOUT", long = "utxo", required = true, value_parser = parse_outpoint)]
+    pub utxos: Vec<OutPoint>,
+}
+
+impl AppCommand<AppContext<OfflineOperations<'_>>> for LockUtxoCommand {
+    type Output = ListResult<String>;
+    fn execute(&self, ctx: &mut AppContext<OfflineOperations<'_>>) -> Result<Self::Output, Error> {
+        let wallet = &mut ctx.state.wallet;
+        for out_point in &self.utxos {
+            if wallet.get_utxo(*out_point).is_none() {
+                eprintln!("warning: {out_point} is not a known wallet UTXO; locking anyway");
+            }
+            wallet.lock_outpoint(*out_point);
+        }
+        let locked = wallet
+            .list_locked_outpoints()
+            .map(|o| o.to_string())
+            .collect();
+        Ok(ListResult::new(locked))
+    }
+}
+
+#[derive(Parser, Debug, Clone, PartialEq)]
+pub struct UnlockUtxoCommand {
+    /// Outpoint(s) to unlock, format TXID:VOUT.
+    #[arg(env = "TXID:VOUT", long = "utxo", required = true, value_parser = parse_outpoint)]
+    pub utxos: Vec<OutPoint>,
+}
+
+impl AppCommand<AppContext<OfflineOperations<'_>>> for UnlockUtxoCommand {
+    type Output = ListResult<String>;
+    fn execute(&self, ctx: &mut AppContext<OfflineOperations<'_>>) -> Result<Self::Output, Error> {
+        let wallet = &mut ctx.state.wallet;
+        for out_point in &self.utxos {
+            wallet.unlock_outpoint(*out_point);
+        }
+        let locked = wallet
+            .list_locked_outpoints()
+            .map(|o| o.to_string())
+            .collect();
+        Ok(ListResult::new(locked))
+    }
+}
+
+#[derive(Parser, Debug, Clone, PartialEq)]
+pub struct LockedUtxosCommand {}
+
+impl AppCommand<AppContext<OfflineOperations<'_>>> for LockedUtxosCommand {
+    type Output = ListResult<String>;
+    fn execute(&self, ctx: &mut AppContext<OfflineOperations<'_>>) -> Result<Self::Output, Error> {
+        let wallet = &mut ctx.state.wallet;
+        let locked = wallet
+            .list_locked_outpoints()
+            .map(|o| o.to_string())
+            .collect();
+        Ok(ListResult::new(locked))
+    }
+}
index 051ebe03b31aad3ec03a93bc5070f6445060f05b..0fa3dca04666a212ddc3fb2ab388060f1dbacb43 100644 (file)
@@ -220,7 +220,10 @@ pub fn command_requires_db(command: &OfflineWalletSubCommand) -> bool {
         | OfflineWalletSubCommand::BumpFee(_)
         | OfflineWalletSubCommand::NewAddress(_)
         | OfflineWalletSubCommand::UnusedAddress(_)
-        | OfflineWalletSubCommand::CreateTx(_) => true,
+        | OfflineWalletSubCommand::CreateTx(_)
+        | OfflineWalletSubCommand::LockUtxo(_)
+        | OfflineWalletSubCommand::UnlockUtxo(_)
+        | OfflineWalletSubCommand::LockedUtxos(_) => true,
 
         OfflineWalletSubCommand::Policies(_)
         | OfflineWalletSubCommand::PublicDescriptor(_)
index 99bdcbd9394b7fb02a4a8865230843f7d3cceb2b..e25d3566ade8d29fa3174d0a7d8b643bd0d733d0 100644 (file)
@@ -55,10 +55,11 @@ pub struct UnspentDetails {
     pub is_spent: bool,
     pub derivation_index: u32,
     pub chain_position: serde_json::Value,
+    pub is_locked: bool,
 }
 
 impl UnspentDetails {
-    pub fn from_local_output(utxo: &LocalOutput, _network: Network) -> Self {
+    pub fn from_local_output(utxo: &LocalOutput, _network: Network, is_locked: bool) -> Self {
         let outpoint_str = utxo.outpoint.to_string();
 
         Self {
@@ -68,6 +69,7 @@ impl UnspentDetails {
             is_spent: utxo.is_spent,
             derivation_index: utxo.derivation_index,
             chain_position: serde_json::to_value(utxo.chain_position).unwrap_or(json!({})),
+            is_locked,
         }
     }
 }