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