diff --git a/CHANGELOG.md b/CHANGELOG.md index 437f97e..55a95b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Changelog info is also documented on the [GitHub releases](https://github.com/bi page. See [DEVELOPMENT_CYCLE.md](DEVELOPMENT_CYCLE.md) for more details. ## [Unreleased] +- Add wallet subcommand `config` to save wallet configs +- Add `wallets` command to list all wallets saved configs ## [2.0.0] diff --git a/Cargo.lock b/Cargo.lock index 4ff8e29..614578c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -192,10 +192,12 @@ dependencies = [ "dirs", "env_logger", "log", + "serde", "serde_json", "shlex", "thiserror", "tokio", + "toml", "tracing", "tracing-subscriber", ] @@ -231,7 +233,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f549541116c9f100cd7aa06b5e551e49bcc1f8dda1d0583e014de891aa943329" dependencies = [ "bitcoin", - "hashbrown", + "hashbrown 0.14.5", "serde", ] @@ -789,6 +791,12 @@ dependencies = [ "log", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.13" @@ -1012,13 +1020,19 @@ dependencies = [ "serde", ] +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" + [[package]] name = "hashlink" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", ] [[package]] @@ -1272,11 +1286,21 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown 0.15.4", +] + [[package]] name = "io-uring" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ "bitflags", "cfg-if", @@ -2099,6 +2123,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2323,6 +2356,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.2" @@ -2815,6 +2889,15 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winnow" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/Cargo.toml b/Cargo.toml index d5767f3..e0b39ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ serde_json = "1.0" thiserror = "2.0.11" tokio = { version = "1", features = ["full"] } cli-table = "0.5.0" +toml = "0.8.23" +serde= {version = "1.0", features = ["derive"]} # Optional dependencies bdk_bitcoind_rpc = { version = "0.21.0", features = ["std"], optional = true } diff --git a/Justfile b/Justfile index 013c407..4842595 100644 --- a/Justfile +++ b/Justfile @@ -99,4 +99,4 @@ descriptors private wallet=default_wallet: # run any bitcoin-cli rpc command [group('rpc')] rpc command wallet=default_wallet: - bitcoin-cli -datadir={{default_datadir}} -regtest -rpcwallet={{wallet}} -rpcuser={{rpc_user}} -rpcpassword={{rpc_password}} {{command}} \ No newline at end of file + bitcoin-cli -datadir={{default_datadir}} -regtest -rpcwallet={{wallet}} -rpcuser={{rpc_user}} -rpcpassword={{rpc_password}} {{command}} diff --git a/README.md b/README.md index bca4e00..3edfe8d 100644 --- a/README.md +++ b/README.md @@ -202,3 +202,44 @@ You can optionally return outputs of commands in human-readable, tabular format cargo run --pretty -n signet wallet -w {wallet_name} -d sqlite balance ``` This is available for wallet, key, repl and compile features. When ommitted, outputs default to `JSON`. + +## Saving and using wallet configurations + +The `wallet config` sub-command allows you to save wallet settings to a `config.toml` file in the default directory (`~/.bdk-bitcoin/`) or custom directory specified with the `--datadir` flag. This eliminate the need to repeatedly specify descriptors, client types, and other parameters for each command. Once configured, you can use any wallet command by simply specifying the wallet name. All other parameters are automatically loaded from the saved configuration. + +To save a wallet settings: + +```shell +cargo run --features -- -n wallet --wallet config [ -f ] --ext-descriptor --int-descriptor --client-type --url [--database-type ] [--rpc-user ] + [--rpc-password ] +``` + +For example, to initialize a wallet named `my_wallet` with `electrum` as the backend on `signet` network: + +```shell +cargo run --features electrum -- -n signet wallet -w my_wallet config -e "tr(tprv8Z.../0/*)#dtdqk3dx" -i "tr(tprv8Z.../1/*)#ulgptya7" -d sqlite -c electrum -u "ssl://mempool.space:60602" +``` + +To overwrite an existing wallet configuration, use the `--force` flag after the `config` sub-command. + +#### Using a Configured Wallet + +Once configured, use any wallet command with just the wallet name: + + +```shell +cargo run --features electrum wallet -w my_wallet new_address + +cargo run --features electrum wallet -w my_wallet full_scan +``` + +Note that each wallet has its own configuration, allowing multiple wallets with different configurations. + +#### View all saved Wallet Configs + +To view all saved wallet configurations: + +```shell +cargo run wallets` +``` +You can also use the `--pretty` flag for a formatted output. diff --git a/src/commands.rs b/src/commands.rs index d3f2d98..f7e7059 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -69,8 +69,10 @@ pub enum CliSubCommand { /// needs backend like `sync` and `broadcast`, compile the binary with specific backend feature /// and use the configuration options below to configure for that backend. Wallet { - #[command(flatten)] - wallet_opts: WalletOpts, + /// Selects the wallet to use. + #[arg(env = "WALLET_NAME", short = 'w', long = "wallet", required = true)] + wallet: String, + #[command(subcommand)] subcommand: WalletSubCommand, }, @@ -103,8 +105,9 @@ pub enum CliSubCommand { /// REPL command loop can be used to make recurring callbacks to an already loaded wallet. /// This mode is useful for hands on live testing of wallet operations. Repl { - #[command(flatten)] - wallet_opts: WalletOpts, + /// Wallet name for this REPL session + #[arg(env = "WALLET_NAME", short = 'w', long = "wallet", required = true)] + wallet: String, }, /// Output Descriptors operations. /// @@ -122,10 +125,21 @@ pub enum CliSubCommand { /// Optional key: xprv, xpub, or mnemonic phrase key: Option, }, + /// List all saved wallet configurations. + Wallets, } /// Wallet operation subcommands. #[derive(Debug, Subcommand, Clone, PartialEq)] pub enum WalletSubCommand { + /// Save wallet configuration to `config.toml`. + Config { + /// Overwrite existing wallet configuration if it exists. + #[arg(short = 'f', long = "force", default_value_t = false)] + force: bool, + + #[command(flatten)] + wallet_opts: WalletOpts, + }, #[cfg(any( feature = "electrum", feature = "esplora", @@ -170,14 +184,14 @@ pub enum ClientType { #[derive(Debug, Args, Clone, PartialEq, Eq)] pub struct WalletOpts { /// Selects the wallet to use. - #[arg(env = "WALLET_NAME", short = 'w', long = "wallet")] + #[arg(skip)] pub wallet: Option, /// Adds verbosity, returns PSBT in JSON format alongside serialized, displays expanded objects. #[arg(env = "VERBOSE", short = 'v', long = "verbose")] pub verbose: bool, /// Sets the descriptor to use for the external addresses. - #[arg(env = "EXT_DESCRIPTOR", short = 'e', long)] - pub ext_descriptor: Option, + #[arg(env = "EXT_DESCRIPTOR", short = 'e', long, required = true)] + pub ext_descriptor: String, /// Sets the descriptor to use for internal/change addresses. #[arg(env = "INT_DESCRIPTOR", short = 'i', long)] pub int_descriptor: Option, diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..f024f0b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,167 @@ +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" +))] +use crate::commands::ClientType; +#[cfg(feature = "sqlite")] +use crate::commands::DatabaseType; +use crate::commands::WalletOpts; +use crate::error::BDKCliError as Error; +use bdk_wallet::bitcoin::Network; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +#[derive(Debug, Serialize, Deserialize)] +pub struct WalletConfig { + pub network: Network, + pub wallets: HashMap, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct WalletConfigInner { + pub wallet: String, + pub network: String, + pub ext_descriptor: String, + pub int_descriptor: Option, + #[cfg(any(feature = "sqlite", feature = "redb"))] + pub database_type: String, + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + pub client_type: Option, + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] + pub server_url: Option, + #[cfg(feature = "rpc")] + pub rpc_user: Option, + #[cfg(feature = "rpc")] + pub rpc_password: Option, + #[cfg(feature = "electrum")] + pub batch_size: Option, + #[cfg(feature = "esplora")] + pub parallel_requests: Option, + #[cfg(feature = "rpc")] + pub cookie: Option, +} + +impl WalletConfig { + /// Load configuration from a TOML file in the wallet's data directory + pub fn load(datadir: &Path) -> Result, Error> { + let config_path = datadir.join("config.toml"); + if !config_path.exists() { + return Ok(None); + } + let config_content = fs::read_to_string(&config_path) + .map_err(|e| Error::Generic(format!("Failed to read config file: {e}")))?; + let config: WalletConfig = toml::from_str(&config_content) + .map_err(|e| Error::Generic(format!("Failed to parse config file: {e}")))?; + Ok(Some(config)) + } + + /// Save configuration to a TOML file + pub fn save(&self, datadir: &Path) -> Result<(), Error> { + let config_path = datadir.join("config.toml"); + let config_content = toml::to_string_pretty(self) + .map_err(|e| Error::Generic(format!("Failed to serialize config: {e}")))?; + fs::create_dir_all(datadir) + .map_err(|e| Error::Generic(format!("Failed to create directory {datadir:?}: {e}")))?; + fs::write(&config_path, config_content).map_err(|e| { + Error::Generic(format!("Failed to write config file {config_path:?}: {e}")) + })?; + log::debug!("Saved config to {config_path:?}"); + Ok(()) + } + + /// Get config for a wallet + pub fn get_wallet_opts(&self, wallet_name: &str) -> Result { + let wallet_config = self + .wallets + .get(wallet_name) + .ok_or_else(|| Error::Generic(format!("Wallet {wallet_name} not found in config")))?; + + let _network = match wallet_config.network.as_str() { + "bitcoin" => Network::Bitcoin, + "testnet" => Network::Testnet, + "regtest" => Network::Regtest, + "signet" => Network::Signet, + "testnet4" => Network::Testnet4, + _ => { + return Err(Error::Generic("Invalid network".to_string())); + } + }; + + #[cfg(any(feature = "sqlite", feature = "redb"))] + let database_type = match wallet_config.database_type.as_str() { + #[cfg(feature = "sqlite")] + "sqlite" => DatabaseType::Sqlite, + #[cfg(feature = "redb")] + "redb" => DatabaseType::Redb, + _ => { + return Err(Error::Generic("Invalid database type".to_string())); + } + }; + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + let client_type = match wallet_config.client_type.as_deref() { + #[cfg(feature = "electrum")] + Some("electrum") => ClientType::Electrum, + #[cfg(feature = "esplora")] + Some("esplora") => ClientType::Esplora, + #[cfg(feature = "rpc")] + Some("rpc") => ClientType::Rpc, + #[cfg(feature = "cbf")] + Some("cbf") => ClientType::Cbf, + _ => return Err(Error::Generic(format!("Invalid client type"))), + }; + + Ok(WalletOpts { + wallet: Some(wallet_config.wallet.clone()), + verbose: false, + ext_descriptor: wallet_config.ext_descriptor.clone(), + int_descriptor: wallet_config.int_descriptor.clone(), + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + client_type, + #[cfg(any(feature = "sqlite", feature = "redb"))] + database_type, + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] + url: wallet_config + .server_url + .clone() + .ok_or_else(|| Error::Generic(format!("Server url not found")))?, + #[cfg(feature = "electrum")] + batch_size: wallet_config.batch_size.unwrap_or(10), + #[cfg(feature = "esplora")] + parallel_requests: wallet_config.parallel_requests.unwrap_or(5), + #[cfg(feature = "rpc")] + basic_auth: ( + wallet_config + .rpc_user + .clone() + .unwrap_or_else(|| "user".into()), + wallet_config + .rpc_password + .clone() + .unwrap_or_else(|| "password".into()), + ), + #[cfg(feature = "rpc")] + cookie: wallet_config.cookie.clone(), + #[cfg(feature = "cbf")] + compactfilter_opts: crate::commands::CompactFilterOpts { conn_count: 2 }, + }) + } +} diff --git a/src/handlers.rs b/src/handlers.rs index 6c58a83..08ce6f0 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -12,6 +12,7 @@ use crate::commands::OfflineWalletSubCommand::*; use crate::commands::*; +use crate::config::{WalletConfig, WalletConfigInner}; use crate::error::BDKCliError as Error; #[cfg(any(feature = "sqlite", feature = "redb"))] use crate::persister::Persister; @@ -51,15 +52,17 @@ use {crate::utils::BlockchainClient::KyotoClient, bdk_kyoto::LightClient, tokio: #[cfg(feature = "electrum")] use crate::utils::BlockchainClient::Electrum; -use std::collections::BTreeMap; #[cfg(any(feature = "electrum", feature = "esplora"))] use std::collections::HashSet; +use std::collections::{BTreeMap, HashMap}; use std::convert::TryFrom; #[cfg(any(feature = "repl", feature = "electrum", feature = "esplora"))] use std::io::Write; +use std::path::Path; use std::str::FromStr; #[cfg(any(feature = "redb", feature = "compiler"))] use std::sync::Arc; + #[cfg(any( feature = "electrum", feature = "esplora", @@ -844,6 +847,125 @@ pub(crate) async fn handle_online_wallet_subcommand( } } +/// Handle wallet config subcommand to create or update config.toml +pub fn handle_config_subcommand( + datadir: &Path, + network: Network, + wallet: String, + wallet_opts: &WalletOpts, + force: bool, +) -> Result { + if network == Network::Bitcoin { + eprintln!( + "WARNING: You are configuring a wallet for Bitcoin MAINNET. + This software is experimental and not recommended for use with real funds. + Consider using a testnet for testing purposes. \n" + ); + } + + let ext_descriptor = wallet_opts.ext_descriptor.clone(); + let int_descriptor = wallet_opts.int_descriptor.clone(); + + if ext_descriptor.contains("xprv") || ext_descriptor.contains("tprv") { + eprintln!( + "WARNING: Your external descriptor contains PRIVATE KEYS. + Private keys will be saved in PLAINTEXT in the config file. + This is a security risk. Consider using public descriptors instead.\n" + ); + } + + if let Some(ref internal_desc) = int_descriptor { + if internal_desc.contains("xprv") || internal_desc.contains("tprv") { + eprintln!( + "WARNING: Your internal descriptor contains PRIVATE KEYS. + Private keys will be saved in PLAINTEXT in the config file. + This is a security risk. Consider using public descriptors instead.\n" + ); + } + } + + let mut config = WalletConfig::load(datadir)?.unwrap_or(WalletConfig { + network, + wallets: HashMap::new(), + }); + + if config.wallets.contains_key(&wallet) && !force { + return Err(Error::Generic(format!( + "Wallet '{wallet}' already exists in config.toml. Use --force to overwrite." + ))); + } + + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + let client_type = wallet_opts.client_type.clone(); + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] + let url = &wallet_opts.url.clone(); + #[cfg(any(feature = "sqlite", feature = "redb"))] + let database_type = match wallet_opts.database_type { + #[cfg(feature = "sqlite")] + DatabaseType::Sqlite => "sqlite".to_string(), + #[cfg(feature = "redb")] + DatabaseType::Redb => "redb".to_string(), + }; + + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + let client_type = match client_type { + #[cfg(feature = "electrum")] + ClientType::Electrum => "electrum".to_string(), + #[cfg(feature = "esplora")] + ClientType::Esplora => "esplora".to_string(), + #[cfg(feature = "rpc")] + ClientType::Rpc => "rpc".to_string(), + #[cfg(feature = "cbf")] + ClientType::Cbf => "cbf".to_string(), + }; + + let wallet_config = WalletConfigInner { + wallet: wallet.clone(), + network: network.to_string(), + ext_descriptor, + int_descriptor, + #[cfg(any(feature = "sqlite", feature = "redb"))] + database_type, + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + client_type: Some(client_type), + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc",))] + server_url: Some(url.to_string()), + #[cfg(feature = "rpc")] + rpc_user: Some(wallet_opts.basic_auth.0.clone()), + #[cfg(feature = "rpc")] + rpc_password: Some(wallet_opts.basic_auth.1.clone()), + #[cfg(feature = "electrum")] + batch_size: Some(wallet_opts.batch_size), + #[cfg(feature = "esplora")] + parallel_requests: Some(wallet_opts.parallel_requests), + #[cfg(feature = "rpc")] + cookie: wallet_opts.cookie.clone(), + }; + + config.network = network; + config.wallets.insert(wallet.clone(), wallet_config); + config.save(datadir)?; + + Ok(serde_json::to_string_pretty(&json!({ + "message": format!("Wallet '{wallet}' initialized successfully in {:?}", datadir.join("config.toml")) + }))?) +} + /// Determine if PSBT has final script sigs or witnesses for all unsigned tx inputs. #[cfg(any( feature = "electrum", @@ -1044,12 +1166,145 @@ pub(crate) fn handle_compile_subcommand( } } +/// Handle wallets command to show all saved wallet configurations +pub fn handle_wallets_subcommand(datadir: &Path, pretty: bool) -> Result { + let load_config = WalletConfig::load(datadir)?; + + let config = match load_config { + Some(c) if !c.wallets.is_empty() => c, + _ => { + return Ok(if pretty { + "No wallet configurations found.".to_string() + } else { + serde_json::to_string_pretty(&json!({ + "wallets": [] + }))? + }); + } + }; + + if pretty { + let mut rows: Vec> = vec![]; + + for (name, wallet_config) in config.wallets.iter() { + let mut row = vec![name.cell(), wallet_config.network.clone().cell()]; + + #[cfg(any(feature = "sqlite", feature = "redb"))] + row.push(wallet_config.database_type.clone().cell()); + + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + { + let client_str = wallet_config.client_type.as_deref().unwrap_or("N/A"); + row.push(client_str.cell()); + } + + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] + { + let url_str = wallet_config.server_url.as_deref().unwrap_or("N/A"); + let display_url = if url_str.len() > 20 { + shorten(url_str, 15, 10) + } else { + url_str.to_string() + }; + row.push(display_url.cell()); + } + + let ext_desc_display = if wallet_config.ext_descriptor.len() > 40 { + shorten(&wallet_config.ext_descriptor, 20, 15) + } else { + wallet_config.ext_descriptor.clone() + }; + row.push(ext_desc_display.cell()); + + let has_int_desc = if wallet_config.int_descriptor.is_some() { + "Yes" + } else { + "No" + }; + row.push(has_int_desc.cell()); + + rows.push(row); + } + + let mut title_cells = vec!["Wallet Name".cell().bold(true), "Network".cell().bold(true)]; + + #[cfg(any(feature = "sqlite", feature = "redb"))] + title_cells.push("Database".cell().bold(true)); + + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + title_cells.push("Client".cell().bold(true)); + + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] + title_cells.push("Server URL".cell().bold(true)); + + title_cells.push("External Desc".cell().bold(true)); + title_cells.push("Internal Desc".cell().bold(true)); + + let table = rows + .table() + .title(title_cells) + .display() + .map_err(|e| Error::Generic(e.to_string()))?; + + Ok(format!("{table}")) + } else { + let wallets_summary: Vec<_> = config + .wallets + .iter() + .map(|(name, wallet_config)| { + let mut wallet_json = json!({ + "name": name, + "network": wallet_config.network, + "ext_descriptor": wallet_config.ext_descriptor, + "int_descriptor": wallet_config.int_descriptor, + }); + + #[cfg(any(feature = "sqlite", feature = "redb"))] + { + wallet_json["database_type"] = json!(wallet_config.database_type.clone()); + } + + #[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "rpc", + feature = "cbf" + ))] + { + wallet_json["client_type"] = json!(wallet_config.client_type.clone()); + } + + #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] + { + wallet_json["server_url"] = json!(wallet_config.server_url.clone()); + } + + wallet_json + }) + .collect(); + + Ok(serde_json::to_string_pretty(&json!({ + "wallets": wallets_summary + }))?) + } +} + /// The global top level handler. pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { - let network = cli_opts.network; let pretty = cli_opts.pretty; + let subcommand = cli_opts.subcommand.clone(); - let result: Result = match cli_opts.subcommand { + let result: Result = match subcommand { #[cfg(any( feature = "electrum", feature = "esplora", @@ -1057,13 +1312,14 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { feature = "rpc" ))] CliSubCommand::Wallet { - ref wallet_opts, + wallet, subcommand: WalletSubCommand::OnlineWalletSubCommand(online_subcommand), } => { - // let network = cli_opts.network; let home_dir = prepare_home_dir(cli_opts.datadir)?; - let wallet_name = &wallet_opts.wallet; - let database_path = prepare_wallet_db_dir(wallet_name, &home_dir)?; + + let (wallet_opts, network) = load_wallet_config(&home_dir, &wallet)?; + + let database_path = prepare_wallet_db_dir(&home_dir, &wallet)?; #[cfg(any(feature = "sqlite", feature = "redb"))] let result = { @@ -1077,6 +1333,7 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { } #[cfg(feature = "redb")] DatabaseType::Redb => { + let wallet_name = &wallet_opts.wallet; let db = Arc::new(bdk_redb::redb::Database::create( home_dir.join("wallet.redb"), )?); @@ -1089,8 +1346,9 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { } }; - let mut wallet = new_persisted_wallet(network, &mut persister, wallet_opts)?; - let blockchain_client = new_blockchain_client(wallet_opts, &wallet, database_path)?; + let mut wallet = new_persisted_wallet(network, &mut persister, &wallet_opts)?; + let blockchain_client = + new_blockchain_client(&wallet_opts, &wallet, database_path)?; let result = handle_online_wallet_subcommand( &mut wallet, @@ -1113,18 +1371,19 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { Ok(result) } CliSubCommand::Wallet { - ref wallet_opts, - subcommand: WalletSubCommand::OfflineWalletSubCommand(ref offline_subcommand), + wallet: wallet_name, + subcommand: WalletSubCommand::OfflineWalletSubCommand(offline_subcommand), } => { - let network = cli_opts.network; + let datadir = cli_opts.datadir.clone(); + let home_dir = prepare_home_dir(datadir)?; + let (wallet_opts, network) = load_wallet_config(&home_dir, &wallet_name)?; + #[cfg(any(feature = "sqlite", feature = "redb"))] let result = { - let home_dir = prepare_home_dir(cli_opts.datadir.clone())?; - let wallet_name = &wallet_opts.wallet; let mut persister: Persister = match &wallet_opts.database_type { #[cfg(feature = "sqlite")] DatabaseType::Sqlite => { - let database_path = prepare_wallet_db_dir(wallet_name, &home_dir)?; + let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?; let db_file = database_path.join("wallet.sqlite"); let connection = Connection::open(db_file)?; log::debug!("Sqlite database opened successfully"); @@ -1135,20 +1394,17 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { let db = Arc::new(bdk_redb::redb::Database::create( home_dir.join("wallet.redb"), )?); - let store = RedbStore::new( - db, - wallet_name.as_deref().unwrap_or("wallet").to_string(), - )?; + let store = RedbStore::new(db, wallet_name)?; log::debug!("Redb database opened successfully"); Persister::RedbStore(store) } }; - let mut wallet = new_persisted_wallet(network, &mut persister, wallet_opts)?; + let mut wallet = new_persisted_wallet(network, &mut persister, &wallet_opts)?; let result = handle_offline_wallet_subcommand( &mut wallet, - wallet_opts, + &wallet_opts, &cli_opts, offline_subcommand.clone(), )?; @@ -1157,19 +1413,34 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { }; #[cfg(not(any(feature = "sqlite", feature = "redb")))] let result = { - let mut wallet = new_wallet(network, wallet_opts)?; + let mut wallet = new_wallet(network, &wallet_opts)?; handle_offline_wallet_subcommand( &mut wallet, - wallet_opts, + &wallet_opts, &cli_opts, offline_subcommand.clone(), )? }; Ok(result) } + CliSubCommand::Wallet { + wallet, + subcommand: WalletSubCommand::Config { force, wallet_opts }, + } => { + let network = cli_opts.network; + let home_dir = prepare_home_dir(cli_opts.datadir)?; + let result = handle_config_subcommand(&home_dir, network, wallet, &wallet_opts, force)?; + Ok(result) + } + CliSubCommand::Wallets => { + let home_dir = prepare_home_dir(cli_opts.datadir)?; + let result = handle_wallets_subcommand(&home_dir, pretty)?; + Ok(result) + } CliSubCommand::Key { subcommand: key_subcommand, } => { + let network = cli_opts.network; let result = handle_key_subcommand(network, key_subcommand, pretty)?; Ok(result) } @@ -1178,22 +1449,23 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { policy, script_type, } => { + let network = cli_opts.network; let result = handle_compile_subcommand(network, policy, script_type, pretty)?; Ok(result) } #[cfg(feature = "repl")] - CliSubCommand::Repl { ref wallet_opts } => { - let network = cli_opts.network; + CliSubCommand::Repl { + wallet: wallet_name, + } => { + let home_dir = prepare_home_dir(cli_opts.datadir.clone())?; + let (wallet_opts, network) = load_wallet_config(&home_dir, &wallet_name)?; + #[cfg(any(feature = "sqlite", feature = "redb"))] let (mut wallet, mut persister) = { - let wallet_name = &wallet_opts.wallet; - - let home_dir = prepare_home_dir(cli_opts.datadir.clone())?; - let mut persister: Persister = match &wallet_opts.database_type { #[cfg(feature = "sqlite")] DatabaseType::Sqlite => { - let database_path = prepare_wallet_db_dir(wallet_name, &home_dir)?; + let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?; let db_file = database_path.join("wallet.sqlite"); let connection = Connection::open(db_file)?; log::debug!("Sqlite database opened successfully"); @@ -1204,22 +1476,18 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { let db = Arc::new(bdk_redb::redb::Database::create( home_dir.join("wallet.redb"), )?); - let store = RedbStore::new( - db, - wallet_name.as_deref().unwrap_or("wallet").to_string(), - )?; + let store = RedbStore::new(db, wallet_name.clone())?; log::debug!("Redb database opened successfully"); Persister::RedbStore(store) } }; - let wallet = new_persisted_wallet(network, &mut persister, wallet_opts)?; + let wallet = new_persisted_wallet(network, &mut persister, &wallet_opts)?; (wallet, persister) }; #[cfg(not(any(feature = "sqlite", feature = "redb")))] - let mut wallet = new_wallet(network, &wallet_opts)?; + let mut wallet = new_wallet(network, &loaded_wallet_opts)?; let home_dir = prepare_home_dir(cli_opts.datadir.clone())?; - let database_path = prepare_wallet_db_dir(&wallet_opts.wallet, &home_dir)?; - + let database_path = prepare_wallet_db_dir(&home_dir, &wallet_name)?; loop { let line = readline()?; let line = line.trim(); @@ -1230,7 +1498,8 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { let result = respond( network, &mut wallet, - wallet_opts, + &wallet_name, + &mut wallet_opts.clone(), line, database_path.clone(), &cli_opts, @@ -1268,7 +1537,8 @@ pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { async fn respond( network: Network, wallet: &mut Wallet, - wallet_opts: &WalletOpts, + wallet_name: &String, + wallet_opts: &mut WalletOpts, line: &str, _datadir: std::path::PathBuf, cli_opts: &CliOpts, @@ -1302,6 +1572,19 @@ async fn respond( .map_err(|e| e.to_string())?; Some(value) } + ReplSubCommand::Wallet { + subcommand: WalletSubCommand::Config { force, wallet_opts }, + } => { + let value = handle_config_subcommand( + &_datadir, + network, + wallet_name.to_string(), + &wallet_opts, + force, + ) + .map_err(|e| e.to_string())?; + Some(value) + } ReplSubCommand::Key { subcommand } => { let value = handle_key_subcommand(network, subcommand, cli_opts.pretty) .map_err(|e| e.to_string())?; diff --git a/src/main.rs b/src/main.rs index c69aecc..cb7fc38 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ #![warn(missing_docs)] mod commands; +mod config; mod error; mod handlers; #[cfg(any(feature = "sqlite", feature = "redb"))] diff --git a/src/utils.rs b/src/utils.rs index 8a3ee04..73d3453 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -9,6 +9,7 @@ //! Utility Tools //! //! This module includes all the utility tools used by the App. +use crate::config::WalletConfig; use crate::error::BDKCliError as Error; use std::{ fmt::Display, @@ -121,13 +122,11 @@ pub(crate) fn prepare_home_dir(home_path: Option) -> Result, home_path: &Path, + wallet_name: &str, ) -> Result { let mut dir = home_path.to_owned(); - if let Some(wallet_name) = wallet_name { - dir.push(wallet_name); - } + dir.push(wallet_name); if !dir.exists() { std::fs::create_dir(&dir).map_err(|e| Error::Generic(e.to_string()))?; @@ -175,7 +174,7 @@ pub(crate) fn new_blockchain_client( _datadir: PathBuf, ) -> Result { #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] - let url = wallet_opts.url.as_str(); + let url = &wallet_opts.url; let client = match wallet_opts.client_type { #[cfg(feature = "electrum")] ClientType::Electrum => { @@ -188,7 +187,7 @@ pub(crate) fn new_blockchain_client( } #[cfg(feature = "esplora")] ClientType::Esplora => { - let client = bdk_esplora::esplora_client::Builder::new(url).build_async()?; + let client = bdk_esplora::esplora_client::Builder::new(&url).build_async()?; BlockchainClient::Esplora { client: Box::new(client), parallel_requests: wallet_opts.parallel_requests, @@ -243,17 +242,14 @@ where let int_descriptor = wallet_opts.int_descriptor.clone(); let mut wallet_load_params = Wallet::load(); - if ext_descriptor.is_some() { - wallet_load_params = - wallet_load_params.descriptor(KeychainKind::External, ext_descriptor.clone()); - } + wallet_load_params = + wallet_load_params.descriptor(KeychainKind::External, Some(ext_descriptor.clone())); + if int_descriptor.is_some() { wallet_load_params = wallet_load_params.descriptor(KeychainKind::Internal, int_descriptor.clone()); } - if ext_descriptor.is_some() || int_descriptor.is_some() { - wallet_load_params = wallet_load_params.extract_keys(); - } + wallet_load_params = wallet_load_params.extract_keys(); let wallet_opt = wallet_load_params .check_network(network) @@ -262,25 +258,16 @@ where let wallet = match wallet_opt { Some(wallet) => wallet, - None => match (ext_descriptor, int_descriptor) { - (Some(ext_descriptor), Some(int_descriptor)) => { - let wallet = Wallet::create(ext_descriptor, int_descriptor) - .network(network) - .create_wallet(persister) - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(wallet) - } - (Some(ext_descriptor), None) => { - let wallet = Wallet::create_single(ext_descriptor) - .network(network) - .create_wallet(persister) - .map_err(|e| Error::Generic(e.to_string()))?; - Ok(wallet) - } - _ => Err(Error::Generic( - "An external descriptor is required.".to_string(), - )), - }?, + None => match int_descriptor { + Some(int_descriptor) => Wallet::create(ext_descriptor, int_descriptor) + .network(network) + .create_wallet(persister) + .map_err(|e| Error::Generic(e.to_string()))?, + None => Wallet::create_single(ext_descriptor) + .network(network) + .create_wallet(persister) + .map_err(|e| Error::Generic(e.to_string()))?, + }, }; Ok(wallet) @@ -292,22 +279,19 @@ pub(crate) fn new_wallet(network: Network, wallet_opts: &WalletOpts) -> Result { + match int_descriptor { + Some(int_descriptor) => { let wallet = Wallet::create(ext_descriptor, int_descriptor) .network(network) .create_wallet_no_persist()?; Ok(wallet) } - (Some(ext_descriptor), None) => { + None => { let wallet = Wallet::create_single(ext_descriptor) .network(network) .create_wallet_no_persist()?; Ok(wallet) } - _ => Err(Error::Generic( - "An external descriptor is required.".to_string(), - )), } } @@ -380,6 +364,11 @@ pub async fn sync_kyoto_client(wallet: &mut Wallet, client: Box) -> pub(crate) fn shorten(displayable: impl Display, start: u8, end: u8) -> String { let displayable = displayable.to_string(); + + if displayable.len() <= (start + end) as usize { + return displayable; + } + let start_str: &str = &displayable[0..start as usize]; let end_str: &str = &displayable[displayable.len() - end as usize..]; format!("{start_str}...{end_str}") @@ -631,3 +620,31 @@ pub fn format_descriptor_output(result: &Value, pretty: bool) -> Result Result<(WalletOpts, Network), Error> { + let config = WalletConfig::load(home_dir)?.ok_or(Error::Generic(format!( + "No config found for wallet {wallet_name}", + )))?; + + let wallet_opts = config.get_wallet_opts(wallet_name)?; + let wallet_config = config + .wallets + .get(wallet_name) + .ok_or(Error::Generic(format!( + "Wallet '{wallet_name}' not found in config" + )))?; + + let network = match wallet_config.network.as_str() { + "bitcoin" => Ok(Network::Bitcoin), + "testnet" => Ok(Network::Testnet), + "regtest" => Ok(Network::Regtest), + "signet" => Ok(Network::Signet), + "testnet4" => Ok(Network::Testnet4), + _ => Err(Error::Generic("Invalid network in config".to_string())), + }?; + + Ok((wallet_opts, network)) +}