From c5c2f71b9746e3302e2afee689316131071b0e22 Mon Sep 17 00:00:00 2001 From: asmo Date: Wed, 13 May 2026 09:05:59 +0200 Subject: [PATCH 1/5] feat: add config check --- crates/cdk-mintd/src/config.rs | 71 ++- crates/cdk-mintd/src/lib.rs | 727 +++++++++++++++++++++++++++- crates/cdk-sql-common/src/info.toml | 43 ++ 3 files changed, 817 insertions(+), 24 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 310fc52570..76efc4a7f1 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, @@ -167,6 +168,7 @@ impl std::str::FromStr for LnBackend { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct Ln { pub ln_backend: LnBackend, #[serde(default)] @@ -506,6 +508,7 @@ fn default_bdk_quote_safety_multiplier() -> f64 { #[cfg(feature = "lnbits")] #[derive(Clone, Serialize, Deserialize)] +#[serde(default)] pub struct LNbits { pub admin_api_key: String, pub invoice_api_key: String, @@ -544,6 +547,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")] @@ -576,6 +580,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, @@ -601,6 +606,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")] @@ -778,6 +784,7 @@ impl FakeWalletCustomPaymentMethod { #[cfg(feature = "fakewallet")] #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct FakeWallet { #[serde(default = "default_fake_wallet_supported_units")] pub supported_units: Vec, @@ -842,6 +849,7 @@ fn default_fake_wallet_supported_units() -> Vec { } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(default)] pub struct GrpcProcessor { #[serde(default)] pub supported_units: Vec, @@ -893,17 +901,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, @@ -923,6 +934,7 @@ impl Default for PostgresAuthConfig { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct PostgresConfig { pub url: String, pub tls_mode: Option, @@ -964,6 +976,7 @@ impl std::str::FromStr for AuthType { } #[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] pub struct Auth { #[serde(default)] pub auth_enabled: bool, @@ -999,6 +1012,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, @@ -1033,6 +1047,7 @@ pub struct Settings { #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[cfg(feature = "prometheus")] +#[serde(default)] pub struct Prometheus { pub enabled: bool, pub address: Option, @@ -1068,6 +1083,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, @@ -1091,6 +1107,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. @@ -1101,7 +1118,6 @@ pub struct MintManagementRpc { } impl Settings { - /// Validate payment backend combinations after config and env overrides are applied. pub fn validate_backend_pairing(&self) -> Result<(), String> { #[cfg(feature = "fakewallet")] self.validate_fake_wallet_backend_pairing()?; @@ -1126,7 +1142,6 @@ impl Settings { .iter() .any(|ln| !matches!(ln.ln_backend, LnBackend::None | LnBackend::FakeWallet)); - // A fake Lightning backend cannot be combined with a real one. if has_fake_wallet_ln_backend && has_real_ln_backend { return Err( "ln_backend = \"fakewallet\" cannot be combined with a real \ @@ -1155,32 +1170,42 @@ impl Settings { Ok(()) } + 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}"); } } } - pub fn try_new

(config_file_name: Option

) -> Result - where - P: Into, - { - Self::new_from_default(&Self::default(), config_file_name) - } - fn new_from_default

( default: &Settings, config_file_name: Option

, @@ -1858,7 +1883,7 @@ max_delay_time = 3 /// This test runs sequentially for all enabled backends to avoid env var interference. #[test] fn test_env_var_only_config_all_backends() { - let _guard = config_env_lock(); + let _guard = config_env_lock(); // Run each backend test sequentially #[cfg(feature = "lnd")] @@ -1964,7 +1989,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 @@ -2021,7 +2046,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 @@ -2079,7 +2104,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 @@ -2137,7 +2162,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 @@ -2214,7 +2239,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 @@ -2272,7 +2297,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/lib.rs b/crates/cdk-mintd/src/lib.rs index 087542cede..67100c140f 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -61,6 +61,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; @@ -258,7 +281,252 @@ pub fn load_settings(work_dir: &Path, config_path: Option) -> 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.is_empty() { + bail!("Ln backend must be set via [[ln]] or CDK_MINTD_LN_* environment variables"); + } + + for ln in &settings.ln { + if ln.min_mint > ln.max_mint { + bail!("Lightning min_mint cannot be greater than max_mint"); + } + if ln.min_melt > ln.max_melt { + bail!("Lightning min_melt cannot be greater than max_melt"); + } + + match ln.ln_backend { + LnBackend::None => {} + #[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(()) } /// Loads settings from command line arguments, environment variables, and optional seed file. @@ -2442,4 +2710,461 @@ mod tests { assert_eq!(methods, vec!["bolt11", "bolt12", "paypal"]); } + + 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]]", + ); + } + + #[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", + ); + } } 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 205c46b6fd779c26b0e46e84963aa34c4f0a765d Mon Sep 17 00:00:00 2001 From: asmo Date: Wed, 13 May 2026 12:13:12 +0200 Subject: [PATCH 2/5] - 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 538534d029..4431ddf1f3 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -35,7 +35,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 76efc4a7f1..d8d7379ac2 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -1180,30 +1180,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 67100c140f..5bd32d1efb 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -331,23 +331,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(()); } @@ -2896,6 +2908,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 ce594ee5c6a9b97864f326d72650a77957d71894 Mon Sep 17 00:00:00 2001 From: asmo Date: Wed, 13 May 2026 12:40:01 +0200 Subject: [PATCH 3/5] 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 d8d7379ac2..778bb0c9a3 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -1193,8 +1193,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

( From 02961a91028abd40e1df111b39f08a5434b845ae Mon Sep 17 00:00:00 2001 From: asmo Date: Sun, 31 May 2026 22:08:38 +0200 Subject: [PATCH 4/5] add tests --- crates/cdk-mintd/src/lib.rs | 491 ++++++++++++++++++++++++++++++++++-- 1 file changed, 467 insertions(+), 24 deletions(-) diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 5bd32d1efb..072b29bb9e 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -71,7 +71,7 @@ pub(crate) mod test_utils { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) .lock() - .expect("environment test lock should not be poisoned") + .unwrap_or_else(|e| e.into_inner()) } pub(crate) fn unique_temp_path(name: &str) -> PathBuf { @@ -390,18 +390,28 @@ fn validate_lightning_config(settings: &config::Settings) -> Result<()> { LnBackend::None => {} #[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") - })?; + let default_cln; + let cln = match settings.cln.as_ref() { + Some(c) => c, + None => { + default_cln = config::Cln::default(); + &default_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") - })?; + let default_lnbits; + let lnbits = match settings.lnbits.as_ref() { + Some(l) => l, + None => { + default_lnbits = config::LNbits::default(); + &default_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"); } @@ -416,14 +426,21 @@ fn validate_lightning_config(settings: &config::Settings) -> Result<()> { } #[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") - })?; + let default_lnd; + let lnd = match settings.lnd.as_ref() { + Some(l) => l, + None => { + default_lnd = config::Lnd::default(); + &default_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"); + 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"); @@ -431,9 +448,14 @@ fn validate_lightning_config(settings: &config::Settings) -> Result<()> { } #[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") - })?; + let default_fake_wallet; + let fake_wallet = match settings.fake_wallet.as_ref() { + Some(f) => f, + None => { + default_fake_wallet = config::FakeWallet::default(); + &default_fake_wallet + } + }; 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"); } @@ -443,11 +465,14 @@ fn validate_lightning_config(settings: &config::Settings) -> Result<()> { } #[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" - ) - })?; + let default_grpc_processor; + let grpc_processor = match settings.grpc_processor.as_ref() { + Some(g) => g, + None => { + default_grpc_processor = config::GrpcProcessor::default(); + &default_grpc_processor + } + }; 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"); } @@ -457,9 +482,14 @@ fn validate_lightning_config(settings: &config::Settings) -> Result<()> { } #[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") - })?; + let _default_ldk_node; + let _ldk_node = match settings.ldk_node.as_ref() { + Some(l) => l, + None => { + _default_ldk_node = config::LdkNode::default(); + &_default_ldk_node + } + }; } } } @@ -2953,7 +2983,7 @@ mnemonic = "{TEST_MNEMONIC}" engine = "sqlite" "# ), - "Ln backend must be set via [[ln]]", + "At least one payment backend", ); } @@ -3094,7 +3124,7 @@ auth_enabled = true [info mnemonic = "not valid toml" "#, - "Error reading config file", + "Failed to read config file", ); } @@ -3170,6 +3200,276 @@ port = 8086 ); } + #[cfg(feature = "fakewallet")] + #[test] + fn test_load_settings_valid_config() { + let _env_lock = crate::test_utils::env_lock(); + clear_mintd_env(); + load_settings_from_toml( + "cdk_mintd_valid", + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" +"# + ), + ) + .expect("valid config should load without error"); + } + + #[cfg(feature = "fakewallet")] + #[test] + fn test_load_settings_valid_config_with_signatory_url() { + let _env_lock = crate::test_utils::env_lock(); + clear_mintd_env(); + load_settings_from_toml( + "cdk_mintd_valid_signatory", + r#" +[info] +signatory_url = "http://127.0.0.1:50051" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" +"#, + ) + .expect("valid config with signatory_url should load without error"); + } + + #[cfg(feature = "fakewallet")] + #[test] + fn test_load_settings_reports_invalid_mnemonic() { + assert_load_settings_error( + r#" +[info] +mnemonic = "not a valid mnemonic phrase at all" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" +"#, + "Invalid mnemonic", + ); + } + + #[cfg(feature = "fakewallet")] + #[test] + fn test_load_settings_reports_invalid_listen_address() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" +listen_host = "999.999.999.999" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" +"# + ), + "Invalid mint listen address", + ); + } + + #[cfg(feature = "fakewallet")] + #[test] + fn test_load_settings_reports_missing_auth_openid_client_id() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" + +[auth] +auth_enabled = true +openid_discovery = "https://issuer.example.com/.well-known/openid-configuration" +"# + ), + "Auth openid_client_id must be set", + ); + } + + #[cfg(feature = "fakewallet")] + #[test] + fn test_load_settings_reports_invalid_melt_limit_range() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" +min_melt = 10 +max_melt = 1 +"# + ), + "Lightning min_melt cannot be greater than max_melt", + ); + } + + #[cfg(feature = "fakewallet")] + #[test] + fn test_load_settings_reports_missing_fakewallet_supported_units() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" + +[fake_wallet] +supported_units = [] +"# + ), + "Fake wallet supported_units must contain at least one unit", + ); + } + + #[cfg(feature = "lnd")] + #[test] + fn test_load_settings_reports_missing_lnd_cert_file() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "lnd" + +[lnd] +address = "127.0.0.1:10009" +"# + ), + "LND cert_file must be set", + ); + } + + #[cfg(feature = "lnd")] + #[test] + fn test_load_settings_reports_missing_lnd_macaroon_file() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "lnd" + +[lnd] +address = "127.0.0.1:10009" +cert_file = "/path/to/tls.cert" +"# + ), + "LND macaroon_file must be set", + ); + } + + #[cfg(feature = "lnbits")] + #[test] + fn test_load_settings_reports_missing_lnbits_invoice_api_key() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "lnbits" + +[lnbits] +admin_api_key = "admin123" +"# + ), + "LNbits invoice_api_key must be set", + ); + } + + #[cfg(feature = "lnbits")] + #[test] + fn test_load_settings_reports_missing_lnbits_api_url() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "lnbits" + +[lnbits] +admin_api_key = "admin123" +invoice_api_key = "inv123" +"# + ), + "LNbits lnbits_api must be set", + ); + } + + #[cfg(feature = "grpc-processor")] + #[test] + fn test_load_settings_reports_missing_grpc_processor_addr() { + assert_load_settings_error( + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "grpcprocessor" + +[grpc_processor] +supported_units = ["sat"] +addr = "" +"# + ), + "gRPC payment processor addr must be set", + ); + } + #[cfg(feature = "fakewallet")] #[test] fn test_load_settings_reports_missing_auth_postgres_url() { @@ -3197,4 +3497,147 @@ openid_client_id = "mintd" "Auth database PostgreSQL URL is required", ); } + + fn load_settings_with_env( + name: &str, + config_content: &str, + setup_env: impl FnOnce(), + ) -> Result { + use std::fs; + + let _env_lock = crate::test_utils::env_lock(); + clear_mintd_env(); + + 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"); + + setup_env(); + + let result = load_settings(&temp_dir, Some(config_path)); + let _ = fs::remove_dir_all(&temp_dir); + clear_mintd_env(); + result + } + + #[cfg(feature = "fakewallet")] + #[test] + fn test_env_var_provides_mnemonic_when_toml_has_none() { + let settings = load_settings_with_env( + "cdk_mintd_env_mnemonic", + r#" +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" +"#, + || std::env::set_var("CDK_MINTD_MNEMONIC", TEST_MNEMONIC), + ) + .expect("valid config with env mnemonic should load"); + + let mnemonic = settings + .info + .mnemonic + .expect("mnemonic should be set from env"); + assert_eq!(mnemonic, TEST_MNEMONIC); + } + + #[cfg(feature = "fakewallet")] + #[test] + fn test_env_var_provides_seed_when_toml_has_none() { + let seed = "a".repeat(32); + let settings = load_settings_with_env( + "cdk_mintd_env_seed", + r#" +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" +"#, + || std::env::set_var("CDK_MINTD_SEED", &seed), + ) + .expect("valid config with env seed should load"); + + let loaded_seed = settings.info.seed.expect("seed should be set from env"); + assert_eq!(loaded_seed, seed); + } + + #[cfg(feature = "fakewallet")] + #[test] + fn test_env_var_provides_ln_backend_when_toml_has_none() { + let settings = load_settings_with_env( + "cdk_mintd_env_ln_only", + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" + +[database] +engine = "sqlite" +"# + ), + || { + std::env::set_var("CDK_MINTD_LN_BACKEND", "fakewallet"); + std::env::set_var("CDK_MINTD_LN_MIN_MINT", "10"); + }, + ) + .expect("env-only LN config should load"); + + assert_eq!(settings.ln.len(), 1); + assert_eq!(settings.ln[0].ln_backend, config::LnBackend::FakeWallet); + } + + #[cfg(feature = "fakewallet")] + #[test] + fn test_env_var_overrides_toml_listen_host() { + let settings = load_settings_with_env( + "cdk_mintd_env_override_listen", + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" +listen_host = "127.0.0.1" + +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" +"# + ), + || std::env::set_var("CDK_MINTD_LISTEN_HOST", "0.0.0.0"), + ) + .expect("config with env override should load"); + + assert_eq!(settings.info.listen_host, "0.0.0.0"); + } + + #[cfg(feature = "fakewallet")] + #[test] + fn test_env_var_overrides_toml_listen_port() { + let settings = load_settings_with_env( + "cdk_mintd_env_override_port", + &format!( + r#" +[info] +mnemonic = "{TEST_MNEMONIC}" +listen_port = 8080 + +[database] +engine = "sqlite" + +[ln] +ln_backend = "fakewallet" +"# + ), + || std::env::set_var("CDK_MINTD_LISTEN_PORT", "9090"), + ) + .expect("config with env port override should load"); + + assert_eq!(settings.info.listen_port, 9090); + } } From 56501987f979f2d618f355297feee13f3b17ea5c Mon Sep 17 00:00:00 2001 From: asmo Date: Thu, 18 Jun 2026 11:31:39 +0200 Subject: [PATCH 5/5] fix(mintd): correct config validation bugs and test flakiness - Don't reject on-chain-only configs: validate_lightning_config no longer bails on an empty `[[ln]]`. from_env already guarantees at least one payment backend (Lightning or on-chain), so the check was both redundant and wrong for valid on-chain-only deployments. - Remove duplicate TEST_MNEMONIC const that broke `cargo test` compilation. - Share a single process-wide env lock between config.rs and lib.rs tests; two separate mutexes over global std::env raced and made test_env_var_only_config_all_backends flaky. - Collapse the no-op LdkNode validation arm to `=> {}`. - Drop misleading "of entropy" from the short-seed error (it measures string bytes, not entropy). - Fix indentation to satisfy `cargo fmt --check`. Co-Authored-By: Claude Opus 4.8 --- crates/cdk-mintd/src/config.rs | 11 +++++------ crates/cdk-mintd/src/lib.rs | 25 +++++++------------------ 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index 778bb0c9a3..aeee29b754 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -1231,11 +1231,10 @@ mod tests { use super::*; fn config_env_lock() -> std::sync::MutexGuard<'static, ()> { - static CONFIG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - CONFIG_ENV_LOCK - .lock() - .expect("config env test lock should not be poisoned") + // Share the single process-wide env lock with the rest of the crate's + // tests. `std::env` is global, so config.rs and lib.rs tests must + // serialize on the *same* mutex or they race over env vars. + crate::test_utils::env_lock() } #[cfg(feature = "bdk")] @@ -1873,7 +1872,7 @@ max_delay_time = 3 /// This test runs sequentially for all enabled backends to avoid env var interference. #[test] fn test_env_var_only_config_all_backends() { - let _guard = config_env_lock(); + let _guard = config_env_lock(); // Run each backend test sequentially #[cfg(feature = "lnd")] diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 072b29bb9e..757a29380d 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -356,7 +356,7 @@ fn validate_signing_config(settings: &config::Settings) -> Result<()> { 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 in [info].seed/CDK_MINTD_SEED is too short ({} bytes); require at least {MIN_SEED_BYTES} bytes", seed.len() ); } @@ -374,10 +374,10 @@ fn validate_signing_config(settings: &config::Settings) -> Result<()> { } fn validate_lightning_config(settings: &config::Settings) -> Result<()> { - if settings.ln.is_empty() { - bail!("Ln backend must be set via [[ln]] or CDK_MINTD_LN_* environment variables"); - } - + // No emptiness check here: `from_env` already guarantees at least one + // payment backend (Lightning *or* on-chain), so requiring `[[ln]]` here + // would wrongly reject valid on-chain-only configs. An empty `ln` simply + // skips the loop below. for ln in &settings.ln { if ln.min_mint > ln.max_mint { bail!("Lightning min_mint cannot be greater than max_mint"); @@ -481,16 +481,8 @@ fn validate_lightning_config(settings: &config::Settings) -> Result<()> { } } #[cfg(feature = "ldk-node")] - LnBackend::LdkNode => { - let _default_ldk_node; - let _ldk_node = match settings.ldk_node.as_ref() { - Some(l) => l, - None => { - _default_ldk_node = config::LdkNode::default(); - &_default_ldk_node - } - }; - } + // LDK node has no required-field validation; defaults are usable. + LnBackend::LdkNode => {} } } @@ -2753,9 +2745,6 @@ mod tests { assert_eq!(methods, vec!["bolt11", "bolt12", "paypal"]); } - 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",