Преглед изворни кода

fix: Enable pure environment variable configuration for Lightning backends (#1299)

* fix: Enable pure environment variable configuration for Lightning backends

  Removes premature validation that prevented configuring backends entirely
  through environment variables, enabling containerized deployments without
  requiring backend sections in config.toml.

  Changes:
  - Remove Stage 1 validation from Settings::new_from_default() that checked
    for backend config sections before environment variables were applied
  - Add comprehensive regression tests for all 6 Lightning backends (LND, CLN,
    LNbits, FakeWallet, GRPC Processor, LDK Node) validating env-var-only config
  - Consolidate tests into single test_env_var_only_config_all_backends() that
    runs sequentially to avoid environment variable interference

  The validation still occurs after environment variables are applied in
  Settings::from_env() and during backend initialization, ensuring proper
  configuration with clear error messages.

  Fixes environment variable configuration issue where users could not configure
  backends without uncommenting all fields in config.toml, which blocked
  container orchestration deployments that rely on dynamic service discovery.

  Fixes: https://github.com/cashubtc/cdk/issues/1281
  Tests: cargo test -p cdk-mintd test_env_var_only_config_all_backends

* feat(cdk-mintd): add config validation for Lightning backend connections

Add validation checks for required connection fields in LND, LNbits, and CLN backend setup. Empty configuration values now produce clear error messages indicating which env vars or config fields are missing. Also improve GrpcProcessor and Lightning backend config structs with explicit Default implementations and serde defaults for better deserialization handling.

* docs(mintd): add Lightning backend configuration documentation

- Add comprehensive configuration guides for CLN, LND, and LNbits backends
- Include both config file and environment variable examples
- Update mintd README with links to backend-specific docs
- Improve example.config.toml with clearer comments and consistent defaults
- Standardize fee defaults to 2% with 2 sat minimum across all backends
tsk пре 1 дан
родитељ
комит
fd6f848120

+ 37 - 0
crates/cdk-cln/README.md

@@ -17,4 +17,41 @@ Add this to your `Cargo.toml`:
 cdk-cln = "*"
 ```
 
+## Configuration for cdk-mintd
+
+### Config File
+
+```toml
+[ln]
+ln_backend = "cln"
+
+[cln]
+rpc_path = "/path/to/.lightning/bitcoin/lightning-rpc"
+bolt12 = true            # Optional, defaults to true
+fee_percent = 0.02       # Optional, defaults to 2%
+reserve_fee_min = 2      # Optional, defaults to 2 sats
+```
+
+### Environment Variables
+
+All configuration can be set via environment variables:
+
+| Variable | Description | Required |
+|----------|-------------|----------|
+| `CDK_MINTD_LN_BACKEND` | Set to `cln` | Yes |
+| `CDK_MINTD_CLN_RPC_PATH` | Path to CLN RPC socket | Yes |
+| `CDK_MINTD_CLN_BOLT12` | Enable BOLT12 support (default: `true`) | No |
+| `CDK_MINTD_CLN_FEE_PERCENT` | Fee percentage (default: `0.02`) | No |
+| `CDK_MINTD_CLN_RESERVE_FEE_MIN` | Minimum fee in sats (default: `2`) | No |
+
+### Example
+
+```bash
+export CDK_MINTD_LN_BACKEND=cln
+export CDK_MINTD_CLN_RPC_PATH=/home/user/.lightning/bitcoin/lightning-rpc
+cdk-mintd
+```
+
+## License
+
 This project is licensed under the [MIT License](../../LICENSE).

+ 45 - 0
crates/cdk-lnbits/README.md

@@ -19,6 +19,51 @@ Add this to your `Cargo.toml`:
 cdk-lnbits = "*"
 ```
 
+## Configuration for cdk-mintd
+
+### Config File
+
+```toml
+[ln]
+ln_backend = "lnbits"
+
+[lnbits]
+admin_api_key = "your-admin-api-key"
+invoice_api_key = "your-invoice-api-key"
+lnbits_api = "https://your-lnbits-instance.com/api/v1"
+fee_percent = 0.02       # Optional, defaults to 2%
+reserve_fee_min = 2      # Optional, defaults to 2 sats
+```
+
+### Environment Variables
+
+All configuration can be set via environment variables:
+
+| Variable | Description | Required |
+|----------|-------------|----------|
+| `CDK_MINTD_LN_BACKEND` | Set to `lnbits` | Yes |
+| `CDK_MINTD_LNBITS_ADMIN_API_KEY` | LNBits admin API key | Yes |
+| `CDK_MINTD_LNBITS_INVOICE_API_KEY` | LNBits invoice API key | Yes |
+| `CDK_MINTD_LNBITS_LNBITS_API` | LNBits API URL | Yes |
+| `CDK_MINTD_LNBITS_FEE_PERCENT` | Fee percentage (default: `0.02`) | No |
+| `CDK_MINTD_LNBITS_RESERVE_FEE_MIN` | Minimum fee in sats (default: `2`) | No |
+
+### Example
+
+```bash
+export CDK_MINTD_LN_BACKEND=lnbits
+export CDK_MINTD_LNBITS_ADMIN_API_KEY=your-admin-api-key
+export CDK_MINTD_LNBITS_INVOICE_API_KEY=your-invoice-api-key
+export CDK_MINTD_LNBITS_LNBITS_API=https://your-lnbits-instance.com/api/v1
+cdk-mintd
+```
+
+### Getting API Keys
+
+1. Log in to your LNBits instance
+2. Go to your wallet
+3. Click on "API Info" to find your admin and invoice API keys
+
 ## License
 
 This project is licensed under the [MIT License](../../LICENSE).

+ 38 - 0
crates/cdk-lnd/README.md

@@ -17,6 +17,44 @@ Add this to your `Cargo.toml`:
 cdk-lnd = "*"
 ```
 
+## Configuration for cdk-mintd
+
+### Config File
+
+```toml
+[ln]
+ln_backend = "lnd"
+
+[lnd]
+address = "https://localhost:10009"
+cert_file = "/path/to/.lnd/tls.cert"
+macaroon_file = "/path/to/.lnd/data/chain/bitcoin/mainnet/admin.macaroon"
+fee_percent = 0.02       # Optional, defaults to 2%
+reserve_fee_min = 2      # Optional, defaults to 2 sats
+```
+
+### Environment Variables
+
+All configuration can be set via environment variables:
+
+| Variable | Description | Required |
+|----------|-------------|----------|
+| `CDK_MINTD_LN_BACKEND` | Set to `lnd` | Yes |
+| `CDK_MINTD_LND_ADDRESS` | LND gRPC address (e.g., `https://localhost:10009`) | Yes |
+| `CDK_MINTD_LND_CERT_FILE` | Path to LND TLS certificate | Yes |
+| `CDK_MINTD_LND_MACAROON_FILE` | Path to LND macaroon file | Yes |
+| `CDK_MINTD_LND_FEE_PERCENT` | Fee percentage (default: `0.02`) | No |
+| `CDK_MINTD_LND_RESERVE_FEE_MIN` | Minimum fee in sats (default: `2`) | No |
+
+### Example
+
+```bash
+export CDK_MINTD_LN_BACKEND=lnd
+export CDK_MINTD_LND_ADDRESS=https://127.0.0.1:10009
+export CDK_MINTD_LND_CERT_FILE=/home/user/.lnd/tls.cert
+export CDK_MINTD_LND_MACAROON_FILE=/home/user/.lnd/data/chain/bitcoin/mainnet/admin.macaroon
+cdk-mintd
+```
 
 ## Minimum Supported Rust Version (MSRV)
 

+ 13 - 5
crates/cdk-mintd/README.md

@@ -12,11 +12,19 @@ Cashu mint daemon implementation for the Cashu Development Kit (CDK). This binar
 ## Features
 
 - **Multiple Database Backends**: SQLite, PostgreSQL, and ReDB
-- **Lightning Network Integration**: Support for CLN, LND, LNbits, LDK Node, and test backends  
+- **Lightning Network Integration**: Support for CLN, LND, LNbits, LDK Node, and test backends
 - **Authentication**: Optional user authentication with OpenID Connect
 - **Management RPC**: gRPC interface for mint management
 - **Docker Support**: Ready-to-use Docker configurations
 
+## Lightning Backend Documentation
+
+For detailed configuration of each Lightning backend, see:
+
+- **[LND](../cdk-lnd/README.md)** - Lightning Network Daemon
+- **[CLN](../cdk-cln/README.md)** - Core Lightning
+- **[LNbits](../cdk-lnbits/README.md)** - LNbits API integration
+
 ## Installation
 
 ### Option 1: Download Pre-built Binary
@@ -100,8 +108,8 @@ ln_backend = "cln"
 
 [cln]
 rpc_path = "/home/bitcoin/.lightning/bitcoin/lightning-rpc"
-fee_percent = 0.01
-reserve_fee_min = 10
+# fee_percent = 0.02      # Optional, defaults to 2%
+# reserve_fee_min = 2     # Optional, defaults to 2 sats
 ```
 
 ### With LND Lightning Backend
@@ -113,8 +121,8 @@ ln_backend = "lnd"
 address = "https://localhost:10009"
 macaroon_file = "/home/bitcoin/.lnd/data/chain/bitcoin/mainnet/admin.macaroon"
 cert_file = "/home/bitcoin/.lnd/tls.cert"
-fee_percent = 0.01
-reserve_fee_min = 10
+# fee_percent = 0.02      # Optional, defaults to 2%
+# reserve_fee_min = 2     # Optional, defaults to 2 sats
 ```
 
 ### With PostgreSQL Database

+ 14 - 14
crates/cdk-mintd/example.config.toml

@@ -94,30 +94,30 @@ ln_backend = "fakewallet"
 # min_melt=1
 # max_melt=500000
 
-[cln]
-rpc_path = ""
-fee_percent = 0.04
-reserve_fee_min = 4
+# [cln]
+# rpc_path = "/path/to/.lightning/bitcoin/lightning-rpc"
+# bolt12 = true              # Optional, defaults to true
+# fee_percent = 0.02         # Optional, defaults to 2%
+# reserve_fee_min = 2        # Optional, defaults to 2 sats
 
 # [lnbits]
 # admin_api_key = ""
 # invoice_api_key = ""
 # lnbits_api = ""
-# fee_percent = 0.04
-# # Fee in sats
-# reserve_fee_min = 4
+# fee_percent = 0.02         # Optional, defaults to 2%
+# reserve_fee_min = 2        # Optional, defaults to 2 sats
 # Note: Only LNBits v1 API is supported (websocket-based)
 
 # [lnd]
-# address = "https://domain:port"
-# macaroon_file = ""
-# cert_file = ""
-# fee_percent=0.04
-# reserve_fee_min=4
+# address = "https://localhost:10009"
+# cert_file = "/path/to/.lnd/tls.cert"
+# macaroon_file = "/path/to/.lnd/data/chain/bitcoin/mainnet/admin.macaroon"
+# fee_percent = 0.02         # Optional, defaults to 2%
+# reserve_fee_min = 2        # Optional, defaults to 2 sats
 
 # [ldk_node]
-# fee_percent = 0.04
-# reserve_fee_min = 4
+# fee_percent = 0.02         # Optional, defaults to 2%
+# reserve_fee_min = 2        # Optional, defaults to 2 sats
 # bitcoin_network = "signet"  # mainnet, testnet, signet, regtest
 # chain_source_type = "esplora"  # esplora, bitcoinrpc  
 # 

+ 456 - 45
crates/cdk-mintd/src/config.rs

@@ -186,35 +186,84 @@ impl Default for Ln {
 }
 
 #[cfg(feature = "lnbits")]
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct LNbits {
     pub admin_api_key: String,
     pub invoice_api_key: String,
     pub lnbits_api: String,
+    #[serde(default = "default_fee_percent")]
     pub fee_percent: f32,
+    #[serde(default = "default_reserve_fee_min")]
     pub reserve_fee_min: Amount,
 }
 
+#[cfg(feature = "lnbits")]
+impl Default for LNbits {
+    fn default() -> Self {
+        Self {
+            admin_api_key: String::new(),
+            invoice_api_key: String::new(),
+            lnbits_api: String::new(),
+            fee_percent: 0.02,
+            reserve_fee_min: 2.into(),
+        }
+    }
+}
+
 #[cfg(feature = "cln")]
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct Cln {
     pub rpc_path: PathBuf,
-    #[serde(default)]
+    #[serde(default = "default_cln_bolt12")]
     pub bolt12: bool,
+    #[serde(default = "default_fee_percent")]
     pub fee_percent: f32,
+    #[serde(default = "default_reserve_fee_min")]
     pub reserve_fee_min: Amount,
 }
 
+#[cfg(feature = "cln")]
+impl Default for Cln {
+    fn default() -> Self {
+        Self {
+            rpc_path: PathBuf::new(),
+            bolt12: true,
+            fee_percent: 0.02,
+            reserve_fee_min: 2.into(),
+        }
+    }
+}
+
+#[cfg(feature = "cln")]
+fn default_cln_bolt12() -> bool {
+    true
+}
+
 #[cfg(feature = "lnd")]
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct Lnd {
     pub address: String,
     pub cert_file: PathBuf,
     pub macaroon_file: PathBuf,
+    #[serde(default = "default_fee_percent")]
     pub fee_percent: f32,
+    #[serde(default = "default_reserve_fee_min")]
     pub reserve_fee_min: Amount,
 }
 
+#[cfg(feature = "lnd")]
+impl Default for Lnd {
+    fn default() -> Self {
+        Self {
+            address: String::new(),
+            cert_file: PathBuf::new(),
+            macaroon_file: PathBuf::new(),
+            fee_percent: 0.02,
+            reserve_fee_min: 2.into(),
+        }
+    }
+}
+
 #[cfg(feature = "ldk-node")]
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct LdkNode {
@@ -323,6 +372,15 @@ impl Default for FakeWallet {
 }
 
 // Helper functions to provide default values
+// Common fee defaults for all backends
+fn default_fee_percent() -> f32 {
+    0.02
+}
+
+fn default_reserve_fee_min() -> Amount {
+    2.into()
+}
+
 #[cfg(feature = "fakewallet")]
 fn default_min_delay_time() -> u64 {
     1
@@ -333,14 +391,37 @@ fn default_max_delay_time() -> u64 {
     3
 }
 
-#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
+#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
 pub struct GrpcProcessor {
+    #[serde(default)]
     pub supported_units: Vec<CurrencyUnit>,
+    #[serde(default = "default_grpc_addr")]
     pub addr: String,
+    #[serde(default = "default_grpc_port")]
     pub port: u16,
+    #[serde(default)]
     pub tls_dir: Option<PathBuf>,
 }
 
+impl Default for GrpcProcessor {
+    fn default() -> Self {
+        Self {
+            supported_units: Vec::new(),
+            addr: default_grpc_addr(),
+            port: default_grpc_port(),
+            tls_dir: None,
+        }
+    }
+}
+
+fn default_grpc_addr() -> String {
+    "127.0.0.1".to_string()
+}
+
+fn default_grpc_port() -> u16 {
+    50051
+}
+
 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
 #[serde(rename_all = "lowercase")]
 pub enum DatabaseEngine {
@@ -579,46 +660,6 @@ impl Settings {
             .build()?;
         let settings: Settings = config.try_deserialize()?;
 
-        match settings.ln.ln_backend {
-            LnBackend::None => panic!("Ln backend must be set"),
-            #[cfg(feature = "cln")]
-            LnBackend::Cln => assert!(
-                settings.cln.is_some(),
-                "CLN backend requires a valid config."
-            ),
-            #[cfg(feature = "lnbits")]
-            LnBackend::LNbits => assert!(
-                settings.lnbits.is_some(),
-                "LNbits backend requires a valid config"
-            ),
-            #[cfg(feature = "lnd")]
-            LnBackend::Lnd => {
-                assert!(
-                    settings.lnd.is_some(),
-                    "LND backend requires a valid config."
-                )
-            }
-            #[cfg(feature = "ldk-node")]
-            LnBackend::LdkNode => {
-                assert!(
-                    settings.ldk_node.is_some(),
-                    "LDK Node backend requires a valid config."
-                )
-            }
-            #[cfg(feature = "fakewallet")]
-            LnBackend::FakeWallet => assert!(
-                settings.fake_wallet.is_some(),
-                "FakeWallet backend requires a valid config."
-            ),
-            #[cfg(feature = "grpc-processor")]
-            LnBackend::GrpcProcessor => {
-                assert!(
-                    settings.grpc_processor.is_some(),
-                    "GRPC backend requires a valid config."
-                )
-            }
-        }
-
         Ok(settings)
     }
 }
@@ -692,4 +733,374 @@ mod tests {
         assert!(!debug_output.contains("特殊字符 !@#$%^&*()"));
         assert!(debug_output.contains("<hashed: "));
     }
+
+    /// Test that configuration can be loaded purely from environment variables
+    /// without requiring a config.toml file with backend sections.
+    ///
+    /// This test runs sequentially for all enabled backends to avoid env var interference.
+    #[test]
+    fn test_env_var_only_config_all_backends() {
+        // Run each backend test sequentially
+        #[cfg(feature = "lnd")]
+        test_lnd_env_config();
+
+        #[cfg(feature = "cln")]
+        test_cln_env_config();
+
+        #[cfg(feature = "lnbits")]
+        test_lnbits_env_config();
+
+        #[cfg(feature = "fakewallet")]
+        test_fakewallet_env_config();
+
+        #[cfg(feature = "grpc-processor")]
+        test_grpc_processor_env_config();
+
+        #[cfg(feature = "ldk-node")]
+        test_ldk_node_env_config();
+    }
+
+    #[cfg(feature = "lnd")]
+    fn test_lnd_env_config() {
+        use std::path::PathBuf;
+        use std::{env, fs};
+
+        // Create a temporary directory for config file
+        let temp_dir = env::temp_dir().join("cdk_test_env_vars");
+        fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
+        let config_path = temp_dir.join("config.toml");
+
+        // Create a minimal config.toml with backend set but NO [lnd] section
+        let config_content = r#"
+[ln]
+backend = "lnd"
+min_mint = 1
+max_mint = 500000
+min_melt = 1
+max_melt = 500000
+"#;
+        fs::write(&config_path, config_content).expect("Failed to write config file");
+
+        // Set environment variables for LND configuration
+        env::set_var(crate::env_vars::ENV_LN_BACKEND, "lnd");
+        env::set_var(crate::env_vars::ENV_LND_ADDRESS, "https://localhost:10009");
+        env::set_var(crate::env_vars::ENV_LND_CERT_FILE, "/tmp/test_tls.cert");
+        env::set_var(
+            crate::env_vars::ENV_LND_MACAROON_FILE,
+            "/tmp/test_admin.macaroon",
+        );
+        env::set_var(crate::env_vars::ENV_LND_FEE_PERCENT, "0.01");
+        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));
+        settings.from_env().expect("Failed to apply env vars");
+
+        // Verify that settings were populated from env vars
+        assert!(settings.lnd.is_some());
+        let lnd_config = settings.lnd.as_ref().unwrap();
+        assert_eq!(lnd_config.address, "https://localhost:10009");
+        assert_eq!(lnd_config.cert_file, PathBuf::from("/tmp/test_tls.cert"));
+        assert_eq!(
+            lnd_config.macaroon_file,
+            PathBuf::from("/tmp/test_admin.macaroon")
+        );
+        assert_eq!(lnd_config.fee_percent, 0.01);
+        let reserve_fee_u64: u64 = lnd_config.reserve_fee_min.into();
+        assert_eq!(reserve_fee_u64, 4);
+
+        // Cleanup env vars
+        env::remove_var(crate::env_vars::ENV_LN_BACKEND);
+        env::remove_var(crate::env_vars::ENV_LND_ADDRESS);
+        env::remove_var(crate::env_vars::ENV_LND_CERT_FILE);
+        env::remove_var(crate::env_vars::ENV_LND_MACAROON_FILE);
+        env::remove_var(crate::env_vars::ENV_LND_FEE_PERCENT);
+        env::remove_var(crate::env_vars::ENV_LND_RESERVE_FEE_MIN);
+
+        // Cleanup test file
+        let _ = fs::remove_dir_all(&temp_dir);
+    }
+
+    #[cfg(feature = "cln")]
+    fn test_cln_env_config() {
+        use std::path::PathBuf;
+        use std::{env, fs};
+
+        // Create a temporary directory for config file
+        let temp_dir = env::temp_dir().join("cdk_test_env_vars_cln");
+        fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
+        let config_path = temp_dir.join("config.toml");
+
+        // Create a minimal config.toml with backend set but NO [cln] section
+        let config_content = r#"
+[ln]
+backend = "cln"
+min_mint = 1
+max_mint = 500000
+min_melt = 1
+max_melt = 500000
+"#;
+        fs::write(&config_path, config_content).expect("Failed to write config file");
+
+        // Set environment variables for CLN configuration
+        env::set_var(crate::env_vars::ENV_LN_BACKEND, "cln");
+        env::set_var(crate::env_vars::ENV_CLN_RPC_PATH, "/tmp/lightning-rpc");
+        env::set_var(crate::env_vars::ENV_CLN_BOLT12, "false");
+        env::set_var(crate::env_vars::ENV_CLN_FEE_PERCENT, "0.01");
+        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));
+        settings.from_env().expect("Failed to apply env vars");
+
+        // Verify that settings were populated from env vars
+        assert!(settings.cln.is_some());
+        let cln_config = settings.cln.as_ref().unwrap();
+        assert_eq!(cln_config.rpc_path, PathBuf::from("/tmp/lightning-rpc"));
+        assert_eq!(cln_config.bolt12, false);
+        assert_eq!(cln_config.fee_percent, 0.01);
+        let reserve_fee_u64: u64 = cln_config.reserve_fee_min.into();
+        assert_eq!(reserve_fee_u64, 4);
+
+        // Cleanup env vars
+        env::remove_var(crate::env_vars::ENV_LN_BACKEND);
+        env::remove_var(crate::env_vars::ENV_CLN_RPC_PATH);
+        env::remove_var(crate::env_vars::ENV_CLN_BOLT12);
+        env::remove_var(crate::env_vars::ENV_CLN_FEE_PERCENT);
+        env::remove_var(crate::env_vars::ENV_CLN_RESERVE_FEE_MIN);
+
+        // Cleanup test file
+        let _ = fs::remove_dir_all(&temp_dir);
+    }
+
+    #[cfg(feature = "lnbits")]
+    fn test_lnbits_env_config() {
+        use std::{env, fs};
+
+        // Create a temporary directory for config file
+        let temp_dir = env::temp_dir().join("cdk_test_env_vars_lnbits");
+        fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
+        let config_path = temp_dir.join("config.toml");
+
+        // Create a minimal config.toml with backend set but NO [lnbits] section
+        let config_content = r#"
+[ln]
+backend = "lnbits"
+min_mint = 1
+max_mint = 500000
+min_melt = 1
+max_melt = 500000
+"#;
+        fs::write(&config_path, config_content).expect("Failed to write config file");
+
+        // Set environment variables for LNbits configuration
+        env::set_var(crate::env_vars::ENV_LN_BACKEND, "lnbits");
+        env::set_var(crate::env_vars::ENV_LNBITS_ADMIN_API_KEY, "test_admin_key");
+        env::set_var(
+            crate::env_vars::ENV_LNBITS_INVOICE_API_KEY,
+            "test_invoice_key",
+        );
+        env::set_var(
+            crate::env_vars::ENV_LNBITS_API,
+            "https://lnbits.example.com",
+        );
+        env::set_var(crate::env_vars::ENV_LNBITS_FEE_PERCENT, "0.02");
+        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));
+        settings.from_env().expect("Failed to apply env vars");
+
+        // Verify that settings were populated from env vars
+        assert!(settings.lnbits.is_some());
+        let lnbits_config = settings.lnbits.as_ref().unwrap();
+        assert_eq!(lnbits_config.admin_api_key, "test_admin_key");
+        assert_eq!(lnbits_config.invoice_api_key, "test_invoice_key");
+        assert_eq!(lnbits_config.lnbits_api, "https://lnbits.example.com");
+        assert_eq!(lnbits_config.fee_percent, 0.02);
+        let reserve_fee_u64: u64 = lnbits_config.reserve_fee_min.into();
+        assert_eq!(reserve_fee_u64, 5);
+
+        // Cleanup env vars
+        env::remove_var(crate::env_vars::ENV_LN_BACKEND);
+        env::remove_var(crate::env_vars::ENV_LNBITS_ADMIN_API_KEY);
+        env::remove_var(crate::env_vars::ENV_LNBITS_INVOICE_API_KEY);
+        env::remove_var(crate::env_vars::ENV_LNBITS_API);
+        env::remove_var(crate::env_vars::ENV_LNBITS_FEE_PERCENT);
+        env::remove_var(crate::env_vars::ENV_LNBITS_RESERVE_FEE_MIN);
+
+        // Cleanup test file
+        let _ = fs::remove_dir_all(&temp_dir);
+    }
+
+    #[cfg(feature = "fakewallet")]
+    fn test_fakewallet_env_config() {
+        use std::{env, fs};
+
+        // Create a temporary directory for config file
+        let temp_dir = env::temp_dir().join("cdk_test_env_vars_fakewallet");
+        fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
+        let config_path = temp_dir.join("config.toml");
+
+        // Create a minimal config.toml with backend set but NO [fake_wallet] section
+        let config_content = r#"
+[ln]
+backend = "fakewallet"
+min_mint = 1
+max_mint = 500000
+min_melt = 1
+max_melt = 500000
+"#;
+        fs::write(&config_path, config_content).expect("Failed to write config file");
+
+        // Set environment variables for FakeWallet configuration
+        env::set_var(crate::env_vars::ENV_LN_BACKEND, "fakewallet");
+        env::set_var(crate::env_vars::ENV_FAKE_WALLET_SUPPORTED_UNITS, "sat,msat");
+        env::set_var(crate::env_vars::ENV_FAKE_WALLET_FEE_PERCENT, "0.0");
+        env::set_var(crate::env_vars::ENV_FAKE_WALLET_RESERVE_FEE_MIN, "0");
+        env::set_var(crate::env_vars::ENV_FAKE_WALLET_MIN_DELAY, "0");
+        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));
+        settings.from_env().expect("Failed to apply env vars");
+
+        // Verify that settings were populated from env vars
+        assert!(settings.fake_wallet.is_some());
+        let fakewallet_config = settings.fake_wallet.as_ref().unwrap();
+        assert_eq!(fakewallet_config.fee_percent, 0.0);
+        let reserve_fee_u64: u64 = fakewallet_config.reserve_fee_min.into();
+        assert_eq!(reserve_fee_u64, 0);
+        assert_eq!(fakewallet_config.min_delay_time, 0);
+        assert_eq!(fakewallet_config.max_delay_time, 5);
+
+        // Cleanup env vars
+        env::remove_var(crate::env_vars::ENV_LN_BACKEND);
+        env::remove_var(crate::env_vars::ENV_FAKE_WALLET_SUPPORTED_UNITS);
+        env::remove_var(crate::env_vars::ENV_FAKE_WALLET_FEE_PERCENT);
+        env::remove_var(crate::env_vars::ENV_FAKE_WALLET_RESERVE_FEE_MIN);
+        env::remove_var(crate::env_vars::ENV_FAKE_WALLET_MIN_DELAY);
+        env::remove_var(crate::env_vars::ENV_FAKE_WALLET_MAX_DELAY);
+
+        // Cleanup test file
+        let _ = fs::remove_dir_all(&temp_dir);
+    }
+
+    #[cfg(feature = "grpc-processor")]
+    fn test_grpc_processor_env_config() {
+        use std::{env, fs};
+
+        // Create a temporary directory for config file
+        let temp_dir = env::temp_dir().join("cdk_test_env_vars_grpc");
+        fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
+        let config_path = temp_dir.join("config.toml");
+
+        // Create a minimal config.toml with backend set but NO [grpc_processor] section
+        let config_content = r#"
+[ln]
+backend = "grpcprocessor"
+min_mint = 1
+max_mint = 500000
+min_melt = 1
+max_melt = 500000
+"#;
+        fs::write(&config_path, config_content).expect("Failed to write config file");
+
+        // Set environment variables for GRPC Processor configuration
+        env::set_var(crate::env_vars::ENV_LN_BACKEND, "grpcprocessor");
+        env::set_var(
+            crate::env_vars::ENV_GRPC_PROCESSOR_SUPPORTED_UNITS,
+            "sat,msat",
+        );
+        env::set_var(crate::env_vars::ENV_GRPC_PROCESSOR_ADDRESS, "localhost");
+        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));
+        settings.from_env().expect("Failed to apply env vars");
+
+        // Verify that settings were populated from env vars
+        assert!(settings.grpc_processor.is_some());
+        let grpc_config = settings.grpc_processor.as_ref().unwrap();
+        assert_eq!(grpc_config.addr, "localhost");
+        assert_eq!(grpc_config.port, 50051);
+
+        // Cleanup env vars
+        env::remove_var(crate::env_vars::ENV_LN_BACKEND);
+        env::remove_var(crate::env_vars::ENV_GRPC_PROCESSOR_SUPPORTED_UNITS);
+        env::remove_var(crate::env_vars::ENV_GRPC_PROCESSOR_ADDRESS);
+        env::remove_var(crate::env_vars::ENV_GRPC_PROCESSOR_PORT);
+
+        // Cleanup test file
+        let _ = fs::remove_dir_all(&temp_dir);
+    }
+
+    #[cfg(feature = "ldk-node")]
+    fn test_ldk_node_env_config() {
+        use std::{env, fs};
+
+        // Create a temporary directory for config file
+        let temp_dir = env::temp_dir().join("cdk_test_env_vars_ldk");
+        fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
+        let config_path = temp_dir.join("config.toml");
+
+        // Create a minimal config.toml with backend set but NO [ldk_node] section
+        let config_content = r#"
+[ln]
+backend = "ldknode"
+min_mint = 1
+max_mint = 500000
+min_melt = 1
+max_melt = 500000
+"#;
+        fs::write(&config_path, config_content).expect("Failed to write config file");
+
+        // Set environment variables for LDK Node configuration
+        env::set_var(crate::env_vars::ENV_LN_BACKEND, "ldknode");
+        env::set_var(crate::env_vars::LDK_NODE_FEE_PERCENT_ENV_VAR, "0.01");
+        env::set_var(crate::env_vars::LDK_NODE_RESERVE_FEE_MIN_ENV_VAR, "4");
+        env::set_var(crate::env_vars::LDK_NODE_BITCOIN_NETWORK_ENV_VAR, "regtest");
+        env::set_var(
+            crate::env_vars::LDK_NODE_CHAIN_SOURCE_TYPE_ENV_VAR,
+            "esplora",
+        );
+        env::set_var(
+            crate::env_vars::LDK_NODE_ESPLORA_URL_ENV_VAR,
+            "http://localhost:3000",
+        );
+        env::set_var(
+            crate::env_vars::LDK_NODE_STORAGE_DIR_PATH_ENV_VAR,
+            "/tmp/ldk",
+        );
+
+        // Load settings and apply environment variables (same as production code)
+        let mut settings = Settings::new(Some(&config_path));
+        settings.from_env().expect("Failed to apply env vars");
+
+        // Verify that settings were populated from env vars
+        assert!(settings.ldk_node.is_some());
+        let ldk_config = settings.ldk_node.as_ref().unwrap();
+        assert_eq!(ldk_config.fee_percent, 0.01);
+        let reserve_fee_u64: u64 = ldk_config.reserve_fee_min.into();
+        assert_eq!(reserve_fee_u64, 4);
+        assert_eq!(ldk_config.bitcoin_network, Some("regtest".to_string()));
+        assert_eq!(ldk_config.chain_source_type, Some("esplora".to_string()));
+        assert_eq!(
+            ldk_config.esplora_url,
+            Some("http://localhost:3000".to_string())
+        );
+        assert_eq!(ldk_config.storage_dir_path, Some("/tmp/ldk".to_string()));
+
+        // Cleanup env vars
+        env::remove_var(crate::env_vars::ENV_LN_BACKEND);
+        env::remove_var(crate::env_vars::LDK_NODE_FEE_PERCENT_ENV_VAR);
+        env::remove_var(crate::env_vars::LDK_NODE_RESERVE_FEE_MIN_ENV_VAR);
+        env::remove_var(crate::env_vars::LDK_NODE_BITCOIN_NETWORK_ENV_VAR);
+        env::remove_var(crate::env_vars::LDK_NODE_CHAIN_SOURCE_TYPE_ENV_VAR);
+        env::remove_var(crate::env_vars::LDK_NODE_ESPLORA_URL_ENV_VAR);
+        env::remove_var(crate::env_vars::LDK_NODE_STORAGE_DIR_PATH_ENV_VAR);
+
+        // Cleanup test file
+        let _ = fs::remove_dir_all(&temp_dir);
+    }
 }

+ 35 - 0
crates/cdk-mintd/src/setup.rs

@@ -7,6 +7,8 @@ use std::sync::Arc;
 
 #[cfg(feature = "cln")]
 use anyhow::anyhow;
+#[cfg(any(feature = "lnbits", feature = "lnd"))]
+use anyhow::bail;
 use async_trait::async_trait;
 #[cfg(feature = "fakewallet")]
 use bip39::rand::{thread_rng, Rng};
@@ -49,6 +51,13 @@ impl LnBackendSetup for config::Cln {
         _work_dir: &Path,
         kv_store: Option<Arc<dyn MintKVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
     ) -> anyhow::Result<cdk_cln::Cln> {
+        // Validate required connection field
+        if self.rpc_path.as_os_str().is_empty() {
+            return Err(anyhow!(
+                "CLN rpc_path must be set via config or CDK_MINTD_CLN_RPC_PATH env var"
+            ));
+        }
+
         let cln_socket = expand_path(
             self.rpc_path
                 .to_str()
@@ -83,6 +92,19 @@ impl LnBackendSetup for config::LNbits {
         _work_dir: &Path,
         _kv_store: Option<Arc<dyn MintKVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
     ) -> anyhow::Result<cdk_lnbits::LNbits> {
+        // Validate required connection fields
+        if self.admin_api_key.is_empty() {
+            bail!("LNbits admin_api_key must be set via config or CDK_MINTD_LNBITS_ADMIN_API_KEY env var");
+        }
+        if self.invoice_api_key.is_empty() {
+            bail!("LNbits invoice_api_key must be set via config or CDK_MINTD_LNBITS_INVOICE_API_KEY env var");
+        }
+        if self.lnbits_api.is_empty() {
+            bail!(
+                "LNbits lnbits_api must be set via config or CDK_MINTD_LNBITS_LNBITS_API env var"
+            );
+        }
+
         let admin_api_key = &self.admin_api_key;
         let invoice_api_key = &self.invoice_api_key;
 
@@ -117,6 +139,19 @@ impl LnBackendSetup for config::Lnd {
         _work_dir: &Path,
         kv_store: Option<Arc<dyn MintKVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
     ) -> anyhow::Result<cdk_lnd::Lnd> {
+        // Validate required connection fields
+        if self.address.is_empty() {
+            bail!("LND address must be set via config or CDK_MINTD_LND_ADDRESS env var");
+        }
+        if self.cert_file.as_os_str().is_empty() {
+            bail!("LND cert_file must be set via config or CDK_MINTD_LND_CERT_FILE env var");
+        }
+        if self.macaroon_file.as_os_str().is_empty() {
+            bail!(
+                "LND macaroon_file must be set via config or CDK_MINTD_LND_MACAROON_FILE env var"
+            );
+        }
+
         let address = &self.address;
         let cert_file = &self.cert_file;
         let macaroon_file = &self.macaroon_file;