1#![cfg_attr(
2 all(target_family = "wasm", target_os = "unknown"),
3 allow(clippy::arc_with_non_send_sync)
4)]
5use std::sync::Arc;
6
7use breez_sdk_common::breez_server::BreezServer;
8use breez_sdk_common::buy::moonpay::MoonpayProvider;
9
10use spark_wallet::{
11 InMemorySessionStore, SessionStore, SparkSigner, SparkWallet, SparkWalletConfig,
12};
13use tokio::sync::watch;
14use tracing::{debug, info, warn};
15
16use flashnet::{FlashnetConfig, IntegratorConfig};
17
18use crate::{
19 Credentials, EventEmitter, FiatService, FiatServiceWrapper, Network, Seed,
20 chain::{
21 BitcoinChainService, ValidatingChainService,
22 rest_client::{BasicAuth, ChainApiType, RestClientChainService},
23 },
24 error::SdkError,
25 lnurl::{DefaultLnurlServerClient, LnurlServerClient},
26 models::Config,
27 payment_observer::{PaymentObserver, SparkTransferObserver},
28 persist::backend::{ResolvedStores, StorageBackend},
29 realtime_sync::{RealTimeSyncParams, init_and_start_real_time_sync},
30 sdk::{BreezSdk, BreezSdkParams, SyncCoordinator, runtime_from_config},
31 sdk_context::{SdkContext, SdkContextConfig, new_shared_sdk_context},
32 signer::{breez::BreezSignerImpl, lnurl_auth::LnurlAuthSignerAdapter, rtsync::RTSyncSigner},
33 stable_balance::StableBalance,
34 token_conversion::TokenConversionMiddleware,
35 token_conversion::{
36 DEFAULT_INTEGRATOR_FEE_BPS, DEFAULT_INTEGRATOR_PUBKEY, FlashnetTokenConverter,
37 TokenConverter,
38 },
39};
40
41#[derive(Clone)]
47struct RestChainServiceConfig {
48 url: String,
49 api_type: ChainApiType,
50 credentials: Option<Credentials>,
51}
52
53#[derive(Clone)]
59enum ExternalBreez {
60 Full(Arc<dyn crate::signer::ExternalBreezSigner>),
61 SigningOnly(Arc<dyn crate::signer::ExternalSigningSigner>),
62}
63
64#[derive(Clone)]
65enum SignerSource {
66 Seed {
67 seed: Seed,
68 account_number: Option<u32>,
69 },
70 External {
71 breez: ExternalBreez,
72 spark: Arc<dyn crate::signer::ExternalSparkSigner>,
73 },
74}
75
76struct Signers {
80 ecies: Option<Arc<dyn crate::signer::EciesSigner>>,
81 spark: Arc<dyn SparkSigner>,
82 rtsync: Option<Arc<RTSyncSigner>>,
83 lnurl_auth: Option<Arc<LnurlAuthSignerAdapter>>,
84}
85
86struct BuildSparkWalletParams {
88 config: SparkWalletConfig,
89 spark_signer: Arc<dyn SparkSigner>,
90 session_store: Arc<dyn SessionStore>,
91 shutdown_receiver: Option<watch::Receiver<()>>,
96 tree_store: Option<Arc<dyn spark_wallet::TreeStore>>,
97 token_output_store: Option<Arc<dyn spark_wallet::TokenOutputStore>>,
98 payment_observer: Option<Arc<dyn PaymentObserver>>,
99 context: Arc<SdkContext>,
100}
101
102#[derive(Clone)]
104pub struct SdkBuilder {
105 config: Config,
106 signer_source: SignerSource,
107
108 storage: Option<Arc<dyn StorageBackend>>,
109 session_store: Option<Arc<dyn crate::session_store::SessionStore>>,
110 chain_service: Option<Arc<dyn BitcoinChainService>>,
111 rest_chain_service_config: Option<RestChainServiceConfig>,
112 fiat_service: Option<Arc<dyn FiatService>>,
113 lnurl_client: Option<Arc<dyn platform_utils::HttpClient>>,
114 lnurl_server_client: Option<Arc<dyn LnurlServerClient>>,
115 payment_observer: Option<Arc<dyn PaymentObserver>>,
116 context: Option<Arc<SdkContext>>,
117}
118
119impl SdkBuilder {
120 #[allow(clippy::needless_pass_by_value)]
128 pub fn new(config: Config, seed: Seed) -> Self {
129 SdkBuilder {
130 config,
131 signer_source: SignerSource::Seed {
132 seed,
133 account_number: None,
134 },
135 storage: None,
136 session_store: None,
137 chain_service: None,
138 rest_chain_service_config: None,
139 fiat_service: None,
140 lnurl_client: None,
141 lnurl_server_client: None,
142 payment_observer: None,
143 context: None,
144 }
145 }
146
147 #[allow(clippy::needless_pass_by_value)]
155 pub fn new_with_signer(
156 config: Config,
157 breez_signer: Arc<dyn crate::signer::ExternalBreezSigner>,
158 spark_signer: Arc<dyn crate::signer::ExternalSparkSigner>,
159 ) -> Self {
160 Self::with_external_signer(config, ExternalBreez::Full(breez_signer), spark_signer)
161 }
162
163 #[allow(clippy::needless_pass_by_value)]
175 pub fn new_with_signing_only_signer(
176 config: Config,
177 breez_signer: Arc<dyn crate::signer::ExternalSigningSigner>,
178 spark_signer: Arc<dyn crate::signer::ExternalSparkSigner>,
179 ) -> Self {
180 Self::with_external_signer(
181 config,
182 ExternalBreez::SigningOnly(breez_signer),
183 spark_signer,
184 )
185 }
186
187 fn with_external_signer(
188 config: Config,
189 breez: ExternalBreez,
190 spark_signer: Arc<dyn crate::signer::ExternalSparkSigner>,
191 ) -> Self {
192 SdkBuilder {
193 config,
194 signer_source: SignerSource::External {
195 breez,
196 spark: spark_signer,
197 },
198 storage: None,
199 session_store: None,
200 chain_service: None,
201 rest_chain_service_config: None,
202 fiat_service: None,
203 lnurl_client: None,
204 lnurl_server_client: None,
205 payment_observer: None,
206 context: None,
207 }
208 }
209
210 #[must_use]
223 pub fn with_account_number(mut self, account_number: u32) -> Self {
224 if let SignerSource::Seed {
225 account_number: ref mut an,
226 ..
227 } = self.signer_source
228 {
229 *an = Some(account_number);
230 }
231 self
232 }
233
234 #[cfg(feature = "sqlite")]
235 #[must_use]
236 pub fn with_default_storage(self, storage_dir: String) -> Self {
242 self.with_storage_backend(crate::default_storage(storage_dir))
243 }
244
245 #[must_use]
246 pub fn with_storage_backend(mut self, storage: Arc<dyn StorageBackend>) -> Self {
256 self.storage = Some(storage);
257 self
258 }
259
260 #[must_use]
274 pub fn with_session_store(
275 mut self,
276 session_store: Arc<dyn crate::session_store::SessionStore>,
277 ) -> Self {
278 self.session_store = Some(session_store);
279 self
280 }
281
282 #[must_use]
283 #[deprecated(note = "use `with_storage_backend(custom_storage(storage))`")]
289 pub fn with_storage(self, storage: Arc<dyn crate::Storage>) -> Self {
290 self.with_storage_backend(crate::custom_storage(storage))
291 }
292
293 #[cfg(feature = "postgres")]
297 #[deprecated(note = "use `with_storage_backend(postgres_storage(config)?)`")]
298 pub fn with_postgres_backend(
299 self,
300 config: crate::persist::postgres::PostgresStorageConfig,
301 ) -> Result<Self, SdkError> {
302 Ok(self.with_storage_backend(crate::postgres_storage(config)?))
303 }
304
305 #[cfg(feature = "mysql")]
309 #[deprecated(note = "use `with_storage_backend(mysql_storage(config)?)`")]
310 pub fn with_mysql_backend(
311 self,
312 config: crate::persist::mysql::MysqlStorageConfig,
313 ) -> Result<Self, SdkError> {
314 Ok(self.with_storage_backend(crate::mysql_storage(config)?))
315 }
316
317 #[must_use]
327 pub fn with_shared_context(mut self, context: Arc<SdkContext>) -> Self {
328 self.context = Some(context);
329 self
330 }
331
332 #[must_use]
336 pub fn with_chain_service(mut self, chain_service: Arc<dyn BitcoinChainService>) -> Self {
337 self.chain_service = Some(chain_service);
338 self.rest_chain_service_config = None;
339 self
340 }
341
342 #[must_use]
352 pub fn with_rest_chain_service(
353 mut self,
354 url: String,
355 api_type: ChainApiType,
356 credentials: Option<Credentials>,
357 ) -> Self {
358 self.chain_service = None;
359 self.rest_chain_service_config = Some(RestChainServiceConfig {
360 url,
361 api_type,
362 credentials,
363 });
364 self
365 }
366
367 #[must_use]
371 pub fn with_fiat_service(mut self, fiat_service: Arc<dyn FiatService>) -> Self {
372 self.fiat_service = Some(fiat_service);
373 self
374 }
375
376 #[must_use]
377 pub fn with_lnurl_client(mut self, lnurl_client: Arc<dyn crate::RestClient>) -> Self {
378 self.lnurl_client = Some(Arc::new(crate::common::rest::RestClientWrapper::new(
379 lnurl_client,
380 )));
381 self
382 }
383
384 #[must_use]
385 #[allow(unused)]
386 pub fn with_lnurl_server_client(
387 mut self,
388 lnurl_serverclient: Arc<dyn LnurlServerClient>,
389 ) -> Self {
390 self.lnurl_server_client = Some(lnurl_serverclient);
391 self
392 }
393
394 #[must_use]
399 #[allow(unused)]
400 pub fn with_payment_observer(mut self, payment_observer: Arc<dyn PaymentObserver>) -> Self {
401 self.payment_observer = Some(payment_observer);
402 self
403 }
404
405 fn build_spark_wallet_config(
408 network: spark_wallet::Network,
409 env_config: &crate::models::SparkConfig,
410 ) -> Result<SparkWalletConfig, SdkError> {
411 let coordinator_index = env_config
412 .signing_operators
413 .iter()
414 .position(|op| op.identifier == env_config.coordinator_identifier)
415 .ok_or_else(|| {
416 SdkError::InvalidInput(
417 "coordinator_identifier does not match any signing operator".to_string(),
418 )
419 })?;
420
421 let operators: Vec<_> = env_config
422 .signing_operators
423 .iter()
424 .map(|op| {
425 let ca_cert = op.ca_cert_pem.as_ref().map(|pem| pem.as_bytes().to_vec());
426 SparkWalletConfig::create_operator_config(
427 op.id as usize,
428 &op.identifier,
429 &op.address,
430 ca_cert.as_deref(),
431 &op.identity_public_key,
432 )
433 .map_err(|e| SdkError::InvalidInput(e.to_string()))
434 })
435 .collect::<Result<_, _>>()?;
436
437 let operator_pool = spark_wallet::OperatorPoolConfig::new(coordinator_index, operators)
438 .map_err(|e| SdkError::InvalidInput(e.to_string()))?;
439
440 let service_provider_config = SparkWalletConfig::create_service_provider_config(
441 &env_config.ssp_config.base_url,
442 &env_config.ssp_config.identity_public_key,
443 env_config.ssp_config.schema_endpoint.clone(),
444 )
445 .map_err(|e| SdkError::InvalidInput(e.to_string()))?;
446
447 let mut config = SparkWalletConfig::default_config(network);
448 config.operator_pool = operator_pool;
449 config.split_secret_threshold = env_config.threshold;
450 config.service_provider_config = service_provider_config;
451 config.tokens_config.expected_withdraw_bond_sats = env_config.expected_withdraw_bond_sats;
452 config
453 .tokens_config
454 .expected_withdraw_relative_block_locktime =
455 env_config.expected_withdraw_relative_block_locktime;
456 if let Some(max_tx_inputs) = env_config.max_token_transaction_inputs {
457 config.tokens_config.max_tx_inputs = max_tx_inputs as usize;
458 }
459
460 Ok(config)
461 }
462
463 fn warn_about_unproxied_overrides(&self) {
470 if self.config.proxy.is_none() {
471 return;
472 }
473 let overrides = [
474 ("chain service", self.chain_service.is_some()),
475 ("fiat service", self.fiat_service.is_some()),
476 ("LNURL HTTP client", self.lnurl_client.is_some()),
477 ("LNURL server client", self.lnurl_server_client.is_some()),
478 ];
479 for (name, supplied) in overrides {
480 if supplied {
481 warn!(
482 "A proxy is configured but a custom {name} was supplied. \
483 The SDK cannot route it: make sure it connects through the proxy itself."
484 );
485 }
486 }
487 }
488
489 #[allow(clippy::too_many_lines)]
492 pub async fn build(self) -> Result<BreezSdk, SdkError> {
493 self.config.validate()?;
494 let runtime = runtime_from_config(&self.config);
495 let background_services_enabled = runtime.starts_background_services();
496 validate_server_mode(&self.config, background_services_enabled)?;
497
498 self.warn_about_unproxied_overrides();
499 let signers = build_signers(&self.config, self.signer_source)?;
500 validate_signer_capabilities(&self.config, signers.ecies.is_some())?;
501
502 let creates_context = self.context.is_none();
503 let context = resolve_context(self.context, &self.config).await?;
504 let stores = resolve_storage(self.storage, &context, &signers.spark, &self.config).await?;
505 if let Some(provider) = &context.jwt_header_provider {
510 provider.start(creates_context.then(|| Arc::clone(&stores.storage)));
511 }
512 let chain_service = resolve_chain_service(
513 self.chain_service,
514 self.rest_chain_service_config,
515 &context,
516 self.config.network,
517 );
518
519 let user_agent = crate::default_user_agent();
520 info!("Building sdk with user agent: {}", user_agent);
521
522 let transport_proxy = self
523 .config
524 .proxy
525 .as_ref()
526 .map(platform_utils::ProxyConfig::from);
527 let lnurl_http_client: Arc<dyn platform_utils::HttpClient> =
534 platform_utils::create_http_client_with_redirect_filter(
535 Some(&user_agent),
536 transport_proxy.as_ref(),
537 breez_sdk_common::lnurl::security::lnurl_redirect_filter(),
538 )
539 .map_err(|e| SdkError::Generic(format!("Failed to build LNURL HTTP client: {e}")))?;
540
541 let input_parser = Arc::new(crate::sdk::SdkInputParser::new(
542 breez_sdk_common::dns::Resolver::with_proxy(transport_proxy.as_ref())
543 .map_err(|e| SdkError::Generic(format!("Failed to build DNS resolver: {e}")))?,
544 Arc::clone(&lnurl_http_client),
545 Some(
546 self.config
547 .get_all_external_input_parsers()
548 .into_iter()
549 .map(Into::into)
550 .collect(),
551 ),
552 ));
553
554 let fiat_service: Arc<dyn breez_sdk_common::fiat::FiatService> = match self.fiat_service {
555 Some(service) => Arc::new(FiatServiceWrapper::new(service)),
556 None => context.breez_server.clone(),
557 };
558 let lnurl_client: Arc<dyn platform_utils::HttpClient> = self
559 .lnurl_client
560 .unwrap_or_else(|| Arc::clone(&lnurl_http_client));
561
562 let spark_wallet_config =
563 finalize_spark_wallet_config(&self.config, &user_agent, background_services_enabled)?;
564 let shutdown_sender = watch::channel::<()>(()).0;
565 let override_store = self.session_store.map(|s| {
569 Arc::new(crate::session_store::SessionStoreAdapter::new(s)) as Arc<dyn SessionStore>
570 });
571 let session_store =
572 wrap_session_store(override_store.or_else(|| stores.session_store.clone()));
573
574 let spark_wallet = build_spark_wallet(BuildSparkWalletParams {
575 config: spark_wallet_config,
576 spark_signer: Arc::clone(&signers.spark),
577 session_store,
578 shutdown_receiver: background_services_enabled.then(|| shutdown_sender.subscribe()),
579 tree_store: stores.tree_store.clone(),
580 token_output_store: stores.token_output_store.clone(),
581 payment_observer: self.payment_observer,
582 context: Arc::clone(&context),
583 })
584 .await?;
585
586 let lnurl_server_client = resolve_lnurl_server_client(
587 self.lnurl_server_client,
588 &self.config,
589 &context,
590 &spark_wallet,
591 );
592
593 let real_time_sync_active =
594 background_services_enabled && self.config.real_time_sync_server_url.is_some();
595 let event_emitter = Arc::new(EventEmitter::new(real_time_sync_active));
596
597 let storage = maybe_wrap_storage_with_real_time_sync(
598 Arc::clone(&stores.storage),
599 &self.config,
600 background_services_enabled,
601 user_agent,
602 signers.rtsync,
603 shutdown_sender.subscribe(),
604 Arc::clone(&event_emitter),
605 lnurl_server_client.clone(),
606 )
607 .await?;
608
609 let buy_bitcoin_provider = Arc::new(MoonpayProvider::new(context.breez_server.clone()));
610 let token_converter =
611 build_token_converter(&self.config, &storage, &spark_wallet, &context);
612
613 let sync_coordinator = SyncCoordinator::new();
614
615 let lightning_sender = Arc::new(crate::sdk::LightningSender::new(
617 Arc::clone(&spark_wallet),
618 Arc::clone(&storage),
619 Arc::clone(&event_emitter),
620 shutdown_sender.clone(),
621 ));
622
623 let cross_chain_context = build_cross_chain_context(
624 &self.config,
625 &context.breez_server,
626 &context.http_client,
627 &spark_wallet,
628 &storage,
629 Arc::clone(&fiat_service),
630 shutdown_sender.subscribe(),
631 );
632
633 let stable_balance = build_stable_balance(
634 &self.config,
635 &token_converter,
636 &spark_wallet,
637 &storage,
638 &event_emitter,
639 )
640 .await;
641
642 event_emitter
645 .add_middleware(Box::new(TokenConversionMiddleware))
646 .await;
647
648 let sdk = BreezSdk::init_and_start(BreezSdkParams {
649 input_parser,
650 config: self.config,
651 storage,
652 chain_service,
653 fiat_service,
654 lnurl_client,
655 lnurl_server_client,
656 lnurl_auth_signer: signers.lnurl_auth,
657 shutdown_sender,
658 runtime,
659 spark_wallet,
660 event_emitter,
661 buy_bitcoin_provider,
662 token_converter,
663 stable_balance,
664 sync_coordinator,
665 cross_chain_context,
666 lightning_sender,
667 })
668 .await?;
669 debug!("Initialized and started breez sdk.");
670
671 Ok(sdk)
672 }
673}
674
675fn validate_server_mode(
677 config: &Config,
678 background_services_enabled: bool,
679) -> Result<(), SdkError> {
680 if background_services_enabled {
681 return Ok(());
682 }
683 if config.stable_balance_config.is_some() {
684 return Err(SdkError::InvalidInput(
685 "stable_balance_config is not supported when background_tasks_enabled is false"
686 .to_string(),
687 ));
688 }
689 if config.real_time_sync_server_url.is_some() {
690 return Err(SdkError::InvalidInput(
691 "real_time_sync_server_url must be None when background_tasks_enabled is false"
692 .to_string(),
693 ));
694 }
695 if config.leaf_optimization_config.auto_enabled {
696 return Err(SdkError::InvalidInput(
697 "leaf_optimization_config.auto_enabled must be false when background_tasks_enabled is false"
698 .to_string(),
699 ));
700 }
701 if config.token_optimization_config.auto_enabled {
702 return Err(SdkError::InvalidInput(
703 "token_optimization_config.auto_enabled must be false when background_tasks_enabled is false"
704 .to_string(),
705 ));
706 }
707 if config.cross_chain_config.is_some() {
708 return Err(SdkError::InvalidInput(
709 "Cross-chain config must be unset when background tasks are disabled".to_string(),
710 ));
711 }
712 Ok(())
713}
714
715fn validate_signer_capabilities(config: &Config, has_ecies: bool) -> Result<(), SdkError> {
718 if has_ecies {
719 return Ok(());
720 }
721 if config.real_time_sync_server_url.is_some() {
722 return Err(SdkError::InvalidInput(
723 "Real-time sync requires a signer that supports ECIES".to_string(),
724 ));
725 }
726 if config.cross_chain_config.is_some() {
727 return Err(SdkError::InvalidInput(
728 "Cross-chain payments require a signer that supports ECIES".to_string(),
729 ));
730 }
731 Ok(())
732}
733
734fn build_signers(config: &Config, signer_source: SignerSource) -> Result<Signers, SdkError> {
739 use crate::signer::{
740 BreezSigner, EciesSigner, ExternalBreezSignerAdapter, ExternalSigningSignerAdapter,
741 ExternalSparkSignerAdapter, HmacSigner,
742 };
743
744 #[allow(clippy::type_complexity)]
748 let (base, ecies, hmac, spark): (
749 Arc<dyn BreezSigner>,
750 Option<Arc<dyn EciesSigner>>,
751 Option<Arc<dyn HmacSigner>>,
752 Arc<dyn SparkSigner>,
753 ) = match signer_source {
754 SignerSource::Seed {
755 seed,
756 account_number,
757 } => {
758 let seed_bytes = seed.to_bytes()?;
759 let network = config.network.into();
760 let signer = Arc::new(BreezSignerImpl::new(
762 spark_wallet::identity_master_key(&seed_bytes, network, account_number)
763 .map_err(|e| SdkError::Generic(e.to_string()))?,
764 ));
765 let spark: Arc<dyn SparkSigner> = Arc::new(spark_wallet::SparkSignerAdapter::new(
766 Arc::new(spark_wallet::DefaultSigner::from_master(
767 spark_wallet::account_master_key(&seed_bytes, network, account_number)
768 .map_err(|e| SdkError::Generic(e.to_string()))?,
769 )),
770 ));
771 (signer.clone(), Some(signer.clone()), Some(signer), spark)
772 }
773 SignerSource::External { breez, spark } => {
774 let spark: Arc<dyn SparkSigner> = Arc::new(ExternalSparkSignerAdapter::new(spark));
775 match breez {
776 ExternalBreez::Full(breez) => {
777 let signer = Arc::new(ExternalBreezSignerAdapter::new(breez));
779 (signer.clone(), Some(signer.clone()), Some(signer), spark)
780 }
781 ExternalBreez::SigningOnly(breez) => {
782 let base: Arc<dyn BreezSigner> =
783 Arc::new(ExternalSigningSignerAdapter::new(breez));
784 (base, None, None, spark)
785 }
786 }
787 }
788 };
789
790 let rtsync = ecies
791 .as_ref()
792 .map(|ecies| {
793 RTSyncSigner::new(base.clone(), ecies.clone(), config.network)
794 .map(Arc::new)
795 .map_err(|e| SdkError::Generic(e.to_string()))
796 })
797 .transpose()?;
798 let lnurl_auth = hmac
799 .as_ref()
800 .map(|hmac| Arc::new(LnurlAuthSignerAdapter::new(base.clone(), hmac.clone())));
801
802 Ok(Signers {
803 ecies,
804 spark,
805 rtsync,
806 lnurl_auth,
807 })
808}
809
810async fn resolve_context(
813 supplied: Option<Arc<SdkContext>>,
814 config: &Config,
815) -> Result<Arc<SdkContext>, SdkError> {
816 let context = match supplied {
817 Some(ctx) => ctx,
818 None => {
819 new_shared_sdk_context(SdkContextConfig {
820 api_key: config.api_key.clone(),
821 proxy: config.proxy.clone(),
822 ..SdkContextConfig::new(config.network)
823 })
824 .await?
825 }
826 };
827 if context.network != config.network || context.api_key != config.api_key {
828 return Err(SdkError::Generic(
829 "SdkContext network/api_key do not match SdkConfig".to_string(),
830 ));
831 }
832 if context.proxy != config.proxy {
835 return Err(SdkError::Generic(
836 "SdkContext proxy does not match SdkConfig proxy".to_string(),
837 ));
838 }
839 Ok(context)
840}
841
842async fn resolve_storage(
845 supplied: Option<Arc<dyn StorageBackend>>,
846 context: &SdkContext,
847 spark_signer: &Arc<dyn SparkSigner>,
848 config: &Config,
849) -> Result<Arc<ResolvedStores>, SdkError> {
850 let storage_backend: Arc<dyn StorageBackend> = match (supplied, context.storage_backend.clone())
851 {
852 (Some(storage), None) => storage,
853 (None, Some(backend)) => backend,
854 (Some(_), Some(_)) => {
855 return Err(SdkError::Generic(
856 "storage is configured on both the SdkBuilder and the shared SdkContext"
857 .to_string(),
858 ));
859 }
860 (None, None) => return Err(SdkError::Generic("No storage configured".to_string())),
861 };
862 let identity_public_key = spark_signer
863 .get_identity_public_key()
864 .await
865 .map_err(|e| SdkError::Generic(e.to_string()))?;
866 storage_backend
867 .create_stores(config.network, identity_public_key.serialize().to_vec())
868 .await
869}
870
871fn resolve_chain_service(
876 supplied: Option<Arc<dyn BitcoinChainService>>,
877 rest_config: Option<RestChainServiceConfig>,
878 context: &SdkContext,
879 network: Network,
880) -> Arc<dyn BitcoinChainService> {
881 let inner: Arc<dyn BitcoinChainService> = if let Some(service) = supplied {
882 service
883 } else if let Some(cfg) = rest_config {
884 Arc::new(RestClientChainService::new(
885 cfg.url,
886 network,
887 5,
888 context.http_client.clone(),
889 cfg.credentials
890 .map(|c| BasicAuth::new(c.username, c.password)),
891 cfg.api_type,
892 ))
893 } else {
894 let inner_client: Arc<dyn platform_utils::HttpClient> = context.http_client.clone();
895 match network {
896 Network::Mainnet => Arc::new(RestClientChainService::new(
897 "https://mempool.space/api".to_string(),
898 network,
899 5,
900 inner_client,
901 None,
902 ChainApiType::MempoolSpace,
903 )),
904 Network::Regtest => Arc::new(RestClientChainService::new(
905 "https://regtest-mempool.us-west-2.sparkinfra.net/api".to_string(),
906 network,
907 5,
908 inner_client,
909 match (
910 std::env::var("CHAIN_SERVICE_USERNAME"),
911 std::env::var("CHAIN_SERVICE_PASSWORD"),
912 ) {
913 (Ok(username), Ok(password)) => Some(BasicAuth::new(username, password)),
914 _ => Some(BasicAuth::new(
915 "spark-sdk".to_string(),
916 "mCMk1JqlBNtetUNy".to_string(),
917 )),
918 },
919 ChainApiType::MempoolSpace,
920 )),
921 }
922 };
923 Arc::new(ValidatingChainService::new(inner))
924}
925
926fn finalize_spark_wallet_config(
930 config: &Config,
931 user_agent: &str,
932 background_services_enabled: bool,
933) -> Result<SparkWalletConfig, SdkError> {
934 let mut spark_wallet_config = if let Some(env_config) = &config.spark_config {
935 SdkBuilder::build_spark_wallet_config(config.network.into(), env_config)?
936 } else {
937 SparkWalletConfig::default_config(config.network.into())
938 };
939 spark_wallet_config.operator_pool = spark_wallet_config
940 .operator_pool
941 .with_user_agent(Some(user_agent.to_string()));
942 spark_wallet_config.service_provider_config.user_agent = Some(user_agent.to_string());
943 spark_wallet_config.leaf_auto_optimize_enabled =
944 background_services_enabled && config.leaf_optimization_config.auto_enabled;
945 spark_wallet_config.leaf_optimization_options.multiplicity =
946 config.leaf_optimization_config.multiplicity;
947
948 let token_opt = &config.token_optimization_config;
949 let token_options = &mut spark_wallet_config.token_outputs_optimization_options;
950 token_options.target_output_count = token_opt.target_output_count;
951 token_options.min_outputs_threshold = token_opt.min_outputs_threshold;
952 if !token_opt.auto_enabled || !background_services_enabled {
954 token_options.auto_optimize_interval = None;
955 }
956 spark_wallet_config.max_concurrent_claims = config.max_concurrent_claims;
957 Ok(spark_wallet_config)
958}
959
960fn wrap_session_store(session_store: Option<Arc<dyn SessionStore>>) -> Arc<dyn SessionStore> {
968 let inner = session_store.unwrap_or_else(|| Arc::new(InMemorySessionStore::default()));
969 Arc::new(crate::session_store::CachingSessionStore::new(inner))
970}
971
972async fn build_spark_wallet(params: BuildSparkWalletParams) -> Result<Arc<SparkWallet>, SdkError> {
974 let mut wallet_builder = spark_wallet::WalletBuilder::new(params.config, params.spark_signer)
975 .with_session_store(params.session_store);
976 if let Some(receiver) = params.shutdown_receiver {
977 wallet_builder = wallet_builder.with_cancellation_token(receiver);
978 }
979 if let Some(provider) = ¶ms.context.jwt_header_provider {
980 wallet_builder = wallet_builder
981 .with_so_extra_header_provider(
982 Arc::clone(provider) as Arc<dyn spark_wallet::HeaderProvider>
983 )
984 .with_ssp_extra_header_provider(
985 Arc::clone(provider) as Arc<dyn spark_wallet::HeaderProvider>
986 );
987 }
988 if let Some(observer) = params.payment_observer {
989 let observer: Arc<dyn spark_wallet::TransferObserver> =
990 Arc::new(SparkTransferObserver::new(observer));
991 wallet_builder = wallet_builder.with_transfer_observer(observer);
992 }
993 if let Some(tree_store) = params.tree_store {
994 wallet_builder = wallet_builder.with_tree_store(tree_store);
995 }
996 if let Some(token_output_store) = params.token_output_store {
997 wallet_builder = wallet_builder.with_token_output_store(token_output_store);
998 }
999 wallet_builder = wallet_builder.with_ssp_http_client(params.context.http_client.clone());
1000 wallet_builder =
1001 wallet_builder.with_connection_manager(params.context.connection_manager.clone());
1002 Ok(Arc::new(wallet_builder.build().await?))
1003}
1004
1005fn resolve_lnurl_server_client(
1008 explicit: Option<Arc<dyn LnurlServerClient>>,
1009 config: &Config,
1010 context: &SdkContext,
1011 spark_wallet: &Arc<SparkWallet>,
1012) -> Option<Arc<dyn LnurlServerClient>> {
1013 if let Some(client) = explicit {
1014 return Some(client);
1015 }
1016 config.lnurl_domain.as_ref().map(|domain| {
1017 Arc::new(DefaultLnurlServerClient::new(
1018 context.http_client.clone(),
1019 domain.clone(),
1020 config.api_key.clone(),
1021 Arc::clone(spark_wallet),
1022 )) as Arc<dyn LnurlServerClient>
1023 })
1024}
1025
1026#[allow(clippy::too_many_arguments)]
1029async fn maybe_wrap_storage_with_real_time_sync(
1030 storage: Arc<dyn crate::persist::Storage>,
1031 config: &Config,
1032 background_services_enabled: bool,
1033 user_agent: String,
1034 rtsync_signer: Option<Arc<RTSyncSigner>>,
1035 shutdown_receiver: watch::Receiver<()>,
1036 event_emitter: Arc<EventEmitter>,
1037 lnurl_server_client: Option<Arc<dyn LnurlServerClient>>,
1038) -> Result<Arc<dyn crate::persist::Storage>, SdkError> {
1039 match (&config.real_time_sync_server_url, rtsync_signer) {
1043 (Some(server_url), Some(signer)) if background_services_enabled => {
1044 init_and_start_real_time_sync(RealTimeSyncParams {
1045 server_url: server_url.clone(),
1046 api_key: config.api_key.clone(),
1047 user_agent,
1048 proxy: config.proxy.as_ref().map(platform_utils::ProxyConfig::from),
1049 signer,
1050 storage,
1051 shutdown_receiver,
1052 event_emitter,
1053 lnurl_server_client,
1054 })
1055 .await
1056 }
1057 _ => Ok(storage),
1058 }
1059}
1060
1061fn build_token_converter(
1063 config: &Config,
1064 storage: &Arc<dyn crate::persist::Storage>,
1065 spark_wallet: &Arc<SparkWallet>,
1066 context: &SdkContext,
1067) -> Arc<dyn TokenConverter> {
1068 let flashnet_config = FlashnetConfig::default_config(
1069 config.network.into(),
1070 DEFAULT_INTEGRATOR_PUBKEY
1071 .parse()
1072 .ok()
1073 .map(|pubkey| IntegratorConfig {
1074 pubkey,
1075 fee_bps: DEFAULT_INTEGRATOR_FEE_BPS,
1076 }),
1077 );
1078 Arc::new(FlashnetTokenConverter::new(
1079 flashnet_config,
1080 Arc::clone(storage),
1081 Arc::clone(spark_wallet),
1082 config.network,
1083 context.http_client.clone(),
1084 ))
1085}
1086
1087async fn build_stable_balance(
1090 config: &Config,
1091 token_converter: &Arc<dyn TokenConverter>,
1092 spark_wallet: &Arc<SparkWallet>,
1093 storage: &Arc<dyn crate::persist::Storage>,
1094 event_emitter: &Arc<EventEmitter>,
1095) -> Option<Arc<StableBalance>> {
1096 let stable_config = config.stable_balance_config.as_ref()?;
1097 Some(Arc::new(
1098 StableBalance::new(
1099 stable_config.clone(),
1100 Arc::clone(token_converter),
1101 Arc::clone(spark_wallet),
1102 Arc::clone(storage),
1103 Arc::clone(event_emitter),
1104 )
1105 .await,
1106 ))
1107}
1108
1109fn build_cross_chain_context(
1112 config: &Config,
1113 breez_server: &Arc<BreezServer>,
1114 http_client: &Arc<dyn platform_utils::HttpClient>,
1115 spark_wallet: &Arc<SparkWallet>,
1116 storage: &Arc<dyn crate::persist::Storage>,
1117 fiat_service: Arc<dyn breez_sdk_common::fiat::FiatService>,
1118 shutdown_receiver: watch::Receiver<()>,
1119) -> crate::cross_chain::CrossChainContext {
1120 let cached_fiat: Arc<dyn breez_sdk_common::fiat::FiatService> =
1122 Arc::new(crate::cross_chain::CachedFiatService::new(
1123 fiat_service,
1124 crate::cross_chain::DEFAULT_FIAT_CACHE_TTL,
1125 ));
1126 let mut providers = crate::cross_chain::CrossChainContext::new(Arc::clone(&cached_fiat));
1127 if config.cross_chain_config.is_none() {
1128 return providers;
1129 }
1130
1131 if matches!(config.network, Network::Mainnet) {
1136 let config_resolver = Arc::new(
1137 crate::cross_chain::BreezServerOrchestraConfigResolver::new(Arc::clone(breez_server)),
1138 );
1139 providers.insert(
1140 crate::cross_chain::CrossChainProvider::Orchestra,
1141 Arc::new(crate::cross_chain::OrchestraService::new(
1142 config_resolver,
1143 Arc::clone(spark_wallet),
1144 Arc::clone(storage),
1145 Arc::clone(&cached_fiat),
1146 Arc::clone(http_client),
1147 shutdown_receiver,
1148 )),
1149 );
1150 }
1151
1152 providers
1153}
1154
1155#[cfg(test)]
1156#[cfg(feature = "sqlite")]
1157mod tests {
1158 use super::SdkBuilder;
1159 use crate::{Network, SdkError, default_config};
1160
1161 #[test]
1162 fn default_config_spark_config_builds_valid_wallet_config() {
1163 for network in [Network::Mainnet, Network::Regtest] {
1164 let config = default_config(network);
1165 let spark_config = config
1166 .spark_config
1167 .as_ref()
1168 .expect("default_config must populate spark_config");
1169 SdkBuilder::build_spark_wallet_config(network.into(), spark_config).unwrap_or_else(
1170 |e| {
1171 panic!(
1172 "default_config({network:?}).spark_config failed to build SparkWalletConfig: {e}"
1173 )
1174 },
1175 );
1176 }
1177 }
1178
1179 #[test]
1180 fn validate_signer_capabilities_gates_encryption_features() {
1181 let mut config = default_config(Network::Regtest);
1183 config.real_time_sync_server_url = Some("https://example.com".to_string());
1184 assert!(super::validate_signer_capabilities(&config, true).is_ok());
1185
1186 let mut config = default_config(Network::Regtest);
1188 config.real_time_sync_server_url = Some("https://example.com".to_string());
1189 config.cross_chain_config = None;
1190 match super::validate_signer_capabilities(&config, false) {
1191 Err(SdkError::InvalidInput(m)) => {
1192 assert!(m.contains("Real-time sync"), "got: {m}");
1193 }
1194 other => panic!("expected InvalidInput, got {other:?}"),
1195 }
1196
1197 let mut config = default_config(Network::Regtest);
1200 config.real_time_sync_server_url = None;
1201 config.cross_chain_config = None;
1202 assert!(super::validate_signer_capabilities(&config, false).is_ok());
1203 }
1204
1205 #[test]
1209 fn build_signers_signing_only_omits_ecies_dependent_signers() {
1210 use crate::error::SignerError;
1211 use crate::signer::external_types::{
1212 EcdsaSignatureBytes, MessageBytes, PublicKeyBytes, RecoverableEcdsaSignatureBytes,
1213 SchnorrSignatureBytes,
1214 };
1215 use crate::signer::{
1216 DefaultExternalSparkSigner, ExternalSigningSigner, ExternalSparkSigner,
1217 };
1218 use std::sync::Arc;
1219
1220 struct StubSigningSigner;
1222 #[macros::async_trait]
1223 impl ExternalSigningSigner for StubSigningSigner {
1224 async fn derive_public_key(&self, _p: String) -> Result<PublicKeyBytes, SignerError> {
1225 unreachable!("build_signers must not call the signer")
1226 }
1227 async fn sign_ecdsa(
1228 &self,
1229 _m: MessageBytes,
1230 _p: String,
1231 ) -> Result<EcdsaSignatureBytes, SignerError> {
1232 unreachable!("build_signers must not call the signer")
1233 }
1234 async fn sign_ecdsa_recoverable(
1235 &self,
1236 _m: MessageBytes,
1237 _p: String,
1238 ) -> Result<RecoverableEcdsaSignatureBytes, SignerError> {
1239 unreachable!("build_signers must not call the signer")
1240 }
1241 async fn sign_hash_schnorr(
1242 &self,
1243 _h: Vec<u8>,
1244 _p: String,
1245 ) -> Result<SchnorrSignatureBytes, SignerError> {
1246 unreachable!("build_signers must not call the signer")
1247 }
1248 }
1249
1250 let config = default_config(Network::Regtest);
1251
1252 let full = super::build_signers(
1254 &config,
1255 super::SignerSource::Seed {
1256 seed: test_seed(),
1257 account_number: None,
1258 },
1259 )
1260 .unwrap();
1261 assert!(full.ecies.is_some());
1262 assert!(full.rtsync.is_some());
1263 assert!(full.lnurl_auth.is_some());
1264
1265 let breez: Arc<dyn ExternalSigningSigner> = Arc::new(StubSigningSigner);
1267 let spark: Arc<dyn ExternalSparkSigner> = Arc::new(
1268 DefaultExternalSparkSigner::new(
1269 TEST_MNEMONIC.to_string(),
1270 None,
1271 Network::Regtest,
1272 None,
1273 )
1274 .unwrap(),
1275 );
1276 let signing_only = super::build_signers(
1277 &config,
1278 super::SignerSource::External {
1279 breez: super::ExternalBreez::SigningOnly(breez),
1280 spark,
1281 },
1282 )
1283 .unwrap();
1284 assert!(signing_only.ecies.is_none());
1285 assert!(signing_only.rtsync.is_none());
1286 assert!(signing_only.lnurl_auth.is_none());
1287 }
1288
1289 #[tokio::test]
1292 async fn signing_only_signer_build_disables_lnurl_auth() {
1293 use crate::error::SignerError;
1294 use crate::signer::external_types::{
1295 EcdsaSignatureBytes, MessageBytes, PublicKeyBytes, RecoverableEcdsaSignatureBytes,
1296 SchnorrSignatureBytes,
1297 };
1298 use crate::signer::{
1299 DefaultExternalSigner, DefaultExternalSparkSigner, ExternalBreezSigner,
1300 ExternalSigningSigner, ExternalSparkSigner,
1301 };
1302 use std::sync::Arc;
1303
1304 struct SigningOnly(DefaultExternalSigner);
1307 #[macros::async_trait]
1308 impl ExternalSigningSigner for SigningOnly {
1309 async fn derive_public_key(&self, path: String) -> Result<PublicKeyBytes, SignerError> {
1310 self.0.derive_public_key(path).await
1311 }
1312 async fn sign_ecdsa(
1313 &self,
1314 message: MessageBytes,
1315 path: String,
1316 ) -> Result<EcdsaSignatureBytes, SignerError> {
1317 self.0.sign_ecdsa(message, path).await
1318 }
1319 async fn sign_ecdsa_recoverable(
1320 &self,
1321 message: MessageBytes,
1322 path: String,
1323 ) -> Result<RecoverableEcdsaSignatureBytes, SignerError> {
1324 self.0.sign_ecdsa_recoverable(message, path).await
1325 }
1326 async fn sign_hash_schnorr(
1327 &self,
1328 hash: Vec<u8>,
1329 path: String,
1330 ) -> Result<SchnorrSignatureBytes, SignerError> {
1331 self.0.sign_hash_schnorr(hash, path).await
1332 }
1333 }
1334
1335 let mut config = default_config(Network::Regtest);
1336 config.real_time_sync_server_url = None;
1338 config.private_enabled_default = false;
1339
1340 let breez: Arc<dyn ExternalSigningSigner> = Arc::new(SigningOnly(
1341 DefaultExternalSigner::new(TEST_MNEMONIC.to_string(), None, Network::Regtest, None)
1342 .unwrap(),
1343 ));
1344 let spark: Arc<dyn ExternalSparkSigner> = Arc::new(
1345 DefaultExternalSparkSigner::new(
1346 TEST_MNEMONIC.to_string(),
1347 None,
1348 Network::Regtest,
1349 None,
1350 )
1351 .unwrap(),
1352 );
1353
1354 let sdk = SdkBuilder::new_with_signing_only_signer(config, breez, spark)
1355 .with_default_storage(unique_storage_dir("signing-only-lnurl"))
1356 .build()
1357 .await
1358 .expect("signing-only build should succeed");
1359
1360 assert!(
1361 sdk.lnurl_auth_signer.is_none(),
1362 "a signing-only signer must not build an lnurl-auth signer"
1363 );
1364
1365 let err = sdk
1366 .lnurl_auth(crate::LnurlAuthRequestDetails {
1367 k1: "00".repeat(32),
1368 action: None,
1369 domain: "example.com".to_string(),
1370 url: "https://example.com/lnurl-auth".to_string(),
1371 })
1372 .await
1373 .expect_err("lnurl_auth must fail for a signing-only signer");
1374 assert!(
1375 matches!(err, SdkError::Generic(_)),
1376 "expected Generic, got {err:?}"
1377 );
1378
1379 sdk.disconnect().await.expect("disconnect should succeed");
1380 }
1381
1382 #[tokio::test]
1386 async fn signing_only_signer_build_rejects_ecies_features() {
1387 use crate::error::SignerError;
1388 use crate::signer::external_types::{
1389 EcdsaSignatureBytes, MessageBytes, PublicKeyBytes, RecoverableEcdsaSignatureBytes,
1390 SchnorrSignatureBytes,
1391 };
1392 use crate::signer::{
1393 DefaultExternalSparkSigner, ExternalSigningSigner, ExternalSparkSigner,
1394 };
1395 use std::sync::Arc;
1396
1397 struct StubSigningSigner;
1399 #[macros::async_trait]
1400 impl ExternalSigningSigner for StubSigningSigner {
1401 async fn derive_public_key(&self, _p: String) -> Result<PublicKeyBytes, SignerError> {
1402 unreachable!("build must fail before calling the signer")
1403 }
1404 async fn sign_ecdsa(
1405 &self,
1406 _m: MessageBytes,
1407 _p: String,
1408 ) -> Result<EcdsaSignatureBytes, SignerError> {
1409 unreachable!("build must fail before calling the signer")
1410 }
1411 async fn sign_ecdsa_recoverable(
1412 &self,
1413 _m: MessageBytes,
1414 _p: String,
1415 ) -> Result<RecoverableEcdsaSignatureBytes, SignerError> {
1416 unreachable!("build must fail before calling the signer")
1417 }
1418 async fn sign_hash_schnorr(
1419 &self,
1420 _h: Vec<u8>,
1421 _p: String,
1422 ) -> Result<SchnorrSignatureBytes, SignerError> {
1423 unreachable!("build must fail before calling the signer")
1424 }
1425 }
1426
1427 let mut config = default_config(Network::Regtest);
1428 config.real_time_sync_server_url = Some("https://example.com".to_string());
1429
1430 let breez: Arc<dyn ExternalSigningSigner> = Arc::new(StubSigningSigner);
1431 let spark: Arc<dyn ExternalSparkSigner> = Arc::new(
1432 DefaultExternalSparkSigner::new(
1433 TEST_MNEMONIC.to_string(),
1434 None,
1435 Network::Regtest,
1436 None,
1437 )
1438 .unwrap(),
1439 );
1440
1441 let result = SdkBuilder::new_with_signing_only_signer(config, breez, spark)
1442 .with_default_storage(unique_storage_dir("signing-only-reject-rtsync"))
1443 .build()
1444 .await;
1445 match result {
1446 Err(SdkError::InvalidInput(_)) => {}
1447 Ok(_) => panic!("build must reject real-time sync for a signing-only signer"),
1448 Err(other) => panic!("expected InvalidInput, got {other:?}"),
1449 }
1450 }
1451
1452 #[tokio::test]
1453 async fn wrap_session_store_stores_token_verbatim() {
1454 use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
1455 use spark_wallet::{Session, SessionStore, SessionStoreError};
1456 use std::collections::HashMap;
1457 use std::sync::{Arc, Mutex};
1458
1459 #[derive(Default)]
1461 struct InspectableInner {
1462 sessions: Mutex<HashMap<PublicKey, Session>>,
1463 }
1464 #[macros::async_trait]
1465 impl SessionStore for InspectableInner {
1466 async fn get_session(&self, key: &PublicKey) -> Result<Session, SessionStoreError> {
1467 self.sessions
1468 .lock()
1469 .unwrap()
1470 .get(key)
1471 .cloned()
1472 .ok_or(SessionStoreError::NotFound)
1473 }
1474 async fn set_session(
1475 &self,
1476 key: &PublicKey,
1477 session: Session,
1478 ) -> Result<(), SessionStoreError> {
1479 self.sessions.lock().unwrap().insert(*key, session);
1480 Ok(())
1481 }
1482 }
1483 fn key() -> PublicKey {
1484 let secp = Secp256k1::new();
1485 PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[3u8; 32]).unwrap())
1486 }
1487
1488 let inner = Arc::new(InspectableInner::default());
1489 let store = super::wrap_session_store(Some(inner.clone()));
1490 let token = "bearer-token".to_string();
1491 store
1492 .set_session(
1493 &key(),
1494 Session {
1495 token: token.clone(),
1496 expiration: 1,
1497 },
1498 )
1499 .await
1500 .unwrap();
1501
1502 let raw = inner.sessions.lock().unwrap().get(&key()).cloned().unwrap();
1505 assert_eq!(raw.token, token, "the SDK must store the token as-is");
1506 assert_eq!(store.get_session(&key()).await.unwrap().token, token);
1507 }
1508
1509 #[tokio::test]
1510 async fn server_mode_rejects_stable_balance_config() {
1511 use crate::{SdkError, StableBalanceConfig, StableBalanceToken, default_server_config};
1512
1513 let mut config = default_server_config(Network::Regtest);
1514 config.stable_balance_config = Some(StableBalanceConfig {
1515 tokens: vec![StableBalanceToken {
1516 label: "USDB".to_string(),
1517 token_identifier: "btkn1test".to_string(),
1518 }],
1519 default_active_label: None,
1520 threshold_sats: None,
1521 max_slippage_bps: None,
1522 });
1523
1524 let seed = test_seed();
1525 let result = SdkBuilder::new(config, seed).build().await;
1526 match result {
1527 Err(SdkError::InvalidInput(message)) => {
1528 assert!(message.contains("stable_balance_config"));
1529 }
1530 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1531 Ok(_) => panic!("expected server mode with Stable Balance config to fail"),
1532 }
1533 }
1534
1535 #[tokio::test]
1536 async fn server_mode_rejects_real_time_sync_server_url() {
1537 use crate::{SdkError, default_server_config};
1538
1539 let mut config = default_server_config(Network::Regtest);
1540 config.real_time_sync_server_url = Some("https://example.com".to_string());
1541
1542 let seed = test_seed();
1543 let result = SdkBuilder::new(config, seed).build().await;
1544 match result {
1545 Err(SdkError::InvalidInput(message)) => {
1546 assert!(message.contains("real_time_sync_server_url"));
1547 }
1548 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1549 Ok(_) => panic!("expected server mode with real_time_sync_server_url to fail"),
1550 }
1551 }
1552
1553 #[tokio::test]
1554 async fn server_mode_rejects_leaf_optimization_auto_enabled() {
1555 use crate::{SdkError, default_server_config};
1556
1557 let mut config = default_server_config(Network::Regtest);
1558 config.leaf_optimization_config.auto_enabled = true;
1559
1560 let seed = test_seed();
1561 let result = SdkBuilder::new(config, seed).build().await;
1562 match result {
1563 Err(SdkError::InvalidInput(message)) => {
1564 assert!(message.contains("leaf_optimization_config.auto_enabled"));
1565 }
1566 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1567 Ok(_) => panic!("expected server mode with optimization auto_enabled to fail"),
1568 }
1569 }
1570
1571 #[tokio::test]
1572 async fn server_mode_rejects_token_optimization_auto_enabled() {
1573 use crate::{SdkError, default_server_config};
1574
1575 let mut config = default_server_config(Network::Regtest);
1576 config.token_optimization_config.auto_enabled = true;
1577
1578 let seed = test_seed();
1579 let result = SdkBuilder::new(config, seed).build().await;
1580 match result {
1581 Err(SdkError::InvalidInput(message)) => {
1582 assert!(message.contains("token_optimization_config.auto_enabled"));
1583 }
1584 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1585 Ok(_) => panic!("expected server mode with optimization auto_enabled to fail"),
1586 }
1587 }
1588
1589 fn test_proxy() -> crate::ProxyConfig {
1592 crate::ProxyConfig {
1593 host: "127.0.0.1".to_string(),
1594 port: 9050,
1595 username: None,
1596 password: None,
1597 }
1598 }
1599
1600 #[test]
1603 fn validate_accepts_proxy_with_cross_chain_config() {
1604 use crate::{CrossChainConfig, default_config};
1605 let mut config = default_config(Network::Mainnet);
1606 config.proxy = Some(test_proxy());
1607 config.cross_chain_config = Some(CrossChainConfig::default());
1608
1609 assert!(config.validate().is_ok());
1610 }
1611
1612 #[test]
1613 fn validate_rejects_half_specified_proxy_credentials() {
1614 use crate::{SdkError, default_config};
1615 let mut config = default_config(Network::Mainnet);
1616 let mut proxy = test_proxy();
1617 proxy.username = Some("user".to_string());
1618 config.proxy = Some(proxy);
1619
1620 match config.validate() {
1621 Err(SdkError::InvalidInput(m)) => assert!(
1622 m.contains("username and password"),
1623 "expected credential-pair rejection, got: {m}"
1624 ),
1625 other => panic!("expected a username without a password to fail, got {other:?}"),
1626 }
1627 }
1628
1629 #[test]
1630 fn validate_rejects_empty_proxy_host_and_zero_port() {
1631 use crate::{SdkError, default_config};
1632 let mut config = default_config(Network::Mainnet);
1633
1634 let mut proxy = test_proxy();
1635 proxy.host = String::new();
1636 config.proxy = Some(proxy);
1637 assert!(matches!(config.validate(), Err(SdkError::InvalidInput(_))));
1638
1639 let mut proxy = test_proxy();
1640 proxy.port = 0;
1641 config.proxy = Some(proxy);
1642 assert!(matches!(config.validate(), Err(SdkError::InvalidInput(_))));
1643 }
1644
1645 #[test]
1649 fn validate_rejects_proxy_host_that_cannot_form_a_url() {
1650 use crate::{SdkError, default_config};
1651 for host in ["local host", "user@host", "socks5://127.0.0.1", "[::g]"] {
1652 let mut config = default_config(Network::Mainnet);
1653 let mut proxy = test_proxy();
1654 proxy.host = host.to_string();
1655 config.proxy = Some(proxy);
1656
1657 match config.validate() {
1658 Err(SdkError::InvalidInput(m)) => {
1659 assert!(m.contains(host), "expected the host in the error, got: {m}");
1660 }
1661 other => panic!("expected host {host:?} to be rejected, got {other:?}"),
1662 }
1663 }
1664 }
1665
1666 #[test]
1669 fn validate_accepts_bare_ipv6_proxy_host() {
1670 use crate::default_config;
1671 let mut config = default_config(Network::Mainnet);
1672 let mut proxy = test_proxy();
1673 proxy.host = "::1".to_string();
1674 config.proxy = Some(proxy);
1675
1676 assert!(config.validate().is_ok());
1677 }
1678
1679 #[tokio::test]
1682 async fn shared_context_rejects_proxy_with_balanced_connections() {
1683 use crate::{SdkContextConfig, SdkError, new_shared_sdk_context};
1684 let result = new_shared_sdk_context(SdkContextConfig {
1685 proxy: Some(test_proxy()),
1686 connections_per_operator: Some(4),
1687 ..SdkContextConfig::new(Network::Regtest)
1688 })
1689 .await;
1690
1691 match result.err() {
1692 Some(SdkError::InvalidInput(m)) => assert!(
1693 m.contains("connections_per_operator"),
1694 "expected balanced-connection rejection, got: {m}"
1695 ),
1696 other => panic!("expected proxy + balanced connections to fail, got {other:?}"),
1697 }
1698 }
1699
1700 #[tokio::test]
1703 async fn resolve_context_errors_on_proxy_mismatch() {
1704 use crate::{SdkContextConfig, default_config, new_shared_sdk_context};
1705 let mut config = default_config(Network::Regtest);
1706 config.proxy = Some(test_proxy());
1707
1708 let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
1709 .await
1710 .expect("regtest context");
1711
1712 let err = super::resolve_context(Some(ctx), &config)
1713 .await
1714 .err()
1715 .expect("expected mismatch error");
1716 assert!(
1717 err.to_string().contains("proxy does not match"),
1718 "unexpected error: {err}"
1719 );
1720 }
1721
1722 #[tokio::test]
1727 async fn build_rejects_cross_chain_config_on_regtest() {
1728 use crate::{CrossChainConfig, SdkError, default_config};
1729 let mut config = default_config(Network::Regtest);
1730 config.cross_chain_config = Some(CrossChainConfig::default());
1731
1732 let seed = test_seed();
1733 let result = SdkBuilder::new(config, seed).build().await;
1734 match result {
1735 Err(SdkError::InvalidInput(m)) => {
1736 assert!(
1737 m.contains("only available on Mainnet"),
1738 "expected mainnet-only rejection, got: {m}"
1739 );
1740 }
1741 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1742 Ok(_) => panic!("expected regtest with cross_chain_config to fail"),
1743 }
1744 }
1745
1746 #[tokio::test]
1750 async fn build_errors_on_network_mismatch() {
1751 use crate::{SdkContextConfig, new_shared_sdk_context};
1752 let mut config = default_config(Network::Mainnet);
1753 config.api_key = Some("partner-key".to_string());
1754 let ctx = new_shared_sdk_context(SdkContextConfig {
1755 api_key: Some("partner-key".to_string()),
1756 ..SdkContextConfig::new(Network::Regtest)
1757 })
1758 .await
1759 .expect("regtest context");
1760 let err = SdkBuilder::new(config, test_seed())
1761 .with_shared_context(ctx)
1762 .with_default_storage("/tmp/breez-sdk-test-network-mismatch".to_string())
1763 .build()
1764 .await
1765 .err()
1766 .expect("expected network-mismatch error");
1767 assert!(
1768 err.to_string().contains("network/api_key do not match"),
1769 "unexpected error: {err}"
1770 );
1771 }
1772
1773 #[tokio::test]
1777 #[allow(clippy::manual_assert)]
1778 async fn build_errors_on_api_key_mismatch() {
1779 use crate::{SdkContextConfig, new_shared_sdk_context};
1780 let mut config = default_config(Network::Mainnet);
1781 config.api_key = Some("intended-key".to_string());
1782 let ctx = new_shared_sdk_context(SdkContextConfig {
1783 api_key: Some("wrong-key".to_string()),
1784 ..SdkContextConfig::new(Network::Mainnet)
1785 })
1786 .await
1787 .expect("mainnet context");
1788 let err = SdkBuilder::new(config, test_seed())
1789 .with_shared_context(ctx)
1790 .with_default_storage("/tmp/breez-sdk-test-key-mismatch".to_string())
1791 .build()
1792 .await
1793 .err()
1794 .expect("expected api_key-mismatch error");
1795 assert!(
1796 err.to_string().contains("network/api_key do not match"),
1797 "unexpected error: {err}"
1798 );
1799 }
1800
1801 const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
1802
1803 fn test_seed() -> crate::Seed {
1804 crate::Seed::Mnemonic {
1805 mnemonic: TEST_MNEMONIC.to_string(),
1806 passphrase: None,
1807 }
1808 }
1809
1810 fn unique_storage_dir(name: &str) -> String {
1811 std::env::temp_dir()
1812 .join(format!("breez-sdk-test-{}-{}", name, uuid::Uuid::new_v4()))
1813 .to_string_lossy()
1814 .into_owned()
1815 }
1816
1817 async fn assert_sdk_graph_freed(
1821 event_emitter: std::sync::Weak<crate::EventEmitter>,
1822 spark_wallet: std::sync::Weak<spark_wallet::SparkWallet>,
1823 ) {
1824 for _ in 0..100 {
1825 if event_emitter.upgrade().is_none() && spark_wallet.upgrade().is_none() {
1826 return;
1827 }
1828 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1829 }
1830 panic!(
1831 "SDK object graph leaked after drop (EventEmitter alive: {}, SparkWallet alive: {})",
1832 event_emitter.upgrade().is_some(),
1833 spark_wallet.upgrade().is_some(),
1834 );
1835 }
1836
1837 #[tokio::test]
1841 async fn server_mode_sdk_graph_is_freed_on_drop() {
1842 use crate::default_server_config;
1843
1844 let config = default_server_config(Network::Regtest);
1845 let sdk = SdkBuilder::new(config, test_seed())
1846 .with_default_storage(unique_storage_dir("leak-server"))
1847 .build()
1848 .await
1849 .expect("server-mode build should succeed");
1850
1851 let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
1852 let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
1853
1854 sdk.disconnect().await.expect("disconnect should succeed");
1855 drop(sdk);
1856
1857 assert_sdk_graph_freed(event_emitter, spark_wallet).await;
1858 }
1859
1860 #[tokio::test]
1864 #[ignore = "requires network access to the regtest operators"]
1865 async fn server_mode_sdk_graph_is_freed_on_drop_after_sync() {
1866 use crate::{SyncWalletRequest, default_server_config};
1867
1868 let config = default_server_config(Network::Regtest);
1869 let sdk = SdkBuilder::new(config, test_seed())
1870 .with_default_storage(unique_storage_dir("leak-server-sync"))
1871 .build()
1872 .await
1873 .expect("server-mode build should succeed");
1874
1875 let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
1876 let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
1877
1878 sdk.sync_wallet(SyncWalletRequest {})
1879 .await
1880 .expect("sync_wallet should succeed");
1881
1882 sdk.disconnect().await.expect("disconnect should succeed");
1883 drop(sdk);
1884
1885 assert_sdk_graph_freed(event_emitter, spark_wallet).await;
1886 }
1887
1888 #[tokio::test]
1891 async fn client_mode_sdk_graph_is_freed_after_disconnect_and_drop() {
1892 let mut config = default_config(Network::Regtest);
1893 config.real_time_sync_server_url = None;
1896 config.private_enabled_default = false;
1897
1898 let sdk = SdkBuilder::new(config, test_seed())
1899 .with_default_storage(unique_storage_dir("leak-client"))
1900 .build()
1901 .await
1902 .expect("client-mode build should succeed");
1903
1904 let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
1905 let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
1906
1907 tokio::time::timeout(std::time::Duration::from_secs(30), sdk.disconnect())
1908 .await
1909 .expect("disconnect should not hang")
1910 .expect("disconnect should succeed");
1911 drop(sdk);
1912
1913 assert_sdk_graph_freed(event_emitter, spark_wallet).await;
1914 }
1915
1916 #[tokio::test]
1921 async fn client_mode_sdk_graph_is_freed_with_real_time_sync_enabled() {
1922 use crate::{StableBalanceConfig, StableBalanceToken};
1923
1924 let mut config = default_config(Network::Regtest);
1925 config.real_time_sync_server_url = Some("http://127.0.0.1:9".to_string());
1929 config.private_enabled_default = false;
1930 config.stable_balance_config = Some(StableBalanceConfig {
1933 tokens: vec![StableBalanceToken {
1934 label: "USDB".to_string(),
1935 token_identifier: "btkn1test".to_string(),
1936 }],
1937 default_active_label: None,
1938 threshold_sats: None,
1939 max_slippage_bps: None,
1940 });
1941
1942 let sdk = SdkBuilder::new(config, test_seed())
1943 .with_default_storage(unique_storage_dir("leak-client-rtsync"))
1944 .build()
1945 .await
1946 .expect("client-mode build with real-time sync should succeed");
1947
1948 let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
1949 let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
1950
1951 tokio::time::timeout(std::time::Duration::from_secs(30), sdk.disconnect())
1952 .await
1953 .expect("disconnect should not hang")
1954 .expect("disconnect should succeed");
1955 drop(sdk);
1956
1957 assert_sdk_graph_freed(event_emitter, spark_wallet).await;
1958 }
1959
1960 #[tokio::test]
1963 async fn disconnect_unregisters_event_listeners() {
1964 use crate::{SdkEvent, default_server_config, events::EventListener};
1965
1966 struct NoopListener;
1967
1968 #[macros::async_trait]
1969 impl EventListener for NoopListener {
1970 async fn on_event(&self, _event: SdkEvent) {}
1971 }
1972
1973 let storage_dir = std::env::temp_dir()
1974 .join(format!(
1975 "breez-sdk-test-listener-clear-{}",
1976 uuid::Uuid::new_v4()
1977 ))
1978 .to_string_lossy()
1979 .into_owned();
1980 let sdk = SdkBuilder::new(default_server_config(Network::Regtest), test_seed())
1981 .with_default_storage(storage_dir)
1982 .build()
1983 .await
1984 .expect("server-mode build should succeed");
1985
1986 let id = sdk.add_event_listener(Box::new(NoopListener)).await;
1987
1988 sdk.disconnect().await.expect("disconnect should succeed");
1989
1990 assert!(
1991 !sdk.remove_event_listener(&id).await,
1992 "listener should already be unregistered by disconnect"
1993 );
1994 }
1995
1996 fn test_spark_signer() -> std::sync::Arc<dyn spark_wallet::SparkSigner> {
1997 use std::sync::Arc;
1998
1999 let seed = test_seed();
2000 let seed_bytes = seed.to_bytes().unwrap();
2001 let master =
2002 spark_wallet::account_master_key(&seed_bytes, Network::Regtest.into(), None).unwrap();
2003 Arc::new(spark_wallet::SparkSignerAdapter::new(Arc::new(
2004 spark_wallet::DefaultSigner::from_master(master),
2005 )))
2006 }
2007
2008 #[test]
2011 fn validate_server_mode_ok_when_background_enabled() {
2012 use crate::{StableBalanceConfig, StableBalanceToken, default_server_config};
2013 let mut config = default_server_config(Network::Regtest);
2014 config.stable_balance_config = Some(StableBalanceConfig {
2015 tokens: vec![StableBalanceToken {
2016 label: "USDB".to_string(),
2017 token_identifier: "btkn1test".to_string(),
2018 }],
2019 default_active_label: None,
2020 threshold_sats: None,
2021 max_slippage_bps: None,
2022 });
2023 config.real_time_sync_server_url = Some("https://example.com".to_string());
2024 config.leaf_optimization_config.auto_enabled = true;
2025 config.token_optimization_config.auto_enabled = true;
2026 assert!(super::validate_server_mode(&config, true).is_ok());
2028 }
2029
2030 #[test]
2031 fn validate_server_mode_ok_in_server_mode_without_background_features() {
2032 use crate::default_server_config;
2033 let config = default_server_config(Network::Regtest);
2034 assert!(super::validate_server_mode(&config, false).is_ok());
2035 }
2036
2037 #[test]
2038 fn validate_server_mode_rejects_stable_balance_directly() {
2039 use crate::{StableBalanceConfig, StableBalanceToken, default_server_config};
2040 let mut config = default_server_config(Network::Regtest);
2041 config.stable_balance_config = Some(StableBalanceConfig {
2042 tokens: vec![StableBalanceToken {
2043 label: "USDB".to_string(),
2044 token_identifier: "btkn1test".to_string(),
2045 }],
2046 default_active_label: None,
2047 threshold_sats: None,
2048 max_slippage_bps: None,
2049 });
2050 match super::validate_server_mode(&config, false) {
2051 Err(SdkError::InvalidInput(m)) => assert!(m.contains("stable_balance_config")),
2052 other => panic!("expected InvalidInput, got {other:?}"),
2053 }
2054 }
2055
2056 #[test]
2057 fn validate_server_mode_rejects_real_time_sync_directly() {
2058 use crate::default_server_config;
2059 let mut config = default_server_config(Network::Regtest);
2060 config.real_time_sync_server_url = Some("https://example.com".to_string());
2061 match super::validate_server_mode(&config, false) {
2062 Err(SdkError::InvalidInput(m)) => assert!(m.contains("real_time_sync_server_url")),
2063 other => panic!("expected InvalidInput, got {other:?}"),
2064 }
2065 }
2066
2067 #[test]
2068 fn validate_server_mode_rejects_leaf_auto_optimize_directly() {
2069 use crate::default_server_config;
2070 let mut config = default_server_config(Network::Regtest);
2071 config.leaf_optimization_config.auto_enabled = true;
2072 match super::validate_server_mode(&config, false) {
2073 Err(SdkError::InvalidInput(m)) => {
2074 assert!(m.contains("leaf_optimization_config.auto_enabled"));
2075 }
2076 other => panic!("expected InvalidInput, got {other:?}"),
2077 }
2078 }
2079
2080 #[test]
2081 fn validate_server_mode_rejects_token_auto_optimize_directly() {
2082 use crate::default_server_config;
2083 let mut config = default_server_config(Network::Regtest);
2084 config.token_optimization_config.auto_enabled = true;
2085 match super::validate_server_mode(&config, false) {
2086 Err(SdkError::InvalidInput(m)) => {
2087 assert!(m.contains("token_optimization_config.auto_enabled"));
2088 }
2089 other => panic!("expected InvalidInput, got {other:?}"),
2090 }
2091 }
2092
2093 #[test]
2094 fn validate_server_mode_rejects_cross_chain_directly() {
2095 use crate::{CrossChainConfig, default_server_config};
2096 let mut config = default_server_config(Network::Regtest);
2097 config.cross_chain_config = Some(CrossChainConfig::default());
2098 match super::validate_server_mode(&config, false) {
2099 Err(SdkError::InvalidInput(m)) => assert!(m.contains("Cross-chain config")),
2100 other => panic!("expected InvalidInput, got {other:?}"),
2101 }
2102 }
2103
2104 #[test]
2107 fn finalize_spark_wallet_config_disabled_background_forces_leaf_auto_off() {
2108 let mut config = default_config(Network::Regtest);
2109 config.leaf_optimization_config.auto_enabled = true;
2110 let result = super::finalize_spark_wallet_config(&config, "test-agent", false).unwrap();
2111 assert!(!result.leaf_auto_optimize_enabled);
2112 }
2113
2114 #[test]
2115 fn finalize_spark_wallet_config_disabled_background_clears_token_auto_interval() {
2116 let mut config = default_config(Network::Regtest);
2117 config.token_optimization_config.auto_enabled = true;
2118 let result = super::finalize_spark_wallet_config(&config, "test-agent", false).unwrap();
2119 assert!(
2120 result
2121 .token_outputs_optimization_options
2122 .auto_optimize_interval
2123 .is_none()
2124 );
2125 }
2126
2127 #[test]
2128 fn finalize_spark_wallet_config_enabled_background_respects_leaf_auto_optimize() {
2129 let mut config = default_config(Network::Regtest);
2130 config.leaf_optimization_config.auto_enabled = true;
2131 let result = super::finalize_spark_wallet_config(&config, "test-agent", true).unwrap();
2132 assert!(result.leaf_auto_optimize_enabled);
2133 }
2134
2135 #[test]
2136 fn finalize_spark_wallet_config_applies_user_agent() {
2137 let config = default_config(Network::Regtest);
2138 let result = super::finalize_spark_wallet_config(&config, "my-app/1.0", true).unwrap();
2139 assert_eq!(
2140 result.service_provider_config.user_agent.as_deref(),
2141 Some("my-app/1.0")
2142 );
2143 }
2144
2145 #[tokio::test]
2148 async fn resolve_context_errors_on_network_mismatch() {
2149 use crate::{SdkContextConfig, new_shared_sdk_context};
2150 let config = default_config(Network::Mainnet);
2151 let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
2152 .await
2153 .expect("regtest context");
2154 let err = super::resolve_context(Some(ctx), &config)
2155 .await
2156 .err()
2157 .expect("expected mismatch error");
2158 assert!(
2159 err.to_string().contains("network/api_key do not match"),
2160 "unexpected error: {err}"
2161 );
2162 }
2163
2164 #[tokio::test]
2165 async fn resolve_context_errors_on_api_key_mismatch() {
2166 use crate::{SdkContextConfig, new_shared_sdk_context};
2167 let mut config = default_config(Network::Mainnet);
2168 config.api_key = Some("intended-key".to_string());
2169 let ctx = new_shared_sdk_context(SdkContextConfig {
2170 api_key: Some("wrong-key".to_string()),
2171 ..SdkContextConfig::new(Network::Mainnet)
2172 })
2173 .await
2174 .expect("mainnet context");
2175 let err = super::resolve_context(Some(ctx), &config)
2176 .await
2177 .err()
2178 .expect("expected mismatch error");
2179 assert!(
2180 err.to_string().contains("network/api_key do not match"),
2181 "unexpected error: {err}"
2182 );
2183 }
2184
2185 #[tokio::test]
2188 async fn resolve_storage_errors_when_neither_supplied() {
2189 use crate::{SdkContextConfig, new_shared_sdk_context};
2190 let config = default_config(Network::Regtest);
2191 let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
2192 .await
2193 .expect("regtest context");
2194 let signer = test_spark_signer();
2195 let err = super::resolve_storage(None, &ctx, &signer, &config)
2196 .await
2197 .err()
2198 .expect("expected no-storage error");
2199 assert!(
2200 err.to_string().contains("No storage configured"),
2201 "unexpected error: {err}"
2202 );
2203 }
2204
2205 #[tokio::test]
2206 async fn resolve_storage_errors_when_supplied_on_both_builder_and_context() {
2207 use crate::{SdkContextConfig, default_storage, new_shared_sdk_context};
2208 let config = default_config(Network::Regtest);
2209 let ctx = new_shared_sdk_context(SdkContextConfig {
2210 storage: Some(default_storage(
2211 "/tmp/breez-sdk-test-resolve-storage-ctx".to_string(),
2212 )),
2213 ..SdkContextConfig::new(Network::Regtest)
2214 })
2215 .await
2216 .expect("regtest context");
2217 let signer = test_spark_signer();
2218 let builder_storage =
2219 default_storage("/tmp/breez-sdk-test-resolve-storage-builder".to_string());
2220 let err = super::resolve_storage(Some(builder_storage), &ctx, &signer, &config)
2221 .await
2222 .err()
2223 .expect("expected duplicate-storage error");
2224 assert!(
2225 err.to_string()
2226 .contains("storage is configured on both the SdkBuilder and the shared SdkContext"),
2227 "unexpected error: {err}"
2228 );
2229 }
2230}