Skip to main content

breez_sdk_spark/
sdk_builder.rs

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