Skip to main content

breez_sdk_spark/sdk/
mod.rs

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