diff --git a/src/commands.rs b/src/commands.rs index 54cddf0..d3f2d98 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -13,7 +13,6 @@ //! All subcommands are defined in the below enums. #![allow(clippy::large_enum_variant)] - use bdk_wallet::bitcoin::{ Address, Network, OutPoint, ScriptBuf, bip32::{DerivationPath, Xpriv}, @@ -107,8 +106,23 @@ pub enum CliSubCommand { #[command(flatten)] wallet_opts: WalletOpts, }, + /// Output Descriptors operations. + /// + /// Generate output descriptors from either extended key (Xprv/Xpub) or mnemonic phrase. + /// This feature is intended for development and testing purposes only. + Descriptor { + /// Descriptor type (script type) + #[arg( + long = "type", + short = 't', + value_parser = ["pkh", "wpkh", "sh", "wsh", "tr"], + default_value = "wsh" + )] + desc_type: String, + /// Optional key: xprv, xpub, or mnemonic phrase + key: Option, + }, } - /// Wallet operation subcommands. #[derive(Debug, Subcommand, Clone, PartialEq)] pub enum WalletSubCommand { @@ -470,6 +484,19 @@ pub enum ReplSubCommand { #[command(subcommand)] subcommand: KeySubCommand, }, + /// Generate descriptors + Descriptor { + /// Descriptor type (script type). + #[arg( + long = "type", + short = 't', + value_parser = ["pkh", "wpkh", "sh", "wsh", "tr"], + default_value = "wsh" + )] + desc_type: String, + /// Optional key: xprv, xpub, or mnemonic phrase + key: Option, + }, /// Exit REPL loop. Exit, } diff --git a/src/error.rs b/src/error.rs index 5f548d9..1b8b5b4 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,8 +5,8 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum BDKCliError { - #[error("BIP39 error: {0}")] - BIP39Error(#[from] bdk_wallet::bip39::Error), + #[error("BIP39 error: {0:?}")] + BIP39Error(#[from] Option), #[error("BIP32 error: {0}")] BIP32Error(#[from] bdk_wallet::bitcoin::bip32::Error), diff --git a/src/handlers.rs b/src/handlers.rs index d9d2cbe..6c58a83 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -15,12 +15,12 @@ use crate::commands::*; 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}, @@ -40,11 +40,17 @@ 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 cli_table::{Cell, CellStruct, Style, Table, format::Justify}; use serde_json::json; +#[cfg(feature = "cbf")] +use {crate::utils::BlockchainClient::KyotoClient, bdk_kyoto::LightClient, tokio::select}; + +#[cfg(feature = "electrum")] +use crate::utils::BlockchainClient::Electrum; use std::collections::BTreeMap; #[cfg(any(feature = "electrum", feature = "esplora"))] use std::collections::HashSet; @@ -54,16 +60,6 @@ use std::io::Write; use std::str::FromStr; #[cfg(any(feature = "redb", feature = "compiler"))] use std::sync::Arc; - -#[cfg(feature = "electrum")] -use crate::utils::BlockchainClient::Electrum; -#[cfg(feature = "cbf")] -use bdk_kyoto::LightClient; -#[cfg(feature = "compiler")] -use bdk_wallet::bitcoin::XOnlyPublicKey; -use bdk_wallet::bitcoin::base64::prelude::*; -#[cfg(feature = "cbf")] -use tokio::select; #[cfg(any( feature = "electrum", feature = "esplora", @@ -1260,6 +1256,10 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { } Ok("".to_string()) } + CliSubCommand::Descriptor { desc_type, key } => { + let descriptor = handle_descriptor_command(cli_opts.network, desc_type, key, pretty)?; + Ok(descriptor) + } }; result } @@ -1307,6 +1307,11 @@ async fn respond( .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 { @@ -1333,6 +1338,35 @@ fn readline() -> Result { 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( diff --git a/src/utils.rs b/src/utils.rs index cb81074..8a3ee04 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -10,10 +10,12 @@ //! //! This module includes all the utility tools used by the App. use crate::error::BDKCliError as Error; -use std::fmt::Display; -use std::str::FromStr; - -use std::path::{Path, PathBuf}; +use std::{ + fmt::Display, + path::{Path, PathBuf}, + str::FromStr, + sync::Arc, +}; use crate::commands::WalletOpts; #[cfg(feature = "cbf")] @@ -21,7 +23,17 @@ use bdk_kyoto::{ BuilderExt, Info, LightClient, Receiver, ScanType::Sync, UnboundedReceiver, Warning, builder::Builder, }; -use bdk_wallet::bitcoin::{Address, Network, OutPoint, ScriptBuf}; +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", @@ -33,7 +45,15 @@ use crate::commands::ClientType; use bdk_wallet::Wallet; #[cfg(any(feature = "sqlite", feature = "redb"))] -use bdk_wallet::{KeychainKind, PersistedWallet, WalletPersister}; +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> { @@ -364,3 +384,250 @@ pub(crate) fn shorten(displayable: impl Display, start: u8, end: u8) -> String { 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}")) +}