Skip to main content

breez_sdk_spark/sdk/
api.rs

1use bitcoin::secp256k1::{PublicKey, ecdsa::Signature};
2use spark_wallet::MasterIdentityPublicKeyUpdate;
3use std::str::FromStr;
4use std::sync::Arc;
5use tracing::{debug, info};
6
7use crate::{
8    BridgeFromCashAppRequest, BridgeFromCashAppResponse, BridgeToCashAppRequest,
9    BridgeToCashAppResponse, CheckMessageRequest, CheckMessageResponse, CrossChainRouteFilter,
10    CrossChainRoutePair, GetTokensMetadataRequest, GetTokensMetadataResponse, InputType,
11    ListFiatCurrenciesResponse, ListFiatRatesResponse, Network, OptimizationMode,
12    OptimizeLeavesRequest, OptimizeLeavesResponse, PreparePaymentLinkRequest,
13    PreparePaymentLinkResponse, RegisterWebhookRequest, RegisterWebhookResponse,
14    SignMessageRequest, SignMessageResponse, UnregisterWebhookRequest, UpdateUserSettingsRequest,
15    UserSettings, Webhook,
16    chain::RecommendedFees,
17    error::SdkError,
18    events::EventListener,
19    issuer::TokenIssuer,
20    models::{
21        GetInfoRequest, GetInfoResponse, SparkMasterIdentityPublicKey, StableBalanceActiveLabel,
22    },
23    persist::ObjectCacheRepository,
24    utils::token::get_tokens_metadata_cached_or_query,
25};
26
27use super::BreezSdk;
28use super::bridging::BridgingClient;
29
30#[macros::instance_span]
31#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
32#[allow(clippy::needless_pass_by_value)]
33impl BreezSdk {
34    /// Registers a listener to receive SDK events
35    ///
36    /// The SDK holds the listener until it is removed with
37    /// `remove_event_listener` or until `disconnect` unregisters all
38    /// listeners. A held listener that references the SDK instance keeps
39    /// that instance alive.
40    ///
41    /// # Arguments
42    ///
43    /// * `listener` - An implementation of the `EventListener` trait
44    ///
45    /// # Returns
46    ///
47    /// A unique identifier for the listener, which can be used to remove it later
48    pub async fn add_event_listener(&self, listener: Box<dyn EventListener>) -> String {
49        self.event_emitter.add_external_listener(listener).await
50    }
51
52    /// Removes a previously registered event listener
53    ///
54    /// # Arguments
55    ///
56    /// * `id` - The listener ID returned from `add_event_listener`
57    ///
58    /// # Returns
59    ///
60    /// `true` if the listener was found and removed, `false` otherwise
61    pub async fn remove_event_listener(&self, id: &str) -> bool {
62        self.event_emitter.remove_external_listener(id).await
63    }
64
65    /// Stops the SDK's background tasks
66    ///
67    /// This method stops the background tasks started by the `start()` method.
68    /// It should be called before your application terminates to ensure proper cleanup.
69    ///
70    /// It also unregisters all event listeners, so listeners that reference
71    /// the SDK no longer keep it alive after this call.
72    ///
73    /// # Returns
74    ///
75    /// Result containing either success or an `SdkError` if the background task couldn't be stopped
76    pub async fn disconnect(&self) -> Result<(), SdkError> {
77        info!("Disconnecting Breez SDK");
78        self.event_emitter.clear_external_listeners().await;
79        if self.shutdown_sender.send(()).is_err() {
80            // A `watch::Sender::send` error means every receiver has been
81            // dropped, i.e. no background task is listening. This is the
82            // expected steady state for a server-mode SDK
83            // (`background_tasks_enabled = false`): there is nothing to
84            // stop, so disconnecting is a successful no-op.
85            debug!("No shutdown receivers; SDK has no background tasks to stop");
86            return Ok(());
87        }
88
89        self.shutdown_sender.closed().await;
90        info!("Breez SDK disconnected");
91        Ok(())
92    }
93
94    pub async fn parse(&self, input: &str) -> Result<InputType, SdkError> {
95        Ok(self.input_parser.parse(input).await?.into())
96    }
97
98    /// Returns the available cross-chain routes.
99    ///
100    /// Use [`CrossChainRouteFilter::Send`] to get routes for sending to
101    /// another chain (filtered by the parsed recipient address), or
102    /// [`CrossChainRouteFilter::Receive`] to get routes for receiving from
103    /// another chain (optionally filtered by a source contract address). By
104    /// default both list the routes paid from or delivered to the Spark
105    /// balance. Set their `delivery_method` to `Lightning` to list the routes an
106    /// external payer funds over Lightning, or that pay out to a Lightning
107    /// address.
108    ///
109    /// Fails with a `CrossChainDisabled` error when cross-chain payments are not
110    /// enabled in the config. An empty list means no route matches the filter.
111    pub async fn get_cross_chain_routes(
112        &self,
113        filter: &CrossChainRouteFilter,
114    ) -> Result<Vec<CrossChainRoutePair>, SdkError> {
115        self.cross_chain_context.list_routes(filter).await
116    }
117
118    /// Returns the balance of the wallet in satoshis
119    #[allow(unused_variables)]
120    pub async fn get_info(&self, request: GetInfoRequest) -> Result<GetInfoResponse, SdkError> {
121        self.runtime.get_info(self, request).await
122    }
123
124    /// List fiat currencies for which there is a known exchange rate,
125    /// sorted by the canonical name of the currency.
126    pub async fn list_fiat_currencies(&self) -> Result<ListFiatCurrenciesResponse, SdkError> {
127        let currencies = self
128            .fiat_service
129            .fetch_fiat_currencies()
130            .await?
131            .into_iter()
132            .map(From::from)
133            .collect();
134        Ok(ListFiatCurrenciesResponse { currencies })
135    }
136
137    /// List the latest rates of fiat currencies, sorted by name.
138    pub async fn list_fiat_rates(&self) -> Result<ListFiatRatesResponse, SdkError> {
139        let rates = self
140            .fiat_service
141            .fetch_fiat_rates()
142            .await?
143            .into_iter()
144            .map(From::from)
145            .collect();
146        Ok(ListFiatRatesResponse { rates })
147    }
148
149    /// Get the recommended BTC fees based on the configured chain service.
150    pub async fn recommended_fees(&self) -> Result<RecommendedFees, SdkError> {
151        Ok(self.chain_service.recommended_fees().await?)
152    }
153
154    /// Returns the metadata for the given token identifiers.
155    ///
156    /// Results are not guaranteed to be in the same order as the input token identifiers.
157    ///
158    /// If the metadata is not found locally in cache, it will be queried from
159    /// the Spark network and then cached.
160    pub async fn get_tokens_metadata(
161        &self,
162        request: GetTokensMetadataRequest,
163    ) -> Result<GetTokensMetadataResponse, SdkError> {
164        let metadata = get_tokens_metadata_cached_or_query(
165            &self.spark_wallet,
166            &ObjectCacheRepository::new(self.storage.clone()),
167            &request
168                .token_identifiers
169                .iter()
170                .map(String::as_str)
171                .collect::<Vec<_>>(),
172        )
173        .await?;
174        Ok(GetTokensMetadataResponse {
175            tokens_metadata: metadata,
176        })
177    }
178
179    /// Signs a message with the wallet's identity key. The message is SHA256
180    /// hashed before signing. The returned signature will be hex encoded in
181    /// DER format by default, or compact format if specified.
182    ///
183    /// Messages starting with `breez-lnurl:` or `kind: rewards_endorsement` are
184    /// refused: they are reserved for the SDK's own requests to the Lightning
185    /// address server and to Flashnet's rewards service.
186    pub async fn sign_message(
187        &self,
188        request: SignMessageRequest,
189    ) -> Result<SignMessageResponse, SdkError> {
190        use bitcoin::hex::DisplayHex;
191
192        reject_reserved_namespace(&request.message)?;
193
194        let pubkey = self.spark_wallet.get_identity_public_key().to_string();
195        let signature = self.spark_wallet.sign_message(&request.message).await?;
196        let signature_hex = if request.compact {
197            signature.serialize_compact().to_lower_hex_string()
198        } else {
199            signature.serialize_der().to_lower_hex_string()
200        };
201
202        Ok(SignMessageResponse {
203            pubkey,
204            signature: signature_hex,
205        })
206    }
207
208    /// Verifies a message signature against the provided public key. The message
209    /// is SHA256 hashed before verification. The signature can be hex encoded
210    /// in either DER or compact format.
211    pub async fn check_message(
212        &self,
213        request: CheckMessageRequest,
214    ) -> Result<CheckMessageResponse, SdkError> {
215        let pubkey = PublicKey::from_str(&request.pubkey)
216            .map_err(|_| SdkError::InvalidInput("Invalid public key".to_string()))?;
217        let signature_bytes = hex::decode(&request.signature)
218            .map_err(|_| SdkError::InvalidInput("Not a valid hex encoded signature".to_string()))?;
219        let signature = Signature::from_der(&signature_bytes)
220            .or_else(|_| Signature::from_compact(&signature_bytes))
221            .map_err(|_| {
222                SdkError::InvalidInput("Not a valid DER or compact encoded signature".to_string())
223            })?;
224
225        let is_valid = self
226            .spark_wallet
227            .verify_message(&request.message, &signature, &pubkey)
228            .await
229            .is_ok();
230        Ok(CheckMessageResponse { is_valid })
231    }
232
233    /// Returns the user settings for the wallet.
234    ///
235    /// Some settings are fetched from the Spark network so network requests are performed.
236    pub async fn get_user_settings(&self) -> Result<UserSettings, SdkError> {
237        // Ensure spark private mode is initialized to avoid race conditions with the initialization task.
238        self.maybe_ensure_spark_private_mode_initialized().await?;
239
240        let spark_user_settings = self.spark_wallet.query_wallet_settings().await?;
241
242        let stable_balance_active_label = match &self.stable_balance {
243            Some(sb) => sb.get_active_label().await,
244            None => None,
245        };
246
247        Ok(UserSettings {
248            spark_private_mode_enabled: spark_user_settings.private_enabled,
249            stable_balance_active_label,
250            spark_master_identity_public_key: spark_user_settings
251                .master_identity_public_key
252                .map(|key| key.to_string()),
253        })
254    }
255
256    /// Updates the user settings for the wallet.
257    ///
258    /// Some settings are updated on the Spark network so network requests may be performed.
259    pub async fn update_user_settings(
260        &self,
261        request: UpdateUserSettingsRequest,
262    ) -> Result<(), SdkError> {
263        let master_identity_public_key = request
264            .spark_master_identity_public_key
265            .map(|update| match update {
266                SparkMasterIdentityPublicKey::Set { public_key } => {
267                    parse_compressed_public_key(&public_key).map(MasterIdentityPublicKeyUpdate::Set)
268                }
269                SparkMasterIdentityPublicKey::Unset => Ok(MasterIdentityPublicKeyUpdate::Clear),
270            })
271            .transpose()?;
272
273        self.spark_wallet
274            .update_wallet_settings(
275                request.spark_private_mode_enabled,
276                master_identity_public_key,
277            )
278            .await?;
279
280        if let Some(active_label) = request.stable_balance_active_label {
281            let sb = self
282                .stable_balance
283                .as_ref()
284                .ok_or_else(|| SdkError::Generic("Stable balance is not configured".to_string()))?;
285            let label = if let StableBalanceActiveLabel::Set { label } = active_label {
286                Some(label)
287            } else {
288                None
289            };
290            sb.set_active_token(label).await?;
291        }
292
293        Ok(())
294    }
295
296    /// Returns an instance of the [`TokenIssuer`] for managing token issuance.
297    pub fn get_token_issuer(&self) -> TokenIssuer {
298        TokenIssuer::new(self.spark_wallet.clone(), self.storage.clone())
299    }
300
301    /// Manually drives leaf optimization, blocking until the requested work
302    /// is done.
303    ///
304    /// With [`OptimizationMode::Full`] (the default) the call runs the entire
305    /// optimization in a single invocation. With
306    /// [`OptimizationMode::SingleRound`] it executes one round and returns —
307    /// the caller drives the loop by inspecting the
308    /// [`OptimizeLeavesResponse::outcome`] and calling again until
309    /// `InProgress` no longer appears.
310    ///
311    /// Returns an error if another optimization run (auto or manual) is
312    /// already in flight ([`SdkError::OptimizationAlreadyRunning`]), or if
313    /// the SDK preempted this run to free leaves for a payment
314    /// ([`SdkError::OptimizationCancelled`]).
315    ///
316    /// Manual runs do not emit events; events ([`SdkEvent::AutoOptimization`])
317    /// are reserved for the background auto-optimizer.
318    pub async fn optimize_leaves(
319        &self,
320        request: OptimizeLeavesRequest,
321    ) -> Result<OptimizeLeavesResponse, SdkError> {
322        let max_rounds = match request.mode {
323            OptimizationMode::Full => None,
324            OptimizationMode::SingleRound => Some(1),
325        };
326        let result = self.spark_wallet.optimize_leaves(max_rounds).await;
327        Ok(OptimizeLeavesResponse {
328            outcome: result?.into(),
329        })
330    }
331
332    /// Registers a webhook to receive notifications for wallet events.
333    ///
334    /// When registered events occur (e.g., a Lightning payment is received),
335    /// the Spark service provider will send an HTTP POST to the specified URL
336    /// with a payload signed using HMAC-SHA256 with the provided secret.
337    ///
338    /// # Arguments
339    ///
340    /// * `request` - The webhook registration details including URL, secret, and event types
341    ///
342    /// # Returns
343    ///
344    /// A response containing the unique identifier of the registered webhook
345    pub async fn register_webhook(
346        &self,
347        request: RegisterWebhookRequest,
348    ) -> Result<RegisterWebhookResponse, SdkError> {
349        let event_types = request.event_types.into_iter().map(Into::into).collect();
350        let webhook_id = self
351            .spark_wallet
352            .register_wallet_webhook(&request.url, &request.secret, event_types)
353            .await
354            .map_err(|e| SdkError::Generic(format!("Failed to register webhook: {e}")))?;
355        Ok(RegisterWebhookResponse { webhook_id })
356    }
357
358    /// Unregisters a previously registered webhook.
359    ///
360    /// After unregistering, the Spark service provider will no longer send
361    /// notifications to the webhook URL.
362    ///
363    /// # Arguments
364    ///
365    /// * `request` - The unregister request containing the webhook ID
366    pub async fn unregister_webhook(
367        &self,
368        request: UnregisterWebhookRequest,
369    ) -> Result<(), SdkError> {
370        self.spark_wallet
371            .delete_wallet_webhook(&request.webhook_id)
372            .await
373            .map_err(|e| SdkError::Generic(format!("Failed to unregister webhook: {e}")))?;
374        Ok(())
375    }
376
377    /// Lists all webhooks currently registered for this wallet.
378    ///
379    /// # Returns
380    ///
381    /// A list of registered webhooks with their IDs, URLs, and subscribed event types
382    pub async fn list_webhooks(&self) -> Result<Vec<Webhook>, SdkError> {
383        let webhooks = self
384            .spark_wallet
385            .list_wallet_webhooks()
386            .await
387            .map_err(|e| SdkError::Generic(format!("Failed to list webhooks: {e}")))?;
388        Ok(webhooks.into_iter().map(Into::into).collect())
389    }
390
391    /// **Deprecated.** Use `bridge_from_cash_app` on the client from
392    /// [`get_bridging_client`](Self::get_bridging_client).
393    pub async fn prepare_payment_link(
394        &self,
395        request: PreparePaymentLinkRequest,
396    ) -> Result<PreparePaymentLinkResponse, SdkError> {
397        self.bridge_from_cash_app(request.into())
398            .await
399            .map(Into::into)
400    }
401
402    /// **Deprecated.** Use `bridge_from_cash_app` on the client from
403    /// [`get_bridging_client`](Self::get_bridging_client), whose bridges can be
404    /// looked up with `get_bridge_status`.
405    ///
406    /// Bridge from Cash App to USDC/USDT on an external chain.
407    ///
408    /// Creates a cross-chain order and returns a `cash.app` deep link the payer
409    /// opens. Once paid over Lightning, the provider delivers the stablecoin to
410    /// `address`. No funds move through the Spark wallet, and nothing is
411    /// tracked. Only available on mainnet.
412    pub async fn bridge_from_cash_app(
413        &self,
414        request: BridgeFromCashAppRequest,
415    ) -> Result<BridgeFromCashAppResponse, SdkError> {
416        if !matches!(self.config.network, Network::Mainnet) {
417            return Err(SdkError::Generic("Only available on mainnet".to_string()));
418        }
419        self.cross_chain_context.ensure_enabled()?;
420        self.get_bridging_client()?
421            .legacy_bridge_from_cash_app(request)
422            .await
423    }
424
425    /// **Deprecated.** Use `bridge_to_cash_app` on the client from
426    /// [`get_bridging_client`](Self::get_bridging_client), whose bridges can be
427    /// looked up with `get_bridge_status`.
428    ///
429    /// Bridge USDC/USDT from an external chain to a Cash App user.
430    ///
431    /// Returns the deposit the payer makes on the source chain. Once it lands,
432    /// the cross-chain provider pays the recipient in Bitcoin over Lightning,
433    /// or refunds `refund_address` if it can't. No funds move through the
434    /// Spark wallet, and nothing is tracked. Only available on mainnet.
435    pub async fn bridge_to_cash_app(
436        &self,
437        request: BridgeToCashAppRequest,
438    ) -> Result<BridgeToCashAppResponse, SdkError> {
439        if !matches!(self.config.network, Network::Mainnet) {
440            return Err(SdkError::Generic("Only available on mainnet".to_string()));
441        }
442        self.cross_chain_context.ensure_enabled()?;
443        self.get_bridging_client()?
444            .legacy_bridge_to_cash_app(request)
445            .await
446    }
447
448    /// Returns a client for bridges: orders in which a payer on another chain
449    /// or on Lightning pays a recipient, with no funds moving through the
450    /// SDK. It shares this SDK's API key, and its cross-chain settings when
451    /// set. It doesn't need cross-chain payments enabled, so it also works in
452    /// server mode. Only available on mainnet.
453    pub fn get_bridging_client(&self) -> Result<Arc<BridgingClient>, SdkError> {
454        let quoter = self
455            .cross_chain_context
456            .orchestra_quoter()
457            .ok_or_else(|| SdkError::Generic("Only available on mainnet".to_string()))?;
458        Ok(Arc::new(BridgingClient::new(
459            self.config.cross_chain_config.clone(),
460            Arc::clone(quoter),
461            Arc::clone(self.cross_chain_context.fiat_service()),
462            Arc::clone(&self.input_parser),
463        )))
464    }
465}
466
467/// Message prefixes reserved for the SDK's own signed requests, each with what
468/// it is reserved for.
469const RESERVED_PREFIXES: [(&str, &str); 2] = [
470    (
471        lnurl_models::signed_message::RESERVED_NAMESPACE,
472        "Lightning address requests",
473    ),
474    (
475        flashnet::rewards::ENDORSEMENT_PAYLOAD_PREFIX,
476        "Flashnet rewards endorsements",
477    ),
478];
479
480/// Refuses a message starting with a prefix reserved for the SDK's own
481/// requests. Matching is ASCII case-insensitive.
482fn reject_reserved_namespace(message: &str) -> Result<(), SdkError> {
483    for (prefix, purpose) in RESERVED_PREFIXES {
484        let reserved = message
485            .as_bytes()
486            .get(..prefix.len())
487            .is_some_and(|start| start.eq_ignore_ascii_case(prefix.as_bytes()));
488        if reserved {
489            return Err(SdkError::InvalidInput(format!(
490                "messages starting with '{prefix}' are reserved for {purpose} and cannot be signed here"
491            )));
492        }
493    }
494    Ok(())
495}
496
497/// Parses a 33-byte compressed public key from hex.
498///
499/// Rejects the uncompressed encoding, which `PublicKey::from_str` also accepts:
500/// Spark serializes identity keys compressed, so accepting it would make the
501/// key read back differ from the one that was set.
502fn parse_compressed_public_key(hex_encoded: &str) -> Result<PublicKey, SdkError> {
503    let invalid = || SdkError::InvalidInput("Invalid master identity public key".to_string());
504    let bytes: [u8; 33] = hex::decode(hex_encoded)
505        .map_err(|_| invalid())?
506        .try_into()
507        .map_err(|_| invalid())?;
508    PublicKey::from_slice(&bytes).map_err(|_| invalid())
509}
510
511#[cfg(test)]
512mod tests {
513    use super::{SdkError, parse_compressed_public_key, reject_reserved_namespace};
514
515    #[cfg(feature = "browser-tests")]
516    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
517
518    const COMPRESSED: &str = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
519    const UNCOMPRESSED: &str = "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179\
520        8483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8";
521
522    #[test]
523    fn parse_compressed_public_key_round_trips() {
524        let key = parse_compressed_public_key(COMPRESSED).unwrap();
525        assert_eq!(key.to_string(), COMPRESSED);
526    }
527
528    #[test]
529    fn reject_reserved_namespace_refuses_lnurl_messages() {
530        let messages = [
531            lnurl_models::signed_message::register("lnurl.example.com", "alice", Some("d"), 1),
532            lnurl_models::signed_message::unregister("lnurl.example.com", "alice", 1),
533            lnurl_models::signed_message::recover("lnurl.example.com", COMPRESSED, 1),
534            lnurl_models::signed_message::metadata("lnurl.example.com", COMPRESSED, 1),
535            "BREEZ-LNURL:v2\nregister\n...".to_string(),
536            "breez-lnurl:".to_string(),
537            "breez-lnurl:v3 whatever comes next".to_string(),
538        ];
539        for message in messages {
540            assert!(
541                matches!(
542                    reject_reserved_namespace(&message),
543                    Err(SdkError::InvalidInput(_))
544                ),
545                "expected {message:?} to be refused"
546            );
547        }
548    }
549
550    #[test]
551    fn reject_reserved_namespace_refuses_rewards_endorsements() {
552        for message in [
553            "kind: rewards_endorsement\nversion: 1\naction: upsert\n...",
554            "KIND: REWARDS_ENDORSEMENT\naction: delete",
555            "kind: rewards_endorsement",
556        ] {
557            assert!(
558                matches!(
559                    reject_reserved_namespace(message),
560                    Err(SdkError::InvalidInput(_))
561                ),
562                "expected {message:?} to be refused"
563            );
564        }
565    }
566
567    #[test]
568    fn reject_reserved_namespace_allows_everything_else() {
569        for message in [
570            "",
571            "hello",
572            "breez-lnur",
573            "breez lnurl:v2",
574            // Only a leading namespace authorizes anything.
575            "please sign breez-lnurl:v2\nregister",
576            "please sign kind: rewards_endorsement",
577            "kind: rewards",
578            // A multi-byte first character must not panic the prefix check.
579            "🥖",
580        ] {
581            assert!(
582                reject_reserved_namespace(message).is_ok(),
583                "expected {message:?} to be allowed"
584            );
585        }
586    }
587
588    #[test]
589    fn parse_compressed_public_key_rejects_invalid() {
590        for input in ["", "not-a-public-key", UNCOMPRESSED, &COMPRESSED[..64]] {
591            assert!(
592                matches!(
593                    parse_compressed_public_key(input),
594                    Err(SdkError::InvalidInput(_))
595                ),
596                "expected {input} to be rejected"
597            );
598        }
599    }
600}