diff --git a/Cargo.lock b/Cargo.lock index e0933eff..915b4ba1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -247,17 +247,16 @@ name = "bdk-cli" version = "3.0.0" dependencies = [ "assert_cmd", - "bdk_bip322", "bdk_bitcoind_rpc", "bdk_electrum", "bdk_esplora", "bdk_kyoto", + "bdk_message_signer", "bdk_redb", "bdk_sp", "bdk_testenv", "bdk_wallet", "bitcoin-payment-instructions", - "claims", "clap", "clap_complete", "cli-table", @@ -280,16 +279,6 @@ dependencies = [ "url", ] -[[package]] -name = "bdk_bip322" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262ee5f0c1056400b354391cabc541cb732c10488679de6389641e37f7e6c698" -dependencies = [ - "bdk_wallet", - "bitcoin", -] - [[package]] name = "bdk_bitcoind_rpc" version = "0.22.0" @@ -349,19 +338,29 @@ dependencies = [ [[package]] name = "bdk_kyoto" -version = "0.15.4" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f35b9f8b3aa8c4647bec7a92b050c496742d955e0ac1edcb4e7c2deabf63c54" +checksum = "346c1d502ee4613cf2201db9c3b93a2317772d8eaa372114f34bf5f374be94fd" dependencies = [ "bdk_wallet", "bip157", ] +[[package]] +name = "bdk_message_signer" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e0c999b3dcc448ceb6337658c363df3197bbed987b663810497bd9c20ea4ef" +dependencies = [ + "bdk_wallet", + "bitcoin", +] + [[package]] name = "bdk_redb" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c21112531eccdcb15c120d6c39f704b0c733b12a3dff6bcce106984ca9f1011" +checksum = "69ac8b88c9594c615fcb5b763e7afa16937e94f7a7f5ca334bf59a7fee69fea1" dependencies = [ "bdk_chain", "bdk_wallet", @@ -390,9 +389,9 @@ dependencies = [ [[package]] name = "bdk_wallet" -version = "2.4.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c99821af39c7df004bd411ece2ef22d9fba5a631b4489f3d9a0ac0d19637e2d0" +checksum = "1284fb23acc3e3022673712b55f4d5ce7e38aadc2c49bbef830dc3935f0a3289" dependencies = [ "bdk_chain", "bip39", @@ -420,13 +419,14 @@ dependencies = [ [[package]] name = "bip157" -version = "0.3.4" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88df5c18baaea9be4219679afbd4fc26491606f89f6ecdaffcdcabd67635b07b" +checksum = "14485468baff5ec4114d186f1683635f3094de9f5a4b3953bf82ac6cccba40e8" dependencies = [ "bip324", "bitcoin", "bitcoin-address-book", + "bitcoin_hashes 0.20.0", "tokio", ] @@ -456,13 +456,14 @@ dependencies = [ [[package]] name = "bitcoin" -version = "0.32.101" +version = "0.32.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed8ccb78a9ff7a6fbb90e2fb9b8588b4a9928d49d8af3cb789108a84ea6b0ce" +checksum = "1e499f9fc0407f50fe98af744ab44fa67d409f76b6772e1689ec8485eb0c0f66" dependencies = [ "base58ck", "base64 0.21.7", "bech32", + "bitcoin-internals 0.3.0", "bitcoin-io 0.1.101", "bitcoin-units", "bitcoin_hashes 0.14.101", @@ -481,6 +482,15 @@ dependencies = [ "bitcoin", ] +[[package]] +name = "bitcoin-consensus-encoding" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d7ca3dc8ff835693ad73bf1596240c06f974a31eeb3f611aaedf855f1f2725" +dependencies = [ + "bitcoin-internals 0.5.0", +] + [[package]] name = "bitcoin-consensus-encoding" version = "1.0.0" @@ -509,6 +519,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" +dependencies = [ + "serde", +] + [[package]] name = "bitcoin-internals" version = "0.4.2" @@ -530,7 +549,7 @@ version = "0.1.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" dependencies = [ - "bitcoin-consensus-encoding", + "bitcoin-consensus-encoding 1.0.0", ] [[package]] @@ -584,7 +603,7 @@ version = "0.1.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cb95693f371d089a4b5b6fc41c6f3ea6e01ee8c15388335dfac8ea685173b51" dependencies = [ - "bitcoin-consensus-encoding", + "bitcoin-consensus-encoding 1.0.0", "serde", ] @@ -609,6 +628,17 @@ dependencies = [ "hex-conservative 0.3.2", ] +[[package]] +name = "bitcoin_hashes" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8a45c2b41c457a9a9e4670422fcbdf109afb3b22bc920b4045e8bdfd788a3d" +dependencies = [ + "bitcoin-consensus-encoding 0.1.0", + "bitcoin-internals 0.5.0", + "hex-conservative 0.3.2", +] + [[package]] name = "bitcoin_uri" version = "0.1.0" @@ -716,9 +746,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bzip2" @@ -877,12 +907,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "claims" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bba18ee93d577a8428902687bcc2b6b45a56b1981a1f6d779731c86cc4c5db18" - [[package]] name = "clap" version = "4.6.1" @@ -1040,9 +1064,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -1929,9 +1953,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" dependencies = [ "defmt", "jiff-static", @@ -1943,9 +1967,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" dependencies = [ "proc-macro2", "quote", @@ -4093,18 +4117,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.53" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.53" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 1f91dd4f..e97625ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ readme = "README.md" license = "MIT" [dependencies] -bdk_wallet = { version = "2.4.0", features = ["rusqlite", "keys-bip39", "compiler", "std"] } +bdk_wallet = { version = "3.1.0", features = ["rusqlite", "keys-bip39", "compiler", "std"] } clap = { version = "4.6", features = ["derive","env"] } clap_complete = "4.6" dirs = { version = "6.0.0" } @@ -32,14 +32,14 @@ tap = "1.0.1" bdk_bitcoind_rpc = { version = "0.22.0", features = ["std"], optional = true } bdk_electrum = { version = "0.24.0", optional = true } bdk_esplora = { version = "0.22.2", features = ["async-https", "tokio"], optional = true } -bdk_kyoto = { version = "0.15.4", optional = true } -bdk_redb = { version = "0.1.1", optional = true } +bdk_kyoto = { version = "0.17.0", optional = true } +bdk_redb = { version = "0.2.0", optional = true } bdk_sp = { version = "0.1.0", optional = true, git = "https://github.com/bitcoindevkit/bdk-sp", tag = "v0.1.0" } shlex = { version = "1.3.0", optional = true } payjoin = { version = "0.25.0", features = ["v1", "v2", "io", "_test-utils"], optional = true} reqwest = { version = "0.13.2", default-features = false, features = ["rustls"], optional = true } url = { version = "2.5.8", optional = true } -bdk_bip322 = { version = "0.1.0", optional = true } +bdk_message_signer = { version = "0.2.0", optional = true } bitcoin-payment-instructions = { version = "0.7.0", optional = true} [features] @@ -61,7 +61,7 @@ dns_payment = ["bitcoin-payment-instructions"] # Internal features _payjoin-dependencies = ["payjoin", "reqwest", "url", "sqlite"] -bip322 = ["bdk_bip322"] +bip322 = ["bdk_message_signer"] # Use this to consensus verify transactions at sync time verify = [] @@ -74,7 +74,6 @@ compiler = [] silent-payments = ["dep:bdk_sp"] [dev-dependencies] -claims = "0.8.0" predicates = "3.0" tempfile = "3.8" assert_cmd = "2.2.2" diff --git a/src/client.rs b/src/client.rs index 483183c0..54c28d55 100644 --- a/src/client.rs +++ b/src/client.rs @@ -23,10 +23,7 @@ use { }; #[cfg(feature = "cbf")] -use { - crate::utils::trace_logger, - bdk_kyoto::{BuilderExt, LightClient}, -}; +use {crate::utils::trace_logger, bdk_kyoto::BuilderExt}; #[cfg(any( feature = "electrum", @@ -101,14 +98,10 @@ impl BlockchainClient { #[cfg(feature = "cbf")] Self::KyotoClient { client } => { let txid = tx.compute_txid(); - let wtxid = client - .requester - .broadcast_random(tx.clone()) - .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) } @@ -205,7 +198,8 @@ impl BlockchainClient { #[cfg(feature = "cbf")] pub struct KyotoClientHandle { pub requester: bdk_kyoto::Requester, - pub update_subscriber: tokio::sync::Mutex, + pub update_subscriber: + tokio::sync::Mutex>, } #[cfg(any( @@ -267,21 +261,16 @@ pub(crate) fn new_blockchain_client( .data_dir(&_datadir) .build_with_wallet(_wallet, scan_type)?; - let LightClient { - requester, - info_subscriber, - warning_subscriber, - update_subscriber, - node, - } = light_client; + let (client, logging, update_subscriber) = light_client.subscribe(); + // `start()` spawns the node's run loop on a tokio task internally. + let requester = client.start().requester(); let subscriber = tracing_subscriber::FmtSubscriber::new(); let _ = tracing::subscriber::set_global_default(subscriber); - tokio::task::spawn(async move { node.run().await }); - tokio::task::spawn( - async move { trace_logger(info_subscriber, warning_subscriber).await }, - ); + tokio::task::spawn(async move { + trace_logger(logging.info_subscriber, logging.warning_subscriber).await + }); BlockchainClient::KyotoClient { client: Box::new(KyotoClientHandle { diff --git a/src/commands.rs b/src/commands.rs index 1d920ad8..613a4f6b 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, }, }; @@ -366,6 +367,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), /// Creates a new unsigned transaction from DNS payment instructions. #[cfg(feature = "dns_payment")] CreateDnsTx(CreateDnsTxCommand), diff --git a/src/error.rs b/src/error.rs index 7f7eab04..dfd9f336 100644 --- a/src/error.rs +++ b/src/error.rs @@ -155,10 +155,9 @@ pub enum BDKCliError { #[cfg(feature = "payjoin")] #[error("Payjoin database error: {0}")] PayjoinDb(#[from] crate::handlers::payjoin::db::Error), - #[cfg(feature = "bip322")] #[error("BIP-322 error: {0}")] - Bip322Error(#[from] bdk_bip322::error::Error), + Bip322Error(#[from] bdk_message_signer::error::Error), } impl From for BDKCliError { diff --git a/src/handlers/descriptor.rs b/src/handlers/descriptor.rs index e52863a8..15d2d59a 100644 --- a/src/handlers/descriptor.rs +++ b/src/handlers/descriptor.rs @@ -53,12 +53,12 @@ impl AppCommand> for DescriptorCommand { match &self.key { Some(key) => { if is_mnemonic(key) { - generate_descriptor_from_mnemonic(key, ctx.network, &self.desc_type) + generate_descriptor_from_mnemonic(key, ctx.network.into(), &self.desc_type) } else { - generate_descriptors(&self.desc_type, key, ctx.network) + generate_descriptors(&self.desc_type, key, ctx.network.into()) } } - None => generate_descriptor_with_mnemonic(ctx.network, &self.desc_type), + None => generate_descriptor_with_mnemonic(ctx.network.into(), &self.desc_type), } } } diff --git a/src/handlers/dns/mod.rs b/src/handlers/dns/mod.rs index 6e682bc6..628bfbdb 100644 --- a/src/handlers/dns/mod.rs +++ b/src/handlers/dns/mod.rs @@ -127,7 +127,7 @@ impl AsyncAppCommand>> for CreateDnsTxCommand { tx_builder.set_exact_sequence(Sequence::MAX); } if self.offline_signer { - tx_builder.include_output_redeem_witness_script(); + tx_builder.add_global_xpubs(); } if let Some(fee_rate) = self.fee_rate && let Some(fee_rate) = FeeRate::from_sat_per_vb(fee_rate as u64) diff --git a/src/handlers/key.rs b/src/handlers/key.rs index c1a9e763..e7bb6457 100644 --- a/src/handlers/key.rs +++ b/src/handlers/key.rs @@ -56,7 +56,7 @@ impl AppCommand> for GenerateKeyCommand { .map_err(|_| Error::Generic("Mnemonic generation error".to_string()))?; let mnemonic = mnemonic.into_key(); let xkey: ExtendedKey = (mnemonic.clone(), self.password.clone()).into_extended_key()?; - let xprv = xkey.into_xprv(ctx.network).ok_or_else(|| { + let xprv = xkey.into_xprv(ctx.network.into()).ok_or_else(|| { Error::Generic("Privatekey info not found (should not happen)".to_string()) })?; let fingerprint = xprv.fingerprint(&secp); @@ -139,7 +139,7 @@ impl AppCommand> for RestoreKeyCommand { let mnemonic = Mnemonic::parse_in(Language::English, &self.mnemonic)?; let xkey: ExtendedKey = (mnemonic.clone(), &self.password).0.into_extended_key()?; - let xprv = xkey.into_xprv(ctx.network).ok_or_else(|| { + let xprv = xkey.into_xprv(ctx.network.into()).ok_or_else(|| { Error::Generic("Privatekey info not found (should not happen)".to_string()) })?; let fingerprint = xprv.fingerprint(&secp); diff --git a/src/handlers/offline.rs b/src/handlers/offline.rs index 6853194e..e24ac5c3 100644 --- a/src/handlers/offline.rs +++ b/src/handlers/offline.rs @@ -32,7 +32,7 @@ use { use { crate::utils::parse_signature_format, crate::utils::types::MessageResult, - bdk_bip322::{BIP322, MessageProof}, + bdk_message_signer::{MessageProof, MessageSigner}, }; impl OfflineWalletSubCommand { @@ -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()) + } #[cfg(feature = "dns_payment")] Self::CreateDnsTx(_) => Err(Error::Generic( "CreateDnsTx is dispatched asynchronously through main".to_string(), @@ -123,9 +128,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)) @@ -269,7 +278,7 @@ impl AppCommand>> for CreateTxCommand { } if self.offline_signer { - tx_builder.include_output_redeem_witness_script(); + tx_builder.add_global_xpubs(); } if let Some(fee_rate) = self.fee_rate @@ -424,7 +433,7 @@ impl AppCommand>> for CreateSpTxCommand { tx_builder.set_exact_sequence(Sequence::MAX); if self.offline_signer { - tx_builder.include_output_redeem_witness_script(); + tx_builder.add_global_xpubs(); } if let Some(fee_rate) = self.fee_rate @@ -586,7 +595,7 @@ impl AppCommand>> for BumpFeeCommand { } if self.offline_signer { - tx_builder.include_output_redeem_witness_script(); + tx_builder.add_global_xpubs(); } if let Some(utxos) = &self.utxos { @@ -851,3 +860,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 e2866617..a5f8c15a 100644 --- a/src/utils/common.rs +++ b/src/utils/common.rs @@ -1,8 +1,8 @@ use crate::{commands::WalletOpts, config::WalletConfig, error::BDKCliError as Error}; -#[cfg(feature = "bip322")] -use bdk_bip322::SignatureFormat; #[cfg(feature = "cbf")] use bdk_kyoto::{Info, Receiver, UnboundedReceiver, Warning}; +#[cfg(feature = "bip322")] +use bdk_message_signer::SignatureFormat; #[cfg(feature = "silent-payments")] use bdk_sp::encoding::SilentPaymentCode; use bdk_wallet::bitcoin::{Address, Network, OutPoint, ScriptBuf}; @@ -12,7 +12,7 @@ use bdk_wallet::bitcoin::{Address, Network, OutPoint, ScriptBuf}; feature = "cbf", feature = "rpc" ))] -use bdk_wallet::{bitcoin::Psbt, event::WalletEvent}; +use bdk_wallet::{WalletEvent, bitcoin::Psbt}; use crate::commands::OfflineWalletSubCommand; use std::{ @@ -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/descriptors.rs b/src/utils/descriptors.rs index 44e795f1..9b0f5b7c 100644 --- a/src/utils/descriptors.rs +++ b/src/utils/descriptors.rs @@ -6,7 +6,7 @@ use bdk_wallet::{ KeychainKind, bip39::{Language, Mnemonic}, bitcoin::{ - Network, + NetworkKind, bip32::{DerivationPath, Xpriv, Xpub}, secp256k1::Secp256k1, }, @@ -25,7 +25,7 @@ use crate::utils::types::KeychainPair; pub fn generate_descriptors( desc_type: &str, key: &str, - network: Network, + network: NetworkKind, ) -> Result { let is_private = key.starts_with("xprv") || key.starts_with("tprv"); @@ -40,7 +40,7 @@ pub fn generate_descriptors( _ => 84u32, }; let coin_type = match network { - Network::Bitcoin => 0u32, + NetworkKind::Main => 0u32, _ => 1u32, }; let derivation_path = DerivationPath::from_str(&format!("m/{purpose}h/{coin_type}h/0h"))?; @@ -52,7 +52,7 @@ pub fn generate_descriptors( fn generate_private_descriptors( desc_type: &str, key: &str, - network: Network, + network: NetworkKind, ) -> Result { use bdk_wallet::template::{Bip44, Bip49, Bip84, Bip86}; @@ -168,7 +168,7 @@ pub fn build_public_descriptor( /// Generate new mnemonic and descriptors pub fn generate_descriptor_with_mnemonic( - network: Network, + network: NetworkKind, desc_type: &str, ) -> Result { let mnemonic: GeneratedKey = @@ -185,7 +185,7 @@ pub fn generate_descriptor_with_mnemonic( /// Generate descriptors from existing mnemonic pub fn generate_descriptor_from_mnemonic( mnemonic_str: &str, - network: Network, + network: NetworkKind, desc_type: &str, ) -> Result { let mnemonic = Mnemonic::parse_in(Language::English, mnemonic_str)?; diff --git a/src/utils/types.rs b/src/utils/types.rs index 176fd155..8a10d03f 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, } } } diff --git a/tests/integration/online.rs b/tests/integration/online.rs index 0ba166d5..e6d7108f 100644 --- a/tests/integration/online.rs +++ b/tests/integration/online.rs @@ -965,6 +965,92 @@ mod test_online { String::from_utf8_lossy(&out.stderr) ); } + + // lock → list → unlock cycle. + #[test] + fn test_lock_unlock_and_list_utxos() { + let (cli, mut cmd_init, env) = setup_online_wallet(); + cmd_init.assert().success(); + fund_and_sync_wallet(&cli, &env); + + let unspent = run_wallet_json(&cli, &["unspent"]); + let outpoint = unspent["items"][0]["outpoint"] + .as_str() + .expect("one utxo") + .to_string(); + + let locked = run_wallet_json(&cli, &["lock_utxo", "--utxo", &outpoint]); + assert_eq!(locked["count"].as_u64(), Some(1), "lock output: {locked}"); + + // separate process — must still be locked (persistence) + let listed = run_wallet_json(&cli, &["locked_utxos"]); + assert_eq!( + listed["count"].as_u64(), + Some(1), + "lock must persist across processes: {listed}" + ); + assert!( + listed["items"] + .as_array() + .unwrap() + .iter() + .any(|i| i.as_str() == Some(outpoint.as_str())), + "locked_utxos should list {outpoint}: {listed}" + ); + + let after = run_wallet_json(&cli, &["unlock_utxo", "--utxo", &outpoint]); + assert_eq!( + after["count"].as_u64(), + Some(0), + "unlock should clear it: {after}" + ); + let final_list = run_wallet_json(&cli, &["locked_utxos"]); + assert_eq!( + final_list["count"].as_u64(), + Some(0), + "empty after unlock: {final_list}" + ); + } + + // a locked UTXO is excluded from coin selection. + #[test] + fn test_locked_utxo_excluded_from_coin_selection() { + let (cli, mut cmd_init, env) = setup_online_wallet(); + cmd_init.assert().success(); + fund_and_sync_wallet(&cli, &env); + + let unspent = run_wallet_json(&cli, &["unspent"]); + let outpoint = unspent["items"][0]["outpoint"] + .as_str() + .unwrap() + .to_string(); + + run_wallet_json(&cli, &["lock_utxo", "--utxo", &outpoint]); + + // only UTXO is locked → create_tx must fail + let out = cli + .wallet_cmd(&[ + "--wallet", + WALLET_NAME, + "create_tx", + "--to", + &format!("{RECIPIENT}:20000"), + ]) + .output() + .unwrap(); + assert!( + !out.status.success(), + "create_tx must fail when the only UTXO is locked" + ); + + // unlock → create_tx succeeds + run_wallet_json(&cli, &["unlock_utxo", "--utxo", &outpoint]); + let psbt = run_wallet_json(&cli, &["create_tx", "--to", &format!("{RECIPIENT}:20000")]); + assert!( + psbt["psbt"].as_str().is_some(), + "create_tx should succeed after unlock: {psbt}" + ); + } } #[cfg(feature = "esplora")]