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::buy::moonpay::MoonpayProvider;
8
9#[cfg(not(target_family = "wasm"))]
10use spark_wallet::Signer;
11use spark_wallet::{InMemorySessionManager, SparkWalletConfig, TokenOutputStore, TreeStore};
12use tokio::sync::watch;
13use tracing::{debug, info};
14
15use flashnet::{FlashnetConfig, IntegratorConfig};
16
17use crate::{
18    Credentials, EventEmitter, FiatService, FiatServiceWrapper, KeySetType, Network, Seed,
19    chain::{
20        BitcoinChainService,
21        rest_client::{BasicAuth, ChainApiType, RestClientChainService},
22    },
23    error::SdkError,
24    lnurl::{DefaultLnurlServerClient, LnurlServerClient},
25    models::Config,
26    payment_observer::{PaymentObserver, SparkTransferObserver},
27    persist::Storage,
28    realtime_sync::{RealTimeSyncParams, init_and_start_real_time_sync},
29    sdk::{BreezSdk, BreezSdkParams, SyncCoordinator, runtime_from_config},
30    sdk_context::{SdkContext, SdkContextConfig, new_shared_sdk_context},
31    session_manager::{SessionManager, SessionManagerAdapter},
32    signer::{
33        breez::BreezSignerImpl, lnurl_auth::LnurlAuthSignerAdapter, rtsync::RTSyncSigner,
34        spark::SparkSigner,
35    },
36    stable_balance::StableBalance,
37    token_conversion::TokenConversionMiddleware,
38    token_conversion::{
39        DEFAULT_INTEGRATOR_FEE_BPS, DEFAULT_INTEGRATOR_PUBKEY, FlashnetTokenConverter,
40        TokenConverter,
41    },
42};
43
44/// Configuration captured by [`SdkBuilder::with_rest_chain_service`].
45///
46/// Stored on the builder and resolved during `build()` so the resulting
47/// `RestClientChainService` reuses the shared HTTP client from the
48/// [`SdkContext`](crate::SdkContext).
49#[derive(Clone)]
50struct RestChainServiceConfig {
51    url: String,
52    api_type: ChainApiType,
53    credentials: Option<Credentials>,
54}
55
56/// Source for the signer - either a seed or an external signer implementation
57#[derive(Clone)]
58enum SignerSource {
59    Seed {
60        seed: Seed,
61        key_set_type: KeySetType,
62        use_address_index: bool,
63        account_number: Option<u32>,
64    },
65    External(Arc<dyn crate::signer::ExternalSigner>),
66}
67
68/// Builder for creating `BreezSdk` instances with customizable components.
69#[derive(Clone)]
70pub struct SdkBuilder {
71    config: Config,
72    signer_source: SignerSource,
73
74    storage_dir: Option<String>,
75    storage: Option<Arc<dyn Storage>>,
76    #[cfg(feature = "postgres")]
77    postgres_pool: Option<Arc<crate::persist::postgres::PostgresConnectionPool>>,
78    #[cfg(feature = "mysql")]
79    mysql_pool: Option<Arc<crate::persist::mysql::MysqlConnectionPool>>,
80    chain_service: Option<Arc<dyn BitcoinChainService>>,
81    rest_chain_service_config: Option<RestChainServiceConfig>,
82    fiat_service: Option<Arc<dyn FiatService>>,
83    lnurl_client: Option<Arc<dyn platform_utils::HttpClient>>,
84    lnurl_server_client: Option<Arc<dyn LnurlServerClient>>,
85    payment_observer: Option<Arc<dyn PaymentObserver>>,
86    tree_store: Option<Arc<dyn TreeStore>>,
87    token_output_store: Option<Arc<dyn TokenOutputStore>>,
88    context: Option<Arc<SdkContext>>,
89    session_manager: Option<Arc<dyn SessionManager>>,
90}
91
92impl SdkBuilder {
93    /// Creates a new `SdkBuilder` with the provided configuration and seed.
94    ///
95    /// For external signer support, use `new_with_signer` instead.
96    ///
97    /// # Arguments
98    /// - `config`: The configuration to be used.
99    /// - `seed`: The seed for wallet generation.
100    #[allow(clippy::needless_pass_by_value)]
101    pub fn new(config: Config, seed: Seed) -> Self {
102        SdkBuilder {
103            config,
104            signer_source: SignerSource::Seed {
105                seed,
106                key_set_type: KeySetType::Default,
107                use_address_index: false,
108                account_number: None,
109            },
110            storage_dir: None,
111            storage: None,
112            #[cfg(feature = "postgres")]
113            postgres_pool: None,
114            #[cfg(feature = "mysql")]
115            mysql_pool: None,
116            chain_service: None,
117            rest_chain_service_config: None,
118            fiat_service: None,
119            lnurl_client: None,
120            lnurl_server_client: None,
121            payment_observer: None,
122            tree_store: None,
123            token_output_store: None,
124            context: None,
125            session_manager: None,
126        }
127    }
128
129    /// Creates a new `SdkBuilder` with the provided configuration and external signer.
130    ///
131    /// # Arguments
132    /// - `config`: The configuration to be used.
133    /// - `signer`: An external signer implementation.
134    #[allow(clippy::needless_pass_by_value)]
135    pub fn new_with_signer(config: Config, signer: Arc<dyn crate::signer::ExternalSigner>) -> Self {
136        SdkBuilder {
137            config,
138            signer_source: SignerSource::External(signer),
139            storage_dir: None,
140            storage: None,
141            #[cfg(feature = "postgres")]
142            postgres_pool: None,
143            #[cfg(feature = "mysql")]
144            mysql_pool: None,
145            chain_service: None,
146            rest_chain_service_config: None,
147            fiat_service: None,
148            lnurl_client: None,
149            lnurl_server_client: None,
150            payment_observer: None,
151            tree_store: None,
152            token_output_store: None,
153            context: None,
154            session_manager: None,
155        }
156    }
157
158    /// Sets the key set type to be used by the SDK.
159    ///
160    /// Note: This only applies when using a seed-based signer. It has no effect
161    /// when using an external signer (created with `new_with_signer`).
162    ///
163    /// # Arguments
164    /// - `config`: Key set configuration containing the key set type, address index flag, and optional account number.
165    #[must_use]
166    pub fn with_key_set(mut self, config: crate::models::KeySetConfig) -> Self {
167        if let SignerSource::Seed {
168            key_set_type: ref mut kst,
169            use_address_index: ref mut uai,
170            account_number: ref mut an,
171            ..
172        } = self.signer_source
173        {
174            *kst = config.key_set_type;
175            *uai = config.use_address_index;
176            *an = config.account_number;
177        }
178        self
179    }
180
181    #[must_use]
182    /// Sets the root storage directory to initialize the default storage with.
183    /// This initializes both storage and real-time sync storage with the
184    /// default implementations.
185    /// Arguments:
186    /// - `storage_dir`: The data directory for storage.
187    pub fn with_default_storage(mut self, storage_dir: String) -> Self {
188        self.storage_dir = Some(storage_dir);
189        self
190    }
191
192    #[must_use]
193    /// Sets the storage implementation to be used by the SDK.
194    /// Arguments:
195    /// - `storage`: The storage implementation to be used.
196    pub fn with_storage(mut self, storage: Arc<dyn Storage>) -> Self {
197        self.storage = Some(storage);
198        self
199    }
200
201    /// Threads a shared [`SdkContext`] into this builder.
202    ///
203    /// Construct the context once via [`new_shared_sdk_context`] and pass the
204    /// same `Arc` to every `SdkBuilder` whose SDKs should share its underlying
205    /// resources (operator gRPC channels, SSP HTTP client, database pool).
206    ///
207    /// If not set, `build()` constructs a context internally from the SDK's
208    /// own network and api key — fine for a single-SDK process with no DB
209    /// backend.
210    #[must_use]
211    pub fn with_shared_context(mut self, context: Arc<SdkContext>) -> Self {
212        self.context = Some(context);
213        self
214    }
215
216    /// Sets a shared `PostgreSQL` connection pool as the backend for all
217    /// stores (storage, tree store, and token store).
218    ///
219    /// Construct the pool once via
220    /// [`create_postgres_connection_pool`](crate::create_postgres_connection_pool) and pass the
221    /// same `Arc` to multiple `SdkBuilder` instances to share connections
222    /// across SDKs. Per-tenant scoping is derived from each SDK's seed.
223    ///
224    /// If you've also threaded an [`SdkContext`] (via [`with_shared_context`](Self::with_shared_context))
225    /// that already carries a Postgres pool, `build()` will error — pick one
226    /// source. Most integrators use either this method *or* a context, not both.
227    #[must_use]
228    #[cfg(feature = "postgres")]
229    pub fn with_postgres_connection_pool(
230        mut self,
231        pool: Arc<crate::persist::postgres::PostgresConnectionPool>,
232    ) -> Self {
233        self.postgres_pool = Some(pool);
234        self
235    }
236
237    /// Sets a shared `MySQL` connection pool as the backend for all stores
238    /// (storage, tree store, and token store).
239    ///
240    /// Construct the pool once via
241    /// [`create_mysql_connection_pool`](crate::create_mysql_connection_pool) and pass the same
242    /// `Arc` to multiple `SdkBuilder` instances to share connections across
243    /// SDKs. Per-tenant scoping is derived from each SDK's seed.
244    ///
245    /// If you've also threaded an [`SdkContext`] (via [`with_shared_context`](Self::with_shared_context))
246    /// that already carries a `MySQL` pool, `build()` will error — pick one
247    /// source. Most integrators use either this method *or* a context, not both.
248    #[must_use]
249    #[cfg(feature = "mysql")]
250    pub fn with_mysql_connection_pool(
251        mut self,
252        pool: Arc<crate::persist::mysql::MysqlConnectionPool>,
253    ) -> Self {
254        self.mysql_pool = Some(pool);
255        self
256    }
257
258    /// Sets `PostgreSQL` as the backend by building a fresh pool from a
259    /// config. The store instances will be created during `build()`.
260    ///
261    /// Arguments:
262    /// - `config`: The `PostgreSQL` storage configuration.
263    #[cfg(feature = "postgres")]
264    #[deprecated(
265        note = "Call `create_postgres_connection_pool(&config)` and `with_postgres_connection_pool(pool)` instead."
266    )]
267    #[allow(clippy::needless_pass_by_value)]
268    pub fn with_postgres_backend(
269        self,
270        config: crate::persist::postgres::PostgresStorageConfig,
271    ) -> Result<Self, SdkError> {
272        let pool = crate::persist::postgres::create_postgres_connection_pool(&config)?;
273        Ok(self.with_postgres_connection_pool(pool))
274    }
275
276    /// Sets `MySQL` as the backend by building a fresh pool from a config.
277    /// The store instances will be created during `build()`.
278    ///
279    /// Arguments:
280    /// - `config`: The `MySQL` storage configuration.
281    #[cfg(feature = "mysql")]
282    #[deprecated(
283        note = "Call `create_mysql_connection_pool(&config)` and `with_mysql_connection_pool(pool)` instead."
284    )]
285    #[allow(clippy::needless_pass_by_value)]
286    pub fn with_mysql_backend(
287        self,
288        config: crate::persist::mysql::MysqlStorageConfig,
289    ) -> Result<Self, SdkError> {
290        let pool = crate::persist::mysql::create_mysql_connection_pool(&config)?;
291        Ok(self.with_mysql_connection_pool(pool))
292    }
293
294    /// WASM-only seam for the JS-side DB-backed session manager.
295    ///
296    /// Cfg-gated to the WASM target so it literally doesn't exist on native
297    /// builds and can't be misused.
298    #[cfg(target_family = "wasm")]
299    #[must_use]
300    pub fn with_session_manager(mut self, session_manager: Arc<dyn SessionManager>) -> Self {
301        self.session_manager = Some(session_manager);
302        self
303    }
304
305    /// Sets the chain service to be used by the SDK.
306    /// Arguments:
307    /// - `chain_service`: The chain service to be used.
308    #[must_use]
309    pub fn with_chain_service(mut self, chain_service: Arc<dyn BitcoinChainService>) -> Self {
310        self.chain_service = Some(chain_service);
311        self.rest_chain_service_config = None;
312        self
313    }
314
315    /// Configures a REST chain service to be used by the SDK.
316    ///
317    /// The service is constructed during [`build()`](Self::build) so it can
318    /// reuse the shared HTTP client carried by the [`SdkContext`](crate::SdkContext).
319    ///
320    /// Arguments:
321    /// - `url`: The base URL of the REST API.
322    /// - `api_type`: The API type to be used.
323    /// - `credentials`: Optional credentials for basic authentication.
324    #[must_use]
325    pub fn with_rest_chain_service(
326        mut self,
327        url: String,
328        api_type: ChainApiType,
329        credentials: Option<Credentials>,
330    ) -> Self {
331        self.chain_service = None;
332        self.rest_chain_service_config = Some(RestChainServiceConfig {
333            url,
334            api_type,
335            credentials,
336        });
337        self
338    }
339
340    /// Sets the fiat service to be used by the SDK.
341    /// Arguments:
342    /// - `fiat_service`: The fiat service to be used.
343    #[must_use]
344    pub fn with_fiat_service(mut self, fiat_service: Arc<dyn FiatService>) -> Self {
345        self.fiat_service = Some(fiat_service);
346        self
347    }
348
349    #[must_use]
350    pub fn with_lnurl_client(mut self, lnurl_client: Arc<dyn crate::RestClient>) -> Self {
351        self.lnurl_client = Some(Arc::new(crate::common::rest::RestClientWrapper::new(
352            lnurl_client,
353        )));
354        self
355    }
356
357    #[must_use]
358    #[allow(unused)]
359    pub fn with_lnurl_server_client(
360        mut self,
361        lnurl_serverclient: Arc<dyn LnurlServerClient>,
362    ) -> Self {
363        self.lnurl_server_client = Some(lnurl_serverclient);
364        self
365    }
366
367    /// Sets the payment observer to be used by the SDK.
368    /// This observer will receive callbacks before outgoing payments for Lightning, Spark and onchain Bitcoin.
369    /// Arguments:
370    /// - `payment_observer`: The payment observer to be used.
371    #[must_use]
372    #[allow(unused)]
373    pub fn with_payment_observer(mut self, payment_observer: Arc<dyn PaymentObserver>) -> Self {
374        self.payment_observer = Some(payment_observer);
375        self
376    }
377
378    /// Sets a custom tree store implementation.
379    ///
380    /// # Arguments
381    /// - `tree_store`: The tree store implementation to use.
382    #[must_use]
383    pub fn with_tree_store(mut self, tree_store: Arc<dyn TreeStore>) -> Self {
384        self.tree_store = Some(tree_store);
385        self
386    }
387
388    /// Sets a custom token output store implementation.
389    ///
390    /// # Arguments
391    /// - `token_output_store`: The token output store implementation to use.
392    #[must_use]
393    pub fn with_token_output_store(
394        mut self,
395        token_output_store: Arc<dyn TokenOutputStore>,
396    ) -> Self {
397        self.token_output_store = Some(token_output_store);
398        self
399    }
400
401    /// Builds a [`SparkWalletConfig`](spark_wallet::SparkWalletConfig) from a
402    /// [`SparkConfig`](crate::models::SparkConfig).
403    fn build_spark_wallet_config(
404        network: spark_wallet::Network,
405        env_config: &crate::models::SparkConfig,
406    ) -> Result<spark_wallet::SparkWalletConfig, SdkError> {
407        let coordinator_index = env_config
408            .signing_operators
409            .iter()
410            .position(|op| op.identifier == env_config.coordinator_identifier)
411            .ok_or_else(|| {
412                SdkError::InvalidInput(
413                    "coordinator_identifier does not match any signing operator".to_string(),
414                )
415            })?;
416
417        let operators: Vec<_> = env_config
418            .signing_operators
419            .iter()
420            .map(|op| {
421                SparkWalletConfig::create_operator_config(
422                    op.id as usize,
423                    &op.identifier,
424                    &op.address,
425                    None,
426                    &op.identity_public_key,
427                )
428                .map_err(|e| SdkError::InvalidInput(e.to_string()))
429            })
430            .collect::<Result<_, _>>()?;
431
432        let operator_pool = spark_wallet::OperatorPoolConfig::new(coordinator_index, operators)
433            .map_err(|e| SdkError::InvalidInput(e.to_string()))?;
434
435        let service_provider_config = SparkWalletConfig::create_service_provider_config(
436            &env_config.ssp_config.base_url,
437            &env_config.ssp_config.identity_public_key,
438            env_config.ssp_config.schema_endpoint.clone(),
439        )
440        .map_err(|e| SdkError::InvalidInput(e.to_string()))?;
441
442        let mut config = SparkWalletConfig::default_config(network);
443        config.operator_pool = operator_pool;
444        config.split_secret_threshold = env_config.threshold;
445        config.service_provider_config = service_provider_config;
446        config.tokens_config.expected_withdraw_bond_sats = env_config.expected_withdraw_bond_sats;
447        config
448            .tokens_config
449            .expected_withdraw_relative_block_locktime =
450            env_config.expected_withdraw_relative_block_locktime;
451
452        Ok(config)
453    }
454
455    /// Builds the `BreezSdk` instance with the configured components.
456    #[allow(clippy::too_many_lines)]
457    pub async fn build(self) -> Result<BreezSdk, SdkError> {
458        // Validate configuration
459        self.config.validate()?;
460        let runtime = runtime_from_config(&self.config);
461        if !runtime.starts_background_services() {
462            if self.config.stable_balance_config.is_some() {
463                return Err(SdkError::InvalidInput(
464                    "stable_balance_config is not supported when background_tasks_enabled is false"
465                        .to_string(),
466                ));
467            }
468            if self.config.real_time_sync_server_url.is_some() {
469                return Err(SdkError::InvalidInput(
470                    "real_time_sync_server_url must be None when background_tasks_enabled is false"
471                        .to_string(),
472                ));
473            }
474            if self.config.leaf_optimization_config.auto_enabled {
475                return Err(SdkError::InvalidInput(
476                    "leaf_optimization_config.auto_enabled must be false when background_tasks_enabled is false"
477                        .to_string(),
478                ));
479            }
480            if self.config.token_optimization_config.auto_enabled {
481                return Err(SdkError::InvalidInput(
482                    "token_optimization_config.auto_enabled must be false when background_tasks_enabled is false"
483                        .to_string(),
484                ));
485            }
486        }
487
488        // Create the base signer based on the signer source
489        let signer: Arc<dyn crate::signer::BreezSigner> = match self.signer_source {
490            SignerSource::Seed {
491                seed,
492                key_set_type,
493                use_address_index,
494                account_number,
495            } => Arc::new(
496                BreezSignerImpl::new(
497                    &self.config,
498                    &seed,
499                    key_set_type.into(),
500                    use_address_index,
501                    account_number,
502                )
503                .map_err(|e| SdkError::Generic(e.to_string()))?,
504            ),
505            SignerSource::External(external_signer) => {
506                use crate::signer::ExternalSignerAdapter;
507                Arc::new(ExternalSignerAdapter::new(external_signer))
508            }
509        };
510
511        // Create the specialized signers
512        let spark_signer = Arc::new(SparkSigner::new(signer.clone()));
513        let rtsync_signer = Arc::new(
514            RTSyncSigner::new(signer.clone(), self.config.network)
515                .map_err(|e| SdkError::Generic(e.to_string()))?,
516        );
517        let lnurl_auth_signer = Arc::new(LnurlAuthSignerAdapter::new(signer.clone()));
518
519        // Resolve the shared resources: use the caller-supplied context if
520        // present, otherwise spin up a default one. Either way, downstream
521        // wiring reads from `context` for connection managers.
522        let context = match self.context {
523            Some(ctx) => ctx,
524            None => {
525                new_shared_sdk_context(SdkContextConfig {
526                    api_key: self.config.api_key.clone(),
527                    ..SdkContextConfig::new(self.config.network)
528                })
529                .await?
530            }
531        };
532
533        // Ensure the context's parameters are the same as the config parameters.
534        if context.network != self.config.network || context.api_key != self.config.api_key {
535            return Err(SdkError::Generic(
536                "SdkContext network/api_key do not match SdkConfig".to_string(),
537            ));
538        }
539
540        // Resolve the DB pools from at most one source: the legacy
541        // `with_postgres_connection_pool` / `with_mysql_connection_pool`
542        // setters take precedence-by-exclusion over a pool carried by the
543        // context. Passing both is a configuration error.
544        #[cfg(feature = "postgres")]
545        let postgres_pool: Option<Arc<crate::persist::postgres::PostgresConnectionPool>> = match (
546            self.postgres_pool,
547            context.postgres_pool.clone(),
548        ) {
549            (Some(_), Some(_)) => {
550                return Err(SdkError::Generic(
551                        "multiple postgres pools configured: passed both via with_postgres_connection_pool and SdkContext"
552                            .to_string(),
553                    ));
554            }
555            (Some(p), None) | (None, Some(p)) => Some(p),
556            (None, None) => None,
557        };
558        #[cfg(feature = "mysql")]
559        let mysql_pool: Option<Arc<crate::persist::mysql::MysqlConnectionPool>> = match (
560            self.mysql_pool,
561            context.mysql_pool.clone(),
562        ) {
563            (Some(_), Some(_)) => {
564                return Err(SdkError::Generic(
565                        "multiple mysql pools configured: passed both via with_mysql_connection_pool and SdkContext"
566                            .to_string(),
567                    ));
568            }
569            (Some(p), None) | (None, Some(p)) => Some(p),
570            (None, None) => None,
571        };
572
573        let chain_service: Arc<dyn BitcoinChainService> = if let Some(service) = self.chain_service
574        {
575            service
576        } else if let Some(cfg) = self.rest_chain_service_config {
577            Arc::new(RestClientChainService::new(
578                cfg.url,
579                self.config.network,
580                5,
581                context.http_client.clone(),
582                cfg.credentials
583                    .map(|c| BasicAuth::new(c.username, c.password)),
584                cfg.api_type,
585            ))
586        } else {
587            let inner_client: Arc<dyn platform_utils::HttpClient> = context.http_client.clone();
588            match self.config.network {
589                Network::Mainnet => Arc::new(RestClientChainService::new(
590                    "https://blockstream.info/api".to_string(),
591                    self.config.network,
592                    5,
593                    inner_client,
594                    None,
595                    ChainApiType::Esplora,
596                )),
597                Network::Regtest => Arc::new(RestClientChainService::new(
598                    "https://regtest-mempool.us-west-2.sparkinfra.net/api".to_string(),
599                    self.config.network,
600                    5,
601                    inner_client,
602                    match (
603                        std::env::var("CHAIN_SERVICE_USERNAME"),
604                        std::env::var("CHAIN_SERVICE_PASSWORD"),
605                    ) {
606                        (Ok(username), Ok(password)) => Some(BasicAuth::new(username, password)),
607                        _ => Some(BasicAuth::new(
608                            "spark-sdk".to_string(),
609                            "mCMk1JqlBNtetUNy".to_string(),
610                        )),
611                    },
612                    ChainApiType::MempoolSpace,
613                )),
614            }
615        };
616
617        // Validate storage configuration
618        #[cfg(feature = "postgres")]
619        let has_postgres = postgres_pool.is_some();
620        #[cfg(not(feature = "postgres"))]
621        let has_postgres = false;
622
623        #[cfg(feature = "mysql")]
624        let has_mysql = mysql_pool.is_some();
625        #[cfg(not(feature = "mysql"))]
626        let has_mysql = false;
627
628        let storage_count = [
629            self.storage.is_some(),
630            self.storage_dir.is_some(),
631            has_postgres,
632            has_mysql,
633        ]
634        .into_iter()
635        .filter(|&v| v)
636        .count();
637        match storage_count {
638            0 => return Err(SdkError::Generic("No storage configured".to_string())),
639            2.. => {
640                return Err(SdkError::Generic(
641                    "Multiple storage configurations provided".to_string(),
642                ));
643            }
644            _ => {}
645        }
646
647        // Bundle the resolved Postgres pool with the tenant identity used to
648        // scope every read/write so storage, tree store, and token store
649        // share the same scope. The pool itself may back many SDK instances.
650        #[cfg(feature = "postgres")]
651        let postgres_backend = if let Some(ref pool) = postgres_pool {
652            let identity = spark_signer
653                .get_identity_public_key()
654                .await
655                .map_err(|e| SdkError::Generic(e.to_string()))?
656                .serialize();
657            Some((pool.inner.clone(), identity, pool.run_migration))
658        } else {
659            None
660        };
661
662        // Same for MySQL.
663        #[cfg(feature = "mysql")]
664        let mysql_backend = if let Some(ref pool) = mysql_pool {
665            let identity = spark_signer
666                .get_identity_public_key()
667                .await
668                .map_err(|e| SdkError::Generic(e.to_string()))?
669                .serialize();
670            Some((
671                pool.inner.clone(),
672                identity,
673                pool.run_migration,
674                pool.foreign_key_mode,
675            ))
676        } else {
677            None
678        };
679
680        // Initialize storage
681        let storage: Arc<dyn Storage> = if let Some(storage) = self.storage {
682            storage
683        } else if let Some(storage_dir) = self.storage_dir {
684            #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
685            {
686                let identity_pub_key = spark_signer
687                    .get_identity_public_key()
688                    .await
689                    .map_err(|e| SdkError::Generic(e.to_string()))?;
690                default_storage(&storage_dir, self.config.network, &identity_pub_key)?
691            }
692            #[cfg(all(target_family = "wasm", target_os = "unknown"))]
693            {
694                let _ = storage_dir;
695                return Err(SdkError::Generic(
696                    "with_default_storage is not supported on WASM".to_string(),
697                ));
698            }
699        } else {
700            #[allow(unused_mut)]
701            let mut s: Option<Arc<dyn Storage>> = None;
702
703            #[cfg(all(
704                feature = "postgres",
705                not(all(target_family = "wasm", target_os = "unknown"))
706            ))]
707            if s.is_none()
708                && let Some((ref pool, ref identity, run_migration)) = postgres_backend
709            {
710                s = Some(Arc::new(
711                    crate::persist::postgres::PostgresStorage::new_with_pool(
712                        pool.clone(),
713                        identity,
714                        run_migration,
715                    )
716                    .await
717                    .map_err(|e| SdkError::Generic(e.to_string()))?,
718                ));
719            }
720
721            #[cfg(all(
722                feature = "mysql",
723                not(all(target_family = "wasm", target_os = "unknown"))
724            ))]
725            if s.is_none()
726                && let Some((ref pool, ref identity, run_migration, _)) = mysql_backend
727            {
728                s = Some(Arc::new(
729                    crate::persist::mysql::MysqlStorage::new_with_pool(
730                        pool.clone(),
731                        identity,
732                        run_migration,
733                    )
734                    .await
735                    .map_err(|e| SdkError::Generic(e.to_string()))?,
736                ));
737            }
738
739            s.ok_or_else(|| SdkError::Generic("No storage configured".to_string()))?
740        };
741
742        let user_agent = crate::default_user_agent();
743        info!("Building sdk with user agent: {}", user_agent);
744
745        let breez_server = Arc::clone(&context.breez_server);
746
747        let fiat_service: Arc<dyn breez_sdk_common::fiat::FiatService> = match self.fiat_service {
748            Some(service) => Arc::new(FiatServiceWrapper::new(service)),
749            None => breez_server.clone(),
750        };
751
752        let lnurl_client: Arc<dyn platform_utils::HttpClient> = match self.lnurl_client {
753            Some(client) => client,
754            None => context.http_client.clone(),
755        };
756        let mut spark_wallet_config = if let Some(env_config) = &self.config.spark_config {
757            Self::build_spark_wallet_config(self.config.network.into(), env_config)?
758        } else {
759            spark_wallet::SparkWalletConfig::default_config(self.config.network.into())
760        };
761        spark_wallet_config.operator_pool = spark_wallet_config
762            .operator_pool
763            .with_user_agent(Some(user_agent.clone()));
764        spark_wallet_config.service_provider_config.user_agent = Some(user_agent.clone());
765        let background_services_enabled = runtime.starts_background_services();
766        spark_wallet_config.leaf_auto_optimize_enabled =
767            background_services_enabled && self.config.leaf_optimization_config.auto_enabled;
768        spark_wallet_config.leaf_optimization_options.multiplicity =
769            self.config.leaf_optimization_config.multiplicity;
770
771        let token_opt = &self.config.token_optimization_config;
772        let token_options = &mut spark_wallet_config.token_outputs_optimization_options;
773        token_options.target_output_count = token_opt.target_output_count;
774        token_options.min_outputs_threshold = token_opt.min_outputs_threshold;
775        // Only override when disabled; enabled keeps the network default interval.
776        if !token_opt.auto_enabled || !background_services_enabled {
777            token_options.auto_optimize_interval = None;
778        }
779        spark_wallet_config.max_concurrent_claims = self.config.max_concurrent_claims;
780
781        let shutdown_sender = watch::channel::<()>(()).0;
782
783        // Create tree store if configured
784        #[allow(unused_mut)]
785        let mut tree_store: Option<Arc<dyn TreeStore>> = self.tree_store;
786
787        #[cfg(feature = "postgres")]
788        if tree_store.is_none()
789            && let Some((ref pool, ref identity, run_migration)) = postgres_backend
790        {
791            tree_store = Some(
792                crate::persist::postgres::create_postgres_tree_store(
793                    pool.clone(),
794                    identity,
795                    run_migration,
796                )
797                .await?,
798            );
799        }
800
801        #[cfg(feature = "mysql")]
802        if tree_store.is_none()
803            && let Some((ref pool, ref identity, run_migration, foreign_key_mode)) = mysql_backend
804        {
805            tree_store = Some(
806                crate::persist::mysql::create_mysql_tree_store(
807                    pool.clone(),
808                    identity,
809                    run_migration,
810                    foreign_key_mode,
811                )
812                .await?,
813            );
814        }
815
816        // Create token output store if configured
817        #[allow(unused_mut)]
818        let mut token_output_store: Option<Arc<dyn TokenOutputStore>> = self.token_output_store;
819
820        #[cfg(feature = "postgres")]
821        if token_output_store.is_none()
822            && let Some((ref pool, ref identity, run_migration)) = postgres_backend
823        {
824            token_output_store = Some(
825                crate::persist::postgres::create_postgres_token_store(
826                    pool.clone(),
827                    identity,
828                    run_migration,
829                )
830                .await?,
831            );
832        }
833
834        #[cfg(feature = "mysql")]
835        if token_output_store.is_none()
836            && let Some((ref pool, ref identity, run_migration, foreign_key_mode)) = mysql_backend
837        {
838            token_output_store = Some(
839                crate::persist::mysql::create_mysql_token_store(
840                    pool.clone(),
841                    identity,
842                    run_migration,
843                    foreign_key_mode,
844                )
845                .await?,
846            );
847        }
848
849        #[allow(unused_mut)]
850        let mut inner_session_manager: Option<Arc<dyn spark_wallet::SessionManager>> = self
851            .session_manager
852            .map(|sm| Arc::new(SessionManagerAdapter(sm)) as Arc<dyn spark_wallet::SessionManager>);
853
854        #[cfg(feature = "postgres")]
855        if inner_session_manager.is_none()
856            && let Some((ref pool, ref identity, run_migration)) = postgres_backend
857        {
858            inner_session_manager = Some(
859                crate::persist::postgres::create_postgres_session_manager(
860                    pool.clone(),
861                    identity,
862                    run_migration,
863                )
864                .await?,
865            );
866        }
867
868        #[cfg(feature = "mysql")]
869        if inner_session_manager.is_none()
870            && let Some((ref pool, ref identity, run_migration, _)) = mysql_backend
871        {
872            inner_session_manager = Some(
873                crate::persist::mysql::create_mysql_session_manager(
874                    pool.clone(),
875                    identity,
876                    run_migration,
877                )
878                .await?,
879            );
880        }
881
882        let inner_session_manager =
883            inner_session_manager.unwrap_or_else(|| Arc::new(InMemorySessionManager::default()));
884        let inner_session_manager: Arc<dyn spark_wallet::SessionManager> = Arc::new(
885            crate::session_manager::EncryptingSessionManager::new(
886                inner_session_manager,
887                signer.clone(),
888                self.config.network,
889            )
890            .map_err(|e| {
891                SdkError::Generic(format!("failed to set up session token encryption: {e}"))
892            })?,
893        );
894        let inner_session_manager: Arc<dyn spark_wallet::SessionManager> = Arc::new(
895            crate::session_manager::CachingSessionManager::new(inner_session_manager),
896        );
897        let mut wallet_builder =
898            spark_wallet::WalletBuilder::new(spark_wallet_config, spark_signer)
899                .with_cancellation_token(shutdown_sender.subscribe())
900                .with_session_manager(inner_session_manager)
901                .with_background_processing(background_services_enabled);
902        if let Some(provider) = &context.jwt_header_provider {
903            wallet_builder = wallet_builder.with_so_extra_header_provider(
904                Arc::clone(provider) as Arc<dyn spark_wallet::HeaderProvider>
905            );
906        }
907        if let Some(observer) = self.payment_observer {
908            let observer: Arc<dyn spark_wallet::TransferObserver> =
909                Arc::new(SparkTransferObserver::new(observer));
910            wallet_builder = wallet_builder.with_transfer_observer(observer);
911        }
912        if let Some(tree_store) = tree_store {
913            wallet_builder = wallet_builder.with_tree_store(tree_store);
914        }
915        if let Some(token_output_store) = token_output_store {
916            wallet_builder = wallet_builder.with_token_output_store(token_output_store);
917        }
918        wallet_builder = wallet_builder.with_ssp_http_client(context.http_client.clone());
919        wallet_builder = wallet_builder.with_connection_manager(context.connection_manager.clone());
920        let spark_wallet = Arc::new(wallet_builder.build().await?);
921
922        let lnurl_server_client: Option<Arc<dyn LnurlServerClient>> = match self.lnurl_server_client
923        {
924            Some(client) => Some(client),
925            None => match &self.config.lnurl_domain {
926                Some(domain) => Some(Arc::new(DefaultLnurlServerClient::new(
927                    context.http_client.clone(),
928                    domain.clone(),
929                    self.config.api_key.clone(),
930                    Arc::clone(&spark_wallet),
931                ))),
932                None => None,
933            },
934        };
935
936        let real_time_sync_active =
937            background_services_enabled && self.config.real_time_sync_server_url.is_some();
938        let event_emitter = Arc::new(EventEmitter::new(real_time_sync_active));
939
940        let storage = match &self.config.real_time_sync_server_url {
941            Some(server_url) if background_services_enabled => {
942                init_and_start_real_time_sync(RealTimeSyncParams {
943                    server_url: server_url.clone(),
944                    api_key: self.config.api_key.clone(),
945                    user_agent,
946                    signer: rtsync_signer,
947                    storage: Arc::clone(&storage),
948                    shutdown_receiver: shutdown_sender.subscribe(),
949                    event_emitter: Arc::clone(&event_emitter),
950                    lnurl_server_client: lnurl_server_client.clone(),
951                })
952                .await?
953            }
954            _ => storage,
955        };
956
957        // Create the MoonPay provider for buying Bitcoin
958        let buy_bitcoin_provider = Arc::new(MoonpayProvider::new(breez_server.clone()));
959
960        // Create the FlashnetTokenConverter. Client runtime starts its refunder.
961        let flashnet_config = FlashnetConfig::default_config(
962            self.config.network.into(),
963            DEFAULT_INTEGRATOR_PUBKEY
964                .parse()
965                .ok()
966                .map(|pubkey| IntegratorConfig {
967                    pubkey,
968                    fee_bps: DEFAULT_INTEGRATOR_FEE_BPS,
969                }),
970        );
971        let flashnet_converter = Arc::new(FlashnetTokenConverter::new(
972            flashnet_config,
973            Arc::clone(&storage),
974            Arc::clone(&spark_wallet),
975            self.config.network,
976            context.http_client.clone(),
977        ));
978        let token_converter: Arc<dyn TokenConverter> = flashnet_converter;
979
980        // Create sync coordinator for the client runtime's sync loop
981        let sync_coordinator = SyncCoordinator::new();
982        // Create StableBalance if configured. Client runtime starts its worker.
983        // It registers itself as event middleware (must be before TokenConversionMiddleware
984        // so it can see conversion child payment events for deferred task resolution)
985        let stable_balance = if let Some(config) = &self.config.stable_balance_config {
986            let stable_balance = Arc::new(
987                StableBalance::new(
988                    config.clone(),
989                    Arc::clone(&token_converter),
990                    Arc::clone(&spark_wallet),
991                    Arc::clone(&storage),
992                    Arc::clone(&event_emitter),
993                )
994                .await,
995            );
996            Some(stable_balance)
997        } else {
998            None
999        };
1000
1001        // Register TokenConversionMiddleware to suppress conversion child events
1002        // before they reach external listeners (after StableBalance middleware)
1003        event_emitter
1004            .add_middleware(Box::new(TokenConversionMiddleware))
1005            .await;
1006
1007        // Create the SDK instance
1008        let sdk = BreezSdk::init_and_start(BreezSdkParams {
1009            config: self.config,
1010            storage,
1011            chain_service,
1012            fiat_service,
1013            lnurl_client,
1014            lnurl_server_client,
1015            lnurl_auth_signer,
1016            shutdown_sender,
1017            runtime,
1018            spark_wallet,
1019            event_emitter,
1020            buy_bitcoin_provider,
1021            token_converter,
1022            stable_balance,
1023            sync_coordinator,
1024        })
1025        .await?;
1026        debug!("Initialized and started breez sdk.");
1027
1028        Ok(sdk)
1029    }
1030}
1031
1032#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
1033fn default_storage(
1034    data_dir: &str,
1035    network: Network,
1036    identity_pub_key: &spark_wallet::PublicKey,
1037) -> Result<Arc<dyn Storage>, SdkError> {
1038    let db_path = crate::default_storage_path(data_dir, &network, identity_pub_key)?;
1039    let storage = Arc::new(crate::SqliteStorage::new(&db_path)?);
1040    Ok(storage)
1041}
1042
1043#[cfg(test)]
1044#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
1045mod tests {
1046    use super::SdkBuilder;
1047    use crate::{Network, default_config};
1048
1049    #[test]
1050    fn default_config_spark_config_builds_valid_wallet_config() {
1051        for network in [Network::Mainnet, Network::Regtest] {
1052            let config = default_config(network);
1053            let spark_config = config
1054                .spark_config
1055                .as_ref()
1056                .expect("default_config must populate spark_config");
1057            SdkBuilder::build_spark_wallet_config(network.into(), spark_config).unwrap_or_else(
1058                |e| {
1059                    panic!(
1060                        "default_config({network:?}).spark_config failed to build SparkWalletConfig: {e}"
1061                    )
1062                },
1063            );
1064        }
1065    }
1066
1067    #[tokio::test]
1068    async fn server_mode_rejects_stable_balance_config() {
1069        use crate::{SdkError, StableBalanceConfig, StableBalanceToken, default_server_config};
1070
1071        let mut config = default_server_config(Network::Regtest);
1072        config.stable_balance_config = Some(StableBalanceConfig {
1073            tokens: vec![StableBalanceToken {
1074                label: "USDB".to_string(),
1075                token_identifier: "btkn1test".to_string(),
1076            }],
1077            default_active_label: None,
1078            threshold_sats: None,
1079            max_slippage_bps: None,
1080        });
1081
1082        let seed = test_seed();
1083        let result = SdkBuilder::new(config, seed).build().await;
1084        match result {
1085            Err(SdkError::InvalidInput(message)) => {
1086                assert!(message.contains("stable_balance_config"));
1087            }
1088            Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1089            Ok(_) => panic!("expected server mode with Stable Balance config to fail"),
1090        }
1091    }
1092
1093    #[tokio::test]
1094    async fn server_mode_rejects_real_time_sync_server_url() {
1095        use crate::{SdkError, default_server_config};
1096
1097        let mut config = default_server_config(Network::Regtest);
1098        config.real_time_sync_server_url = Some("https://example.com".to_string());
1099
1100        let seed = test_seed();
1101        let result = SdkBuilder::new(config, seed).build().await;
1102        match result {
1103            Err(SdkError::InvalidInput(message)) => {
1104                assert!(message.contains("real_time_sync_server_url"));
1105            }
1106            Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1107            Ok(_) => panic!("expected server mode with real_time_sync_server_url to fail"),
1108        }
1109    }
1110
1111    #[tokio::test]
1112    async fn server_mode_rejects_leaf_optimization_auto_enabled() {
1113        use crate::{SdkError, default_server_config};
1114
1115        let mut config = default_server_config(Network::Regtest);
1116        config.leaf_optimization_config.auto_enabled = true;
1117
1118        let seed = test_seed();
1119        let result = SdkBuilder::new(config, seed).build().await;
1120        match result {
1121            Err(SdkError::InvalidInput(message)) => {
1122                assert!(message.contains("leaf_optimization_config.auto_enabled"));
1123            }
1124            Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1125            Ok(_) => panic!("expected server mode with optimization auto_enabled to fail"),
1126        }
1127    }
1128
1129    #[tokio::test]
1130    async fn server_mode_rejects_token_optimization_auto_enabled() {
1131        use crate::{SdkError, default_server_config};
1132
1133        let mut config = default_server_config(Network::Regtest);
1134        config.token_optimization_config.auto_enabled = true;
1135
1136        let seed = test_seed();
1137        let result = SdkBuilder::new(config, seed).build().await;
1138        match result {
1139            Err(SdkError::InvalidInput(message)) => {
1140                assert!(message.contains("token_optimization_config.auto_enabled"));
1141            }
1142            Err(err) => panic!("expected InvalidInput error, got {err:?}"),
1143            Ok(_) => panic!("expected server mode with optimization auto_enabled to fail"),
1144        }
1145    }
1146
1147    /// Mainnet SDK with a caller-supplied Regtest context errors at `build()`
1148    /// — the context has no JWT provider so the partner JWT would be silently
1149    /// disabled.
1150    #[tokio::test]
1151    async fn build_errors_on_network_mismatch() {
1152        use crate::{SdkContextConfig, new_shared_sdk_context};
1153        let mut config = default_config(Network::Mainnet);
1154        config.api_key = Some("partner-key".to_string());
1155        let ctx = new_shared_sdk_context(SdkContextConfig {
1156            api_key: Some("partner-key".to_string()),
1157            ..SdkContextConfig::new(Network::Regtest)
1158        })
1159        .await
1160        .expect("regtest context");
1161        let err = SdkBuilder::new(config, test_seed())
1162            .with_shared_context(ctx)
1163            .with_default_storage("/tmp/breez-sdk-test-network-mismatch".to_string())
1164            .build()
1165            .await
1166            .err()
1167            .expect("expected network-mismatch error");
1168        assert!(
1169            err.to_string().contains("network/api_key do not match"),
1170            "unexpected error: {err}"
1171        );
1172    }
1173
1174    /// Mainnet SDK with a Mainnet context whose `api_key` differs from
1175    /// `Config`'s errors at `build()` — the JWT provider would sign with a
1176    /// different key than the integrator intended.
1177    #[tokio::test]
1178    #[allow(clippy::manual_assert)]
1179    async fn build_errors_on_api_key_mismatch() {
1180        use crate::{SdkContextConfig, new_shared_sdk_context};
1181        let mut config = default_config(Network::Mainnet);
1182        config.api_key = Some("intended-key".to_string());
1183        let ctx = new_shared_sdk_context(SdkContextConfig {
1184            api_key: Some("wrong-key".to_string()),
1185            ..SdkContextConfig::new(Network::Mainnet)
1186        })
1187        .await
1188        .expect("mainnet context");
1189        let err = SdkBuilder::new(config, test_seed())
1190            .with_shared_context(ctx)
1191            .with_default_storage("/tmp/breez-sdk-test-key-mismatch".to_string())
1192            .build()
1193            .await
1194            .err()
1195            .expect("expected api_key-mismatch error");
1196        assert!(
1197            err.to_string().contains("network/api_key do not match"),
1198            "unexpected error: {err}"
1199        );
1200    }
1201
1202    fn test_seed() -> crate::Seed {
1203        crate::Seed::Mnemonic {
1204            mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon \
1205                       abandon abandon about"
1206                .to_string(),
1207            passphrase: None,
1208        }
1209    }
1210}