1#[cfg(feature = "fakewallet")]
2use std::collections::HashMap;
3#[cfg(feature = "fakewallet")]
4use std::collections::HashSet;
5use std::path::Path;
6#[cfg(any(feature = "grpc-processor", 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 = "cln",
20 feature = "lnd",
21 feature = "ldk-node",
22 feature = "bdk",
23 feature = "fakewallet"
24))]
25use cdk::types::FeeReserve;
26
27use crate::config::{self, Settings};
28#[cfg(feature = "cln")]
29use crate::expand_path;
30
31#[async_trait]
32pub trait PaymentBackendSetup {
33 async fn setup(
34 &self,
35 settings: &Settings,
36 unit: CurrencyUnit,
37 runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
38 work_dir: &Path,
39 kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
40 ) -> anyhow::Result<impl MintPayment<Err = cdk_common::payment::Error>>;
41}
42
43#[async_trait]
44pub trait OnchainBackendSetup {
45 async fn setup(
46 &self,
47 settings: &Settings,
48 unit: CurrencyUnit,
49 runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
50 work_dir: &Path,
51 kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
52 ) -> anyhow::Result<impl MintPayment<Err = cdk_common::payment::Error>>;
53}
54
55#[cfg(feature = "cln")]
56#[async_trait]
57impl PaymentBackendSetup for config::Cln {
58 async fn setup(
59 &self,
60 _settings: &Settings,
61 _unit: CurrencyUnit,
62 _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
63 _work_dir: &Path,
64 kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
65 ) -> anyhow::Result<cdk_cln::Cln> {
66 if self.rpc_path.as_os_str().is_empty() {
68 return Err(anyhow::anyhow!(
69 "CLN rpc_path must be set in [cln].rpc_path"
70 ));
71 }
72
73 let cln_socket = expand_path(
74 self.rpc_path
75 .to_str()
76 .ok_or(anyhow::anyhow!("cln socket not defined"))?,
77 )
78 .ok_or(anyhow::anyhow!("cln socket not defined"))?;
79
80 let fee_reserve = FeeReserve {
81 min_fee_reserve: self.reserve_fee_min,
82 percent_fee_reserve: self.fee_percent,
83 };
84
85 let cln = cdk_cln::Cln::new(
86 cln_socket,
87 fee_reserve,
88 self.expose_private_channels,
89 kv_store.expect("Cln needs kv store"),
90 )
91 .await?
92 .with_bolt12(self.bolt12);
93
94 Ok(cln)
95 }
96}
97
98#[cfg(feature = "lnd")]
99#[async_trait]
100impl PaymentBackendSetup for config::Lnd {
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_lnd::Lnd> {
109 use anyhow::bail;
110 if self.address.is_empty() {
112 bail!("LND address must be set in [lnd].address");
113 }
114 if self.cert_file.as_os_str().is_empty() {
115 bail!("LND cert_file must be set in [lnd].cert_file");
116 }
117 if self.macaroon_file.as_os_str().is_empty() {
118 bail!("LND macaroon_file must be set in [lnd].macaroon_file");
119 }
120
121 let address = &self.address;
122 let cert_file = &self.cert_file;
123 let macaroon_file = &self.macaroon_file;
124
125 let fee_reserve = FeeReserve {
126 min_fee_reserve: self.reserve_fee_min,
127 percent_fee_reserve: self.fee_percent,
128 };
129
130 let lnd = cdk_lnd::Lnd::new(
131 address.to_string(),
132 cert_file.clone(),
133 macaroon_file.clone(),
134 fee_reserve,
135 kv_store.expect("Lnd needs kv store"),
136 )
137 .await?;
138
139 Ok(lnd)
140 }
141}
142
143#[cfg(feature = "fakewallet")]
144#[async_trait]
145impl PaymentBackendSetup for config::FakeWallet {
146 async fn setup(
147 &self,
148 _settings: &Settings,
149 unit: CurrencyUnit,
150 _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
151 _work_dir: &Path,
152 _kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
153 ) -> anyhow::Result<cdk_fake_wallet::FakeWallet> {
154 let fee_reserve = FeeReserve {
155 min_fee_reserve: self.reserve_fee_min,
156 percent_fee_reserve: self.fee_percent,
157 };
158
159 let mut rng = thread_rng();
161 let delay_time = rng.gen_range(self.min_delay_time..=self.max_delay_time);
162
163 let mut custom_payment_methods = HashMap::new();
164 for custom_payment_method in &self.custom_payment_methods {
165 if !custom_payment_method.applies_to_unit(&unit) {
166 continue;
167 }
168
169 let method = custom_payment_method.method().trim().to_lowercase();
170 if method.is_empty() {
171 anyhow::bail!("Fake wallet custom payment method cannot be empty");
172 }
173
174 if matches!(method.as_str(), "bolt11" | "bolt12" | "onchain") {
175 anyhow::bail!(
176 "Fake wallet custom payment method `{method}` conflicts with a known payment method"
177 );
178 }
179
180 custom_payment_methods.insert(method, "{}".to_string());
181 }
182
183 let fake_wallet = cdk_fake_wallet::FakeWallet::new(
184 fee_reserve,
185 HashMap::default(),
186 HashSet::default(),
187 delay_time,
188 unit,
189 )
190 .with_custom_payment_methods(custom_payment_methods);
191
192 Ok(fake_wallet)
193 }
194}
195
196#[cfg(all(test, feature = "fakewallet"))]
197mod tests {
198 use cdk::cdk_payment::MintPayment;
199
200 use super::*;
201
202 #[tokio::test]
203 async fn fake_wallet_setup_filters_custom_methods_by_unit() {
204 let fake_wallet = config::FakeWallet {
205 supported_units: vec![CurrencyUnit::Sat, CurrencyUnit::Usd],
206 custom_payment_methods: vec![
207 config::FakeWalletCustomPaymentMethod::MethodForUnit {
208 method: "paypal".to_string(),
209 unit: CurrencyUnit::Sat,
210 },
211 config::FakeWalletCustomPaymentMethod::MethodForUnit {
212 method: "venmo".to_string(),
213 unit: CurrencyUnit::Usd,
214 },
215 config::FakeWalletCustomPaymentMethod::Method("cashapp".to_string()),
216 ],
217 min_delay_time: 0,
218 max_delay_time: 0,
219 ..Default::default()
220 };
221
222 let sat_wallet = fake_wallet
223 .setup(
224 &Settings::default(),
225 CurrencyUnit::Sat,
226 None,
227 Path::new("."),
228 None,
229 )
230 .await
231 .expect("sat fake wallet should set up");
232 let sat_settings = sat_wallet
233 .get_settings()
234 .await
235 .expect("sat fake wallet settings should load");
236
237 assert!(sat_settings.custom.contains_key("paypal"));
238 assert!(sat_settings.custom.contains_key("cashapp"));
239 assert!(!sat_settings.custom.contains_key("venmo"));
240
241 let usd_wallet = fake_wallet
242 .setup(
243 &Settings::default(),
244 CurrencyUnit::Usd,
245 None,
246 Path::new("."),
247 None,
248 )
249 .await
250 .expect("usd fake wallet should set up");
251 let usd_settings = usd_wallet
252 .get_settings()
253 .await
254 .expect("usd fake wallet settings should load");
255
256 assert!(!usd_settings.custom.contains_key("paypal"));
257 assert!(usd_settings.custom.contains_key("cashapp"));
258 assert!(usd_settings.custom.contains_key("venmo"));
259 }
260}
261
262#[cfg(feature = "grpc-processor")]
263#[async_trait]
264impl PaymentBackendSetup for config::GrpcProcessor {
265 async fn setup(
266 &self,
267 _settings: &Settings,
268 _unit: CurrencyUnit,
269 _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
270 _work_dir: &Path,
271 _kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
272 ) -> anyhow::Result<cdk_payment_processor::PaymentProcessorClient> {
273 let tls_dir = grpc_processor_tls_dir(self)?;
274
275 let payment_processor =
276 cdk_payment_processor::PaymentProcessorClient::new(&self.address, self.port, tls_dir)
277 .await?;
278
279 Ok(payment_processor)
280 }
281}
282
283#[cfg(feature = "grpc-processor")]
284fn grpc_processor_tls_dir(processor: &config::GrpcProcessor) -> anyhow::Result<Option<PathBuf>> {
285 match (&processor.tls_dir, processor.allow_insecure) {
286 (Some(tls_dir), _) => Ok(Some(tls_dir.clone())),
287 (None, false) => anyhow::bail!(
288 "gRPC payment processor TLS is not configured. Set [grpc_processor].tls_dir \
289 or [grpc_processor].allow_insecure = true to connect without TLS"
290 ),
291 (None, true) => {
292 tracing::warn!(
293 address = %processor.address,
294 port = processor.port,
295 "SECURITY WARNING: connecting to the gRPC payment processor without TLS because \
296 allow_insecure is true; traffic is unencrypted and unauthenticated and can be \
297 intercepted or modified"
298 );
299
300 Ok(None)
301 }
302 }
303}
304
305#[cfg(all(test, feature = "grpc-processor"))]
306mod grpc_processor_tests {
307 use super::grpc_processor_tls_dir;
308 use crate::config::GrpcProcessor;
309
310 #[test]
311 fn remote_plaintext_processor_requires_explicit_opt_in() {
312 let mut processor = GrpcProcessor {
313 address: "192.0.2.1".to_string(),
314 ..Default::default()
315 };
316
317 let error = grpc_processor_tls_dir(&processor)
318 .expect_err("plaintext should require explicit opt-in");
319 assert!(error
320 .to_string()
321 .contains("allow_insecure = true to connect without TLS"));
322
323 processor.allow_insecure = true;
324 assert_eq!(
325 grpc_processor_tls_dir(&processor).expect("remote plaintext should be allowed"),
326 None
327 );
328 }
329}
330
331#[cfg(feature = "ldk-node")]
332impl config::LdkNode {
333 fn chain_source(&self) -> anyhow::Result<cdk_ldk_node::ChainSource> {
334 use anyhow::bail;
335
336 let chain_source_type = self
337 .chain_source_type
338 .as_ref()
339 .map(|source| source.to_lowercase())
340 .unwrap_or_else(|| "esplora".to_string());
341
342 match chain_source_type.as_str() {
343 "esplora" => {
344 let esplora_url = self.esplora_url.clone().ok_or_else(|| {
345 anyhow::anyhow!(
346 "LDK Node esplora_url must be set when chain_source_type is esplora"
347 )
348 })?;
349 Ok(cdk_ldk_node::ChainSource::Esplora(esplora_url))
350 }
351 "electrum" => {
352 let electrum_url = self.electrum_url.clone().ok_or_else(|| {
353 anyhow::anyhow!(
354 "LDK Node electrum_url must be set when chain_source_type is electrum"
355 )
356 })?;
357 Ok(cdk_ldk_node::ChainSource::Electrum(electrum_url))
358 }
359 "bitcoinrpc" => {
360 let host = self
361 .bitcoind_rpc_host
362 .clone()
363 .unwrap_or_else(|| "127.0.0.1".to_string());
364 let port = self.bitcoind_rpc_port.unwrap_or(18443);
365 let user = self
366 .bitcoind_rpc_user
367 .clone()
368 .unwrap_or_else(|| "testuser".to_string());
369 let password = self
370 .bitcoind_rpc_password
371 .clone()
372 .unwrap_or_else(|| "testpass".to_string());
373
374 Ok(cdk_ldk_node::ChainSource::BitcoinRpc(
375 cdk_ldk_node::BitcoinRpcConfig {
376 host,
377 port,
378 user,
379 password,
380 },
381 ))
382 }
383 _ => bail!("Unknown LDK Node chain_source_type: {chain_source_type}"),
384 }
385 }
386}
387
388#[cfg(all(test, feature = "ldk-node"))]
389mod ldk_node_tests {
390 use super::*;
391
392 #[test]
393 fn parses_electrum_chain_source() {
394 let config = config::LdkNode {
395 chain_source_type: Some("ElEcTrUm".to_string()),
396 electrum_url: Some("ssl://electrum.example.com:50002".to_string()),
397 ..Default::default()
398 };
399
400 match config.chain_source().expect("electrum config should parse") {
401 cdk_ldk_node::ChainSource::Electrum(url) => {
402 assert_eq!(url, "ssl://electrum.example.com:50002");
403 }
404 _ => panic!("expected an Electrum chain source"),
405 }
406 }
407
408 #[test]
409 fn esplora_chain_source_requires_url() {
410 let config = config::LdkNode {
411 chain_source_type: Some("esplora".to_string()),
412 ..Default::default()
413 };
414
415 let error = config
416 .chain_source()
417 .expect_err("esplora config without a URL should fail");
418
419 assert!(error.to_string().contains("esplora_url must be set"));
420 }
421
422 #[test]
423 fn electrum_chain_source_requires_url() {
424 let config = config::LdkNode {
425 chain_source_type: Some("electrum".to_string()),
426 ..Default::default()
427 };
428
429 let error = config
430 .chain_source()
431 .expect_err("electrum config without a URL should fail");
432
433 assert!(error.to_string().contains("electrum_url must be set"));
434 }
435
436 #[test]
437 fn rejects_unknown_chain_source() {
438 let config = config::LdkNode {
439 chain_source_type: Some("unknown".to_string()),
440 ..Default::default()
441 };
442
443 let error = config
444 .chain_source()
445 .expect_err("unknown chain source should fail");
446
447 assert_eq!(
448 error.to_string(),
449 "Unknown LDK Node chain_source_type: unknown"
450 );
451 }
452}
453
454#[cfg(feature = "ldk-node")]
455#[async_trait]
456impl PaymentBackendSetup for config::LdkNode {
457 async fn setup(
458 &self,
459 settings: &Settings,
460 _unit: CurrencyUnit,
461 _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
462 work_dir: &Path,
463 kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
464 ) -> anyhow::Result<cdk_ldk_node::CdkLdkNode> {
465 use std::net::SocketAddr;
466
467 use anyhow::bail;
468 use bip39::Mnemonic;
469 use bitcoin::Network;
470
471 let fee_reserve = FeeReserve {
472 min_fee_reserve: self.reserve_fee_min,
473 percent_fee_reserve: self.fee_percent,
474 };
475
476 let network_str = self.bitcoin_network.as_ref().ok_or_else(|| {
478 anyhow::anyhow!("LDK Node bitcoin_network must be set in [ldk_node].bitcoin_network")
479 })?;
480
481 let network = match network_str.to_lowercase().as_str() {
482 "mainnet" | "bitcoin" => Network::Bitcoin,
483 "testnet" => Network::Testnet,
484 "signet" => Network::Signet,
485 "regtest" => Network::Regtest,
486 _ => bail!("Unknown LDK Node bitcoin_network: {}", network_str),
487 };
488
489 let chain_source = self.chain_source()?;
491
492 let gossip_source = match self.rgs_url.clone() {
494 Some(rgs_url) => cdk_ldk_node::GossipSource::RapidGossipSync(rgs_url),
495 None => cdk_ldk_node::GossipSource::P2P,
496 };
497
498 let storage_dir_path = if let Some(dir_path) = &self.storage_dir_path {
500 dir_path.clone()
501 } else {
502 let mut work_dir = work_dir.to_path_buf();
503 work_dir.push("ldk-node");
504 work_dir.to_string_lossy().to_string()
505 };
506
507 let host = self
509 .ldk_node_host
510 .clone()
511 .unwrap_or_else(|| "127.0.0.1".to_string());
512 let port = self.ldk_node_port.unwrap_or(8090);
513
514 let socket_addr = SocketAddr::new(host.parse()?, port);
515
516 let listen_address = vec![socket_addr.into()];
520
521 let mnemonic_opt = settings
523 .clone()
524 .ldk_node
525 .as_ref()
526 .and_then(|ldk_config| ldk_config.ldk_node_mnemonic.clone());
527
528 let seed = if let Some(mnemonic_str) = mnemonic_opt {
531 Some(
532 mnemonic_str
533 .parse::<Mnemonic>()
534 .map_err(|e| anyhow::anyhow!("invalid ldk_node_mnemonic in config: {e}"))?,
535 )
536 } else {
537 let storage_dir = PathBuf::from(&storage_dir_path);
539 let keys_seed_file = storage_dir.join("keys_seed");
540
541 if !keys_seed_file.exists() {
542 bail!("ldk_node_mnemonic should be set in the [ldk_node] configuration section.");
543 }
544
545 None
547 };
548
549 let ldk_node_settings = settings
550 .ldk_node
551 .as_ref()
552 .ok_or_else(|| anyhow::anyhow!("ldk_node configuration is required"))?;
553 let announce_addrs: Vec<_> = ldk_node_settings
554 .ldk_node_announce_addresses
555 .as_ref()
556 .map(|addrs| addrs.iter().filter_map(|addr| addr.parse().ok()).collect())
557 .unwrap_or_default();
558
559 let kv_store = kv_store.ok_or_else(|| anyhow::anyhow!("LdkNode needs a KV store"))?;
560
561 let mut ldk_node_builder = cdk_ldk_node::CdkLdkNodeBuilder::new(
562 network,
563 chain_source,
564 gossip_source,
565 storage_dir_path,
566 fee_reserve,
567 listen_address,
568 kv_store,
569 );
570
571 if let Some(mnemonic) = seed {
573 ldk_node_builder = ldk_node_builder.with_seed(mnemonic);
574 }
575
576 if !announce_addrs.is_empty() {
577 ldk_node_builder = ldk_node_builder.with_announcement_address(announce_addrs)
578 }
579 let webserver_addr = if let Some(host) = &self.webserver_host {
581 let port = self.webserver_port.unwrap_or(8091);
582 let socket_addr: SocketAddr = format!("{host}:{port}").parse()?;
583 Some(socket_addr)
584 } else if self.webserver_port.is_some() {
585 let port = self.webserver_port.unwrap_or(8091);
587 let socket_addr: SocketAddr = format!("127.0.0.1:{port}").parse()?;
588 Some(socket_addr)
589 } else {
590 Some(cdk_ldk_node::CdkLdkNode::default_web_addr())
592 };
593
594 if let Some(log_dir_path) = ldk_node_settings.log_dir_path.as_ref() {
595 ldk_node_builder = ldk_node_builder.with_log_dir_path(log_dir_path.clone());
596 }
597 let mut ldk_node = ldk_node_builder.build()?;
598 ldk_node.set_web_addr(webserver_addr);
599
600 Ok(ldk_node)
601 }
602}
603
604#[cfg(feature = "bdk")]
605impl crate::config::Bdk {
606 fn wallet_identity(&self) -> Result<(bip39::Mnemonic, bitcoin::Network), cdk_bdk::Error> {
607 let network_str = self.network.as_ref().ok_or_else(|| {
608 cdk_bdk::Error::InvalidConfig("BDK network must be set in [bdk].network".to_string())
609 })?;
610 let network = match network_str.to_lowercase().as_str() {
611 "mainnet" | "bitcoin" => bitcoin::Network::Bitcoin,
612 "testnet" => bitcoin::Network::Testnet,
613 "signet" => bitcoin::Network::Signet,
614 "regtest" => bitcoin::Network::Regtest,
615 _ => {
616 return Err(cdk_bdk::Error::InvalidConfig(format!(
617 "Unknown BDK network: {network_str}"
618 )));
619 }
620 };
621 let mnemonic = self
622 .mnemonic
623 .as_ref()
624 .ok_or_else(|| cdk_bdk::Error::InvalidConfig("BDK mnemonic must be set".to_string()))?;
625 let mnemonic = bip39::Mnemonic::parse(mnemonic)
626 .map_err(|error| cdk_bdk::Error::InvalidConfig(error.to_string()))?;
627
628 Ok((mnemonic, network))
629 }
630
631 pub(crate) fn validate_wallet_identity(&self) -> Result<(), cdk_bdk::Error> {
632 self.wallet_identity().map(|_| ())
633 }
634
635 pub(crate) fn validate_existing_wallet(&self, work_dir: &Path) -> Result<(), cdk_bdk::Error> {
636 let (mnemonic, network) = self.wallet_identity()?;
637 cdk_bdk::validate_existing_wallet(mnemonic, network, work_dir)?;
638 Ok(())
639 }
640
641 fn chain_source(&self) -> anyhow::Result<cdk_bdk::ChainSource> {
642 use anyhow::bail;
643
644 let chain_source_type = self
645 .chain_source_type
646 .as_deref()
647 .unwrap_or("bitcoinrpc")
648 .to_lowercase();
649
650 match chain_source_type.as_str() {
651 "esplora" => {
652 let esplora_url = self.esplora_url.clone().ok_or_else(|| {
653 anyhow::anyhow!("BDK esplora_url must be set when chain_source_type is esplora")
654 })?;
655 Ok(cdk_bdk::ChainSource::Esplora(cdk_bdk::EsploraConfig {
656 url: esplora_url,
657 parallel_requests: self.esplora_parallel_requests.max(1),
658 }))
659 }
660 "electrum" => {
661 let electrum_url = self.electrum_url.clone().ok_or_else(|| {
662 anyhow::anyhow!(
663 "BDK electrum_url must be set when chain_source_type is electrum"
664 )
665 })?;
666 if self.electrum_batch_size == 0 {
667 bail!("BDK electrum_batch_size must be greater than zero");
668 }
669 Ok(cdk_bdk::ChainSource::Electrum(cdk_bdk::ElectrumConfig {
670 url: electrum_url,
671 batch_size: self.electrum_batch_size,
672 }))
673 }
674 "bitcoinrpc" => {
675 let host = self
676 .bitcoind_rpc_host
677 .clone()
678 .unwrap_or_else(|| "127.0.0.1".to_string());
679 let port = self.bitcoind_rpc_port.unwrap_or(18443);
680 let user = self
681 .bitcoind_rpc_user
682 .clone()
683 .unwrap_or_else(|| "user".to_string());
684 let password = self
685 .bitcoind_rpc_password
686 .clone()
687 .unwrap_or_else(|| "pass".to_string());
688
689 Ok(cdk_bdk::ChainSource::BitcoinRpc(
690 cdk_bdk::BitcoinRpcConfig {
691 host,
692 port,
693 user,
694 password,
695 wallet_rescan_from_height: self.wallet_rescan_from_height,
696 },
697 ))
698 }
699 _ => bail!("Unknown BDK chain_source_type: {chain_source_type}"),
700 }
701 }
702}
703
704#[cfg(all(test, feature = "bdk"))]
705mod bdk_tests {
706 use super::*;
707
708 #[test]
709 fn forwards_bitcoin_rpc_wallet_rescan_height() {
710 let config = config::Bdk {
711 chain_source_type: Some("bitcoinrpc".to_string()),
712 wallet_rescan_from_height: Some(850000),
713 ..Default::default()
714 };
715
716 match config
717 .chain_source()
718 .expect("Bitcoin RPC config should parse")
719 {
720 cdk_bdk::ChainSource::BitcoinRpc(config) => {
721 assert_eq!(config.wallet_rescan_from_height, Some(850000));
722 }
723 _ => panic!("expected a Bitcoin RPC chain source"),
724 }
725 }
726
727 #[test]
728 fn parses_electrum_chain_source() {
729 let config = config::Bdk {
730 chain_source_type: Some("ElEcTrUm".to_string()),
731 electrum_url: Some("ssl://electrum.example.com:50002".to_string()),
732 electrum_batch_size: 7,
733 ..Default::default()
734 };
735
736 match config.chain_source().expect("electrum config should parse") {
737 cdk_bdk::ChainSource::Electrum(config) => {
738 assert_eq!(config.url, "ssl://electrum.example.com:50002");
739 assert_eq!(config.batch_size, 7);
740 }
741 _ => panic!("expected an Electrum chain source"),
742 }
743 }
744
745 #[test]
746 fn electrum_chain_source_requires_url() {
747 let config = config::Bdk {
748 chain_source_type: Some("electrum".to_string()),
749 ..Default::default()
750 };
751
752 let error = config
753 .chain_source()
754 .expect_err("electrum config without a URL should fail");
755
756 assert!(error.to_string().contains("electrum_url must be set"));
757 }
758
759 #[test]
760 fn electrum_chain_source_rejects_zero_batch_size() {
761 let config = config::Bdk {
762 chain_source_type: Some("electrum".to_string()),
763 electrum_url: Some("tcp://127.0.0.1:50001".to_string()),
764 electrum_batch_size: 0,
765 ..Default::default()
766 };
767
768 let error = config
769 .chain_source()
770 .expect_err("zero Electrum batch size should fail");
771
772 assert!(error
773 .to_string()
774 .contains("electrum_batch_size must be greater than zero"));
775 }
776
777 #[test]
778 fn esplora_chain_source_requires_url() {
779 let config = config::Bdk {
780 chain_source_type: Some("esplora".to_string()),
781 ..Default::default()
782 };
783
784 let error = config
785 .chain_source()
786 .expect_err("esplora config without a URL should fail");
787
788 assert!(error.to_string().contains("esplora_url must be set"));
789 }
790
791 #[test]
792 fn rejects_unknown_chain_source() {
793 let config = config::Bdk {
794 chain_source_type: Some("unknown".to_string()),
795 ..Default::default()
796 };
797
798 let error = config
799 .chain_source()
800 .expect_err("unknown chain source should fail");
801
802 assert_eq!(error.to_string(), "Unknown BDK chain_source_type: unknown");
803 }
804}
805
806#[cfg(feature = "bdk")]
807#[async_trait]
808impl OnchainBackendSetup for crate::config::Bdk {
809 async fn setup(
810 &self,
811 settings: &Settings,
812 _unit: CurrencyUnit,
813 _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
814 work_dir: &Path,
815 kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
816 ) -> anyhow::Result<cdk_bdk::CdkBdk> {
817 self.validate().map_err(anyhow::Error::msg)?;
818
819 let fee_reserve = FeeReserve {
820 min_fee_reserve: self.reserve_fee_min,
821 percent_fee_reserve: self.fee_percent,
822 };
823
824 let (mnemonic, network) = self.wallet_identity()?;
825
826 let chain_source = self.chain_source()?;
827
828 let min_receive_amount_sat = settings
829 .onchain
830 .as_ref()
831 .map(|onchain| onchain.min_mint.to_u64().max(self.min_receive_amount_sat))
832 .unwrap_or(self.min_receive_amount_sat);
833
834 let bdk = cdk_bdk::CdkBdk::new(
835 mnemonic,
836 network,
837 chain_source,
838 work_dir.to_string_lossy().to_string(),
839 fee_reserve,
840 kv_store.ok_or_else(|| anyhow::anyhow!("BDK backend requires a KV store"))?,
841 Some(self.batch_config.clone().into()),
842 self.num_confs,
843 min_receive_amount_sat,
844 self.min_send_amount_sat,
845 self.sync_interval_secs,
846 None,
847 None,
848 )?;
849
850 Ok(bdk)
851 }
852}
853
854#[cfg(feature = "bdk")]
855impl From<crate::config::BatchConfig> for cdk_bdk::BatchConfig {
856 fn from(config: crate::config::BatchConfig) -> Self {
857 let target_block_time = Duration::from_secs(config.target_block_time_secs);
858 let standard_deadline = config
859 .standard_deadline_secs
860 .map(Duration::from_secs)
861 .unwrap_or_else(|| {
862 cdk_bdk::BatchConfig::deadline_for_target_blocks(
863 cdk_bdk::PaymentTier::Standard,
864 target_block_time,
865 )
866 });
867 let economy_deadline = config
868 .economy_deadline_secs
869 .map(Duration::from_secs)
870 .unwrap_or_else(|| {
871 cdk_bdk::BatchConfig::deadline_for_target_blocks(
872 cdk_bdk::PaymentTier::Economy,
873 target_block_time,
874 )
875 });
876 let fee_estimation = cdk_bdk::FeeEstimationConfig {
877 fallback_sat_per_vb: config.fee_fallback_sat_per_vb,
878 cache_ttl_secs: config.fee_cache_ttl_secs,
879 quote_max_input_count: config.quote_max_input_count,
880 quote_fixed_safety_sat: config.quote_fixed_safety_sat,
881 quote_safety_multiplier: config.quote_safety_multiplier,
882 };
883
884 Self {
885 poll_interval: Duration::from_secs(config.poll_interval_secs),
886 max_batch_size: config.max_batch_size,
887 target_block_time,
888 standard_deadline,
889 economy_deadline,
890 max_intent_age: Some(
891 economy_deadline.saturating_add(Duration::from_secs(config.poll_interval_secs)),
892 ),
893 fee_options: config
894 .fee_options
895 .iter()
896 .map(|tier| {
897 cdk_bdk::PaymentTier::from_config_name(tier)
898 .expect("BDK fee_options should be validated before setup")
899 })
900 .collect(),
901 fee_estimation,
902 }
903 }
904}