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