From: Vihiga Tyonum Date: Tue, 21 Apr 2026 10:15:43 +0000 (+0100) Subject: ref(handlers): split handlers into config, key X-Git-Url: http://internal-gitweb-vhost/blockdata/script/encode/-script/display/FromScriptException.UnrecognizedScript.html?a=commitdiff_plain;h=03307c96cf6e19238021949da9b805a0b3aafdd2;p=bdk-cli ref(handlers): split handlers into config, key - split handlers into top level commands config, key and descriptors --- diff --git a/src/handlers.rs b/src/handlers.rs deleted file mode 100644 index 15a4d5a..0000000 --- a/src/handlers.rs +++ /dev/null @@ -1,2081 +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 = "silent-payments")] -use { - bdk_sp::{ - bitcoin::{PrivateKey, PublicKey, ScriptBuf}, - encoding::SilentPaymentCode, - send::psbt::derive_sp, - }, - bdk_wallet::keys::{DescriptorPublicKey, DescriptorSecretKey, SinglePubKey}, -}; - -#[cfg(feature = "electrum")] -use crate::utils::BlockchainClient::Electrum; -#[cfg(any(feature = "electrum", feature = "esplora"))] -use std::collections::HashSet; -use std::collections::{BTreeMap, HashMap}; -use std::convert::TryFrom; -#[cfg(any(feature = "repl", feature = "electrum", feature = "esplora"))] -use std::io::Write; -use std::path::Path; -use std::str::FromStr; -#[cfg(any( - feature = "redb", - feature = "compiler", - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -use std::sync::Arc; - -#[cfg(feature = "bip322")] -use crate::error::BDKCliError; -#[cfg(feature = "bip322")] -use bdk_bip322::{BIP322, MessageProof, MessageVerificationResult}; - -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -use { - crate::commands::OnlineWalletSubCommand::*, - crate::payjoin::{PayjoinManager, ohttp::RelayManager}, - bdk_wallet::bitcoin::{Transaction, consensus::Decodable, hex::FromHex}, - std::sync::Mutex, -}; -#[cfg(feature = "esplora")] -use {crate::utils::BlockchainClient::Esplora, bdk_esplora::EsploraAsyncExt}; -#[cfg(feature = "rpc")] -use { - crate::utils::BlockchainClient::RpcClient, - bdk_bitcoind_rpc::{Emitter, NO_EXPECTED_MEMPOOL_TXS, bitcoincore_rpc::RpcApi}, - bdk_wallet::chain::{BlockId, CanonicalizationParams, CheckPoint}, -}; - -#[cfg(feature = "compiler")] -const NUMS_UNSPENDABLE_KEY_HEX: &str = - "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"; - -/// Execute an offline wallet sub-command -/// -/// Offline wallet sub-commands are described in [`OfflineWalletSubCommand`]. -pub fn handle_offline_wallet_subcommand( - wallet: &mut Wallet, - wallet_opts: &WalletOpts, - cli_opts: &CliOpts, - offline_subcommand: OfflineWalletSubCommand, -) -> Result { - match offline_subcommand { - NewAddress => { - let addr = wallet.reveal_next_address(KeychainKind::External); - if cli_opts.pretty { - let table = vec![ - vec!["Address".cell().bold(true), addr.address.to_string().cell()], - vec![ - "Index".cell().bold(true), - addr.index.to_string().cell().justify(Justify::Right), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else if wallet_opts.verbose { - Ok(serde_json::to_string_pretty(&json!({ - "address": addr.address, - "index": addr.index - }))?) - } else { - Ok(serde_json::to_string_pretty(&json!({ - "address": addr.address, - }))?) - } - } - UnusedAddress => { - let addr = wallet.next_unused_address(KeychainKind::External); - - if cli_opts.pretty { - let table = vec![ - vec!["Address".cell().bold(true), addr.address.to_string().cell()], - vec![ - "Index".cell().bold(true), - addr.index.to_string().cell().justify(Justify::Right), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else if wallet_opts.verbose { - Ok(serde_json::to_string_pretty(&json!({ - "address": addr.address, - "index": addr.index - }))?) - } else { - Ok(serde_json::to_string_pretty(&json!({ - "address": addr.address, - }))?) - } - } - Unspent => { - let utxos = wallet.list_unspent().collect::>(); - if cli_opts.pretty { - let mut rows: Vec> = vec![]; - for utxo in &utxos { - let height = utxo - .chain_position - .confirmation_height_upper_bound() - .map(|h| h.to_string()) - .unwrap_or("Pending".to_string()); - - let block_hash = match &utxo.chain_position { - ChainPosition::Confirmed { anchor, .. } => anchor.block_id.hash.to_string(), - ChainPosition::Unconfirmed { .. } => "Unconfirmed".to_string(), - }; - - rows.push(vec![ - shorten(utxo.outpoint, 8, 10).cell(), - utxo.txout - .value - .to_sat() - .to_string() - .cell() - .justify(Justify::Right), - Address::from_script(&utxo.txout.script_pubkey, cli_opts.network) - .unwrap() - .cell(), - utxo.keychain.cell(), - utxo.is_spent.cell(), - utxo.derivation_index.cell(), - height.to_string().cell().justify(Justify::Right), - shorten(block_hash, 8, 8).cell().justify(Justify::Right), - ]); - } - let table = rows - .table() - .title(vec![ - "Outpoint".cell().bold(true), - "Output (sat)".cell().bold(true), - "Output Address".cell().bold(true), - "Keychain".cell().bold(true), - "Is Spent".cell().bold(true), - "Index".cell().bold(true), - "Block Height".cell().bold(true), - "Block Hash".cell().bold(true), - ]) - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty(&utxos)?) - } - } - Transactions => { - let transactions = wallet.transactions(); - - if cli_opts.pretty { - let txns = transactions - .map(|tx| { - let total_value = tx - .tx_node - .output - .iter() - .map(|output| output.value.to_sat()) - .sum::(); - ( - tx.tx_node.txid.to_string(), - tx.tx_node.version, - tx.tx_node.is_explicitly_rbf(), - tx.tx_node.input.len(), - tx.tx_node.output.len(), - total_value, - ) - }) - .collect::>(); - let mut rows: Vec> = vec![]; - for (txid, version, is_rbf, input_count, output_count, total_value) in txns { - rows.push(vec![ - txid.cell(), - version.to_string().cell().justify(Justify::Right), - is_rbf.to_string().cell().justify(Justify::Center), - input_count.to_string().cell().justify(Justify::Right), - output_count.to_string().cell().justify(Justify::Right), - total_value.to_string().cell().justify(Justify::Right), - ]); - } - let table = rows - .table() - .title(vec![ - "Txid".cell().bold(true), - "Version".cell().bold(true), - "Is RBF".cell().bold(true), - "Input Count".cell().bold(true), - "Output Count".cell().bold(true), - "Total Value (sat)".cell().bold(true), - ]) - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - let txns: Vec<_> = transactions - .map(|tx| { - json!({ - "txid": tx.tx_node.txid, - "is_coinbase": tx.tx_node.is_coinbase(), - "wtxid": tx.tx_node.compute_wtxid(), - "version": tx.tx_node.version, - "is_rbf": tx.tx_node.is_explicitly_rbf(), - "inputs": tx.tx_node.input, - "outputs": tx.tx_node.output, - }) - }) - .collect(); - Ok(serde_json::to_string_pretty(&txns)?) - } - } - Balance => { - let balance = wallet.balance(); - if cli_opts.pretty { - let table = vec![ - vec!["Type".cell().bold(true), "Amount (sat)".cell().bold(true)], - vec![ - "Total".cell(), - balance - .total() - .to_sat() - .to_string() - .cell() - .justify(Justify::Right), - ], - vec![ - "Confirmed".cell(), - balance - .confirmed - .to_sat() - .to_string() - .cell() - .justify(Justify::Right), - ], - vec![ - "Unconfirmed".cell(), - balance - .immature - .to_sat() - .to_string() - .cell() - .justify(Justify::Right), - ], - vec![ - "Trusted Pending".cell(), - balance - .trusted_pending - .to_sat() - .cell() - .justify(Justify::Right), - ], - vec![ - "Untrusted Pending".cell(), - balance - .untrusted_pending - .to_sat() - .cell() - .justify(Justify::Right), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({"satoshi": wallet.balance()}), - )?) - } - } - #[cfg(feature = "silent-payments")] - CreateSpTx { - recipients: maybe_recipients, - silent_payment_recipients, - send_all, - offline_signer, - utxos, - unspendable, - fee_rate, - external_policy, - internal_policy, - add_data, - add_string, - } => { - let mut tx_builder = wallet.build_tx(); - - let sp_recipients: Vec = silent_payment_recipients - .iter() - .map(|(sp_code, _)| sp_code.clone()) - .collect(); - - if send_all { - if sp_recipients.len() == 1 && maybe_recipients.is_none() { - tx_builder - .drain_wallet() - .drain_to(sp_recipients[0].get_placeholder_p2tr_spk()); - } else if let Some(ref recipients) = maybe_recipients - && sp_recipients.is_empty() - { - if recipients.len() == 1 { - tx_builder.drain_wallet().drain_to(recipients[0].0.clone()); - } else { - return Err(Error::Generic( - "Wallet can only be drain to a single output".to_string(), - )); - } - } else { - return Err(Error::Generic( - "Wallet can only be drain to a single output".to_string(), - )); - } - } else { - let mut outputs: Vec<(ScriptBuf, Amount)> = silent_payment_recipients - .iter() - .map(|(sp_code, amount)| { - let script = sp_code.get_placeholder_p2tr_spk(); - (script, Amount::from_sat(*amount)) - }) - .collect(); - - if let Some(recipients) = maybe_recipients { - let recipients = recipients - .into_iter() - .map(|(script, amount)| (script, Amount::from_sat(amount))); - - outputs.extend(recipients); - } - - tx_builder.set_recipients(outputs); - } - - // Do not enable RBF for this transaction - 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[..]) - .map_err(|_| bdk_wallet::error::CreateTxError::UnknownUtxo)?; - } - - 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) - .map_err(|e| Error::Generic(e.to_string()))?; - tx_builder.add_data( - &PushBytesBuf::try_from(op_return_data) - .map_err(|e| Error::Generic(e.to_string()))?, - ); - } else if let Some(string_data) = add_string { - let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec()) - .map_err(|e| Error::Generic(e.to_string()))?; - 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 mut psbt = tx_builder.finish()?; - - let unsigned_psbt = psbt.clone(); - - let finalized = wallet.sign(&mut psbt, SignOptions::default())?; - - if !finalized { - return Err(Error::Generic( - "Cannot produce silent payment outputs without intermediate signing phase." - .to_string(), - )); - } - - for (full_input, psbt_input) in unsigned_psbt.inputs.iter().zip(psbt.inputs.iter_mut()) - { - // repopulate key derivation data - psbt_input.bip32_derivation = full_input.bip32_derivation.clone(); - psbt_input.tap_key_origins = full_input.tap_key_origins.clone(); - } - - let secp = Secp256k1::new(); - let mut external_signers = wallet.get_signers(KeychainKind::External).as_key_map(&secp); - let internal_signers = wallet.get_signers(KeychainKind::Internal).as_key_map(&secp); - external_signers.extend(internal_signers); - - match external_signers.iter().next().expect("not empty") { - (DescriptorPublicKey::Single(single_pub), DescriptorSecretKey::Single(prv)) => { - match single_pub.key { - SinglePubKey::FullKey(pk) => { - let keys: HashMap = [(pk, prv.key)].into(); - derive_sp(&mut psbt, &keys, &sp_recipients, &secp) - .expect("will fix later"); - } - SinglePubKey::XOnly(xonly) => { - let keys: HashMap = - [(xonly, prv.key)].into(); - derive_sp(&mut psbt, &keys, &sp_recipients, &secp) - .expect("will fix later"); - } - }; - } - (_, DescriptorSecretKey::XPrv(k)) => { - derive_sp(&mut psbt, &k.xkey, &sp_recipients, &secp).expect("will fix later"); - } - _ => unimplemented!("multi xkey signer"), - }; - - // Unfinalize PSBT to resign - for psbt_input in psbt.inputs.iter_mut() { - psbt_input.final_script_sig = None; - psbt_input.final_script_witness = None; - } - - let _resigned = wallet.sign(&mut psbt, SignOptions::default())?; - - 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)}), - )?) - } - } - 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[..]) - .map_err(|_| bdk_wallet::error::CreateTxError::UnknownUtxo)?; - } - - 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) - .map_err(|e| Error::Generic(e.to_string()))?; - tx_builder.add_data( - &PushBytesBuf::try_from(op_return_data) - .map_err(|e| Error::Generic(e.to_string()))?, - ); - } else if let Some(string_data) = add_string { - let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec()) - .map_err(|e| Error::Generic(e.to_string()))?; - tx_builder.add_data(&data); - } - - let policies = vec![ - external_policy.map(|p| (p, KeychainKind::External)), - internal_policy.map(|p| (p, KeychainKind::Internal)), - ]; - - for (policy, keychain) in policies.into_iter().flatten() { - let policy = serde_json::from_str::>>(&policy)?; - tx_builder.policy_path(policy, keychain); - } - - let psbt = tx_builder.finish()?; - - let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); - - if wallet_opts.verbose { - Ok(serde_json::to_string_pretty( - &json!({"psbt": psbt_base64, "details": psbt}), - )?) - } else { - Ok(serde_json::to_string_pretty( - &json!({"psbt": psbt_base64 }), - )?) - } - } - BumpFee { - txid, - shrink_address, - offline_signer, - utxos, - unspendable, - fee_rate, - } => { - let txid = Txid::from_str(txid.as_str())?; - - let mut tx_builder = wallet.build_fee_bump(txid)?; - let fee_rate = - FeeRate::from_sat_per_vb(fee_rate as u64).unwrap_or(FeeRate::BROADCAST_MIN); - tx_builder.fee_rate(fee_rate); - - if let Some(address) = shrink_address { - let script_pubkey = address.script_pubkey(); - tx_builder.drain_to(script_pubkey); - } - - if offline_signer { - tx_builder.include_output_redeem_witness_script(); - } - - if let Some(utxos) = utxos { - tx_builder.add_utxos(&utxos[..]).unwrap(); - } - - if let Some(unspendable) = unspendable { - tx_builder.unspendable(unspendable); - } - - let psbt = tx_builder.finish()?; - - let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); - - Ok(serde_json::to_string_pretty( - &json!({"psbt": psbt_base64 }), - )?) - } - Policies => { - let external_policy = wallet.policies(KeychainKind::External)?; - let internal_policy = wallet.policies(KeychainKind::Internal)?; - if cli_opts.pretty { - let table = vec![ - vec![ - "External".cell().bold(true), - serde_json::to_string_pretty(&external_policy)?.cell(), - ], - vec![ - "Internal".cell().bold(true), - serde_json::to_string_pretty(&internal_policy)?.cell(), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty(&json!({ - "external": external_policy, - "internal": internal_policy, - }))?) - } - } - PublicDescriptor => { - let external = wallet.public_descriptor(KeychainKind::External).to_string(); - let internal = wallet.public_descriptor(KeychainKind::Internal).to_string(); - - if cli_opts.pretty { - let table = vec![ - vec![ - "External Descriptor".cell().bold(true), - external.to_string().cell(), - ], - vec![ - "Internal Descriptor".cell().bold(true), - internal.to_string().cell(), - ], - ] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty(&json!({ - "external": external.to_string(), - "internal": internal.to_string(), - }))?) - } - } - Sign { - psbt, - assume_height, - trust_witness_utxo, - } => { - let psbt_bytes = BASE64_STANDARD.decode(psbt)?; - let mut psbt = Psbt::deserialize(&psbt_bytes)?; - let signopt = SignOptions { - assume_height, - trust_witness_utxo: trust_witness_utxo.unwrap_or(false), - ..Default::default() - }; - let finalized = wallet.sign(&mut psbt, signopt)?; - let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); - if wallet_opts.verbose { - Ok(serde_json::to_string_pretty( - &json!({"psbt": &psbt_base64, "is_finalized": finalized, "serialized_psbt": &psbt}), - )?) - } else { - Ok(serde_json::to_string_pretty( - &json!({"psbt": &psbt_base64, "is_finalized": finalized}), - )?) - } - } - ExtractPsbt { psbt } => { - let psbt_serialized = BASE64_STANDARD.decode(psbt)?; - let psbt = Psbt::deserialize(&psbt_serialized)?; - let raw_tx = psbt.extract_tx()?; - if cli_opts.pretty { - let table = vec![vec![ - "Raw Transaction".cell().bold(true), - serialize_hex(&raw_tx).cell(), - ]] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({"raw_tx": serialize_hex(&raw_tx)}), - )?) - } - } - FinalizePsbt { - psbt, - assume_height, - trust_witness_utxo, - } => { - let psbt_bytes = BASE64_STANDARD.decode(psbt)?; - let mut psbt: Psbt = Psbt::deserialize(&psbt_bytes)?; - - let signopt = SignOptions { - assume_height, - trust_witness_utxo: trust_witness_utxo.unwrap_or(false), - ..Default::default() - }; - let finalized = wallet.finalize_psbt(&mut psbt, signopt)?; - if wallet_opts.verbose { - Ok(serde_json::to_string_pretty( - &json!({ "psbt": BASE64_STANDARD.encode(psbt.serialize()), "is_finalized": finalized, "details": psbt}), - )?) - } else { - Ok(serde_json::to_string_pretty( - &json!({ "psbt": BASE64_STANDARD.encode(psbt.serialize()), "is_finalized": finalized}), - )?) - } - } - CombinePsbt { psbt } => { - let mut psbts = psbt - .iter() - .map(|s| { - let psbt = BASE64_STANDARD.decode(s)?; - Ok(Psbt::deserialize(&psbt)?) - }) - .collect::, Error>>()?; - - let init_psbt = psbts - .pop() - .ok_or_else(|| Error::Generic("Invalid PSBT input".to_string()))?; - let final_psbt = psbts.into_iter().try_fold::<_, _, Result>( - init_psbt, - |mut acc, x| { - let _ = acc.combine(x); - Ok(acc) - }, - )?; - Ok(serde_json::to_string_pretty( - &json!({ "psbt": BASE64_STANDARD.encode(final_psbt.serialize()) }), - )?) - } - #[cfg(feature = "bip322")] - SignMessage { - message, - signature_type, - address, - utxos, - } => { - let address: Address = parse_address(&address)?; - let signature_format = parse_signature_format(&signature_type)?; - - if !wallet.is_mine(address.script_pubkey()) { - return Err(Error::Generic(format!( - "Address {} does not belong to this wallet.", - address - ))); - } - - let proof: MessageProof = - wallet.sign_message(message.as_str(), signature_format, &address, utxos)?; - - Ok(json!({"proof": proof.to_base64()}).to_string()) - } - #[cfg(feature = "bip322")] - VerifyMessage { - proof, - message, - address, - } => { - let address: Address = parse_address(&address)?; - let parsed_proof: MessageProof = MessageProof::from_base64(&proof) - .map_err(|e| BDKCliError::Generic(format!("Invalid proof: {e}")))?; - - let is_valid: MessageVerificationResult = - wallet.verify_message(&parsed_proof, &message, &address)?; - - Ok(json!({ - "valid": is_valid.valid, - "proven_amount": is_valid.proven_amount.map(|a| a.to_sat()) // optional field - }) - .to_string()) - } - } -} - -/// Execute an online wallet sub-command -/// -/// Online wallet sub-commands are described in [`OnlineWalletSubCommand`]. -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -pub(crate) async fn handle_online_wallet_subcommand( - wallet: &mut Wallet, - client: &BlockchainClient, - online_subcommand: OnlineWalletSubCommand, -) -> Result { - match online_subcommand { - FullScan { - stop_gap: _stop_gap, - } => { - #[cfg(any(feature = "electrum", feature = "esplora"))] - let request = wallet.start_full_scan().inspect({ - let mut stdout = std::io::stdout(); - let mut once = HashSet::::new(); - move |k, spk_i, _| { - if once.insert(k) { - print!("\nScanning keychain [{k:?}]"); - } - print!(" {spk_i:<3}"); - stdout.flush().expect("must flush"); - } - }); - match client { - #[cfg(feature = "electrum")] - Electrum { client, batch_size } => { - // Populate the electrum client's transaction cache so it doesn't re-download transaction we - // already have. - client - .populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); - - let update = client.full_scan(request, _stop_gap, *batch_size, false)?; - wallet.apply_update(update)?; - } - #[cfg(feature = "esplora")] - Esplora { - client, - parallel_requests, - } => { - let update = client - .full_scan(request, _stop_gap, *parallel_requests) - .await - .map_err(|e| *e)?; - wallet.apply_update(update)?; - } - - #[cfg(feature = "rpc")] - RpcClient { client } => { - let blockchain_info = client.get_blockchain_info()?; - - let genesis_block = - bdk_wallet::bitcoin::constants::genesis_block(wallet.network()); - let genesis_cp = CheckPoint::new(BlockId { - height: 0, - hash: genesis_block.block_hash(), - }); - let mut emitter = Emitter::new( - client.as_ref(), - genesis_cp.clone(), - genesis_cp.height(), - NO_EXPECTED_MEMPOOL_TXS, - ); - - while let Some(block_event) = emitter.next_block()? { - if block_event.block_height() % 10_000 == 0 { - let percent_done = f64::from(block_event.block_height()) - / f64::from(blockchain_info.headers as u32) - * 100f64; - println!( - "Applying block at height: {}, {:.2}% done.", - block_event.block_height(), - percent_done - ); - } - - wallet.apply_block_connected_to( - &block_event.block, - block_event.block_height(), - block_event.connected_to(), - )?; - } - - let mempool_txs = emitter.mempool()?; - wallet.apply_unconfirmed_txs(mempool_txs.update); - } - #[cfg(feature = "cbf")] - KyotoClient { client } => { - sync_kyoto_client(wallet, client).await?; - } - } - Ok(serde_json::to_string_pretty(&json!({}))?) - } - Sync => { - sync_wallet(client, wallet).await?; - Ok(serde_json::to_string_pretty(&json!({}))?) - } - Broadcast { psbt, tx } => { - let tx = match (psbt, tx) { - (Some(psbt), None) => { - let psbt = BASE64_STANDARD - .decode(psbt) - .map_err(|e| Error::Generic(e.to_string()))?; - let psbt: Psbt = Psbt::deserialize(&psbt)?; - is_final(&psbt)?; - psbt.extract_tx()? - } - (None, Some(tx)) => { - let tx_bytes = Vec::::from_hex(&tx)?; - Transaction::consensus_decode(&mut tx_bytes.as_slice())? - } - (Some(_), Some(_)) => panic!("Both `psbt` and `tx` options not allowed"), - (None, None) => panic!("Missing `psbt` and `tx` option"), - }; - let txid = broadcast_transaction(client, tx).await?; - Ok(serde_json::to_string_pretty(&json!({ "txid": txid }))?) - } - ReceivePayjoin { - amount, - directory, - ohttp_relay, - max_fee_rate, - } => { - let relay_manager = Arc::new(Mutex::new(RelayManager::new())); - let mut payjoin_manager = PayjoinManager::new(wallet, relay_manager); - return payjoin_manager - .receive_payjoin(amount, directory, max_fee_rate, ohttp_relay, client) - .await; - } - SendPayjoin { - uri, - ohttp_relay, - fee_rate, - } => { - let relay_manager = Arc::new(Mutex::new(RelayManager::new())); - let mut payjoin_manager = PayjoinManager::new(wallet, relay_manager); - return payjoin_manager - .send_payjoin(uri, fee_rate, ohttp_relay, client) - .await; - } - } -} - -/// Handle wallet config subcommand to create or update config.toml -pub fn handle_config_subcommand( - datadir: &Path, - network: Network, - wallet: String, - wallet_opts: &WalletOpts, - force: bool, -) -> Result { - if network == Network::Bitcoin { - eprintln!( - "WARNING: You are configuring a wallet for Bitcoin MAINNET. - This software is experimental and not recommended for use with real funds. - Consider using a testnet for testing purposes. \n" - ); - } - - let ext_descriptor = wallet_opts.ext_descriptor.clone(); - let int_descriptor = wallet_opts.int_descriptor.clone(); - - if ext_descriptor.contains("xprv") || ext_descriptor.contains("tprv") { - eprintln!( - "WARNING: Your external descriptor contains PRIVATE KEYS. - Private keys will be saved in PLAINTEXT in the config file. - This is a security risk. Consider using public descriptors instead.\n" - ); - } - - if let Some(ref internal_desc) = int_descriptor - && (internal_desc.contains("xprv") || internal_desc.contains("tprv")) - { - eprintln!( - "WARNING: Your internal descriptor contains PRIVATE KEYS. - Private keys will be saved in PLAINTEXT in the config file. - This is a security risk. Consider using public descriptors instead.\n" - ); - } - - let mut config = WalletConfig::load(datadir)?.unwrap_or(WalletConfig { - wallets: HashMap::new(), - }); - - if config.wallets.contains_key(&wallet) && !force { - return Err(Error::Generic(format!( - "Wallet '{wallet}' already exists in config.toml. Use --force to overwrite." - ))); - } - - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] - let client_type = wallet_opts.client_type.clone(); - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] - let url = &wallet_opts.url.clone(); - #[cfg(any(feature = "sqlite", feature = "redb"))] - let database_type = match wallet_opts.database_type { - #[cfg(feature = "sqlite")] - DatabaseType::Sqlite => "sqlite".to_string(), - #[cfg(feature = "redb")] - DatabaseType::Redb => "redb".to_string(), - }; - - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] - let client_type = match client_type { - #[cfg(feature = "electrum")] - ClientType::Electrum => "electrum".to_string(), - #[cfg(feature = "esplora")] - ClientType::Esplora => "esplora".to_string(), - #[cfg(feature = "rpc")] - ClientType::Rpc => "rpc".to_string(), - #[cfg(feature = "cbf")] - ClientType::Cbf => "cbf".to_string(), - }; - - let wallet_config = WalletConfigInner { - wallet: wallet.clone(), - network: network.to_string(), - ext_descriptor, - int_descriptor, - #[cfg(any(feature = "sqlite", feature = "redb"))] - database_type, - #[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "rpc", - feature = "cbf" - ))] - client_type: Some(client_type), - #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc",))] - server_url: Some(url.to_string()), - #[cfg(feature = "rpc")] - rpc_user: Some(wallet_opts.basic_auth.0.clone()), - #[cfg(feature = "rpc")] - rpc_password: Some(wallet_opts.basic_auth.1.clone()), - #[cfg(feature = "electrum")] - batch_size: Some(wallet_opts.batch_size), - #[cfg(feature = "esplora")] - parallel_requests: Some(wallet_opts.parallel_requests), - #[cfg(feature = "rpc")] - cookie: wallet_opts.cookie.clone(), - }; - - config.wallets.insert(wallet.clone(), wallet_config); - config.save(datadir)?; - - Ok(serde_json::to_string_pretty(&json!({ - "message": format!("Wallet '{wallet}' initialized successfully in {:?}", datadir.join("config.toml")) - }))?) -} - -/// Determine if PSBT has final script sigs or witnesses for all unsigned tx inputs. -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] -pub(crate) fn is_final(psbt: &Psbt) -> Result<(), Error> { - let unsigned_tx_inputs = psbt.unsigned_tx.input.len(); - let psbt_inputs = psbt.inputs.len(); - if unsigned_tx_inputs != psbt_inputs { - return Err(Error::Generic(format!( - "Malformed PSBT, {unsigned_tx_inputs} unsigned tx inputs and {psbt_inputs} psbt inputs." - ))); - } - let sig_count = psbt.inputs.iter().fold(0, |count, input| { - if input.final_script_sig.is_some() || input.final_script_witness.is_some() { - count + 1 - } else { - count - } - }); - if unsigned_tx_inputs > sig_count { - return Err(Error::Generic( - "The PSBT is not finalized, inputs are are not fully signed.".to_string(), - )); - } - Ok(()) -} - -#[cfg(feature = "silent-payments")] -pub(crate) fn handle_sp_subcommand( - scan_pubkey: bdk_sp::bitcoin::secp256k1::PublicKey, - spend_pubkey: bdk_sp::bitcoin::secp256k1::PublicKey, - network: Network, - pretty: bool, -) -> Result { - let sp_code = SilentPaymentCode::new_v0(scan_pubkey, spend_pubkey, network); - if pretty { - let table = vec![vec![ - "sp_code".cell().bold(true), - sp_code.to_string().cell(), - ]] - .table() - .display() - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(format!("{table}")) - } else { - Ok(serde_json::to_string_pretty( - &json!({"sp_code": sp_code.to_string()}), - )?) - } -} - -/// 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 = "silent-payments")] - CliSubCommand::SilentPaymentCode { scan, spend } => { - let network = cli_opts.network; - let result = handle_sp_subcommand(scan, spend, network, 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 0000000..e64d165 --- /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 0000000..c76c078 --- /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 0000000..40c050c --- /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())) + } + } + } +}