Skip to main content

breez_sdk_spark/sdk/
mod.rs

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