Skip to main content

breez_sdk_spark/
sdk_builder.rs

1#![cfg_attr(
2    all(target_family = "wasm", target_os = "unknown"),
3    allow(clippy::arc_with_non_send_sync)
4)]
5use std::sync::Arc;
6
7use breez_sdk_common::breez_server::BreezServer;
8use breez_sdk_common::buy::moonpay::MoonpayProvider;
9
10use spark_wallet::{
11    InMemorySessionStore, SessionStore, SparkSigner, SparkWallet, SparkWalletConfig,
12};
13use tokio::sync::watch;
14use tracing::{debug, info, warn};
15
16use flashnet::{FlashnetConfig, IntegratorConfig};
17
18use crate::{
19    Credentials, EventEmitter, FiatService, FiatServiceWrapper, Network, Seed,
20    chain::{
21        BitcoinChainService, ValidatingChainService,
22        rest_client::{BasicAuth, ChainApiType, RestClientChainService},
23    },
24    error::SdkError,
25    lnurl::{DefaultLnurlServerClient, LnurlServerClient},
26    models::Config,
27    payment_observer::{PaymentObserver, SparkTransferObserver},
28    persist::backend::{ResolvedStores, StorageBackend},
29    realtime_sync::{RealTimeSyncParams, init_and_start_real_time_sync},
30    sdk::{BreezSdk, BreezSdkParams, SyncCoordinator, runtime_from_config},
31    sdk_context::{SdkContext, SdkContextConfig, new_shared_sdk_context},
32    signer::{breez::BreezSignerImpl, lnurl_auth::LnurlAuthSignerAdapter, rtsync::RTSyncSigner},
33    stable_balance::StableBalance,
34    token_conversion::TokenConversionMiddleware,
35    token_conversion::{
36        DEFAULT_INTEGRATOR_FEE_BPS, DEFAULT_INTEGRATOR_PUBKEY, FlashnetTokenConverter,
37        TokenConverter,
38    },
39};
40
41/// Configuration captured by [`SdkBuilder::with_rest_chain_service`].
42///
43/// Stored on the builder and resolved during `build()` so the resulting
44/// `RestClientChainService` reuses the shared HTTP client from the
45/// [`SdkContext`](crate::SdkContext).
46#[derive(Clone)]
47struct RestChainServiceConfig {
48    url: String,
49    api_type: ChainApiType,
50    credentials: Option<Credentials>,
51}
52
53/// Source for the signer - either a seed or an external signer implementation
54/// External SDK-layer signer, in one of its two capability profiles. The profile
55/// is chosen by the type the integrator supplies at connect, not a runtime flag:
56/// a [`SigningOnly`](ExternalBreez::SigningOnly) signer can't perform the SDK's
57/// local ECIES/HMAC operations.
58#[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
76/// The signers derived from a single signer source. `ecies` is absent for a
77/// signing-only signer; the features that depend on it (`rtsync`, session-token
78/// encryption, cross-chain) and on HMAC (`lnurl_auth`) are then absent too.
79struct 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
86/// Inputs to [`build_spark_wallet`] — bundled to avoid an >8-argument helper.
87struct BuildSparkWalletParams {
88    config: SparkWalletConfig,
89    spark_signer: Arc<dyn SparkSigner>,
90    session_store: Arc<dyn SessionStore>,
91    /// `Some` only in client mode. Wiring it in server mode would leave the
92    /// wallet holding a stored `cancellation_token` receiver that's never
93    /// consumed (server mode never calls `start_background_processing`), which
94    /// would stall `disconnect`'s `Sender::closed()` await forever.
95    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/// Builder for creating `BreezSdk` instances with customizable components.
103#[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    /// Creates a new `SdkBuilder` with the provided configuration and seed.
121    ///
122    /// For external signer support, use `new_with_signer` instead.
123    ///
124    /// # Arguments
125    /// - `config`: The configuration to be used.
126    /// - `seed`: The seed for wallet generation.
127    #[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    /// Creates a new `SdkBuilder` with the provided configuration and external signers.
148    ///
149    /// # Arguments
150    /// - `config`: The configuration to be used.
151    /// - `breez_signer`: External signer for non-Spark SDK signing (LNURL-auth,
152    ///   real-time sync, message signing, ECIES).
153    /// - `spark_signer`: External high-level Spark signer for the Spark wallet.
154    #[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    /// Creates a new `SdkBuilder` with a signing-only external signer.
164    ///
165    /// Use this for a signer that can't perform the SDK's local ECIES/HMAC
166    /// operations (for example a policy-restricted enclave). The SDK keeps
167    /// session tokens in plaintext and disables the features that rely on
168    /// ECIES/HMAC.
169    ///
170    /// # Arguments
171    /// - `config`: The configuration to be used.
172    /// - `breez_signer`: Signing-only external signer for non-Spark SDK signing.
173    /// - `spark_signer`: External high-level Spark signer for the Spark wallet.
174    #[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    /// Sets the account number for key derivation. All wallet keys derive from
211    /// the seed at `m/8797555'/<account number>'`, so each account number
212    /// yields an independent wallet from the same seed.
213    ///
214    /// When unset, the account number defaults to 0 on Regtest and 1 on all
215    /// other networks.
216    ///
217    /// Note: This only applies when using a seed-based signer. It has no effect
218    /// when using an external signer (created with `new_with_signer`).
219    ///
220    /// # Arguments
221    /// - `account_number`: The account number in the derivation path.
222    #[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    /// Sets the root storage directory to initialize the default storage with.
237    /// This initializes both storage and real-time sync storage with the
238    /// default implementations.
239    /// Arguments:
240    /// - `storage_dir`: The data directory for storage.
241    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    /// Sets the storage backend to be used by the SDK.
247    ///
248    /// Build the [`StorageBackend`](crate::StorageBackend) with
249    /// [`default_storage`](crate::default_storage),
250    /// [`postgres_storage`](crate::postgres_storage),
251    /// [`mysql_storage`](crate::mysql_storage) or
252    /// [`custom_storage`](crate::custom_storage).
253    /// Arguments:
254    /// - `storage`: The storage backend to be used.
255    pub fn with_storage_backend(mut self, storage: Arc<dyn StorageBackend>) -> Self {
256        self.storage = Some(storage);
257        self
258    }
259
260    /// Overrides the session store used to cache Spark operator and SSP auth
261    /// tokens, replacing the one the [`StorageBackend`] provides.
262    ///
263    /// Supply any [`SessionStore`]: a different persistence layer, or a decorator
264    /// that wraps the backend's own store (from
265    /// [`default_session_store`](crate::default_session_store)) to transform
266    /// tokens on read/write while keeping its persistence. One such transform is
267    /// at-rest encryption, which the SDK does not apply itself: wrap the store in
268    /// a [`SessionStore`] that encrypts in `set_session` and decrypts in
269    /// `get_session`.
270    ///
271    /// Arguments:
272    /// - `session_store`: The session store to use in place of the backend's.
273    #[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.** Use
284    /// [`with_storage_backend`](Self::with_storage_backend) with
285    /// [`custom_storage`](crate::custom_storage).
286    /// Arguments:
287    /// - `storage`: The storage implementation to be used.
288    #[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    /// **Deprecated.** Use
294    /// [`with_storage_backend`](Self::with_storage_backend) with
295    /// [`postgres_storage`](crate::postgres_storage).
296    #[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    /// **Deprecated.** Use
306    /// [`with_storage_backend`](Self::with_storage_backend) with
307    /// [`mysql_storage`](crate::mysql_storage).
308    #[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    /// Threads a shared [`SdkContext`] into this builder.
318    ///
319    /// Construct the context once via [`new_shared_sdk_context`] and pass the
320    /// same `Arc` to every `SdkBuilder` whose SDKs should share its underlying
321    /// resources (operator gRPC channels, SSP HTTP client, database pool).
322    ///
323    /// If not set, `build()` constructs a context internally from the SDK's
324    /// own network and api key — fine for a single-SDK process with no DB
325    /// backend.
326    #[must_use]
327    pub fn with_shared_context(mut self, context: Arc<SdkContext>) -> Self {
328        self.context = Some(context);
329        self
330    }
331
332    /// Sets the chain service to be used by the SDK.
333    /// Arguments:
334    /// - `chain_service`: The chain service to be used.
335    #[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    /// Configures a REST chain service to be used by the SDK.
343    ///
344    /// The service is constructed during [`build()`](Self::build) so it can
345    /// reuse the shared HTTP client carried by the [`SdkContext`](crate::SdkContext).
346    ///
347    /// Arguments:
348    /// - `url`: The base URL of the REST API.
349    /// - `api_type`: The API type to be used.
350    /// - `credentials`: Optional credentials for basic authentication.
351    #[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    /// Sets the fiat service to be used by the SDK.
368    /// Arguments:
369    /// - `fiat_service`: The fiat service to be used.
370    #[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    /// Sets the payment observer to be used by the SDK.
395    /// This observer will receive callbacks before outgoing payments for Lightning, Spark and onchain Bitcoin.
396    /// Arguments:
397    /// - `payment_observer`: The payment observer to be used.
398    #[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    /// Builds a [`SparkWalletConfig`](spark_wallet::SparkWalletConfig) from a
406    /// [`SparkConfig`](crate::models::SparkConfig).
407    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    /// Warns when a proxy is configured alongside a caller-supplied service.
464    ///
465    /// Those arrive as trait objects that already own their transport, which
466    /// the SDK cannot inspect or re-route: honouring the proxy is the caller's
467    /// responsibility. `with_rest_chain_service` is not listed, since it builds
468    /// on the context's proxied client.
469    fn warn_about_unproxied_overrides(&self) {
470        if self.config.proxy.is_none() {
471            return;
472        }
473        let overrides = [
474            ("chain service", self.chain_service.is_some()),
475            ("fiat service", self.fiat_service.is_some()),
476            ("LNURL HTTP client", self.lnurl_client.is_some()),
477            ("LNURL server client", self.lnurl_server_client.is_some()),
478        ];
479        for (name, supplied) in overrides {
480            if supplied {
481                warn!(
482                    "A proxy is configured but a custom {name} was supplied. \
483                     The SDK cannot route it: make sure it connects through the proxy itself."
484                );
485            }
486        }
487    }
488
489    /// Builds the `BreezSdk` instance from the configured components, reading
490    /// top-to-bottom as a sequence of named assembly steps.
491    #[allow(clippy::too_many_lines)]
492    pub async fn build(self) -> Result<BreezSdk, SdkError> {
493        self.config.validate()?;
494        let runtime = runtime_from_config(&self.config);
495        let background_services_enabled = runtime.starts_background_services();
496        validate_server_mode(&self.config, background_services_enabled)?;
497
498        self.warn_about_unproxied_overrides();
499        let signers = build_signers(&self.config, self.signer_source)?;
500        validate_signer_capabilities(&self.config, signers.ecies.is_some())?;
501
502        let creates_context = self.context.is_none();
503        let context = resolve_context(self.context, &self.config).await?;
504        let stores = resolve_storage(self.storage, &context, &signers.spark, &self.config).await?;
505        // Start the partner-JWT provider now that storage is resolved. When the builder
506        // creates its own context (no shared context supplied), bind the resolved
507        // storage so the token warm-starts and survives restarts; a shared context
508        // starts in-memory only.
509        if let Some(provider) = &context.jwt_header_provider {
510            provider.start(creates_context.then(|| Arc::clone(&stores.storage)));
511        }
512        let chain_service = resolve_chain_service(
513            self.chain_service,
514            self.rest_chain_service_config,
515            &context,
516            self.config.network,
517        );
518
519        let user_agent = crate::default_user_agent();
520        info!("Building sdk with user agent: {}", user_agent);
521
522        let transport_proxy = self
523            .config
524            .proxy
525            .as_ref()
526            .map(platform_utils::ProxyConfig::from);
527        // LNURL and lightning-address hosts are chosen by remote parties, so
528        // their traffic gets a client that revalidates every redirect hop:
529        // an unvalidated redirect would bypass the checks done on the
530        // original URL. It shares the shared client's user agent and proxy,
531        // and reqwest pools per host, so nothing is lost by keeping it
532        // separate.
533        let lnurl_http_client: Arc<dyn platform_utils::HttpClient> =
534            platform_utils::create_http_client_with_redirect_filter(
535                Some(&user_agent),
536                transport_proxy.as_ref(),
537                breez_sdk_common::lnurl::security::lnurl_redirect_filter(),
538            )
539            .map_err(|e| SdkError::Generic(format!("Failed to build LNURL HTTP client: {e}")))?;
540
541        let input_parser = Arc::new(crate::sdk::SdkInputParser::new(
542            breez_sdk_common::dns::Resolver::with_proxy(transport_proxy.as_ref())
543                .map_err(|e| SdkError::Generic(format!("Failed to build DNS resolver: {e}")))?,
544            Arc::clone(&lnurl_http_client),
545            Some(
546                self.config
547                    .get_all_external_input_parsers()
548                    .into_iter()
549                    .map(Into::into)
550                    .collect(),
551            ),
552        ));
553
554        let fiat_service: Arc<dyn breez_sdk_common::fiat::FiatService> = match self.fiat_service {
555            Some(service) => Arc::new(FiatServiceWrapper::new(service)),
556            None => context.breez_server.clone(),
557        };
558        let lnurl_client: Arc<dyn platform_utils::HttpClient> = self
559            .lnurl_client
560            .unwrap_or_else(|| Arc::clone(&lnurl_http_client));
561
562        let spark_wallet_config =
563            finalize_spark_wallet_config(&self.config, &user_agent, background_services_enabled)?;
564        let shutdown_sender = watch::channel::<()>(()).0;
565        // An explicit `with_session_store` override (adapted to the wallet's
566        // session-store trait) wins; otherwise use the store the backend
567        // resolved (or an in-memory default).
568        let override_store = self.session_store.map(|s| {
569            Arc::new(crate::session_store::SessionStoreAdapter::new(s)) as Arc<dyn SessionStore>
570        });
571        let session_store =
572            wrap_session_store(override_store.or_else(|| stores.session_store.clone()));
573
574        let spark_wallet = build_spark_wallet(BuildSparkWalletParams {
575            config: spark_wallet_config,
576            spark_signer: Arc::clone(&signers.spark),
577            session_store,
578            shutdown_receiver: background_services_enabled.then(|| shutdown_sender.subscribe()),
579            tree_store: stores.tree_store.clone(),
580            token_output_store: stores.token_output_store.clone(),
581            payment_observer: self.payment_observer,
582            context: Arc::clone(&context),
583        })
584        .await?;
585
586        let lnurl_server_client = resolve_lnurl_server_client(
587            self.lnurl_server_client,
588            &self.config,
589            &context,
590            &spark_wallet,
591        );
592
593        let real_time_sync_active =
594            background_services_enabled && self.config.real_time_sync_server_url.is_some();
595        let event_emitter = Arc::new(EventEmitter::new(real_time_sync_active));
596
597        let storage = maybe_wrap_storage_with_real_time_sync(
598            Arc::clone(&stores.storage),
599            &self.config,
600            background_services_enabled,
601            user_agent,
602            signers.rtsync,
603            shutdown_sender.subscribe(),
604            Arc::clone(&event_emitter),
605            lnurl_server_client.clone(),
606        )
607        .await?;
608
609        let buy_bitcoin_provider = Arc::new(MoonpayProvider::new(context.breez_server.clone()));
610        let token_converter =
611            build_token_converter(&self.config, &storage, &spark_wallet, &context);
612
613        let sync_coordinator = SyncCoordinator::new();
614
615        // Shared lightning-send helper used by `send_bolt11_invoice`.
616        let lightning_sender = Arc::new(crate::sdk::LightningSender::new(
617            Arc::clone(&spark_wallet),
618            Arc::clone(&storage),
619            Arc::clone(&event_emitter),
620            shutdown_sender.clone(),
621        ));
622
623        let cross_chain_context = build_cross_chain_context(
624            &self.config,
625            &context.breez_server,
626            &context.http_client,
627            &spark_wallet,
628            &storage,
629            Arc::clone(&fiat_service),
630            shutdown_sender.subscribe(),
631        );
632
633        let stable_balance = build_stable_balance(
634            &self.config,
635            &token_converter,
636            &spark_wallet,
637            &storage,
638            &event_emitter,
639        )
640        .await;
641
642        // Register TokenConversionMiddleware to suppress conversion child events
643        // before they reach external listeners (after StableBalance middleware).
644        event_emitter
645            .add_middleware(Box::new(TokenConversionMiddleware))
646            .await;
647
648        let sdk = BreezSdk::init_and_start(BreezSdkParams {
649            input_parser,
650            config: self.config,
651            storage,
652            chain_service,
653            fiat_service,
654            lnurl_client,
655            lnurl_server_client,
656            lnurl_auth_signer: signers.lnurl_auth,
657            shutdown_sender,
658            runtime,
659            spark_wallet,
660            event_emitter,
661            buy_bitcoin_provider,
662            token_converter,
663            stable_balance,
664            sync_coordinator,
665            cross_chain_context,
666            lightning_sender,
667        })
668        .await?;
669        debug!("Initialized and started breez sdk.");
670
671        Ok(sdk)
672    }
673}
674
675/// Rejects server-mode configs that depend on background services.
676fn validate_server_mode(
677    config: &Config,
678    background_services_enabled: bool,
679) -> Result<(), SdkError> {
680    if background_services_enabled {
681        return Ok(());
682    }
683    if config.stable_balance_config.is_some() {
684        return Err(SdkError::InvalidInput(
685            "stable_balance_config is not supported when background_tasks_enabled is false"
686                .to_string(),
687        ));
688    }
689    if config.real_time_sync_server_url.is_some() {
690        return Err(SdkError::InvalidInput(
691            "real_time_sync_server_url must be None when background_tasks_enabled is false"
692                .to_string(),
693        ));
694    }
695    if config.leaf_optimization_config.auto_enabled {
696        return Err(SdkError::InvalidInput(
697            "leaf_optimization_config.auto_enabled must be false when background_tasks_enabled is false"
698                .to_string(),
699        ));
700    }
701    if config.token_optimization_config.auto_enabled {
702        return Err(SdkError::InvalidInput(
703            "token_optimization_config.auto_enabled must be false when background_tasks_enabled is false"
704                .to_string(),
705        ));
706    }
707    if config.cross_chain_config.is_some() {
708        return Err(SdkError::InvalidInput(
709            "Cross-chain config must be unset when background tasks are disabled".to_string(),
710        ));
711    }
712    Ok(())
713}
714
715/// Rejects configs whose features need local encryption when the signer can't
716/// perform ECIES.
717fn validate_signer_capabilities(config: &Config, has_ecies: bool) -> Result<(), SdkError> {
718    if has_ecies {
719        return Ok(());
720    }
721    if config.real_time_sync_server_url.is_some() {
722        return Err(SdkError::InvalidInput(
723            "Real-time sync requires a signer that supports ECIES".to_string(),
724        ));
725    }
726    if config.cross_chain_config.is_some() {
727        return Err(SdkError::InvalidInput(
728            "Cross-chain payments require a signer that supports ECIES".to_string(),
729        ));
730    }
731    Ok(())
732}
733
734/// Derives the SDK-layer signers from one signer source: the Spark signer, and
735/// (when the signer can perform ECIES/HMAC) the `ecies` signer plus the
736/// real-time-sync and lnurl-auth signers. A signing-only external signer can do
737/// neither, so `ecies`, `rtsync`, and `lnurl_auth` are all left `None`.
738fn build_signers(config: &Config, signer_source: SignerSource) -> Result<Signers, SdkError> {
739    use crate::signer::{
740        BreezSigner, EciesSigner, ExternalBreezSignerAdapter, ExternalSigningSignerAdapter,
741        ExternalSparkSignerAdapter, HmacSigner,
742    };
743
744    // The SDK-layer `BreezSigner` (`base`) roots at the identity master
745    // (`base/0'`); the high-level Spark signer (the in-process `DefaultSigner`
746    // wrapped in a `SparkSignerAdapter`) roots at the account master (`base`).
747    #[allow(clippy::type_complexity)]
748    let (base, ecies, hmac, spark): (
749        Arc<dyn BreezSigner>,
750        Option<Arc<dyn EciesSigner>>,
751        Option<Arc<dyn HmacSigner>>,
752        Arc<dyn SparkSigner>,
753    ) = match signer_source {
754        SignerSource::Seed {
755            seed,
756            account_number,
757        } => {
758            let seed_bytes = seed.to_bytes()?;
759            let network = config.network.into();
760            // One `BreezSignerImpl` implements all three capability traits.
761            let signer = Arc::new(BreezSignerImpl::new(
762                spark_wallet::identity_master_key(&seed_bytes, network, account_number)
763                    .map_err(|e| SdkError::Generic(e.to_string()))?,
764            ));
765            let spark: Arc<dyn SparkSigner> = Arc::new(spark_wallet::SparkSignerAdapter::new(
766                Arc::new(spark_wallet::DefaultSigner::from_master(
767                    spark_wallet::account_master_key(&seed_bytes, network, account_number)
768                        .map_err(|e| SdkError::Generic(e.to_string()))?,
769                )),
770            ));
771            (signer.clone(), Some(signer.clone()), Some(signer), spark)
772        }
773        SignerSource::External { breez, spark } => {
774            let spark: Arc<dyn SparkSigner> = Arc::new(ExternalSparkSignerAdapter::new(spark));
775            match breez {
776                ExternalBreez::Full(breez) => {
777                    // The full adapter implements all three capability traits.
778                    let signer = Arc::new(ExternalBreezSignerAdapter::new(breez));
779                    (signer.clone(), Some(signer.clone()), Some(signer), spark)
780                }
781                ExternalBreez::SigningOnly(breez) => {
782                    let base: Arc<dyn BreezSigner> =
783                        Arc::new(ExternalSigningSignerAdapter::new(breez));
784                    (base, None, None, spark)
785                }
786            }
787        }
788    };
789
790    let rtsync = ecies
791        .as_ref()
792        .map(|ecies| {
793            RTSyncSigner::new(base.clone(), ecies.clone(), config.network)
794                .map(Arc::new)
795                .map_err(|e| SdkError::Generic(e.to_string()))
796        })
797        .transpose()?;
798    let lnurl_auth = hmac
799        .as_ref()
800        .map(|hmac| Arc::new(LnurlAuthSignerAdapter::new(base.clone(), hmac.clone())));
801
802    Ok(Signers {
803        ecies,
804        spark,
805        rtsync,
806        lnurl_auth,
807    })
808}
809
810/// Resolves the [`SdkContext`] — either the caller-supplied one or a fresh
811/// default — and validates that its `network`/`api_key` match the SDK config.
812async fn resolve_context(
813    supplied: Option<Arc<SdkContext>>,
814    config: &Config,
815) -> Result<Arc<SdkContext>, SdkError> {
816    let context = match supplied {
817        Some(ctx) => ctx,
818        None => {
819            new_shared_sdk_context(SdkContextConfig {
820                api_key: config.api_key.clone(),
821                proxy: config.proxy.clone(),
822                ..SdkContextConfig::new(config.network)
823            })
824            .await?
825        }
826    };
827    if context.network != config.network || context.api_key != config.api_key {
828        return Err(SdkError::Generic(
829            "SdkContext network/api_key do not match SdkConfig".to_string(),
830        ));
831    }
832    // The context owns the shared HTTP and gRPC clients, so a proxy that
833    // disagrees with the config would leave part of the traffic unproxied.
834    if context.proxy != config.proxy {
835        return Err(SdkError::Generic(
836            "SdkContext proxy does not match SdkConfig proxy".to_string(),
837        ));
838    }
839    Ok(context)
840}
841
842/// Resolves the single [`StorageBackend`] — from the builder or the shared
843/// context, never both — and asks it for the per-tenant store set.
844async fn resolve_storage(
845    supplied: Option<Arc<dyn StorageBackend>>,
846    context: &SdkContext,
847    spark_signer: &Arc<dyn SparkSigner>,
848    config: &Config,
849) -> Result<Arc<ResolvedStores>, SdkError> {
850    let storage_backend: Arc<dyn StorageBackend> = match (supplied, context.storage_backend.clone())
851    {
852        (Some(storage), None) => storage,
853        (None, Some(backend)) => backend,
854        (Some(_), Some(_)) => {
855            return Err(SdkError::Generic(
856                "storage is configured on both the SdkBuilder and the shared SdkContext"
857                    .to_string(),
858            ));
859        }
860        (None, None) => return Err(SdkError::Generic("No storage configured".to_string())),
861    };
862    let identity_public_key = spark_signer
863        .get_identity_public_key()
864        .await
865        .map_err(|e| SdkError::Generic(e.to_string()))?;
866    storage_backend
867        .create_stores(config.network, identity_public_key.serialize().to_vec())
868        .await
869}
870
871/// Resolves the chain service: caller-supplied override → REST config → network
872/// default (mempool.space on mainnet, a hosted mempool instance on regtest).
873/// Whichever backend is resolved is wrapped in a [`ValidatingChainService`],
874/// so every transaction it serves is rebound to the requested txid.
875fn resolve_chain_service(
876    supplied: Option<Arc<dyn BitcoinChainService>>,
877    rest_config: Option<RestChainServiceConfig>,
878    context: &SdkContext,
879    network: Network,
880) -> Arc<dyn BitcoinChainService> {
881    let inner: Arc<dyn BitcoinChainService> = if let Some(service) = supplied {
882        service
883    } else if let Some(cfg) = rest_config {
884        Arc::new(RestClientChainService::new(
885            cfg.url,
886            network,
887            5,
888            context.http_client.clone(),
889            cfg.credentials
890                .map(|c| BasicAuth::new(c.username, c.password)),
891            cfg.api_type,
892        ))
893    } else {
894        let inner_client: Arc<dyn platform_utils::HttpClient> = context.http_client.clone();
895        match network {
896            Network::Mainnet => Arc::new(RestClientChainService::new(
897                "https://mempool.space/api".to_string(),
898                network,
899                5,
900                inner_client,
901                None,
902                ChainApiType::MempoolSpace,
903            )),
904            Network::Regtest => Arc::new(RestClientChainService::new(
905                "https://regtest-mempool.us-west-2.sparkinfra.net/api".to_string(),
906                network,
907                5,
908                inner_client,
909                match (
910                    std::env::var("CHAIN_SERVICE_USERNAME"),
911                    std::env::var("CHAIN_SERVICE_PASSWORD"),
912                ) {
913                    (Ok(username), Ok(password)) => Some(BasicAuth::new(username, password)),
914                    _ => Some(BasicAuth::new(
915                        "spark-sdk".to_string(),
916                        "mCMk1JqlBNtetUNy".to_string(),
917                    )),
918                },
919                ChainApiType::MempoolSpace,
920            )),
921        }
922    };
923    Arc::new(ValidatingChainService::new(inner))
924}
925
926/// Builds the full [`SparkWalletConfig`] with user-agent and SDK-level
927/// optimization overrides applied. `background_services_enabled` gates the
928/// auto-optimization flags so server-mode SDKs don't run background loops.
929fn finalize_spark_wallet_config(
930    config: &Config,
931    user_agent: &str,
932    background_services_enabled: bool,
933) -> Result<SparkWalletConfig, SdkError> {
934    let mut spark_wallet_config = if let Some(env_config) = &config.spark_config {
935        SdkBuilder::build_spark_wallet_config(config.network.into(), env_config)?
936    } else {
937        SparkWalletConfig::default_config(config.network.into())
938    };
939    spark_wallet_config.operator_pool = spark_wallet_config
940        .operator_pool
941        .with_user_agent(Some(user_agent.to_string()));
942    spark_wallet_config.service_provider_config.user_agent = Some(user_agent.to_string());
943    spark_wallet_config.leaf_auto_optimize_enabled =
944        background_services_enabled && config.leaf_optimization_config.auto_enabled;
945    spark_wallet_config.leaf_optimization_options.multiplicity =
946        config.leaf_optimization_config.multiplicity;
947
948    let token_opt = &config.token_optimization_config;
949    let token_options = &mut spark_wallet_config.token_outputs_optimization_options;
950    token_options.target_output_count = token_opt.target_output_count;
951    token_options.min_outputs_threshold = token_opt.min_outputs_threshold;
952    // Only override when disabled; enabled keeps the network default interval.
953    if !token_opt.auto_enabled || !background_services_enabled {
954        token_options.auto_optimize_interval = None;
955    }
956    spark_wallet_config.max_concurrent_claims = config.max_concurrent_claims;
957    Ok(spark_wallet_config)
958}
959
960/// Wraps the resolved session store (or an in-memory default) in the in-memory
961/// caching layer. Tokens are stored as-is: the SDK applies no encryption (see
962/// [`SdkBuilder::with_session_store`] to layer your own).
963///
964/// A token a server later rejects (a stale cached one, or one a prior SDK
965/// version wrote in a format this version does not recognize) self-heals: the
966/// request layer force-refreshes the session on an auth error and re-authenticates.
967fn wrap_session_store(session_store: Option<Arc<dyn SessionStore>>) -> Arc<dyn SessionStore> {
968    let inner = session_store.unwrap_or_else(|| Arc::new(InMemorySessionStore::default()));
969    Arc::new(crate::session_store::CachingSessionStore::new(inner))
970}
971
972/// Builds the [`SparkWallet`] from the assembled config, signers and stores.
973async fn build_spark_wallet(params: BuildSparkWalletParams) -> Result<Arc<SparkWallet>, SdkError> {
974    let mut wallet_builder = spark_wallet::WalletBuilder::new(params.config, params.spark_signer)
975        .with_session_store(params.session_store);
976    if let Some(receiver) = params.shutdown_receiver {
977        wallet_builder = wallet_builder.with_cancellation_token(receiver);
978    }
979    if let Some(provider) = &params.context.jwt_header_provider {
980        wallet_builder = wallet_builder
981            .with_so_extra_header_provider(
982                Arc::clone(provider) as Arc<dyn spark_wallet::HeaderProvider>
983            )
984            .with_ssp_extra_header_provider(
985                Arc::clone(provider) as Arc<dyn spark_wallet::HeaderProvider>
986            );
987    }
988    if let Some(observer) = params.payment_observer {
989        let observer: Arc<dyn spark_wallet::TransferObserver> =
990            Arc::new(SparkTransferObserver::new(observer));
991        wallet_builder = wallet_builder.with_transfer_observer(observer);
992    }
993    if let Some(tree_store) = params.tree_store {
994        wallet_builder = wallet_builder.with_tree_store(tree_store);
995    }
996    if let Some(token_output_store) = params.token_output_store {
997        wallet_builder = wallet_builder.with_token_output_store(token_output_store);
998    }
999    wallet_builder = wallet_builder.with_ssp_http_client(params.context.http_client.clone());
1000    wallet_builder =
1001        wallet_builder.with_connection_manager(params.context.connection_manager.clone());
1002    Ok(Arc::new(wallet_builder.build().await?))
1003}
1004
1005/// Resolves the LNURL server client: explicit override → built from
1006/// `config.lnurl_domain` → none.
1007fn resolve_lnurl_server_client(
1008    explicit: Option<Arc<dyn LnurlServerClient>>,
1009    config: &Config,
1010    context: &SdkContext,
1011    spark_wallet: &Arc<SparkWallet>,
1012) -> Option<Arc<dyn LnurlServerClient>> {
1013    if let Some(client) = explicit {
1014        return Some(client);
1015    }
1016    config.lnurl_domain.as_ref().map(|domain| {
1017        Arc::new(DefaultLnurlServerClient::new(
1018            context.http_client.clone(),
1019            domain.clone(),
1020            config.api_key.clone(),
1021            Arc::clone(spark_wallet),
1022        )) as Arc<dyn LnurlServerClient>
1023    })
1024}
1025
1026/// Wraps the base storage with the real-time-sync layer when configured and
1027/// background services are enabled. Otherwise returns the storage unchanged.
1028#[allow(clippy::too_many_arguments)]
1029async fn maybe_wrap_storage_with_real_time_sync(
1030    storage: Arc<dyn crate::persist::Storage>,
1031    config: &Config,
1032    background_services_enabled: bool,
1033    user_agent: String,
1034    rtsync_signer: Option<Arc<RTSyncSigner>>,
1035    shutdown_receiver: watch::Receiver<()>,
1036    event_emitter: Arc<EventEmitter>,
1037    lnurl_server_client: Option<Arc<dyn LnurlServerClient>>,
1038) -> Result<Arc<dyn crate::persist::Storage>, SdkError> {
1039    // `validate_signer_capabilities` rejects real-time sync without an
1040    // ECIES-capable signer, so `rtsync_signer` is present whenever the URL is
1041    // set; a missing signer can't be reached and falls through to the no-op arm.
1042    match (&config.real_time_sync_server_url, rtsync_signer) {
1043        (Some(server_url), Some(signer)) if background_services_enabled => {
1044            init_and_start_real_time_sync(RealTimeSyncParams {
1045                server_url: server_url.clone(),
1046                api_key: config.api_key.clone(),
1047                user_agent,
1048                proxy: config.proxy.as_ref().map(platform_utils::ProxyConfig::from),
1049                signer,
1050                storage,
1051                shutdown_receiver,
1052                event_emitter,
1053                lnurl_server_client,
1054            })
1055            .await
1056        }
1057        _ => Ok(storage),
1058    }
1059}
1060
1061/// Builds the [`FlashnetTokenConverter`] used for in-SDK token conversion.
1062fn build_token_converter(
1063    config: &Config,
1064    storage: &Arc<dyn crate::persist::Storage>,
1065    spark_wallet: &Arc<SparkWallet>,
1066    context: &SdkContext,
1067) -> Arc<dyn TokenConverter> {
1068    let flashnet_config = FlashnetConfig::default_config(
1069        config.network.into(),
1070        DEFAULT_INTEGRATOR_PUBKEY
1071            .parse()
1072            .ok()
1073            .map(|pubkey| IntegratorConfig {
1074                pubkey,
1075                fee_bps: DEFAULT_INTEGRATOR_FEE_BPS,
1076            }),
1077    );
1078    Arc::new(FlashnetTokenConverter::new(
1079        flashnet_config,
1080        Arc::clone(storage),
1081        Arc::clone(spark_wallet),
1082        config.network,
1083        context.http_client.clone(),
1084    ))
1085}
1086
1087/// Builds the optional [`StableBalance`] middleware, which must be registered
1088/// before [`TokenConversionMiddleware`] so it can see conversion child events.
1089async fn build_stable_balance(
1090    config: &Config,
1091    token_converter: &Arc<dyn TokenConverter>,
1092    spark_wallet: &Arc<SparkWallet>,
1093    storage: &Arc<dyn crate::persist::Storage>,
1094    event_emitter: &Arc<EventEmitter>,
1095) -> Option<Arc<StableBalance>> {
1096    let stable_config = config.stable_balance_config.as_ref()?;
1097    Some(Arc::new(
1098        StableBalance::new(
1099            stable_config.clone(),
1100            Arc::clone(token_converter),
1101            Arc::clone(spark_wallet),
1102            Arc::clone(storage),
1103            Arc::clone(event_emitter),
1104        )
1105        .await,
1106    ))
1107}
1108
1109/// Builds the cross-chain context: provider registry + shared cached fiat
1110/// service. Returns an empty registry when `config.cross_chain_config` is unset.
1111fn build_cross_chain_context(
1112    config: &Config,
1113    breez_server: &Arc<BreezServer>,
1114    http_client: &Arc<dyn platform_utils::HttpClient>,
1115    spark_wallet: &Arc<SparkWallet>,
1116    storage: &Arc<dyn crate::persist::Storage>,
1117    fiat_service: Arc<dyn breez_sdk_common::fiat::FiatService>,
1118    shutdown_receiver: watch::Receiver<()>,
1119) -> crate::cross_chain::CrossChainContext {
1120    // Cache scoped to cross-chain: providers + dispatcher share one TTL window.
1121    let cached_fiat: Arc<dyn breez_sdk_common::fiat::FiatService> =
1122        Arc::new(crate::cross_chain::CachedFiatService::new(
1123            fiat_service,
1124            crate::cross_chain::DEFAULT_FIAT_CACHE_TTL,
1125        ));
1126    let mut providers = crate::cross_chain::CrossChainContext::new(Arc::clone(&cached_fiat));
1127    if config.cross_chain_config.is_none() {
1128        return providers;
1129    }
1130
1131    // Orchestra cross-chain is mainnet-only. Its base URL and API key are
1132    // fetched from Breez server (so the key can be rotated and revoked without
1133    // an SDK release) lazily on first cross-chain use, not at connect: see
1134    // `BreezServerOrchestraConfigResolver`. There is no bundled fallback key.
1135    if matches!(config.network, Network::Mainnet) {
1136        let config_resolver = Arc::new(
1137            crate::cross_chain::BreezServerOrchestraConfigResolver::new(Arc::clone(breez_server)),
1138        );
1139        providers.insert(
1140            crate::cross_chain::CrossChainProvider::Orchestra,
1141            Arc::new(crate::cross_chain::OrchestraService::new(
1142                config_resolver,
1143                Arc::clone(spark_wallet),
1144                Arc::clone(storage),
1145                Arc::clone(&cached_fiat),
1146                Arc::clone(http_client),
1147                shutdown_receiver,
1148            )),
1149        );
1150    }
1151
1152    providers
1153}
1154
1155#[cfg(test)]
1156#[cfg(feature = "sqlite")]
1157mod tests {
1158    use super::SdkBuilder;
1159    use crate::{Network, SdkError, default_config};
1160
1161    #[test]
1162    fn default_config_spark_config_builds_valid_wallet_config() {
1163        for network in [Network::Mainnet, Network::Regtest] {
1164            let config = default_config(network);
1165            let spark_config = config
1166                .spark_config
1167                .as_ref()
1168                .expect("default_config must populate spark_config");
1169            SdkBuilder::build_spark_wallet_config(network.into(), spark_config).unwrap_or_else(
1170                |e| {
1171                    panic!(
1172                        "default_config({network:?}).spark_config failed to build SparkWalletConfig: {e}"
1173                    )
1174                },
1175            );
1176        }
1177    }
1178
1179    #[test]
1180    fn validate_signer_capabilities_gates_encryption_features() {
1181        // ECIES/HMAC supported: encryption-dependent features are allowed.
1182        let mut config = default_config(Network::Regtest);
1183        config.real_time_sync_server_url = Some("https://example.com".to_string());
1184        assert!(super::validate_signer_capabilities(&config, true).is_ok());
1185
1186        // No ECIES/HMAC + real-time sync: rejected with a clear error.
1187        let mut config = default_config(Network::Regtest);
1188        config.real_time_sync_server_url = Some("https://example.com".to_string());
1189        config.cross_chain_config = None;
1190        match super::validate_signer_capabilities(&config, false) {
1191            Err(SdkError::InvalidInput(m)) => {
1192                assert!(m.contains("Real-time sync"), "got: {m}");
1193            }
1194            other => panic!("expected InvalidInput, got {other:?}"),
1195        }
1196
1197        // No ECIES/HMAC + no encryption-dependent feature: allowed, so a
1198        // payments-only wallet still builds.
1199        let mut config = default_config(Network::Regtest);
1200        config.real_time_sync_server_url = None;
1201        config.cross_chain_config = None;
1202        assert!(super::validate_signer_capabilities(&config, false).is_ok());
1203    }
1204
1205    /// A signing-only signer yields no ECIES/HMAC, so `build_signers` leaves the
1206    /// dependent signers (real-time sync, lnurl-auth) unbuilt; a full/seed signer
1207    /// builds them.
1208    #[test]
1209    fn build_signers_signing_only_omits_ecies_dependent_signers() {
1210        use crate::error::SignerError;
1211        use crate::signer::external_types::{
1212            EcdsaSignatureBytes, MessageBytes, PublicKeyBytes, RecoverableEcdsaSignatureBytes,
1213            SchnorrSignatureBytes,
1214        };
1215        use crate::signer::{
1216            DefaultExternalSparkSigner, ExternalSigningSigner, ExternalSparkSigner,
1217        };
1218        use std::sync::Arc;
1219
1220        // A signing-only signer; `build_signers` only wraps it, never calls it.
1221        struct StubSigningSigner;
1222        #[macros::async_trait]
1223        impl ExternalSigningSigner for StubSigningSigner {
1224            async fn derive_public_key(&self, _p: String) -> Result<PublicKeyBytes, SignerError> {
1225                unreachable!("build_signers must not call the signer")
1226            }
1227            async fn sign_ecdsa(
1228                &self,
1229                _m: MessageBytes,
1230                _p: String,
1231            ) -> Result<EcdsaSignatureBytes, SignerError> {
1232                unreachable!("build_signers must not call the signer")
1233            }
1234            async fn sign_ecdsa_recoverable(
1235                &self,
1236                _m: MessageBytes,
1237                _p: String,
1238            ) -> Result<RecoverableEcdsaSignatureBytes, SignerError> {
1239                unreachable!("build_signers must not call the signer")
1240            }
1241            async fn sign_hash_schnorr(
1242                &self,
1243                _h: Vec<u8>,
1244                _p: String,
1245            ) -> Result<SchnorrSignatureBytes, SignerError> {
1246                unreachable!("build_signers must not call the signer")
1247            }
1248        }
1249
1250        let config = default_config(Network::Regtest);
1251
1252        // Seed source: full capability, so rtsync + lnurl-auth signers are built.
1253        let full = super::build_signers(
1254            &config,
1255            super::SignerSource::Seed {
1256                seed: test_seed(),
1257                account_number: None,
1258            },
1259        )
1260        .unwrap();
1261        assert!(full.ecies.is_some());
1262        assert!(full.rtsync.is_some());
1263        assert!(full.lnurl_auth.is_some());
1264
1265        // Signing-only external source: no ECIES/HMAC, so both are left unbuilt.
1266        let breez: Arc<dyn ExternalSigningSigner> = Arc::new(StubSigningSigner);
1267        let spark: Arc<dyn ExternalSparkSigner> = Arc::new(
1268            DefaultExternalSparkSigner::new(
1269                TEST_MNEMONIC.to_string(),
1270                None,
1271                Network::Regtest,
1272                None,
1273            )
1274            .unwrap(),
1275        );
1276        let signing_only = super::build_signers(
1277            &config,
1278            super::SignerSource::External {
1279                breez: super::ExternalBreez::SigningOnly(breez),
1280                spark,
1281            },
1282        )
1283        .unwrap();
1284        assert!(signing_only.ecies.is_none());
1285        assert!(signing_only.rtsync.is_none());
1286        assert!(signing_only.lnurl_auth.is_none());
1287    }
1288
1289    /// End to end: an offline SDK built with a signing-only signer has no
1290    /// lnurl-auth signer, and `lnurl_auth` rejects with `InvalidInput`.
1291    #[tokio::test]
1292    async fn signing_only_signer_build_disables_lnurl_auth() {
1293        use crate::error::SignerError;
1294        use crate::signer::external_types::{
1295            EcdsaSignatureBytes, MessageBytes, PublicKeyBytes, RecoverableEcdsaSignatureBytes,
1296            SchnorrSignatureBytes,
1297        };
1298        use crate::signer::{
1299            DefaultExternalSigner, DefaultExternalSparkSigner, ExternalBreezSigner,
1300            ExternalSigningSigner, ExternalSparkSigner,
1301        };
1302        use std::sync::Arc;
1303
1304        // A real signing-only signer: delegates its four methods to a
1305        // seed-derived reference signer.
1306        struct SigningOnly(DefaultExternalSigner);
1307        #[macros::async_trait]
1308        impl ExternalSigningSigner for SigningOnly {
1309            async fn derive_public_key(&self, path: String) -> Result<PublicKeyBytes, SignerError> {
1310                self.0.derive_public_key(path).await
1311            }
1312            async fn sign_ecdsa(
1313                &self,
1314                message: MessageBytes,
1315                path: String,
1316            ) -> Result<EcdsaSignatureBytes, SignerError> {
1317                self.0.sign_ecdsa(message, path).await
1318            }
1319            async fn sign_ecdsa_recoverable(
1320                &self,
1321                message: MessageBytes,
1322                path: String,
1323            ) -> Result<RecoverableEcdsaSignatureBytes, SignerError> {
1324                self.0.sign_ecdsa_recoverable(message, path).await
1325            }
1326            async fn sign_hash_schnorr(
1327                &self,
1328                hash: Vec<u8>,
1329                path: String,
1330            ) -> Result<SchnorrSignatureBytes, SignerError> {
1331                self.0.sign_hash_schnorr(hash, path).await
1332            }
1333        }
1334
1335        let mut config = default_config(Network::Regtest);
1336        // Keep the build offline: no real-time sync, no network private-mode init.
1337        config.real_time_sync_server_url = None;
1338        config.private_enabled_default = false;
1339
1340        let breez: Arc<dyn ExternalSigningSigner> = Arc::new(SigningOnly(
1341            DefaultExternalSigner::new(TEST_MNEMONIC.to_string(), None, Network::Regtest, None)
1342                .unwrap(),
1343        ));
1344        let spark: Arc<dyn ExternalSparkSigner> = Arc::new(
1345            DefaultExternalSparkSigner::new(
1346                TEST_MNEMONIC.to_string(),
1347                None,
1348                Network::Regtest,
1349                None,
1350            )
1351            .unwrap(),
1352        );
1353
1354        let sdk = SdkBuilder::new_with_signing_only_signer(config, breez, spark)
1355            .with_default_storage(unique_storage_dir("signing-only-lnurl"))
1356            .build()
1357            .await
1358            .expect("signing-only build should succeed");
1359
1360        assert!(
1361            sdk.lnurl_auth_signer.is_none(),
1362            "a signing-only signer must not build an lnurl-auth signer"
1363        );
1364
1365        let err = sdk
1366            .lnurl_auth(crate::LnurlAuthRequestDetails {
1367                k1: "00".repeat(32),
1368                action: None,
1369                domain: "example.com".to_string(),
1370                url: "https://example.com/lnurl-auth".to_string(),
1371            })
1372            .await
1373            .expect_err("lnurl_auth must fail for a signing-only signer");
1374        assert!(
1375            matches!(err, SdkError::Generic(_)),
1376            "expected Generic, got {err:?}"
1377        );
1378
1379        sdk.disconnect().await.expect("disconnect should succeed");
1380    }
1381
1382    /// A signing-only signer is rejected at build when a feature that needs ECIES
1383    /// is configured. Here real-time sync is set, so `build()` returns
1384    /// `InvalidInput` before any signer method runs.
1385    #[tokio::test]
1386    async fn signing_only_signer_build_rejects_ecies_features() {
1387        use crate::error::SignerError;
1388        use crate::signer::external_types::{
1389            EcdsaSignatureBytes, MessageBytes, PublicKeyBytes, RecoverableEcdsaSignatureBytes,
1390            SchnorrSignatureBytes,
1391        };
1392        use crate::signer::{
1393            DefaultExternalSparkSigner, ExternalSigningSigner, ExternalSparkSigner,
1394        };
1395        use std::sync::Arc;
1396
1397        // Never called: the build fails at capability validation before signing.
1398        struct StubSigningSigner;
1399        #[macros::async_trait]
1400        impl ExternalSigningSigner for StubSigningSigner {
1401            async fn derive_public_key(&self, _p: String) -> Result<PublicKeyBytes, SignerError> {
1402                unreachable!("build must fail before calling the signer")
1403            }
1404            async fn sign_ecdsa(
1405                &self,
1406                _m: MessageBytes,
1407                _p: String,
1408            ) -> Result<EcdsaSignatureBytes, SignerError> {
1409                unreachable!("build must fail before calling the signer")
1410            }
1411            async fn sign_ecdsa_recoverable(
1412                &self,
1413                _m: MessageBytes,
1414                _p: String,
1415            ) -> Result<RecoverableEcdsaSignatureBytes, SignerError> {
1416                unreachable!("build must fail before calling the signer")
1417            }
1418            async fn sign_hash_schnorr(
1419                &self,
1420                _h: Vec<u8>,
1421                _p: String,
1422            ) -> Result<SchnorrSignatureBytes, SignerError> {
1423                unreachable!("build must fail before calling the signer")
1424            }
1425        }
1426
1427        let mut config = default_config(Network::Regtest);
1428        config.real_time_sync_server_url = Some("https://example.com".to_string());
1429
1430        let breez: Arc<dyn ExternalSigningSigner> = Arc::new(StubSigningSigner);
1431        let spark: Arc<dyn ExternalSparkSigner> = Arc::new(
1432            DefaultExternalSparkSigner::new(
1433                TEST_MNEMONIC.to_string(),
1434                None,
1435                Network::Regtest,
1436                None,
1437            )
1438            .unwrap(),
1439        );
1440
1441        let result = SdkBuilder::new_with_signing_only_signer(config, breez, spark)
1442            .with_default_storage(unique_storage_dir("signing-only-reject-rtsync"))
1443            .build()
1444            .await;
1445        match result {
1446            Err(SdkError::InvalidInput(_)) => {}
1447            Ok(_) => panic!("build must reject real-time sync for a signing-only signer"),
1448            Err(other) => panic!("expected InvalidInput, got {other:?}"),
1449        }
1450    }
1451
1452    #[tokio::test]
1453    async fn wrap_session_store_stores_token_verbatim() {
1454        use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
1455        use spark_wallet::{Session, SessionStore, SessionStoreError};
1456        use std::collections::HashMap;
1457        use std::sync::{Arc, Mutex};
1458
1459        // Inner store that exposes the raw bytes the wrapper writes through.
1460        #[derive(Default)]
1461        struct InspectableInner {
1462            sessions: Mutex<HashMap<PublicKey, Session>>,
1463        }
1464        #[macros::async_trait]
1465        impl SessionStore for InspectableInner {
1466            async fn get_session(&self, key: &PublicKey) -> Result<Session, SessionStoreError> {
1467                self.sessions
1468                    .lock()
1469                    .unwrap()
1470                    .get(key)
1471                    .cloned()
1472                    .ok_or(SessionStoreError::NotFound)
1473            }
1474            async fn set_session(
1475                &self,
1476                key: &PublicKey,
1477                session: Session,
1478            ) -> Result<(), SessionStoreError> {
1479                self.sessions.lock().unwrap().insert(*key, session);
1480                Ok(())
1481            }
1482        }
1483        fn key() -> PublicKey {
1484            let secp = Secp256k1::new();
1485            PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[3u8; 32]).unwrap())
1486        }
1487
1488        let inner = Arc::new(InspectableInner::default());
1489        let store = super::wrap_session_store(Some(inner.clone()));
1490        let token = "bearer-token".to_string();
1491        store
1492            .set_session(
1493                &key(),
1494                Session {
1495                    token: token.clone(),
1496                    expiration: 1,
1497                },
1498            )
1499            .await
1500            .unwrap();
1501
1502        // The SDK applies no encryption or tagging: the inner store holds the
1503        // token verbatim, and it round-trips through the caching wrapper.
1504        let raw = inner.sessions.lock().unwrap().get(&key()).cloned().unwrap();
1505        assert_eq!(raw.token, token, "the SDK must store the token as-is");
1506        assert_eq!(store.get_session(&key()).await.unwrap().token, token);
1507    }
1508
1509    #[tokio::test]
1510    async fn server_mode_rejects_stable_balance_config() {
1511        use crate::{SdkError, StableBalanceConfig, StableBalanceToken, default_server_config};
1512
1513        let mut config = default_server_config(Network::Regtest);
1514        config.stable_balance_config = Some(StableBalanceConfig {
1515            tokens: vec![StableBalanceToken {
1516                label: "USDB".to_string(),
1517                token_identifier: "btkn1test".to_string(),
1518            }],
1519            default_active_label: None,
1520            threshold_sats: None,
1521            max_slippage_bps: None,
1522        });
1523
1524        let seed = test_seed();
1525        let result = SdkBuilder::new(config, seed).build().await;
1526        match result {
1527            Err(SdkError::InvalidInput(message)) => {
1528                assert!(message.contains("stable_balance_config"));
1529            }
1530            Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1531            Ok(_) => panic!("expected server mode with Stable Balance config to fail"),
1532        }
1533    }
1534
1535    #[tokio::test]
1536    async fn server_mode_rejects_real_time_sync_server_url() {
1537        use crate::{SdkError, default_server_config};
1538
1539        let mut config = default_server_config(Network::Regtest);
1540        config.real_time_sync_server_url = Some("https://example.com".to_string());
1541
1542        let seed = test_seed();
1543        let result = SdkBuilder::new(config, seed).build().await;
1544        match result {
1545            Err(SdkError::InvalidInput(message)) => {
1546                assert!(message.contains("real_time_sync_server_url"));
1547            }
1548            Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1549            Ok(_) => panic!("expected server mode with real_time_sync_server_url to fail"),
1550        }
1551    }
1552
1553    #[tokio::test]
1554    async fn server_mode_rejects_leaf_optimization_auto_enabled() {
1555        use crate::{SdkError, default_server_config};
1556
1557        let mut config = default_server_config(Network::Regtest);
1558        config.leaf_optimization_config.auto_enabled = true;
1559
1560        let seed = test_seed();
1561        let result = SdkBuilder::new(config, seed).build().await;
1562        match result {
1563            Err(SdkError::InvalidInput(message)) => {
1564                assert!(message.contains("leaf_optimization_config.auto_enabled"));
1565            }
1566            Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1567            Ok(_) => panic!("expected server mode with optimization auto_enabled to fail"),
1568        }
1569    }
1570
1571    #[tokio::test]
1572    async fn server_mode_rejects_token_optimization_auto_enabled() {
1573        use crate::{SdkError, default_server_config};
1574
1575        let mut config = default_server_config(Network::Regtest);
1576        config.token_optimization_config.auto_enabled = true;
1577
1578        let seed = test_seed();
1579        let result = SdkBuilder::new(config, seed).build().await;
1580        match result {
1581            Err(SdkError::InvalidInput(message)) => {
1582                assert!(message.contains("token_optimization_config.auto_enabled"));
1583            }
1584            Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1585            Ok(_) => panic!("expected server mode with optimization auto_enabled to fail"),
1586        }
1587    }
1588
1589    // ---- proxy ----
1590
1591    fn test_proxy() -> crate::ProxyConfig {
1592        crate::ProxyConfig {
1593            host: "127.0.0.1".to_string(),
1594            port: 9050,
1595            username: None,
1596            password: None,
1597        }
1598    }
1599
1600    /// Both cross-chain providers honour the proxy: Orchestra on the shared
1601    /// client, Boltz through its own config.
1602    #[test]
1603    fn validate_accepts_proxy_with_cross_chain_config() {
1604        use crate::{CrossChainConfig, default_config};
1605        let mut config = default_config(Network::Mainnet);
1606        config.proxy = Some(test_proxy());
1607        config.cross_chain_config = Some(CrossChainConfig::default());
1608
1609        assert!(config.validate().is_ok());
1610    }
1611
1612    #[test]
1613    fn validate_rejects_half_specified_proxy_credentials() {
1614        use crate::{SdkError, default_config};
1615        let mut config = default_config(Network::Mainnet);
1616        let mut proxy = test_proxy();
1617        proxy.username = Some("user".to_string());
1618        config.proxy = Some(proxy);
1619
1620        match config.validate() {
1621            Err(SdkError::InvalidInput(m)) => assert!(
1622                m.contains("username and password"),
1623                "expected credential-pair rejection, got: {m}"
1624            ),
1625            other => panic!("expected a username without a password to fail, got {other:?}"),
1626        }
1627    }
1628
1629    #[test]
1630    fn validate_rejects_empty_proxy_host_and_zero_port() {
1631        use crate::{SdkError, default_config};
1632        let mut config = default_config(Network::Mainnet);
1633
1634        let mut proxy = test_proxy();
1635        proxy.host = String::new();
1636        config.proxy = Some(proxy);
1637        assert!(matches!(config.validate(), Err(SdkError::InvalidInput(_))));
1638
1639        let mut proxy = test_proxy();
1640        proxy.port = 0;
1641        config.proxy = Some(proxy);
1642        assert!(matches!(config.validate(), Err(SdkError::InvalidInput(_))));
1643    }
1644
1645    /// A host that cannot appear in a URL authority has to be caught at
1646    /// connect: the client build downstream would otherwise be the first
1647    /// thing to notice, far from the config that caused it.
1648    #[test]
1649    fn validate_rejects_proxy_host_that_cannot_form_a_url() {
1650        use crate::{SdkError, default_config};
1651        for host in ["local host", "user@host", "socks5://127.0.0.1", "[::g]"] {
1652            let mut config = default_config(Network::Mainnet);
1653            let mut proxy = test_proxy();
1654            proxy.host = host.to_string();
1655            config.proxy = Some(proxy);
1656
1657            match config.validate() {
1658                Err(SdkError::InvalidInput(m)) => {
1659                    assert!(m.contains(host), "expected the host in the error, got: {m}");
1660                }
1661                other => panic!("expected host {host:?} to be rejected, got {other:?}"),
1662            }
1663        }
1664    }
1665
1666    /// A bare IPv6 literal is bracketed before it reaches a URL, so it stays
1667    /// valid rather than tripping the check above.
1668    #[test]
1669    fn validate_accepts_bare_ipv6_proxy_host() {
1670        use crate::default_config;
1671        let mut config = default_config(Network::Mainnet);
1672        let mut proxy = test_proxy();
1673        proxy.host = "::1".to_string();
1674        config.proxy = Some(proxy);
1675
1676        assert!(config.validate().is_ok());
1677    }
1678
1679    /// Balanced operator connections build their own connectors, so a proxy
1680    /// there would silently open the extra channels direct.
1681    #[tokio::test]
1682    async fn shared_context_rejects_proxy_with_balanced_connections() {
1683        use crate::{SdkContextConfig, SdkError, new_shared_sdk_context};
1684        let result = new_shared_sdk_context(SdkContextConfig {
1685            proxy: Some(test_proxy()),
1686            connections_per_operator: Some(4),
1687            ..SdkContextConfig::new(Network::Regtest)
1688        })
1689        .await;
1690
1691        match result.err() {
1692            Some(SdkError::InvalidInput(m)) => assert!(
1693                m.contains("connections_per_operator"),
1694                "expected balanced-connection rejection, got: {m}"
1695            ),
1696            other => panic!("expected proxy + balanced connections to fail, got {other:?}"),
1697        }
1698    }
1699
1700    /// The context owns the shared clients, so a config that disagrees with it
1701    /// would leave part of the traffic unproxied.
1702    #[tokio::test]
1703    async fn resolve_context_errors_on_proxy_mismatch() {
1704        use crate::{SdkContextConfig, default_config, new_shared_sdk_context};
1705        let mut config = default_config(Network::Regtest);
1706        config.proxy = Some(test_proxy());
1707
1708        let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
1709            .await
1710            .expect("regtest context");
1711
1712        let err = super::resolve_context(Some(ctx), &config)
1713            .await
1714            .err()
1715            .expect("expected mismatch error");
1716        assert!(
1717            err.to_string().contains("proxy does not match"),
1718            "unexpected error: {err}"
1719        );
1720    }
1721
1722    /// Regtest + `cross_chain_config` trips the Mainnet-only gate in
1723    /// `Config::validate` before reaching the server-mode reject in
1724    /// `build`. The server-mode gate is still in place (verified by the
1725    /// inline check in `build`); this test pins the more specific failure.
1726    #[tokio::test]
1727    async fn build_rejects_cross_chain_config_on_regtest() {
1728        use crate::{CrossChainConfig, SdkError, default_config};
1729        let mut config = default_config(Network::Regtest);
1730        config.cross_chain_config = Some(CrossChainConfig::default());
1731
1732        let seed = test_seed();
1733        let result = SdkBuilder::new(config, seed).build().await;
1734        match result {
1735            Err(SdkError::InvalidInput(m)) => {
1736                assert!(
1737                    m.contains("only available on Mainnet"),
1738                    "expected mainnet-only rejection, got: {m}"
1739                );
1740            }
1741            Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1742            Ok(_) => panic!("expected regtest with cross_chain_config to fail"),
1743        }
1744    }
1745
1746    /// Mainnet SDK with a caller-supplied Regtest context errors at `build()`
1747    /// — the context has no JWT provider so the partner JWT would be silently
1748    /// disabled.
1749    #[tokio::test]
1750    async fn build_errors_on_network_mismatch() {
1751        use crate::{SdkContextConfig, new_shared_sdk_context};
1752        let mut config = default_config(Network::Mainnet);
1753        config.api_key = Some("partner-key".to_string());
1754        let ctx = new_shared_sdk_context(SdkContextConfig {
1755            api_key: Some("partner-key".to_string()),
1756            ..SdkContextConfig::new(Network::Regtest)
1757        })
1758        .await
1759        .expect("regtest context");
1760        let err = SdkBuilder::new(config, test_seed())
1761            .with_shared_context(ctx)
1762            .with_default_storage("/tmp/breez-sdk-test-network-mismatch".to_string())
1763            .build()
1764            .await
1765            .err()
1766            .expect("expected network-mismatch error");
1767        assert!(
1768            err.to_string().contains("network/api_key do not match"),
1769            "unexpected error: {err}"
1770        );
1771    }
1772
1773    /// Mainnet SDK with a Mainnet context whose `api_key` differs from
1774    /// `Config`'s errors at `build()` — the JWT provider would sign with a
1775    /// different key than the integrator intended.
1776    #[tokio::test]
1777    #[allow(clippy::manual_assert)]
1778    async fn build_errors_on_api_key_mismatch() {
1779        use crate::{SdkContextConfig, new_shared_sdk_context};
1780        let mut config = default_config(Network::Mainnet);
1781        config.api_key = Some("intended-key".to_string());
1782        let ctx = new_shared_sdk_context(SdkContextConfig {
1783            api_key: Some("wrong-key".to_string()),
1784            ..SdkContextConfig::new(Network::Mainnet)
1785        })
1786        .await
1787        .expect("mainnet context");
1788        let err = SdkBuilder::new(config, test_seed())
1789            .with_shared_context(ctx)
1790            .with_default_storage("/tmp/breez-sdk-test-key-mismatch".to_string())
1791            .build()
1792            .await
1793            .err()
1794            .expect("expected api_key-mismatch error");
1795        assert!(
1796            err.to_string().contains("network/api_key do not match"),
1797            "unexpected error: {err}"
1798        );
1799    }
1800
1801    const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
1802
1803    fn test_seed() -> crate::Seed {
1804        crate::Seed::Mnemonic {
1805            mnemonic: TEST_MNEMONIC.to_string(),
1806            passphrase: None,
1807        }
1808    }
1809
1810    fn unique_storage_dir(name: &str) -> String {
1811        std::env::temp_dir()
1812            .join(format!("breez-sdk-test-{}-{}", name, uuid::Uuid::new_v4()))
1813            .to_string_lossy()
1814            .into_owned()
1815    }
1816
1817    /// Waits for the SDK object graph to be released. Background tasks drop
1818    /// their `BreezSdk` clones asynchronously after the shutdown signal, so
1819    /// poll briefly instead of asserting immediately after `drop`.
1820    async fn assert_sdk_graph_freed(
1821        event_emitter: std::sync::Weak<crate::EventEmitter>,
1822        spark_wallet: std::sync::Weak<spark_wallet::SparkWallet>,
1823    ) {
1824        for _ in 0..100 {
1825            if event_emitter.upgrade().is_none() && spark_wallet.upgrade().is_none() {
1826                return;
1827            }
1828            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1829        }
1830        panic!(
1831            "SDK object graph leaked after drop (EventEmitter alive: {}, SparkWallet alive: {})",
1832            event_emitter.upgrade().is_some(),
1833            spark_wallet.upgrade().is_some(),
1834        );
1835    }
1836
1837    /// Regression test for <https://github.com/breez/spark-sdk/issues/947>:
1838    /// each server-mode build → disconnect → drop lifecycle must release the
1839    /// whole per-instance object graph.
1840    #[tokio::test]
1841    async fn server_mode_sdk_graph_is_freed_on_drop() {
1842        use crate::default_server_config;
1843
1844        let config = default_server_config(Network::Regtest);
1845        let sdk = SdkBuilder::new(config, test_seed())
1846            .with_default_storage(unique_storage_dir("leak-server"))
1847            .build()
1848            .await
1849            .expect("server-mode build should succeed");
1850
1851        let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
1852        let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
1853
1854        sdk.disconnect().await.expect("disconnect should succeed");
1855        drop(sdk);
1856
1857        assert_sdk_graph_freed(event_emitter, spark_wallet).await;
1858    }
1859
1860    /// Network-dependent variant of `server_mode_sdk_graph_is_freed_on_drop`
1861    /// that runs a real `sync_wallet` against the regtest operators before
1862    /// dropping, proving the sync path retains nothing either.
1863    #[tokio::test]
1864    #[ignore = "requires network access to the regtest operators"]
1865    async fn server_mode_sdk_graph_is_freed_on_drop_after_sync() {
1866        use crate::{SyncWalletRequest, default_server_config};
1867
1868        let config = default_server_config(Network::Regtest);
1869        let sdk = SdkBuilder::new(config, test_seed())
1870            .with_default_storage(unique_storage_dir("leak-server-sync"))
1871            .build()
1872            .await
1873            .expect("server-mode build should succeed");
1874
1875        let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
1876        let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
1877
1878        sdk.sync_wallet(SyncWalletRequest {})
1879            .await
1880            .expect("sync_wallet should succeed");
1881
1882        sdk.disconnect().await.expect("disconnect should succeed");
1883        drop(sdk);
1884
1885        assert_sdk_graph_freed(event_emitter, spark_wallet).await;
1886    }
1887
1888    /// Client-mode counterpart of the issue #947 regression test: after
1889    /// `disconnect`, dropping the SDK must release the whole object graph.
1890    #[tokio::test]
1891    async fn client_mode_sdk_graph_is_freed_after_disconnect_and_drop() {
1892        let mut config = default_config(Network::Regtest);
1893        // Keep the build offline: no real-time sync connection and no
1894        // network-backed private-mode setup on startup.
1895        config.real_time_sync_server_url = None;
1896        config.private_enabled_default = false;
1897
1898        let sdk = SdkBuilder::new(config, test_seed())
1899            .with_default_storage(unique_storage_dir("leak-client"))
1900            .build()
1901            .await
1902            .expect("client-mode build should succeed");
1903
1904        let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
1905        let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
1906
1907        tokio::time::timeout(std::time::Duration::from_secs(30), sdk.disconnect())
1908            .await
1909            .expect("disconnect should not hang")
1910            .expect("disconnect should succeed");
1911        drop(sdk);
1912
1913        assert_sdk_graph_freed(event_emitter, spark_wallet).await;
1914    }
1915
1916    /// Variant of the client-mode leak regression test with real-time sync
1917    /// and stable balance enabled: the sync record handler and the conversion
1918    /// worker hold the emitter strongly, so their tasks must release it on
1919    /// `disconnect`.
1920    #[tokio::test]
1921    async fn client_mode_sdk_graph_is_freed_with_real_time_sync_enabled() {
1922        use crate::{StableBalanceConfig, StableBalanceToken};
1923
1924        let mut config = default_config(Network::Regtest);
1925        // Unreachable sync server: the gRPC client connects lazily, so the
1926        // build succeeds offline and the sync tasks just retry in the
1927        // background until shutdown.
1928        config.real_time_sync_server_url = Some("http://127.0.0.1:9".to_string());
1929        config.private_enabled_default = false;
1930        // No active token, so the conversion worker spawns without making
1931        // network calls.
1932        config.stable_balance_config = Some(StableBalanceConfig {
1933            tokens: vec![StableBalanceToken {
1934                label: "USDB".to_string(),
1935                token_identifier: "btkn1test".to_string(),
1936            }],
1937            default_active_label: None,
1938            threshold_sats: None,
1939            max_slippage_bps: None,
1940        });
1941
1942        let sdk = SdkBuilder::new(config, test_seed())
1943            .with_default_storage(unique_storage_dir("leak-client-rtsync"))
1944            .build()
1945            .await
1946            .expect("client-mode build with real-time sync should succeed");
1947
1948        let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
1949        let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
1950
1951        tokio::time::timeout(std::time::Duration::from_secs(30), sdk.disconnect())
1952            .await
1953            .expect("disconnect should not hang")
1954            .expect("disconnect should succeed");
1955        drop(sdk);
1956
1957        assert_sdk_graph_freed(event_emitter, spark_wallet).await;
1958    }
1959
1960    /// `disconnect` must unregister event listeners, so a listener that
1961    /// references the SDK instance cannot keep it alive afterwards.
1962    #[tokio::test]
1963    async fn disconnect_unregisters_event_listeners() {
1964        use crate::{SdkEvent, default_server_config, events::EventListener};
1965
1966        struct NoopListener;
1967
1968        #[macros::async_trait]
1969        impl EventListener for NoopListener {
1970            async fn on_event(&self, _event: SdkEvent) {}
1971        }
1972
1973        let storage_dir = std::env::temp_dir()
1974            .join(format!(
1975                "breez-sdk-test-listener-clear-{}",
1976                uuid::Uuid::new_v4()
1977            ))
1978            .to_string_lossy()
1979            .into_owned();
1980        let sdk = SdkBuilder::new(default_server_config(Network::Regtest), test_seed())
1981            .with_default_storage(storage_dir)
1982            .build()
1983            .await
1984            .expect("server-mode build should succeed");
1985
1986        let id = sdk.add_event_listener(Box::new(NoopListener)).await;
1987
1988        sdk.disconnect().await.expect("disconnect should succeed");
1989
1990        assert!(
1991            !sdk.remove_event_listener(&id).await,
1992            "listener should already be unregistered by disconnect"
1993        );
1994    }
1995
1996    fn test_spark_signer() -> std::sync::Arc<dyn spark_wallet::SparkSigner> {
1997        use std::sync::Arc;
1998
1999        let seed = test_seed();
2000        let seed_bytes = seed.to_bytes().unwrap();
2001        let master =
2002            spark_wallet::account_master_key(&seed_bytes, Network::Regtest.into(), None).unwrap();
2003        Arc::new(spark_wallet::SparkSignerAdapter::new(Arc::new(
2004            spark_wallet::DefaultSigner::from_master(master),
2005        )))
2006    }
2007
2008    // ---- validate_server_mode ----
2009
2010    #[test]
2011    fn validate_server_mode_ok_when_background_enabled() {
2012        use crate::{StableBalanceConfig, StableBalanceToken, default_server_config};
2013        let mut config = default_server_config(Network::Regtest);
2014        config.stable_balance_config = Some(StableBalanceConfig {
2015            tokens: vec![StableBalanceToken {
2016                label: "USDB".to_string(),
2017                token_identifier: "btkn1test".to_string(),
2018            }],
2019            default_active_label: None,
2020            threshold_sats: None,
2021            max_slippage_bps: None,
2022        });
2023        config.real_time_sync_server_url = Some("https://example.com".to_string());
2024        config.leaf_optimization_config.auto_enabled = true;
2025        config.token_optimization_config.auto_enabled = true;
2026        // background_services_enabled = true → none of the gates fire.
2027        assert!(super::validate_server_mode(&config, true).is_ok());
2028    }
2029
2030    #[test]
2031    fn validate_server_mode_ok_in_server_mode_without_background_features() {
2032        use crate::default_server_config;
2033        let config = default_server_config(Network::Regtest);
2034        assert!(super::validate_server_mode(&config, false).is_ok());
2035    }
2036
2037    #[test]
2038    fn validate_server_mode_rejects_stable_balance_directly() {
2039        use crate::{StableBalanceConfig, StableBalanceToken, default_server_config};
2040        let mut config = default_server_config(Network::Regtest);
2041        config.stable_balance_config = Some(StableBalanceConfig {
2042            tokens: vec![StableBalanceToken {
2043                label: "USDB".to_string(),
2044                token_identifier: "btkn1test".to_string(),
2045            }],
2046            default_active_label: None,
2047            threshold_sats: None,
2048            max_slippage_bps: None,
2049        });
2050        match super::validate_server_mode(&config, false) {
2051            Err(SdkError::InvalidInput(m)) => assert!(m.contains("stable_balance_config")),
2052            other => panic!("expected InvalidInput, got {other:?}"),
2053        }
2054    }
2055
2056    #[test]
2057    fn validate_server_mode_rejects_real_time_sync_directly() {
2058        use crate::default_server_config;
2059        let mut config = default_server_config(Network::Regtest);
2060        config.real_time_sync_server_url = Some("https://example.com".to_string());
2061        match super::validate_server_mode(&config, false) {
2062            Err(SdkError::InvalidInput(m)) => assert!(m.contains("real_time_sync_server_url")),
2063            other => panic!("expected InvalidInput, got {other:?}"),
2064        }
2065    }
2066
2067    #[test]
2068    fn validate_server_mode_rejects_leaf_auto_optimize_directly() {
2069        use crate::default_server_config;
2070        let mut config = default_server_config(Network::Regtest);
2071        config.leaf_optimization_config.auto_enabled = true;
2072        match super::validate_server_mode(&config, false) {
2073            Err(SdkError::InvalidInput(m)) => {
2074                assert!(m.contains("leaf_optimization_config.auto_enabled"));
2075            }
2076            other => panic!("expected InvalidInput, got {other:?}"),
2077        }
2078    }
2079
2080    #[test]
2081    fn validate_server_mode_rejects_token_auto_optimize_directly() {
2082        use crate::default_server_config;
2083        let mut config = default_server_config(Network::Regtest);
2084        config.token_optimization_config.auto_enabled = true;
2085        match super::validate_server_mode(&config, false) {
2086            Err(SdkError::InvalidInput(m)) => {
2087                assert!(m.contains("token_optimization_config.auto_enabled"));
2088            }
2089            other => panic!("expected InvalidInput, got {other:?}"),
2090        }
2091    }
2092
2093    #[test]
2094    fn validate_server_mode_rejects_cross_chain_directly() {
2095        use crate::{CrossChainConfig, default_server_config};
2096        let mut config = default_server_config(Network::Regtest);
2097        config.cross_chain_config = Some(CrossChainConfig::default());
2098        match super::validate_server_mode(&config, false) {
2099            Err(SdkError::InvalidInput(m)) => assert!(m.contains("Cross-chain config")),
2100            other => panic!("expected InvalidInput, got {other:?}"),
2101        }
2102    }
2103
2104    // ---- finalize_spark_wallet_config ----
2105
2106    #[test]
2107    fn finalize_spark_wallet_config_disabled_background_forces_leaf_auto_off() {
2108        let mut config = default_config(Network::Regtest);
2109        config.leaf_optimization_config.auto_enabled = true;
2110        let result = super::finalize_spark_wallet_config(&config, "test-agent", false).unwrap();
2111        assert!(!result.leaf_auto_optimize_enabled);
2112    }
2113
2114    #[test]
2115    fn finalize_spark_wallet_config_disabled_background_clears_token_auto_interval() {
2116        let mut config = default_config(Network::Regtest);
2117        config.token_optimization_config.auto_enabled = true;
2118        let result = super::finalize_spark_wallet_config(&config, "test-agent", false).unwrap();
2119        assert!(
2120            result
2121                .token_outputs_optimization_options
2122                .auto_optimize_interval
2123                .is_none()
2124        );
2125    }
2126
2127    #[test]
2128    fn finalize_spark_wallet_config_enabled_background_respects_leaf_auto_optimize() {
2129        let mut config = default_config(Network::Regtest);
2130        config.leaf_optimization_config.auto_enabled = true;
2131        let result = super::finalize_spark_wallet_config(&config, "test-agent", true).unwrap();
2132        assert!(result.leaf_auto_optimize_enabled);
2133    }
2134
2135    #[test]
2136    fn finalize_spark_wallet_config_applies_user_agent() {
2137        let config = default_config(Network::Regtest);
2138        let result = super::finalize_spark_wallet_config(&config, "my-app/1.0", true).unwrap();
2139        assert_eq!(
2140            result.service_provider_config.user_agent.as_deref(),
2141            Some("my-app/1.0")
2142        );
2143    }
2144
2145    // ---- resolve_context ----
2146
2147    #[tokio::test]
2148    async fn resolve_context_errors_on_network_mismatch() {
2149        use crate::{SdkContextConfig, new_shared_sdk_context};
2150        let config = default_config(Network::Mainnet);
2151        let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
2152            .await
2153            .expect("regtest context");
2154        let err = super::resolve_context(Some(ctx), &config)
2155            .await
2156            .err()
2157            .expect("expected mismatch error");
2158        assert!(
2159            err.to_string().contains("network/api_key do not match"),
2160            "unexpected error: {err}"
2161        );
2162    }
2163
2164    #[tokio::test]
2165    async fn resolve_context_errors_on_api_key_mismatch() {
2166        use crate::{SdkContextConfig, new_shared_sdk_context};
2167        let mut config = default_config(Network::Mainnet);
2168        config.api_key = Some("intended-key".to_string());
2169        let ctx = new_shared_sdk_context(SdkContextConfig {
2170            api_key: Some("wrong-key".to_string()),
2171            ..SdkContextConfig::new(Network::Mainnet)
2172        })
2173        .await
2174        .expect("mainnet context");
2175        let err = super::resolve_context(Some(ctx), &config)
2176            .await
2177            .err()
2178            .expect("expected mismatch error");
2179        assert!(
2180            err.to_string().contains("network/api_key do not match"),
2181            "unexpected error: {err}"
2182        );
2183    }
2184
2185    // ---- resolve_storage ----
2186
2187    #[tokio::test]
2188    async fn resolve_storage_errors_when_neither_supplied() {
2189        use crate::{SdkContextConfig, new_shared_sdk_context};
2190        let config = default_config(Network::Regtest);
2191        let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
2192            .await
2193            .expect("regtest context");
2194        let signer = test_spark_signer();
2195        let err = super::resolve_storage(None, &ctx, &signer, &config)
2196            .await
2197            .err()
2198            .expect("expected no-storage error");
2199        assert!(
2200            err.to_string().contains("No storage configured"),
2201            "unexpected error: {err}"
2202        );
2203    }
2204
2205    #[tokio::test]
2206    async fn resolve_storage_errors_when_supplied_on_both_builder_and_context() {
2207        use crate::{SdkContextConfig, default_storage, new_shared_sdk_context};
2208        let config = default_config(Network::Regtest);
2209        let ctx = new_shared_sdk_context(SdkContextConfig {
2210            storage: Some(default_storage(
2211                "/tmp/breez-sdk-test-resolve-storage-ctx".to_string(),
2212            )),
2213            ..SdkContextConfig::new(Network::Regtest)
2214        })
2215        .await
2216        .expect("regtest context");
2217        let signer = test_spark_signer();
2218        let builder_storage =
2219            default_storage("/tmp/breez-sdk-test-resolve-storage-builder".to_string());
2220        let err = super::resolve_storage(Some(builder_storage), &ctx, &signer, &config)
2221            .await
2222            .err()
2223            .expect("expected duplicate-storage error");
2224        assert!(
2225            err.to_string()
2226                .contains("storage is configured on both the SdkBuilder and the shared SdkContext"),
2227            "unexpected error: {err}"
2228        );
2229    }
2230}