Skip to main content

breez_sdk_spark/sdk/
mod.rs

1mod api;
2mod contacts;
3mod deposits;
4mod helpers;
5mod init;
6mod lightning_address;
7mod lnurl;
8mod payments;
9mod runtime;
10mod sync;
11mod sync_coordinator;
12
13pub(crate) use runtime::{RuntimeEvent, SdkRuntime, runtime_from_config};
14pub(crate) use sync_coordinator::SyncCoordinator;
15
16use bitflags::bitflags;
17use breez_sdk_common::{buy::moonpay::MoonpayProvider, fiat::FiatService};
18use platform_utils::HttpClient;
19use platform_utils::tokio;
20use spark_wallet::SparkWallet;
21use std::sync::Arc;
22use tokio::sync::{Mutex, OnceCell, oneshot, watch};
23
24use crate::{
25    BitcoinChainService, ExternalInputParser, InputType, LeafOptimizationConfig, Logger, Network,
26    TokenOptimizationConfig, error::SdkError, events::EventEmitter, lnurl::LnurlServerClient,
27    logger, models::Config, persist::Storage, signer::lnurl_auth::LnurlAuthSignerAdapter,
28    stable_balance::StableBalance, token_conversion::TokenConverter,
29};
30
31#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
32const BREEZ_SYNC_SERVICE_URL: &str = "https://datasync.breez.technology";
33
34#[cfg(all(target_family = "wasm", target_os = "unknown"))]
35const BREEZ_SYNC_SERVICE_URL: &str = "https://datasync.breez.technology:442";
36
37pub(crate) const CLAIM_TX_SIZE_VBYTES: u64 = 99;
38pub(crate) const SYNC_PAGING_LIMIT: u32 = 100;
39
40bitflags! {
41    #[derive(Clone, Debug, PartialEq, Eq)]
42    pub(crate) struct SyncType: u32 {
43        const Wallet = 1 << 0;
44        const WalletState = 1 << 1;
45        const Deposits = 1 << 2;
46        const LnurlMetadata = 1 << 3;
47        const Full = Self::Wallet.0.0
48            | Self::WalletState.0.0
49            | Self::Deposits.0.0
50            | Self::LnurlMetadata.0.0;
51    }
52}
53
54#[derive(Clone, Debug)]
55pub(crate) struct SyncRequest {
56    pub(crate) sync_type: SyncType,
57    #[allow(clippy::type_complexity)]
58    pub(crate) reply: Arc<Mutex<Option<oneshot::Sender<Result<(), SdkError>>>>>,
59    /// If true, bypass the "recently synced" check and sync immediately.
60    /// Use for event-driven syncs (after payments, transfers, etc.) that should happen immediately.
61    pub(crate) force: bool,
62}
63
64impl SyncRequest {
65    pub(crate) async fn reply(&self, error: Option<SdkError>) {
66        if let Some(reply) = self.reply.lock().await.take() {
67            let _ = match error {
68                Some(e) => reply.send(Err(e)),
69                None => reply.send(Ok(())),
70            };
71        }
72    }
73}
74
75/// `BreezSDK` is a wrapper around `SparkSDK` that provides a more structured API
76/// with request/response objects and comprehensive error handling.
77#[derive(Clone)]
78#[cfg_attr(feature = "uniffi", derive(uniffi::Object))]
79pub struct BreezSdk {
80    pub(crate) config: Config,
81    pub(crate) spark_wallet: Arc<SparkWallet>,
82    pub(crate) storage: Arc<dyn Storage>,
83    pub(crate) chain_service: Arc<dyn BitcoinChainService>,
84    pub(crate) fiat_service: Arc<dyn FiatService>,
85    pub(crate) lnurl_client: Arc<dyn HttpClient>,
86    pub(crate) lnurl_server_client: Option<Arc<dyn LnurlServerClient>>,
87    pub(crate) lnurl_auth_signer: Arc<LnurlAuthSignerAdapter>,
88    pub(crate) event_emitter: Arc<EventEmitter>,
89    pub(crate) shutdown_sender: watch::Sender<()>,
90    pub(crate) runtime: SdkRuntime,
91    /// Coordinator for coalescing duplicate sync requests
92    pub(crate) sync_coordinator: SyncCoordinator,
93    pub(crate) initial_synced_watcher: watch::Receiver<bool>,
94    pub(crate) external_input_parsers: Vec<ExternalInputParser>,
95    pub(crate) spark_private_mode_initialized: Arc<OnceCell<()>>,
96    pub(crate) token_converter: Arc<dyn TokenConverter>,
97    pub(crate) stable_balance: Option<Arc<StableBalance>>,
98    pub(crate) buy_bitcoin_provider: Arc<MoonpayProvider>,
99}
100
101pub(crate) struct BreezSdkParams {
102    pub config: Config,
103    pub storage: Arc<dyn Storage>,
104    pub chain_service: Arc<dyn BitcoinChainService>,
105    pub fiat_service: Arc<dyn FiatService>,
106    pub lnurl_client: Arc<dyn HttpClient>,
107    pub lnurl_server_client: Option<Arc<dyn LnurlServerClient>>,
108    pub lnurl_auth_signer: Arc<LnurlAuthSignerAdapter>,
109    pub shutdown_sender: watch::Sender<()>,
110    pub runtime: SdkRuntime,
111    pub spark_wallet: Arc<SparkWallet>,
112    pub event_emitter: Arc<EventEmitter>,
113    pub buy_bitcoin_provider: Arc<MoonpayProvider>,
114    pub token_converter: Arc<dyn TokenConverter>,
115    pub stable_balance: Option<Arc<StableBalance>>,
116    pub sync_coordinator: SyncCoordinator,
117}
118
119pub async fn parse_input(
120    input: &str,
121    external_input_parsers: Option<Vec<ExternalInputParser>>,
122) -> Result<InputType, SdkError> {
123    Ok(breez_sdk_common::input::parse(
124        input,
125        external_input_parsers.map(|parsers| parsers.into_iter().map(From::from).collect()),
126    )
127    .await?
128    .into())
129}
130
131#[cfg_attr(feature = "uniffi", uniffi::export)]
132pub fn init_logging(
133    log_dir: Option<String>,
134    app_logger: Option<Box<dyn Logger>>,
135    log_filter: Option<String>,
136) -> Result<(), SdkError> {
137    logger::init_logging(log_dir, app_logger, log_filter)
138}
139
140/// Connects to the Spark network using the provided configuration and mnemonic.
141///
142/// # Arguments
143///
144/// * `request` - The connection request object
145///
146/// # Returns
147///
148/// Result containing either the initialized `BreezSdk` or an `SdkError`
149#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
150#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
151pub async fn connect(request: crate::ConnectRequest) -> Result<BreezSdk, SdkError> {
152    let builder = super::sdk_builder::SdkBuilder::new(request.config, request.seed)
153        .with_default_storage(request.storage_dir);
154    let sdk = builder.build().await?;
155    Ok(sdk)
156}
157
158/// Connects to the Spark network using an external signer.
159///
160/// This method allows using a custom signer implementation instead of providing
161/// a seed directly.
162///
163/// # Arguments
164///
165/// * `request` - The connection request object with external signer
166///
167/// # Returns
168///
169/// Result containing either the initialized `BreezSdk` or an `SdkError`
170#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
171#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
172pub async fn connect_with_signer(
173    request: crate::ConnectWithSignerRequest,
174) -> Result<BreezSdk, SdkError> {
175    let builder = super::sdk_builder::SdkBuilder::new_with_signer(request.config, request.signer)
176        .with_default_storage(request.storage_dir);
177    let sdk = builder.build().await?;
178    Ok(sdk)
179}
180
181#[cfg_attr(feature = "uniffi", uniffi::export)]
182pub fn default_config(network: Network) -> Config {
183    let lnurl_domain = match network {
184        Network::Mainnet => Some("breez.tips".to_string()),
185        Network::Regtest => None,
186    };
187    Config {
188        api_key: None,
189        network,
190        sync_interval_secs: 60, // every 1 minute
191        max_deposit_claim_fee: Some(crate::MaxFee::Rate { sat_per_vbyte: 1 }),
192        lnurl_domain,
193        prefer_spark_over_lightning: false,
194        external_input_parsers: None,
195        use_default_external_input_parsers: true,
196        real_time_sync_server_url: Some(BREEZ_SYNC_SERVICE_URL.to_string()),
197        private_enabled_default: true,
198        leaf_optimization_config: LeafOptimizationConfig {
199            auto_enabled: true,
200            multiplicity: 1,
201        },
202        token_optimization_config: TokenOptimizationConfig {
203            auto_enabled: true,
204            target_output_count: 5,
205            min_outputs_threshold: 50,
206        },
207        stable_balance_config: None,
208        max_concurrent_claims: 4,
209        spark_config: Some(default_spark_config(network)),
210        background_tasks_enabled: true,
211    }
212}
213
214/// Builds a [`Config`] suitable for multi-tenant server-mode deployments.
215///
216/// This preset returns the same configuration as [`default_config`] with
217/// [`background_tasks_enabled`](Config::background_tasks_enabled) set to
218/// `false`. In server mode, the SDK is treated as a library: the host
219/// orchestrates sync, claiming, and event delivery (typically via webhooks)
220/// explicitly, so an ephemeral SDK instance stays cheap and predictable.
221///
222/// Config fields whose background services are gated off are reset to their
223/// inactive shape: `real_time_sync_server_url` is set to `None`, and both
224/// `leaf_optimization_config.auto_enabled` and
225/// `token_optimization_config.auto_enabled` are set to `false`. The SDK
226/// rejects builds where `background_tasks_enabled` is `false` and any of
227/// those fields is left in its active shape, so flip the flag via this
228/// helper rather than by hand.
229///
230/// Explicit operations (`sync_wallet`, `claim_deposit`,
231/// `list_unclaimed_deposits`, `refund_deposit`,
232/// `refund_pending_conversions`, etc.) continue to work and are the intended
233/// entry points in this mode.
234///
235/// Stable Balance is not supported in this mode because its conversion worker
236/// is a background service.
237///
238/// One-time setup that the SDK normally applies automatically — notably
239/// `private_enabled_default` — is NOT applied in this mode. Drive setup
240/// explicitly via `update_user_settings` (and any other relevant APIs) so
241/// ephemeral per-request SDK instances incur no implicit setup overhead.
242///
243/// `get_info` reads balance directly from the spark wallet in this mode
244/// rather than from the background-maintained storage cache, so balance
245/// reflects the latest local sync and `ensure_synced=true` is rejected with
246/// an invalid-input error
247#[cfg_attr(feature = "uniffi", uniffi::export)]
248pub fn default_server_config(network: Network) -> Config {
249    let mut config = default_config(network);
250    config.background_tasks_enabled = false;
251    config.real_time_sync_server_url = None;
252    config.leaf_optimization_config.auto_enabled = false;
253    config.token_optimization_config.auto_enabled = false;
254    config
255}
256
257/// Builds the default [`SparkConfig`](crate::models::SparkConfig) for the given network.
258///
259/// Surfaced through [`default_config`] as `Config::spark_config` so callers can read the
260/// baked-in operator and SSP endpoints and selectively override individual fields (e.g. to
261/// point at a staging environment) before passing the [`Config`] to [`connect`].
262fn default_spark_config(network: Network) -> crate::models::SparkConfig {
263    use crate::models::{SparkSigningOperator, SparkSspConfig};
264
265    let wallet_config = spark_wallet::SparkWalletConfig::default_config(network.into());
266
267    let coordinator_identifier = hex::encode(
268        wallet_config
269            .operator_pool
270            .get_coordinator()
271            .identifier
272            .serialize(),
273    );
274
275    let signing_operators = wallet_config
276        .operator_pool
277        .get_all_operators()
278        .map(|op| SparkSigningOperator {
279            id: u32::try_from(op.id).expect("operator id fits in u32"),
280            identifier: hex::encode(op.identifier.serialize()),
281            address: op.address.clone(),
282            identity_public_key: hex::encode(op.identity_public_key.serialize()),
283        })
284        .collect();
285
286    let ssp = &wallet_config.service_provider_config;
287
288    crate::models::SparkConfig {
289        coordinator_identifier,
290        threshold: wallet_config.split_secret_threshold,
291        signing_operators,
292        ssp_config: SparkSspConfig {
293            base_url: ssp.base_url.clone(),
294            identity_public_key: hex::encode(ssp.identity_public_key.serialize()),
295            schema_endpoint: ssp.schema_endpoint.clone(),
296        },
297        expected_withdraw_bond_sats: wallet_config.tokens_config.expected_withdraw_bond_sats,
298        expected_withdraw_relative_block_locktime: wallet_config
299            .tokens_config
300            .expected_withdraw_relative_block_locktime,
301    }
302}
303
304/// Creates a default external signer from a mnemonic.
305///
306/// This is a convenience factory method for creating a signer that can be used
307/// with `connect_with_signer` or `SdkBuilder::new_with_signer`.
308///
309/// # Arguments
310///
311/// * `mnemonic` - BIP39 mnemonic phrase (12 or 24 words)
312/// * `passphrase` - Optional passphrase for the mnemonic
313/// * `network` - Network to use (Mainnet or Regtest)
314/// * `key_set_config` - Optional key set configuration. If None, uses default configuration.
315///
316/// # Returns
317///
318/// Result containing the signer as `Arc<dyn ExternalSigner>`
319#[cfg_attr(feature = "uniffi", uniffi::export)]
320pub fn default_external_signer(
321    mnemonic: String,
322    passphrase: Option<String>,
323    network: Network,
324    key_set_config: Option<crate::models::KeySetConfig>,
325) -> Result<Arc<dyn crate::signer::ExternalSigner>, SdkError> {
326    use crate::signer::DefaultExternalSigner;
327
328    let config = key_set_config.unwrap_or_default();
329    let signer = DefaultExternalSigner::new(
330        mnemonic,
331        passphrase,
332        network,
333        config.key_set_type,
334        config.use_address_index,
335        config.account_number,
336    )?;
337
338    Ok(Arc::new(signer))
339}
340
341/// Fetches the current status of Spark network services relevant to the SDK.
342///
343/// This function queries the Spark status API and returns the worst status
344/// across the Spark Operators and SSP services.
345#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
346pub async fn get_spark_status() -> Result<crate::SparkStatus, SdkError> {
347    use chrono::DateTime;
348    use platform_utils::DefaultHttpClient;
349
350    #[derive(serde::Deserialize)]
351    struct StatusApiResponse {
352        services: Vec<StatusApiService>,
353        #[serde(rename = "lastUpdated")]
354        last_updated: String,
355    }
356
357    #[derive(serde::Deserialize)]
358    struct StatusApiService {
359        name: String,
360        status: String,
361    }
362
363    fn parse_service_status(s: &str) -> crate::ServiceStatus {
364        match s {
365            "operational" => crate::ServiceStatus::Operational,
366            "degraded" => crate::ServiceStatus::Degraded,
367            "partial" => crate::ServiceStatus::Partial,
368            "major" => crate::ServiceStatus::Major,
369            _ => {
370                tracing::warn!("Unknown service status: {s}");
371                crate::ServiceStatus::Unknown
372            }
373        }
374    }
375
376    let http_client = DefaultHttpClient::default();
377
378    let response = http_client
379        .get("https://spark.money/api/v1/status".to_string(), None)
380        .await
381        .map_err(|e| SdkError::NetworkError(e.to_string()))?;
382
383    let api_response: StatusApiResponse = response
384        .json()
385        .map_err(|e| SdkError::Generic(format!("Failed to parse status response: {e}")))?;
386
387    let status = api_response
388        .services
389        .iter()
390        .filter(|s| s.name == "Spark Operators" || s.name == "SSP")
391        .map(|s| parse_service_status(&s.status))
392        .max()
393        .unwrap_or(crate::ServiceStatus::Unknown);
394
395    let last_updated = DateTime::parse_from_rfc3339(&api_response.last_updated)
396        .map(|dt| dt.timestamp().cast_unsigned())
397        .map_err(|e| SdkError::Generic(format!("Failed to parse lastUpdated timestamp: {e}")))?;
398
399    Ok(crate::SparkStatus {
400        status,
401        last_updated,
402    })
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn default_server_config_disables_background_tasks() {
411        for network in [Network::Mainnet, Network::Regtest] {
412            let cfg = default_server_config(network);
413            assert!(!cfg.background_tasks_enabled);
414            assert!(cfg.real_time_sync_server_url.is_none());
415            assert!(!cfg.leaf_optimization_config.auto_enabled);
416            assert!(!cfg.token_optimization_config.auto_enabled);
417        }
418    }
419
420    #[test]
421    fn default_config_enables_background_tasks() {
422        assert!(default_config(Network::Mainnet).background_tasks_enabled);
423        assert!(default_config(Network::Regtest).background_tasks_enabled);
424    }
425}