diff --git a/Cargo.toml b/Cargo.toml index 5817b979..2515e63b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ cc = { version = "=1.0.62", optional = true } socks = { version = "0.3", optional = true } lazy_static = { version = "1.4", optional = true } tiny-bip39 = { version = "^0.8", optional = true } +structopt = { version = "^0.3", optional = true } # Platform-specific dependencies [target.'cfg(not(target_arch = "wasm32"))'.dependencies] @@ -45,7 +46,7 @@ electrum = ["electrum-client"] esplora = ["reqwest", "futures"] compact_filters = ["rocksdb", "socks", "lazy_static", "cc"] key-value-db = ["sled"] -cli-utils = ["clap", "base64"] +cli-utils = ["clap", "base64", "structopt"] async-interface = ["async-trait"] all-keys = ["keys-bip39"] keys-bip39 = ["tiny-bip39"] diff --git a/examples/repl.rs b/examples/repl.rs index 723b6786..f3b2887d 100644 --- a/examples/repl.rs +++ b/examples/repl.rs @@ -24,27 +24,34 @@ use std::fs; use std::path::PathBuf; +use std::str::FromStr; use std::sync::Arc; +use bitcoin::Network; +use clap::AppSettings; +use log::{debug, error, info, trace, warn, LevelFilter}; use rustyline::error::ReadlineError; use rustyline::Editor; - -use clap::AppSettings; - -#[allow(unused_imports)] -use log::{debug, error, info, trace, LevelFilter}; - -use bitcoin::Network; +use structopt::StructOpt; use bdk::bitcoin; +use bdk::blockchain::esplora::EsploraBlockchainConfig; use bdk::blockchain::{ AnyBlockchain, AnyBlockchainConfig, ConfigurableBlockchain, ElectrumBlockchainConfig, }; -use bdk::cli; +use bdk::cli::{self, WalletOpt, WalletSubCommand}; use bdk::sled; use bdk::Wallet; -use bdk::blockchain::esplora::EsploraBlockchainConfig; +#[derive(Debug, StructOpt, Clone, PartialEq)] +#[structopt(name = "BDK Wallet", setting = AppSettings::NoBinaryName, +version = option_env ! ("CARGO_PKG_VERSION").unwrap_or("unknown"), +author = option_env ! ("CARGO_PKG_AUTHORS").unwrap_or(""))] +struct ReplOpt { + /// Wallet sub-command + #[structopt(subcommand)] + pub subcommand: WalletSubCommand, +} fn prepare_home_dir() -> PathBuf { let mut dir = PathBuf::new(); @@ -61,100 +68,96 @@ fn prepare_home_dir() -> PathBuf { } fn main() { - env_logger::init(); + let cli_opt: WalletOpt = WalletOpt::from_args(); - let app = cli::make_cli_subcommands(); - let mut repl_app = app.clone().setting(AppSettings::NoBinaryName); + let level = LevelFilter::from_str(cli_opt.log_level.as_str()).unwrap_or(LevelFilter::Info); + env_logger::builder().filter_level(level).init(); - let app = cli::add_global_flags(app); + let network = Network::from_str(cli_opt.network.as_str()).unwrap_or(Network::Testnet); + debug!("network: {:?}", network); + if network == Network::Bitcoin { + warn!("This is experimental software and not currently recommended for use on Bitcoin mainnet, proceed with caution.") + } - let matches = app.get_matches(); - - // TODO - // let level = match matches.occurrences_of("v") { - // 0 => LevelFilter::Info, - // 1 => LevelFilter::Debug, - // _ => LevelFilter::Trace, - // }; - - let network = match matches.value_of("network") { - Some("regtest") => Network::Regtest, - Some("testnet") | _ => Network::Testnet, - }; - - let descriptor = matches.value_of("descriptor").unwrap(); - let change_descriptor = matches.value_of("change_descriptor"); + let descriptor = cli_opt.descriptor.as_str(); + let change_descriptor = cli_opt.change_descriptor.as_deref(); debug!("descriptors: {:?} {:?}", descriptor, change_descriptor); let database = sled::open(prepare_home_dir().to_str().unwrap()).unwrap(); - let tree = database - .open_tree(matches.value_of("wallet").unwrap()) - .unwrap(); + let tree = database.open_tree(cli_opt.wallet).unwrap(); debug!("database opened successfully"); - let config = match matches.value_of("esplora") { + let config = match cli_opt.esplora { Some(base_url) => AnyBlockchainConfig::Esplora(EsploraBlockchainConfig { base_url: base_url.to_string(), - concurrency: matches - .value_of("esplora_concurrency") - .and_then(|v| v.parse::().ok()), + concurrency: Some(cli_opt.esplora_concurrency), }), None => AnyBlockchainConfig::Electrum(ElectrumBlockchainConfig { - url: matches.value_of("server").unwrap().to_string(), - socks5: matches.value_of("proxy").map(ToString::to_string), + url: cli_opt.electrum, + socks5: cli_opt.proxy, retry: 10, timeout: 10, }), }; - let wallet = Arc::new( - Wallet::new( - descriptor, - change_descriptor, - network, - tree, - AnyBlockchain::from_config(&config).unwrap(), - ) - .unwrap(), - ); - if let Some(_sub_matches) = matches.subcommand_matches("repl") { - let mut rl = Editor::<()>::new(); + let wallet = Wallet::new( + descriptor, + change_descriptor, + network, + tree, + AnyBlockchain::from_config(&config).unwrap(), + ) + .unwrap(); - // if rl.load_history("history.txt").is_err() { - // println!("No previous history."); - // } + let wallet = Arc::new(wallet); - loop { - let readline = rl.readline(">> "); - match readline { - Ok(line) => { - if line.trim() == "" { - continue; + match cli_opt.subcommand { + WalletSubCommand::Other(external) if external.contains(&"repl".to_string()) => { + let mut rl = Editor::<()>::new(); + + // if rl.load_history("history.txt").is_err() { + // println!("No previous history."); + // } + + loop { + let readline = rl.readline(">> "); + match readline { + Ok(line) => { + if line.trim() == "" { + continue; + } + rl.add_history_entry(line.as_str()); + let split_line: Vec<&str> = line.split(" ").collect(); + let repl_subcommand: Result = + ReplOpt::from_iter_safe(split_line); + debug!("repl_subcommand = {:?}", repl_subcommand); + + if let Err(err) = repl_subcommand { + println!("{}", err.message); + continue; + } + + let result = cli::handle_wallet_subcommand( + &Arc::clone(&wallet), + repl_subcommand.unwrap().subcommand, + ) + .unwrap(); + println!("{}", serde_json::to_string_pretty(&result).unwrap()); } - - rl.add_history_entry(line.as_str()); - let matches = repl_app.get_matches_from_safe_borrow(line.split(" ")); - if let Err(err) = matches { - println!("{}", err.message); - continue; + Err(ReadlineError::Interrupted) => continue, + Err(ReadlineError::Eof) => break, + Err(err) => { + println!("{:?}", err); + break; } - - let result = - cli::handle_matches(&Arc::clone(&wallet), matches.unwrap()).unwrap(); - println!("{}", serde_json::to_string_pretty(&result).unwrap()); - } - Err(ReadlineError::Interrupted) => continue, - Err(ReadlineError::Eof) => break, - Err(err) => { - println!("{:?}", err); - break; } } - } - // rl.save_history("history.txt").unwrap(); - } else { - let result = cli::handle_matches(&wallet, matches).unwrap(); - println!("{}", serde_json::to_string_pretty(&result).unwrap()); + // rl.save_history("history.txt").unwrap(); + } + _ => { + let result = cli::handle_wallet_subcommand(&wallet, cli_opt.subcommand).unwrap(); + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + } } } diff --git a/src/cli.rs b/src/cli.rs index 882f6da7..b4f0b732 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -22,10 +22,77 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +//! Command line interface +//! +//! This module provides a [structopt](https://docs.rs/crate/structopt) `struct` and `enum` that +//! parse global wallet options and wallet subcommand options needed for a wallet command line +//! interface. +//! +//! See the `repl.rs` example for how to use this module to create a simple command line REPL +//! wallet application. +//! +//! See [`WalletOpt`] for global wallet options and [`WalletSubCommand`] for supported sub-commands. +//! +//! # Example +//! +//! ``` +//! # use bdk::bitcoin::Network; +//! # use bdk::blockchain::esplora::EsploraBlockchainConfig; +//! # use bdk::blockchain::{AnyBlockchain, ConfigurableBlockchain}; +//! # use bdk::blockchain::{AnyBlockchainConfig, ElectrumBlockchainConfig}; +//! # use bdk::cli::{self, WalletOpt, WalletSubCommand}; +//! # use bdk::database::MemoryDatabase; +//! # use bdk::Wallet; +//! # use bitcoin::hashes::core::str::FromStr; +//! # use std::sync::Arc; +//! # use structopt::StructOpt; +//! +//! // to get args from cli use: +//! // let cli_opt = WalletOpt::from_args(); +//! +//! let cli_args = vec!["repl", "--network", "testnet", "--descriptor", +//! "wpkh(tpubEBr4i6yk5nf5DAaJpsi9N2pPYBeJ7fZ5Z9rmN4977iYLCGco1VyjB9tvvuvYtfZzjD5A8igzgw3HeWeeKFmanHYqksqZXYXGsw5zjnj7KM9/*)", +//! "sync", "--max_addresses", "50"]; +//! let cli_opt = WalletOpt::from_iter(&cli_args); +//! +//! let network = Network::from_str(cli_opt.network.as_str()).unwrap_or(Network::Testnet); +//! +//! let descriptor = cli_opt.descriptor.as_str(); +//! let change_descriptor = cli_opt.change_descriptor.as_deref(); +//! +//! let database = MemoryDatabase::new(); +//! +//! let config = match cli_opt.esplora { +//! Some(base_url) => AnyBlockchainConfig::Esplora(EsploraBlockchainConfig { +//! base_url: base_url.to_string(), +//! concurrency: Some(cli_opt.esplora_concurrency), +//! }), +//! None => AnyBlockchainConfig::Electrum(ElectrumBlockchainConfig { +//! url: cli_opt.electrum, +//! socks5: cli_opt.proxy, +//! retry: 3, +//! timeout: 5, +//! }), +//! }; +//! +//! let wallet = Wallet::new( +//! descriptor, +//! change_descriptor, +//! network, +//! database, +//! AnyBlockchain::from_config(&config).unwrap(), +//! ).unwrap(); +//! +//! let wallet = Arc::new(wallet); +//! +//! let result = cli::handle_wallet_subcommand(&wallet, cli_opt.subcommand).unwrap(); +//! println!("{}", serde_json::to_string_pretty(&result).unwrap()); +//! ``` + use std::collections::BTreeMap; use std::str::FromStr; -use clap::{App, Arg, ArgMatches, SubCommand}; +use structopt::StructOpt; #[allow(unused_imports)] use log::{debug, error, info, trace, LevelFilter}; @@ -40,6 +107,289 @@ use crate::error::Error; use crate::types::ScriptType; use crate::{FeeRate, TxBuilder, Wallet}; +/// Wallet global options and sub-command +/// +/// A [structopt](https://docs.rs/crate/structopt) `struct` that parses wallet global options and +/// sub-command from the command line or from a `String` vector. See [`WalletSubCommand`] for details +/// on parsing sub-commands. +/// +/// # Example +/// +/// ``` +/// # use bdk::cli::{WalletOpt, WalletSubCommand}; +/// # use structopt::StructOpt; +/// +/// let cli_args = vec!["repl", "--network", "testnet", +/// "--descriptor", "wpkh(tpubEBr4i6yk5nf5DAaJpsi9N2pPYBeJ7fZ5Z9rmN4977iYLCGco1VyjB9tvvuvYtfZzjD5A8igzgw3HeWeeKFmanHYqksqZXYXGsw5zjnj7KM9/44'/1'/0'/0/*)", +/// "sync", "--max_addresses", "50"]; +/// +/// // to get WalletOpt from OS command line args use: +/// // let wallet_opt = WalletOpt::from_args(); +/// +/// let wallet_opt = WalletOpt::from_iter(&cli_args); +/// +/// let expected_wallet_opt = WalletOpt { +/// network: "testnet".to_string(), +/// wallet: "main".to_string(), +/// proxy: None, +/// descriptor: "wpkh(tpubEBr4i6yk5nf5DAaJpsi9N2pPYBeJ7fZ5Z9rmN4977iYLCGco1VyjB9tvvuvYtfZzjD5A8igzgw3HeWeeKFmanHYqksqZXYXGsw5zjnj7KM9/44'/1'/0'/0/*)".to_string(), +/// change_descriptor: None, +/// log_level: "info".to_string(), +/// #[cfg(feature = "esplora")] +/// esplora: None, +/// #[cfg(feature = "esplora")] +/// esplora_concurrency: 4, +/// electrum: "ssl://electrum.blockstream.info:60002".to_string(), +/// subcommand: WalletSubCommand::Sync { +/// max_addresses: Some(50) +/// }, +/// }; +/// +/// assert_eq!(expected_wallet_opt, wallet_opt); +/// ``` + +#[derive(Debug, StructOpt, Clone, PartialEq)] +#[structopt(name = "BDK Wallet", +version = option_env ! ("CARGO_PKG_VERSION").unwrap_or("unknown"), +author = option_env ! ("CARGO_PKG_AUTHORS").unwrap_or(""))] +pub struct WalletOpt { + /// Sets the network + #[structopt( + name = "NETWORK", + short = "n", + long = "network", + default_value = "testnet" + )] + pub network: String, + /// Selects the wallet to use + #[structopt( + name = "WALLET_NAME", + short = "w", + long = "wallet", + default_value = "main" + )] + pub wallet: String, + #[cfg(feature = "electrum")] + /// Sets the SOCKS5 proxy for the Electrum client + #[structopt(name = "PROXY_SERVER:PORT", short = "p", long = "proxy")] + pub proxy: Option, + /// Sets the descriptor to use for the external addresses + #[structopt(name = "DESCRIPTOR", short = "d", long = "descriptor", required = true)] + pub descriptor: String, + /// Sets the descriptor to use for internal addresses + #[structopt(name = "CHANGE_DESCRIPTOR", short = "c", long = "change_descriptor")] + pub change_descriptor: Option, + /// Sets the logging level filter (off, error, warn, info, debug, trace) + #[structopt(long = "log_level", short = "l", default_value = "info")] + pub log_level: String, + #[cfg(feature = "esplora")] + /// Use the esplora server if given as parameter + #[structopt(name = "ESPLORA_URL", short = "e", long = "esplora")] + pub esplora: Option, + #[cfg(feature = "esplora")] + /// Concurrency of requests made to the esplora server + #[structopt( + name = "ESPLORA_CONCURRENCY", + long = "esplora_concurrency", + default_value = "4" + )] + pub esplora_concurrency: u8, + #[cfg(feature = "electrum")] + /// Sets the Electrum server to use + #[structopt( + name = "SERVER:PORT", + short = "s", + long = "server", + default_value = "ssl://electrum.blockstream.info:60002" + )] + pub electrum: String, + /// Wallet sub-command + #[structopt(subcommand)] + pub subcommand: WalletSubCommand, +} + +/// Wallet sub-command +/// +/// A [structopt](https://docs.rs/crate/structopt) enum that parses wallet sub-command arguments from +/// the command line or from a `String` vector, such as in the [`repl`](https://github.com/bitcoindevkit/bdk/blob/master/examples/repl.rs) +/// example app. +/// +/// Additional "external" sub-commands can be captured via the [`WalletSubCommand::Other`] enum and passed to a +/// custom `structopt` or another parser. See [structopt "External subcommands"](https://docs.rs/structopt/0.3.21/structopt/index.html#external-subcommands) +/// for more information. +/// +/// # Example +/// +/// ``` +/// # use bdk::cli::WalletSubCommand; +/// # use structopt::StructOpt; +/// +/// let sync_sub_command = WalletSubCommand::from_iter(&["repl", "sync", "--max_addresses", "50"]); +/// assert!(matches!( +/// sync_sub_command, +/// WalletSubCommand::Sync { +/// max_addresses: Some(50) +/// } +/// )); +/// +/// let other_sub_command = WalletSubCommand::from_iter(&["repl", "custom", "--param1", "20"]); +/// let external_args: Vec = vec!["custom".to_string(), "--param1".to_string(), "20".to_string()]; +/// assert!(matches!( +/// other_sub_command, +/// WalletSubCommand::Other(v) if v == external_args +/// )); +/// ``` +/// +/// To capture wallet sub-commands from a string vector without a preceeding binary name you can +/// create a custom struct the includes the `NoBinaryName` clap setting and wraps the WalletSubCommand +/// enum. See also the [`repl`](https://github.com/bitcoindevkit/bdk/blob/master/examples/repl.rs) +/// example app. +/// +/// # Example +/// ``` +/// # use bdk::cli::WalletSubCommand; +/// # use structopt::StructOpt; +/// # use clap::AppSettings; +/// +/// #[derive(Debug, StructOpt, Clone, PartialEq)] +/// #[structopt(name = "BDK Wallet", setting = AppSettings::NoBinaryName, +/// version = option_env ! ("CARGO_PKG_VERSION").unwrap_or("unknown"), +/// author = option_env ! ("CARGO_PKG_AUTHORS").unwrap_or(""))] +/// struct ReplOpt { +/// /// Wallet sub-command +/// #[structopt(subcommand)] +/// pub subcommand: WalletSubCommand, +/// } +/// ``` +#[derive(Debug, StructOpt, Clone, PartialEq)] +#[structopt( + rename_all = "snake", + long_about = "A modern, lightweight, descriptor-based wallet" +)] +pub enum WalletSubCommand { + /// Generates a new external address + GetNewAddress, + /// Syncs with the chosen blockchain server + Sync { + /// max addresses to consider + #[structopt(short = "v", long = "max_addresses")] + max_addresses: Option, + }, + /// Lists the available spendable UTXOs + ListUnspent, + /// Lists all the incoming and outgoing transactions of the wallet + ListTransactions, + /// Returns the current wallet balance + GetBalance, + /// Creates a new unsigned transaction + CreateTx { + /// Adds a recipient to the transaction + #[structopt(name = "ADDRESS:SAT", long = "to", required = true, parse(try_from_str = parse_recipient))] + recipients: Vec<(Script, u64)>, + /// Sends all the funds (or all the selected utxos). Requires only one recipients of value 0 + #[structopt(short = "all", long = "send_all")] + send_all: bool, + /// Enables Replace-By-Fee (BIP125) + #[structopt(short = "rbf", long = "enable_rbf")] + enable_rbf: bool, + /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. + #[structopt(long = "offline_signer")] + offline_signer: bool, + /// Selects which utxos *must* be spent + #[structopt(name = "MUST_SPEND_TXID:VOUT", long = "utxos", parse(try_from_str = parse_outpoint))] + utxos: Option>, + /// Marks a utxo as unspendable + #[structopt(name = "CANT_SPEND_TXID:VOUT", long = "unspendable", parse(try_from_str = parse_outpoint))] + unspendable: Option>, + /// Fee rate to use in sat/vbyte + #[structopt(name = "SATS_VBYTE", short = "fee", long = "fee_rate")] + fee_rate: Option, + /// Selects which policy should be used to satisfy the external descriptor + #[structopt(name = "EXT_POLICY", long = "external_policy")] + external_policy: Option, + /// Selects which policy should be used to satisfy the internal descriptor + #[structopt(name = "INT_POLICY", long = "internal_policy")] + internal_policy: Option, + }, + /// Bumps the fees of an RBF transaction + BumpFee { + /// TXID of the transaction to update + #[structopt(name = "TXID", short = "txid", long = "txid")] + txid: String, + /// Allows the wallet to reduce the amount of the only output in order to increase fees. This is generally the expected behavior for transactions originally created with `send_all` + #[structopt(short = "all", long = "send_all")] + send_all: bool, + /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. + #[structopt(long = "offline_signer")] + offline_signer: bool, + /// Selects which utxos *must* be added to the tx. Unconfirmed utxos cannot be used + #[structopt(name = "MUST_SPEND_TXID:VOUT", long = "utxos", parse(try_from_str = parse_outpoint))] + utxos: Option>, + /// Marks an utxo as unspendable, in case more inputs are needed to cover the extra fees + #[structopt(name = "CANT_SPEND_TXID:VOUT", long = "unspendable", parse(try_from_str = parse_outpoint))] + unspendable: Option>, + /// The new targeted fee rate in sat/vbyte + #[structopt(name = "SATS_VBYTE", short = "fee", long = "fee_rate")] + fee_rate: f32, + }, + /// Returns the available spending policies for the descriptor + Policies, + /// Returns the public version of the wallet's descriptor(s) + PublicDescriptor, + /// Signs and tries to finalize a PSBT + Sign { + /// Sets the PSBT to sign + #[structopt(name = "BASE64_PSBT", long = "psbt")] + psbt: String, + /// Assume the blockchain has reached a specific height. This affects the transaction finalization, if there are timelocks in the descriptor + #[structopt(name = "HEIGHT", long = "assume_height")] + assume_height: Option, + }, + /// Broadcasts a transaction to the network. Takes either a raw transaction or a PSBT to extract + Broadcast { + /// Sets the PSBT to sign + #[structopt( + name = "BASE64_PSBT", + long = "psbt", + required_unless = "RAWTX", + conflicts_with = "RAWTX" + )] + psbt: Option, + /// Sets the raw transaction to broadcast + #[structopt( + name = "RAWTX", + long = "tx", + required_unless = "BASE64_PSBT", + conflicts_with = "BASE64_PSBT" + )] + tx: Option, + }, + /// Extracts a raw transaction from a PSBT + ExtractPsbt { + /// Sets the PSBT to extract + #[structopt(name = "BASE64_PSBT", long = "psbt")] + psbt: String, + }, + /// Finalizes a PSBT + FinalizePsbt { + /// Sets the PSBT to finalize + #[structopt(name = "BASE64_PSBT", long = "psbt")] + psbt: String, + /// Assume the blockchain has reached a specific height + #[structopt(name = "HEIGHT", long = "assume_height")] + assume_height: Option, + }, + /// Combines multiple PSBTs into one + CombinePsbt { + /// Add one PSBT to combine. This option can be repeated multiple times, one for each PSBT + #[structopt(name = "BASE64_PSBT", long = "psbt", required = true)] + psbt: Vec, + }, + /// Put any extra arguments into this Vec + #[structopt(external_subcommand)] + Other(Vec), +} + fn parse_recipient(s: &str) -> Result<(Script, u64), String> { let parts: Vec<_> = s.split(':').collect(); if parts.len() != 2 { @@ -62,565 +412,339 @@ fn parse_outpoint(s: &str) -> Result { OutPoint::from_str(s).map_err(|e| format!("{:?}", e)) } -fn recipient_validator(s: String) -> Result<(), String> { - parse_recipient(&s).map(|_| ()) -} - -fn outpoint_validator(s: String) -> Result<(), String> { - parse_outpoint(&s).map(|_| ()) -} - -pub fn make_cli_subcommands<'a, 'b>() -> App<'a, 'b> { - App::new("Magical Bitcoin Wallet") - .version(option_env!("CARGO_PKG_VERSION").unwrap_or("unknown")) - .author(option_env!("CARGO_PKG_AUTHORS").unwrap_or("")) - .about("A modern, lightweight, descriptor-based wallet") - .subcommand( - SubCommand::with_name("get_new_address").about("Generates a new external address"), - ) - .subcommand(SubCommand::with_name("sync").about("Syncs with the chosen Electrum server").arg( - Arg::with_name("max_addresses") - .required(false) - .takes_value(true) - .long("max_addresses") - .help("max addresses to consider"), - )) - .subcommand( - SubCommand::with_name("list_unspent").about("Lists the available spendable UTXOs"), - ) - .subcommand( - SubCommand::with_name("list_transactions").about("Lists all the incoming and outgoing transactions of the wallet"), - ) - .subcommand( - SubCommand::with_name("get_balance").about("Returns the current wallet balance"), - ) - .subcommand( - SubCommand::with_name("create_tx") - .about("Creates a new unsigned tranasaction") - .arg( - Arg::with_name("to") - .long("to") - .value_name("ADDRESS:SAT") - .help("Adds a recipient to the transaction") - .takes_value(true) - .number_of_values(1) - .required(true) - .multiple(true) - .validator(recipient_validator), - ) - .arg( - Arg::with_name("send_all") - .short("all") - .long("send_all") - .help("Sends all the funds (or all the selected utxos). Requires only one recipients of value 0"), - ) - .arg( - Arg::with_name("enable_rbf") - .short("rbf") - .long("enable_rbf") - .help("Enables Replace-By-Fee (BIP125)"), - ) - .arg( - Arg::with_name("utxos") - .long("utxos") - .value_name("TXID:VOUT") - .help("Selects which utxos *must* be spent") - .takes_value(true) - .number_of_values(1) - .multiple(true) - .validator(outpoint_validator), - ) - .arg( - Arg::with_name("unspendable") - .long("unspendable") - .value_name("TXID:VOUT") - .help("Marks an utxo as unspendable") - .takes_value(true) - .number_of_values(1) - .multiple(true) - .validator(outpoint_validator), - ) - .arg( - Arg::with_name("fee_rate") - .short("fee") - .long("fee_rate") - .value_name("SATS_VBYTE") - .help("Fee rate to use in sat/vbyte") - .takes_value(true), - ) - .arg( - Arg::with_name("external_policy") - .long("external_policy") - .value_name("POLICY") - .help("Selects which policy should be used to satisfy the external descriptor") - .takes_value(true) - .number_of_values(1), - ) - .arg( - Arg::with_name("internal_policy") - .long("internal_policy") - .value_name("POLICY") - .help("Selects which policy should be used to satisfy the internal descriptor") - .takes_value(true) - .number_of_values(1), - ) - .arg( - Arg::with_name("offline_signer") - .long("offline_signer") - .help("Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output.") - .takes_value(false), - ), - ) - .subcommand( - SubCommand::with_name("bump_fee") - .about("Bumps the fees of an RBF transaction") - .arg( - Arg::with_name("txid") - .required(true) - .takes_value(true) - .short("txid") - .long("txid") - .help("TXID of the transaction to update"), - ) - .arg( - Arg::with_name("send_all") - .short("all") - .long("send_all") - .help("Allows the wallet to reduce the amount of the only output in order to increase fees. This is generally the expected behavior for transactions originally created with `send_all`"), - ) - .arg( - Arg::with_name("utxos") - .long("utxos") - .value_name("TXID:VOUT") - .help("Selects which utxos *must* be added to the tx. Unconfirmed utxos cannot be used") - .takes_value(true) - .number_of_values(1) - .multiple(true) - .validator(outpoint_validator), - ) - .arg( - Arg::with_name("unspendable") - .long("unspendable") - .value_name("TXID:VOUT") - .help("Marks an utxo as unspendable, in case more inputs are needed to cover the extra fees") - .takes_value(true) - .number_of_values(1) - .multiple(true) - .validator(outpoint_validator), - ) - .arg( - Arg::with_name("fee_rate") - .required(true) - .short("fee") - .long("fee_rate") - .value_name("SATS_VBYTE") - .help("The new targeted fee rate in sat/vbyte") - .takes_value(true), - ) - .arg( - Arg::with_name("offline_signer") - .long("offline_signer") - .help("Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output.") - .takes_value(false), - ), - ) - .subcommand( - SubCommand::with_name("policies") - .about("Returns the available spending policies for the descriptor") - ) - .subcommand( - SubCommand::with_name("public_descriptor") - .about("Returns the public version of the wallet's descriptor(s)") - ) - .subcommand( - SubCommand::with_name("sign") - .about("Signs and tries to finalize a PSBT") - .arg( - Arg::with_name("psbt") - .long("psbt") - .value_name("BASE64_PSBT") - .help("Sets the PSBT to sign") - .takes_value(true) - .number_of_values(1) - .required(true), - ) - .arg( - Arg::with_name("assume_height") - .long("assume_height") - .value_name("HEIGHT") - .help("Assume the blockchain has reached a specific height. This affects the transaction finalization, if there are timelocks in the descriptor") - .takes_value(true) - .number_of_values(1) - .required(false), - )) - .subcommand( - SubCommand::with_name("broadcast") - .about("Broadcasts a transaction to the network. Takes either a raw transaction or a PSBT to extract") - .arg( - Arg::with_name("psbt") - .long("psbt") - .value_name("BASE64_PSBT") - .help("Sets the PSBT to extract and broadcast") - .takes_value(true) - .required_unless("tx") - .number_of_values(1)) - .arg( - Arg::with_name("tx") - .long("tx") - .value_name("RAWTX") - .help("Sets the raw transaction to broadcast") - .takes_value(true) - .required_unless("psbt") - .number_of_values(1)) - ) - .subcommand( - SubCommand::with_name("extract_psbt") - .about("Extracts a raw transaction from a PSBT") - .arg( - Arg::with_name("psbt") - .long("psbt") - .value_name("BASE64_PSBT") - .help("Sets the PSBT to extract") - .takes_value(true) - .required(true) - .number_of_values(1)) - ) - .subcommand( - SubCommand::with_name("finalize_psbt") - .about("Finalizes a psbt") - .arg( - Arg::with_name("psbt") - .long("psbt") - .value_name("BASE64_PSBT") - .help("Sets the PSBT to finalize") - .takes_value(true) - .required(true) - .number_of_values(1)) - .arg( - Arg::with_name("assume_height") - .long("assume_height") - .value_name("HEIGHT") - .help("Assume the blockchain has reached a specific height") - .takes_value(true) - .number_of_values(1) - .required(false)) - ) - .subcommand( - SubCommand::with_name("combine_psbt") - .about("Combines multiple PSBTs into one") - .arg( - Arg::with_name("psbt") - .long("psbt") - .value_name("BASE64_PSBT") - .help("Add one PSBT to comine. This option can be repeated multiple times, one for each PSBT") - .takes_value(true) - .number_of_values(1) - .required(true) - .multiple(true)) - ) -} - -pub fn add_global_flags<'a, 'b>(app: App<'a, 'b>) -> App<'a, 'b> { - let mut app = app - .arg( - Arg::with_name("network") - .short("n") - .long("network") - .value_name("NETWORK") - .help("Sets the network") - .takes_value(true) - .default_value("testnet") - .possible_values(&["testnet", "regtest"]), - ) - .arg( - Arg::with_name("wallet") - .short("w") - .long("wallet") - .value_name("WALLET_NAME") - .help("Selects the wallet to use") - .takes_value(true) - .default_value("main"), - ) - .arg( - Arg::with_name("proxy") - .short("p") - .long("proxy") - .value_name("SERVER:PORT") - .help("Sets the SOCKS5 proxy for the Electrum client") - .takes_value(true), - ) - .arg( - Arg::with_name("descriptor") - .short("d") - .long("descriptor") - .value_name("DESCRIPTOR") - .help("Sets the descriptor to use for the external addresses") - .required(true) - .takes_value(true), - ) - .arg( - Arg::with_name("change_descriptor") - .short("c") - .long("change_descriptor") - .value_name("DESCRIPTOR") - .help("Sets the descriptor to use for internal addresses") - .takes_value(true), - ) - .arg( - Arg::with_name("v") - .short("v") - .multiple(true) - .help("Sets the level of verbosity"), - ); - - if cfg!(feature = "esplora") { - app = app - .arg( - Arg::with_name("esplora") - .short("e") - .long("esplora") - .value_name("ESPLORA") - .help("Use the esplora server if given as parameter") - .takes_value(true), - ) - .arg( - Arg::with_name("esplora_concurrency") - .long("esplora_concurrency") - .value_name("ESPLORA_CONCURRENCY") - .help("Concurrency of requests made to the esplora server") - .default_value("4") - .takes_value(true), - ) - } - - if cfg!(feature = "electrum") { - app = app.arg( - Arg::with_name("server") - .short("s") - .long("server") - .value_name("SERVER:PORT") - .help("Sets the Electrum server to use") - .takes_value(true) - .default_value("ssl://electrum.blockstream.info:60002"), - ); - } - - app.subcommand(SubCommand::with_name("repl").about("Opens an interactive shell")) -} - +/// Execute a wallet sub-command with a given [`Wallet`]. +/// +/// Wallet sub-commands are described in [`WalletSubCommand`]. See [`super::cli`] for example usage. #[maybe_async] -pub fn handle_matches( +pub fn handle_wallet_subcommand( wallet: &Wallet, - matches: ArgMatches<'_>, + wallet_subcommand: WalletSubCommand, ) -> Result where C: crate::blockchain::Blockchain, D: crate::database::BatchDatabase, { - if let Some(_sub_matches) = matches.subcommand_matches("get_new_address") { - Ok(json!({ - "address": wallet.get_new_address()? - })) - } else if let Some(sub_matches) = matches.subcommand_matches("sync") { - let max_addresses: Option = sub_matches - .value_of("max_addresses") - .and_then(|m| m.parse().ok()); - maybe_await!(wallet.sync(log_progress(), max_addresses))?; - Ok(json!({})) - } else if let Some(_sub_matches) = matches.subcommand_matches("list_unspent") { - Ok(serde_json::to_value(&wallet.list_unspent()?)?) - } else if let Some(_sub_matches) = matches.subcommand_matches("list_transactions") { - Ok(serde_json::to_value(&wallet.list_transactions(false)?)?) - } else if let Some(_sub_matches) = matches.subcommand_matches("get_balance") { - Ok(json!({ - "satoshi": wallet.get_balance()? - })) - } else if let Some(sub_matches) = matches.subcommand_matches("create_tx") { - let recipients = sub_matches - .values_of("to") - .unwrap() - .map(|s| parse_recipient(s)) - .collect::, _>>() - .map_err(Error::Generic)?; - let mut tx_builder = TxBuilder::new(); - - if sub_matches.is_present("send_all") { - tx_builder = tx_builder - .drain_wallet() - .set_single_recipient(recipients[0].0.clone()); - } else { - tx_builder = tx_builder.set_recipients(recipients); + match wallet_subcommand { + WalletSubCommand::GetNewAddress => Ok(json!({"address": wallet.get_new_address()?})), + WalletSubCommand::Sync { max_addresses } => { + maybe_await!(wallet.sync(log_progress(), max_addresses))?; + Ok(json!({})) } - - if sub_matches.is_present("enable_rbf") { - tx_builder = tx_builder.enable_rbf(); + WalletSubCommand::ListUnspent => Ok(serde_json::to_value(&wallet.list_unspent()?)?), + WalletSubCommand::ListTransactions => { + Ok(serde_json::to_value(&wallet.list_transactions(false)?)?) } + WalletSubCommand::GetBalance => Ok(json!({"satoshi": wallet.get_balance()?})), + WalletSubCommand::CreateTx { + recipients, + send_all, + enable_rbf, + offline_signer, + utxos, + unspendable, + fee_rate, + external_policy, + internal_policy, + } => { + let mut tx_builder = TxBuilder::new(); - if sub_matches.is_present("offline_signer") { - tx_builder = tx_builder - .add_global_xpubs() - .force_non_witness_utxo() - .include_output_redeem_witness_script(); + if send_all { + tx_builder = tx_builder + .drain_wallet() + .set_single_recipient(recipients[0].0.clone()); + } else { + tx_builder = tx_builder.set_recipients(recipients); + } + + if enable_rbf { + tx_builder = tx_builder.enable_rbf(); + } + + if offline_signer { + tx_builder = tx_builder + .force_non_witness_utxo() + .include_output_redeem_witness_script(); + } + + if let Some(fee_rate) = fee_rate { + tx_builder = tx_builder.fee_rate(FeeRate::from_sat_per_vb(fee_rate)); + } + + if let Some(utxos) = utxos { + tx_builder = tx_builder.utxos(utxos).manually_selected_only(); + } + + if let Some(unspendable) = unspendable { + tx_builder = tx_builder.unspendable(unspendable); + } + + let policies = vec![ + external_policy.map(|p| (p, ScriptType::External)), + internal_policy.map(|p| (p, ScriptType::Internal)), + ]; + + for (policy, script_type) in policies.into_iter().filter_map(|x| x) { + let policy = serde_json::from_str::>>(&policy) + .map_err(|s| Error::Generic(s.to_string()))?; + tx_builder = tx_builder.policy_path(policy, script_type); + } + + let (psbt, details) = wallet.create_tx(tx_builder)?; + Ok(json!({"psbt": base64::encode(&serialize(&psbt)),"details": details,})) } + WalletSubCommand::BumpFee { + txid, + send_all, + offline_signer, + utxos, + unspendable, + fee_rate, + } => { + let txid = Txid::from_str(txid.as_str()).map_err(|s| Error::Generic(s.to_string()))?; - if let Some(fee_rate) = sub_matches.value_of("fee_rate") { - let fee_rate = f32::from_str(fee_rate).map_err(|s| Error::Generic(s.to_string()))?; - tx_builder = tx_builder.fee_rate(FeeRate::from_sat_per_vb(fee_rate)); + let mut tx_builder = TxBuilder::new().fee_rate(FeeRate::from_sat_per_vb(fee_rate)); + + if send_all { + tx_builder = tx_builder.maintain_single_recipient(); + } + + if offline_signer { + tx_builder = tx_builder + .force_non_witness_utxo() + .include_output_redeem_witness_script(); + } + + if let Some(utxos) = utxos { + tx_builder = tx_builder.utxos(utxos); + } + + if let Some(unspendable) = unspendable { + tx_builder = tx_builder.unspendable(unspendable); + } + + let (psbt, details) = wallet.bump_fee(&txid, tx_builder)?; + Ok(json!({"psbt": base64::encode(&serialize(&psbt)),"details": details,})) } - if let Some(utxos) = sub_matches.values_of("utxos") { - let utxos = utxos - .map(|i| parse_outpoint(i)) - .collect::, _>>() - .map_err(Error::Generic)?; - tx_builder = tx_builder.utxos(utxos).manually_selected_only(); - } - - if let Some(unspendable) = sub_matches.values_of("unspendable") { - let unspendable = unspendable - .map(|i| parse_outpoint(i)) - .collect::, _>>() - .map_err(Error::Generic)?; - tx_builder = tx_builder.unspendable(unspendable); - } - - let policies = vec![ - sub_matches - .value_of("external_policy") - .map(|p| (p, ScriptType::External)), - sub_matches - .value_of("internal_policy") - .map(|p| (p, ScriptType::Internal)), - ]; - for (policy, script_type) in policies.into_iter().filter_map(|x| x) { - let policy = serde_json::from_str::>>(&policy) - .map_err(|s| Error::Generic(s.to_string()))?; - tx_builder = tx_builder.policy_path(policy, script_type); - } - - let (psbt, details) = wallet.create_tx(tx_builder)?; - Ok(json!({ - "psbt": base64::encode(&serialize(&psbt)), - "details": details, - })) - } else if let Some(sub_matches) = matches.subcommand_matches("bump_fee") { - let txid = Txid::from_str(sub_matches.value_of("txid").unwrap()) - .map_err(|s| Error::Generic(s.to_string()))?; - - let fee_rate = f32::from_str(sub_matches.value_of("fee_rate").unwrap()) - .map_err(|s| Error::Generic(s.to_string()))?; - let mut tx_builder = TxBuilder::new().fee_rate(FeeRate::from_sat_per_vb(fee_rate)); - - if sub_matches.is_present("send_all") { - tx_builder = tx_builder.maintain_single_recipient(); - } - - if sub_matches.is_present("offline_signer") { - tx_builder = tx_builder - .add_global_xpubs() - .force_non_witness_utxo() - .include_output_redeem_witness_script(); - } - - if let Some(utxos) = sub_matches.values_of("utxos") { - let utxos = utxos - .map(|i| parse_outpoint(i)) - .collect::, _>>() - .map_err(Error::Generic)?; - tx_builder = tx_builder.utxos(utxos); - } - - if let Some(unspendable) = sub_matches.values_of("unspendable") { - let unspendable = unspendable - .map(|i| parse_outpoint(i)) - .collect::, _>>() - .map_err(Error::Generic)?; - tx_builder = tx_builder.unspendable(unspendable); - } - - let (psbt, details) = wallet.bump_fee(&txid, tx_builder)?; - Ok(json!({ - "psbt": base64::encode(&serialize(&psbt)), - "details": details, - })) - } else if let Some(_sub_matches) = matches.subcommand_matches("policies") { - Ok(json!({ + WalletSubCommand::Policies => Ok(json!({ "external": wallet.policies(ScriptType::External)?, "internal": wallet.policies(ScriptType::Internal)?, - })) - } else if let Some(_sub_matches) = matches.subcommand_matches("public_descriptor") { - Ok(json!({ + })), + WalletSubCommand::PublicDescriptor => Ok(json!({ "external": wallet.public_descriptor(ScriptType::External)?.map(|d| d.to_string()), "internal": wallet.public_descriptor(ScriptType::Internal)?.map(|d| d.to_string()), - })) - } else if let Some(sub_matches) = matches.subcommand_matches("sign") { - let psbt = base64::decode(sub_matches.value_of("psbt").unwrap()).unwrap(); - let psbt: PartiallySignedTransaction = deserialize(&psbt).unwrap(); - let assume_height = sub_matches - .value_of("assume_height") - .map(|s| s.parse().unwrap()); - let (psbt, finalized) = wallet.sign(psbt, assume_height)?; - Ok(json!({ - "psbt": base64::encode(&serialize(&psbt)), - "is_finalized": finalized, - })) - } else if let Some(sub_matches) = matches.subcommand_matches("broadcast") { - let tx = if sub_matches.value_of("psbt").is_some() { - let psbt = base64::decode(&sub_matches.value_of("psbt").unwrap()).unwrap(); + })), + WalletSubCommand::Sign { + psbt, + assume_height, + } => { + let psbt = base64::decode(&psbt).unwrap(); let psbt: PartiallySignedTransaction = deserialize(&psbt).unwrap(); - psbt.extract_tx() - } else if sub_matches.value_of("tx").is_some() { - deserialize(&Vec::::from_hex(&sub_matches.value_of("tx").unwrap()).unwrap()) - .unwrap() - } else { - panic!("Missing `psbt` and `tx` option"); - }; + let (psbt, finalized) = wallet.sign(psbt, assume_height)?; + Ok(json!({"psbt": base64::encode(&serialize(&psbt)),"is_finalized": finalized,})) + } + WalletSubCommand::Broadcast { psbt, tx } => { + let tx = match (psbt, tx) { + (Some(psbt), None) => { + let psbt = base64::decode(&psbt).unwrap(); + let psbt: PartiallySignedTransaction = deserialize(&psbt).unwrap(); + psbt.extract_tx() + } + (None, Some(tx)) => deserialize(&Vec::::from_hex(&tx).unwrap()).unwrap(), + (Some(_), Some(_)) => panic!("Both `psbt` and `tx` options not allowed"), + (None, None) => panic!("Missing `psbt` and `tx` option"), + }; - let txid = maybe_await!(wallet.broadcast(tx))?; - Ok(json!({ "txid": txid })) - } else if let Some(sub_matches) = matches.subcommand_matches("extract_psbt") { - let psbt = base64::decode(&sub_matches.value_of("psbt").unwrap()).unwrap(); - let psbt: PartiallySignedTransaction = deserialize(&psbt).unwrap(); - Ok(json!({ - "raw_tx": serialize_hex(&psbt.extract_tx()), - })) - } else if let Some(sub_matches) = matches.subcommand_matches("finalize_psbt") { - let psbt = base64::decode(&sub_matches.value_of("psbt").unwrap()).unwrap(); - let psbt: PartiallySignedTransaction = deserialize(&psbt).unwrap(); + let txid = maybe_await!(wallet.broadcast(tx))?; + Ok(json!({ "txid": txid })) + } + WalletSubCommand::ExtractPsbt { psbt } => { + let psbt = base64::decode(&psbt).unwrap(); + let psbt: PartiallySignedTransaction = deserialize(&psbt).unwrap(); + Ok(json!({"raw_tx": serialize_hex(&psbt.extract_tx()),})) + } + WalletSubCommand::FinalizePsbt { + psbt, + assume_height, + } => { + let psbt = base64::decode(&psbt).unwrap(); + let psbt: PartiallySignedTransaction = deserialize(&psbt).unwrap(); - let assume_height = sub_matches - .value_of("assume_height") - .map(|s| s.parse().unwrap()); + let (psbt, finalized) = wallet.finalize_psbt(psbt, assume_height)?; + Ok(json!({ "psbt": base64::encode(&serialize(&psbt)),"is_finalized": finalized,})) + } + WalletSubCommand::CombinePsbt { psbt } => { + let mut psbts = psbt + .iter() + .map(|s| { + let psbt = base64::decode(&s).unwrap(); + let psbt: PartiallySignedTransaction = deserialize(&psbt).unwrap(); + psbt + }) + .collect::>(); - let (psbt, finalized) = wallet.finalize_psbt(psbt, assume_height)?; - Ok(json!({ - "psbt": base64::encode(&serialize(&psbt)), - "is_finalized": finalized, - })) - } else if let Some(sub_matches) = matches.subcommand_matches("combine_psbt") { - let mut psbts = sub_matches - .values_of("psbt") - .unwrap() - .map(|s| { - let psbt = base64::decode(&s).unwrap(); - let psbt: PartiallySignedTransaction = deserialize(&psbt).unwrap(); + let init_psbt = psbts.pop().unwrap(); + let final_psbt = psbts + .into_iter() + .try_fold::<_, _, Result>( + init_psbt, + |mut acc, x| { + acc.merge(x)?; + Ok(acc) + }, + )?; - psbt - }) - .collect::>(); - - let init_psbt = psbts.pop().unwrap(); - let final_psbt = psbts - .into_iter() - .try_fold::<_, _, Result>( - init_psbt, - |mut acc, x| { - acc.merge(x)?; - Ok(acc) - }, - )?; - - Ok(json!({ "psbt": base64::encode(&serialize(&final_psbt)) })) - } else { - Ok(serde_json::Value::Null) + Ok(json!({ "psbt": base64::encode(&serialize(&final_psbt)) })) + } + WalletSubCommand::Other(_) => Ok(json!({})), + } +} + +#[cfg(test)] +mod test { + use super::{WalletOpt, WalletSubCommand}; + use bitcoin::hashes::core::str::FromStr; + use bitcoin::{Address, OutPoint}; + use structopt::StructOpt; + + #[test] + fn test_get_new_address() { + let cli_args = vec!["repl", "--network", "bitcoin", + "--descriptor", "wpkh(xpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)", + "--change_descriptor", "wpkh(xpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/1/*)", + "--esplora", "https://blockstream.info/api/", + "--esplora_concurrency", "5", + "get_new_address"]; + + let wallet_opt = WalletOpt::from_iter(&cli_args); + + let expected_wallet_opt = WalletOpt { + network: "bitcoin".to_string(), + wallet: "main".to_string(), + proxy: None, + descriptor: "wpkh(xpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)".to_string(), + change_descriptor: Some("wpkh(xpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/1/*)".to_string()), + log_level: "info".to_string(), + #[cfg(feature = "esplora")] + esplora: Some("https://blockstream.info/api/".to_string()), + #[cfg(feature = "esplora")] + esplora_concurrency: 5, + electrum: "ssl://electrum.blockstream.info:60002".to_string(), + subcommand: WalletSubCommand::GetNewAddress, + }; + + assert_eq!(expected_wallet_opt, wallet_opt); + } + + #[test] + fn test_sync() { + let cli_args = vec!["repl", "--network", "testnet", + "--descriptor", "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)", + "sync", "--max_addresses", "50"]; + + let wallet_opt = WalletOpt::from_iter(&cli_args); + + let expected_wallet_opt = WalletOpt { + network: "testnet".to_string(), + wallet: "main".to_string(), + proxy: None, + descriptor: "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)".to_string(), + change_descriptor: None, + log_level: "info".to_string(), + #[cfg(feature = "esplora")] + esplora: None, + #[cfg(feature = "esplora")] + esplora_concurrency: 4, + electrum: "ssl://electrum.blockstream.info:60002".to_string(), + subcommand: WalletSubCommand::Sync { + max_addresses: Some(50) + }, + }; + + assert_eq!(expected_wallet_opt, wallet_opt); + } + + #[test] + fn test_create_tx() { + let cli_args = vec!["repl", "--network", "testnet", "--proxy", "127.0.0.1:9150", + "--descriptor", "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)", + "--change_descriptor", "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/1/*)", + "--server","ssl://electrum.blockstream.info:50002", + "create_tx", "--to", "n2Z3YNXtceeJhFkTknVaNjT1mnCGWesykJ:123456","mjDZ34icH4V2k9GmC8niCrhzVuR3z8Mgkf:78910", + "--utxos","87345e46bfd702d24d54890cc094d08a005f773b27c8f965dfe0eb1e23eef88e:1", + "--utxos","87345e46bfd702d24d54890cc094d08a005f773b27c8f965dfe0eb1e23eef88e:2"]; + + let wallet_opt = WalletOpt::from_iter(&cli_args); + + let script1 = Address::from_str("n2Z3YNXtceeJhFkTknVaNjT1mnCGWesykJ") + .unwrap() + .script_pubkey(); + let script2 = Address::from_str("mjDZ34icH4V2k9GmC8niCrhzVuR3z8Mgkf") + .unwrap() + .script_pubkey(); + let outpoint1 = OutPoint::from_str( + "87345e46bfd702d24d54890cc094d08a005f773b27c8f965dfe0eb1e23eef88e:1", + ) + .unwrap(); + let outpoint2 = OutPoint::from_str( + "87345e46bfd702d24d54890cc094d08a005f773b27c8f965dfe0eb1e23eef88e:2", + ) + .unwrap(); + + let expected_wallet_opt = WalletOpt { + network: "testnet".to_string(), + wallet: "main".to_string(), + proxy: Some("127.0.0.1:9150".to_string()), + descriptor: "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)".to_string(), + change_descriptor: Some("wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/1/*)".to_string()), + log_level: "info".to_string(), + #[cfg(feature = "esplora")] + esplora: None, + #[cfg(feature = "esplora")] + esplora_concurrency: 4, + electrum: "ssl://electrum.blockstream.info:50002".to_string(), + subcommand: WalletSubCommand::CreateTx { + recipients: vec![(script1, 123456), (script2, 78910)], + send_all: false, + enable_rbf: false, + offline_signer: false, + utxos: Some(vec!(outpoint1, outpoint2)), + unspendable: None, + fee_rate: None, + external_policy: None, + internal_policy: None, + }, + }; + + assert_eq!(expected_wallet_opt, wallet_opt); + } + + #[test] + fn test_broadcast() { + let cli_args = vec!["repl", "--network", "testnet", + "--descriptor", "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)", + "broadcast", + "--psbt", "cHNidP8BAEICAAAAASWhGE1AhvtO+2GjJHopssFmgfbq+WweHd8zN/DeaqmDAAAAAAD/////AQAAAAAAAAAABmoEAAECAwAAAAAAAAA="]; + + let wallet_opt = WalletOpt::from_iter(&cli_args); + + let expected_wallet_opt = WalletOpt { + network: "testnet".to_string(), + wallet: "main".to_string(), + proxy: None, + descriptor: "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)".to_string(), + change_descriptor: None, + log_level: "info".to_string(), + #[cfg(feature = "esplora")] + esplora: None, + #[cfg(feature = "esplora")] + esplora_concurrency: 4, + electrum: "ssl://electrum.blockstream.info:60002".to_string(), + subcommand: WalletSubCommand::Broadcast { + psbt: Some("cHNidP8BAEICAAAAASWhGE1AhvtO+2GjJHopssFmgfbq+WweHd8zN/DeaqmDAAAAAAD/////AQAAAAAAAAAABmoEAAECAwAAAAAAAAA=".to_string()), + tx: None + }, + }; + + assert_eq!(expected_wallet_opt, wallet_opt); } }