From 0876b6b5c3cdcc5bd67c9215d7d8d395a8d501fe Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Tue, 21 Apr 2026 10:52:35 +0100 Subject: [PATCH 01/20] ref(commands): mv commands & clients into subdirs - move all blockchain clients code into the backend subdirectory - move commands.rs content into commands subdirectory --- src/backend/mod.rs | 172 +++++++++++++++++++++++++++ src/{commands.rs => commands/mod.rs} | 3 +- 2 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 src/backend/mod.rs rename src/{commands.rs => commands/mod.rs} (99%) diff --git a/src/backend/mod.rs b/src/backend/mod.rs new file mode 100644 index 00000000..a4695b76 --- /dev/null +++ b/src/backend/mod.rs @@ -0,0 +1,172 @@ +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] +use { + crate::commands::{ClientType, WalletOpts}, + crate::error::BDKCliError as Error, + bdk_wallet::Wallet, + std::path::PathBuf, +}; + +#[cfg(feature = "cbf")] +use { + crate::utils::trace_logger, + bdk_kyoto::{BuilderExt, LightClient}, +}; + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] +pub(crate) enum BlockchainClient { + #[cfg(feature = "electrum")] + Electrum { + client: Box>, + batch_size: usize, + }, + #[cfg(feature = "esplora")] + Esplora { + client: Box, + parallel_requests: usize, + }, + #[cfg(feature = "rpc")] + RpcClient { + client: Box, + }, + + #[cfg(feature = "cbf")] + KyotoClient { client: Box }, +} + +/// Handle for the Kyoto client after the node has been started. +/// Contains only the components needed for sync and broadcast operations. +#[cfg(feature = "cbf")] +pub struct KyotoClientHandle { + pub requester: bdk_kyoto::Requester, + pub update_subscriber: tokio::sync::Mutex, +} + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf", +))] +/// Create a new blockchain from the wallet configuration options. +pub(crate) fn new_blockchain_client( + wallet_opts: &WalletOpts, + _wallet: &Wallet, + _datadir: PathBuf, +) -> Result { + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] + let url = &wallet_opts.url; + let client = match wallet_opts.client_type { + #[cfg(feature = "electrum")] + ClientType::Electrum => { + let client = bdk_electrum::electrum_client::Client::new(url) + .map(bdk_electrum::BdkElectrumClient::new)?; + BlockchainClient::Electrum { + client: Box::new(client), + batch_size: wallet_opts.batch_size, + } + } + #[cfg(feature = "esplora")] + ClientType::Esplora => { + let client = bdk_esplora::esplora_client::Builder::new(url).build_async()?; + BlockchainClient::Esplora { + client: Box::new(client), + parallel_requests: wallet_opts.parallel_requests, + } + } + + #[cfg(feature = "rpc")] + ClientType::Rpc => { + let auth = match &wallet_opts.cookie { + Some(cookie) => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile(cookie.into()), + None => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::UserPass( + wallet_opts.basic_auth.0.clone(), + wallet_opts.basic_auth.1.clone(), + ), + }; + let client = bdk_bitcoind_rpc::bitcoincore_rpc::Client::new(url, auth) + .map_err(|e| Error::Generic(e.to_string()))?; + BlockchainClient::RpcClient { + client: Box::new(client), + } + } + + #[cfg(feature = "cbf")] + ClientType::Cbf => { + let scan_type = bdk_kyoto::ScanType::Sync; + let builder = bdk_kyoto::builder::Builder::new(_wallet.network()); + + let light_client = builder + .required_peers(wallet_opts.compactfilter_opts.conn_count) + .data_dir(&_datadir) + .build_with_wallet(_wallet, scan_type)?; + + let LightClient { + requester, + info_subscriber, + warning_subscriber, + update_subscriber, + node, + } = light_client; + + 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 }, + ); + + BlockchainClient::KyotoClient { + client: Box::new(KyotoClientHandle { + requester, + update_subscriber: tokio::sync::Mutex::new(update_subscriber), + }), + } + } + }; + Ok(client) +} + +// Handle Kyoto Client sync +#[cfg(feature = "cbf")] +pub async fn sync_kyoto_client( + wallet: &mut Wallet, + handle: &KyotoClientHandle, +) -> Result<(), Error> { + if !handle.requester.is_running() { + tracing::error!("Kyoto node is not running"); + return Err(Error::Generic("Kyoto node failed to start".to_string())); + } + tracing::info!("Kyoto node is running"); + + let update = handle.update_subscriber.lock().await.update().await?; + tracing::info!("Received update: applying to wallet"); + wallet + .apply_update(update) + .map_err(|e| Error::Generic(format!("Failed to apply update: {e}")))?; + + tracing::info!( + "Chain tip: {}, Transactions: {}, Balance: {}", + wallet.local_chain().tip().height(), + wallet.transactions().count(), + wallet.balance().total().to_sat() + ); + + tracing::info!( + "Sync completed: tx_count={}, balance={}", + wallet.transactions().count(), + wallet.balance().total().to_sat() + ); + + Ok(()) +} diff --git a/src/commands.rs b/src/commands/mod.rs similarity index 99% rename from src/commands.rs rename to src/commands/mod.rs index 80035b08..70fb8a0e 100644 --- a/src/commands.rs +++ b/src/commands/mod.rs @@ -20,9 +20,10 @@ use bdk_wallet::bitcoin::{ use clap::{Args, Parser, Subcommand, ValueEnum, value_parser}; use clap_complete::Shell; +use crate::utils::{parse_address, parse_outpoint, parse_recipient}; + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] use crate::utils::parse_proxy_auth; -use crate::utils::{parse_address, parse_outpoint, parse_recipient}; /// The BDK Command Line Wallet App /// From c94b3fdf8d33e8550b8c1dc518c72246b585b60a Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Tue, 21 Apr 2026 11:03:00 +0100 Subject: [PATCH 02/20] ref(config): move config into config subdir --- src/{config.rs => config/mod.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/{config.rs => config/mod.rs} (100%) diff --git a/src/config.rs b/src/config/mod.rs similarity index 100% rename from src/config.rs rename to src/config/mod.rs From 616713d0c1ba6b2c55c2f01653fd72938967f31e Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Tue, 21 Apr 2026 11:05:04 +0100 Subject: [PATCH 03/20] ref(utils): refactor utils into utils subdir - split util fns into `descriptors` and `common` --- src/utils.rs | 683 --------------------------------------- src/utils/common.rs | 192 +++++++++++ src/utils/descriptors.rs | 265 +++++++++++++++ src/utils/mod.rs | 4 + 4 files changed, 461 insertions(+), 683 deletions(-) delete mode 100644 src/utils.rs create mode 100644 src/utils/common.rs create mode 100644 src/utils/descriptors.rs create mode 100644 src/utils/mod.rs diff --git a/src/utils.rs b/src/utils.rs deleted file mode 100644 index cf60c380..00000000 --- a/src/utils.rs +++ /dev/null @@ -1,683 +0,0 @@ -// Copyright (c) 2020-2025 Bitcoin Dev Kit Developers -// -// This file is licensed under the Apache License, Version 2.0 or the MIT license -// , at your option. -// You may not use this file except in accordance with one or both of these -// licenses. - -//! Utility Tools -//! -//! This module includes all the utility tools used by the App. -use crate::config::WalletConfig; -use crate::error::BDKCliError as Error; -use std::{ - fmt::Display, - path::{Path, PathBuf}, - str::FromStr, - sync::Arc, -}; - -use crate::commands::WalletOpts; -#[cfg(feature = "cbf")] -use bdk_kyoto::{ - BuilderExt, Info, LightClient, Receiver, ScanType::Sync, UnboundedReceiver, Warning, - builder::Builder, -}; -use bdk_wallet::{ - KeychainKind, - bitcoin::bip32::{DerivationPath, Xpub}, - keys::DescriptorPublicKey, - miniscript::{ - Descriptor, Miniscript, Terminal, - descriptor::{DescriptorXKey, Wildcard}, - }, - template::DescriptorTemplate, -}; -use cli_table::{Cell, CellStruct, Style, Table}; - -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" -))] -use crate::commands::ClientType; - -use bdk_wallet::Wallet; -#[cfg(any(feature = "sqlite", feature = "redb"))] -use bdk_wallet::{PersistedWallet, WalletPersister}; - -use bdk_wallet::bip39::{Language, Mnemonic}; -use bdk_wallet::bitcoin::{ - Address, Network, OutPoint, ScriptBuf, bip32::Xpriv, secp256k1::Secp256k1, -}; -use bdk_wallet::descriptor::Segwitv0; -use bdk_wallet::keys::{GeneratableKey, GeneratedKey, bip39::WordCount}; -use serde_json::{Value, json}; - -#[cfg(feature = "bip322")] -use bdk_bip322::SignatureFormat; - -/// Parse the recipient (Address,Amount) argument from cli input. -pub(crate) fn parse_recipient(s: &str) -> Result<(ScriptBuf, u64), String> { - let parts: Vec<_> = s.split(':').collect(); - if parts.len() != 2 { - return Err("Invalid format".to_string()); - } - let addr = Address::from_str(parts[0]) - .map_err(|e| e.to_string())? - .assume_checked(); - let val = u64::from_str(parts[1]).map_err(|e| e.to_string())?; - - Ok((addr.script_pubkey(), val)) -} - -#[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] -/// Parse the proxy (Socket:Port) argument from the cli input. -pub(crate) fn parse_proxy_auth(s: &str) -> Result<(String, String), Error> { - let parts: Vec<_> = s.split(':').collect(); - if parts.len() != 2 { - return Err(Error::Generic("Invalid format".to_string())); - } - - let user = parts[0].to_string(); - let passwd = parts[1].to_string(); - - Ok((user, passwd)) -} - -/// Parse a outpoint (Txid:Vout) argument from cli input. -pub(crate) fn parse_outpoint(s: &str) -> Result { - Ok(OutPoint::from_str(s)?) -} - -/// Parse an address string into `Address`. -pub(crate) fn parse_address(address_str: &str) -> Result { - let unchecked_address = Address::from_str(address_str)?; - Ok(unchecked_address.assume_checked()) -} - -/// Function to parse the signature format from a string -#[cfg(feature = "bip322")] -pub(crate) fn parse_signature_format(format_str: &str) -> Result { - match format_str.to_lowercase().as_str() { - "legacy" => Ok(SignatureFormat::Legacy), - "simple" => Ok(SignatureFormat::Simple), - "full" => Ok(SignatureFormat::Full), - "fullproofoffunds" => Ok(SignatureFormat::FullProofOfFunds), - _ => Err(Error::Generic( - "Invalid signature format. Use 'legacy', 'simple', 'full', or 'fullproofoffunds'" - .to_string(), - )), - } -} - -/// Prepare bdk-cli home directory -/// -/// This function is called to check if [`crate::CliOpts`] datadir is set. -/// If not the default home directory is created at `~/.bdk-bitcoin`. -#[allow(dead_code)] -pub(crate) fn prepare_home_dir(home_path: Option) -> Result { - let dir = home_path.unwrap_or_else(|| { - let mut dir = PathBuf::new(); - dir.push( - dirs::home_dir() - .ok_or_else(|| Error::Generic("home dir not found".to_string())) - .unwrap(), - ); - dir.push(".bdk-bitcoin"); - dir - }); - - if !dir.exists() { - std::fs::create_dir(&dir).map_err(|e| Error::Generic(e.to_string()))?; - } - - Ok(dir) -} - -/// Prepare wallet database directory. -#[allow(dead_code)] -pub(crate) fn prepare_wallet_db_dir( - home_path: &Path, - wallet_name: &str, -) -> Result { - let mut dir = home_path.to_owned(); - dir.push(wallet_name); - - if !dir.exists() { - std::fs::create_dir(&dir).map_err(|e| Error::Generic(e.to_string()))?; - } - - Ok(dir) -} - -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf", -))] -pub(crate) enum BlockchainClient { - #[cfg(feature = "electrum")] - Electrum { - client: Box>, - batch_size: usize, - }, - #[cfg(feature = "esplora")] - Esplora { - client: Box, - parallel_requests: usize, - }, - #[cfg(feature = "rpc")] - RpcClient { - client: Box, - }, - - #[cfg(feature = "cbf")] - KyotoClient { client: Box }, -} - -/// Handle for the Kyoto client after the node has been started. -/// Contains only the components needed for sync and broadcast operations. -#[cfg(feature = "cbf")] -pub struct KyotoClientHandle { - pub requester: bdk_kyoto::Requester, - pub update_subscriber: tokio::sync::Mutex, -} - -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf", -))] -/// Create a new blockchain from the wallet configuration options. -pub(crate) fn new_blockchain_client( - wallet_opts: &WalletOpts, - _wallet: &Wallet, - _datadir: PathBuf, -) -> Result { - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] - let url = &wallet_opts.url; - let client = match wallet_opts.client_type { - #[cfg(feature = "electrum")] - ClientType::Electrum => { - let client = bdk_electrum::electrum_client::Client::new(url) - .map(bdk_electrum::BdkElectrumClient::new)?; - BlockchainClient::Electrum { - client: Box::new(client), - batch_size: wallet_opts.batch_size, - } - } - #[cfg(feature = "esplora")] - ClientType::Esplora => { - let client = bdk_esplora::esplora_client::Builder::new(url).build_async()?; - BlockchainClient::Esplora { - client: Box::new(client), - parallel_requests: wallet_opts.parallel_requests, - } - } - - #[cfg(feature = "rpc")] - ClientType::Rpc => { - let auth = match &wallet_opts.cookie { - Some(cookie) => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile(cookie.into()), - None => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::UserPass( - wallet_opts.basic_auth.0.clone(), - wallet_opts.basic_auth.1.clone(), - ), - }; - let client = bdk_bitcoind_rpc::bitcoincore_rpc::Client::new(url, auth) - .map_err(|e| Error::Generic(e.to_string()))?; - BlockchainClient::RpcClient { - client: Box::new(client), - } - } - - #[cfg(feature = "cbf")] - ClientType::Cbf => { - let scan_type = Sync; - let builder = Builder::new(_wallet.network()); - - let light_client = builder - .required_peers(wallet_opts.compactfilter_opts.conn_count) - .data_dir(&_datadir) - .build_with_wallet(_wallet, scan_type)?; - - let LightClient { - requester, - info_subscriber, - warning_subscriber, - update_subscriber, - node, - } = light_client; - - 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 }, - ); - - BlockchainClient::KyotoClient { - client: Box::new(KyotoClientHandle { - requester, - update_subscriber: tokio::sync::Mutex::new(update_subscriber), - }), - } - } - }; - Ok(client) -} - -#[cfg(any(feature = "sqlite", feature = "redb"))] -/// Create a new persisted wallet from given wallet configuration options. -pub(crate) fn new_persisted_wallet( - network: Network, - persister: &mut P, - wallet_opts: &WalletOpts, -) -> Result, Error> -where - P::Error: std::fmt::Display, -{ - let ext_descriptor = wallet_opts.ext_descriptor.clone(); - let int_descriptor = wallet_opts.int_descriptor.clone(); - - let mut wallet_load_params = Wallet::load(); - wallet_load_params = - wallet_load_params.descriptor(KeychainKind::External, Some(ext_descriptor.clone())); - - if int_descriptor.is_some() { - wallet_load_params = - wallet_load_params.descriptor(KeychainKind::Internal, int_descriptor.clone()); - } - wallet_load_params = wallet_load_params.extract_keys(); - - let wallet_opt = wallet_load_params - .check_network(network) - .load_wallet(persister) - .map_err(|e| Error::Generic(e.to_string()))?; - - let wallet = match wallet_opt { - Some(wallet) => wallet, - None => match int_descriptor { - Some(int_descriptor) => Wallet::create(ext_descriptor, int_descriptor) - .network(network) - .create_wallet(persister) - .map_err(|e| Error::Generic(e.to_string()))?, - None => Wallet::create_single(ext_descriptor) - .network(network) - .create_wallet(persister) - .map_err(|e| Error::Generic(e.to_string()))?, - }, - }; - - Ok(wallet) -} - -#[cfg(not(any(feature = "sqlite", feature = "redb")))] -/// Create a new non-persisted wallet from given wallet configuration options. -pub(crate) fn new_wallet(network: Network, wallet_opts: &WalletOpts) -> Result { - let ext_descriptor = wallet_opts.ext_descriptor.clone(); - let int_descriptor = wallet_opts.int_descriptor.clone(); - - match int_descriptor { - Some(int_descriptor) => { - let wallet = Wallet::create(ext_descriptor, int_descriptor) - .network(network) - .create_wallet_no_persist()?; - Ok(wallet) - } - None => { - let wallet = Wallet::create_single(ext_descriptor) - .network(network) - .create_wallet_no_persist()?; - Ok(wallet) - } - } -} - -#[cfg(feature = "cbf")] -pub async fn trace_logger( - mut info_subcriber: Receiver, - mut warning_subscriber: UnboundedReceiver, -) { - loop { - tokio::select! { - info = info_subcriber.recv() => { - if let Some(info) = info { - tracing::info!("{info}") - } - } - warn = warning_subscriber.recv() => { - if let Some(warn) = warn { - tracing::warn!("{warn}") - } - } - } - } -} - -// Handle Kyoto Client sync -#[cfg(feature = "cbf")] -pub async fn sync_kyoto_client( - wallet: &mut Wallet, - handle: &KyotoClientHandle, -) -> Result<(), Error> { - if !handle.requester.is_running() { - tracing::error!("Kyoto node is not running"); - return Err(Error::Generic("Kyoto node failed to start".to_string())); - } - tracing::info!("Kyoto node is running"); - - let update = handle.update_subscriber.lock().await.update().await?; - tracing::info!("Received update: applying to wallet"); - wallet - .apply_update(update) - .map_err(|e| Error::Generic(format!("Failed to apply update: {e}")))?; - - tracing::info!( - "Chain tip: {}, Transactions: {}, Balance: {}", - wallet.local_chain().tip().height(), - wallet.transactions().count(), - wallet.balance().total().to_sat() - ); - - tracing::info!( - "Sync completed: tx_count={}, balance={}", - wallet.transactions().count(), - wallet.balance().total().to_sat() - ); - - Ok(()) -} - -pub(crate) fn shorten(displayable: impl Display, start: u8, end: u8) -> String { - let displayable = displayable.to_string(); - - if displayable.len() <= (start + end) as usize { - return displayable; - } - - let start_str: &str = &displayable[0..start as usize]; - let end_str: &str = &displayable[displayable.len() - end as usize..]; - format!("{start_str}...{end_str}") -} - -pub fn is_mnemonic(s: &str) -> bool { - let word_count = s.split_whitespace().count(); - (12..=24).contains(&word_count) && s.chars().all(|c| c.is_alphanumeric() || c.is_whitespace()) -} - -pub fn generate_descriptors(desc_type: &str, key: &str, network: Network) -> Result { - let is_private = key.starts_with("xprv") || key.starts_with("tprv"); - - if is_private { - generate_private_descriptors(desc_type, key, network) - } else { - let purpose = match desc_type.to_lowercase().as_str() { - "pkh" => 44u32, - "sh" => 49u32, - "wpkh" | "wsh" => 84u32, - "tr" => 86u32, - _ => 84u32, - }; - let coin_type = match network { - Network::Bitcoin => 0u32, - _ => 1u32, - }; - let derivation_path = DerivationPath::from_str(&format!("m/{purpose}h/{coin_type}h/0h"))?; - generate_public_descriptors(desc_type, key, &derivation_path) - } -} - -/// Generate descriptors from private key using BIP templates -fn generate_private_descriptors( - desc_type: &str, - key: &str, - network: Network, -) -> Result { - use bdk_wallet::template::{Bip44, Bip49, Bip84, Bip86}; - - let secp = Secp256k1::new(); - let xprv: Xpriv = key.parse()?; - let fingerprint = xprv.fingerprint(&secp); - - let (external_desc, external_keymap, _) = match desc_type.to_lowercase().as_str() { - "pkh" => Bip44(xprv, KeychainKind::External).build(network)?, - "sh" => Bip49(xprv, KeychainKind::External).build(network)?, - "wpkh" | "wsh" => Bip84(xprv, KeychainKind::External).build(network)?, - "tr" => Bip86(xprv, KeychainKind::External).build(network)?, - _ => { - return Err(Error::Generic(format!( - "Unsupported descriptor type: {desc_type}" - ))); - } - }; - - let (internal_desc, internal_keymap, _) = match desc_type.to_lowercase().as_str() { - "pkh" => Bip44(xprv, KeychainKind::Internal).build(network)?, - "sh" => Bip49(xprv, KeychainKind::Internal).build(network)?, - "wpkh" | "wsh" => Bip84(xprv, KeychainKind::Internal).build(network)?, - "tr" => Bip86(xprv, KeychainKind::Internal).build(network)?, - _ => { - return Err(Error::Generic(format!( - "Unsupported descriptor type: {desc_type}" - ))); - } - }; - - let external_priv = external_desc.to_string_with_secret(&external_keymap); - let external_pub = external_desc.to_string(); - let internal_priv = internal_desc.to_string_with_secret(&internal_keymap); - let internal_pub = internal_desc.to_string(); - - Ok(json!({ - "public_descriptors": { - "external": external_pub, - "internal": internal_pub - }, - "private_descriptors": { - "external": external_priv, - "internal": internal_priv - }, - "fingerprint": fingerprint.to_string() - })) -} - -/// Generate descriptors from public key (xpub/tpub) -pub fn generate_public_descriptors( - desc_type: &str, - key: &str, - derivation_path: &DerivationPath, -) -> Result { - let xpub: Xpub = key.parse()?; - let fingerprint = xpub.fingerprint(); - - let build_descriptor = |branch: &str| -> Result { - let branch_path = DerivationPath::from_str(branch)?; - let desc_xpub = DescriptorXKey { - origin: Some((fingerprint, derivation_path.clone())), - xkey: xpub, - derivation_path: branch_path, - wildcard: Wildcard::Unhardened, - }; - let desc_pub = DescriptorPublicKey::XPub(desc_xpub); - let descriptor = build_public_descriptor(desc_type, desc_pub)?; - Ok(descriptor.to_string()) - }; - - let external_pub = build_descriptor("0")?; - let internal_pub = build_descriptor("1")?; - - Ok(json!({ - "public_descriptors": { - "external": external_pub, - "internal": internal_pub - }, - "fingerprint": fingerprint.to_string() - })) -} - -/// Build a descriptor from a public key -pub fn build_public_descriptor( - desc_type: &str, - key: DescriptorPublicKey, -) -> Result, Error> { - match desc_type.to_lowercase().as_str() { - "pkh" => Descriptor::new_pkh(key).map_err(Error::from), - "wpkh" => Descriptor::new_wpkh(key).map_err(Error::from), - "sh" => Descriptor::new_sh_wpkh(key).map_err(Error::from), - "wsh" => { - let pk_k = Miniscript::from_ast(Terminal::PkK(key)).map_err(Error::from)?; - let pk_ms: Miniscript = - Miniscript::from_ast(Terminal::Check(Arc::new(pk_k))).map_err(Error::from)?; - Descriptor::new_wsh(pk_ms).map_err(Error::from) - } - "tr" => Descriptor::new_tr(key, None).map_err(Error::from), - _ => Err(Error::Generic(format!( - "Unsupported descriptor type: {desc_type}" - ))), - } -} - -/// Generate new mnemonic and descriptors -pub fn generate_descriptor_with_mnemonic( - network: Network, - desc_type: &str, -) -> Result { - let mnemonic: GeneratedKey = - Mnemonic::generate((WordCount::Words12, Language::English)).map_err(Error::BIP39Error)?; - - let seed = mnemonic.to_seed(""); - let xprv = Xpriv::new_master(network, &seed)?; - - let mut result = generate_descriptors(desc_type, &xprv.to_string(), network)?; - result["mnemonic"] = json!(mnemonic.to_string()); - Ok(result) -} - -/// Generate descriptors from existing mnemonic -pub fn generate_descriptor_from_mnemonic( - mnemonic_str: &str, - network: Network, - desc_type: &str, -) -> Result { - let mnemonic = Mnemonic::parse_in(Language::English, mnemonic_str)?; - let seed = mnemonic.to_seed(""); - let xprv = Xpriv::new_master(network, &seed)?; - - let mut result = generate_descriptors(desc_type, &xprv.to_string(), network)?; - result["mnemonic"] = json!(mnemonic_str); - Ok(result) -} - -pub fn format_descriptor_output(result: &Value, pretty: bool) -> Result { - if !pretty { - return Ok(serde_json::to_string_pretty(result)?); - } - - let mut rows: Vec> = vec![]; - - if let Some(desc_type) = result.get("type") { - rows.push(vec![ - "Type".cell().bold(true), - desc_type.as_str().unwrap_or("N/A").cell(), - ]); - } - - if let Some(finger_print) = result.get("fingerprint") { - rows.push(vec![ - "Fingerprint".cell().bold(true), - finger_print.as_str().unwrap_or("N/A").cell(), - ]); - } - - if let Some(network) = result.get("network") { - rows.push(vec![ - "Network".cell().bold(true), - network.as_str().unwrap_or("N/A").cell(), - ]); - } - if let Some(multipath_desc) = result.get("multipath_descriptor") { - rows.push(vec![ - "Multipart Descriptor".cell().bold(true), - multipath_desc.as_str().unwrap_or("N/A").cell(), - ]); - } - if let Some(pub_descs) = result.get("public_descriptors").and_then(|v| v.as_object()) { - if let Some(ext) = pub_descs.get("external") { - rows.push(vec![ - "External Public".cell().bold(true), - ext.as_str().unwrap_or("N/A").cell(), - ]); - } - if let Some(int) = pub_descs.get("internal") { - rows.push(vec![ - "Internal Public".cell().bold(true), - int.as_str().unwrap_or("N/A").cell(), - ]); - } - } - if let Some(priv_descs) = result - .get("private_descriptors") - .and_then(|v| v.as_object()) - { - if let Some(ext) = priv_descs.get("external") { - rows.push(vec![ - "External Private".cell().bold(true), - ext.as_str().unwrap_or("N/A").cell(), - ]); - } - if let Some(int) = priv_descs.get("internal") { - rows.push(vec![ - "Internal Private".cell().bold(true), - int.as_str().unwrap_or("N/A").cell(), - ]); - } - } - if let Some(mnemonic) = result.get("mnemonic") { - rows.push(vec![ - "Mnemonic".cell().bold(true), - mnemonic.as_str().unwrap_or("N/A").cell(), - ]); - } - - let table = rows - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - - Ok(format!("{table}")) -} - -pub fn load_wallet_config( - home_dir: &Path, - wallet_name: &str, -) -> Result<(WalletOpts, Network), Error> { - let config = WalletConfig::load(home_dir)?.ok_or(Error::Generic(format!( - "No config found for wallet {wallet_name}", - )))?; - - let wallet_opts = config.get_wallet_opts(wallet_name)?; - let wallet_config = config - .wallets - .get(wallet_name) - .ok_or(Error::Generic(format!( - "Wallet '{wallet_name}' not found in config" - )))?; - - let network = match wallet_config.network.as_str() { - "bitcoin" => Ok(Network::Bitcoin), - "testnet" => Ok(Network::Testnet), - "regtest" => Ok(Network::Regtest), - "signet" => Ok(Network::Signet), - "testnet4" => Ok(Network::Testnet4), - _ => Err(Error::Generic("Invalid network in config".to_string())), - }?; - - Ok((wallet_opts, network)) -} diff --git a/src/utils/common.rs b/src/utils/common.rs new file mode 100644 index 00000000..f50be3af --- /dev/null +++ b/src/utils/common.rs @@ -0,0 +1,192 @@ +use crate::{commands::WalletOpts, config::WalletConfig, error::BDKCliError as Error}; +#[cfg(feature = "cbf")] +use bdk_kyoto::{Info, Receiver, UnboundedReceiver, Warning}; +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +use bdk_wallet::bitcoin::Psbt; +use bdk_wallet::bitcoin::{Address, Network, OutPoint, ScriptBuf}; + +use std::{ + fmt::Display, + path::{Path, PathBuf}, + str::FromStr, +}; + +/// Determine if PSBT has final script sigs or witnesses for all unsigned tx inputs. +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +pub(crate) fn is_final(psbt: &Psbt) -> Result<(), Error> { + let unsigned_tx_inputs = psbt.unsigned_tx.input.len(); + let psbt_inputs = psbt.inputs.len(); + if unsigned_tx_inputs != psbt_inputs { + return Err(Error::Generic(format!( + "Malformed PSBT, {unsigned_tx_inputs} unsigned tx inputs and {psbt_inputs} psbt inputs." + ))); + } + let sig_count = psbt.inputs.iter().fold(0, |count, input| { + if input.final_script_sig.is_some() || input.final_script_witness.is_some() { + count + 1 + } else { + count + } + }); + if unsigned_tx_inputs > sig_count { + return Err(Error::Generic( + "The PSBT is not finalized, inputs are are not fully signed.".to_string(), + )); + } + Ok(()) +} + +pub(crate) fn shorten(displayable: impl Display, start: u8, end: u8) -> String { + let displayable = displayable.to_string(); + + if displayable.len() <= (start + end) as usize { + return displayable; + } + + let start_str: &str = &displayable[0..start as usize]; + let end_str: &str = &displayable[displayable.len() - end as usize..]; + format!("{start_str}...{end_str}") +} + +/// Parse the recipient (Address,Amount) argument from cli input. +pub(crate) fn parse_recipient(s: &str) -> Result<(ScriptBuf, u64), String> { + let parts: Vec<_> = s.split(':').collect(); + if parts.len() != 2 { + return Err("Invalid format".to_string()); + } + let addr = Address::from_str(parts[0]) + .map_err(|e| e.to_string())? + .assume_checked(); + let val = u64::from_str(parts[1]).map_err(|e| e.to_string())?; + + Ok((addr.script_pubkey(), val)) +} + +#[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] +/// Parse the proxy (Socket:Port) argument from the cli input. +pub(crate) fn parse_proxy_auth(s: &str) -> Result<(String, String), Error> { + let parts: Vec<_> = s.split(':').collect(); + if parts.len() != 2 { + return Err(Error::Generic("Invalid format".to_string())); + } + + let user = parts[0].to_string(); + let passwd = parts[1].to_string(); + + Ok((user, passwd)) +} + +/// Parse a outpoint (Txid:Vout) argument from cli input. +pub(crate) fn parse_outpoint(s: &str) -> Result { + Ok(OutPoint::from_str(s)?) +} + +/// Parse an address string into `Address`. +pub(crate) fn parse_address(address_str: &str) -> Result { + let unchecked_address = Address::from_str(address_str)?; + Ok(unchecked_address.assume_checked()) +} + +/// Prepare bdk-cli home directory +/// +/// This function is called to check if [`crate::CliOpts`] datadir is set. +/// If not the default home directory is created at `~/.bdk-bitcoin`. +#[allow(dead_code)] +pub(crate) fn prepare_home_dir(home_path: Option) -> Result { + let dir = home_path.unwrap_or_else(|| { + let mut dir = PathBuf::new(); + dir.push( + dirs::home_dir() + .ok_or_else(|| Error::Generic("home dir not found".to_string())) + .unwrap(), + ); + dir.push(".bdk-bitcoin"); + dir + }); + + if !dir.exists() { + std::fs::create_dir(&dir).map_err(|e| Error::Generic(e.to_string()))?; + } + + Ok(dir) +} + +/// Prepare wallet database directory. +#[allow(dead_code)] +pub(crate) fn prepare_wallet_db_dir( + home_path: &Path, + wallet_name: &str, +) -> Result { + let mut dir = home_path.to_owned(); + dir.push(wallet_name); + + if !dir.exists() { + std::fs::create_dir(&dir).map_err(|e| Error::Generic(e.to_string()))?; + } + + Ok(dir) +} + +pub fn is_mnemonic(s: &str) -> bool { + let word_count = s.split_whitespace().count(); + (12..=24).contains(&word_count) && s.chars().all(|c| c.is_alphanumeric() || c.is_whitespace()) +} + +#[cfg(feature = "cbf")] +pub async fn trace_logger( + mut info_subcriber: Receiver, + mut warning_subscriber: UnboundedReceiver, +) { + loop { + tokio::select! { + info = info_subcriber.recv() => { + if let Some(info) = info { + tracing::info!("{info}") + } + } + warn = warning_subscriber.recv() => { + if let Some(warn) = warn { + tracing::warn!("{warn}") + } + } + } + } +} + +pub fn load_wallet_config( + home_dir: &Path, + wallet_name: &str, +) -> Result<(WalletOpts, Network), Error> { + let config = WalletConfig::load(home_dir)?.ok_or(Error::Generic(format!( + "No config found for wallet {wallet_name}", + )))?; + + let wallet_opts = config.get_wallet_opts(wallet_name)?; + let wallet_config = config + .wallets + .get(wallet_name) + .ok_or(Error::Generic(format!( + "Wallet '{wallet_name}' not found in config" + )))?; + + let network = match wallet_config.network.as_str() { + "bitcoin" => Ok(Network::Bitcoin), + "testnet" => Ok(Network::Testnet), + "regtest" => Ok(Network::Regtest), + "signet" => Ok(Network::Signet), + "testnet4" => Ok(Network::Testnet4), + _ => Err(Error::Generic("Invalid network in config".to_string())), + }?; + + Ok((wallet_opts, network)) +} diff --git a/src/utils/descriptors.rs b/src/utils/descriptors.rs new file mode 100644 index 00000000..30a0f237 --- /dev/null +++ b/src/utils/descriptors.rs @@ -0,0 +1,265 @@ +use bdk_wallet::keys::GeneratableKey; +use std::{str::FromStr, sync::Arc}; + +use bdk_wallet::keys::DescriptorPublicKey; +use bdk_wallet::{ + KeychainKind, + bip39::{Language, Mnemonic}, + bitcoin::{ + Network, + bip32::{DerivationPath, Xpriv, Xpub}, + secp256k1::Secp256k1, + }, + keys::{GeneratedKey, bip39::WordCount}, + miniscript::{ + Descriptor, Miniscript, Segwitv0, Terminal, + descriptor::{DescriptorXKey, Wildcard}, + }, + template::DescriptorTemplate, +}; +use cli_table::{Cell, CellStruct, Style, Table}; +use serde_json::{Value, json}; + +use crate::error::BDKCliError as Error; + +pub fn generate_descriptors(desc_type: &str, key: &str, network: Network) -> Result { + let is_private = key.starts_with("xprv") || key.starts_with("tprv"); + + if is_private { + generate_private_descriptors(desc_type, key, network) + } else { + let purpose = match desc_type.to_lowercase().as_str() { + "pkh" => 44u32, + "sh" => 49u32, + "wpkh" | "wsh" => 84u32, + "tr" => 86u32, + _ => 84u32, + }; + let coin_type = match network { + Network::Bitcoin => 0u32, + _ => 1u32, + }; + let derivation_path = DerivationPath::from_str(&format!("m/{purpose}h/{coin_type}h/0h"))?; + generate_public_descriptors(desc_type, key, &derivation_path) + } +} + +/// Generate descriptors from private key using BIP templates +fn generate_private_descriptors( + desc_type: &str, + key: &str, + network: Network, +) -> Result { + use bdk_wallet::template::{Bip44, Bip49, Bip84, Bip86}; + + let secp = Secp256k1::new(); + let xprv: Xpriv = key.parse()?; + let fingerprint = xprv.fingerprint(&secp); + + let (external_desc, external_keymap, _) = match desc_type.to_lowercase().as_str() { + "pkh" => Bip44(xprv, KeychainKind::External).build(network)?, + "sh" => Bip49(xprv, KeychainKind::External).build(network)?, + "wpkh" | "wsh" => Bip84(xprv, KeychainKind::External).build(network)?, + "tr" => Bip86(xprv, KeychainKind::External).build(network)?, + _ => { + return Err(Error::Generic(format!( + "Unsupported descriptor type: {desc_type}" + ))); + } + }; + + let (internal_desc, internal_keymap, _) = match desc_type.to_lowercase().as_str() { + "pkh" => Bip44(xprv, KeychainKind::Internal).build(network)?, + "sh" => Bip49(xprv, KeychainKind::Internal).build(network)?, + "wpkh" | "wsh" => Bip84(xprv, KeychainKind::Internal).build(network)?, + "tr" => Bip86(xprv, KeychainKind::Internal).build(network)?, + _ => { + return Err(Error::Generic(format!( + "Unsupported descriptor type: {desc_type}" + ))); + } + }; + + let external_priv = external_desc.to_string_with_secret(&external_keymap); + let external_pub = external_desc.to_string(); + let internal_priv = internal_desc.to_string_with_secret(&internal_keymap); + let internal_pub = internal_desc.to_string(); + + Ok(json!({ + "public_descriptors": { + "external": external_pub, + "internal": internal_pub + }, + "private_descriptors": { + "external": external_priv, + "internal": internal_priv + }, + "fingerprint": fingerprint.to_string() + })) +} + +/// Generate descriptors from public key (xpub/tpub) +pub fn generate_public_descriptors( + desc_type: &str, + key: &str, + derivation_path: &DerivationPath, +) -> Result { + let xpub: Xpub = key.parse()?; + let fingerprint = xpub.fingerprint(); + + let build_descriptor = |branch: &str| -> Result { + let branch_path = DerivationPath::from_str(branch)?; + let desc_xpub = DescriptorXKey { + origin: Some((fingerprint, derivation_path.clone())), + xkey: xpub, + derivation_path: branch_path, + wildcard: Wildcard::Unhardened, + }; + let desc_pub = DescriptorPublicKey::XPub(desc_xpub); + let descriptor = build_public_descriptor(desc_type, desc_pub)?; + Ok(descriptor.to_string()) + }; + + let external_pub = build_descriptor("0")?; + let internal_pub = build_descriptor("1")?; + + Ok(json!({ + "public_descriptors": { + "external": external_pub, + "internal": internal_pub + }, + "fingerprint": fingerprint.to_string() + })) +} + +/// Build a descriptor from a public key +pub fn build_public_descriptor( + desc_type: &str, + key: DescriptorPublicKey, +) -> Result, Error> { + match desc_type.to_lowercase().as_str() { + "pkh" => Descriptor::new_pkh(key).map_err(Error::from), + "wpkh" => Descriptor::new_wpkh(key).map_err(Error::from), + "sh" => Descriptor::new_sh_wpkh(key).map_err(Error::from), + "wsh" => { + let pk_k = Miniscript::from_ast(Terminal::PkK(key)).map_err(Error::from)?; + let pk_ms: Miniscript = + Miniscript::from_ast(Terminal::Check(Arc::new(pk_k))).map_err(Error::from)?; + Descriptor::new_wsh(pk_ms).map_err(Error::from) + } + "tr" => Descriptor::new_tr(key, None).map_err(Error::from), + _ => Err(Error::Generic(format!( + "Unsupported descriptor type: {desc_type}" + ))), + } +} + +/// Generate new mnemonic and descriptors +pub fn generate_descriptor_with_mnemonic( + network: Network, + desc_type: &str, +) -> Result { + let mnemonic: GeneratedKey = + Mnemonic::generate((WordCount::Words12, Language::English)).map_err(Error::BIP39Error)?; + + let seed = mnemonic.to_seed(""); + let xprv = Xpriv::new_master(network, &seed)?; + + let mut result = generate_descriptors(desc_type, &xprv.to_string(), network)?; + result["mnemonic"] = json!(mnemonic.to_string()); + Ok(result) +} + +/// Generate descriptors from existing mnemonic +pub fn generate_descriptor_from_mnemonic( + mnemonic_str: &str, + network: Network, + desc_type: &str, +) -> Result { + let mnemonic = Mnemonic::parse_in(Language::English, mnemonic_str)?; + let seed = mnemonic.to_seed(""); + let xprv = Xpriv::new_master(network, &seed)?; + + let mut result = generate_descriptors(desc_type, &xprv.to_string(), network)?; + result["mnemonic"] = json!(mnemonic_str); + Ok(result) +} + +pub fn format_descriptor_output(result: &Value, pretty: bool) -> Result { + if !pretty { + return Ok(serde_json::to_string_pretty(result)?); + } + + let mut rows: Vec> = vec![]; + + if let Some(desc_type) = result.get("type") { + rows.push(vec![ + "Type".cell().bold(true), + desc_type.as_str().unwrap_or("N/A").cell(), + ]); + } + + if let Some(finger_print) = result.get("fingerprint") { + rows.push(vec![ + "Fingerprint".cell().bold(true), + finger_print.as_str().unwrap_or("N/A").cell(), + ]); + } + + if let Some(network) = result.get("network") { + rows.push(vec![ + "Network".cell().bold(true), + network.as_str().unwrap_or("N/A").cell(), + ]); + } + if let Some(multipath_desc) = result.get("multipath_descriptor") { + rows.push(vec![ + "Multipart Descriptor".cell().bold(true), + multipath_desc.as_str().unwrap_or("N/A").cell(), + ]); + } + if let Some(pub_descs) = result.get("public_descriptors").and_then(|v| v.as_object()) { + if let Some(ext) = pub_descs.get("external") { + rows.push(vec![ + "External Public".cell().bold(true), + ext.as_str().unwrap_or("N/A").cell(), + ]); + } + if let Some(int) = pub_descs.get("internal") { + rows.push(vec![ + "Internal Public".cell().bold(true), + int.as_str().unwrap_or("N/A").cell(), + ]); + } + } + if let Some(priv_descs) = result + .get("private_descriptors") + .and_then(|v| v.as_object()) + { + if let Some(ext) = priv_descs.get("external") { + rows.push(vec![ + "External Private".cell().bold(true), + ext.as_str().unwrap_or("N/A").cell(), + ]); + } + if let Some(int) = priv_descs.get("internal") { + rows.push(vec![ + "Internal Private".cell().bold(true), + int.as_str().unwrap_or("N/A").cell(), + ]); + } + } + if let Some(mnemonic) = result.get("mnemonic") { + rows.push(vec![ + "Mnemonic".cell().bold(true), + mnemonic.as_str().unwrap_or("N/A").cell(), + ]); + } + + let table = rows + .table() + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + + Ok(format!("{table}")) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 00000000..0e827e41 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,4 @@ +pub mod common; +pub mod descriptors; + +pub use common::*; From 0c1f0e7b1a838a97c2c96a8b425c1e858ebf1866 Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Tue, 21 Apr 2026 11:08:44 +0100 Subject: [PATCH 04/20] ref(persister): mv persister into wallet subdir - move persister into wallet subdirectory - update imports in payjoin module --- src/payjoin/mod.rs | 25 +++++++++++++++++++++---- src/{ => wallet}/persister.rs | 0 2 files changed, 21 insertions(+), 4 deletions(-) rename src/{ => wallet}/persister.rs (100%) diff --git a/src/payjoin/mod.rs b/src/payjoin/mod.rs index 19beb7d2..30a67741 100644 --- a/src/payjoin/mod.rs +++ b/src/payjoin/mod.rs @@ -1,6 +1,14 @@ -use crate::error::BDKCliError as Error; -use crate::handlers::{broadcast_transaction, sync_wallet}; -use crate::utils::BlockchainClient; +#![cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] + +use crate::{ + backend::BlockchainClient, + handlers::online::{broadcast_transaction, sync_wallet}, +}; use bdk_wallet::{ SignOptions, Wallet, bitcoin::{FeeRate, Psbt, Txid, consensus::encode::serialize_hex}, @@ -22,7 +30,10 @@ use payjoin::{ImplementationError, UriExt}; use serde_json::{json, to_string_pretty}; use std::sync::{Arc, Mutex}; -use crate::payjoin::ohttp::{RelayManager, fetch_ohttp_keys}; +use crate::{ + error::BDKCliError as Error, + payjoin::ohttp::{RelayManager, fetch_ohttp_keys}, +}; pub mod ohttp; @@ -109,6 +120,12 @@ impl<'a> PayjoinManager<'a> { Ok(to_string_pretty(&json!({}))?) } + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] pub async fn send_payjoin( &mut self, uri: String, diff --git a/src/persister.rs b/src/wallet/persister.rs similarity index 100% rename from src/persister.rs rename to src/wallet/persister.rs From b6d86c9354bce6de5aeffc8613794c6e3ca4338a Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Tue, 21 Apr 2026 11:15:43 +0100 Subject: [PATCH 05/20] ref(handlers): split handlers into config, key - split handlers into top level commands config, key and descriptors --- src/handlers.rs | 1856 ------------------------------------ src/handlers/config.rs | 136 +++ src/handlers/descriptor.rs | 110 +++ src/handlers/key.rs | 124 +++ 4 files changed, 370 insertions(+), 1856 deletions(-) delete mode 100644 src/handlers.rs create mode 100644 src/handlers/config.rs create mode 100644 src/handlers/descriptor.rs create mode 100644 src/handlers/key.rs diff --git a/src/handlers.rs b/src/handlers.rs deleted file mode 100644 index fb1544f0..00000000 --- a/src/handlers.rs +++ /dev/null @@ -1,1856 +0,0 @@ -// Copyright (c) 2020-2025 Bitcoin Dev Kit Developers -// -// This file is licensed under the Apache License, Version 2.0 or the MIT license -// , at your option. -// You may not use this file except in accordance with one or both of these -// licenses. - -//! Command Handlers -//! -//! This module describes all the command handling logic used by bdk-cli. -use crate::commands::OfflineWalletSubCommand::*; -use crate::commands::*; -use crate::config::{WalletConfig, WalletConfigInner}; -use crate::error::BDKCliError as Error; -#[cfg(any(feature = "sqlite", feature = "redb"))] -use crate::persister::Persister; -#[cfg(feature = "cbf")] -use crate::utils::BlockchainClient::KyotoClient; -use crate::utils::*; -#[cfg(feature = "redb")] -use bdk_redb::Store as RedbStore; -use bdk_wallet::bip39::{Language, Mnemonic}; -use bdk_wallet::bitcoin::base64::Engine; -use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD; -use bdk_wallet::bitcoin::{ - Address, Amount, FeeRate, Network, Psbt, Sequence, Txid, - bip32::{DerivationPath, KeySource}, - consensus::encode::serialize_hex, - script::PushBytesBuf, - secp256k1::Secp256k1, -}; -use bdk_wallet::chain::ChainPosition; -use bdk_wallet::descriptor::Segwitv0; -use bdk_wallet::keys::{ - DerivableKey, DescriptorKey, DescriptorKey::Secret, ExtendedKey, GeneratableKey, GeneratedKey, - bip39::WordCount, -}; -use bdk_wallet::miniscript::miniscript; -#[cfg(feature = "sqlite")] -use bdk_wallet::rusqlite::Connection; -use bdk_wallet::{KeychainKind, SignOptions, Wallet}; -#[cfg(feature = "compiler")] -use bdk_wallet::{ - bitcoin::XOnlyPublicKey, - descriptor::{Descriptor, Legacy, Miniscript}, - miniscript::{Tap, descriptor::TapTree, policy::Concrete}, -}; -use clap::CommandFactory; -use cli_table::{Cell, CellStruct, Style, Table, format::Justify}; -use serde_json::json; - -#[cfg(feature = "electrum")] -use crate::utils::BlockchainClient::Electrum; -#[cfg(any(feature = "electrum", feature = "esplora"))] -use std::collections::HashSet; -use std::collections::{BTreeMap, HashMap}; -use std::convert::TryFrom; -#[cfg(any(feature = "repl", feature = "electrum", feature = "esplora"))] -use std::io::Write; -use std::path::Path; -use std::str::FromStr; -#[cfg(any( - feature = "redb", - feature = "compiler", - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -use std::sync::Arc; - -#[cfg(feature = "bip322")] -use crate::error::BDKCliError; -#[cfg(feature = "bip322")] -use bdk_bip322::{BIP322, MessageProof, MessageVerificationResult}; - -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -use { - crate::commands::OnlineWalletSubCommand::*, - crate::payjoin::{PayjoinManager, ohttp::RelayManager}, - bdk_wallet::bitcoin::{Transaction, consensus::Decodable, hex::FromHex}, - std::sync::Mutex, -}; -#[cfg(feature = "esplora")] -use {crate::utils::BlockchainClient::Esplora, bdk_esplora::EsploraAsyncExt}; -#[cfg(feature = "rpc")] -use { - crate::utils::BlockchainClient::RpcClient, - bdk_bitcoind_rpc::{Emitter, NO_EXPECTED_MEMPOOL_TXS, bitcoincore_rpc::RpcApi}, - bdk_wallet::chain::{BlockId, CanonicalizationParams, CheckPoint}, -}; - -#[cfg(feature = "compiler")] -const NUMS_UNSPENDABLE_KEY_HEX: &str = - "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"; - -/// Execute an offline wallet sub-command -/// -/// Offline wallet sub-commands are described in [`OfflineWalletSubCommand`]. -pub fn handle_offline_wallet_subcommand( - wallet: &mut Wallet, - wallet_opts: &WalletOpts, - cli_opts: &CliOpts, - offline_subcommand: OfflineWalletSubCommand, -) -> Result { - match offline_subcommand { - NewAddress => { - let addr = wallet.reveal_next_address(KeychainKind::External); - if cli_opts.pretty { - let table = vec![ - vec!["Address".cell().bold(true), addr.address.to_string().cell()], - vec![ - "Index".cell().bold(true), - addr.index.to_string().cell().justify(Justify::Right), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else if wallet_opts.verbose { - Ok(serde_json::to_string_pretty(&json!({ - "address": addr.address, - "index": addr.index - }))?) - } else { - Ok(serde_json::to_string_pretty(&json!({ - "address": addr.address, - }))?) - } - } - UnusedAddress => { - let addr = wallet.next_unused_address(KeychainKind::External); - - if cli_opts.pretty { - let table = vec![ - vec!["Address".cell().bold(true), addr.address.to_string().cell()], - vec![ - "Index".cell().bold(true), - addr.index.to_string().cell().justify(Justify::Right), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else if wallet_opts.verbose { - Ok(serde_json::to_string_pretty(&json!({ - "address": addr.address, - "index": addr.index - }))?) - } else { - Ok(serde_json::to_string_pretty(&json!({ - "address": addr.address, - }))?) - } - } - Unspent => { - let utxos = wallet.list_unspent().collect::>(); - if cli_opts.pretty { - let mut rows: Vec> = vec![]; - for utxo in &utxos { - let height = utxo - .chain_position - .confirmation_height_upper_bound() - .map(|h| h.to_string()) - .unwrap_or("Pending".to_string()); - - let block_hash = match &utxo.chain_position { - ChainPosition::Confirmed { anchor, .. } => anchor.block_id.hash.to_string(), - ChainPosition::Unconfirmed { .. } => "Unconfirmed".to_string(), - }; - - rows.push(vec![ - shorten(utxo.outpoint, 8, 10).cell(), - utxo.txout - .value - .to_sat() - .to_string() - .cell() - .justify(Justify::Right), - Address::from_script(&utxo.txout.script_pubkey, cli_opts.network) - .unwrap() - .cell(), - utxo.keychain.cell(), - utxo.is_spent.cell(), - utxo.derivation_index.cell(), - height.to_string().cell().justify(Justify::Right), - shorten(block_hash, 8, 8).cell().justify(Justify::Right), - ]); - } - let table = rows - .table() - .title(vec![ - "Outpoint".cell().bold(true), - "Output (sat)".cell().bold(true), - "Output Address".cell().bold(true), - "Keychain".cell().bold(true), - "Is Spent".cell().bold(true), - "Index".cell().bold(true), - "Block Height".cell().bold(true), - "Block Hash".cell().bold(true), - ]) - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty(&utxos)?) - } - } - Transactions => { - let transactions = wallet.transactions(); - - if cli_opts.pretty { - let txns = transactions - .map(|tx| { - let total_value = tx - .tx_node - .output - .iter() - .map(|output| output.value.to_sat()) - .sum::(); - ( - tx.tx_node.txid.to_string(), - tx.tx_node.version, - tx.tx_node.is_explicitly_rbf(), - tx.tx_node.input.len(), - tx.tx_node.output.len(), - total_value, - ) - }) - .collect::>(); - let mut rows: Vec> = vec![]; - for (txid, version, is_rbf, input_count, output_count, total_value) in txns { - rows.push(vec![ - txid.cell(), - version.to_string().cell().justify(Justify::Right), - is_rbf.to_string().cell().justify(Justify::Center), - input_count.to_string().cell().justify(Justify::Right), - output_count.to_string().cell().justify(Justify::Right), - total_value.to_string().cell().justify(Justify::Right), - ]); - } - let table = rows - .table() - .title(vec![ - "Txid".cell().bold(true), - "Version".cell().bold(true), - "Is RBF".cell().bold(true), - "Input Count".cell().bold(true), - "Output Count".cell().bold(true), - "Total Value (sat)".cell().bold(true), - ]) - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - let txns: Vec<_> = transactions - .map(|tx| { - json!({ - "txid": tx.tx_node.txid, - "is_coinbase": tx.tx_node.is_coinbase(), - "wtxid": tx.tx_node.compute_wtxid(), - "version": tx.tx_node.version, - "is_rbf": tx.tx_node.is_explicitly_rbf(), - "inputs": tx.tx_node.input, - "outputs": tx.tx_node.output, - }) - }) - .collect(); - Ok(serde_json::to_string_pretty(&txns)?) - } - } - Balance => { - let balance = wallet.balance(); - if cli_opts.pretty { - let table = vec![ - vec!["Type".cell().bold(true), "Amount (sat)".cell().bold(true)], - vec![ - "Total".cell(), - balance - .total() - .to_sat() - .to_string() - .cell() - .justify(Justify::Right), - ], - vec![ - "Confirmed".cell(), - balance - .confirmed - .to_sat() - .to_string() - .cell() - .justify(Justify::Right), - ], - vec![ - "Unconfirmed".cell(), - balance - .immature - .to_sat() - .to_string() - .cell() - .justify(Justify::Right), - ], - vec![ - "Trusted Pending".cell(), - balance - .trusted_pending - .to_sat() - .cell() - .justify(Justify::Right), - ], - vec![ - "Untrusted Pending".cell(), - balance - .untrusted_pending - .to_sat() - .cell() - .justify(Justify::Right), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({"satoshi": wallet.balance()}), - )?) - } - } - - CreateTx { - recipients, - send_all, - enable_rbf, - offline_signer, - utxos, - unspendable, - fee_rate, - external_policy, - internal_policy, - add_data, - add_string, - } => { - let mut tx_builder = wallet.build_tx(); - - if send_all { - tx_builder.drain_wallet().drain_to(recipients[0].0.clone()); - } else { - let recipients = recipients - .into_iter() - .map(|(script, amount)| (script, Amount::from_sat(amount))) - .collect(); - tx_builder.set_recipients(recipients); - } - - if !enable_rbf { - tx_builder.set_exact_sequence(Sequence::MAX); - } - - if offline_signer { - tx_builder.include_output_redeem_witness_script(); - } - - if let Some(fee_rate) = fee_rate - && let Some(fee_rate) = FeeRate::from_sat_per_vb(fee_rate as u64) - { - tx_builder.fee_rate(fee_rate); - } - - if let Some(utxos) = utxos { - tx_builder.add_utxos(&utxos[..]).unwrap(); - } - - if let Some(unspendable) = unspendable { - tx_builder.unspendable(unspendable); - } - - if let Some(base64_data) = add_data { - let op_return_data = BASE64_STANDARD.decode(base64_data).unwrap(); - tx_builder.add_data(&PushBytesBuf::try_from(op_return_data).unwrap()); - } else if let Some(string_data) = add_string { - let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec()).unwrap(); - tx_builder.add_data(&data); - } - - let policies = vec![ - external_policy.map(|p| (p, KeychainKind::External)), - internal_policy.map(|p| (p, KeychainKind::Internal)), - ]; - - for (policy, keychain) in policies.into_iter().flatten() { - let policy = serde_json::from_str::>>(&policy)?; - tx_builder.policy_path(policy, keychain); - } - - let psbt = tx_builder.finish()?; - - let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); - - if wallet_opts.verbose { - Ok(serde_json::to_string_pretty( - &json!({"psbt": psbt_base64, "details": psbt}), - )?) - } else { - Ok(serde_json::to_string_pretty( - &json!({"psbt": psbt_base64 }), - )?) - } - } - BumpFee { - txid, - shrink_address, - offline_signer, - utxos, - unspendable, - fee_rate, - } => { - let txid = Txid::from_str(txid.as_str())?; - - let mut tx_builder = wallet.build_fee_bump(txid)?; - let fee_rate = - FeeRate::from_sat_per_vb(fee_rate as u64).unwrap_or(FeeRate::BROADCAST_MIN); - tx_builder.fee_rate(fee_rate); - - if let Some(address) = shrink_address { - let script_pubkey = address.script_pubkey(); - tx_builder.drain_to(script_pubkey); - } - - if offline_signer { - tx_builder.include_output_redeem_witness_script(); - } - - if let Some(utxos) = utxos { - tx_builder.add_utxos(&utxos[..]).unwrap(); - } - - if let Some(unspendable) = unspendable { - tx_builder.unspendable(unspendable); - } - - let psbt = tx_builder.finish()?; - - let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); - - Ok(serde_json::to_string_pretty( - &json!({"psbt": psbt_base64 }), - )?) - } - Policies => { - let external_policy = wallet.policies(KeychainKind::External)?; - let internal_policy = wallet.policies(KeychainKind::Internal)?; - if cli_opts.pretty { - let table = vec![ - vec![ - "External".cell().bold(true), - serde_json::to_string_pretty(&external_policy)?.cell(), - ], - vec![ - "Internal".cell().bold(true), - serde_json::to_string_pretty(&internal_policy)?.cell(), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty(&json!({ - "external": external_policy, - "internal": internal_policy, - }))?) - } - } - PublicDescriptor => { - let external = wallet.public_descriptor(KeychainKind::External).to_string(); - let internal = wallet.public_descriptor(KeychainKind::Internal).to_string(); - - if cli_opts.pretty { - let table = vec![ - vec![ - "External Descriptor".cell().bold(true), - external.to_string().cell(), - ], - vec![ - "Internal Descriptor".cell().bold(true), - internal.to_string().cell(), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty(&json!({ - "external": external.to_string(), - "internal": internal.to_string(), - }))?) - } - } - Sign { - psbt, - assume_height, - trust_witness_utxo, - } => { - let psbt_bytes = BASE64_STANDARD.decode(psbt)?; - let mut psbt = Psbt::deserialize(&psbt_bytes)?; - let signopt = SignOptions { - assume_height, - trust_witness_utxo: trust_witness_utxo.unwrap_or(false), - ..Default::default() - }; - let finalized = wallet.sign(&mut psbt, signopt)?; - let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); - if wallet_opts.verbose { - Ok(serde_json::to_string_pretty( - &json!({"psbt": &psbt_base64, "is_finalized": finalized, "serialized_psbt": &psbt}), - )?) - } else { - Ok(serde_json::to_string_pretty( - &json!({"psbt": &psbt_base64, "is_finalized": finalized}), - )?) - } - } - ExtractPsbt { psbt } => { - let psbt_serialized = BASE64_STANDARD.decode(psbt)?; - let psbt = Psbt::deserialize(&psbt_serialized)?; - let raw_tx = psbt.extract_tx()?; - if cli_opts.pretty { - let table = vec![vec![ - "Raw Transaction".cell().bold(true), - serialize_hex(&raw_tx).cell(), - ]] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({"raw_tx": serialize_hex(&raw_tx)}), - )?) - } - } - FinalizePsbt { - psbt, - assume_height, - trust_witness_utxo, - } => { - let psbt_bytes = BASE64_STANDARD.decode(psbt)?; - let mut psbt: Psbt = Psbt::deserialize(&psbt_bytes)?; - - let signopt = SignOptions { - assume_height, - trust_witness_utxo: trust_witness_utxo.unwrap_or(false), - ..Default::default() - }; - let finalized = wallet.finalize_psbt(&mut psbt, signopt)?; - if wallet_opts.verbose { - Ok(serde_json::to_string_pretty( - &json!({ "psbt": BASE64_STANDARD.encode(psbt.serialize()), "is_finalized": finalized, "details": psbt}), - )?) - } else { - Ok(serde_json::to_string_pretty( - &json!({ "psbt": BASE64_STANDARD.encode(psbt.serialize()), "is_finalized": finalized}), - )?) - } - } - CombinePsbt { psbt } => { - let mut psbts = psbt - .iter() - .map(|s| { - let psbt = BASE64_STANDARD.decode(s)?; - Ok(Psbt::deserialize(&psbt)?) - }) - .collect::, Error>>()?; - - let init_psbt = psbts - .pop() - .ok_or_else(|| Error::Generic("Invalid PSBT input".to_string()))?; - let final_psbt = psbts.into_iter().try_fold::<_, _, Result>( - init_psbt, - |mut acc, x| { - let _ = acc.combine(x); - Ok(acc) - }, - )?; - Ok(serde_json::to_string_pretty( - &json!({ "psbt": BASE64_STANDARD.encode(final_psbt.serialize()) }), - )?) - } - #[cfg(feature = "bip322")] - SignMessage { - message, - signature_type, - address, - utxos, - } => { - let address: Address = parse_address(&address)?; - let signature_format = parse_signature_format(&signature_type)?; - - if !wallet.is_mine(address.script_pubkey()) { - return Err(Error::Generic(format!( - "Address {} does not belong to this wallet.", - address - ))); - } - - let proof: MessageProof = - wallet.sign_message(message.as_str(), signature_format, &address, utxos)?; - - Ok(json!({"proof": proof.to_base64()}).to_string()) - } - #[cfg(feature = "bip322")] - VerifyMessage { - proof, - message, - address, - } => { - let address: Address = parse_address(&address)?; - let parsed_proof: MessageProof = MessageProof::from_base64(&proof) - .map_err(|e| BDKCliError::Generic(format!("Invalid proof: {e}")))?; - - let is_valid: MessageVerificationResult = - wallet.verify_message(&parsed_proof, &message, &address)?; - - Ok(json!({ - "valid": is_valid.valid, - "proven_amount": is_valid.proven_amount.map(|a| a.to_sat()) // optional field - }) - .to_string()) - } - } -} - -/// Execute an online wallet sub-command -/// -/// Online wallet sub-commands are described in [`OnlineWalletSubCommand`]. -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -pub(crate) async fn handle_online_wallet_subcommand( - wallet: &mut Wallet, - client: &BlockchainClient, - online_subcommand: OnlineWalletSubCommand, -) -> Result { - match online_subcommand { - FullScan { - stop_gap: _stop_gap, - } => { - #[cfg(any(feature = "electrum", feature = "esplora"))] - let request = wallet.start_full_scan().inspect({ - let mut stdout = std::io::stdout(); - let mut once = HashSet::::new(); - move |k, spk_i, _| { - if once.insert(k) { - print!("\nScanning keychain [{k:?}]"); - } - print!(" {spk_i:<3}"); - stdout.flush().expect("must flush"); - } - }); - match client { - #[cfg(feature = "electrum")] - Electrum { client, batch_size } => { - // Populate the electrum client's transaction cache so it doesn't re-download transaction we - // already have. - client - .populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); - - let update = client.full_scan(request, _stop_gap, *batch_size, false)?; - wallet.apply_update(update)?; - } - #[cfg(feature = "esplora")] - Esplora { - client, - parallel_requests, - } => { - let update = client - .full_scan(request, _stop_gap, *parallel_requests) - .await - .map_err(|e| *e)?; - wallet.apply_update(update)?; - } - - #[cfg(feature = "rpc")] - RpcClient { client } => { - let blockchain_info = client.get_blockchain_info()?; - - let genesis_block = - bdk_wallet::bitcoin::constants::genesis_block(wallet.network()); - let genesis_cp = CheckPoint::new(BlockId { - height: 0, - hash: genesis_block.block_hash(), - }); - let mut emitter = Emitter::new( - client.as_ref(), - genesis_cp.clone(), - genesis_cp.height(), - NO_EXPECTED_MEMPOOL_TXS, - ); - - while let Some(block_event) = emitter.next_block()? { - if block_event.block_height() % 10_000 == 0 { - let percent_done = f64::from(block_event.block_height()) - / f64::from(blockchain_info.headers as u32) - * 100f64; - println!( - "Applying block at height: {}, {:.2}% done.", - block_event.block_height(), - percent_done - ); - } - - wallet.apply_block_connected_to( - &block_event.block, - block_event.block_height(), - block_event.connected_to(), - )?; - } - - let mempool_txs = emitter.mempool()?; - wallet.apply_unconfirmed_txs(mempool_txs.update); - } - #[cfg(feature = "cbf")] - KyotoClient { client } => { - sync_kyoto_client(wallet, client).await?; - } - } - Ok(serde_json::to_string_pretty(&json!({}))?) - } - Sync => { - sync_wallet(client, wallet).await?; - Ok(serde_json::to_string_pretty(&json!({}))?) - } - Broadcast { psbt, tx } => { - let tx = match (psbt, tx) { - (Some(psbt), None) => { - let psbt = BASE64_STANDARD - .decode(psbt) - .map_err(|e| Error::Generic(e.to_string()))?; - let psbt: Psbt = Psbt::deserialize(&psbt)?; - is_final(&psbt)?; - psbt.extract_tx()? - } - (None, Some(tx)) => { - let tx_bytes = Vec::::from_hex(&tx)?; - Transaction::consensus_decode(&mut tx_bytes.as_slice())? - } - (Some(_), Some(_)) => panic!("Both `psbt` and `tx` options not allowed"), - (None, None) => panic!("Missing `psbt` and `tx` option"), - }; - let txid = broadcast_transaction(client, tx).await?; - Ok(serde_json::to_string_pretty(&json!({ "txid": txid }))?) - } - ReceivePayjoin { - amount, - directory, - ohttp_relay, - max_fee_rate, - } => { - let relay_manager = Arc::new(Mutex::new(RelayManager::new())); - let mut payjoin_manager = PayjoinManager::new(wallet, relay_manager); - return payjoin_manager - .receive_payjoin(amount, directory, max_fee_rate, ohttp_relay, client) - .await; - } - SendPayjoin { - uri, - ohttp_relay, - fee_rate, - } => { - let relay_manager = Arc::new(Mutex::new(RelayManager::new())); - let mut payjoin_manager = PayjoinManager::new(wallet, relay_manager); - return payjoin_manager - .send_payjoin(uri, fee_rate, ohttp_relay, client) - .await; - } - } -} - -/// Handle wallet config subcommand to create or update config.toml -pub fn handle_config_subcommand( - datadir: &Path, - network: Network, - wallet: String, - wallet_opts: &WalletOpts, - force: bool, -) -> Result { - if network == Network::Bitcoin { - eprintln!( - "WARNING: You are configuring a wallet for Bitcoin MAINNET. - This software is experimental and not recommended for use with real funds. - Consider using a testnet for testing purposes. \n" - ); - } - - let ext_descriptor = wallet_opts.ext_descriptor.clone(); - let int_descriptor = wallet_opts.int_descriptor.clone(); - - if ext_descriptor.contains("xprv") || ext_descriptor.contains("tprv") { - eprintln!( - "WARNING: Your external descriptor contains PRIVATE KEYS. - Private keys will be saved in PLAINTEXT in the config file. - This is a security risk. Consider using public descriptors instead.\n" - ); - } - - if let Some(ref internal_desc) = int_descriptor - && (internal_desc.contains("xprv") || internal_desc.contains("tprv")) - { - eprintln!( - "WARNING: Your internal descriptor contains PRIVATE KEYS. - Private keys will be saved in PLAINTEXT in the config file. - This is a security risk. Consider using public descriptors instead.\n" - ); - } - - let mut config = WalletConfig::load(datadir)?.unwrap_or(WalletConfig { - wallets: HashMap::new(), - }); - - if config.wallets.contains_key(&wallet) && !force { - return Err(Error::Generic(format!( - "Wallet '{wallet}' already exists in config.toml. Use --force to overwrite." - ))); - } - - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] - let client_type = wallet_opts.client_type.clone(); - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] - let url = &wallet_opts.url.clone(); - #[cfg(any(feature = "sqlite", feature = "redb"))] - let database_type = match wallet_opts.database_type { - #[cfg(feature = "sqlite")] - DatabaseType::Sqlite => "sqlite".to_string(), - #[cfg(feature = "redb")] - DatabaseType::Redb => "redb".to_string(), - }; - - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] - let client_type = match client_type { - #[cfg(feature = "electrum")] - ClientType::Electrum => "electrum".to_string(), - #[cfg(feature = "esplora")] - ClientType::Esplora => "esplora".to_string(), - #[cfg(feature = "rpc")] - ClientType::Rpc => "rpc".to_string(), - #[cfg(feature = "cbf")] - ClientType::Cbf => "cbf".to_string(), - }; - - let wallet_config = WalletConfigInner { - wallet: wallet.clone(), - network: network.to_string(), - ext_descriptor, - int_descriptor, - #[cfg(any(feature = "sqlite", feature = "redb"))] - database_type, - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] - client_type: Some(client_type), - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc",))] - server_url: Some(url.to_string()), - #[cfg(feature = "rpc")] - rpc_user: Some(wallet_opts.basic_auth.0.clone()), - #[cfg(feature = "rpc")] - rpc_password: Some(wallet_opts.basic_auth.1.clone()), - #[cfg(feature = "electrum")] - batch_size: Some(wallet_opts.batch_size), - #[cfg(feature = "esplora")] - parallel_requests: Some(wallet_opts.parallel_requests), - #[cfg(feature = "rpc")] - cookie: wallet_opts.cookie.clone(), - }; - - config.wallets.insert(wallet.clone(), wallet_config); - config.save(datadir)?; - - Ok(serde_json::to_string_pretty(&json!({ - "message": format!("Wallet '{wallet}' initialized successfully in {:?}", datadir.join("config.toml")) - }))?) -} - -/// Determine if PSBT has final script sigs or witnesses for all unsigned tx inputs. -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -pub(crate) fn is_final(psbt: &Psbt) -> Result<(), Error> { - let unsigned_tx_inputs = psbt.unsigned_tx.input.len(); - let psbt_inputs = psbt.inputs.len(); - if unsigned_tx_inputs != psbt_inputs { - return Err(Error::Generic(format!( - "Malformed PSBT, {unsigned_tx_inputs} unsigned tx inputs and {psbt_inputs} psbt inputs." - ))); - } - let sig_count = psbt.inputs.iter().fold(0, |count, input| { - if input.final_script_sig.is_some() || input.final_script_witness.is_some() { - count + 1 - } else { - count - } - }); - if unsigned_tx_inputs > sig_count { - return Err(Error::Generic( - "The PSBT is not finalized, inputs are are not fully signed.".to_string(), - )); - } - Ok(()) -} - -/// Handle a key sub-command -/// -/// Key sub-commands are described in [`KeySubCommand`]. -pub(crate) fn handle_key_subcommand( - network: Network, - subcommand: KeySubCommand, - pretty: bool, -) -> Result { - let secp = Secp256k1::new(); - - match subcommand { - KeySubCommand::Generate { - word_count, - password, - } => { - let mnemonic_type = match word_count { - 12 => WordCount::Words12, - _ => WordCount::Words24, - }; - let mnemonic: GeneratedKey<_, miniscript::BareCtx> = - Mnemonic::generate((mnemonic_type, Language::English)) - .map_err(|_| Error::Generic("Mnemonic generation error".to_string()))?; - let mnemonic = mnemonic.into_key(); - let xkey: ExtendedKey = (mnemonic.clone(), password).into_extended_key()?; - let xprv = xkey.into_xprv(network).ok_or_else(|| { - Error::Generic("Privatekey info not found (should not happen)".to_string()) - })?; - let fingerprint = xprv.fingerprint(&secp); - let phrase = mnemonic - .words() - .fold("".to_string(), |phrase, w| phrase + w + " ") - .trim() - .to_string(); - if pretty { - let table = vec![ - vec![ - "Fingerprint".cell().bold(true), - fingerprint.to_string().cell(), - ], - vec!["Mnemonic".cell().bold(true), mnemonic.to_string().cell()], - vec!["Xprv".cell().bold(true), xprv.to_string().cell()], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({ "mnemonic": phrase, "xprv": xprv.to_string(), "fingerprint": fingerprint.to_string() }), - )?) - } - } - KeySubCommand::Restore { mnemonic, password } => { - let mnemonic = Mnemonic::parse_in(Language::English, mnemonic)?; - let xkey: ExtendedKey = (mnemonic.clone(), password).into_extended_key()?; - let xprv = xkey.into_xprv(network).ok_or_else(|| { - Error::Generic("Privatekey info not found (should not happen)".to_string()) - })?; - let fingerprint = xprv.fingerprint(&secp); - if pretty { - let table = vec![ - vec![ - "Fingerprint".cell().bold(true), - fingerprint.to_string().cell(), - ], - vec!["Mnemonic".cell().bold(true), mnemonic.to_string().cell()], - vec!["Xprv".cell().bold(true), xprv.to_string().cell()], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({ "xprv": xprv.to_string(), "fingerprint": fingerprint.to_string() }), - )?) - } - } - KeySubCommand::Derive { xprv, path } => { - if xprv.network != network.into() { - return Err(Error::Generic("Invalid network".to_string())); - } - let derived_xprv = &xprv.derive_priv(&secp, &path)?; - - let origin: KeySource = (xprv.fingerprint(&secp), path); - - let derived_xprv_desc_key: DescriptorKey = - derived_xprv.into_descriptor_key(Some(origin), DerivationPath::default())?; - - if let Secret(desc_seckey, _, _) = derived_xprv_desc_key { - let desc_pubkey = desc_seckey.to_public(&secp)?; - if pretty { - let table = vec![ - vec!["Xpub".cell().bold(true), desc_pubkey.to_string().cell()], - vec!["Xprv".cell().bold(true), xprv.to_string().cell()], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({"xpub": desc_pubkey.to_string(), "xprv": desc_seckey.to_string()}), - )?) - } - } else { - Err(Error::Generic("Invalid key variant".to_string())) - } - } - } -} - -/// Handle the miniscript compiler sub-command -/// -/// Compiler options are described in [`CliSubCommand::Compile`]. -#[cfg(feature = "compiler")] -pub(crate) fn handle_compile_subcommand( - _network: Network, - policy: String, - script_type: String, - pretty: bool, -) -> Result { - let policy = Concrete::::from_str(policy.as_str())?; - let legacy_policy: Miniscript = policy - .compile() - .map_err(|e| Error::Generic(e.to_string()))?; - let segwit_policy: Miniscript = policy - .compile() - .map_err(|e| Error::Generic(e.to_string()))?; - let taproot_policy: Miniscript = policy - .compile() - .map_err(|e| Error::Generic(e.to_string()))?; - - let descriptor = match script_type.as_str() { - "sh" => Descriptor::new_sh(legacy_policy), - "wsh" => Descriptor::new_wsh(segwit_policy), - "sh-wsh" => Descriptor::new_sh_wsh(segwit_policy), - "tr" => { - // For tr descriptors, we use a well-known unspendable key (NUMS point). - // This ensures the key path is effectively disabled and only script path can be used. - // See https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs - - let xonly_public_key = XOnlyPublicKey::from_str(NUMS_UNSPENDABLE_KEY_HEX) - .map_err(|e| Error::Generic(format!("Invalid NUMS key: {e}")))?; - - let tree = TapTree::Leaf(Arc::new(taproot_policy)); - Descriptor::new_tr(xonly_public_key.to_string(), Some(tree)) - } - _ => { - return Err(Error::Generic( - "Invalid script type. Supported types: sh, wsh, sh-wsh, tr".to_string(), - )); - } - }?; - if pretty { - let table = vec![vec![ - "Descriptor".cell().bold(true), - descriptor.to_string().cell(), - ]] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({"descriptor": descriptor.to_string()}), - )?) - } -} - -/// Handle wallets command to show all saved wallet configurations -pub fn handle_wallets_subcommand(datadir: &Path, pretty: bool) -> Result { - let load_config = WalletConfig::load(datadir)?; - - let config = match load_config { - Some(c) if !c.wallets.is_empty() => c, - _ => { - return Ok(if pretty { - "No wallet configurations found.".to_string() - } else { - serde_json::to_string_pretty(&json!({ - "wallets": [] - }))? - }); - } - }; - - if pretty { - let mut rows: Vec> = vec![]; - - for (name, wallet_config) in config.wallets.iter() { - let mut row = vec![name.cell(), wallet_config.network.clone().cell()]; - - #[cfg(any(feature = "sqlite", feature = "redb"))] - row.push(wallet_config.database_type.clone().cell()); - - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] - { - let client_str = wallet_config.client_type.as_deref().unwrap_or("N/A"); - row.push(client_str.cell()); - } - - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] - { - let url_str = wallet_config.server_url.as_deref().unwrap_or("N/A"); - let display_url = if url_str.len() > 20 { - shorten(url_str, 15, 10) - } else { - url_str.to_string() - }; - row.push(display_url.cell()); - } - - let ext_desc_display = if wallet_config.ext_descriptor.len() > 40 { - shorten(&wallet_config.ext_descriptor, 20, 15) - } else { - wallet_config.ext_descriptor.clone() - }; - row.push(ext_desc_display.cell()); - - let has_int_desc = if wallet_config.int_descriptor.is_some() { - "Yes" - } else { - "No" - }; - row.push(has_int_desc.cell()); - - rows.push(row); - } - - let mut title_cells = vec!["Wallet Name".cell().bold(true), "Network".cell().bold(true)]; - - #[cfg(any(feature = "sqlite", feature = "redb"))] - title_cells.push("Database".cell().bold(true)); - - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] - title_cells.push("Client".cell().bold(true)); - - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] - title_cells.push("Server URL".cell().bold(true)); - - title_cells.push("External Desc".cell().bold(true)); - title_cells.push("Internal Desc".cell().bold(true)); - - let table = rows - .table() - .title(title_cells) - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - - Ok(format!("{table}")) - } else { - let wallets_summary: Vec<_> = config - .wallets - .iter() - .map(|(name, wallet_config)| { - #[allow(unused_mut)] - let mut wallet_json = json!({ - "name": name, - "network": wallet_config.network, - "ext_descriptor": wallet_config.ext_descriptor, - "int_descriptor": wallet_config.int_descriptor, - }); - - #[cfg(any(feature = "sqlite", feature = "redb"))] - { - wallet_json["database_type"] = json!(wallet_config.database_type.clone()); - } - - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] - { - wallet_json["client_type"] = json!(wallet_config.client_type.clone()); - } - - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] - { - wallet_json["server_url"] = json!(wallet_config.server_url.clone()); - } - - wallet_json - }) - .collect(); - - Ok(serde_json::to_string_pretty(&json!({ - "wallets": wallets_summary - }))?) - } -} - -/// The global top level handler. -pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { - let pretty = cli_opts.pretty; - let subcommand = cli_opts.subcommand.clone(); - - let result: Result = match subcommand { - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" - ))] - CliSubCommand::Wallet { - wallet, - subcommand: WalletSubCommand::OnlineWalletSubCommand(online_subcommand), - } => { - let home_dir = prepare_home_dir(cli_opts.datadir)?; - - let (wallet_opts, network) = load_wallet_config(&home_dir, &wallet)?; - - let database_path = prepare_wallet_db_dir(&home_dir, &wallet)?; - - #[cfg(any(feature = "sqlite", feature = "redb"))] - let result = { - let mut persister: Persister = match &wallet_opts.database_type { - #[cfg(feature = "sqlite")] - DatabaseType::Sqlite => { - let db_file = database_path.join("wallet.sqlite"); - let connection = Connection::open(db_file)?; - log::debug!("Sqlite database opened successfully"); - Persister::Connection(connection) - } - #[cfg(feature = "redb")] - DatabaseType::Redb => { - let wallet_name = &wallet_opts.wallet; - let db = Arc::new(bdk_redb::redb::Database::create( - home_dir.join("wallet.redb"), - )?); - let store = RedbStore::new( - db, - wallet_name.as_deref().unwrap_or("wallet").to_string(), - )?; - log::debug!("Redb database opened successfully"); - Persister::RedbStore(store) - } - }; - - let mut wallet = new_persisted_wallet(network, &mut persister, &wallet_opts)?; - let blockchain_client = - new_blockchain_client(&wallet_opts, &wallet, database_path)?; - - let result = handle_online_wallet_subcommand( - &mut wallet, - &blockchain_client, - online_subcommand, - ) - .await?; - wallet.persist(&mut persister)?; - result - }; - #[cfg(not(any(feature = "sqlite", feature = "redb")))] - let result = { - let mut wallet = new_wallet(network, wallet_opts)?; - let blockchain_client = - crate::utils::new_blockchain_client(wallet_opts, &wallet, database_path)?; - handle_online_wallet_subcommand(&mut wallet, &blockchain_client, online_subcommand) - .await? - }; - Ok(result) - } - CliSubCommand::Wallet { - wallet: wallet_name, - subcommand: WalletSubCommand::OfflineWalletSubCommand(offline_subcommand), - } => { - let datadir = cli_opts.datadir.clone(); - let home_dir = prepare_home_dir(datadir)?; - let (wallet_opts, network) = load_wallet_config(&home_dir, &wallet_name)?; - - #[cfg(any(feature = "sqlite", feature = "redb"))] - let result = { - let mut persister: Persister = match &wallet_opts.database_type { - #[cfg(feature = "sqlite")] - DatabaseType::Sqlite => { - let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?; - let db_file = database_path.join("wallet.sqlite"); - let connection = Connection::open(db_file)?; - log::debug!("Sqlite database opened successfully"); - Persister::Connection(connection) - } - #[cfg(feature = "redb")] - DatabaseType::Redb => { - let db = Arc::new(bdk_redb::redb::Database::create( - home_dir.join("wallet.redb"), - )?); - let store = RedbStore::new(db, wallet_name)?; - log::debug!("Redb database opened successfully"); - Persister::RedbStore(store) - } - }; - - let mut wallet = new_persisted_wallet(network, &mut persister, &wallet_opts)?; - - let result = handle_offline_wallet_subcommand( - &mut wallet, - &wallet_opts, - &cli_opts, - offline_subcommand.clone(), - )?; - wallet.persist(&mut persister)?; - result - }; - #[cfg(not(any(feature = "sqlite", feature = "redb")))] - let result = { - let mut wallet = new_wallet(network, &wallet_opts)?; - handle_offline_wallet_subcommand( - &mut wallet, - &wallet_opts, - &cli_opts, - offline_subcommand.clone(), - )? - }; - Ok(result) - } - CliSubCommand::Wallet { - wallet, - subcommand: WalletSubCommand::Config { force, wallet_opts }, - } => { - let network = cli_opts.network; - let home_dir = prepare_home_dir(cli_opts.datadir)?; - let result = handle_config_subcommand(&home_dir, network, wallet, &wallet_opts, force)?; - Ok(result) - } - CliSubCommand::Wallets => { - let home_dir = prepare_home_dir(cli_opts.datadir)?; - let result = handle_wallets_subcommand(&home_dir, pretty)?; - Ok(result) - } - CliSubCommand::Key { - subcommand: key_subcommand, - } => { - let network = cli_opts.network; - let result = handle_key_subcommand(network, key_subcommand, pretty)?; - Ok(result) - } - #[cfg(feature = "compiler")] - CliSubCommand::Compile { - policy, - script_type, - } => { - let network = cli_opts.network; - let result = handle_compile_subcommand(network, policy, script_type, pretty)?; - Ok(result) - } - #[cfg(feature = "repl")] - CliSubCommand::Repl { - wallet: wallet_name, - } => { - let home_dir = prepare_home_dir(cli_opts.datadir.clone())?; - let (wallet_opts, network) = load_wallet_config(&home_dir, &wallet_name)?; - - #[cfg(any(feature = "sqlite", feature = "redb"))] - let (mut wallet, mut persister) = { - let mut persister: Persister = match &wallet_opts.database_type { - #[cfg(feature = "sqlite")] - DatabaseType::Sqlite => { - let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?; - let db_file = database_path.join("wallet.sqlite"); - let connection = Connection::open(db_file)?; - log::debug!("Sqlite database opened successfully"); - Persister::Connection(connection) - } - #[cfg(feature = "redb")] - DatabaseType::Redb => { - let db = Arc::new(bdk_redb::redb::Database::create( - home_dir.join("wallet.redb"), - )?); - let store = RedbStore::new(db, wallet_name.clone())?; - log::debug!("Redb database opened successfully"); - Persister::RedbStore(store) - } - }; - let wallet = new_persisted_wallet(network, &mut persister, &wallet_opts)?; - (wallet, persister) - }; - #[cfg(not(any(feature = "sqlite", feature = "redb")))] - let mut wallet = new_wallet(network, &loaded_wallet_opts)?; - let home_dir = prepare_home_dir(cli_opts.datadir.clone())?; - let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?; - loop { - let line = readline()?; - let line = line.trim(); - if line.is_empty() { - continue; - } - - let result = respond( - network, - &mut wallet, - &wallet_name, - &mut wallet_opts.clone(), - line, - database_path.clone(), - &cli_opts, - ) - .await; - #[cfg(any(feature = "sqlite", feature = "redb"))] - wallet.persist(&mut persister)?; - - match result { - Ok(quit) => { - if quit { - break; - } - } - Err(err) => { - writeln!(std::io::stdout(), "{err}") - .map_err(|e| Error::Generic(e.to_string()))?; - std::io::stdout() - .flush() - .map_err(|e| Error::Generic(e.to_string()))?; - } - } - } - Ok("".to_string()) - } - CliSubCommand::Descriptor { desc_type, key } => { - let descriptor = handle_descriptor_command(cli_opts.network, desc_type, key, pretty)?; - Ok(descriptor) - } - CliSubCommand::Completions { shell } => { - clap_complete::generate( - shell, - &mut CliOpts::command(), - "bdk-cli", - &mut std::io::stdout(), - ); - - Ok("".to_string()) - } - }; - result -} - -#[cfg(feature = "repl")] -async fn respond( - network: Network, - wallet: &mut Wallet, - wallet_name: &String, - wallet_opts: &mut WalletOpts, - line: &str, - _datadir: std::path::PathBuf, - cli_opts: &CliOpts, -) -> Result { - use clap::Parser; - - let args = shlex::split(line).ok_or("error: Invalid quoting".to_string())?; - let repl_subcommand = ReplSubCommand::try_parse_from(args).map_err(|e| e.to_string())?; - let response = match repl_subcommand { - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" - ))] - ReplSubCommand::Wallet { - subcommand: WalletSubCommand::OnlineWalletSubCommand(online_subcommand), - } => { - let blockchain = - new_blockchain_client(wallet_opts, wallet, _datadir).map_err(|e| e.to_string())?; - let value = handle_online_wallet_subcommand(wallet, &blockchain, online_subcommand) - .await - .map_err(|e| e.to_string())?; - Some(value) - } - ReplSubCommand::Wallet { - subcommand: WalletSubCommand::OfflineWalletSubCommand(offline_subcommand), - } => { - let value = - handle_offline_wallet_subcommand(wallet, wallet_opts, cli_opts, offline_subcommand) - .map_err(|e| e.to_string())?; - Some(value) - } - ReplSubCommand::Wallet { - subcommand: WalletSubCommand::Config { force, wallet_opts }, - } => { - let value = handle_config_subcommand( - &_datadir, - network, - wallet_name.to_string(), - &wallet_opts, - force, - ) - .map_err(|e| e.to_string())?; - Some(value) - } - ReplSubCommand::Key { subcommand } => { - let value = handle_key_subcommand(network, subcommand, cli_opts.pretty) - .map_err(|e| e.to_string())?; - Some(value) - } - ReplSubCommand::Descriptor { desc_type, key } => { - let value = handle_descriptor_command(network, desc_type, key, cli_opts.pretty) - .map_err(|e| e.to_string())?; - Some(value) - } - ReplSubCommand::Exit => None, - }; - if let Some(value) = response { - writeln!(std::io::stdout(), "{value}").map_err(|e| e.to_string())?; - std::io::stdout().flush().map_err(|e| e.to_string())?; - Ok(false) - } else { - writeln!(std::io::stdout(), "Exiting...").map_err(|e| e.to_string())?; - std::io::stdout().flush().map_err(|e| e.to_string())?; - Ok(true) - } -} - -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -/// Syncs a given wallet using the blockchain client. -pub async fn sync_wallet(client: &BlockchainClient, wallet: &mut Wallet) -> Result<(), Error> { - #[cfg(any(feature = "electrum", feature = "esplora"))] - let request = wallet - .start_sync_with_revealed_spks() - .inspect(|item, progress| { - let pc = (100 * progress.consumed()) as f32 / progress.total() as f32; - eprintln!("[ SCANNING {pc:03.0}% ] {item}"); - }); - match client { - #[cfg(feature = "electrum")] - Electrum { client, batch_size } => { - // Populate the electrum client's transaction cache so it doesn't re-download transaction we - // already have. - client.populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); - - let update = client.sync(request, *batch_size, false)?; - wallet - .apply_update(update) - .map_err(|e| Error::Generic(e.to_string())) - } - #[cfg(feature = "esplora")] - Esplora { - client, - parallel_requests, - } => { - let update = client - .sync(request, *parallel_requests) - .await - .map_err(|e| *e)?; - wallet - .apply_update(update) - .map_err(|e| Error::Generic(e.to_string())) - } - #[cfg(feature = "rpc")] - RpcClient { client } => { - let blockchain_info = client.get_blockchain_info()?; - let wallet_cp = wallet.latest_checkpoint(); - - // reload the last 200 blocks in case of a reorg - let emitter_height = wallet_cp.height().saturating_sub(200); - let mut emitter = Emitter::new( - client.as_ref(), - wallet_cp, - emitter_height, - wallet - .tx_graph() - .list_canonical_txs( - wallet.local_chain(), - wallet.local_chain().tip().block_id(), - CanonicalizationParams::default(), - ) - .filter(|tx| tx.chain_position.is_unconfirmed()), - ); - - while let Some(block_event) = emitter.next_block()? { - if block_event.block_height() % 10_000 == 0 { - let percent_done = f64::from(block_event.block_height()) - / f64::from(blockchain_info.headers as u32) - * 100f64; - println!( - "Applying block at height: {}, {:.2}% done.", - block_event.block_height(), - percent_done - ); - } - - wallet.apply_block_connected_to( - &block_event.block, - block_event.block_height(), - block_event.connected_to(), - )?; - } - - let mempool_txs = emitter.mempool()?; - wallet.apply_unconfirmed_txs(mempool_txs.update); - Ok(()) - } - #[cfg(feature = "cbf")] - KyotoClient { client } => sync_kyoto_client(wallet, client) - .await - .map_err(|e| Error::Generic(e.to_string())), - } -} - -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -/// Broadcasts a given transaction using the blockchain client. -pub async fn broadcast_transaction( - client: &BlockchainClient, - tx: Transaction, -) -> Result { - match client { - #[cfg(feature = "electrum")] - Electrum { - client, - batch_size: _, - } => client - .transaction_broadcast(&tx) - .map_err(|e| Error::Generic(e.to_string())), - #[cfg(feature = "esplora")] - Esplora { - client, - parallel_requests: _, - } => client - .broadcast(&tx) - .await - .map(|()| tx.compute_txid()) - .map_err(|e| Error::Generic(e.to_string())), - #[cfg(feature = "rpc")] - RpcClient { client } => client - .send_raw_transaction(&tx) - .map_err(|e| Error::Generic(e.to_string())), - - #[cfg(feature = "cbf")] - 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()) - })?; - tracing::info!("Successfully broadcast WTXID: {wtxid}"); - Ok(txid) - } - } -} - -#[cfg(feature = "repl")] -fn readline() -> Result { - write!(std::io::stdout(), "> ").map_err(|e| Error::Generic(e.to_string()))?; - std::io::stdout() - .flush() - .map_err(|e| Error::Generic(e.to_string()))?; - let mut buffer = String::new(); - std::io::stdin() - .read_line(&mut buffer) - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(buffer) -} - -/// Handle the descriptor command -pub fn handle_descriptor_command( - network: Network, - desc_type: String, - key: Option, - pretty: bool, -) -> Result { - let result = match key { - Some(key) => { - if is_mnemonic(&key) { - // User provided mnemonic - generate_descriptor_from_mnemonic(&key, network, &desc_type) - } else { - // User provided xprv/xpub - generate_descriptors(&desc_type, &key, network) - } - } - // Generate new mnemonic and descriptors - None => generate_descriptor_with_mnemonic(network, &desc_type), - }?; - format_descriptor_output(&result, pretty) -} - -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -#[cfg(test)] -mod test { - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" - ))] - #[test] - fn test_psbt_is_final() { - use super::is_final; - use bdk_wallet::bitcoin::Psbt; - use std::str::FromStr; - - let unsigned_psbt = Psbt::from_str("cHNidP8BAIkBAAAAASWJHzxzyVORV/C3lAynKHVVL7+Rw7/Jj8U9fuvD24olAAAAAAD+////AiBOAAAAAAAAIgAgLzY9yE4jzTFJnHtTjkc+rFAtJ9NB7ENFQ1xLYoKsI1cfqgKVAAAAACIAIFsbWgDeLGU8EA+RGwBDIbcv4gaGG0tbEIhDvwXXa/E7LwEAAAABALUCAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BALLAAD/////AgD5ApUAAAAAIgAgWxtaAN4sZTwQD5EbAEMhty/iBoYbS1sQiEO/Bddr8TsAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QEgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQErAPkClQAAAAAiACBbG1oA3ixlPBAPkRsAQyG3L+IGhhtLWxCIQ78F12vxOwEFR1IhA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDIQLKhV/gEZYmlsQXnsL5/Uqv5Y8O31tmWW1LQqIBkiqzCVKuIgYCyoVf4BGWJpbEF57C+f1Kr+WPDt9bZlltS0KiAZIqswkEboH3lCIGA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDBDS6ZSEAACICAsqFX+ARliaWxBeewvn9Sq/ljw7fW2ZZbUtCogGSKrMJBG6B95QiAgPyVdlP9KV1voj+PUHLGIpRL5GRHeKYZgzPJ1fMAvjHgwQ0umUhAA==").unwrap(); - assert!(is_final(&unsigned_psbt).is_err()); - - let part_signed_psbt = Psbt::from_str("cHNidP8BAIkBAAAAASWJHzxzyVORV/C3lAynKHVVL7+Rw7/Jj8U9fuvD24olAAAAAAD+////AiBOAAAAAAAAIgAgLzY9yE4jzTFJnHtTjkc+rFAtJ9NB7ENFQ1xLYoKsI1cfqgKVAAAAACIAIFsbWgDeLGU8EA+RGwBDIbcv4gaGG0tbEIhDvwXXa/E7LwEAAAABALUCAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BALLAAD/////AgD5ApUAAAAAIgAgWxtaAN4sZTwQD5EbAEMhty/iBoYbS1sQiEO/Bddr8TsAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QEgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQErAPkClQAAAAAiACBbG1oA3ixlPBAPkRsAQyG3L+IGhhtLWxCIQ78F12vxOyICA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDSDBFAiEAnNPpu6wNX2HXYz8s2q5nXug4cWfvCGD3SSH2CNKm+yECIEQO7/URhUPsGoknMTE+GrYJf9Wxqn9QsuN9FGj32cQpAQEFR1IhA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDIQLKhV/gEZYmlsQXnsL5/Uqv5Y8O31tmWW1LQqIBkiqzCVKuIgYCyoVf4BGWJpbEF57C+f1Kr+WPDt9bZlltS0KiAZIqswkEboH3lCIGA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDBDS6ZSEAACICAsqFX+ARliaWxBeewvn9Sq/ljw7fW2ZZbUtCogGSKrMJBG6B95QiAgPyVdlP9KV1voj+PUHLGIpRL5GRHeKYZgzPJ1fMAvjHgwQ0umUhAA==").unwrap(); - assert!(is_final(&part_signed_psbt).is_err()); - - let full_signed_psbt = Psbt::from_str("cHNidP8BAIkBAAAAASWJHzxzyVORV/C3lAynKHVVL7+Rw7/Jj8U9fuvD24olAAAAAAD+////AiBOAAAAAAAAIgAgLzY9yE4jzTFJnHtTjkc+rFAtJ9NB7ENFQ1xLYoKsI1cfqgKVAAAAACIAIFsbWgDeLGU8EA+RGwBDIbcv4gaGG0tbEIhDvwXXa/E7LwEAAAABALUCAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BALLAAD/////AgD5ApUAAAAAIgAgWxtaAN4sZTwQD5EbAEMhty/iBoYbS1sQiEO/Bddr8TsAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QEgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQErAPkClQAAAAAiACBbG1oA3ixlPBAPkRsAQyG3L+IGhhtLWxCIQ78F12vxOwEFR1IhA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDIQLKhV/gEZYmlsQXnsL5/Uqv5Y8O31tmWW1LQqIBkiqzCVKuIgYCyoVf4BGWJpbEF57C+f1Kr+WPDt9bZlltS0KiAZIqswkEboH3lCIGA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDBDS6ZSEBBwABCNsEAEgwRQIhAJzT6busDV9h12M/LNquZ17oOHFn7whg90kh9gjSpvshAiBEDu/1EYVD7BqJJzExPhq2CX/Vsap/ULLjfRRo99nEKQFHMEQCIGoFCvJ2zPB7PCpznh4+1jsY03kMie49KPoPDdr7/T9TAiB3jV7wzR9BH11FSbi+8U8gSX95PrBlnp1lOBgTUIUw3QFHUiED8lXZT/Sldb6I/j1ByxiKUS+RkR3imGYMzydXzAL4x4MhAsqFX+ARliaWxBeewvn9Sq/ljw7fW2ZZbUtCogGSKrMJUq4AACICAsqFX+ARliaWxBeewvn9Sq/ljw7fW2ZZbUtCogGSKrMJBG6B95QiAgPyVdlP9KV1voj+PUHLGIpRL5GRHeKYZgzPJ1fMAvjHgwQ0umUhAA==").unwrap(); - assert!(is_final(&full_signed_psbt).is_ok()); - } - - #[cfg(feature = "compiler")] - #[test] - fn test_compile_taproot() { - use super::{NUMS_UNSPENDABLE_KEY_HEX, handle_compile_subcommand}; - use bdk_wallet::bitcoin::Network; - - // Expected taproot descriptors with checksums (using NUMS key from constant) - let expected_pk_a = format!("tr({},pk(A))#a2mlskt0", NUMS_UNSPENDABLE_KEY_HEX); - let expected_and_ab = format!( - "tr({},and_v(v:pk(A),pk(B)))#sfplm6kv", - NUMS_UNSPENDABLE_KEY_HEX - ); - - // Test simple pk policy compilation to taproot - let result = handle_compile_subcommand( - Network::Testnet, - "pk(A)".to_string(), - "tr".to_string(), - false, - ); - assert!(result.is_ok()); - let json_string = result.unwrap(); - let json_result: serde_json::Value = serde_json::from_str(&json_string).unwrap(); - let descriptor = json_result.get("descriptor").unwrap().as_str().unwrap(); - assert_eq!(descriptor, expected_pk_a); - - // Test more complex policy - let result = handle_compile_subcommand( - Network::Testnet, - "and(pk(A),pk(B))".to_string(), - "tr".to_string(), - false, - ); - assert!(result.is_ok()); - let json_string = result.unwrap(); - let json_result: serde_json::Value = serde_json::from_str(&json_string).unwrap(); - let descriptor = json_result.get("descriptor").unwrap().as_str().unwrap(); - assert_eq!(descriptor, expected_and_ab); - } - - #[cfg(feature = "compiler")] - #[test] - fn test_compile_invalid_cases() { - use super::handle_compile_subcommand; - use bdk_wallet::bitcoin::Network; - - // Test invalid policy syntax - let result = handle_compile_subcommand( - Network::Testnet, - "invalid_policy".to_string(), - "tr".to_string(), - false, - ); - assert!(result.is_err()); - - // Test invalid script type - let result = handle_compile_subcommand( - Network::Testnet, - "pk(A)".to_string(), - "invalid_type".to_string(), - false, - ); - assert!(result.is_err()); - - // Test empty policy - let result = - handle_compile_subcommand(Network::Testnet, "".to_string(), "tr".to_string(), false); - assert!(result.is_err()); - - // Test malformed policy with unmatched parentheses - let result = handle_compile_subcommand( - Network::Testnet, - "pk(A".to_string(), - "tr".to_string(), - false, - ); - assert!(result.is_err()); - - // Test policy with unknown function - let result = handle_compile_subcommand( - Network::Testnet, - "unknown_func(A)".to_string(), - "tr".to_string(), - false, - ); - assert!(result.is_err()); - } -} diff --git a/src/handlers/config.rs b/src/handlers/config.rs new file mode 100644 index 00000000..e64d1654 --- /dev/null +++ b/src/handlers/config.rs @@ -0,0 +1,136 @@ +use std::collections::HashMap; +use std::path::Path; + +#[cfg(any(feature = "sqlite", feature = "redb"))] +#[cfg(feature = "sqlite")] +use crate::commands::DatabaseType; +use crate::commands::WalletOpts; +use crate::config::{WalletConfig, WalletConfigInner}; +use crate::error::BDKCliError as Error; +use bdk_wallet::bitcoin::Network; +use serde_json::json; + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] +use crate::commands::ClientType; + +/// Handle wallet config subcommand to create or update config.toml +pub fn handle_config_subcommand( + datadir: &Path, + network: Network, + wallet: String, + wallet_opts: &WalletOpts, + force: bool, +) -> Result { + if network == Network::Bitcoin { + eprintln!( + "WARNING: You are configuring a wallet for Bitcoin MAINNET. + This software is experimental and not recommended for use with real funds. + Consider using a testnet for testing purposes. \n" + ); + } + + let ext_descriptor = wallet_opts.ext_descriptor.clone(); + let int_descriptor = wallet_opts.int_descriptor.clone(); + + if ext_descriptor.contains("xprv") || ext_descriptor.contains("tprv") { + eprintln!( + "WARNING: Your external descriptor contains PRIVATE KEYS. + Private keys will be saved in PLAINTEXT in the config file. + This is a security risk. Consider using public descriptors instead.\n" + ); + } + + if let Some(ref internal_desc) = int_descriptor + && (internal_desc.contains("xprv") || internal_desc.contains("tprv")) + { + eprintln!( + "WARNING: Your internal descriptor contains PRIVATE KEYS. + Private keys will be saved in PLAINTEXT in the config file. + This is a security risk. Consider using public descriptors instead.\n" + ); + } + + let mut config = WalletConfig::load(datadir)?.unwrap_or(WalletConfig { + wallets: HashMap::new(), + }); + + if config.wallets.contains_key(&wallet) && !force { + return Err(Error::Generic(format!( + "Wallet '{wallet}' already exists in config.toml. Use --force to overwrite." + ))); + } + + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + let client_type = wallet_opts.client_type.clone(); + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] + let url = &wallet_opts.url.clone(); + #[cfg(any(feature = "sqlite", feature = "redb"))] + let database_type = match wallet_opts.database_type { + #[cfg(feature = "sqlite")] + DatabaseType::Sqlite => "sqlite".to_string(), + #[cfg(feature = "redb")] + DatabaseType::Redb => "redb".to_string(), + }; + + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + let client_type = match client_type { + #[cfg(feature = "electrum")] + ClientType::Electrum => "electrum".to_string(), + #[cfg(feature = "esplora")] + ClientType::Esplora => "esplora".to_string(), + #[cfg(feature = "rpc")] + ClientType::Rpc => "rpc".to_string(), + #[cfg(feature = "cbf")] + ClientType::Cbf => "cbf".to_string(), + }; + + let wallet_config = WalletConfigInner { + wallet: wallet.clone(), + network: network.to_string(), + ext_descriptor, + int_descriptor, + #[cfg(any(feature = "sqlite", feature = "redb"))] + database_type, + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + client_type: Some(client_type), + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc",))] + server_url: Some(url.to_string()), + #[cfg(feature = "rpc")] + rpc_user: Some(wallet_opts.basic_auth.0.clone()), + #[cfg(feature = "rpc")] + rpc_password: Some(wallet_opts.basic_auth.1.clone()), + #[cfg(feature = "electrum")] + batch_size: Some(wallet_opts.batch_size), + #[cfg(feature = "esplora")] + parallel_requests: Some(wallet_opts.parallel_requests), + #[cfg(feature = "rpc")] + cookie: wallet_opts.cookie.clone(), + }; + + config.wallets.insert(wallet.clone(), wallet_config); + config.save(datadir)?; + + Ok(serde_json::to_string_pretty(&json!({ + "message": format!("Wallet '{wallet}' initialized successfully in {:?}", datadir.join("config.toml")) + }))?) +} diff --git a/src/handlers/descriptor.rs b/src/handlers/descriptor.rs new file mode 100644 index 00000000..c76c0781 --- /dev/null +++ b/src/handlers/descriptor.rs @@ -0,0 +1,110 @@ +use crate::{ + error::BDKCliError as Error, + utils::{ + descriptors::{ + format_descriptor_output, generate_descriptor_from_mnemonic, + generate_descriptor_with_mnemonic, generate_descriptors, + }, + is_mnemonic, + }, +}; + +#[cfg(feature = "compiler")] +use { + bdk_wallet::{ + bitcoin::XOnlyPublicKey, + miniscript::{ + Descriptor, Legacy, Miniscript, Segwitv0, Tap, descriptor::TapTree, policy::Concrete, + }, + }, + cli_table::{Cell, Style, Table}, + serde_json::json, + std::{str::FromStr, sync::Arc}, +}; + +use bdk_wallet::bitcoin::Network; + +#[cfg(feature = "compiler")] +const NUMS_UNSPENDABLE_KEY_HEX: &str = + "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"; + +/// Handle the top-level `descriptor` command +pub fn handle_descriptor_command( + network: Network, + desc_type: String, + key: Option, + pretty: bool, +) -> Result { + let result = match key { + Some(key) => { + if is_mnemonic(&key) { + // User provided mnemonic + generate_descriptor_from_mnemonic(&key, network, &desc_type) + } else { + // User provided xprv/xpub + generate_descriptors(&desc_type, &key, network) + } + } + // Generate new mnemonic and descriptors + None => generate_descriptor_with_mnemonic(network, &desc_type), + }?; + format_descriptor_output(&result, pretty) +} + +/// Handle the miniscript compiler sub-command +/// +/// Compiler options are described in [`CliSubCommand::Compile`]. +#[cfg(feature = "compiler")] +pub(crate) fn handle_compile_subcommand( + _network: Network, + policy: String, + script_type: String, + pretty: bool, +) -> Result { + let policy = Concrete::::from_str(policy.as_str())?; + let legacy_policy: Miniscript = policy + .compile() + .map_err(|e| Error::Generic(e.to_string()))?; + let segwit_policy: Miniscript = policy + .compile() + .map_err(|e| Error::Generic(e.to_string()))?; + let taproot_policy: Miniscript = policy + .compile() + .map_err(|e| Error::Generic(e.to_string()))?; + + let descriptor = match script_type.as_str() { + "sh" => Descriptor::new_sh(legacy_policy), + "wsh" => Descriptor::new_wsh(segwit_policy), + "sh-wsh" => Descriptor::new_sh_wsh(segwit_policy), + "tr" => { + // For tr descriptors, we use a well-known unspendable key (NUMS point). + // This ensures the key path is effectively disabled and only script path can be used. + // See https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs + + let xonly_public_key = XOnlyPublicKey::from_str(NUMS_UNSPENDABLE_KEY_HEX) + .map_err(|e| Error::Generic(format!("Invalid NUMS key: {e}")))?; + + let tree = TapTree::Leaf(Arc::new(taproot_policy)); + Descriptor::new_tr(xonly_public_key.to_string(), Some(tree)) + } + _ => { + return Err(Error::Generic( + "Invalid script type. Supported types: sh, wsh, sh-wsh, tr".to_string(), + )); + } + }?; + if pretty { + let table = vec![vec![ + "Descriptor".cell().bold(true), + descriptor.to_string().cell(), + ]] + .table() + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + Ok(format!("{table}")) + } else { + Ok(serde_json::to_string_pretty( + &json!({"descriptor": descriptor.to_string()}), + )?) + } +} diff --git a/src/handlers/key.rs b/src/handlers/key.rs new file mode 100644 index 00000000..40c050c4 --- /dev/null +++ b/src/handlers/key.rs @@ -0,0 +1,124 @@ +use crate::commands::KeySubCommand; +use crate::error::BDKCliError as Error; +use bdk_wallet::bip39::{Language, Mnemonic}; +use bdk_wallet::bitcoin::bip32::KeySource; +use bdk_wallet::bitcoin::key::Secp256k1; +use bdk_wallet::bitcoin::{Network, bip32::DerivationPath}; +use bdk_wallet::keys::bip39::WordCount; +use bdk_wallet::keys::{DerivableKey, GeneratableKey}; +use bdk_wallet::keys::{DescriptorKey, DescriptorKey::Secret, ExtendedKey, GeneratedKey}; +use bdk_wallet::miniscript::{self, Segwitv0}; +use cli_table::{Cell, Style, Table}; +use serde_json::json; + +/// Handle a key sub-command +/// +/// Key sub-commands are described in [`KeySubCommand`]. +pub(crate) fn handle_key_subcommand( + network: Network, + subcommand: KeySubCommand, + pretty: bool, +) -> Result { + let secp = Secp256k1::new(); + + match subcommand { + KeySubCommand::Generate { + word_count, + password, + } => { + let mnemonic_type = match word_count { + 12 => WordCount::Words12, + _ => WordCount::Words24, + }; + let mnemonic: GeneratedKey<_, miniscript::BareCtx> = + Mnemonic::generate((mnemonic_type, Language::English)) + .map_err(|_| Error::Generic("Mnemonic generation error".to_string()))?; + let mnemonic = mnemonic.into_key(); + let xkey: ExtendedKey = (mnemonic.clone(), password).into_extended_key()?; + let xprv = xkey.into_xprv(network).ok_or_else(|| { + Error::Generic("Privatekey info not found (should not happen)".to_string()) + })?; + let fingerprint = xprv.fingerprint(&secp); + let phrase = mnemonic + .words() + .fold("".to_string(), |phrase, w| phrase + w + " ") + .trim() + .to_string(); + if pretty { + let table = vec![ + vec![ + "Fingerprint".cell().bold(true), + fingerprint.to_string().cell(), + ], + vec!["Mnemonic".cell().bold(true), mnemonic.to_string().cell()], + vec!["Xprv".cell().bold(true), xprv.to_string().cell()], + ] + .table() + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + Ok(format!("{table}")) + } else { + Ok(serde_json::to_string_pretty( + &json!({ "mnemonic": phrase, "xprv": xprv.to_string(), "fingerprint": fingerprint.to_string() }), + )?) + } + } + KeySubCommand::Restore { mnemonic, password } => { + let mnemonic = Mnemonic::parse_in(Language::English, mnemonic)?; + let xkey: ExtendedKey = (mnemonic.clone(), password).into_extended_key()?; + let xprv = xkey.into_xprv(network).ok_or_else(|| { + Error::Generic("Privatekey info not found (should not happen)".to_string()) + })?; + let fingerprint = xprv.fingerprint(&secp); + if pretty { + let table = vec![ + vec![ + "Fingerprint".cell().bold(true), + fingerprint.to_string().cell(), + ], + vec!["Mnemonic".cell().bold(true), mnemonic.to_string().cell()], + vec!["Xprv".cell().bold(true), xprv.to_string().cell()], + ] + .table() + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + Ok(format!("{table}")) + } else { + Ok(serde_json::to_string_pretty( + &json!({ "xprv": xprv.to_string(), "fingerprint": fingerprint.to_string() }), + )?) + } + } + KeySubCommand::Derive { xprv, path } => { + if xprv.network != network.into() { + return Err(Error::Generic("Invalid network".to_string())); + } + let derived_xprv = &xprv.derive_priv(&secp, &path)?; + + let origin: KeySource = (xprv.fingerprint(&secp), path); + + let derived_xprv_desc_key: DescriptorKey = + derived_xprv.into_descriptor_key(Some(origin), DerivationPath::default())?; + + if let Secret(desc_seckey, _, _) = derived_xprv_desc_key { + let desc_pubkey = desc_seckey.to_public(&secp)?; + if pretty { + let table = vec![ + vec!["Xpub".cell().bold(true), desc_pubkey.to_string().cell()], + vec!["Xprv".cell().bold(true), xprv.to_string().cell()], + ] + .table() + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + Ok(format!("{table}")) + } else { + Ok(serde_json::to_string_pretty( + &json!({"xpub": desc_pubkey.to_string(), "xprv": desc_seckey.to_string()}), + )?) + } + } else { + Err(Error::Generic("Invalid key variant".to_string())) + } + } + } +} From 6a37493e59f15329bc87f53dc9d1bccc9ee870f9 Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Tue, 21 Apr 2026 11:19:03 +0100 Subject: [PATCH 06/20] ref(handlers):mv handler fns into offline & others - move handler fns into offline, online, wallets modules --- src/handlers/offline.rs | 516 ++++++++++++++++++++++++++++++++++++++++ src/handlers/online.rs | 327 +++++++++++++++++++++++++ src/handlers/wallets.rs | 37 +++ 3 files changed, 880 insertions(+) create mode 100644 src/handlers/offline.rs create mode 100644 src/handlers/online.rs create mode 100644 src/handlers/wallets.rs diff --git a/src/handlers/offline.rs b/src/handlers/offline.rs new file mode 100644 index 00000000..9d564ebb --- /dev/null +++ b/src/handlers/offline.rs @@ -0,0 +1,516 @@ +use crate::commands::OfflineWalletSubCommand::*; +use crate::commands::{CliOpts, OfflineWalletSubCommand, WalletOpts}; +use crate::error::BDKCliError as Error; +use crate::utils::shorten; +use bdk_wallet::bitcoin::base64::Engine; +use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD; +use bdk_wallet::bitcoin::consensus::encode::serialize_hex; +use bdk_wallet::bitcoin::script::PushBytesBuf; +use bdk_wallet::bitcoin::{Address, Amount, FeeRate, Psbt, Sequence, Txid}; +use bdk_wallet::chain::ChainPosition; +use bdk_wallet::{KeychainKind, SignOptions, Wallet}; +use cli_table::format::Justify; +use cli_table::{Cell, CellStruct, Style, Table}; +use serde_json::json; +use std::collections::BTreeMap; +use std::str::FromStr; + +/// Execute an offline wallet sub-command +/// +/// Offline wallet sub-commands are described in [`OfflineWalletSubCommand`]. +pub fn handle_offline_wallet_subcommand( + wallet: &mut Wallet, + wallet_opts: &WalletOpts, + cli_opts: &CliOpts, + offline_subcommand: OfflineWalletSubCommand, +) -> Result { + match offline_subcommand { + NewAddress => { + let addr = wallet.reveal_next_address(KeychainKind::External); + if cli_opts.pretty { + let table = vec![ + vec!["Address".cell().bold(true), addr.address.to_string().cell()], + vec![ + "Index".cell().bold(true), + addr.index.to_string().cell().justify(Justify::Right), + ], + ] + .table() + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + Ok(format!("{table}")) + } else if wallet_opts.verbose { + Ok(serde_json::to_string_pretty(&json!({ + "address": addr.address, + "index": addr.index + }))?) + } else { + Ok(serde_json::to_string_pretty(&json!({ + "address": addr.address, + }))?) + } + } + UnusedAddress => { + let addr = wallet.next_unused_address(KeychainKind::External); + + if cli_opts.pretty { + let table = vec![ + vec!["Address".cell().bold(true), addr.address.to_string().cell()], + vec![ + "Index".cell().bold(true), + addr.index.to_string().cell().justify(Justify::Right), + ], + ] + .table() + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + Ok(format!("{table}")) + } else if wallet_opts.verbose { + Ok(serde_json::to_string_pretty(&json!({ + "address": addr.address, + "index": addr.index + }))?) + } else { + Ok(serde_json::to_string_pretty(&json!({ + "address": addr.address, + }))?) + } + } + Unspent => { + let utxos = wallet.list_unspent().collect::>(); + if cli_opts.pretty { + let mut rows: Vec> = vec![]; + for utxo in &utxos { + let height = utxo + .chain_position + .confirmation_height_upper_bound() + .map(|h| h.to_string()) + .unwrap_or("Pending".to_string()); + + let block_hash = match &utxo.chain_position { + ChainPosition::Confirmed { anchor, .. } => anchor.block_id.hash.to_string(), + ChainPosition::Unconfirmed { .. } => "Unconfirmed".to_string(), + }; + + rows.push(vec![ + shorten(utxo.outpoint, 8, 10).cell(), + utxo.txout + .value + .to_sat() + .to_string() + .cell() + .justify(Justify::Right), + Address::from_script(&utxo.txout.script_pubkey, cli_opts.network) + .unwrap() + .cell(), + utxo.keychain.cell(), + utxo.is_spent.cell(), + utxo.derivation_index.cell(), + height.to_string().cell().justify(Justify::Right), + shorten(block_hash, 8, 8).cell().justify(Justify::Right), + ]); + } + let table = rows + .table() + .title(vec![ + "Outpoint".cell().bold(true), + "Output (sat)".cell().bold(true), + "Output Address".cell().bold(true), + "Keychain".cell().bold(true), + "Is Spent".cell().bold(true), + "Index".cell().bold(true), + "Block Height".cell().bold(true), + "Block Hash".cell().bold(true), + ]) + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + Ok(format!("{table}")) + } else { + Ok(serde_json::to_string_pretty(&utxos)?) + } + } + Transactions => { + let transactions = wallet.transactions(); + + if cli_opts.pretty { + let txns = transactions + .map(|tx| { + let total_value = tx + .tx_node + .output + .iter() + .map(|output| output.value.to_sat()) + .sum::(); + ( + tx.tx_node.txid.to_string(), + tx.tx_node.version, + tx.tx_node.is_explicitly_rbf(), + tx.tx_node.input.len(), + tx.tx_node.output.len(), + total_value, + ) + }) + .collect::>(); + let mut rows: Vec> = vec![]; + for (txid, version, is_rbf, input_count, output_count, total_value) in txns { + rows.push(vec![ + txid.cell(), + version.to_string().cell().justify(Justify::Right), + is_rbf.to_string().cell().justify(Justify::Center), + input_count.to_string().cell().justify(Justify::Right), + output_count.to_string().cell().justify(Justify::Right), + total_value.to_string().cell().justify(Justify::Right), + ]); + } + let table = rows + .table() + .title(vec![ + "Txid".cell().bold(true), + "Version".cell().bold(true), + "Is RBF".cell().bold(true), + "Input Count".cell().bold(true), + "Output Count".cell().bold(true), + "Total Value (sat)".cell().bold(true), + ]) + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + Ok(format!("{table}")) + } else { + let txns: Vec<_> = transactions + .map(|tx| { + json!({ + "txid": tx.tx_node.txid, + "is_coinbase": tx.tx_node.is_coinbase(), + "wtxid": tx.tx_node.compute_wtxid(), + "version": tx.tx_node.version, + "is_rbf": tx.tx_node.is_explicitly_rbf(), + "inputs": tx.tx_node.input, + "outputs": tx.tx_node.output, + }) + }) + .collect(); + Ok(serde_json::to_string_pretty(&txns)?) + } + } + Balance => { + let balance = wallet.balance(); + if cli_opts.pretty { + let table = vec![ + vec!["Type".cell().bold(true), "Amount (sat)".cell().bold(true)], + vec![ + "Total".cell(), + balance + .total() + .to_sat() + .to_string() + .cell() + .justify(Justify::Right), + ], + vec![ + "Confirmed".cell(), + balance + .confirmed + .to_sat() + .to_string() + .cell() + .justify(Justify::Right), + ], + vec![ + "Unconfirmed".cell(), + balance + .immature + .to_sat() + .to_string() + .cell() + .justify(Justify::Right), + ], + vec![ + "Trusted Pending".cell(), + balance + .trusted_pending + .to_sat() + .cell() + .justify(Justify::Right), + ], + vec![ + "Untrusted Pending".cell(), + balance + .untrusted_pending + .to_sat() + .cell() + .justify(Justify::Right), + ], + ] + .table() + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + Ok(format!("{table}")) + } else { + Ok(serde_json::to_string_pretty( + &json!({"satoshi": wallet.balance()}), + )?) + } + } + + CreateTx { + recipients, + send_all, + enable_rbf, + offline_signer, + utxos, + unspendable, + fee_rate, + external_policy, + internal_policy, + add_data, + add_string, + } => { + let mut tx_builder = wallet.build_tx(); + + if send_all { + tx_builder.drain_wallet().drain_to(recipients[0].0.clone()); + } else { + let recipients = recipients + .into_iter() + .map(|(script, amount)| (script, Amount::from_sat(amount))) + .collect(); + tx_builder.set_recipients(recipients); + } + + if !enable_rbf { + tx_builder.set_exact_sequence(Sequence::MAX); + } + + if offline_signer { + tx_builder.include_output_redeem_witness_script(); + } + + if let Some(fee_rate) = fee_rate + && let Some(fee_rate) = FeeRate::from_sat_per_vb(fee_rate as u64) + { + tx_builder.fee_rate(fee_rate); + } + + if let Some(utxos) = utxos { + tx_builder.add_utxos(&utxos[..]).unwrap(); + } + + if let Some(unspendable) = unspendable { + tx_builder.unspendable(unspendable); + } + + if let Some(base64_data) = add_data { + let op_return_data = BASE64_STANDARD.decode(base64_data).unwrap(); + tx_builder.add_data(&PushBytesBuf::try_from(op_return_data).unwrap()); + } else if let Some(string_data) = add_string { + let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec()).unwrap(); + tx_builder.add_data(&data); + } + + let policies = vec![ + external_policy.map(|p| (p, KeychainKind::External)), + internal_policy.map(|p| (p, KeychainKind::Internal)), + ]; + + for (policy, keychain) in policies.into_iter().flatten() { + let policy = serde_json::from_str::>>(&policy)?; + tx_builder.policy_path(policy, keychain); + } + + let psbt = tx_builder.finish()?; + + let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); + + if wallet_opts.verbose { + Ok(serde_json::to_string_pretty( + &json!({"psbt": psbt_base64, "details": psbt}), + )?) + } else { + Ok(serde_json::to_string_pretty( + &json!({"psbt": psbt_base64 }), + )?) + } + } + BumpFee { + txid, + shrink_address, + offline_signer, + utxos, + unspendable, + fee_rate, + } => { + let txid = Txid::from_str(txid.as_str())?; + + let mut tx_builder = wallet.build_fee_bump(txid)?; + let fee_rate = + FeeRate::from_sat_per_vb(fee_rate as u64).unwrap_or(FeeRate::BROADCAST_MIN); + tx_builder.fee_rate(fee_rate); + + if let Some(address) = shrink_address { + let script_pubkey = address.script_pubkey(); + tx_builder.drain_to(script_pubkey); + } + + if offline_signer { + tx_builder.include_output_redeem_witness_script(); + } + + if let Some(utxos) = utxos { + tx_builder.add_utxos(&utxos[..]).unwrap(); + } + + if let Some(unspendable) = unspendable { + tx_builder.unspendable(unspendable); + } + + let psbt = tx_builder.finish()?; + + let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); + + Ok(serde_json::to_string_pretty( + &json!({"psbt": psbt_base64 }), + )?) + } + Policies => { + let external_policy = wallet.policies(KeychainKind::External)?; + let internal_policy = wallet.policies(KeychainKind::Internal)?; + if cli_opts.pretty { + let table = vec![ + vec![ + "External".cell().bold(true), + serde_json::to_string_pretty(&external_policy)?.cell(), + ], + vec![ + "Internal".cell().bold(true), + serde_json::to_string_pretty(&internal_policy)?.cell(), + ], + ] + .table() + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + Ok(format!("{table}")) + } else { + Ok(serde_json::to_string_pretty(&json!({ + "external": external_policy, + "internal": internal_policy, + }))?) + } + } + PublicDescriptor => { + let external = wallet.public_descriptor(KeychainKind::External).to_string(); + let internal = wallet.public_descriptor(KeychainKind::Internal).to_string(); + + if cli_opts.pretty { + let table = vec![ + vec![ + "External Descriptor".cell().bold(true), + external.to_string().cell(), + ], + vec![ + "Internal Descriptor".cell().bold(true), + internal.to_string().cell(), + ], + ] + .table() + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + Ok(format!("{table}")) + } else { + Ok(serde_json::to_string_pretty(&json!({ + "external": external.to_string(), + "internal": internal.to_string(), + }))?) + } + } + Sign { + psbt, + assume_height, + trust_witness_utxo, + } => { + let psbt_bytes = BASE64_STANDARD.decode(psbt)?; + let mut psbt = Psbt::deserialize(&psbt_bytes)?; + let signopt = SignOptions { + assume_height, + trust_witness_utxo: trust_witness_utxo.unwrap_or(false), + ..Default::default() + }; + let finalized = wallet.sign(&mut psbt, signopt)?; + let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); + if wallet_opts.verbose { + Ok(serde_json::to_string_pretty( + &json!({"psbt": &psbt_base64, "is_finalized": finalized, "serialized_psbt": &psbt}), + )?) + } else { + Ok(serde_json::to_string_pretty( + &json!({"psbt": &psbt_base64, "is_finalized": finalized}), + )?) + } + } + ExtractPsbt { psbt } => { + let psbt_serialized = BASE64_STANDARD.decode(psbt)?; + let psbt = Psbt::deserialize(&psbt_serialized)?; + let raw_tx = psbt.extract_tx()?; + if cli_opts.pretty { + let table = vec![vec![ + "Raw Transaction".cell().bold(true), + serialize_hex(&raw_tx).cell(), + ]] + .table() + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + Ok(format!("{table}")) + } else { + Ok(serde_json::to_string_pretty( + &json!({"raw_tx": serialize_hex(&raw_tx)}), + )?) + } + } + FinalizePsbt { + psbt, + assume_height, + trust_witness_utxo, + } => { + let psbt_bytes = BASE64_STANDARD.decode(psbt)?; + let mut psbt: Psbt = Psbt::deserialize(&psbt_bytes)?; + + let signopt = SignOptions { + assume_height, + trust_witness_utxo: trust_witness_utxo.unwrap_or(false), + ..Default::default() + }; + let finalized = wallet.finalize_psbt(&mut psbt, signopt)?; + if wallet_opts.verbose { + Ok(serde_json::to_string_pretty( + &json!({ "psbt": BASE64_STANDARD.encode(psbt.serialize()), "is_finalized": finalized, "details": psbt}), + )?) + } else { + Ok(serde_json::to_string_pretty( + &json!({ "psbt": BASE64_STANDARD.encode(psbt.serialize()), "is_finalized": finalized}), + )?) + } + } + CombinePsbt { psbt } => { + let mut psbts = psbt + .iter() + .map(|s| { + let psbt = BASE64_STANDARD.decode(s)?; + Ok(Psbt::deserialize(&psbt)?) + }) + .collect::, Error>>()?; + + let init_psbt = psbts + .pop() + .ok_or_else(|| Error::Generic("Invalid PSBT input".to_string()))?; + let final_psbt = psbts.into_iter().try_fold::<_, _, Result>( + init_psbt, + |mut acc, x| { + let _ = acc.combine(x); + Ok(acc) + }, + )?; + Ok(serde_json::to_string_pretty( + &json!({ "psbt": BASE64_STANDARD.encode(final_psbt.serialize()) }), + )?) + } + } +} diff --git a/src/handlers/online.rs b/src/handlers/online.rs new file mode 100644 index 00000000..ec3ff2de --- /dev/null +++ b/src/handlers/online.rs @@ -0,0 +1,327 @@ +#[cfg(feature = "electrum")] +use crate::backend::BlockchainClient::Electrum; +#[cfg(feature = "cbf")] +use crate::backend::BlockchainClient::KyotoClient; +#[cfg(feature = "rpc")] +use crate::backend::BlockchainClient::RpcClient; +#[cfg(feature = "esplora")] +use {crate::backend::BlockchainClient::Esplora, bdk_esplora::EsploraAsyncExt}; +#[cfg(feature = "rpc")] +use { + bdk_bitcoind_rpc::{Emitter, NO_EXPECTED_MEMPOOL_TXS, bitcoincore_rpc::RpcApi}, + bdk_wallet::chain::{BlockId, CanonicalizationParams, CheckPoint}, +}; +#[cfg(any(feature = "electrum", feature = "esplora"))] +use {bdk_wallet::KeychainKind, std::collections::HashSet, std::io::Write}; + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +use { + crate::backend::{BlockchainClient, sync_kyoto_client}, + crate::commands::OnlineWalletSubCommand::*, + crate::error::BDKCliError as Error, + crate::payjoin::PayjoinManager, + crate::payjoin::ohttp::RelayManager, + crate::utils::is_final, + bdk_wallet::Wallet, + bdk_wallet::bitcoin::{ + Psbt, Transaction, Txid, base64::Engine, base64::prelude::BASE64_STANDARD, + consensus::Decodable, hex::FromHex, + }, + serde_json::json, + std::sync::{Arc, Mutex}, +}; + +/// Execute an online wallet sub-command +/// +/// Online wallet sub-commands are described in [`OnlineWalletSubCommand`]. +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +pub(crate) async fn handle_online_wallet_subcommand( + wallet: &mut Wallet, + client: &BlockchainClient, + online_subcommand: crate::commands::OnlineWalletSubCommand, +) -> Result { + match online_subcommand { + FullScan { + stop_gap: _stop_gap, + } => { + #[cfg(any(feature = "electrum", feature = "esplora"))] + let request = wallet.start_full_scan().inspect({ + let mut stdout = std::io::stdout(); + let mut once = HashSet::::new(); + move |k, spk_i, _| { + if once.insert(k) { + print!("\nScanning keychain [{k:?}]"); + } + print!(" {spk_i:<3}"); + stdout.flush().expect("must flush"); + } + }); + match client { + #[cfg(feature = "electrum")] + Electrum { client, batch_size } => { + // Populate the electrum client's transaction cache so it doesn't re-download transaction we + // already have. + client + .populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); + + let update = client.full_scan(request, _stop_gap, *batch_size, false)?; + wallet.apply_update(update)?; + } + #[cfg(feature = "esplora")] + Esplora { + client, + parallel_requests, + } => { + let update = client + .full_scan(request, _stop_gap, *parallel_requests) + .await + .map_err(|e| *e)?; + wallet.apply_update(update)?; + } + + #[cfg(feature = "rpc")] + RpcClient { client } => { + let blockchain_info = client.get_blockchain_info()?; + + let genesis_block = + bdk_wallet::bitcoin::constants::genesis_block(wallet.network()); + let genesis_cp = CheckPoint::new(BlockId { + height: 0, + hash: genesis_block.block_hash(), + }); + let mut emitter = Emitter::new( + client.as_ref(), + genesis_cp.clone(), + genesis_cp.height(), + NO_EXPECTED_MEMPOOL_TXS, + ); + + while let Some(block_event) = emitter.next_block()? { + if block_event.block_height() % 10_000 == 0 { + let percent_done = f64::from(block_event.block_height()) + / f64::from(blockchain_info.headers as u32) + * 100f64; + println!( + "Applying block at height: {}, {:.2}% done.", + block_event.block_height(), + percent_done + ); + } + + wallet.apply_block_connected_to( + &block_event.block, + block_event.block_height(), + block_event.connected_to(), + )?; + } + + let mempool_txs = emitter.mempool()?; + wallet.apply_unconfirmed_txs(mempool_txs.update); + } + #[cfg(feature = "cbf")] + KyotoClient { client } => { + sync_kyoto_client(wallet, client).await?; + } + } + Ok(serde_json::to_string_pretty(&json!({}))?) + } + Sync => { + sync_wallet(client, wallet).await?; + Ok(serde_json::to_string_pretty(&json!({}))?) + } + Broadcast { psbt, tx } => { + let tx = match (psbt, tx) { + (Some(psbt), None) => { + let psbt = BASE64_STANDARD + .decode(psbt) + .map_err(|e| Error::Generic(e.to_string()))?; + let psbt: Psbt = Psbt::deserialize(&psbt)?; + is_final(&psbt)?; + psbt.extract_tx()? + } + (None, Some(tx)) => { + let tx_bytes = Vec::::from_hex(&tx)?; + Transaction::consensus_decode(&mut tx_bytes.as_slice())? + } + (Some(_), Some(_)) => panic!("Both `psbt` and `tx` options not allowed"), + (None, None) => panic!("Missing `psbt` and `tx` option"), + }; + let txid = broadcast_transaction(client, tx).await?; + Ok(serde_json::to_string_pretty(&json!({ "txid": txid }))?) + } + ReceivePayjoin { + amount, + directory, + ohttp_relay, + max_fee_rate, + } => { + let relay_manager = Arc::new(Mutex::new(RelayManager::new())); + let mut payjoin_manager = PayjoinManager::new(wallet, relay_manager); + return payjoin_manager + .receive_payjoin(amount, directory, max_fee_rate, ohttp_relay, client) + .await; + } + SendPayjoin { + uri, + ohttp_relay, + fee_rate, + } => { + let relay_manager = Arc::new(Mutex::new(RelayManager::new())); + let mut payjoin_manager = PayjoinManager::new(wallet, relay_manager); + return payjoin_manager + .send_payjoin(uri, fee_rate, ohttp_relay, client) + .await; + } + } +} + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +/// Syncs a given wallet using the blockchain client. +pub async fn sync_wallet(client: &BlockchainClient, wallet: &mut Wallet) -> Result<(), Error> { + // #[cfg(any(feature = "electrum", feature = "esplora"))] + let request = wallet + .start_sync_with_revealed_spks() + .inspect(|item, progress| { + let pc = (100 * progress.consumed()) as f32 / progress.total() as f32; + eprintln!("[ SCANNING {pc:03.0}% ] {item}"); + }); + match client { + #[cfg(feature = "electrum")] + Electrum { client, batch_size } => { + // Populate the electrum client's transaction cache so it doesn't re-download transaction we + // already have. + client.populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); + + let update = client.sync(request, *batch_size, false)?; + wallet + .apply_update(update) + .map_err(|e| Error::Generic(e.to_string())) + } + #[cfg(feature = "esplora")] + Esplora { + client, + parallel_requests, + } => { + let update = client + .sync(request, *parallel_requests) + .await + .map_err(|e| *e)?; + wallet + .apply_update(update) + .map_err(|e| Error::Generic(e.to_string())) + } + #[cfg(feature = "rpc")] + RpcClient { client } => { + let blockchain_info = client.get_blockchain_info()?; + let wallet_cp = wallet.latest_checkpoint(); + + // reload the last 200 blocks in case of a reorg + let emitter_height = wallet_cp.height().saturating_sub(200); + let mut emitter = Emitter::new( + client.as_ref(), + wallet_cp, + emitter_height, + wallet + .tx_graph() + .list_canonical_txs( + wallet.local_chain(), + wallet.local_chain().tip().block_id(), + CanonicalizationParams::default(), + ) + .filter(|tx| tx.chain_position.is_unconfirmed()), + ); + + while let Some(block_event) = emitter.next_block()? { + if block_event.block_height() % 10_000 == 0 { + let percent_done = f64::from(block_event.block_height()) + / f64::from(blockchain_info.headers as u32) + * 100f64; + println!( + "Applying block at height: {}, {:.2}% done.", + block_event.block_height(), + percent_done + ); + } + + wallet.apply_block_connected_to( + &block_event.block, + block_event.block_height(), + block_event.connected_to(), + )?; + } + + let mempool_txs = emitter.mempool()?; + wallet.apply_unconfirmed_txs(mempool_txs.update); + Ok(()) + } + #[cfg(feature = "cbf")] + KyotoClient { client } => sync_kyoto_client(wallet, client) + .await + .map_err(|e| Error::Generic(e.to_string())), + } +} + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +/// Broadcasts a given transaction using the blockchain client. +pub async fn broadcast_transaction( + client: &BlockchainClient, + tx: Transaction, +) -> Result { + match client { + #[cfg(feature = "electrum")] + Electrum { + client, + batch_size: _, + } => client + .transaction_broadcast(&tx) + .map_err(|e| Error::Generic(e.to_string())), + #[cfg(feature = "esplora")] + Esplora { + client, + parallel_requests: _, + } => client + .broadcast(&tx) + .await + .map(|()| tx.compute_txid()) + .map_err(|e| Error::Generic(e.to_string())), + #[cfg(feature = "rpc")] + RpcClient { client } => client + .send_raw_transaction(&tx) + .map_err(|e| Error::Generic(e.to_string())), + + #[cfg(feature = "cbf")] + 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()) + })?; + tracing::info!("Successfully broadcast WTXID: {wtxid}"); + Ok(txid) + } + } +} diff --git a/src/handlers/wallets.rs b/src/handlers/wallets.rs new file mode 100644 index 00000000..17c25c78 --- /dev/null +++ b/src/handlers/wallets.rs @@ -0,0 +1,37 @@ +use crate::config::WalletConfig; +use crate::error::BDKCliError as Error; +use cli_table::{Cell, CellStruct, Style, Table}; +use std::path::Path; + +/// Handle the top-level `wallets` command (lists all saved wallets) +pub fn handle_wallets_subcommand(home_dir: &Path, pretty: bool) -> Result { + let config = match WalletConfig::load(home_dir)? { + Some(cfg) => cfg, + None => return Ok("No wallets configured yet.".to_string()), + }; + + if pretty { + let mut rows: Vec> = vec![]; + for (name, inner) in &config.wallets { + rows.push(vec![ + name.cell(), + inner.network.clone().cell(), + inner.ext_descriptor[..30].to_string().cell(), + ]); + } + + let table = rows + .table() + .title(vec![ + "Wallet Name".cell().bold(true), + "Network".cell().bold(true), + "External Descriptor (truncated)".cell().bold(true), + ]) + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + + Ok(format!("{table}")) + } else { + Ok(serde_json::to_string_pretty(&config.wallets)?) + } +} From 374cd3bff30fa636a4aa8ebb62d0be5f9b8857f0 Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Tue, 21 Apr 2026 11:25:55 +0100 Subject: [PATCH 07/20] ref(handlers): split handler fns into repl - split handler fns into repl - add entry point to the handlers subdir - add wallet subdir entry point --- src/handlers/mod.rs | 297 +++++++++++++++++++++++++++++++++++++++++++ src/handlers/repl.rs | 111 ++++++++++++++++ src/wallet/mod.rs | 74 +++++++++++ 3 files changed, 482 insertions(+) create mode 100644 src/handlers/mod.rs create mode 100644 src/handlers/repl.rs create mode 100644 src/wallet/mod.rs diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 00000000..f7a4737e --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,297 @@ +pub mod config; +pub mod descriptor; +pub mod key; +pub mod offline; +pub mod online; +pub mod repl; +pub mod wallets; + +#[cfg(feature = "repl")] +use crate::handlers::repl::respond; +use crate::{ + commands::{CliOpts, CliSubCommand, WalletSubCommand}, + error::BDKCliError as Error, + handlers::{ + config::handle_config_subcommand, descriptor::handle_descriptor_command, + key::handle_key_subcommand, wallets::handle_wallets_subcommand, + }, + utils::{load_wallet_config, prepare_home_dir}, +}; + +#[cfg(any(feature = "sqlite", feature = "redb"))] +use crate::utils::prepare_wallet_db_dir; +#[cfg(not(any(feature = "sqlite", feature = "redb")))] +use crate::wallet::new_wallet; + +#[cfg(feature = "compiler")] +use { + crate::handlers::descriptor::handle_compile_subcommand, bdk_redb::Store as RedbStore, + std::sync::Arc, +}; + +#[cfg(feature = "repl")] +use crate::handlers::repl::readline; + +#[cfg(any(feature = "sqlite", feature = "redb"))] +use crate::commands::DatabaseType; +use crate::handlers::offline::handle_offline_wallet_subcommand; +use clap::CommandFactory; +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf", +))] +use { + crate::backend::new_blockchain_client, crate::handlers::online::handle_online_wallet_subcommand, +}; +#[cfg(any(feature = "sqlite", feature = "redb"))] +use { + crate::wallet::{new_persisted_wallet, persister::Persister}, + bdk_wallet::rusqlite::Connection, + std::io::Write, +}; + +/// The global top level handler. +pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { + let pretty = cli_opts.pretty; + let subcommand = cli_opts.subcommand.clone(); + + let result: Result = match subcommand { + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" + ))] + CliSubCommand::Wallet { + wallet, + subcommand: WalletSubCommand::OnlineWalletSubCommand(online_subcommand), + } => { + let home_dir = prepare_home_dir(cli_opts.datadir)?; + + let (wallet_opts, network) = load_wallet_config(&home_dir, &wallet)?; + + let database_path = prepare_wallet_db_dir(&home_dir, &wallet)?; + + #[cfg(any(feature = "sqlite", feature = "redb"))] + let result = { + #[cfg(feature = "sqlite")] + let mut persister: Persister = match &wallet_opts.database_type { + #[cfg(feature = "sqlite")] + DatabaseType::Sqlite => { + let db_file = database_path.join("wallet.sqlite"); + let connection = Connection::open(db_file)?; + log::debug!("Sqlite database opened successfully"); + Persister::Connection(connection) + } + #[cfg(feature = "redb")] + DatabaseType::Redb => { + let wallet_name = &wallet_opts.wallet; + let db = Arc::new(bdk_redb::redb::Database::create( + home_dir.join("wallet.redb"), + )?); + let store = RedbStore::new( + db, + wallet_name.as_deref().unwrap_or("wallet").to_string(), + )?; + log::debug!("Redb database opened successfully"); + Persister::RedbStore(store) + } + }; + + let mut wallet = new_persisted_wallet(network, &mut persister, &wallet_opts)?; + let blockchain_client = + new_blockchain_client(&wallet_opts, &wallet, database_path)?; + + let result = handle_online_wallet_subcommand( + &mut wallet, + &blockchain_client, + online_subcommand, + ) + .await?; + wallet.persist(&mut persister)?; + result + }; + #[cfg(not(any(feature = "sqlite", feature = "redb")))] + let result = { + let mut wallet = new_wallet(network, &wallet_opts)?; + let blockchain_client = + new_blockchain_client(&wallet_opts, &wallet, database_path)?; + handle_online_wallet_subcommand(&mut wallet, &blockchain_client, online_subcommand) + .await? + }; + Ok(result) + } + CliSubCommand::Wallet { + wallet: wallet_name, + subcommand: WalletSubCommand::OfflineWalletSubCommand(offline_subcommand), + } => { + let datadir = cli_opts.datadir.clone(); + let home_dir = prepare_home_dir(datadir)?; + let (wallet_opts, network) = load_wallet_config(&home_dir, &wallet_name)?; + + #[cfg(any(feature = "sqlite", feature = "redb"))] + let result = { + let mut persister: Persister = match &wallet_opts.database_type { + #[cfg(feature = "sqlite")] + DatabaseType::Sqlite => { + let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?; + let db_file = database_path.join("wallet.sqlite"); + let connection = Connection::open(db_file)?; + log::debug!("Sqlite database opened successfully"); + Persister::Connection(connection) + } + #[cfg(feature = "redb")] + DatabaseType::Redb => { + let db = Arc::new(bdk_redb::redb::Database::create( + home_dir.join("wallet.redb"), + )?); + let store = RedbStore::new(db, wallet_name)?; + log::debug!("Redb database opened successfully"); + Persister::RedbStore(store) + } + }; + + let mut wallet = new_persisted_wallet(network, &mut persister, &wallet_opts)?; + + let result = handle_offline_wallet_subcommand( + &mut wallet, + &wallet_opts, + &cli_opts, + offline_subcommand.clone(), + )?; + wallet.persist(&mut persister)?; + result + }; + #[cfg(not(any(feature = "sqlite", feature = "redb")))] + let result = { + let mut wallet = new_wallet(network, &wallet_opts)?; + handle_offline_wallet_subcommand( + &mut wallet, + &wallet_opts, + &cli_opts, + offline_subcommand.clone(), + )? + }; + Ok(result) + } + CliSubCommand::Wallet { + wallet, + subcommand: WalletSubCommand::Config { force, wallet_opts }, + } => { + let network = cli_opts.network; + let home_dir = prepare_home_dir(cli_opts.datadir)?; + let result = handle_config_subcommand(&home_dir, network, wallet, &wallet_opts, force)?; + Ok(result) + } + CliSubCommand::Wallets => { + let home_dir = prepare_home_dir(cli_opts.datadir)?; + let result = handle_wallets_subcommand(&home_dir, pretty)?; + Ok(result) + } + CliSubCommand::Key { + subcommand: key_subcommand, + } => { + let network = cli_opts.network; + let result = handle_key_subcommand(network, key_subcommand, pretty)?; + Ok(result) + } + #[cfg(feature = "compiler")] + CliSubCommand::Compile { + policy, + script_type, + } => { + let network = cli_opts.network; + let result = handle_compile_subcommand(network, policy, script_type, pretty)?; + Ok(result) + } + #[cfg(feature = "repl")] + CliSubCommand::Repl { + wallet: wallet_name, + } => { + let home_dir = prepare_home_dir(cli_opts.datadir.clone())?; + let (wallet_opts, network) = load_wallet_config(&home_dir, &wallet_name)?; + + #[cfg(any(feature = "sqlite", feature = "redb"))] + let (mut wallet, mut persister) = { + let mut persister: Persister = match &wallet_opts.database_type { + #[cfg(feature = "sqlite")] + DatabaseType::Sqlite => { + let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?; + let db_file = database_path.join("wallet.sqlite"); + let connection = Connection::open(db_file)?; + log::debug!("Sqlite database opened successfully"); + Persister::Connection(connection) + } + #[cfg(feature = "redb")] + DatabaseType::Redb => { + let db = Arc::new(bdk_redb::redb::Database::create( + home_dir.join("wallet.redb"), + )?); + let store = RedbStore::new(db, wallet_name.clone())?; + log::debug!("Redb database opened successfully"); + Persister::RedbStore(store) + } + }; + let wallet = new_persisted_wallet(network, &mut persister, &wallet_opts)?; + (wallet, persister) + }; + #[cfg(not(any(feature = "sqlite", feature = "redb")))] + let mut wallet = new_wallet(network, &loaded_wallet_opts)?; + let home_dir = prepare_home_dir(cli_opts.datadir.clone())?; + let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?; + loop { + let line = readline()?; + let line = line.trim(); + if line.is_empty() { + continue; + } + + let result = respond( + network, + &mut wallet, + &wallet_name, + &mut wallet_opts.clone(), + line, + database_path.clone(), + &cli_opts, + ) + .await; + #[cfg(any(feature = "sqlite", feature = "redb"))] + wallet.persist(&mut persister)?; + + match result { + Ok(quit) => { + if quit { + break; + } + } + Err(err) => { + writeln!(std::io::stdout(), "{err}") + .map_err(|e| Error::Generic(e.to_string()))?; + std::io::stdout() + .flush() + .map_err(|e| Error::Generic(e.to_string()))?; + } + } + } + Ok("".to_string()) + } + CliSubCommand::Descriptor { desc_type, key } => { + let descriptor = handle_descriptor_command(cli_opts.network, desc_type, key, pretty)?; + Ok(descriptor) + } + CliSubCommand::Completions { shell } => { + clap_complete::generate( + shell, + &mut CliOpts::command(), + "bdk-cli", + &mut std::io::stdout(), + ); + + Ok("".to_string()) + } + }; + result +} diff --git a/src/handlers/repl.rs b/src/handlers/repl.rs new file mode 100644 index 00000000..432af013 --- /dev/null +++ b/src/handlers/repl.rs @@ -0,0 +1,111 @@ +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +use crate::{backend::new_blockchain_client, handlers::online::handle_online_wallet_subcommand}; + +#[cfg(feature = "sqlite")] +use crate::commands::ReplSubCommand; +#[cfg(feature = "repl")] +use { + crate::error::BDKCliError as Error, + crate::{ + commands::{CliOpts, WalletOpts, WalletSubCommand}, + handlers::{ + config::handle_config_subcommand, descriptor::handle_descriptor_command, + key::handle_key_subcommand, offline::handle_offline_wallet_subcommand, + }, + }, + bdk_wallet::{Wallet, bitcoin::Network}, + std::io::Write, +}; + +#[cfg(feature = "repl")] +pub(crate) async fn respond( + network: Network, + wallet: &mut Wallet, + wallet_name: &String, + wallet_opts: &mut WalletOpts, + line: &str, + _datadir: std::path::PathBuf, + cli_opts: &CliOpts, +) -> Result { + use clap::Parser; + + let args = shlex::split(line).ok_or("error: Invalid quoting".to_string())?; + let repl_subcommand = ReplSubCommand::try_parse_from(args).map_err(|e| e.to_string())?; + let response = match repl_subcommand { + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" + ))] + ReplSubCommand::Wallet { + subcommand: WalletSubCommand::OnlineWalletSubCommand(online_subcommand), + } => { + let blockchain = + new_blockchain_client(wallet_opts, wallet, _datadir).map_err(|e| e.to_string())?; + let value = handle_online_wallet_subcommand(wallet, &blockchain, online_subcommand) + .await + .map_err(|e| e.to_string())?; + Some(value) + } + ReplSubCommand::Wallet { + subcommand: WalletSubCommand::OfflineWalletSubCommand(offline_subcommand), + } => { + let value = + handle_offline_wallet_subcommand(wallet, wallet_opts, cli_opts, offline_subcommand) + .map_err(|e| e.to_string())?; + Some(value) + } + ReplSubCommand::Wallet { + subcommand: WalletSubCommand::Config { force, wallet_opts }, + } => { + let value = handle_config_subcommand( + &_datadir, + network, + wallet_name.to_string(), + &wallet_opts, + force, + ) + .map_err(|e| e.to_string())?; + Some(value) + } + ReplSubCommand::Key { subcommand } => { + let value = handle_key_subcommand(network, subcommand, cli_opts.pretty) + .map_err(|e| e.to_string())?; + Some(value) + } + ReplSubCommand::Descriptor { desc_type, key } => { + let value = handle_descriptor_command(network, desc_type, key, cli_opts.pretty) + .map_err(|e| e.to_string())?; + Some(value) + } + ReplSubCommand::Exit => None, + }; + if let Some(value) = response { + writeln!(std::io::stdout(), "{value}").map_err(|e| e.to_string())?; + std::io::stdout().flush().map_err(|e| e.to_string())?; + Ok(false) + } else { + writeln!(std::io::stdout(), "Exiting...").map_err(|e| e.to_string())?; + std::io::stdout().flush().map_err(|e| e.to_string())?; + Ok(true) + } +} + +#[cfg(feature = "repl")] +pub(crate) fn readline() -> Result { + write!(std::io::stdout(), "> ").map_err(|e| Error::Generic(e.to_string()))?; + std::io::stdout() + .flush() + .map_err(|e| Error::Generic(e.to_string()))?; + let mut buffer = String::new(); + std::io::stdin() + .read_line(&mut buffer) + .map_err(|e| Error::Generic(e.to_string()))?; + Ok(buffer) +} diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs new file mode 100644 index 00000000..7ef2e65b --- /dev/null +++ b/src/wallet/mod.rs @@ -0,0 +1,74 @@ +use crate::commands::WalletOpts; +use crate::error::BDKCliError as Error; +use bdk_wallet::Wallet; +use bdk_wallet::bitcoin::Network; +#[cfg(any(feature = "sqlite", feature = "redb"))] +use bdk_wallet::{KeychainKind, PersistedWallet, WalletPersister}; + +#[cfg(any(feature = "sqlite", feature = "redb"))] +pub mod persister; + +#[cfg(any(feature = "sqlite", feature = "redb"))] +pub(crate) fn new_persisted_wallet( + network: Network, + persister: &mut P, + wallet_opts: &WalletOpts, +) -> Result, Error> +where + P::Error: std::fmt::Display, +{ + let ext_descriptor = wallet_opts.ext_descriptor.clone(); + let int_descriptor = wallet_opts.int_descriptor.clone(); + + let mut wallet_load_params = Wallet::load(); + wallet_load_params = + wallet_load_params.descriptor(KeychainKind::External, Some(ext_descriptor.clone())); + + if int_descriptor.is_some() { + wallet_load_params = + wallet_load_params.descriptor(KeychainKind::Internal, int_descriptor.clone()); + } + wallet_load_params = wallet_load_params.extract_keys(); + + let wallet_opt = wallet_load_params + .check_network(network) + .load_wallet(persister) + .map_err(|e| Error::Generic(e.to_string()))?; + + let wallet = match wallet_opt { + Some(wallet) => wallet, + None => match int_descriptor { + Some(int_descriptor) => Wallet::create(ext_descriptor, int_descriptor) + .network(network) + .create_wallet(persister) + .map_err(|e| Error::Generic(e.to_string()))?, + None => Wallet::create_single(ext_descriptor) + .network(network) + .create_wallet(persister) + .map_err(|e| Error::Generic(e.to_string()))?, + }, + }; + + Ok(wallet) +} + +#[cfg(not(any(feature = "sqlite", feature = "redb")))] +pub(crate) fn new_wallet(network: Network, wallet_opts: &WalletOpts) -> Result { + let ext_descriptor = wallet_opts.ext_descriptor.clone(); + let int_descriptor = wallet_opts.int_descriptor.clone(); + + match int_descriptor { + Some(int_descriptor) => { + let wallet = Wallet::create(ext_descriptor, int_descriptor) + .network(network) + .create_wallet_no_persist()?; + Ok(wallet) + } + None => { + let wallet = Wallet::create_single(ext_descriptor) + .network(network) + .create_wallet_no_persist()?; + Ok(wallet) + } + } +} From 0e9c6da88c3193ea7d11372674eb55859fbd5d4d Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Tue, 21 Apr 2026 11:39:06 +0100 Subject: [PATCH 08/20] ref(main): update main entry point --- Cargo.lock | 29 ++++++++++++++--------------- src/main.rs | 6 +++--- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b27c190e..8e8f10d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -506,7 +506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0982261c82a50d89d1a411602afee0498b3e0debe3d36693f0c661352809639" dependencies = [ "bitcoin-io 0.2.0", - "hex-conservative 0.3.1", + "hex-conservative 0.3.2", ] [[package]] @@ -1267,9 +1267,9 @@ dependencies = [ [[package]] name = "hex-conservative" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2b9348ee0d8d4e3a894946c1ab104d08a2e44ca13656613afada8905ea609b6" +checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" dependencies = [ "arrayvec", ] @@ -1380,19 +1380,18 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls 0.23.36", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.5", + "webpki-roots 1.0.7", ] [[package]] @@ -2019,7 +2018,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.3", + "rand 0.9.4", "ring", "rustc-hash", "rustls 0.23.36", @@ -2073,9 +2072,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -2215,7 +2214,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.5", + "webpki-roots 1.0.7", ] [[package]] @@ -2277,9 +2276,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustix" @@ -3126,9 +3125,9 @@ checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" [[package]] name = "webpki-roots" -version = "1.0.5" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] diff --git a/src/main.rs b/src/main.rs index 90d701b0..64600bef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ #![doc(html_logo_url = "https://github.com/bitcoindevkit/bdk/raw/master/static/bdk.png")] #![warn(missing_docs)] +mod backend; mod commands; mod config; mod error; @@ -21,15 +22,14 @@ mod handlers; feature = "rpc" ))] mod payjoin; -#[cfg(any(feature = "sqlite", feature = "redb"))] -mod persister; mod utils; +mod wallet; use bdk_wallet::bitcoin::Network; use log::{debug, error, warn}; use crate::commands::CliOpts; -use crate::handlers::*; +use crate::handlers::handle_command; use clap::Parser; #[tokio::main] From b5edf0ebd253571dd7ddcc9499367bf69e3cdb19 Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Fri, 24 Apr 2026 19:41:23 +0100 Subject: [PATCH 09/20] refactor(handlers): Add types for outputting data - add trait for formatting outputs - add types for presenting data to the output - refactor offline mod to use types --- src/handlers/mod.rs | 1 + src/handlers/offline.rs | 382 +++++++------------------------------ src/handlers/types.rs | 411 ++++++++++++++++++++++++++++++++++++++++ src/utils/mod.rs | 2 +- src/utils/output.rs | 17 ++ 5 files changed, 500 insertions(+), 313 deletions(-) create mode 100644 src/handlers/types.rs create mode 100644 src/utils/output.rs diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index f7a4737e..7fb0179d 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -4,6 +4,7 @@ pub mod key; pub mod offline; pub mod online; pub mod repl; +pub mod types; pub mod wallets; #[cfg(feature = "repl")] diff --git a/src/handlers/offline.rs b/src/handlers/offline.rs index 9d564ebb..63994139 100644 --- a/src/handlers/offline.rs +++ b/src/handlers/offline.rs @@ -1,16 +1,17 @@ use crate::commands::OfflineWalletSubCommand::*; use crate::commands::{CliOpts, OfflineWalletSubCommand, WalletOpts}; use crate::error::BDKCliError as Error; -use crate::utils::shorten; +use crate::handlers::types::{ + AddressResult, BalanceResult, PoliciesResult, PsbtResult, PublicDescriptorResult, RawPsbt, + TransactionDetails, TransactionListResult, UnspentDetails, UnspentListResult, +}; +use crate::utils::output::FormatOutput; use bdk_wallet::bitcoin::base64::Engine; use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD; use bdk_wallet::bitcoin::consensus::encode::serialize_hex; use bdk_wallet::bitcoin::script::PushBytesBuf; -use bdk_wallet::bitcoin::{Address, Amount, FeeRate, Psbt, Sequence, Txid}; -use bdk_wallet::chain::ChainPosition; +use bdk_wallet::bitcoin::{Amount, FeeRate, Psbt, Sequence, Txid}; use bdk_wallet::{KeychainKind, SignOptions, Wallet}; -use cli_table::format::Justify; -use cli_table::{Cell, CellStruct, Style, Table}; use serde_json::json; use std::collections::BTreeMap; use std::str::FromStr; @@ -24,232 +25,62 @@ pub fn handle_offline_wallet_subcommand( cli_opts: &CliOpts, offline_subcommand: OfflineWalletSubCommand, ) -> Result { + let pretty = cli_opts.pretty; match offline_subcommand { NewAddress => { let addr = wallet.reveal_next_address(KeychainKind::External); - if cli_opts.pretty { - let table = vec![ - vec!["Address".cell().bold(true), addr.address.to_string().cell()], - vec![ - "Index".cell().bold(true), - addr.index.to_string().cell().justify(Justify::Right), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else if wallet_opts.verbose { - Ok(serde_json::to_string_pretty(&json!({ - "address": addr.address, - "index": addr.index - }))?) - } else { - Ok(serde_json::to_string_pretty(&json!({ - "address": addr.address, - }))?) - } + let result: AddressResult = addr.into(); + result.format(pretty) } UnusedAddress => { let addr = wallet.next_unused_address(KeychainKind::External); - - if cli_opts.pretty { - let table = vec![ - vec!["Address".cell().bold(true), addr.address.to_string().cell()], - vec![ - "Index".cell().bold(true), - addr.index.to_string().cell().justify(Justify::Right), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else if wallet_opts.verbose { - Ok(serde_json::to_string_pretty(&json!({ - "address": addr.address, - "index": addr.index - }))?) - } else { - Ok(serde_json::to_string_pretty(&json!({ - "address": addr.address, - }))?) - } + let result: AddressResult = addr.into(); + result.format(pretty) } Unspent => { - let utxos = wallet.list_unspent().collect::>(); - if cli_opts.pretty { - let mut rows: Vec> = vec![]; - for utxo in &utxos { - let height = utxo - .chain_position - .confirmation_height_upper_bound() - .map(|h| h.to_string()) - .unwrap_or("Pending".to_string()); + let utxos: Vec = wallet + .list_unspent() + .map(|utxo| UnspentDetails::from_local_output(&utxo, cli_opts.network)) + .collect(); - let block_hash = match &utxo.chain_position { - ChainPosition::Confirmed { anchor, .. } => anchor.block_id.hash.to_string(), - ChainPosition::Unconfirmed { .. } => "Unconfirmed".to_string(), - }; - - rows.push(vec![ - shorten(utxo.outpoint, 8, 10).cell(), - utxo.txout - .value - .to_sat() - .to_string() - .cell() - .justify(Justify::Right), - Address::from_script(&utxo.txout.script_pubkey, cli_opts.network) - .unwrap() - .cell(), - utxo.keychain.cell(), - utxo.is_spent.cell(), - utxo.derivation_index.cell(), - height.to_string().cell().justify(Justify::Right), - shorten(block_hash, 8, 8).cell().justify(Justify::Right), - ]); - } - let table = rows - .table() - .title(vec![ - "Outpoint".cell().bold(true), - "Output (sat)".cell().bold(true), - "Output Address".cell().bold(true), - "Keychain".cell().bold(true), - "Is Spent".cell().bold(true), - "Index".cell().bold(true), - "Block Height".cell().bold(true), - "Block Hash".cell().bold(true), - ]) - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty(&utxos)?) - } + let result = UnspentListResult(utxos); + result.format(pretty) } Transactions => { let transactions = wallet.transactions(); - if cli_opts.pretty { - let txns = transactions - .map(|tx| { - let total_value = tx - .tx_node - .output - .iter() - .map(|output| output.value.to_sat()) - .sum::(); - ( - tx.tx_node.txid.to_string(), - tx.tx_node.version, - tx.tx_node.is_explicitly_rbf(), - tx.tx_node.input.len(), - tx.tx_node.output.len(), - total_value, - ) - }) - .collect::>(); - let mut rows: Vec> = vec![]; - for (txid, version, is_rbf, input_count, output_count, total_value) in txns { - rows.push(vec![ - txid.cell(), - version.to_string().cell().justify(Justify::Right), - is_rbf.to_string().cell().justify(Justify::Center), - input_count.to_string().cell().justify(Justify::Right), - output_count.to_string().cell().justify(Justify::Right), - total_value.to_string().cell().justify(Justify::Right), - ]); - } - let table = rows - .table() - .title(vec![ - "Txid".cell().bold(true), - "Version".cell().bold(true), - "Is RBF".cell().bold(true), - "Input Count".cell().bold(true), - "Output Count".cell().bold(true), - "Total Value (sat)".cell().bold(true), - ]) - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - let txns: Vec<_> = transactions - .map(|tx| { - json!({ - "txid": tx.tx_node.txid, - "is_coinbase": tx.tx_node.is_coinbase(), - "wtxid": tx.tx_node.compute_wtxid(), - "version": tx.tx_node.version, - "is_rbf": tx.tx_node.is_explicitly_rbf(), - "inputs": tx.tx_node.input, - "outputs": tx.tx_node.output, - }) - }) - .collect(); - Ok(serde_json::to_string_pretty(&txns)?) - } + let txns: Vec = transactions + .map(|tx| { + let total_value = tx + .tx_node + .output + .iter() + .map(|output| output.value.to_sat()) + .sum::(); + + TransactionDetails { + txid: tx.tx_node.txid.to_string(), + is_coinbase: tx.tx_node.is_coinbase(), + wtxid: tx.tx_node.compute_wtxid().to_string(), + version: serde_json::to_value(tx.tx_node.version).unwrap_or(json!(1)), + version_display: tx.tx_node.version.to_string(), + is_rbf: tx.tx_node.is_explicitly_rbf(), + inputs: serde_json::to_value(&tx.tx_node.input).unwrap_or_default(), + outputs: serde_json::to_value(&tx.tx_node.output).unwrap_or_default(), + input_count: tx.tx_node.input.len(), + output_count: tx.tx_node.output.len(), + total_value, + } + }) + .collect(); + + let result = TransactionListResult(txns); + result.format(pretty) } Balance => { let balance = wallet.balance(); - if cli_opts.pretty { - let table = vec![ - vec!["Type".cell().bold(true), "Amount (sat)".cell().bold(true)], - vec![ - "Total".cell(), - balance - .total() - .to_sat() - .to_string() - .cell() - .justify(Justify::Right), - ], - vec![ - "Confirmed".cell(), - balance - .confirmed - .to_sat() - .to_string() - .cell() - .justify(Justify::Right), - ], - vec![ - "Unconfirmed".cell(), - balance - .immature - .to_sat() - .to_string() - .cell() - .justify(Justify::Right), - ], - vec![ - "Trusted Pending".cell(), - balance - .trusted_pending - .to_sat() - .cell() - .justify(Justify::Right), - ], - vec![ - "Untrusted Pending".cell(), - balance - .untrusted_pending - .to_sat() - .cell() - .justify(Justify::Right), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({"satoshi": wallet.balance()}), - )?) - } + let result: BalanceResult = balance.into(); + result.format(pretty) } CreateTx { @@ -319,17 +150,8 @@ pub fn handle_offline_wallet_subcommand( let psbt = tx_builder.finish()?; - let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); - - if wallet_opts.verbose { - Ok(serde_json::to_string_pretty( - &json!({"psbt": psbt_base64, "details": psbt}), - )?) - } else { - Ok(serde_json::to_string_pretty( - &json!({"psbt": psbt_base64 }), - )?) - } + let result = PsbtResult::with_details(&psbt, wallet_opts.verbose); + result.format(pretty) } BumpFee { txid, @@ -365,62 +187,24 @@ pub fn handle_offline_wallet_subcommand( let psbt = tx_builder.finish()?; - let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); - - Ok(serde_json::to_string_pretty( - &json!({"psbt": psbt_base64 }), - )?) + let result = PsbtResult::with_details(&psbt, wallet_opts.verbose); + result.format(pretty) } Policies => { let external_policy = wallet.policies(KeychainKind::External)?; let internal_policy = wallet.policies(KeychainKind::Internal)?; - if cli_opts.pretty { - let table = vec![ - vec![ - "External".cell().bold(true), - serde_json::to_string_pretty(&external_policy)?.cell(), - ], - vec![ - "Internal".cell().bold(true), - serde_json::to_string_pretty(&internal_policy)?.cell(), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty(&json!({ - "external": external_policy, - "internal": internal_policy, - }))?) - } + let result = PoliciesResult { + external: serde_json::to_value(&external_policy).unwrap_or(json!(null)), + internal: serde_json::to_value(&internal_policy).unwrap_or(json!(null)), + }; + result.format(pretty) } PublicDescriptor => { - let external = wallet.public_descriptor(KeychainKind::External).to_string(); - let internal = wallet.public_descriptor(KeychainKind::Internal).to_string(); - - if cli_opts.pretty { - let table = vec![ - vec![ - "External Descriptor".cell().bold(true), - external.to_string().cell(), - ], - vec![ - "Internal Descriptor".cell().bold(true), - internal.to_string().cell(), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty(&json!({ - "external": external.to_string(), - "internal": internal.to_string(), - }))?) - } + let result = PublicDescriptorResult { + external: wallet.public_descriptor(KeychainKind::External).to_string(), + internal: wallet.public_descriptor(KeychainKind::Internal).to_string(), + }; + result.format(pretty) } Sign { psbt, @@ -435,35 +219,16 @@ pub fn handle_offline_wallet_subcommand( ..Default::default() }; let finalized = wallet.sign(&mut psbt, signopt)?; - let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); - if wallet_opts.verbose { - Ok(serde_json::to_string_pretty( - &json!({"psbt": &psbt_base64, "is_finalized": finalized, "serialized_psbt": &psbt}), - )?) - } else { - Ok(serde_json::to_string_pretty( - &json!({"psbt": &psbt_base64, "is_finalized": finalized}), - )?) - } + + let result = PsbtResult::with_status_and_details(&psbt, finalized, wallet_opts.verbose); + result.format(pretty) } ExtractPsbt { psbt } => { let psbt_serialized = BASE64_STANDARD.decode(psbt)?; let psbt = Psbt::deserialize(&psbt_serialized)?; let raw_tx = psbt.extract_tx()?; - if cli_opts.pretty { - let table = vec![vec![ - "Raw Transaction".cell().bold(true), - serialize_hex(&raw_tx).cell(), - ]] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({"raw_tx": serialize_hex(&raw_tx)}), - )?) - } + let result = RawPsbt::new(&raw_tx); + result.format(pretty) } FinalizePsbt { psbt, @@ -479,15 +244,9 @@ pub fn handle_offline_wallet_subcommand( ..Default::default() }; let finalized = wallet.finalize_psbt(&mut psbt, signopt)?; - if wallet_opts.verbose { - Ok(serde_json::to_string_pretty( - &json!({ "psbt": BASE64_STANDARD.encode(psbt.serialize()), "is_finalized": finalized, "details": psbt}), - )?) - } else { - Ok(serde_json::to_string_pretty( - &json!({ "psbt": BASE64_STANDARD.encode(psbt.serialize()), "is_finalized": finalized}), - )?) - } + + let result = PsbtResult::with_status_and_details(&psbt, finalized, wallet_opts.verbose); + result.format(pretty) } CombinePsbt { psbt } => { let mut psbts = psbt @@ -508,9 +267,8 @@ pub fn handle_offline_wallet_subcommand( Ok(acc) }, )?; - Ok(serde_json::to_string_pretty( - &json!({ "psbt": BASE64_STANDARD.encode(final_psbt.serialize()) }), - )?) + let result = PsbtResult::new(&final_psbt); + result.format(pretty) } } } diff --git a/src/handlers/types.rs b/src/handlers/types.rs new file mode 100644 index 00000000..cd25313b --- /dev/null +++ b/src/handlers/types.rs @@ -0,0 +1,411 @@ +use crate::utils::output::FormatOutput; +use crate::{error::BDKCliError as Error, utils::shorten}; +use bdk_wallet::bitcoin::base64::Engine; +use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD; +use bdk_wallet::bitcoin::consensus::encode::serialize_hex; +use bdk_wallet::bitcoin::{Address, Network, Psbt, Transaction}; +use bdk_wallet::chain::ChainPosition; +use bdk_wallet::{AddressInfo, Balance, LocalOutput}; +use cli_table::{Cell, CellStruct, Style, Table, format::Justify}; +use serde::Serialize; +use serde_json::json; + +/// Represent address result +#[derive(Serialize)] +pub struct AddressResult { + pub address: String, + pub index: u32, +} + +impl From for AddressResult { + fn from(info: AddressInfo) -> Self { + Self { + address: info.address.to_string(), + index: info.index, + } + } +} + +/// pretty presentation for address +impl FormatOutput for AddressResult { + fn to_table(&self) -> Result { + let table = vec![ + vec!["Address".cell().bold(true), self.address.clone().cell()], + vec!["Index".cell().bold(true), self.index.cell()], + ] + .table() + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + + Ok(format!("{table}")) + } +} + +/// Represents the data for a single transaction +#[derive(Serialize)] +pub struct TransactionDetails { + pub txid: String, + pub is_coinbase: bool, + pub wtxid: String, + pub version: serde_json::Value, + pub is_rbf: bool, + pub inputs: serde_json::Value, + pub outputs: serde_json::Value, + #[serde(skip)] + pub version_display: String, + #[serde(skip)] + pub input_count: usize, + #[serde(skip)] + pub output_count: usize, + #[serde(skip)] + pub total_value: u64, +} + +/// A wrapper type for a list of transactions. +#[derive(Serialize)] +#[serde(transparent)] +pub struct TransactionListResult(pub Vec); + +impl FormatOutput for TransactionListResult { + fn to_table(&self) -> Result { + let mut rows: Vec> = vec![]; + + for tx in &self.0 { + rows.push(vec![ + tx.txid.clone().cell(), + tx.version_display.clone().cell().justify(Justify::Right), + tx.is_rbf.to_string().cell().justify(Justify::Center), + tx.input_count.to_string().cell().justify(Justify::Right), + tx.output_count.to_string().cell().justify(Justify::Right), + tx.total_value.to_string().cell().justify(Justify::Right), + ]); + } + + let table = rows + .table() + .title(vec![ + "Txid".cell().bold(true), + "Version".cell().bold(true), + "Is RBF".cell().bold(true), + "Input Count".cell().bold(true), + "Output Count".cell().bold(true), + "Total Value (sat)".cell().bold(true), + ]) + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + + Ok(format!("{table}")) + } +} + +/// Balance representation +#[derive(Serialize)] +pub struct BalanceResult { + pub total: u64, + pub trusted_pending: u64, + pub untrusted_pending: u64, + pub immature: u64, + pub confirmed: u64, +} + +impl From for BalanceResult { + fn from(b: Balance) -> Self { + Self { + total: b.total().to_sat(), + confirmed: b.confirmed.to_sat(), + trusted_pending: b.trusted_pending.to_sat(), + untrusted_pending: b.untrusted_pending.to_sat(), + immature: b.immature.to_sat(), + } + } +} + +impl FormatOutput for BalanceResult { + fn to_table(&self) -> Result { + let table = vec![ + vec![ + "Total".cell().bold(true), + self.total.cell().justify(Justify::Right), + ], + vec![ + "Confirmed".cell().bold(true), + self.confirmed.cell().justify(Justify::Right), + ], + vec![ + "Trusted Pending".cell().bold(true), + self.trusted_pending.cell().justify(Justify::Right), + ], + vec![ + "Untrusted Pending".cell().bold(true), + self.untrusted_pending.cell().justify(Justify::Right), + ], + vec![ + "Immature".cell().bold(true), + self.immature.cell().justify(Justify::Right), + ], + ] + .table() + .title(vec![ + "Status".cell().bold(true), + "Amount (sat)".cell().bold(true), + ]) + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + + Ok(format!("{table}")) + } +} + +/// single UTXO +#[derive(Serialize)] +pub struct UnspentDetails { + pub outpoint: String, + pub txout: serde_json::Value, + pub keychain: String, + pub is_spent: bool, + pub derivation_index: u32, + pub chain_position: serde_json::Value, + + #[serde(skip)] + pub value_sat: u64, + #[serde(skip)] + pub address: String, + #[serde(skip)] + pub outpoint_display: String, + #[serde(skip)] + pub height_display: String, + #[serde(skip)] + pub block_hash_display: String, +} + +impl UnspentDetails { + pub fn from_local_output(utxo: &LocalOutput, network: Network) -> Self { + let height = utxo.chain_position.confirmation_height_upper_bound(); + let height_display = height + .map(|h| h.to_string()) + .unwrap_or_else(|| "Pending".to_string()); + + let (_, block_hash_display) = match &utxo.chain_position { + ChainPosition::Confirmed { anchor, .. } => { + let hash = anchor.block_id.hash.to_string(); + (Some(hash.clone()), shorten(&hash, 8, 8)) + } + ChainPosition::Unconfirmed { .. } => (None, "Unconfirmed".to_string()), + }; + + let address = Address::from_script(&utxo.txout.script_pubkey, network) + .map(|a| a.to_string()) + .unwrap_or_else(|_| "Unknown Script".to_string()); + + let outpoint_str = utxo.outpoint.to_string(); + + Self { + outpoint: outpoint_str.clone(), + txout: serde_json::to_value(&utxo.txout).unwrap_or(json!({})), + keychain: format!("{:?}", utxo.keychain), + is_spent: utxo.is_spent, + derivation_index: utxo.derivation_index, + chain_position: serde_json::to_value(utxo.chain_position).unwrap_or(json!({})), + + value_sat: utxo.txout.value.to_sat(), + address, + outpoint_display: shorten(&outpoint_str, 8, 10), + height_display, + block_hash_display, + } + } +} + +/// Wrapper for the list of UTXOs +#[derive(Serialize)] +#[serde(transparent)] +pub struct UnspentListResult(pub Vec); + +impl FormatOutput for UnspentListResult { + fn to_table(&self) -> Result { + let mut rows: Vec> = vec![]; + + for utxo in &self.0 { + rows.push(vec![ + utxo.outpoint_display.clone().cell(), + utxo.value_sat.to_string().cell().justify(Justify::Right), + utxo.address.clone().cell(), + utxo.keychain.clone().cell(), + utxo.is_spent.cell(), + utxo.derivation_index.cell(), + utxo.height_display.clone().cell().justify(Justify::Right), + utxo.block_hash_display + .clone() + .cell() + .justify(Justify::Right), + ]); + } + + let table = rows + .table() + .title(vec![ + "Outpoint".cell().bold(true), + "Output (sat)".cell().bold(true), + "Output Address".cell().bold(true), + "Keychain".cell().bold(true), + "Is Spent".cell().bold(true), + "Index".cell().bold(true), + "Block Height".cell().bold(true), + "Block Hash".cell().bold(true), + ]) + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + + Ok(format!("{table}")) + } +} + +#[derive(Serialize)] +pub struct PsbtResult { + pub psbt: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub is_finalized: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl PsbtResult { + pub fn new(psbt: &Psbt) -> Self { + Self { + psbt: BASE64_STANDARD.encode(psbt.serialize()), + is_finalized: None, + details: None, + } + } + + pub fn with_details(psbt: &Psbt, verbose: bool) -> Self { + Self { + psbt: BASE64_STANDARD.encode(psbt.serialize()), + is_finalized: None, + details: if verbose { + Some(serde_json::to_value(psbt).unwrap_or(json!({}))) + } else { + None + }, + } + } + + pub fn with_status_and_details(psbt: &Psbt, is_finalized: bool, verbose: bool) -> Self { + Self { + psbt: BASE64_STANDARD.encode(psbt.serialize()), + is_finalized: Some(is_finalized), + details: if verbose { + Some(serde_json::to_value(psbt).unwrap_or(json!({}))) + } else { + None + }, + } + } +} + +impl FormatOutput for PsbtResult { + fn to_table(&self) -> Result { + let mut rows = vec![vec![ + "PSBT (Base64)".cell().bold(true), + self.psbt.clone().cell(), + ]]; + + if let Some(finalized) = self.is_finalized { + rows.push(vec!["Is Finalized".cell().bold(true), finalized.cell()]); + } + + if self.details.is_some() { + rows.push(vec![ + "Details".cell().bold(true), + "Run without --pretty to view verbose JSON details".cell(), + ]); + } + + let table = rows + .table() + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + Ok(format!("{table}")) + } +} + +/// Policies representation +#[derive(Serialize)] +pub struct PoliciesResult { + pub external: serde_json::Value, + pub internal: serde_json::Value, +} + +impl FormatOutput for PoliciesResult { + fn to_table(&self) -> Result { + let ext_str = serde_json::to_string_pretty(&self.external) + .map_err(|e| Error::Generic(e.to_string()))?; + let int_str = serde_json::to_string_pretty(&self.internal) + .map_err(|e| Error::Generic(e.to_string()))?; + + let table = vec![ + vec!["External".cell().bold(true), ext_str.cell()], + vec!["Internal".cell().bold(true), int_str.cell()], + ] + .table() + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + + Ok(format!("{table}")) + } +} + +#[derive(Serialize)] +pub struct PublicDescriptorResult { + pub external: String, + pub internal: String, +} + +impl FormatOutput for PublicDescriptorResult { + fn to_table(&self) -> Result { + let table = vec![ + vec![ + "External Descriptor".cell().bold(true), + self.external.clone().cell(), + ], + vec![ + "Internal Descriptor".cell().bold(true), + self.internal.clone().cell(), + ], + ] + .table() + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + + Ok(format!("{table}")) + } +} + +#[derive(Serialize)] +pub struct RawPsbt { + pub raw_tx: String, +} + +impl RawPsbt { + pub fn new(tx: &Transaction) -> Self { + Self { + raw_tx: serialize_hex(tx), + } + } +} + +impl FormatOutput for RawPsbt { + fn to_table(&self) -> Result { + let table = vec![vec![ + "Raw Transaction".cell().bold(true), + self.raw_tx.clone().cell(), + ]] + .table() + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + + Ok(format!("{table}")) + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 0e827e41..cea89351 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,4 @@ pub mod common; pub mod descriptors; - +pub mod output; pub use common::*; diff --git a/src/utils/output.rs b/src/utils/output.rs new file mode 100644 index 00000000..96b8d9ee --- /dev/null +++ b/src/utils/output.rs @@ -0,0 +1,17 @@ +use crate::error::BDKCliError as Error; +use serde::Serialize; + +/// A trait for data structures that can be rendered to the CLI. +pub trait FormatOutput: Serialize { + /// Implement this to define how the data looks as a CLI table. + fn to_table(&self) -> Result; + + /// Formats the output based on the user's `--pretty` flag. + fn format(&self, pretty: bool) -> Result { + if pretty { + self.to_table() + } else { + serde_json::to_string_pretty(self).map_err(|e| Error::Generic(e.to_string())) + } + } +} From 29d3f082ea523f00f012805a6afd9bc8700cc314 Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Wed, 29 Apr 2026 09:09:01 +0100 Subject: [PATCH 10/20] ref(handlers): add types for desc, key & wallets - add types for descriptor, key and wallets mods --- src/handlers/descriptor.rs | 26 +++++------- src/handlers/key.rs | 83 ++++++++++++++------------------------ src/handlers/offline.rs | 23 +++++------ src/handlers/wallets.rs | 30 ++------------ 4 files changed, 55 insertions(+), 107 deletions(-) diff --git a/src/handlers/descriptor.rs b/src/handlers/descriptor.rs index c76c0781..44f1954a 100644 --- a/src/handlers/descriptor.rs +++ b/src/handlers/descriptor.rs @@ -11,14 +11,14 @@ use crate::{ #[cfg(feature = "compiler")] use { + crate::handlers::types::DescriptorResult, + crate::utils::output::FormatOutput, bdk_wallet::{ bitcoin::XOnlyPublicKey, miniscript::{ Descriptor, Legacy, Miniscript, Segwitv0, Tap, descriptor::TapTree, policy::Concrete, }, }, - cli_table::{Cell, Style, Table}, - serde_json::json, std::{str::FromStr, sync::Arc}, }; @@ -93,18 +93,12 @@ pub(crate) fn handle_compile_subcommand( )); } }?; - if pretty { - let table = vec![vec![ - "Descriptor".cell().bold(true), - descriptor.to_string().cell(), - ]] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({"descriptor": descriptor.to_string()}), - )?) - } + let result = DescriptorResult { + descriptor: Some(descriptor.to_string()), + multipath_descriptor: None, + public_descriptors: None, + private_descriptors: None, + mnemonic: None, + }; + result.format(pretty) } diff --git a/src/handlers/key.rs b/src/handlers/key.rs index 40c050c4..abf3833b 100644 --- a/src/handlers/key.rs +++ b/src/handlers/key.rs @@ -1,5 +1,7 @@ use crate::commands::KeySubCommand; use crate::error::BDKCliError as Error; +use crate::handlers::types::KeyResult; +use crate::utils::output::FormatOutput; use bdk_wallet::bip39::{Language, Mnemonic}; use bdk_wallet::bitcoin::bip32::KeySource; use bdk_wallet::bitcoin::key::Secp256k1; @@ -8,8 +10,6 @@ use bdk_wallet::keys::bip39::WordCount; use bdk_wallet::keys::{DerivableKey, GeneratableKey}; use bdk_wallet::keys::{DescriptorKey, DescriptorKey::Secret, ExtendedKey, GeneratedKey}; use bdk_wallet::miniscript::{self, Segwitv0}; -use cli_table::{Cell, Style, Table}; -use serde_json::json; /// Handle a key sub-command /// @@ -44,24 +44,15 @@ pub(crate) fn handle_key_subcommand( .fold("".to_string(), |phrase, w| phrase + w + " ") .trim() .to_string(); - if pretty { - let table = vec![ - vec![ - "Fingerprint".cell().bold(true), - fingerprint.to_string().cell(), - ], - vec!["Mnemonic".cell().bold(true), mnemonic.to_string().cell()], - vec!["Xprv".cell().bold(true), xprv.to_string().cell()], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({ "mnemonic": phrase, "xprv": xprv.to_string(), "fingerprint": fingerprint.to_string() }), - )?) - } + + let result = KeyResult { + xprv: xprv.to_string(), + mnemonic: Some(phrase), + fingerprint: Some(fingerprint.to_string()), + xpub: None, + }; + + result.format(pretty) } KeySubCommand::Restore { mnemonic, password } => { let mnemonic = Mnemonic::parse_in(Language::English, mnemonic)?; @@ -70,24 +61,14 @@ pub(crate) fn handle_key_subcommand( Error::Generic("Privatekey info not found (should not happen)".to_string()) })?; let fingerprint = xprv.fingerprint(&secp); - if pretty { - let table = vec![ - vec![ - "Fingerprint".cell().bold(true), - fingerprint.to_string().cell(), - ], - vec!["Mnemonic".cell().bold(true), mnemonic.to_string().cell()], - vec!["Xprv".cell().bold(true), xprv.to_string().cell()], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({ "xprv": xprv.to_string(), "fingerprint": fingerprint.to_string() }), - )?) - } + + let result = KeyResult { + xprv: xprv.to_string(), + mnemonic: Some(mnemonic.to_string()), + fingerprint: Some(fingerprint.to_string()), + xpub: None, + }; + result.format(pretty) } KeySubCommand::Derive { xprv, path } => { if xprv.network != network.into() { @@ -102,22 +83,18 @@ pub(crate) fn handle_key_subcommand( if let Secret(desc_seckey, _, _) = derived_xprv_desc_key { let desc_pubkey = desc_seckey.to_public(&secp)?; - if pretty { - let table = vec![ - vec!["Xpub".cell().bold(true), desc_pubkey.to_string().cell()], - vec!["Xprv".cell().bold(true), xprv.to_string().cell()], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({"xpub": desc_pubkey.to_string(), "xprv": desc_seckey.to_string()}), - )?) - } + + let result = KeyResult { + xprv: desc_seckey.to_string(), + xpub: Some(desc_pubkey.to_string()), + mnemonic: None, + fingerprint: None, + }; + result.format(pretty) } else { - Err(Error::Generic("Invalid key variant".to_string())) + Err(Error::Generic( + "Derived key is not a secret key".to_string(), + )) } } } diff --git a/src/handlers/offline.rs b/src/handlers/offline.rs index 63994139..8ce056b1 100644 --- a/src/handlers/offline.rs +++ b/src/handlers/offline.rs @@ -2,13 +2,12 @@ use crate::commands::OfflineWalletSubCommand::*; use crate::commands::{CliOpts, OfflineWalletSubCommand, WalletOpts}; use crate::error::BDKCliError as Error; use crate::handlers::types::{ - AddressResult, BalanceResult, PoliciesResult, PsbtResult, PublicDescriptorResult, RawPsbt, - TransactionDetails, TransactionListResult, UnspentDetails, UnspentListResult, + AddressResult, BalanceResult, KeychainPair, PsbtResult, RawPsbt, TransactionDetails, + TransactionListResult, UnspentDetails, UnspentListResult, }; use crate::utils::output::FormatOutput; use bdk_wallet::bitcoin::base64::Engine; use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD; -use bdk_wallet::bitcoin::consensus::encode::serialize_hex; use bdk_wallet::bitcoin::script::PushBytesBuf; use bdk_wallet::bitcoin::{Amount, FeeRate, Psbt, Sequence, Txid}; use bdk_wallet::{KeychainKind, SignOptions, Wallet}; @@ -150,7 +149,7 @@ pub fn handle_offline_wallet_subcommand( let psbt = tx_builder.finish()?; - let result = PsbtResult::with_details(&psbt, wallet_opts.verbose); + let result = PsbtResult::new(&psbt, wallet_opts.verbose, None); result.format(pretty) } BumpFee { @@ -187,24 +186,24 @@ pub fn handle_offline_wallet_subcommand( let psbt = tx_builder.finish()?; - let result = PsbtResult::with_details(&psbt, wallet_opts.verbose); + let result = PsbtResult::new(&psbt, wallet_opts.verbose, None); result.format(pretty) } Policies => { let external_policy = wallet.policies(KeychainKind::External)?; let internal_policy = wallet.policies(KeychainKind::Internal)?; - let result = PoliciesResult { + let result = KeychainPair:: { external: serde_json::to_value(&external_policy).unwrap_or(json!(null)), internal: serde_json::to_value(&internal_policy).unwrap_or(json!(null)), }; - result.format(pretty) + result.format(cli_opts.pretty) } PublicDescriptor => { - let result = PublicDescriptorResult { + let result = KeychainPair:: { external: wallet.public_descriptor(KeychainKind::External).to_string(), internal: wallet.public_descriptor(KeychainKind::Internal).to_string(), }; - result.format(pretty) + result.format(cli_opts.pretty) } Sign { psbt, @@ -220,7 +219,7 @@ pub fn handle_offline_wallet_subcommand( }; let finalized = wallet.sign(&mut psbt, signopt)?; - let result = PsbtResult::with_status_and_details(&psbt, finalized, wallet_opts.verbose); + let result = PsbtResult::new(&psbt, wallet_opts.verbose, Some(finalized)); result.format(pretty) } ExtractPsbt { psbt } => { @@ -245,7 +244,7 @@ pub fn handle_offline_wallet_subcommand( }; let finalized = wallet.finalize_psbt(&mut psbt, signopt)?; - let result = PsbtResult::with_status_and_details(&psbt, finalized, wallet_opts.verbose); + let result = PsbtResult::new(&psbt, wallet_opts.verbose, Some(finalized)); result.format(pretty) } CombinePsbt { psbt } => { @@ -267,7 +266,7 @@ pub fn handle_offline_wallet_subcommand( Ok(acc) }, )?; - let result = PsbtResult::new(&final_psbt); + let result = PsbtResult::new(&final_psbt, wallet_opts.verbose, None); result.format(pretty) } } diff --git a/src/handlers/wallets.rs b/src/handlers/wallets.rs index 17c25c78..9f2b3217 100644 --- a/src/handlers/wallets.rs +++ b/src/handlers/wallets.rs @@ -1,6 +1,6 @@ -use crate::config::WalletConfig; +use crate::utils::output::FormatOutput; +use crate::{config::WalletConfig, handlers::types::WalletsListResult}; use crate::error::BDKCliError as Error; -use cli_table::{Cell, CellStruct, Style, Table}; use std::path::Path; /// Handle the top-level `wallets` command (lists all saved wallets) @@ -10,28 +10,6 @@ pub fn handle_wallets_subcommand(home_dir: &Path, pretty: bool) -> Result return Ok("No wallets configured yet.".to_string()), }; - if pretty { - let mut rows: Vec> = vec![]; - for (name, inner) in &config.wallets { - rows.push(vec![ - name.cell(), - inner.network.clone().cell(), - inner.ext_descriptor[..30].to_string().cell(), - ]); - } - - let table = rows - .table() - .title(vec![ - "Wallet Name".cell().bold(true), - "Network".cell().bold(true), - "External Descriptor (truncated)".cell().bold(true), - ]) - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty(&config.wallets)?) - } + let result = WalletsListResult(config.wallets); + result.format(pretty) } From 2bf4cfc7352184f90d57a604f7f153a98bc58a6a Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Wed, 29 Apr 2026 09:11:33 +0100 Subject: [PATCH 11/20] ref(types): add simple table helper - add simple table helper fn for creating tables - refactor types to use simple table helper - add type defs for key, wallets and descriptors --- src/handlers/types.rs | 269 ++++++++++++++++++++++++------------------ src/utils/output.rs | 23 +++- 2 files changed, 172 insertions(+), 120 deletions(-) diff --git a/src/handlers/types.rs b/src/handlers/types.rs index cd25313b..94aca123 100644 --- a/src/handlers/types.rs +++ b/src/handlers/types.rs @@ -1,11 +1,12 @@ -use crate::utils::output::FormatOutput; +use std::collections::HashMap; + +use crate::config::WalletConfigInner; +use crate::utils::output::{FormatOutput, simple_table}; use crate::{error::BDKCliError as Error, utils::shorten}; -use bdk_wallet::bitcoin::base64::Engine; -use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD; -use bdk_wallet::bitcoin::consensus::encode::serialize_hex; -use bdk_wallet::bitcoin::{Address, Network, Psbt, Transaction}; -use bdk_wallet::chain::ChainPosition; -use bdk_wallet::{AddressInfo, Balance, LocalOutput}; +use bdk_wallet::bitcoin::{ + Address, Network, Psbt, Transaction, base64::Engine, consensus::encode::serialize_hex, +}; +use bdk_wallet::{AddressInfo, Balance, LocalOutput, chain::ChainPosition}; use cli_table::{Cell, CellStruct, Style, Table, format::Justify}; use serde::Serialize; use serde_json::json; @@ -29,15 +30,16 @@ impl From for AddressResult { /// pretty presentation for address impl FormatOutput for AddressResult { fn to_table(&self) -> Result { - let table = vec![ - vec!["Address".cell().bold(true), self.address.clone().cell()], - vec!["Index".cell().bold(true), self.index.cell()], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - - Ok(format!("{table}")) + simple_table( + vec![ + vec!["Address".cell().bold(true), self.address.clone().cell()], + vec![ + "Index".cell().bold(true), + self.index.cell().justify(Justify::Right), + ], + ], + None, + ) } } @@ -122,37 +124,34 @@ impl From for BalanceResult { impl FormatOutput for BalanceResult { fn to_table(&self) -> Result { - let table = vec![ - vec![ - "Total".cell().bold(true), - self.total.cell().justify(Justify::Right), - ], - vec![ - "Confirmed".cell().bold(true), - self.confirmed.cell().justify(Justify::Right), - ], - vec![ - "Trusted Pending".cell().bold(true), - self.trusted_pending.cell().justify(Justify::Right), - ], - vec![ - "Untrusted Pending".cell().bold(true), - self.untrusted_pending.cell().justify(Justify::Right), - ], + simple_table( vec![ - "Immature".cell().bold(true), - self.immature.cell().justify(Justify::Right), + vec![ + "Total".cell().bold(true), + self.total.cell().justify(Justify::Right), + ], + vec![ + "Confirmed".cell().bold(true), + self.confirmed.cell().justify(Justify::Right), + ], + vec![ + "Trusted Pending".cell().bold(true), + self.trusted_pending.cell().justify(Justify::Right), + ], + vec![ + "Untrusted Pending".cell().bold(true), + self.untrusted_pending.cell().justify(Justify::Right), + ], + vec![ + "Immature".cell().bold(true), + self.immature.cell().justify(Justify::Right), + ], ], - ] - .table() - .title(vec![ - "Status".cell().bold(true), - "Amount (sat)".cell().bold(true), - ]) - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - - Ok(format!("{table}")) + Some(vec![ + "Status".cell().bold(true), + "Amount (sat)".cell().bold(true), + ]), + ) } } @@ -272,32 +271,12 @@ pub struct PsbtResult { } impl PsbtResult { - pub fn new(psbt: &Psbt) -> Self { - Self { - psbt: BASE64_STANDARD.encode(psbt.serialize()), - is_finalized: None, - details: None, - } - } - - pub fn with_details(psbt: &Psbt, verbose: bool) -> Self { - Self { - psbt: BASE64_STANDARD.encode(psbt.serialize()), - is_finalized: None, - details: if verbose { - Some(serde_json::to_value(psbt).unwrap_or(json!({}))) - } else { - None - }, - } - } - - pub fn with_status_and_details(psbt: &Psbt, is_finalized: bool, verbose: bool) -> Self { + pub fn new(psbt: &Psbt, verbose: bool, finalized: Option) -> Self { Self { - psbt: BASE64_STANDARD.encode(psbt.serialize()), - is_finalized: Some(is_finalized), + psbt: bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD.encode(psbt.serialize()), + is_finalized: finalized, details: if verbose { - Some(serde_json::to_value(psbt).unwrap_or(json!({}))) + Some(serde_json::to_value(psbt).unwrap_or_default()) } else { None }, @@ -331,24 +310,25 @@ impl FormatOutput for PsbtResult { } } -/// Policies representation #[derive(Serialize)] -pub struct PoliciesResult { - pub external: serde_json::Value, - pub internal: serde_json::Value, +pub struct RawPsbt { + pub raw_tx: String, } -impl FormatOutput for PoliciesResult { - fn to_table(&self) -> Result { - let ext_str = serde_json::to_string_pretty(&self.external) - .map_err(|e| Error::Generic(e.to_string()))?; - let int_str = serde_json::to_string_pretty(&self.internal) - .map_err(|e| Error::Generic(e.to_string()))?; +impl RawPsbt { + pub fn new(tx: &Transaction) -> Self { + Self { + raw_tx: serialize_hex(tx), + } + } +} - let table = vec![ - vec!["External".cell().bold(true), ext_str.cell()], - vec!["Internal".cell().bold(true), int_str.cell()], - ] +impl FormatOutput for RawPsbt { + fn to_table(&self) -> Result { + let table = vec![vec![ + "Raw Transaction".cell().bold(true), + self.raw_tx.clone().cell(), + ]] .table() .display() .map_err(|e| Error::Generic(e.to_string()))?; @@ -358,54 +338,109 @@ impl FormatOutput for PoliciesResult { } #[derive(Serialize)] -pub struct PublicDescriptorResult { - pub external: String, - pub internal: String, +pub struct KeychainPair { + pub external: T, + pub internal: T, } -impl FormatOutput for PublicDescriptorResult { +// Table formatting for string pairs (used by PublicDescriptor) +impl FormatOutput for KeychainPair { fn to_table(&self) -> Result { - let table = vec![ - vec![ - "External Descriptor".cell().bold(true), - self.external.clone().cell(), - ], - vec![ - "Internal Descriptor".cell().bold(true), - self.internal.clone().cell(), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - - Ok(format!("{table}")) + let rows = vec![ + vec!["External".cell().bold(true), self.external.clone().cell()], + vec!["Internal".cell().bold(true), self.internal.clone().cell()], + ]; + simple_table(rows, None) } } +// Table formatting for JSON value pairs (used by Policies) +impl FormatOutput for KeychainPair { + fn to_table(&self) -> Result { + let ext_str = serde_json::to_string_pretty(&self.external) + .map_err(|e| Error::Generic(e.to_string()))?; + let int_str = serde_json::to_string_pretty(&self.internal) + .map_err(|e| Error::Generic(e.to_string()))?; + + let rows = vec![ + vec!["External".cell().bold(true), ext_str.cell()], + vec!["Internal".cell().bold(true), int_str.cell()], + ]; + simple_table(rows, None) + } +} #[derive(Serialize)] -pub struct RawPsbt { - pub raw_tx: String, +pub struct KeyResult { + pub xprv: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub xpub: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub mnemonic: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, } -impl RawPsbt { - pub fn new(tx: &Transaction) -> Self { - Self { - raw_tx: serialize_hex(tx), +impl FormatOutput for KeyResult { + fn to_table(&self) -> Result { + let mut rows: Vec> = vec![]; + + if let Some(mnemonic) = &self.mnemonic { + rows.push(vec!["Mnemonic".cell().bold(true), mnemonic.clone().cell()]); } + if let Some(xpub) = &self.xpub { + rows.push(vec!["Xpub".cell().bold(true), xpub.clone().cell()]); + } + + rows.push(vec!["Xprv".cell().bold(true), self.xprv.clone().cell()]); + + if let Some(fingerprint) = &self.fingerprint { + rows.push(vec![ + "Fingerprint".cell().bold(true), + fingerprint.clone().cell(), + ]); + } + + simple_table(rows, None) } } -impl FormatOutput for RawPsbt { + +#[derive(Serialize)] +#[serde(transparent)] +pub struct WalletsListResult(pub HashMap); + +impl FormatOutput for WalletsListResult { fn to_table(&self) -> Result { - let table = vec![vec![ - "Raw Transaction".cell().bold(true), - self.raw_tx.clone().cell(), - ]] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; + if self.0.is_empty() { + return Ok("No wallets configured yet.".to_string()); + } - Ok(format!("{table}")) + let mut rows: Vec> = vec![]; + for (name, inner) in &self.0 { + let desc: String = inner.ext_descriptor.chars().take(30).collect(); + let desc_display = if inner.ext_descriptor.len() > 30 { + format!("{}...", desc) + } else { + desc + }; + + rows.push(vec![ + name.clone().cell(), + inner.network.clone().cell(), + desc_display.cell(), + ]); + } + + simple_table( + rows, + Some(vec![ + "Wallet Name".cell().bold(true), + "Network".cell().bold(true), + "External Descriptor".cell().bold(true), + ]), + ) } } diff --git a/src/utils/output.rs b/src/utils/output.rs index 96b8d9ee..5c4360b8 100644 --- a/src/utils/output.rs +++ b/src/utils/output.rs @@ -1,9 +1,10 @@ use crate::error::BDKCliError as Error; +use cli_table::{CellStruct, Table}; use serde::Serialize; -/// A trait for data structures that can be rendered to the CLI. +/// A trait for types that can be presented to the user. pub trait FormatOutput: Serialize { - /// Implement this to define how the data looks as a CLI table. + /// Return a pretty table representation. fn to_table(&self) -> Result; /// Formats the output based on the user's `--pretty` flag. @@ -11,7 +12,23 @@ pub trait FormatOutput: Serialize { if pretty { self.to_table() } else { - serde_json::to_string_pretty(self).map_err(|e| Error::Generic(e.to_string())) + serde_json::to_string_pretty(self) + .map_err(|e| Error::Generic(format!("JSON serialization failed: {e}"))) } } } + +/// Helper for building simple tables +pub fn simple_table( + rows: Vec>, + title: Option>, +) -> Result { + let mut table = rows.table(); + if let Some(title) = title { + table = table.title(title); + } + table + .display() + .map_err(|e| Error::Generic(e.to_string())) + .map(|t| t.to_string()) +} From 2ce28b2544506479acff4cff90b1601c8920cf11 Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Wed, 29 Apr 2026 15:07:54 +0100 Subject: [PATCH 12/20] ref(handlers): rebase bip322 feature - rebase master for bip322 feature - update types to use simple table helper --- src/handlers/offline.rs | 47 +++++++++++++++++ src/handlers/types.rs | 113 +++++++++++++++++++++++++--------------- src/handlers/wallets.rs | 2 +- src/utils/common.rs | 17 ++++++ 4 files changed, 135 insertions(+), 44 deletions(-) diff --git a/src/handlers/offline.rs b/src/handlers/offline.rs index 8ce056b1..b75e6ed7 100644 --- a/src/handlers/offline.rs +++ b/src/handlers/offline.rs @@ -14,6 +14,12 @@ use bdk_wallet::{KeychainKind, SignOptions, Wallet}; use serde_json::json; use std::collections::BTreeMap; use std::str::FromStr; +#[cfg(feature = "bip322")] +use { + crate::utils::{parse_address, parse_signature_format}, + bdk_bip322::{BIP322, MessageProof, MessageVerificationResult}, + bdk_wallet::bitcoin::Address, +}; /// Execute an offline wallet sub-command /// @@ -269,5 +275,46 @@ pub fn handle_offline_wallet_subcommand( let result = PsbtResult::new(&final_psbt, wallet_opts.verbose, None); result.format(pretty) } + #[cfg(feature = "bip322")] + SignMessage { + message, + signature_type, + address, + utxos, + } => { + let address: Address = parse_address(&address)?; + let signature_format = parse_signature_format(&signature_type)?; + + if !wallet.is_mine(address.script_pubkey()) { + return Err(Error::Generic(format!( + "Address {} does not belong to this wallet.", + address + ))); + } + + let proof: MessageProof = + wallet.sign_message(message.as_str(), signature_format, &address, utxos)?; + + Ok(json!({"proof": proof.to_base64()}).to_string()) + } + #[cfg(feature = "bip322")] + VerifyMessage { + proof, + message, + address, + } => { + let address: Address = parse_address(&address)?; + let parsed_proof: MessageProof = MessageProof::from_base64(&proof) + .map_err(|e| Error::Generic(format!("Invalid proof: {e}")))?; + + let is_valid: MessageVerificationResult = + wallet.verify_message(&parsed_proof, &message, &address)?; + + Ok(json!({ + "valid": is_valid.valid, + "proven_amount": is_valid.proven_amount.map(|a| a.to_sat()) // optional field + }) + .to_string()) + } } } diff --git a/src/handlers/types.rs b/src/handlers/types.rs index 94aca123..51a1b56a 100644 --- a/src/handlers/types.rs +++ b/src/handlers/types.rs @@ -7,7 +7,7 @@ use bdk_wallet::bitcoin::{ Address, Network, Psbt, Transaction, base64::Engine, consensus::encode::serialize_hex, }; use bdk_wallet::{AddressInfo, Balance, LocalOutput, chain::ChainPosition}; -use cli_table::{Cell, CellStruct, Style, Table, format::Justify}; +use cli_table::{Cell, CellStruct, Style, format::Justify}; use serde::Serialize; use serde_json::json; @@ -83,20 +83,16 @@ impl FormatOutput for TransactionListResult { ]); } - let table = rows - .table() - .title(vec![ - "Txid".cell().bold(true), - "Version".cell().bold(true), - "Is RBF".cell().bold(true), - "Input Count".cell().bold(true), - "Output Count".cell().bold(true), - "Total Value (sat)".cell().bold(true), - ]) - .display() - .map_err(|e| Error::Generic(e.to_string()))?; + let title = vec![ + "Txid".cell().bold(true), + "Version".cell().bold(true), + "Is RBF".cell().bold(true), + "Input Count".cell().bold(true), + "Output Count".cell().bold(true), + "Total Value (sat)".cell().bold(true), + ]; - Ok(format!("{table}")) + simple_table(rows, Some(title)) } } @@ -240,22 +236,17 @@ impl FormatOutput for UnspentListResult { ]); } - let table = rows - .table() - .title(vec![ - "Outpoint".cell().bold(true), - "Output (sat)".cell().bold(true), - "Output Address".cell().bold(true), - "Keychain".cell().bold(true), - "Is Spent".cell().bold(true), - "Index".cell().bold(true), - "Block Height".cell().bold(true), - "Block Hash".cell().bold(true), - ]) - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - - Ok(format!("{table}")) + let title = vec![ + "Outpoint".cell().bold(true), + "Output (sat)".cell().bold(true), + "Output Address".cell().bold(true), + "Keychain".cell().bold(true), + "Is Spent".cell().bold(true), + "Index".cell().bold(true), + "Block Height".cell().bold(true), + "Block Hash".cell().bold(true), + ]; + simple_table(rows, Some(title)) } } @@ -302,11 +293,7 @@ impl FormatOutput for PsbtResult { ]); } - let table = rows - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) + simple_table(rows, None) } } @@ -325,15 +312,11 @@ impl RawPsbt { impl FormatOutput for RawPsbt { fn to_table(&self) -> Result { - let table = vec![vec![ + let rows = vec![vec![ "Raw Transaction".cell().bold(true), self.raw_tx.clone().cell(), - ]] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - - Ok(format!("{table}")) + ]]; + simple_table(rows, None) } } @@ -407,7 +390,6 @@ impl FormatOutput for KeyResult { } } - #[derive(Serialize)] #[serde(transparent)] pub struct WalletsListResult(pub HashMap); @@ -444,3 +426,48 @@ impl FormatOutput for WalletsListResult { ) } } + + +#[derive(Serialize)] +pub struct DescriptorResult { + #[serde(skip_serializing_if = "Option::is_none")] + pub descriptor: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub multipath_descriptor: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub public_descriptors: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub private_descriptors: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub mnemonic: Option, +} + +impl FormatOutput for DescriptorResult { + fn to_table(&self) -> Result { + let mut rows: Vec> = vec![]; + + if let Some(desc) = &self.descriptor { + rows.push(vec!["Descriptor".cell().bold(true), desc.clone().cell()]); + } + if let Some(desc) = &self.multipath_descriptor { + rows.push(vec!["Multipath Descriptor".cell().bold(true), desc.clone().cell()]); + } + if let Some(pub_desc) = &self.public_descriptors { + rows.push(vec!["External Public".cell().bold(true), pub_desc.external.clone().cell()]); + rows.push(vec!["Internal Public".cell().bold(true), pub_desc.internal.clone().cell()]); + } + if let Some(priv_desc) = &self.private_descriptors { + rows.push(vec!["External Private".cell().bold(true), priv_desc.external.clone().cell()]); + rows.push(vec!["Internal Private".cell().bold(true), priv_desc.internal.clone().cell()]); + } + if let Some(mnemonic) = &self.mnemonic { + rows.push(vec!["Mnemonic".cell().bold(true), mnemonic.clone().cell()]); + } + + simple_table(rows, None) + } +} diff --git a/src/handlers/wallets.rs b/src/handlers/wallets.rs index 9f2b3217..fdfdff2a 100644 --- a/src/handlers/wallets.rs +++ b/src/handlers/wallets.rs @@ -1,6 +1,6 @@ +use crate::error::BDKCliError as Error; use crate::utils::output::FormatOutput; use crate::{config::WalletConfig, handlers::types::WalletsListResult}; -use crate::error::BDKCliError as Error; use std::path::Path; /// Handle the top-level `wallets` command (lists all saved wallets) diff --git a/src/utils/common.rs b/src/utils/common.rs index f50be3af..52afdd4d 100644 --- a/src/utils/common.rs +++ b/src/utils/common.rs @@ -1,4 +1,6 @@ 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(any( @@ -190,3 +192,18 @@ pub fn load_wallet_config( Ok((wallet_opts, network)) } + +/// Function to parse the signature format from a string +#[cfg(feature = "bip322")] +pub(crate) fn parse_signature_format(format_str: &str) -> Result { + match format_str.to_lowercase().as_str() { + "legacy" => Ok(SignatureFormat::Legacy), + "simple" => Ok(SignatureFormat::Simple), + "full" => Ok(SignatureFormat::Full), + "fullproofoffunds" => Ok(SignatureFormat::FullProofOfFunds), + _ => Err(Error::Generic( + "Invalid signature format. Use 'legacy', 'simple', 'full', or 'fullproofoffunds'" + .to_string(), + )), + } +} From 70965f0a1340c2efdd1a7c9602c9322b8f8df7f5 Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Wed, 29 Apr 2026 16:35:19 +0100 Subject: [PATCH 13/20] ref(utils): use types in desc output in utils --- src/handlers/descriptor.rs | 9 +-- src/handlers/types.rs | 83 ++++++++++++--------- src/utils/descriptors.rs | 144 ++++++++++--------------------------- src/utils/output.rs | 9 +-- 4 files changed, 98 insertions(+), 147 deletions(-) diff --git a/src/handlers/descriptor.rs b/src/handlers/descriptor.rs index 44f1954a..9babbc8e 100644 --- a/src/handlers/descriptor.rs +++ b/src/handlers/descriptor.rs @@ -2,17 +2,17 @@ use crate::{ error::BDKCliError as Error, utils::{ descriptors::{ - format_descriptor_output, generate_descriptor_from_mnemonic, - generate_descriptor_with_mnemonic, generate_descriptors, + generate_descriptor_from_mnemonic, generate_descriptor_with_mnemonic, + generate_descriptors, }, is_mnemonic, + output::FormatOutput, }, }; #[cfg(feature = "compiler")] use { crate::handlers::types::DescriptorResult, - crate::utils::output::FormatOutput, bdk_wallet::{ bitcoin::XOnlyPublicKey, miniscript::{ @@ -48,7 +48,7 @@ pub fn handle_descriptor_command( // Generate new mnemonic and descriptors None => generate_descriptor_with_mnemonic(network, &desc_type), }?; - format_descriptor_output(&result, pretty) + result.format(pretty) } /// Handle the miniscript compiler sub-command @@ -99,6 +99,7 @@ pub(crate) fn handle_compile_subcommand( public_descriptors: None, private_descriptors: None, mnemonic: None, + fingerprint: None, }; result.format(pretty) } diff --git a/src/handlers/types.rs b/src/handlers/types.rs index 51a1b56a..dacd5783 100644 --- a/src/handlers/types.rs +++ b/src/handlers/types.rs @@ -70,29 +70,28 @@ pub struct TransactionListResult(pub Vec); impl FormatOutput for TransactionListResult { fn to_table(&self) -> Result { - let mut rows: Vec> = vec![]; - - for tx in &self.0 { - rows.push(vec![ + let rows = self.0.iter().map(|tx| { + vec![ tx.txid.clone().cell(), tx.version_display.clone().cell().justify(Justify::Right), tx.is_rbf.to_string().cell().justify(Justify::Center), tx.input_count.to_string().cell().justify(Justify::Right), tx.output_count.to_string().cell().justify(Justify::Right), tx.total_value.to_string().cell().justify(Justify::Right), - ]); - } + ] + }); - let title = vec![ - "Txid".cell().bold(true), - "Version".cell().bold(true), - "Is RBF".cell().bold(true), - "Input Count".cell().bold(true), - "Output Count".cell().bold(true), - "Total Value (sat)".cell().bold(true), - ]; - - simple_table(rows, Some(title)) + simple_table( + rows, + Some(vec![ + "Txid".cell().bold(true), + "Version".cell().bold(true), + "Is RBF".cell().bold(true), + "Input Count".cell().bold(true), + "Output Count".cell().bold(true), + "Total Value (sat)".cell().bold(true), + ]), + ) } } @@ -400,8 +399,7 @@ impl FormatOutput for WalletsListResult { return Ok("No wallets configured yet.".to_string()); } - let mut rows: Vec> = vec![]; - for (name, inner) in &self.0 { + let rows = self.0.iter().map(|(name, inner)| { let desc: String = inner.ext_descriptor.chars().take(30).collect(); let desc_display = if inner.ext_descriptor.len() > 30 { format!("{}...", desc) @@ -409,12 +407,12 @@ impl FormatOutput for WalletsListResult { desc }; - rows.push(vec![ + vec![ name.clone().cell(), inner.network.clone().cell(), desc_display.cell(), - ]); - } + ] + }); simple_table( rows, @@ -427,47 +425,68 @@ impl FormatOutput for WalletsListResult { } } - #[derive(Serialize)] pub struct DescriptorResult { #[serde(skip_serializing_if = "Option::is_none")] pub descriptor: Option, - + #[serde(skip_serializing_if = "Option::is_none")] pub multipath_descriptor: Option, - + #[serde(skip_serializing_if = "Option::is_none")] pub public_descriptors: Option>, - + #[serde(skip_serializing_if = "Option::is_none")] pub private_descriptors: Option>, - + #[serde(skip_serializing_if = "Option::is_none")] pub mnemonic: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, } impl FormatOutput for DescriptorResult { fn to_table(&self) -> Result { let mut rows: Vec> = vec![]; - + if let Some(desc) = &self.descriptor { rows.push(vec!["Descriptor".cell().bold(true), desc.clone().cell()]); } if let Some(desc) = &self.multipath_descriptor { - rows.push(vec!["Multipath Descriptor".cell().bold(true), desc.clone().cell()]); + rows.push(vec![ + "Multipath Descriptor".cell().bold(true), + desc.clone().cell(), + ]); } if let Some(pub_desc) = &self.public_descriptors { - rows.push(vec!["External Public".cell().bold(true), pub_desc.external.clone().cell()]); - rows.push(vec!["Internal Public".cell().bold(true), pub_desc.internal.clone().cell()]); + rows.push(vec![ + "External Public".cell().bold(true), + pub_desc.external.clone().cell(), + ]); + rows.push(vec![ + "Internal Public".cell().bold(true), + pub_desc.internal.clone().cell(), + ]); } if let Some(priv_desc) = &self.private_descriptors { - rows.push(vec!["External Private".cell().bold(true), priv_desc.external.clone().cell()]); - rows.push(vec!["Internal Private".cell().bold(true), priv_desc.internal.clone().cell()]); + rows.push(vec![ + "External Private".cell().bold(true), + priv_desc.external.clone().cell(), + ]); + rows.push(vec![ + "Internal Private".cell().bold(true), + priv_desc.internal.clone().cell(), + ]); } if let Some(mnemonic) = &self.mnemonic { rows.push(vec!["Mnemonic".cell().bold(true), mnemonic.clone().cell()]); } + if let Some(fp) = &self.fingerprint { + rows.push(vec!["Fingerprint".cell().bold(true), fp.clone().cell()]); + } + simple_table(rows, None) } } diff --git a/src/utils/descriptors.rs b/src/utils/descriptors.rs index 30a0f237..d2289993 100644 --- a/src/utils/descriptors.rs +++ b/src/utils/descriptors.rs @@ -17,12 +17,15 @@ use bdk_wallet::{ }, template::DescriptorTemplate, }; -use cli_table::{Cell, CellStruct, Style, Table}; -use serde_json::{Value, json}; use crate::error::BDKCliError as Error; +use crate::handlers::types::{DescriptorResult, KeychainPair}; -pub fn generate_descriptors(desc_type: &str, key: &str, network: Network) -> Result { +pub fn generate_descriptors( + desc_type: &str, + key: &str, + network: Network, +) -> Result { let is_private = key.starts_with("xprv") || key.starts_with("tprv"); if is_private { @@ -49,7 +52,7 @@ fn generate_private_descriptors( desc_type: &str, key: &str, network: Network, -) -> Result { +) -> Result { use bdk_wallet::template::{Bip44, Bip49, Bip84, Bip86}; let secp = Secp256k1::new(); @@ -85,17 +88,20 @@ fn generate_private_descriptors( let internal_priv = internal_desc.to_string_with_secret(&internal_keymap); let internal_pub = internal_desc.to_string(); - Ok(json!({ - "public_descriptors": { - "external": external_pub, - "internal": internal_pub - }, - "private_descriptors": { - "external": external_priv, - "internal": internal_priv - }, - "fingerprint": fingerprint.to_string() - })) + Ok(DescriptorResult { + descriptor: None, + multipath_descriptor: None, + public_descriptors: Some(KeychainPair { + external: external_pub, + internal: internal_pub, + }), + private_descriptors: Some(KeychainPair { + external: external_priv, + internal: internal_priv, + }), + mnemonic: None, + fingerprint: Some(fingerprint.to_string()), + }) } /// Generate descriptors from public key (xpub/tpub) @@ -103,7 +109,7 @@ pub fn generate_public_descriptors( desc_type: &str, key: &str, derivation_path: &DerivationPath, -) -> Result { +) -> Result { let xpub: Xpub = key.parse()?; let fingerprint = xpub.fingerprint(); @@ -122,14 +128,17 @@ pub fn generate_public_descriptors( let external_pub = build_descriptor("0")?; let internal_pub = build_descriptor("1")?; - - Ok(json!({ - "public_descriptors": { - "external": external_pub, - "internal": internal_pub - }, - "fingerprint": fingerprint.to_string() - })) + Ok(DescriptorResult { + descriptor: None, + multipath_descriptor: None, + public_descriptors: Some(KeychainPair { + external: external_pub, + internal: internal_pub, + }), + private_descriptors: None, + mnemonic: None, + fingerprint: Some(fingerprint.to_string()), + }) } /// Build a descriptor from a public key @@ -158,7 +167,7 @@ pub fn build_public_descriptor( pub fn generate_descriptor_with_mnemonic( network: Network, desc_type: &str, -) -> Result { +) -> Result { let mnemonic: GeneratedKey = Mnemonic::generate((WordCount::Words12, Language::English)).map_err(Error::BIP39Error)?; @@ -166,7 +175,7 @@ pub fn generate_descriptor_with_mnemonic( let xprv = Xpriv::new_master(network, &seed)?; let mut result = generate_descriptors(desc_type, &xprv.to_string(), network)?; - result["mnemonic"] = json!(mnemonic.to_string()); + result.mnemonic = Some(mnemonic.to_string()); Ok(result) } @@ -175,91 +184,12 @@ pub fn generate_descriptor_from_mnemonic( mnemonic_str: &str, network: Network, desc_type: &str, -) -> Result { +) -> Result { let mnemonic = Mnemonic::parse_in(Language::English, mnemonic_str)?; let seed = mnemonic.to_seed(""); let xprv = Xpriv::new_master(network, &seed)?; let mut result = generate_descriptors(desc_type, &xprv.to_string(), network)?; - result["mnemonic"] = json!(mnemonic_str); + result.mnemonic = Some(mnemonic_str.to_string()); Ok(result) } - -pub fn format_descriptor_output(result: &Value, pretty: bool) -> Result { - if !pretty { - return Ok(serde_json::to_string_pretty(result)?); - } - - let mut rows: Vec> = vec![]; - - if let Some(desc_type) = result.get("type") { - rows.push(vec![ - "Type".cell().bold(true), - desc_type.as_str().unwrap_or("N/A").cell(), - ]); - } - - if let Some(finger_print) = result.get("fingerprint") { - rows.push(vec![ - "Fingerprint".cell().bold(true), - finger_print.as_str().unwrap_or("N/A").cell(), - ]); - } - - if let Some(network) = result.get("network") { - rows.push(vec![ - "Network".cell().bold(true), - network.as_str().unwrap_or("N/A").cell(), - ]); - } - if let Some(multipath_desc) = result.get("multipath_descriptor") { - rows.push(vec![ - "Multipart Descriptor".cell().bold(true), - multipath_desc.as_str().unwrap_or("N/A").cell(), - ]); - } - if let Some(pub_descs) = result.get("public_descriptors").and_then(|v| v.as_object()) { - if let Some(ext) = pub_descs.get("external") { - rows.push(vec![ - "External Public".cell().bold(true), - ext.as_str().unwrap_or("N/A").cell(), - ]); - } - if let Some(int) = pub_descs.get("internal") { - rows.push(vec![ - "Internal Public".cell().bold(true), - int.as_str().unwrap_or("N/A").cell(), - ]); - } - } - if let Some(priv_descs) = result - .get("private_descriptors") - .and_then(|v| v.as_object()) - { - if let Some(ext) = priv_descs.get("external") { - rows.push(vec![ - "External Private".cell().bold(true), - ext.as_str().unwrap_or("N/A").cell(), - ]); - } - if let Some(int) = priv_descs.get("internal") { - rows.push(vec![ - "Internal Private".cell().bold(true), - int.as_str().unwrap_or("N/A").cell(), - ]); - } - } - if let Some(mnemonic) = result.get("mnemonic") { - rows.push(vec![ - "Mnemonic".cell().bold(true), - mnemonic.as_str().unwrap_or("N/A").cell(), - ]); - } - - let table = rows - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - - Ok(format!("{table}")) -} diff --git a/src/utils/output.rs b/src/utils/output.rs index 5c4360b8..4ad6e8e2 100644 --- a/src/utils/output.rs +++ b/src/utils/output.rs @@ -19,10 +19,11 @@ pub trait FormatOutput: Serialize { } /// Helper for building simple tables -pub fn simple_table( - rows: Vec>, - title: Option>, -) -> Result { +pub fn simple_table(rows: R, title: Option>) -> Result +where + R: IntoIterator, + C: IntoIterator, +{ let mut table = rows.table(); if let Some(title) = title { table = table.title(title); From a8f387373aae3e0efa1661e167f30ef702b4a1ca Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Thu, 14 May 2026 11:09:22 +0100 Subject: [PATCH 14/20] revert mod names for command and clients --- src/client.rs | 293 +++++++++++++++++++ src/commands.rs | 725 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1018 insertions(+) create mode 100644 src/client.rs create mode 100644 src/commands.rs diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 00000000..2b97330e --- /dev/null +++ b/src/client.rs @@ -0,0 +1,293 @@ +use bdk_bitcoind_rpc::{Emitter, bitcoincore_rpc::RpcApi}; +use bdk_esplora::EsploraAsyncExt; +use bdk_wallet::{ + bitcoin::{Transaction, Txid}, + chain::CanonicalizationParams, +}; +// #[cfg(any( +// feature = "electrum", +// feature = "esplora", +// feature = "rpc", +// feature = "cbf" +// ))] +use { + crate::commands::{ClientType, WalletOpts}, + crate::error::BDKCliError as Error, + bdk_wallet::Wallet, + std::path::PathBuf, +}; + +// #[cfg(feature = "cbf")] +use { + crate::utils::trace_logger, + bdk_kyoto::{BuilderExt, LightClient}, +}; + +// #[cfg(any( +// feature = "electrum", +// feature = "esplora", +// feature = "rpc", +// feature = "cbf" +// ))] +pub(crate) enum BlockchainClient { + // #[cfg(feature = "electrum")] + Electrum { + client: Box>, + batch_size: usize, + }, + // #[cfg(feature = "esplora")] + Esplora { + client: Box, + parallel_requests: usize, + }, + // #[cfg(feature = "rpc")] + RpcClient { + client: Box, + }, + + // #[cfg(feature = "cbf")] + KyotoClient { + client: Box, + }, +} + +impl BlockchainClient { + pub async fn broadcast(&self, tx: Transaction) -> Result { + match self { + // #[cfg(feature = "electrum")] + Self::Electrum { client, .. } => client + .transaction_broadcast(&tx) + .map_err(|e| Error::Generic(e.to_string())), + + // #[cfg(feature = "esplora")] + Self::Esplora { client, .. } => client + .broadcast(&tx) + .await + .map(|()| tx.compute_txid()) + .map_err(|e| Error::Generic(e.to_string())), + + // #[cfg(feature = "rpc")] + Self::RpcClient { client } => client + .send_raw_transaction(&tx) + .map_err(|e| Error::Generic(e.to_string())), + + // #[cfg(feature = "cbf")] + Self::KyotoClient { client } => { + // ... (Kyoto broadcast logic from your online.rs) ... + Ok(tx.compute_txid()) + } + } + } + + pub async fn sync_wallet(&self, wallet: &mut Wallet) -> Result<(), Error> { + // #[cfg(any(feature = "electrum", feature = "esplora"))] + let request = wallet + .start_sync_with_revealed_spks() + .inspect(|item, progress| { + let pc = (100 * progress.consumed()) as f32 / progress.total() as f32; + eprintln!("[ SCANNING {pc:03.0}% ] {item}"); + }); + match self { + // #[cfg(feature = "electrum")] + Self::Electrum { client, batch_size } => { + // Populate the electrum client's transaction cache so it doesn't re-download transaction we + // already have. + client.populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); + + let update = client.sync(request, *batch_size, false)?; + wallet + .apply_update(update) + .map_err(|e| Error::Generic(e.to_string())) + } + // #[cfg(feature = "esplora")] + Self::Esplora { + client, + parallel_requests, + } => { + let update = client + .sync(request, *parallel_requests) + .await + .map_err(|e| *e)?; + wallet + .apply_update(update) + .map_err(|e| Error::Generic(e.to_string())) + } + // #[cfg(feature = "rpc")] + Self::RpcClient { client } => { + let blockchain_info = client.get_blockchain_info()?; + let wallet_cp = wallet.latest_checkpoint(); + + // reload the last 200 blocks in case of a reorg + let emitter_height = wallet_cp.height().saturating_sub(200); + let mut emitter = Emitter::new( + client.as_ref(), + wallet_cp, + emitter_height, + wallet + .tx_graph() + .list_canonical_txs( + wallet.local_chain(), + wallet.local_chain().tip().block_id(), + CanonicalizationParams::default(), + ) + .filter(|tx| tx.chain_position.is_unconfirmed()), + ); + + while let Some(block_event) = emitter.next_block()? { + if block_event.block_height() % 10_000 == 0 { + let percent_done = f64::from(block_event.block_height()) + / f64::from(blockchain_info.headers as u32) + * 100f64; + println!( + "Applying block at height: {}, {:.2}% done.", + block_event.block_height(), + percent_done + ); + } + + wallet.apply_block_connected_to( + &block_event.block, + block_event.block_height(), + block_event.connected_to(), + )?; + } + + let mempool_txs = emitter.mempool()?; + wallet.apply_unconfirmed_txs(mempool_txs.update); + Ok(()) + } + // #[cfg(feature = "cbf")] + Self::KyotoClient { client } => sync_kyoto_client(wallet, client) + .await + .map_err(|e| Error::Generic(e.to_string())), + } + } +} + +/// Handle for the Kyoto client after the node has been started. +/// Contains only the components needed for sync and broadcast operations. +// #[cfg(feature = "cbf")] +pub struct KyotoClientHandle { + pub requester: bdk_kyoto::Requester, + pub update_subscriber: tokio::sync::Mutex, +} + +// #[cfg(any( +// feature = "electrum", +// feature = "esplora", +// feature = "rpc", +// feature = "cbf", +// ))] +/// Create a new blockchain from the wallet configuration options. +pub(crate) fn new_blockchain_client( + wallet_opts: &WalletOpts, + _wallet: &Wallet, + _datadir: PathBuf, +) -> Result { + // #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] + let url = &wallet_opts.url; + let client = match wallet_opts.client_type { + // #[cfg(feature = "electrum")] + ClientType::Electrum => { + let client = bdk_electrum::electrum_client::Client::new(url) + .map(bdk_electrum::BdkElectrumClient::new)?; + BlockchainClient::Electrum { + client: Box::new(client), + batch_size: wallet_opts.batch_size, + } + } + // #[cfg(feature = "esplora")] + ClientType::Esplora => { + let client = bdk_esplora::esplora_client::Builder::new(url).build_async()?; + BlockchainClient::Esplora { + client: Box::new(client), + parallel_requests: wallet_opts.parallel_requests, + } + } + + // #[cfg(feature = "rpc")] + ClientType::Rpc => { + let auth = match &wallet_opts.cookie { + Some(cookie) => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile(cookie.into()), + None => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::UserPass( + wallet_opts.basic_auth.0.clone(), + wallet_opts.basic_auth.1.clone(), + ), + }; + let client = bdk_bitcoind_rpc::bitcoincore_rpc::Client::new(url, auth) + .map_err(|e| Error::Generic(e.to_string()))?; + BlockchainClient::RpcClient { + client: Box::new(client), + } + } + + // #[cfg(feature = "cbf")] + ClientType::Cbf => { + let scan_type = bdk_kyoto::ScanType::Sync; + let builder = bdk_kyoto::builder::Builder::new(_wallet.network()); + + let light_client = builder + .required_peers(wallet_opts.compactfilter_opts.conn_count) + .data_dir(&_datadir) + .build_with_wallet(_wallet, scan_type)?; + + let LightClient { + requester, + info_subscriber, + warning_subscriber, + update_subscriber, + node, + } = light_client; + + 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 }, + ); + + BlockchainClient::KyotoClient { + client: Box::new(KyotoClientHandle { + requester, + update_subscriber: tokio::sync::Mutex::new(update_subscriber), + }), + } + } + }; + Ok(client) +} + +// Handle Kyoto Client sync +// #[cfg(feature = "cbf")] +pub async fn sync_kyoto_client( + wallet: &mut Wallet, + handle: &KyotoClientHandle, +) -> Result<(), Error> { + if !handle.requester.is_running() { + tracing::error!("Kyoto node is not running"); + return Err(Error::Generic("Kyoto node failed to start".to_string())); + } + tracing::info!("Kyoto node is running"); + + let update = handle.update_subscriber.lock().await.update().await?; + tracing::info!("Received update: applying to wallet"); + wallet + .apply_update(update) + .map_err(|e| Error::Generic(format!("Failed to apply update: {e}")))?; + + tracing::info!( + "Chain tip: {}, Transactions: {}, Balance: {}", + wallet.local_chain().tip().height(), + wallet.transactions().count(), + wallet.balance().total().to_sat() + ); + + tracing::info!( + "Sync completed: tx_count={}, balance={}", + wallet.transactions().count(), + wallet.balance().total().to_sat() + ); + + Ok(()) +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 00000000..127d32bd --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,725 @@ +// Copyright (c) 2020-2025 Bitcoin Dev Kit Developers +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! bdk-cli Command structure +//! +//! This module defines all the bdk-cli commands structure. +//! All optional args are defined in the structs below. +//! All subcommands are defined in the below enums. + +#![allow(clippy::large_enum_variant)] +use crate::{ + error::BDKCliError as Error, + handlers::{ + AppCommand, + offline::{ + BalanceCommand, BumpFeeCommand, CombinePsbtCommand, CreateTxCommand, + ExtractPsbtCommand, FinalizePsbtCommand, NewAddressCommand, PoliciesCommand, + PublicDescriptorCommand, SignCommand, SignMessageCommand, TransactionsCommand, + UnspentCommand, UnusedAddressCommand, VerifyMessageCommand, + }, + online::{ + BroadcastCommand, FullScanCommand, ReceivePayjoinCommand, SendPayjoinCommand, + SyncCommand, + }, + }, + utils::output::FormatOutput, +}; +use bdk_wallet::bitcoin::{ + Address, Network, OutPoint, ScriptBuf, + bip32::{DerivationPath, Xpriv}, +}; +use clap::{Args, Parser, Subcommand, ValueEnum, value_parser}; +use clap_complete::Shell; + +use crate::{ + handlers::AppContext, + utils::{parse_address, parse_outpoint, parse_recipient}, +}; + +// #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] +use crate::utils::parse_proxy_auth; + +/// The BDK Command Line Wallet App +/// +/// bdk-cli is a lightweight command line bitcoin wallet, powered by BDK. +/// This app can be used as a playground as well as testing environment to simulate +/// various wallet testing situations. If you are planning to use BDK in your wallet, bdk-cli +/// is also a great intro tool to get familiar with the BDK API. +/// +/// But this is not just any toy. +/// bdk-cli is also a fully functioning Bitcoin wallet with taproot support! +/// +/// For more information checkout +#[derive(PartialEq, Clone, Debug, Parser)] +#[command(version, about, long_about = None)] +pub struct CliOpts { + /// Sets the network. + #[arg( + env = "NETWORK", + short = 'n', + long = "network", + default_value = "testnet", + value_parser = value_parser!(Network) + )] + pub network: Network, + /// Sets the wallet data directory. + /// Default value : ~/.bdk-bitcoin + #[arg(env = "DATADIR", short = 'd', long = "datadir")] + pub datadir: Option, + /// Output results in pretty format (instead of JSON). + #[arg(long = "pretty", global = true)] + pub pretty: bool, + /// Top level cli sub-commands. + #[command(subcommand)] + pub subcommand: CliSubCommand, +} + +/// Top level cli sub-commands. +#[derive(Debug, Subcommand, Clone, PartialEq)] +#[command(rename_all = "snake")] +pub enum CliSubCommand { + /// Wallet operations. + /// + /// bdk-cli wallet operations includes all the basic wallet level tasks. + /// Most commands can be used without connecting to any backend. To use commands that + /// needs backend like `sync` and `broadcast`, compile the binary with specific backend feature + /// and use the configuration options below to configure for that backend. + Wallet { + /// Selects the wallet to use. + #[arg(env = "WALLET_NAME", short = 'w', long = "wallet", required = true)] + wallet: String, + + #[command(subcommand)] + subcommand: WalletSubCommand, + }, + /// Key management operations. + /// + /// Provides basic key operations that are not related to a specific wallet such as generating a + /// new random master extended key or restoring a master extended key from mnemonic words. + /// + /// These sub-commands are **EXPERIMENTAL** and should only be used for testing. Do not use this + /// feature to create keys that secure actual funds on the Bitcoin mainnet. + Key { + #[clap(subcommand)] + subcommand: KeySubCommand, + }, + /// Compile a miniscript policy to an output descriptor. + // #[cfg(feature = "compiler")] + #[clap(long_about = "Miniscript policy compiler")] + Compile { + /// Sets the spending policy to compile. + #[arg(env = "POLICY", required = true, index = 1)] + policy: String, + /// Sets the script type used to embed the compiled policy. + #[arg(env = "TYPE", short = 't', long = "type", default_value = "wsh", value_parser = ["sh","wsh", "sh-wsh", "tr"] + )] + script_type: String, + }, + // #[cfg(feature = "repl")] + /// REPL command loop mode. + /// + /// REPL command loop can be used to make recurring callbacks to an already loaded wallet. + /// This mode is useful for hands on live testing of wallet operations. + Repl { + /// Wallet name for this REPL session + #[arg(env = "WALLET_NAME", short = 'w', long = "wallet", required = true)] + wallet: String, + }, + /// Output Descriptors operations. + /// + /// Generate output descriptors from either extended key (Xprv/Xpub) or mnemonic phrase. + /// This feature is intended for development and testing purposes only. + Descriptor { + /// Descriptor type (script type) + #[arg( + long = "type", + short = 't', + value_parser = ["pkh", "wpkh", "sh", "wsh", "tr"], + default_value = "wsh" + )] + desc_type: String, + /// Optional key: xprv, xpub, or mnemonic phrase + key: Option, + }, + /// List all saved wallet configurations. + Wallets, + /// Generate tab-completion scripts for your shell. + /// + /// The completion script is output on stdout, allowing you to redirect + /// it to a file of your choosing. Where you place the file will depend + /// on your shell and operating system. + /// + /// Here are common setups for supported shells: + /// + /// Bash: + /// + /// Completion files are commonly stored in + /// `~/.local/share/bash-completion/completions` for user-specific commands. + /// Run the commands: + /// + /// $ mkdir -p ~/.local/share/bash-completion/completions + /// $ bdk-cli completions bash > ~/.local/share/bash-completion/completions/bdk-cli + /// + /// Zsh: + /// + /// Completion files are commonly stored in a directory listed in your `fpath`. + /// Run the commands: + /// + /// $ mkdir -p ~/.zfunc + /// $ bdk-cli completions zsh > ~/.zfunc/_bdk-cli + /// + /// Make sure `~/.zfunc` is in your fpath by adding to your `.zshrc`: + /// + /// fpath=(~/.zfunc $fpath) + /// autoload -Uz compinit && compinit + /// + /// Fish: + /// + /// Completion files are commonly stored in + /// `~/.config/fish/completions`. Run the commands: + /// + /// $ mkdir -p ~/.config/fish/completions + /// $ bdk-cli completions fish > ~/.config/fish/completions/bdk-cli.fish + /// + /// PowerShell: + /// + /// $ bdk-cli completions powershell >> $PROFILE + /// + /// Elvish: + /// + /// $ bdk-cli completions elvish >> ~/.elvish/rc.elv + /// + /// After installing the completion script, restart your shell or source + /// the configuration file for the changes to take effect. + #[command(verbatim_doc_comment)] + Completions { + /// Target shell syntax + #[arg(value_enum)] + shell: Shell, + }, +} + +impl CliSubCommand { + pub async fn execute( + &self, + ctx: &mut AppContext<'_>, + cli_opts: &CliOpts, + #[cfg(feature = "repl")] wallet_opts: &mut WalletOpts, + datadir: std::path::PathBuf, + ) -> Result { + match self { + CliSubCommand::Wallet { subcommand, wallet } => { + match subcommand { + WalletSubCommand::OfflineWalletSubCommand(cmd) => { + cmd.execute(ctx, cli_opts.pretty) + } + // #[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf", feature = "rpc"))] + WalletSubCommand::OnlineWalletSubCommand(cmd) => { + cmd.execute(ctx, cli_opts.pretty).await + } + WalletSubCommand::Config { force, wallet_opts } => {} + } + } + + CliSubCommand::Key { subcommand } => subcommand.execute(ctx, cli_opts.pretty), + + CliSubCommand::Descriptor { desc_type, key } => { + cmd.execute(ctx).and_then(|out| out.format(cli_opts.pretty)) + } + + // #[cfg(feature = "compiler")] + CliSubCommand::Compile { + policy, + script_type, + } => cmd.execute(ctx).and_then(|out| out.format(cli_opts.pretty)), + + CliSubCommand::Wallets(cmd) => { + cmd.execute(ctx).and_then(|out| out.format(cli_opts.pretty)) + } + + CliSubCommand::Config(cmd) => { + cmd.execute(ctx).and_then(|out| out.format(cli_opts.pretty)) + } + + CliSubCommand::Completions { shell } => { + use clap::CommandFactory; + clap_complete::generate( + *shell, + &mut CliOpts::command(), + "bdk-cli", + &mut std::io::stdout(), + ); + Ok("".to_string()) + } + } + } +} + +/// Wallet operation subcommands. +#[derive(Debug, Subcommand, Clone, PartialEq)] +pub enum WalletSubCommand { + /// Save wallet configuration to `config.toml`. + Config { + /// Overwrite existing wallet configuration if it exists. + #[arg(short = 'f', long = "force", default_value_t = false)] + force: bool, + + #[command(flatten)] + wallet_opts: WalletOpts, + }, + // #[cfg(any( + // feature = "electrum", + // feature = "esplora", + // feature = "cbf", + // feature = "rpc" + // ))] + #[command(flatten)] + OnlineWalletSubCommand(OnlineWalletSubCommand), + #[command(flatten)] + OfflineWalletSubCommand(OfflineWalletSubCommand), +} + +#[derive(Clone, ValueEnum, Debug, Eq, PartialEq)] +pub enum DatabaseType { + /// Sqlite database + #[cfg(feature = "sqlite")] + Sqlite, + /// Redb database + #[cfg(feature = "redb")] + Redb, +} + +// #[cfg(any( +// feature = "electrum", +// feature = "esplora", +// feature = "rpc", +// feature = "cbf" +// ))] +#[derive(Clone, ValueEnum, Debug, Eq, PartialEq)] +pub enum ClientType { + // #[cfg(feature = "electrum")] + Electrum, + // #[cfg(feature = "esplora")] + Esplora, + // #[cfg(feature = "rpc")] + Rpc, + // #[cfg(feature = "cbf")] + Cbf, +} + +/// Config options wallet operations can take. +#[derive(Debug, Args, Clone, PartialEq, Eq)] +pub struct WalletOpts { + /// Selects the wallet to use. + #[arg(skip)] + pub wallet: Option, + /// Adds verbosity, returns PSBT in JSON format alongside serialized, displays expanded objects. + #[arg(env = "VERBOSE", short = 'v', long = "verbose")] + pub verbose: bool, + /// Sets the descriptor to use for the external addresses. + #[arg(env = "EXT_DESCRIPTOR", short = 'e', long, required = true)] + pub ext_descriptor: String, + /// Sets the descriptor to use for internal/change addresses. + #[arg(env = "INT_DESCRIPTOR", short = 'i', long)] + pub int_descriptor: Option, + // #[cfg(any( + // feature = "electrum", + // feature = "esplora", + // feature = "rpc", + // feature = "cbf" + // ))] + #[arg(env = "CLIENT_TYPE", short = 'c', long, value_enum, required = true)] + pub client_type: ClientType, + #[cfg(any(feature = "sqlite", feature = "redb"))] + #[arg(env = "DATABASE_TYPE", short = 'd', long, value_enum, required = true)] + pub database_type: DatabaseType, + /// Sets the server url. + // #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] + #[arg(env = "SERVER_URL", short = 'u', long, required = true)] + pub url: String, + /// Electrum batch size. + // #[cfg(feature = "electrum")] + #[arg(env = "ELECTRUM_BATCH_SIZE", short = 'b', long, default_value = "10")] + pub batch_size: usize, + /// Esplora parallel requests. + // #[cfg(feature = "esplora")] + #[arg( + env = "ESPLORA_PARALLEL_REQUESTS", + short = 'p', + long, + default_value = "5" + )] + pub parallel_requests: usize, + // #[cfg(feature = "rpc")] + /// Sets the rpc basic authentication. + #[arg( + env = "USER:PASSWD", + short = 'a', + long, + value_parser = parse_proxy_auth, + default_value = "user:password", + )] + pub basic_auth: (String, String), + // #[cfg(feature = "rpc")] + /// Sets an optional cookie authentication. + #[arg(env = "COOKIE")] + pub cookie: Option, + // #[cfg(feature = "cbf")] + #[clap(flatten)] + pub compactfilter_opts: CompactFilterOpts, +} + +/// Options to configure a SOCKS5 proxy for a blockchain client connection. +// #[cfg(any(feature = "electrum", feature = "esplora"))] +#[derive(Debug, Args, Clone, PartialEq, Eq)] +pub struct ProxyOpts { + /// Sets the SOCKS5 proxy for a blockchain client. + #[arg(env = "PROXY_ADDRS:PORT", long = "proxy", short = 'p')] + pub proxy: Option, + + /// Sets the SOCKS5 proxy credential. + #[arg(env = "PROXY_USER:PASSWD", long="proxy_auth", short='a', value_parser = parse_proxy_auth)] + pub proxy_auth: Option<(String, String)>, + + /// Sets the SOCKS5 proxy retries for the blockchain client. + #[arg( + env = "PROXY_RETRIES", + short = 'r', + long = "retries", + default_value = "5" + )] + pub retries: u8, + + /// Sets the SOCKS5 proxy timeout for the blockchain client. + #[arg(env = "PROXY_TIMEOUT", short = 't', long = "timeout")] + pub timeout: Option, +} + +/// Options to configure a BIP157 Compact Filter backend. +// #[cfg(feature = "cbf")] +#[derive(Debug, Args, Clone, PartialEq, Eq)] +pub struct CompactFilterOpts { + /// Sets the number of parallel node connections. + #[clap(name = "CONNECTIONS", long = "cbf-conn-count", default_value = "2", value_parser = value_parser!(u8).range(1..=15))] + pub conn_count: u8, +} + +/// Wallet subcommands that can be issued without a blockchain backend. +#[derive(Debug, Subcommand, Clone, PartialEq)] +#[command(rename_all = "snake")] +pub enum OfflineWalletSubCommand { + /// Get a new external address. + NewAddress(NewAddressCommand), + /// Get the first unused external address. + UnusedAddress(UnusedAddressCommand), + /// Lists the available spendable UTXOs. + Unspent(UnspentCommand), + /// Lists all the incoming and outgoing transactions of the wallet. + Transactions(TransactionsCommand), + /// Returns the current wallet balance. + Balance(BalanceCommand), + /// Creates a new unsigned transaction. + CreateTx(CreateTxCommand), + // CreateTx { + // /// Adds a recipient to the transaction. + // // Clap Doesn't support complex vector parsing https://github.com/clap-rs/clap/issues/1704. + // // Address and amount parsing is done at run time in handler function. + // #[arg(env = "ADDRESS:SAT", long = "to", required = true, value_parser = parse_recipient)] + // recipients: Vec<(ScriptBuf, u64)>, + // /// Sends all the funds (or all the selected utxos). Requires only one recipient with value 0. + // #[arg(long = "send_all", short = 'a')] + // send_all: bool, + // /// Enables Replace-By-Fee (BIP125). + // #[arg(long = "enable_rbf", short = 'r', default_value_t = true)] + // enable_rbf: bool, + // /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. + // #[arg(long = "offline_signer")] + // offline_signer: bool, + // /// Selects which utxos *must* be spent. + // #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] + // utxos: Option>, + // /// Marks a utxo as unspendable. + // #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] + // unspendable: Option>, + // /// Fee rate to use in sat/vbyte. + // #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")] + // fee_rate: Option, + // /// Selects which policy should be used to satisfy the external descriptor. + // #[arg(env = "EXT_POLICY", long = "external_policy")] + // external_policy: Option, + // /// Selects which policy should be used to satisfy the internal descriptor. + // #[arg(env = "INT_POLICY", long = "internal_policy")] + // internal_policy: Option, + // /// Optionally create an OP_RETURN output containing given String in utf8 encoding (max 80 bytes) + // #[arg( + // env = "ADD_STRING", + // long = "add_string", + // short = 's', + // conflicts_with = "add_data" + // )] + // add_string: Option, + // /// Optionally create an OP_RETURN output containing given base64 encoded String. (max 80 bytes) + // #[arg( + // env = "ADD_DATA", + // long = "add_data", + // short = 'o', + // conflicts_with = "add_string" + // )] + // add_data: Option, //base 64 econding + // }, + /// Bumps the fees of an RBF transaction. + BumpFee(BumpFeeCommand), + // BumpFee { + // /// TXID of the transaction to update. + // #[arg(env = "TXID", long = "txid")] + // txid: String, + // /// Allows the wallet to reduce the amount to the specified address in order to increase fees. + // #[arg(env = "SHRINK_ADDRESS", long = "shrink", value_parser = parse_address)] + // shrink_address: Option
, + // /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. + // #[arg(long = "offline_signer")] + // offline_signer: bool, + // /// Selects which utxos *must* be added to the tx. Unconfirmed utxos cannot be used. + // #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] + // utxos: Option>, + // /// Marks an utxo as unspendable, in case more inputs are needed to cover the extra fees. + // #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] + // unspendable: Option>, + // /// The new targeted fee rate in sat/vbyte. + // #[arg( + // env = "SATS_VBYTE", + // short = 'f', + // long = "fee_rate", + // default_value = "1.0" + // )] + // fee_rate: f32, + // }, + /// Returns the available spending policies for the descriptor. + Policies(PoliciesCommand), + /// Returns the public version of the wallet's descriptor(s). + PublicDescriptor(PublicDescriptorCommand), + /// Signs and tries to finalize a PSBT. + Sign(SignCommand), + // Sign { + // /// Sets the PSBT to sign. + // #[arg(env = "BASE64_PSBT")] + // psbt: String, + // /// Assume the blockchain has reached a specific height. This affects the transaction finalization, if there are timelocks in the descriptor. + // #[arg(env = "HEIGHT", long = "assume_height")] + // assume_height: Option, + // /// Whether the signer should trust the witness_utxo, if the non_witness_utxo hasn’t been provided. + // #[arg(env = "WITNESS", long = "trust_witness_utxo")] + // trust_witness_utxo: Option, + // }, + /// Extracts a raw transaction from a PSBT. + // ExtractPsbt { + // /// Sets the PSBT to extract + // #[arg(env = "BASE64_PSBT")] + // psbt: String, + // }, + ExtractPsbt(ExtractPsbtCommand), + /// Finalizes a PSBT. + // FinalizePsbt { + // /// Sets the PSBT to finalize. + // #[arg(env = "BASE64_PSBT")] + // psbt: String, + // /// Assume the blockchain has reached a specific height. + // #[arg(env = "HEIGHT", long = "assume_height")] + // assume_height: Option, + // /// Whether the signer should trust the witness_utxo, if the non_witness_utxo hasn’t been provided. + // #[arg(env = "WITNESS", long = "trust_witness_utxo")] + // trust_witness_utxo: Option, + // }, + FinalizePsbt(FinalizePsbtCommand), + /// Combines multiple PSBTs into one. + // CombinePsbt { + // /// Add one PSBT to combine. This option can be repeated multiple times, one for each PSBT. + // #[arg(env = "BASE64_PSBT", required = true)] + // psbt: Vec, + // }, + CombinePsbt(CombinePsbtCommand), + /// Sign a message using BIP322 + // #[cfg(feature = "bip322")] + // SignMessage { + // /// The message to sign + // #[arg(long)] + // message: String, + // /// The signature format (e.g., Legacy, Simple, Full) + // #[arg(long, default_value = "simple")] + // signature_type: String, + // /// Address to sign + // #[arg(long)] + // address: String, + // /// Optional list of specific UTXOs for proof-of-funds (only for `FullWithProofOfFunds`) + // #[arg(long)] + // utxos: Option>, + // }, + SignMessage(SignMessageCommand), + /// Verify a BIP322 signature + // #[cfg(feature = "bip322")] + // VerifyMessage { + // /// The signature proof to verify + // #[arg(long)] + // proof: String, + // /// The message that was signed + // #[arg(long)] + // message: String, + // /// The address associated with the signature + // #[arg(long)] + // address: String, + // }, + VerifyMessage(VerifyMessageCommand), +} + +/// Wallet subcommands that needs a blockchain backend. +#[derive(Debug, Subcommand, Clone, PartialEq, Eq)] +#[command(rename_all = "snake")] +// #[cfg(any( +// feature = "electrum", +// feature = "esplora", +// feature = "cbf", +// feature = "rpc" +// ))] +pub enum OnlineWalletSubCommand { + /// Full Scan with the chosen blockchain server. + // FullScan { + // /// Stop searching addresses for transactions after finding an unused gap of this length. + // #[arg(env = "STOP_GAP", long = "scan-stop-gap", default_value = "20")] + // stop_gap: usize, + // }, + FullScan(FullScanCommand), + /// Syncs with the chosen blockchain server. + Sync(SyncCommand), + /// Broadcasts a transaction to the network. Takes either a raw transaction or a PSBT to extract. + // Broadcast { + // /// Sets the PSBT to sign. + // #[arg( + // env = "BASE64_PSBT", + // long = "psbt", + // required_unless_present = "tx", + // conflicts_with = "tx" + // )] + // psbt: Option, + // /// Sets the raw transaction to broadcast. + // #[arg( + // env = "RAWTX", + // long = "tx", + // required_unless_present = "psbt", + // conflicts_with = "psbt" + // )] + // tx: Option, + // }, + Broadcast(BroadcastCommand), + /// Generates a Payjoin receive URI and processes the sender's Payjoin proposal. + // ReceivePayjoin { + // /// Amount to be received in sats. + // #[arg(env = "PAYJOIN_AMOUNT", long = "amount", required = true)] + // amount: u64, + // /// Payjoin directory which will be used to store the PSBTs which are pending action + // /// from one of the parties. + // #[arg(env = "PAYJOIN_DIRECTORY", long = "directory", required = true)] + // directory: String, + // /// URL of the Payjoin OHTTP relay. Can be repeated multiple times to attempt the + // /// operation with multiple relays for redundancy. + // #[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)] + // ohttp_relay: Vec, + // /// Maximum effective fee rate the receiver is willing to pay for their own input/output contributions. + // #[arg(env = "PAYJOIN_RECEIVER_MAX_FEE_RATE", long = "max_fee_rate")] + // max_fee_rate: Option, + // }, + ReceivePayjoin(ReceivePayjoinCommand), + /// Sends an original PSBT to a BIP 21 URI and broadcasts the returned Payjoin PSBT. + // SendPayjoin { + // /// BIP 21 URI for the Payjoin. + // #[arg(env = "PAYJOIN_URI", long = "uri", required = true)] + // uri: String, + // /// URL of the Payjoin OHTTP relay. Can be repeated multiple times to attempt the + // /// operation with multiple relays for redundancy. + // #[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)] + // ohttp_relay: Vec, + // /// Fee rate to use in sat/vbyte. + // #[arg( + // env = "PAYJOIN_SENDER_FEE_RATE", + // short = 'f', + // long = "fee_rate", + // required = true + // )] + // fee_rate: u64, + // }, + SendPayjoin(SendPayjoinCommand), +} + +/// Subcommands for Key operations. +#[derive(Debug, Subcommand, Clone, PartialEq, Eq)] +pub enum KeySubCommand { + /// Generates new random seed mnemonic phrase and corresponding master extended key. + Generate { + /// Entropy level based on number of random seed mnemonic words. + #[arg( + env = "WORD_COUNT", + short = 'e', + long = "entropy", + default_value = "12" + )] + word_count: usize, + /// Seed password. + #[arg(env = "PASSWORD", short = 'p', long = "password")] + password: Option, + }, + /// Restore a master extended key from seed backup mnemonic words. + Restore { + /// Seed mnemonic words, must be quoted (eg. "word1 word2 ..."). + #[arg(env = "MNEMONIC", short = 'm', long = "mnemonic")] + mnemonic: String, + /// Seed password. + #[arg(env = "PASSWORD", short = 'p', long = "password")] + password: Option, + }, + /// Derive a child key pair from a master extended key and a derivation path string (eg. "m/84'/1'/0'/0" or "m/84h/1h/0h/0"). + Derive { + /// Extended private key to derive from. + #[arg(env = "XPRV", short = 'x', long = "xprv")] + xprv: Xpriv, + /// Path to use to derive extended public key from extended private key. + #[arg(env = "PATH", short = 'p', long = "path")] + path: DerivationPath, + }, +} + +/// Subcommands available in REPL mode. +#[cfg(any(feature = "repl", target_arch = "wasm32"))] +#[derive(Debug, Parser)] +#[command(rename_all = "lower", multicall = true)] +pub enum ReplSubCommand { + /// Execute wallet commands. + Wallet { + #[command(subcommand)] + subcommand: WalletSubCommand, + }, + /// Execute key commands. + Key { + #[command(subcommand)] + subcommand: KeySubCommand, + }, + /// Generate descriptors + Descriptor { + /// Descriptor type (script type). + #[arg( + long = "type", + short = 't', + value_parser = ["pkh", "wpkh", "sh", "wsh", "tr"], + default_value = "wsh" + )] + desc_type: String, + /// Optional key: xprv, xpub, or mnemonic phrase + key: Option, + }, + /// Exit REPL loop. + Exit, +} From 345adc9066983d6124a57df268451f5d63657b4d Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Thu, 14 May 2026 11:13:27 +0100 Subject: [PATCH 15/20] update namespace and update types --- src/backend/mod.rs | 172 --------- src/client.rs | 82 ++-- src/commands/mod.rs | 637 ------------------------------- src/{config/mod.rs => config.rs} | 0 src/error.rs | 2 +- src/handlers/types.rs | 81 ++++ src/handlers/wallets.rs | 15 - src/payjoin/mod.rs | 25 +- src/utils/common.rs | 2 +- 9 files changed, 141 insertions(+), 875 deletions(-) delete mode 100644 src/backend/mod.rs delete mode 100644 src/commands/mod.rs rename src/{config/mod.rs => config.rs} (100%) delete mode 100644 src/handlers/wallets.rs diff --git a/src/backend/mod.rs b/src/backend/mod.rs deleted file mode 100644 index a4695b76..00000000 --- a/src/backend/mod.rs +++ /dev/null @@ -1,172 +0,0 @@ -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" -))] -use { - crate::commands::{ClientType, WalletOpts}, - crate::error::BDKCliError as Error, - bdk_wallet::Wallet, - std::path::PathBuf, -}; - -#[cfg(feature = "cbf")] -use { - crate::utils::trace_logger, - bdk_kyoto::{BuilderExt, LightClient}, -}; - -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" -))] -pub(crate) enum BlockchainClient { - #[cfg(feature = "electrum")] - Electrum { - client: Box>, - batch_size: usize, - }, - #[cfg(feature = "esplora")] - Esplora { - client: Box, - parallel_requests: usize, - }, - #[cfg(feature = "rpc")] - RpcClient { - client: Box, - }, - - #[cfg(feature = "cbf")] - KyotoClient { client: Box }, -} - -/// Handle for the Kyoto client after the node has been started. -/// Contains only the components needed for sync and broadcast operations. -#[cfg(feature = "cbf")] -pub struct KyotoClientHandle { - pub requester: bdk_kyoto::Requester, - pub update_subscriber: tokio::sync::Mutex, -} - -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf", -))] -/// Create a new blockchain from the wallet configuration options. -pub(crate) fn new_blockchain_client( - wallet_opts: &WalletOpts, - _wallet: &Wallet, - _datadir: PathBuf, -) -> Result { - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] - let url = &wallet_opts.url; - let client = match wallet_opts.client_type { - #[cfg(feature = "electrum")] - ClientType::Electrum => { - let client = bdk_electrum::electrum_client::Client::new(url) - .map(bdk_electrum::BdkElectrumClient::new)?; - BlockchainClient::Electrum { - client: Box::new(client), - batch_size: wallet_opts.batch_size, - } - } - #[cfg(feature = "esplora")] - ClientType::Esplora => { - let client = bdk_esplora::esplora_client::Builder::new(url).build_async()?; - BlockchainClient::Esplora { - client: Box::new(client), - parallel_requests: wallet_opts.parallel_requests, - } - } - - #[cfg(feature = "rpc")] - ClientType::Rpc => { - let auth = match &wallet_opts.cookie { - Some(cookie) => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile(cookie.into()), - None => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::UserPass( - wallet_opts.basic_auth.0.clone(), - wallet_opts.basic_auth.1.clone(), - ), - }; - let client = bdk_bitcoind_rpc::bitcoincore_rpc::Client::new(url, auth) - .map_err(|e| Error::Generic(e.to_string()))?; - BlockchainClient::RpcClient { - client: Box::new(client), - } - } - - #[cfg(feature = "cbf")] - ClientType::Cbf => { - let scan_type = bdk_kyoto::ScanType::Sync; - let builder = bdk_kyoto::builder::Builder::new(_wallet.network()); - - let light_client = builder - .required_peers(wallet_opts.compactfilter_opts.conn_count) - .data_dir(&_datadir) - .build_with_wallet(_wallet, scan_type)?; - - let LightClient { - requester, - info_subscriber, - warning_subscriber, - update_subscriber, - node, - } = light_client; - - 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 }, - ); - - BlockchainClient::KyotoClient { - client: Box::new(KyotoClientHandle { - requester, - update_subscriber: tokio::sync::Mutex::new(update_subscriber), - }), - } - } - }; - Ok(client) -} - -// Handle Kyoto Client sync -#[cfg(feature = "cbf")] -pub async fn sync_kyoto_client( - wallet: &mut Wallet, - handle: &KyotoClientHandle, -) -> Result<(), Error> { - if !handle.requester.is_running() { - tracing::error!("Kyoto node is not running"); - return Err(Error::Generic("Kyoto node failed to start".to_string())); - } - tracing::info!("Kyoto node is running"); - - let update = handle.update_subscriber.lock().await.update().await?; - tracing::info!("Received update: applying to wallet"); - wallet - .apply_update(update) - .map_err(|e| Error::Generic(format!("Failed to apply update: {e}")))?; - - tracing::info!( - "Chain tip: {}, Transactions: {}, Balance: {}", - wallet.local_chain().tip().height(), - wallet.transactions().count(), - wallet.balance().total().to_sat() - ); - - tracing::info!( - "Sync completed: tx_count={}, balance={}", - wallet.transactions().count(), - wallet.balance().total().to_sat() - ); - - Ok(()) -} diff --git a/src/client.rs b/src/client.rs index 2b97330e..6c024411 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,77 +1,87 @@ -use bdk_bitcoind_rpc::{Emitter, bitcoincore_rpc::RpcApi}; +use crate::error::BDKCliError as Error; +#[cfg(feature = "esplora")] use bdk_esplora::EsploraAsyncExt; use bdk_wallet::{ bitcoin::{Transaction, Txid}, chain::CanonicalizationParams, }; -// #[cfg(any( -// feature = "electrum", -// feature = "esplora", -// feature = "rpc", -// feature = "cbf" -// ))] +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] use { crate::commands::{ClientType, WalletOpts}, - crate::error::BDKCliError as Error, + bdk_wallet::Wallet, std::path::PathBuf, }; +#[cfg(feature = "rpc")] +use bdk_bitcoind_rpc::{Emitter, bitcoincore_rpc::RpcApi}; -// #[cfg(feature = "cbf")] + +#[cfg(feature = "cbf")] use { crate::utils::trace_logger, bdk_kyoto::{BuilderExt, LightClient}, }; -// #[cfg(any( -// feature = "electrum", -// feature = "esplora", -// feature = "rpc", -// feature = "cbf" -// ))] +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] pub(crate) enum BlockchainClient { - // #[cfg(feature = "electrum")] + #[cfg(feature = "electrum")] Electrum { client: Box>, batch_size: usize, }, - // #[cfg(feature = "esplora")] + #[cfg(feature = "esplora")] Esplora { client: Box, parallel_requests: usize, }, - // #[cfg(feature = "rpc")] + #[cfg(feature = "rpc")] RpcClient { client: Box, }, - // #[cfg(feature = "cbf")] + #[cfg(feature = "cbf")] KyotoClient { client: Box, }, } +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] impl BlockchainClient { pub async fn broadcast(&self, tx: Transaction) -> Result { match self { - // #[cfg(feature = "electrum")] + #[cfg(feature = "electrum")] Self::Electrum { client, .. } => client .transaction_broadcast(&tx) .map_err(|e| Error::Generic(e.to_string())), - // #[cfg(feature = "esplora")] + #[cfg(feature = "esplora")] Self::Esplora { client, .. } => client .broadcast(&tx) .await .map(|()| tx.compute_txid()) .map_err(|e| Error::Generic(e.to_string())), - // #[cfg(feature = "rpc")] + #[cfg(feature = "rpc")] Self::RpcClient { client } => client .send_raw_transaction(&tx) .map_err(|e| Error::Generic(e.to_string())), - // #[cfg(feature = "cbf")] + #[cfg(feature = "cbf")] Self::KyotoClient { client } => { // ... (Kyoto broadcast logic from your online.rs) ... Ok(tx.compute_txid()) @@ -166,28 +176,28 @@ impl BlockchainClient { /// Handle for the Kyoto client after the node has been started. /// Contains only the components needed for sync and broadcast operations. -// #[cfg(feature = "cbf")] +#[cfg(feature = "cbf")] pub struct KyotoClientHandle { pub requester: bdk_kyoto::Requester, pub update_subscriber: tokio::sync::Mutex, } -// #[cfg(any( -// feature = "electrum", -// feature = "esplora", -// feature = "rpc", -// feature = "cbf", -// ))] +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf", +))] /// Create a new blockchain from the wallet configuration options. pub(crate) fn new_blockchain_client( wallet_opts: &WalletOpts, _wallet: &Wallet, _datadir: PathBuf, ) -> Result { - // #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] let url = &wallet_opts.url; let client = match wallet_opts.client_type { - // #[cfg(feature = "electrum")] + #[cfg(feature = "electrum")] ClientType::Electrum => { let client = bdk_electrum::electrum_client::Client::new(url) .map(bdk_electrum::BdkElectrumClient::new)?; @@ -196,7 +206,7 @@ pub(crate) fn new_blockchain_client( batch_size: wallet_opts.batch_size, } } - // #[cfg(feature = "esplora")] + #[cfg(feature = "esplora")] ClientType::Esplora => { let client = bdk_esplora::esplora_client::Builder::new(url).build_async()?; BlockchainClient::Esplora { @@ -205,7 +215,7 @@ pub(crate) fn new_blockchain_client( } } - // #[cfg(feature = "rpc")] + #[cfg(feature = "rpc")] ClientType::Rpc => { let auth = match &wallet_opts.cookie { Some(cookie) => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile(cookie.into()), @@ -221,7 +231,7 @@ pub(crate) fn new_blockchain_client( } } - // #[cfg(feature = "cbf")] + #[cfg(feature = "cbf")] ClientType::Cbf => { let scan_type = bdk_kyoto::ScanType::Sync; let builder = bdk_kyoto::builder::Builder::new(_wallet.network()); @@ -259,7 +269,7 @@ pub(crate) fn new_blockchain_client( } // Handle Kyoto Client sync -// #[cfg(feature = "cbf")] +#[cfg(feature = "cbf")] pub async fn sync_kyoto_client( wallet: &mut Wallet, handle: &KyotoClientHandle, diff --git a/src/commands/mod.rs b/src/commands/mod.rs deleted file mode 100644 index 70fb8a0e..00000000 --- a/src/commands/mod.rs +++ /dev/null @@ -1,637 +0,0 @@ -// Copyright (c) 2020-2025 Bitcoin Dev Kit Developers -// -// This file is licensed under the Apache License, Version 2.0 or the MIT license -// , at your option. -// You may not use this file except in accordance with one or both of these -// licenses. - -//! bdk-cli Command structure -//! -//! This module defines all the bdk-cli commands structure. -//! All optional args are defined in the structs below. -//! All subcommands are defined in the below enums. - -#![allow(clippy::large_enum_variant)] -use bdk_wallet::bitcoin::{ - Address, Network, OutPoint, ScriptBuf, - bip32::{DerivationPath, Xpriv}, -}; -use clap::{Args, Parser, Subcommand, ValueEnum, value_parser}; -use clap_complete::Shell; - -use crate::utils::{parse_address, parse_outpoint, parse_recipient}; - -#[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] -use crate::utils::parse_proxy_auth; - -/// The BDK Command Line Wallet App -/// -/// bdk-cli is a lightweight command line bitcoin wallet, powered by BDK. -/// This app can be used as a playground as well as testing environment to simulate -/// various wallet testing situations. If you are planning to use BDK in your wallet, bdk-cli -/// is also a great intro tool to get familiar with the BDK API. -/// -/// But this is not just any toy. -/// bdk-cli is also a fully functioning Bitcoin wallet with taproot support! -/// -/// For more information checkout -#[derive(PartialEq, Clone, Debug, Parser)] -#[command(version, about, long_about = None)] -pub struct CliOpts { - /// Sets the network. - #[arg( - env = "NETWORK", - short = 'n', - long = "network", - default_value = "testnet", - value_parser = value_parser!(Network) - )] - pub network: Network, - /// Sets the wallet data directory. - /// Default value : ~/.bdk-bitcoin - #[arg(env = "DATADIR", short = 'd', long = "datadir")] - pub datadir: Option, - /// Output results in pretty format (instead of JSON). - #[arg(long = "pretty", global = true)] - pub pretty: bool, - /// Top level cli sub-commands. - #[command(subcommand)] - pub subcommand: CliSubCommand, -} - -/// Top level cli sub-commands. -#[derive(Debug, Subcommand, Clone, PartialEq)] -#[command(rename_all = "snake")] -pub enum CliSubCommand { - /// Wallet operations. - /// - /// bdk-cli wallet operations includes all the basic wallet level tasks. - /// Most commands can be used without connecting to any backend. To use commands that - /// needs backend like `sync` and `broadcast`, compile the binary with specific backend feature - /// and use the configuration options below to configure for that backend. - Wallet { - /// Selects the wallet to use. - #[arg(env = "WALLET_NAME", short = 'w', long = "wallet", required = true)] - wallet: String, - - #[command(subcommand)] - subcommand: WalletSubCommand, - }, - /// Key management operations. - /// - /// Provides basic key operations that are not related to a specific wallet such as generating a - /// new random master extended key or restoring a master extended key from mnemonic words. - /// - /// These sub-commands are **EXPERIMENTAL** and should only be used for testing. Do not use this - /// feature to create keys that secure actual funds on the Bitcoin mainnet. - Key { - #[clap(subcommand)] - subcommand: KeySubCommand, - }, - /// Compile a miniscript policy to an output descriptor. - #[cfg(feature = "compiler")] - #[clap(long_about = "Miniscript policy compiler")] - Compile { - /// Sets the spending policy to compile. - #[arg(env = "POLICY", required = true, index = 1)] - policy: String, - /// Sets the script type used to embed the compiled policy. - #[arg(env = "TYPE", short = 't', long = "type", default_value = "wsh", value_parser = ["sh","wsh", "sh-wsh", "tr"] - )] - script_type: String, - }, - #[cfg(feature = "repl")] - /// REPL command loop mode. - /// - /// REPL command loop can be used to make recurring callbacks to an already loaded wallet. - /// This mode is useful for hands on live testing of wallet operations. - Repl { - /// Wallet name for this REPL session - #[arg(env = "WALLET_NAME", short = 'w', long = "wallet", required = true)] - wallet: String, - }, - /// Output Descriptors operations. - /// - /// Generate output descriptors from either extended key (Xprv/Xpub) or mnemonic phrase. - /// This feature is intended for development and testing purposes only. - Descriptor { - /// Descriptor type (script type) - #[arg( - long = "type", - short = 't', - value_parser = ["pkh", "wpkh", "sh", "wsh", "tr"], - default_value = "wsh" - )] - desc_type: String, - /// Optional key: xprv, xpub, or mnemonic phrase - key: Option, - }, - /// List all saved wallet configurations. - Wallets, - /// Generate tab-completion scripts for your shell. - /// - /// The completion script is output on stdout, allowing you to redirect - /// it to a file of your choosing. Where you place the file will depend - /// on your shell and operating system. - /// - /// Here are common setups for supported shells: - /// - /// Bash: - /// - /// Completion files are commonly stored in - /// `~/.local/share/bash-completion/completions` for user-specific commands. - /// Run the commands: - /// - /// $ mkdir -p ~/.local/share/bash-completion/completions - /// $ bdk-cli completions bash > ~/.local/share/bash-completion/completions/bdk-cli - /// - /// Zsh: - /// - /// Completion files are commonly stored in a directory listed in your `fpath`. - /// Run the commands: - /// - /// $ mkdir -p ~/.zfunc - /// $ bdk-cli completions zsh > ~/.zfunc/_bdk-cli - /// - /// Make sure `~/.zfunc` is in your fpath by adding to your `.zshrc`: - /// - /// fpath=(~/.zfunc $fpath) - /// autoload -Uz compinit && compinit - /// - /// Fish: - /// - /// Completion files are commonly stored in - /// `~/.config/fish/completions`. Run the commands: - /// - /// $ mkdir -p ~/.config/fish/completions - /// $ bdk-cli completions fish > ~/.config/fish/completions/bdk-cli.fish - /// - /// PowerShell: - /// - /// $ bdk-cli completions powershell >> $PROFILE - /// - /// Elvish: - /// - /// $ bdk-cli completions elvish >> ~/.elvish/rc.elv - /// - /// After installing the completion script, restart your shell or source - /// the configuration file for the changes to take effect. - #[command(verbatim_doc_comment)] - Completions { - /// Target shell syntax - #[arg(value_enum)] - shell: Shell, - }, -} - -/// Wallet operation subcommands. -#[derive(Debug, Subcommand, Clone, PartialEq)] -pub enum WalletSubCommand { - /// Save wallet configuration to `config.toml`. - Config { - /// Overwrite existing wallet configuration if it exists. - #[arg(short = 'f', long = "force", default_value_t = false)] - force: bool, - - #[command(flatten)] - wallet_opts: WalletOpts, - }, - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" - ))] - #[command(flatten)] - OnlineWalletSubCommand(OnlineWalletSubCommand), - #[command(flatten)] - OfflineWalletSubCommand(OfflineWalletSubCommand), -} - -#[derive(Clone, ValueEnum, Debug, Eq, PartialEq)] -pub enum DatabaseType { - /// Sqlite database - #[cfg(feature = "sqlite")] - Sqlite, - /// Redb database - #[cfg(feature = "redb")] - Redb, -} - -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" -))] -#[derive(Clone, ValueEnum, Debug, Eq, PartialEq)] -pub enum ClientType { - #[cfg(feature = "electrum")] - Electrum, - #[cfg(feature = "esplora")] - Esplora, - #[cfg(feature = "rpc")] - Rpc, - #[cfg(feature = "cbf")] - Cbf, -} - -/// Config options wallet operations can take. -#[derive(Debug, Args, Clone, PartialEq, Eq)] -pub struct WalletOpts { - /// Selects the wallet to use. - #[arg(skip)] - pub wallet: Option, - /// Adds verbosity, returns PSBT in JSON format alongside serialized, displays expanded objects. - #[arg(env = "VERBOSE", short = 'v', long = "verbose")] - pub verbose: bool, - /// Sets the descriptor to use for the external addresses. - #[arg(env = "EXT_DESCRIPTOR", short = 'e', long, required = true)] - pub ext_descriptor: String, - /// Sets the descriptor to use for internal/change addresses. - #[arg(env = "INT_DESCRIPTOR", short = 'i', long)] - pub int_descriptor: Option, - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] - #[arg(env = "CLIENT_TYPE", short = 'c', long, value_enum, required = true)] - pub client_type: ClientType, - #[cfg(any(feature = "sqlite", feature = "redb"))] - #[arg(env = "DATABASE_TYPE", short = 'd', long, value_enum, required = true)] - pub database_type: DatabaseType, - /// Sets the server url. - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] - #[arg(env = "SERVER_URL", short = 'u', long, required = true)] - pub url: String, - /// Electrum batch size. - #[cfg(feature = "electrum")] - #[arg(env = "ELECTRUM_BATCH_SIZE", short = 'b', long, default_value = "10")] - pub batch_size: usize, - /// Esplora parallel requests. - #[cfg(feature = "esplora")] - #[arg( - env = "ESPLORA_PARALLEL_REQUESTS", - short = 'p', - long, - default_value = "5" - )] - pub parallel_requests: usize, - #[cfg(feature = "rpc")] - /// Sets the rpc basic authentication. - #[arg( - env = "USER:PASSWD", - short = 'a', - long, - value_parser = parse_proxy_auth, - default_value = "user:password", - )] - pub basic_auth: (String, String), - #[cfg(feature = "rpc")] - /// Sets an optional cookie authentication. - #[arg(env = "COOKIE")] - pub cookie: Option, - #[cfg(feature = "cbf")] - #[clap(flatten)] - pub compactfilter_opts: CompactFilterOpts, -} - -/// Options to configure a SOCKS5 proxy for a blockchain client connection. -#[cfg(any(feature = "electrum", feature = "esplora"))] -#[derive(Debug, Args, Clone, PartialEq, Eq)] -pub struct ProxyOpts { - /// Sets the SOCKS5 proxy for a blockchain client. - #[arg(env = "PROXY_ADDRS:PORT", long = "proxy", short = 'p')] - pub proxy: Option, - - /// Sets the SOCKS5 proxy credential. - #[arg(env = "PROXY_USER:PASSWD", long="proxy_auth", short='a', value_parser = parse_proxy_auth)] - pub proxy_auth: Option<(String, String)>, - - /// Sets the SOCKS5 proxy retries for the blockchain client. - #[arg( - env = "PROXY_RETRIES", - short = 'r', - long = "retries", - default_value = "5" - )] - pub retries: u8, - - /// Sets the SOCKS5 proxy timeout for the blockchain client. - #[arg(env = "PROXY_TIMEOUT", short = 't', long = "timeout")] - pub timeout: Option, -} - -/// Options to configure a BIP157 Compact Filter backend. -#[cfg(feature = "cbf")] -#[derive(Debug, Args, Clone, PartialEq, Eq)] -pub struct CompactFilterOpts { - /// Sets the number of parallel node connections. - #[clap(name = "CONNECTIONS", long = "cbf-conn-count", default_value = "2", value_parser = value_parser!(u8).range(1..=15))] - pub conn_count: u8, -} - -/// Wallet subcommands that can be issued without a blockchain backend. -#[derive(Debug, Subcommand, Clone, PartialEq)] -#[command(rename_all = "snake")] -pub enum OfflineWalletSubCommand { - /// Get a new external address. - NewAddress, - /// Get the first unused external address. - UnusedAddress, - /// Lists the available spendable UTXOs. - Unspent, - /// Lists all the incoming and outgoing transactions of the wallet. - Transactions, - /// Returns the current wallet balance. - Balance, - /// Creates a new unsigned transaction. - CreateTx { - /// Adds a recipient to the transaction. - // Clap Doesn't support complex vector parsing https://github.com/clap-rs/clap/issues/1704. - // Address and amount parsing is done at run time in handler function. - #[arg(env = "ADDRESS:SAT", long = "to", required = true, value_parser = parse_recipient)] - recipients: Vec<(ScriptBuf, u64)>, - /// Sends all the funds (or all the selected utxos). Requires only one recipient with value 0. - #[arg(long = "send_all", short = 'a')] - send_all: bool, - /// Enables Replace-By-Fee (BIP125). - #[arg(long = "enable_rbf", short = 'r', default_value_t = true)] - enable_rbf: bool, - /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. - #[arg(long = "offline_signer")] - offline_signer: bool, - /// Selects which utxos *must* be spent. - #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] - utxos: Option>, - /// Marks a utxo as unspendable. - #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] - unspendable: Option>, - /// Fee rate to use in sat/vbyte. - #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")] - fee_rate: Option, - /// Selects which policy should be used to satisfy the external descriptor. - #[arg(env = "EXT_POLICY", long = "external_policy")] - external_policy: Option, - /// Selects which policy should be used to satisfy the internal descriptor. - #[arg(env = "INT_POLICY", long = "internal_policy")] - internal_policy: Option, - /// Optionally create an OP_RETURN output containing given String in utf8 encoding (max 80 bytes) - #[arg( - env = "ADD_STRING", - long = "add_string", - short = 's', - conflicts_with = "add_data" - )] - add_string: Option, - /// Optionally create an OP_RETURN output containing given base64 encoded String. (max 80 bytes) - #[arg( - env = "ADD_DATA", - long = "add_data", - short = 'o', - conflicts_with = "add_string" - )] - add_data: Option, //base 64 econding - }, - /// Bumps the fees of an RBF transaction. - BumpFee { - /// TXID of the transaction to update. - #[arg(env = "TXID", long = "txid")] - txid: String, - /// Allows the wallet to reduce the amount to the specified address in order to increase fees. - #[arg(env = "SHRINK_ADDRESS", long = "shrink", value_parser = parse_address)] - shrink_address: Option
, - /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. - #[arg(long = "offline_signer")] - offline_signer: bool, - /// Selects which utxos *must* be added to the tx. Unconfirmed utxos cannot be used. - #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] - utxos: Option>, - /// Marks an utxo as unspendable, in case more inputs are needed to cover the extra fees. - #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] - unspendable: Option>, - /// The new targeted fee rate in sat/vbyte. - #[arg( - env = "SATS_VBYTE", - short = 'f', - long = "fee_rate", - default_value = "1.0" - )] - fee_rate: f32, - }, - /// Returns the available spending policies for the descriptor. - Policies, - /// Returns the public version of the wallet's descriptor(s). - PublicDescriptor, - /// Signs and tries to finalize a PSBT. - Sign { - /// Sets the PSBT to sign. - #[arg(env = "BASE64_PSBT")] - psbt: String, - /// Assume the blockchain has reached a specific height. This affects the transaction finalization, if there are timelocks in the descriptor. - #[arg(env = "HEIGHT", long = "assume_height")] - assume_height: Option, - /// Whether the signer should trust the witness_utxo, if the non_witness_utxo hasn’t been provided. - #[arg(env = "WITNESS", long = "trust_witness_utxo")] - trust_witness_utxo: Option, - }, - /// Extracts a raw transaction from a PSBT. - ExtractPsbt { - /// Sets the PSBT to extract - #[arg(env = "BASE64_PSBT")] - psbt: String, - }, - /// Finalizes a PSBT. - FinalizePsbt { - /// Sets the PSBT to finalize. - #[arg(env = "BASE64_PSBT")] - psbt: String, - /// Assume the blockchain has reached a specific height. - #[arg(env = "HEIGHT", long = "assume_height")] - assume_height: Option, - /// Whether the signer should trust the witness_utxo, if the non_witness_utxo hasn’t been provided. - #[arg(env = "WITNESS", long = "trust_witness_utxo")] - trust_witness_utxo: Option, - }, - /// Combines multiple PSBTs into one. - CombinePsbt { - /// Add one PSBT to combine. This option can be repeated multiple times, one for each PSBT. - #[arg(env = "BASE64_PSBT", required = true)] - psbt: Vec, - }, - /// Sign a message using BIP322 - #[cfg(feature = "bip322")] - SignMessage { - /// The message to sign - #[arg(long)] - message: String, - /// The signature format (e.g., Legacy, Simple, Full) - #[arg(long, default_value = "simple")] - signature_type: String, - /// Address to sign - #[arg(long)] - address: String, - /// Optional list of specific UTXOs for proof-of-funds (only for `FullWithProofOfFunds`) - #[arg(long)] - utxos: Option>, - }, - /// Verify a BIP322 signature - #[cfg(feature = "bip322")] - VerifyMessage { - /// The signature proof to verify - #[arg(long)] - proof: String, - /// The message that was signed - #[arg(long)] - message: String, - /// The address associated with the signature - #[arg(long)] - address: String, - }, -} - -/// Wallet subcommands that needs a blockchain backend. -#[derive(Debug, Subcommand, Clone, PartialEq, Eq)] -#[command(rename_all = "snake")] -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -pub enum OnlineWalletSubCommand { - /// Full Scan with the chosen blockchain server. - FullScan { - /// Stop searching addresses for transactions after finding an unused gap of this length. - #[arg(env = "STOP_GAP", long = "scan-stop-gap", default_value = "20")] - stop_gap: usize, - }, - /// Syncs with the chosen blockchain server. - Sync, - /// Broadcasts a transaction to the network. Takes either a raw transaction or a PSBT to extract. - Broadcast { - /// Sets the PSBT to sign. - #[arg( - env = "BASE64_PSBT", - long = "psbt", - required_unless_present = "tx", - conflicts_with = "tx" - )] - psbt: Option, - /// Sets the raw transaction to broadcast. - #[arg( - env = "RAWTX", - long = "tx", - required_unless_present = "psbt", - conflicts_with = "psbt" - )] - tx: Option, - }, - /// Generates a Payjoin receive URI and processes the sender's Payjoin proposal. - ReceivePayjoin { - /// Amount to be received in sats. - #[arg(env = "PAYJOIN_AMOUNT", long = "amount", required = true)] - amount: u64, - /// Payjoin directory which will be used to store the PSBTs which are pending action - /// from one of the parties. - #[arg(env = "PAYJOIN_DIRECTORY", long = "directory", required = true)] - directory: String, - /// URL of the Payjoin OHTTP relay. Can be repeated multiple times to attempt the - /// operation with multiple relays for redundancy. - #[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)] - ohttp_relay: Vec, - /// Maximum effective fee rate the receiver is willing to pay for their own input/output contributions. - #[arg(env = "PAYJOIN_RECEIVER_MAX_FEE_RATE", long = "max_fee_rate")] - max_fee_rate: Option, - }, - /// Sends an original PSBT to a BIP 21 URI and broadcasts the returned Payjoin PSBT. - SendPayjoin { - /// BIP 21 URI for the Payjoin. - #[arg(env = "PAYJOIN_URI", long = "uri", required = true)] - uri: String, - /// URL of the Payjoin OHTTP relay. Can be repeated multiple times to attempt the - /// operation with multiple relays for redundancy. - #[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)] - ohttp_relay: Vec, - /// Fee rate to use in sat/vbyte. - #[arg( - env = "PAYJOIN_SENDER_FEE_RATE", - short = 'f', - long = "fee_rate", - required = true - )] - fee_rate: u64, - }, -} - -/// Subcommands for Key operations. -#[derive(Debug, Subcommand, Clone, PartialEq, Eq)] -pub enum KeySubCommand { - /// Generates new random seed mnemonic phrase and corresponding master extended key. - Generate { - /// Entropy level based on number of random seed mnemonic words. - #[arg( - env = "WORD_COUNT", - short = 'e', - long = "entropy", - default_value = "12" - )] - word_count: usize, - /// Seed password. - #[arg(env = "PASSWORD", short = 'p', long = "password")] - password: Option, - }, - /// Restore a master extended key from seed backup mnemonic words. - Restore { - /// Seed mnemonic words, must be quoted (eg. "word1 word2 ..."). - #[arg(env = "MNEMONIC", short = 'm', long = "mnemonic")] - mnemonic: String, - /// Seed password. - #[arg(env = "PASSWORD", short = 'p', long = "password")] - password: Option, - }, - /// Derive a child key pair from a master extended key and a derivation path string (eg. "m/84'/1'/0'/0" or "m/84h/1h/0h/0"). - Derive { - /// Extended private key to derive from. - #[arg(env = "XPRV", short = 'x', long = "xprv")] - xprv: Xpriv, - /// Path to use to derive extended public key from extended private key. - #[arg(env = "PATH", short = 'p', long = "path")] - path: DerivationPath, - }, -} - -/// Subcommands available in REPL mode. -#[cfg(any(feature = "repl", target_arch = "wasm32"))] -#[derive(Debug, Parser)] -#[command(rename_all = "lower", multicall = true)] -pub enum ReplSubCommand { - /// Execute wallet commands. - Wallet { - #[command(subcommand)] - subcommand: WalletSubCommand, - }, - /// Execute key commands. - Key { - #[command(subcommand)] - subcommand: KeySubCommand, - }, - /// Generate descriptors - Descriptor { - /// Descriptor type (script type). - #[arg( - long = "type", - short = 't', - value_parser = ["pkh", "wpkh", "sh", "wsh", "tr"], - default_value = "wsh" - )] - desc_type: String, - /// Optional key: xprv, xpub, or mnemonic phrase - key: Option, - }, - /// Exit REPL loop. - Exit, -} diff --git a/src/config/mod.rs b/src/config.rs similarity index 100% rename from src/config/mod.rs rename to src/config.rs diff --git a/src/error.rs b/src/error.rs index 98468666..f4f8630a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -57,7 +57,7 @@ pub enum BDKCliError { #[error("PsbtError: {0}")] PsbtError(#[from] bdk_wallet::bitcoin::psbt::Error), - #[cfg(feature = "sqlite")] + // #[cfg(feature = "sqlite")] #[error("Rusqlite error: {0}")] RusqliteError(Box), diff --git a/src/handlers/types.rs b/src/handlers/types.rs index dacd5783..f6f03d45 100644 --- a/src/handlers/types.rs +++ b/src/handlers/types.rs @@ -490,3 +490,84 @@ impl FormatOutput for DescriptorResult { simple_table(rows, None) } } + +#[derive(Serialize, Debug, Default)] +pub struct MessageResult { + #[serde(skip_serializing_if = "Option::is_none")] + pub proof: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub valid: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub proven_amount: Option, +} + +impl FormatOutput for MessageResult { + fn to_table(&self) -> Result { + let mut rows = vec![]; + + if let Some(proof) = &self.proof { + rows.push(vec!["Proof".cell().bold(true), proof.cell()]); + } + if let Some(valid) = self.valid { + rows.push(vec!["Is Valid".cell().bold(true), valid.to_string().cell()]); + } + if let Some(amount) = self.proven_amount { + rows.push(vec![ + "Proven Amount (sats)".cell().bold(true), + amount.cell(), + ]); + } + + let title = vec!["Property".cell().bold(true), "Value".cell().bold(true)]; + + simple_table(rows, Some(title)) + } +} + +#[derive(Serialize, Debug)] +pub struct StatusResult { + pub message: String, +} + +impl StatusResult { + pub fn new(msg: &str) -> Self { + Self { + message: msg.to_string(), + } + } +} + +impl FormatOutput for StatusResult { + fn to_table(&self) -> Result { + Ok(self.message.clone()) + } +} + +#[derive(Serialize, Debug)] +pub struct TransactionResult { + pub txid: String, +} + +impl FormatOutput for TransactionResult { + fn to_table(&self) -> Result { + simple_table( + vec![vec!["TXID".cell().bold(true), self.txid.clone().cell()]], + None, + ) + } +} + +#[derive(Serialize, Debug)] +pub struct ConfigResult { + pub wallet_name: String, + pub path: String, + pub message: String, +} + +impl FormatOutput for ConfigResult { + fn to_table(&self) -> Result { + Ok(format!("{} saved to {}", self.message, self.path)) + } +} diff --git a/src/handlers/wallets.rs b/src/handlers/wallets.rs deleted file mode 100644 index fdfdff2a..00000000 --- a/src/handlers/wallets.rs +++ /dev/null @@ -1,15 +0,0 @@ -use crate::error::BDKCliError as Error; -use crate::utils::output::FormatOutput; -use crate::{config::WalletConfig, handlers::types::WalletsListResult}; -use std::path::Path; - -/// Handle the top-level `wallets` command (lists all saved wallets) -pub fn handle_wallets_subcommand(home_dir: &Path, pretty: bool) -> Result { - let config = match WalletConfig::load(home_dir)? { - Some(cfg) => cfg, - None => return Ok("No wallets configured yet.".to_string()), - }; - - let result = WalletsListResult(config.wallets); - result.format(pretty) -} diff --git a/src/payjoin/mod.rs b/src/payjoin/mod.rs index 30a67741..894508b5 100644 --- a/src/payjoin/mod.rs +++ b/src/payjoin/mod.rs @@ -5,10 +5,7 @@ feature = "cbf" ))] -use crate::{ - backend::BlockchainClient, - handlers::online::{broadcast_transaction, sync_wallet}, -}; +use crate::client::BlockchainClient; use bdk_wallet::{ SignOptions, Wallet, bitcoin::{FeeRate, Psbt, Txid, consensus::encode::serialize_hex}, @@ -120,12 +117,12 @@ impl<'a> PayjoinManager<'a> { Ok(to_string_pretty(&json!({}))?) } - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] + // #[cfg(any( + // feature = "electrum", + // feature = "esplora", + // feature = "rpc", + // feature = "cbf" + // ))] pub async fn send_payjoin( &mut self, uri: String, @@ -612,7 +609,7 @@ impl<'a> PayjoinManager<'a> { let mut sync_timer = tokio::time::interval(sync_interval); poll_timer.tick().await; sync_timer.tick().await; - sync_wallet(blockchain_client, self.wallet).await?; + blockchain_client.sync_wallet(self.wallet).await?; loop { tokio::select! { @@ -653,7 +650,7 @@ impl<'a> PayjoinManager<'a> { } _ = sync_timer.tick() => { // Time to sync wallet - sync_wallet(blockchain_client, self.wallet).await?; + blockchain_client.sync_wallet(self.wallet).await?; } } } @@ -809,7 +806,9 @@ impl<'a> PayjoinManager<'a> { )); } - broadcast_transaction(blockchain_client, psbt.extract_tx_fee_rate_limit()?).await + blockchain_client + .broadcast(psbt.extract_tx_fee_rate_limit()?) + .await } async fn send_payjoin_post_request( diff --git a/src/utils/common.rs b/src/utils/common.rs index 52afdd4d..294aef1b 100644 --- a/src/utils/common.rs +++ b/src/utils/common.rs @@ -74,7 +74,7 @@ pub(crate) fn parse_recipient(s: &str) -> Result<(ScriptBuf, u64), String> { Ok((addr.script_pubkey(), val)) } -#[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] +// #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] /// Parse the proxy (Socket:Port) argument from the cli input. pub(crate) fn parse_proxy_auth(s: &str) -> Result<(String, String), Error> { let parts: Vec<_> = s.split(':').collect(); From 180df23098ffbf139766f7fe530c839298ac1265 Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Thu, 21 May 2026 15:59:56 +0100 Subject: [PATCH 16/20] ref(persister): collapse wallet subdir to persister - move wallet subdir into the persister module to simplify the structure as wallet was confusing --- src/{wallet/mod.rs => persister.rs} | 53 ++++++++++++++++++++++++++--- src/wallet/persister.rs | 40 ---------------------- 2 files changed, 49 insertions(+), 44 deletions(-) rename src/{wallet/mod.rs => persister.rs} (59%) delete mode 100644 src/wallet/persister.rs diff --git a/src/wallet/mod.rs b/src/persister.rs similarity index 59% rename from src/wallet/mod.rs rename to src/persister.rs index 7ef2e65b..2a17db1b 100644 --- a/src/wallet/mod.rs +++ b/src/persister.rs @@ -1,12 +1,57 @@ +#[cfg(any(feature = "sqlite", feature = "redb"))] use crate::commands::WalletOpts; use crate::error::BDKCliError as Error; -use bdk_wallet::Wallet; -use bdk_wallet::bitcoin::Network; #[cfg(any(feature = "sqlite", feature = "redb"))] -use bdk_wallet::{KeychainKind, PersistedWallet, WalletPersister}; +use bdk_wallet::{KeychainKind, PersistedWallet, bitcoin::Network}; +use bdk_wallet::{Wallet, WalletPersister}; +use clap::ValueEnum; + +#[derive(Clone, ValueEnum, Debug, Eq, PartialEq)] +pub enum DatabaseType { + /// Sqlite database + #[cfg(feature = "sqlite")] + Sqlite, + /// Redb database + #[cfg(feature = "redb")] + Redb, +} +// Types of Persistence backends supported by bdk-cli #[cfg(any(feature = "sqlite", feature = "redb"))] -pub mod persister; +pub(crate) enum Persister { + #[cfg(feature = "sqlite")] + Connection(bdk_wallet::rusqlite::Connection), + #[cfg(feature = "redb")] + RedbStore(bdk_redb::Store), +} + +impl WalletPersister for Persister { + type Error = Error; + + fn initialize(persister: &mut Self) -> Result { + match persister { + #[cfg(feature = "sqlite")] + Persister::Connection(connection) => { + WalletPersister::initialize(connection).map_err(Error::from) + } + #[cfg(feature = "redb")] + Persister::RedbStore(store) => WalletPersister::initialize(store).map_err(Error::from), + } + } + + fn persist(persister: &mut Self, changeset: &bdk_wallet::ChangeSet) -> Result<(), Self::Error> { + match persister { + #[cfg(feature = "sqlite")] + Persister::Connection(connection) => { + WalletPersister::persist(connection, changeset).map_err(Error::from) + } + #[cfg(feature = "redb")] + Persister::RedbStore(store) => { + WalletPersister::persist(store, changeset).map_err(Error::from) + } + } + } +} #[cfg(any(feature = "sqlite", feature = "redb"))] pub(crate) fn new_persisted_wallet( diff --git a/src/wallet/persister.rs b/src/wallet/persister.rs deleted file mode 100644 index 1f9a742c..00000000 --- a/src/wallet/persister.rs +++ /dev/null @@ -1,40 +0,0 @@ -use crate::error::BDKCliError; -use bdk_wallet::WalletPersister; - -// Types of Persistence backends supported by bdk-cli -pub(crate) enum Persister { - #[cfg(feature = "sqlite")] - Connection(bdk_wallet::rusqlite::Connection), - #[cfg(feature = "redb")] - RedbStore(bdk_redb::Store), -} - -impl WalletPersister for Persister { - type Error = BDKCliError; - - fn initialize(persister: &mut Self) -> Result { - match persister { - #[cfg(feature = "sqlite")] - Persister::Connection(connection) => { - WalletPersister::initialize(connection).map_err(BDKCliError::from) - } - #[cfg(feature = "redb")] - Persister::RedbStore(store) => { - WalletPersister::initialize(store).map_err(BDKCliError::from) - } - } - } - - fn persist(persister: &mut Self, changeset: &bdk_wallet::ChangeSet) -> Result<(), Self::Error> { - match persister { - #[cfg(feature = "sqlite")] - Persister::Connection(connection) => { - WalletPersister::persist(connection, changeset).map_err(BDKCliError::from) - } - #[cfg(feature = "redb")] - Persister::RedbStore(store) => { - WalletPersister::persist(store, changeset).map_err(BDKCliError::from) - } - } - } -} From 1d476d4c979f785ed6381c4b10886797cd39ceef Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Thu, 21 May 2026 16:17:39 +0100 Subject: [PATCH 17/20] ref(pretty): remove `--pretty` flag - remove --pretty flag - move types to utils - update commands --- README.md | 9 - src/commands.rs | 392 +++++------------------------ src/handlers/types.rs | 573 ------------------------------------------ src/utils/types.rs | 272 ++++++++++++++++++++ 4 files changed, 333 insertions(+), 913 deletions(-) delete mode 100644 src/handlers/types.rs create mode 100644 src/utils/types.rs diff --git a/README.md b/README.md index fd01432c..a19eee47 100644 --- a/README.md +++ b/README.md @@ -229,15 +229,6 @@ Note: You can modify the `Justfile` to reflect your nodes' configuration values. cargo run --features rpc -- wallet -w regtest1 balance ``` -## Formatting Responses using `--pretty` flag - -You can optionally return outputs of commands in human-readable, tabular format instead of `JSON`. To enable this option, simply add the `--pretty` flag as a top level flag. For instance, you wallet's balance in a pretty format, you can run: - -```shell -cargo run -- --pretty -n signet wallet -w {wallet_name} balance -``` -This is available for wallet, key, repl and compile features. When ommitted, outputs default to `JSON`. - ## Shell Completions `bdk-cli` supports generating shell completions for Bash, Zsh, Fish, Elvish, and PowerShell. For setup instructions, run: diff --git a/src/commands.rs b/src/commands.rs index 127d32bd..cb1c9251 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -13,34 +13,38 @@ //! All subcommands are defined in the below enums. #![allow(clippy::large_enum_variant)] -use crate::{ - error::BDKCliError as Error, - handlers::{ - AppCommand, - offline::{ - BalanceCommand, BumpFeeCommand, CombinePsbtCommand, CreateTxCommand, - ExtractPsbtCommand, FinalizePsbtCommand, NewAddressCommand, PoliciesCommand, - PublicDescriptorCommand, SignCommand, SignMessageCommand, TransactionsCommand, - UnspentCommand, UnusedAddressCommand, VerifyMessageCommand, - }, - online::{ - BroadcastCommand, FullScanCommand, ReceivePayjoinCommand, SendPayjoinCommand, - SyncCommand, - }, +#[cfg(feature = "bip322")] +use crate::handlers::offline::{SignMessageCommand, VerifyMessageCommand}; +use crate::handlers::{ + config::{ListWalletsCommand, SaveConfigCommand}, + descriptor::DescriptorCommand, + key::{DeriveKeyCommand, GenerateKeyCommand, RestoreKeyCommand}, + offline::{ + BalanceCommand, BumpFeeCommand, CombinePsbtCommand, CreateTxCommand, ExtractPsbtCommand, + FinalizePsbtCommand, NewAddressCommand, PoliciesCommand, PublicDescriptorCommand, + SignCommand, TransactionsCommand, UnspentCommand, UnusedAddressCommand, + }, + online::{ + BroadcastCommand, FullScanCommand, ReceivePayjoinCommand, SendPayjoinCommand, SyncCommand, }, - utils::output::FormatOutput, -}; -use bdk_wallet::bitcoin::{ - Address, Network, OutPoint, ScriptBuf, - bip32::{DerivationPath, Xpriv}, }; -use clap::{Args, Parser, Subcommand, ValueEnum, value_parser}; -use clap_complete::Shell; -use crate::{ - handlers::AppContext, - utils::{parse_address, parse_outpoint, parse_recipient}, -}; +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] +use crate::client::ClientType; + +#[cfg(feature = "compiler")] +use crate::handlers::descriptor::CompileCommand; +#[cfg(any(feature = "sqlite", feature = "redb"))] +use crate::persister::DatabaseType; + +use bdk_wallet::bitcoin::Network; +use clap::{Args, Parser, Subcommand, value_parser}; +use clap_complete::Shell; // #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] use crate::utils::parse_proxy_auth; @@ -72,9 +76,6 @@ pub struct CliOpts { /// Default value : ~/.bdk-bitcoin #[arg(env = "DATADIR", short = 'd', long = "datadir")] pub datadir: Option, - /// Output results in pretty format (instead of JSON). - #[arg(long = "pretty", global = true)] - pub pretty: bool, /// Top level cli sub-commands. #[command(subcommand)] pub subcommand: CliSubCommand, @@ -110,17 +111,9 @@ pub enum CliSubCommand { subcommand: KeySubCommand, }, /// Compile a miniscript policy to an output descriptor. - // #[cfg(feature = "compiler")] + #[cfg(feature = "compiler")] #[clap(long_about = "Miniscript policy compiler")] - Compile { - /// Sets the spending policy to compile. - #[arg(env = "POLICY", required = true, index = 1)] - policy: String, - /// Sets the script type used to embed the compiled policy. - #[arg(env = "TYPE", short = 't', long = "type", default_value = "wsh", value_parser = ["sh","wsh", "sh-wsh", "tr"] - )] - script_type: String, - }, + Compile(CompileCommand), // #[cfg(feature = "repl")] /// REPL command loop mode. /// @@ -131,24 +124,15 @@ pub enum CliSubCommand { #[arg(env = "WALLET_NAME", short = 'w', long = "wallet", required = true)] wallet: String, }, + /// Output Descriptors operations. /// /// Generate output descriptors from either extended key (Xprv/Xpub) or mnemonic phrase. /// This feature is intended for development and testing purposes only. - Descriptor { - /// Descriptor type (script type) - #[arg( - long = "type", - short = 't', - value_parser = ["pkh", "wpkh", "sh", "wsh", "tr"], - default_value = "wsh" - )] - desc_type: String, - /// Optional key: xprv, xpub, or mnemonic phrase - key: Option, - }, + Descriptor(DescriptorCommand), + /// List all saved wallet configurations. - Wallets, + Wallets(ListWalletsCommand), /// Generate tab-completion scripts for your shell. /// /// The completion script is output on stdout, allowing you to redirect @@ -205,114 +189,23 @@ pub enum CliSubCommand { }, } -impl CliSubCommand { - pub async fn execute( - &self, - ctx: &mut AppContext<'_>, - cli_opts: &CliOpts, - #[cfg(feature = "repl")] wallet_opts: &mut WalletOpts, - datadir: std::path::PathBuf, - ) -> Result { - match self { - CliSubCommand::Wallet { subcommand, wallet } => { - match subcommand { - WalletSubCommand::OfflineWalletSubCommand(cmd) => { - cmd.execute(ctx, cli_opts.pretty) - } - // #[cfg(any(feature = "electrum", feature = "esplora", feature = "cbf", feature = "rpc"))] - WalletSubCommand::OnlineWalletSubCommand(cmd) => { - cmd.execute(ctx, cli_opts.pretty).await - } - WalletSubCommand::Config { force, wallet_opts } => {} - } - } - - CliSubCommand::Key { subcommand } => subcommand.execute(ctx, cli_opts.pretty), - - CliSubCommand::Descriptor { desc_type, key } => { - cmd.execute(ctx).and_then(|out| out.format(cli_opts.pretty)) - } - - // #[cfg(feature = "compiler")] - CliSubCommand::Compile { - policy, - script_type, - } => cmd.execute(ctx).and_then(|out| out.format(cli_opts.pretty)), - - CliSubCommand::Wallets(cmd) => { - cmd.execute(ctx).and_then(|out| out.format(cli_opts.pretty)) - } - - CliSubCommand::Config(cmd) => { - cmd.execute(ctx).and_then(|out| out.format(cli_opts.pretty)) - } - - CliSubCommand::Completions { shell } => { - use clap::CommandFactory; - clap_complete::generate( - *shell, - &mut CliOpts::command(), - "bdk-cli", - &mut std::io::stdout(), - ); - Ok("".to_string()) - } - } - } -} - /// Wallet operation subcommands. #[derive(Debug, Subcommand, Clone, PartialEq)] pub enum WalletSubCommand { /// Save wallet configuration to `config.toml`. - Config { - /// Overwrite existing wallet configuration if it exists. - #[arg(short = 'f', long = "force", default_value_t = false)] - force: bool, - - #[command(flatten)] - wallet_opts: WalletOpts, - }, - // #[cfg(any( - // feature = "electrum", - // feature = "esplora", - // feature = "cbf", - // feature = "rpc" - // ))] + Config(SaveConfigCommand), + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] #[command(flatten)] OnlineWalletSubCommand(OnlineWalletSubCommand), #[command(flatten)] OfflineWalletSubCommand(OfflineWalletSubCommand), } -#[derive(Clone, ValueEnum, Debug, Eq, PartialEq)] -pub enum DatabaseType { - /// Sqlite database - #[cfg(feature = "sqlite")] - Sqlite, - /// Redb database - #[cfg(feature = "redb")] - Redb, -} - -// #[cfg(any( -// feature = "electrum", -// feature = "esplora", -// feature = "rpc", -// feature = "cbf" -// ))] -#[derive(Clone, ValueEnum, Debug, Eq, PartialEq)] -pub enum ClientType { - // #[cfg(feature = "electrum")] - Electrum, - // #[cfg(feature = "esplora")] - Esplora, - // #[cfg(feature = "rpc")] - Rpc, - // #[cfg(feature = "cbf")] - Cbf, -} - /// Config options wallet operations can take. #[derive(Debug, Args, Clone, PartialEq, Eq)] pub struct WalletOpts { @@ -328,27 +221,27 @@ pub struct WalletOpts { /// Sets the descriptor to use for internal/change addresses. #[arg(env = "INT_DESCRIPTOR", short = 'i', long)] pub int_descriptor: Option, - // #[cfg(any( - // feature = "electrum", - // feature = "esplora", - // feature = "rpc", - // feature = "cbf" - // ))] + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] #[arg(env = "CLIENT_TYPE", short = 'c', long, value_enum, required = true)] pub client_type: ClientType, #[cfg(any(feature = "sqlite", feature = "redb"))] #[arg(env = "DATABASE_TYPE", short = 'd', long, value_enum, required = true)] pub database_type: DatabaseType, /// Sets the server url. - // #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] #[arg(env = "SERVER_URL", short = 'u', long, required = true)] pub url: String, /// Electrum batch size. - // #[cfg(feature = "electrum")] + #[cfg(feature = "electrum")] #[arg(env = "ELECTRUM_BATCH_SIZE", short = 'b', long, default_value = "10")] pub batch_size: usize, /// Esplora parallel requests. - // #[cfg(feature = "esplora")] + #[cfg(feature = "esplora")] #[arg( env = "ESPLORA_PARALLEL_REQUESTS", short = 'p', @@ -356,7 +249,7 @@ pub struct WalletOpts { default_value = "5" )] pub parallel_requests: usize, - // #[cfg(feature = "rpc")] + #[cfg(feature = "rpc")] /// Sets the rpc basic authentication. #[arg( env = "USER:PASSWD", @@ -366,11 +259,11 @@ pub struct WalletOpts { default_value = "user:password", )] pub basic_auth: (String, String), - // #[cfg(feature = "rpc")] + #[cfg(feature = "rpc")] /// Sets an optional cookie authentication. #[arg(env = "COOKIE")] pub cookie: Option, - // #[cfg(feature = "cbf")] + #[cfg(feature = "cbf")] #[clap(flatten)] pub compactfilter_opts: CompactFilterOpts, } @@ -426,126 +319,23 @@ pub enum OfflineWalletSubCommand { Balance(BalanceCommand), /// Creates a new unsigned transaction. CreateTx(CreateTxCommand), - // CreateTx { - // /// Adds a recipient to the transaction. - // // Clap Doesn't support complex vector parsing https://github.com/clap-rs/clap/issues/1704. - // // Address and amount parsing is done at run time in handler function. - // #[arg(env = "ADDRESS:SAT", long = "to", required = true, value_parser = parse_recipient)] - // recipients: Vec<(ScriptBuf, u64)>, - // /// Sends all the funds (or all the selected utxos). Requires only one recipient with value 0. - // #[arg(long = "send_all", short = 'a')] - // send_all: bool, - // /// Enables Replace-By-Fee (BIP125). - // #[arg(long = "enable_rbf", short = 'r', default_value_t = true)] - // enable_rbf: bool, - // /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. - // #[arg(long = "offline_signer")] - // offline_signer: bool, - // /// Selects which utxos *must* be spent. - // #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] - // utxos: Option>, - // /// Marks a utxo as unspendable. - // #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] - // unspendable: Option>, - // /// Fee rate to use in sat/vbyte. - // #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")] - // fee_rate: Option, - // /// Selects which policy should be used to satisfy the external descriptor. - // #[arg(env = "EXT_POLICY", long = "external_policy")] - // external_policy: Option, - // /// Selects which policy should be used to satisfy the internal descriptor. - // #[arg(env = "INT_POLICY", long = "internal_policy")] - // internal_policy: Option, - // /// Optionally create an OP_RETURN output containing given String in utf8 encoding (max 80 bytes) - // #[arg( - // env = "ADD_STRING", - // long = "add_string", - // short = 's', - // conflicts_with = "add_data" - // )] - // add_string: Option, - // /// Optionally create an OP_RETURN output containing given base64 encoded String. (max 80 bytes) - // #[arg( - // env = "ADD_DATA", - // long = "add_data", - // short = 'o', - // conflicts_with = "add_string" - // )] - // add_data: Option, //base 64 econding - // }, /// Bumps the fees of an RBF transaction. BumpFee(BumpFeeCommand), - // BumpFee { - // /// TXID of the transaction to update. - // #[arg(env = "TXID", long = "txid")] - // txid: String, - // /// Allows the wallet to reduce the amount to the specified address in order to increase fees. - // #[arg(env = "SHRINK_ADDRESS", long = "shrink", value_parser = parse_address)] - // shrink_address: Option
, - // /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. - // #[arg(long = "offline_signer")] - // offline_signer: bool, - // /// Selects which utxos *must* be added to the tx. Unconfirmed utxos cannot be used. - // #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] - // utxos: Option>, - // /// Marks an utxo as unspendable, in case more inputs are needed to cover the extra fees. - // #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] - // unspendable: Option>, - // /// The new targeted fee rate in sat/vbyte. - // #[arg( - // env = "SATS_VBYTE", - // short = 'f', - // long = "fee_rate", - // default_value = "1.0" - // )] - // fee_rate: f32, - // }, /// Returns the available spending policies for the descriptor. Policies(PoliciesCommand), /// Returns the public version of the wallet's descriptor(s). PublicDescriptor(PublicDescriptorCommand), /// Signs and tries to finalize a PSBT. Sign(SignCommand), - // Sign { - // /// Sets the PSBT to sign. - // #[arg(env = "BASE64_PSBT")] - // psbt: String, - // /// Assume the blockchain has reached a specific height. This affects the transaction finalization, if there are timelocks in the descriptor. - // #[arg(env = "HEIGHT", long = "assume_height")] - // assume_height: Option, - // /// Whether the signer should trust the witness_utxo, if the non_witness_utxo hasn’t been provided. - // #[arg(env = "WITNESS", long = "trust_witness_utxo")] - // trust_witness_utxo: Option, - // }, + /// Extracts a raw transaction from a PSBT. - // ExtractPsbt { - // /// Sets the PSBT to extract - // #[arg(env = "BASE64_PSBT")] - // psbt: String, - // }, ExtractPsbt(ExtractPsbtCommand), /// Finalizes a PSBT. - // FinalizePsbt { - // /// Sets the PSBT to finalize. - // #[arg(env = "BASE64_PSBT")] - // psbt: String, - // /// Assume the blockchain has reached a specific height. - // #[arg(env = "HEIGHT", long = "assume_height")] - // assume_height: Option, - // /// Whether the signer should trust the witness_utxo, if the non_witness_utxo hasn’t been provided. - // #[arg(env = "WITNESS", long = "trust_witness_utxo")] - // trust_witness_utxo: Option, - // }, FinalizePsbt(FinalizePsbtCommand), /// Combines multiple PSBTs into one. - // CombinePsbt { - // /// Add one PSBT to combine. This option can be repeated multiple times, one for each PSBT. - // #[arg(env = "BASE64_PSBT", required = true)] - // psbt: Vec, - // }, CombinePsbt(CombinePsbtCommand), /// Sign a message using BIP322 - // #[cfg(feature = "bip322")] + #[cfg(feature = "bip322")] // SignMessage { // /// The message to sign // #[arg(long)] @@ -562,7 +352,7 @@ pub enum OfflineWalletSubCommand { // }, SignMessage(SignMessageCommand), /// Verify a BIP322 signature - // #[cfg(feature = "bip322")] + #[cfg(feature = "bip322")] // VerifyMessage { // /// The signature proof to verify // #[arg(long)] @@ -588,33 +378,10 @@ pub enum OfflineWalletSubCommand { // ))] pub enum OnlineWalletSubCommand { /// Full Scan with the chosen blockchain server. - // FullScan { - // /// Stop searching addresses for transactions after finding an unused gap of this length. - // #[arg(env = "STOP_GAP", long = "scan-stop-gap", default_value = "20")] - // stop_gap: usize, - // }, FullScan(FullScanCommand), /// Syncs with the chosen blockchain server. Sync(SyncCommand), /// Broadcasts a transaction to the network. Takes either a raw transaction or a PSBT to extract. - // Broadcast { - // /// Sets the PSBT to sign. - // #[arg( - // env = "BASE64_PSBT", - // long = "psbt", - // required_unless_present = "tx", - // conflicts_with = "tx" - // )] - // psbt: Option, - // /// Sets the raw transaction to broadcast. - // #[arg( - // env = "RAWTX", - // long = "tx", - // required_unless_present = "psbt", - // conflicts_with = "psbt" - // )] - // tx: Option, - // }, Broadcast(BroadcastCommand), /// Generates a Payjoin receive URI and processes the sender's Payjoin proposal. // ReceivePayjoin { @@ -659,37 +426,11 @@ pub enum OnlineWalletSubCommand { #[derive(Debug, Subcommand, Clone, PartialEq, Eq)] pub enum KeySubCommand { /// Generates new random seed mnemonic phrase and corresponding master extended key. - Generate { - /// Entropy level based on number of random seed mnemonic words. - #[arg( - env = "WORD_COUNT", - short = 'e', - long = "entropy", - default_value = "12" - )] - word_count: usize, - /// Seed password. - #[arg(env = "PASSWORD", short = 'p', long = "password")] - password: Option, - }, + Generate(GenerateKeyCommand), /// Restore a master extended key from seed backup mnemonic words. - Restore { - /// Seed mnemonic words, must be quoted (eg. "word1 word2 ..."). - #[arg(env = "MNEMONIC", short = 'm', long = "mnemonic")] - mnemonic: String, - /// Seed password. - #[arg(env = "PASSWORD", short = 'p', long = "password")] - password: Option, - }, + Restore(RestoreKeyCommand), /// Derive a child key pair from a master extended key and a derivation path string (eg. "m/84'/1'/0'/0" or "m/84h/1h/0h/0"). - Derive { - /// Extended private key to derive from. - #[arg(env = "XPRV", short = 'x', long = "xprv")] - xprv: Xpriv, - /// Path to use to derive extended public key from extended private key. - #[arg(env = "PATH", short = 'p', long = "path")] - path: DerivationPath, - }, + Derive(DeriveKeyCommand), } /// Subcommands available in REPL mode. @@ -708,18 +449,7 @@ pub enum ReplSubCommand { subcommand: KeySubCommand, }, /// Generate descriptors - Descriptor { - /// Descriptor type (script type). - #[arg( - long = "type", - short = 't', - value_parser = ["pkh", "wpkh", "sh", "wsh", "tr"], - default_value = "wsh" - )] - desc_type: String, - /// Optional key: xprv, xpub, or mnemonic phrase - key: Option, - }, + Descriptor(DescriptorCommand), /// Exit REPL loop. Exit, } diff --git a/src/handlers/types.rs b/src/handlers/types.rs deleted file mode 100644 index f6f03d45..00000000 --- a/src/handlers/types.rs +++ /dev/null @@ -1,573 +0,0 @@ -use std::collections::HashMap; - -use crate::config::WalletConfigInner; -use crate::utils::output::{FormatOutput, simple_table}; -use crate::{error::BDKCliError as Error, utils::shorten}; -use bdk_wallet::bitcoin::{ - Address, Network, Psbt, Transaction, base64::Engine, consensus::encode::serialize_hex, -}; -use bdk_wallet::{AddressInfo, Balance, LocalOutput, chain::ChainPosition}; -use cli_table::{Cell, CellStruct, Style, format::Justify}; -use serde::Serialize; -use serde_json::json; - -/// Represent address result -#[derive(Serialize)] -pub struct AddressResult { - pub address: String, - pub index: u32, -} - -impl From for AddressResult { - fn from(info: AddressInfo) -> Self { - Self { - address: info.address.to_string(), - index: info.index, - } - } -} - -/// pretty presentation for address -impl FormatOutput for AddressResult { - fn to_table(&self) -> Result { - simple_table( - vec![ - vec!["Address".cell().bold(true), self.address.clone().cell()], - vec![ - "Index".cell().bold(true), - self.index.cell().justify(Justify::Right), - ], - ], - None, - ) - } -} - -/// Represents the data for a single transaction -#[derive(Serialize)] -pub struct TransactionDetails { - pub txid: String, - pub is_coinbase: bool, - pub wtxid: String, - pub version: serde_json::Value, - pub is_rbf: bool, - pub inputs: serde_json::Value, - pub outputs: serde_json::Value, - #[serde(skip)] - pub version_display: String, - #[serde(skip)] - pub input_count: usize, - #[serde(skip)] - pub output_count: usize, - #[serde(skip)] - pub total_value: u64, -} - -/// A wrapper type for a list of transactions. -#[derive(Serialize)] -#[serde(transparent)] -pub struct TransactionListResult(pub Vec); - -impl FormatOutput for TransactionListResult { - fn to_table(&self) -> Result { - let rows = self.0.iter().map(|tx| { - vec![ - tx.txid.clone().cell(), - tx.version_display.clone().cell().justify(Justify::Right), - tx.is_rbf.to_string().cell().justify(Justify::Center), - tx.input_count.to_string().cell().justify(Justify::Right), - tx.output_count.to_string().cell().justify(Justify::Right), - tx.total_value.to_string().cell().justify(Justify::Right), - ] - }); - - simple_table( - rows, - Some(vec![ - "Txid".cell().bold(true), - "Version".cell().bold(true), - "Is RBF".cell().bold(true), - "Input Count".cell().bold(true), - "Output Count".cell().bold(true), - "Total Value (sat)".cell().bold(true), - ]), - ) - } -} - -/// Balance representation -#[derive(Serialize)] -pub struct BalanceResult { - pub total: u64, - pub trusted_pending: u64, - pub untrusted_pending: u64, - pub immature: u64, - pub confirmed: u64, -} - -impl From for BalanceResult { - fn from(b: Balance) -> Self { - Self { - total: b.total().to_sat(), - confirmed: b.confirmed.to_sat(), - trusted_pending: b.trusted_pending.to_sat(), - untrusted_pending: b.untrusted_pending.to_sat(), - immature: b.immature.to_sat(), - } - } -} - -impl FormatOutput for BalanceResult { - fn to_table(&self) -> Result { - simple_table( - vec![ - vec![ - "Total".cell().bold(true), - self.total.cell().justify(Justify::Right), - ], - vec![ - "Confirmed".cell().bold(true), - self.confirmed.cell().justify(Justify::Right), - ], - vec![ - "Trusted Pending".cell().bold(true), - self.trusted_pending.cell().justify(Justify::Right), - ], - vec![ - "Untrusted Pending".cell().bold(true), - self.untrusted_pending.cell().justify(Justify::Right), - ], - vec![ - "Immature".cell().bold(true), - self.immature.cell().justify(Justify::Right), - ], - ], - Some(vec![ - "Status".cell().bold(true), - "Amount (sat)".cell().bold(true), - ]), - ) - } -} - -/// single UTXO -#[derive(Serialize)] -pub struct UnspentDetails { - pub outpoint: String, - pub txout: serde_json::Value, - pub keychain: String, - pub is_spent: bool, - pub derivation_index: u32, - pub chain_position: serde_json::Value, - - #[serde(skip)] - pub value_sat: u64, - #[serde(skip)] - pub address: String, - #[serde(skip)] - pub outpoint_display: String, - #[serde(skip)] - pub height_display: String, - #[serde(skip)] - pub block_hash_display: String, -} - -impl UnspentDetails { - pub fn from_local_output(utxo: &LocalOutput, network: Network) -> Self { - let height = utxo.chain_position.confirmation_height_upper_bound(); - let height_display = height - .map(|h| h.to_string()) - .unwrap_or_else(|| "Pending".to_string()); - - let (_, block_hash_display) = match &utxo.chain_position { - ChainPosition::Confirmed { anchor, .. } => { - let hash = anchor.block_id.hash.to_string(); - (Some(hash.clone()), shorten(&hash, 8, 8)) - } - ChainPosition::Unconfirmed { .. } => (None, "Unconfirmed".to_string()), - }; - - let address = Address::from_script(&utxo.txout.script_pubkey, network) - .map(|a| a.to_string()) - .unwrap_or_else(|_| "Unknown Script".to_string()); - - let outpoint_str = utxo.outpoint.to_string(); - - Self { - outpoint: outpoint_str.clone(), - txout: serde_json::to_value(&utxo.txout).unwrap_or(json!({})), - keychain: format!("{:?}", utxo.keychain), - is_spent: utxo.is_spent, - derivation_index: utxo.derivation_index, - chain_position: serde_json::to_value(utxo.chain_position).unwrap_or(json!({})), - - value_sat: utxo.txout.value.to_sat(), - address, - outpoint_display: shorten(&outpoint_str, 8, 10), - height_display, - block_hash_display, - } - } -} - -/// Wrapper for the list of UTXOs -#[derive(Serialize)] -#[serde(transparent)] -pub struct UnspentListResult(pub Vec); - -impl FormatOutput for UnspentListResult { - fn to_table(&self) -> Result { - let mut rows: Vec> = vec![]; - - for utxo in &self.0 { - rows.push(vec![ - utxo.outpoint_display.clone().cell(), - utxo.value_sat.to_string().cell().justify(Justify::Right), - utxo.address.clone().cell(), - utxo.keychain.clone().cell(), - utxo.is_spent.cell(), - utxo.derivation_index.cell(), - utxo.height_display.clone().cell().justify(Justify::Right), - utxo.block_hash_display - .clone() - .cell() - .justify(Justify::Right), - ]); - } - - let title = vec![ - "Outpoint".cell().bold(true), - "Output (sat)".cell().bold(true), - "Output Address".cell().bold(true), - "Keychain".cell().bold(true), - "Is Spent".cell().bold(true), - "Index".cell().bold(true), - "Block Height".cell().bold(true), - "Block Hash".cell().bold(true), - ]; - simple_table(rows, Some(title)) - } -} - -#[derive(Serialize)] -pub struct PsbtResult { - pub psbt: String, - - #[serde(skip_serializing_if = "Option::is_none")] - pub is_finalized: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub details: Option, -} - -impl PsbtResult { - pub fn new(psbt: &Psbt, verbose: bool, finalized: Option) -> Self { - Self { - psbt: bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD.encode(psbt.serialize()), - is_finalized: finalized, - details: if verbose { - Some(serde_json::to_value(psbt).unwrap_or_default()) - } else { - None - }, - } - } -} - -impl FormatOutput for PsbtResult { - fn to_table(&self) -> Result { - let mut rows = vec![vec![ - "PSBT (Base64)".cell().bold(true), - self.psbt.clone().cell(), - ]]; - - if let Some(finalized) = self.is_finalized { - rows.push(vec!["Is Finalized".cell().bold(true), finalized.cell()]); - } - - if self.details.is_some() { - rows.push(vec![ - "Details".cell().bold(true), - "Run without --pretty to view verbose JSON details".cell(), - ]); - } - - simple_table(rows, None) - } -} - -#[derive(Serialize)] -pub struct RawPsbt { - pub raw_tx: String, -} - -impl RawPsbt { - pub fn new(tx: &Transaction) -> Self { - Self { - raw_tx: serialize_hex(tx), - } - } -} - -impl FormatOutput for RawPsbt { - fn to_table(&self) -> Result { - let rows = vec![vec![ - "Raw Transaction".cell().bold(true), - self.raw_tx.clone().cell(), - ]]; - simple_table(rows, None) - } -} - -#[derive(Serialize)] -pub struct KeychainPair { - pub external: T, - pub internal: T, -} - -// Table formatting for string pairs (used by PublicDescriptor) -impl FormatOutput for KeychainPair { - fn to_table(&self) -> Result { - let rows = vec![ - vec!["External".cell().bold(true), self.external.clone().cell()], - vec!["Internal".cell().bold(true), self.internal.clone().cell()], - ]; - simple_table(rows, None) - } -} - -// Table formatting for JSON value pairs (used by Policies) -impl FormatOutput for KeychainPair { - fn to_table(&self) -> Result { - let ext_str = serde_json::to_string_pretty(&self.external) - .map_err(|e| Error::Generic(e.to_string()))?; - let int_str = serde_json::to_string_pretty(&self.internal) - .map_err(|e| Error::Generic(e.to_string()))?; - - let rows = vec![ - vec!["External".cell().bold(true), ext_str.cell()], - vec!["Internal".cell().bold(true), int_str.cell()], - ]; - simple_table(rows, None) - } -} -#[derive(Serialize)] -pub struct KeyResult { - pub xprv: String, - - #[serde(skip_serializing_if = "Option::is_none")] - pub xpub: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub mnemonic: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub fingerprint: Option, -} - -impl FormatOutput for KeyResult { - fn to_table(&self) -> Result { - let mut rows: Vec> = vec![]; - - if let Some(mnemonic) = &self.mnemonic { - rows.push(vec!["Mnemonic".cell().bold(true), mnemonic.clone().cell()]); - } - if let Some(xpub) = &self.xpub { - rows.push(vec!["Xpub".cell().bold(true), xpub.clone().cell()]); - } - - rows.push(vec!["Xprv".cell().bold(true), self.xprv.clone().cell()]); - - if let Some(fingerprint) = &self.fingerprint { - rows.push(vec![ - "Fingerprint".cell().bold(true), - fingerprint.clone().cell(), - ]); - } - - simple_table(rows, None) - } -} - -#[derive(Serialize)] -#[serde(transparent)] -pub struct WalletsListResult(pub HashMap); - -impl FormatOutput for WalletsListResult { - fn to_table(&self) -> Result { - if self.0.is_empty() { - return Ok("No wallets configured yet.".to_string()); - } - - let rows = self.0.iter().map(|(name, inner)| { - let desc: String = inner.ext_descriptor.chars().take(30).collect(); - let desc_display = if inner.ext_descriptor.len() > 30 { - format!("{}...", desc) - } else { - desc - }; - - vec![ - name.clone().cell(), - inner.network.clone().cell(), - desc_display.cell(), - ] - }); - - simple_table( - rows, - Some(vec![ - "Wallet Name".cell().bold(true), - "Network".cell().bold(true), - "External Descriptor".cell().bold(true), - ]), - ) - } -} - -#[derive(Serialize)] -pub struct DescriptorResult { - #[serde(skip_serializing_if = "Option::is_none")] - pub descriptor: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub multipath_descriptor: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub public_descriptors: Option>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub private_descriptors: Option>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub mnemonic: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub fingerprint: Option, -} - -impl FormatOutput for DescriptorResult { - fn to_table(&self) -> Result { - let mut rows: Vec> = vec![]; - - if let Some(desc) = &self.descriptor { - rows.push(vec!["Descriptor".cell().bold(true), desc.clone().cell()]); - } - if let Some(desc) = &self.multipath_descriptor { - rows.push(vec![ - "Multipath Descriptor".cell().bold(true), - desc.clone().cell(), - ]); - } - if let Some(pub_desc) = &self.public_descriptors { - rows.push(vec![ - "External Public".cell().bold(true), - pub_desc.external.clone().cell(), - ]); - rows.push(vec![ - "Internal Public".cell().bold(true), - pub_desc.internal.clone().cell(), - ]); - } - if let Some(priv_desc) = &self.private_descriptors { - rows.push(vec![ - "External Private".cell().bold(true), - priv_desc.external.clone().cell(), - ]); - rows.push(vec![ - "Internal Private".cell().bold(true), - priv_desc.internal.clone().cell(), - ]); - } - if let Some(mnemonic) = &self.mnemonic { - rows.push(vec!["Mnemonic".cell().bold(true), mnemonic.clone().cell()]); - } - - if let Some(fp) = &self.fingerprint { - rows.push(vec!["Fingerprint".cell().bold(true), fp.clone().cell()]); - } - - simple_table(rows, None) - } -} - -#[derive(Serialize, Debug, Default)] -pub struct MessageResult { - #[serde(skip_serializing_if = "Option::is_none")] - pub proof: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub valid: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub proven_amount: Option, -} - -impl FormatOutput for MessageResult { - fn to_table(&self) -> Result { - let mut rows = vec![]; - - if let Some(proof) = &self.proof { - rows.push(vec!["Proof".cell().bold(true), proof.cell()]); - } - if let Some(valid) = self.valid { - rows.push(vec!["Is Valid".cell().bold(true), valid.to_string().cell()]); - } - if let Some(amount) = self.proven_amount { - rows.push(vec![ - "Proven Amount (sats)".cell().bold(true), - amount.cell(), - ]); - } - - let title = vec!["Property".cell().bold(true), "Value".cell().bold(true)]; - - simple_table(rows, Some(title)) - } -} - -#[derive(Serialize, Debug)] -pub struct StatusResult { - pub message: String, -} - -impl StatusResult { - pub fn new(msg: &str) -> Self { - Self { - message: msg.to_string(), - } - } -} - -impl FormatOutput for StatusResult { - fn to_table(&self) -> Result { - Ok(self.message.clone()) - } -} - -#[derive(Serialize, Debug)] -pub struct TransactionResult { - pub txid: String, -} - -impl FormatOutput for TransactionResult { - fn to_table(&self) -> Result { - simple_table( - vec![vec!["TXID".cell().bold(true), self.txid.clone().cell()]], - None, - ) - } -} - -#[derive(Serialize, Debug)] -pub struct ConfigResult { - pub wallet_name: String, - pub path: String, - pub message: String, -} - -impl FormatOutput for ConfigResult { - fn to_table(&self) -> Result { - Ok(format!("{} saved to {}", self.message, self.path)) - } -} diff --git a/src/utils/types.rs b/src/utils/types.rs new file mode 100644 index 00000000..f3c5ddc8 --- /dev/null +++ b/src/utils/types.rs @@ -0,0 +1,272 @@ +use std::collections::HashMap; + +use crate::config::WalletConfigInner; +use crate::utils::output::FormatOutput; +use crate::utils::shorten; +use bdk_wallet::Balance; +use bdk_wallet::bitcoin::{ + Address, Network, Psbt, Transaction, base64::Engine, consensus::encode::serialize_hex, +}; +use bdk_wallet::{AddressInfo, LocalOutput, chain::ChainPosition}; +use serde::Serialize; +use serde_json::json; + +/// Represent address result +#[derive(Serialize)] +pub struct AddressResult { + pub address: String, + pub index: u32, +} + +impl From for AddressResult { + fn from(info: AddressInfo) -> Self { + Self { + address: info.address.to_string(), + index: info.index, + } + } +} + +impl FormatOutput for AddressResult {} + +/// Represents the data for a single transaction +#[derive(Serialize)] +pub struct TransactionDetails { + pub txid: String, + pub is_coinbase: bool, + pub wtxid: String, + pub version: serde_json::Value, + pub is_rbf: bool, + pub inputs: serde_json::Value, + pub outputs: serde_json::Value, + #[serde(skip)] + pub version_display: String, + #[serde(skip)] + pub input_count: usize, + #[serde(skip)] + pub output_count: usize, + #[serde(skip)] + pub total_value: u64, +} + +/// A wrapper type for a list of transactions. +#[derive(Serialize)] +#[serde(transparent)] +pub struct TransactionListResult(pub Vec); + +impl FormatOutput for TransactionListResult {} + +/// single UTXO +#[derive(Serialize)] +pub struct UnspentDetails { + pub outpoint: String, + pub txout: serde_json::Value, + pub keychain: String, + pub is_spent: bool, + pub derivation_index: u32, + pub chain_position: serde_json::Value, + +} + +impl UnspentDetails { + pub fn from_local_output(utxo: &LocalOutput, network: Network) -> Self { + let height = utxo.chain_position.confirmation_height_upper_bound(); + let height_display = height + .map(|h| h.to_string()) + .unwrap_or_else(|| "Pending".to_string()); + + let (_, block_hash_display) = match &utxo.chain_position { + ChainPosition::Confirmed { anchor, .. } => { + let hash = anchor.block_id.hash.to_string(); + (Some(hash.clone()), shorten(&hash, 8, 8)) + } + ChainPosition::Unconfirmed { .. } => (None, "Unconfirmed".to_string()), + }; + + let address = Address::from_script(&utxo.txout.script_pubkey, network) + .map(|a| a.to_string()) + .unwrap_or_else(|_| "Unknown Script".to_string()); + + let outpoint_str = utxo.outpoint.to_string(); + + Self { + outpoint: outpoint_str.clone(), + txout: serde_json::to_value(&utxo.txout).unwrap_or(json!({})), + keychain: format!("{:?}", utxo.keychain), + is_spent: utxo.is_spent, + derivation_index: utxo.derivation_index, + chain_position: serde_json::to_value(utxo.chain_position).unwrap_or(json!({})), + } + } +} + +/// Wrapper for the list of UTXOs +#[derive(Serialize)] +#[serde(transparent)] +pub struct UnspentListResult(pub Vec); + +impl FormatOutput for UnspentListResult {} + +#[derive(Serialize)] +pub struct PsbtResult { + pub psbt: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub is_finalized: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl PsbtResult { + pub fn new(psbt: &Psbt, verbose: bool, finalized: Option) -> Self { + Self { + psbt: bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD.encode(psbt.serialize()), + is_finalized: finalized, + details: if verbose { + Some(serde_json::to_value(psbt).unwrap_or_default()) + } else { + None + }, + } + } +} + +impl FormatOutput for PsbtResult {} + +#[derive(Serialize)] +pub struct RawPsbt { + pub raw_tx: String, +} + +impl RawPsbt { + pub fn new(tx: &Transaction) -> Self { + Self { + raw_tx: serialize_hex(tx), + } + } +} + +impl FormatOutput for RawPsbt {} + +#[derive(Serialize)] +pub struct KeychainPair { + pub external: T, + pub internal: T, +} + +impl FormatOutput for KeychainPair {} + +// Table formatting for JSON value pairs (used by Policies) +impl FormatOutput for KeychainPair {} + +#[derive(Serialize, Debug, Default)] +pub struct MessageResult { + #[serde(skip_serializing_if = "Option::is_none")] + pub proof: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub valid: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub proven_amount: Option, +} + +impl FormatOutput for MessageResult {} + +#[derive(Serialize, Debug)] +pub struct StatusResult { + pub message: String, +} + +impl StatusResult { + pub fn new(msg: &str) -> Self { + Self { + message: msg.to_string(), + } + } +} + +impl FormatOutput for StatusResult {} + +#[derive(Serialize, Debug)] +pub struct TransactionResult { + pub txid: String, +} + +impl FormatOutput for TransactionResult {} + + + +/// Return type definition +#[derive(Serialize)] +#[serde(transparent)] +pub struct WalletsListResult(pub HashMap); + +impl FormatOutput for WalletsListResult {} + + +/// return type +#[derive(Serialize)] +pub struct DescriptorResult { + #[serde(skip_serializing_if = "Option::is_none")] + pub descriptor: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub multipath_descriptor: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub public_descriptors: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub private_descriptors: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub mnemonic: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, +} + +impl FormatOutput for DescriptorResult {} + +#[derive(Serialize)] +pub struct KeyResult { + pub xprv: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub xpub: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub mnemonic: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, +} + +impl FormatOutput for KeyResult {} + + +/// Balance representation +#[derive(Serialize)] +pub struct BalanceResult { + pub total: u64, + pub trusted_pending: u64, + pub untrusted_pending: u64, + pub immature: u64, + pub confirmed: u64, +} + +impl From for BalanceResult { + fn from(b: Balance) -> Self { + Self { + total: b.total().to_sat(), + confirmed: b.confirmed.to_sat(), + trusted_pending: b.trusted_pending.to_sat(), + untrusted_pending: b.untrusted_pending.to_sat(), + immature: b.immature.to_sat(), + } + } +} + +impl FormatOutput for BalanceResult {} From 389bf5c53826a4959fd305d11c6bf7175a622ce6 Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Thu, 21 May 2026 16:20:00 +0100 Subject: [PATCH 18/20] ref(main): add run to handle routing - add run fn in main to handle routing --- src/client.rs | 66 +++++++++++++++------- src/config.rs | 6 +- src/main.rs | 149 ++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 186 insertions(+), 35 deletions(-) diff --git a/src/client.rs b/src/client.rs index 6c024411..db31bf69 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,10 +1,7 @@ -use crate::error::BDKCliError as Error; +#[cfg(feature = "rpc")] +use bdk_bitcoind_rpc::{Emitter, bitcoincore_rpc::RpcApi}; #[cfg(feature = "esplora")] use bdk_esplora::EsploraAsyncExt; -use bdk_wallet::{ - bitcoin::{Transaction, Txid}, - chain::CanonicalizationParams, -}; #[cfg(any( feature = "electrum", feature = "esplora", @@ -12,14 +9,16 @@ use bdk_wallet::{ feature = "cbf" ))] use { - crate::commands::{ClientType, WalletOpts}, - - bdk_wallet::Wallet, + crate::commands::WalletOpts, + crate::error::BDKCliError as Error, + bdk_wallet::{ + Wallet, + bitcoin::{Transaction, Txid}, + chain::CanonicalizationParams, + }, + clap::ValueEnum, std::path::PathBuf, }; -#[cfg(feature = "rpc")] -use bdk_bitcoind_rpc::{Emitter, bitcoincore_rpc::RpcApi}; - #[cfg(feature = "cbf")] use { @@ -27,6 +26,24 @@ use { bdk_kyoto::{BuilderExt, LightClient}, }; +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] +#[derive(Clone, ValueEnum, Debug, Eq, PartialEq)] +pub enum ClientType { + #[cfg(feature = "electrum")] + Electrum, + #[cfg(feature = "esplora")] + Esplora, + #[cfg(feature = "rpc")] + Rpc, + #[cfg(feature = "cbf")] + Cbf, +} + #[cfg(any( feature = "electrum", feature = "esplora", @@ -50,9 +67,7 @@ pub(crate) enum BlockchainClient { }, #[cfg(feature = "cbf")] - KyotoClient { - client: Box, - }, + KyotoClient { client: Box }, } #[cfg(any( @@ -83,14 +98,23 @@ impl BlockchainClient { #[cfg(feature = "cbf")] Self::KyotoClient { client } => { - // ... (Kyoto broadcast logic from your online.rs) ... - Ok(tx.compute_txid()) + 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()) + })?; + tracing::info!("Successfully broadcast WTXID: {wtxid}"); + Ok(txid) } } } pub async fn sync_wallet(&self, wallet: &mut Wallet) -> Result<(), Error> { - // #[cfg(any(feature = "electrum", feature = "esplora"))] + #[cfg(any(feature = "electrum", feature = "esplora"))] let request = wallet .start_sync_with_revealed_spks() .inspect(|item, progress| { @@ -98,7 +122,7 @@ impl BlockchainClient { eprintln!("[ SCANNING {pc:03.0}% ] {item}"); }); match self { - // #[cfg(feature = "electrum")] + #[cfg(feature = "electrum")] Self::Electrum { client, batch_size } => { // Populate the electrum client's transaction cache so it doesn't re-download transaction we // already have. @@ -109,7 +133,7 @@ impl BlockchainClient { .apply_update(update) .map_err(|e| Error::Generic(e.to_string())) } - // #[cfg(feature = "esplora")] + #[cfg(feature = "esplora")] Self::Esplora { client, parallel_requests, @@ -122,7 +146,7 @@ impl BlockchainClient { .apply_update(update) .map_err(|e| Error::Generic(e.to_string())) } - // #[cfg(feature = "rpc")] + #[cfg(feature = "rpc")] Self::RpcClient { client } => { let blockchain_info = client.get_blockchain_info()?; let wallet_cp = wallet.latest_checkpoint(); @@ -166,7 +190,7 @@ impl BlockchainClient { wallet.apply_unconfirmed_txs(mempool_txs.update); Ok(()) } - // #[cfg(feature = "cbf")] + #[cfg(feature = "cbf")] Self::KyotoClient { client } => sync_kyoto_client(wallet, client) .await .map_err(|e| Error::Generic(e.to_string())), diff --git a/src/config.rs b/src/config.rs index 197dc07c..ec6adfaa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,11 +4,11 @@ feature = "rpc", feature = "cbf" ))] -use crate::commands::ClientType; -#[cfg(feature = "sqlite")] -use crate::commands::DatabaseType; +use crate::client::ClientType; use crate::commands::WalletOpts; use crate::error::BDKCliError as Error; +#[cfg(feature = "sqlite")] +use crate::persister::DatabaseType; use bdk_wallet::bitcoin::Network; #[cfg(any(feature = "sqlite", feature = "redb"))] use clap::ValueEnum; diff --git a/src/main.rs b/src/main.rs index 64600bef..df5cfc4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,7 @@ #![doc(html_logo_url = "https://github.com/bitcoindevkit/bdk/raw/master/static/bdk.png")] #![warn(missing_docs)] -mod backend; +mod client; mod commands; mod config; mod error; @@ -22,14 +22,29 @@ mod handlers; feature = "rpc" ))] mod payjoin; +mod persister; mod utils; -mod wallet; +#[cfg(feature = "redb")] +use bdk_redb::Store as RedbStore; use bdk_wallet::bitcoin::Network; -use log::{debug, error, warn}; +use log::{debug, warn}; -use crate::commands::CliOpts; -use crate::handlers::handle_command; +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] +use crate::client::new_blockchain_client; +use crate::commands::{CliOpts, CliSubCommand, WalletSubCommand}; +use crate::error::BDKCliError as Error; +use crate::handlers::{AppCommand, AppContext}; +#[cfg(any(feature = "sqlite", feature = "redb"))] +use crate::persister::{Persister, new_persisted_wallet}; +use crate::utils::output::FormatOutput; +use crate::utils::prepare_wallet_db_dir; +use crate::utils::{load_wallet_config, prepare_home_dir}; use clap::Parser; #[tokio::main] @@ -45,11 +60,123 @@ async fn main() { ) } - match handle_command(cli_opts).await { - Ok(result) => println!("{result}"), - Err(e) => { - error!("{e}"); - std::process::exit(1); - } + if let Err(e) = run(cli_opts).await { + eprintln!("Error: {}", e); + std::process::exit(1); } } + +async fn run(cli_opts: CliOpts) -> Result<(), Error> { + let datadir = cli_opts.datadir.clone(); + let home_dir = prepare_home_dir(datadir)?; + + match cli_opts.subcommand.clone() { + CliSubCommand::Wallet { + wallet: wallet_name, + subcommand, + } => match subcommand { + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + WalletSubCommand::OnlineWalletSubCommand(online_cmd) => { + let (wallet_opts, network) = load_wallet_config(&home_dir, &wallet_name)?; + + let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?; + #[cfg(any(feature = "sqlite", feature = "redb"))] + let mut persister: Persister = match &wallet_opts.database_type { + #[cfg(feature = "sqlite")] + crate::persister::DatabaseType::Sqlite => { + let db_file = database_path.join("wallet.sqlite"); + let connection = bdk_wallet::rusqlite::Connection::open(db_file)?; + Persister::Connection(connection) + } + #[cfg(feature = "redb")] + crate::persister::DatabaseType::Redb => { + use crate::persister::Persister; + + let db = std::sync::Arc::new(bdk_redb::redb::Database::create( + home_dir.join("wallet.redb"), + )?); + let store = RedbStore::new(db, wallet_name)?; + log::debug!("Redb database opened successfully"); + Persister::RedbStore(store) + } + }; + + let mut wallet = new_persisted_wallet(network, &mut persister, &wallet_opts)?; + + let client = new_blockchain_client(&wallet_opts, &wallet, database_path)?; + + let mut ctx = AppContext::new(network, home_dir) + .with_wallet(&mut wallet) + .with_client(&client); + + online_cmd.execute(&mut ctx).await?; + } + WalletSubCommand::OfflineWalletSubCommand(offline_cmd) => { + let (wallet_opts, network) = load_wallet_config(&home_dir, &wallet_name)?; + + let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?; + + #[cfg(any(feature = "sqlite", feature = "redb"))] + let mut persister: Persister = match &wallet_opts.database_type { + #[cfg(feature = "sqlite")] + crate::persister::DatabaseType::Sqlite => { + let db_file = database_path.join("wallet.sqlite"); + let connection = bdk_wallet::rusqlite::Connection::open(db_file)?; + Persister::Connection(connection) + } + #[cfg(feature = "redb")] + crate::persister::DatabaseType::Redb => { + use crate::persister::Persister; + let db = std::sync::Arc::new(bdk_redb::redb::Database::create( + home_dir.join("wallet.redb"), + )?); + let store = RedbStore::new(db, wallet_name)?; + Persister::RedbStore(store) + } + }; + + let mut wallet = new_persisted_wallet(network, &mut persister, &wallet_opts)?; + + let mut ctx = AppContext::new(network, home_dir).with_wallet(&mut wallet); + + offline_cmd.execute(&mut ctx)?; + } + WalletSubCommand::Config(mut config_cmd) => { + config_cmd.wallet_opts.wallet = Some(wallet_name); + + let mut ctx = AppContext::new(cli_opts.network, home_dir); + + config_cmd.execute(&mut ctx)?.print()?; + } + }, + + CliSubCommand::Key { subcommand } => { + let mut ctx = AppContext::new(cli_opts.network, home_dir); + subcommand.execute(&mut ctx)?; + } + CliSubCommand::Descriptor(descriptor_command) => { + let mut ctx = AppContext::new(cli_opts.network, home_dir); + descriptor_command.execute(&mut ctx)?.print()?; + } + CliSubCommand::Wallets(cmd) => { + let mut ctx = AppContext::new(cli_opts.network, home_dir); + cmd.execute(&mut ctx)?.print()?; + } + CliSubCommand::Repl { wallet: _ } => todo!(), + CliSubCommand::Completions { shell } => { + shell; + } + #[cfg(feature = "compiler")] + CliSubCommand::Compile(cmd) => { + let mut ctx = AppContext::new(cli_opts.network, home_dir); + cmd.execute(&mut ctx)?.print()?; + } + }; + + Ok(()) +} From 306069ab8093387c098399f6be540401925b980d Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Thu, 21 May 2026 16:22:04 +0100 Subject: [PATCH 19/20] ref(handlers): refactor config, descr and key mods - move execution logic into config, desc and key modules --- src/handlers/config.rs | 244 ++++++++++++++++++++----------------- src/handlers/descriptor.rs | 151 ++++++++++++----------- src/handlers/key.rs | 228 +++++++++++++++++++++------------- 3 files changed, 355 insertions(+), 268 deletions(-) diff --git a/src/handlers/config.rs b/src/handlers/config.rs index e64d1654..fe64132f 100644 --- a/src/handlers/config.rs +++ b/src/handlers/config.rs @@ -1,14 +1,4 @@ use std::collections::HashMap; -use std::path::Path; - -#[cfg(any(feature = "sqlite", feature = "redb"))] -#[cfg(feature = "sqlite")] -use crate::commands::DatabaseType; -use crate::commands::WalletOpts; -use crate::config::{WalletConfig, WalletConfigInner}; -use crate::error::BDKCliError as Error; -use bdk_wallet::bitcoin::Network; -use serde_json::json; #[cfg(any( feature = "electrum", @@ -16,121 +6,153 @@ use serde_json::json; feature = "rpc", feature = "cbf" ))] -use crate::commands::ClientType; - -/// Handle wallet config subcommand to create or update config.toml -pub fn handle_config_subcommand( - datadir: &Path, - network: Network, - wallet: String, - wallet_opts: &WalletOpts, - force: bool, -) -> Result { - if network == Network::Bitcoin { - eprintln!( - "WARNING: You are configuring a wallet for Bitcoin MAINNET. - This software is experimental and not recommended for use with real funds. - Consider using a testnet for testing purposes. \n" - ); - } +use crate::client::ClientType; +use crate::commands::WalletOpts; +use crate::config::{WalletConfig, WalletConfigInner}; +use crate::error::BDKCliError as Error; +use crate::handlers::{AppCommand, AppContext}; +#[cfg(feature = "sqlite")] +use crate::persister::DatabaseType; +use crate::utils::types::{StatusResult, WalletsListResult}; +use bdk_wallet::bitcoin::Network; +use clap::Args; + +#[derive(Args, Debug, Clone, PartialEq)] +pub struct SaveConfigCommand { + /// Overwrite existing wallet configuration if it exists. + #[arg(short = 'f', long = "force", default_value_t = false)] + pub(crate) force: bool, + + #[command(flatten)] + pub(crate) wallet_opts: WalletOpts, +} + +impl AppCommand for SaveConfigCommand { + type Output = StatusResult; + + fn execute(&self, ctx: &mut AppContext<'_>) -> Result { + if ctx.network == Network::Bitcoin { + eprintln!("WARNING: Configuring for Bitcoin MAINNET. Experimental software!"); + } + + let wallet_name = match &self.wallet_opts.wallet { + Some(wallet) => wallet, + None => return Err(Error::Generic("wallet is required".to_owned())), + }; - let ext_descriptor = wallet_opts.ext_descriptor.clone(); - let int_descriptor = wallet_opts.int_descriptor.clone(); + let ext_descriptor = self.wallet_opts.ext_descriptor.clone(); + let int_descriptor = self.wallet_opts.int_descriptor.clone(); - if ext_descriptor.contains("xprv") || ext_descriptor.contains("tprv") { - eprintln!( - "WARNING: Your external descriptor contains PRIVATE KEYS. + if ext_descriptor.contains("xprv") || ext_descriptor.contains("tprv") { + eprintln!( + "WARNING: Your external descriptor contains PRIVATE KEYS. Private keys will be saved in PLAINTEXT in the config file. This is a security risk. Consider using public descriptors instead.\n" - ); - } + ); + } - if let Some(ref internal_desc) = int_descriptor - && (internal_desc.contains("xprv") || internal_desc.contains("tprv")) - { - eprintln!( - "WARNING: Your internal descriptor contains PRIVATE KEYS. + if let Some(ref internal_desc) = int_descriptor + && (internal_desc.contains("xprv") || internal_desc.contains("tprv")) + { + eprintln!( + "WARNING: Your internal descriptor contains PRIVATE KEYS. Private keys will be saved in PLAINTEXT in the config file. This is a security risk. Consider using public descriptors instead.\n" - ); - } + ); + } - let mut config = WalletConfig::load(datadir)?.unwrap_or(WalletConfig { - wallets: HashMap::new(), - }); + let mut config = WalletConfig::load(&ctx.datadir)?.unwrap_or(WalletConfig { + wallets: HashMap::new(), + }); - if config.wallets.contains_key(&wallet) && !force { - return Err(Error::Generic(format!( - "Wallet '{wallet}' already exists in config.toml. Use --force to overwrite." - ))); - } + if config.wallets.contains_key(wallet_name.as_str()) && !self.force { + return Err(Error::Generic(format!( + "Wallet '{}' already exists. Use --force to overwrite.", + wallet_name + ))); + }; - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] - let client_type = wallet_opts.client_type.clone(); - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] - let url = &wallet_opts.url.clone(); - #[cfg(any(feature = "sqlite", feature = "redb"))] - let database_type = match wallet_opts.database_type { - #[cfg(feature = "sqlite")] - DatabaseType::Sqlite => "sqlite".to_string(), - #[cfg(feature = "redb")] - DatabaseType::Redb => "redb".to_string(), - }; - - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] - let client_type = match client_type { - #[cfg(feature = "electrum")] - ClientType::Electrum => "electrum".to_string(), - #[cfg(feature = "esplora")] - ClientType::Esplora => "esplora".to_string(), - #[cfg(feature = "rpc")] - ClientType::Rpc => "rpc".to_string(), - #[cfg(feature = "cbf")] - ClientType::Cbf => "cbf".to_string(), - }; - - let wallet_config = WalletConfigInner { - wallet: wallet.clone(), - network: network.to_string(), - ext_descriptor, - int_descriptor, - #[cfg(any(feature = "sqlite", feature = "redb"))] - database_type, #[cfg(any( feature = "electrum", feature = "esplora", feature = "rpc", feature = "cbf" ))] - client_type: Some(client_type), - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc",))] - server_url: Some(url.to_string()), - #[cfg(feature = "rpc")] - rpc_user: Some(wallet_opts.basic_auth.0.clone()), - #[cfg(feature = "rpc")] - rpc_password: Some(wallet_opts.basic_auth.1.clone()), - #[cfg(feature = "electrum")] - batch_size: Some(wallet_opts.batch_size), - #[cfg(feature = "esplora")] - parallel_requests: Some(wallet_opts.parallel_requests), - #[cfg(feature = "rpc")] - cookie: wallet_opts.cookie.clone(), - }; - - config.wallets.insert(wallet.clone(), wallet_config); - config.save(datadir)?; - - Ok(serde_json::to_string_pretty(&json!({ - "message": format!("Wallet '{wallet}' initialized successfully in {:?}", datadir.join("config.toml")) - }))?) + let client_type = match self.wallet_opts.client_type.clone() { + #[cfg(feature = "electrum")] + ClientType::Electrum => "electrum".to_string(), + #[cfg(feature = "esplora")] + ClientType::Esplora => "esplora".to_string(), + #[cfg(feature = "rpc")] + ClientType::Rpc => "rpc".to_string(), + #[cfg(feature = "cbf")] + ClientType::Cbf => "cbf".to_string(), + }; + + let wallet_config = WalletConfigInner { + wallet: wallet_name.clone(), + network: ctx.network.to_string(), + ext_descriptor: self.wallet_opts.ext_descriptor.clone(), + int_descriptor: self.wallet_opts.int_descriptor.clone(), + + #[cfg(any(feature = "sqlite", feature = "redb"))] + database_type: match self.wallet_opts.database_type { + #[cfg(feature = "sqlite")] + DatabaseType::Sqlite => "sqlite".to_string(), + #[cfg(feature = "redb")] + DatabaseType::Redb => "redb".to_string(), + }, + + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + client_type: Some(client_type), + + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] + server_url: Some(self.wallet_opts.url.clone()), + + #[cfg(feature = "rpc")] + rpc_user: Some(self.wallet_opts.basic_auth.0.clone()), + #[cfg(feature = "rpc")] + rpc_password: Some(self.wallet_opts.basic_auth.1.clone()), + #[cfg(feature = "electrum")] + batch_size: Some(self.wallet_opts.batch_size), + #[cfg(feature = "esplora")] + parallel_requests: Some(self.wallet_opts.parallel_requests), + #[cfg(feature = "rpc")] + cookie: self.wallet_opts.cookie.clone(), + }; + + config.wallets.insert(wallet_name.clone(), wallet_config); + config + .save(&ctx.datadir) + .map_err(|error| Error::Generic(error.to_string()))?; + + Ok(StatusResult { + message: format!( + "Wallet '{}' initialized successfully in {:?}", + wallet_name, + ctx.datadir.join("config.toml") + ), + }) + } +} + +#[derive(Args, Debug, Clone, PartialEq)] +pub struct ListWalletsCommand {} + +impl AppCommand for ListWalletsCommand { + type Output = WalletsListResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let config = match WalletConfig::load(&ctx.datadir)? { + Some(cfg) => cfg, + None => return Err(Error::Generic("No wallets configured yet.".into())), + }; + + Ok(WalletsListResult(config.wallets)) + } } diff --git a/src/handlers/descriptor.rs b/src/handlers/descriptor.rs index 9babbc8e..188abd81 100644 --- a/src/handlers/descriptor.rs +++ b/src/handlers/descriptor.rs @@ -1,105 +1,114 @@ +use crate::utils::types::DescriptorResult; use crate::{ error::BDKCliError as Error, + handlers::{AppCommand, AppContext}, utils::{ descriptors::{ generate_descriptor_from_mnemonic, generate_descriptor_with_mnemonic, generate_descriptors, }, is_mnemonic, - output::FormatOutput, }, }; - +use clap::Parser; #[cfg(feature = "compiler")] use { - crate::handlers::types::DescriptorResult, bdk_wallet::{ bitcoin::XOnlyPublicKey, - miniscript::{ - Descriptor, Legacy, Miniscript, Segwitv0, Tap, descriptor::TapTree, policy::Concrete, - }, + miniscript::{Descriptor, Miniscript, descriptor::TapTree, policy::Concrete}, }, std::{str::FromStr, sync::Arc}, }; -use bdk_wallet::bitcoin::Network; - #[cfg(feature = "compiler")] const NUMS_UNSPENDABLE_KEY_HEX: &str = "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"; -/// Handle the top-level `descriptor` command -pub fn handle_descriptor_command( - network: Network, - desc_type: String, +#[derive(Parser, Debug, Clone, PartialEq)] +pub struct DescriptorCommand { + /// Descriptor type (script type) + #[arg( + long = "type", + short = 't', + value_parser = ["pkh", "wpkh", "sh", "wsh", "tr"], + default_value = "wsh" + )] + pub(crate) desc_type: String, + + /// Optional key: xprv, xpub, or mnemonic phrase key: Option, - pretty: bool, -) -> Result { - let result = match key { - Some(key) => { - if is_mnemonic(&key) { - // User provided mnemonic - generate_descriptor_from_mnemonic(&key, network, &desc_type) - } else { - // User provided xprv/xpub - generate_descriptors(&desc_type, &key, network) +} +impl AppCommand for DescriptorCommand { + type Output = DescriptorResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + match &self.key { + Some(key) => { + if is_mnemonic(key) { + generate_descriptor_from_mnemonic(key, ctx.network, &self.desc_type) + } else { + generate_descriptors(&self.desc_type, key, ctx.network) + } } + None => generate_descriptor_with_mnemonic(ctx.network, &self.desc_type), } - // Generate new mnemonic and descriptors - None => generate_descriptor_with_mnemonic(network, &desc_type), - }?; - result.format(pretty) + } } -/// Handle the miniscript compiler sub-command -/// -/// Compiler options are described in [`CliSubCommand::Compile`]. #[cfg(feature = "compiler")] -pub(crate) fn handle_compile_subcommand( - _network: Network, +#[derive(Parser, Debug, Clone, PartialEq)] +pub struct CompileCommand { + /// Sets the spending policy to compile. + #[arg(env = "POLICY", required = true, index = 1)] policy: String, + /// Sets the script type used to embed the compiled policy. + #[arg(env = "TYPE", short = 't', long = "type", default_value = "wsh", value_parser = ["sh","wsh", "sh-wsh", "tr"] + )] script_type: String, - pretty: bool, -) -> Result { - let policy = Concrete::::from_str(policy.as_str())?; - let legacy_policy: Miniscript = policy - .compile() - .map_err(|e| Error::Generic(e.to_string()))?; - let segwit_policy: Miniscript = policy - .compile() - .map_err(|e| Error::Generic(e.to_string()))?; - let taproot_policy: Miniscript = policy - .compile() - .map_err(|e| Error::Generic(e.to_string()))?; +} - let descriptor = match script_type.as_str() { - "sh" => Descriptor::new_sh(legacy_policy), - "wsh" => Descriptor::new_wsh(segwit_policy), - "sh-wsh" => Descriptor::new_sh_wsh(segwit_policy), - "tr" => { - // For tr descriptors, we use a well-known unspendable key (NUMS point). - // This ensures the key path is effectively disabled and only script path can be used. - // See https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs +#[cfg(feature = "compiler")] +impl AppCommand for CompileCommand { + type Output = DescriptorResult; - let xonly_public_key = XOnlyPublicKey::from_str(NUMS_UNSPENDABLE_KEY_HEX) - .map_err(|e| Error::Generic(format!("Invalid NUMS key: {e}")))?; + fn execute(&self, _ctx: &mut AppContext) -> Result { + let policy: Concrete = Concrete::from_str(&self.policy) + .map_err(|e| Error::Generic(format!("Invalid policy: {e}")))?; - let tree = TapTree::Leaf(Arc::new(taproot_policy)); - Descriptor::new_tr(xonly_public_key.to_string(), Some(tree)) - } - _ => { - return Err(Error::Generic( - "Invalid script type. Supported types: sh, wsh, sh-wsh, tr".to_string(), - )); - } - }?; - let result = DescriptorResult { - descriptor: Some(descriptor.to_string()), - multipath_descriptor: None, - public_descriptors: None, - private_descriptors: None, - mnemonic: None, - fingerprint: None, - }; - result.format(pretty) + let legacy_policy: Miniscript = policy + .compile() + .map_err(|e| Error::Generic(e.to_string()))?; + let segwit_policy: Miniscript = policy + .compile() + .map_err(|e| Error::Generic(e.to_string()))?; + let taproot_policy: Miniscript = policy + .compile() + .map_err(|e| Error::Generic(e.to_string()))?; + + let descriptor = match self.script_type.as_str() { + "sh" => Descriptor::new_sh(legacy_policy), + "wsh" => Descriptor::new_wsh(segwit_policy), + "sh-wsh" => Descriptor::new_sh_wsh(segwit_policy), + "tr" => { + let xonly_public_key = XOnlyPublicKey::from_str(NUMS_UNSPENDABLE_KEY_HEX) + .map_err(|e| Error::Generic(format!("Invalid NUMS key: {e}")))?; + let tree = TapTree::Leaf(Arc::new(taproot_policy)); + Descriptor::new_tr(xonly_public_key.to_string(), Some(tree)) + } + _ => { + return Err(Error::Generic( + "Invalid script type. Supported: sh, wsh, sh-wsh, tr".into(), + )); + } + }?; + + Ok(DescriptorResult { + descriptor: Some(descriptor.to_string()), + mnemonic: None, + multipath_descriptor: None, + public_descriptors: None, + private_descriptors: None, + fingerprint: None, + }) + } } diff --git a/src/handlers/key.rs b/src/handlers/key.rs index abf3833b..6f3960ee 100644 --- a/src/handlers/key.rs +++ b/src/handlers/key.rs @@ -1,101 +1,157 @@ use crate::commands::KeySubCommand; use crate::error::BDKCliError as Error; -use crate::handlers::types::KeyResult; +use crate::handlers::{AppCommand, AppContext}; use crate::utils::output::FormatOutput; +use crate::utils::types::KeyResult; use bdk_wallet::bip39::{Language, Mnemonic}; +use bdk_wallet::bitcoin::bip32::DerivationPath; use bdk_wallet::bitcoin::bip32::KeySource; +use bdk_wallet::bitcoin::bip32::Xpriv; use bdk_wallet::bitcoin::key::Secp256k1; -use bdk_wallet::bitcoin::{Network, bip32::DerivationPath}; use bdk_wallet::keys::bip39::WordCount; use bdk_wallet::keys::{DerivableKey, GeneratableKey}; -use bdk_wallet::keys::{DescriptorKey, DescriptorKey::Secret, ExtendedKey, GeneratedKey}; +use bdk_wallet::keys::{DescriptorKey, ExtendedKey, GeneratedKey}; use bdk_wallet::miniscript::{self, Segwitv0}; +use clap::Parser; -/// Handle a key sub-command -/// -/// Key sub-commands are described in [`KeySubCommand`]. -pub(crate) fn handle_key_subcommand( - network: Network, - subcommand: KeySubCommand, - pretty: bool, -) -> Result { - let secp = Secp256k1::new(); - - match subcommand { - KeySubCommand::Generate { - word_count, - password, - } => { - let mnemonic_type = match word_count { - 12 => WordCount::Words12, - _ => WordCount::Words24, - }; - let mnemonic: GeneratedKey<_, miniscript::BareCtx> = - Mnemonic::generate((mnemonic_type, Language::English)) - .map_err(|_| Error::Generic("Mnemonic generation error".to_string()))?; - let mnemonic = mnemonic.into_key(); - let xkey: ExtendedKey = (mnemonic.clone(), password).into_extended_key()?; - let xprv = xkey.into_xprv(network).ok_or_else(|| { - Error::Generic("Privatekey info not found (should not happen)".to_string()) - })?; - let fingerprint = xprv.fingerprint(&secp); - let phrase = mnemonic - .words() - .fold("".to_string(), |phrase, w| phrase + w + " ") - .trim() - .to_string(); - - let result = KeyResult { - xprv: xprv.to_string(), - mnemonic: Some(phrase), - fingerprint: Some(fingerprint.to_string()), - xpub: None, - }; - - result.format(pretty) - } - KeySubCommand::Restore { mnemonic, password } => { - let mnemonic = Mnemonic::parse_in(Language::English, mnemonic)?; - let xkey: ExtendedKey = (mnemonic.clone(), password).into_extended_key()?; - let xprv = xkey.into_xprv(network).ok_or_else(|| { - Error::Generic("Privatekey info not found (should not happen)".to_string()) - })?; - let fingerprint = xprv.fingerprint(&secp); - - let result = KeyResult { - xprv: xprv.to_string(), - mnemonic: Some(mnemonic.to_string()), - fingerprint: Some(fingerprint.to_string()), - xpub: None, - }; - result.format(pretty) - } - KeySubCommand::Derive { xprv, path } => { - if xprv.network != network.into() { - return Err(Error::Generic("Invalid network".to_string())); + + +impl KeySubCommand { + pub fn execute(&self, ctx: &mut AppContext) -> Result<(), Error> { + match self { + KeySubCommand::Generate(generate_key_command) => { + generate_key_command.execute(ctx)?.print() } - let derived_xprv = &xprv.derive_priv(&secp, &path)?; - - let origin: KeySource = (xprv.fingerprint(&secp), path); - - let derived_xprv_desc_key: DescriptorKey = - derived_xprv.into_descriptor_key(Some(origin), DerivationPath::default())?; - - if let Secret(desc_seckey, _, _) = derived_xprv_desc_key { - let desc_pubkey = desc_seckey.to_public(&secp)?; - - let result = KeyResult { - xprv: desc_seckey.to_string(), - xpub: Some(desc_pubkey.to_string()), - mnemonic: None, - fingerprint: None, - }; - result.format(pretty) - } else { - Err(Error::Generic( - "Derived key is not a secret key".to_string(), - )) + KeySubCommand::Restore(restore_key_command) => { + restore_key_command.execute(ctx)?.print() } + KeySubCommand::Derive(derive_key_command) => derive_key_command.execute(ctx)?.print(), } } } +#[derive(Parser, Debug, Clone, PartialEq, Eq)] +pub struct GenerateKeyCommand { + /// Entropy level based on number of random seed mnemonic words. + #[arg( + env = "WORD_COUNT", + short = 'e', + long = "entropy", + default_value = "12" + )] + word_count: usize, + /// Seed password. + #[arg(env = "PASSWORD", short = 'p', long = "password")] + password: Option, +} + +impl AppCommand for GenerateKeyCommand { + type Output = KeyResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let secp = Secp256k1::new(); + let mnemonic_type = match self.word_count { + 12 => WordCount::Words12, + _ => WordCount::Words24, + }; + + let mnemonic: GeneratedKey<_, miniscript::BareCtx> = + Mnemonic::generate((mnemonic_type, Language::English)) + .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(|| { + Error::Generic("Privatekey info not found (should not happen)".to_string()) + })?; + let fingerprint = xprv.fingerprint(&secp); + let phrase = mnemonic + .words() + .fold("".to_string(), |phrase, w| phrase + w + " ") + .trim() + .to_string(); + + Ok(KeyResult { + xprv: xprv.to_string(), + mnemonic: Some(phrase), + fingerprint: Some(fingerprint.to_string()), + xpub: None, + }) + } +} + +#[derive(Parser, Debug, Clone, PartialEq, Eq)] +pub struct DeriveKeyCommand { + /// Extended private key to derive from. + #[arg(env = "XPRV", short = 'x', long = "xprv")] + xprv: Xpriv, + /// Path to use to derive extended public key from extended private key. + #[arg(env = "DERIVATION_PATH", short = 'p', long = "derivation_path")] + path: DerivationPath, +} + +impl AppCommand for DeriveKeyCommand { + type Output = KeyResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let secp = Secp256k1::new(); + + let derived_xprv = &self.xprv.derive_priv(&secp, &self.path)?; + + let origin: KeySource = (self.xprv.fingerprint(&secp), self.path.clone()); + + if self.xprv.network != ctx.network.into() { + return Err(Error::Generic( + "Extended key network does not match current network".to_string(), + )); + } + + let derived_xprv_desc_key: DescriptorKey = + derived_xprv.into_descriptor_key(Some(origin), DerivationPath::default())?; + + if let DescriptorKey::Secret(desc_seckey, _, _) = derived_xprv_desc_key { + let desc_pubkey = desc_seckey.to_public(&secp)?; + + Ok(KeyResult { + xprv: desc_seckey.to_string(), + xpub: Some(desc_pubkey.to_string()), + mnemonic: None, + fingerprint: None, + }) + } else { + Err(Error::Generic( + "Derived key is not a secret key".to_string(), + )) + } + } +} + +#[derive(Parser, Debug, Clone, PartialEq, Eq)] +pub struct RestoreKeyCommand { + /// Seed mnemonic words, must be quoted (eg. "word1 word2 ..."). + #[arg(env = "MNEMONIC", short = 'm', long = "mnemonic")] + mnemonic: String, + /// Seed password. + #[arg(env = "PASSWORD", short = 'p', long = "password")] + password: Option, +} + +impl AppCommand for RestoreKeyCommand { + type Output = KeyResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let secp = Secp256k1::new(); + + 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(|| { + Error::Generic("Privatekey info not found (should not happen)".to_string()) + })?; + let fingerprint = xprv.fingerprint(&secp); + + Ok(KeyResult { + xprv: xprv.to_string(), + mnemonic: Some(mnemonic.to_string()), + fingerprint: Some(fingerprint.to_string()), + xpub: None, + }) + } +} From de1fc98296f663477d1dcd0b936633b51ff23163 Mon Sep 17 00:00:00 2001 From: Vihiga Tyonum Date: Thu, 21 May 2026 16:23:38 +0100 Subject: [PATCH 20/20] ref(handlers): fix offline, online and desc mod - fix execution logic for subcommands under offline and online wallet subcommands --- src/commands.rs | 90 +--- src/handlers/key.rs | 2 - src/handlers/mod.rs | 348 +++------------- src/handlers/offline.rs | 863 +++++++++++++++++++++++++++------------ src/handlers/online.rs | 664 ++++++++++++++++++------------ src/handlers/repl.rs | 126 +++--- src/main.rs | 2 +- src/payjoin/mod.rs | 12 +- src/utils/common.rs | 2 +- src/utils/descriptors.rs | 3 +- src/utils/mod.rs | 1 + src/utils/output.rs | 33 +- src/utils/types.rs | 5 - 13 files changed, 1153 insertions(+), 998 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index cb1c9251..52c24045 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -24,9 +24,6 @@ use crate::handlers::{ FinalizePsbtCommand, NewAddressCommand, PoliciesCommand, PublicDescriptorCommand, SignCommand, TransactionsCommand, UnspentCommand, UnusedAddressCommand, }, - online::{ - BroadcastCommand, FullScanCommand, ReceivePayjoinCommand, SendPayjoinCommand, SyncCommand, - }, }; #[cfg(any( @@ -35,7 +32,12 @@ use crate::handlers::{ feature = "rpc", feature = "cbf" ))] -use crate::client::ClientType; +use crate::{ + client::ClientType, + online::{ + BroadcastCommand, FullScanCommand, ReceivePayjoinCommand, SendPayjoinCommand, SyncCommand, + }, +}; #[cfg(feature = "compiler")] use crate::handlers::descriptor::CompileCommand; @@ -46,7 +48,7 @@ use bdk_wallet::bitcoin::Network; use clap::{Args, Parser, Subcommand, value_parser}; use clap_complete::Shell; -// #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] +#[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] use crate::utils::parse_proxy_auth; /// The BDK Command Line Wallet App @@ -197,8 +199,8 @@ pub enum WalletSubCommand { #[cfg(any( feature = "electrum", feature = "esplora", - feature = "rpc", - feature = "cbf" + feature = "cbf", + feature = "rpc" ))] #[command(flatten)] OnlineWalletSubCommand(OnlineWalletSubCommand), @@ -269,7 +271,7 @@ pub struct WalletOpts { } /// Options to configure a SOCKS5 proxy for a blockchain client connection. -// #[cfg(any(feature = "electrum", feature = "esplora"))] +#[cfg(any(feature = "electrum", feature = "esplora"))] #[derive(Debug, Args, Clone, PartialEq, Eq)] pub struct ProxyOpts { /// Sets the SOCKS5 proxy for a blockchain client. @@ -295,7 +297,7 @@ pub struct ProxyOpts { } /// Options to configure a BIP157 Compact Filter backend. -// #[cfg(feature = "cbf")] +#[cfg(feature = "cbf")] #[derive(Debug, Args, Clone, PartialEq, Eq)] pub struct CompactFilterOpts { /// Sets the number of parallel node connections. @@ -336,46 +338,21 @@ pub enum OfflineWalletSubCommand { CombinePsbt(CombinePsbtCommand), /// Sign a message using BIP322 #[cfg(feature = "bip322")] - // SignMessage { - // /// The message to sign - // #[arg(long)] - // message: String, - // /// The signature format (e.g., Legacy, Simple, Full) - // #[arg(long, default_value = "simple")] - // signature_type: String, - // /// Address to sign - // #[arg(long)] - // address: String, - // /// Optional list of specific UTXOs for proof-of-funds (only for `FullWithProofOfFunds`) - // #[arg(long)] - // utxos: Option>, - // }, SignMessage(SignMessageCommand), /// Verify a BIP322 signature #[cfg(feature = "bip322")] - // VerifyMessage { - // /// The signature proof to verify - // #[arg(long)] - // proof: String, - // /// The message that was signed - // #[arg(long)] - // message: String, - // /// The address associated with the signature - // #[arg(long)] - // address: String, - // }, VerifyMessage(VerifyMessageCommand), } /// Wallet subcommands that needs a blockchain backend. #[derive(Debug, Subcommand, Clone, PartialEq, Eq)] #[command(rename_all = "snake")] -// #[cfg(any( -// feature = "electrum", -// feature = "esplora", -// feature = "cbf", -// feature = "rpc" -// ))] +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] pub enum OnlineWalletSubCommand { /// Full Scan with the chosen blockchain server. FullScan(FullScanCommand), @@ -384,41 +361,8 @@ pub enum OnlineWalletSubCommand { /// Broadcasts a transaction to the network. Takes either a raw transaction or a PSBT to extract. Broadcast(BroadcastCommand), /// Generates a Payjoin receive URI and processes the sender's Payjoin proposal. - // ReceivePayjoin { - // /// Amount to be received in sats. - // #[arg(env = "PAYJOIN_AMOUNT", long = "amount", required = true)] - // amount: u64, - // /// Payjoin directory which will be used to store the PSBTs which are pending action - // /// from one of the parties. - // #[arg(env = "PAYJOIN_DIRECTORY", long = "directory", required = true)] - // directory: String, - // /// URL of the Payjoin OHTTP relay. Can be repeated multiple times to attempt the - // /// operation with multiple relays for redundancy. - // #[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)] - // ohttp_relay: Vec, - // /// Maximum effective fee rate the receiver is willing to pay for their own input/output contributions. - // #[arg(env = "PAYJOIN_RECEIVER_MAX_FEE_RATE", long = "max_fee_rate")] - // max_fee_rate: Option, - // }, ReceivePayjoin(ReceivePayjoinCommand), /// Sends an original PSBT to a BIP 21 URI and broadcasts the returned Payjoin PSBT. - // SendPayjoin { - // /// BIP 21 URI for the Payjoin. - // #[arg(env = "PAYJOIN_URI", long = "uri", required = true)] - // uri: String, - // /// URL of the Payjoin OHTTP relay. Can be repeated multiple times to attempt the - // /// operation with multiple relays for redundancy. - // #[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)] - // ohttp_relay: Vec, - // /// Fee rate to use in sat/vbyte. - // #[arg( - // env = "PAYJOIN_SENDER_FEE_RATE", - // short = 'f', - // long = "fee_rate", - // required = true - // )] - // fee_rate: u64, - // }, SendPayjoin(SendPayjoinCommand), } diff --git a/src/handlers/key.rs b/src/handlers/key.rs index 6f3960ee..3f175d77 100644 --- a/src/handlers/key.rs +++ b/src/handlers/key.rs @@ -14,8 +14,6 @@ use bdk_wallet::keys::{DescriptorKey, ExtendedKey, GeneratedKey}; use bdk_wallet::miniscript::{self, Segwitv0}; use clap::Parser; - - impl KeySubCommand { pub fn execute(&self, ctx: &mut AppContext) -> Result<(), Error> { match self { diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 7fb0179d..25b53852 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -4,295 +4,83 @@ pub mod key; pub mod offline; pub mod online; pub mod repl; -pub mod types; -pub mod wallets; -#[cfg(feature = "repl")] -use crate::handlers::repl::respond; -use crate::{ - commands::{CliOpts, CliSubCommand, WalletSubCommand}, - error::BDKCliError as Error, - handlers::{ - config::handle_config_subcommand, descriptor::handle_descriptor_command, - key::handle_key_subcommand, wallets::handle_wallets_subcommand, - }, - utils::{load_wallet_config, prepare_home_dir}, -}; - -#[cfg(any(feature = "sqlite", feature = "redb"))] -use crate::utils::prepare_wallet_db_dir; -#[cfg(not(any(feature = "sqlite", feature = "redb")))] -use crate::wallet::new_wallet; - -#[cfg(feature = "compiler")] -use { - crate::handlers::descriptor::handle_compile_subcommand, bdk_redb::Store as RedbStore, - std::sync::Arc, -}; - -#[cfg(feature = "repl")] -use crate::handlers::repl::readline; - -#[cfg(any(feature = "sqlite", feature = "redb"))] -use crate::commands::DatabaseType; -use crate::handlers::offline::handle_offline_wallet_subcommand; -use clap::CommandFactory; #[cfg(any( feature = "electrum", feature = "esplora", feature = "rpc", - feature = "cbf", + feature = "cbf" ))] -use { - crate::backend::new_blockchain_client, crate::handlers::online::handle_online_wallet_subcommand, -}; -#[cfg(any(feature = "sqlite", feature = "redb"))] -use { - crate::wallet::{new_persisted_wallet, persister::Persister}, - bdk_wallet::rusqlite::Connection, - std::io::Write, -}; - -/// The global top level handler. -pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { - let pretty = cli_opts.pretty; - let subcommand = cli_opts.subcommand.clone(); - - let result: Result = match subcommand { - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" - ))] - CliSubCommand::Wallet { - wallet, - subcommand: WalletSubCommand::OnlineWalletSubCommand(online_subcommand), - } => { - let home_dir = prepare_home_dir(cli_opts.datadir)?; - - let (wallet_opts, network) = load_wallet_config(&home_dir, &wallet)?; - - let database_path = prepare_wallet_db_dir(&home_dir, &wallet)?; - - #[cfg(any(feature = "sqlite", feature = "redb"))] - let result = { - #[cfg(feature = "sqlite")] - let mut persister: Persister = match &wallet_opts.database_type { - #[cfg(feature = "sqlite")] - DatabaseType::Sqlite => { - let db_file = database_path.join("wallet.sqlite"); - let connection = Connection::open(db_file)?; - log::debug!("Sqlite database opened successfully"); - Persister::Connection(connection) - } - #[cfg(feature = "redb")] - DatabaseType::Redb => { - let wallet_name = &wallet_opts.wallet; - let db = Arc::new(bdk_redb::redb::Database::create( - home_dir.join("wallet.redb"), - )?); - let store = RedbStore::new( - db, - wallet_name.as_deref().unwrap_or("wallet").to_string(), - )?; - log::debug!("Redb database opened successfully"); - Persister::RedbStore(store) - } - }; - - let mut wallet = new_persisted_wallet(network, &mut persister, &wallet_opts)?; - let blockchain_client = - new_blockchain_client(&wallet_opts, &wallet, database_path)?; - - let result = handle_online_wallet_subcommand( - &mut wallet, - &blockchain_client, - online_subcommand, - ) - .await?; - wallet.persist(&mut persister)?; - result - }; - #[cfg(not(any(feature = "sqlite", feature = "redb")))] - let result = { - let mut wallet = new_wallet(network, &wallet_opts)?; - let blockchain_client = - new_blockchain_client(&wallet_opts, &wallet, database_path)?; - handle_online_wallet_subcommand(&mut wallet, &blockchain_client, online_subcommand) - .await? - }; - Ok(result) - } - CliSubCommand::Wallet { - wallet: wallet_name, - subcommand: WalletSubCommand::OfflineWalletSubCommand(offline_subcommand), - } => { - let datadir = cli_opts.datadir.clone(); - let home_dir = prepare_home_dir(datadir)?; - let (wallet_opts, network) = load_wallet_config(&home_dir, &wallet_name)?; - - #[cfg(any(feature = "sqlite", feature = "redb"))] - let result = { - let mut persister: Persister = match &wallet_opts.database_type { - #[cfg(feature = "sqlite")] - DatabaseType::Sqlite => { - let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?; - let db_file = database_path.join("wallet.sqlite"); - let connection = Connection::open(db_file)?; - log::debug!("Sqlite database opened successfully"); - Persister::Connection(connection) - } - #[cfg(feature = "redb")] - DatabaseType::Redb => { - let db = Arc::new(bdk_redb::redb::Database::create( - home_dir.join("wallet.redb"), - )?); - let store = RedbStore::new(db, wallet_name)?; - log::debug!("Redb database opened successfully"); - Persister::RedbStore(store) - } - }; - - let mut wallet = new_persisted_wallet(network, &mut persister, &wallet_opts)?; +use crate::client::BlockchainClient; +use std::path::PathBuf; + +use crate::{error::BDKCliError as Error, utils::output::FormatOutput}; +use bdk_wallet::{Wallet, bitcoin::Network}; + +/// The shared environment for all commands +pub struct AppContext<'a> { + pub network: Network, + pub datadir: PathBuf, + pub wallet: Option<&'a mut Wallet>, + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + pub client: Option<&'a BlockchainClient>, +} - let result = handle_offline_wallet_subcommand( - &mut wallet, - &wallet_opts, - &cli_opts, - offline_subcommand.clone(), - )?; - wallet.persist(&mut persister)?; - result - }; - #[cfg(not(any(feature = "sqlite", feature = "redb")))] - let result = { - let mut wallet = new_wallet(network, &wallet_opts)?; - handle_offline_wallet_subcommand( - &mut wallet, - &wallet_opts, - &cli_opts, - offline_subcommand.clone(), - )? - }; - Ok(result) - } - CliSubCommand::Wallet { - wallet, - subcommand: WalletSubCommand::Config { force, wallet_opts }, - } => { - let network = cli_opts.network; - let home_dir = prepare_home_dir(cli_opts.datadir)?; - let result = handle_config_subcommand(&home_dir, network, wallet, &wallet_opts, force)?; - Ok(result) - } - CliSubCommand::Wallets => { - let home_dir = prepare_home_dir(cli_opts.datadir)?; - let result = handle_wallets_subcommand(&home_dir, pretty)?; - Ok(result) +impl<'a> AppContext<'a> { + pub fn new(network: Network, datadir: PathBuf) -> Self { + Self { + network, + datadir, + wallet: None, + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + client: None, } - CliSubCommand::Key { - subcommand: key_subcommand, - } => { - let network = cli_opts.network; - let result = handle_key_subcommand(network, key_subcommand, pretty)?; - Ok(result) - } - #[cfg(feature = "compiler")] - CliSubCommand::Compile { - policy, - script_type, - } => { - let network = cli_opts.network; - let result = handle_compile_subcommand(network, policy, script_type, pretty)?; - Ok(result) - } - #[cfg(feature = "repl")] - CliSubCommand::Repl { - wallet: wallet_name, - } => { - let home_dir = prepare_home_dir(cli_opts.datadir.clone())?; - let (wallet_opts, network) = load_wallet_config(&home_dir, &wallet_name)?; - - #[cfg(any(feature = "sqlite", feature = "redb"))] - let (mut wallet, mut persister) = { - let mut persister: Persister = match &wallet_opts.database_type { - #[cfg(feature = "sqlite")] - DatabaseType::Sqlite => { - let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?; - let db_file = database_path.join("wallet.sqlite"); - let connection = Connection::open(db_file)?; - log::debug!("Sqlite database opened successfully"); - Persister::Connection(connection) - } - #[cfg(feature = "redb")] - DatabaseType::Redb => { - let db = Arc::new(bdk_redb::redb::Database::create( - home_dir.join("wallet.redb"), - )?); - let store = RedbStore::new(db, wallet_name.clone())?; - log::debug!("Redb database opened successfully"); - Persister::RedbStore(store) - } - }; - let wallet = new_persisted_wallet(network, &mut persister, &wallet_opts)?; - (wallet, persister) - }; - #[cfg(not(any(feature = "sqlite", feature = "redb")))] - let mut wallet = new_wallet(network, &loaded_wallet_opts)?; - let home_dir = prepare_home_dir(cli_opts.datadir.clone())?; - let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?; - loop { - let line = readline()?; - let line = line.trim(); - if line.is_empty() { - continue; - } + } + + /// Attach a mutable wallet reference to the context. + pub fn with_wallet(mut self, wallet: &'a mut Wallet) -> Self { + self.wallet = Some(wallet); + self + } + + /// Attach a client reference to the context. + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + pub fn with_client(mut self, client: &'a BlockchainClient) -> Self { + self.client = Some(client); + self + } +} - let result = respond( - network, - &mut wallet, - &wallet_name, - &mut wallet_opts.clone(), - line, - database_path.clone(), - &cli_opts, - ) - .await; - #[cfg(any(feature = "sqlite", feature = "redb"))] - wallet.persist(&mut persister)?; +pub trait AsyncCommand { + type Output: FormatOutput; + async fn execute(&self, ctx: &mut AppContext<'_>) -> Result; +} - match result { - Ok(quit) => { - if quit { - break; - } - } - Err(err) => { - writeln!(std::io::stdout(), "{err}") - .map_err(|e| Error::Generic(e.to_string()))?; - std::io::stdout() - .flush() - .map_err(|e| Error::Generic(e.to_string()))?; - } - } - } - Ok("".to_string()) - } - CliSubCommand::Descriptor { desc_type, key } => { - let descriptor = handle_descriptor_command(cli_opts.network, desc_type, key, pretty)?; - Ok(descriptor) - } - CliSubCommand::Completions { shell } => { - clap_complete::generate( - shell, - &mut CliOpts::command(), - "bdk-cli", - &mut std::io::stdout(), - ); +/// The command trait +pub trait AppCommand { + type Output: FormatOutput; - Ok("".to_string()) - } - }; - result + /// The execution logic + fn execute(&self, ctx: &mut AppContext) -> Result; } + +// context for online and online +// => cli.rs +// handlers/{mod for commands} +// wallet subdir / +// wallet-offline and wallet-online (client mod) diff --git a/src/handlers/offline.rs b/src/handlers/offline.rs index b75e6ed7..62d89d57 100644 --- a/src/handlers/offline.rs +++ b/src/handlers/offline.rs @@ -1,320 +1,639 @@ -use crate::commands::OfflineWalletSubCommand::*; -use crate::commands::{CliOpts, OfflineWalletSubCommand, WalletOpts}; +use crate::commands::OfflineWalletSubCommand; use crate::error::BDKCliError as Error; -use crate::handlers::types::{ +use crate::handlers::{AppCommand, AppContext}; +use crate::utils::output::FormatOutput; +use crate::utils::parse_address; +use crate::utils::types::{ AddressResult, BalanceResult, KeychainPair, PsbtResult, RawPsbt, TransactionDetails, TransactionListResult, UnspentDetails, UnspentListResult, }; -use crate::utils::output::FormatOutput; +use crate::utils::{parse_outpoint, parse_recipient}; use bdk_wallet::bitcoin::base64::Engine; use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD; use bdk_wallet::bitcoin::script::PushBytesBuf; -use bdk_wallet::bitcoin::{Amount, FeeRate, Psbt, Sequence, Txid}; -use bdk_wallet::{KeychainKind, SignOptions, Wallet}; +use bdk_wallet::bitcoin::{Address, Amount, FeeRate, OutPoint, Psbt, ScriptBuf, Sequence, Txid}; +use bdk_wallet::{KeychainKind, SignOptions}; +use clap::Parser; use serde_json::json; use std::collections::BTreeMap; use std::str::FromStr; #[cfg(feature = "bip322")] use { - crate::utils::{parse_address, parse_signature_format}, - bdk_bip322::{BIP322, MessageProof, MessageVerificationResult}, - bdk_wallet::bitcoin::Address, + crate::utils::parse_signature_format, + crate::utils::types::MessageResult, + bdk_bip322::{BIP322, MessageProof}, }; -/// Execute an offline wallet sub-command -/// -/// Offline wallet sub-commands are described in [`OfflineWalletSubCommand`]. -pub fn handle_offline_wallet_subcommand( - wallet: &mut Wallet, - wallet_opts: &WalletOpts, - cli_opts: &CliOpts, - offline_subcommand: OfflineWalletSubCommand, -) -> Result { - let pretty = cli_opts.pretty; - match offline_subcommand { - NewAddress => { - let addr = wallet.reveal_next_address(KeychainKind::External); - let result: AddressResult = addr.into(); - result.format(pretty) - } - UnusedAddress => { - let addr = wallet.next_unused_address(KeychainKind::External); - let result: AddressResult = addr.into(); - result.format(pretty) +impl OfflineWalletSubCommand { + pub fn execute(&self, ctx: &mut AppContext<'_>) -> Result<(), Error> { + match self { + Self::NewAddress(new_address) => new_address.execute(ctx)?.print(), + Self::Balance(balance) => balance.execute(ctx)?.print(), + Self::UnusedAddress(unused_address_command) => { + unused_address_command.execute(ctx)?.print() + } + Self::Unspent(unspent_command) => unspent_command.execute(ctx)?.print(), + Self::Transactions(transactions_command) => transactions_command.execute(ctx)?.print(), + Self::CreateTx(createtx_command) => createtx_command.execute(ctx)?.print(), + Self::BumpFee(bumpfee_command) => bumpfee_command.execute(ctx)?.print(), + Self::Policies(policies_command) => policies_command.execute(ctx)?.print(), + Self::PublicDescriptor(public_descriptor_command) => { + public_descriptor_command.execute(ctx)?.print() + } + Self::Sign(sign_command) => sign_command.execute(ctx)?.print(), + Self::ExtractPsbt(extract_psbt_command) => extract_psbt_command.execute(ctx)?.print(), + Self::FinalizePsbt(finalize_psbt_command) => { + finalize_psbt_command.execute(ctx)?.print() + } + Self::CombinePsbt(combine_psbt_command) => combine_psbt_command.execute(ctx)?.print(), + #[cfg(feature = "bip322")] + Self::SignMessage(sign_message_command) => sign_message_command.execute(ctx)?.print(), + #[cfg(feature = "bip322")] + Self::VerifyMessage(verify_message_command) => { + verify_message_command.execute(ctx)?.print() + } } - Unspent => { - let utxos: Vec = wallet - .list_unspent() - .map(|utxo| UnspentDetails::from_local_output(&utxo, cli_opts.network)) - .collect(); + } +} - let result = UnspentListResult(utxos); - result.format(pretty) - } - Transactions => { - let transactions = wallet.transactions(); - - let txns: Vec = transactions - .map(|tx| { - let total_value = tx - .tx_node - .output - .iter() - .map(|output| output.value.to_sat()) - .sum::(); - - TransactionDetails { - txid: tx.tx_node.txid.to_string(), - is_coinbase: tx.tx_node.is_coinbase(), - wtxid: tx.tx_node.compute_wtxid().to_string(), - version: serde_json::to_value(tx.tx_node.version).unwrap_or(json!(1)), - version_display: tx.tx_node.version.to_string(), - is_rbf: tx.tx_node.is_explicitly_rbf(), - inputs: serde_json::to_value(&tx.tx_node.input).unwrap_or_default(), - outputs: serde_json::to_value(&tx.tx_node.output).unwrap_or_default(), - input_count: tx.tx_node.input.len(), - output_count: tx.tx_node.output.len(), - total_value, - } - }) - .collect(); +#[derive(Parser, Debug, Clone, PartialEq)] +pub struct NewAddressCommand {} - let result = TransactionListResult(txns); - result.format(pretty) - } - Balance => { - let balance = wallet.balance(); - let result: BalanceResult = balance.into(); - result.format(pretty) - } +impl AppCommand for NewAddressCommand { + type Output = AddressResult; - CreateTx { - recipients, - send_all, - enable_rbf, - offline_signer, - utxos, - unspendable, - fee_rate, - external_policy, - internal_policy, - add_data, - add_string, - } => { - let mut tx_builder = wallet.build_tx(); - - if send_all { - tx_builder.drain_wallet().drain_to(recipients[0].0.clone()); - } else { - let recipients = recipients - .into_iter() - .map(|(script, amount)| (script, Amount::from_sat(amount))) - .collect(); - tx_builder.set_recipients(recipients); - } + fn execute(&self, ctx: &mut AppContext) -> Result { + let wallet = ctx + .wallet + .as_deref_mut() + .ok_or(Error::Generic("Wallet required".to_string()))?; + let address_info = wallet.reveal_next_address(KeychainKind::External); + Ok(AddressResult::from(address_info)) + } +} - if !enable_rbf { - tx_builder.set_exact_sequence(Sequence::MAX); - } +#[derive(Parser, Debug, PartialEq, Clone)] +pub struct UnusedAddressCommand {} - if offline_signer { - tx_builder.include_output_redeem_witness_script(); - } +impl AppCommand for UnusedAddressCommand { + type Output = AddressResult; - if let Some(fee_rate) = fee_rate - && let Some(fee_rate) = FeeRate::from_sat_per_vb(fee_rate as u64) - { - tx_builder.fee_rate(fee_rate); - } + fn execute(&self, ctx: &mut AppContext) -> Result { + let wallet = ctx + .wallet + .as_deref_mut() + .ok_or(Error::Generic("wallet is required".to_string()))?; + let address_info = wallet.next_unused_address(KeychainKind::External); + Ok(AddressResult::from(address_info)) + } +} - if let Some(utxos) = utxos { - tx_builder.add_utxos(&utxos[..]).unwrap(); - } +#[derive(Parser, Debug, PartialEq, Clone)] +pub struct UnspentCommand {} - if let Some(unspendable) = unspendable { - tx_builder.unspendable(unspendable); - } +impl AppCommand for UnspentCommand { + type Output = UnspentListResult; - if let Some(base64_data) = add_data { - let op_return_data = BASE64_STANDARD.decode(base64_data).unwrap(); - tx_builder.add_data(&PushBytesBuf::try_from(op_return_data).unwrap()); - } else if let Some(string_data) = add_string { - let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec()).unwrap(); - tx_builder.add_data(&data); - } + fn execute(&self, ctx: &mut AppContext) -> Result { + let wallet = ctx + .wallet + .as_deref_mut() + .ok_or(Error::Generic("Wallet is required".to_string()))?; + let utxos = wallet + .list_unspent() + .map(|utxo| UnspentDetails::from_local_output(&utxo, ctx.network)) + .collect(); - let policies = vec![ - external_policy.map(|p| (p, KeychainKind::External)), - internal_policy.map(|p| (p, KeychainKind::Internal)), - ]; + Ok(UnspentListResult(utxos)) + } +} - for (policy, keychain) in policies.into_iter().flatten() { - let policy = serde_json::from_str::>>(&policy)?; - tx_builder.policy_path(policy, keychain); - } +#[derive(Parser, Debug, PartialEq, Clone)] +pub struct TransactionsCommand {} + +impl AppCommand for TransactionsCommand { + type Output = TransactionListResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let wallet = ctx + .wallet + .as_deref_mut() + .ok_or(Error::Generic("wallet required".to_string()))?; + + let transactions = wallet.transactions(); + + let txns: Vec = transactions + .map(|tx| { + let total_value = tx + .tx_node + .output + .iter() + .map(|output| output.value.to_sat()) + .sum::(); + + TransactionDetails { + txid: tx.tx_node.txid.to_string(), + is_coinbase: tx.tx_node.is_coinbase(), + wtxid: tx.tx_node.compute_wtxid().to_string(), + version: serde_json::to_value(tx.tx_node.version).unwrap_or(json!(1)), + version_display: tx.tx_node.version.to_string(), + is_rbf: tx.tx_node.is_explicitly_rbf(), + inputs: serde_json::to_value(&tx.tx_node.input).unwrap_or_default(), + outputs: serde_json::to_value(&tx.tx_node.output).unwrap_or_default(), + input_count: tx.tx_node.input.len(), + output_count: tx.tx_node.output.len(), + total_value, + } + }) + .collect(); - let psbt = tx_builder.finish()?; + Ok(TransactionListResult(txns)) + } +} - let result = PsbtResult::new(&psbt, wallet_opts.verbose, None); - result.format(pretty) - } - BumpFee { - txid, - shrink_address, - offline_signer, - utxos, - unspendable, - fee_rate, - } => { - let txid = Txid::from_str(txid.as_str())?; - - let mut tx_builder = wallet.build_fee_bump(txid)?; - let fee_rate = - FeeRate::from_sat_per_vb(fee_rate as u64).unwrap_or(FeeRate::BROADCAST_MIN); - tx_builder.fee_rate(fee_rate); +#[derive(Parser, Debug, PartialEq, Clone)] +pub struct BalanceCommand {} - if let Some(address) = shrink_address { - let script_pubkey = address.script_pubkey(); - tx_builder.drain_to(script_pubkey); - } +impl AppCommand for BalanceCommand { + type Output = BalanceResult; - if offline_signer { - tx_builder.include_output_redeem_witness_script(); - } + fn execute(&self, ctx: &mut AppContext) -> Result { + let wallet = ctx + .wallet + .as_deref_mut() + .ok_or_else(|| Error::Generic("Wallet required".into()))?; + let balance = wallet.balance(); + Ok(BalanceResult::from(balance)) + } +} +#[derive(Debug, Parser, Clone, PartialEq)] +pub struct CreateTxCommand { + /// Adds a recipient to the transaction. + #[arg(env = "ADDRESS:SAT", long = "to", required = true, value_parser = parse_recipient)] + pub recipients: Vec<(ScriptBuf, u64)>, + + /// Sends all the funds (or all the selected utxos). Requires only one recipient with value 0. + #[arg(long = "send_all", short = 'a')] + pub send_all: bool, + + /// Enables Replace-By-Fee (BIP125). + #[arg(long = "enable_rbf", short = 'r', default_value_t = true)] + pub enable_rbf: bool, + + /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. + #[arg(long = "offline_signer")] + pub offline_signer: bool, + + /// Selects which utxos *must* be spent. + #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] + pub utxos: Option>, + + /// Marks a utxo as unspendable. + #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] + pub unspendable: Option>, + + /// Fee rate to use in sat/vbyte. + #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")] + pub fee_rate: Option, + + /// Selects which policy should be used to satisfy the external descriptor. + #[arg(env = "EXT_POLICY", long = "external_policy")] + pub external_policy: Option, + + /// Selects which policy should be used to satisfy the internal descriptor. + #[arg(env = "INT_POLICY", long = "internal_policy")] + pub internal_policy: Option, + + /// Optionally create an OP_RETURN output containing given String in utf8 encoding (max 80 bytes) + #[arg( + env = "ADD_STRING", + long = "add_string", + short = 's', + conflicts_with = "add_data" + )] + pub add_string: Option, + + /// Optionally create an OP_RETURN output containing given base64 encoded String. (max 80 bytes) + #[arg( + env = "ADD_DATA", + long = "add_data", + short = 'o', + conflicts_with = "add_string" + )] + pub add_data: Option, +} - if let Some(utxos) = utxos { - tx_builder.add_utxos(&utxos[..]).unwrap(); - } +impl AppCommand for CreateTxCommand { + type Output = PsbtResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let wallet = ctx + .wallet + .as_deref_mut() + .ok_or_else(|| Error::Generic("Wallet required".into()))?; + + let mut tx_builder = wallet.build_tx(); + + if self.send_all { + tx_builder + .drain_wallet() + .drain_to(self.recipients[0].0.clone()); + } else { + let recipients = self + .recipients + .clone() + .into_iter() + .map(|(script, amount)| (script, Amount::from_sat(amount))) + .collect(); + tx_builder.set_recipients(recipients); + } - if let Some(unspendable) = unspendable { - tx_builder.unspendable(unspendable); - } + if !self.enable_rbf { + tx_builder.set_exact_sequence(Sequence::MAX); + } - let psbt = tx_builder.finish()?; + if self.offline_signer { + tx_builder.include_output_redeem_witness_script(); + } - let result = PsbtResult::new(&psbt, wallet_opts.verbose, None); - result.format(pretty) + if let Some(fee_rate) = self.fee_rate + && let Some(fee_rate) = FeeRate::from_sat_per_vb(fee_rate as u64) + { + tx_builder.fee_rate(fee_rate); } - Policies => { - let external_policy = wallet.policies(KeychainKind::External)?; - let internal_policy = wallet.policies(KeychainKind::Internal)?; - let result = KeychainPair:: { - external: serde_json::to_value(&external_policy).unwrap_or(json!(null)), - internal: serde_json::to_value(&internal_policy).unwrap_or(json!(null)), - }; - result.format(cli_opts.pretty) + + if let Some(utxos) = &self.utxos { + tx_builder.add_utxos(&utxos[..]).unwrap(); } - PublicDescriptor => { - let result = KeychainPair:: { - external: wallet.public_descriptor(KeychainKind::External).to_string(), - internal: wallet.public_descriptor(KeychainKind::Internal).to_string(), - }; - result.format(cli_opts.pretty) + + if let Some(unspendable) = &self.unspendable { + tx_builder.unspendable(unspendable.to_vec()); } - Sign { - psbt, - assume_height, - trust_witness_utxo, - } => { - let psbt_bytes = BASE64_STANDARD.decode(psbt)?; - let mut psbt = Psbt::deserialize(&psbt_bytes)?; - let signopt = SignOptions { - assume_height, - trust_witness_utxo: trust_witness_utxo.unwrap_or(false), - ..Default::default() - }; - let finalized = wallet.sign(&mut psbt, signopt)?; - - let result = PsbtResult::new(&psbt, wallet_opts.verbose, Some(finalized)); - result.format(pretty) + + if let Some(base64_data) = &self.add_data { + let op_return_data = BASE64_STANDARD.decode(base64_data).unwrap(); + tx_builder.add_data(&PushBytesBuf::try_from(op_return_data).unwrap()); + } else if let Some(string_data) = &self.add_string { + let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec()).unwrap(); + tx_builder.add_data(&data); } - ExtractPsbt { psbt } => { - let psbt_serialized = BASE64_STANDARD.decode(psbt)?; - let psbt = Psbt::deserialize(&psbt_serialized)?; - let raw_tx = psbt.extract_tx()?; - let result = RawPsbt::new(&raw_tx); - result.format(pretty) + + let policies = vec![ + self.external_policy + .clone() + .map(|p| (p, KeychainKind::External)), + self.internal_policy + .clone() + .map(|p| (p, KeychainKind::Internal)), + ]; + + for (policy, keychain) in policies.into_iter().flatten() { + let policy = serde_json::from_str::>>(&policy)?; + tx_builder.policy_path(policy, keychain); } - FinalizePsbt { - psbt, - assume_height, - trust_witness_utxo, - } => { - let psbt_bytes = BASE64_STANDARD.decode(psbt)?; - let mut psbt: Psbt = Psbt::deserialize(&psbt_bytes)?; - - let signopt = SignOptions { - assume_height, - trust_witness_utxo: trust_witness_utxo.unwrap_or(false), - ..Default::default() - }; - let finalized = wallet.finalize_psbt(&mut psbt, signopt)?; - - let result = PsbtResult::new(&psbt, wallet_opts.verbose, Some(finalized)); - result.format(pretty) + + let psbt = tx_builder.finish()?; + + // let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); + + Ok(PsbtResult::new(&psbt, false, Some(false))) + } +} + +#[derive(Debug, Parser, Clone, PartialEq)] +pub struct BumpFeeCommand { + /// TXID of the transaction to update. + #[arg(env = "TXID", long = "txid")] + pub txid: String, + + /// Allows the wallet to reduce the amount to the specified address in order to increase fees. + #[arg(env = "SHRINK_ADDRESS", long = "shrink", value_parser = parse_address)] + pub shrink_address: Option
, + + /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. + #[arg(long = "offline_signer")] + pub offline_signer: bool, + + /// Selects which utxos *must* be added to the tx. Unconfirmed utxos cannot be used. + #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] + pub utxos: Option>, + + /// Marks an utxo as unspendable, in case more inputs are needed to cover the extra fees. + #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] + pub unspendable: Option>, + + /// The new targeted fee rate in sat/vbyte. + #[arg( + env = "SATS_VBYTE", + short = 'f', + long = "fee_rate", + default_value = "1.0" + )] + pub fee_rate: f32, +} + +impl AppCommand for BumpFeeCommand { + type Output = PsbtResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let wallet = ctx + .wallet + .as_deref_mut() + .ok_or_else(|| Error::Generic("Wallet required".into()))?; + + let txid = Txid::from_str(self.txid.as_str())?; + + let mut tx_builder = wallet.build_fee_bump(txid)?; + let fee_rate = + FeeRate::from_sat_per_vb(self.fee_rate as u64).unwrap_or(FeeRate::BROADCAST_MIN); + tx_builder.fee_rate(fee_rate); + + if let Some(address) = &self.shrink_address { + let script_pubkey = address.script_pubkey(); + tx_builder.drain_to(script_pubkey); } - CombinePsbt { psbt } => { - let mut psbts = psbt - .iter() - .map(|s| { - let psbt = BASE64_STANDARD.decode(s)?; - Ok(Psbt::deserialize(&psbt)?) - }) - .collect::, Error>>()?; - - let init_psbt = psbts - .pop() - .ok_or_else(|| Error::Generic("Invalid PSBT input".to_string()))?; - let final_psbt = psbts.into_iter().try_fold::<_, _, Result>( - init_psbt, - |mut acc, x| { - let _ = acc.combine(x); - Ok(acc) - }, - )?; - let result = PsbtResult::new(&final_psbt, wallet_opts.verbose, None); - result.format(pretty) + + if self.offline_signer { + tx_builder.include_output_redeem_witness_script(); } - #[cfg(feature = "bip322")] - SignMessage { - message, - signature_type, - address, - utxos, - } => { - let address: Address = parse_address(&address)?; - let signature_format = parse_signature_format(&signature_type)?; - - if !wallet.is_mine(address.script_pubkey()) { - return Err(Error::Generic(format!( - "Address {} does not belong to this wallet.", - address - ))); - } - let proof: MessageProof = - wallet.sign_message(message.as_str(), signature_format, &address, utxos)?; + if let Some(utxos) = &self.utxos { + tx_builder.add_utxos(&utxos[..]).unwrap(); + } - Ok(json!({"proof": proof.to_base64()}).to_string()) + if let Some(unspendable) = &self.unspendable { + tx_builder.unspendable(unspendable.to_vec()); } - #[cfg(feature = "bip322")] - VerifyMessage { - proof, - message, - address, - } => { - let address: Address = parse_address(&address)?; - let parsed_proof: MessageProof = MessageProof::from_base64(&proof) - .map_err(|e| Error::Generic(format!("Invalid proof: {e}")))?; - - let is_valid: MessageVerificationResult = - wallet.verify_message(&parsed_proof, &message, &address)?; - - Ok(json!({ - "valid": is_valid.valid, - "proven_amount": is_valid.proven_amount.map(|a| a.to_sat()) // optional field + + let psbt = tx_builder.finish()?; + + // let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); + + Ok(PsbtResult::new(&psbt, false, Some(false))) + } +} + +#[derive(Parser, Debug, PartialEq, Clone)] +pub struct PoliciesCommand {} + +impl AppCommand for PoliciesCommand { + type Output = KeychainPair; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let wallet = ctx + .wallet + .as_deref_mut() + .ok_or(Error::Generic("Wallet required".into()))?; + let external_policy = wallet.policies(KeychainKind::External)?; + let internal_policy = wallet.policies(KeychainKind::Internal)?; + + Ok(KeychainPair { + external: serde_json::to_value(&external_policy).unwrap_or(serde_json::json!(null)), + internal: serde_json::to_value(&internal_policy).unwrap_or(serde_json::json!(null)), + }) + } +} + +#[derive(Parser, Debug, PartialEq, Clone)] +pub struct PublicDescriptorCommand {} + +impl AppCommand for PublicDescriptorCommand { + type Output = KeychainPair; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let wallet = ctx + .wallet + .as_deref_mut() + .ok_or(Error::Generic("Wallet required".into()))?; + Ok(KeychainPair { + external: wallet.public_descriptor(KeychainKind::External).to_string(), + internal: wallet.public_descriptor(KeychainKind::Internal).to_string(), + }) + } +} + +#[derive(Debug, Parser, Clone, PartialEq)] +pub struct SignCommand { + /// Sets the PSBT to sign. + #[arg(env = "BASE64_PSBT")] + pub psbt: String, + + /// Assume the blockchain has reached a specific height. This affects the transaction finalization, if there are timelocks in the descriptor. + #[arg(env = "HEIGHT", long = "assume_height")] + pub assume_height: Option, + + /// Whether the signer should trust the witness_utxo, if the non_witness_utxo hasn’t been provided. + #[arg(env = "WITNESS", long = "trust_witness_utxo")] + pub trust_witness_utxo: Option, +} + +impl AppCommand for SignCommand { + type Output = PsbtResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let wallet = ctx + .wallet + .as_deref_mut() + .ok_or(Error::Generic("Wallet required".into()))?; + let psbt_bytes = BASE64_STANDARD + .decode(&self.psbt) + .map_err(|e| Error::Generic(e.to_string()))?; + let mut psbt = Psbt::deserialize(&psbt_bytes).map_err(|e| Error::Generic(e.to_string()))?; + + let signopt = SignOptions { + assume_height: self.assume_height, + trust_witness_utxo: self.trust_witness_utxo.unwrap_or(false), + ..Default::default() + }; + let finalized = wallet.sign(&mut psbt, signopt)?; + Ok(PsbtResult::new(&psbt, false, Some(finalized))) + } +} + +#[derive(Debug, Parser, Clone, PartialEq)] +pub struct ExtractPsbtCommand { + /// Sets the PSBT to extract + #[arg(env = "BASE64_PSBT")] + pub psbt: String, +} + +impl AppCommand for ExtractPsbtCommand { + type Output = RawPsbt; + + fn execute(&self, _ctx: &mut AppContext) -> Result { + let psbt_serialized = BASE64_STANDARD.decode(self.psbt.clone())?; + let psbt = Psbt::deserialize(&psbt_serialized)?; + let raw_tx = psbt.extract_tx()?; + + Ok(RawPsbt::new(&raw_tx)) + } +} + +#[derive(Debug, Parser, Clone, PartialEq)] +pub struct FinalizePsbtCommand { + /// Sets the PSBT to finalize. + #[arg(env = "BASE64_PSBT")] + pub psbt: String, + + /// Assume the blockchain has reached a specific height. + #[arg(env = "HEIGHT", long = "assume_height")] + pub assume_height: Option, + + /// Whether the signer should trust the witness_utxo, if the non_witness_utxo hasn’t been provided. + #[arg(env = "WITNESS", long = "trust_witness_utxo")] + pub trust_witness_utxo: Option, +} + +impl AppCommand for FinalizePsbtCommand { + type Output = PsbtResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let wallet = ctx + .wallet + .as_deref_mut() + .ok_or(Error::Generic("Wallet required".into()))?; + let psbt_bytes = BASE64_STANDARD + .decode(&self.psbt) + .map_err(|e| Error::Generic(e.to_string()))?; + let mut psbt = Psbt::deserialize(&psbt_bytes).map_err(|e| Error::Generic(e.to_string()))?; + + let signopt = SignOptions { + assume_height: self.assume_height, + trust_witness_utxo: self.trust_witness_utxo.unwrap_or(false), + ..Default::default() + }; + + let finalized = wallet.finalize_psbt(&mut psbt, signopt)?; + + Ok(PsbtResult::new(&psbt, false, Some(finalized))) + } +} + +#[derive(Debug, Parser, Clone, PartialEq)] +pub struct CombinePsbtCommand { + /// Add one PSBT to combine. This option can be repeated multiple times, one for each PSBT. + #[arg(env = "BASE64_PSBT", required = true)] + pub psbt: Vec, +} + +impl AppCommand for CombinePsbtCommand { + type Output = PsbtResult; + + fn execute(&self, _ctx: &mut AppContext) -> Result { + let mut psbts = self + .psbt + .iter() + .map(|s| { + let psbt = BASE64_STANDARD.decode(s)?; + Ok(Psbt::deserialize(&psbt)?) }) - .to_string()) + .collect::, Error>>()?; + + let init_psbt = psbts + .pop() + .ok_or_else(|| Error::Generic("Invalid PSBT input".to_string()))?; + let final_psbt = + psbts + .into_iter() + .try_fold::<_, _, Result>(init_psbt, |mut acc, x| { + let _ = acc.combine(x); + Ok(acc) + })?; + + Ok(PsbtResult::new(&final_psbt, false, None)) + } +} + +#[cfg(feature = "bip322")] +#[derive(Debug, Parser, Clone, PartialEq)] +pub struct SignMessageCommand { + /// The message to sign + #[arg(long)] + pub message: String, + + /// The signature format (e.g., Legacy, Simple, Full) + #[arg(long, default_value = "simple")] + pub signature_type: String, + + /// Address to sign + #[arg(long)] + pub address: String, + + /// Optional list of specific UTXOs for proof-of-funds (only for `FullWithProofOfFunds`) + #[arg(long)] + pub utxos: Option>, +} + +#[cfg(feature = "bip322")] +impl AppCommand for SignMessageCommand { + type Output = MessageResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let wallet = ctx + .wallet + .as_deref_mut() + .ok_or(Error::Generic("Wallet required".into()))?; + let address: Address = parse_address(&self.address)?; + let signature_format = parse_signature_format(&self.signature_type)?; + + if !wallet.is_mine(address.script_pubkey()) { + return Err(Error::Generic(format!( + "Address {} does not belong to this wallet.", + address + ))); } + + let proof = wallet.sign_message( + self.message.as_str(), + signature_format, + &address, + self.utxos.clone(), + )?; + + Ok(MessageResult { + proof: Some(proof.to_base64()), + ..Default::default() + }) + } +} + +// #[cfg(feature = "bip322")] +#[derive(Debug, Parser, Clone, PartialEq)] +pub struct VerifyMessageCommand { + /// The signature proof to verify + #[arg(long)] + pub proof: String, + + /// The message that was signed + #[arg(long)] + pub message: String, + + /// The address associated with the signature + #[arg(long)] + pub address: String, +} + +#[cfg(feature = "bip322")] +impl AppCommand for VerifyMessageCommand { + type Output = MessageResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let wallet = ctx + .wallet + .as_deref_mut() + .ok_or(Error::Generic("Wallet required".into()))?; + + let address: Address = parse_address(&self.address)?; + + let parsed_proof = MessageProof::from_base64(&self.proof) + .map_err(|e| Error::Generic(format!("Invalid proof format: {e}")))?; + + let is_valid = wallet.verify_message(&parsed_proof, &self.message, &address)?; + + Ok(MessageResult { + valid: Some(is_valid.valid), + proven_amount: is_valid.proven_amount.map(|a| a.to_sat()), + ..Default::default() + }) } } diff --git a/src/handlers/online.rs b/src/handlers/online.rs index ec3ff2de..eb125815 100644 --- a/src/handlers/online.rs +++ b/src/handlers/online.rs @@ -1,11 +1,13 @@ +use clap::Parser; + #[cfg(feature = "electrum")] -use crate::backend::BlockchainClient::Electrum; -#[cfg(feature = "cbf")] -use crate::backend::BlockchainClient::KyotoClient; +use crate::client::BlockchainClient::Electrum; #[cfg(feature = "rpc")] -use crate::backend::BlockchainClient::RpcClient; +use crate::client::BlockchainClient::RpcClient; +#[cfg(feature = "cbf")] +use crate::client::{BlockchainClient::KyotoClient, sync_kyoto_client}; #[cfg(feature = "esplora")] -use {crate::backend::BlockchainClient::Esplora, bdk_esplora::EsploraAsyncExt}; +use {crate::client::BlockchainClient::Esplora, bdk_esplora::EsploraAsyncExt}; #[cfg(feature = "rpc")] use { bdk_bitcoind_rpc::{Emitter, NO_EXPECTED_MEMPOOL_TXS, bitcoincore_rpc::RpcApi}, @@ -21,307 +23,445 @@ use {bdk_wallet::KeychainKind, std::collections::HashSet, std::io::Write}; feature = "rpc" ))] use { - crate::backend::{BlockchainClient, sync_kyoto_client}, - crate::commands::OnlineWalletSubCommand::*, + crate::commands::OnlineWalletSubCommand, crate::error::BDKCliError as Error, - crate::payjoin::PayjoinManager, - crate::payjoin::ohttp::RelayManager, + crate::handlers::{AppContext, AsyncCommand}, + crate::payjoin::{PayjoinManager, ohttp::RelayManager}, crate::utils::is_final, - bdk_wallet::Wallet, + crate::utils::output::FormatOutput, + crate::utils::types::{StatusResult, TransactionResult}, bdk_wallet::bitcoin::{ - Psbt, Transaction, Txid, base64::Engine, base64::prelude::BASE64_STANDARD, - consensus::Decodable, hex::FromHex, + Psbt, Transaction, base64::Engine, base64::prelude::BASE64_STANDARD, consensus::Decodable, + hex::FromHex, }, - serde_json::json, std::sync::{Arc, Mutex}, }; +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +impl OnlineWalletSubCommand { + pub async fn execute(&self, ctx: &mut AppContext<'_>) -> Result<(), Error> { + match self { + OnlineWalletSubCommand::FullScan(full_scan_command) => { + full_scan_command.execute(ctx).await?.print() + } + OnlineWalletSubCommand::Sync(sync_command) => sync_command.execute(ctx).await?.print(), + OnlineWalletSubCommand::Broadcast(broadcast_command) => { + broadcast_command.execute(ctx).await?.print() + } + OnlineWalletSubCommand::ReceivePayjoin(receive_payjoin_command) => { + receive_payjoin_command.execute(ctx).await?.print() + } + OnlineWalletSubCommand::SendPayjoin(send_payjoin_command) => { + send_payjoin_command.execute(ctx).await?.print() + } + } + } +} + +#[derive(Parser, Debug, PartialEq, Clone, Eq)] +pub struct FullScanCommand { + /// Stop searching addresses for transactions after finding an unused gap of this length. + #[arg(env = "STOP_GAP", long = "scan-stop-gap", default_value = "20")] + stop_gap: usize, + // #[clap(long, default_value = "5")] + // pub parallel_request: usize, +} -/// Execute an online wallet sub-command -/// -/// Online wallet sub-commands are described in [`OnlineWalletSubCommand`]. #[cfg(any( feature = "electrum", feature = "esplora", feature = "cbf", feature = "rpc" ))] -pub(crate) async fn handle_online_wallet_subcommand( - wallet: &mut Wallet, - client: &BlockchainClient, - online_subcommand: crate::commands::OnlineWalletSubCommand, -) -> Result { - match online_subcommand { - FullScan { - stop_gap: _stop_gap, - } => { - #[cfg(any(feature = "electrum", feature = "esplora"))] - let request = wallet.start_full_scan().inspect({ - let mut stdout = std::io::stdout(); - let mut once = HashSet::::new(); - move |k, spk_i, _| { - if once.insert(k) { - print!("\nScanning keychain [{k:?}]"); - } - print!(" {spk_i:<3}"); - stdout.flush().expect("must flush"); - } - }); - match client { - #[cfg(feature = "electrum")] - Electrum { client, batch_size } => { - // Populate the electrum client's transaction cache so it doesn't re-download transaction we - // already have. - client - .populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); - - let update = client.full_scan(request, _stop_gap, *batch_size, false)?; - wallet.apply_update(update)?; - } - #[cfg(feature = "esplora")] - Esplora { - client, - parallel_requests, - } => { - let update = client - .full_scan(request, _stop_gap, *parallel_requests) - .await - .map_err(|e| *e)?; - wallet.apply_update(update)?; +impl AsyncCommand for FullScanCommand { + type Output = StatusResult; + + async fn execute(&self, ctx: &mut AppContext<'_>) -> Result { + let wallet = ctx + .wallet + .as_deref_mut() + .ok_or(Error::Generic("Wallet required".into()))?; + let client = ctx + .client + .ok_or(Error::Generic("Online client required".into()))?; + + #[cfg(any(feature = "electrum", feature = "esplora"))] + let request = wallet.start_full_scan().inspect({ + let mut stdout = std::io::stdout(); + let mut once = HashSet::::new(); + move |k, spk_i, _| { + if once.insert(k) { + print!("\nScanning keychain [{k:?}]"); } + print!(" {spk_i:<3}"); + stdout.flush().expect("must flush"); + } + }); - #[cfg(feature = "rpc")] - RpcClient { client } => { - let blockchain_info = client.get_blockchain_info()?; - - let genesis_block = - bdk_wallet::bitcoin::constants::genesis_block(wallet.network()); - let genesis_cp = CheckPoint::new(BlockId { - height: 0, - hash: genesis_block.block_hash(), - }); - let mut emitter = Emitter::new( - client.as_ref(), - genesis_cp.clone(), - genesis_cp.height(), - NO_EXPECTED_MEMPOOL_TXS, - ); - - while let Some(block_event) = emitter.next_block()? { - if block_event.block_height() % 10_000 == 0 { - let percent_done = f64::from(block_event.block_height()) - / f64::from(blockchain_info.headers as u32) - * 100f64; - println!( - "Applying block at height: {}, {:.2}% done.", - block_event.block_height(), - percent_done - ); - } - - wallet.apply_block_connected_to( - &block_event.block, + match client { + #[cfg(feature = "electrum")] + Electrum { client, batch_size } => { + client.populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); + let update = client.full_scan(request, self.stop_gap, *batch_size, false)?; + wallet.apply_update(update)?; + } + + #[cfg(feature = "esplora")] + Esplora { + client, + parallel_requests, + } => { + let update = client + .full_scan(request, self.stop_gap, *parallel_requests) + .await + .map_err(|e| *e)?; + wallet.apply_update(update)?; + } + #[cfg(feature = "rpc")] + RpcClient { client } => { + let blockchain_info = client.get_blockchain_info()?; + + let genesis_block = bdk_wallet::bitcoin::constants::genesis_block(wallet.network()); + let genesis_cp = CheckPoint::new(BlockId { + height: 0, + hash: genesis_block.block_hash(), + }); + let mut emitter = Emitter::new( + client.as_ref(), + genesis_cp.clone(), + genesis_cp.height(), + NO_EXPECTED_MEMPOOL_TXS, + ); + + while let Some(block_event) = emitter.next_block()? { + if block_event.block_height() % 10_000 == 0 { + let percent_done = f64::from(block_event.block_height()) + / f64::from(blockchain_info.headers as u32) + * 100f64; + println!( + "Applying block at height: {}, {:.2}% done.", block_event.block_height(), - block_event.connected_to(), - )?; + percent_done + ); } - let mempool_txs = emitter.mempool()?; - wallet.apply_unconfirmed_txs(mempool_txs.update); - } - #[cfg(feature = "cbf")] - KyotoClient { client } => { - sync_kyoto_client(wallet, client).await?; + wallet.apply_block_connected_to( + &block_event.block, + block_event.block_height(), + block_event.connected_to(), + )?; } + + let mempool_txs = emitter.mempool()?; + wallet.apply_unconfirmed_txs(mempool_txs.update); + } + + #[cfg(feature = "cbf")] + KyotoClient { client } => { + sync_kyoto_client(wallet, client).await?; } - Ok(serde_json::to_string_pretty(&json!({}))?) - } - Sync => { - sync_wallet(client, wallet).await?; - Ok(serde_json::to_string_pretty(&json!({}))?) - } - Broadcast { psbt, tx } => { - let tx = match (psbt, tx) { - (Some(psbt), None) => { - let psbt = BASE64_STANDARD - .decode(psbt) - .map_err(|e| Error::Generic(e.to_string()))?; - let psbt: Psbt = Psbt::deserialize(&psbt)?; - is_final(&psbt)?; - psbt.extract_tx()? - } - (None, Some(tx)) => { - let tx_bytes = Vec::::from_hex(&tx)?; - Transaction::consensus_decode(&mut tx_bytes.as_slice())? - } - (Some(_), Some(_)) => panic!("Both `psbt` and `tx` options not allowed"), - (None, None) => panic!("Missing `psbt` and `tx` option"), - }; - let txid = broadcast_transaction(client, tx).await?; - Ok(serde_json::to_string_pretty(&json!({ "txid": txid }))?) - } - ReceivePayjoin { - amount, - directory, - ohttp_relay, - max_fee_rate, - } => { - let relay_manager = Arc::new(Mutex::new(RelayManager::new())); - let mut payjoin_manager = PayjoinManager::new(wallet, relay_manager); - return payjoin_manager - .receive_payjoin(amount, directory, max_fee_rate, ohttp_relay, client) - .await; - } - SendPayjoin { - uri, - ohttp_relay, - fee_rate, - } => { - let relay_manager = Arc::new(Mutex::new(RelayManager::new())); - let mut payjoin_manager = PayjoinManager::new(wallet, relay_manager); - return payjoin_manager - .send_payjoin(uri, fee_rate, ohttp_relay, client) - .await; } + Ok(StatusResult::new("Full scan completed successfully.")) } } +#[derive(Parser, Debug, PartialEq, Eq, Clone)] +pub struct SyncCommand {} + #[cfg(any( feature = "electrum", feature = "esplora", feature = "cbf", feature = "rpc" ))] -/// Syncs a given wallet using the blockchain client. -pub async fn sync_wallet(client: &BlockchainClient, wallet: &mut Wallet) -> Result<(), Error> { - // #[cfg(any(feature = "electrum", feature = "esplora"))] - let request = wallet - .start_sync_with_revealed_spks() - .inspect(|item, progress| { - let pc = (100 * progress.consumed()) as f32 / progress.total() as f32; - eprintln!("[ SCANNING {pc:03.0}% ] {item}"); - }); - match client { - #[cfg(feature = "electrum")] - Electrum { client, batch_size } => { - // Populate the electrum client's transaction cache so it doesn't re-download transaction we - // already have. - client.populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); - - let update = client.sync(request, *batch_size, false)?; - wallet - .apply_update(update) - .map_err(|e| Error::Generic(e.to_string())) - } - #[cfg(feature = "esplora")] - Esplora { - client, - parallel_requests, - } => { - let update = client - .sync(request, *parallel_requests) - .await - .map_err(|e| *e)?; - wallet - .apply_update(update) - .map_err(|e| Error::Generic(e.to_string())) - } - #[cfg(feature = "rpc")] - RpcClient { client } => { - let blockchain_info = client.get_blockchain_info()?; - let wallet_cp = wallet.latest_checkpoint(); - - // reload the last 200 blocks in case of a reorg - let emitter_height = wallet_cp.height().saturating_sub(200); - let mut emitter = Emitter::new( - client.as_ref(), - wallet_cp, - emitter_height, +impl AsyncCommand for SyncCommand { + type Output = StatusResult; + + async fn execute(&self, ctx: &mut AppContext<'_>) -> Result { + let wallet = ctx + .wallet + .as_deref_mut() + .ok_or(Error::Generic("Wallet required".to_string()))?; + let client = ctx + .client + .ok_or(Error::Generic("Client is required".to_string()))?; + #[cfg(any(feature = "electrum", feature = "esplora"))] + let request = wallet + .start_sync_with_revealed_spks() + .inspect(|item, progress| { + let pc = (100 * progress.consumed()) as f32 / progress.total() as f32; + eprintln!("[ SCANNING {pc:03.0}% ] {item}"); + }); + + match client { + #[cfg(feature = "electrum")] + Electrum { client, batch_size } => { + // Populate the electrum client's transaction cache so it doesn't re-download transaction we + // already have. + client.populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); + + let update = client.sync(request, *batch_size, false)?; + wallet + .apply_update(update) + .map_err(|e| Error::Generic(e.to_string()))?; + } + #[cfg(feature = "esplora")] + Esplora { + client, + parallel_requests, + } => { + let update = client + .sync(request, *parallel_requests) + .await + .map_err(|e| *e)?; wallet - .tx_graph() - .list_canonical_txs( - wallet.local_chain(), - wallet.local_chain().tip().block_id(), - CanonicalizationParams::default(), - ) - .filter(|tx| tx.chain_position.is_unconfirmed()), - ); - - while let Some(block_event) = emitter.next_block()? { - if block_event.block_height() % 10_000 == 0 { - let percent_done = f64::from(block_event.block_height()) - / f64::from(blockchain_info.headers as u32) - * 100f64; - println!( - "Applying block at height: {}, {:.2}% done.", + .apply_update(update) + .map_err(|e| Error::Generic(e.to_string()))?; + } + #[cfg(feature = "rpc")] + RpcClient { client } => { + let blockchain_info = client.get_blockchain_info()?; + let wallet_cp = wallet.latest_checkpoint(); + + let emitter_height = wallet_cp.height().saturating_sub(200); + let mut emitter = Emitter::new( + client.as_ref(), + wallet_cp, + emitter_height, + wallet + .tx_graph() + .list_canonical_txs( + wallet.local_chain(), + wallet.local_chain().tip().block_id(), + CanonicalizationParams::default(), + ) + .filter(|tx| tx.chain_position.is_unconfirmed()), + ); + + while let Some(block_event) = emitter.next_block()? { + if block_event.block_height() % 10_000 == 0 { + let percent_done = f64::from(block_event.block_height()) + / f64::from(blockchain_info.headers as u32) + * 100f64; + println!( + "Applying block at height: {}, {:.2}% done.", + block_event.block_height(), + percent_done + ); + } + + wallet.apply_block_connected_to( + &block_event.block, block_event.block_height(), - percent_done - ); + block_event.connected_to(), + )?; } - wallet.apply_block_connected_to( - &block_event.block, - block_event.block_height(), - block_event.connected_to(), - )?; + let mempool_txs = emitter.mempool()?; + wallet.apply_unconfirmed_txs(mempool_txs.update); } - - let mempool_txs = emitter.mempool()?; - wallet.apply_unconfirmed_txs(mempool_txs.update); - Ok(()) + #[cfg(feature = "cbf")] + KyotoClient { client } => sync_kyoto_client(wallet, client) + .await + .map_err(|e| Error::Generic(e.to_string()))?, } - #[cfg(feature = "cbf")] - KyotoClient { client } => sync_kyoto_client(wallet, client) - .await - .map_err(|e| Error::Generic(e.to_string())), + Ok(StatusResult::new("Wallet synced successfully.")) } } +#[derive(Parser, Debug, Clone, PartialEq, Eq)] +pub struct BroadcastCommand { + /// Sets the PSBT to sign. + #[arg( + env = "BASE64_PSBT", + long = "psbt", + required_unless_present = "tx", + conflicts_with = "tx" + )] + psbt: Option, + /// Sets the raw transaction to broadcast. + #[arg( + env = "RAWTX", + long = "tx", + required_unless_present = "psbt", + conflicts_with = "psbt" + )] + tx: Option, +} + #[cfg(any( feature = "electrum", feature = "esplora", feature = "cbf", feature = "rpc" ))] -/// Broadcasts a given transaction using the blockchain client. -pub async fn broadcast_transaction( - client: &BlockchainClient, - tx: Transaction, -) -> Result { - match client { - #[cfg(feature = "electrum")] - Electrum { - client, - batch_size: _, - } => client - .transaction_broadcast(&tx) - .map_err(|e| Error::Generic(e.to_string())), - #[cfg(feature = "esplora")] - Esplora { - client, - parallel_requests: _, - } => client - .broadcast(&tx) - .await - .map(|()| tx.compute_txid()) - .map_err(|e| Error::Generic(e.to_string())), - #[cfg(feature = "rpc")] - RpcClient { client } => client - .send_raw_transaction(&tx) - .map_err(|e| Error::Generic(e.to_string())), - - #[cfg(feature = "cbf")] - 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()) - })?; - tracing::info!("Successfully broadcast WTXID: {wtxid}"); - Ok(txid) - } + +impl AsyncCommand for BroadcastCommand { + type Output = TransactionResult; + + async fn execute(&self, ctx: &mut AppContext<'_>) -> Result { + let client = ctx + .client + .ok_or(Error::Generic("Online client required".into()))?; + + let tx = match (&self.psbt, &self.tx) { + (Some(psbt), None) => { + let psbt = BASE64_STANDARD + .decode(psbt) + .map_err(|e| Error::Generic(e.to_string()))?; + let psbt: Psbt = Psbt::deserialize(&psbt)?; + is_final(&psbt)?; + psbt.extract_tx()? + } + (None, Some(tx)) => { + let tx_bytes = Vec::::from_hex(&tx)?; + Transaction::consensus_decode(&mut tx_bytes.as_slice())? + } + (Some(_), Some(_)) => { + return Err(Error::Generic( + "Both `psbt` and `tx` options are not allowed".into(), + )); + } + (None, None) => { + return Err(Error::Generic( + "Must provide either a `psbt` or `tx` to broadcast".into(), + )); + } + }; + + let txid = client.broadcast(tx).await?; + + Ok(TransactionResult { + txid: txid.to_string(), + }) + } +} + +#[derive(Parser, Debug, Clone, PartialEq, Eq)] +pub struct ReceivePayjoinCommand { + // /// The amount to receive in satoshis. + // pub amount: u64, + + // /// The payjoin directory URL. + // #[clap(long)] + // pub directory: String, + + // /// Optional OHTTP relay URLs for privacy. + // #[clap(long)] + // pub ohttp_relays: Vec, + + // /// Maximum fee rate in sat/vB to pay for the payjoin transaction. + // #[clap(long)] + // pub max_fee_rate: u64, + /// Amount to be received in sats. + #[arg(env = "PAYJOIN_AMOUNT", long = "amount", required = true)] + amount: u64, + /// Payjoin directory which will be used to store the PSBTs which are pending action + /// from one of the parties. + #[arg(env = "PAYJOIN_DIRECTORY", long = "directory", required = true)] + directory: String, + /// URL of the Payjoin OHTTP relay. Can be repeated multiple times to attempt the + /// operation with multiple relays for redundancy. + #[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)] + ohttp_relay: Vec, + /// Maximum effective fee rate the receiver is willing to pay for their own input/output contributions. + #[arg(env = "PAYJOIN_RECEIVER_MAX_FEE_RATE", long = "max_fee_rate")] + max_fee_rate: Option, +} + +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +impl AsyncCommand for ReceivePayjoinCommand { + type Output = StatusResult; + + async fn execute(&self, ctx: &mut AppContext<'_>) -> Result { + let wallet = ctx + .wallet + .as_deref_mut() + .ok_or(Error::Generic("Wallet required".into()))?; + let client = ctx + .client + .ok_or(Error::Generic("Online client required".into()))?; + + let relay_manager = Arc::new(Mutex::new(RelayManager::new())); + let mut payjoin_manager = PayjoinManager::new(wallet, relay_manager); + let result = payjoin_manager + .receive_payjoin( + self.amount, + self.directory.clone(), + self.max_fee_rate, + self.ohttp_relay.clone(), + client, + ) + .await?; + + Ok(StatusResult { message: result }) + } +} + +#[derive(Parser, Debug, Clone, PartialEq, Eq)] +pub struct SendPayjoinCommand { + // /// The Payjoin URI string to send to. + // pub uri: String, + + // /// Optional OHTTP relay URLs for privacy. + // #[clap(long)] + // pub ohttp_relays: Vec, + + // /// Fee rate in sat/vB for the transaction. + // #[clap(long, short)] + // pub fee_rate: u64, + /// BIP 21 URI for the Payjoin. + #[arg(env = "PAYJOIN_URI", long = "uri", required = true)] + uri: String, + /// URL of the Payjoin OHTTP relay. Can be repeated multiple times to attempt the + /// operation with multiple relays for redundancy. + #[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)] + ohttp_relay: Vec, + /// Fee rate to use in sat/vbyte. + #[arg( + env = "PAYJOIN_SENDER_FEE_RATE", + short = 'f', + long = "fee_rate", + required = true + )] + fee_rate: u64, +} +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +impl AsyncCommand for SendPayjoinCommand { + type Output = StatusResult; + + async fn execute(&self, ctx: &mut AppContext<'_>) -> Result { + let wallet = ctx + .wallet + .as_deref_mut() + .ok_or(Error::Generic("Wallet required".into()))?; + let client = ctx.client.ok_or(Error::Generic("client required".into()))?; + + let relay_manager = Arc::new(Mutex::new(RelayManager::new())); + let mut payjoin_manager = PayjoinManager::new(wallet, relay_manager); + let result = payjoin_manager + .send_payjoin( + self.uri.clone(), + self.fee_rate, + self.ohttp_relay.clone(), + client, + ) + .await?; + + Ok(StatusResult { message: result }) } } diff --git a/src/handlers/repl.rs b/src/handlers/repl.rs index 432af013..6f9bd521 100644 --- a/src/handlers/repl.rs +++ b/src/handlers/repl.rs @@ -1,93 +1,81 @@ -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -use crate::{backend::new_blockchain_client, handlers::online::handle_online_wallet_subcommand}; +use bdk_wallet::{Wallet, bitcoin::Network}; + +// #[cfg(feature = "repl")] +use crate::handlers::{AppCommand, AppContext}; +use crate::utils::output::FormatOutput; #[cfg(feature = "sqlite")] use crate::commands::ReplSubCommand; +use clap::Parser; #[cfg(feature = "repl")] +use std::io::Write; use { + crate::commands::{CliOpts, WalletSubCommand}, crate::error::BDKCliError as Error, - crate::{ - commands::{CliOpts, WalletOpts, WalletSubCommand}, - handlers::{ - config::handle_config_subcommand, descriptor::handle_descriptor_command, - key::handle_key_subcommand, offline::handle_offline_wallet_subcommand, - }, - }, - bdk_wallet::{Wallet, bitcoin::Network}, - std::io::Write, }; #[cfg(feature = "repl")] pub(crate) async fn respond( network: Network, wallet: &mut Wallet, - wallet_name: &String, - wallet_opts: &mut WalletOpts, line: &str, - _datadir: std::path::PathBuf, + datadir: std::path::PathBuf, cli_opts: &CliOpts, ) -> Result { - use clap::Parser; - let args = shlex::split(line).ok_or("error: Invalid quoting".to_string())?; - let repl_subcommand = ReplSubCommand::try_parse_from(args).map_err(|e| e.to_string())?; - let response = match repl_subcommand { - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" - ))] - ReplSubCommand::Wallet { - subcommand: WalletSubCommand::OnlineWalletSubCommand(online_subcommand), - } => { - let blockchain = - new_blockchain_client(wallet_opts, wallet, _datadir).map_err(|e| e.to_string())?; - let value = handle_online_wallet_subcommand(wallet, &blockchain, online_subcommand) - .await - .map_err(|e| e.to_string())?; - Some(value) - } - ReplSubCommand::Wallet { - subcommand: WalletSubCommand::OfflineWalletSubCommand(offline_subcommand), - } => { - let value = - handle_offline_wallet_subcommand(wallet, wallet_opts, cli_opts, offline_subcommand) - .map_err(|e| e.to_string())?; - Some(value) - } - ReplSubCommand::Wallet { - subcommand: WalletSubCommand::Config { force, wallet_opts }, - } => { - let value = handle_config_subcommand( - &_datadir, - network, - wallet_name.to_string(), - &wallet_opts, - force, - ) - .map_err(|e| e.to_string())?; - Some(value) - } - ReplSubCommand::Key { subcommand } => { - let value = handle_key_subcommand(network, subcommand, cli_opts.pretty) - .map_err(|e| e.to_string())?; - Some(value) + + let mut repl_args = vec!["repl".to_string()]; + repl_args.extend(args); + + let repl_subcommand = match ReplSubCommand::try_parse_from(&repl_args) { + Ok(cmd) => cmd, + Err(e) => { + writeln!(std::io::stdout(), "{}", e).map_err(|e| e.to_string())?; + return Ok(false); } - ReplSubCommand::Descriptor { desc_type, key } => { - let value = handle_descriptor_command(network, desc_type, key, cli_opts.pretty) - .map_err(|e| e.to_string())?; - Some(value) + }; + + let mut ctx = AppContext::new(network, datadir.clone()).with_wallet(&mut *wallet); + + let response = match repl_subcommand { + ReplSubCommand::Wallet { subcommand } => match subcommand { + WalletSubCommand::OfflineWalletSubCommand(cmd) => { + cmd.execute(&mut ctx).map_err(|e| e.to_string())?; + Some(()) + } + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" + ))] + WalletSubCommand::OnlineWalletSubCommand(cmd) => { + let value = cmd.execute(&mut ctx).await.map_err(|e| e.to_string())?; + Some(()) + // Some(value) + } + WalletSubCommand::Config(config_cmd) => { + let mut ctx = AppContext::new(network, datadir); + let res = config_cmd + .execute(&mut ctx) + .map_err(|e| e.to_string())? + .print(); + Some(()) + } + }, + + // Assuming your REPL Descriptor command is an inline struct based on commands.rs + ReplSubCommand::Descriptor(cmd) => { + let value = cmd.execute(&mut ctx).map_err(|e| e.to_string())?.print(); + Some(()) } + ReplSubCommand::Exit => None, + _ => todo!(), }; + if let Some(value) = response { - writeln!(std::io::stdout(), "{value}").map_err(|e| e.to_string())?; + // writeln!(std::io::stdout(), "{value}").map_err(|e| e.to_string())?; std::io::stdout().flush().map_err(|e| e.to_string())?; Ok(false) } else { diff --git a/src/main.rs b/src/main.rs index df5cfc4c..f8877a66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -169,7 +169,7 @@ async fn run(cli_opts: CliOpts) -> Result<(), Error> { } CliSubCommand::Repl { wallet: _ } => todo!(), CliSubCommand::Completions { shell } => { - shell; + shell; } #[cfg(feature = "compiler")] CliSubCommand::Compile(cmd) => { diff --git a/src/payjoin/mod.rs b/src/payjoin/mod.rs index 894508b5..0eaa0598 100644 --- a/src/payjoin/mod.rs +++ b/src/payjoin/mod.rs @@ -117,12 +117,12 @@ impl<'a> PayjoinManager<'a> { Ok(to_string_pretty(&json!({}))?) } - // #[cfg(any( - // feature = "electrum", - // feature = "esplora", - // feature = "rpc", - // feature = "cbf" - // ))] + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] pub async fn send_payjoin( &mut self, uri: String, diff --git a/src/utils/common.rs b/src/utils/common.rs index 294aef1b..52afdd4d 100644 --- a/src/utils/common.rs +++ b/src/utils/common.rs @@ -74,7 +74,7 @@ pub(crate) fn parse_recipient(s: &str) -> Result<(ScriptBuf, u64), String> { Ok((addr.script_pubkey(), val)) } -// #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] +#[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] /// Parse the proxy (Socket:Port) argument from the cli input. pub(crate) fn parse_proxy_auth(s: &str) -> Result<(String, String), Error> { let parts: Vec<_> = s.split(':').collect(); diff --git a/src/utils/descriptors.rs b/src/utils/descriptors.rs index d2289993..04391e50 100644 --- a/src/utils/descriptors.rs +++ b/src/utils/descriptors.rs @@ -19,7 +19,8 @@ use bdk_wallet::{ }; use crate::error::BDKCliError as Error; -use crate::handlers::types::{DescriptorResult, KeychainPair}; +use crate::utils::types::DescriptorResult; +use crate::utils::types::KeychainPair; pub fn generate_descriptors( desc_type: &str, diff --git a/src/utils/mod.rs b/src/utils/mod.rs index cea89351..afd02f9a 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,3 +2,4 @@ pub mod common; pub mod descriptors; pub mod output; pub use common::*; +pub mod types; diff --git a/src/utils/output.rs b/src/utils/output.rs index 4ad6e8e2..3f9a5e9f 100644 --- a/src/utils/output.rs +++ b/src/utils/output.rs @@ -1,35 +1,16 @@ use crate::error::BDKCliError as Error; -use cli_table::{CellStruct, Table}; use serde::Serialize; /// A trait for types that can be presented to the user. pub trait FormatOutput: Serialize { - /// Return a pretty table representation. - fn to_table(&self) -> Result; - - /// Formats the output based on the user's `--pretty` flag. - fn format(&self, pretty: bool) -> Result { - if pretty { - self.to_table() - } else { - serde_json::to_string_pretty(self) - .map_err(|e| Error::Generic(format!("JSON serialization failed: {e}"))) - } + fn format(&self) -> Result { + serde_json::to_string_pretty(self) + .map_err(|e| Error::Generic(format!("JSON serialization failed: {e}"))) } -} -/// Helper for building simple tables -pub fn simple_table(rows: R, title: Option>) -> Result -where - R: IntoIterator, - C: IntoIterator, -{ - let mut table = rows.table(); - if let Some(title) = title { - table = table.title(title); + fn print(&self) -> Result<(), Error> { + let output = self.format()?; + println!("{}", output); + Ok(()) } - table - .display() - .map_err(|e| Error::Generic(e.to_string())) - .map(|t| t.to_string()) } diff --git a/src/utils/types.rs b/src/utils/types.rs index f3c5ddc8..2fa16b97 100644 --- a/src/utils/types.rs +++ b/src/utils/types.rs @@ -65,7 +65,6 @@ pub struct UnspentDetails { pub is_spent: bool, pub derivation_index: u32, pub chain_position: serde_json::Value, - } impl UnspentDetails { @@ -196,8 +195,6 @@ pub struct TransactionResult { impl FormatOutput for TransactionResult {} - - /// Return type definition #[derive(Serialize)] #[serde(transparent)] @@ -205,7 +202,6 @@ pub struct WalletsListResult(pub HashMap); impl FormatOutput for WalletsListResult {} - /// return type #[derive(Serialize)] pub struct DescriptorResult { @@ -246,7 +242,6 @@ pub struct KeyResult { impl FormatOutput for KeyResult {} - /// Balance representation #[derive(Serialize)] pub struct BalanceResult {