diff --git a/Cargo.lock b/Cargo.lock index b491c2e6..5f124710 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -495,7 +495,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]] @@ -1256,9 +1256,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", ] @@ -1369,19 +1369,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]] @@ -2008,7 +2007,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls 0.23.36", @@ -2062,9 +2061,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -2204,7 +2203,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.5", + "webpki-roots 1.0.7", ] [[package]] @@ -2266,9 +2265,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" @@ -3115,9 +3114,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/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 44b13f27..dcf3d010 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 /// 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 diff --git a/src/handlers.rs b/src/handlers.rs deleted file mode 100644 index a98b172d..00000000 --- a/src/handlers.rs +++ /dev/null @@ -1,1810 +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(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()) }), - )?) - } - } -} - -/// 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())) + } + } + } +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 00000000..7fb0179d --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,298 @@ +pub mod config; +pub mod descriptor; +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", +))] +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/offline.rs b/src/handlers/offline.rs new file mode 100644 index 00000000..63994139 --- /dev/null +++ b/src/handlers/offline.rs @@ -0,0 +1,274 @@ +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, +}; +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}; +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 { + 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) + } + 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(); + + let result = TransactionListResult(txns); + result.format(pretty) + } + Balance => { + let balance = wallet.balance(); + let result: BalanceResult = balance.into(); + result.format(pretty) + } + + 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 result = PsbtResult::with_details(&psbt, wallet_opts.verbose); + 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); + + 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 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)?; + 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 result = PublicDescriptorResult { + external: wallet.public_descriptor(KeychainKind::External).to_string(), + internal: wallet.public_descriptor(KeychainKind::Internal).to_string(), + }; + result.format(pretty) + } + 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::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()?; + let result = RawPsbt::new(&raw_tx); + result.format(pretty) + } + 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::with_status_and_details(&psbt, finalized, wallet_opts.verbose); + result.format(pretty) + } + 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); + result.format(pretty) + } + } +} 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/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/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/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)?) + } +} 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] 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/utils.rs b/src/utils.rs deleted file mode 100644 index 76e56a0f..00000000 --- a/src/utils.rs +++ /dev/null @@ -1,665 +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}; - -/// 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) -} - -#[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..cea89351 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +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())) + } + } +} 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) + } + } +} 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