Skip to main content

breez_sdk_spark/
sdk_builder.rs

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