bdk-cli 3.0.0

An experimental CLI wallet application and playground, powered by BDK
#[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;
#[cfg(any(feature = "sqlite", feature = "redb"))]
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::str::FromStr;

#[derive(Debug, Serialize, Deserialize)]
pub struct WalletConfig {
    pub wallets: HashMap<String, WalletConfigInner>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct WalletConfigInner {
    pub wallet: String,
    pub network: String,
    pub ext_descriptor: String,
    pub int_descriptor: Option<String>,
    #[cfg(any(feature = "sqlite", feature = "redb"))]
    pub database_type: String,
    #[cfg(any(
        feature = "electrum",
        feature = "esplora",
        feature = "rpc",
        feature = "cbf"
    ))]
    pub client_type: Option<String>,
    #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))]
    pub server_url: Option<String>,
    #[cfg(feature = "rpc")]
    pub rpc_user: Option<String>,
    #[cfg(feature = "rpc")]
    pub rpc_password: Option<String>,
    #[cfg(feature = "electrum")]
    pub batch_size: Option<usize>,
    #[cfg(feature = "esplora")]
    pub parallel_requests: Option<usize>,
    #[cfg(feature = "rpc")]
    pub cookie: Option<String>,
}

impl WalletConfig {
    /// Load configuration from a TOML file in the wallet's data directory
    pub fn load(datadir: &Path) -> Result<Option<WalletConfig>, 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<WalletOpts, Error> {
        self.wallets
            .get(wallet_name)
            .ok_or_else(|| Error::Generic(format!("Wallet {wallet_name} not found in config")))?
            .try_into()
    }
}

impl TryFrom<&WalletConfigInner> for WalletOpts {
    type Error = Error;

    fn try_from(config: &WalletConfigInner) -> Result<Self, Self::Error> {
        let _network = Network::from_str(&config.network)
            .map_err(|_| Error::Generic("Invalid network".to_string()))?;

        #[cfg(any(feature = "sqlite", feature = "redb"))]
        let database_type = DatabaseType::from_str(&config.database_type, true)
            .map_err(|_| Error::Generic("Invalid database type".to_string()))?;

        #[cfg(any(
            feature = "electrum",
            feature = "esplora",
            feature = "rpc",
            feature = "cbf"
        ))]
        let client_type = config
            .client_type
            .as_deref()
            .ok_or_else(|| Error::Generic("Client type missing".into()))
            .and_then(|s| {
                ClientType::from_str(s, true)
                    .map_err(|_| Error::Generic("Invalid client type".into()))
            })?;

        Ok(WalletOpts {
            wallet: Some(config.wallet.clone()),
            verbose: false,
            ext_descriptor: config.ext_descriptor.clone(),
            int_descriptor: 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: config
                .server_url
                .clone()
                .ok_or_else(|| Error::Generic("Server url not found".into()))?,

            #[cfg(feature = "electrum")]
            batch_size: config.batch_size.unwrap_or(10),

            #[cfg(feature = "esplora")]
            parallel_requests: config.parallel_requests.unwrap_or(5),

            #[cfg(feature = "rpc")]
            basic_auth: (
                config.rpc_user.clone().unwrap_or_else(|| "user".into()),
                config
                    .rpc_password
                    .clone()
                    .unwrap_or_else(|| "password".into()),
            ),

            #[cfg(feature = "rpc")]
            cookie: config.cookie.clone(),

            #[cfg(feature = "cbf")]
            compactfilter_opts: crate::commands::CompactFilterOpts { conn_count: 2 },
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::convert::TryInto;
    const EXT_DESCRIPTOR: &str = "wpkh([07234a14/84'/1'/0']tpubDCSgT6PaVLQH9h2TAxKryhvkEurUBcYRJc9dhTcMDyahhWiMWfEWvQQX89yaw7w7XU8bcVujoALfxq59VkFATri3Cxm5mkp9kfHfRFDckEh/0/*)#429nsxmg";
    const INT_DESCRIPTOR: &str = "wpkh([07234a14/84'/1'/0']tpubDCSgT6PaVLQH9h2TAxKryhvkEurUBcYRJc9dhTcMDyahhWiMWfEWvQQX89yaw7w7XU8bcVujoALfxq59VkFATri3Cxm5mkp9kfHfRFDckEh/1/*)#y7qjdnts";

    #[test]
    fn test_wallet_config_inner_to_opts_conversion() {
        #[cfg(any(
            feature = "electrum",
            feature = "esplora",
            feature = "rpc",
            feature = "cbf"
        ))]
        let client_type = {
            if cfg!(feature = "esplora") {
                Some("esplora".to_string())
            } else if cfg!(feature = "rpc") {
                Some("rpc".to_string())
            } else if cfg!(feature = "electrum") {
                Some("electrum".to_string())
            } else if cfg!(feature = "cbf") {
                Some("cbf".to_string())
            } else {
                None
            }
        };

        let wallet_config = WalletConfigInner {
            wallet: "test_wallet".to_string(),
            network: "testnet4".to_string(),
            ext_descriptor: EXT_DESCRIPTOR.to_string(),
            int_descriptor: Some(INT_DESCRIPTOR.to_string()),
            #[cfg(any(feature = "sqlite", feature = "redb"))]
            database_type: "sqlite".to_string(),

            #[cfg(any(
                feature = "electrum",
                feature = "esplora",
                feature = "rpc",
                feature = "cbf"
            ))]
            client_type,

            #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))]
            server_url: Some("https://example.com/testnet/api".to_string()),
            #[cfg(feature = "electrum")]
            batch_size: None,
            #[cfg(feature = "esplora")]
            parallel_requests: None,
            #[cfg(feature = "rpc")]
            rpc_user: None,
            #[cfg(feature = "rpc")]
            rpc_password: None,
            #[cfg(feature = "rpc")]
            cookie: None,
        };

        let opts: WalletOpts = (&wallet_config)
            .try_into()
            .expect("Conversion should succeed");

        assert_eq!(opts.wallet, Some("test_wallet".to_string()));

        #[cfg(all(
            feature = "esplora",
            not(any(feature = "electrum", feature = "rpc", feature = "cbf"))
        ))]
        assert_eq!(opts.client_type, ClientType::Esplora);

        #[cfg(all(
            feature = "rpc",
            not(any(feature = "esplora", feature = "electrum", feature = "cbf"))
        ))]
        assert_eq!(opts.client_type, ClientType::Rpc);

        #[cfg(all(feature = "electrum", not(any(feature = "esplora", feature = "rpc"))))]
        assert_eq!(opts.client_type, ClientType::Electrum);

        #[cfg(all(
            feature = "cbf",
            not(any(feature = "esplora", feature = "rpc", feature = "electrum"))
        ))]
        assert_eq!(opts.client_type, ClientType::Cbf);

        #[cfg(feature = "sqlite")]
        assert_eq!(opts.database_type, DatabaseType::Sqlite);

        assert_eq!(opts.ext_descriptor, EXT_DESCRIPTOR);

        #[cfg(feature = "electrum")]
        assert_eq!(opts.batch_size, 10);

        #[cfg(feature = "esplora")]
        assert_eq!(opts.parallel_requests, 5);
    }

    #[cfg(any(
        feature = "electrum",
        feature = "esplora",
        feature = "rpc",
        feature = "cbf"
    ))]
    #[test]
    fn test_invalid_client_type_fails() {
        let inner = WalletConfigInner {
            wallet: "test".to_string(),
            network: "regtest".to_string(),
            ext_descriptor: "desc".to_string(),
            int_descriptor: None,
            #[cfg(any(feature = "sqlite", feature = "redb"))]
            database_type: "sqlite".to_string(),
            #[cfg(any(
                feature = "electrum",
                feature = "esplora",
                feature = "rpc",
                feature = "cbf"
            ))]
            client_type: Some("invalid_backend".to_string()),
            #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))]
            server_url: Some("url".to_string()),
            #[cfg(feature = "electrum")]
            batch_size: None,
            #[cfg(feature = "esplora")]
            parallel_requests: None,
            #[cfg(feature = "rpc")]
            rpc_user: None,
            #[cfg(feature = "rpc")]
            rpc_password: None,
            #[cfg(feature = "rpc")]
            cookie: None,
        };

        let result: Result<WalletOpts, Error> = (&inner).try_into();
        assert!(result.is_err());
    }
}