cdk_mintd/
config.rs

1use std::path::PathBuf;
2
3use bitcoin::hashes::{sha256, Hash};
4use cdk::nuts::{CurrencyUnit, PublicKey};
5use cdk::Amount;
6use cdk_axum::cache;
7use cdk_common::common::QuoteTTL;
8use config::{Config, ConfigError, File};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
12#[serde(rename_all = "lowercase")]
13pub enum LoggingOutput {
14    /// Log to stderr only
15    Stderr,
16    /// Log to file only
17    File,
18    /// Log to both stderr and file (default)
19    #[default]
20    Both,
21}
22
23impl std::str::FromStr for LoggingOutput {
24    type Err = String;
25
26    fn from_str(s: &str) -> Result<Self, Self::Err> {
27        match s.to_lowercase().as_str() {
28            "stderr" => Ok(LoggingOutput::Stderr),
29            "file" => Ok(LoggingOutput::File),
30            "both" => Ok(LoggingOutput::Both),
31            _ => Err(format!(
32                "Unknown logging output: {s}. Valid options: stdout, file, both"
33            )),
34        }
35    }
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, Default)]
39pub struct LoggingConfig {
40    /// Where to output logs: stdout, file, or both
41    #[serde(default)]
42    pub output: LoggingOutput,
43    /// Log level for console output (when stdout or both)
44    pub console_level: Option<String>,
45    /// Log level for file output (when file or both)
46    pub file_level: Option<String>,
47}
48
49#[derive(Clone, Serialize, Deserialize)]
50pub struct Info {
51    pub url: String,
52    pub listen_host: String,
53    pub listen_port: u16,
54    /// Overrides mnemonic
55    pub seed: Option<String>,
56    pub mnemonic: Option<String>,
57    pub signatory_url: Option<String>,
58    pub signatory_certs: Option<String>,
59    pub input_fee_ppk: Option<u64>,
60
61    pub http_cache: cache::Config,
62
63    /// Logging configuration
64    #[serde(default)]
65    pub logging: LoggingConfig,
66
67    /// When this is set to true, the mint exposes a Swagger UI for it's API at
68    /// `[listen_host]:[listen_port]/swagger-ui`
69    ///
70    /// This requires `mintd` was built with the `swagger` feature flag.
71    pub enable_swagger_ui: Option<bool>,
72
73    /// Optional persisted quote TTL values (seconds) to initialize the database with
74    /// when RPC is disabled or on first-run when RPC is enabled.
75    /// If not provided, defaults are used.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub quote_ttl: Option<QuoteTTL>,
78}
79
80impl Default for Info {
81    fn default() -> Self {
82        Info {
83            url: String::new(),
84            listen_host: "127.0.0.1".to_string(),
85            listen_port: 8091, // Default to port 8091 instead of 0
86            seed: None,
87            mnemonic: None,
88            signatory_url: None,
89            signatory_certs: None,
90            input_fee_ppk: None,
91            http_cache: cache::Config::default(),
92            enable_swagger_ui: None,
93            logging: LoggingConfig::default(),
94            quote_ttl: None,
95        }
96    }
97}
98
99impl std::fmt::Debug for Info {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        // Use a fallback approach that won't panic
102        let mnemonic_display: String = {
103            if let Some(mnemonic) = self.mnemonic.as_ref() {
104                let hash = sha256::Hash::hash(mnemonic.as_bytes());
105                format!("<hashed: {hash}>")
106            } else {
107                format!("<url: {}>", self.signatory_url.clone().unwrap_or_default())
108            }
109        };
110
111        f.debug_struct("Info")
112            .field("url", &self.url)
113            .field("listen_host", &self.listen_host)
114            .field("listen_port", &self.listen_port)
115            .field("mnemonic", &mnemonic_display)
116            .field("input_fee_ppk", &self.input_fee_ppk)
117            .field("http_cache", &self.http_cache)
118            .field("logging", &self.logging)
119            .field("enable_swagger_ui", &self.enable_swagger_ui)
120            .finish()
121    }
122}
123
124#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
125#[serde(rename_all = "lowercase")]
126pub enum LnBackend {
127    #[default]
128    None,
129    #[cfg(feature = "cln")]
130    Cln,
131    #[cfg(feature = "lnbits")]
132    LNbits,
133    #[cfg(feature = "fakewallet")]
134    FakeWallet,
135    #[cfg(feature = "lnd")]
136    Lnd,
137    #[cfg(feature = "ldk-node")]
138    LdkNode,
139    #[cfg(feature = "grpc-processor")]
140    GrpcProcessor,
141}
142
143impl std::str::FromStr for LnBackend {
144    type Err = String;
145
146    fn from_str(s: &str) -> Result<Self, Self::Err> {
147        match s.to_lowercase().as_str() {
148            #[cfg(feature = "cln")]
149            "cln" => Ok(LnBackend::Cln),
150            #[cfg(feature = "lnbits")]
151            "lnbits" => Ok(LnBackend::LNbits),
152            #[cfg(feature = "fakewallet")]
153            "fakewallet" => Ok(LnBackend::FakeWallet),
154            #[cfg(feature = "lnd")]
155            "lnd" => Ok(LnBackend::Lnd),
156            #[cfg(feature = "ldk-node")]
157            "ldk-node" | "ldknode" => Ok(LnBackend::LdkNode),
158            #[cfg(feature = "grpc-processor")]
159            "grpcprocessor" => Ok(LnBackend::GrpcProcessor),
160            _ => Err(format!("Unknown Lightning backend: {s}")),
161        }
162    }
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct Ln {
167    pub ln_backend: LnBackend,
168    pub invoice_description: Option<String>,
169    pub min_mint: Amount,
170    pub max_mint: Amount,
171    pub min_melt: Amount,
172    pub max_melt: Amount,
173}
174
175impl Default for Ln {
176    fn default() -> Self {
177        Ln {
178            ln_backend: LnBackend::default(),
179            invoice_description: None,
180            min_mint: 1.into(),
181            max_mint: 500_000.into(),
182            min_melt: 1.into(),
183            max_melt: 500_000.into(),
184        }
185    }
186}
187
188#[cfg(feature = "lnbits")]
189#[derive(Debug, Clone, Serialize, Deserialize, Default)]
190pub struct LNbits {
191    pub admin_api_key: String,
192    pub invoice_api_key: String,
193    pub lnbits_api: String,
194    pub fee_percent: f32,
195    pub reserve_fee_min: Amount,
196}
197
198#[cfg(feature = "cln")]
199#[derive(Debug, Clone, Serialize, Deserialize, Default)]
200pub struct Cln {
201    pub rpc_path: PathBuf,
202    #[serde(default)]
203    pub bolt12: bool,
204    pub fee_percent: f32,
205    pub reserve_fee_min: Amount,
206}
207
208#[cfg(feature = "lnd")]
209#[derive(Debug, Clone, Serialize, Deserialize, Default)]
210pub struct Lnd {
211    pub address: String,
212    pub cert_file: PathBuf,
213    pub macaroon_file: PathBuf,
214    pub fee_percent: f32,
215    pub reserve_fee_min: Amount,
216}
217
218#[cfg(feature = "ldk-node")]
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct LdkNode {
221    /// Fee percentage (e.g., 0.02 for 2%)
222    #[serde(default = "default_ldk_fee_percent")]
223    pub fee_percent: f32,
224    /// Minimum reserve fee
225    #[serde(default = "default_ldk_reserve_fee_min")]
226    pub reserve_fee_min: Amount,
227    /// Bitcoin network (mainnet, testnet, signet, regtest)
228    pub bitcoin_network: Option<String>,
229    /// Chain source type (esplora or bitcoinrpc)
230    pub chain_source_type: Option<String>,
231    /// Esplora URL (when chain_source_type = "esplora")
232    pub esplora_url: Option<String>,
233    /// Bitcoin RPC configuration (when chain_source_type = "bitcoinrpc")
234    pub bitcoind_rpc_host: Option<String>,
235    pub bitcoind_rpc_port: Option<u16>,
236    pub bitcoind_rpc_user: Option<String>,
237    pub bitcoind_rpc_password: Option<String>,
238    /// Storage directory path
239    pub storage_dir_path: Option<String>,
240    /// LDK node listening host
241    pub ldk_node_host: Option<String>,
242    /// LDK node listening port
243    pub ldk_node_port: Option<u16>,
244    /// Gossip source type (p2p or rgs)
245    pub gossip_source_type: Option<String>,
246    /// Rapid Gossip Sync URL (when gossip_source_type = "rgs")
247    pub rgs_url: Option<String>,
248    /// Webserver host (defaults to 127.0.0.1)
249    #[serde(default = "default_webserver_host")]
250    pub webserver_host: Option<String>,
251    /// Webserver port
252    #[serde(default = "default_webserver_port")]
253    pub webserver_port: Option<u16>,
254}
255
256#[cfg(feature = "ldk-node")]
257impl Default for LdkNode {
258    fn default() -> Self {
259        Self {
260            fee_percent: default_ldk_fee_percent(),
261            reserve_fee_min: default_ldk_reserve_fee_min(),
262            bitcoin_network: None,
263            chain_source_type: None,
264            esplora_url: None,
265            bitcoind_rpc_host: None,
266            bitcoind_rpc_port: None,
267            bitcoind_rpc_user: None,
268            bitcoind_rpc_password: None,
269            storage_dir_path: None,
270            ldk_node_host: None,
271            ldk_node_port: None,
272            gossip_source_type: None,
273            rgs_url: None,
274            webserver_host: default_webserver_host(),
275            webserver_port: default_webserver_port(),
276        }
277    }
278}
279
280#[cfg(feature = "ldk-node")]
281fn default_ldk_fee_percent() -> f32 {
282    0.04
283}
284
285#[cfg(feature = "ldk-node")]
286fn default_ldk_reserve_fee_min() -> Amount {
287    4.into()
288}
289
290#[cfg(feature = "ldk-node")]
291fn default_webserver_host() -> Option<String> {
292    Some("127.0.0.1".to_string())
293}
294
295#[cfg(feature = "ldk-node")]
296fn default_webserver_port() -> Option<u16> {
297    Some(8091)
298}
299
300#[cfg(feature = "fakewallet")]
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct FakeWallet {
303    pub supported_units: Vec<CurrencyUnit>,
304    pub fee_percent: f32,
305    pub reserve_fee_min: Amount,
306    #[serde(default = "default_min_delay_time")]
307    pub min_delay_time: u64,
308    #[serde(default = "default_max_delay_time")]
309    pub max_delay_time: u64,
310}
311
312#[cfg(feature = "fakewallet")]
313impl Default for FakeWallet {
314    fn default() -> Self {
315        Self {
316            supported_units: vec![CurrencyUnit::Sat],
317            fee_percent: 0.02,
318            reserve_fee_min: 2.into(),
319            min_delay_time: 1,
320            max_delay_time: 3,
321        }
322    }
323}
324
325// Helper functions to provide default values
326#[cfg(feature = "fakewallet")]
327fn default_min_delay_time() -> u64 {
328    1
329}
330
331#[cfg(feature = "fakewallet")]
332fn default_max_delay_time() -> u64 {
333    3
334}
335
336#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
337pub struct GrpcProcessor {
338    pub supported_units: Vec<CurrencyUnit>,
339    pub addr: String,
340    pub port: u16,
341    pub tls_dir: Option<PathBuf>,
342}
343
344#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
345#[serde(rename_all = "lowercase")]
346pub enum DatabaseEngine {
347    #[default]
348    Sqlite,
349    Postgres,
350}
351
352impl std::str::FromStr for DatabaseEngine {
353    type Err = String;
354
355    fn from_str(s: &str) -> Result<Self, Self::Err> {
356        match s.to_lowercase().as_str() {
357            "sqlite" => Ok(DatabaseEngine::Sqlite),
358            "postgres" => Ok(DatabaseEngine::Postgres),
359            _ => Err(format!("Unknown database engine: {s}")),
360        }
361    }
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize, Default)]
365pub struct Database {
366    pub engine: DatabaseEngine,
367    pub postgres: Option<PostgresConfig>,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize, Default)]
371pub struct AuthDatabase {
372    pub postgres: Option<PostgresAuthConfig>,
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct PostgresAuthConfig {
377    pub url: String,
378    pub tls_mode: Option<String>,
379    pub max_connections: Option<usize>,
380    pub connection_timeout_seconds: Option<u64>,
381}
382
383impl Default for PostgresAuthConfig {
384    fn default() -> Self {
385        Self {
386            url: String::new(),
387            tls_mode: Some("disable".to_string()),
388            max_connections: Some(20),
389            connection_timeout_seconds: Some(10),
390        }
391    }
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct PostgresConfig {
396    pub url: String,
397    pub tls_mode: Option<String>,
398    pub max_connections: Option<usize>,
399    pub connection_timeout_seconds: Option<u64>,
400}
401
402impl Default for PostgresConfig {
403    fn default() -> Self {
404        Self {
405            url: String::new(),
406            tls_mode: Some("disable".to_string()),
407            max_connections: Some(20),
408            connection_timeout_seconds: Some(10),
409        }
410    }
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
414#[serde(rename_all = "lowercase")]
415pub enum AuthType {
416    Clear,
417    Blind,
418    #[default]
419    None,
420}
421
422impl std::str::FromStr for AuthType {
423    type Err = String;
424
425    fn from_str(s: &str) -> Result<Self, Self::Err> {
426        match s.to_lowercase().as_str() {
427            "clear" => Ok(AuthType::Clear),
428            "blind" => Ok(AuthType::Blind),
429            "none" => Ok(AuthType::None),
430            _ => Err(format!("Unknown auth type: {s}")),
431        }
432    }
433}
434
435#[derive(Debug, Clone, Default, Serialize, Deserialize)]
436pub struct Auth {
437    #[serde(default)]
438    pub auth_enabled: bool,
439    pub openid_discovery: String,
440    pub openid_client_id: String,
441    pub mint_max_bat: u64,
442    #[serde(default = "default_blind")]
443    pub mint: AuthType,
444    #[serde(default)]
445    pub get_mint_quote: AuthType,
446    #[serde(default)]
447    pub check_mint_quote: AuthType,
448    #[serde(default)]
449    pub melt: AuthType,
450    #[serde(default)]
451    pub get_melt_quote: AuthType,
452    #[serde(default)]
453    pub check_melt_quote: AuthType,
454    #[serde(default = "default_blind")]
455    pub swap: AuthType,
456    #[serde(default = "default_blind")]
457    pub restore: AuthType,
458    #[serde(default)]
459    pub check_proof_state: AuthType,
460}
461
462fn default_blind() -> AuthType {
463    AuthType::Blind
464}
465
466/// CDK settings, derived from `config.toml`
467#[derive(Debug, Clone, Serialize, Deserialize, Default)]
468pub struct Settings {
469    pub info: Info,
470    pub mint_info: MintInfo,
471    pub ln: Ln,
472    #[cfg(feature = "cln")]
473    pub cln: Option<Cln>,
474    #[cfg(feature = "lnbits")]
475    pub lnbits: Option<LNbits>,
476    #[cfg(feature = "lnd")]
477    pub lnd: Option<Lnd>,
478    #[cfg(feature = "ldk-node")]
479    pub ldk_node: Option<LdkNode>,
480    #[cfg(feature = "fakewallet")]
481    pub fake_wallet: Option<FakeWallet>,
482    pub grpc_processor: Option<GrpcProcessor>,
483    pub database: Database,
484    #[cfg(feature = "auth")]
485    pub auth_database: Option<AuthDatabase>,
486    #[cfg(feature = "management-rpc")]
487    pub mint_management_rpc: Option<MintManagementRpc>,
488    pub auth: Option<Auth>,
489    #[cfg(feature = "prometheus")]
490    pub prometheus: Option<Prometheus>,
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize, Default)]
494#[cfg(feature = "prometheus")]
495pub struct Prometheus {
496    pub enabled: bool,
497    pub address: Option<String>,
498    pub port: Option<u16>,
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize, Default)]
502pub struct MintInfo {
503    /// name of the mint and should be recognizable
504    pub name: String,
505    /// hex pubkey of the mint
506    pub pubkey: Option<PublicKey>,
507    /// short description of the mint
508    pub description: String,
509    /// long description
510    pub description_long: Option<String>,
511    /// url to the mint icon
512    pub icon_url: Option<String>,
513    /// message of the day that the wallet must display to the user
514    pub motd: Option<String>,
515    /// Nostr publickey
516    pub contact_nostr_public_key: Option<String>,
517    /// Contact email
518    pub contact_email: Option<String>,
519    /// URL to the terms of service
520    pub tos_url: Option<String>,
521}
522
523#[cfg(feature = "management-rpc")]
524#[derive(Debug, Clone, Serialize, Deserialize, Default)]
525pub struct MintManagementRpc {
526    /// When this is set to `true` the mint use the config file for the initial set up on first start.
527    /// Changes to the `[mint_info]` after this **MUST** be made via the RPC changes to the config file or env vars will be ignored.
528    pub enabled: bool,
529    pub address: Option<String>,
530    pub port: Option<u16>,
531    pub tls_dir_path: Option<PathBuf>,
532}
533
534impl Settings {
535    #[must_use]
536    pub fn new<P>(config_file_name: Option<P>) -> Self
537    where
538        P: Into<PathBuf>,
539    {
540        let default_settings = Self::default();
541        // attempt to construct settings with file
542        let from_file = Self::new_from_default(&default_settings, config_file_name);
543        match from_file {
544            Ok(f) => f,
545            Err(e) => {
546                tracing::error!(
547                    "Error reading config file, falling back to defaults. Error: {e:?}"
548                );
549                default_settings
550            }
551        }
552    }
553
554    fn new_from_default<P>(
555        default: &Settings,
556        config_file_name: Option<P>,
557    ) -> Result<Self, ConfigError>
558    where
559        P: Into<PathBuf>,
560    {
561        let mut default_config_file_name = home::home_dir()
562            .ok_or(ConfigError::NotFound("Config Path".to_string()))?
563            .join("cashu-rs-mint");
564
565        default_config_file_name.push("config.toml");
566        let config: String = match config_file_name {
567            Some(value) => value.into().to_string_lossy().to_string(),
568            None => default_config_file_name.to_string_lossy().to_string(),
569        };
570        let builder = Config::builder();
571        let config: Config = builder
572            // use defaults
573            .add_source(Config::try_from(default)?)
574            // override with file contents
575            .add_source(File::with_name(&config))
576            .build()?;
577        let settings: Settings = config.try_deserialize()?;
578
579        match settings.ln.ln_backend {
580            LnBackend::None => panic!("Ln backend must be set"),
581            #[cfg(feature = "cln")]
582            LnBackend::Cln => assert!(
583                settings.cln.is_some(),
584                "CLN backend requires a valid config."
585            ),
586            #[cfg(feature = "lnbits")]
587            LnBackend::LNbits => assert!(
588                settings.lnbits.is_some(),
589                "LNbits backend requires a valid config"
590            ),
591            #[cfg(feature = "lnd")]
592            LnBackend::Lnd => {
593                assert!(
594                    settings.lnd.is_some(),
595                    "LND backend requires a valid config."
596                )
597            }
598            #[cfg(feature = "ldk-node")]
599            LnBackend::LdkNode => {
600                assert!(
601                    settings.ldk_node.is_some(),
602                    "LDK Node backend requires a valid config."
603                )
604            }
605            #[cfg(feature = "fakewallet")]
606            LnBackend::FakeWallet => assert!(
607                settings.fake_wallet.is_some(),
608                "FakeWallet backend requires a valid config."
609            ),
610            #[cfg(feature = "grpc-processor")]
611            LnBackend::GrpcProcessor => {
612                assert!(
613                    settings.grpc_processor.is_some(),
614                    "GRPC backend requires a valid config."
615                )
616            }
617        }
618
619        Ok(settings)
620    }
621}
622
623#[cfg(test)]
624mod tests {
625
626    use super::*;
627
628    #[test]
629    fn test_info_debug_impl() {
630        // Create a sample Info struct with test data
631        let info = Info {
632            url: "http://example.com".to_string(),
633            listen_host: "127.0.0.1".to_string(),
634            listen_port: 8080,
635            mnemonic: Some("test secret mnemonic phrase".to_string()),
636            input_fee_ppk: Some(100),
637            ..Default::default()
638        };
639
640        // Convert the Info struct to a debug string
641        let debug_output = format!("{info:?}");
642
643        // Verify the debug output contains expected fields
644        assert!(debug_output.contains("url: \"http://example.com\""));
645        assert!(debug_output.contains("listen_host: \"127.0.0.1\""));
646        assert!(debug_output.contains("listen_port: 8080"));
647
648        // The mnemonic should be hashed, not displayed in plaintext
649        assert!(!debug_output.contains("test secret mnemonic phrase"));
650        assert!(debug_output.contains("<hashed: "));
651
652        assert!(debug_output.contains("input_fee_ppk: Some(100)"));
653    }
654
655    #[test]
656    fn test_info_debug_with_empty_mnemonic() {
657        // Test with an empty mnemonic to ensure it doesn't panic
658        let info = Info {
659            url: "http://example.com".to_string(),
660            listen_host: "127.0.0.1".to_string(),
661            listen_port: 8080,
662            mnemonic: Some("".to_string()), // Empty mnemonic
663            enable_swagger_ui: Some(false),
664            ..Default::default()
665        };
666
667        // This should not panic
668        let debug_output = format!("{:?}", info);
669
670        // The empty mnemonic should still be hashed
671        assert!(debug_output.contains("<hashed: "));
672    }
673
674    #[test]
675    fn test_info_debug_with_special_chars() {
676        // Test with a mnemonic containing special characters
677        let info = Info {
678            url: "http://example.com".to_string(),
679            listen_host: "127.0.0.1".to_string(),
680            listen_port: 8080,
681            mnemonic: Some("特殊字符 !@#$%^&*()".to_string()), // Special characters
682            ..Default::default()
683        };
684
685        // This should not panic
686        let debug_output = format!("{:?}", info);
687
688        // The mnemonic with special chars should be hashed
689        assert!(!debug_output.contains("特殊字符 !@#$%^&*()"));
690        assert!(debug_output.contains("<hashed: "));
691    }
692}