diff --git a/CHANGELOG.md b/CHANGELOG.md index 098eee19..ec941f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ page. See [DEVELOPMENT_CYCLE.md](DEVELOPMENT_CYCLE.md) for more details. ## [Unreleased] - Added support for Multipath (two-paths) descriptors. - +- Replaced plain `wallets` with `wallets list`, and added `wallets delete ` for unused saved wallet configurations. ## [4.0.0] diff --git a/README.md b/README.md index 94278f3c..c297da79 100644 --- a/README.md +++ b/README.md @@ -328,12 +328,18 @@ 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 +#### Manage saved Wallet Configs To view all saved wallet configurations: ```shell -cargo run wallets` +cargo run -- wallets list +``` + +To delete a saved wallet configuration: + +```shell +cargo run -- wallets delete ``` ## Adding new features/command diff --git a/src/commands.rs b/src/commands.rs index 37f80523..9097b75a 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -16,7 +16,7 @@ #[cfg(feature = "message_signer")] use crate::handlers::offline::{SignMessageCommand, VerifyMessageCommand}; use crate::handlers::{ - config::{ListWalletsCommand, SaveConfigCommand}, + config::{DeleteWalletConfigCommand, ListWalletsCommand, SaveConfigCommand}, descriptor::DescriptorCommand, key::{DeriveKeyCommand, GenerateKeyCommand, RestoreKeyCommand}, offline::{ @@ -141,8 +141,12 @@ pub enum CliSubCommand { /// This feature is intended for development and testing purposes only. Descriptor(DescriptorCommand), - /// List all saved wallet configurations. - Wallets(ListWalletsCommand), + /// Saved wallet configuration operations. + Wallets { + #[command(subcommand)] + subcommand: WalletsSubCommand, + }, + /// Generate tab-completion scripts for your shell. /// /// The completion script is output on stdout, allowing you to redirect @@ -208,6 +212,19 @@ pub enum CliSubCommand { ResolveDnsRecipient(ResolveDnsRecipientCommand), } +/// Saved wallet configuration subcommands. +#[derive(Debug, Subcommand, Clone, PartialEq)] +pub enum WalletsSubCommand { + /// List saved wallet configurations. + List(ListWalletsCommand), + + /// Delete an unused saved wallet configuration. + /// + /// The command refuses deletion once persistent wallet data exists. + /// Wallet database files are never deleted. + Delete(DeleteWalletConfigCommand), +} + /// Wallet operation subcommands. #[derive(Debug, Subcommand, Clone, PartialEq)] pub enum WalletSubCommand { diff --git a/src/config.rs b/src/config.rs index 60580037..4b45fe34 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,7 +7,7 @@ use crate::client::ClientType; use crate::commands::WalletOpts; use crate::error::BDKCliError as Error; -#[cfg(feature = "sqlite")] +#[cfg(any(feature = "sqlite", feature = "redb"))] use crate::persister::DatabaseType; use bdk_wallet::bitcoin::Network; #[cfg(any(feature = "sqlite", feature = "redb"))] @@ -223,8 +223,10 @@ mod tests { network: "testnet4".to_string(), ext_descriptor: EXT_DESCRIPTOR.to_string(), int_descriptor: Some(INT_DESCRIPTOR.to_string()), - #[cfg(any(feature = "sqlite", feature = "redb"))] + #[cfg(feature = "sqlite")] database_type: "sqlite".to_string(), + #[cfg(all(feature = "redb", not(feature = "sqlite")))] + database_type: "redb".to_string(), #[cfg(any( feature = "electrum", @@ -310,8 +312,10 @@ mod tests { network: "regtest".to_string(), ext_descriptor: "desc".to_string(), int_descriptor: None, - #[cfg(any(feature = "sqlite", feature = "redb"))] + #[cfg(feature = "sqlite")] database_type: "sqlite".to_string(), + #[cfg(all(feature = "redb", not(feature = "sqlite")))] + database_type: "redb".to_string(), #[cfg(any( feature = "electrum", feature = "esplora", diff --git a/src/handlers/config.rs b/src/handlers/config.rs index 13131c19..d614ba8c 100644 --- a/src/handlers/config.rs +++ b/src/handlers/config.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; feature = "cbf" ))] use crate::client::ClientType; -use crate::commands::WalletOpts; +use crate::commands::{WalletOpts, WalletsSubCommand}; use crate::config::{WalletConfig, WalletConfigInner}; use crate::error::BDKCliError as Error; use crate::handlers::Init; @@ -15,7 +15,10 @@ use crate::handlers::{AppCommand, AppContext}; #[cfg(any(feature = "sqlite", feature = "redb"))] use crate::persister::DatabaseType; use crate::utils::descriptors::validate_descriptor_pair; +use crate::utils::output::FormatOutput; use crate::utils::types::{StatusResult, WalletsListResult}; +#[cfg(feature = "redb")] +use bdk_redb::redb::TableHandle; use bdk_wallet::bitcoin::Network; use clap::Args; @@ -175,3 +178,209 @@ impl AppCommand> for ListWalletsCommand { Ok(WalletsListResult(config.wallets)) } } + +fn wallet_data_path_exists(path: &std::path::Path) -> Result { + match std::fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(Error::Generic(format!( + "Failed to inspect wallet data at {path:?}: {error}" + ))), + } +} + +#[cfg(any(feature = "sqlite", feature = "redb"))] +fn wallet_data_exists( + datadir: &std::path::Path, + wallet_name: &str, + database_type: &str, +) -> Result { + // Validate the persisted configuration before inspecting any data. In particular, an + // unknown database type must never turn a delete into a silent success. + match database_type { + // Both markers are valid persisted formats even when this binary only + // supports one of them; the opposite backend is checked conservatively below. + "sqlite" | "redb" => {} + _ => { + return Err(Error::Generic(format!( + "Unsupported database type: {database_type}" + ))); + } + } + + // A configuration can be changed to another backend without moving the original data, so + // inspect every persistence backend marker rather than only the configured backend. + let sqlite_path = datadir.join(wallet_name).join("wallet.sqlite"); + let sqlite_data_exists = wallet_data_path_exists(&sqlite_path)?; + + #[cfg(feature = "redb")] + let redb_data_exists = { + // Redb is shared by wallets, so only block deletion when this wallet has its sentinel + // table. + redb_wallet_data_exists(datadir, wallet_name)? + }; + + #[cfg(not(feature = "redb"))] + let redb_data_exists = { + // Without Redb support, conservatively treat the shared marker as data because this + // build cannot inspect its wallet-specific tables. + wallet_data_path_exists(&datadir.join("wallet.redb"))? + }; + + Ok(sqlite_data_exists || redb_data_exists) +} + +#[cfg(feature = "redb")] +fn redb_wallet_data_exists(datadir: &std::path::Path, wallet_name: &str) -> Result { + let db_path = datadir.join("wallet.redb"); + if !wallet_data_path_exists(&db_path)? { + return Ok(false); + } + + let database = bdk_redb::redb::Database::open(&db_path).map_err(|error| { + Error::Generic(format!( + "Failed to open Redb database at {db_path:?}: {error}" + )) + })?; + + let read_transactions = database + .begin_read() + .map_err(|error| Error::Generic(error.to_string()))?; + + let mut tables = read_transactions.list_tables().map_err(|error| { + Error::Generic(format!( + "Failed to list tables in Redb database at {db_path:?}: {error}" + )) + })?; + + // bdk_redb creates this per-wallet table in the first committed table batch for a + // persisted wallet, so it is the sentinel for an initialized wallet store. + let keychain_table_name = format!("{wallet_name}_keychain"); + + Ok(tables.any(|table| table.name() == keychain_table_name.as_str())) +} + +#[cfg(not(any(feature = "sqlite", feature = "redb")))] +fn wallet_data_exists(datadir: &std::path::Path, wallet_name: &str) -> Result { + // The typed config intentionally omits `database_type` in this build, so inspect the raw + // wallet entry as well. A config carrying that field may have been created by a build with a + // database backend and must not be silently deleted just because its marker is absent. + let config_path = datadir.join("config.toml"); + let config_content = std::fs::read_to_string(&config_path) + .map_err(|error| Error::Generic(format!("Failed to read config file: {error}")))?; + let raw_config: toml::Table = toml::from_str(&config_content) + .map_err(|error| Error::Generic(format!("Failed to parse config file: {error}")))?; + let database_type_present = raw_config + .get("wallets") + .and_then(toml::Value::as_table) + .and_then(|wallets| wallets.get(wallet_name)) + .and_then(toml::Value::as_table) + .is_some_and(|wallet| wallet.contains_key("database_type")); + + Ok(database_type_present + || wallet_data_path_exists(&datadir.join(wallet_name).join("wallet.sqlite"))? + || wallet_data_path_exists(&datadir.join("wallet.redb"))?) +} + +fn remove_wallet_from_config_file( + datadir: &std::path::Path, + wallet_name: &str, +) -> Result<(), Error> { + let config_path = datadir.join("config.toml"); + let config_content = std::fs::read_to_string(&config_path) + .map_err(|error| Error::Generic(format!("Failed to read config file: {error}")))?; + let mut raw_config: toml::Table = toml::from_str(&config_content) + .map_err(|error| Error::Generic(format!("Failed to parse config file: {error}")))?; + let wallets = raw_config + .get_mut("wallets") + .and_then(toml::Value::as_table_mut) + .ok_or_else(|| Error::Generic("Config does not contain a wallets table".into()))?; + + if wallets.remove(wallet_name).is_none() { + return Err(Error::Generic(format!( + "Wallet '{wallet_name}' not found in config" + ))); + } + + let updated_config = toml::to_string_pretty(&raw_config) + .map_err(|error| Error::Generic(format!("Failed to serialize config: {error}")))?; + std::fs::write(&config_path, updated_config) + .map_err(|error| Error::Generic(format!("Failed to write config file: {error}"))) +} + +#[derive(Args, Debug, Clone, PartialEq)] +pub struct DeleteWalletConfigCommand { + /// Name of the saved wallet configuration to delete. + #[arg(value_name = "WALLET_NAME")] + pub(crate) wallet_name: String, +} + +impl AppCommand> for DeleteWalletConfigCommand { + type Output = StatusResult; + + fn execute(&self, ctx: &mut AppContext) -> Result { + let config = match WalletConfig::load(&ctx.datadir)? { + Some(config) => config, + None => return Err(Error::Generic("No wallets configured yet.".into())), + }; + + #[cfg(any(feature = "sqlite", feature = "redb"))] + let data_exists = { + let wallet_config = config.wallets.get(&self.wallet_name).ok_or_else(|| { + Error::Generic(format!("Wallet '{}' not found in config", self.wallet_name)) + })?; + + wallet_data_exists( + &ctx.datadir, + &self.wallet_name, + &wallet_config.database_type, + )? + }; + + #[cfg(not(any(feature = "sqlite", feature = "redb")))] + let data_exists = { + if !config.wallets.contains_key(&self.wallet_name) { + return Err(Error::Generic(format!( + "Wallet '{}' not found in config", + self.wallet_name + ))); + } + + wallet_data_exists(&ctx.datadir, &self.wallet_name)? + }; + + if data_exists { + return Err(Error::Generic(format!( + "Wallet data exists for configuration '{}'; the saved configuration was not deleted", + self.wallet_name + ))); + } + + if config.wallets.len() == 1 { + let config_path = ctx.datadir.join("config.toml"); + std::fs::remove_file(&config_path).map_err(|error| { + Error::Generic(format!( + "Failed to remove config at {config_path:?}: {error}" + )) + })?; + } else { + remove_wallet_from_config_file(&ctx.datadir, &self.wallet_name)?; + } + + Ok(StatusResult { + message: format!( + "Wallet configuration '{}' deleted successfully", + self.wallet_name + ), + }) + } +} + +impl WalletsSubCommand { + pub fn execute(&self, ctx: &mut AppContext) -> Result<(), Error> { + match self { + Self::List(command) => command.execute(ctx)?.write_out(std::io::stdout()), + Self::Delete(command) => command.execute(ctx)?.write_out(std::io::stdout()), + } + } +} diff --git a/src/main.rs b/src/main.rs index 06e3ea24..047e8f9e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -129,10 +129,10 @@ async fn run(cli_opts: CliOpts) -> Result<(), Error> { cmd.execute(&mut ctx)?.write_out(std::io::stdout())?; } - CliSubCommand::Wallets(cmd) => { + CliSubCommand::Wallets { subcommand } => { let mut ctx = AppContext::new(cli_opts.network, home_dir); - cmd.execute(&mut ctx)?.write_out(std::io::stdout())?; + subcommand.execute(&mut ctx)?; } #[cfg(feature = "repl")] diff --git a/tests/integration/init.rs b/tests/integration/init.rs index 17fdfce2..4c24f26e 100644 --- a/tests/integration/init.rs +++ b/tests/integration/init.rs @@ -113,7 +113,7 @@ mod test_wallets { let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); let mut cmd = cli.build_base_cmd(); - cmd.arg("wallets"); + cmd.arg("wallets").arg("list"); cmd.assert() .failure() @@ -157,6 +157,7 @@ mod test_wallets { cli.build_base_cmd() .arg("wallets") + .arg("list") .assert() .success() .stdout(predicate::str::contains("wallet_one")) @@ -164,6 +165,203 @@ mod test_wallets { } } +// --- REDB WALLET CONFIGURATION TESTS --- +#[cfg(feature = "redb")] +mod test_redb_wallet_config { + use super::*; + use serde_json::Value; + + fn save_wallet(cli: &BdkCli, wallet_name: &str) { + let descriptor = cli + .cmd("descriptor", &["--type", "tr"]) + .output() + .expect("Command to generate descriptors failed"); + assert!(descriptor.status.success()); + + let descriptor_json: Value = + serde_json::from_slice(&descriptor.stdout).expect("Invalid descriptor JSON"); + let public_descriptors = &descriptor_json["public_descriptors"]; + + cli.build_base_cmd() + .arg("wallet") + .arg("--wallet") + .arg(wallet_name) + .arg("config") + .arg("--ext-descriptor") + .arg(public_descriptors["external"].as_str().unwrap()) + .arg("--int-descriptor") + .arg(public_descriptors["internal"].as_str().unwrap()) + .arg("--database-type") + .arg("redb") + .assert() + .success(); + } + + #[test] + fn test_delete_redb_configs_preserves_shared_database() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); + let persisted_wallet = "persisted_redb_wallet"; + let unused_wallet = "unused_redb_wallet"; + let config_path = temp_dir.path().join("config.toml"); + let database_path = temp_dir.path().join("wallet.redb"); + + save_wallet(&cli, persisted_wallet); + cli.wallet_cmd(&["--wallet", persisted_wallet, "new_address"]) + .assert() + .success(); + assert!(database_path.is_file()); + + save_wallet(&cli, unused_wallet); + cli.build_base_cmd() + .args(["wallets", "delete", unused_wallet]) + .assert() + .success(); + + assert!(config_path.is_file()); + assert!(database_path.is_file()); + cli.build_base_cmd() + .args(["wallets", "list"]) + .assert() + .success() + .stdout(predicate::str::contains(persisted_wallet)) + .stdout(predicate::str::contains(unused_wallet).not()); + + cli.build_base_cmd() + .args(["wallets", "delete", persisted_wallet]) + .assert() + .failure() + .stderr(predicate::str::contains( + "Wallet data exists for configuration 'persisted_redb_wallet'; the saved configuration was not deleted", + )); + + assert!(config_path.is_file()); + assert!(database_path.is_file()); + } +} + +// --- SINGLE-BACKEND CROSS-BACKEND MARKER TESTS --- +#[cfg(all(feature = "sqlite", not(feature = "redb")))] +mod test_sqlite_only_cross_backend_marker { + use super::*; + use std::fs; + + #[test] + fn test_delete_rejects_foreign_redb_file_with_sqlite_marker() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); + let wallet_name = "sqlite_only_foreign_redb_data"; + let config_path = temp_dir.path().join("config.toml"); + let redb_path = temp_dir.path().join("wallet.redb"); + + fs::write( + &config_path, + format!( + "[wallets.{wallet_name}]\nwallet = \"{wallet_name}\"\nnetwork = \"regtest\"\next_descriptor = \"wpkh(test)\"\nint_descriptor = \"wpkh(test)\"\ndatabase_type = \"sqlite\"\n" + ), + ) + .unwrap(); + fs::write(&redb_path, []).unwrap(); + + cli.build_base_cmd() + .args(["wallets", "delete", wallet_name]) + .assert() + .failure() + .stderr(predicate::str::contains( + "Wallet data exists for configuration 'sqlite_only_foreign_redb_data'; the saved configuration was not deleted", + )); + + assert!(config_path.is_file()); + assert!(redb_path.is_file()); + } +} + +#[cfg(all(feature = "redb", not(feature = "sqlite")))] +mod test_redb_only_cross_backend_marker { + use super::*; + use std::fs; + + #[test] + fn test_delete_rejects_foreign_sqlite_file_with_redb_marker() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); + let wallet_name = "redb_only_foreign_sqlite_data"; + let config_path = temp_dir.path().join("config.toml"); + let sqlite_path = temp_dir.path().join(wallet_name).join("wallet.sqlite"); + + fs::write( + &config_path, + format!( + "[wallets.{wallet_name}]\nwallet = \"{wallet_name}\"\nnetwork = \"regtest\"\next_descriptor = \"wpkh(test)\"\nint_descriptor = \"wpkh(test)\"\ndatabase_type = \"redb\"\n" + ), + ) + .unwrap(); + fs::create_dir_all(sqlite_path.parent().unwrap()).unwrap(); + fs::write(&sqlite_path, []).unwrap(); + + cli.build_base_cmd() + .args(["wallets", "delete", wallet_name]) + .assert() + .failure() + .stderr(predicate::str::contains( + "Wallet data exists for configuration 'redb_only_foreign_sqlite_data'; the saved configuration was not deleted", + )); + + assert!(config_path.is_file()); + assert!(sqlite_path.is_file()); + } +} + +// --- DATABASE-DISABLED WALLET CONFIGURATION TESTS --- +#[cfg(not(any(feature = "sqlite", feature = "redb")))] +mod test_database_disabled_wallet_config { + use super::*; + use std::fs; + + fn write_config(datadir: &std::path::Path, wallet_name: &str, database_type: Option<&str>) { + let database_type = database_type + .map(|database_type| format!("\ndatabase_type = \"{database_type}\"")) + .unwrap_or_default(); + + fs::write( + datadir.join("config.toml"), + format!( + "[wallets.{wallet_name}]\nwallet = \"{wallet_name}\"\nnetwork = \"regtest\"\next_descriptor = \"wpkh(test)\"\nint_descriptor = \"wpkh(test)\"{database_type}\n" + ), + ) + .unwrap(); + } + + #[test] + fn test_delete_wallet_config_without_database_support() { + for (wallet_name, database_type) in [ + ("marker_wallet", Some("sqlite")), + ("database_free_wallet", None), + ] { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); + let config_path = temp_dir.path().join("config.toml"); + + write_config(temp_dir.path(), wallet_name, database_type); + + let assertion = cli + .build_base_cmd() + .args(["wallets", "delete", wallet_name]) + .assert(); + + if database_type.is_some() { + assertion.failure().stderr(predicate::str::contains( + "Wallet data exists for configuration", + )); + assert!(config_path.is_file()); + } else { + assertion.success(); + assert!(!config_path.exists()); + } + } + } +} + // --- DESCRIPTOR COMMAND TESTS --- mod test_descriptor { use super::*; @@ -216,11 +414,49 @@ mod test_compile { } // --- CONFIG COMMAND TESTS --- -#[cfg(feature = "rpc")] +#[cfg(any(feature = "rpc", feature = "sqlite"))] mod test_config { use super::*; use serde_json::Value; + use std::fs; + + fn save_wallet(cli: &BdkCli, wallet_name: &str) { + let desc = cli + .cmd("descriptor", &["--type", "tr"]) + .output() + .expect("Command to generate descriptors failed"); + + let desc_values: Value = + serde_json::from_slice(&desc.stdout).expect("Invalid JSON from output descriptor"); + + let pub_desc = &desc_values["public_descriptors"]; + + let mut command = cli.build_base_cmd(); + command + .arg("wallet") + .arg("--wallet") + .arg(wallet_name) + .arg("config") + .arg("--ext-descriptor") + .arg(pub_desc["external"].as_str().unwrap()) + .arg("--int-descriptor") + .arg(pub_desc["internal"].as_str().unwrap()); + + #[cfg(feature = "rpc")] + command + .arg("--client-type") + .arg("rpc") + .arg("--url") + .arg("http://localhost:18443"); + command + .arg("--database-type") + .arg("sqlite") + .assert() + .success(); + } + + #[cfg(feature = "rpc")] #[test] fn test_save_and_read_wallet_config() { let temp_dir = TempDir::new().unwrap(); @@ -264,7 +500,7 @@ mod test_config { // verify saved config let mut cmd = cli.build_base_cmd(); - cmd.arg("wallets"); + cmd.arg("wallets").arg("list"); let output = cmd.output().expect("Failed to execute wallets command"); @@ -291,6 +527,192 @@ mod test_config { assert_eq!(config["ext_descriptor"].as_str().unwrap(), ext_desc); assert_eq!(config["int_descriptor"].as_str().unwrap(), int_desc); } + + #[test] + fn test_delete_wallet_config() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); + let remove_wallet_name = "test_delete_wallet"; + let keep_wallet_name = "test_keep_wallet"; + + save_wallet(&cli, remove_wallet_name); + save_wallet(&cli, keep_wallet_name); + + // Delete one config: the output is a confirmation message + let output = cli + .build_base_cmd() + .arg("wallets") + .arg("delete") + .arg(remove_wallet_name) + .output() + .expect("Failed to execute wallets delete command"); + assert!(output.status.success(), "wallets delete failed"); + + let json: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + json["message"].as_str().unwrap(), + "Wallet configuration 'test_delete_wallet' deleted successfully" + ); + + // Re-listing no longer contains the deleted wallet + let output = cli + .build_base_cmd() + .arg("wallets") + .arg("list") + .output() + .expect("Failed to execute wallets list command"); + + let list: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(list.get(remove_wallet_name).is_none()); + assert!(list.get(keep_wallet_name).is_some()); + } + + #[test] + fn test_delete_unknown_wallet_config() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); + save_wallet(&cli, "existing_wallet"); + + cli.build_base_cmd() + .arg("wallets") + .arg("delete") + .arg("ghost_wallet") + .assert() + .failure() + .stderr(predicate::str::contains("not found in config")); + } + + #[test] + fn test_delete_last_wallet_config() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); + let config_path = temp_dir.path().join("config.toml"); + + save_wallet(&cli, "last_wallet"); + assert!(config_path.exists()); + + cli.build_base_cmd() + .arg("wallets") + .arg("delete") + .arg("last_wallet") + .assert() + .success(); + + assert!(!config_path.exists()); + + cli.build_base_cmd() + .arg("wallets") + .arg("list") + .assert() + .failure() + .stderr(predicate::str::contains("No wallets configured yet.")); + } + + #[test] + fn test_delete_wallet_config_with_persisted_data_fails() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); + let wallet_name = "persisted_wallet"; + + save_wallet(&cli, wallet_name); + + let config_path = temp_dir.path().join("config.toml"); + let database_path = temp_dir.path().join(wallet_name).join("wallet.sqlite"); + + assert!(config_path.is_file()); + assert!( + !database_path.exists(), + "saving a configuration alone should not create wallet data" + ); + + cli.wallet_cmd(&["--wallet", wallet_name, "new_address"]) + .assert() + .success(); + + assert!( + database_path.is_file(), + "new_address should initialize the wallet database" + ); + + cli.build_base_cmd() + .arg("wallets") + .arg("delete") + .arg(wallet_name) + .assert() + .failure() + .stderr(predicate::str::contains( + "Wallet data exists for configuration 'persisted_wallet'", + )); + + assert!( + config_path.is_file(), + "failed deletion should preserve config.toml" + ); + + assert!( + database_path.is_file(), + "failed deletion should preserve wallet data" + ); + + cli.build_base_cmd() + .arg("wallets") + .arg("list") + .assert() + .success() + .stdout(predicate::str::contains(wallet_name)); + } + + #[cfg(feature = "sqlite")] + #[test] + fn test_delete_preserves_fields_unknown_to_current_build() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("regtest", Some(temp_dir.path().to_path_buf())); + let config_path = temp_dir.path().join("config.toml"); + + fs::write( + &config_path, + r#"[wallets.remove_wallet] +wallet = "remove_wallet" +network = "regtest" +ext_descriptor = "wpkh(test)" +int_descriptor = "wpkh(test)" +database_type = "sqlite" + +[wallets.preserved_wallet] +wallet = "preserved_wallet" +network = "regtest" +ext_descriptor = "wpkh(test)" +int_descriptor = "wpkh(test)" +database_type = "sqlite" +client_type = "rpc" +server_url = "http://localhost:18443" +rpc_user = "preserved-user" +rpc_password = "preserved-password" +"#, + ) + .unwrap(); + + cli.build_base_cmd() + .args(["wallets", "delete", "remove_wallet"]) + .assert() + .success(); + + let raw_config: toml::Table = + toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap(); + let preserved = raw_config["wallets"]["preserved_wallet"] + .as_table() + .unwrap(); + assert_eq!(preserved["client_type"].as_str(), Some("rpc")); + assert_eq!( + preserved["server_url"].as_str(), + Some("http://localhost:18443") + ); + assert_eq!(preserved["rpc_user"].as_str(), Some("preserved-user")); + assert_eq!( + preserved["rpc_password"].as_str(), + Some("preserved-password") + ); + } } // SILENT PAYMENTS