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, time::Duration};
6
7use breez_sdk_common::breez_server::BreezServer;
8use breez_sdk_common::buy::moonpay::MoonpayProvider;
9
10use spark_wallet::{
11    InMemorySessionStore, PublicKey, SessionStore, SparkSigner, SparkWallet, SparkWalletConfig,
12};
13use tokio::sync::watch;
14use tracing::{Instrument, debug, info, warn};
15
16use flashnet::rewards::{REWARDS_MAINNET_BASE_URL, RewardsClient};
17use flashnet::{FlashnetConfig, IntegratorConfig};
18
19use crate::{
20    Credentials, EventEmitter, FiatService, FiatServiceWrapper, Network, Seed,
21    chain::{
22        BitcoinChainService, FallbackChainService, ValidatingChainService,
23        rest_client::{BasicAuth, ChainApiType, RestClientChainService},
24    },
25    error::SdkError,
26    lnurl::{DefaultLnurlServerClient, LnurlServerClient},
27    models::Config,
28    payment_observer::{PaymentObserver, SparkTransferObserver},
29    persist::backend::{ResolvedStores, StorageBackend},
30    realtime_sync::{RealTimeSyncParams, init_and_start_real_time_sync},
31    rewards::RewardsDelegator,
32    sdk::{BreezSdk, BreezSdkParams, SdkRuntime, SyncCoordinator, runtime_from_config},
33    sdk_context::{SdkContext, SdkContextConfig, new_shared_sdk_context},
34    signer::{breez::BreezSignerImpl, lnurl_auth::LnurlAuthSignerAdapter, rtsync::RTSyncSigner},
35    stable_balance::StableBalance,
36    token_conversion::TokenConversionMiddleware,
37    token_conversion::{
38        DEFAULT_INTEGRATOR_FEE_BPS, DEFAULT_INTEGRATOR_PUBKEY, FlashnetTokenConverter,
39        TokenConverter,
40    },
41};
42
43/// One backend added by [`SdkBuilder::with_rest_chain_service`].
44///
45/// Stored on the builder and resolved during `build()` so the resulting
46/// `RestClientChainService` reuses the shared HTTP client from the
47/// [`SdkContext`](crate::SdkContext).
48#[derive(Clone)]
49struct RestChainServiceConfig {
50    url: String,
51    api_type: ChainApiType,
52    credentials: Option<Credentials>,
53}
54
55/// Source for the signer - either a seed or an external signer implementation
56/// External SDK-layer signer, in one of its two capability profiles. The profile
57/// is chosen by the type the integrator supplies at connect, not a runtime flag:
58/// a [`SigningOnly`](ExternalBreez::SigningOnly) signer can't perform the SDK's
59/// local ECIES/HMAC operations.
60#[derive(Clone)]
61enum ExternalBreez {
62    Full(Arc<dyn crate::signer::ExternalBreezSigner>),
63    SigningOnly(Arc<dyn crate::signer::ExternalSigningSigner>),
64}
65
66#[derive(Clone)]
67enum SignerSource {
68    Seed {
69        seed: Seed,
70        account_number: Option<u32>,
71    },
72    External {
73        breez: ExternalBreez,
74        spark: Arc<dyn crate::signer::ExternalSparkSigner>,
75    },
76}
77
78/// The signers derived from a single signer source. `ecies` is absent for a
79/// signing-only signer; the features that depend on it (`rtsync`, session-token
80/// encryption, cross-chain) and on HMAC (`lnurl_auth`) are then absent too.
81struct Signers {
82    ecies: Option<Arc<dyn crate::signer::EciesSigner>>,
83    spark: Arc<dyn SparkSigner>,
84    rtsync: Option<Arc<RTSyncSigner>>,
85    lnurl_auth: Option<Arc<LnurlAuthSignerAdapter>>,
86}
87
88/// Inputs to [`build_spark_wallet`] — bundled to avoid an >8-argument helper.
89struct BuildSparkWalletParams {
90    config: SparkWalletConfig,
91    spark_signer: Arc<dyn SparkSigner>,
92    session_store: Arc<dyn SessionStore>,
93    /// `Some` only in client mode. Wiring it in server mode would leave the
94    /// wallet holding a stored `cancellation_token` receiver that's never
95    /// consumed (server mode never calls `start_background_processing`), which
96    /// would stall `disconnect`'s `Sender::closed()` await forever.
97    shutdown_receiver: Option<watch::Receiver<()>>,
98    tree_store: Option<Arc<dyn spark_wallet::TreeStore>>,
99    token_output_store: Option<Arc<dyn spark_wallet::TokenOutputStore>>,
100    payment_observer: Option<Arc<dyn PaymentObserver>>,
101    context: Arc<SdkContext>,
102}
103
104/// Builder for creating `BreezSdk` instances with customizable components.
105#[derive(Clone)]
106pub struct SdkBuilder {
107    config: Config,
108    signer_source: SignerSource,
109
110    storage: Option<Arc<dyn StorageBackend>>,
111    session_store: Option<Arc<dyn crate::session_store::SessionStore>>,
112    chain_service: Option<Arc<dyn BitcoinChainService>>,
113    rest_chain_service_configs: Vec<RestChainServiceConfig>,
114    fiat_service: Option<Arc<dyn FiatService>>,
115    lnurl_client: Option<Arc<dyn platform_utils::HttpClient>>,
116    lnurl_server_client: Option<Arc<dyn LnurlServerClient>>,
117    payment_observer: Option<Arc<dyn PaymentObserver>>,
118    context: Option<Arc<SdkContext>>,
119}
120
121impl SdkBuilder {
122    /// Creates a new `SdkBuilder` with the provided configuration and seed.
123    ///
124    /// For external signer support, use `new_with_signer` instead.
125    ///
126    /// # Arguments
127    /// - `config`: The configuration to be used.
128    /// - `seed`: The seed for wallet generation.
129    #[allow(clippy::needless_pass_by_value)]
130    pub fn new(config: Config, seed: Seed) -> Self {
131        SdkBuilder {
132            config,
133            signer_source: SignerSource::Seed {
134                seed,
135                account_number: None,
136            },
137            storage: None,
138            session_store: None,
139            chain_service: None,
140            rest_chain_service_configs: Vec::new(),
141            fiat_service: None,
142            lnurl_client: None,
143            lnurl_server_client: None,
144            payment_observer: None,
145            context: None,
146        }
147    }
148
149    /// Creates a new `SdkBuilder` with the provided configuration and external signers.
150    ///
151    /// # Arguments
152    /// - `config`: The configuration to be used.
153    /// - `breez_signer`: External signer for non-Spark SDK signing (LNURL-auth,
154    ///   real-time sync, message signing, ECIES).
155    /// - `spark_signer`: External high-level Spark signer for the Spark wallet.
156    #[allow(clippy::needless_pass_by_value)]
157    pub fn new_with_signer(
158        config: Config,
159        breez_signer: Arc<dyn crate::signer::ExternalBreezSigner>,
160        spark_signer: Arc<dyn crate::signer::ExternalSparkSigner>,
161    ) -> Self {
162        Self::with_external_signer(config, ExternalBreez::Full(breez_signer), spark_signer)
163    }
164
165    /// Creates a new `SdkBuilder` with a signing-only external signer.
166    ///
167    /// Use this for a signer that can't perform the SDK's local ECIES/HMAC
168    /// operations (for example a policy-restricted enclave). The SDK keeps
169    /// session tokens in plaintext and disables the features that rely on
170    /// ECIES/HMAC.
171    ///
172    /// # Arguments
173    /// - `config`: The configuration to be used.
174    /// - `breez_signer`: Signing-only external signer for non-Spark SDK signing.
175    /// - `spark_signer`: External high-level Spark signer for the Spark wallet.
176    #[allow(clippy::needless_pass_by_value)]
177    pub fn new_with_signing_only_signer(
178        config: Config,
179        breez_signer: Arc<dyn crate::signer::ExternalSigningSigner>,
180        spark_signer: Arc<dyn crate::signer::ExternalSparkSigner>,
181    ) -> Self {
182        Self::with_external_signer(
183            config,
184            ExternalBreez::SigningOnly(breez_signer),
185            spark_signer,
186        )
187    }
188
189    fn with_external_signer(
190        config: Config,
191        breez: ExternalBreez,
192        spark_signer: Arc<dyn crate::signer::ExternalSparkSigner>,
193    ) -> Self {
194        SdkBuilder {
195            config,
196            signer_source: SignerSource::External {
197                breez,
198                spark: spark_signer,
199            },
200            storage: None,
201            session_store: None,
202            chain_service: None,
203            rest_chain_service_configs: Vec::new(),
204            fiat_service: None,
205            lnurl_client: None,
206            lnurl_server_client: None,
207            payment_observer: None,
208            context: None,
209        }
210    }
211
212    /// Sets the account number for key derivation. All wallet keys derive from
213    /// the seed at `m/8797555'/<account number>'`, so each account number
214    /// yields an independent wallet from the same seed.
215    ///
216    /// When unset, the account number defaults to 0 on Regtest and Signet,
217    /// and 1 on Mainnet.
218    ///
219    /// Note: This only applies when using a seed-based signer. It has no effect
220    /// when using an external signer (created with `new_with_signer`).
221    ///
222    /// # Arguments
223    /// - `account_number`: The account number in the derivation path.
224    #[must_use]
225    pub fn with_account_number(mut self, account_number: u32) -> Self {
226        if let SignerSource::Seed {
227            account_number: ref mut an,
228            ..
229        } = self.signer_source
230        {
231            *an = Some(account_number);
232        }
233        self
234    }
235
236    #[cfg(feature = "sqlite")]
237    #[must_use]
238    /// Sets the root storage directory to initialize the default storage with.
239    /// This initializes both storage and real-time sync storage with the
240    /// default implementations.
241    /// Arguments:
242    /// - `storage_dir`: The data directory for storage.
243    pub fn with_default_storage(self, storage_dir: String) -> Self {
244        self.with_storage_backend(crate::default_storage(storage_dir))
245    }
246
247    #[must_use]
248    /// Sets the storage backend to be used by the SDK.
249    ///
250    /// Build the [`StorageBackend`](crate::StorageBackend) with
251    /// [`default_storage`](crate::default_storage),
252    /// [`postgres_storage`](crate::postgres_storage),
253    /// [`mysql_storage`](crate::mysql_storage) or
254    /// [`custom_storage`](crate::custom_storage).
255    /// Arguments:
256    /// - `storage`: The storage backend to be used.
257    pub fn with_storage_backend(mut self, storage: Arc<dyn StorageBackend>) -> Self {
258        self.storage = Some(storage);
259        self
260    }
261
262    /// Overrides the session store used to cache Spark operator and SSP auth
263    /// tokens, replacing the one the [`StorageBackend`] provides.
264    ///
265    /// Supply any [`SessionStore`]: a different persistence layer, or a decorator
266    /// that wraps the backend's own store (from
267    /// [`default_session_store`](crate::default_session_store)) to transform
268    /// tokens on read/write while keeping its persistence. One such transform is
269    /// at-rest encryption, which the SDK does not apply itself: wrap the store in
270    /// a [`SessionStore`] that encrypts in `set_session` and decrypts in
271    /// `get_session`.
272    ///
273    /// Arguments:
274    /// - `session_store`: The session store to use in place of the backend's.
275    #[must_use]
276    pub fn with_session_store(
277        mut self,
278        session_store: Arc<dyn crate::session_store::SessionStore>,
279    ) -> Self {
280        self.session_store = Some(session_store);
281        self
282    }
283
284    #[must_use]
285    /// **Deprecated.** Use
286    /// [`with_storage_backend`](Self::with_storage_backend) with
287    /// [`custom_storage`](crate::custom_storage).
288    /// Arguments:
289    /// - `storage`: The storage implementation to be used.
290    #[deprecated(note = "use `with_storage_backend(custom_storage(storage))`")]
291    pub fn with_storage(self, storage: Arc<dyn crate::Storage>) -> Self {
292        self.with_storage_backend(crate::custom_storage(storage))
293    }
294
295    /// **Deprecated.** Use
296    /// [`with_storage_backend`](Self::with_storage_backend) with
297    /// [`postgres_storage`](crate::postgres_storage).
298    #[cfg(any(feature = "postgres", feature = "db-storage-api"))]
299    #[deprecated(note = "use `with_storage_backend(postgres_storage(config)?)`")]
300    pub fn with_postgres_backend(
301        self,
302        config: crate::persist::postgres::PostgresStorageConfig,
303    ) -> Result<Self, SdkError> {
304        Ok(self.with_storage_backend(crate::postgres_storage(config)?))
305    }
306
307    /// **Deprecated.** Use
308    /// [`with_storage_backend`](Self::with_storage_backend) with
309    /// [`mysql_storage`](crate::mysql_storage).
310    #[cfg(any(feature = "mysql", feature = "db-storage-api"))]
311    #[deprecated(note = "use `with_storage_backend(mysql_storage(config)?)`")]
312    pub fn with_mysql_backend(
313        self,
314        config: crate::persist::mysql::MysqlStorageConfig,
315    ) -> Result<Self, SdkError> {
316        Ok(self.with_storage_backend(crate::mysql_storage(config)?))
317    }
318
319    /// Threads a shared [`SdkContext`] into this builder.
320    ///
321    /// Construct the context once via [`new_shared_sdk_context`] and pass the
322    /// same `Arc` to every `SdkBuilder` whose SDKs should share its underlying
323    /// resources (operator gRPC channels, SSP HTTP client, database pool).
324    ///
325    /// If not set, `build()` constructs a context internally from the SDK's
326    /// own network and api key — fine for a single-SDK process with no DB
327    /// backend.
328    #[must_use]
329    pub fn with_shared_context(mut self, context: Arc<SdkContext>) -> Self {
330        self.context = Some(context);
331        self
332    }
333
334    /// Sets the chain service to be used by the SDK.
335    /// Arguments:
336    /// - `chain_service`: The chain service to be used.
337    #[must_use]
338    pub fn with_chain_service(mut self, chain_service: Arc<dyn BitcoinChainService>) -> Self {
339        self.chain_service = Some(chain_service);
340        self.rest_chain_service_configs.clear();
341        self
342    }
343
344    /// Adds a REST chain service backend to be used by the SDK.
345    ///
346    /// Call it more than once to add fallbacks: backends are tried in the
347    /// order they were added, moving on to the next when one is unreachable.
348    /// Replaces any service set with [`with_chain_service`](Self::with_chain_service).
349    ///
350    /// Arguments:
351    /// - `url`: The base URL of the REST API.
352    /// - `api_type`: The API type to be used.
353    /// - `credentials`: Optional credentials for basic authentication.
354    #[must_use]
355    pub fn with_rest_chain_service(
356        mut self,
357        url: String,
358        api_type: ChainApiType,
359        credentials: Option<Credentials>,
360    ) -> Self {
361        self.chain_service = None;
362        self.rest_chain_service_configs
363            .push(RestChainServiceConfig {
364                url,
365                api_type,
366                credentials,
367            });
368        self
369    }
370
371    /// Sets the fiat service to be used by the SDK.
372    /// Arguments:
373    /// - `fiat_service`: The fiat service to be used.
374    #[must_use]
375    pub fn with_fiat_service(mut self, fiat_service: Arc<dyn FiatService>) -> Self {
376        self.fiat_service = Some(fiat_service);
377        self
378    }
379
380    #[must_use]
381    pub fn with_lnurl_client(mut self, lnurl_client: Arc<dyn crate::RestClient>) -> Self {
382        self.lnurl_client = Some(Arc::new(crate::common::rest::RestClientWrapper::new(
383            lnurl_client,
384        )));
385        self
386    }
387
388    #[must_use]
389    #[allow(unused)]
390    pub fn with_lnurl_server_client(
391        mut self,
392        lnurl_serverclient: Arc<dyn LnurlServerClient>,
393    ) -> Self {
394        self.lnurl_server_client = Some(lnurl_serverclient);
395        self
396    }
397
398    /// Sets the payment observer to be used by the SDK.
399    /// This observer will receive callbacks before outgoing payments for Lightning, Spark and onchain Bitcoin.
400    /// Arguments:
401    /// - `payment_observer`: The payment observer to be used.
402    #[must_use]
403    #[allow(unused)]
404    pub fn with_payment_observer(mut self, payment_observer: Arc<dyn PaymentObserver>) -> Self {
405        self.payment_observer = Some(payment_observer);
406        self
407    }
408
409    /// Builds a [`SparkWalletConfig`](spark_wallet::SparkWalletConfig) from a
410    /// [`SparkConfig`](crate::models::SparkConfig).
411    fn build_spark_wallet_config(
412        network: spark_wallet::Network,
413        env_config: &crate::models::SparkConfig,
414    ) -> Result<SparkWalletConfig, SdkError> {
415        spark_wallet::SparkDeployment::from(env_config)
416            .wallet_config(network)
417            .map_err(|e| SdkError::InvalidInput(e.to_string()))
418    }
419
420    /// Warns when a proxy is configured alongside a caller-supplied service.
421    ///
422    /// Those arrive as trait objects that already own their transport, which
423    /// the SDK cannot inspect or re-route: honouring the proxy is the caller's
424    /// responsibility. `with_rest_chain_service` is not listed, since it builds
425    /// on the context's proxied client.
426    fn warn_about_unproxied_overrides(&self) {
427        if self.config.proxy.is_none() {
428            return;
429        }
430        let overrides = [
431            ("chain service", self.chain_service.is_some()),
432            ("fiat service", self.fiat_service.is_some()),
433            ("LNURL HTTP client", self.lnurl_client.is_some()),
434            ("LNURL server client", self.lnurl_server_client.is_some()),
435        ];
436        for (name, supplied) in overrides {
437            if supplied {
438                warn!(
439                    "A proxy is configured but a custom {name} was supplied. \
440                     The SDK cannot route it: make sure it connects through the proxy itself."
441                );
442            }
443        }
444    }
445
446    /// Builds the `BreezSdk` instance from the configured components.
447    pub async fn build(self) -> Result<BreezSdk, SdkError> {
448        self.config.validate()?;
449        let runtime = runtime_from_config(&self.config);
450        validate_server_mode(&self.config, runtime.starts_background_services())?;
451
452        let signers = build_signers(&self.config, &self.signer_source)?;
453        validate_signer_capabilities(&self.config, signers.ecies.is_some())?;
454        let identity_public_key = signers
455            .spark
456            .get_identity_public_key()
457            .await
458            .map_err(|e| SdkError::Generic(e.to_string()))?;
459
460        // Everything the instance logs, here and in the tasks it spawns, is
461        // attributed to it.
462        let span = crate::logger::instance_span(&identity_public_key.to_string());
463        self.build_instance(runtime, signers, identity_public_key)
464            .instrument(span)
465            .await
466    }
467
468    /// Assembles the instance, reading top-to-bottom as a sequence of named
469    /// assembly steps.
470    #[allow(clippy::too_many_lines)]
471    async fn build_instance(
472        self,
473        runtime: SdkRuntime,
474        signers: Signers,
475        identity_public_key: PublicKey,
476    ) -> Result<BreezSdk, SdkError> {
477        let background_services_enabled = runtime.starts_background_services();
478        self.warn_about_unproxied_overrides();
479
480        let creates_context = self.context.is_none();
481        let context = resolve_context(self.context, &self.config).await?;
482        let chain_service = resolve_chain_service(
483            self.chain_service,
484            self.rest_chain_service_configs,
485            &context,
486            self.config.network,
487        )?;
488        let stores =
489            resolve_storage(self.storage, &context, &identity_public_key, &self.config).await?;
490        // Start the partner-JWT provider now that storage is resolved. When the builder
491        // creates its own context (no shared context supplied), bind the resolved
492        // storage so the token warm-starts and survives restarts; a shared context
493        // starts in-memory only.
494        if let Some(provider) = &context.jwt_header_provider {
495            provider.start(creates_context.then(|| Arc::clone(&stores.storage)));
496        }
497
498        let user_agent = crate::default_user_agent();
499        info!("Building sdk with user agent: {}", user_agent);
500
501        let transport_proxy = self
502            .config
503            .proxy
504            .as_ref()
505            .map(platform_utils::ProxyConfig::from);
506        // LNURL and lightning-address hosts are chosen by remote parties, so
507        // their traffic gets a client that revalidates every redirect hop:
508        // an unvalidated redirect would bypass the checks done on the
509        // original URL. It shares the shared client's user agent and proxy,
510        // and reqwest pools per host, so nothing is lost by keeping it
511        // separate.
512        let lnurl_http_client: Arc<dyn platform_utils::HttpClient> =
513            platform_utils::create_http_client_with_redirect_filter(
514                Some(&user_agent),
515                transport_proxy.as_ref(),
516                breez_sdk_common::lnurl::security::lnurl_redirect_filter(),
517            )
518            .map_err(|e| SdkError::Generic(format!("Failed to build LNURL HTTP client: {e}")))?;
519
520        let input_parser = Arc::new(crate::sdk::SdkInputParser::new(
521            breez_sdk_common::dns::Resolver::with_proxy(transport_proxy.as_ref())
522                .map_err(|e| SdkError::Generic(format!("Failed to build DNS resolver: {e}")))?,
523            Arc::clone(&lnurl_http_client),
524            Some(
525                self.config
526                    .get_all_external_input_parsers()
527                    .into_iter()
528                    .map(Into::into)
529                    .collect(),
530            ),
531        ));
532
533        let fiat_service: Arc<dyn breez_sdk_common::fiat::FiatService> = match self.fiat_service {
534            Some(service) => Arc::new(FiatServiceWrapper::new(service)),
535            None => context.breez_server.clone(),
536        };
537        let lnurl_client: Arc<dyn platform_utils::HttpClient> = self
538            .lnurl_client
539            .unwrap_or_else(|| Arc::clone(&lnurl_http_client));
540
541        let spark_wallet_config = finalize_spark_wallet_config(
542            &self.config,
543            &user_agent,
544            background_services_enabled,
545            signers.spark.is_remote(),
546        )?;
547        let shutdown_sender = watch::channel::<()>(()).0;
548        // An explicit `with_session_store` override (adapted to the wallet's
549        // session-store trait) wins; otherwise use the store the backend
550        // resolved (or an in-memory default).
551        let override_store = self.session_store.map(|s| {
552            Arc::new(crate::session_store::SessionStoreAdapter::new(s)) as Arc<dyn SessionStore>
553        });
554        let session_store =
555            wrap_session_store(override_store.or_else(|| stores.session_store.clone()));
556
557        let spark_wallet = build_spark_wallet(BuildSparkWalletParams {
558            config: spark_wallet_config,
559            spark_signer: Arc::clone(&signers.spark),
560            session_store,
561            shutdown_receiver: background_services_enabled.then(|| shutdown_sender.subscribe()),
562            tree_store: stores.tree_store.clone(),
563            token_output_store: stores.token_output_store.clone(),
564            payment_observer: self.payment_observer,
565            context: Arc::clone(&context),
566        })
567        .await?;
568
569        let lnurl_server_client = resolve_lnurl_server_client(
570            self.lnurl_server_client,
571            &self.config,
572            &context,
573            &spark_wallet,
574        );
575
576        let real_time_sync_active =
577            background_services_enabled && self.config.real_time_sync_server_url.is_some();
578        let event_emitter = Arc::new(EventEmitter::new(real_time_sync_active));
579
580        let storage = maybe_wrap_storage_with_real_time_sync(
581            Arc::clone(&stores.storage),
582            &self.config,
583            background_services_enabled,
584            user_agent,
585            signers.rtsync,
586            shutdown_sender.subscribe(),
587            Arc::clone(&event_emitter),
588            lnurl_server_client.clone(),
589        )
590        .await?;
591
592        let buy_bitcoin_provider = Arc::new(MoonpayProvider::new(context.breez_server.clone()));
593        let token_converter =
594            build_token_converter(&self.config, &storage, &spark_wallet, &context);
595        let rewards_delegator =
596            build_rewards_delegator(&self.config, &storage, &spark_wallet, &context);
597
598        let sync_coordinator = SyncCoordinator::new();
599
600        // Shared lightning-send helper used by `send_bolt11_invoice`.
601        let lightning_sender = Arc::new(crate::sdk::LightningSender::new(
602            Arc::clone(&spark_wallet),
603            Arc::clone(&storage),
604            Arc::clone(&event_emitter),
605            shutdown_sender.clone(),
606        ));
607
608        let (cross_chain_context, orchestra) = build_cross_chain_context(
609            &self.config,
610            background_services_enabled,
611            &context.breez_server,
612            &context.http_client,
613            &spark_wallet,
614            &storage,
615            Arc::clone(&fiat_service),
616            &event_emitter,
617            shutdown_sender.subscribe(),
618        )
619        .await;
620
621        let stable_balance = build_stable_balance(
622            &self.config,
623            &token_converter,
624            &spark_wallet,
625            &storage,
626            &event_emitter,
627        )
628        .await;
629
630        // Register TokenConversionMiddleware to suppress conversion child events
631        // before they reach external listeners (after StableBalance middleware).
632        event_emitter
633            .add_middleware(Box::new(TokenConversionMiddleware))
634            .await;
635
636        let sdk = BreezSdk::init_and_start(BreezSdkParams {
637            instance_id: identity_public_key.to_string(),
638            input_parser,
639            config: self.config,
640            storage,
641            chain_service,
642            fiat_service,
643            lnurl_client,
644            lnurl_server_client,
645            lnurl_auth_signer: signers.lnurl_auth,
646            shutdown_sender,
647            runtime,
648            spark_wallet,
649            event_emitter,
650            buy_bitcoin_provider,
651            token_converter,
652            rewards_delegator,
653            stable_balance,
654            sync_coordinator,
655            cross_chain_context,
656            orchestra,
657            lightning_sender,
658        })
659        .await?;
660        debug!("Initialized and started breez sdk.");
661
662        Ok(sdk)
663    }
664}
665
666/// Rejects server-mode configs that depend on background services.
667fn validate_server_mode(
668    config: &Config,
669    background_services_enabled: bool,
670) -> Result<(), SdkError> {
671    if background_services_enabled {
672        return Ok(());
673    }
674    if config.stable_balance_config.is_some() {
675        return Err(SdkError::InvalidInput(
676            "stable_balance_config is not supported when background_tasks_enabled is false"
677                .to_string(),
678        ));
679    }
680    if config.real_time_sync_server_url.is_some() {
681        return Err(SdkError::InvalidInput(
682            "real_time_sync_server_url must be None when background_tasks_enabled is false"
683                .to_string(),
684        ));
685    }
686    if config.leaf_optimization_config.auto_enabled {
687        return Err(SdkError::InvalidInput(
688            "leaf_optimization_config.auto_enabled must be false when background_tasks_enabled is false"
689                .to_string(),
690        ));
691    }
692    if config.token_optimization_config.auto_enabled {
693        return Err(SdkError::InvalidInput(
694            "token_optimization_config.auto_enabled must be false when background_tasks_enabled is false"
695                .to_string(),
696        ));
697    }
698    if config.cross_chain_config.is_some() {
699        return Err(SdkError::InvalidInput(
700            "Cross-chain config must be unset when background tasks are disabled".to_string(),
701        ));
702    }
703    Ok(())
704}
705
706/// Rejects configs whose features need local encryption when the signer can't
707/// perform ECIES.
708fn validate_signer_capabilities(config: &Config, has_ecies: bool) -> Result<(), SdkError> {
709    if has_ecies {
710        return Ok(());
711    }
712    if config.real_time_sync_server_url.is_some() {
713        return Err(SdkError::InvalidInput(
714            "Real-time sync requires a signer that supports ECIES".to_string(),
715        ));
716    }
717    if config.cross_chain_config.is_some() {
718        return Err(SdkError::InvalidInput(
719            "Cross-chain payments require a signer that supports ECIES".to_string(),
720        ));
721    }
722    Ok(())
723}
724
725/// Derives the SDK-layer signers from one signer source: the Spark signer, and
726/// (when the signer can perform ECIES/HMAC) the `ecies` signer plus the
727/// real-time-sync and lnurl-auth signers. A signing-only external signer can do
728/// neither, so `ecies`, `rtsync`, and `lnurl_auth` are all left `None`.
729fn build_signers(config: &Config, signer_source: &SignerSource) -> Result<Signers, SdkError> {
730    use crate::signer::{
731        BreezSigner, EciesSigner, ExternalBreezSignerAdapter, ExternalSigningSignerAdapter,
732        ExternalSparkSignerAdapter, HmacSigner,
733    };
734
735    // The SDK-layer `BreezSigner` (`base`) roots at the identity master
736    // (`base/0'`); the high-level Spark signer (the in-process `DefaultSigner`
737    // wrapped in a `SparkSignerAdapter`) roots at the account master (`base`).
738    #[allow(clippy::type_complexity)]
739    let (base, ecies, hmac, spark): (
740        Arc<dyn BreezSigner>,
741        Option<Arc<dyn EciesSigner>>,
742        Option<Arc<dyn HmacSigner>>,
743        Arc<dyn SparkSigner>,
744    ) = match signer_source {
745        SignerSource::Seed {
746            seed,
747            account_number,
748        } => {
749            let seed_bytes = seed.to_bytes()?;
750            let network = config.network.into();
751            let account_number = *account_number;
752            // One `BreezSignerImpl` implements all three capability traits.
753            let signer = Arc::new(BreezSignerImpl::new(
754                spark_wallet::identity_master_key(&seed_bytes, network, account_number)
755                    .map_err(|e| SdkError::Generic(e.to_string()))?,
756            ));
757            let spark: Arc<dyn SparkSigner> = Arc::new(spark_wallet::SparkSignerAdapter::new(
758                Arc::new(spark_wallet::DefaultSigner::from_master(
759                    spark_wallet::account_master_key(&seed_bytes, network, account_number)
760                        .map_err(|e| SdkError::Generic(e.to_string()))?,
761                )),
762            ));
763            (signer.clone(), Some(signer.clone()), Some(signer), spark)
764        }
765        SignerSource::External { breez, spark } => {
766            let spark: Arc<dyn SparkSigner> =
767                Arc::new(ExternalSparkSignerAdapter::new(Arc::clone(spark)));
768            match breez {
769                ExternalBreez::Full(breez) => {
770                    // The full adapter implements all three capability traits.
771                    let signer = Arc::new(ExternalBreezSignerAdapter::new(Arc::clone(breez)));
772                    (signer.clone(), Some(signer.clone()), Some(signer), spark)
773                }
774                ExternalBreez::SigningOnly(breez) => {
775                    let base: Arc<dyn BreezSigner> =
776                        Arc::new(ExternalSigningSignerAdapter::new(Arc::clone(breez)));
777                    (base, None, None, spark)
778                }
779            }
780        }
781    };
782
783    let rtsync = ecies
784        .as_ref()
785        .map(|ecies| {
786            RTSyncSigner::new(base.clone(), ecies.clone(), config.network)
787                .map(Arc::new)
788                .map_err(|e| SdkError::Generic(e.to_string()))
789        })
790        .transpose()?;
791    let lnurl_auth = hmac
792        .as_ref()
793        .map(|hmac| Arc::new(LnurlAuthSignerAdapter::new(base.clone(), hmac.clone())));
794
795    Ok(Signers {
796        ecies,
797        spark,
798        rtsync,
799        lnurl_auth,
800    })
801}
802
803/// Resolves the [`SdkContext`] — either the caller-supplied one or a fresh
804/// default — and validates that its `network`/`api_key` match the SDK config.
805async fn resolve_context(
806    supplied: Option<Arc<SdkContext>>,
807    config: &Config,
808) -> Result<Arc<SdkContext>, SdkError> {
809    let context = match supplied {
810        Some(ctx) => ctx,
811        None => {
812            new_shared_sdk_context(SdkContextConfig {
813                api_key: config.api_key.clone(),
814                proxy: config.proxy.clone(),
815                ..SdkContextConfig::new(config.network)
816            })
817            .await?
818        }
819    };
820    if context.network != config.network || context.api_key != config.api_key {
821        return Err(SdkError::Generic(
822            "SdkContext network/api_key do not match SdkConfig".to_string(),
823        ));
824    }
825    // The context owns the shared HTTP and gRPC clients, so a proxy that
826    // disagrees with the config would leave part of the traffic unproxied.
827    if context.proxy != config.proxy {
828        return Err(SdkError::Generic(
829            "SdkContext proxy does not match SdkConfig proxy".to_string(),
830        ));
831    }
832    Ok(context)
833}
834
835/// Resolves the single [`StorageBackend`] — from the builder or the shared
836/// context, never both — and asks it for the per-tenant store set.
837async fn resolve_storage(
838    supplied: Option<Arc<dyn StorageBackend>>,
839    context: &SdkContext,
840    identity_public_key: &PublicKey,
841    config: &Config,
842) -> Result<Arc<ResolvedStores>, SdkError> {
843    let storage_backend: Arc<dyn StorageBackend> = match (supplied, context.storage_backend.clone())
844    {
845        (Some(storage), None) => storage,
846        (None, Some(backend)) => backend,
847        (Some(_), Some(_)) => {
848            return Err(SdkError::Generic(
849                "storage is configured on both the SdkBuilder and the shared SdkContext"
850                    .to_string(),
851            ));
852        }
853        (None, None) => return Err(SdkError::Generic("No storage configured".to_string())),
854    };
855    storage_backend
856        .create_stores(config.network, identity_public_key.serialize().to_vec())
857        .await
858}
859
860/// Per-backend cap on a retrying call when other backends can take over.
861const FALLBACK_BACKEND_TIMEOUT: Duration = Duration::from_secs(30);
862
863/// Resolves the chain service: caller-supplied override → REST backends →
864/// network default (mempool.space with blockstream.info as fallback on mainnet,
865/// a hosted mempool instance on regtest). Signet requires an explicit override
866/// or REST configuration. Every backend is wrapped in a [`ValidatingChainService`],
867/// so every transaction it serves is rebound to the requested txid.
868fn resolve_chain_service(
869    supplied: Option<Arc<dyn BitcoinChainService>>,
870    rest_configs: Vec<RestChainServiceConfig>,
871    context: &SdkContext,
872    network: Network,
873) -> Result<Arc<dyn BitcoinChainService>, SdkError> {
874    if let Some(service) = supplied {
875        return Ok(Arc::new(ValidatingChainService::new(service)));
876    }
877    let rest_configs = if rest_configs.is_empty() {
878        default_rest_chain_services(network)?
879    } else {
880        rest_configs
881    };
882    let has_fallback = rest_configs.len() > 1;
883    let backends: Vec<Arc<dyn BitcoinChainService>> = rest_configs
884        .into_iter()
885        .map(|cfg| {
886            let mut service = RestClientChainService::new(
887                cfg.url,
888                network,
889                5,
890                context.http_client.clone(),
891                cfg.credentials
892                    .map(|c| BasicAuth::new(c.username, c.password)),
893                cfg.api_type,
894            );
895            if has_fallback {
896                service = service.with_total_timeout(FALLBACK_BACKEND_TIMEOUT);
897            }
898            Arc::new(ValidatingChainService::new(Arc::new(service))) as Arc<dyn BitcoinChainService>
899        })
900        .collect();
901    if let [backend] = backends.as_slice() {
902        return Ok(backend.clone());
903    }
904    Ok(Arc::new(FallbackChainService::new(backends)))
905}
906
907fn default_rest_chain_services(network: Network) -> Result<Vec<RestChainServiceConfig>, SdkError> {
908    match network {
909        Network::Mainnet => Ok(vec![
910            RestChainServiceConfig {
911                url: "https://mempool.space/api".to_string(),
912                api_type: ChainApiType::MempoolSpace,
913                credentials: None,
914            },
915            RestChainServiceConfig {
916                url: "https://blockstream.info/api".to_string(),
917                api_type: ChainApiType::Esplora,
918                credentials: None,
919            },
920        ]),
921        Network::Signet => Err(SdkError::InvalidInput(
922            "Signet requires an explicit chain service via with_chain_service or with_rest_chain_service".to_string(),
923        )),
924        Network::Regtest => {
925            let (username, password) = match (
926                std::env::var("CHAIN_SERVICE_USERNAME"),
927                std::env::var("CHAIN_SERVICE_PASSWORD"),
928            ) {
929                (Ok(username), Ok(password)) => (username, password),
930                _ => ("spark-sdk".to_string(), "mCMk1JqlBNtetUNy".to_string()),
931            };
932            Ok(vec![RestChainServiceConfig {
933                url: "https://regtest-mempool.us-west-2.sparkinfra.net/api".to_string(),
934                api_type: ChainApiType::MempoolSpace,
935                credentials: Some(Credentials { username, password }),
936            }])
937        }
938    }
939}
940
941/// Builds the full [`SparkWalletConfig`] with user-agent and SDK-level
942/// optimization overrides applied. `background_services_enabled` gates the
943/// auto-optimization flags so server-mode SDKs don't run background loops.
944fn finalize_spark_wallet_config(
945    config: &Config,
946    user_agent: &str,
947    background_services_enabled: bool,
948    remote_signer: bool,
949) -> Result<SparkWalletConfig, SdkError> {
950    let mut spark_wallet_config = if let Some(env_config) = &config.spark_config {
951        SdkBuilder::build_spark_wallet_config(config.network.into(), env_config)?
952    } else {
953        SparkWalletConfig::default_config(config.network.into())
954    };
955    spark_wallet_config.operator_pool = spark_wallet_config
956        .operator_pool
957        .with_user_agent(Some(user_agent.to_string()));
958    spark_wallet_config.service_provider_config.user_agent = Some(user_agent.to_string());
959    spark_wallet_config.leaf_auto_optimize_enabled =
960        background_services_enabled && config.leaf_optimization_config.auto_enabled;
961    spark_wallet_config.leaf_optimization_options.multiplicity =
962        config.leaf_optimization_config.multiplicity;
963
964    let token_opt = &config.token_optimization_config;
965    let token_options = &mut spark_wallet_config.token_outputs_optimization_options;
966    token_options.target_output_count = token_opt.target_output_count;
967    token_options.min_outputs_threshold = token_opt.min_outputs_threshold;
968    // Only override when disabled; enabled keeps the network default interval.
969    if !token_opt.auto_enabled || !background_services_enabled {
970        token_options.auto_optimize_interval = None;
971    }
972    spark_wallet_config.max_concurrent_claims = config.max_concurrent_claims;
973    // A remote signer may bill each of a ladder's signatures, up to 24 per leaf
974    // claimed, and Turnkey cannot take part in the cooperative recovery a leaf
975    // needs after the watchtower exits it through a direct node ladder rung.
976    spark_wallet_config.fee_ladder_enabled =
977        match (config.watchtower_fee_ladder_enabled, remote_signer) {
978            (Some(true), true) => {
979                return Err(SdkError::InvalidInput(
980                    "watchtower_fee_ladder_enabled is not supported with a remote signer"
981                        .to_string(),
982                ));
983            }
984            (_, true) => false,
985            (enabled, false) => enabled.unwrap_or(true),
986        };
987    Ok(spark_wallet_config)
988}
989
990/// Wraps the resolved session store (or an in-memory default) in the in-memory
991/// caching layer. Tokens are stored as-is: the SDK applies no encryption (see
992/// [`SdkBuilder::with_session_store`] to layer your own).
993///
994/// A token a server later rejects (a stale cached one, or one a prior SDK
995/// version wrote in a format this version does not recognize) self-heals: the
996/// request layer renews the session with its refresh token or re-authenticates.
997fn wrap_session_store(session_store: Option<Arc<dyn SessionStore>>) -> Arc<dyn SessionStore> {
998    let inner = session_store.unwrap_or_else(|| Arc::new(InMemorySessionStore::default()));
999    Arc::new(crate::session_store::CachingSessionStore::new(inner))
1000}
1001
1002/// Builds the [`SparkWallet`] from the assembled config, signers and stores.
1003async fn build_spark_wallet(params: BuildSparkWalletParams) -> Result<Arc<SparkWallet>, SdkError> {
1004    let mut wallet_builder = spark_wallet::WalletBuilder::new(params.config, params.spark_signer)
1005        .with_session_store(params.session_store);
1006    if let Some(receiver) = params.shutdown_receiver {
1007        wallet_builder = wallet_builder.with_cancellation_token(receiver);
1008    }
1009    if let Some(provider) = &params.context.jwt_header_provider {
1010        wallet_builder = wallet_builder
1011            .with_so_extra_header_provider(
1012                Arc::clone(provider) as Arc<dyn spark_wallet::HeaderProvider>
1013            )
1014            .with_ssp_extra_header_provider(
1015                Arc::clone(provider) as Arc<dyn spark_wallet::HeaderProvider>
1016            );
1017    }
1018    if let Some(observer) = params.payment_observer {
1019        let observer: Arc<dyn spark_wallet::TransferObserver> =
1020            Arc::new(SparkTransferObserver::new(observer));
1021        wallet_builder = wallet_builder.with_transfer_observer(observer);
1022    }
1023    if let Some(tree_store) = params.tree_store {
1024        wallet_builder = wallet_builder.with_tree_store(tree_store);
1025    }
1026    if let Some(token_output_store) = params.token_output_store {
1027        wallet_builder = wallet_builder.with_token_output_store(token_output_store);
1028    }
1029    wallet_builder = wallet_builder.with_ssp_http_client(params.context.http_client.clone());
1030    wallet_builder =
1031        wallet_builder.with_connection_manager(params.context.connection_manager.clone());
1032    Ok(Arc::new(wallet_builder.build().await?))
1033}
1034
1035/// Resolves the LNURL server client: explicit override → built from
1036/// `config.lnurl_domain` → none.
1037fn resolve_lnurl_server_client(
1038    explicit: Option<Arc<dyn LnurlServerClient>>,
1039    config: &Config,
1040    context: &SdkContext,
1041    spark_wallet: &Arc<SparkWallet>,
1042) -> Option<Arc<dyn LnurlServerClient>> {
1043    if let Some(client) = explicit {
1044        return Some(client);
1045    }
1046    config.lnurl_domain.as_ref().map(|domain| {
1047        Arc::new(DefaultLnurlServerClient::new(
1048            context.http_client.clone(),
1049            domain.clone(),
1050            config.api_key.clone(),
1051            Arc::clone(spark_wallet),
1052        )) as Arc<dyn LnurlServerClient>
1053    })
1054}
1055
1056/// Wraps the base storage with the real-time-sync layer when configured and
1057/// background services are enabled. Otherwise returns the storage unchanged.
1058#[allow(clippy::too_many_arguments)]
1059async fn maybe_wrap_storage_with_real_time_sync(
1060    storage: Arc<dyn crate::persist::Storage>,
1061    config: &Config,
1062    background_services_enabled: bool,
1063    user_agent: String,
1064    rtsync_signer: Option<Arc<RTSyncSigner>>,
1065    shutdown_receiver: watch::Receiver<()>,
1066    event_emitter: Arc<EventEmitter>,
1067    lnurl_server_client: Option<Arc<dyn LnurlServerClient>>,
1068) -> Result<Arc<dyn crate::persist::Storage>, SdkError> {
1069    // `validate_signer_capabilities` rejects real-time sync without an
1070    // ECIES-capable signer, so `rtsync_signer` is present whenever the URL is
1071    // set; a missing signer can't be reached and falls through to the no-op arm.
1072    match (&config.real_time_sync_server_url, rtsync_signer) {
1073        (Some(server_url), Some(signer)) if background_services_enabled => {
1074            init_and_start_real_time_sync(RealTimeSyncParams {
1075                server_url: server_url.clone(),
1076                api_key: config.api_key.clone(),
1077                user_agent,
1078                proxy: config.proxy.as_ref().map(platform_utils::ProxyConfig::from),
1079                signer,
1080                storage,
1081                shutdown_receiver,
1082                event_emitter,
1083                lnurl_server_client,
1084            })
1085            .await
1086        }
1087        _ => Ok(storage),
1088    }
1089}
1090
1091/// Builds the [`FlashnetTokenConverter`] used for in-SDK token conversion.
1092fn build_token_converter(
1093    config: &Config,
1094    storage: &Arc<dyn crate::persist::Storage>,
1095    spark_wallet: &Arc<SparkWallet>,
1096    context: &SdkContext,
1097) -> Arc<dyn TokenConverter> {
1098    let flashnet_config = FlashnetConfig::default_config(
1099        config.network.into(),
1100        DEFAULT_INTEGRATOR_PUBKEY
1101            .parse()
1102            .ok()
1103            .map(|pubkey| IntegratorConfig {
1104                pubkey,
1105                fee_bps: DEFAULT_INTEGRATOR_FEE_BPS,
1106            }),
1107    );
1108    Arc::new(FlashnetTokenConverter::new(
1109        flashnet_config,
1110        Arc::clone(storage),
1111        Arc::clone(spark_wallet),
1112        config.network,
1113        context.http_client.clone(),
1114    ))
1115}
1116
1117/// Builds the rewards delegator on mainnet, the only network the Rewards API
1118/// serves.
1119fn build_rewards_delegator(
1120    config: &Config,
1121    storage: &Arc<dyn crate::persist::Storage>,
1122    spark_wallet: &Arc<SparkWallet>,
1123    context: &SdkContext,
1124) -> Option<Arc<RewardsDelegator>> {
1125    matches!(config.network, Network::Mainnet).then(|| {
1126        Arc::new(RewardsDelegator::new(
1127            RewardsClient::new(
1128                REWARDS_MAINNET_BASE_URL.to_string(),
1129                Arc::clone(spark_wallet),
1130                context.http_client.clone(),
1131            ),
1132            Arc::clone(storage),
1133        ))
1134    })
1135}
1136
1137/// Builds the optional [`StableBalance`] middleware, which must be registered
1138/// before [`TokenConversionMiddleware`] so it can see conversion child events.
1139async fn build_stable_balance(
1140    config: &Config,
1141    token_converter: &Arc<dyn TokenConverter>,
1142    spark_wallet: &Arc<SparkWallet>,
1143    storage: &Arc<dyn crate::persist::Storage>,
1144    event_emitter: &Arc<EventEmitter>,
1145) -> Option<Arc<StableBalance>> {
1146    let stable_config = config.stable_balance_config.as_ref()?;
1147    Some(Arc::new(
1148        StableBalance::new(
1149            stable_config.clone(),
1150            Arc::clone(token_converter),
1151            Arc::clone(spark_wallet),
1152            Arc::clone(storage),
1153            Arc::clone(event_emitter),
1154        )
1155        .await,
1156    ))
1157}
1158
1159/// Builds the cross-chain context (provider registry + shared cached fiat
1160/// service) and the wallet-backed Orchestra service. The registry is empty
1161/// when `config.cross_chain_config` is unset. The service is built on mainnet
1162/// with background services on, and registered as a provider only when
1163/// cross-chain is enabled.
1164#[allow(clippy::too_many_arguments)]
1165async fn build_cross_chain_context(
1166    config: &Config,
1167    background_services_enabled: bool,
1168    breez_server: &Arc<BreezServer>,
1169    http_client: &Arc<dyn platform_utils::HttpClient>,
1170    spark_wallet: &Arc<SparkWallet>,
1171    storage: &Arc<dyn crate::persist::Storage>,
1172    fiat_service: Arc<dyn breez_sdk_common::fiat::FiatService>,
1173    event_emitter: &Arc<EventEmitter>,
1174    shutdown_receiver: watch::Receiver<()>,
1175) -> (
1176    crate::cross_chain::CrossChainContext,
1177    Option<Arc<crate::cross_chain::OrchestraService>>,
1178) {
1179    // Cache scoped to cross-chain: providers + dispatcher share one TTL window.
1180    let cached_fiat: Arc<dyn breez_sdk_common::fiat::FiatService> =
1181        Arc::new(crate::cross_chain::CachedFiatService::new(
1182            fiat_service,
1183            crate::cross_chain::DEFAULT_FIAT_CACHE_TTL,
1184        ));
1185    let mut providers = crate::cross_chain::CrossChainContext::new(Arc::clone(&cached_fiat));
1186
1187    // Orchestra is mainnet-only. Its base URL and API key are fetched from
1188    // Breez server (so the key can be rotated and revoked without an SDK
1189    // release) lazily on first use, not at connect: see
1190    // `BreezServerOrchestraConfigResolver`. There is no bundled fallback key.
1191    if !matches!(config.network, Network::Mainnet) {
1192        return (providers, None);
1193    }
1194    // The quoter makes no call until used, so every mainnet SDK gets one for
1195    // bridging, including in server mode.
1196    let config_resolver = Arc::new(crate::cross_chain::BreezServerOrchestraConfigResolver::new(
1197        Arc::clone(breez_server),
1198    ));
1199    let quoter = Arc::new(crate::cross_chain::OrchestraQuoter::new(
1200        config_resolver,
1201        config.api_key.as_deref().and_then(crate::sdk::partner_id),
1202        Arc::clone(&cached_fiat),
1203        Arc::clone(http_client),
1204    ));
1205    providers.set_orchestra_quoter(Arc::clone(&quoter));
1206    // The wallet-backed service needs background services for its monitor to
1207    // finish the receives it opens.
1208    if !background_services_enabled {
1209        return (providers, None);
1210    }
1211    let orchestra = Arc::new(
1212        crate::cross_chain::OrchestraService::new(
1213            quoter,
1214            Arc::clone(spark_wallet),
1215            Arc::clone(storage),
1216            Arc::clone(event_emitter),
1217            shutdown_receiver,
1218            config.cross_chain_config.is_some(),
1219        )
1220        .await,
1221    );
1222    if config.cross_chain_config.is_some() {
1223        providers.insert(
1224            crate::cross_chain::CrossChainProvider::Orchestra,
1225            Arc::clone(&orchestra) as Arc<dyn crate::cross_chain::CrossChainService>,
1226        );
1227    }
1228
1229    (providers, Some(orchestra))
1230}
1231
1232#[cfg(test)]
1233#[cfg(feature = "sqlite")]
1234mod tests {
1235    use super::SdkBuilder;
1236    use crate::{Network, SdkError, default_config};
1237
1238    fn signet_test_spark_config() -> crate::SparkConfig {
1239        let identifier = format!("{:064x}", 1);
1240        let public_key = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
1241        crate::SparkConfig {
1242            coordinator_identifier: identifier.clone(),
1243            threshold: 1,
1244            signing_operators: vec![crate::SparkSigningOperator {
1245                id: 0,
1246                identifier,
1247                address: "https://signet-operator.invalid".to_string(),
1248                identity_public_key: public_key.to_string(),
1249                ca_cert_pem: None,
1250            }],
1251            ssp_config: crate::SparkSspConfig {
1252                base_url: "https://signet-ssp.invalid".to_string(),
1253                identity_public_key: public_key.to_string(),
1254                schema_endpoint: None,
1255            },
1256            expected_withdraw_bond_sats: 10_000,
1257            expected_withdraw_relative_block_locktime: 1_000,
1258            max_token_transaction_inputs: None,
1259        }
1260    }
1261
1262    #[tokio::test]
1263    async fn signet_requires_explicit_spark_config() {
1264        for config in [
1265            default_config(Network::Signet),
1266            crate::default_server_config(Network::Signet),
1267        ] {
1268            assert!(config.spark_config.is_none());
1269            assert!(matches!(
1270                config.validate(),
1271                Err(SdkError::InvalidInput(message)) if message.contains("spark_config")
1272            ));
1273            assert!(matches!(
1274                SdkBuilder::new(config, test_seed()).build().await,
1275                Err(SdkError::InvalidInput(message)) if message.contains("spark_config")
1276            ));
1277        }
1278    }
1279
1280    #[tokio::test]
1281    async fn signet_requires_explicit_chain_service() {
1282        for mut config in [
1283            default_config(Network::Signet),
1284            crate::default_server_config(Network::Signet),
1285        ] {
1286            config.spark_config = Some(signet_test_spark_config());
1287            assert!(matches!(
1288                SdkBuilder::new(config, test_seed()).build().await,
1289                Err(SdkError::InvalidInput(message)) if message.contains("explicit chain service")
1290            ));
1291        }
1292    }
1293
1294    #[tokio::test]
1295    async fn signet_sdk_builds_without_api_key_and_preserves_network() {
1296        let mut config = crate::default_server_config("signet".parse().unwrap());
1297        config.spark_config = Some(signet_test_spark_config());
1298        assert!(config.api_key.is_none());
1299        assert!(config.lnurl_domain.is_none());
1300        let sdk = SdkBuilder::new(config, test_seed())
1301            .with_rest_chain_service(
1302                "https://signet-chain.invalid/api".to_string(),
1303                crate::ChainApiType::Esplora,
1304                None,
1305            )
1306            .with_default_storage(unique_storage_dir("signet"))
1307            .build()
1308            .await
1309            .expect("Signet SDK should initialize without a Breez API key");
1310
1311        let address = sdk.spark_wallet.get_spark_address().unwrap();
1312        assert_eq!(address.network, spark_wallet::Network::Signet);
1313        let encoded = address.to_address_string().unwrap();
1314        let decoded: spark_wallet::SparkAddress = encoded.parse().unwrap();
1315        assert_eq!(decoded.network, spark_wallet::Network::Signet);
1316        assert_eq!(
1317            bitcoin::Network::from(Network::Signet),
1318            bitcoin::Network::Signet
1319        );
1320        assert!(matches!(
1321            crate::BitcoinNetwork::from(Network::Signet),
1322            crate::BitcoinNetwork::Signet
1323        ));
1324        assert!(matches!(
1325            breez_sdk_common::network::BitcoinNetwork::from(Network::Signet),
1326            breez_sdk_common::network::BitcoinNetwork::Signet
1327        ));
1328        sdk.disconnect().await.unwrap();
1329    }
1330
1331    #[tokio::test]
1332    async fn signet_explicit_chain_service_uses_esplora() {
1333        use platform_utils::{HttpClient, HttpError, HttpResponse};
1334        use std::{collections::HashMap, sync::Arc};
1335
1336        struct SignetChainClient;
1337
1338        #[macros::async_trait]
1339        impl HttpClient for SignetChainClient {
1340            async fn get(
1341                &self,
1342                url: String,
1343                headers: Option<HashMap<String, String>>,
1344            ) -> Result<HttpResponse, HttpError> {
1345                assert_eq!(url, "https://signet-chain.invalid/api/fee-estimates");
1346                assert!(
1347                    !headers
1348                        .unwrap_or_default()
1349                        .keys()
1350                        .any(|key| key.eq_ignore_ascii_case("authorization"))
1351                );
1352                Ok(HttpResponse {
1353                    status: 200,
1354                    body: r#"{"1":5.0,"3":3.0,"6":2.0,"144":1.0}"#.to_string(),
1355                    headers: HashMap::new(),
1356                })
1357            }
1358
1359            async fn post(
1360                &self,
1361                _url: String,
1362                _headers: Option<HashMap<String, String>>,
1363                _body: Option<String>,
1364            ) -> Result<HttpResponse, HttpError> {
1365                panic!("fee estimation must not POST")
1366            }
1367
1368            async fn delete(
1369                &self,
1370                _url: String,
1371                _headers: Option<HashMap<String, String>>,
1372                _body: Option<String>,
1373            ) -> Result<HttpResponse, HttpError> {
1374                panic!("fee estimation must not DELETE")
1375            }
1376        }
1377
1378        let mut context =
1379            crate::new_shared_sdk_context(crate::SdkContextConfig::new(Network::Signet))
1380                .await
1381                .unwrap();
1382        Arc::get_mut(&mut context).unwrap().http_client = Arc::new(SignetChainClient);
1383        let chain_service = super::resolve_chain_service(
1384            None,
1385            vec![super::RestChainServiceConfig {
1386                url: "https://signet-chain.invalid/api".to_string(),
1387                api_type: crate::ChainApiType::Esplora,
1388                credentials: None,
1389            }],
1390            &context,
1391            Network::Signet,
1392        )
1393        .unwrap();
1394        // A caller-supplied trait implementation must also satisfy the requirement.
1395        let chain_service =
1396            super::resolve_chain_service(Some(chain_service), vec![], &context, Network::Signet)
1397                .unwrap();
1398        let fees = chain_service.recommended_fees().await.unwrap();
1399        assert_eq!(fees.fastest_fee, 5);
1400        assert_eq!(fees.half_hour_fee, 3);
1401        assert_eq!(fees.hour_fee, 2);
1402    }
1403
1404    #[tokio::test]
1405    async fn mainnet_default_chain_service_falls_back_to_blockstream() {
1406        use platform_utils::{HttpClient, HttpError, HttpResponse};
1407        use std::{collections::HashMap, sync::Arc};
1408
1409        /// Refuses every mempool.space request and serves blockstream.info.
1410        struct BlockedMempoolClient;
1411
1412        #[macros::async_trait]
1413        impl HttpClient for BlockedMempoolClient {
1414            async fn get(
1415                &self,
1416                url: String,
1417                _headers: Option<HashMap<String, String>>,
1418            ) -> Result<HttpResponse, HttpError> {
1419                if url.starts_with("https://mempool.space/") {
1420                    return Err(HttpError::Status {
1421                        status: 403,
1422                        body: "blocked".to_string(),
1423                    });
1424                }
1425                assert_eq!(url, "https://blockstream.info/api/fee-estimates");
1426                Ok(HttpResponse {
1427                    status: 200,
1428                    body: r#"{"1":5.0,"3":3.0,"6":2.0,"144":1.0}"#.to_string(),
1429                    headers: HashMap::new(),
1430                })
1431            }
1432
1433            async fn post(
1434                &self,
1435                _url: String,
1436                _headers: Option<HashMap<String, String>>,
1437                _body: Option<String>,
1438            ) -> Result<HttpResponse, HttpError> {
1439                panic!("fee estimation must not POST")
1440            }
1441
1442            async fn delete(
1443                &self,
1444                _url: String,
1445                _headers: Option<HashMap<String, String>>,
1446                _body: Option<String>,
1447            ) -> Result<HttpResponse, HttpError> {
1448                panic!("fee estimation must not DELETE")
1449            }
1450        }
1451
1452        let mut context =
1453            crate::new_shared_sdk_context(crate::SdkContextConfig::new(Network::Mainnet))
1454                .await
1455                .unwrap();
1456        Arc::get_mut(&mut context).unwrap().http_client = Arc::new(BlockedMempoolClient);
1457        let chain_service =
1458            super::resolve_chain_service(None, vec![], &context, Network::Mainnet).unwrap();
1459        let fees = chain_service.recommended_fees().await.unwrap();
1460        assert_eq!(fees.fastest_fee, 5);
1461    }
1462
1463    #[test]
1464    fn default_config_spark_config_builds_valid_wallet_config() {
1465        for network in [Network::Mainnet, Network::Regtest] {
1466            let config = default_config(network);
1467            let spark_config = config
1468                .spark_config
1469                .as_ref()
1470                .expect("default_config must populate spark_config");
1471            SdkBuilder::build_spark_wallet_config(network.into(), spark_config).unwrap_or_else(
1472                |e| {
1473                    panic!(
1474                        "default_config({network:?}).spark_config failed to build SparkWalletConfig: {e}"
1475                    )
1476                },
1477            );
1478        }
1479    }
1480
1481    #[test]
1482    fn validate_signer_capabilities_gates_encryption_features() {
1483        // ECIES/HMAC supported: encryption-dependent features are allowed.
1484        let mut config = default_config(Network::Regtest);
1485        config.real_time_sync_server_url = Some("https://example.com".to_string());
1486        assert!(super::validate_signer_capabilities(&config, true).is_ok());
1487
1488        // No ECIES/HMAC + real-time sync: rejected with a clear error.
1489        let mut config = default_config(Network::Regtest);
1490        config.real_time_sync_server_url = Some("https://example.com".to_string());
1491        config.cross_chain_config = None;
1492        match super::validate_signer_capabilities(&config, false) {
1493            Err(SdkError::InvalidInput(m)) => {
1494                assert!(m.contains("Real-time sync"), "got: {m}");
1495            }
1496            other => panic!("expected InvalidInput, got {other:?}"),
1497        }
1498
1499        // No ECIES/HMAC + no encryption-dependent feature: allowed, so a
1500        // payments-only wallet still builds.
1501        let mut config = default_config(Network::Regtest);
1502        config.real_time_sync_server_url = None;
1503        config.cross_chain_config = None;
1504        assert!(super::validate_signer_capabilities(&config, false).is_ok());
1505    }
1506
1507    /// A signing-only signer yields no ECIES/HMAC, so `build_signers` leaves the
1508    /// dependent signers (real-time sync, lnurl-auth) unbuilt; a full/seed signer
1509    /// builds them.
1510    #[test]
1511    fn build_signers_signing_only_omits_ecies_dependent_signers() {
1512        use crate::error::SignerError;
1513        use crate::signer::external_types::{
1514            EcdsaSignatureBytes, MessageBytes, PublicKeyBytes, RecoverableEcdsaSignatureBytes,
1515            SchnorrSignatureBytes,
1516        };
1517        use crate::signer::{
1518            DefaultExternalSparkSigner, ExternalSigningSigner, ExternalSparkSigner,
1519        };
1520        use std::sync::Arc;
1521
1522        // A signing-only signer; `build_signers` only wraps it, never calls it.
1523        struct StubSigningSigner;
1524        #[macros::async_trait]
1525        impl ExternalSigningSigner for StubSigningSigner {
1526            async fn derive_public_key(&self, _p: String) -> Result<PublicKeyBytes, SignerError> {
1527                unreachable!("build_signers must not call the signer")
1528            }
1529            async fn sign_ecdsa(
1530                &self,
1531                _m: MessageBytes,
1532                _p: String,
1533            ) -> Result<EcdsaSignatureBytes, SignerError> {
1534                unreachable!("build_signers must not call the signer")
1535            }
1536            async fn sign_ecdsa_recoverable(
1537                &self,
1538                _m: MessageBytes,
1539                _p: String,
1540            ) -> Result<RecoverableEcdsaSignatureBytes, SignerError> {
1541                unreachable!("build_signers must not call the signer")
1542            }
1543            async fn sign_hash_schnorr(
1544                &self,
1545                _h: Vec<u8>,
1546                _p: String,
1547            ) -> Result<SchnorrSignatureBytes, SignerError> {
1548                unreachable!("build_signers must not call the signer")
1549            }
1550        }
1551
1552        let config = default_config(Network::Regtest);
1553
1554        // Seed source: full capability, so rtsync + lnurl-auth signers are built.
1555        let full = super::build_signers(
1556            &config,
1557            &super::SignerSource::Seed {
1558                seed: test_seed(),
1559                account_number: None,
1560            },
1561        )
1562        .unwrap();
1563        assert!(full.ecies.is_some());
1564        assert!(full.rtsync.is_some());
1565        assert!(full.lnurl_auth.is_some());
1566
1567        // Signing-only external source: no ECIES/HMAC, so both are left unbuilt.
1568        let breez: Arc<dyn ExternalSigningSigner> = Arc::new(StubSigningSigner);
1569        let spark: Arc<dyn ExternalSparkSigner> = Arc::new(
1570            DefaultExternalSparkSigner::new(
1571                TEST_MNEMONIC.to_string(),
1572                None,
1573                Network::Regtest,
1574                None,
1575            )
1576            .unwrap(),
1577        );
1578        let signing_only = super::build_signers(
1579            &config,
1580            &super::SignerSource::External {
1581                breez: super::ExternalBreez::SigningOnly(breez),
1582                spark,
1583            },
1584        )
1585        .unwrap();
1586        assert!(signing_only.ecies.is_none());
1587        assert!(signing_only.rtsync.is_none());
1588        assert!(signing_only.lnurl_auth.is_none());
1589    }
1590
1591    /// End to end: an offline SDK built with a signing-only signer has no
1592    /// lnurl-auth signer, and `lnurl_auth` rejects with `InvalidInput`.
1593    #[tokio::test]
1594    async fn signing_only_signer_build_disables_lnurl_auth() {
1595        use crate::error::SignerError;
1596        use crate::signer::external_types::{
1597            EcdsaSignatureBytes, MessageBytes, PublicKeyBytes, RecoverableEcdsaSignatureBytes,
1598            SchnorrSignatureBytes,
1599        };
1600        use crate::signer::{
1601            DefaultExternalSigner, DefaultExternalSparkSigner, ExternalBreezSigner,
1602            ExternalSigningSigner, ExternalSparkSigner,
1603        };
1604        use std::sync::Arc;
1605
1606        // A real signing-only signer: delegates its four methods to a
1607        // seed-derived reference signer.
1608        struct SigningOnly(DefaultExternalSigner);
1609        #[macros::async_trait]
1610        impl ExternalSigningSigner for SigningOnly {
1611            async fn derive_public_key(&self, path: String) -> Result<PublicKeyBytes, SignerError> {
1612                self.0.derive_public_key(path).await
1613            }
1614            async fn sign_ecdsa(
1615                &self,
1616                message: MessageBytes,
1617                path: String,
1618            ) -> Result<EcdsaSignatureBytes, SignerError> {
1619                self.0.sign_ecdsa(message, path).await
1620            }
1621            async fn sign_ecdsa_recoverable(
1622                &self,
1623                message: MessageBytes,
1624                path: String,
1625            ) -> Result<RecoverableEcdsaSignatureBytes, SignerError> {
1626                self.0.sign_ecdsa_recoverable(message, path).await
1627            }
1628            async fn sign_hash_schnorr(
1629                &self,
1630                hash: Vec<u8>,
1631                path: String,
1632            ) -> Result<SchnorrSignatureBytes, SignerError> {
1633                self.0.sign_hash_schnorr(hash, path).await
1634            }
1635        }
1636
1637        let mut config = default_config(Network::Regtest);
1638        // Keep the build offline: no real-time sync, no network private-mode init.
1639        config.real_time_sync_server_url = None;
1640        config.private_enabled_default = false;
1641
1642        let breez: Arc<dyn ExternalSigningSigner> = Arc::new(SigningOnly(
1643            DefaultExternalSigner::new(TEST_MNEMONIC.to_string(), None, Network::Regtest, None)
1644                .unwrap(),
1645        ));
1646        let spark: Arc<dyn ExternalSparkSigner> = Arc::new(
1647            DefaultExternalSparkSigner::new(
1648                TEST_MNEMONIC.to_string(),
1649                None,
1650                Network::Regtest,
1651                None,
1652            )
1653            .unwrap(),
1654        );
1655
1656        let sdk = SdkBuilder::new_with_signing_only_signer(config, breez, spark)
1657            .with_default_storage(unique_storage_dir("signing-only-lnurl"))
1658            .build()
1659            .await
1660            .expect("signing-only build should succeed");
1661
1662        assert!(
1663            sdk.lnurl_auth_signer.is_none(),
1664            "a signing-only signer must not build an lnurl-auth signer"
1665        );
1666
1667        let err = sdk
1668            .lnurl_auth(crate::LnurlAuthRequestDetails {
1669                k1: "00".repeat(32),
1670                action: None,
1671                domain: "example.com".to_string(),
1672                url: "https://example.com/lnurl-auth".to_string(),
1673            })
1674            .await
1675            .expect_err("lnurl_auth must fail for a signing-only signer");
1676        assert!(
1677            matches!(err, SdkError::Generic(_)),
1678            "expected Generic, got {err:?}"
1679        );
1680
1681        sdk.disconnect().await.expect("disconnect should succeed");
1682    }
1683
1684    /// A signing-only signer is rejected at build when a feature that needs ECIES
1685    /// is configured. Here real-time sync is set, so `build()` returns
1686    /// `InvalidInput` before any signer method runs.
1687    #[tokio::test]
1688    async fn signing_only_signer_build_rejects_ecies_features() {
1689        use crate::error::SignerError;
1690        use crate::signer::external_types::{
1691            EcdsaSignatureBytes, MessageBytes, PublicKeyBytes, RecoverableEcdsaSignatureBytes,
1692            SchnorrSignatureBytes,
1693        };
1694        use crate::signer::{
1695            DefaultExternalSparkSigner, ExternalSigningSigner, ExternalSparkSigner,
1696        };
1697        use std::sync::Arc;
1698
1699        // Never called: the build fails at capability validation before signing.
1700        struct StubSigningSigner;
1701        #[macros::async_trait]
1702        impl ExternalSigningSigner for StubSigningSigner {
1703            async fn derive_public_key(&self, _p: String) -> Result<PublicKeyBytes, SignerError> {
1704                unreachable!("build must fail before calling the signer")
1705            }
1706            async fn sign_ecdsa(
1707                &self,
1708                _m: MessageBytes,
1709                _p: String,
1710            ) -> Result<EcdsaSignatureBytes, SignerError> {
1711                unreachable!("build must fail before calling the signer")
1712            }
1713            async fn sign_ecdsa_recoverable(
1714                &self,
1715                _m: MessageBytes,
1716                _p: String,
1717            ) -> Result<RecoverableEcdsaSignatureBytes, SignerError> {
1718                unreachable!("build must fail before calling the signer")
1719            }
1720            async fn sign_hash_schnorr(
1721                &self,
1722                _h: Vec<u8>,
1723                _p: String,
1724            ) -> Result<SchnorrSignatureBytes, SignerError> {
1725                unreachable!("build must fail before calling the signer")
1726            }
1727        }
1728
1729        let mut config = default_config(Network::Regtest);
1730        config.real_time_sync_server_url = Some("https://example.com".to_string());
1731
1732        let breez: Arc<dyn ExternalSigningSigner> = Arc::new(StubSigningSigner);
1733        let spark: Arc<dyn ExternalSparkSigner> = Arc::new(
1734            DefaultExternalSparkSigner::new(
1735                TEST_MNEMONIC.to_string(),
1736                None,
1737                Network::Regtest,
1738                None,
1739            )
1740            .unwrap(),
1741        );
1742
1743        let result = SdkBuilder::new_with_signing_only_signer(config, breez, spark)
1744            .with_default_storage(unique_storage_dir("signing-only-reject-rtsync"))
1745            .build()
1746            .await;
1747        match result {
1748            Err(SdkError::InvalidInput(_)) => {}
1749            Ok(_) => panic!("build must reject real-time sync for a signing-only signer"),
1750            Err(other) => panic!("expected InvalidInput, got {other:?}"),
1751        }
1752    }
1753
1754    #[tokio::test]
1755    async fn wrap_session_store_stores_token_verbatim() {
1756        use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
1757        use spark_wallet::{Session, SessionStore, SessionStoreError};
1758        use std::collections::HashMap;
1759        use std::sync::{Arc, Mutex};
1760
1761        // Inner store that exposes the raw bytes the wrapper writes through.
1762        #[derive(Default)]
1763        struct InspectableInner {
1764            sessions: Mutex<HashMap<PublicKey, Session>>,
1765        }
1766        #[macros::async_trait]
1767        impl SessionStore for InspectableInner {
1768            async fn get_session(&self, key: &PublicKey) -> Result<Session, SessionStoreError> {
1769                self.sessions
1770                    .lock()
1771                    .unwrap()
1772                    .get(key)
1773                    .cloned()
1774                    .ok_or(SessionStoreError::NotFound)
1775            }
1776            async fn set_session(
1777                &self,
1778                key: &PublicKey,
1779                session: Session,
1780            ) -> Result<(), SessionStoreError> {
1781                self.sessions.lock().unwrap().insert(*key, session);
1782                Ok(())
1783            }
1784        }
1785        fn key() -> PublicKey {
1786            let secp = Secp256k1::new();
1787            PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[3u8; 32]).unwrap())
1788        }
1789
1790        let inner = Arc::new(InspectableInner::default());
1791        let store = super::wrap_session_store(Some(inner.clone()));
1792        let token = "bearer-token".to_string();
1793        store
1794            .set_session(
1795                &key(),
1796                Session {
1797                    token: token.clone(),
1798                    expiration: 1,
1799                    refresh_token: None,
1800                    refresh_token_expiration: None,
1801                },
1802            )
1803            .await
1804            .unwrap();
1805
1806        // The SDK applies no encryption or tagging: the inner store holds the
1807        // token verbatim, and it round-trips through the caching wrapper.
1808        let raw = inner.sessions.lock().unwrap().get(&key()).cloned().unwrap();
1809        assert_eq!(raw.token, token, "the SDK must store the token as-is");
1810        assert_eq!(store.get_session(&key()).await.unwrap().token, token);
1811    }
1812
1813    #[tokio::test]
1814    async fn server_mode_rejects_stable_balance_config() {
1815        use crate::{SdkError, StableBalanceConfig, StableBalanceToken, default_server_config};
1816
1817        let mut config = default_server_config(Network::Regtest);
1818        config.stable_balance_config = Some(StableBalanceConfig {
1819            tokens: vec![StableBalanceToken {
1820                label: "USDB".to_string(),
1821                token_identifier: REGTEST_TOKEN.to_string(),
1822            }],
1823            default_active_label: None,
1824            threshold_sats: None,
1825            max_slippage_bps: None,
1826        });
1827
1828        let seed = test_seed();
1829        let result = SdkBuilder::new(config, seed).build().await;
1830        match result {
1831            Err(SdkError::InvalidInput(message)) => {
1832                assert!(message.contains("stable_balance_config"));
1833            }
1834            Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1835            Ok(_) => panic!("expected server mode with Stable Balance config to fail"),
1836        }
1837    }
1838
1839    #[tokio::test]
1840    async fn server_mode_rejects_real_time_sync_server_url() {
1841        use crate::{SdkError, default_server_config};
1842
1843        let mut config = default_server_config(Network::Regtest);
1844        config.real_time_sync_server_url = Some("https://example.com".to_string());
1845
1846        let seed = test_seed();
1847        let result = SdkBuilder::new(config, seed).build().await;
1848        match result {
1849            Err(SdkError::InvalidInput(message)) => {
1850                assert!(message.contains("real_time_sync_server_url"));
1851            }
1852            Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1853            Ok(_) => panic!("expected server mode with real_time_sync_server_url to fail"),
1854        }
1855    }
1856
1857    #[tokio::test]
1858    async fn server_mode_rejects_leaf_optimization_auto_enabled() {
1859        use crate::{SdkError, default_server_config};
1860
1861        let mut config = default_server_config(Network::Regtest);
1862        config.leaf_optimization_config.auto_enabled = true;
1863
1864        let seed = test_seed();
1865        let result = SdkBuilder::new(config, seed).build().await;
1866        match result {
1867            Err(SdkError::InvalidInput(message)) => {
1868                assert!(message.contains("leaf_optimization_config.auto_enabled"));
1869            }
1870            Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1871            Ok(_) => panic!("expected server mode with optimization auto_enabled to fail"),
1872        }
1873    }
1874
1875    #[tokio::test]
1876    async fn server_mode_rejects_token_optimization_auto_enabled() {
1877        use crate::{SdkError, default_server_config};
1878
1879        let mut config = default_server_config(Network::Regtest);
1880        config.token_optimization_config.auto_enabled = true;
1881
1882        let seed = test_seed();
1883        let result = SdkBuilder::new(config, seed).build().await;
1884        match result {
1885            Err(SdkError::InvalidInput(message)) => {
1886                assert!(message.contains("token_optimization_config.auto_enabled"));
1887            }
1888            Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1889            Ok(_) => panic!("expected server mode with optimization auto_enabled to fail"),
1890        }
1891    }
1892
1893    // ---- proxy ----
1894
1895    fn test_proxy() -> crate::ProxyConfig {
1896        crate::ProxyConfig {
1897            host: "127.0.0.1".to_string(),
1898            port: 9050,
1899            username: None,
1900            password: None,
1901        }
1902    }
1903
1904    /// Both cross-chain providers honour the proxy: Orchestra on the shared
1905    /// client, Boltz through its own config.
1906    #[test]
1907    fn validate_accepts_proxy_with_cross_chain_config() {
1908        use crate::{CrossChainConfig, default_config};
1909        let mut config = default_config(Network::Mainnet);
1910        config.proxy = Some(test_proxy());
1911        config.cross_chain_config = Some(CrossChainConfig::default());
1912
1913        assert!(config.validate().is_ok());
1914    }
1915
1916    #[test]
1917    fn validate_rejects_half_specified_proxy_credentials() {
1918        use crate::{SdkError, default_config};
1919        let mut config = default_config(Network::Mainnet);
1920        let mut proxy = test_proxy();
1921        proxy.username = Some("user".to_string());
1922        config.proxy = Some(proxy);
1923
1924        match config.validate() {
1925            Err(SdkError::InvalidInput(m)) => assert!(
1926                m.contains("username and password"),
1927                "expected credential-pair rejection, got: {m}"
1928            ),
1929            other => panic!("expected a username without a password to fail, got {other:?}"),
1930        }
1931    }
1932
1933    #[test]
1934    fn validate_rejects_empty_proxy_host_and_zero_port() {
1935        use crate::{SdkError, default_config};
1936        let mut config = default_config(Network::Mainnet);
1937
1938        let mut proxy = test_proxy();
1939        proxy.host = String::new();
1940        config.proxy = Some(proxy);
1941        assert!(matches!(config.validate(), Err(SdkError::InvalidInput(_))));
1942
1943        let mut proxy = test_proxy();
1944        proxy.port = 0;
1945        config.proxy = Some(proxy);
1946        assert!(matches!(config.validate(), Err(SdkError::InvalidInput(_))));
1947    }
1948
1949    /// A host that cannot appear in a URL authority has to be caught at
1950    /// connect: the client build downstream would otherwise be the first
1951    /// thing to notice, far from the config that caused it.
1952    #[test]
1953    fn validate_rejects_proxy_host_that_cannot_form_a_url() {
1954        use crate::{SdkError, default_config};
1955        for host in ["local host", "user@host", "socks5://127.0.0.1", "[::g]"] {
1956            let mut config = default_config(Network::Mainnet);
1957            let mut proxy = test_proxy();
1958            proxy.host = host.to_string();
1959            config.proxy = Some(proxy);
1960
1961            match config.validate() {
1962                Err(SdkError::InvalidInput(m)) => {
1963                    assert!(m.contains(host), "expected the host in the error, got: {m}");
1964                }
1965                other => panic!("expected host {host:?} to be rejected, got {other:?}"),
1966            }
1967        }
1968    }
1969
1970    /// A bare IPv6 literal is bracketed before it reaches a URL, so it stays
1971    /// valid rather than tripping the check above.
1972    #[test]
1973    fn validate_accepts_bare_ipv6_proxy_host() {
1974        use crate::default_config;
1975        let mut config = default_config(Network::Mainnet);
1976        let mut proxy = test_proxy();
1977        proxy.host = "::1".to_string();
1978        config.proxy = Some(proxy);
1979
1980        assert!(config.validate().is_ok());
1981    }
1982
1983    /// Balanced operator connections build their own connectors, so a proxy
1984    /// there would silently open the extra channels direct.
1985    #[tokio::test]
1986    async fn shared_context_rejects_proxy_with_balanced_connections() {
1987        use crate::{SdkContextConfig, SdkError, new_shared_sdk_context};
1988        let result = new_shared_sdk_context(SdkContextConfig {
1989            proxy: Some(test_proxy()),
1990            connections_per_operator: Some(4),
1991            ..SdkContextConfig::new(Network::Regtest)
1992        })
1993        .await;
1994
1995        match result.err() {
1996            Some(SdkError::InvalidInput(m)) => assert!(
1997                m.contains("connections_per_operator"),
1998                "expected balanced-connection rejection, got: {m}"
1999            ),
2000            other => panic!("expected proxy + balanced connections to fail, got {other:?}"),
2001        }
2002    }
2003
2004    /// The context owns the shared clients, so a config that disagrees with it
2005    /// would leave part of the traffic unproxied.
2006    #[tokio::test]
2007    async fn resolve_context_errors_on_proxy_mismatch() {
2008        use crate::{SdkContextConfig, default_config, new_shared_sdk_context};
2009        let mut config = default_config(Network::Regtest);
2010        config.proxy = Some(test_proxy());
2011
2012        let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
2013            .await
2014            .expect("regtest context");
2015
2016        let err = super::resolve_context(Some(ctx), &config)
2017            .await
2018            .err()
2019            .expect("expected mismatch error");
2020        assert!(
2021            err.to_string().contains("proxy does not match"),
2022            "unexpected error: {err}"
2023        );
2024    }
2025
2026    /// Regtest + `cross_chain_config` trips the Mainnet-only gate in
2027    /// `Config::validate` before reaching the server-mode reject in
2028    /// `build`. The server-mode gate is still in place (verified by the
2029    /// inline check in `build`); this test pins the more specific failure.
2030    #[tokio::test]
2031    async fn build_rejects_cross_chain_config_on_regtest() {
2032        use crate::{CrossChainConfig, SdkError, default_config};
2033        let mut config = default_config(Network::Regtest);
2034        config.cross_chain_config = Some(CrossChainConfig::default());
2035
2036        let seed = test_seed();
2037        let result = SdkBuilder::new(config, seed).build().await;
2038        match result {
2039            Err(SdkError::InvalidInput(m)) => {
2040                assert!(
2041                    m.contains("only available on Mainnet"),
2042                    "expected mainnet-only rejection, got: {m}"
2043                );
2044            }
2045            Err(err) => panic!("expected InvalidInput error, got {err:?}"),
2046            Ok(_) => panic!("expected regtest with cross_chain_config to fail"),
2047        }
2048    }
2049
2050    /// Mainnet SDK with a caller-supplied Regtest context errors at `build()`
2051    /// — the context has no JWT provider so the partner JWT would be silently
2052    /// disabled.
2053    #[tokio::test]
2054    async fn build_errors_on_network_mismatch() {
2055        use crate::{SdkContextConfig, new_shared_sdk_context};
2056        let mut config = default_config(Network::Mainnet);
2057        config.api_key = Some("partner-key".to_string());
2058        let ctx = new_shared_sdk_context(SdkContextConfig {
2059            api_key: Some("partner-key".to_string()),
2060            ..SdkContextConfig::new(Network::Regtest)
2061        })
2062        .await
2063        .expect("regtest context");
2064        let err = SdkBuilder::new(config, test_seed())
2065            .with_shared_context(ctx)
2066            .with_default_storage("/tmp/breez-sdk-test-network-mismatch".to_string())
2067            .build()
2068            .await
2069            .err()
2070            .expect("expected network-mismatch error");
2071        assert!(
2072            err.to_string().contains("network/api_key do not match"),
2073            "unexpected error: {err}"
2074        );
2075    }
2076
2077    /// Mainnet SDK with a Mainnet context whose `api_key` differs from
2078    /// `Config`'s errors at `build()` — the JWT provider would sign with a
2079    /// different key than the integrator intended.
2080    #[tokio::test]
2081    #[allow(clippy::manual_assert)]
2082    async fn build_errors_on_api_key_mismatch() {
2083        use crate::{SdkContextConfig, new_shared_sdk_context};
2084        let mut config = default_config(Network::Mainnet);
2085        config.api_key = Some("intended-key".to_string());
2086        let ctx = new_shared_sdk_context(SdkContextConfig {
2087            api_key: Some("wrong-key".to_string()),
2088            ..SdkContextConfig::new(Network::Mainnet)
2089        })
2090        .await
2091        .expect("mainnet context");
2092        let err = SdkBuilder::new(config, test_seed())
2093            .with_shared_context(ctx)
2094            .with_default_storage("/tmp/breez-sdk-test-key-mismatch".to_string())
2095            .build()
2096            .await
2097            .err()
2098            .expect("expected api_key-mismatch error");
2099        assert!(
2100            err.to_string().contains("network/api_key do not match"),
2101            "unexpected error: {err}"
2102        );
2103    }
2104
2105    const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
2106
2107    fn test_seed() -> crate::Seed {
2108        crate::Seed::Mnemonic {
2109            mnemonic: TEST_MNEMONIC.to_string(),
2110            passphrase: None,
2111        }
2112    }
2113
2114    fn unique_storage_dir(name: &str) -> String {
2115        std::env::temp_dir()
2116            .join(format!("breez-sdk-test-{}-{}", name, uuid::Uuid::new_v4()))
2117            .to_string_lossy()
2118            .into_owned()
2119    }
2120
2121    /// Waits for the SDK object graph to be released. Background tasks drop
2122    /// their `BreezSdk` clones asynchronously after the shutdown signal, so
2123    /// poll briefly instead of asserting immediately after `drop`.
2124    async fn assert_sdk_graph_freed(
2125        event_emitter: std::sync::Weak<crate::EventEmitter>,
2126        spark_wallet: std::sync::Weak<spark_wallet::SparkWallet>,
2127    ) {
2128        for _ in 0..100 {
2129            if event_emitter.upgrade().is_none() && spark_wallet.upgrade().is_none() {
2130                return;
2131            }
2132            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2133        }
2134        panic!(
2135            "SDK object graph leaked after drop (EventEmitter alive: {}, SparkWallet alive: {})",
2136            event_emitter.upgrade().is_some(),
2137            spark_wallet.upgrade().is_some(),
2138        );
2139    }
2140
2141    #[tokio::test]
2142    async fn rewards_delegator_is_not_built_off_mainnet() {
2143        let sdk = SdkBuilder::new(default_config(Network::Regtest), test_seed())
2144            .with_default_storage(unique_storage_dir("rewards-off-mainnet"))
2145            .build()
2146            .await
2147            .expect("build should succeed");
2148
2149        assert!(sdk.rewards_delegator.is_none());
2150        sdk.disconnect().await.expect("disconnect should succeed");
2151    }
2152
2153    /// Regression test for <https://github.com/breez/spark-sdk/issues/947>:
2154    /// each server-mode build → disconnect → drop lifecycle must release the
2155    /// whole per-instance object graph.
2156    #[tokio::test]
2157    async fn server_mode_sdk_graph_is_freed_on_drop() {
2158        use crate::default_server_config;
2159
2160        let config = default_server_config(Network::Regtest);
2161        let sdk = SdkBuilder::new(config, test_seed())
2162            .with_default_storage(unique_storage_dir("leak-server"))
2163            .build()
2164            .await
2165            .expect("server-mode build should succeed");
2166
2167        let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
2168        let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
2169
2170        sdk.disconnect().await.expect("disconnect should succeed");
2171        drop(sdk);
2172
2173        assert_sdk_graph_freed(event_emitter, spark_wallet).await;
2174    }
2175
2176    /// Network-dependent variant of `server_mode_sdk_graph_is_freed_on_drop`
2177    /// that runs a real `sync_wallet` against the regtest operators before
2178    /// dropping, proving the sync path retains nothing either.
2179    #[tokio::test]
2180    #[ignore = "requires network access to the regtest operators"]
2181    async fn server_mode_sdk_graph_is_freed_on_drop_after_sync() {
2182        use crate::{SyncWalletRequest, default_server_config};
2183
2184        let config = default_server_config(Network::Regtest);
2185        let sdk = SdkBuilder::new(config, test_seed())
2186            .with_default_storage(unique_storage_dir("leak-server-sync"))
2187            .build()
2188            .await
2189            .expect("server-mode build should succeed");
2190
2191        let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
2192        let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
2193
2194        sdk.sync_wallet(SyncWalletRequest {})
2195            .await
2196            .expect("sync_wallet should succeed");
2197
2198        sdk.disconnect().await.expect("disconnect should succeed");
2199        drop(sdk);
2200
2201        assert_sdk_graph_freed(event_emitter, spark_wallet).await;
2202    }
2203
2204    /// Client-mode counterpart of the issue #947 regression test: after
2205    /// `disconnect`, dropping the SDK must release the whole object graph.
2206    #[tokio::test]
2207    async fn client_mode_sdk_graph_is_freed_after_disconnect_and_drop() {
2208        let mut config = default_config(Network::Regtest);
2209        // Keep the build offline: no real-time sync connection and no
2210        // network-backed private-mode setup on startup.
2211        config.real_time_sync_server_url = None;
2212        config.private_enabled_default = false;
2213
2214        let sdk = SdkBuilder::new(config, test_seed())
2215            .with_default_storage(unique_storage_dir("leak-client"))
2216            .build()
2217            .await
2218            .expect("client-mode build should succeed");
2219
2220        let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
2221        let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
2222
2223        tokio::time::timeout(std::time::Duration::from_secs(30), sdk.disconnect())
2224            .await
2225            .expect("disconnect should not hang")
2226            .expect("disconnect should succeed");
2227        drop(sdk);
2228
2229        assert_sdk_graph_freed(event_emitter, spark_wallet).await;
2230    }
2231
2232    /// Variant of the client-mode leak regression test with real-time sync
2233    /// and stable balance enabled: the sync record handler and the conversion
2234    /// worker hold the emitter strongly, so their tasks must release it on
2235    /// `disconnect`.
2236    #[tokio::test]
2237    async fn client_mode_sdk_graph_is_freed_with_real_time_sync_enabled() {
2238        use crate::{StableBalanceConfig, StableBalanceToken};
2239
2240        let mut config = default_config(Network::Regtest);
2241        // Unreachable sync server: the gRPC client connects lazily, so the
2242        // build succeeds offline and the sync tasks just retry in the
2243        // background until shutdown.
2244        config.real_time_sync_server_url = Some("http://127.0.0.1:9".to_string());
2245        config.private_enabled_default = false;
2246        // No active token, so the conversion worker spawns without making
2247        // network calls.
2248        config.stable_balance_config = Some(StableBalanceConfig {
2249            tokens: vec![StableBalanceToken {
2250                label: "USDB".to_string(),
2251                token_identifier: REGTEST_TOKEN.to_string(),
2252            }],
2253            default_active_label: None,
2254            threshold_sats: None,
2255            max_slippage_bps: None,
2256        });
2257
2258        let sdk = SdkBuilder::new(config, test_seed())
2259            .with_default_storage(unique_storage_dir("leak-client-rtsync"))
2260            .build()
2261            .await
2262            .expect("client-mode build with real-time sync should succeed");
2263
2264        let event_emitter = std::sync::Arc::downgrade(&sdk.event_emitter);
2265        let spark_wallet = std::sync::Arc::downgrade(&sdk.spark_wallet);
2266
2267        tokio::time::timeout(std::time::Duration::from_secs(30), sdk.disconnect())
2268            .await
2269            .expect("disconnect should not hang")
2270            .expect("disconnect should succeed");
2271        drop(sdk);
2272
2273        assert_sdk_graph_freed(event_emitter, spark_wallet).await;
2274    }
2275
2276    /// `disconnect` must unregister event listeners, so a listener that
2277    /// references the SDK instance cannot keep it alive afterwards.
2278    #[tokio::test]
2279    async fn disconnect_unregisters_event_listeners() {
2280        use crate::{SdkEvent, default_server_config, events::EventListener};
2281
2282        struct NoopListener;
2283
2284        #[macros::async_trait]
2285        impl EventListener for NoopListener {
2286            async fn on_event(&self, _event: SdkEvent) {}
2287        }
2288
2289        let storage_dir = std::env::temp_dir()
2290            .join(format!(
2291                "breez-sdk-test-listener-clear-{}",
2292                uuid::Uuid::new_v4()
2293            ))
2294            .to_string_lossy()
2295            .into_owned();
2296        let sdk = SdkBuilder::new(default_server_config(Network::Regtest), test_seed())
2297            .with_default_storage(storage_dir)
2298            .build()
2299            .await
2300            .expect("server-mode build should succeed");
2301
2302        let id = sdk.add_event_listener(Box::new(NoopListener)).await;
2303
2304        sdk.disconnect().await.expect("disconnect should succeed");
2305
2306        assert!(
2307            !sdk.remove_event_listener(&id).await,
2308            "listener should already be unregistered by disconnect"
2309        );
2310    }
2311
2312    /// Off mainnet there is no bridging client, and the Cash App bridges fail
2313    /// with "Only available on mainnet".
2314    #[tokio::test]
2315    async fn bridging_client_is_mainnet_only() {
2316        use crate::{BridgeToCashAppRequest, CrossChainProvider, CrossChainRoutePair, SdkError};
2317
2318        let storage_dir = std::env::temp_dir()
2319            .join(format!("breez-sdk-test-bridging-{}", uuid::Uuid::new_v4()))
2320            .to_string_lossy()
2321            .into_owned();
2322        let sdk = SdkBuilder::new(crate::default_server_config(Network::Regtest), test_seed())
2323            .with_default_storage(storage_dir)
2324            .build()
2325            .await
2326            .expect("server-mode build should succeed");
2327
2328        assert!(matches!(
2329            sdk.get_bridging_client(),
2330            Err(SdkError::Generic(message)) if message == "Only available on mainnet"
2331        ));
2332        let bridge = sdk
2333            .bridge_to_cash_app(BridgeToCashAppRequest {
2334                recipient: "alice".to_string(),
2335                route: CrossChainRoutePair {
2336                    provider: CrossChainProvider::Orchestra,
2337                    chain: "base".to_string(),
2338                    chain_id: None,
2339                    asset: "USDC".to_string(),
2340                    contract_address: None,
2341                    decimals: 6,
2342                    exact_out_eligible: false,
2343                    accepted_assets: vec![],
2344                    delivery_methods: vec![],
2345                },
2346                amount: 1_000_000,
2347                fee_policy: None,
2348                refund_address: "0x0000000000000000000000000000000000000001".to_string(),
2349                max_slippage_bps: None,
2350            })
2351            .await;
2352        assert!(
2353            matches!(bridge, Err(SdkError::Generic(message)) if message == "Only available on mainnet")
2354        );
2355
2356        sdk.disconnect().await.expect("disconnect should succeed");
2357    }
2358
2359    fn test_spark_signer() -> std::sync::Arc<dyn spark_wallet::SparkSigner> {
2360        use std::sync::Arc;
2361
2362        let seed = test_seed();
2363        let seed_bytes = seed.to_bytes().unwrap();
2364        let master =
2365            spark_wallet::account_master_key(&seed_bytes, Network::Regtest.into(), None).unwrap();
2366        Arc::new(spark_wallet::SparkSignerAdapter::new(Arc::new(
2367            spark_wallet::DefaultSigner::from_master(master),
2368        )))
2369    }
2370
2371    const REGTEST_TOKEN: &str = "btknrt14w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w4sh59f4v";
2372
2373    fn stable_balance_with(token_identifier: &str) -> crate::StableBalanceConfig {
2374        crate::StableBalanceConfig {
2375            tokens: vec![crate::StableBalanceToken {
2376                label: "USDB".to_string(),
2377                token_identifier: token_identifier.to_string(),
2378            }],
2379            default_active_label: None,
2380            threshold_sats: None,
2381            max_slippage_bps: None,
2382        }
2383    }
2384
2385    fn assert_rejects_token(config: &crate::Config) {
2386        match config.validate() {
2387            Err(SdkError::InvalidInput(m)) => assert!(
2388                m.contains("bech32m"),
2389                "expected a token identifier rejection, got: {m}"
2390            ),
2391            other => panic!("expected InvalidInput, got {other:?}"),
2392        }
2393    }
2394
2395    #[test]
2396    fn validate_accepts_a_bech32m_stable_balance_token() {
2397        let mut config = crate::default_config(Network::Regtest);
2398        config.stable_balance_config = Some(stable_balance_with(REGTEST_TOKEN));
2399        assert!(config.validate().is_ok());
2400    }
2401
2402    #[test]
2403    fn validate_rejects_a_hex_stable_balance_token() {
2404        let mut config = crate::default_config(Network::Mainnet);
2405        config.stable_balance_config = Some(stable_balance_with(
2406            "3206c93b24a4d18ea19d0a9a213204af2c7e74a6d16c7535cc5d33eca4ad1eca",
2407        ));
2408        assert_rejects_token(&config);
2409    }
2410
2411    #[test]
2412    fn validate_rejects_a_stable_balance_token_for_another_network() {
2413        let mut config = crate::default_config(Network::Regtest);
2414        config.stable_balance_config = Some(stable_balance_with(
2415            "btkn1xgrvjwey5ngcagvap2dzzvsy4uk8ua9x69k82dwvt5e7ef9drm9qztux87",
2416        ));
2417        assert_rejects_token(&config);
2418    }
2419
2420    // ---- validate_server_mode ----
2421
2422    #[test]
2423    fn validate_server_mode_ok_when_background_enabled() {
2424        use crate::{StableBalanceConfig, StableBalanceToken, default_server_config};
2425        let mut config = default_server_config(Network::Regtest);
2426        config.stable_balance_config = Some(StableBalanceConfig {
2427            tokens: vec![StableBalanceToken {
2428                label: "USDB".to_string(),
2429                token_identifier: REGTEST_TOKEN.to_string(),
2430            }],
2431            default_active_label: None,
2432            threshold_sats: None,
2433            max_slippage_bps: None,
2434        });
2435        config.real_time_sync_server_url = Some("https://example.com".to_string());
2436        config.leaf_optimization_config.auto_enabled = true;
2437        config.token_optimization_config.auto_enabled = true;
2438        // background_services_enabled = true → none of the gates fire.
2439        assert!(super::validate_server_mode(&config, true).is_ok());
2440    }
2441
2442    #[test]
2443    fn validate_server_mode_ok_in_server_mode_without_background_features() {
2444        use crate::default_server_config;
2445        let config = default_server_config(Network::Regtest);
2446        assert!(super::validate_server_mode(&config, false).is_ok());
2447    }
2448
2449    #[test]
2450    fn validate_server_mode_rejects_stable_balance_directly() {
2451        use crate::{StableBalanceConfig, StableBalanceToken, default_server_config};
2452        let mut config = default_server_config(Network::Regtest);
2453        config.stable_balance_config = Some(StableBalanceConfig {
2454            tokens: vec![StableBalanceToken {
2455                label: "USDB".to_string(),
2456                token_identifier: REGTEST_TOKEN.to_string(),
2457            }],
2458            default_active_label: None,
2459            threshold_sats: None,
2460            max_slippage_bps: None,
2461        });
2462        match super::validate_server_mode(&config, false) {
2463            Err(SdkError::InvalidInput(m)) => assert!(m.contains("stable_balance_config")),
2464            other => panic!("expected InvalidInput, got {other:?}"),
2465        }
2466    }
2467
2468    #[test]
2469    fn validate_server_mode_rejects_real_time_sync_directly() {
2470        use crate::default_server_config;
2471        let mut config = default_server_config(Network::Regtest);
2472        config.real_time_sync_server_url = Some("https://example.com".to_string());
2473        match super::validate_server_mode(&config, false) {
2474            Err(SdkError::InvalidInput(m)) => assert!(m.contains("real_time_sync_server_url")),
2475            other => panic!("expected InvalidInput, got {other:?}"),
2476        }
2477    }
2478
2479    #[test]
2480    fn validate_server_mode_rejects_leaf_auto_optimize_directly() {
2481        use crate::default_server_config;
2482        let mut config = default_server_config(Network::Regtest);
2483        config.leaf_optimization_config.auto_enabled = true;
2484        match super::validate_server_mode(&config, false) {
2485            Err(SdkError::InvalidInput(m)) => {
2486                assert!(m.contains("leaf_optimization_config.auto_enabled"));
2487            }
2488            other => panic!("expected InvalidInput, got {other:?}"),
2489        }
2490    }
2491
2492    #[test]
2493    fn validate_server_mode_rejects_token_auto_optimize_directly() {
2494        use crate::default_server_config;
2495        let mut config = default_server_config(Network::Regtest);
2496        config.token_optimization_config.auto_enabled = true;
2497        match super::validate_server_mode(&config, false) {
2498            Err(SdkError::InvalidInput(m)) => {
2499                assert!(m.contains("token_optimization_config.auto_enabled"));
2500            }
2501            other => panic!("expected InvalidInput, got {other:?}"),
2502        }
2503    }
2504
2505    #[test]
2506    fn validate_server_mode_rejects_cross_chain_directly() {
2507        use crate::{CrossChainConfig, default_server_config};
2508        let mut config = default_server_config(Network::Regtest);
2509        config.cross_chain_config = Some(CrossChainConfig::default());
2510        match super::validate_server_mode(&config, false) {
2511            Err(SdkError::InvalidInput(m)) => assert!(m.contains("Cross-chain config")),
2512            other => panic!("expected InvalidInput, got {other:?}"),
2513        }
2514    }
2515
2516    // ---- finalize_spark_wallet_config ----
2517
2518    #[test]
2519    fn finalize_spark_wallet_config_disabled_background_forces_leaf_auto_off() {
2520        let mut config = default_config(Network::Regtest);
2521        config.leaf_optimization_config.auto_enabled = true;
2522        let result =
2523            super::finalize_spark_wallet_config(&config, "test-agent", false, false).unwrap();
2524        assert!(!result.leaf_auto_optimize_enabled);
2525    }
2526
2527    #[test]
2528    fn finalize_spark_wallet_config_disabled_background_clears_token_auto_interval() {
2529        let mut config = default_config(Network::Regtest);
2530        config.token_optimization_config.auto_enabled = true;
2531        let result =
2532            super::finalize_spark_wallet_config(&config, "test-agent", false, false).unwrap();
2533        assert!(
2534            result
2535                .token_outputs_optimization_options
2536                .auto_optimize_interval
2537                .is_none()
2538        );
2539    }
2540
2541    #[test]
2542    fn finalize_spark_wallet_config_enabled_background_respects_leaf_auto_optimize() {
2543        let mut config = default_config(Network::Regtest);
2544        config.leaf_optimization_config.auto_enabled = true;
2545        let result =
2546            super::finalize_spark_wallet_config(&config, "test-agent", true, false).unwrap();
2547        assert!(result.leaf_auto_optimize_enabled);
2548    }
2549
2550    #[test]
2551    fn finalize_spark_wallet_config_applies_user_agent() {
2552        let config = default_config(Network::Regtest);
2553        let result =
2554            super::finalize_spark_wallet_config(&config, "my-app/1.0", true, false).unwrap();
2555        assert_eq!(
2556            result.service_provider_config.user_agent.as_deref(),
2557            Some("my-app/1.0")
2558        );
2559    }
2560
2561    #[test]
2562    fn finalize_spark_wallet_config_keeps_the_fee_ladder_off_for_a_remote_signer() {
2563        let mut config = default_config(Network::Regtest);
2564        let ladder = |config: &crate::Config, remote_signer| {
2565            super::finalize_spark_wallet_config(config, "test-agent", true, remote_signer)
2566                .map(|wallet_config| wallet_config.fee_ladder_enabled)
2567        };
2568        assert!(ladder(&config, false).unwrap());
2569        assert!(!ladder(&config, true).unwrap());
2570
2571        config.watchtower_fee_ladder_enabled = Some(false);
2572        assert!(!ladder(&config, false).unwrap());
2573        assert!(!ladder(&config, true).unwrap());
2574
2575        config.watchtower_fee_ladder_enabled = Some(true);
2576        assert!(ladder(&config, false).unwrap());
2577        assert!(matches!(
2578            ladder(&config, true),
2579            Err(SdkError::InvalidInput(_))
2580        ));
2581    }
2582
2583    // ---- resolve_context ----
2584
2585    #[tokio::test]
2586    async fn resolve_context_errors_on_network_mismatch() {
2587        use crate::{SdkContextConfig, new_shared_sdk_context};
2588        let config = default_config(Network::Mainnet);
2589        let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
2590            .await
2591            .expect("regtest context");
2592        let err = super::resolve_context(Some(ctx), &config)
2593            .await
2594            .err()
2595            .expect("expected mismatch error");
2596        assert!(
2597            err.to_string().contains("network/api_key do not match"),
2598            "unexpected error: {err}"
2599        );
2600    }
2601
2602    #[tokio::test]
2603    async fn resolve_context_errors_on_api_key_mismatch() {
2604        use crate::{SdkContextConfig, new_shared_sdk_context};
2605        let mut config = default_config(Network::Mainnet);
2606        config.api_key = Some("intended-key".to_string());
2607        let ctx = new_shared_sdk_context(SdkContextConfig {
2608            api_key: Some("wrong-key".to_string()),
2609            ..SdkContextConfig::new(Network::Mainnet)
2610        })
2611        .await
2612        .expect("mainnet context");
2613        let err = super::resolve_context(Some(ctx), &config)
2614            .await
2615            .err()
2616            .expect("expected mismatch error");
2617        assert!(
2618            err.to_string().contains("network/api_key do not match"),
2619            "unexpected error: {err}"
2620        );
2621    }
2622
2623    // ---- resolve_storage ----
2624
2625    #[tokio::test]
2626    async fn resolve_storage_errors_when_neither_supplied() {
2627        use crate::{SdkContextConfig, new_shared_sdk_context};
2628        let config = default_config(Network::Regtest);
2629        let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
2630            .await
2631            .expect("regtest context");
2632        let identity_public_key = test_spark_signer().get_identity_public_key().await.unwrap();
2633        let err = super::resolve_storage(None, &ctx, &identity_public_key, &config)
2634            .await
2635            .err()
2636            .expect("expected no-storage error");
2637        assert!(
2638            err.to_string().contains("No storage configured"),
2639            "unexpected error: {err}"
2640        );
2641    }
2642
2643    #[tokio::test]
2644    async fn resolve_storage_errors_when_supplied_on_both_builder_and_context() {
2645        use crate::{SdkContextConfig, default_storage, new_shared_sdk_context};
2646        let config = default_config(Network::Regtest);
2647        let ctx = new_shared_sdk_context(SdkContextConfig {
2648            storage: Some(default_storage(
2649                "/tmp/breez-sdk-test-resolve-storage-ctx".to_string(),
2650            )),
2651            ..SdkContextConfig::new(Network::Regtest)
2652        })
2653        .await
2654        .expect("regtest context");
2655        let identity_public_key = test_spark_signer().get_identity_public_key().await.unwrap();
2656        let builder_storage =
2657            default_storage("/tmp/breez-sdk-test-resolve-storage-builder".to_string());
2658        let err =
2659            super::resolve_storage(Some(builder_storage), &ctx, &identity_public_key, &config)
2660                .await
2661                .err()
2662                .expect("expected duplicate-storage error");
2663        assert!(
2664            err.to_string()
2665                .contains("storage is configured on both the SdkBuilder and the shared SdkContext"),
2666            "unexpected error: {err}"
2667        );
2668    }
2669}