Skip to main content

cdk_mintd/
setup.rs

1#[cfg(feature = "fakewallet")]
2use std::collections::HashMap;
3#[cfg(feature = "fakewallet")]
4use std::collections::HashSet;
5use std::path::Path;
6#[cfg(feature = "ldk-node")]
7use std::path::PathBuf;
8use std::sync::Arc;
9#[cfg(feature = "bdk")]
10use std::time::Duration;
11
12use async_trait::async_trait;
13#[cfg(feature = "fakewallet")]
14use bip39::rand::{thread_rng, Rng};
15use cdk::cdk_database::KVStore;
16use cdk::cdk_payment::MintPayment;
17use cdk::nuts::CurrencyUnit;
18#[cfg(any(
19    feature = "lnbits",
20    feature = "cln",
21    feature = "lnd",
22    feature = "ldk-node",
23    feature = "bdk",
24    feature = "fakewallet"
25))]
26use cdk::types::FeeReserve;
27
28use crate::config::{self, Settings};
29#[cfg(feature = "cln")]
30use crate::expand_path;
31
32#[async_trait]
33pub trait LnBackendSetup {
34    async fn setup(
35        &self,
36        settings: &Settings,
37        unit: CurrencyUnit,
38        runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
39        work_dir: &Path,
40        kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
41    ) -> anyhow::Result<impl MintPayment<Err = cdk_common::payment::Error>>;
42}
43
44#[async_trait]
45pub trait OnchainBackendSetup {
46    async fn setup(
47        &self,
48        settings: &Settings,
49        unit: CurrencyUnit,
50        runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
51        work_dir: &Path,
52        kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
53    ) -> anyhow::Result<impl MintPayment<Err = cdk_common::payment::Error>>;
54}
55
56#[cfg(feature = "cln")]
57#[async_trait]
58impl LnBackendSetup for config::Cln {
59    async fn setup(
60        &self,
61        _settings: &Settings,
62        _unit: CurrencyUnit,
63        _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
64        _work_dir: &Path,
65        kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
66    ) -> anyhow::Result<cdk_cln::Cln> {
67        // Validate required connection field
68        if self.rpc_path.as_os_str().is_empty() {
69            return Err(anyhow::anyhow!(
70                "CLN rpc_path must be set via config or CDK_MINTD_CLN_RPC_PATH env var"
71            ));
72        }
73
74        let cln_socket = expand_path(
75            self.rpc_path
76                .to_str()
77                .ok_or(anyhow::anyhow!("cln socket not defined"))?,
78        )
79        .ok_or(anyhow::anyhow!("cln socket not defined"))?;
80
81        let fee_reserve = FeeReserve {
82            min_fee_reserve: self.reserve_fee_min,
83            percent_fee_reserve: self.fee_percent,
84        };
85
86        let cln = cdk_cln::Cln::new(
87            cln_socket,
88            fee_reserve,
89            self.expose_private_channels,
90            kv_store.expect("Cln needs kv store"),
91        )
92        .await?;
93
94        Ok(cln)
95    }
96}
97
98#[cfg(feature = "lnbits")]
99#[async_trait]
100impl LnBackendSetup for config::LNbits {
101    async fn setup(
102        &self,
103        _settings: &Settings,
104        _unit: CurrencyUnit,
105        _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
106        _work_dir: &Path,
107        _kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
108    ) -> anyhow::Result<cdk_lnbits::LNbits> {
109        use anyhow::bail;
110
111        // Validate required connection fields
112        if self.admin_api_key.is_empty() {
113            bail!("LNbits admin_api_key must be set via config or CDK_MINTD_LNBITS_ADMIN_API_KEY env var");
114        }
115        if self.invoice_api_key.is_empty() {
116            bail!("LNbits invoice_api_key must be set via config or CDK_MINTD_LNBITS_INVOICE_API_KEY env var");
117        }
118        if self.lnbits_api.is_empty() {
119            bail!(
120                "LNbits lnbits_api must be set via config or CDK_MINTD_LNBITS_LNBITS_API env var"
121            );
122        }
123
124        let admin_api_key = &self.admin_api_key;
125        let invoice_api_key = &self.invoice_api_key;
126
127        let fee_reserve = FeeReserve {
128            min_fee_reserve: self.reserve_fee_min,
129            percent_fee_reserve: self.fee_percent,
130        };
131
132        let lnbits = cdk_lnbits::LNbits::new(
133            admin_api_key.clone(),
134            invoice_api_key.clone(),
135            self.lnbits_api.clone(),
136            fee_reserve,
137        )
138        .await?;
139
140        // Use v1 websocket API
141        lnbits.subscribe_ws().await?;
142
143        Ok(lnbits)
144    }
145}
146
147#[cfg(feature = "lnd")]
148#[async_trait]
149impl LnBackendSetup for config::Lnd {
150    async fn setup(
151        &self,
152        _settings: &Settings,
153        _unit: CurrencyUnit,
154        _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
155        _work_dir: &Path,
156        kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
157    ) -> anyhow::Result<cdk_lnd::Lnd> {
158        use anyhow::bail;
159        // Validate required connection fields
160        if self.address.is_empty() {
161            bail!("LND address must be set via config or CDK_MINTD_LND_ADDRESS env var");
162        }
163        if self.cert_file.as_os_str().is_empty() {
164            bail!("LND cert_file must be set via config or CDK_MINTD_LND_CERT_FILE env var");
165        }
166        if self.macaroon_file.as_os_str().is_empty() {
167            bail!(
168                "LND macaroon_file must be set via config or CDK_MINTD_LND_MACAROON_FILE env var"
169            );
170        }
171
172        let address = &self.address;
173        let cert_file = &self.cert_file;
174        let macaroon_file = &self.macaroon_file;
175
176        let fee_reserve = FeeReserve {
177            min_fee_reserve: self.reserve_fee_min,
178            percent_fee_reserve: self.fee_percent,
179        };
180
181        let lnd = cdk_lnd::Lnd::new(
182            address.to_string(),
183            cert_file.clone(),
184            macaroon_file.clone(),
185            fee_reserve,
186            kv_store.expect("Lnd needs kv store"),
187        )
188        .await?;
189
190        Ok(lnd)
191    }
192}
193
194#[cfg(feature = "fakewallet")]
195#[async_trait]
196impl LnBackendSetup for config::FakeWallet {
197    async fn setup(
198        &self,
199        _settings: &Settings,
200        unit: CurrencyUnit,
201        _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
202        _work_dir: &Path,
203        _kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
204    ) -> anyhow::Result<cdk_fake_wallet::FakeWallet> {
205        let fee_reserve = FeeReserve {
206            min_fee_reserve: self.reserve_fee_min,
207            percent_fee_reserve: self.fee_percent,
208        };
209
210        // calculate random delay time
211        let mut rng = thread_rng();
212        let delay_time = rng.gen_range(self.min_delay_time..=self.max_delay_time);
213
214        let mut custom_payment_methods = HashMap::new();
215        for custom_payment_method in &self.custom_payment_methods {
216            if !custom_payment_method.applies_to_unit(&unit) {
217                continue;
218            }
219
220            let method = custom_payment_method.method().trim().to_lowercase();
221            if method.is_empty() {
222                anyhow::bail!("Fake wallet custom payment method cannot be empty");
223            }
224
225            if matches!(method.as_str(), "bolt11" | "bolt12" | "onchain") {
226                anyhow::bail!(
227                    "Fake wallet custom payment method `{method}` conflicts with a known payment method"
228                );
229            }
230
231            custom_payment_methods.insert(method, "{}".to_string());
232        }
233
234        let fake_wallet = cdk_fake_wallet::FakeWallet::new(
235            fee_reserve,
236            HashMap::default(),
237            HashSet::default(),
238            delay_time,
239            unit,
240        )
241        .with_custom_payment_methods(custom_payment_methods);
242
243        Ok(fake_wallet)
244    }
245}
246
247#[cfg(all(test, feature = "fakewallet"))]
248mod tests {
249    use cdk::cdk_payment::MintPayment;
250
251    use super::*;
252
253    #[tokio::test]
254    async fn fake_wallet_setup_filters_custom_methods_by_unit() {
255        let fake_wallet = config::FakeWallet {
256            supported_units: vec![CurrencyUnit::Sat, CurrencyUnit::Usd],
257            custom_payment_methods: vec![
258                config::FakeWalletCustomPaymentMethod::MethodForUnit {
259                    method: "paypal".to_string(),
260                    unit: CurrencyUnit::Sat,
261                },
262                config::FakeWalletCustomPaymentMethod::MethodForUnit {
263                    method: "venmo".to_string(),
264                    unit: CurrencyUnit::Usd,
265                },
266                config::FakeWalletCustomPaymentMethod::Method("cashapp".to_string()),
267            ],
268            min_delay_time: 0,
269            max_delay_time: 0,
270            ..Default::default()
271        };
272
273        let sat_wallet = fake_wallet
274            .setup(
275                &Settings::default(),
276                CurrencyUnit::Sat,
277                None,
278                Path::new("."),
279                None,
280            )
281            .await
282            .expect("sat fake wallet should set up");
283        let sat_settings = sat_wallet
284            .get_settings()
285            .await
286            .expect("sat fake wallet settings should load");
287
288        assert!(sat_settings.custom.contains_key("paypal"));
289        assert!(sat_settings.custom.contains_key("cashapp"));
290        assert!(!sat_settings.custom.contains_key("venmo"));
291
292        let usd_wallet = fake_wallet
293            .setup(
294                &Settings::default(),
295                CurrencyUnit::Usd,
296                None,
297                Path::new("."),
298                None,
299            )
300            .await
301            .expect("usd fake wallet should set up");
302        let usd_settings = usd_wallet
303            .get_settings()
304            .await
305            .expect("usd fake wallet settings should load");
306
307        assert!(!usd_settings.custom.contains_key("paypal"));
308        assert!(usd_settings.custom.contains_key("cashapp"));
309        assert!(usd_settings.custom.contains_key("venmo"));
310    }
311}
312
313#[cfg(feature = "grpc-processor")]
314#[async_trait]
315impl LnBackendSetup for config::GrpcProcessor {
316    async fn setup(
317        &self,
318        _settings: &Settings,
319        _unit: CurrencyUnit,
320        _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
321        _work_dir: &Path,
322        _kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
323    ) -> anyhow::Result<cdk_payment_processor::PaymentProcessorClient> {
324        let payment_processor = cdk_payment_processor::PaymentProcessorClient::new(
325            &self.addr,
326            self.port,
327            self.tls_dir.clone(),
328        )
329        .await?;
330
331        Ok(payment_processor)
332    }
333}
334
335#[cfg(feature = "ldk-node")]
336#[async_trait]
337impl LnBackendSetup for config::LdkNode {
338    async fn setup(
339        &self,
340        settings: &Settings,
341        _unit: CurrencyUnit,
342        _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
343        work_dir: &Path,
344        _kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
345    ) -> anyhow::Result<cdk_ldk_node::CdkLdkNode> {
346        use std::net::SocketAddr;
347
348        use anyhow::bail;
349        use bip39::Mnemonic;
350        use bitcoin::Network;
351
352        let fee_reserve = FeeReserve {
353            min_fee_reserve: self.reserve_fee_min,
354            percent_fee_reserve: self.fee_percent,
355        };
356
357        // Parse network from config
358        let network_str = self
359            .bitcoin_network
360            .as_ref()
361            .ok_or_else(|| anyhow::anyhow!("LDK Node bitcoin_network must be set via config or CDK_MINTD_LDK_NODE_BITCOIN_NETWORK env var"))?;
362
363        let network = match network_str.to_lowercase().as_str() {
364            "mainnet" | "bitcoin" => Network::Bitcoin,
365            "testnet" => Network::Testnet,
366            "signet" => Network::Signet,
367            "regtest" => Network::Regtest,
368            _ => bail!("Unknown LDK Node bitcoin_network: {}", network_str),
369        };
370
371        // Parse chain source from config
372        let chain_source = match self
373            .chain_source_type
374            .as_ref()
375            .map(|s| s.to_lowercase())
376            .as_deref()
377            .unwrap_or("esplora")
378        {
379            "bitcoinrpc" => {
380                let host = self
381                    .bitcoind_rpc_host
382                    .clone()
383                    .unwrap_or_else(|| "127.0.0.1".to_string());
384                let port = self.bitcoind_rpc_port.unwrap_or(18443);
385                let user = self
386                    .bitcoind_rpc_user
387                    .clone()
388                    .unwrap_or_else(|| "testuser".to_string());
389                let password = self
390                    .bitcoind_rpc_password
391                    .clone()
392                    .unwrap_or_else(|| "testpass".to_string());
393
394                cdk_ldk_node::ChainSource::BitcoinRpc(cdk_ldk_node::BitcoinRpcConfig {
395                    host,
396                    port,
397                    user,
398                    password,
399                })
400            }
401            _ => {
402                let esplora_url = self
403                    .esplora_url
404                    .clone()
405                    .unwrap_or_else(|| "https://mutinynet.com/api".to_string());
406                cdk_ldk_node::ChainSource::Esplora(esplora_url)
407            }
408        };
409
410        // Parse gossip source from config
411        let gossip_source = match self.rgs_url.clone() {
412            Some(rgs_url) => cdk_ldk_node::GossipSource::RapidGossipSync(rgs_url),
413            None => cdk_ldk_node::GossipSource::P2P,
414        };
415
416        // Get storage directory path
417        let storage_dir_path = if let Some(dir_path) = &self.storage_dir_path {
418            dir_path.clone()
419        } else {
420            let mut work_dir = work_dir.to_path_buf();
421            work_dir.push("ldk-node");
422            work_dir.to_string_lossy().to_string()
423        };
424
425        // Get LDK node listen address
426        let host = self
427            .ldk_node_host
428            .clone()
429            .unwrap_or_else(|| "127.0.0.1".to_string());
430        let port = self.ldk_node_port.unwrap_or(8090);
431
432        let socket_addr = SocketAddr::new(host.parse()?, port);
433
434        // Parse socket address using ldk_node's SocketAddress
435        // We need to get the actual socket address struct from ldk_node
436        // For now, let's construct it manually based on the cdk-ldk-node implementation
437        let listen_address = vec![socket_addr.into()];
438
439        // Check if ldk_node_mnemonic is provided in the ldk_node config
440        let mnemonic_opt = settings
441            .clone()
442            .ldk_node
443            .as_ref()
444            .and_then(|ldk_config| ldk_config.ldk_node_mnemonic.clone());
445
446        // Only set seed if mnemonic is explicitly provided
447        // This maintains backward compatibility with existing nodes that use LDK's default seed storage
448        let seed = if let Some(mnemonic_str) = mnemonic_opt {
449            Some(
450                mnemonic_str
451                    .parse::<Mnemonic>()
452                    .map_err(|e| anyhow::anyhow!("invalid ldk_node_mnemonic in config: {e}"))?,
453            )
454        } else {
455            // Check if this is a new node or an existing node
456            let storage_dir = PathBuf::from(&storage_dir_path);
457            let keys_seed_file = storage_dir.join("keys_seed");
458
459            if !keys_seed_file.exists() {
460                bail!("ldk_node_mnemonic should be set in the [ldk_node] configuration section.");
461            }
462
463            // Existing node with stored seed, don't set a mnemonic
464            None
465        };
466
467        let ldk_node_settings = settings
468            .ldk_node
469            .as_ref()
470            .ok_or_else(|| anyhow::anyhow!("ldk_node configuration is required"))?;
471        let announce_addrs: Vec<_> = ldk_node_settings
472            .ldk_node_announce_addresses
473            .as_ref()
474            .map(|addrs| addrs.iter().filter_map(|addr| addr.parse().ok()).collect())
475            .unwrap_or_default();
476
477        let mut ldk_node_builder = cdk_ldk_node::CdkLdkNodeBuilder::new(
478            network,
479            chain_source,
480            gossip_source,
481            storage_dir_path,
482            fee_reserve,
483            listen_address,
484        );
485
486        // Only set seed if provided
487        if let Some(mnemonic) = seed {
488            ldk_node_builder = ldk_node_builder.with_seed(mnemonic);
489        }
490
491        if !announce_addrs.is_empty() {
492            ldk_node_builder = ldk_node_builder.with_announcement_address(announce_addrs)
493        }
494        // Configure webserver address if specified
495        let webserver_addr = if let Some(host) = &self.webserver_host {
496            let port = self.webserver_port.unwrap_or(8091);
497            let socket_addr: SocketAddr = format!("{host}:{port}").parse()?;
498            Some(socket_addr)
499        } else if self.webserver_port.is_some() {
500            // If only port is specified, use default host
501            let port = self.webserver_port.unwrap_or(8091);
502            let socket_addr: SocketAddr = format!("127.0.0.1:{port}").parse()?;
503            Some(socket_addr)
504        } else {
505            // Use default webserver address if nothing is configured
506            Some(cdk_ldk_node::CdkLdkNode::default_web_addr())
507        };
508
509        if let Some(log_dir_path) = ldk_node_settings.log_dir_path.as_ref() {
510            ldk_node_builder = ldk_node_builder.with_log_dir_path(log_dir_path.clone());
511        }
512        let mut ldk_node = ldk_node_builder.build()?;
513        ldk_node.set_web_addr(webserver_addr);
514
515        Ok(ldk_node)
516    }
517}
518
519#[cfg(feature = "bdk")]
520#[async_trait]
521impl OnchainBackendSetup for crate::config::Bdk {
522    async fn setup(
523        &self,
524        settings: &Settings,
525        _unit: CurrencyUnit,
526        _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
527        work_dir: &Path,
528        kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
529    ) -> anyhow::Result<cdk_bdk::CdkBdk> {
530        use anyhow::bail;
531        use bip39::Mnemonic;
532        use bitcoin::Network;
533
534        self.validate().map_err(anyhow::Error::msg)?;
535
536        let fee_reserve = FeeReserve {
537            min_fee_reserve: self.reserve_fee_min,
538            percent_fee_reserve: self.fee_percent,
539        };
540
541        let network_str = self.network.as_ref().ok_or_else(|| {
542            anyhow::anyhow!("BDK network must be set via config or CDK_MINTD_BDK_NETWORK env var")
543        })?;
544
545        let network = match network_str.to_lowercase().as_str() {
546            "mainnet" | "bitcoin" => Network::Bitcoin,
547            "testnet" => Network::Testnet,
548            "signet" => Network::Signet,
549            "regtest" => Network::Regtest,
550            _ => bail!("Unknown BDK network: {}", network_str),
551        };
552
553        let chain_source_type = self
554            .chain_source_type
555            .as_deref()
556            .unwrap_or("bitcoinrpc")
557            .to_lowercase();
558
559        let chain_source = match chain_source_type.as_str() {
560            "esplora" => {
561                let esplora_url = self
562                    .esplora_url
563                    .clone()
564                    .unwrap_or_else(|| "https://mutinynet.com/api".to_string());
565                cdk_bdk::ChainSource::Esplora(cdk_bdk::EsploraConfig {
566                    url: esplora_url,
567                    parallel_requests: self.esplora_parallel_requests.max(1),
568                })
569            }
570            "bitcoinrpc" => {
571                let host = self
572                    .bitcoind_rpc_host
573                    .clone()
574                    .unwrap_or_else(|| "127.0.0.1".to_string());
575                let port = self.bitcoind_rpc_port.unwrap_or(18443);
576                let user = self
577                    .bitcoind_rpc_user
578                    .clone()
579                    .unwrap_or_else(|| "user".to_string());
580                let password = self
581                    .bitcoind_rpc_password
582                    .clone()
583                    .unwrap_or_else(|| "pass".to_string());
584
585                cdk_bdk::ChainSource::BitcoinRpc(cdk_bdk::BitcoinRpcConfig {
586                    host,
587                    port,
588                    user,
589                    password,
590                })
591            }
592            _ => bail!("Unknown BDK chain_source_type: {}", chain_source_type),
593        };
594
595        let mnemonic = match &self.mnemonic {
596            Some(m) => Mnemonic::parse(m)?,
597            None => bail!("BDK mnemonic must be set"),
598        };
599
600        let min_receive_amount_sat = settings
601            .onchain
602            .as_ref()
603            .map(|onchain| onchain.min_mint.to_u64().max(self.min_receive_amount_sat))
604            .unwrap_or(self.min_receive_amount_sat);
605
606        let bdk = cdk_bdk::CdkBdk::new(
607            mnemonic,
608            network,
609            chain_source,
610            work_dir.to_string_lossy().to_string(),
611            fee_reserve,
612            kv_store.ok_or_else(|| anyhow::anyhow!("BDK backend requires a KV store"))?,
613            Some(self.batch_config.clone().into()),
614            self.num_confs,
615            min_receive_amount_sat,
616            self.min_send_amount_sat,
617            self.sync_interval_secs,
618            None,
619            None,
620        )?;
621
622        Ok(bdk)
623    }
624}
625
626#[cfg(feature = "bdk")]
627impl From<crate::config::BatchConfig> for cdk_bdk::BatchConfig {
628    fn from(config: crate::config::BatchConfig) -> Self {
629        let target_block_time = Duration::from_secs(config.target_block_time_secs);
630        let standard_deadline = config
631            .standard_deadline_secs
632            .map(Duration::from_secs)
633            .unwrap_or_else(|| {
634                cdk_bdk::BatchConfig::deadline_for_target_blocks(
635                    cdk_bdk::PaymentTier::Standard,
636                    target_block_time,
637                )
638            });
639        let economy_deadline = config
640            .economy_deadline_secs
641            .map(Duration::from_secs)
642            .unwrap_or_else(|| {
643                cdk_bdk::BatchConfig::deadline_for_target_blocks(
644                    cdk_bdk::PaymentTier::Economy,
645                    target_block_time,
646                )
647            });
648        let fee_estimation = cdk_bdk::FeeEstimationConfig {
649            fallback_sat_per_vb: config.fee_fallback_sat_per_vb,
650            cache_ttl_secs: config.fee_cache_ttl_secs,
651            quote_max_input_count: config.quote_max_input_count,
652            quote_fixed_safety_sat: config.quote_fixed_safety_sat,
653            quote_safety_multiplier: config.quote_safety_multiplier,
654        };
655
656        Self {
657            poll_interval: Duration::from_secs(config.poll_interval_secs),
658            max_batch_size: config.max_batch_size,
659            target_block_time,
660            standard_deadline,
661            economy_deadline,
662            max_intent_age: Some(
663                economy_deadline.saturating_add(Duration::from_secs(config.poll_interval_secs)),
664            ),
665            fee_options: config
666                .fee_options
667                .iter()
668                .map(|tier| {
669                    cdk_bdk::PaymentTier::from_config_name(tier)
670                        .expect("BDK fee_options should be validated before setup")
671                })
672                .collect(),
673            fee_estimation,
674        }
675    }
676}