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 chain_service = resolve_chain_service(
505 self.chain_service,
506 self.rest_chain_service_config,
507 &context,
508 self.config.network,
509 )?;
510 let stores = resolve_storage(self.storage, &context, &signers.spark, &self.config).await?;
511 if let Some(provider) = &context.jwt_header_provider {
516 provider.start(creates_context.then(|| Arc::clone(&stores.storage)));
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 &event_emitter,
631 shutdown_sender.subscribe(),
632 )
633 .await;
634
635 let stable_balance = build_stable_balance(
636 &self.config,
637 &token_converter,
638 &spark_wallet,
639 &storage,
640 &event_emitter,
641 )
642 .await;
643
644 event_emitter
647 .add_middleware(Box::new(TokenConversionMiddleware))
648 .await;
649
650 let sdk = BreezSdk::init_and_start(BreezSdkParams {
651 input_parser,
652 config: self.config,
653 storage,
654 chain_service,
655 fiat_service,
656 lnurl_client,
657 lnurl_server_client,
658 lnurl_auth_signer: signers.lnurl_auth,
659 shutdown_sender,
660 runtime,
661 spark_wallet,
662 event_emitter,
663 buy_bitcoin_provider,
664 token_converter,
665 stable_balance,
666 sync_coordinator,
667 cross_chain_context,
668 lightning_sender,
669 })
670 .await?;
671 debug!("Initialized and started breez sdk.");
672
673 Ok(sdk)
674 }
675}
676
677fn validate_server_mode(
679 config: &Config,
680 background_services_enabled: bool,
681) -> Result<(), SdkError> {
682 if background_services_enabled {
683 return Ok(());
684 }
685 if config.stable_balance_config.is_some() {
686 return Err(SdkError::InvalidInput(
687 "stable_balance_config is not supported when background_tasks_enabled is false"
688 .to_string(),
689 ));
690 }
691 if config.real_time_sync_server_url.is_some() {
692 return Err(SdkError::InvalidInput(
693 "real_time_sync_server_url must be None when background_tasks_enabled is false"
694 .to_string(),
695 ));
696 }
697 if config.leaf_optimization_config.auto_enabled {
698 return Err(SdkError::InvalidInput(
699 "leaf_optimization_config.auto_enabled must be false when background_tasks_enabled is false"
700 .to_string(),
701 ));
702 }
703 if config.token_optimization_config.auto_enabled {
704 return Err(SdkError::InvalidInput(
705 "token_optimization_config.auto_enabled must be false when background_tasks_enabled is false"
706 .to_string(),
707 ));
708 }
709 if config.cross_chain_config.is_some() {
710 return Err(SdkError::InvalidInput(
711 "Cross-chain config must be unset when background tasks are disabled".to_string(),
712 ));
713 }
714 Ok(())
715}
716
717fn validate_signer_capabilities(config: &Config, has_ecies: bool) -> Result<(), SdkError> {
720 if has_ecies {
721 return Ok(());
722 }
723 if config.real_time_sync_server_url.is_some() {
724 return Err(SdkError::InvalidInput(
725 "Real-time sync requires a signer that supports ECIES".to_string(),
726 ));
727 }
728 if config.cross_chain_config.is_some() {
729 return Err(SdkError::InvalidInput(
730 "Cross-chain payments require a signer that supports ECIES".to_string(),
731 ));
732 }
733 Ok(())
734}
735
736fn build_signers(config: &Config, signer_source: SignerSource) -> Result<Signers, SdkError> {
741 use crate::signer::{
742 BreezSigner, EciesSigner, ExternalBreezSignerAdapter, ExternalSigningSignerAdapter,
743 ExternalSparkSignerAdapter, HmacSigner,
744 };
745
746 #[allow(clippy::type_complexity)]
750 let (base, ecies, hmac, spark): (
751 Arc<dyn BreezSigner>,
752 Option<Arc<dyn EciesSigner>>,
753 Option<Arc<dyn HmacSigner>>,
754 Arc<dyn SparkSigner>,
755 ) = match signer_source {
756 SignerSource::Seed {
757 seed,
758 account_number,
759 } => {
760 let seed_bytes = seed.to_bytes()?;
761 let network = config.network.into();
762 let signer = Arc::new(BreezSignerImpl::new(
764 spark_wallet::identity_master_key(&seed_bytes, network, account_number)
765 .map_err(|e| SdkError::Generic(e.to_string()))?,
766 ));
767 let spark: Arc<dyn SparkSigner> = Arc::new(spark_wallet::SparkSignerAdapter::new(
768 Arc::new(spark_wallet::DefaultSigner::from_master(
769 spark_wallet::account_master_key(&seed_bytes, network, account_number)
770 .map_err(|e| SdkError::Generic(e.to_string()))?,
771 )),
772 ));
773 (signer.clone(), Some(signer.clone()), Some(signer), spark)
774 }
775 SignerSource::External { breez, spark } => {
776 let spark: Arc<dyn SparkSigner> = Arc::new(ExternalSparkSignerAdapter::new(spark));
777 match breez {
778 ExternalBreez::Full(breez) => {
779 let signer = Arc::new(ExternalBreezSignerAdapter::new(breez));
781 (signer.clone(), Some(signer.clone()), Some(signer), spark)
782 }
783 ExternalBreez::SigningOnly(breez) => {
784 let base: Arc<dyn BreezSigner> =
785 Arc::new(ExternalSigningSignerAdapter::new(breez));
786 (base, None, None, spark)
787 }
788 }
789 }
790 };
791
792 let rtsync = ecies
793 .as_ref()
794 .map(|ecies| {
795 RTSyncSigner::new(base.clone(), ecies.clone(), config.network)
796 .map(Arc::new)
797 .map_err(|e| SdkError::Generic(e.to_string()))
798 })
799 .transpose()?;
800 let lnurl_auth = hmac
801 .as_ref()
802 .map(|hmac| Arc::new(LnurlAuthSignerAdapter::new(base.clone(), hmac.clone())));
803
804 Ok(Signers {
805 ecies,
806 spark,
807 rtsync,
808 lnurl_auth,
809 })
810}
811
812async fn resolve_context(
815 supplied: Option<Arc<SdkContext>>,
816 config: &Config,
817) -> Result<Arc<SdkContext>, SdkError> {
818 let context = match supplied {
819 Some(ctx) => ctx,
820 None => {
821 new_shared_sdk_context(SdkContextConfig {
822 api_key: config.api_key.clone(),
823 proxy: config.proxy.clone(),
824 ..SdkContextConfig::new(config.network)
825 })
826 .await?
827 }
828 };
829 if context.network != config.network || context.api_key != config.api_key {
830 return Err(SdkError::Generic(
831 "SdkContext network/api_key do not match SdkConfig".to_string(),
832 ));
833 }
834 if context.proxy != config.proxy {
837 return Err(SdkError::Generic(
838 "SdkContext proxy does not match SdkConfig proxy".to_string(),
839 ));
840 }
841 Ok(context)
842}
843
844async fn resolve_storage(
847 supplied: Option<Arc<dyn StorageBackend>>,
848 context: &SdkContext,
849 spark_signer: &Arc<dyn SparkSigner>,
850 config: &Config,
851) -> Result<Arc<ResolvedStores>, SdkError> {
852 let storage_backend: Arc<dyn StorageBackend> = match (supplied, context.storage_backend.clone())
853 {
854 (Some(storage), None) => storage,
855 (None, Some(backend)) => backend,
856 (Some(_), Some(_)) => {
857 return Err(SdkError::Generic(
858 "storage is configured on both the SdkBuilder and the shared SdkContext"
859 .to_string(),
860 ));
861 }
862 (None, None) => return Err(SdkError::Generic("No storage configured".to_string())),
863 };
864 let identity_public_key = spark_signer
865 .get_identity_public_key()
866 .await
867 .map_err(|e| SdkError::Generic(e.to_string()))?;
868 storage_backend
869 .create_stores(config.network, identity_public_key.serialize().to_vec())
870 .await
871}
872
873fn resolve_chain_service(
879 supplied: Option<Arc<dyn BitcoinChainService>>,
880 rest_config: Option<RestChainServiceConfig>,
881 context: &SdkContext,
882 network: Network,
883) -> Result<Arc<dyn BitcoinChainService>, SdkError> {
884 let inner: Arc<dyn BitcoinChainService> = if let Some(service) = supplied {
885 service
886 } else if let Some(cfg) = rest_config {
887 Arc::new(RestClientChainService::new(
888 cfg.url,
889 network,
890 5,
891 context.http_client.clone(),
892 cfg.credentials
893 .map(|c| BasicAuth::new(c.username, c.password)),
894 cfg.api_type,
895 ))
896 } else {
897 let inner_client: Arc<dyn platform_utils::HttpClient> = context.http_client.clone();
898 match network {
899 Network::Mainnet => Arc::new(RestClientChainService::new(
900 "https://mempool.space/api".to_string(),
901 network,
902 5,
903 inner_client,
904 None,
905 ChainApiType::MempoolSpace,
906 )),
907 Network::Signet => return Err(SdkError::InvalidInput(
908 "Signet requires an explicit chain service via with_chain_service or with_rest_chain_service".to_string(),
909 )),
910 Network::Regtest => Arc::new(RestClientChainService::new(
911 "https://regtest-mempool.us-west-2.sparkinfra.net/api".to_string(),
912 network,
913 5,
914 inner_client,
915 match (
916 std::env::var("CHAIN_SERVICE_USERNAME"),
917 std::env::var("CHAIN_SERVICE_PASSWORD"),
918 ) {
919 (Ok(username), Ok(password)) => Some(BasicAuth::new(username, password)),
920 _ => Some(BasicAuth::new(
921 "spark-sdk".to_string(),
922 "mCMk1JqlBNtetUNy".to_string(),
923 )),
924 },
925 ChainApiType::MempoolSpace,
926 )),
927 }
928 };
929 Ok(Arc::new(ValidatingChainService::new(inner)))
930}
931
932fn finalize_spark_wallet_config(
936 config: &Config,
937 user_agent: &str,
938 background_services_enabled: bool,
939) -> Result<SparkWalletConfig, SdkError> {
940 let mut spark_wallet_config = if let Some(env_config) = &config.spark_config {
941 SdkBuilder::build_spark_wallet_config(config.network.into(), env_config)?
942 } else {
943 SparkWalletConfig::default_config(config.network.into())
944 };
945 spark_wallet_config.operator_pool = spark_wallet_config
946 .operator_pool
947 .with_user_agent(Some(user_agent.to_string()));
948 spark_wallet_config.service_provider_config.user_agent = Some(user_agent.to_string());
949 spark_wallet_config.leaf_auto_optimize_enabled =
950 background_services_enabled && config.leaf_optimization_config.auto_enabled;
951 spark_wallet_config.leaf_optimization_options.multiplicity =
952 config.leaf_optimization_config.multiplicity;
953
954 let token_opt = &config.token_optimization_config;
955 let token_options = &mut spark_wallet_config.token_outputs_optimization_options;
956 token_options.target_output_count = token_opt.target_output_count;
957 token_options.min_outputs_threshold = token_opt.min_outputs_threshold;
958 if !token_opt.auto_enabled || !background_services_enabled {
960 token_options.auto_optimize_interval = None;
961 }
962 spark_wallet_config.max_concurrent_claims = config.max_concurrent_claims;
963 Ok(spark_wallet_config)
964}
965
966fn wrap_session_store(session_store: Option<Arc<dyn SessionStore>>) -> Arc<dyn SessionStore> {
974 let inner = session_store.unwrap_or_else(|| Arc::new(InMemorySessionStore::default()));
975 Arc::new(crate::session_store::CachingSessionStore::new(inner))
976}
977
978async fn build_spark_wallet(params: BuildSparkWalletParams) -> Result<Arc<SparkWallet>, SdkError> {
980 let mut wallet_builder = spark_wallet::WalletBuilder::new(params.config, params.spark_signer)
981 .with_session_store(params.session_store);
982 if let Some(receiver) = params.shutdown_receiver {
983 wallet_builder = wallet_builder.with_cancellation_token(receiver);
984 }
985 if let Some(provider) = ¶ms.context.jwt_header_provider {
986 wallet_builder = wallet_builder
987 .with_so_extra_header_provider(
988 Arc::clone(provider) as Arc<dyn spark_wallet::HeaderProvider>
989 )
990 .with_ssp_extra_header_provider(
991 Arc::clone(provider) as Arc<dyn spark_wallet::HeaderProvider>
992 );
993 }
994 if let Some(observer) = params.payment_observer {
995 let observer: Arc<dyn spark_wallet::TransferObserver> =
996 Arc::new(SparkTransferObserver::new(observer));
997 wallet_builder = wallet_builder.with_transfer_observer(observer);
998 }
999 if let Some(tree_store) = params.tree_store {
1000 wallet_builder = wallet_builder.with_tree_store(tree_store);
1001 }
1002 if let Some(token_output_store) = params.token_output_store {
1003 wallet_builder = wallet_builder.with_token_output_store(token_output_store);
1004 }
1005 wallet_builder = wallet_builder.with_ssp_http_client(params.context.http_client.clone());
1006 wallet_builder =
1007 wallet_builder.with_connection_manager(params.context.connection_manager.clone());
1008 Ok(Arc::new(wallet_builder.build().await?))
1009}
1010
1011fn resolve_lnurl_server_client(
1014 explicit: Option<Arc<dyn LnurlServerClient>>,
1015 config: &Config,
1016 context: &SdkContext,
1017 spark_wallet: &Arc<SparkWallet>,
1018) -> Option<Arc<dyn LnurlServerClient>> {
1019 if let Some(client) = explicit {
1020 return Some(client);
1021 }
1022 config.lnurl_domain.as_ref().map(|domain| {
1023 Arc::new(DefaultLnurlServerClient::new(
1024 context.http_client.clone(),
1025 domain.clone(),
1026 config.api_key.clone(),
1027 Arc::clone(spark_wallet),
1028 )) as Arc<dyn LnurlServerClient>
1029 })
1030}
1031
1032#[allow(clippy::too_many_arguments)]
1035async fn maybe_wrap_storage_with_real_time_sync(
1036 storage: Arc<dyn crate::persist::Storage>,
1037 config: &Config,
1038 background_services_enabled: bool,
1039 user_agent: String,
1040 rtsync_signer: Option<Arc<RTSyncSigner>>,
1041 shutdown_receiver: watch::Receiver<()>,
1042 event_emitter: Arc<EventEmitter>,
1043 lnurl_server_client: Option<Arc<dyn LnurlServerClient>>,
1044) -> Result<Arc<dyn crate::persist::Storage>, SdkError> {
1045 match (&config.real_time_sync_server_url, rtsync_signer) {
1049 (Some(server_url), Some(signer)) if background_services_enabled => {
1050 init_and_start_real_time_sync(RealTimeSyncParams {
1051 server_url: server_url.clone(),
1052 api_key: config.api_key.clone(),
1053 user_agent,
1054 proxy: config.proxy.as_ref().map(platform_utils::ProxyConfig::from),
1055 signer,
1056 storage,
1057 shutdown_receiver,
1058 event_emitter,
1059 lnurl_server_client,
1060 })
1061 .await
1062 }
1063 _ => Ok(storage),
1064 }
1065}
1066
1067fn build_token_converter(
1069 config: &Config,
1070 storage: &Arc<dyn crate::persist::Storage>,
1071 spark_wallet: &Arc<SparkWallet>,
1072 context: &SdkContext,
1073) -> Arc<dyn TokenConverter> {
1074 let flashnet_config = FlashnetConfig::default_config(
1075 config.network.into(),
1076 DEFAULT_INTEGRATOR_PUBKEY
1077 .parse()
1078 .ok()
1079 .map(|pubkey| IntegratorConfig {
1080 pubkey,
1081 fee_bps: DEFAULT_INTEGRATOR_FEE_BPS,
1082 }),
1083 );
1084 Arc::new(FlashnetTokenConverter::new(
1085 flashnet_config,
1086 Arc::clone(storage),
1087 Arc::clone(spark_wallet),
1088 config.network,
1089 context.http_client.clone(),
1090 ))
1091}
1092
1093async fn build_stable_balance(
1096 config: &Config,
1097 token_converter: &Arc<dyn TokenConverter>,
1098 spark_wallet: &Arc<SparkWallet>,
1099 storage: &Arc<dyn crate::persist::Storage>,
1100 event_emitter: &Arc<EventEmitter>,
1101) -> Option<Arc<StableBalance>> {
1102 let stable_config = config.stable_balance_config.as_ref()?;
1103 Some(Arc::new(
1104 StableBalance::new(
1105 stable_config.clone(),
1106 Arc::clone(token_converter),
1107 Arc::clone(spark_wallet),
1108 Arc::clone(storage),
1109 Arc::clone(event_emitter),
1110 )
1111 .await,
1112 ))
1113}
1114
1115#[allow(clippy::too_many_arguments)]
1118async fn build_cross_chain_context(
1119 config: &Config,
1120 breez_server: &Arc<BreezServer>,
1121 http_client: &Arc<dyn platform_utils::HttpClient>,
1122 spark_wallet: &Arc<SparkWallet>,
1123 storage: &Arc<dyn crate::persist::Storage>,
1124 fiat_service: Arc<dyn breez_sdk_common::fiat::FiatService>,
1125 event_emitter: &Arc<EventEmitter>,
1126 shutdown_receiver: watch::Receiver<()>,
1127) -> crate::cross_chain::CrossChainContext {
1128 let cached_fiat: Arc<dyn breez_sdk_common::fiat::FiatService> =
1130 Arc::new(crate::cross_chain::CachedFiatService::new(
1131 fiat_service,
1132 crate::cross_chain::DEFAULT_FIAT_CACHE_TTL,
1133 ));
1134 let mut providers = crate::cross_chain::CrossChainContext::new(Arc::clone(&cached_fiat));
1135 if config.cross_chain_config.is_none() {
1136 return providers;
1137 }
1138
1139 if matches!(config.network, Network::Mainnet) {
1144 let config_resolver = Arc::new(
1145 crate::cross_chain::BreezServerOrchestraConfigResolver::new(Arc::clone(breez_server)),
1146 );
1147 providers.insert(
1148 crate::cross_chain::CrossChainProvider::Orchestra,
1149 Arc::new(
1150 crate::cross_chain::OrchestraService::new(
1151 config_resolver,
1152 Arc::clone(spark_wallet),
1153 Arc::clone(storage),
1154 Arc::clone(&cached_fiat),
1155 Arc::clone(http_client),
1156 Arc::clone(event_emitter),
1157 shutdown_receiver,
1158 )
1159 .await,
1160 ),
1161 );
1162 }
1163
1164 providers
1165}
1166
1167#[cfg(test)]
1168#[cfg(feature = "sqlite")]
1169mod tests {
1170 use super::SdkBuilder;
1171 use crate::{Network, SdkError, default_config};
1172
1173 fn signet_test_spark_config() -> crate::SparkConfig {
1174 let identifier = format!("{:064x}", 1);
1175 let public_key = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
1176 crate::SparkConfig {
1177 coordinator_identifier: identifier.clone(),
1178 threshold: 1,
1179 signing_operators: vec![crate::SparkSigningOperator {
1180 id: 0,
1181 identifier,
1182 address: "https://signet-operator.invalid".to_string(),
1183 identity_public_key: public_key.to_string(),
1184 ca_cert_pem: None,
1185 }],
1186 ssp_config: crate::SparkSspConfig {
1187 base_url: "https://signet-ssp.invalid".to_string(),
1188 identity_public_key: public_key.to_string(),
1189 schema_endpoint: None,
1190 },
1191 expected_withdraw_bond_sats: 10_000,
1192 expected_withdraw_relative_block_locktime: 1_000,
1193 max_token_transaction_inputs: None,
1194 }
1195 }
1196
1197 #[tokio::test]
1198 async fn signet_requires_explicit_spark_config() {
1199 for config in [
1200 default_config(Network::Signet),
1201 crate::default_server_config(Network::Signet),
1202 ] {
1203 assert!(config.spark_config.is_none());
1204 assert!(matches!(
1205 config.validate(),
1206 Err(SdkError::InvalidInput(message)) if message.contains("spark_config")
1207 ));
1208 assert!(matches!(
1209 SdkBuilder::new(config, test_seed()).build().await,
1210 Err(SdkError::InvalidInput(message)) if message.contains("spark_config")
1211 ));
1212 }
1213 }
1214
1215 #[tokio::test]
1216 async fn signet_requires_explicit_chain_service() {
1217 for mut config in [
1218 default_config(Network::Signet),
1219 crate::default_server_config(Network::Signet),
1220 ] {
1221 config.spark_config = Some(signet_test_spark_config());
1222 assert!(matches!(
1223 SdkBuilder::new(config, test_seed()).build().await,
1224 Err(SdkError::InvalidInput(message)) if message.contains("explicit chain service")
1225 ));
1226 }
1227 }
1228
1229 #[tokio::test]
1230 async fn signet_sdk_builds_without_api_key_and_preserves_network() {
1231 let mut config = crate::default_server_config("signet".parse().unwrap());
1232 config.spark_config = Some(signet_test_spark_config());
1233 assert!(config.api_key.is_none());
1234 assert!(config.lnurl_domain.is_none());
1235 let sdk = SdkBuilder::new(config, test_seed())
1236 .with_rest_chain_service(
1237 "https://signet-chain.invalid/api".to_string(),
1238 crate::ChainApiType::Esplora,
1239 None,
1240 )
1241 .with_default_storage(unique_storage_dir("signet"))
1242 .build()
1243 .await
1244 .expect("Signet SDK should initialize without a Breez API key");
1245
1246 let address = sdk.spark_wallet.get_spark_address().unwrap();
1247 assert_eq!(address.network, spark_wallet::Network::Signet);
1248 let encoded = address.to_address_string().unwrap();
1249 let decoded: spark_wallet::SparkAddress = encoded.parse().unwrap();
1250 assert_eq!(decoded.network, spark_wallet::Network::Signet);
1251 assert_eq!(
1252 bitcoin::Network::from(Network::Signet),
1253 bitcoin::Network::Signet
1254 );
1255 assert!(matches!(
1256 crate::BitcoinNetwork::from(Network::Signet),
1257 crate::BitcoinNetwork::Signet
1258 ));
1259 assert!(matches!(
1260 breez_sdk_common::network::BitcoinNetwork::from(Network::Signet),
1261 breez_sdk_common::network::BitcoinNetwork::Signet
1262 ));
1263 sdk.disconnect().await.unwrap();
1264 }
1265
1266 #[tokio::test]
1267 async fn signet_explicit_chain_service_uses_esplora() {
1268 use platform_utils::{HttpClient, HttpError, HttpResponse};
1269 use std::{collections::HashMap, sync::Arc};
1270
1271 struct SignetChainClient;
1272
1273 #[macros::async_trait]
1274 impl HttpClient for SignetChainClient {
1275 async fn get(
1276 &self,
1277 url: String,
1278 headers: Option<HashMap<String, String>>,
1279 ) -> Result<HttpResponse, HttpError> {
1280 assert_eq!(url, "https://signet-chain.invalid/api/fee-estimates");
1281 assert!(
1282 !headers
1283 .unwrap_or_default()
1284 .keys()
1285 .any(|key| key.eq_ignore_ascii_case("authorization"))
1286 );
1287 Ok(HttpResponse {
1288 status: 200,
1289 body: r#"{"1":5.0,"3":3.0,"6":2.0,"144":1.0}"#.to_string(),
1290 headers: HashMap::new(),
1291 })
1292 }
1293
1294 async fn post(
1295 &self,
1296 _url: String,
1297 _headers: Option<HashMap<String, String>>,
1298 _body: Option<String>,
1299 ) -> Result<HttpResponse, HttpError> {
1300 panic!("fee estimation must not POST")
1301 }
1302
1303 async fn delete(
1304 &self,
1305 _url: String,
1306 _headers: Option<HashMap<String, String>>,
1307 _body: Option<String>,
1308 ) -> Result<HttpResponse, HttpError> {
1309 panic!("fee estimation must not DELETE")
1310 }
1311 }
1312
1313 let mut context =
1314 crate::new_shared_sdk_context(crate::SdkContextConfig::new(Network::Signet))
1315 .await
1316 .unwrap();
1317 Arc::get_mut(&mut context).unwrap().http_client = Arc::new(SignetChainClient);
1318 let chain_service = super::resolve_chain_service(
1319 None,
1320 Some(super::RestChainServiceConfig {
1321 url: "https://signet-chain.invalid/api".to_string(),
1322 api_type: crate::ChainApiType::Esplora,
1323 credentials: None,
1324 }),
1325 &context,
1326 Network::Signet,
1327 )
1328 .unwrap();
1329 let chain_service =
1331 super::resolve_chain_service(Some(chain_service), None, &context, Network::Signet)
1332 .unwrap();
1333 let fees = chain_service.recommended_fees().await.unwrap();
1334 assert_eq!(fees.fastest_fee, 5);
1335 assert_eq!(fees.half_hour_fee, 3);
1336 assert_eq!(fees.hour_fee, 2);
1337 }
1338
1339 #[test]
1340 fn default_config_spark_config_builds_valid_wallet_config() {
1341 for network in [Network::Mainnet, Network::Regtest] {
1342 let config = default_config(network);
1343 let spark_config = config
1344 .spark_config
1345 .as_ref()
1346 .expect("default_config must populate spark_config");
1347 SdkBuilder::build_spark_wallet_config(network.into(), spark_config).unwrap_or_else(
1348 |e| {
1349 panic!(
1350 "default_config({network:?}).spark_config failed to build SparkWalletConfig: {e}"
1351 )
1352 },
1353 );
1354 }
1355 }
1356
1357 #[test]
1358 fn validate_signer_capabilities_gates_encryption_features() {
1359 let mut config = default_config(Network::Regtest);
1361 config.real_time_sync_server_url = Some("https://example.com".to_string());
1362 assert!(super::validate_signer_capabilities(&config, true).is_ok());
1363
1364 let mut config = default_config(Network::Regtest);
1366 config.real_time_sync_server_url = Some("https://example.com".to_string());
1367 config.cross_chain_config = None;
1368 match super::validate_signer_capabilities(&config, false) {
1369 Err(SdkError::InvalidInput(m)) => {
1370 assert!(m.contains("Real-time sync"), "got: {m}");
1371 }
1372 other => panic!("expected InvalidInput, got {other:?}"),
1373 }
1374
1375 let mut config = default_config(Network::Regtest);
1378 config.real_time_sync_server_url = None;
1379 config.cross_chain_config = None;
1380 assert!(super::validate_signer_capabilities(&config, false).is_ok());
1381 }
1382
1383 #[test]
1387 fn build_signers_signing_only_omits_ecies_dependent_signers() {
1388 use crate::error::SignerError;
1389 use crate::signer::external_types::{
1390 EcdsaSignatureBytes, MessageBytes, PublicKeyBytes, RecoverableEcdsaSignatureBytes,
1391 SchnorrSignatureBytes,
1392 };
1393 use crate::signer::{
1394 DefaultExternalSparkSigner, ExternalSigningSigner, ExternalSparkSigner,
1395 };
1396 use std::sync::Arc;
1397
1398 struct StubSigningSigner;
1400 #[macros::async_trait]
1401 impl ExternalSigningSigner for StubSigningSigner {
1402 async fn derive_public_key(&self, _p: String) -> Result<PublicKeyBytes, SignerError> {
1403 unreachable!("build_signers must not call the signer")
1404 }
1405 async fn sign_ecdsa(
1406 &self,
1407 _m: MessageBytes,
1408 _p: String,
1409 ) -> Result<EcdsaSignatureBytes, SignerError> {
1410 unreachable!("build_signers must not call the signer")
1411 }
1412 async fn sign_ecdsa_recoverable(
1413 &self,
1414 _m: MessageBytes,
1415 _p: String,
1416 ) -> Result<RecoverableEcdsaSignatureBytes, SignerError> {
1417 unreachable!("build_signers must not call the signer")
1418 }
1419 async fn sign_hash_schnorr(
1420 &self,
1421 _h: Vec<u8>,
1422 _p: String,
1423 ) -> Result<SchnorrSignatureBytes, SignerError> {
1424 unreachable!("build_signers must not call the signer")
1425 }
1426 }
1427
1428 let config = default_config(Network::Regtest);
1429
1430 let full = super::build_signers(
1432 &config,
1433 super::SignerSource::Seed {
1434 seed: test_seed(),
1435 account_number: None,
1436 },
1437 )
1438 .unwrap();
1439 assert!(full.ecies.is_some());
1440 assert!(full.rtsync.is_some());
1441 assert!(full.lnurl_auth.is_some());
1442
1443 let breez: Arc<dyn ExternalSigningSigner> = Arc::new(StubSigningSigner);
1445 let spark: Arc<dyn ExternalSparkSigner> = Arc::new(
1446 DefaultExternalSparkSigner::new(
1447 TEST_MNEMONIC.to_string(),
1448 None,
1449 Network::Regtest,
1450 None,
1451 )
1452 .unwrap(),
1453 );
1454 let signing_only = super::build_signers(
1455 &config,
1456 super::SignerSource::External {
1457 breez: super::ExternalBreez::SigningOnly(breez),
1458 spark,
1459 },
1460 )
1461 .unwrap();
1462 assert!(signing_only.ecies.is_none());
1463 assert!(signing_only.rtsync.is_none());
1464 assert!(signing_only.lnurl_auth.is_none());
1465 }
1466
1467 #[tokio::test]
1470 async fn signing_only_signer_build_disables_lnurl_auth() {
1471 use crate::error::SignerError;
1472 use crate::signer::external_types::{
1473 EcdsaSignatureBytes, MessageBytes, PublicKeyBytes, RecoverableEcdsaSignatureBytes,
1474 SchnorrSignatureBytes,
1475 };
1476 use crate::signer::{
1477 DefaultExternalSigner, DefaultExternalSparkSigner, ExternalBreezSigner,
1478 ExternalSigningSigner, ExternalSparkSigner,
1479 };
1480 use std::sync::Arc;
1481
1482 struct SigningOnly(DefaultExternalSigner);
1485 #[macros::async_trait]
1486 impl ExternalSigningSigner for SigningOnly {
1487 async fn derive_public_key(&self, path: String) -> Result<PublicKeyBytes, SignerError> {
1488 self.0.derive_public_key(path).await
1489 }
1490 async fn sign_ecdsa(
1491 &self,
1492 message: MessageBytes,
1493 path: String,
1494 ) -> Result<EcdsaSignatureBytes, SignerError> {
1495 self.0.sign_ecdsa(message, path).await
1496 }
1497 async fn sign_ecdsa_recoverable(
1498 &self,
1499 message: MessageBytes,
1500 path: String,
1501 ) -> Result<RecoverableEcdsaSignatureBytes, SignerError> {
1502 self.0.sign_ecdsa_recoverable(message, path).await
1503 }
1504 async fn sign_hash_schnorr(
1505 &self,
1506 hash: Vec<u8>,
1507 path: String,
1508 ) -> Result<SchnorrSignatureBytes, SignerError> {
1509 self.0.sign_hash_schnorr(hash, path).await
1510 }
1511 }
1512
1513 let mut config = default_config(Network::Regtest);
1514 config.real_time_sync_server_url = None;
1516 config.private_enabled_default = false;
1517
1518 let breez: Arc<dyn ExternalSigningSigner> = Arc::new(SigningOnly(
1519 DefaultExternalSigner::new(TEST_MNEMONIC.to_string(), None, Network::Regtest, None)
1520 .unwrap(),
1521 ));
1522 let spark: Arc<dyn ExternalSparkSigner> = Arc::new(
1523 DefaultExternalSparkSigner::new(
1524 TEST_MNEMONIC.to_string(),
1525 None,
1526 Network::Regtest,
1527 None,
1528 )
1529 .unwrap(),
1530 );
1531
1532 let sdk = SdkBuilder::new_with_signing_only_signer(config, breez, spark)
1533 .with_default_storage(unique_storage_dir("signing-only-lnurl"))
1534 .build()
1535 .await
1536 .expect("signing-only build should succeed");
1537
1538 assert!(
1539 sdk.lnurl_auth_signer.is_none(),
1540 "a signing-only signer must not build an lnurl-auth signer"
1541 );
1542
1543 let err = sdk
1544 .lnurl_auth(crate::LnurlAuthRequestDetails {
1545 k1: "00".repeat(32),
1546 action: None,
1547 domain: "example.com".to_string(),
1548 url: "https://example.com/lnurl-auth".to_string(),
1549 })
1550 .await
1551 .expect_err("lnurl_auth must fail for a signing-only signer");
1552 assert!(
1553 matches!(err, SdkError::Generic(_)),
1554 "expected Generic, got {err:?}"
1555 );
1556
1557 sdk.disconnect().await.expect("disconnect should succeed");
1558 }
1559
1560 #[tokio::test]
1564 async fn signing_only_signer_build_rejects_ecies_features() {
1565 use crate::error::SignerError;
1566 use crate::signer::external_types::{
1567 EcdsaSignatureBytes, MessageBytes, PublicKeyBytes, RecoverableEcdsaSignatureBytes,
1568 SchnorrSignatureBytes,
1569 };
1570 use crate::signer::{
1571 DefaultExternalSparkSigner, ExternalSigningSigner, ExternalSparkSigner,
1572 };
1573 use std::sync::Arc;
1574
1575 struct StubSigningSigner;
1577 #[macros::async_trait]
1578 impl ExternalSigningSigner for StubSigningSigner {
1579 async fn derive_public_key(&self, _p: String) -> Result<PublicKeyBytes, SignerError> {
1580 unreachable!("build must fail before calling the signer")
1581 }
1582 async fn sign_ecdsa(
1583 &self,
1584 _m: MessageBytes,
1585 _p: String,
1586 ) -> Result<EcdsaSignatureBytes, SignerError> {
1587 unreachable!("build must fail before calling the signer")
1588 }
1589 async fn sign_ecdsa_recoverable(
1590 &self,
1591 _m: MessageBytes,
1592 _p: String,
1593 ) -> Result<RecoverableEcdsaSignatureBytes, SignerError> {
1594 unreachable!("build must fail before calling the signer")
1595 }
1596 async fn sign_hash_schnorr(
1597 &self,
1598 _h: Vec<u8>,
1599 _p: String,
1600 ) -> Result<SchnorrSignatureBytes, SignerError> {
1601 unreachable!("build must fail before calling the signer")
1602 }
1603 }
1604
1605 let mut config = default_config(Network::Regtest);
1606 config.real_time_sync_server_url = Some("https://example.com".to_string());
1607
1608 let breez: Arc<dyn ExternalSigningSigner> = Arc::new(StubSigningSigner);
1609 let spark: Arc<dyn ExternalSparkSigner> = Arc::new(
1610 DefaultExternalSparkSigner::new(
1611 TEST_MNEMONIC.to_string(),
1612 None,
1613 Network::Regtest,
1614 None,
1615 )
1616 .unwrap(),
1617 );
1618
1619 let result = SdkBuilder::new_with_signing_only_signer(config, breez, spark)
1620 .with_default_storage(unique_storage_dir("signing-only-reject-rtsync"))
1621 .build()
1622 .await;
1623 match result {
1624 Err(SdkError::InvalidInput(_)) => {}
1625 Ok(_) => panic!("build must reject real-time sync for a signing-only signer"),
1626 Err(other) => panic!("expected InvalidInput, got {other:?}"),
1627 }
1628 }
1629
1630 #[tokio::test]
1631 async fn wrap_session_store_stores_token_verbatim() {
1632 use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
1633 use spark_wallet::{Session, SessionStore, SessionStoreError};
1634 use std::collections::HashMap;
1635 use std::sync::{Arc, Mutex};
1636
1637 #[derive(Default)]
1639 struct InspectableInner {
1640 sessions: Mutex<HashMap<PublicKey, Session>>,
1641 }
1642 #[macros::async_trait]
1643 impl SessionStore for InspectableInner {
1644 async fn get_session(&self, key: &PublicKey) -> Result<Session, SessionStoreError> {
1645 self.sessions
1646 .lock()
1647 .unwrap()
1648 .get(key)
1649 .cloned()
1650 .ok_or(SessionStoreError::NotFound)
1651 }
1652 async fn set_session(
1653 &self,
1654 key: &PublicKey,
1655 session: Session,
1656 ) -> Result<(), SessionStoreError> {
1657 self.sessions.lock().unwrap().insert(*key, session);
1658 Ok(())
1659 }
1660 }
1661 fn key() -> PublicKey {
1662 let secp = Secp256k1::new();
1663 PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[3u8; 32]).unwrap())
1664 }
1665
1666 let inner = Arc::new(InspectableInner::default());
1667 let store = super::wrap_session_store(Some(inner.clone()));
1668 let token = "bearer-token".to_string();
1669 store
1670 .set_session(
1671 &key(),
1672 Session {
1673 token: token.clone(),
1674 expiration: 1,
1675 },
1676 )
1677 .await
1678 .unwrap();
1679
1680 let raw = inner.sessions.lock().unwrap().get(&key()).cloned().unwrap();
1683 assert_eq!(raw.token, token, "the SDK must store the token as-is");
1684 assert_eq!(store.get_session(&key()).await.unwrap().token, token);
1685 }
1686
1687 #[tokio::test]
1688 async fn server_mode_rejects_stable_balance_config() {
1689 use crate::{SdkError, StableBalanceConfig, StableBalanceToken, default_server_config};
1690
1691 let mut config = default_server_config(Network::Regtest);
1692 config.stable_balance_config = Some(StableBalanceConfig {
1693 tokens: vec![StableBalanceToken {
1694 label: "USDB".to_string(),
1695 token_identifier: "btkn1test".to_string(),
1696 }],
1697 default_active_label: None,
1698 threshold_sats: None,
1699 max_slippage_bps: None,
1700 });
1701
1702 let seed = test_seed();
1703 let result = SdkBuilder::new(config, seed).build().await;
1704 match result {
1705 Err(SdkError::InvalidInput(message)) => {
1706 assert!(message.contains("stable_balance_config"));
1707 }
1708 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1709 Ok(_) => panic!("expected server mode with Stable Balance config to fail"),
1710 }
1711 }
1712
1713 #[tokio::test]
1714 async fn server_mode_rejects_real_time_sync_server_url() {
1715 use crate::{SdkError, default_server_config};
1716
1717 let mut config = default_server_config(Network::Regtest);
1718 config.real_time_sync_server_url = Some("https://example.com".to_string());
1719
1720 let seed = test_seed();
1721 let result = SdkBuilder::new(config, seed).build().await;
1722 match result {
1723 Err(SdkError::InvalidInput(message)) => {
1724 assert!(message.contains("real_time_sync_server_url"));
1725 }
1726 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1727 Ok(_) => panic!("expected server mode with real_time_sync_server_url to fail"),
1728 }
1729 }
1730
1731 #[tokio::test]
1732 async fn server_mode_rejects_leaf_optimization_auto_enabled() {
1733 use crate::{SdkError, default_server_config};
1734
1735 let mut config = default_server_config(Network::Regtest);
1736 config.leaf_optimization_config.auto_enabled = true;
1737
1738 let seed = test_seed();
1739 let result = SdkBuilder::new(config, seed).build().await;
1740 match result {
1741 Err(SdkError::InvalidInput(message)) => {
1742 assert!(message.contains("leaf_optimization_config.auto_enabled"));
1743 }
1744 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1745 Ok(_) => panic!("expected server mode with optimization auto_enabled to fail"),
1746 }
1747 }
1748
1749 #[tokio::test]
1750 async fn server_mode_rejects_token_optimization_auto_enabled() {
1751 use crate::{SdkError, default_server_config};
1752
1753 let mut config = default_server_config(Network::Regtest);
1754 config.token_optimization_config.auto_enabled = true;
1755
1756 let seed = test_seed();
1757 let result = SdkBuilder::new(config, seed).build().await;
1758 match result {
1759 Err(SdkError::InvalidInput(message)) => {
1760 assert!(message.contains("token_optimization_config.auto_enabled"));
1761 }
1762 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1763 Ok(_) => panic!("expected server mode with optimization auto_enabled to fail"),
1764 }
1765 }
1766
1767 fn test_proxy() -> crate::ProxyConfig {
1770 crate::ProxyConfig {
1771 host: "127.0.0.1".to_string(),
1772 port: 9050,
1773 username: None,
1774 password: None,
1775 }
1776 }
1777
1778 #[test]
1781 fn validate_accepts_proxy_with_cross_chain_config() {
1782 use crate::{CrossChainConfig, default_config};
1783 let mut config = default_config(Network::Mainnet);
1784 config.proxy = Some(test_proxy());
1785 config.cross_chain_config = Some(CrossChainConfig::default());
1786
1787 assert!(config.validate().is_ok());
1788 }
1789
1790 #[test]
1791 fn validate_rejects_half_specified_proxy_credentials() {
1792 use crate::{SdkError, default_config};
1793 let mut config = default_config(Network::Mainnet);
1794 let mut proxy = test_proxy();
1795 proxy.username = Some("user".to_string());
1796 config.proxy = Some(proxy);
1797
1798 match config.validate() {
1799 Err(SdkError::InvalidInput(m)) => assert!(
1800 m.contains("username and password"),
1801 "expected credential-pair rejection, got: {m}"
1802 ),
1803 other => panic!("expected a username without a password to fail, got {other:?}"),
1804 }
1805 }
1806
1807 #[test]
1808 fn validate_rejects_empty_proxy_host_and_zero_port() {
1809 use crate::{SdkError, default_config};
1810 let mut config = default_config(Network::Mainnet);
1811
1812 let mut proxy = test_proxy();
1813 proxy.host = String::new();
1814 config.proxy = Some(proxy);
1815 assert!(matches!(config.validate(), Err(SdkError::InvalidInput(_))));
1816
1817 let mut proxy = test_proxy();
1818 proxy.port = 0;
1819 config.proxy = Some(proxy);
1820 assert!(matches!(config.validate(), Err(SdkError::InvalidInput(_))));
1821 }
1822
1823 #[test]
1827 fn validate_rejects_proxy_host_that_cannot_form_a_url() {
1828 use crate::{SdkError, default_config};
1829 for host in ["local host", "user@host", "socks5://127.0.0.1", "[::g]"] {
1830 let mut config = default_config(Network::Mainnet);
1831 let mut proxy = test_proxy();
1832 proxy.host = host.to_string();
1833 config.proxy = Some(proxy);
1834
1835 match config.validate() {
1836 Err(SdkError::InvalidInput(m)) => {
1837 assert!(m.contains(host), "expected the host in the error, got: {m}");
1838 }
1839 other => panic!("expected host {host:?} to be rejected, got {other:?}"),
1840 }
1841 }
1842 }
1843
1844 #[test]
1847 fn validate_accepts_bare_ipv6_proxy_host() {
1848 use crate::default_config;
1849 let mut config = default_config(Network::Mainnet);
1850 let mut proxy = test_proxy();
1851 proxy.host = "::1".to_string();
1852 config.proxy = Some(proxy);
1853
1854 assert!(config.validate().is_ok());
1855 }
1856
1857 #[tokio::test]
1860 async fn shared_context_rejects_proxy_with_balanced_connections() {
1861 use crate::{SdkContextConfig, SdkError, new_shared_sdk_context};
1862 let result = new_shared_sdk_context(SdkContextConfig {
1863 proxy: Some(test_proxy()),
1864 connections_per_operator: Some(4),
1865 ..SdkContextConfig::new(Network::Regtest)
1866 })
1867 .await;
1868
1869 match result.err() {
1870 Some(SdkError::InvalidInput(m)) => assert!(
1871 m.contains("connections_per_operator"),
1872 "expected balanced-connection rejection, got: {m}"
1873 ),
1874 other => panic!("expected proxy + balanced connections to fail, got {other:?}"),
1875 }
1876 }
1877
1878 #[tokio::test]
1881 async fn resolve_context_errors_on_proxy_mismatch() {
1882 use crate::{SdkContextConfig, default_config, new_shared_sdk_context};
1883 let mut config = default_config(Network::Regtest);
1884 config.proxy = Some(test_proxy());
1885
1886 let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
1887 .await
1888 .expect("regtest context");
1889
1890 let err = super::resolve_context(Some(ctx), &config)
1891 .await
1892 .err()
1893 .expect("expected mismatch error");
1894 assert!(
1895 err.to_string().contains("proxy does not match"),
1896 "unexpected error: {err}"
1897 );
1898 }
1899
1900 #[tokio::test]
1905 async fn build_rejects_cross_chain_config_on_regtest() {
1906 use crate::{CrossChainConfig, SdkError, default_config};
1907 let mut config = default_config(Network::Regtest);
1908 config.cross_chain_config = Some(CrossChainConfig::default());
1909
1910 let seed = test_seed();
1911 let result = SdkBuilder::new(config, seed).build().await;
1912 match result {
1913 Err(SdkError::InvalidInput(m)) => {
1914 assert!(
1915 m.contains("only available on Mainnet"),
1916 "expected mainnet-only rejection, got: {m}"
1917 );
1918 }
1919 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1920 Ok(_) => panic!("expected regtest with cross_chain_config to fail"),
1921 }
1922 }
1923
1924 #[tokio::test]
1928 async fn build_errors_on_network_mismatch() {
1929 use crate::{SdkContextConfig, new_shared_sdk_context};
1930 let mut config = default_config(Network::Mainnet);
1931 config.api_key = Some("partner-key".to_string());
1932 let ctx = new_shared_sdk_context(SdkContextConfig {
1933 api_key: Some("partner-key".to_string()),
1934 ..SdkContextConfig::new(Network::Regtest)
1935 })
1936 .await
1937 .expect("regtest context");
1938 let err = SdkBuilder::new(config, test_seed())
1939 .with_shared_context(ctx)
1940 .with_default_storage("/tmp/breez-sdk-test-network-mismatch".to_string())
1941 .build()
1942 .await
1943 .err()
1944 .expect("expected network-mismatch error");
1945 assert!(
1946 err.to_string().contains("network/api_key do not match"),
1947 "unexpected error: {err}"
1948 );
1949 }
1950
1951 #[tokio::test]
1955 #[allow(clippy::manual_assert)]
1956 async fn build_errors_on_api_key_mismatch() {
1957 use crate::{SdkContextConfig, new_shared_sdk_context};
1958 let mut config = default_config(Network::Mainnet);
1959 config.api_key = Some("intended-key".to_string());
1960 let ctx = new_shared_sdk_context(SdkContextConfig {
1961 api_key: Some("wrong-key".to_string()),
1962 ..SdkContextConfig::new(Network::Mainnet)
1963 })
1964 .await
1965 .expect("mainnet context");
1966 let err = SdkBuilder::new(config, test_seed())
1967 .with_shared_context(ctx)
1968 .with_default_storage("/tmp/breez-sdk-test-key-mismatch".to_string())
1969 .build()
1970 .await
1971 .err()
1972 .expect("expected api_key-mismatch error");
1973 assert!(
1974 err.to_string().contains("network/api_key do not match"),
1975 "unexpected error: {err}"
1976 );
1977 }
1978
1979 const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
1980
1981 fn test_seed() -> crate::Seed {
1982 crate::Seed::Mnemonic {
1983 mnemonic: TEST_MNEMONIC.to_string(),
1984 passphrase: None,
1985 }
1986 }
1987
1988 fn unique_storage_dir(name: &str) -> String {
1989 std::env::temp_dir()
1990 .join(format!("breez-sdk-test-{}-{}", name, uuid::Uuid::new_v4()))
1991 .to_string_lossy()
1992 .into_owned()
1993 }
1994
1995 async fn assert_sdk_graph_freed(
1999 event_emitter: std::sync::Weak<crate::EventEmitter>,
2000 spark_wallet: std::sync::Weak<spark_wallet::SparkWallet>,
2001 ) {
2002 for _ in 0..100 {
2003 if event_emitter.upgrade().is_none() && spark_wallet.upgrade().is_none() {
2004 return;
2005 }
2006 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2007 }
2008 panic!(
2009 "SDK object graph leaked after drop (EventEmitter alive: {}, SparkWallet alive: {})",
2010 event_emitter.upgrade().is_some(),
2011 spark_wallet.upgrade().is_some(),
2012 );
2013 }
2014
2015 #[tokio::test]
2019 async fn server_mode_sdk_graph_is_freed_on_drop() {
2020 use crate::default_server_config;
2021
2022 let config = default_server_config(Network::Regtest);
2023 let sdk = SdkBuilder::new(config, test_seed())
2024 .with_default_storage(unique_storage_dir("leak-server"))
2025 .build()
2026 .await
2027 .expect("server-mode build should succeed");
2028
2029 let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
2030 let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
2031
2032 sdk.disconnect().await.expect("disconnect should succeed");
2033 drop(sdk);
2034
2035 assert_sdk_graph_freed(event_emitter, spark_wallet).await;
2036 }
2037
2038 #[tokio::test]
2042 #[ignore = "requires network access to the regtest operators"]
2043 async fn server_mode_sdk_graph_is_freed_on_drop_after_sync() {
2044 use crate::{SyncWalletRequest, default_server_config};
2045
2046 let config = default_server_config(Network::Regtest);
2047 let sdk = SdkBuilder::new(config, test_seed())
2048 .with_default_storage(unique_storage_dir("leak-server-sync"))
2049 .build()
2050 .await
2051 .expect("server-mode build should succeed");
2052
2053 let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
2054 let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
2055
2056 sdk.sync_wallet(SyncWalletRequest {})
2057 .await
2058 .expect("sync_wallet should succeed");
2059
2060 sdk.disconnect().await.expect("disconnect should succeed");
2061 drop(sdk);
2062
2063 assert_sdk_graph_freed(event_emitter, spark_wallet).await;
2064 }
2065
2066 #[tokio::test]
2069 async fn client_mode_sdk_graph_is_freed_after_disconnect_and_drop() {
2070 let mut config = default_config(Network::Regtest);
2071 config.real_time_sync_server_url = None;
2074 config.private_enabled_default = false;
2075
2076 let sdk = SdkBuilder::new(config, test_seed())
2077 .with_default_storage(unique_storage_dir("leak-client"))
2078 .build()
2079 .await
2080 .expect("client-mode build should succeed");
2081
2082 let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
2083 let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
2084
2085 tokio::time::timeout(std::time::Duration::from_secs(30), sdk.disconnect())
2086 .await
2087 .expect("disconnect should not hang")
2088 .expect("disconnect should succeed");
2089 drop(sdk);
2090
2091 assert_sdk_graph_freed(event_emitter, spark_wallet).await;
2092 }
2093
2094 #[tokio::test]
2099 async fn client_mode_sdk_graph_is_freed_with_real_time_sync_enabled() {
2100 use crate::{StableBalanceConfig, StableBalanceToken};
2101
2102 let mut config = default_config(Network::Regtest);
2103 config.real_time_sync_server_url = Some("http://127.0.0.1:9".to_string());
2107 config.private_enabled_default = false;
2108 config.stable_balance_config = Some(StableBalanceConfig {
2111 tokens: vec![StableBalanceToken {
2112 label: "USDB".to_string(),
2113 token_identifier: "btkn1test".to_string(),
2114 }],
2115 default_active_label: None,
2116 threshold_sats: None,
2117 max_slippage_bps: None,
2118 });
2119
2120 let sdk = SdkBuilder::new(config, test_seed())
2121 .with_default_storage(unique_storage_dir("leak-client-rtsync"))
2122 .build()
2123 .await
2124 .expect("client-mode build with real-time sync should succeed");
2125
2126 let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
2127 let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
2128
2129 tokio::time::timeout(std::time::Duration::from_secs(30), sdk.disconnect())
2130 .await
2131 .expect("disconnect should not hang")
2132 .expect("disconnect should succeed");
2133 drop(sdk);
2134
2135 assert_sdk_graph_freed(event_emitter, spark_wallet).await;
2136 }
2137
2138 #[tokio::test]
2141 async fn disconnect_unregisters_event_listeners() {
2142 use crate::{SdkEvent, default_server_config, events::EventListener};
2143
2144 struct NoopListener;
2145
2146 #[macros::async_trait]
2147 impl EventListener for NoopListener {
2148 async fn on_event(&self, _event: SdkEvent) {}
2149 }
2150
2151 let storage_dir = std::env::temp_dir()
2152 .join(format!(
2153 "breez-sdk-test-listener-clear-{}",
2154 uuid::Uuid::new_v4()
2155 ))
2156 .to_string_lossy()
2157 .into_owned();
2158 let sdk = SdkBuilder::new(default_server_config(Network::Regtest), test_seed())
2159 .with_default_storage(storage_dir)
2160 .build()
2161 .await
2162 .expect("server-mode build should succeed");
2163
2164 let id = sdk.add_event_listener(Box::new(NoopListener)).await;
2165
2166 sdk.disconnect().await.expect("disconnect should succeed");
2167
2168 assert!(
2169 !sdk.remove_event_listener(&id).await,
2170 "listener should already be unregistered by disconnect"
2171 );
2172 }
2173
2174 fn test_spark_signer() -> std::sync::Arc<dyn spark_wallet::SparkSigner> {
2175 use std::sync::Arc;
2176
2177 let seed = test_seed();
2178 let seed_bytes = seed.to_bytes().unwrap();
2179 let master =
2180 spark_wallet::account_master_key(&seed_bytes, Network::Regtest.into(), None).unwrap();
2181 Arc::new(spark_wallet::SparkSignerAdapter::new(Arc::new(
2182 spark_wallet::DefaultSigner::from_master(master),
2183 )))
2184 }
2185
2186 #[test]
2189 fn validate_server_mode_ok_when_background_enabled() {
2190 use crate::{StableBalanceConfig, StableBalanceToken, default_server_config};
2191 let mut config = default_server_config(Network::Regtest);
2192 config.stable_balance_config = Some(StableBalanceConfig {
2193 tokens: vec![StableBalanceToken {
2194 label: "USDB".to_string(),
2195 token_identifier: "btkn1test".to_string(),
2196 }],
2197 default_active_label: None,
2198 threshold_sats: None,
2199 max_slippage_bps: None,
2200 });
2201 config.real_time_sync_server_url = Some("https://example.com".to_string());
2202 config.leaf_optimization_config.auto_enabled = true;
2203 config.token_optimization_config.auto_enabled = true;
2204 assert!(super::validate_server_mode(&config, true).is_ok());
2206 }
2207
2208 #[test]
2209 fn validate_server_mode_ok_in_server_mode_without_background_features() {
2210 use crate::default_server_config;
2211 let config = default_server_config(Network::Regtest);
2212 assert!(super::validate_server_mode(&config, false).is_ok());
2213 }
2214
2215 #[test]
2216 fn validate_server_mode_rejects_stable_balance_directly() {
2217 use crate::{StableBalanceConfig, StableBalanceToken, default_server_config};
2218 let mut config = default_server_config(Network::Regtest);
2219 config.stable_balance_config = Some(StableBalanceConfig {
2220 tokens: vec![StableBalanceToken {
2221 label: "USDB".to_string(),
2222 token_identifier: "btkn1test".to_string(),
2223 }],
2224 default_active_label: None,
2225 threshold_sats: None,
2226 max_slippage_bps: None,
2227 });
2228 match super::validate_server_mode(&config, false) {
2229 Err(SdkError::InvalidInput(m)) => assert!(m.contains("stable_balance_config")),
2230 other => panic!("expected InvalidInput, got {other:?}"),
2231 }
2232 }
2233
2234 #[test]
2235 fn validate_server_mode_rejects_real_time_sync_directly() {
2236 use crate::default_server_config;
2237 let mut config = default_server_config(Network::Regtest);
2238 config.real_time_sync_server_url = Some("https://example.com".to_string());
2239 match super::validate_server_mode(&config, false) {
2240 Err(SdkError::InvalidInput(m)) => assert!(m.contains("real_time_sync_server_url")),
2241 other => panic!("expected InvalidInput, got {other:?}"),
2242 }
2243 }
2244
2245 #[test]
2246 fn validate_server_mode_rejects_leaf_auto_optimize_directly() {
2247 use crate::default_server_config;
2248 let mut config = default_server_config(Network::Regtest);
2249 config.leaf_optimization_config.auto_enabled = true;
2250 match super::validate_server_mode(&config, false) {
2251 Err(SdkError::InvalidInput(m)) => {
2252 assert!(m.contains("leaf_optimization_config.auto_enabled"));
2253 }
2254 other => panic!("expected InvalidInput, got {other:?}"),
2255 }
2256 }
2257
2258 #[test]
2259 fn validate_server_mode_rejects_token_auto_optimize_directly() {
2260 use crate::default_server_config;
2261 let mut config = default_server_config(Network::Regtest);
2262 config.token_optimization_config.auto_enabled = true;
2263 match super::validate_server_mode(&config, false) {
2264 Err(SdkError::InvalidInput(m)) => {
2265 assert!(m.contains("token_optimization_config.auto_enabled"));
2266 }
2267 other => panic!("expected InvalidInput, got {other:?}"),
2268 }
2269 }
2270
2271 #[test]
2272 fn validate_server_mode_rejects_cross_chain_directly() {
2273 use crate::{CrossChainConfig, default_server_config};
2274 let mut config = default_server_config(Network::Regtest);
2275 config.cross_chain_config = Some(CrossChainConfig::default());
2276 match super::validate_server_mode(&config, false) {
2277 Err(SdkError::InvalidInput(m)) => assert!(m.contains("Cross-chain config")),
2278 other => panic!("expected InvalidInput, got {other:?}"),
2279 }
2280 }
2281
2282 #[test]
2285 fn finalize_spark_wallet_config_disabled_background_forces_leaf_auto_off() {
2286 let mut config = default_config(Network::Regtest);
2287 config.leaf_optimization_config.auto_enabled = true;
2288 let result = super::finalize_spark_wallet_config(&config, "test-agent", false).unwrap();
2289 assert!(!result.leaf_auto_optimize_enabled);
2290 }
2291
2292 #[test]
2293 fn finalize_spark_wallet_config_disabled_background_clears_token_auto_interval() {
2294 let mut config = default_config(Network::Regtest);
2295 config.token_optimization_config.auto_enabled = true;
2296 let result = super::finalize_spark_wallet_config(&config, "test-agent", false).unwrap();
2297 assert!(
2298 result
2299 .token_outputs_optimization_options
2300 .auto_optimize_interval
2301 .is_none()
2302 );
2303 }
2304
2305 #[test]
2306 fn finalize_spark_wallet_config_enabled_background_respects_leaf_auto_optimize() {
2307 let mut config = default_config(Network::Regtest);
2308 config.leaf_optimization_config.auto_enabled = true;
2309 let result = super::finalize_spark_wallet_config(&config, "test-agent", true).unwrap();
2310 assert!(result.leaf_auto_optimize_enabled);
2311 }
2312
2313 #[test]
2314 fn finalize_spark_wallet_config_applies_user_agent() {
2315 let config = default_config(Network::Regtest);
2316 let result = super::finalize_spark_wallet_config(&config, "my-app/1.0", true).unwrap();
2317 assert_eq!(
2318 result.service_provider_config.user_agent.as_deref(),
2319 Some("my-app/1.0")
2320 );
2321 }
2322
2323 #[tokio::test]
2326 async fn resolve_context_errors_on_network_mismatch() {
2327 use crate::{SdkContextConfig, new_shared_sdk_context};
2328 let config = default_config(Network::Mainnet);
2329 let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
2330 .await
2331 .expect("regtest context");
2332 let err = super::resolve_context(Some(ctx), &config)
2333 .await
2334 .err()
2335 .expect("expected mismatch error");
2336 assert!(
2337 err.to_string().contains("network/api_key do not match"),
2338 "unexpected error: {err}"
2339 );
2340 }
2341
2342 #[tokio::test]
2343 async fn resolve_context_errors_on_api_key_mismatch() {
2344 use crate::{SdkContextConfig, new_shared_sdk_context};
2345 let mut config = default_config(Network::Mainnet);
2346 config.api_key = Some("intended-key".to_string());
2347 let ctx = new_shared_sdk_context(SdkContextConfig {
2348 api_key: Some("wrong-key".to_string()),
2349 ..SdkContextConfig::new(Network::Mainnet)
2350 })
2351 .await
2352 .expect("mainnet context");
2353 let err = super::resolve_context(Some(ctx), &config)
2354 .await
2355 .err()
2356 .expect("expected mismatch error");
2357 assert!(
2358 err.to_string().contains("network/api_key do not match"),
2359 "unexpected error: {err}"
2360 );
2361 }
2362
2363 #[tokio::test]
2366 async fn resolve_storage_errors_when_neither_supplied() {
2367 use crate::{SdkContextConfig, new_shared_sdk_context};
2368 let config = default_config(Network::Regtest);
2369 let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
2370 .await
2371 .expect("regtest context");
2372 let signer = test_spark_signer();
2373 let err = super::resolve_storage(None, &ctx, &signer, &config)
2374 .await
2375 .err()
2376 .expect("expected no-storage error");
2377 assert!(
2378 err.to_string().contains("No storage configured"),
2379 "unexpected error: {err}"
2380 );
2381 }
2382
2383 #[tokio::test]
2384 async fn resolve_storage_errors_when_supplied_on_both_builder_and_context() {
2385 use crate::{SdkContextConfig, default_storage, new_shared_sdk_context};
2386 let config = default_config(Network::Regtest);
2387 let ctx = new_shared_sdk_context(SdkContextConfig {
2388 storage: Some(default_storage(
2389 "/tmp/breez-sdk-test-resolve-storage-ctx".to_string(),
2390 )),
2391 ..SdkContextConfig::new(Network::Regtest)
2392 })
2393 .await
2394 .expect("regtest context");
2395 let signer = test_spark_signer();
2396 let builder_storage =
2397 default_storage("/tmp/breez-sdk-test-resolve-storage-builder".to_string());
2398 let err = super::resolve_storage(Some(builder_storage), &ctx, &signer, &config)
2399 .await
2400 .err()
2401 .expect("expected duplicate-storage error");
2402 assert!(
2403 err.to_string()
2404 .contains("storage is configured on both the SdkBuilder and the shared SdkContext"),
2405 "unexpected error: {err}"
2406 );
2407 }
2408}