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};
15
16use flashnet::{FlashnetConfig, IntegratorConfig};
17
18use crate::{
19 Credentials, EventEmitter, FiatService, FiatServiceWrapper, Network, Seed,
20 chain::{
21 BitcoinChainService,
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 #[allow(clippy::too_many_lines)]
466 pub async fn build(self) -> Result<BreezSdk, SdkError> {
467 self.config.validate()?;
468 let runtime = runtime_from_config(&self.config);
469 let background_services_enabled = runtime.starts_background_services();
470 validate_server_mode(&self.config, background_services_enabled)?;
471
472 let signers = build_signers(&self.config, self.signer_source)?;
473 validate_signer_capabilities(&self.config, signers.ecies.is_some())?;
474
475 let context = resolve_context(self.context, &self.config).await?;
476 let stores = resolve_storage(self.storage, &context, &signers.spark, &self.config).await?;
477 let chain_service = resolve_chain_service(
478 self.chain_service,
479 self.rest_chain_service_config,
480 &context,
481 self.config.network,
482 );
483
484 let user_agent = crate::default_user_agent();
485 info!("Building sdk with user agent: {}", user_agent);
486
487 let fiat_service: Arc<dyn breez_sdk_common::fiat::FiatService> = match self.fiat_service {
488 Some(service) => Arc::new(FiatServiceWrapper::new(service)),
489 None => context.breez_server.clone(),
490 };
491 let lnurl_client: Arc<dyn platform_utils::HttpClient> = self
492 .lnurl_client
493 .unwrap_or_else(|| context.http_client.clone());
494
495 let spark_wallet_config =
496 finalize_spark_wallet_config(&self.config, &user_agent, background_services_enabled)?;
497 let shutdown_sender = watch::channel::<()>(()).0;
498 let override_store = self.session_store.map(|s| {
502 Arc::new(crate::session_store::SessionStoreAdapter::new(s)) as Arc<dyn SessionStore>
503 });
504 let session_store =
505 wrap_session_store(override_store.or_else(|| stores.session_store.clone()));
506
507 let spark_wallet = build_spark_wallet(BuildSparkWalletParams {
508 config: spark_wallet_config,
509 spark_signer: Arc::clone(&signers.spark),
510 session_store,
511 shutdown_receiver: background_services_enabled.then(|| shutdown_sender.subscribe()),
512 tree_store: stores.tree_store.clone(),
513 token_output_store: stores.token_output_store.clone(),
514 payment_observer: self.payment_observer,
515 context: Arc::clone(&context),
516 })
517 .await?;
518
519 let lnurl_server_client = resolve_lnurl_server_client(
520 self.lnurl_server_client,
521 &self.config,
522 &context,
523 &spark_wallet,
524 );
525
526 let real_time_sync_active =
527 background_services_enabled && self.config.real_time_sync_server_url.is_some();
528 let event_emitter = Arc::new(EventEmitter::new(real_time_sync_active));
529
530 let storage = maybe_wrap_storage_with_real_time_sync(
531 Arc::clone(&stores.storage),
532 &self.config,
533 background_services_enabled,
534 user_agent,
535 signers.rtsync,
536 shutdown_sender.subscribe(),
537 Arc::clone(&event_emitter),
538 lnurl_server_client.clone(),
539 )
540 .await?;
541
542 let buy_bitcoin_provider = Arc::new(MoonpayProvider::new(context.breez_server.clone()));
543 let token_converter =
544 build_token_converter(&self.config, &storage, &spark_wallet, &context);
545
546 let sync_coordinator = SyncCoordinator::new();
547
548 let lightning_sender = Arc::new(crate::sdk::LightningSender::new(
552 Arc::clone(&spark_wallet),
553 Arc::clone(&storage),
554 Arc::clone(&event_emitter),
555 shutdown_sender.clone(),
556 ));
557
558 let cross_chain_context = build_cross_chain_context(
559 &self.config,
560 &context.breez_server,
561 &spark_wallet,
562 &storage,
563 signers.ecies.clone(),
564 &lightning_sender,
565 Arc::clone(&fiat_service),
566 shutdown_sender.subscribe(),
567 )
568 .await;
569
570 let stable_balance = build_stable_balance(
571 &self.config,
572 &token_converter,
573 &spark_wallet,
574 &storage,
575 &event_emitter,
576 )
577 .await;
578
579 event_emitter
582 .add_middleware(Box::new(TokenConversionMiddleware))
583 .await;
584
585 let sdk = BreezSdk::init_and_start(BreezSdkParams {
586 config: self.config,
587 storage,
588 chain_service,
589 fiat_service,
590 lnurl_client,
591 lnurl_server_client,
592 lnurl_auth_signer: signers.lnurl_auth,
593 shutdown_sender,
594 runtime,
595 spark_wallet,
596 event_emitter,
597 buy_bitcoin_provider,
598 token_converter,
599 stable_balance,
600 sync_coordinator,
601 cross_chain_context,
602 lightning_sender,
603 })
604 .await?;
605 debug!("Initialized and started breez sdk.");
606
607 Ok(sdk)
608 }
609}
610
611fn validate_server_mode(
613 config: &Config,
614 background_services_enabled: bool,
615) -> Result<(), SdkError> {
616 if background_services_enabled {
617 return Ok(());
618 }
619 if config.stable_balance_config.is_some() {
620 return Err(SdkError::InvalidInput(
621 "stable_balance_config is not supported when background_tasks_enabled is false"
622 .to_string(),
623 ));
624 }
625 if config.real_time_sync_server_url.is_some() {
626 return Err(SdkError::InvalidInput(
627 "real_time_sync_server_url must be None when background_tasks_enabled is false"
628 .to_string(),
629 ));
630 }
631 if config.leaf_optimization_config.auto_enabled {
632 return Err(SdkError::InvalidInput(
633 "leaf_optimization_config.auto_enabled must be false when background_tasks_enabled is false"
634 .to_string(),
635 ));
636 }
637 if config.token_optimization_config.auto_enabled {
638 return Err(SdkError::InvalidInput(
639 "token_optimization_config.auto_enabled must be false when background_tasks_enabled is false"
640 .to_string(),
641 ));
642 }
643 if config.cross_chain_config.is_some() {
644 return Err(SdkError::InvalidInput(
645 "Cross-chain config must be unset when background tasks are disabled".to_string(),
646 ));
647 }
648 Ok(())
649}
650
651fn validate_signer_capabilities(config: &Config, has_ecies: bool) -> Result<(), SdkError> {
654 if has_ecies {
655 return Ok(());
656 }
657 if config.real_time_sync_server_url.is_some() {
658 return Err(SdkError::InvalidInput(
659 "Real-time sync requires a signer that supports ECIES".to_string(),
660 ));
661 }
662 if config.cross_chain_config.is_some() {
663 return Err(SdkError::InvalidInput(
664 "Cross-chain payments require a signer that supports ECIES".to_string(),
665 ));
666 }
667 Ok(())
668}
669
670fn build_signers(config: &Config, signer_source: SignerSource) -> Result<Signers, SdkError> {
675 use crate::signer::{
676 BreezSigner, EciesSigner, ExternalBreezSignerAdapter, ExternalSigningSignerAdapter,
677 ExternalSparkSignerAdapter, HmacSigner,
678 };
679
680 #[allow(clippy::type_complexity)]
684 let (base, ecies, hmac, spark): (
685 Arc<dyn BreezSigner>,
686 Option<Arc<dyn EciesSigner>>,
687 Option<Arc<dyn HmacSigner>>,
688 Arc<dyn SparkSigner>,
689 ) = match signer_source {
690 SignerSource::Seed {
691 seed,
692 account_number,
693 } => {
694 let seed_bytes = seed.to_bytes()?;
695 let network = config.network.into();
696 let signer = Arc::new(BreezSignerImpl::new(
698 spark_wallet::identity_master_key(&seed_bytes, network, account_number)
699 .map_err(|e| SdkError::Generic(e.to_string()))?,
700 ));
701 let spark: Arc<dyn SparkSigner> = Arc::new(spark_wallet::SparkSignerAdapter::new(
702 Arc::new(spark_wallet::DefaultSigner::from_master(
703 spark_wallet::account_master_key(&seed_bytes, network, account_number)
704 .map_err(|e| SdkError::Generic(e.to_string()))?,
705 )),
706 ));
707 (signer.clone(), Some(signer.clone()), Some(signer), spark)
708 }
709 SignerSource::External { breez, spark } => {
710 let spark: Arc<dyn SparkSigner> = Arc::new(ExternalSparkSignerAdapter::new(spark));
711 match breez {
712 ExternalBreez::Full(breez) => {
713 let signer = Arc::new(ExternalBreezSignerAdapter::new(breez));
715 (signer.clone(), Some(signer.clone()), Some(signer), spark)
716 }
717 ExternalBreez::SigningOnly(breez) => {
718 let base: Arc<dyn BreezSigner> =
719 Arc::new(ExternalSigningSignerAdapter::new(breez));
720 (base, None, None, spark)
721 }
722 }
723 }
724 };
725
726 let rtsync = ecies
727 .as_ref()
728 .map(|ecies| {
729 RTSyncSigner::new(base.clone(), ecies.clone(), config.network)
730 .map(Arc::new)
731 .map_err(|e| SdkError::Generic(e.to_string()))
732 })
733 .transpose()?;
734 let lnurl_auth = hmac
735 .as_ref()
736 .map(|hmac| Arc::new(LnurlAuthSignerAdapter::new(base.clone(), hmac.clone())));
737
738 Ok(Signers {
739 ecies,
740 spark,
741 rtsync,
742 lnurl_auth,
743 })
744}
745
746async fn resolve_context(
749 supplied: Option<Arc<SdkContext>>,
750 config: &Config,
751) -> Result<Arc<SdkContext>, SdkError> {
752 let context = match supplied {
753 Some(ctx) => ctx,
754 None => {
755 new_shared_sdk_context(SdkContextConfig {
756 api_key: config.api_key.clone(),
757 ..SdkContextConfig::new(config.network)
758 })
759 .await?
760 }
761 };
762 if context.network != config.network || context.api_key != config.api_key {
763 return Err(SdkError::Generic(
764 "SdkContext network/api_key do not match SdkConfig".to_string(),
765 ));
766 }
767 Ok(context)
768}
769
770async fn resolve_storage(
773 supplied: Option<Arc<dyn StorageBackend>>,
774 context: &SdkContext,
775 spark_signer: &Arc<dyn SparkSigner>,
776 config: &Config,
777) -> Result<Arc<ResolvedStores>, SdkError> {
778 let storage_backend: Arc<dyn StorageBackend> = match (supplied, context.storage_backend.clone())
779 {
780 (Some(storage), None) => storage,
781 (None, Some(backend)) => backend,
782 (Some(_), Some(_)) => {
783 return Err(SdkError::Generic(
784 "storage is configured on both the SdkBuilder and the shared SdkContext"
785 .to_string(),
786 ));
787 }
788 (None, None) => return Err(SdkError::Generic("No storage configured".to_string())),
789 };
790 let identity_public_key = spark_signer
791 .get_identity_public_key()
792 .await
793 .map_err(|e| SdkError::Generic(e.to_string()))?;
794 storage_backend
795 .create_stores(config.network, identity_public_key.serialize().to_vec())
796 .await
797}
798
799fn resolve_chain_service(
802 supplied: Option<Arc<dyn BitcoinChainService>>,
803 rest_config: Option<RestChainServiceConfig>,
804 context: &SdkContext,
805 network: Network,
806) -> Arc<dyn BitcoinChainService> {
807 if let Some(service) = supplied {
808 return service;
809 }
810 if let Some(cfg) = rest_config {
811 return Arc::new(RestClientChainService::new(
812 cfg.url,
813 network,
814 5,
815 context.http_client.clone(),
816 cfg.credentials
817 .map(|c| BasicAuth::new(c.username, c.password)),
818 cfg.api_type,
819 ));
820 }
821 let inner_client: Arc<dyn platform_utils::HttpClient> = context.http_client.clone();
822 match network {
823 Network::Mainnet => Arc::new(RestClientChainService::new(
824 "https://blockstream.info/api".to_string(),
825 network,
826 5,
827 inner_client,
828 None,
829 ChainApiType::Esplora,
830 )),
831 Network::Regtest => Arc::new(RestClientChainService::new(
832 "https://regtest-mempool.us-west-2.sparkinfra.net/api".to_string(),
833 network,
834 5,
835 inner_client,
836 match (
837 std::env::var("CHAIN_SERVICE_USERNAME"),
838 std::env::var("CHAIN_SERVICE_PASSWORD"),
839 ) {
840 (Ok(username), Ok(password)) => Some(BasicAuth::new(username, password)),
841 _ => Some(BasicAuth::new(
842 "spark-sdk".to_string(),
843 "mCMk1JqlBNtetUNy".to_string(),
844 )),
845 },
846 ChainApiType::MempoolSpace,
847 )),
848 }
849}
850
851fn finalize_spark_wallet_config(
855 config: &Config,
856 user_agent: &str,
857 background_services_enabled: bool,
858) -> Result<SparkWalletConfig, SdkError> {
859 let mut spark_wallet_config = if let Some(env_config) = &config.spark_config {
860 SdkBuilder::build_spark_wallet_config(config.network.into(), env_config)?
861 } else {
862 SparkWalletConfig::default_config(config.network.into())
863 };
864 spark_wallet_config.operator_pool = spark_wallet_config
865 .operator_pool
866 .with_user_agent(Some(user_agent.to_string()));
867 spark_wallet_config.service_provider_config.user_agent = Some(user_agent.to_string());
868 spark_wallet_config.leaf_auto_optimize_enabled =
869 background_services_enabled && config.leaf_optimization_config.auto_enabled;
870 spark_wallet_config.leaf_optimization_options.multiplicity =
871 config.leaf_optimization_config.multiplicity;
872
873 let token_opt = &config.token_optimization_config;
874 let token_options = &mut spark_wallet_config.token_outputs_optimization_options;
875 token_options.target_output_count = token_opt.target_output_count;
876 token_options.min_outputs_threshold = token_opt.min_outputs_threshold;
877 if !token_opt.auto_enabled || !background_services_enabled {
879 token_options.auto_optimize_interval = None;
880 }
881 spark_wallet_config.max_concurrent_claims = config.max_concurrent_claims;
882 Ok(spark_wallet_config)
883}
884
885fn wrap_session_store(session_store: Option<Arc<dyn SessionStore>>) -> Arc<dyn SessionStore> {
893 let inner = session_store.unwrap_or_else(|| Arc::new(InMemorySessionStore::default()));
894 Arc::new(crate::session_store::CachingSessionStore::new(inner))
895}
896
897async fn build_spark_wallet(params: BuildSparkWalletParams) -> Result<Arc<SparkWallet>, SdkError> {
899 let mut wallet_builder = spark_wallet::WalletBuilder::new(params.config, params.spark_signer)
900 .with_session_store(params.session_store);
901 if let Some(receiver) = params.shutdown_receiver {
902 wallet_builder = wallet_builder.with_cancellation_token(receiver);
903 }
904 if let Some(provider) = ¶ms.context.jwt_header_provider {
905 wallet_builder = wallet_builder
906 .with_so_extra_header_provider(
907 Arc::clone(provider) as Arc<dyn spark_wallet::HeaderProvider>
908 )
909 .with_ssp_extra_header_provider(
910 Arc::clone(provider) as Arc<dyn spark_wallet::HeaderProvider>
911 );
912 }
913 if let Some(observer) = params.payment_observer {
914 let observer: Arc<dyn spark_wallet::TransferObserver> =
915 Arc::new(SparkTransferObserver::new(observer));
916 wallet_builder = wallet_builder.with_transfer_observer(observer);
917 }
918 if let Some(tree_store) = params.tree_store {
919 wallet_builder = wallet_builder.with_tree_store(tree_store);
920 }
921 if let Some(token_output_store) = params.token_output_store {
922 wallet_builder = wallet_builder.with_token_output_store(token_output_store);
923 }
924 wallet_builder = wallet_builder.with_ssp_http_client(params.context.http_client.clone());
925 wallet_builder =
926 wallet_builder.with_connection_manager(params.context.connection_manager.clone());
927 Ok(Arc::new(wallet_builder.build().await?))
928}
929
930fn resolve_lnurl_server_client(
933 explicit: Option<Arc<dyn LnurlServerClient>>,
934 config: &Config,
935 context: &SdkContext,
936 spark_wallet: &Arc<SparkWallet>,
937) -> Option<Arc<dyn LnurlServerClient>> {
938 if let Some(client) = explicit {
939 return Some(client);
940 }
941 config.lnurl_domain.as_ref().map(|domain| {
942 Arc::new(DefaultLnurlServerClient::new(
943 context.http_client.clone(),
944 domain.clone(),
945 config.api_key.clone(),
946 Arc::clone(spark_wallet),
947 )) as Arc<dyn LnurlServerClient>
948 })
949}
950
951#[allow(clippy::too_many_arguments)]
954async fn maybe_wrap_storage_with_real_time_sync(
955 storage: Arc<dyn crate::persist::Storage>,
956 config: &Config,
957 background_services_enabled: bool,
958 user_agent: String,
959 rtsync_signer: Option<Arc<RTSyncSigner>>,
960 shutdown_receiver: watch::Receiver<()>,
961 event_emitter: Arc<EventEmitter>,
962 lnurl_server_client: Option<Arc<dyn LnurlServerClient>>,
963) -> Result<Arc<dyn crate::persist::Storage>, SdkError> {
964 match (&config.real_time_sync_server_url, rtsync_signer) {
968 (Some(server_url), Some(signer)) if background_services_enabled => {
969 init_and_start_real_time_sync(RealTimeSyncParams {
970 server_url: server_url.clone(),
971 api_key: config.api_key.clone(),
972 user_agent,
973 signer,
974 storage,
975 shutdown_receiver,
976 event_emitter,
977 lnurl_server_client,
978 })
979 .await
980 }
981 _ => Ok(storage),
982 }
983}
984
985fn build_token_converter(
987 config: &Config,
988 storage: &Arc<dyn crate::persist::Storage>,
989 spark_wallet: &Arc<SparkWallet>,
990 context: &SdkContext,
991) -> Arc<dyn TokenConverter> {
992 let flashnet_config = FlashnetConfig::default_config(
993 config.network.into(),
994 DEFAULT_INTEGRATOR_PUBKEY
995 .parse()
996 .ok()
997 .map(|pubkey| IntegratorConfig {
998 pubkey,
999 fee_bps: DEFAULT_INTEGRATOR_FEE_BPS,
1000 }),
1001 );
1002 Arc::new(FlashnetTokenConverter::new(
1003 flashnet_config,
1004 Arc::clone(storage),
1005 Arc::clone(spark_wallet),
1006 config.network,
1007 context.http_client.clone(),
1008 ))
1009}
1010
1011async fn build_stable_balance(
1014 config: &Config,
1015 token_converter: &Arc<dyn TokenConverter>,
1016 spark_wallet: &Arc<SparkWallet>,
1017 storage: &Arc<dyn crate::persist::Storage>,
1018 event_emitter: &Arc<EventEmitter>,
1019) -> Option<Arc<StableBalance>> {
1020 let stable_config = config.stable_balance_config.as_ref()?;
1021 Some(Arc::new(
1022 StableBalance::new(
1023 stable_config.clone(),
1024 Arc::clone(token_converter),
1025 Arc::clone(spark_wallet),
1026 Arc::clone(storage),
1027 Arc::clone(event_emitter),
1028 )
1029 .await,
1030 ))
1031}
1032
1033#[allow(clippy::too_many_arguments)]
1036async fn build_cross_chain_context(
1037 config: &Config,
1038 breez_server: &Arc<BreezServer>,
1039 spark_wallet: &Arc<SparkWallet>,
1040 storage: &Arc<dyn crate::persist::Storage>,
1041 ecies: Option<Arc<dyn crate::signer::EciesSigner>>,
1042 lightning_sender: &Arc<crate::sdk::LightningSender>,
1043 fiat_service: Arc<dyn breez_sdk_common::fiat::FiatService>,
1044 shutdown_receiver: watch::Receiver<()>,
1045) -> crate::cross_chain::CrossChainContext {
1046 let cached_fiat: Arc<dyn breez_sdk_common::fiat::FiatService> =
1048 Arc::new(crate::cross_chain::CachedFiatService::new(
1049 fiat_service,
1050 crate::cross_chain::DEFAULT_FIAT_CACHE_TTL,
1051 ));
1052 let mut providers = crate::cross_chain::CrossChainContext::new(Arc::clone(&cached_fiat));
1053 if config.cross_chain_config.is_none() {
1054 return providers;
1055 }
1056
1057 if matches!(config.network, Network::Mainnet) {
1062 let config_resolver = Arc::new(
1063 crate::cross_chain::BreezServerOrchestraConfigResolver::new(Arc::clone(breez_server)),
1064 );
1065 providers.insert(
1066 crate::cross_chain::CrossChainProvider::Orchestra,
1067 Arc::new(crate::cross_chain::OrchestraService::new(
1068 config_resolver,
1069 Arc::clone(spark_wallet),
1070 Arc::clone(storage),
1071 Arc::clone(&cached_fiat),
1072 shutdown_receiver,
1073 )),
1074 );
1075 }
1076
1077 match crate::cross_chain::BoltzService::build(
1078 config.network,
1079 Arc::clone(spark_wallet),
1080 Arc::clone(storage),
1081 ecies,
1082 cached_fiat,
1083 Arc::clone(lightning_sender),
1084 )
1085 .await
1086 {
1087 Ok(Some(service)) => {
1088 providers.insert(crate::cross_chain::CrossChainProvider::Boltz, service);
1089 }
1090 Ok(None) => {
1091 info!(
1092 "Boltz provider skipped: no default configuration for network {:?}",
1093 config.network
1094 );
1095 }
1096 Err(e) => {
1097 tracing::error!("Failed to initialize Boltz provider: {e:?}");
1098 }
1099 }
1100
1101 providers
1102}
1103
1104#[cfg(test)]
1105#[cfg(feature = "sqlite")]
1106mod tests {
1107 use super::SdkBuilder;
1108 use crate::{Network, SdkError, default_config};
1109
1110 #[test]
1111 fn default_config_spark_config_builds_valid_wallet_config() {
1112 for network in [Network::Mainnet, Network::Regtest] {
1113 let config = default_config(network);
1114 let spark_config = config
1115 .spark_config
1116 .as_ref()
1117 .expect("default_config must populate spark_config");
1118 SdkBuilder::build_spark_wallet_config(network.into(), spark_config).unwrap_or_else(
1119 |e| {
1120 panic!(
1121 "default_config({network:?}).spark_config failed to build SparkWalletConfig: {e}"
1122 )
1123 },
1124 );
1125 }
1126 }
1127
1128 #[test]
1129 fn validate_signer_capabilities_gates_encryption_features() {
1130 let mut config = default_config(Network::Regtest);
1132 config.real_time_sync_server_url = Some("https://example.com".to_string());
1133 assert!(super::validate_signer_capabilities(&config, true).is_ok());
1134
1135 let mut config = default_config(Network::Regtest);
1137 config.real_time_sync_server_url = Some("https://example.com".to_string());
1138 config.cross_chain_config = None;
1139 match super::validate_signer_capabilities(&config, false) {
1140 Err(SdkError::InvalidInput(m)) => {
1141 assert!(m.contains("Real-time sync"), "got: {m}");
1142 }
1143 other => panic!("expected InvalidInput, got {other:?}"),
1144 }
1145
1146 let mut config = default_config(Network::Regtest);
1149 config.real_time_sync_server_url = None;
1150 config.cross_chain_config = None;
1151 assert!(super::validate_signer_capabilities(&config, false).is_ok());
1152 }
1153
1154 #[test]
1158 fn build_signers_signing_only_omits_ecies_dependent_signers() {
1159 use crate::error::SignerError;
1160 use crate::signer::external_types::{
1161 EcdsaSignatureBytes, MessageBytes, PublicKeyBytes, RecoverableEcdsaSignatureBytes,
1162 SchnorrSignatureBytes,
1163 };
1164 use crate::signer::{
1165 DefaultExternalSparkSigner, ExternalSigningSigner, ExternalSparkSigner,
1166 };
1167 use std::sync::Arc;
1168
1169 struct StubSigningSigner;
1171 #[macros::async_trait]
1172 impl ExternalSigningSigner for StubSigningSigner {
1173 async fn derive_public_key(&self, _p: String) -> Result<PublicKeyBytes, SignerError> {
1174 unreachable!("build_signers must not call the signer")
1175 }
1176 async fn sign_ecdsa(
1177 &self,
1178 _m: MessageBytes,
1179 _p: String,
1180 ) -> Result<EcdsaSignatureBytes, SignerError> {
1181 unreachable!("build_signers must not call the signer")
1182 }
1183 async fn sign_ecdsa_recoverable(
1184 &self,
1185 _m: MessageBytes,
1186 _p: String,
1187 ) -> Result<RecoverableEcdsaSignatureBytes, SignerError> {
1188 unreachable!("build_signers must not call the signer")
1189 }
1190 async fn sign_hash_schnorr(
1191 &self,
1192 _h: Vec<u8>,
1193 _p: String,
1194 ) -> Result<SchnorrSignatureBytes, SignerError> {
1195 unreachable!("build_signers must not call the signer")
1196 }
1197 }
1198
1199 let config = default_config(Network::Regtest);
1200
1201 let full = super::build_signers(
1203 &config,
1204 super::SignerSource::Seed {
1205 seed: test_seed(),
1206 account_number: None,
1207 },
1208 )
1209 .unwrap();
1210 assert!(full.ecies.is_some());
1211 assert!(full.rtsync.is_some());
1212 assert!(full.lnurl_auth.is_some());
1213
1214 let breez: Arc<dyn ExternalSigningSigner> = Arc::new(StubSigningSigner);
1216 let spark: Arc<dyn ExternalSparkSigner> = Arc::new(
1217 DefaultExternalSparkSigner::new(
1218 TEST_MNEMONIC.to_string(),
1219 None,
1220 Network::Regtest,
1221 None,
1222 )
1223 .unwrap(),
1224 );
1225 let signing_only = super::build_signers(
1226 &config,
1227 super::SignerSource::External {
1228 breez: super::ExternalBreez::SigningOnly(breez),
1229 spark,
1230 },
1231 )
1232 .unwrap();
1233 assert!(signing_only.ecies.is_none());
1234 assert!(signing_only.rtsync.is_none());
1235 assert!(signing_only.lnurl_auth.is_none());
1236 }
1237
1238 #[tokio::test]
1241 async fn signing_only_signer_build_disables_lnurl_auth() {
1242 use crate::error::SignerError;
1243 use crate::signer::external_types::{
1244 EcdsaSignatureBytes, MessageBytes, PublicKeyBytes, RecoverableEcdsaSignatureBytes,
1245 SchnorrSignatureBytes,
1246 };
1247 use crate::signer::{
1248 DefaultExternalSigner, DefaultExternalSparkSigner, ExternalBreezSigner,
1249 ExternalSigningSigner, ExternalSparkSigner,
1250 };
1251 use std::sync::Arc;
1252
1253 struct SigningOnly(DefaultExternalSigner);
1256 #[macros::async_trait]
1257 impl ExternalSigningSigner for SigningOnly {
1258 async fn derive_public_key(&self, path: String) -> Result<PublicKeyBytes, SignerError> {
1259 self.0.derive_public_key(path).await
1260 }
1261 async fn sign_ecdsa(
1262 &self,
1263 message: MessageBytes,
1264 path: String,
1265 ) -> Result<EcdsaSignatureBytes, SignerError> {
1266 self.0.sign_ecdsa(message, path).await
1267 }
1268 async fn sign_ecdsa_recoverable(
1269 &self,
1270 message: MessageBytes,
1271 path: String,
1272 ) -> Result<RecoverableEcdsaSignatureBytes, SignerError> {
1273 self.0.sign_ecdsa_recoverable(message, path).await
1274 }
1275 async fn sign_hash_schnorr(
1276 &self,
1277 hash: Vec<u8>,
1278 path: String,
1279 ) -> Result<SchnorrSignatureBytes, SignerError> {
1280 self.0.sign_hash_schnorr(hash, path).await
1281 }
1282 }
1283
1284 let mut config = default_config(Network::Regtest);
1285 config.real_time_sync_server_url = None;
1287 config.private_enabled_default = false;
1288
1289 let breez: Arc<dyn ExternalSigningSigner> = Arc::new(SigningOnly(
1290 DefaultExternalSigner::new(TEST_MNEMONIC.to_string(), None, Network::Regtest, None)
1291 .unwrap(),
1292 ));
1293 let spark: Arc<dyn ExternalSparkSigner> = Arc::new(
1294 DefaultExternalSparkSigner::new(
1295 TEST_MNEMONIC.to_string(),
1296 None,
1297 Network::Regtest,
1298 None,
1299 )
1300 .unwrap(),
1301 );
1302
1303 let sdk = SdkBuilder::new_with_signing_only_signer(config, breez, spark)
1304 .with_default_storage(unique_storage_dir("signing-only-lnurl"))
1305 .build()
1306 .await
1307 .expect("signing-only build should succeed");
1308
1309 assert!(
1310 sdk.lnurl_auth_signer.is_none(),
1311 "a signing-only signer must not build an lnurl-auth signer"
1312 );
1313
1314 let err = sdk
1315 .lnurl_auth(crate::LnurlAuthRequestDetails {
1316 k1: "00".repeat(32),
1317 action: None,
1318 domain: "example.com".to_string(),
1319 url: "https://example.com/lnurl-auth".to_string(),
1320 })
1321 .await
1322 .expect_err("lnurl_auth must fail for a signing-only signer");
1323 assert!(
1324 matches!(err, SdkError::Generic(_)),
1325 "expected Generic, got {err:?}"
1326 );
1327
1328 sdk.disconnect().await.expect("disconnect should succeed");
1329 }
1330
1331 #[tokio::test]
1335 async fn signing_only_signer_build_rejects_ecies_features() {
1336 use crate::error::SignerError;
1337 use crate::signer::external_types::{
1338 EcdsaSignatureBytes, MessageBytes, PublicKeyBytes, RecoverableEcdsaSignatureBytes,
1339 SchnorrSignatureBytes,
1340 };
1341 use crate::signer::{
1342 DefaultExternalSparkSigner, ExternalSigningSigner, ExternalSparkSigner,
1343 };
1344 use std::sync::Arc;
1345
1346 struct StubSigningSigner;
1348 #[macros::async_trait]
1349 impl ExternalSigningSigner for StubSigningSigner {
1350 async fn derive_public_key(&self, _p: String) -> Result<PublicKeyBytes, SignerError> {
1351 unreachable!("build must fail before calling the signer")
1352 }
1353 async fn sign_ecdsa(
1354 &self,
1355 _m: MessageBytes,
1356 _p: String,
1357 ) -> Result<EcdsaSignatureBytes, SignerError> {
1358 unreachable!("build must fail before calling the signer")
1359 }
1360 async fn sign_ecdsa_recoverable(
1361 &self,
1362 _m: MessageBytes,
1363 _p: String,
1364 ) -> Result<RecoverableEcdsaSignatureBytes, SignerError> {
1365 unreachable!("build must fail before calling the signer")
1366 }
1367 async fn sign_hash_schnorr(
1368 &self,
1369 _h: Vec<u8>,
1370 _p: String,
1371 ) -> Result<SchnorrSignatureBytes, SignerError> {
1372 unreachable!("build must fail before calling the signer")
1373 }
1374 }
1375
1376 let mut config = default_config(Network::Regtest);
1377 config.real_time_sync_server_url = Some("https://example.com".to_string());
1378
1379 let breez: Arc<dyn ExternalSigningSigner> = Arc::new(StubSigningSigner);
1380 let spark: Arc<dyn ExternalSparkSigner> = Arc::new(
1381 DefaultExternalSparkSigner::new(
1382 TEST_MNEMONIC.to_string(),
1383 None,
1384 Network::Regtest,
1385 None,
1386 )
1387 .unwrap(),
1388 );
1389
1390 let result = SdkBuilder::new_with_signing_only_signer(config, breez, spark)
1391 .with_default_storage(unique_storage_dir("signing-only-reject-rtsync"))
1392 .build()
1393 .await;
1394 match result {
1395 Err(SdkError::InvalidInput(_)) => {}
1396 Ok(_) => panic!("build must reject real-time sync for a signing-only signer"),
1397 Err(other) => panic!("expected InvalidInput, got {other:?}"),
1398 }
1399 }
1400
1401 #[tokio::test]
1402 async fn wrap_session_store_stores_token_verbatim() {
1403 use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
1404 use spark_wallet::{Session, SessionStore, SessionStoreError};
1405 use std::collections::HashMap;
1406 use std::sync::{Arc, Mutex};
1407
1408 #[derive(Default)]
1410 struct InspectableInner {
1411 sessions: Mutex<HashMap<PublicKey, Session>>,
1412 }
1413 #[macros::async_trait]
1414 impl SessionStore for InspectableInner {
1415 async fn get_session(&self, key: &PublicKey) -> Result<Session, SessionStoreError> {
1416 self.sessions
1417 .lock()
1418 .unwrap()
1419 .get(key)
1420 .cloned()
1421 .ok_or(SessionStoreError::NotFound)
1422 }
1423 async fn set_session(
1424 &self,
1425 key: &PublicKey,
1426 session: Session,
1427 ) -> Result<(), SessionStoreError> {
1428 self.sessions.lock().unwrap().insert(*key, session);
1429 Ok(())
1430 }
1431 }
1432 fn key() -> PublicKey {
1433 let secp = Secp256k1::new();
1434 PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[3u8; 32]).unwrap())
1435 }
1436
1437 let inner = Arc::new(InspectableInner::default());
1438 let store = super::wrap_session_store(Some(inner.clone()));
1439 let token = "bearer-token".to_string();
1440 store
1441 .set_session(
1442 &key(),
1443 Session {
1444 token: token.clone(),
1445 expiration: 1,
1446 },
1447 )
1448 .await
1449 .unwrap();
1450
1451 let raw = inner.sessions.lock().unwrap().get(&key()).cloned().unwrap();
1454 assert_eq!(raw.token, token, "the SDK must store the token as-is");
1455 assert_eq!(store.get_session(&key()).await.unwrap().token, token);
1456 }
1457
1458 #[tokio::test]
1459 async fn server_mode_rejects_stable_balance_config() {
1460 use crate::{SdkError, StableBalanceConfig, StableBalanceToken, default_server_config};
1461
1462 let mut config = default_server_config(Network::Regtest);
1463 config.stable_balance_config = Some(StableBalanceConfig {
1464 tokens: vec![StableBalanceToken {
1465 label: "USDB".to_string(),
1466 token_identifier: "btkn1test".to_string(),
1467 }],
1468 default_active_label: None,
1469 threshold_sats: None,
1470 max_slippage_bps: None,
1471 });
1472
1473 let seed = test_seed();
1474 let result = SdkBuilder::new(config, seed).build().await;
1475 match result {
1476 Err(SdkError::InvalidInput(message)) => {
1477 assert!(message.contains("stable_balance_config"));
1478 }
1479 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1480 Ok(_) => panic!("expected server mode with Stable Balance config to fail"),
1481 }
1482 }
1483
1484 #[tokio::test]
1485 async fn server_mode_rejects_real_time_sync_server_url() {
1486 use crate::{SdkError, default_server_config};
1487
1488 let mut config = default_server_config(Network::Regtest);
1489 config.real_time_sync_server_url = Some("https://example.com".to_string());
1490
1491 let seed = test_seed();
1492 let result = SdkBuilder::new(config, seed).build().await;
1493 match result {
1494 Err(SdkError::InvalidInput(message)) => {
1495 assert!(message.contains("real_time_sync_server_url"));
1496 }
1497 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1498 Ok(_) => panic!("expected server mode with real_time_sync_server_url to fail"),
1499 }
1500 }
1501
1502 #[tokio::test]
1503 async fn server_mode_rejects_leaf_optimization_auto_enabled() {
1504 use crate::{SdkError, default_server_config};
1505
1506 let mut config = default_server_config(Network::Regtest);
1507 config.leaf_optimization_config.auto_enabled = true;
1508
1509 let seed = test_seed();
1510 let result = SdkBuilder::new(config, seed).build().await;
1511 match result {
1512 Err(SdkError::InvalidInput(message)) => {
1513 assert!(message.contains("leaf_optimization_config.auto_enabled"));
1514 }
1515 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1516 Ok(_) => panic!("expected server mode with optimization auto_enabled to fail"),
1517 }
1518 }
1519
1520 #[tokio::test]
1521 async fn server_mode_rejects_token_optimization_auto_enabled() {
1522 use crate::{SdkError, default_server_config};
1523
1524 let mut config = default_server_config(Network::Regtest);
1525 config.token_optimization_config.auto_enabled = true;
1526
1527 let seed = test_seed();
1528 let result = SdkBuilder::new(config, seed).build().await;
1529 match result {
1530 Err(SdkError::InvalidInput(message)) => {
1531 assert!(message.contains("token_optimization_config.auto_enabled"));
1532 }
1533 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1534 Ok(_) => panic!("expected server mode with optimization auto_enabled to fail"),
1535 }
1536 }
1537
1538 #[tokio::test]
1543 async fn build_rejects_cross_chain_config_on_regtest() {
1544 use crate::{CrossChainConfig, SdkError, default_config};
1545 let mut config = default_config(Network::Regtest);
1546 config.cross_chain_config = Some(CrossChainConfig::default());
1547
1548 let seed = test_seed();
1549 let result = SdkBuilder::new(config, seed).build().await;
1550 match result {
1551 Err(SdkError::InvalidInput(m)) => {
1552 assert!(
1553 m.contains("only available on Mainnet"),
1554 "expected mainnet-only rejection, got: {m}"
1555 );
1556 }
1557 Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1558 Ok(_) => panic!("expected regtest with cross_chain_config to fail"),
1559 }
1560 }
1561
1562 #[tokio::test]
1566 async fn build_errors_on_network_mismatch() {
1567 use crate::{SdkContextConfig, new_shared_sdk_context};
1568 let mut config = default_config(Network::Mainnet);
1569 config.api_key = Some("partner-key".to_string());
1570 let ctx = new_shared_sdk_context(SdkContextConfig {
1571 api_key: Some("partner-key".to_string()),
1572 ..SdkContextConfig::new(Network::Regtest)
1573 })
1574 .await
1575 .expect("regtest context");
1576 let err = SdkBuilder::new(config, test_seed())
1577 .with_shared_context(ctx)
1578 .with_default_storage("/tmp/breez-sdk-test-network-mismatch".to_string())
1579 .build()
1580 .await
1581 .err()
1582 .expect("expected network-mismatch error");
1583 assert!(
1584 err.to_string().contains("network/api_key do not match"),
1585 "unexpected error: {err}"
1586 );
1587 }
1588
1589 #[tokio::test]
1593 #[allow(clippy::manual_assert)]
1594 async fn build_errors_on_api_key_mismatch() {
1595 use crate::{SdkContextConfig, new_shared_sdk_context};
1596 let mut config = default_config(Network::Mainnet);
1597 config.api_key = Some("intended-key".to_string());
1598 let ctx = new_shared_sdk_context(SdkContextConfig {
1599 api_key: Some("wrong-key".to_string()),
1600 ..SdkContextConfig::new(Network::Mainnet)
1601 })
1602 .await
1603 .expect("mainnet context");
1604 let err = SdkBuilder::new(config, test_seed())
1605 .with_shared_context(ctx)
1606 .with_default_storage("/tmp/breez-sdk-test-key-mismatch".to_string())
1607 .build()
1608 .await
1609 .err()
1610 .expect("expected api_key-mismatch error");
1611 assert!(
1612 err.to_string().contains("network/api_key do not match"),
1613 "unexpected error: {err}"
1614 );
1615 }
1616
1617 const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
1618
1619 fn test_seed() -> crate::Seed {
1620 crate::Seed::Mnemonic {
1621 mnemonic: TEST_MNEMONIC.to_string(),
1622 passphrase: None,
1623 }
1624 }
1625
1626 fn unique_storage_dir(name: &str) -> String {
1627 std::env::temp_dir()
1628 .join(format!("breez-sdk-test-{}-{}", name, uuid::Uuid::new_v4()))
1629 .to_string_lossy()
1630 .into_owned()
1631 }
1632
1633 async fn assert_sdk_graph_freed(
1637 event_emitter: std::sync::Weak<crate::EventEmitter>,
1638 spark_wallet: std::sync::Weak<spark_wallet::SparkWallet>,
1639 ) {
1640 for _ in 0..100 {
1641 if event_emitter.upgrade().is_none() && spark_wallet.upgrade().is_none() {
1642 return;
1643 }
1644 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1645 }
1646 panic!(
1647 "SDK object graph leaked after drop (EventEmitter alive: {}, SparkWallet alive: {})",
1648 event_emitter.upgrade().is_some(),
1649 spark_wallet.upgrade().is_some(),
1650 );
1651 }
1652
1653 #[tokio::test]
1657 async fn server_mode_sdk_graph_is_freed_on_drop() {
1658 use crate::default_server_config;
1659
1660 let config = default_server_config(Network::Regtest);
1661 let sdk = SdkBuilder::new(config, test_seed())
1662 .with_default_storage(unique_storage_dir("leak-server"))
1663 .build()
1664 .await
1665 .expect("server-mode build should succeed");
1666
1667 let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
1668 let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
1669
1670 sdk.disconnect().await.expect("disconnect should succeed");
1671 drop(sdk);
1672
1673 assert_sdk_graph_freed(event_emitter, spark_wallet).await;
1674 }
1675
1676 #[tokio::test]
1680 #[ignore = "requires network access to the regtest operators"]
1681 async fn server_mode_sdk_graph_is_freed_on_drop_after_sync() {
1682 use crate::{SyncWalletRequest, default_server_config};
1683
1684 let config = default_server_config(Network::Regtest);
1685 let sdk = SdkBuilder::new(config, test_seed())
1686 .with_default_storage(unique_storage_dir("leak-server-sync"))
1687 .build()
1688 .await
1689 .expect("server-mode build should succeed");
1690
1691 let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
1692 let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
1693
1694 sdk.sync_wallet(SyncWalletRequest {})
1695 .await
1696 .expect("sync_wallet should succeed");
1697
1698 sdk.disconnect().await.expect("disconnect should succeed");
1699 drop(sdk);
1700
1701 assert_sdk_graph_freed(event_emitter, spark_wallet).await;
1702 }
1703
1704 #[tokio::test]
1707 async fn client_mode_sdk_graph_is_freed_after_disconnect_and_drop() {
1708 let mut config = default_config(Network::Regtest);
1709 config.real_time_sync_server_url = None;
1712 config.private_enabled_default = false;
1713
1714 let sdk = SdkBuilder::new(config, test_seed())
1715 .with_default_storage(unique_storage_dir("leak-client"))
1716 .build()
1717 .await
1718 .expect("client-mode build should succeed");
1719
1720 let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
1721 let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
1722
1723 tokio::time::timeout(std::time::Duration::from_secs(30), sdk.disconnect())
1724 .await
1725 .expect("disconnect should not hang")
1726 .expect("disconnect should succeed");
1727 drop(sdk);
1728
1729 assert_sdk_graph_freed(event_emitter, spark_wallet).await;
1730 }
1731
1732 #[tokio::test]
1737 async fn client_mode_sdk_graph_is_freed_with_real_time_sync_enabled() {
1738 use crate::{StableBalanceConfig, StableBalanceToken};
1739
1740 let mut config = default_config(Network::Regtest);
1741 config.real_time_sync_server_url = Some("http://127.0.0.1:9".to_string());
1745 config.private_enabled_default = false;
1746 config.stable_balance_config = Some(StableBalanceConfig {
1749 tokens: vec![StableBalanceToken {
1750 label: "USDB".to_string(),
1751 token_identifier: "btkn1test".to_string(),
1752 }],
1753 default_active_label: None,
1754 threshold_sats: None,
1755 max_slippage_bps: None,
1756 });
1757
1758 let sdk = SdkBuilder::new(config, test_seed())
1759 .with_default_storage(unique_storage_dir("leak-client-rtsync"))
1760 .build()
1761 .await
1762 .expect("client-mode build with real-time sync should succeed");
1763
1764 let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
1765 let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
1766
1767 tokio::time::timeout(std::time::Duration::from_secs(30), sdk.disconnect())
1768 .await
1769 .expect("disconnect should not hang")
1770 .expect("disconnect should succeed");
1771 drop(sdk);
1772
1773 assert_sdk_graph_freed(event_emitter, spark_wallet).await;
1774 }
1775
1776 #[tokio::test]
1779 async fn disconnect_unregisters_event_listeners() {
1780 use crate::{SdkEvent, default_server_config, events::EventListener};
1781
1782 struct NoopListener;
1783
1784 #[macros::async_trait]
1785 impl EventListener for NoopListener {
1786 async fn on_event(&self, _event: SdkEvent) {}
1787 }
1788
1789 let storage_dir = std::env::temp_dir()
1790 .join(format!(
1791 "breez-sdk-test-listener-clear-{}",
1792 uuid::Uuid::new_v4()
1793 ))
1794 .to_string_lossy()
1795 .into_owned();
1796 let sdk = SdkBuilder::new(default_server_config(Network::Regtest), test_seed())
1797 .with_default_storage(storage_dir)
1798 .build()
1799 .await
1800 .expect("server-mode build should succeed");
1801
1802 let id = sdk.add_event_listener(Box::new(NoopListener)).await;
1803
1804 sdk.disconnect().await.expect("disconnect should succeed");
1805
1806 assert!(
1807 !sdk.remove_event_listener(&id).await,
1808 "listener should already be unregistered by disconnect"
1809 );
1810 }
1811
1812 fn test_spark_signer() -> std::sync::Arc<dyn spark_wallet::SparkSigner> {
1813 use std::sync::Arc;
1814
1815 let seed = test_seed();
1816 let seed_bytes = seed.to_bytes().unwrap();
1817 let master =
1818 spark_wallet::account_master_key(&seed_bytes, Network::Regtest.into(), None).unwrap();
1819 Arc::new(spark_wallet::SparkSignerAdapter::new(Arc::new(
1820 spark_wallet::DefaultSigner::from_master(master),
1821 )))
1822 }
1823
1824 #[test]
1827 fn validate_server_mode_ok_when_background_enabled() {
1828 use crate::{StableBalanceConfig, StableBalanceToken, default_server_config};
1829 let mut config = default_server_config(Network::Regtest);
1830 config.stable_balance_config = Some(StableBalanceConfig {
1831 tokens: vec![StableBalanceToken {
1832 label: "USDB".to_string(),
1833 token_identifier: "btkn1test".to_string(),
1834 }],
1835 default_active_label: None,
1836 threshold_sats: None,
1837 max_slippage_bps: None,
1838 });
1839 config.real_time_sync_server_url = Some("https://example.com".to_string());
1840 config.leaf_optimization_config.auto_enabled = true;
1841 config.token_optimization_config.auto_enabled = true;
1842 assert!(super::validate_server_mode(&config, true).is_ok());
1844 }
1845
1846 #[test]
1847 fn validate_server_mode_ok_in_server_mode_without_background_features() {
1848 use crate::default_server_config;
1849 let config = default_server_config(Network::Regtest);
1850 assert!(super::validate_server_mode(&config, false).is_ok());
1851 }
1852
1853 #[test]
1854 fn validate_server_mode_rejects_stable_balance_directly() {
1855 use crate::{StableBalanceConfig, StableBalanceToken, default_server_config};
1856 let mut config = default_server_config(Network::Regtest);
1857 config.stable_balance_config = Some(StableBalanceConfig {
1858 tokens: vec![StableBalanceToken {
1859 label: "USDB".to_string(),
1860 token_identifier: "btkn1test".to_string(),
1861 }],
1862 default_active_label: None,
1863 threshold_sats: None,
1864 max_slippage_bps: None,
1865 });
1866 match super::validate_server_mode(&config, false) {
1867 Err(SdkError::InvalidInput(m)) => assert!(m.contains("stable_balance_config")),
1868 other => panic!("expected InvalidInput, got {other:?}"),
1869 }
1870 }
1871
1872 #[test]
1873 fn validate_server_mode_rejects_real_time_sync_directly() {
1874 use crate::default_server_config;
1875 let mut config = default_server_config(Network::Regtest);
1876 config.real_time_sync_server_url = Some("https://example.com".to_string());
1877 match super::validate_server_mode(&config, false) {
1878 Err(SdkError::InvalidInput(m)) => assert!(m.contains("real_time_sync_server_url")),
1879 other => panic!("expected InvalidInput, got {other:?}"),
1880 }
1881 }
1882
1883 #[test]
1884 fn validate_server_mode_rejects_leaf_auto_optimize_directly() {
1885 use crate::default_server_config;
1886 let mut config = default_server_config(Network::Regtest);
1887 config.leaf_optimization_config.auto_enabled = true;
1888 match super::validate_server_mode(&config, false) {
1889 Err(SdkError::InvalidInput(m)) => {
1890 assert!(m.contains("leaf_optimization_config.auto_enabled"));
1891 }
1892 other => panic!("expected InvalidInput, got {other:?}"),
1893 }
1894 }
1895
1896 #[test]
1897 fn validate_server_mode_rejects_token_auto_optimize_directly() {
1898 use crate::default_server_config;
1899 let mut config = default_server_config(Network::Regtest);
1900 config.token_optimization_config.auto_enabled = true;
1901 match super::validate_server_mode(&config, false) {
1902 Err(SdkError::InvalidInput(m)) => {
1903 assert!(m.contains("token_optimization_config.auto_enabled"));
1904 }
1905 other => panic!("expected InvalidInput, got {other:?}"),
1906 }
1907 }
1908
1909 #[test]
1910 fn validate_server_mode_rejects_cross_chain_directly() {
1911 use crate::{CrossChainConfig, default_server_config};
1912 let mut config = default_server_config(Network::Regtest);
1913 config.cross_chain_config = Some(CrossChainConfig::default());
1914 match super::validate_server_mode(&config, false) {
1915 Err(SdkError::InvalidInput(m)) => assert!(m.contains("Cross-chain config")),
1916 other => panic!("expected InvalidInput, got {other:?}"),
1917 }
1918 }
1919
1920 #[test]
1923 fn finalize_spark_wallet_config_disabled_background_forces_leaf_auto_off() {
1924 let mut config = default_config(Network::Regtest);
1925 config.leaf_optimization_config.auto_enabled = true;
1926 let result = super::finalize_spark_wallet_config(&config, "test-agent", false).unwrap();
1927 assert!(!result.leaf_auto_optimize_enabled);
1928 }
1929
1930 #[test]
1931 fn finalize_spark_wallet_config_disabled_background_clears_token_auto_interval() {
1932 let mut config = default_config(Network::Regtest);
1933 config.token_optimization_config.auto_enabled = true;
1934 let result = super::finalize_spark_wallet_config(&config, "test-agent", false).unwrap();
1935 assert!(
1936 result
1937 .token_outputs_optimization_options
1938 .auto_optimize_interval
1939 .is_none()
1940 );
1941 }
1942
1943 #[test]
1944 fn finalize_spark_wallet_config_enabled_background_respects_leaf_auto_optimize() {
1945 let mut config = default_config(Network::Regtest);
1946 config.leaf_optimization_config.auto_enabled = true;
1947 let result = super::finalize_spark_wallet_config(&config, "test-agent", true).unwrap();
1948 assert!(result.leaf_auto_optimize_enabled);
1949 }
1950
1951 #[test]
1952 fn finalize_spark_wallet_config_applies_user_agent() {
1953 let config = default_config(Network::Regtest);
1954 let result = super::finalize_spark_wallet_config(&config, "my-app/1.0", true).unwrap();
1955 assert_eq!(
1956 result.service_provider_config.user_agent.as_deref(),
1957 Some("my-app/1.0")
1958 );
1959 }
1960
1961 #[tokio::test]
1964 async fn resolve_context_errors_on_network_mismatch() {
1965 use crate::{SdkContextConfig, new_shared_sdk_context};
1966 let config = default_config(Network::Mainnet);
1967 let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
1968 .await
1969 .expect("regtest context");
1970 let err = super::resolve_context(Some(ctx), &config)
1971 .await
1972 .err()
1973 .expect("expected mismatch error");
1974 assert!(
1975 err.to_string().contains("network/api_key do not match"),
1976 "unexpected error: {err}"
1977 );
1978 }
1979
1980 #[tokio::test]
1981 async fn resolve_context_errors_on_api_key_mismatch() {
1982 use crate::{SdkContextConfig, new_shared_sdk_context};
1983 let mut config = default_config(Network::Mainnet);
1984 config.api_key = Some("intended-key".to_string());
1985 let ctx = new_shared_sdk_context(SdkContextConfig {
1986 api_key: Some("wrong-key".to_string()),
1987 ..SdkContextConfig::new(Network::Mainnet)
1988 })
1989 .await
1990 .expect("mainnet context");
1991 let err = super::resolve_context(Some(ctx), &config)
1992 .await
1993 .err()
1994 .expect("expected mismatch error");
1995 assert!(
1996 err.to_string().contains("network/api_key do not match"),
1997 "unexpected error: {err}"
1998 );
1999 }
2000
2001 #[tokio::test]
2004 async fn resolve_storage_errors_when_neither_supplied() {
2005 use crate::{SdkContextConfig, new_shared_sdk_context};
2006 let config = default_config(Network::Regtest);
2007 let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
2008 .await
2009 .expect("regtest context");
2010 let signer = test_spark_signer();
2011 let err = super::resolve_storage(None, &ctx, &signer, &config)
2012 .await
2013 .err()
2014 .expect("expected no-storage error");
2015 assert!(
2016 err.to_string().contains("No storage configured"),
2017 "unexpected error: {err}"
2018 );
2019 }
2020
2021 #[tokio::test]
2022 async fn resolve_storage_errors_when_supplied_on_both_builder_and_context() {
2023 use crate::{SdkContextConfig, default_storage, new_shared_sdk_context};
2024 let config = default_config(Network::Regtest);
2025 let ctx = new_shared_sdk_context(SdkContextConfig {
2026 storage: Some(default_storage(
2027 "/tmp/breez-sdk-test-resolve-storage-ctx".to_string(),
2028 )),
2029 ..SdkContextConfig::new(Network::Regtest)
2030 })
2031 .await
2032 .expect("regtest context");
2033 let signer = test_spark_signer();
2034 let builder_storage =
2035 default_storage("/tmp/breez-sdk-test-resolve-storage-builder".to_string());
2036 let err = super::resolve_storage(Some(builder_storage), &ctx, &signer, &config)
2037 .await
2038 .err()
2039 .expect("expected duplicate-storage error");
2040 assert!(
2041 err.to_string()
2042 .contains("storage is configured on both the SdkBuilder and the shared SdkContext"),
2043 "unexpected error: {err}"
2044 );
2045 }
2046}