Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <wallet_name>` for unused saved wallet configurations.

## [4.0.0]

Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <wallet_name>
```

## Adding new features/command
Expand Down
23 changes: 20 additions & 3 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 7 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))]
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
211 changes: 210 additions & 1 deletion src/handlers/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ 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::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;

Expand Down Expand Up @@ -175,3 +178,209 @@ impl AppCommand<AppContext<Init>> for ListWalletsCommand {
Ok(WalletsListResult(config.wallets))
}
}

fn wallet_data_path_exists(path: &std::path::Path) -> Result<bool, Error> {
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<bool, Error> {
// 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<bool, Error> {
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<bool, Error> {
// 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<AppContext<Init>> for DeleteWalletConfigCommand {
type Output = StatusResult;

fn execute(&self, ctx: &mut AppContext<Init>) -> Result<Self::Output, Error> {
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<Init>) -> 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()),
}
}
}
4 changes: 2 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Loading