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(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        // Validate required connection field
67        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        // Validate required connection fields
111        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        // calculate random delay time
160        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        // Parse network from config
477        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        // Parse chain source from config
490        let chain_source = self.chain_source()?;
491
492        // Parse gossip source from config
493        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        // Get storage directory path
499        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        // Get LDK node listen address
508        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        // Parse socket address using ldk_node's SocketAddress
517        // We need to get the actual socket address struct from ldk_node
518        // For now, let's construct it manually based on the cdk-ldk-node implementation
519        let listen_address = vec![socket_addr.into()];
520
521        // Check if ldk_node_mnemonic is provided in the ldk_node config
522        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        // Only set seed if mnemonic is explicitly provided
529        // This maintains backward compatibility with existing nodes that use LDK's default seed storage
530        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            // Check if this is a new node or an existing node
538            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            // Existing node with stored seed, don't set a mnemonic
546            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        // Only set seed if provided
572        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        // Configure webserver address if specified
580        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            // If only port is specified, use default host
586            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            // Use default webserver address if nothing is configured
591            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}