diff --git a/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml index c5e7536..d1c5203 100644 --- a/.github/workflows/cont_integration.yml +++ b/.github/workflows/cont_integration.yml @@ -14,7 +14,10 @@ jobs: - 1.45.0 # MSRV features: - default - - default,esplora + - repl + - electrum + - esplora + - repl,electrum,esplora steps: - name: Checkout uses: actions/checkout@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ccf7c8..a440715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Project -- Upgrade BDK to `0.3` #### Added - Add support for `wasm` +- `CliOpts` struct and `CliSubCommand` enum representing top level cli options and commands +- `KeySubCommand` enum +- `handle_key_subcommand` function + +#### Changed +- Upgrade BDK to `0.3` +- Renamed `WalletOpt` struct to `WalletOpts` +- `WalletSubCommand` enum split into `OfflineWalletSubCommand` and `OnlineWalletSubCommand` +- Split `handle_wallet_subcommand` into two functions, `handle_offline_wallet_subcommand` and `handle_online_wallet_subcommand` +- A wallet without a `Blockchain` is used when handling offline wallet sub-commands + +### `bdk-cli` bin + +#### Added +- Top level commands "wallet", "key", and "repl" +- "key" command has sub-commands to "generate" and "restore" a master extended key +- "repl" command now has an "exit" sub-command + +#### Changed +- "wallet" sub-commands and options must be proceeded by "wallet" command +- "repl" command loop now includes both "wallet" and "key" sub-commands ## [0.1.0] diff --git a/Cargo.toml b/Cargo.toml index 958b00c..056f512 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,8 @@ readme = "README.md" license = "MIT" [dependencies] -bdk = { version = "^0.3", default-features = false } -bdk-macros = "^0.2" +bdk = { git = "https://github.com/bitcoindevkit/bdk.git", rev = "c4f2179", default-features = false, features = ["all-keys"]} +bdk-macros = { git = "https://github.com/bitcoindevkit/bdk.git", rev = "c4f2179" } structopt = "^0.3" serde_json = { version = "^1.0" } log = "^0.4" @@ -25,10 +25,11 @@ rustyline = { version = "6.0", optional = true } dirs-next = { version = "2.0", optional = true } env_logger = { version = "0.7", optional = true } clap = { version = "2.33", optional = true } +regex = {version = "1", optional = true } [features] -default = ["repl", "esplora", "electrum", "bdk/key-value-db"] -repl = ["async-trait", "rustyline", "dirs-next", "env_logger", "clap", "electrum"] +default = [] +repl = ["async-trait", "bdk/key-value-db", "clap", "dirs-next", "env_logger", "regex", "rustyline"] electrum = ["bdk/electrum"] esplora = ["bdk/esplora"] compiler = ["bdk/compiler"] @@ -36,4 +37,7 @@ compiler = ["bdk/compiler"] [[bin]] name = "bdk-cli" path = "src/bdk_cli.rs" -required-features = ["repl"] +required-features = ["repl", "electrum"] + +[package.metadata.docs.rs] +all-features = true diff --git a/README.md b/README.md index d698abf..e5d4886 100644 --- a/README.md +++ b/README.md @@ -14,17 +14,32 @@ To get usage information for the `bdk-cli` bin use the below command which retur available wallet options and commands: ```shell -cargo run +cargo run --features repl,electrum ``` To sync a wallet to the default electrum server: ```shell -cargo run -- --descriptor "wpkh(tpubEBr4i6yk5nf5DAaJpsi9N2pPYBeJ7fZ5Z9rmN4977iYLCGco1VyjB9tvvuvYtfZzjD5A8igzgw3HeWeeKFmanHYqksqZXYXGsw5zjnj7KM9/*)" sync +cargo run --features repl,electrum,esplora -- wallet --descriptor "wpkh(tpubEBr4i6yk5nf5DAaJpsi9N2pPYBeJ7fZ5Z9rmN4977iYLCGco1VyjB9tvvuvYtfZzjD5A8igzgw3HeWeeKFmanHYqksqZXYXGsw5zjnj7KM9/*)" sync ``` To get a wallet balance with customized logging: ```shell -RUST_LOG=debug,sled=info,rustls=info cargo run -- --descriptor "wpkh(tpubEBr4i6yk5nf5DAaJpsi9N2pPYBeJ7fZ5Z9rmN4977iYLCGco1VyjB9tvvuvYtfZzjD5A8igzgw3HeWeeKFmanHYqksqZXYXGsw5zjnj7KM9/*)" get_balance +RUST_LOG=debug,sled=info,rustls=info cargo run --features repl,electrum,esplora -- wallet --descriptor "wpkh(tpubEBr4i6yk5nf5DAaJpsi9N2pPYBeJ7fZ5Z9rmN4977iYLCGco1VyjB9tvvuvYtfZzjD5A8igzgw3HeWeeKFmanHYqksqZXYXGsw5zjnj7KM9/*)" get_balance +``` + +To generate a new extended master key, suitable for using in a descriptor: + +```shell +cargo run --features repl,electrum -- key generate +``` + +To install dev version of `bdk-cli` from local git repo: + +```shell +cd +cargo install --path . --features repl,electrum,esplora + +bdk-cli help # to verify it worked ``` \ No newline at end of file diff --git a/src/bdk_cli.rs b/src/bdk_cli.rs index 8dfc632..17ed3aa 100644 --- a/src/bdk_cli.rs +++ b/src/bdk_cli.rs @@ -24,7 +24,6 @@ use std::fs; use std::path::PathBuf; -use std::sync::Arc; use bitcoin::Network; use clap::AppSettings; @@ -33,24 +32,39 @@ use rustyline::error::ReadlineError; use rustyline::Editor; use structopt::StructOpt; -use bdk::bitcoin; #[cfg(feature = "esplora")] use bdk::blockchain::esplora::EsploraBlockchainConfig; use bdk::blockchain::{ AnyBlockchain, AnyBlockchainConfig, ConfigurableBlockchain, ElectrumBlockchainConfig, }; +use bdk::database::BatchDatabase; use bdk::sled; +use bdk::sled::Tree; use bdk::Wallet; -use bdk_cli::{self, WalletOpt, WalletSubCommand}; +use bdk::{bitcoin, Error}; +use bdk_cli::WalletSubCommand; +use bdk_cli::{ + CliOpts, CliSubCommand, KeySubCommand, OfflineWalletSubCommand, OnlineWalletSubCommand, + WalletOpts, +}; +use regex::Regex; + +const REPL_LINE_SPLIT_REGEX: &str = r#""([^"]*)"|'([^']*)'|([\w\-]+)"#; +/// REPL mode #[derive(Debug, StructOpt, Clone, PartialEq)] -#[structopt(name = "BDK CLI", setting = AppSettings::NoBinaryName, +#[structopt(name = "", 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, +pub enum ReplSubCommand { + #[structopt(flatten)] + OnlineWalletSubCommand(OnlineWalletSubCommand), + #[structopt(flatten)] + OfflineWalletSubCommand(OfflineWalletSubCommand), + #[structopt(flatten)] + KeySubCommand(KeySubCommand), + /// Exit REPL loop + Exit, } fn prepare_home_dir() -> PathBuf { @@ -67,30 +81,26 @@ fn prepare_home_dir() -> PathBuf { dir } -fn main() { - env_logger::init(); - - let cli_opt: WalletOpt = WalletOpt::from_args(); - - let network = cli_opt.network; - 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 descriptor = cli_opt.descriptor.as_str(); - let change_descriptor = cli_opt.change_descriptor.as_deref(); - debug!("descriptors: {:?} {:?}", descriptor, change_descriptor); - +fn open_database(wallet_opts: &WalletOpts) -> Tree { let database = sled::open(prepare_home_dir().to_str().unwrap()).unwrap(); - let tree = database.open_tree(cli_opt.wallet).unwrap(); + let tree = database.open_tree(&wallet_opts.wallet).unwrap(); debug!("database opened successfully"); + tree +} +fn new_online_wallet( + network: Network, + wallet_opts: &WalletOpts, + database: D, +) -> Result, Error> +where + D: BatchDatabase, +{ // Try to use Esplora config if "esplora" feature is enabled #[cfg(feature = "esplora")] let config_esplora: Option = { - let esplora_concurrency = cli_opt.esplora_concurrency; - cli_opt.esplora.map(|base_url| { + let esplora_concurrency = wallet_opts.esplora_opts.esplora_concurrency; + wallet_opts.esplora_opts.esplora.clone().map(|base_url| { AnyBlockchainConfig::Esplora(EsploraBlockchainConfig { base_url, concurrency: Some(esplora_concurrency), @@ -100,34 +110,90 @@ fn main() { #[cfg(not(feature = "esplora"))] let config_esplora = None; + let config_electrum = AnyBlockchainConfig::Electrum(ElectrumBlockchainConfig { + url: wallet_opts.electrum_opts.electrum.clone(), + socks5: wallet_opts.electrum_opts.proxy.clone(), + retry: wallet_opts.electrum_opts.retries, + timeout: wallet_opts.electrum_opts.timeout, + }); + // Fall back to Electrum config if Esplora config isn't provided - let config = - config_esplora.unwrap_or(AnyBlockchainConfig::Electrum(ElectrumBlockchainConfig { - url: cli_opt.electrum, - socks5: cli_opt.proxy, - retry: 10, - timeout: None, - })); + let config = config_esplora.unwrap_or(config_electrum); + let descriptor = wallet_opts.descriptor.as_str(); + let change_descriptor = wallet_opts.change_descriptor.as_deref(); let wallet = Wallet::new( descriptor, change_descriptor, network, - tree, - AnyBlockchain::from_config(&config).unwrap(), - ) - .unwrap(); + database, + AnyBlockchain::from_config(&config)?, + )?; + Ok(wallet) +} + +fn new_offline_wallet( + network: Network, + wallet_opts: &WalletOpts, + database: D, +) -> Result, Error> +where + D: BatchDatabase, +{ + let descriptor = wallet_opts.descriptor.as_str(); + let change_descriptor = wallet_opts.change_descriptor.as_deref(); + let wallet = Wallet::new_offline(descriptor, change_descriptor, network, database)?; + Ok(wallet) +} + +fn main() { + env_logger::init(); + + let cli_opts: CliOpts = CliOpts::from_args(); - let wallet = Arc::new(wallet); + let network = cli_opts.network; + 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 result = match cli_opts.subcommand { + CliSubCommand::Wallet { + wallet_opts, + subcommand: WalletSubCommand::OnlineWalletSubCommand(online_subcommand), + } => { + let database = open_database(&wallet_opts); + let wallet = new_online_wallet(network, &wallet_opts, database).unwrap(); + let result = bdk_cli::handle_online_wallet_subcommand(&wallet, online_subcommand); + serde_json::to_string_pretty(&result.unwrap()).unwrap() + } + CliSubCommand::Wallet { + wallet_opts, + subcommand: WalletSubCommand::OfflineWalletSubCommand(offline_subcommand), + } => { + let database = open_database(&wallet_opts); + let wallet = new_offline_wallet(network, &wallet_opts, database).unwrap(); + let result = bdk_cli::handle_offline_wallet_subcommand(&wallet, offline_subcommand); + serde_json::to_string_pretty(&result.unwrap()).unwrap() + } + CliSubCommand::Key { + subcommand: key_subcommand, + } => { + let result = bdk_cli::handle_key_subcommand(network, key_subcommand); + serde_json::to_string_pretty(&result.unwrap()).unwrap() + } + CliSubCommand::Repl { wallet_opts } => { + let database = open_database(&wallet_opts); + let online_wallet = new_online_wallet(network, &wallet_opts, database.clone()).unwrap(); - match cli_opt.subcommand { - WalletSubCommand::Repl => { let mut rl = Editor::<()>::new(); // if rl.load_history("history.txt").is_err() { // println!("No previous history."); // } + let split_regex = Regex::new(REPL_LINE_SPLIT_REGEX).unwrap(); + loop { let readline = rl.readline(">> "); match readline { @@ -136,9 +202,18 @@ fn main() { 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); + let split_line: Vec<&str> = split_regex + .captures_iter(&line) + .map(|c| { + c.get(1) + .or_else(|| c.get(2)) + .or_else(|| c.get(3)) + .unwrap() + .as_str() + }) + .collect(); + let repl_subcommand: Result = + ReplSubCommand::from_iter_safe(split_line); debug!("repl_subcommand = {:?}", repl_subcommand); if let Err(err) = repl_subcommand { @@ -146,12 +221,31 @@ fn main() { continue; } - let result = bdk_cli::handle_wallet_subcommand( - &Arc::clone(&wallet), - repl_subcommand.unwrap().subcommand, - ) - .unwrap(); - println!("{}", serde_json::to_string_pretty(&result).unwrap()); + let repl_subcommand = repl_subcommand.unwrap(); + + let result = match repl_subcommand { + ReplSubCommand::OnlineWalletSubCommand(online_subcommand) => { + bdk_cli::handle_online_wallet_subcommand( + &online_wallet, + online_subcommand, + ) + } + ReplSubCommand::OfflineWalletSubCommand(offline_subcommand) => { + bdk_cli::handle_offline_wallet_subcommand( + &online_wallet, + offline_subcommand, + ) + } + ReplSubCommand::KeySubCommand(key_subcommand) => { + bdk_cli::handle_key_subcommand(network, key_subcommand) + } + ReplSubCommand::Exit => break, + }; + + println!( + "{}", + serde_json::to_string_pretty(&result.unwrap()).unwrap() + ); } Err(ReadlineError::Interrupted) => continue, Err(ReadlineError::Eof) => break, @@ -163,10 +257,67 @@ fn main() { } // rl.save_history("history.txt").unwrap(); + "Exiting REPL".to_string() } - _ => { - let result = bdk_cli::handle_wallet_subcommand(&wallet, cli_opt.subcommand).unwrap(); - println!("{}", serde_json::to_string_pretty(&result).unwrap()); - } + }; + + println!("{}", result); +} + +#[cfg(test)] +mod test { + use crate::REPL_LINE_SPLIT_REGEX; + use regex::Regex; + + #[test] + fn test_regex_double_quotes() { + let split_regex = Regex::new(REPL_LINE_SPLIT_REGEX).unwrap(); + let line = r#"restore -m "word1 word2 word3" -p 'test! 123 -test' "#; + let split_line: Vec<&str> = split_regex + .captures_iter(&line) + .map(|c| { + c.get(1) + .or_else(|| c.get(2)) + .or_else(|| c.get(3)) + .unwrap() + .as_str() + }) + .collect(); + assert_eq!( + vec!( + "restore", + "-m", + "word1 word2 word3", + "-p", + "test! 123 -test" + ), + split_line + ); + } + + #[test] + fn test_regex_single_quotes() { + let split_regex = Regex::new(REPL_LINE_SPLIT_REGEX).unwrap(); + let line = r#"restore -m 'word1 word2 word3' -p "test *123 -test" "#; + let split_line: Vec<&str> = split_regex + .captures_iter(&line) + .map(|c| { + c.get(1) + .or_else(|| c.get(2)) + .or_else(|| c.get(3)) + .unwrap() + .as_str() + }) + .collect(); + assert_eq!( + vec!( + "restore", + "-m", + "word1 word2 word3", + "-p", + "test *123 -test" + ), + split_line + ); } } diff --git a/src/lib.rs b/src/lib.rs index bc8f0c4..b5c05d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,25 +22,31 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -//! BDK Command line interface +//! BDK command line interface //! -//! This lib 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. +//! This lib provides [`structopt`] structs and enums that parse CLI options and sub-commands from +//! the command line or from a `String` vector that can be used to access features of the [`bdk`] +//! library. Functions are also provided to handle subcommands and options and provide results via +//! the [`bdk`] lib. //! -//! See the `bdk-cli` example bin for how to use this module to create a simple command line -//! wallet application. +//! See the [`bdk-cli`] example bin for how to use this lib to create a simple command line +//! application that demonstrates [`bdk`] wallet and key management features. //! -//! See [`WalletOpt`] for global wallet options and [`WalletSubCommand`] for supported sub-commands. +//! See [`CliOpts`] for global cli options and [`CliSubCommand`] for supported top level sub-commands. +//! +//! [`structopt`]: https://docs.rs/crate/structopt +//! [`bdk`]: https://github.com/bitcoindevkit/bdk +//! [`bdk-cli`]: https://github.com/bitcoindevkit/bdk-cli/blob/master/src/bdk_cli.rs //! //! # Example //! -//! ``` +//! ```no_run +//! # #[cfg(feature = "electrum")] +//! # { //! # 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_cli::{self, CliOpts, CliSubCommand, WalletOpts, OfflineWalletSubCommand, WalletSubCommand}; //! # use bdk::database::MemoryDatabase; //! # use bdk::Wallet; //! # use std::sync::Arc; @@ -48,49 +54,47 @@ //! # use std::str::FromStr; //! //! // to get args from cli use: -//! // let cli_opt = WalletOpt::from_args(); +//! // let cli_opts = CliOpts::from_args(); //! -//! let cli_args = vec!["bdk-cli", "--network", "testnet", "--descriptor", +//! let cli_args = vec!["bdk-cli", "--network", "testnet", "wallet", "--descriptor", //! "wpkh(tpubEBr4i6yk5nf5DAaJpsi9N2pPYBeJ7fZ5Z9rmN4977iYLCGco1VyjB9tvvuvYtfZzjD5A8igzgw3HeWeeKFmanHYqksqZXYXGsw5zjnj7KM9/*)", //! "sync", "--max_addresses", "50"]; -//! let cli_opt = WalletOpt::from_iter(&cli_args); //! -//! let network = cli_opt.network; +//! let cli_opts = CliOpts::from_iter(&cli_args); +//! let network = cli_opts.network; //! -//! let descriptor = cli_opt.descriptor.as_str(); -//! let change_descriptor = cli_opt.change_descriptor.as_deref(); +//! if let CliSubCommand::Wallet { +//! wallet_opts, +//! subcommand: WalletSubCommand::OnlineWalletSubCommand(online_subcommand) +//! } = cli_opts.subcommand { //! -//! let database = MemoryDatabase::new(); +//! let descriptor = wallet_opts.descriptor.as_str(); +//! let change_descriptor = wallet_opts.change_descriptor.as_deref(); //! -//! 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: None, -//! }), -//! }; +//! let database = MemoryDatabase::new(); //! -//! let wallet = Wallet::new( -//! descriptor, -//! change_descriptor, -//! network, -//! database, -//! AnyBlockchain::from_config(&config).unwrap(), -//! ).unwrap(); +//! let config = AnyBlockchainConfig::Electrum(ElectrumBlockchainConfig { +//! url: wallet_opts.electrum_opts.electrum, +//! socks5: wallet_opts.electrum_opts.proxy, +//! retry: 3, +//! timeout: None, +//! }); //! -//! let wallet = Arc::new(wallet); +//! let wallet = Wallet::new( +//! descriptor, +//! change_descriptor, +//! network, +//! database, +//! AnyBlockchain::from_config(&config).unwrap(), +//! ).unwrap(); //! -//! let result = bdk_cli::handle_wallet_subcommand(&wallet, cli_opt.subcommand).unwrap(); -//! println!("{}", serde_json::to_string_pretty(&result).unwrap()); +//! let result = bdk_cli::handle_online_wallet_subcommand(&wallet, online_subcommand).unwrap(); +//! println!("{}", serde_json::to_string_pretty(&result).unwrap()); +//! } +//! # } //! ``` pub extern crate bdk; -pub extern crate structopt; #[macro_use] extern crate serde_json; #[cfg(any(target_arch = "wasm32", feature = "async-interface"))] @@ -104,60 +108,79 @@ use std::str::FromStr; use structopt::StructOpt; +use crate::OfflineWalletSubCommand::*; +use crate::OnlineWalletSubCommand::*; use bdk::bitcoin::consensus::encode::{deserialize, serialize, serialize_hex}; use bdk::bitcoin::hashes::hex::FromHex; +use bdk::bitcoin::secp256k1::Secp256k1; +use bdk::bitcoin::util::bip32::ExtendedPubKey; use bdk::bitcoin::util::psbt::PartiallySignedTransaction; use bdk::bitcoin::{Address, Network, OutPoint, Script, Txid}; -use bdk::blockchain::log_progress; +use bdk::blockchain::{log_progress, Blockchain}; +use bdk::database::BatchDatabase; +use bdk::keys::bip39::{Language, Mnemonic, MnemonicType}; +use bdk::keys::{DerivableKey, ExtendedKey, GeneratableKey, GeneratedKey}; +use bdk::miniscript::miniscript; use bdk::Error; -use bdk::{FeeRate, KeychainKind, TxBuilder, Wallet}; +use bdk::{FeeRate, KeychainKind, Wallet}; -/// Wallet global options and sub-command +/// Global options /// -/// 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. +/// The global options and top level sub-command required for all subsequent [`CliSubCommand`]'s. /// /// # Example /// /// ``` /// # use bdk::bitcoin::Network; /// # use structopt::StructOpt; -/// # use bdk_cli::{WalletSubCommand, WalletOpt}; +/// # use bdk_cli::{CliOpts, WalletOpts, CliSubCommand, WalletSubCommand}; +/// # #[cfg(feature = "electrum")] +/// # use bdk_cli::ElectrumOpts; +/// # #[cfg(feature = "esplora")] +/// # use bdk_cli::EsploraOpts; +/// # use bdk_cli::OnlineWalletSubCommand::Sync; /// -/// let cli_args = vec!["bdk-cli", "--network", "testnet", +/// let cli_args = vec!["bdk-cli", "--network", "testnet", "wallet", /// "--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(); +/// // to get CliOpts from the OS command line args use: +/// // let cli_opts = CliOpts::from_args(); +/// let cli_opts = CliOpts::from_iter(&cli_args); /// -/// let wallet_opt = WalletOpt::from_iter(&cli_args); +/// let expected_cli_opts = CliOpts { +/// network: Network::Testnet, +/// subcommand: CliSubCommand::Wallet { +/// wallet_opts: WalletOpts { +/// wallet: "main".to_string(), +/// descriptor: "wpkh(tpubEBr4i6yk5nf5DAaJpsi9N2pPYBeJ7fZ5Z9rmN4977iYLCGco1VyjB9tvvuvYtfZzjD5A8igzgw3HeWeeKFmanHYqksqZXYXGsw5zjnj7KM9/44'/1'/0'/0/*)".to_string(), +/// change_descriptor: None, +/// #[cfg(feature = "electrum")] +/// electrum_opts: ElectrumOpts { +/// proxy: None, +/// retries: 5, +/// timeout: None, +/// electrum: "ssl://electrum.blockstream.info:60002".to_string(), +/// }, +/// #[cfg(feature = "esplora")] +/// esplora_opts: EsploraOpts { +/// esplora: None, +/// esplora_concurrency: 4, +/// } +/// }, +/// subcommand: WalletSubCommand::OnlineWalletSubCommand(Sync { +/// max_addresses: Some(50) +/// }), +/// }, +/// }; /// -/// let expected_wallet_opt = WalletOpt { -/// network: Network::Testnet, -/// wallet: "main".to_string(), -/// proxy: None, -/// descriptor: "wpkh(tpubEBr4i6yk5nf5DAaJpsi9N2pPYBeJ7fZ5Z9rmN4977iYLCGco1VyjB9tvvuvYtfZzjD5A8igzgw3HeWeeKFmanHYqksqZXYXGsw5zjnj7KM9/44'/1'/0'/0/*)".to_string(), -/// change_descriptor: None, -/// #[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); +/// assert_eq!(expected_cli_opts, cli_opts); /// ``` - #[derive(Debug, StructOpt, Clone, PartialEq)] #[structopt(name = "BDK CLI", version = option_env ! ("CARGO_PKG_VERSION").unwrap_or("unknown"), author = option_env ! ("CARGO_PKG_AUTHORS").unwrap_or(""))] -pub struct WalletOpt { +pub struct CliOpts { /// Sets the network #[structopt( name = "NETWORK", @@ -166,6 +189,105 @@ pub struct WalletOpt { default_value = "testnet" )] pub network: Network, + /// Top level cli sub-command + #[structopt(subcommand)] + pub subcommand: CliSubCommand, +} + +/// CLI sub-commands +/// +/// The top level sub-commands, each may have different required options. For +/// instance [`CliSubCommand::Wallet`] requires [`WalletOpts`] with a required descriptor but +/// [`CliSubCommand::Key`] sub-command does not. [`CliSubCommand::Repl`] also requires +/// [`WalletOpts`] and a descriptor because in this mode both [`WalletSubCommand`] and +/// [`KeySubCommand`] sub-commands are available. +#[derive(Debug, StructOpt, Clone, PartialEq)] +#[structopt( + rename_all = "snake", + long_about = "Top level options and command modes" +)] +pub enum CliSubCommand { + /// Wallet options and sub-commands + #[structopt(long_about = "Wallet mode")] + Wallet { + #[structopt(flatten)] + wallet_opts: WalletOpts, + #[structopt(subcommand)] + subcommand: WalletSubCommand, + }, + /// Key management sub-commands + #[structopt(long_about = "Key management mode")] + Key { + #[structopt(subcommand)] + subcommand: KeySubCommand, + }, + /// Enter REPL command loop mode + #[structopt(long_about = "REPL command loop mode")] + Repl { + #[structopt(flatten)] + wallet_opts: WalletOpts, + }, +} + +/// Wallet sub-commands +/// +/// Can use either an online or offline wallet. An [`OnlineWalletSubCommand`] requires a blockchain +/// client and network connection and an [`OfflineWalletSubCommand`] does not. +#[derive(Debug, StructOpt, Clone, PartialEq)] +pub enum WalletSubCommand { + #[structopt(flatten)] + OnlineWalletSubCommand(OnlineWalletSubCommand), + #[structopt(flatten)] + OfflineWalletSubCommand(OfflineWalletSubCommand), +} + +/// Wallet options +/// +/// The wallet options required for all [`CliSubCommand::Wallet`] or [`CliSubCommand::Repl`] +/// sub-commands. These options capture wallet descriptor and blockchain client information. The +/// blockchain client details are only used for [`OnlineWalletSubCommand`]s. +/// +/// # Example +/// +/// ``` +/// # use bdk::bitcoin::Network; +/// # use structopt::StructOpt; +/// # use bdk_cli::WalletOpts; +/// # #[cfg(feature = "electrum")] +/// # use bdk_cli::ElectrumOpts; +/// # #[cfg(feature = "esplora")] +/// # use bdk_cli::EsploraOpts; +/// +/// let cli_args = vec!["wallet", +/// "--descriptor", "wpkh(tpubEBr4i6yk5nf5DAaJpsi9N2pPYBeJ7fZ5Z9rmN4977iYLCGco1VyjB9tvvuvYtfZzjD5A8igzgw3HeWeeKFmanHYqksqZXYXGsw5zjnj7KM9/44'/1'/0'/0/*)"]; +/// +/// // to get WalletOpt from OS command line args use: +/// // let wallet_opt = WalletOpt::from_args(); +/// +/// let wallet_opts = WalletOpts::from_iter(&cli_args); +/// +/// let expected_wallet_opts = WalletOpts { +/// wallet: "main".to_string(), +/// descriptor: "wpkh(tpubEBr4i6yk5nf5DAaJpsi9N2pPYBeJ7fZ5Z9rmN4977iYLCGco1VyjB9tvvuvYtfZzjD5A8igzgw3HeWeeKFmanHYqksqZXYXGsw5zjnj7KM9/44'/1'/0'/0/*)".to_string(), +/// change_descriptor: None, +/// #[cfg(feature = "electrum")] +/// electrum_opts: ElectrumOpts { +/// proxy: None, +/// retries: 5, +/// timeout: None, +/// electrum: "ssl://electrum.blockstream.info:60002".to_string(), +/// }, +/// #[cfg(feature = "esplora")] +/// esplora_opts: EsploraOpts { +/// esplora: None, +/// esplora_concurrency: 4, +/// } +/// }; +/// +/// assert_eq!(expected_wallet_opts, wallet_opts); +/// ``` +#[derive(Debug, StructOpt, Clone, PartialEq)] +pub struct WalletOpts { /// Selects the wallet to use #[structopt( name = "WALLET_NAME", @@ -174,29 +296,40 @@ pub struct WalletOpt { 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, + #[cfg(feature = "electrum")] + #[structopt(flatten)] + pub electrum_opts: ElectrumOpts, #[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(flatten)] + pub esplora_opts: EsploraOpts, +} + +/// Electrum options +/// +/// Electrum blockchain client information used by [`OnlineWalletSubCommand`]s. +#[cfg(feature = "electrum")] +#[derive(Debug, StructOpt, Clone, PartialEq)] +pub struct ElectrumOpts { + /// Sets the SOCKS5 proxy for the Electrum client + #[structopt(name = "PROXY_SERVER:PORT", short = "p", long = "proxy")] + pub proxy: Option, + /// Sets the SOCKS5 proxy retries for the Electrum client #[structopt( - name = "ESPLORA_CONCURRENCY", - long = "esplora_concurrency", - default_value = "4" + name = "PROXY_RETRIES", + short = "r", + long = "retries", + default_value = "5" )] - pub esplora_concurrency: u8, - #[cfg(feature = "electrum")] + pub retries: u8, + /// Sets the SOCKS5 proxy timeout for the Electrum client + #[structopt(name = "PROXY_TIMEOUT", short = "t", long = "timeout")] + pub timeout: Option, /// Sets the Electrum server to use #[structopt( name = "SERVER:PORT", @@ -205,67 +338,88 @@ pub struct WalletOpt { 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 [`bdk-cli`](https://github.com/bitcoindevkit/bdk-cli/blob/master/src/bdkcli.rs) -/// example cli wallet. +/// Esplora options /// -/// # Example -/// -/// ``` -/// # use bdk_cli::WalletSubCommand; -/// # use structopt::StructOpt; -/// -/// let sync_sub_command = WalletSubCommand::from_iter(&["bdk-cli", "sync", "--max_addresses", "50"]); -/// assert!(matches!( -/// sync_sub_command, -/// WalletSubCommand::Sync { -/// max_addresses: Some(50) -/// } -/// )); -/// ``` -/// -/// 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 [`bdk-cli`](https://github.com/bitcoindevkit/bdk-cli/blob/master/src/bdkcli.rs) -/// example app. -/// -/// # Example -/// ``` -/// # use bdk_cli::WalletSubCommand; -/// # use structopt::StructOpt; -/// # use clap::AppSettings; -/// -/// #[derive(Debug, StructOpt, Clone, PartialEq)] -/// #[structopt(name = "BDK CLI", 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, -/// } -/// ``` +/// Esplora blockchain client information used by [`OnlineWalletSubCommand`]s. +#[cfg(feature = "esplora")] #[derive(Debug, StructOpt, Clone, PartialEq)] -#[structopt( - rename_all = "snake", - long_about = "A modern, lightweight, descriptor-based wallet" +pub struct EsploraOpts { + /// Use the esplora server if given as parameter + #[structopt(name = "ESPLORA_URL", short = "e", long = "esplora")] + pub esplora: Option, + /// Concurrency of requests made to the esplora server + #[structopt( + name = "ESPLORA_CONCURRENCY", + long = "esplora_concurrency", + default_value = "4" + )] + pub esplora_concurrency: u8, +} + +// This is a workaround for `structopt` issue #333, #391, #418; see https://github.com/TeXitoi/structopt/issues/333#issuecomment-712265332 +#[cfg_attr(not(doc), allow(missing_docs))] +#[cfg_attr( + doc, + doc = r#" +Offline Wallet sub-command + +[`CliSubCommand::Wallet`] sub-commands that do not require a blockchain client and network +connection. These sub-commands use only the provided descriptor or locally cached wallet +information. + +# Example + +``` +# use bdk_cli::OfflineWalletSubCommand; +# use structopt::StructOpt; + +let address_sub_command = OfflineWalletSubCommand::from_iter(&["wallet", "get_new_address"]); +assert!(matches!( + address_sub_command, + OfflineWalletSubCommand::GetNewAddress +)); +``` + +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 [`bdk-cli`](https://github.com/bitcoindevkit/bdk-cli/blob/master/src/bdkcli.rs) +example app. +"# )] -pub enum WalletSubCommand { +#[cfg_attr( + all(doc, feature = "repl"), + doc = r#" + +# Example +``` +# use bdk_cli::OfflineWalletSubCommand; +# use structopt::StructOpt; +# use clap::AppSettings; + +#[derive(Debug, StructOpt, Clone, PartialEq)] +#[structopt(name = "BDK CLI", setting = AppSettings::NoBinaryName, +version = option_env ! ("CARGO_PKG_VERSION").unwrap_or("unknown"), +author = option_env ! ("CARGO_PKG_AUTHORS").unwrap_or(""))] +struct ReplOpts { + /// Wallet sub-command + #[structopt(subcommand)] + pub subcommand: OfflineWalletSubCommand, +} + +let repl_opts = ReplOpts::from_iter(&["get_new_address"]); +assert!(matches!( + repl_opts.subcommand, + OfflineWalletSubCommand::GetNewAddress +)); +"# +)] +#[derive(Debug, StructOpt, Clone, PartialEq)] +#[structopt(rename_all = "snake")] +pub enum OfflineWalletSubCommand { /// 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 @@ -336,25 +490,6 @@ pub enum WalletSubCommand { #[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 @@ -376,8 +511,47 @@ pub enum WalletSubCommand { #[structopt(name = "BASE64_PSBT", long = "psbt", required = true)] psbt: Vec, }, - /// Enter REPL command loop mode - Repl, +} + +#[cfg_attr(not(doc), allow(missing_docs))] +#[cfg_attr( + doc, + doc = r#" +Online Wallet sub-command + +[`CliSubCommand::Wallet`] sub-commands that require a blockchain client and network connection. +These sub-commands use a provided descriptor, locally cached wallet information, and require a +blockchain client and network connection. +"# +)] +#[derive(Debug, StructOpt, Clone, PartialEq)] +#[structopt(rename_all = "snake")] +pub enum OnlineWalletSubCommand { + /// Syncs with the chosen blockchain server + Sync { + /// max addresses to consider + #[structopt(short = "v", long = "max_addresses")] + max_addresses: 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, + }, } fn parse_recipient(s: &str) -> Result<(Script, u64), String> { @@ -402,30 +576,23 @@ fn parse_outpoint(s: &str) -> Result { OutPoint::from_str(s).map_err(|e| format!("{:?}", e)) } -/// Execute a wallet sub-command with a given [`Wallet`]. +/// Execute an offline wallet sub-command /// -/// Wallet sub-commands are described in [`WalletSubCommand`]. See [`crate`] for example usage. +/// Offline wallet sub-commands are described in [`OfflineWalletSubCommand`]. #[maybe_async] -pub fn handle_wallet_subcommand( - wallet: &Wallet, - wallet_subcommand: WalletSubCommand, +pub fn handle_offline_wallet_subcommand( + wallet: &Wallet, + offline_subcommand: OfflineWalletSubCommand, ) -> Result where - C: bdk::blockchain::Blockchain, - D: bdk::database::BatchDatabase, + D: BatchDatabase, { - 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!({})) - } - 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 { + match offline_subcommand { + GetNewAddress => Ok(json!({"address": wallet.get_new_address()?})), + ListUnspent => Ok(serde_json::to_value(&wallet.list_unspent()?)?), + ListTransactions => Ok(serde_json::to_value(&wallet.list_transactions(false)?)?), + GetBalance => Ok(json!({"satoshi": wallet.get_balance()?})), + CreateTx { recipients, send_all, enable_rbf, @@ -436,36 +603,36 @@ where external_policy, internal_policy, } => { - let mut tx_builder = TxBuilder::new(); + let mut tx_builder = wallet.build_tx(); if send_all { - tx_builder = tx_builder + tx_builder .drain_wallet() .set_single_recipient(recipients[0].0.clone()); } else { - tx_builder = tx_builder.set_recipients(recipients); + tx_builder.set_recipients(recipients); } if enable_rbf { - tx_builder = tx_builder.enable_rbf(); + tx_builder.enable_rbf(); } if offline_signer { - tx_builder = 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)); + 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(); + tx_builder.add_utxos(&utxos[..])?.manually_selected_only(); } if let Some(unspendable) = unspendable { - tx_builder = tx_builder.unspendable(unspendable); + tx_builder.unspendable(unspendable); } let policies = vec![ @@ -476,13 +643,13 @@ where for (policy, keychain) 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, keychain); + tx_builder.policy_path(policy, keychain); } - let (psbt, details) = wallet.create_tx(tx_builder)?; + let (psbt, details) = tx_builder.finish()?; Ok(json!({"psbt": base64::encode(&serialize(&psbt)),"details": details,})) } - WalletSubCommand::BumpFee { + BumpFee { txid, send_all, offline_signer, @@ -492,38 +659,39 @@ where } => { let txid = Txid::from_str(txid.as_str()).map_err(|s| Error::Generic(s.to_string()))?; - let mut tx_builder = TxBuilder::new().fee_rate(FeeRate::from_sat_per_vb(fee_rate)); + let mut tx_builder = wallet.build_fee_bump(txid)?; + tx_builder.fee_rate(FeeRate::from_sat_per_vb(fee_rate)); if send_all { - tx_builder = tx_builder.maintain_single_recipient(); + tx_builder.maintain_single_recipient()?; } if offline_signer { - tx_builder = tx_builder + tx_builder .force_non_witness_utxo() .include_output_redeem_witness_script(); } if let Some(utxos) = utxos { - tx_builder = tx_builder.utxos(utxos); + tx_builder.add_utxos(&utxos[..])?; } if let Some(unspendable) = unspendable { - tx_builder = tx_builder.unspendable(unspendable); + tx_builder.unspendable(unspendable); } - let (psbt, details) = wallet.bump_fee(&txid, tx_builder)?; + let (psbt, details) = tx_builder.finish()?; Ok(json!({"psbt": base64::encode(&serialize(&psbt)),"details": details,})) } - WalletSubCommand::Policies => Ok(json!({ + Policies => Ok(json!({ "external": wallet.policies(KeychainKind::External)?, "internal": wallet.policies(KeychainKind::Internal)?, })), - WalletSubCommand::PublicDescriptor => Ok(json!({ + PublicDescriptor => Ok(json!({ "external": wallet.public_descriptor(KeychainKind::External)?.map(|d| d.to_string()), "internal": wallet.public_descriptor(KeychainKind::Internal)?.map(|d| d.to_string()), })), - WalletSubCommand::Sign { + Sign { psbt, assume_height, } => { @@ -532,27 +700,12 @@ where 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 })) - } - WalletSubCommand::ExtractPsbt { psbt } => { + 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 { + FinalizePsbt { psbt, assume_height, } => { @@ -562,7 +715,7 @@ where let (psbt, finalized) = wallet.finalize_psbt(psbt, assume_height)?; Ok(json!({ "psbt": base64::encode(&serialize(&psbt)),"is_finalized": finalized,})) } - WalletSubCommand::CombinePsbt { psbt } => { + CombinePsbt { psbt } => { let mut psbts = psbt .iter() .map(|s| { @@ -582,86 +735,313 @@ where Ok(acc) }, )?; - Ok(json!({ "psbt": base64::encode(&serialize(&final_psbt)) })) } - WalletSubCommand::Repl => Ok(json!({})), + } +} + +/// Execute an online wallet sub-command +/// +/// Online wallet sub-commands are described in [`OnlineWalletSubCommand`]. See [`crate`] for +/// example usage. +#[maybe_async] +pub fn handle_online_wallet_subcommand( + wallet: &Wallet, + online_subcommand: OnlineWalletSubCommand, +) -> Result +where + C: Blockchain, + D: BatchDatabase, +{ + match online_subcommand { + Sync { max_addresses } => { + maybe_await!(wallet.sync(log_progress(), max_addresses))?; + Ok(json!({})) + } + 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 })) + } + } +} + +#[cfg_attr(not(doc), allow(missing_docs))] +#[cfg_attr( + doc, + doc = r#" +Key sub-command + +Provides basic key operations that are not related to a specific wallet such as generating a +new random master extended key or restoring a master extended key from mnemonic words. + +These sub-commands are **EXPERIMENTAL** and should only be used for testing. Do not use this +feature to create keys that secure actual funds on the Bitcoin mainnet. +"# +)] +#[derive(Debug, StructOpt, Clone, PartialEq)] +#[structopt(rename_all = "snake")] +pub enum KeySubCommand { + /// Generates new random seed mnemonic phrase and corresponding master extended key + Generate { + /// Entropy level based on number of random seed mnemonic words + #[structopt( + name = "WORD_COUNT", + short = "e", + long = "entropy", + default_value = "24", + possible_values = &["12","24"], + )] + word_count: usize, + /// Seed password + #[structopt(name = "PASSWORD", short = "p", long = "password")] + password: Option, + }, + /// Restore a master extended key from seed backup mnemonic words + Restore { + /// Seed mnemonic words, must be quoted (eg. "word1 word2 ...") + #[structopt(name = "MNEMONIC", short = "m", long = "mnemonic")] + mnemonic: String, + /// Seed password + #[structopt(name = "PASSWORD", short = "p", long = "password")] + password: Option, + }, +} + +/// Execute a key sub-command +/// +/// Key sub-commands are described in [`KeySubCommand`]. +pub fn handle_key_subcommand( + network: Network, + subcommand: KeySubCommand, +) -> Result { + let secp = Secp256k1::new(); + + match subcommand { + KeySubCommand::Generate { + word_count, + password, + } => { + let mnemonic_type = match word_count { + 12 => MnemonicType::Words12, + _ => MnemonicType::Words24, + }; + let mnemonic: GeneratedKey<_, miniscript::BareCtx> = + Mnemonic::generate((mnemonic_type, Language::English)).unwrap(); + //.map_err(|e| KeyError::from(e.unwrap()))?; + let mnemonic = mnemonic.into_key(); + let xkey: ExtendedKey = (mnemonic.clone(), password).into_extended_key()?; + let xprv = xkey.into_xprv(network).unwrap(); + let xpub = ExtendedPubKey::from_private(&secp, &xprv); + let fingerprint = xprv.fingerprint(&secp); + Ok( + json!({ "mnemonic": mnemonic.phrase(), "xprv": xprv.to_string(),"xpub": xpub.to_string(), "fingerprint": fingerprint.to_string() }), + ) + } + KeySubCommand::Restore { mnemonic, password } => { + let mnemonic = Mnemonic::from_phrase(mnemonic.as_ref(), Language::English).unwrap(); + // .map_err(|e| { + // KeyError::from(e.downcast::().unwrap()) + // })?; + let xkey: ExtendedKey = (mnemonic, password).into_extended_key()?; + let xprv = xkey.into_xprv(network).unwrap(); + let xpub = ExtendedPubKey::from_private(&secp, &xprv); + let fingerprint = xprv.fingerprint(&secp); + + Ok( + json!({ "xprv": xprv.to_string(),"xpub": xpub.to_string(), "fingerprint": fingerprint.to_string() }), + ) + } } } #[cfg(test)] mod test { - use super::{WalletOpt, WalletSubCommand}; + use super::{CliOpts, WalletOpts}; + #[cfg(feature = "electrum")] + use crate::ElectrumOpts; + #[cfg(feature = "esplora")] + use crate::EsploraOpts; + use crate::OfflineWalletSubCommand::{CreateTx, GetNewAddress}; + use crate::OnlineWalletSubCommand::{Broadcast, Sync}; + use crate::{handle_key_subcommand, CliSubCommand, KeySubCommand, WalletSubCommand}; + use bdk::bitcoin::{Address, Network, OutPoint}; + use bdk::miniscript::bitcoin::network::constants::Network::Testnet; use std::str::FromStr; use structopt::StructOpt; #[test] - fn test_get_new_address() { - let cli_args = vec!["bdk-cli", "--network", "bitcoin", + fn test_wallet_get_new_address() { + let cli_args = vec!["bdk-cli", "--network", "bitcoin", "wallet", "--descriptor", "wpkh(xpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)", "--change_descriptor", "wpkh(xpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/1/*)", + "get_new_address"]; + + let cli_opts = CliOpts::from_iter(&cli_args); + + let expected_cli_opts = CliOpts { + network: Network::Bitcoin, + subcommand: CliSubCommand::Wallet { + wallet_opts: WalletOpts { + wallet: "main".to_string(), + descriptor: "wpkh(xpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)".to_string(), + change_descriptor: Some("wpkh(xpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/1/*)".to_string()), + #[cfg(feature = "electrum")] + electrum_opts: ElectrumOpts { + proxy: None, + retries: 5, + timeout: None, + electrum: "ssl://electrum.blockstream.info:60002".to_string() + }, + #[cfg(feature = "esplora")] + esplora_opts: EsploraOpts { + esplora: None, + esplora_concurrency: 4 + }, + }, + subcommand: WalletSubCommand::OfflineWalletSubCommand(GetNewAddress), + }, + }; + + assert_eq!(expected_cli_opts, cli_opts); + } + + #[cfg(feature = "electrum")] + #[test] + fn test_wallet_electrum() { + let cli_args = vec!["bdk-cli", "--network", "testnet", "wallet", + "--proxy", "127.0.0.1:9150", "--retries", "3", "--timeout", "10", + "--descriptor", "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)", + "--change_descriptor", "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/1/*)", + "--server","ssl://electrum.blockstream.info:50002", + "get_new_address"]; + + let cli_opts = CliOpts::from_iter(&cli_args); + + let expected_cli_opts = CliOpts { + network: Network::Testnet, + subcommand: CliSubCommand::Wallet { + wallet_opts: WalletOpts { + wallet: "main".to_string(), + descriptor: "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)".to_string(), + change_descriptor: Some("wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/1/*)".to_string()), + #[cfg(feature = "electrum")] + electrum_opts: ElectrumOpts { + proxy: Some("127.0.0.1:9150".to_string()), + retries: 3, + timeout: Some(10), + electrum: "ssl://electrum.blockstream.info:50002".to_string(), + }, + #[cfg(feature = "esplora")] + esplora_opts: EsploraOpts { + esplora: None, + esplora_concurrency: 4, + } + }, + subcommand: WalletSubCommand::OfflineWalletSubCommand(GetNewAddress), + }, + }; + + assert_eq!(expected_cli_opts, cli_opts); + } + + #[cfg(feature = "esplora")] + #[test] + fn test_wallet_esplora() { + let cli_args = vec!["bdk-cli", "--network", "bitcoin", "wallet", + "--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 cli_opts = CliOpts::from_iter(&cli_args); - let expected_wallet_opt = WalletOpt { + let expected_cli_opts = CliOpts { network: Network::Bitcoin, - wallet: "main".to_string(), - proxy: None, - descriptor: "wpkh(xpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)".to_string(), - change_descriptor: Some("wpkh(xpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/1/*)".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, + subcommand: CliSubCommand::Wallet { + wallet_opts: WalletOpts { + wallet: "main".to_string(), + descriptor: "wpkh(xpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)".to_string(), + change_descriptor: Some("wpkh(xpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/1/*)".to_string()), + #[cfg(feature = "electrum")] + electrum_opts: ElectrumOpts { + proxy: None, + retries: 5, + timeout: None, + electrum: "ssl://electrum.blockstream.info:60002".to_string(), + }, + #[cfg(feature = "esplora")] + esplora_opts: EsploraOpts { + esplora: Some("https://blockstream.info/api/".to_string()), + esplora_concurrency: 5, + } + }, + subcommand: WalletSubCommand::OfflineWalletSubCommand(GetNewAddress), + }, }; - assert_eq!(expected_wallet_opt, wallet_opt); + assert_eq!(expected_cli_opts, cli_opts); } #[test] - fn test_sync() { - let cli_args = vec!["bdk-cli", "--network", "testnet", + fn test_wallet_sync() { + let cli_args = vec!["bdk-cli", "--network", "testnet", "wallet", "--descriptor", "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)", "sync", "--max_addresses", "50"]; - let wallet_opt = WalletOpt::from_iter(&cli_args); + let cli_opts = CliOpts::from_iter(&cli_args); - let expected_wallet_opt = WalletOpt { + let expected_cli_opts = CliOpts { network: Network::Testnet, - wallet: "main".to_string(), - proxy: None, - descriptor: "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)".to_string(), - change_descriptor: None, - #[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) + subcommand: CliSubCommand::Wallet { + wallet_opts: WalletOpts { + wallet: "main".to_string(), + descriptor: "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)".to_string(), + change_descriptor: None, + #[cfg(feature = "electrum")] + electrum_opts: ElectrumOpts { + proxy: None, + retries: 5, + timeout: None, + electrum: "ssl://electrum.blockstream.info:60002".to_string(), + }, + #[cfg(feature = "esplora")] + esplora_opts: EsploraOpts { + esplora: None, + esplora_concurrency: 4, + } + }, + subcommand: WalletSubCommand::OnlineWalletSubCommand(Sync { + max_addresses: Some(50) + }), }, }; - assert_eq!(expected_wallet_opt, wallet_opt); + assert_eq!(expected_cli_opts, cli_opts); } #[test] - fn test_create_tx() { - let cli_args = vec!["bdk-cli", "--network", "testnet", "--proxy", "127.0.0.1:9150", + fn test_wallet_create_tx() { + let cli_args = vec!["bdk-cli", "--network", "testnet", "wallet", "--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 cli_opts = CliOpts::from_iter(&cli_args); let script1 = Address::from_str("n2Z3YNXtceeJhFkTknVaNjT1mnCGWesykJ") .unwrap() @@ -678,69 +1058,131 @@ mod test { ) .unwrap(); - let expected_wallet_opt = WalletOpt { + let expected_cli_opts = CliOpts { network: Network::Testnet, - 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()), - #[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, + subcommand: CliSubCommand::Wallet { + wallet_opts: WalletOpts { + wallet: "main".to_string(), + descriptor: "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)".to_string(), + change_descriptor: Some("wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/1/*)".to_string()), + #[cfg(feature = "electrum")] + electrum_opts: ElectrumOpts { + proxy: None, + retries: 5, + timeout: None, + electrum: "ssl://electrum.blockstream.info:60002".to_string(), + }, + #[cfg(feature = "esplora")] + esplora_opts: EsploraOpts { + esplora: None, + esplora_concurrency: 4, + } + }, + subcommand: WalletSubCommand::OfflineWalletSubCommand(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); + assert_eq!(expected_cli_opts, cli_opts); } #[test] - fn test_broadcast() { - let cli_args = vec!["bdk-cli", "--network", "testnet", + fn test_wallet_broadcast() { + let cli_args = vec!["bdk-cli", "--network", "testnet", "wallet", "--descriptor", "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)", "broadcast", "--psbt", "cHNidP8BAEICAAAAASWhGE1AhvtO+2GjJHopssFmgfbq+WweHd8zN/DeaqmDAAAAAAD/////AQAAAAAAAAAABmoEAAECAwAAAAAAAAA="]; - let wallet_opt = WalletOpt::from_iter(&cli_args); + let cli_opts = CliOpts::from_iter(&cli_args); - let expected_wallet_opt = WalletOpt { + let expected_cli_opts = CliOpts { network: Network::Testnet, - wallet: "main".to_string(), - proxy: None, - descriptor: "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)".to_string(), - change_descriptor: None, - #[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 + subcommand: CliSubCommand::Wallet { + wallet_opts: WalletOpts { + wallet: "main".to_string(), + descriptor: "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)".to_string(), + change_descriptor: None, + #[cfg(feature = "electrum")] + electrum_opts: ElectrumOpts { + proxy: None, + retries: 5, + timeout: None, + electrum: "ssl://electrum.blockstream.info:60002".to_string(), + }, + #[cfg(feature = "esplora")] + esplora_opts: EsploraOpts { + esplora: None, + esplora_concurrency: 4, + } + }, + subcommand: WalletSubCommand::OnlineWalletSubCommand(Broadcast { + psbt: Some("cHNidP8BAEICAAAAASWhGE1AhvtO+2GjJHopssFmgfbq+WweHd8zN/DeaqmDAAAAAAD/////AQAAAAAAAAAABmoEAAECAwAAAAAAAAA=".to_string()), + tx: None + }), }, }; - assert_eq!(expected_wallet_opt, wallet_opt); + assert_eq!(expected_cli_opts, cli_opts); } #[test] fn test_wrong_network() { - let cli_args = vec!["repl", "--network", "badnet", + let cli_args = vec!["repl", "--network", "badnet", "wallet", "--descriptor", "wpkh(tpubDEnoLuPdBep9bzw5LoGYpsxUQYheRQ9gcgrJhJEcdKFB9cWQRyYmkCyRoTqeD4tJYiVVgt6A3rN6rWn9RYhR9sBsGxji29LYWHuKKbdb1ev/0/*)", "sync", "--max_addresses", "50"]; - let wallet_opt = WalletOpt::from_iter_safe(&cli_args); - assert!(wallet_opt.is_err()); + let cli_opts = CliOpts::from_iter_safe(&cli_args); + assert!(cli_opts.is_err()); + } + + #[test] + fn test_key_generate() { + let network = Testnet; + let key_generate_cmd = KeySubCommand::Generate { + word_count: 12, + password: Some("test123".to_string()), + }; + + let result = handle_key_subcommand(network, key_generate_cmd).unwrap(); + let result_obj = result.as_object().unwrap(); + + let mnemonic = result_obj.get("mnemonic").unwrap().as_str().unwrap(); + let mnemonic: Vec<&str> = mnemonic.split(' ').collect(); + let xprv = result_obj.get("xprv").unwrap().as_str().unwrap(); + let xpub = result_obj.get("xpub").unwrap().as_str().unwrap(); + + assert_eq!(mnemonic.len(), 12); + assert_eq!(&xprv[0..4], "tprv"); + assert_eq!(&xpub[0..4], "tpub"); + } + + #[test] + fn test_key_restore() { + let network = Testnet; + let key_generate_cmd = KeySubCommand::Restore { + mnemonic: "payment battle unit sword token broccoli era violin purse trip blood hire" + .to_string(), + password: Some("test123".to_string()), + }; + + let result = handle_key_subcommand(network, key_generate_cmd).unwrap(); + let result_obj = result.as_object().unwrap(); + + let fingerprint = result_obj.get("fingerprint").unwrap().as_str().unwrap(); + let xprv = result_obj.get("xprv").unwrap().as_str().unwrap(); + let xpub = result_obj.get("xpub").unwrap().as_str().unwrap(); + + assert_eq!(&fingerprint, &"828af366"); + assert_eq!(&xprv, &"tprv8ZgxMBicQKsPd18TeiFknZKqaZFwpdX9tvvKh8eeHSSPBQi5g9xPHztBg411o78G8XkrhQb6Q1cVvBJ1a9xuFHpmWgvQsvkJkNxBjfGoqhK"); + assert_eq!(&xpub, &"tpubD6NzVbkrYhZ4WUAFYMvMBxyx9amsyxi4UEX6yegwhiEn1txrJYmyUVW3rABPz2emcVT5H8PqBoMmWHmJG8fWi9a4iiGLDquUDtyYDKe9cqk"); } }