1#![cfg_attr(
2 all(target_family = "wasm", target_os = "unknown"),
3 allow(clippy::arc_with_non_send_sync)
4)]
5use std::{sync::Arc, time::Duration};
6
7use breez_sdk_common::breez_server::BreezServer;
8use breez_sdk_common::buy::moonpay::MoonpayProvider;
9
10use spark_wallet::{
11 InMemorySessionStore, PublicKey, SessionStore, SparkSigner, SparkWallet, SparkWalletConfig,
12};
13use tokio::sync::watch;
14use tracing::{Instrument, debug, info, warn};
15
16use flashnet::rewards::{REWARDS_MAINNET_BASE_URL, RewardsClient};
17use flashnet::{FlashnetConfig, IntegratorConfig};
18
19use crate::{
20 Credentials, EventEmitter, FiatService, FiatServiceWrapper, Network, Seed,
21 chain::{
22 BitcoinChainService, FallbackChainService, ValidatingChainService,
23 rest_client::{BasicAuth, ChainApiType, RestClientChainService},
24 },
25 error::SdkError,
26 lnurl::{DefaultLnurlServerClient, LnurlServerClient},
27 models::Config,
28 payment_observer::{PaymentObserver, SparkTransferObserver},
29 persist::backend::{ResolvedStores, StorageBackend},
30 realtime_sync::{RealTimeSyncParams, init_and_start_real_time_sync},
31 rewards::RewardsDelegator,
32 sdk::{BreezSdk, BreezSdkParams, SdkRuntime, SyncCoordinator, runtime_from_config},
33 sdk_context::{SdkContext, SdkContextConfig, new_shared_sdk_context},
34 signer::{breez::BreezSignerImpl, lnurl_auth::LnurlAuthSignerAdapter, rtsync::RTSyncSigner},
35 stable_balance::StableBalance,
36 token_conversion::TokenConversionMiddleware,
37 token_conversion::{
38 DEFAULT_INTEGRATOR_FEE_BPS, DEFAULT_INTEGRATOR_PUBKEY, FlashnetTokenConverter,
39 TokenConverter,
40 },
41};
42
43#[derive(Clone)]
49struct RestChainServiceConfig {
50 url: String,
51 api_type: ChainApiType,
52 credentials: Option<Credentials>,
53}
54
55#[derive(Clone)]
61enum ExternalBreez {
62 Full(Arc<dyn crate::signer::ExternalBreezSigner>),
63 SigningOnly(Arc<dyn crate::signer::ExternalSigningSigner>),
64}
65
66#[derive(Clone)]
67enum SignerSource {
68 Seed {
69 seed: Seed,
70 account_number: Option<u32>,
71 },
72 External {
73 breez: ExternalBreez,
74 spark: Arc<dyn crate::signer::ExternalSparkSigner>,
75 },
76}
77
78struct Signers {
82 ecies: Option<Arc<dyn crate::signer::EciesSigner>>,
83 spark: Arc<dyn SparkSigner>,
84 rtsync: Option<Arc<RTSyncSigner>>,
85 lnurl_auth: Option<Arc<LnurlAuthSignerAdapter>>,
86}
87
88struct BuildSparkWalletParams {
90 config: SparkWalletConfig,
91 spark_signer: Arc<dyn SparkSigner>,
92 session_store: Arc<dyn SessionStore>,
93 shutdown_receiver: Option<watch::Receiver<()>>,
98 tree_store: Option<Arc<dyn spark_wallet::TreeStore>>,
99 token_output_store: Option<Arc<dyn spark_wallet::TokenOutputStore>>,
100 payment_observer: Option<Arc<dyn PaymentObserver>>,
101 context: Arc<SdkContext>,
102}
103
104#[derive(Clone)]
106pub struct SdkBuilder {
107 config: Config,
108 signer_source: SignerSource,
109
110 storage: Option<Arc<dyn StorageBackend>>,
111 session_store: Option<Arc<dyn crate::session_store::SessionStore>>,
112 chain_service: Option<Arc<dyn BitcoinChainService>>,
113 rest_chain_service_configs: Vec<RestChainServiceConfig>,
114 fiat_service: Option<Arc<dyn FiatService>>,
115 lnurl_client: Option<Arc<dyn platform_utils::HttpClient>>,
116 lnurl_server_client: Option<Arc<dyn LnurlServerClient>>,
117 payment_observer: Option<Arc<dyn PaymentObserver>>,
118 context: Option<Arc<SdkContext>>,
119}
120
121impl SdkBuilder {
122 #[allow(clippy::needless_pass_by_value)]
130 pub fn new(config: Config, seed: Seed) -> Self {
131 SdkBuilder {
132 config,
133 signer_source: SignerSource::Seed {
134 seed,
135 account_number: None,
136 },
137 storage: None,
138 session_store: None,
139 chain_service: None,
140 rest_chain_service_configs: Vec::new(),
141 fiat_service: None,
142 lnurl_client: None,
143 lnurl_server_client: None,
144 payment_observer: None,
145 context: None,
146 }
147 }
148
149 #[allow(clippy::needless_pass_by_value)]
157 pub fn new_with_signer(
158 config: Config,
159 breez_signer: Arc<dyn crate::signer::ExternalBreezSigner>,
160 spark_signer: Arc<dyn crate::signer::ExternalSparkSigner>,
161 ) -> Self {
162 Self::with_external_signer(config, ExternalBreez::Full(breez_signer), spark_signer)
163 }
164
165 #[allow(clippy::needless_pass_by_value)]
177 pub fn new_with_signing_only_signer(
178 config: Config,
179 breez_signer: Arc<dyn crate::signer::ExternalSigningSigner>,
180 spark_signer: Arc<dyn crate::signer::ExternalSparkSigner>,
181 ) -> Self {
182 Self::with_external_signer(
183 config,
184 ExternalBreez::SigningOnly(breez_signer),
185 spark_signer,
186 )
187 }
188
189 fn with_external_signer(
190 config: Config,
191 breez: ExternalBreez,
192 spark_signer: Arc<dyn crate::signer::ExternalSparkSigner>,
193 ) -> Self {
194 SdkBuilder {
195 config,
196 signer_source: SignerSource::External {
197 breez,
198 spark: spark_signer,
199 },
200 storage: None,
201 session_store: None,
202 chain_service: None,
203 rest_chain_service_configs: Vec::new(),
204 fiat_service: None,
205 lnurl_client: None,
206 lnurl_server_client: None,
207 payment_observer: None,
208 context: None,
209 }
210 }
211
212 #[must_use]
225 pub fn with_account_number(mut self, account_number: u32) -> Self {
226 if let SignerSource::Seed {
227 account_number: ref mut an,
228 ..
229 } = self.signer_source
230 {
231 *an = Some(account_number);
232 }
233 self
234 }
235
236 #[cfg(feature = "sqlite")]
237 #[must_use]
238 pub fn with_default_storage(self, storage_dir: String) -> Self {
244 self.with_storage_backend(crate::default_storage(storage_dir))
245 }
246
247 #[must_use]
248 pub fn with_storage_backend(mut self, storage: Arc<dyn StorageBackend>) -> Self {
258 self.storage = Some(storage);
259 self
260 }
261
262 #[must_use]
276 pub fn with_session_store(
277 mut self,
278 session_store: Arc<dyn crate::session_store::SessionStore>,
279 ) -> Self {
280 self.session_store = Some(session_store);
281 self
282 }
283
284 #[must_use]
285 #[deprecated(note = "use `with_storage_backend(custom_storage(storage))`")]
291 pub fn with_storage(self, storage: Arc<dyn crate::Storage>) -> Self {
292 self.with_storage_backend(crate::custom_storage(storage))
293 }
294
295 #[cfg(any(feature = "postgres", feature = "db-storage-api"))]
299 #[deprecated(note = "use `with_storage_backend(postgres_storage(config)?)`")]
300 pub fn with_postgres_backend(
301 self,
302 config: crate::persist::postgres::PostgresStorageConfig,
303 ) -> Result<Self, SdkError> {
304 Ok(self.with_storage_backend(crate::postgres_storage(config)?))
305 }
306
307 #[cfg(any(feature = "mysql", feature = "db-storage-api"))]
311 #[deprecated(note = "use `with_storage_backend(mysql_storage(config)?)`")]
312 pub fn with_mysql_backend(
313 self,
314 config: crate::persist::mysql::MysqlStorageConfig,
315 ) -> Result<Self, SdkError> {
316 Ok(self.with_storage_backend(crate::mysql_storage(config)?))
317 }
318
319 #[must_use]
329 pub fn with_shared_context(mut self, context: Arc<SdkContext>) -> Self {
330 self.context = Some(context);
331 self
332 }
333
334 #[must_use]
338 pub fn with_chain_service(mut self, chain_service: Arc<dyn BitcoinChainService>) -> Self {
339 self.chain_service = Some(chain_service);
340 self.rest_chain_service_configs.clear();
341 self
342 }
343
344 #[must_use]
355 pub fn with_rest_chain_service(
356 mut self,
357 url: String,
358 api_type: ChainApiType,
359 credentials: Option<Credentials>,
360 ) -> Self {
361 self.chain_service = None;
362 self.rest_chain_service_configs
363 .push(RestChainServiceConfig {
364 url,
365 api_type,
366 credentials,
367 });
368 self
369 }
370
371 #[must_use]
375 pub fn with_fiat_service(mut self, fiat_service: Arc<dyn FiatService>) -> Self {
376 self.fiat_service = Some(fiat_service);
377 self
378 }
379
380 #[must_use]
381 pub fn with_lnurl_client(mut self, lnurl_client: Arc<dyn crate::RestClient>) -> Self {
382 self.lnurl_client = Some(Arc::new(crate::common::rest::RestClientWrapper::new(
383 lnurl_client,
384 )));
385 self
386 }
387
388 #[must_use]
389 #[allow(unused)]
390 pub fn with_lnurl_server_client(
391 mut self,
392 lnurl_serverclient: Arc<dyn LnurlServerClient>,
393 ) -> Self {
394 self.lnurl_server_client = Some(lnurl_serverclient);
395 self
396 }
397
398 #[must_use]
403 #[allow(unused)]
404 pub fn with_payment_observer(mut self, payment_observer: Arc<dyn PaymentObserver>) -> Self {
405 self.payment_observer = Some(payment_observer);
406 self
407 }
408
409 fn build_spark_wallet_config(
412 network: spark_wallet::Network,
413 env_config: &crate::models::SparkConfig,
414 ) -> Result<SparkWalletConfig, SdkError> {
415 spark_wallet::SparkDeployment::from(env_config)
416 .wallet_config(network)
417 .map_err(|e| SdkError::InvalidInput(e.to_string()))
418 }
419
420 fn warn_about_unproxied_overrides(&self) {
427 if self.config.proxy.is_none() {
428 return;
429 }
430 let overrides = [
431 ("chain service", self.chain_service.is_some()),
432 ("fiat service", self.fiat_service.is_some()),
433 ("LNURL HTTP client", self.lnurl_client.is_some()),
434 ("LNURL server client", self.lnurl_server_client.is_some()),
435 ];
436 for (name, supplied) in overrides {
437 if supplied {
438 warn!(
439 "A proxy is configured but a custom {name} was supplied. \
440 The SDK cannot route it: make sure it connects through the proxy itself."
441 );
442 }
443 }
444 }
445
446 pub async fn build(self) -> Result<BreezSdk, SdkError> {
448 self.config.validate()?;
449 let runtime = runtime_from_config(&self.config);
450 validate_server_mode(&self.config, runtime.starts_background_services())?;
451
452 let signers = build_signers(&self.config, &self.signer_source)?;
453 validate_signer_capabilities(&self.config, signers.ecies.is_some())?;
454 let identity_public_key = signers
455 .spark
456 .get_identity_public_key()
457 .await
458 .map_err(|e| SdkError::Generic(e.to_string()))?;
459
460 let span = crate::logger::instance_span(&identity_public_key.to_string());
463 self.build_instance(runtime, signers, identity_public_key)
464 .instrument(span)
465 .await
466 }
467
468 #[allow(clippy::too_many_lines)]
471 async fn build_instance(
472 self,
473 runtime: SdkRuntime,
474 signers: Signers,
475 identity_public_key: PublicKey,
476 ) -> Result<BreezSdk, SdkError> {
477 let background_services_enabled = runtime.starts_background_services();
478 self.warn_about_unproxied_overrides();
479
480 let creates_context = self.context.is_none();
481 let context = resolve_context(self.context, &self.config).await?;
482 let chain_service = resolve_chain_service(
483 self.chain_service,
484 self.rest_chain_service_configs,
485 &context,
486 self.config.network,
487 )?;
488 let stores =
489 resolve_storage(self.storage, &context, &identity_public_key, &self.config).await?;
490 if let Some(provider) = &context.jwt_header_provider {
495 provider.start(creates_context.then(|| Arc::clone(&stores.storage)));
496 }
497
498 let user_agent = crate::default_user_agent();
499 info!("Building sdk with user agent: {}", user_agent);
500
501 let transport_proxy = self
502 .config
503 .proxy
504 .as_ref()
505 .map(platform_utils::ProxyConfig::from);
506 let lnurl_http_client: Arc<dyn platform_utils::HttpClient> =
513 platform_utils::create_http_client_with_redirect_filter(
514 Some(&user_agent),
515 transport_proxy.as_ref(),
516 breez_sdk_common::lnurl::security::lnurl_redirect_filter(),
517 )
518 .map_err(|e| SdkError::Generic(format!("Failed to build LNURL HTTP client: {e}")))?;
519
520 let input_parser = Arc::new(crate::sdk::SdkInputParser::new(
521 breez_sdk_common::dns::Resolver::with_proxy(transport_proxy.as_ref())
522 .map_err(|e| SdkError::Generic(format!("Failed to build DNS resolver: {e}")))?,
523 Arc::clone(&lnurl_http_client),
524 Some(
525 self.config
526 .get_all_external_input_parsers()
527 .into_iter()
528 .map(Into::into)
529 .collect(),
530 ),
531 ));
532
533 let fiat_service: Arc<dyn breez_sdk_common::fiat::FiatService> = match self.fiat_service {
534 Some(service) => Arc::new(FiatServiceWrapper::new(service)),
535 None => context.breez_server.clone(),
536 };
537 let lnurl_client: Arc<dyn platform_utils::HttpClient> = self
538 .lnurl_client
539 .unwrap_or_else(|| Arc::clone(&lnurl_http_client));
540
541 let spark_wallet_config = finalize_spark_wallet_config(
542 &self.config,
543 &user_agent,
544 background_services_enabled,
545 signers.spark.is_remote(),
546 )?;
547 let shutdown_sender = watch::channel::<()>(()).0;
548 let override_store = self.session_store.map(|s| {
552 Arc::new(crate::session_store::SessionStoreAdapter::new(s)) as Arc<dyn SessionStore>
553 });
554 let session_store =
555 wrap_session_store(override_store.or_else(|| stores.session_store.clone()));
556
557 let spark_wallet = build_spark_wallet(BuildSparkWalletParams {
558 config: spark_wallet_config,
559 spark_signer: Arc::clone(&signers.spark),
560 session_store,
561 shutdown_receiver: background_services_enabled.then(|| shutdown_sender.subscribe()),
562 tree_store: stores.tree_store.clone(),
563 token_output_store: stores.token_output_store.clone(),
564 payment_observer: self.payment_observer,
565 context: Arc::clone(&context),
566 })
567 .await?;
568
569 let lnurl_server_client = resolve_lnurl_server_client(
570 self.lnurl_server_client,
571 &self.config,
572 &context,
573 &spark_wallet,
574 );
575
576 let real_time_sync_active =
577 background_services_enabled && self.config.real_time_sync_server_url.is_some();
578 let event_emitter = Arc::new(EventEmitter::new(real_time_sync_active));
579
580 let storage = maybe_wrap_storage_with_real_time_sync(
581 Arc::clone(&stores.storage),
582 &self.config,
583 background_services_enabled,
584 user_agent,
585 signers.rtsync,
586 shutdown_sender.subscribe(),
587 Arc::clone(&event_emitter),
588 lnurl_server_client.clone(),
589 )
590 .await?;
591
592 let buy_bitcoin_provider = Arc::new(MoonpayProvider::new(context.breez_server.clone()));
593 let token_converter =
594 build_token_converter(&self.config, &storage, &spark_wallet, &context);
595 let rewards_delegator =
596 build_rewards_delegator(&self.config, &storage, &spark_wallet, &context);
597
598 let sync_coordinator = SyncCoordinator::new();
599
600 let lightning_sender = Arc::new(crate::sdk::LightningSender::new(
602 Arc::clone(&spark_wallet),
603 Arc::clone(&storage),
604 Arc::clone(&event_emitter),
605 shutdown_sender.clone(),
606 ));
607
608 let (cross_chain_context, orchestra) = build_cross_chain_context(
609 &self.config,
610 background_services_enabled,
611 &context.breez_server,
612 &context.http_client,
613 &spark_wallet,
614 &storage,
615 Arc::clone(&fiat_service),
616 &event_emitter,
617 shutdown_sender.subscribe(),
618 )
619 .await;
620
621 let stable_balance = build_stable_balance(
622 &self.config,
623 &token_converter,
624 &spark_wallet,
625 &storage,
626 &event_emitter,
627 )
628 .await;
629
630 event_emitter
633 .add_middleware(Box::new(TokenConversionMiddleware))
634 .await;
635
636 let sdk = BreezSdk::init_and_start(BreezSdkParams {
637 instance_id: identity_public_key.to_string(),
638 input_parser,
639 config: self.config,
640 storage,
641 chain_service,
642 fiat_service,
643 lnurl_client,
644 lnurl_server_client,
645 lnurl_auth_signer: signers.lnurl_auth,
646 shutdown_sender,
647 runtime,
648 spark_wallet,
649 event_emitter,
650 buy_bitcoin_provider,
651 token_converter,
652 rewards_delegator,
653 stable_balance,
654 sync_coordinator,
655 cross_chain_context,
656 orchestra,
657 lightning_sender,
658 })
659 .await?;
660 debug!("Initialized and started breez sdk.");
661
662 Ok(sdk)
663 }
664}
665
666fn validate_server_mode(
668 config: &Config,
669 background_services_enabled: bool,
670) -> Result<(), SdkError> {
671 if background_services_enabled {
672 return Ok(());
673 }
674 if config.stable_balance_config.is_some() {
675 return Err(SdkError::InvalidInput(
676 "stable_balance_config is not supported when background_tasks_enabled is false"
677 .to_string(),
678 ));
679 }
680 if config.real_time_sync_server_url.is_some() {
681 return Err(SdkError::InvalidInput(
682 "real_time_sync_server_url must be None when background_tasks_enabled is false"
683 .to_string(),
684 ));
685 }
686 if config.leaf_optimization_config.auto_enabled {
687 return Err(SdkError::InvalidInput(
688 "leaf_optimization_config.auto_enabled must be false when background_tasks_enabled is false"
689 .to_string(),
690 ));
691 }
692 if config.token_optimization_config.auto_enabled {
693 return Err(SdkError::InvalidInput(
694 "token_optimization_config.auto_enabled must be false when background_tasks_enabled is false"
695 .to_string(),
696 ));
697 }
698 if config.cross_chain_config.is_some() {
699 return Err(SdkError::InvalidInput(
700 "Cross-chain config must be unset when background tasks are disabled".to_string(),
701 ));
702 }
703 Ok(())
704}
705
706fn validate_signer_capabilities(config: &Config, has_ecies: bool) -> Result<(), SdkError> {
709 if has_ecies {
710 return Ok(());
711 }
712 if config.real_time_sync_server_url.is_some() {
713 return Err(SdkError::InvalidInput(
714 "Real-time sync requires a signer that supports ECIES".to_string(),
715 ));
716 }
717 if config.cross_chain_config.is_some() {
718 return Err(SdkError::InvalidInput(
719 "Cross-chain payments require a signer that supports ECIES".to_string(),
720 ));
721 }
722 Ok(())
723}
724
725fn build_signers(config: &Config, signer_source: &SignerSource) -> Result<Signers, SdkError> {
730 use crate::signer::{
731 BreezSigner, EciesSigner, ExternalBreezSignerAdapter, ExternalSigningSignerAdapter,
732 ExternalSparkSignerAdapter, HmacSigner,
733 };
734
735 #[allow(clippy::type_complexity)]
739 let (base, ecies, hmac, spark): (
740 Arc<dyn BreezSigner>,
741 Option<Arc<dyn EciesSigner>>,
742 Option<Arc<dyn HmacSigner>>,
743 Arc<dyn SparkSigner>,
744 ) = match signer_source {
745 SignerSource::Seed {
746 seed,
747 account_number,
748 } => {
749 let seed_bytes = seed.to_bytes()?;
750 let network = config.network.into();
751 let account_number = *account_number;
752 let signer = Arc::new(BreezSignerImpl::new(
754 spark_wallet::identity_master_key(&seed_bytes, network, account_number)
755 .map_err(|e| SdkError::Generic(e.to_string()))?,
756 ));
757 let spark: Arc<dyn SparkSigner> = Arc::new(spark_wallet::SparkSignerAdapter::new(
758 Arc::new(spark_wallet::DefaultSigner::from_master(
759 spark_wallet::account_master_key(&seed_bytes, network, account_number)
760 .map_err(|e| SdkError::Generic(e.to_string()))?,
761 )),
762 ));
763 (signer.clone(), Some(signer.clone()), Some(signer), spark)
764 }
765 SignerSource::External { breez, spark } => {
766 let spark: Arc<dyn SparkSigner> =
767 Arc::new(ExternalSparkSignerAdapter::new(Arc::clone(spark)));
768 match breez {
769 ExternalBreez::Full(breez) => {
770 let signer = Arc::new(ExternalBreezSignerAdapter::new(Arc::clone(breez)));
772 (signer.clone(), Some(signer.clone()), Some(signer), spark)
773 }
774 ExternalBreez::SigningOnly(breez) => {
775 let base: Arc<dyn BreezSigner> =
776 Arc::new(ExternalSigningSignerAdapter::new(Arc::clone(breez)));
777 (base, None, None, spark)
778 }
779 }
780 }
781 };
782
783 let rtsync = ecies
784 .as_ref()
785 .map(|ecies| {
786 RTSyncSigner::new(base.clone(), ecies.clone(), config.network)
787 .map(Arc::new)
788 .map_err(|e| SdkError::Generic(e.to_string()))
789 })
790 .transpose()?;
791 let lnurl_auth = hmac
792 .as_ref()
793 .map(|hmac| Arc::new(LnurlAuthSignerAdapter::new(base.clone(), hmac.clone())));
794
795 Ok(Signers {
796 ecies,
797 spark,
798 rtsync,
799 lnurl_auth,
800 })
801}
802
803async fn resolve_context(
806 supplied: Option<Arc<SdkContext>>,
807 config: &Config,
808) -> Result<Arc<SdkContext>, SdkError> {
809 let context = match supplied {
810 Some(ctx) => ctx,
811 None => {
812 new_shared_sdk_context(SdkContextConfig {
813 api_key: config.api_key.clone(),
814 proxy: config.proxy.clone(),
815 ..SdkContextConfig::new(config.network)
816 })
817 .await?
818 }
819 };
820 if context.network != config.network || context.api_key != config.api_key {
821 return Err(SdkError::Generic(
822 "SdkContext network/api_key do not match SdkConfig".to_string(),
823 ));
824 }
825 if context.proxy != config.proxy {
828 return Err(SdkError::Generic(
829 "SdkContext proxy does not match SdkConfig proxy".to_string(),
830 ));
831 }
832 Ok(context)
833}
834
835async fn resolve_storage(
838 supplied: Option<Arc<dyn StorageBackend>>,
839 context: &SdkContext,
840 identity_public_key: &PublicKey,
841 config: &Config,
842) -> Result<Arc<ResolvedStores>, SdkError> {
843 let storage_backend: Arc<dyn StorageBackend> = match (supplied, context.storage_backend.clone())
844 {
845 (Some(storage), None) => storage,
846 (None, Some(backend)) => backend,
847 (Some(_), Some(_)) => {
848 return Err(SdkError::Generic(
849 "storage is configured on both the SdkBuilder and the shared SdkContext"
850 .to_string(),
851 ));
852 }
853 (None, None) => return Err(SdkError::Generic("No storage configured".to_string())),
854 };
855 storage_backend
856 .create_stores(config.network, identity_public_key.serialize().to_vec())
857 .await
858}
859
860const FALLBACK_BACKEND_TIMEOUT: Duration = Duration::from_secs(30);
862
863fn resolve_chain_service(
869 supplied: Option<Arc<dyn BitcoinChainService>>,
870 rest_configs: Vec<RestChainServiceConfig>,
871 context: &SdkContext,
872 network: Network,
873) -> Result<Arc<dyn BitcoinChainService>, SdkError> {
874 if let Some(service) = supplied {
875 return Ok(Arc::new(ValidatingChainService::new(service)));
876 }
877 let rest_configs = if rest_configs.is_empty() {
878 default_rest_chain_services(network)?
879 } else {
880 rest_configs
881 };
882 let has_fallback = rest_configs.len() > 1;
883 let backends: Vec<Arc<dyn BitcoinChainService>> = rest_configs
884 .into_iter()
885 .map(|cfg| {
886 let mut service = RestClientChainService::new(
887 cfg.url,
888 network,
889 5,
890 context.http_client.clone(),
891 cfg.credentials
892 .map(|c| BasicAuth::new(c.username, c.password)),
893 cfg.api_type,
894 );
895 if has_fallback {
896 service = service.with_total_timeout(FALLBACK_BACKEND_TIMEOUT);
897 }
898 Arc::new(ValidatingChainService::new(Arc::new(service))) as Arc<dyn BitcoinChainService>
899 })
900 .collect();
901 if let [backend] = backends.as_slice() {
902 return Ok(backend.clone());
903 }
904 Ok(Arc::new(FallbackChainService::new(backends)))
905}
906
907fn default_rest_chain_services(network: Network) -> Result<Vec<RestChainServiceConfig>, SdkError> {
908 match network {
909 Network::Mainnet => Ok(vec![
910 RestChainServiceConfig {
911 url: "https://mempool.space/api".to_string(),
912 api_type: ChainApiType::MempoolSpace,
913 credentials: None,
914 },
915 RestChainServiceConfig {
916 url: "https://blockstream.info/api".to_string(),
917 api_type: ChainApiType::Esplora,
918 credentials: None,
919 },
920 ]),
921 Network::Signet => Err(SdkError::InvalidInput(
922 "Signet requires an explicit chain service via with_chain_service or with_rest_chain_service".to_string(),
923 )),
924 Network::Regtest => {
925 let (username, password) = match (
926 std::env::var("CHAIN_SERVICE_USERNAME"),
927 std::env::var("CHAIN_SERVICE_PASSWORD"),
928 ) {
929 (Ok(username), Ok(password)) => (username, password),
930 _ => ("spark-sdk".to_string(), "mCMk1JqlBNtetUNy".to_string()),
931 };
932 Ok(vec![RestChainServiceConfig {
933 url: "https://regtest-mempool.us-west-2.sparkinfra.net/api".to_string(),
934 api_type: ChainApiType::MempoolSpace,
935 credentials: Some(Credentials { username, password }),
936 }])
937 }
938 }
939}
940
941fn finalize_spark_wallet_config(
945 config: &Config,
946 user_agent: &str,
947 background_services_enabled: bool,
948 remote_signer: bool,
949) -> Result<SparkWalletConfig, SdkError> {
950 let mut spark_wallet_config = if let Some(env_config) = &config.spark_config {
951 SdkBuilder::build_spark_wallet_config(config.network.into(), env_config)?
952 } else {
953 SparkWalletConfig::default_config(config.network.into())
954 };
955 spark_wallet_config.operator_pool = spark_wallet_config
956 .operator_pool
957 .with_user_agent(Some(user_agent.to_string()));
958 spark_wallet_config.service_provider_config.user_agent = Some(user_agent.to_string());
959 spark_wallet_config.leaf_auto_optimize_enabled =
960 background_services_enabled && config.leaf_optimization_config.auto_enabled;
961 spark_wallet_config.leaf_optimization_options.multiplicity =
962 config.leaf_optimization_config.multiplicity;
963
964 let token_opt = &config.token_optimization_config;
965 let token_options = &mut spark_wallet_config.token_outputs_optimization_options;
966 token_options.target_output_count = token_opt.target_output_count;
967 token_options.min_outputs_threshold = token_opt.min_outputs_threshold;
968 if !token_opt.auto_enabled || !background_services_enabled {
970 token_options.auto_optimize_interval = None;
971 }
972 spark_wallet_config.max_concurrent_claims = config.max_concurrent_claims;
973 spark_wallet_config.fee_ladder_enabled =
977 match (config.watchtower_fee_ladder_enabled, remote_signer) {
978 (Some(true), true) => {
979 return Err(SdkError::InvalidInput(
980 "watchtower_fee_ladder_enabled is not supported with a remote signer"
981 .to_string(),
982 ));
983 }
984 (_, true) => false,
985 (enabled, false) => enabled.unwrap_or(true),
986 };
987 Ok(spark_wallet_config)
988}
989
990fn wrap_session_store(session_store: Option<Arc<dyn SessionStore>>) -> Arc<dyn SessionStore> {
998 let inner = session_store.unwrap_or_else(|| Arc::new(InMemorySessionStore::default()));
999 Arc::new(crate::session_store::CachingSessionStore::new(inner))
1000}
1001
1002async fn build_spark_wallet(params: BuildSparkWalletParams) -> Result<Arc<SparkWallet>, SdkError> {
1004 let mut wallet_builder = spark_wallet::WalletBuilder::new(params.config, params.spark_signer)
1005 .with_session_store(params.session_store);
1006 if let Some(receiver) = params.shutdown_receiver {
1007 wallet_builder = wallet_builder.with_cancellation_token(receiver);
1008 }
1009 if let Some(provider) = ¶ms.context.jwt_header_provider {
1010 wallet_builder = wallet_builder
1011 .with_so_extra_header_provider(
1012 Arc::clone(provider) as Arc<dyn spark_wallet::HeaderProvider>
1013 )
1014 .with_ssp_extra_header_provider(
1015 Arc::clone(provider) as Arc<dyn spark_wallet::HeaderProvider>
1016 );
1017 }
1018 if let Some(observer) = params.payment_observer {
1019 let observer: Arc<dyn spark_wallet::TransferObserver> =
1020 Arc::new(SparkTransferObserver::new(observer));
1021 wallet_builder = wallet_builder.with_transfer_observer(observer);
1022 }
1023 if let Some(tree_store) = params.tree_store {
1024 wallet_builder = wallet_builder.with_tree_store(tree_store);
1025 }
1026 if let Some(token_output_store) = params.token_output_store {
1027 wallet_builder = wallet_builder.with_token_output_store(token_output_store);
1028 }
1029 wallet_builder = wallet_builder.with_ssp_http_client(params.context.http_client.clone());
1030 wallet_builder =
1031 wallet_builder.with_connection_manager(params.context.connection_manager.clone());
1032 Ok(Arc::new(wallet_builder.build().await?))
1033}
1034
1035fn resolve_lnurl_server_client(
1038 explicit: Option<Arc<dyn LnurlServerClient>>,
1039 config: &Config,
1040 context: &SdkContext,
1041 spark_wallet: &Arc<SparkWallet>,
1042) -> Option<Arc<dyn LnurlServerClient>> {
1043 if let Some(client) = explicit {
1044 return Some(client);
1045 }
1046 config.lnurl_domain.as_ref().map(|domain| {
1047 Arc::new(DefaultLnurlServerClient::new(
1048 context.http_client.clone(),
1049 domain.clone(),
1050 config.api_key.clone(),
1051 Arc::clone(spark_wallet),
1052 )) as Arc<dyn LnurlServerClient>
1053 })
1054}
1055
1056#[allow(clippy::too_many_arguments)]
1059async fn maybe_wrap_storage_with_real_time_sync(
1060 storage: Arc<dyn crate::persist::Storage>,
1061 config: &Config,
1062 background_services_enabled: bool,
1063 user_agent: String,
1064 rtsync_signer: Option<Arc<RTSyncSigner>>,
1065 shutdown_receiver: watch::Receiver<()>,
1066 event_emitter: Arc<EventEmitter>,
1067 lnurl_server_client: Option<Arc<dyn LnurlServerClient>>,
1068) -> Result<Arc<dyn crate::persist::Storage>, SdkError> {
1069 match (&config.real_time_sync_server_url, rtsync_signer) {
1073 (Some(server_url), Some(signer)) if background_services_enabled => {
1074 init_and_start_real_time_sync(RealTimeSyncParams {
1075 server_url: server_url.clone(),
1076 api_key: config.api_key.clone(),
1077 user_agent,
1078 proxy: config.proxy.as_ref().map(platform_utils::ProxyConfig::from),
1079 signer,
1080 storage,
1081 shutdown_receiver,
1082 event_emitter,
1083 lnurl_server_client,
1084 })
1085 .await
1086 }
1087 _ => Ok(storage),
1088 }
1089}
1090
1091fn build_token_converter(
1093 config: &Config,
1094 storage: &Arc<dyn crate::persist::Storage>,
1095 spark_wallet: &Arc<SparkWallet>,
1096 context: &SdkContext,
1097) -> Arc<dyn TokenConverter> {
1098 let flashnet_config = FlashnetConfig::default_config(
1099 config.network.into(),
1100 DEFAULT_INTEGRATOR_PUBKEY
1101 .parse()
1102 .ok()
1103 .map(|pubkey| IntegratorConfig {
1104 pubkey,
1105 fee_bps: DEFAULT_INTEGRATOR_FEE_BPS,
1106 }),
1107 );
1108 Arc::new(FlashnetTokenConverter::new(
1109 flashnet_config,
1110 Arc::clone(storage),
1111 Arc::clone(spark_wallet),
1112 config.network,
1113 context.http_client.clone(),
1114 ))
1115}
1116
1117fn build_rewards_delegator(
1120 config: &Config,
1121 storage: &Arc<dyn crate::persist::Storage>,
1122 spark_wallet: &Arc<SparkWallet>,
1123 context: &SdkContext,
1124) -> Option<Arc<RewardsDelegator>> {
1125 matches!(config.network, Network::Mainnet).then(|| {
1126 Arc::new(RewardsDelegator::new(
1127 RewardsClient::new(
1128 REWARDS_MAINNET_BASE_URL.to_string(),
1129 Arc::clone(spark_wallet),
1130 context.http_client.clone(),
1131 ),
1132 Arc::clone(storage),
1133 ))
1134 })
1135}
1136
1137async fn build_stable_balance(
1140 config: &Config,
1141 token_converter: &Arc<dyn TokenConverter>,
1142 spark_wallet: &Arc<SparkWallet>,
1143 storage: &Arc<dyn crate::persist::Storage>,
1144 event_emitter: &Arc<EventEmitter>,
1145) -> Option<Arc<StableBalance>> {
1146 let stable_config = config.stable_balance_config.as_ref()?;
1147 Some(Arc::new(
1148 StableBalance::new(
1149 stable_config.clone(),
1150 Arc::clone(token_converter),
1151 Arc::clone(spark_wallet),
1152 Arc::clone(storage),
1153 Arc::clone(event_emitter),
1154 )
1155 .await,
1156 ))
1157}
1158
1159#[allow(clippy::too_many_arguments)]
1165async fn build_cross_chain_context(
1166 config: &Config,
1167 background_services_enabled: bool,
1168 breez_server: &Arc<BreezServer>,
1169 http_client: &Arc<dyn platform_utils::HttpClient>,
1170 spark_wallet: &Arc<SparkWallet>,
1171 storage: &Arc<dyn crate::persist::Storage>,
1172 fiat_service: Arc<dyn breez_sdk_common::fiat::FiatService>,
1173 event_emitter: &Arc<EventEmitter>,
1174 shutdown_receiver: watch::Receiver<()>,
1175) -> (
1176 crate::cross_chain::CrossChainContext,
1177 Option<Arc<crate::cross_chain::OrchestraService>>,
1178) {
1179 let cached_fiat: Arc<dyn breez_sdk_common::fiat::FiatService> =
1181 Arc::new(crate::cross_chain::CachedFiatService::new(
1182 fiat_service,
1183 crate::cross_chain::DEFAULT_FIAT_CACHE_TTL,
1184 ));
1185 let mut providers = crate::cross_chain::CrossChainContext::new(Arc::clone(&cached_fiat));
1186
1187 if !matches!(config.network, Network::Mainnet) {
1192 return (providers, None);
1193 }
1194 let config_resolver = Arc::new(crate::cross_chain::BreezServerOrchestraConfigResolver::new(
1197 Arc::clone(breez_server),
1198 ));
1199 let quoter = Arc::new(crate::cross_chain::OrchestraQuoter::new(
1200 config_resolver,
1201 config.api_key.as_deref().and_then(crate::sdk::partner_id),
1202 Arc::clone(&cached_fiat),
1203 Arc::clone(http_client),
1204 ));
1205 providers.set_orchestra_quoter(Arc::clone("er));
1206 if !background_services_enabled {
1209 return (providers, None);
1210 }
1211 let orchestra = Arc::new(
1212 crate::cross_chain::OrchestraService::new(
1213 quoter,
1214 Arc::clone(spark_wallet),
1215 Arc::clone(storage),
1216 Arc::clone(event_emitter),
1217 shutdown_receiver,
1218 config.cross_chain_config.is_some(),
1219 )
1220 .await,
1221 );
1222 if config.cross_chain_config.is_some() {
1223 providers.insert(
1224 crate::cross_chain::CrossChainProvider::Orchestra,
1225 Arc::clone(&orchestra) as Arc<dyn crate::cross_chain::CrossChainService>,
1226 );
1227 }
1228
1229 (providers, Some(orchestra))
1230}
1231
1232#[cfg(test)]
1233#[cfg(feature = "sqlite")]
1234mod tests {
1235 use super::SdkBuilder;
1236 use crate::{Network, SdkError, default_config};
1237
1238 fn signet_test_spark_config() -> crate::SparkConfig {
1239 let identifier = format!("{:064x}", 1);
1240 let public_key = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
1241 crate::SparkConfig {
1242 coordinator_identifier: identifier.clone(),
1243 threshold: 1,
1244 signing_operators: vec![crate::SparkSigningOperator {
1245 id: 0,
1246 identifier,
1247 address: "https://signet-operator.invalid".to_string(),
1248 identity_public_key: public_key.to_string(),
1249 ca_cert_pem: None,
1250 }],
1251 ssp_config: crate::SparkSspConfig {
1252 base_url: "https://signet-ssp.invalid".to_string(),
1253 identity_public_key: public_key.to_string(),
1254 schema_endpoint: None,
1255 },
1256 expected_withdraw_bond_sats: 10_000,
1257 expected_withdraw_relative_block_locktime: 1_000,
1258 max_token_transaction_inputs: None,
1259 }
1260 }
1261
1262 #[tokio::test]
1263 async fn signet_requires_explicit_spark_config() {
1264 for config in [
1265 default_config(Network::Signet),
1266 crate::default_server_config(Network::Signet),
1267 ] {
1268 assert!(config.spark_config.is_none());
1269 assert!(matches!(
1270 config.validate(),
1271 Err(SdkError::InvalidInput(message)) if message.contains("spark_config")
1272 ));
1273 assert!(matches!(
1274 SdkBuilder::new(config, test_seed()).build().await,
1275 Err(SdkError::InvalidInput(message)) if message.contains("spark_config")
1276 ));
1277 }
1278 }
1279
1280 #[tokio::test]
1281 async fn signet_requires_explicit_chain_service() {
1282 for mut config in [
1283 default_config(Network::Signet),
1284 crate::default_server_config(Network::Signet),
1285 ] {
1286 config.spark_config = Some(signet_test_spark_config());
1287 assert!(matches!(
1288 SdkBuilder::new(config, test_seed()).build().await,
1289 Err(SdkError::InvalidInput(message)) if message.contains("explicit chain service")
1290 ));
1291 }
1292 }
1293
1294 #[tokio::test]
1295 async fn signet_sdk_builds_without_api_key_and_preserves_network() {
1296 let mut config = crate::default_server_config("signet".parse().unwrap());
1297 config.spark_config = Some(signet_test_spark_config());
1298 assert!(config.api_key.is_none());
1299 assert!(config.lnurl_domain.is_none());
1300 let sdk = SdkBuilder::new(config, test_seed())
1301 .with_rest_chain_service(
1302 "https://signet-chain.invalid/api".to_string(),
1303 crate::ChainApiType::Esplora,
1304 None,
1305 )
1306 .with_default_storage(unique_storage_dir("signet"))
1307 .build()
1308 .await
1309 .expect("Signet SDK should initialize without a Breez API key");
1310
1311 let address = sdk.spark_wallet.get_spark_address().unwrap();
1312 assert_eq!(address.network, spark_wallet::Network::Signet);
1313 let encoded = address.to_address_string().unwrap();
1314 let decoded: spark_wallet::SparkAddress = encoded.parse().unwrap();
1315 assert_eq!(decoded.network, spark_wallet::Network::Signet);
1316 assert_eq!(
1317 bitcoin::Network::from(Network::Signet),
1318 bitcoin::Network::Signet
1319 );
1320 assert!(matches!(
1321 crate::BitcoinNetwork::from(Network::Signet),
1322 crate::BitcoinNetwork::Signet
1323 ));
1324 assert!(matches!(
1325 breez_sdk_common::network::BitcoinNetwork::from(Network::Signet),
1326 breez_sdk_common::network::BitcoinNetwork::Signet
1327 ));
1328 sdk.disconnect().await.unwrap();
1329 }
1330
1331 #[tokio::test]
1332 async fn signet_explicit_chain_service_uses_esplora() {
1333 use platform_utils::{HttpClient, HttpError, HttpResponse};
1334 use std::{collections::HashMap, sync::Arc};
1335
1336 struct SignetChainClient;
1337
1338 #[macros::async_trait]
1339 impl HttpClient for SignetChainClient {
1340 async fn get(
1341 &self,
1342 url: String,
1343 headers: Option<HashMap<String, String>>,
1344 ) -> Result<HttpResponse, HttpError> {
1345 assert_eq!(url, "https://signet-chain.invalid/api/fee-estimates");
1346 assert!(
1347 !headers
1348 .unwrap_or_default()
1349 .keys()
1350 .any(|key| key.eq_ignore_ascii_case("authorization"))
1351 );
1352 Ok(HttpResponse {
1353 status: 200,
1354 body: r#"{"1":5.0,"3":3.0,"6":2.0,"144":1.0}"#.to_string(),
1355 headers: HashMap::new(),
1356 })
1357 }
1358
1359 async fn post(
1360 &self,
1361 _url: String,
1362 _headers: Option<HashMap<String, String>>,
1363 _body: Option<String>,
1364 ) -> Result<HttpResponse, HttpError> {
1365 panic!("fee estimation must not POST")
1366 }
1367
1368 async fn delete(
1369 &self,
1370 _url: String,
1371 _headers: Option<HashMap<String, String>>,
1372 _body: Option<String>,
1373 ) -> Result<HttpResponse, HttpError> {
1374 panic!("fee estimation must not DELETE")
1375 }
1376 }
1377
1378 let mut context =
1379 crate::new_shared_sdk_context(crate::SdkContextConfig::new(Network::Signet))
1380 .await
1381 .unwrap();
1382 Arc::get_mut(&mut context).unwrap().http_client = Arc::new(SignetChainClient);
1383 let chain_service = super::resolve_chain_service(
1384 None,
1385 vec![super::RestChainServiceConfig {
1386 url: "https://signet-chain.invalid/api".to_string(),
1387 api_type: crate::ChainApiType::Esplora,
1388 credentials: None,
1389 }],
1390 &context,
1391 Network::Signet,
1392 )
1393 .unwrap();
1394 let chain_service =
1396 super::resolve_chain_service(Some(chain_service), vec![], &context, Network::Signet)
1397 .unwrap();
1398 let fees = chain_service.recommended_fees().await.unwrap();
1399 assert_eq!(fees.fastest_fee, 5);
1400 assert_eq!(fees.half_hour_fee, 3);
1401 assert_eq!(fees.hour_fee, 2);
1402 }
1403
1404 #[tokio::test]
1405 async fn mainnet_default_chain_service_falls_back_to_blockstream() {
1406 use platform_utils::{HttpClient, HttpError, HttpResponse};
1407 use std::{collections::HashMap, sync::Arc};
1408
1409 struct BlockedMempoolClient;
1411
1412 #[macros::async_trait]
1413 impl HttpClient for BlockedMempoolClient {
1414 async fn get(
1415 &self,
1416 url: String,
1417 _headers: Option<HashMap<String, String>>,
1418 ) -> Result<HttpResponse, HttpError> {
1419 if url.starts_with("https://mempool.space/") {
1420 return Err(HttpError::Status {
1421 status: 403,
1422 body: "blocked".to_string(),
1423 });
1424 }
1425 assert_eq!(url, "https://blockstream.info/api/fee-estimates");
1426 Ok(HttpResponse {
1427 status: 200,
1428 body: r#"{"1":5.0,"3":3.0,"6":2.0,"144":1.0}"#.to_string(),
1429 headers: HashMap::new(),
1430 })
1431 }
1432
1433 async fn post(
1434 &self,
1435 _url: String,
1436 _headers: Option<HashMap<String, String>>,
1437 _body: Option<String>,
1438 ) -> Result<HttpResponse, HttpError> {
1439 panic!("fee estimation must not POST")
1440 }
1441
1442 async fn delete(
1443 &self,
1444 _url: String,
1445 _headers: Option<HashMap<String, String>>,
1446 _body: Option<String>,
1447 ) -> Result<HttpResponse, HttpError> {
1448 panic!("fee estimation must not DELETE")
1449 }
1450 }
1451
1452 let mut context =
1453 crate::new_shared_sdk_context(crate::SdkContextConfig::new(Network::Mainnet))
1454 .await
1455 .unwrap();
1456 Arc::get_mut(&mut context).unwrap().http_client = Arc::new(BlockedMempoolClient);
1457 let chain_service =
1458 super::resolve_chain_service(None, vec![], &context, Network::Mainnet).unwrap();
1459 let fees = chain_service.recommended_fees().await.unwrap();
1460 assert_eq!(fees.fastest_fee, 5);
1461 }
1462
1463 #[test]
1464 fn default_config_spark_config_builds_valid_wallet_config() {
1465 for network in [Network::Mainnet, Network::Regtest] {
1466 let config = default_config(network);
1467 let spark_config = config
1468 .spark_config
1469 .as_ref()
1470 .expect("default_config must populate spark_config");
1471 SdkBuilder::build_spark_wallet_config(network.into(), spark_config).unwrap_or_else(
1472 |e| {
1473 panic!(
1474 "default_config({network:?}).spark_config failed to build SparkWalletConfig: {e}"
1475 )
1476 },
1477 );
1478 }
1479 }
1480
1481 #[test]
1482 fn validate_signer_capabilities_gates_encryption_features() {
1483 let mut config = default_config(Network::Regtest);
1485 config.real_time_sync_server_url = Some("https://example.com".to_string());
1486 assert!(super::validate_signer_capabilities(&config, true).is_ok());
1487
1488 let mut config = default_config(Network::Regtest);
1490 config.real_time_sync_server_url = Some("https://example.com".to_string());
1491 config.cross_chain_config = None;
1492 match super::validate_signer_capabilities(&config, false) {
1493 Err(SdkError::InvalidInput(m)) => {
1494 assert!(m.contains("Real-time sync"), "got: {m}");
1495 }
1496 other => panic!("expected InvalidInput, got {other:?}"),
1497 }
1498
1499 let mut config = default_config(Network::Regtest);
1502 config.real_time_sync_server_url = None;
1503 config.cross_chain_config = None;
1504 assert!(super::validate_signer_capabilities(&config, false).is_ok());
1505 }
1506
1507 #[test]
1511 fn build_signers_signing_only_omits_ecies_dependent_signers() {
1512 use crate::error::SignerError;
1513 use crate::signer::external_types::{
1514 EcdsaSignatureBytes, MessageBytes, PublicKeyBytes, RecoverableEcdsaSignatureBytes,
1515 SchnorrSignatureBytes,
1516 };
1517 use crate::signer::{
1518 DefaultExternalSparkSigner, ExternalSigningSigner, ExternalSparkSigner,
1519 };
1520 use std::sync::Arc;
1521
1522 struct StubSigningSigner;
1524 #[macros::async_trait]
1525 impl ExternalSigningSigner for StubSigningSigner {
1526 async fn derive_public_key(&self, _p: String) -> Result<PublicKeyBytes, SignerError> {
1527 unreachable!("build_signers must not call the signer")
1528 }
1529 async fn sign_ecdsa(
1530 &self,
1531 _m: MessageBytes,
1532 _p: String,
1533 ) -> Result<EcdsaSignatureBytes, SignerError> {
1534 unreachable!("build_signers must not call the signer")
1535 }
1536 async fn sign_ecdsa_recoverable(
1537 &self,
1538 _m: MessageBytes,
1539 _p: String,
1540 ) -> Result<RecoverableEcdsaSignatureBytes, SignerError> {
1541 unreachable!("build_signers must not call the signer")
1542 }
1543 async fn sign_hash_schnorr(
1544 &self,
1545 _h: Vec<u8>,
1546 _p: String,
1547 ) -> Result<SchnorrSignatureBytes, SignerError> {
1548 unreachable!("build_signers must not call the signer")
1549 }
1550 }
1551
1552 let config = default_config(Network::Regtest);
1553
1554 let full = super::build_signers(
1556 &config,
1557 &super::SignerSource::Seed {
1558 seed: test_seed(),
1559 account_number: None,
1560 },
1561 )
1562 .unwrap();
1563 assert!(full.ecies.is_some());
1564 assert!(full.rtsync.is_some());
1565 assert!(full.lnurl_auth.is_some());
1566
1567 let breez: Arc<dyn ExternalSigningSigner> = Arc::new(StubSigningSigner);
1569 let spark: Arc<dyn ExternalSparkSigner> = Arc::new(
1570 DefaultExternalSparkSigner::new(
1571 TEST_MNEMONIC.to_string(),
1572 None,
1573 Network::Regtest,
1574 None,
1575 )
1576 .unwrap(),
1577 );
1578 let signing_only = super::build_signers(
1579 &config,
1580 &super::SignerSource::External {
1581 breez: super::ExternalBreez::SigningOnly(breez),
1582 spark,
1583 },
1584 )
1585 .unwrap();
1586 assert!(signing_only.ecies.is_none());
1587 assert!(signing_only.rtsync.is_none());
1588 assert!(signing_only.lnurl_auth.is_none());
1589 }
1590
1591 #[tokio::test]
1594 async fn signing_only_signer_build_disables_lnurl_auth() {
1595 use crate::error::SignerError;
1596 use crate::signer::external_types::{
1597 EcdsaSignatureBytes, MessageBytes, PublicKeyBytes, RecoverableEcdsaSignatureBytes,
1598 SchnorrSignatureBytes,
1599 };
1600 use crate::signer::{
1601 DefaultExternalSigner, DefaultExternalSparkSigner, ExternalBreezSigner,
1602 ExternalSigningSigner, ExternalSparkSigner,
1603 };
1604 use std::sync::Arc;
1605
1606 struct SigningOnly(DefaultExternalSigner);
1609 #[macros::async_trait]
1610 impl ExternalSigningSigner for SigningOnly {
1611 async fn derive_public_key(&self, path: String) -> Result<PublicKeyBytes, SignerError> {
1612 self.0.derive_public_key(path).await
1613 }
1614 async fn sign_ecdsa(
1615 &self,
1616 message: MessageBytes,
1617 path: String,
1618 ) -> Result<EcdsaSignatureBytes, SignerError> {
1619 self.0.sign_ecdsa(message, path).await
1620 }
1621 async fn sign_ecdsa_recoverable(
1622 &self,
1623 message: MessageBytes,
1624 path: String,
1625 ) -> Result<RecoverableEcdsaSignatureBytes, SignerError> {
1626 self.0.sign_ecdsa_recoverable(message, path).await
1627 }
1628 async fn sign_hash_schnorr(
1629 &self,
1630 hash: Vec<u8>,
1631 path: String,
1632 ) -> Result<SchnorrSignatureBytes, SignerError> {
1633 self.0.sign_hash_schnorr(hash, path).await
1634 }
1635 }
1636
1637 let mut config = default_config(Network::Regtest);
1638 config.real_time_sync_server_url = None;
1640 config.private_enabled_default = false;
1641
1642 let breez: Arc<dyn ExternalSigningSigner> = Arc::new(SigningOnly(
1643 DefaultExternalSigner::new(TEST_MNEMONIC.to_string(), None, Network::Regtest, None)
1644 .unwrap(),
1645 ));
1646 let spark: Arc<dyn ExternalSparkSigner> = Arc::new(
1647 DefaultExternalSparkSigner::new(
1648 TEST_MNEMONIC.to_string(),
1649 None,
1650 Network::Regtest,
1651 None,
1652 )
1653 .unwrap(),
1654 );
1655
1656 let sdk = SdkBuilder::new_with_signing_only_signer(config, breez, spark)
1657 .with_default_storage(unique_storage_dir("signing-only-lnurl"))
1658 .build()
1659 .await
1660 .expect("signing-only build should succeed");
1661
1662 assert!(
1663 sdk.lnurl_auth_signer.is_none(),
1664 "a signing-only signer must not build an lnurl-auth signer"
1665 );
1666
1667 let err = sdk
1668 .lnurl_auth(crate::LnurlAuthRequestDetails {
1669 k1: "00".repeat(32),
1670 action: None,
1671 domain: "example.com".to_string(),
1672 url: "https://example.com/lnurl-auth".to_string(),
1673 })
1674 .await
1675 .expect_err("lnurl_auth must fail for a signing-only signer");
1676 assert!(
1677 matches!(err, SdkError::Generic(_)),
1678 "expected Generic, got {err:?}"
1679 );
1680
1681 sdk.disconnect().await.expect("disconnect should succeed");
1682 }
1683
1684 #[tokio::test]
1688 async fn signing_only_signer_build_rejects_ecies_features() {
1689 use crate::error::SignerError;
1690 use crate::signer::external_types::{
1691 EcdsaSignatureBytes, MessageBytes, PublicKeyBytes, RecoverableEcdsaSignatureBytes,
1692 SchnorrSignatureBytes,
1693 };
1694 use crate::signer::{
1695 DefaultExternalSparkSigner, ExternalSigningSigner, ExternalSparkSigner,
1696 };
1697 use std::sync::Arc;
1698
1699 struct StubSigningSigner;
1701 #[macros::async_trait]
1702 impl ExternalSigningSigner for StubSigningSigner {
1703 async fn derive_public_key(&self, _p: String) -> Result<PublicKeyBytes, SignerError> {
1704 unreachable!("build must fail before calling the signer")
1705 }
1706 async fn sign_ecdsa(
1707 &self,
1708 _m: MessageBytes,
1709 _p: String,
1710 ) -> Result<EcdsaSignatureBytes, SignerError> {
1711 unreachable!("build must fail before calling the signer")
1712 }
1713 async fn sign_ecdsa_recoverable(
1714 &self,
1715 _m: MessageBytes,
1716 _p: String,
1717 ) -> Result<RecoverableEcdsaSignatureBytes, SignerError> {
1718 unreachable!("build must fail before calling the signer")
1719 }
1720 async fn sign_hash_schnorr(
1721 &self,
1722 _h: Vec<u8>,
1723 _p: String,
1724 ) -> Result<SchnorrSignatureBytes, SignerError> {
1725 unreachable!("build must fail before calling the signer")
1726 }
1727 }
1728
1729 let mut config = default_config(Network::Regtest);
1730 config.real_time_sync_server_url = Some("https://example.com".to_string());
1731
1732 let breez: Arc<dyn ExternalSigningSigner> = Arc::new(StubSigningSigner);
1733 let spark: Arc<dyn ExternalSparkSigner> = Arc::new(
1734 DefaultExternalSparkSigner::new(
1735 TEST_MNEMONIC.to_string(),
1736 None,
1737 Network::Regtest,
1738 None,
1739 )
1740 .unwrap(),
1741 );
1742
1743 let result = SdkBuilder::new_with_signing_only_signer(config, breez, spark)
1744 .with_default_storage(unique_storage_dir("signing-only-reject-rtsync"))
1745 .build()
1746 .await;
1747 match result {
1748 Err(SdkError::InvalidInput(_)) => {}
1749 Ok(_) => panic!("build must reject real-time sync for a signing-only signer"),
1750 Err(other) => panic!("expected InvalidInput, got {other:?}"),
1751 }
1752 }
1753
1754 #[tokio::test]
1755 async fn wrap_session_store_stores_token_verbatim() {
1756 use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
1757 use spark_wallet::{Session, SessionStore, SessionStoreError};
1758 use std::collections::HashMap;
1759 use std::sync::{Arc, Mutex};
1760
1761 #[derive(Default)]
1763 struct InspectableInner {
1764 sessions: Mutex<HashMap<PublicKey, Session>>,
1765 }
1766 #[macros::async_trait]
1767 impl SessionStore for InspectableInner {
1768 async fn get_session(&self, key: &PublicKey) -> Result<Session, SessionStoreError> {
1769 self.sessions
1770 .lock()
1771 .unwrap()
1772 .get(key)
1773 .cloned()
1774 .ok_or(SessionStoreError::NotFound)
1775 }
1776 async fn set_session(
1777 &self,
1778 key: &PublicKey,
1779 session: Session,
1780 ) -> Result<(), SessionStoreError> {
1781 self.sessions.lock().unwrap().insert(*key, session);
1782 Ok(())
1783 }
1784 }
1785 fn key() -> PublicKey {
1786 let secp = Secp256k1::new();
1787 PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[3u8; 32]).unwrap())
1788 }
1789
1790 let inner = Arc::new(InspectableInner::default());
1791 let store = super::wrap_session_store(Some(inner.clone()));
1792 let token = "bearer-token".to_string();
1793 store
1794 .set_session(
1795 &key(),
1796 Session {
1797 token: token.clone(),
1798 expiration: 1,
1799 refresh_token: None,
1800 refresh_token_expiration: None,
1801 },
1802 )
1803 .await
1804 .unwrap();
1805
1806 let raw = inner.sessions.lock().unwrap().get(&key()).cloned().unwrap();
1809 assert_eq!(raw.token, token, "the SDK must store the token as-is");
1810 assert_eq!(store.get_session(&key()).await.unwrap().token, token);
1811 }
1812
1813 #[tokio::test]
1814 async fn server_mode_rejects_stable_balance_config() {
1815 use crate::{SdkError, StableBalanceConfig, StableBalanceToken, default_server_config};
1816
1817 let mut config = default_server_config(Network::Regtest);
1818 config.stable_balance_config = Some(StableBalanceConfig {
1819 tokens: vec![StableBalanceToken {
1820 label: "USDB".to_string(),
1821 token_identifier: REGTEST_TOKEN.to_string(),
1822 }],
1823 default_active_label: None,
1824 threshold_sats: None,
1825 max_slippage_bps: None,
1826 });
1827
1828 let seed = test_seed();
1829 let result = SdkBuilder::new(config, seed).build().await;
1830 match result {
1831 Err(SdkError::InvalidInput(message)) => {
1832 assert!(message.contains("stable_balance_config"));
1833 }
1834 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1835 Ok(_) => panic!("expected server mode with Stable Balance config to fail"),
1836 }
1837 }
1838
1839 #[tokio::test]
1840 async fn server_mode_rejects_real_time_sync_server_url() {
1841 use crate::{SdkError, default_server_config};
1842
1843 let mut config = default_server_config(Network::Regtest);
1844 config.real_time_sync_server_url = Some("https://example.com".to_string());
1845
1846 let seed = test_seed();
1847 let result = SdkBuilder::new(config, seed).build().await;
1848 match result {
1849 Err(SdkError::InvalidInput(message)) => {
1850 assert!(message.contains("real_time_sync_server_url"));
1851 }
1852 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1853 Ok(_) => panic!("expected server mode with real_time_sync_server_url to fail"),
1854 }
1855 }
1856
1857 #[tokio::test]
1858 async fn server_mode_rejects_leaf_optimization_auto_enabled() {
1859 use crate::{SdkError, default_server_config};
1860
1861 let mut config = default_server_config(Network::Regtest);
1862 config.leaf_optimization_config.auto_enabled = true;
1863
1864 let seed = test_seed();
1865 let result = SdkBuilder::new(config, seed).build().await;
1866 match result {
1867 Err(SdkError::InvalidInput(message)) => {
1868 assert!(message.contains("leaf_optimization_config.auto_enabled"));
1869 }
1870 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1871 Ok(_) => panic!("expected server mode with optimization auto_enabled to fail"),
1872 }
1873 }
1874
1875 #[tokio::test]
1876 async fn server_mode_rejects_token_optimization_auto_enabled() {
1877 use crate::{SdkError, default_server_config};
1878
1879 let mut config = default_server_config(Network::Regtest);
1880 config.token_optimization_config.auto_enabled = true;
1881
1882 let seed = test_seed();
1883 let result = SdkBuilder::new(config, seed).build().await;
1884 match result {
1885 Err(SdkError::InvalidInput(message)) => {
1886 assert!(message.contains("token_optimization_config.auto_enabled"));
1887 }
1888 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1889 Ok(_) => panic!("expected server mode with optimization auto_enabled to fail"),
1890 }
1891 }
1892
1893 fn test_proxy() -> crate::ProxyConfig {
1896 crate::ProxyConfig {
1897 host: "127.0.0.1".to_string(),
1898 port: 9050,
1899 username: None,
1900 password: None,
1901 }
1902 }
1903
1904 #[test]
1907 fn validate_accepts_proxy_with_cross_chain_config() {
1908 use crate::{CrossChainConfig, default_config};
1909 let mut config = default_config(Network::Mainnet);
1910 config.proxy = Some(test_proxy());
1911 config.cross_chain_config = Some(CrossChainConfig::default());
1912
1913 assert!(config.validate().is_ok());
1914 }
1915
1916 #[test]
1917 fn validate_rejects_half_specified_proxy_credentials() {
1918 use crate::{SdkError, default_config};
1919 let mut config = default_config(Network::Mainnet);
1920 let mut proxy = test_proxy();
1921 proxy.username = Some("user".to_string());
1922 config.proxy = Some(proxy);
1923
1924 match config.validate() {
1925 Err(SdkError::InvalidInput(m)) => assert!(
1926 m.contains("username and password"),
1927 "expected credential-pair rejection, got: {m}"
1928 ),
1929 other => panic!("expected a username without a password to fail, got {other:?}"),
1930 }
1931 }
1932
1933 #[test]
1934 fn validate_rejects_empty_proxy_host_and_zero_port() {
1935 use crate::{SdkError, default_config};
1936 let mut config = default_config(Network::Mainnet);
1937
1938 let mut proxy = test_proxy();
1939 proxy.host = String::new();
1940 config.proxy = Some(proxy);
1941 assert!(matches!(config.validate(), Err(SdkError::InvalidInput(_))));
1942
1943 let mut proxy = test_proxy();
1944 proxy.port = 0;
1945 config.proxy = Some(proxy);
1946 assert!(matches!(config.validate(), Err(SdkError::InvalidInput(_))));
1947 }
1948
1949 #[test]
1953 fn validate_rejects_proxy_host_that_cannot_form_a_url() {
1954 use crate::{SdkError, default_config};
1955 for host in ["local host", "user@host", "socks5://127.0.0.1", "[::g]"] {
1956 let mut config = default_config(Network::Mainnet);
1957 let mut proxy = test_proxy();
1958 proxy.host = host.to_string();
1959 config.proxy = Some(proxy);
1960
1961 match config.validate() {
1962 Err(SdkError::InvalidInput(m)) => {
1963 assert!(m.contains(host), "expected the host in the error, got: {m}");
1964 }
1965 other => panic!("expected host {host:?} to be rejected, got {other:?}"),
1966 }
1967 }
1968 }
1969
1970 #[test]
1973 fn validate_accepts_bare_ipv6_proxy_host() {
1974 use crate::default_config;
1975 let mut config = default_config(Network::Mainnet);
1976 let mut proxy = test_proxy();
1977 proxy.host = "::1".to_string();
1978 config.proxy = Some(proxy);
1979
1980 assert!(config.validate().is_ok());
1981 }
1982
1983 #[tokio::test]
1986 async fn shared_context_rejects_proxy_with_balanced_connections() {
1987 use crate::{SdkContextConfig, SdkError, new_shared_sdk_context};
1988 let result = new_shared_sdk_context(SdkContextConfig {
1989 proxy: Some(test_proxy()),
1990 connections_per_operator: Some(4),
1991 ..SdkContextConfig::new(Network::Regtest)
1992 })
1993 .await;
1994
1995 match result.err() {
1996 Some(SdkError::InvalidInput(m)) => assert!(
1997 m.contains("connections_per_operator"),
1998 "expected balanced-connection rejection, got: {m}"
1999 ),
2000 other => panic!("expected proxy + balanced connections to fail, got {other:?}"),
2001 }
2002 }
2003
2004 #[tokio::test]
2007 async fn resolve_context_errors_on_proxy_mismatch() {
2008 use crate::{SdkContextConfig, default_config, new_shared_sdk_context};
2009 let mut config = default_config(Network::Regtest);
2010 config.proxy = Some(test_proxy());
2011
2012 let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
2013 .await
2014 .expect("regtest context");
2015
2016 let err = super::resolve_context(Some(ctx), &config)
2017 .await
2018 .err()
2019 .expect("expected mismatch error");
2020 assert!(
2021 err.to_string().contains("proxy does not match"),
2022 "unexpected error: {err}"
2023 );
2024 }
2025
2026 #[tokio::test]
2031 async fn build_rejects_cross_chain_config_on_regtest() {
2032 use crate::{CrossChainConfig, SdkError, default_config};
2033 let mut config = default_config(Network::Regtest);
2034 config.cross_chain_config = Some(CrossChainConfig::default());
2035
2036 let seed = test_seed();
2037 let result = SdkBuilder::new(config, seed).build().await;
2038 match result {
2039 Err(SdkError::InvalidInput(m)) => {
2040 assert!(
2041 m.contains("only available on Mainnet"),
2042 "expected mainnet-only rejection, got: {m}"
2043 );
2044 }
2045 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
2046 Ok(_) => panic!("expected regtest with cross_chain_config to fail"),
2047 }
2048 }
2049
2050 #[tokio::test]
2054 async fn build_errors_on_network_mismatch() {
2055 use crate::{SdkContextConfig, new_shared_sdk_context};
2056 let mut config = default_config(Network::Mainnet);
2057 config.api_key = Some("partner-key".to_string());
2058 let ctx = new_shared_sdk_context(SdkContextConfig {
2059 api_key: Some("partner-key".to_string()),
2060 ..SdkContextConfig::new(Network::Regtest)
2061 })
2062 .await
2063 .expect("regtest context");
2064 let err = SdkBuilder::new(config, test_seed())
2065 .with_shared_context(ctx)
2066 .with_default_storage("/tmp/breez-sdk-test-network-mismatch".to_string())
2067 .build()
2068 .await
2069 .err()
2070 .expect("expected network-mismatch error");
2071 assert!(
2072 err.to_string().contains("network/api_key do not match"),
2073 "unexpected error: {err}"
2074 );
2075 }
2076
2077 #[tokio::test]
2081 #[allow(clippy::manual_assert)]
2082 async fn build_errors_on_api_key_mismatch() {
2083 use crate::{SdkContextConfig, new_shared_sdk_context};
2084 let mut config = default_config(Network::Mainnet);
2085 config.api_key = Some("intended-key".to_string());
2086 let ctx = new_shared_sdk_context(SdkContextConfig {
2087 api_key: Some("wrong-key".to_string()),
2088 ..SdkContextConfig::new(Network::Mainnet)
2089 })
2090 .await
2091 .expect("mainnet context");
2092 let err = SdkBuilder::new(config, test_seed())
2093 .with_shared_context(ctx)
2094 .with_default_storage("/tmp/breez-sdk-test-key-mismatch".to_string())
2095 .build()
2096 .await
2097 .err()
2098 .expect("expected api_key-mismatch error");
2099 assert!(
2100 err.to_string().contains("network/api_key do not match"),
2101 "unexpected error: {err}"
2102 );
2103 }
2104
2105 const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
2106
2107 fn test_seed() -> crate::Seed {
2108 crate::Seed::Mnemonic {
2109 mnemonic: TEST_MNEMONIC.to_string(),
2110 passphrase: None,
2111 }
2112 }
2113
2114 fn unique_storage_dir(name: &str) -> String {
2115 std::env::temp_dir()
2116 .join(format!("breez-sdk-test-{}-{}", name, uuid::Uuid::new_v4()))
2117 .to_string_lossy()
2118 .into_owned()
2119 }
2120
2121 async fn assert_sdk_graph_freed(
2125 event_emitter: std::sync::Weak<crate::EventEmitter>,
2126 spark_wallet: std::sync::Weak<spark_wallet::SparkWallet>,
2127 ) {
2128 for _ in 0..100 {
2129 if event_emitter.upgrade().is_none() && spark_wallet.upgrade().is_none() {
2130 return;
2131 }
2132 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2133 }
2134 panic!(
2135 "SDK object graph leaked after drop (EventEmitter alive: {}, SparkWallet alive: {})",
2136 event_emitter.upgrade().is_some(),
2137 spark_wallet.upgrade().is_some(),
2138 );
2139 }
2140
2141 #[tokio::test]
2142 async fn rewards_delegator_is_not_built_off_mainnet() {
2143 let sdk = SdkBuilder::new(default_config(Network::Regtest), test_seed())
2144 .with_default_storage(unique_storage_dir("rewards-off-mainnet"))
2145 .build()
2146 .await
2147 .expect("build should succeed");
2148
2149 assert!(sdk.rewards_delegator.is_none());
2150 sdk.disconnect().await.expect("disconnect should succeed");
2151 }
2152
2153 #[tokio::test]
2157 async fn server_mode_sdk_graph_is_freed_on_drop() {
2158 use crate::default_server_config;
2159
2160 let config = default_server_config(Network::Regtest);
2161 let sdk = SdkBuilder::new(config, test_seed())
2162 .with_default_storage(unique_storage_dir("leak-server"))
2163 .build()
2164 .await
2165 .expect("server-mode build should succeed");
2166
2167 let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
2168 let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
2169
2170 sdk.disconnect().await.expect("disconnect should succeed");
2171 drop(sdk);
2172
2173 assert_sdk_graph_freed(event_emitter, spark_wallet).await;
2174 }
2175
2176 #[tokio::test]
2180 #[ignore = "requires network access to the regtest operators"]
2181 async fn server_mode_sdk_graph_is_freed_on_drop_after_sync() {
2182 use crate::{SyncWalletRequest, default_server_config};
2183
2184 let config = default_server_config(Network::Regtest);
2185 let sdk = SdkBuilder::new(config, test_seed())
2186 .with_default_storage(unique_storage_dir("leak-server-sync"))
2187 .build()
2188 .await
2189 .expect("server-mode build should succeed");
2190
2191 let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
2192 let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
2193
2194 sdk.sync_wallet(SyncWalletRequest {})
2195 .await
2196 .expect("sync_wallet should succeed");
2197
2198 sdk.disconnect().await.expect("disconnect should succeed");
2199 drop(sdk);
2200
2201 assert_sdk_graph_freed(event_emitter, spark_wallet).await;
2202 }
2203
2204 #[tokio::test]
2207 async fn client_mode_sdk_graph_is_freed_after_disconnect_and_drop() {
2208 let mut config = default_config(Network::Regtest);
2209 config.real_time_sync_server_url = None;
2212 config.private_enabled_default = false;
2213
2214 let sdk = SdkBuilder::new(config, test_seed())
2215 .with_default_storage(unique_storage_dir("leak-client"))
2216 .build()
2217 .await
2218 .expect("client-mode build should succeed");
2219
2220 let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
2221 let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
2222
2223 tokio::time::timeout(std::time::Duration::from_secs(30), sdk.disconnect())
2224 .await
2225 .expect("disconnect should not hang")
2226 .expect("disconnect should succeed");
2227 drop(sdk);
2228
2229 assert_sdk_graph_freed(event_emitter, spark_wallet).await;
2230 }
2231
2232 #[tokio::test]
2237 async fn client_mode_sdk_graph_is_freed_with_real_time_sync_enabled() {
2238 use crate::{StableBalanceConfig, StableBalanceToken};
2239
2240 let mut config = default_config(Network::Regtest);
2241 config.real_time_sync_server_url = Some("http://127.0.0.1:9".to_string());
2245 config.private_enabled_default = false;
2246 config.stable_balance_config = Some(StableBalanceConfig {
2249 tokens: vec![StableBalanceToken {
2250 label: "USDB".to_string(),
2251 token_identifier: REGTEST_TOKEN.to_string(),
2252 }],
2253 default_active_label: None,
2254 threshold_sats: None,
2255 max_slippage_bps: None,
2256 });
2257
2258 let sdk = SdkBuilder::new(config, test_seed())
2259 .with_default_storage(unique_storage_dir("leak-client-rtsync"))
2260 .build()
2261 .await
2262 .expect("client-mode build with real-time sync should succeed");
2263
2264 let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
2265 let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
2266
2267 tokio::time::timeout(std::time::Duration::from_secs(30), sdk.disconnect())
2268 .await
2269 .expect("disconnect should not hang")
2270 .expect("disconnect should succeed");
2271 drop(sdk);
2272
2273 assert_sdk_graph_freed(event_emitter, spark_wallet).await;
2274 }
2275
2276 #[tokio::test]
2279 async fn disconnect_unregisters_event_listeners() {
2280 use crate::{SdkEvent, default_server_config, events::EventListener};
2281
2282 struct NoopListener;
2283
2284 #[macros::async_trait]
2285 impl EventListener for NoopListener {
2286 async fn on_event(&self, _event: SdkEvent) {}
2287 }
2288
2289 let storage_dir = std::env::temp_dir()
2290 .join(format!(
2291 "breez-sdk-test-listener-clear-{}",
2292 uuid::Uuid::new_v4()
2293 ))
2294 .to_string_lossy()
2295 .into_owned();
2296 let sdk = SdkBuilder::new(default_server_config(Network::Regtest), test_seed())
2297 .with_default_storage(storage_dir)
2298 .build()
2299 .await
2300 .expect("server-mode build should succeed");
2301
2302 let id = sdk.add_event_listener(Box::new(NoopListener)).await;
2303
2304 sdk.disconnect().await.expect("disconnect should succeed");
2305
2306 assert!(
2307 !sdk.remove_event_listener(&id).await,
2308 "listener should already be unregistered by disconnect"
2309 );
2310 }
2311
2312 #[tokio::test]
2315 async fn bridging_client_is_mainnet_only() {
2316 use crate::{BridgeToCashAppRequest, CrossChainProvider, CrossChainRoutePair, SdkError};
2317
2318 let storage_dir = std::env::temp_dir()
2319 .join(format!("breez-sdk-test-bridging-{}", uuid::Uuid::new_v4()))
2320 .to_string_lossy()
2321 .into_owned();
2322 let sdk = SdkBuilder::new(crate::default_server_config(Network::Regtest), test_seed())
2323 .with_default_storage(storage_dir)
2324 .build()
2325 .await
2326 .expect("server-mode build should succeed");
2327
2328 assert!(matches!(
2329 sdk.get_bridging_client(),
2330 Err(SdkError::Generic(message)) if message == "Only available on mainnet"
2331 ));
2332 let bridge = sdk
2333 .bridge_to_cash_app(BridgeToCashAppRequest {
2334 recipient: "alice".to_string(),
2335 route: CrossChainRoutePair {
2336 provider: CrossChainProvider::Orchestra,
2337 chain: "base".to_string(),
2338 chain_id: None,
2339 asset: "USDC".to_string(),
2340 contract_address: None,
2341 decimals: 6,
2342 exact_out_eligible: false,
2343 accepted_assets: vec![],
2344 delivery_methods: vec![],
2345 },
2346 amount: 1_000_000,
2347 fee_policy: None,
2348 refund_address: "0x0000000000000000000000000000000000000001".to_string(),
2349 max_slippage_bps: None,
2350 })
2351 .await;
2352 assert!(
2353 matches!(bridge, Err(SdkError::Generic(message)) if message == "Only available on mainnet")
2354 );
2355
2356 sdk.disconnect().await.expect("disconnect should succeed");
2357 }
2358
2359 fn test_spark_signer() -> std::sync::Arc<dyn spark_wallet::SparkSigner> {
2360 use std::sync::Arc;
2361
2362 let seed = test_seed();
2363 let seed_bytes = seed.to_bytes().unwrap();
2364 let master =
2365 spark_wallet::account_master_key(&seed_bytes, Network::Regtest.into(), None).unwrap();
2366 Arc::new(spark_wallet::SparkSignerAdapter::new(Arc::new(
2367 spark_wallet::DefaultSigner::from_master(master),
2368 )))
2369 }
2370
2371 const REGTEST_TOKEN: &str = "btknrt14w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w4sh59f4v";
2372
2373 fn stable_balance_with(token_identifier: &str) -> crate::StableBalanceConfig {
2374 crate::StableBalanceConfig {
2375 tokens: vec![crate::StableBalanceToken {
2376 label: "USDB".to_string(),
2377 token_identifier: token_identifier.to_string(),
2378 }],
2379 default_active_label: None,
2380 threshold_sats: None,
2381 max_slippage_bps: None,
2382 }
2383 }
2384
2385 fn assert_rejects_token(config: &crate::Config) {
2386 match config.validate() {
2387 Err(SdkError::InvalidInput(m)) => assert!(
2388 m.contains("bech32m"),
2389 "expected a token identifier rejection, got: {m}"
2390 ),
2391 other => panic!("expected InvalidInput, got {other:?}"),
2392 }
2393 }
2394
2395 #[test]
2396 fn validate_accepts_a_bech32m_stable_balance_token() {
2397 let mut config = crate::default_config(Network::Regtest);
2398 config.stable_balance_config = Some(stable_balance_with(REGTEST_TOKEN));
2399 assert!(config.validate().is_ok());
2400 }
2401
2402 #[test]
2403 fn validate_rejects_a_hex_stable_balance_token() {
2404 let mut config = crate::default_config(Network::Mainnet);
2405 config.stable_balance_config = Some(stable_balance_with(
2406 "3206c93b24a4d18ea19d0a9a213204af2c7e74a6d16c7535cc5d33eca4ad1eca",
2407 ));
2408 assert_rejects_token(&config);
2409 }
2410
2411 #[test]
2412 fn validate_rejects_a_stable_balance_token_for_another_network() {
2413 let mut config = crate::default_config(Network::Regtest);
2414 config.stable_balance_config = Some(stable_balance_with(
2415 "btkn1xgrvjwey5ngcagvap2dzzvsy4uk8ua9x69k82dwvt5e7ef9drm9qztux87",
2416 ));
2417 assert_rejects_token(&config);
2418 }
2419
2420 #[test]
2423 fn validate_server_mode_ok_when_background_enabled() {
2424 use crate::{StableBalanceConfig, StableBalanceToken, default_server_config};
2425 let mut config = default_server_config(Network::Regtest);
2426 config.stable_balance_config = Some(StableBalanceConfig {
2427 tokens: vec![StableBalanceToken {
2428 label: "USDB".to_string(),
2429 token_identifier: REGTEST_TOKEN.to_string(),
2430 }],
2431 default_active_label: None,
2432 threshold_sats: None,
2433 max_slippage_bps: None,
2434 });
2435 config.real_time_sync_server_url = Some("https://example.com".to_string());
2436 config.leaf_optimization_config.auto_enabled = true;
2437 config.token_optimization_config.auto_enabled = true;
2438 assert!(super::validate_server_mode(&config, true).is_ok());
2440 }
2441
2442 #[test]
2443 fn validate_server_mode_ok_in_server_mode_without_background_features() {
2444 use crate::default_server_config;
2445 let config = default_server_config(Network::Regtest);
2446 assert!(super::validate_server_mode(&config, false).is_ok());
2447 }
2448
2449 #[test]
2450 fn validate_server_mode_rejects_stable_balance_directly() {
2451 use crate::{StableBalanceConfig, StableBalanceToken, default_server_config};
2452 let mut config = default_server_config(Network::Regtest);
2453 config.stable_balance_config = Some(StableBalanceConfig {
2454 tokens: vec![StableBalanceToken {
2455 label: "USDB".to_string(),
2456 token_identifier: REGTEST_TOKEN.to_string(),
2457 }],
2458 default_active_label: None,
2459 threshold_sats: None,
2460 max_slippage_bps: None,
2461 });
2462 match super::validate_server_mode(&config, false) {
2463 Err(SdkError::InvalidInput(m)) => assert!(m.contains("stable_balance_config")),
2464 other => panic!("expected InvalidInput, got {other:?}"),
2465 }
2466 }
2467
2468 #[test]
2469 fn validate_server_mode_rejects_real_time_sync_directly() {
2470 use crate::default_server_config;
2471 let mut config = default_server_config(Network::Regtest);
2472 config.real_time_sync_server_url = Some("https://example.com".to_string());
2473 match super::validate_server_mode(&config, false) {
2474 Err(SdkError::InvalidInput(m)) => assert!(m.contains("real_time_sync_server_url")),
2475 other => panic!("expected InvalidInput, got {other:?}"),
2476 }
2477 }
2478
2479 #[test]
2480 fn validate_server_mode_rejects_leaf_auto_optimize_directly() {
2481 use crate::default_server_config;
2482 let mut config = default_server_config(Network::Regtest);
2483 config.leaf_optimization_config.auto_enabled = true;
2484 match super::validate_server_mode(&config, false) {
2485 Err(SdkError::InvalidInput(m)) => {
2486 assert!(m.contains("leaf_optimization_config.auto_enabled"));
2487 }
2488 other => panic!("expected InvalidInput, got {other:?}"),
2489 }
2490 }
2491
2492 #[test]
2493 fn validate_server_mode_rejects_token_auto_optimize_directly() {
2494 use crate::default_server_config;
2495 let mut config = default_server_config(Network::Regtest);
2496 config.token_optimization_config.auto_enabled = true;
2497 match super::validate_server_mode(&config, false) {
2498 Err(SdkError::InvalidInput(m)) => {
2499 assert!(m.contains("token_optimization_config.auto_enabled"));
2500 }
2501 other => panic!("expected InvalidInput, got {other:?}"),
2502 }
2503 }
2504
2505 #[test]
2506 fn validate_server_mode_rejects_cross_chain_directly() {
2507 use crate::{CrossChainConfig, default_server_config};
2508 let mut config = default_server_config(Network::Regtest);
2509 config.cross_chain_config = Some(CrossChainConfig::default());
2510 match super::validate_server_mode(&config, false) {
2511 Err(SdkError::InvalidInput(m)) => assert!(m.contains("Cross-chain config")),
2512 other => panic!("expected InvalidInput, got {other:?}"),
2513 }
2514 }
2515
2516 #[test]
2519 fn finalize_spark_wallet_config_disabled_background_forces_leaf_auto_off() {
2520 let mut config = default_config(Network::Regtest);
2521 config.leaf_optimization_config.auto_enabled = true;
2522 let result =
2523 super::finalize_spark_wallet_config(&config, "test-agent", false, false).unwrap();
2524 assert!(!result.leaf_auto_optimize_enabled);
2525 }
2526
2527 #[test]
2528 fn finalize_spark_wallet_config_disabled_background_clears_token_auto_interval() {
2529 let mut config = default_config(Network::Regtest);
2530 config.token_optimization_config.auto_enabled = true;
2531 let result =
2532 super::finalize_spark_wallet_config(&config, "test-agent", false, false).unwrap();
2533 assert!(
2534 result
2535 .token_outputs_optimization_options
2536 .auto_optimize_interval
2537 .is_none()
2538 );
2539 }
2540
2541 #[test]
2542 fn finalize_spark_wallet_config_enabled_background_respects_leaf_auto_optimize() {
2543 let mut config = default_config(Network::Regtest);
2544 config.leaf_optimization_config.auto_enabled = true;
2545 let result =
2546 super::finalize_spark_wallet_config(&config, "test-agent", true, false).unwrap();
2547 assert!(result.leaf_auto_optimize_enabled);
2548 }
2549
2550 #[test]
2551 fn finalize_spark_wallet_config_applies_user_agent() {
2552 let config = default_config(Network::Regtest);
2553 let result =
2554 super::finalize_spark_wallet_config(&config, "my-app/1.0", true, false).unwrap();
2555 assert_eq!(
2556 result.service_provider_config.user_agent.as_deref(),
2557 Some("my-app/1.0")
2558 );
2559 }
2560
2561 #[test]
2562 fn finalize_spark_wallet_config_keeps_the_fee_ladder_off_for_a_remote_signer() {
2563 let mut config = default_config(Network::Regtest);
2564 let ladder = |config: &crate::Config, remote_signer| {
2565 super::finalize_spark_wallet_config(config, "test-agent", true, remote_signer)
2566 .map(|wallet_config| wallet_config.fee_ladder_enabled)
2567 };
2568 assert!(ladder(&config, false).unwrap());
2569 assert!(!ladder(&config, true).unwrap());
2570
2571 config.watchtower_fee_ladder_enabled = Some(false);
2572 assert!(!ladder(&config, false).unwrap());
2573 assert!(!ladder(&config, true).unwrap());
2574
2575 config.watchtower_fee_ladder_enabled = Some(true);
2576 assert!(ladder(&config, false).unwrap());
2577 assert!(matches!(
2578 ladder(&config, true),
2579 Err(SdkError::InvalidInput(_))
2580 ));
2581 }
2582
2583 #[tokio::test]
2586 async fn resolve_context_errors_on_network_mismatch() {
2587 use crate::{SdkContextConfig, new_shared_sdk_context};
2588 let config = default_config(Network::Mainnet);
2589 let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
2590 .await
2591 .expect("regtest context");
2592 let err = super::resolve_context(Some(ctx), &config)
2593 .await
2594 .err()
2595 .expect("expected mismatch error");
2596 assert!(
2597 err.to_string().contains("network/api_key do not match"),
2598 "unexpected error: {err}"
2599 );
2600 }
2601
2602 #[tokio::test]
2603 async fn resolve_context_errors_on_api_key_mismatch() {
2604 use crate::{SdkContextConfig, new_shared_sdk_context};
2605 let mut config = default_config(Network::Mainnet);
2606 config.api_key = Some("intended-key".to_string());
2607 let ctx = new_shared_sdk_context(SdkContextConfig {
2608 api_key: Some("wrong-key".to_string()),
2609 ..SdkContextConfig::new(Network::Mainnet)
2610 })
2611 .await
2612 .expect("mainnet context");
2613 let err = super::resolve_context(Some(ctx), &config)
2614 .await
2615 .err()
2616 .expect("expected mismatch error");
2617 assert!(
2618 err.to_string().contains("network/api_key do not match"),
2619 "unexpected error: {err}"
2620 );
2621 }
2622
2623 #[tokio::test]
2626 async fn resolve_storage_errors_when_neither_supplied() {
2627 use crate::{SdkContextConfig, new_shared_sdk_context};
2628 let config = default_config(Network::Regtest);
2629 let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
2630 .await
2631 .expect("regtest context");
2632 let identity_public_key = test_spark_signer().get_identity_public_key().await.unwrap();
2633 let err = super::resolve_storage(None, &ctx, &identity_public_key, &config)
2634 .await
2635 .err()
2636 .expect("expected no-storage error");
2637 assert!(
2638 err.to_string().contains("No storage configured"),
2639 "unexpected error: {err}"
2640 );
2641 }
2642
2643 #[tokio::test]
2644 async fn resolve_storage_errors_when_supplied_on_both_builder_and_context() {
2645 use crate::{SdkContextConfig, default_storage, new_shared_sdk_context};
2646 let config = default_config(Network::Regtest);
2647 let ctx = new_shared_sdk_context(SdkContextConfig {
2648 storage: Some(default_storage(
2649 "/tmp/breez-sdk-test-resolve-storage-ctx".to_string(),
2650 )),
2651 ..SdkContextConfig::new(Network::Regtest)
2652 })
2653 .await
2654 .expect("regtest context");
2655 let identity_public_key = test_spark_signer().get_identity_public_key().await.unwrap();
2656 let builder_storage =
2657 default_storage("/tmp/breez-sdk-test-resolve-storage-builder".to_string());
2658 let err =
2659 super::resolve_storage(Some(builder_storage), &ctx, &identity_public_key, &config)
2660 .await
2661 .err()
2662 .expect("expected duplicate-storage error");
2663 assert!(
2664 err.to_string()
2665 .contains("storage is configured on both the SdkBuilder and the shared SdkContext"),
2666 "unexpected error: {err}"
2667 );
2668 }
2669}