Skip to main content

breez_sdk_spark/sdk/
mod.rs

1mod api;
2mod bridging;
3mod buy;
4mod chain_queries;
5mod contacts;
6mod deposits;
7pub(crate) mod exit_chain_downloader;
8mod helpers;
9mod init;
10mod lightning_address;
11mod lightning_sender;
12mod lnurl;
13mod payments;
14mod recover_funds;
15mod runtime;
16mod sync;
17mod sync_coordinator;
18mod token_allowances;
19mod unilateral_exit;
20mod unilateral_exit_backup;
21mod watchtower_exit;
22
23pub use bridging::{
24    BridgeRoutePair, BridgeRoutePairSide, BridgeStatus, BridgingClient, BridgingConfig,
25    CreateBridgeRequest, CreateBridgeResponse, GetBridgeRoutesRequest, GetBridgeRoutesResponse,
26    GetBridgeStatusRequest, GetBridgeStatusResponse, default_bridging_config, new_bridging_client,
27};
28pub(crate) use helpers::partner_id;
29pub(crate) use lightning_sender::{LightningSender, PendingLightningSend};
30pub(crate) use runtime::{RuntimeEvent, SdkRuntime, runtime_from_config};
31pub(crate) use sync_coordinator::SyncCoordinator;
32
33use bitflags::bitflags;
34use breez_sdk_common::{buy::moonpay::MoonpayProvider, fiat::FiatService};
35use platform_utils::HttpClient;
36use platform_utils::tokio;
37use spark_wallet::SparkWallet;
38use std::sync::Arc;
39use tokio::sync::{Mutex, OnceCell, oneshot, watch};
40
41use crate::{
42    BitcoinChainService, LeafOptimizationConfig, Logger, Network, SyncError,
43    TokenOptimizationConfig, error::SdkError, events::EventEmitter, lnurl::LnurlServerClient,
44    logger, models::Config, models::SparkConfig, persist::Storage, rewards::RewardsDelegator,
45    signer::lnurl_auth::LnurlAuthSignerAdapter, stable_balance::StableBalance,
46    token_conversion::TokenConverter,
47};
48
49#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
50const BREEZ_SYNC_SERVICE_URL: &str = "https://datasync.breez.technology";
51
52#[cfg(all(target_family = "wasm", target_os = "unknown"))]
53const BREEZ_SYNC_SERVICE_URL: &str = "https://datasync.breez.technology:442";
54
55pub(crate) const CLAIM_TX_SIZE_VBYTES: u64 = 99;
56pub(crate) const SYNC_PAGING_LIMIT: u32 = 100;
57
58bitflags! {
59    #[derive(Clone, Debug, PartialEq, Eq)]
60    pub(crate) struct SyncType: u32 {
61        const Wallet = 1 << 0;
62        const WalletState = 1 << 1;
63        const Deposits = 1 << 2;
64        const LnurlMetadata = 1 << 3;
65        const ExitChainState = 1 << 4;
66        const Full = Self::Wallet.0.0
67            | Self::WalletState.0.0
68            | Self::Deposits.0.0
69            | Self::LnurlMetadata.0.0
70            | Self::ExitChainState.0.0;
71    }
72}
73
74#[derive(Clone, Debug)]
75pub(crate) struct SyncRequest {
76    pub(crate) sync_type: SyncType,
77    #[allow(clippy::type_complexity)]
78    pub(crate) reply: Arc<Mutex<Option<oneshot::Sender<Result<(), SdkError>>>>>,
79    /// If true, bypass the "recently synced" check and sync immediately.
80    /// Use for event-driven syncs (after payments, transfers, etc.) that should happen immediately.
81    pub(crate) force: bool,
82}
83
84impl SyncRequest {
85    pub(crate) async fn reply(&self, error: Option<SdkError>) {
86        if let Some(reply) = self.reply.lock().await.take() {
87            let _ = match error {
88                Some(e) => reply.send(Err(e)),
89                None => reply.send(Ok(())),
90            };
91        }
92    }
93}
94
95/// `BreezSDK` is a wrapper around `SparkSDK` that provides a more structured API
96/// with request/response objects and comprehensive error handling.
97#[derive(Clone)]
98#[cfg_attr(feature = "uniffi", derive(uniffi::Object))]
99pub struct BreezSdk {
100    /// The wallet's identity public key, which tags the instance's log lines.
101    pub(crate) instance_id: String,
102    pub(crate) config: Config,
103    pub(crate) spark_wallet: Arc<SparkWallet>,
104    pub(crate) storage: Arc<dyn Storage>,
105    pub(crate) chain_service: Arc<dyn BitcoinChainService>,
106    pub(crate) fiat_service: Arc<dyn FiatService>,
107    pub(crate) lnurl_client: Arc<dyn HttpClient>,
108    pub(crate) lnurl_server_client: Option<Arc<dyn LnurlServerClient>>,
109    pub(crate) lnurl_auth_signer: Option<Arc<LnurlAuthSignerAdapter>>,
110    pub(crate) event_emitter: Arc<EventEmitter>,
111    pub(crate) shutdown_sender: watch::Sender<()>,
112    /// Wakes the exit chain downloader once an operation has changed the leaf
113    /// set. Held even when collection is off, so nothing has to branch on it.
114    pub(crate) exit_chain_trigger: exit_chain_downloader::ExitChainTrigger,
115    pub(crate) runtime: SdkRuntime,
116    /// Coordinator for coalescing duplicate sync requests
117    pub(crate) sync_coordinator: SyncCoordinator,
118    /// Serialises claim attempts on the same deposit across the sync cascade
119    /// and explicit `claim_deposit` calls.
120    pub(crate) claim_guards: deposits::ClaimGuards,
121    pub(crate) initial_synced_watcher: watch::Receiver<bool>,
122    /// The latest wallet sync's failure, cleared when one succeeds.
123    pub(crate) last_sync_error: Arc<Mutex<Option<SyncError>>>,
124    /// Parses payment inputs over the SDK's own transports, so lightning-address
125    /// and LNURL lookups honour the proxy and ride the redirect-validating
126    /// LNURL client (their hosts are chosen by remote parties).
127    pub(crate) input_parser: Arc<SdkInputParser>,
128    pub(crate) spark_private_mode_initialized: Arc<OnceCell<()>>,
129    pub(crate) token_converter: Arc<dyn TokenConverter>,
130    /// Set on mainnet only.
131    pub(crate) rewards_delegator: Option<Arc<RewardsDelegator>>,
132    pub(crate) stable_balance: Option<Arc<StableBalance>>,
133    pub(crate) buy_bitcoin_provider: Arc<MoonpayProvider>,
134    pub(crate) cross_chain_context: crate::cross_chain::CrossChainContext,
135    /// The wallet-backed Orchestra service, built whether or not cross-chain
136    /// payments are enabled. Set on mainnet with background tasks on.
137    pub(crate) orchestra: Option<Arc<crate::cross_chain::OrchestraService>>,
138    /// Shared helper for paying LN invoices and persisting the resulting
139    /// payment rows. Reused by cross-chain providers (e.g. Boltz) that
140    /// need to pay an LN invoice as part of a larger flow.
141    #[allow(dead_code)]
142    pub(crate) lightning_sender: Arc<LightningSender>,
143}
144
145/// The parser bound to the SDK's shared HTTP client and its DNS resolver.
146pub(crate) type SdkInputParser =
147    breez_sdk_common::input::InputParser<Arc<dyn HttpClient>, breez_sdk_common::dns::Resolver>;
148
149pub(crate) struct BreezSdkParams {
150    pub instance_id: String,
151    pub config: Config,
152    pub input_parser: Arc<SdkInputParser>,
153    pub storage: Arc<dyn Storage>,
154    pub chain_service: Arc<dyn BitcoinChainService>,
155    pub fiat_service: Arc<dyn FiatService>,
156    pub lnurl_client: Arc<dyn HttpClient>,
157    pub lnurl_server_client: Option<Arc<dyn LnurlServerClient>>,
158    pub lnurl_auth_signer: Option<Arc<LnurlAuthSignerAdapter>>,
159    pub shutdown_sender: watch::Sender<()>,
160    pub runtime: SdkRuntime,
161    pub spark_wallet: Arc<SparkWallet>,
162    pub event_emitter: Arc<EventEmitter>,
163    pub buy_bitcoin_provider: Arc<MoonpayProvider>,
164    pub token_converter: Arc<dyn TokenConverter>,
165    pub rewards_delegator: Option<Arc<RewardsDelegator>>,
166    pub stable_balance: Option<Arc<StableBalance>>,
167    pub sync_coordinator: SyncCoordinator,
168    pub cross_chain_context: crate::cross_chain::CrossChainContext,
169    pub orchestra: Option<Arc<crate::cross_chain::OrchestraService>>,
170    pub lightning_sender: Arc<LightningSender>,
171}
172
173#[allow(clippy::needless_pass_by_value)]
174#[cfg_attr(feature = "uniffi", uniffi::export)]
175pub fn init_logging(
176    log_dir: Option<String>,
177    app_logger: Option<Box<dyn Logger>>,
178    log_filter: Option<String>,
179) -> Result<(), SdkError> {
180    logger::init_logging(log_dir.as_deref(), app_logger, log_filter.as_deref())
181}
182
183/// Connects to the Spark network using the provided configuration and mnemonic.
184///
185/// # Arguments
186///
187/// * `request` - The connection request object
188///
189/// # Returns
190///
191/// Result containing either the initialized `BreezSdk` or an `SdkError`
192#[cfg(feature = "sqlite")]
193#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
194pub async fn connect(request: crate::ConnectRequest) -> Result<BreezSdk, SdkError> {
195    let builder = super::sdk_builder::SdkBuilder::new(request.config, request.seed)
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 an external signer.
202///
203/// This method allows using a custom signer implementation instead of providing
204/// a seed directly.
205///
206/// # Arguments
207///
208/// * `request` - The connection request object with external signer
209///
210/// # Returns
211///
212/// Result containing either the initialized `BreezSdk` or an `SdkError`
213#[cfg(feature = "sqlite")]
214#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
215pub async fn connect_with_signer(
216    request: crate::ConnectWithSignerRequest,
217) -> Result<BreezSdk, SdkError> {
218    let builder = super::sdk_builder::SdkBuilder::new_with_signer(
219        request.config,
220        request.breez_signer,
221        request.spark_signer,
222    )
223    .with_default_storage(request.storage_dir);
224    let sdk = builder.build().await?;
225    Ok(sdk)
226}
227
228/// Connects to the Spark network using a signing-only external signer.
229///
230/// Use this instead of [`connect_with_signer`] for a signer that can't perform
231/// the SDK's local ECIES/HMAC operations (for example a policy-restricted
232/// enclave). The SDK keeps session tokens in plaintext and disables the features
233/// that rely on ECIES/HMAC.
234///
235/// # Arguments
236///
237/// * `request` - The connection request object with a signing-only external signer
238///
239/// # Returns
240///
241/// Result containing either the initialized `BreezSdk` or an `SdkError`
242#[cfg(feature = "sqlite")]
243#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
244pub async fn connect_with_signing_only_signer(
245    request: crate::ConnectWithSigningOnlySignerRequest,
246) -> Result<BreezSdk, SdkError> {
247    let builder = super::sdk_builder::SdkBuilder::new_with_signing_only_signer(
248        request.config,
249        request.breez_signer,
250        request.spark_signer,
251    )
252    .with_default_storage(request.storage_dir);
253    let sdk = builder.build().await?;
254    Ok(sdk)
255}
256
257/// Reads a [`SparkConfig`] from JSON, for a deployment that publishes its
258/// operators, service provider and certificates as a file. Set it on
259/// [`Config::spark_config`] to connect a wallet to that deployment.
260#[cfg_attr(feature = "uniffi", uniffi::export)]
261// The bindings pass an owned string.
262#[allow(clippy::needless_pass_by_value)]
263pub fn parse_spark_config(json: String) -> Result<SparkConfig, SdkError> {
264    serde_json::from_str(&json).map_err(|e| SdkError::InvalidInput(e.to_string()))
265}
266
267#[cfg_attr(feature = "uniffi", uniffi::export)]
268pub fn default_config(network: Network) -> Config {
269    let lnurl_domain = match network {
270        Network::Mainnet => Some("breez.tips".to_string()),
271        Network::Signet | Network::Regtest => None,
272    };
273    Config {
274        api_key: None,
275        network,
276        sync_interval_secs: 60, // every 1 minute
277        max_deposit_claim_fee: Some(crate::MaxFee {
278            onchain: crate::MaxOnchainFee::Rate { sat_per_vbyte: 1 },
279            proportional_ppm: Some(1_000),
280        }),
281        lnurl_domain,
282        prefer_spark_over_lightning: false,
283        exit_chain_auto_fetch_enabled: true,
284        watchtower_fee_ladder_enabled: None,
285        external_input_parsers: None,
286        use_default_external_input_parsers: true,
287        real_time_sync_server_url: Some(BREEZ_SYNC_SERVICE_URL.to_string()),
288        private_enabled_default: true,
289        leaf_optimization_config: LeafOptimizationConfig {
290            auto_enabled: true,
291            multiplicity: 1,
292        },
293        token_optimization_config: TokenOptimizationConfig {
294            auto_enabled: true,
295            target_output_count: 5,
296            min_outputs_threshold: 50,
297        },
298        stable_balance_config: None,
299        max_concurrent_claims: 4,
300        spark_config: match network {
301            Network::Mainnet | Network::Regtest => Some(default_spark_config(network)),
302            Network::Signet => None,
303        },
304        background_tasks_enabled: true,
305        proxy: None,
306        cross_chain_config: None,
307    }
308}
309
310/// Builds a [`Config`] suitable for multi-tenant server-mode deployments.
311///
312/// This preset returns the same configuration as [`default_config`] with
313/// [`background_tasks_enabled`](Config::background_tasks_enabled) set to
314/// `false`. In server mode, the SDK is treated as a library: the host
315/// orchestrates sync, claiming, and event delivery (typically via webhooks)
316/// explicitly, so an ephemeral SDK instance stays cheap and predictable.
317///
318/// Config fields whose background services are gated off are reset to their
319/// inactive shape: `real_time_sync_server_url` and `cross_chain_config` are
320/// set to `None`, and both `leaf_optimization_config.auto_enabled` and
321/// `token_optimization_config.auto_enabled` are set to `false`. The SDK
322/// rejects builds where `background_tasks_enabled` is `false` and any of
323/// those fields is left in its active shape, so flip the flag via this
324/// helper rather than by hand.
325///
326/// Explicit operations (`sync_wallet`, `claim_deposit`,
327/// `list_unclaimed_deposits`, `refund_deposit`,
328/// `refund_pending_conversions`, etc.) continue to work and are the intended
329/// entry points in this mode.
330///
331/// Stable Balance is not supported in this mode because its conversion worker
332/// is a background service.
333///
334/// One-time setup that the SDK normally applies automatically — notably
335/// `private_enabled_default` — is NOT applied in this mode. Drive setup
336/// explicitly via `update_user_settings` (and any other relevant APIs) so
337/// ephemeral per-request SDK instances incur no implicit setup overhead.
338///
339/// `get_info` reads balance directly from the spark wallet in this mode
340/// rather than from the background-maintained storage cache, so balance
341/// reflects the latest local sync and `ensure_synced=true` is rejected with
342/// an invalid-input error
343#[cfg_attr(feature = "uniffi", uniffi::export)]
344pub fn default_server_config(network: Network) -> Config {
345    let mut config = default_config(network);
346    config.background_tasks_enabled = false;
347    config.real_time_sync_server_url = None;
348    config.leaf_optimization_config.auto_enabled = false;
349    config.token_optimization_config.auto_enabled = false;
350    config.cross_chain_config = None;
351    config
352}
353
354/// Builds the default [`SparkConfig`](crate::models::SparkConfig) for the given network.
355///
356/// Surfaced through [`default_config`] as `Config::spark_config` so callers can read the
357/// baked-in operator and SSP endpoints and selectively override individual fields (e.g. to
358/// point at a staging environment) before passing the [`Config`] to [`connect`].
359fn default_spark_config(network: Network) -> crate::models::SparkConfig {
360    use crate::models::{SparkSigningOperator, SparkSspConfig};
361
362    let wallet_config = spark_wallet::SparkWalletConfig::default_config(network.into());
363
364    let coordinator_identifier = hex::encode(
365        wallet_config
366            .operator_pool
367            .get_coordinator()
368            .identifier
369            .serialize(),
370    );
371
372    let signing_operators = wallet_config
373        .operator_pool
374        .get_all_operators()
375        .map(|op| SparkSigningOperator {
376            id: u32::try_from(op.id).expect("operator id fits in u32"),
377            identifier: hex::encode(op.identifier.serialize()),
378            address: op.address.clone(),
379            identity_public_key: hex::encode(op.identity_public_key.serialize()),
380            ca_cert_pem: op
381                .ca_cert
382                .as_ref()
383                .and_then(|b| String::from_utf8(b.clone()).ok()),
384        })
385        .collect();
386
387    let ssp = &wallet_config.service_provider_config;
388
389    crate::models::SparkConfig {
390        coordinator_identifier,
391        threshold: wallet_config.split_secret_threshold,
392        signing_operators,
393        ssp_config: SparkSspConfig {
394            base_url: ssp.base_url.clone(),
395            identity_public_key: hex::encode(ssp.identity_public_key.serialize()),
396            schema_endpoint: ssp.schema_endpoint.clone(),
397        },
398        expected_withdraw_bond_sats: wallet_config.tokens_config.expected_withdraw_bond_sats,
399        expected_withdraw_relative_block_locktime: wallet_config
400            .tokens_config
401            .expected_withdraw_relative_block_locktime,
402        max_token_transaction_inputs: None,
403    }
404}
405
406/// The two default external signers created from one mnemonic by
407/// [`default_external_signers`].
408#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
409pub struct ExternalSigners {
410    /// External signer for non-Spark SDK signing (LNURL-auth, sync, message
411    /// signing, ECIES).
412    pub breez_signer: Arc<dyn crate::signer::ExternalBreezSigner>,
413    /// External high-level Spark signer for the Spark wallet flows.
414    pub spark_signer: Arc<dyn crate::signer::ExternalSparkSigner>,
415}
416
417/// A signing-only external signer paired with the Spark signer, for wallets that
418/// connect via [`connect_with_signing_only_signer`]. The Breez half performs
419/// signing only, without the SDK's local ECIES/HMAC operations.
420#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
421pub struct SigningOnlyExternalSigners {
422    /// Signing-only external signer for non-Spark SDK signing.
423    pub breez_signer: Arc<dyn crate::signer::ExternalSigningSigner>,
424    /// External high-level Spark signer for the Spark wallet flows.
425    pub spark_signer: Arc<dyn crate::signer::ExternalSparkSigner>,
426}
427
428/// Creates the default external signers from a mnemonic.
429///
430/// This is a convenience factory method for creating the two signer halves
431/// that can be passed to `connect_with_signer` or `SdkBuilder::new_with_signer`.
432/// Key derivation matches the seed-based connect path: an SDK built either way
433/// from the same mnemonic is the same wallet.
434///
435/// # Arguments
436///
437/// * `mnemonic` - BIP39 mnemonic phrase (12 or 24 words)
438/// * `passphrase` - Optional passphrase for the mnemonic
439/// * `network` - Network to use (Mainnet, Signet, or Regtest)
440/// * `account_number` - Account number in the derivation path. Unset uses the
441///   network default: 0 on Regtest and Signet, 1 on Mainnet.
442#[cfg_attr(feature = "uniffi", uniffi::export)]
443pub fn default_external_signers(
444    mnemonic: String,
445    passphrase: Option<String>,
446    network: Network,
447    account_number: Option<u32>,
448) -> Result<ExternalSigners, SdkError> {
449    use crate::signer::{DefaultExternalSigner, DefaultExternalSparkSigner};
450
451    let breez_signer = DefaultExternalSigner::new(
452        mnemonic.clone(),
453        passphrase.clone(),
454        network,
455        account_number,
456    )?;
457    let spark_signer =
458        DefaultExternalSparkSigner::new(mnemonic, passphrase, network, account_number)?;
459
460    Ok(ExternalSigners {
461        breez_signer: Arc::new(breez_signer),
462        spark_signer: Arc::new(spark_signer),
463    })
464}
465
466/// Options for [`get_spark_status`].
467#[derive(Debug, Clone, Default)]
468#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
469pub struct GetSparkStatusRequest {
470    /// Routes the status request through a SOCKS5 proxy. Pass the same value as
471    /// [`Config::proxy`]: this call runs without an SDK instance, so it cannot
472    /// pick the setting up on its own.
473    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
474    pub proxy: Option<crate::ProxyConfig>,
475}
476
477/// Fetches the current status of Spark network services relevant to the SDK.
478///
479/// This function queries the Spark status API and returns the worst status
480/// across the Spark Operators and SSP services.
481#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
482pub async fn get_spark_status(
483    request: GetSparkStatusRequest,
484) -> Result<crate::SparkStatus, SdkError> {
485    use chrono::DateTime;
486
487    #[derive(serde::Deserialize)]
488    struct StatusApiResponse {
489        services: Vec<StatusApiService>,
490        #[serde(rename = "lastUpdated")]
491        last_updated: String,
492    }
493
494    #[derive(serde::Deserialize)]
495    struct StatusApiService {
496        name: String,
497        status: String,
498    }
499
500    fn parse_service_status(s: &str) -> crate::ServiceStatus {
501        match s {
502            "operational" => crate::ServiceStatus::Operational,
503            "degraded" => crate::ServiceStatus::Degraded,
504            "partial" => crate::ServiceStatus::Partial,
505            "major" => crate::ServiceStatus::Major,
506            _ => {
507                tracing::warn!("Unknown service status: {s}");
508                crate::ServiceStatus::Unknown
509            }
510        }
511    }
512
513    let http_client = crate::ProxyConfig::http_client(request.proxy.as_ref(), None)?;
514
515    let response = http_client
516        .get("https://spark.money/api/v1/status".to_string(), None)
517        .await
518        .map_err(|e| SdkError::NetworkError(e.to_string()))?;
519
520    let api_response: StatusApiResponse = response
521        .json()
522        .map_err(|e| SdkError::Generic(format!("Failed to parse status response: {e}")))?;
523
524    let status = api_response
525        .services
526        .iter()
527        .filter(|s| s.name == "Spark Operators" || s.name == "SSP")
528        .map(|s| parse_service_status(&s.status))
529        .max()
530        .unwrap_or(crate::ServiceStatus::Unknown);
531
532    let last_updated = DateTime::parse_from_rfc3339(&api_response.last_updated)
533        .map(|dt| dt.timestamp().cast_unsigned())
534        .map_err(|e| SdkError::Generic(format!("Failed to parse lastUpdated timestamp: {e}")))?;
535
536    Ok(crate::SparkStatus {
537        status,
538        last_updated,
539    })
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn default_server_config_disables_background_tasks() {
548        for network in [Network::Mainnet, Network::Regtest, Network::Signet] {
549            let cfg = default_server_config(network);
550            assert!(!cfg.background_tasks_enabled);
551            assert!(cfg.real_time_sync_server_url.is_none());
552            assert!(!cfg.leaf_optimization_config.auto_enabled);
553            assert!(!cfg.token_optimization_config.auto_enabled);
554        }
555    }
556
557    #[test]
558    fn default_config_enables_background_tasks() {
559        assert!(default_config(Network::Mainnet).background_tasks_enabled);
560        assert!(default_config(Network::Regtest).background_tasks_enabled);
561        assert!(default_config(Network::Signet).background_tasks_enabled);
562    }
563
564    /// The shape a local environment writes, which a wallet loads as it is.
565    #[test]
566    fn parse_spark_config_reads_an_environments_file() {
567        let json = r#"{
568          "coordinator_identifier": "01",
569          "threshold": 2,
570          "signing_operators": [
571            {
572              "id": 0,
573              "identifier": "01",
574              "address": "http://127.0.0.1:8535",
575              "identity_public_key": "02aa"
576            }
577          ],
578          "ssp_config": {
579            "base_url": "http://127.0.0.1:59049",
580            "identity_public_key": "03bb",
581            "schema_endpoint": "graphql/spark/rc"
582          },
583          "expected_withdraw_bond_sats": 10000,
584          "expected_withdraw_relative_block_locktime": 1000
585        }"#;
586
587        let config = parse_spark_config(json.to_string()).unwrap();
588
589        assert_eq!(config.threshold, 2);
590        assert_eq!(config.signing_operators.len(), 1);
591        assert_eq!(config.signing_operators[0].address, "http://127.0.0.1:8535");
592        assert_eq!(config.ssp_config.base_url, "http://127.0.0.1:59049");
593        // Absent from the file, and not required of it.
594        assert!(config.signing_operators[0].ca_cert_pem.is_none());
595        assert!(config.max_token_transaction_inputs.is_none());
596    }
597
598    #[test]
599    fn parse_spark_config_rejects_what_is_not_one() {
600        assert!(parse_spark_config("{}".to_string()).is_err());
601    }
602
603    /// Whether an impl block's body declares a public method taking `self`.
604    fn has_public_method(body: &[&str]) -> bool {
605        let next_lines = body.iter().skip(1).map(Some).chain(std::iter::once(None));
606        body.iter().zip(next_lines).any(|(line, next)| {
607            let Some(signature) = line.strip_prefix("    pub ") else {
608                return false;
609            };
610            if !signature.starts_with("fn ") && !signature.starts_with("async fn ") {
611                return false;
612            }
613            // The receiver is on the signature's first line or right after it.
614            line.contains("self") || next.is_some_and(|next| next.contains("self"))
615        })
616    }
617
618    fn rust_files(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
619        for entry in std::fs::read_dir(dir).unwrap() {
620            let path = entry.unwrap().path();
621            if path.is_dir() {
622                rust_files(&path, files);
623            } else if path.extension().is_some_and(|ext| ext == "rs") {
624                files.push(path);
625            }
626        }
627    }
628
629    /// Public `BreezSdk` methods log in their instance's span only through
630    /// `#[macros::instance_span]` on their impl block, which is easy to forget
631    /// on a new one.
632    #[test]
633    fn impl_blocks_with_public_methods_have_an_instance_span() {
634        let mut files = Vec::new();
635        rust_files(
636            &std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"),
637            &mut files,
638        );
639        let mut missing = Vec::new();
640        for path in files {
641            let source = std::fs::read_to_string(&path).unwrap();
642            let lines: Vec<&str> = source.lines().collect();
643            for (i, line) in lines.iter().enumerate() {
644                if !line.starts_with("impl BreezSdk") {
645                    continue;
646                }
647                let body: Vec<&str> = lines[i..]
648                    .iter()
649                    .skip(1)
650                    .copied()
651                    .take_while(|l| *l != "}")
652                    .collect();
653                if !has_public_method(&body) {
654                    continue;
655                }
656                let has_attribute = lines[..i]
657                    .iter()
658                    .rev()
659                    .take_while(|l| l.starts_with("#[") || l.starts_with("///"))
660                    .any(|l| *l == "#[macros::instance_span]");
661                if !has_attribute {
662                    missing.push(format!("{}:{}", path.display(), i.saturating_add(1)));
663                }
664            }
665        }
666        assert!(
667            missing.is_empty(),
668            "impl blocks missing #[macros::instance_span]: {missing:?}"
669        );
670    }
671}