From 96b750c20177ccfd4b93d5e72f7592052d809d58 Mon Sep 17 00:00:00 2001 From: asmo Date: Wed, 13 May 2026 09:05:59 +0200 Subject: [PATCH 1/3] feat: add config check --- crates/cdk-mintd/src/config.rs | 63 ++- crates/cdk-mintd/src/env_vars/mod.rs | 2 +- crates/cdk-mintd/src/lib.rs | 732 ++++++++++++++++++++++++++- crates/cdk-sql-common/src/info.toml | 43 ++ 4 files changed, 823 insertions(+), 17 deletions(-) create mode 100644 crates/cdk-sql-common/src/info.toml diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index a750c9dbd4..048be75c2c 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -47,6 +47,7 @@ pub struct LoggingConfig { } #[derive(Clone, Serialize, Deserialize)] +#[serde(default)] pub struct Info { pub url: String, pub listen_host: String, @@ -175,6 +176,7 @@ impl std::str::FromStr for LnBackend { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct Ln { pub ln_backend: LnBackend, pub invoice_description: Option, @@ -199,6 +201,7 @@ impl Default for Ln { #[cfg(feature = "lnbits")] #[derive(Clone, Serialize, Deserialize)] +#[serde(default)] pub struct LNbits { pub admin_api_key: String, pub invoice_api_key: String, @@ -237,6 +240,7 @@ impl Default for LNbits { #[cfg(feature = "cln")] #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct Cln { pub rpc_path: PathBuf, #[serde(default = "default_cln_bolt12")] @@ -269,6 +273,7 @@ fn default_cln_bolt12() -> bool { #[cfg(feature = "lnd")] #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct Lnd { pub address: String, pub cert_file: PathBuf, @@ -294,6 +299,7 @@ impl Default for Lnd { #[cfg(feature = "ldk-node")] #[derive(Clone, Serialize, Deserialize)] +#[serde(default)] pub struct LdkNode { /// Fee percentage (e.g., 0.02 for 2%) #[serde(default = "default_ldk_fee_percent")] @@ -437,6 +443,7 @@ fn default_keyset_version() -> String { #[cfg(feature = "fakewallet")] #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct FakeWallet { pub supported_units: Vec, pub fee_percent: f32, @@ -487,6 +494,7 @@ fn default_max_delay_time() -> u64 { } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(default)] pub struct GrpcProcessor { #[serde(default)] pub supported_units: Vec, @@ -538,17 +546,20 @@ impl std::str::FromStr for DatabaseEngine { } #[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] pub struct Database { pub engine: DatabaseEngine, pub postgres: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] pub struct AuthDatabase { pub postgres: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct PostgresAuthConfig { pub url: String, pub tls_mode: Option, @@ -568,6 +579,7 @@ impl Default for PostgresAuthConfig { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct PostgresConfig { pub url: String, pub tls_mode: Option, @@ -609,6 +621,7 @@ impl std::str::FromStr for AuthType { } #[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] pub struct Auth { #[serde(default)] pub auth_enabled: bool, @@ -644,6 +657,7 @@ fn default_blind() -> AuthType { /// CDK settings, derived from `config.toml` #[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] pub struct Settings { pub info: Info, pub mint_info: MintInfo, @@ -668,11 +682,13 @@ pub struct Settings { pub mint_management_rpc: Option, pub auth: Option, #[cfg(feature = "prometheus")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub prometheus: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[cfg(feature = "prometheus")] +#[serde(default)] pub struct Prometheus { pub enabled: bool, pub address: Option, @@ -708,6 +724,7 @@ fn default_max_outputs() -> usize { } #[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] pub struct MintInfo { /// name of the mint and should be recognizable pub name: String, @@ -731,6 +748,7 @@ pub struct MintInfo { #[cfg(feature = "management-rpc")] #[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] pub struct MintManagementRpc { /// When this is set to `true` the mint use the config file for the initial set up on first start. /// Changes to the `[mint_info]` after this **MUST** be made via the RPC changes to the config file or env vars will be ignored. @@ -741,21 +759,38 @@ pub struct MintManagementRpc { } impl Settings { + pub fn try_new

(config_file_name: Option

) -> Result + where + P: Into, + { + let default_settings = Self::default(); + Self::new_from_default(&default_settings, config_file_name) + } + + /// Loads settings from defaults and an optional config file. + /// + /// Use [`Self::try_new`] when the caller can return a recoverable config error. + /// + /// # Panics + /// + /// Panics when an explicitly provided config file cannot be read or deserialized. #[must_use] pub fn new

(config_file_name: Option

) -> Self where P: Into, { - let default_settings = Self::default(); - // attempt to construct settings with file - let from_file = Self::new_from_default(&default_settings, config_file_name); - match from_file { + let config_file_name = config_file_name.map(Into::into); + + match Self::try_new(config_file_name.clone()) { Ok(f) => f, - Err(e) => { + Err(e) if config_file_name.is_none() => { tracing::error!( - "Error reading config file, falling back to defaults. Error: {e:?}" + "Error reading default config file, falling back to defaults. Error: {e:?}" ); - default_settings + Self::default() + } + Err(e) => { + panic!("Error reading config file: {e}"); } } } @@ -865,6 +900,8 @@ mod tests { /// This test runs sequentially for all enabled backends to avoid env var interference. #[test] fn test_env_var_only_config_all_backends() { + let _env_lock = crate::test_utils::env_lock(); + // Run each backend test sequentially #[cfg(feature = "lnd")] test_lnd_env_config(); @@ -918,7 +955,7 @@ max_melt = 500000 env::set_var(crate::env_vars::ENV_LND_RESERVE_FEE_MIN, "4"); // Load settings and apply environment variables (same as production code) - let mut settings = Settings::new(Some(&config_path)); + let mut settings = Settings::try_new(Some(&config_path)).expect("Failed to load config"); settings.from_env().expect("Failed to apply env vars"); // Verify that settings were populated from env vars @@ -975,7 +1012,7 @@ max_melt = 500000 env::set_var(crate::env_vars::ENV_CLN_RESERVE_FEE_MIN, "4"); // Load settings and apply environment variables (same as production code) - let mut settings = Settings::new(Some(&config_path)); + let mut settings = Settings::try_new(Some(&config_path)).expect("Failed to load config"); settings.from_env().expect("Failed to apply env vars"); // Verify that settings were populated from env vars @@ -1033,7 +1070,7 @@ max_melt = 500000 env::set_var(crate::env_vars::ENV_LNBITS_RESERVE_FEE_MIN, "5"); // Load settings and apply environment variables (same as production code) - let mut settings = Settings::new(Some(&config_path)); + let mut settings = Settings::try_new(Some(&config_path)).expect("Failed to load config"); settings.from_env().expect("Failed to apply env vars"); // Verify that settings were populated from env vars @@ -1087,7 +1124,7 @@ max_melt = 500000 env::set_var(crate::env_vars::ENV_FAKE_WALLET_MAX_DELAY, "5"); // Load settings and apply environment variables (same as production code) - let mut settings = Settings::new(Some(&config_path)); + let mut settings = Settings::try_new(Some(&config_path)).expect("Failed to load config"); settings.from_env().expect("Failed to apply env vars"); // Verify that settings were populated from env vars @@ -1141,7 +1178,7 @@ max_melt = 500000 env::set_var(crate::env_vars::ENV_GRPC_PROCESSOR_PORT, "50051"); // Load settings and apply environment variables (same as production code) - let mut settings = Settings::new(Some(&config_path)); + let mut settings = Settings::try_new(Some(&config_path)).expect("Failed to load config"); settings.from_env().expect("Failed to apply env vars"); // Verify that settings were populated from env vars @@ -1199,7 +1236,7 @@ max_melt = 500000 ); // Load settings and apply environment variables (same as production code) - let mut settings = Settings::new(Some(&config_path)); + let mut settings = Settings::try_new(Some(&config_path)).expect("Failed to load config"); settings.from_env().expect("Failed to apply env vars"); // Verify that settings were populated from env vars diff --git a/crates/cdk-mintd/src/env_vars/mod.rs b/crates/cdk-mintd/src/env_vars/mod.rs index 97b5c780b4..2bf8d3715a 100644 --- a/crates/cdk-mintd/src/env_vars/mod.rs +++ b/crates/cdk-mintd/src/env_vars/mod.rs @@ -146,7 +146,7 @@ impl Settings { self.grpc_processor = Some(self.grpc_processor.clone().unwrap_or_default().from_env()); } - LnBackend::None => bail!("Ln backend must be set"), + LnBackend::None => {} #[allow(unreachable_patterns)] _ => bail!("Selected Ln backend is not enabled in this build"), } diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 70eb3e99a4..9a82da5adb 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -69,6 +69,29 @@ pub mod config; pub mod env_vars; pub mod setup; +#[cfg(test)] +pub(crate) mod test_utils { + use std::path::PathBuf; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Mutex, MutexGuard, OnceLock}; + + pub(crate) fn env_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .expect("environment test lock should not be poisoned") + } + + pub(crate) fn unique_temp_path(name: &str) -> PathBuf { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + std::env::temp_dir().join(format!( + "{name}_{}_{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )) + } +} + const CARGO_PKG_VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); const DEFAULT_BATCH_MINT_SIZE: u64 = 100; const REQUEST_BODY_LIMIT_BYTES: usize = 1_048_576; @@ -261,7 +284,12 @@ pub fn load_settings(work_dir: &Path, config_path: Option) -> Result) -> Result Result<()> { + validate_listen_config(settings)?; + validate_signing_config(settings)?; + validate_lightning_config(settings)?; + validate_database_config(settings)?; + validate_auth_config(settings)?; + validate_management_rpc_config(settings)?; + validate_prometheus_config(settings)?; + + Ok(()) +} + +fn validate_database_config(settings: &config::Settings) -> Result<()> { + if settings.database.engine == DatabaseEngine::Postgres { + let pg_config = settings.database.postgres.as_ref().ok_or_else(|| { + anyhow!("PostgreSQL configuration is required when using PostgreSQL engine") + })?; + + if pg_config.url.is_empty() { + bail!("PostgreSQL URL is required. Set it in config file [database.postgres] section or via CDK_MINTD_POSTGRES_URL/CDK_MINTD_DATABASE_URL environment variable"); + } + } + + Ok(()) +} + +fn validate_listen_config(settings: &config::Settings) -> Result<()> { + format!( + "{}:{}", + settings.info.listen_host, settings.info.listen_port + ) + .parse::() + .map_err(|err| { + anyhow!( + "Invalid mint listen address [info].listen_host/[info].listen_port ({}:{}): {err}", + settings.info.listen_host, + settings.info.listen_port + ) + })?; + + Ok(()) +} + +fn validate_signing_config(settings: &config::Settings) -> Result<()> { + let has_signatory = settings + .info + .signatory_url + .as_ref() + .is_some_and(|value| !value.is_empty()); + let has_seed = settings + .info + .seed + .as_ref() + .is_some_and(|value| !value.is_empty()); + let mnemonic = settings + .info + .mnemonic + .as_ref() + .filter(|value| !value.is_empty()); + + if has_signatory || has_seed { + return Ok(()); + } + + if let Some(mnemonic) = mnemonic { + Mnemonic::from_str(mnemonic).map_err(|err| { + anyhow!("Invalid mnemonic in [info].mnemonic/CDK_MINTD_MNEMONIC: {err}") + })?; + return Ok(()); + } + + bail!("No signing source configured. Set one of [info].mnemonic/CDK_MINTD_MNEMONIC, [info].seed/CDK_MINTD_SEED, or [info].signatory_url/CDK_MINTD_SIGNATORY_URL"); +} + +fn validate_lightning_config(settings: &config::Settings) -> Result<()> { + if settings.ln.min_mint > settings.ln.max_mint { + bail!("Lightning min_mint cannot be greater than max_mint"); + } + if settings.ln.min_melt > settings.ln.max_melt { + bail!("Lightning min_melt cannot be greater than max_melt"); + } + + match settings.ln.ln_backend { + LnBackend::None => { + bail!("Ln backend must be set via [ln].ln_backend or CDK_MINTD_LN_BACKEND"); + } + #[cfg(feature = "cln")] + LnBackend::Cln => { + let cln = settings.cln.as_ref().ok_or_else(|| { + anyhow!("CLN configuration is required when [ln].ln_backend is cln") + })?; + if cln.rpc_path.as_os_str().is_empty() { + bail!("CLN rpc_path must be set via [cln].rpc_path or CDK_MINTD_CLN_RPC_PATH"); + } + } + #[cfg(feature = "lnbits")] + LnBackend::LNbits => { + let lnbits = settings.lnbits.as_ref().ok_or_else(|| { + anyhow!("LNbits configuration is required when [ln].ln_backend is lnbits") + })?; + if lnbits.admin_api_key.is_empty() { + bail!("LNbits admin_api_key must be set via [lnbits].admin_api_key or CDK_MINTD_LNBITS_ADMIN_API_KEY"); + } + if lnbits.invoice_api_key.is_empty() { + bail!("LNbits invoice_api_key must be set via [lnbits].invoice_api_key or CDK_MINTD_LNBITS_INVOICE_API_KEY"); + } + if lnbits.lnbits_api.is_empty() { + bail!( + "LNbits lnbits_api must be set via [lnbits].lnbits_api or CDK_MINTD_LNBITS_API" + ); + } + } + #[cfg(feature = "lnd")] + LnBackend::Lnd => { + let lnd = settings.lnd.as_ref().ok_or_else(|| { + anyhow!("LND configuration is required when [ln].ln_backend is lnd") + })?; + if lnd.address.is_empty() { + bail!("LND address must be set via [lnd].address or CDK_MINTD_LND_ADDRESS"); + } + if lnd.cert_file.as_os_str().is_empty() { + bail!("LND cert_file must be set via [lnd].cert_file or CDK_MINTD_LND_CERT_FILE"); + } + if lnd.macaroon_file.as_os_str().is_empty() { + bail!("LND macaroon_file must be set via [lnd].macaroon_file or CDK_MINTD_LND_MACAROON_FILE"); + } + } + #[cfg(feature = "fakewallet")] + LnBackend::FakeWallet => { + let fake_wallet = settings.fake_wallet.as_ref().ok_or_else(|| { + anyhow!("Fake wallet configuration is required when [ln].ln_backend is fakewallet") + })?; + if fake_wallet.supported_units.is_empty() { + bail!("Fake wallet supported_units must contain at least one unit via [fake_wallet].supported_units or CDK_MINTD_FAKE_WALLET_SUPPORTED_UNITS"); + } + if fake_wallet.min_delay_time > fake_wallet.max_delay_time { + bail!("Fake wallet min_delay_time cannot be greater than max_delay_time"); + } + } + #[cfg(feature = "grpc-processor")] + LnBackend::GrpcProcessor => { + let grpc_processor = settings.grpc_processor.as_ref().ok_or_else(|| { + anyhow!( + "gRPC payment processor configuration is required when [ln].ln_backend is grpcprocessor" + ) + })?; + if grpc_processor.supported_units.is_empty() { + bail!("gRPC payment processor supported_units must contain at least one unit via [grpc_processor].supported_units or CDK_MINTD_GRPC_PAYMENT_PROCESSOR_SUPPORTED_UNITS"); + } + if grpc_processor.addr.is_empty() { + bail!("gRPC payment processor addr must be set via [grpc_processor].addr or CDK_MINTD_GRPC_PAYMENT_PROCESSOR_ADDRESS"); + } + } + #[cfg(feature = "ldk-node")] + LnBackend::LdkNode => { + settings.ldk_node.as_ref().ok_or_else(|| { + anyhow!("LDK node configuration is required when [ln].ln_backend is ldk-node") + })?; + } + } + + Ok(()) +} + +fn validate_auth_config(settings: &config::Settings) -> Result<()> { + let Some(auth) = settings.auth.as_ref() else { + return Ok(()); + }; + + if auth.openid_discovery.is_empty() { + bail!("Auth openid_discovery must be set via [auth].openid_discovery or CDK_MINTD_AUTH_OPENID_DISCOVERY"); + } + if auth.openid_client_id.is_empty() { + bail!("Auth openid_client_id must be set via [auth].openid_client_id or CDK_MINTD_AUTH_OPENID_CLIENT_ID"); + } + + if settings.database.engine == DatabaseEngine::Postgres { + let auth_db_config = settings.auth_database.as_ref().ok_or_else(|| { + anyhow!("Auth database configuration is required when using PostgreSQL with authentication. Set [auth_database] section or CDK_MINTD_AUTH_POSTGRES_URL") + })?; + let auth_pg_config = auth_db_config.postgres.as_ref().ok_or_else(|| { + anyhow!("PostgreSQL auth database configuration is required when using PostgreSQL with authentication. Set [auth_database.postgres] section or CDK_MINTD_AUTH_POSTGRES_URL") + })?; + if auth_pg_config.url.is_empty() { + bail!("Auth database PostgreSQL URL is required. Set [auth_database.postgres].url or CDK_MINTD_AUTH_POSTGRES_URL"); + } + } + + Ok(()) +} + +fn validate_management_rpc_config(settings: &config::Settings) -> Result<()> { + #[cfg(not(feature = "management-rpc"))] + let _ = settings; + + #[cfg(feature = "management-rpc")] + if let Some(rpc_settings) = settings.mint_management_rpc.as_ref() { + if rpc_settings.enabled { + let address = rpc_settings.address.as_deref().unwrap_or("127.0.0.1"); + let port = rpc_settings.port.unwrap_or(8086); + format!("{address}:{port}") + .parse::() + .map_err(|err| { + anyhow!( + "Invalid mint management RPC address [mint_management_rpc].address/[mint_management_rpc].port ({address}:{port}): {err}" + ) + })?; + } + } + + Ok(()) +} + +fn validate_prometheus_config(settings: &config::Settings) -> Result<()> { + #[cfg(not(feature = "prometheus"))] + let _ = settings; + + #[cfg(feature = "prometheus")] + if let Some(prometheus_settings) = settings.prometheus.as_ref() { + if prometheus_settings.enabled { + let address = prometheus_settings + .address + .as_deref() + .unwrap_or("127.0.0.1"); + let port = prometheus_settings.port.unwrap_or(9000); + format!("{address}:{port}") + .parse::() + .map_err(|err| { + anyhow!( + "Invalid Prometheus address [prometheus].address/[prometheus].port ({address}:{port}): {err}" + ) + })?; + } + } + + Ok(()) } async fn setup_database( @@ -1252,7 +1521,7 @@ async fn start_services_with_shutdown( let address = format!("{}:{}", addr, port) .parse() - .expect("Invalid prometheus address"); + .map_err(|err| anyhow!("Invalid prometheus address {addr}:{port}: {err}"))?; let server = cdk_prometheus::PrometheusBuilder::new() .bind_address(address) @@ -1461,6 +1730,463 @@ mod tests { use super::*; + const TEST_MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + fn clear_mintd_env() { + for var in [ + "CDK_MINTD_DATABASE", + "CDK_MINTD_DATABASE_URL", + "CDK_MINTD_POSTGRES_URL", + "CDK_MINTD_POSTGRES_TLS_MODE", + "CDK_MINTD_POSTGRES_MAX_CONNECTIONS", + "CDK_MINTD_POSTGRES_CONNECTION_TIMEOUT_SECONDS", + "CDK_MINTD_SEED", + "CDK_MINTD_MNEMONIC", + "CDK_MINTD_SIGNATORY_URL", + "CDK_MINTD_SIGNATORY_CERTS", + "CDK_MINTD_LISTEN_HOST", + "CDK_MINTD_LISTEN_PORT", + "CDK_MINTD_LN_BACKEND", + "CDK_MINTD_LN_MIN_MINT", + "CDK_MINTD_LN_MAX_MINT", + "CDK_MINTD_LN_MIN_MELT", + "CDK_MINTD_LN_MAX_MELT", + "CDK_MINTD_AUTH_ENABLED", + "CDK_MINTD_AUTH_OPENID_DISCOVERY", + "CDK_MINTD_AUTH_OPENID_CLIENT_ID", + "CDK_MINTD_AUTH_POSTGRES_URL", + "CDK_MINTD_AUTH_POSTGRES_TLS_MODE", + "CDK_MINTD_AUTH_POSTGRES_MAX_CONNECTIONS", + "CDK_MINTD_AUTH_POSTGRES_CONNECTION_TIMEOUT_SECONDS", + "CDK_MINTD_CLN_RPC_PATH", + "CDK_MINTD_LNBITS_ADMIN_API_KEY", + "CDK_MINTD_LNBITS_INVOICE_API_KEY", + "CDK_MINTD_LNBITS_API", + "CDK_MINTD_LND_ADDRESS", + "CDK_MINTD_LND_CERT_FILE", + "CDK_MINTD_LND_MACAROON_FILE", + "CDK_MINTD_FAKE_WALLET_SUPPORTED_UNITS", + "CDK_MINTD_FAKE_WALLET_FEE_PERCENT", + "CDK_MINTD_FAKE_WALLET_RESERVE_FEE_MIN", + "CDK_MINTD_FAKE_WALLET_MIN_DELAY", + "CDK_MINTD_FAKE_WALLET_MAX_DELAY", + "CDK_MINTD_GRPC_PAYMENT_PROCESSOR_SUPPORTED_UNITS", + "CDK_MINTD_GRPC_PAYMENT_PROCESSOR_ADDRESS", + "CDK_MINTD_GRPC_PAYMENT_PROCESSOR_PORT", + "CDK_MINTD_PROMETHEUS_ENABLED", + "CDK_MINTD_PROMETHEUS_ADDRESS", + "CDK_MINTD_PROMETHEUS_PORT", + "CDK_MINTD_MINT_MANAGEMENT_ENABLED", + "CDK_MINTD_MANAGEMENT_ADDRESS", + "CDK_MINTD_MANAGEMENT_PORT", + ] { + std::env::remove_var(var); + } + } + + fn load_settings_from_toml(name: &str, config_content: &str) -> Result { + use std::fs; + + let temp_dir = crate::test_utils::unique_temp_path(name); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).expect("Failed to create temp dir"); + let config_path = temp_dir.join("config.toml"); + fs::write(&config_path, config_content).expect("Failed to write config file"); + + let result = load_settings(&temp_dir, Some(config_path)); + + let _ = fs::remove_dir_all(&temp_dir); + + result + } + + fn assert_load_settings_error(config_content: &str, expected: &str) { + let _env_lock = crate::test_utils::env_lock(); + clear_mintd_env(); + let err = load_settings_from_toml("cdk_mintd_invalid_config", config_content) + .expect_err("Settings should fail validation"); + assert!( + err.to_string().contains(expected), + "expected error containing `{expected}`, got `{err}`" + ); + } + + #[cfg(all(feature = "prometheus", feature = "fakewallet"))] + #[test] + fn test_load_settings_merges_partial_postgres_toml_with_env() { + use std::{env, fs}; + + let _env_lock = crate::test_utils::env_lock(); + clear_mintd_env(); + env::remove_var(crate::env_vars::DATABASE_URL_ENV_VAR); + env::remove_var(crate::env_vars::ENV_POSTGRES_URL); + env::remove_var(crate::env_vars::ENV_PROMETHEUS_ENABLED); + env::remove_var(crate::env_vars::ENV_PROMETHEUS_ADDRESS); + env::remove_var(crate::env_vars::ENV_PROMETHEUS_PORT); + + let postgres_url = "postgresql://user:password@localhost:5432/cdk_mint"; + env::set_var(crate::env_vars::ENV_POSTGRES_URL, postgres_url); + + let temp_dir = crate::test_utils::unique_temp_path("cdk_mintd_partial_config"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).expect("Failed to create temp dir"); + let config_path = temp_dir.join("config.toml"); + + let config_content = r#" +[info] +mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + +[database] +engine = "postgres" + +[database.postgres] +tls_mode = "require" +max_connections = 30 +connection_timeout_seconds = 15 + +[ln] +ln_backend = "fakewallet" + +[prometheus] +enabled = true +address = "0.0.0.0" +port = 9090 +"#; + fs::write(&config_path, config_content).expect("Failed to write config file"); + + let settings = + load_settings(&temp_dir, Some(config_path)).expect("Failed to load settings"); + + let postgres = settings + .database + .postgres + .as_ref() + .expect("Postgres config should be present"); + assert_eq!(postgres.url, postgres_url); + assert_eq!(postgres.tls_mode.as_deref(), Some("require")); + + let prometheus = settings + .prometheus + .as_ref() + .expect("Prometheus config should be loaded from TOML"); + assert!(prometheus.enabled); + assert_eq!(prometheus.address.as_deref(), Some("0.0.0.0")); + assert_eq!(prometheus.port, Some(9090)); + + env::remove_var(crate::env_vars::ENV_POSTGRES_URL); + let _ = fs::remove_dir_all(&temp_dir); + } + + #[cfg(feature = "fakewallet")] + #[test] + fn test_load_settings_reports_missing_postgres_url_after_merge() { + use std::{env, fs}; + + let _env_lock = crate::test_utils::env_lock(); + clear_mintd_env(); + env::remove_var(crate::env_vars::DATABASE_URL_ENV_VAR); + env::remove_var(crate::env_vars::ENV_POSTGRES_URL); + + let temp_dir = crate::test_utils::unique_temp_path("cdk_mintd_invalid_config"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).expect("Failed to create temp dir"); + let config_path = temp_dir.join("config.toml"); + + let config_content = r#" +[info] +mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + +[database] +engine = "postgres" + +[database.postgres] +tls_mode = "require" + +[ln] +ln_backend = "fakewallet" +"#; + fs::write(&config_path, config_content).expect("Failed to write config file"); + + let err = load_settings(&temp_dir, Some(config_path)) + .expect_err("Settings should fail validation without a Postgres URL"); + assert!(err.to_string().contains("PostgreSQL URL is required")); + + let _ = fs::remove_dir_all(&temp_dir); + } + + #[cfg(feature = "fakewallet")] + #[test] + fn test_load_settings_reports_missing_signing_source() { + assert_load_settings_error( + r#" +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" +"#, + "No signing source configured", + ); + } + + #[test] + fn test_load_settings_reports_missing_ln_backend() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" +"# + ), + "Ln backend must be set via [ln].ln_backend", + ); + } + + #[cfg(feature = "cln")] + #[test] + fn test_load_settings_reports_missing_cln_rpc_path() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "cln" +"# + ), + "CLN rpc_path must be set", + ); + } + + #[cfg(feature = "lnbits")] + #[test] + fn test_load_settings_reports_missing_lnbits_credentials() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "lnbits" +"# + ), + "LNbits admin_api_key must be set", + ); + } + + #[cfg(feature = "lnd")] + #[test] + fn test_load_settings_reports_missing_lnd_address() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "lnd" +"# + ), + "LND address must be set", + ); + } + + #[cfg(feature = "grpc-processor")] + #[test] + fn test_load_settings_reports_missing_grpc_supported_units() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "grpcprocessor" + +[grpc_processor] +addr = "http://127.0.0.1" +"# + ), + "gRPC payment processor supported_units must contain at least one unit", + ); + } + + #[cfg(feature = "fakewallet")] + #[test] + fn test_load_settings_reports_invalid_fakewallet_delay_range() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" + +[fake_wallet] +min_delay_time = 10 +max_delay_time = 1 +"# + ), + "Fake wallet min_delay_time cannot be greater than max_delay_time", + ); + } + + #[cfg(feature = "fakewallet")] + #[test] + fn test_load_settings_reports_missing_auth_openid_config() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" + +[auth] +auth_enabled = true +"# + ), + "Auth openid_discovery must be set", + ); + } + + #[test] + fn test_load_settings_reports_toml_parse_errors() { + assert_load_settings_error( + r#" +[info +mnemonic = "not valid toml" +"#, + "Error reading config file", + ); + } + + #[cfg(feature = "fakewallet")] + #[test] + fn test_load_settings_reports_invalid_ln_limit_range() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" +min_mint = 10 +max_mint = 1 +"# + ), + "Lightning min_mint cannot be greater than max_mint", + ); + } + + #[cfg(all(feature = "prometheus", feature = "fakewallet"))] + #[test] + fn test_load_settings_reports_invalid_prometheus_address() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" + +[prometheus] +enabled = true +address = "localhost" +port = 9090 +"# + ), + "Invalid Prometheus address", + ); + } + + #[cfg(all(feature = "management-rpc", feature = "fakewallet"))] + #[test] + fn test_load_settings_reports_invalid_management_rpc_address() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" + +[mint_management_rpc] +enabled = true +address = "localhost" +port = 8086 +"# + ), + "Invalid mint management RPC address", + ); + } + + #[cfg(feature = "fakewallet")] + #[test] + fn test_load_settings_reports_missing_auth_postgres_url() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "postgres" + +[database.postgres] +url = "postgresql://user:password@localhost:5432/cdk_mint" + +[ln] +ln_backend = "fakewallet" + +[auth] +auth_enabled = true +openid_discovery = "https://issuer.example.com/.well-known/openid-configuration" +openid_client_id = "mintd" +"# + ), + "Auth database PostgreSQL URL is required", + ); + } + #[test] fn test_postgres_auth_url_validation() { // Test that the auth database config requires explicit configuration diff --git a/crates/cdk-sql-common/src/info.toml b/crates/cdk-sql-common/src/info.toml new file mode 100644 index 0000000000..0775e45a45 --- /dev/null +++ b/crates/cdk-sql-common/src/info.toml @@ -0,0 +1,43 @@ +[info] +url = "https://mint.clawi.ai" +listen_host = "0.0.0.0" +listen_port = 8085 + +[info.logging] +output = "stderr" +console_level = "info" + +[mint_info] +name = "ucash mint" +description = "Cashu mint backed by cdk-spark payment processor" + +[database] +engine = "postgres" + +[database.postgres] +tls_mode = "require" +max_connections = 30 +connection_timeout_seconds = 15 + +[ln] +ln_backend = "grpcprocessor" +min_mint = 100 +max_mint = 1000000 +min_melt = 100 +max_melt = 1000000 + +[grpc_processor] +addr = "http://127.0.0.1" +port = 50051 +supported_units = ["sat"] + +[mint_management_rpc] +enabled = true +address = "127.0.0.1" +port = 8086 +tls_dir_path = "/secrets/management-rpc-tls" + +[prometheus] +enabled = true +address = "0.0.0.0" +port = 9090 From a866717a73d32b31f3a89683182dd9d8de06af32 Mon Sep 17 00:00:00 2001 From: asmo Date: Wed, 13 May 2026 12:13:12 +0200 Subject: [PATCH 2/3] - remove stuff. - check seed - no silent fallback to default config --- crates/cdk-mintd/example.config.toml | 2 +- crates/cdk-mintd/src/config.rs | 23 +++++---------- crates/cdk-mintd/src/lib.rs | 36 +++++++++++++++++++++-- crates/cdk-sql-common/src/info.toml | 43 ---------------------------- 4 files changed, 41 insertions(+), 63 deletions(-) delete mode 100644 crates/cdk-sql-common/src/info.toml diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index aa13d27bdb..7bba2f14a3 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -36,7 +36,7 @@ enabled = false #[prometheus] #enabled = true #address = "127.0.0.1" -#port = 9090 +#port = 9000 # [info.http_cache] # memory or redis diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index 048be75c2c..51171d7a24 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -769,30 +769,21 @@ impl Settings { /// Loads settings from defaults and an optional config file. /// - /// Use [`Self::try_new`] when the caller can return a recoverable config error. + /// Prefer [`Self::try_new`] in any code path that can surface a recoverable + /// config error; this constructor exists for callers that want a hard fail. /// /// # Panics /// - /// Panics when an explicitly provided config file cannot be read or deserialized. + /// Panics if the config file cannot be read or deserialized. Unlike earlier + /// versions, this never silently falls back to defaults — silent fallback + /// hid real misconfiguration. #[must_use] pub fn new

(config_file_name: Option

) -> Self where P: Into, { - let config_file_name = config_file_name.map(Into::into); - - match Self::try_new(config_file_name.clone()) { - Ok(f) => f, - Err(e) if config_file_name.is_none() => { - tracing::error!( - "Error reading default config file, falling back to defaults. Error: {e:?}" - ); - Self::default() - } - Err(e) => { - panic!("Error reading config file: {e}"); - } - } + Self::try_new(config_file_name) + .unwrap_or_else(|e| panic!("Error reading config file: {e}")) } fn new_from_default

( diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 9a82da5adb..9a9df1c81a 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -347,23 +347,35 @@ fn validate_listen_config(settings: &config::Settings) -> Result<()> { } fn validate_signing_config(settings: &config::Settings) -> Result<()> { + const MIN_SEED_BYTES: usize = 32; + let has_signatory = settings .info .signatory_url .as_ref() .is_some_and(|value| !value.is_empty()); - let has_seed = settings + let seed = settings .info .seed .as_ref() - .is_some_and(|value| !value.is_empty()); + .filter(|value| !value.is_empty()); let mnemonic = settings .info .mnemonic .as_ref() .filter(|value| !value.is_empty()); - if has_signatory || has_seed { + if has_signatory { + return Ok(()); + } + + if let Some(seed) = seed { + if seed.len() < MIN_SEED_BYTES { + bail!( + "Seed in [info].seed/CDK_MINTD_SEED is too short ({} bytes); require at least {MIN_SEED_BYTES} bytes of entropy", + seed.len() + ); + } return Ok(()); } @@ -1915,6 +1927,24 @@ ln_backend = "fakewallet" let _ = fs::remove_dir_all(&temp_dir); } + #[cfg(feature = "fakewallet")] + #[test] + fn test_load_settings_reports_short_seed() { + assert_load_settings_error( + r#" +[info] +seed = "tooshort" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" +"#, + "Seed in [info].seed/CDK_MINTD_SEED is too short", + ); + } + #[cfg(feature = "fakewallet")] #[test] fn test_load_settings_reports_missing_signing_source() { diff --git a/crates/cdk-sql-common/src/info.toml b/crates/cdk-sql-common/src/info.toml deleted file mode 100644 index 0775e45a45..0000000000 --- a/crates/cdk-sql-common/src/info.toml +++ /dev/null @@ -1,43 +0,0 @@ -[info] -url = "https://mint.clawi.ai" -listen_host = "0.0.0.0" -listen_port = 8085 - -[info.logging] -output = "stderr" -console_level = "info" - -[mint_info] -name = "ucash mint" -description = "Cashu mint backed by cdk-spark payment processor" - -[database] -engine = "postgres" - -[database.postgres] -tls_mode = "require" -max_connections = 30 -connection_timeout_seconds = 15 - -[ln] -ln_backend = "grpcprocessor" -min_mint = 100 -max_mint = 1000000 -min_melt = 100 -max_melt = 1000000 - -[grpc_processor] -addr = "http://127.0.0.1" -port = 50051 -supported_units = ["sat"] - -[mint_management_rpc] -enabled = true -address = "127.0.0.1" -port = 8086 -tls_dir_path = "/secrets/management-rpc-tls" - -[prometheus] -enabled = true -address = "0.0.0.0" -port = 9090 From d3a41858222696d9ccf5d440b25313933838032e Mon Sep 17 00:00:00 2001 From: asmo Date: Wed, 13 May 2026 12:40:01 +0200 Subject: [PATCH 3/3] fmt: config.rs --- crates/cdk-mintd/src/config.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index 51171d7a24..ec4b482d4d 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -782,8 +782,7 @@ impl Settings { where P: Into, { - Self::try_new(config_file_name) - .unwrap_or_else(|e| panic!("Error reading config file: {e}")) + Self::try_new(config_file_name).unwrap_or_else(|e| panic!("Error reading config file: {e}")) } fn new_from_default

(