From fcd2a19d19c5d11cc6cb98956d3b3df92a2cefc1 Mon Sep 17 00:00:00 2001 From: yan <102800044+yan-pi@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:08:48 -0300 Subject: [PATCH 1/4] feat(wallet): add saved config list and delete commands --- src/commands.rs | 20 ++++++- src/handlers/config.rs | 55 +++++++++++++++++- src/main.rs | 4 +- tests/integration/init.rs | 115 +++++++++++++++++++++++++++++++++++++- 4 files changed, 186 insertions(+), 8 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index 975f33be..87d4f372 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,16 @@ pub enum CliSubCommand { ResolveDnsRecipient(ResolveDnsRecipientCommand), } +/// Saved wallet configuration subcommands. +#[derive(Debug, Subcommand, Clone, PartialEq)] +pub enum WalletsSubCommand { + /// List saved wallet configurations. + List(ListWalletsCommand), + + /// Delete a saved wallet configuration. + Delete(DeleteWalletConfigCommand), +} + /// Wallet operation subcommands. #[derive(Debug, Subcommand, Clone, PartialEq)] pub enum WalletSubCommand { diff --git a/src/handlers/config.rs b/src/handlers/config.rs index 3409bfa3..0d238996 100644 --- a/src/handlers/config.rs +++ b/src/handlers/config.rs @@ -7,13 +7,14 @@ 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; use crate::handlers::{AppCommand, AppContext}; #[cfg(any(feature = "sqlite", feature = "redb"))] use crate::persister::DatabaseType; +use crate::utils::output::FormatOutput; use crate::utils::types::{StatusResult, WalletsListResult}; use bdk_wallet::bitcoin::Network; use clap::Args; @@ -172,3 +173,55 @@ impl AppCommand> for ListWalletsCommand { Ok(WalletsListResult(config.wallets)) } } + +#[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 mut config = match WalletConfig::load(&ctx.datadir)? { + Some(config) => config, + None => return Err(Error::Generic("No wallets configured yet.".into())), + }; + + if config.wallets.remove(&self.wallet_name).is_none() { + return Err(Error::Generic(format!( + "Wallet '{}' not found in config", + self.wallet_name + ))); + } + + if config.wallets.is_empty() { + 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 { + config.save(&ctx.datadir)?; + } + + 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..4810a51b 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")) @@ -221,6 +222,36 @@ mod test_config { use super::*; use serde_json::Value; + 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"]; + + cli.build_base_cmd() + .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()) + .arg("--client-type") + .arg("rpc") + .arg("--database-type") + .arg("sqlite") + .arg("--url") + .arg("http://localhost:18443") + .assert() + .success(); + } + #[test] fn test_save_and_read_wallet_config() { let temp_dir = TempDir::new().unwrap(); @@ -264,7 +295,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 +322,86 @@ 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.")); + } } // SILENT PAYMENTS From f2a7e8f8f4d0cfda7b916a83d3947f12825cfc5b Mon Sep 17 00:00:00 2001 From: yan <102800044+yan-pi@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:08:49 -0300 Subject: [PATCH 2/4] docs: document saved wallet config commands --- CHANGELOG.md | 2 ++ README.md | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79da994d..0bfba5a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ page. See [DEVELOPMENT_CYCLE.md](DEVELOPMENT_CYCLE.md) for more details. ## [Unreleased] +- Split saved wallet configuration operations into `wallets list` and `wallets delete ` + ## [4.0.0] - Added persistance to existing async payjoin integration 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 From 8168651456b59d2a753e348ade37fc2364ad759c Mon Sep 17 00:00:00 2001 From: yan <102800044+yan-pi@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:44:14 -0300 Subject: [PATCH 3/4] test: validate PR 312 wallet deletion changes --- src/commands.rs | 5 ++- src/handlers/config.rs | 75 ++++++++++++++++++++++++++++++++++++++- tests/integration/init.rs | 54 ++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index 87d4f372..b20fa715 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -218,7 +218,10 @@ pub enum WalletsSubCommand { /// List saved wallet configurations. List(ListWalletsCommand), - /// Delete a saved wallet configuration. + /// Delete an unused saved wallet configuration. + /// + /// The command refuses deletion once persistent wallet data exists. + /// Wallet database files are never deleted. Delete(DeleteWalletConfigCommand), } diff --git a/src/handlers/config.rs b/src/handlers/config.rs index 0d238996..0871c260 100644 --- a/src/handlers/config.rs +++ b/src/handlers/config.rs @@ -16,6 +16,8 @@ use crate::handlers::{AppCommand, AppContext}; use crate::persister::DatabaseType; 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; @@ -174,6 +176,55 @@ impl AppCommand> for ListWalletsCommand { } } +#[cfg(any(feature = "sqlite", feature = "redb"))] +fn wallet_data_exists( + datadir: &std::path::Path, + wallet_name: &str, + database_type: &str, +) -> Result { + match database_type { + #[cfg(feature = "sqlite")] + "sqlite" => { + let db_path = datadir.join(wallet_name).join("wallet.sqlite"); + Ok(db_path.is_file()) + } + #[cfg(feature = "redb")] + "redb" => redb_wallet_data_exists(datadir, wallet_name), + + _ => Err(Error::Generic(format!( + "Unsupported database type: {database_type}" + ))), + } +} + +#[cfg(feature = "redb")] +fn redb_wallet_data_exists(datadir: &std::path::Path, wallet_name: &str) -> Result { + let db_path = datadir.join("wallet.redb"); + if !db_path.is_file() { + 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}" + )) + })?; + + let keychain_table_name = format!("{wallet_name}_keychain"); + + Ok(tables.any(|table| table.name() == keychain_table_name.as_str())) +} + #[derive(Args, Debug, Clone, PartialEq)] pub struct DeleteWalletConfigCommand { /// Name of the saved wallet configuration to delete. @@ -190,13 +241,35 @@ impl AppCommand> for DeleteWalletConfigCommand { None => return Err(Error::Generic("No wallets configured yet.".into())), }; - if config.wallets.remove(&self.wallet_name).is_none() { + if !config.wallets.contains_key(&self.wallet_name) { return Err(Error::Generic(format!( "Wallet '{}' not found in config", self.wallet_name ))); } + #[cfg(any(feature = "sqlite", feature = "redb"))] + { + let wallet_config = config.wallets.get(&self.wallet_name).ok_or_else(|| { + Error::Generic(format!("Wallet '{}' not found in config", self.wallet_name)) + })?; + + if wallet_data_exists( + &ctx.datadir, + &self.wallet_name, + &wallet_config.database_type, + )? { + return Err(Error::Generic(format!( + "Wallet data exists for configuration '{}'; the saved configuration was not deleted", + self.wallet_name + ))); + } + } + + let _removed = config.wallets.remove(&self.wallet_name).ok_or_else(|| { + Error::Generic(format!("Wallet '{}' not found in config", self.wallet_name)) + })?; + if config.wallets.is_empty() { let config_path = ctx.datadir.join("config.toml"); std::fs::remove_file(&config_path).map_err(|error| { diff --git a/tests/integration/init.rs b/tests/integration/init.rs index 4810a51b..0d51101d 100644 --- a/tests/integration/init.rs +++ b/tests/integration/init.rs @@ -402,6 +402,60 @@ mod test_config { .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)); + } } // SILENT PAYMENTS From b281ece33045937f363d1219d37e726044e59314 Mon Sep 17 00:00:00 2001 From: yan <102800044+yan-pi@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:37:12 -0300 Subject: [PATCH 4/4] ci: retrigger validation workflows