From: Vihiga Tyonum Date: Tue, 30 Jun 2026 14:19:29 +0000 (+0100) Subject: feat(utxo-locking): Add wallet locking commands X-Git-Url: http://internal-gitweb-vhost/blockdata/script/encode/-script/display/ScriptInterface.Companion.html?a=commitdiff_plain;h=242d4da7feda9a1c2e564c8ebb811e2e018a6e4f;p=bdk-cli feat(utxo-locking): Add wallet locking commands - add wallet commands to lock, unlock and list locked utxos - update unspent command to show status of each outpoint --- diff --git a/src/client.rs b/src/client.rs index 9aed57f..53b3ecf 100644 --- a/src/client.rs +++ b/src/client.rs @@ -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) } diff --git a/src/commands.rs b/src/commands.rs index 7559d5d..5ed726e 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -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. diff --git a/src/handlers/offline.rs b/src/handlers/offline.rs index 5af69b5..e0ee284 100644 --- a/src/handlers/offline.rs +++ b/src/handlers/offline.rs @@ -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>> for UnspentCommand { fn execute(&self, ctx: &mut AppContext>) -> Result { 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>> 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, +} + +impl AppCommand>> for LockUtxoCommand { + type Output = ListResult; + fn execute(&self, ctx: &mut AppContext>) -> Result { + 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, +} + +impl AppCommand>> for UnlockUtxoCommand { + type Output = ListResult; + fn execute(&self, ctx: &mut AppContext>) -> Result { + 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>> for LockedUtxosCommand { + type Output = ListResult; + fn execute(&self, ctx: &mut AppContext>) -> Result { + let wallet = &mut ctx.state.wallet; + let locked = wallet + .list_locked_outpoints() + .map(|o| o.to_string()) + .collect(); + Ok(ListResult::new(locked)) + } +} diff --git a/src/utils/common.rs b/src/utils/common.rs index 051ebe0..0fa3dca 100644 --- a/src/utils/common.rs +++ b/src/utils/common.rs @@ -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(_) diff --git a/src/utils/types.rs b/src/utils/types.rs index 99bdcbd..e25d356 100644 --- a/src/utils/types.rs +++ b/src/utils/types.rs @@ -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, } } }