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