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 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 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 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 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 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 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 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 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 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 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 let listen_address = vec![socket_addr.into()];
438
439 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 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 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 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 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 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 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 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}