Skip to main content

breez_sdk_spark/sdk/
api.rs

1use bitcoin::secp256k1::{PublicKey, ecdsa::Signature};
2use breez_sdk_common::buy::cashapp::CashAppProvider;
3use std::str::FromStr;
4use tracing::{debug, info};
5
6use crate::{
7    BuyBitcoinRequest, BuyBitcoinResponse, CheckMessageRequest, CheckMessageResponse,
8    CrossChainRouteFilter, CrossChainRoutePair, GetTokensMetadataRequest,
9    GetTokensMetadataResponse, InputType, ListFiatCurrenciesResponse, ListFiatRatesResponse,
10    Network, OptimizationMode, OptimizeLeavesRequest, OptimizeLeavesResponse,
11    RegisterWebhookRequest, RegisterWebhookResponse, SignMessageRequest, SignMessageResponse,
12    UnregisterWebhookRequest, UpdateUserSettingsRequest, UserSettings, Webhook,
13    chain::RecommendedFees,
14    error::SdkError,
15    events::EventListener,
16    issuer::TokenIssuer,
17    models::{GetInfoRequest, GetInfoResponse, StableBalanceActiveLabel},
18    persist::ObjectCacheRepository,
19    utils::token::get_tokens_metadata_cached_or_query,
20};
21
22use super::{BreezSdk, helpers::get_deposit_address, parse_input};
23
24#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
25#[allow(clippy::needless_pass_by_value)]
26impl BreezSdk {
27    /// Registers a listener to receive SDK events
28    ///
29    /// The SDK holds the listener until it is removed with
30    /// `remove_event_listener` or until `disconnect` unregisters all
31    /// listeners. A held listener that references the SDK instance keeps
32    /// that instance alive.
33    ///
34    /// # Arguments
35    ///
36    /// * `listener` - An implementation of the `EventListener` trait
37    ///
38    /// # Returns
39    ///
40    /// A unique identifier for the listener, which can be used to remove it later
41    pub async fn add_event_listener(&self, listener: Box<dyn EventListener>) -> String {
42        self.event_emitter.add_external_listener(listener).await
43    }
44
45    /// Removes a previously registered event listener
46    ///
47    /// # Arguments
48    ///
49    /// * `id` - The listener ID returned from `add_event_listener`
50    ///
51    /// # Returns
52    ///
53    /// `true` if the listener was found and removed, `false` otherwise
54    pub async fn remove_event_listener(&self, id: &str) -> bool {
55        self.event_emitter.remove_external_listener(id).await
56    }
57
58    /// Stops the SDK's background tasks
59    ///
60    /// This method stops the background tasks started by the `start()` method.
61    /// It should be called before your application terminates to ensure proper cleanup.
62    ///
63    /// It also unregisters all event listeners, so listeners that reference
64    /// the SDK no longer keep it alive after this call.
65    ///
66    /// # Returns
67    ///
68    /// Result containing either success or an `SdkError` if the background task couldn't be stopped
69    pub async fn disconnect(&self) -> Result<(), SdkError> {
70        info!("Disconnecting Breez SDK");
71        self.event_emitter.clear_external_listeners().await;
72        if self.shutdown_sender.send(()).is_err() {
73            // A `watch::Sender::send` error means every receiver has been
74            // dropped, i.e. no background task is listening. This is the
75            // expected steady state for a server-mode SDK
76            // (`background_tasks_enabled = false`): there is nothing to
77            // stop, so disconnecting is a successful no-op.
78            debug!("No shutdown receivers; SDK has no background tasks to stop");
79            return Ok(());
80        }
81
82        self.shutdown_sender.closed().await;
83        info!("Breez SDK disconnected");
84        Ok(())
85    }
86
87    pub async fn parse(&self, input: &str) -> Result<InputType, SdkError> {
88        parse_input(input, Some(self.external_input_parsers.clone())).await
89    }
90
91    /// Returns the available cross-chain routes.
92    ///
93    /// Use [`CrossChainRouteFilter::Send`] to get routes for sending from Spark
94    /// (filtered by the parsed recipient address), or
95    /// [`CrossChainRouteFilter::Receive`] to get routes for receiving into Spark
96    /// (optionally filtered by a source contract address).
97    pub async fn get_cross_chain_routes(
98        &self,
99        filter: &CrossChainRouteFilter,
100    ) -> Result<Vec<CrossChainRoutePair>, SdkError> {
101        let mut all_routes = Vec::new();
102        for svc in self.cross_chain_context.values() {
103            match svc.get_routes(filter).await {
104                Ok(routes) => all_routes.extend(routes),
105                Err(e) => tracing::warn!("Cross-chain provider route fetch failed: {e}"),
106            }
107        }
108
109        // Filter to USD-pegged destinations only.
110        all_routes.retain(|r| crate::cross_chain::is_usd_stable_asset(&r.asset));
111
112        all_routes.sort_by(|a, b| {
113            a.asset
114                .cmp(&b.asset)
115                .then_with(|| a.chain.cmp(&b.chain))
116                .then_with(|| a.provider.cmp(&b.provider))
117        });
118        Ok(all_routes)
119    }
120
121    /// Returns the balance of the wallet in satoshis
122    #[allow(unused_variables)]
123    pub async fn get_info(&self, request: GetInfoRequest) -> Result<GetInfoResponse, SdkError> {
124        self.runtime.get_info(self, request).await
125    }
126
127    /// List fiat currencies for which there is a known exchange rate,
128    /// sorted by the canonical name of the currency.
129    pub async fn list_fiat_currencies(&self) -> Result<ListFiatCurrenciesResponse, SdkError> {
130        let currencies = self
131            .fiat_service
132            .fetch_fiat_currencies()
133            .await?
134            .into_iter()
135            .map(From::from)
136            .collect();
137        Ok(ListFiatCurrenciesResponse { currencies })
138    }
139
140    /// List the latest rates of fiat currencies, sorted by name.
141    pub async fn list_fiat_rates(&self) -> Result<ListFiatRatesResponse, SdkError> {
142        let rates = self
143            .fiat_service
144            .fetch_fiat_rates()
145            .await?
146            .into_iter()
147            .map(From::from)
148            .collect();
149        Ok(ListFiatRatesResponse { rates })
150    }
151
152    /// Get the recommended BTC fees based on the configured chain service.
153    pub async fn recommended_fees(&self) -> Result<RecommendedFees, SdkError> {
154        Ok(self.chain_service.recommended_fees().await?)
155    }
156
157    /// Returns the metadata for the given token identifiers.
158    ///
159    /// Results are not guaranteed to be in the same order as the input token identifiers.
160    ///
161    /// If the metadata is not found locally in cache, it will be queried from
162    /// the Spark network and then cached.
163    pub async fn get_tokens_metadata(
164        &self,
165        request: GetTokensMetadataRequest,
166    ) -> Result<GetTokensMetadataResponse, SdkError> {
167        let metadata = get_tokens_metadata_cached_or_query(
168            &self.spark_wallet,
169            &ObjectCacheRepository::new(self.storage.clone()),
170            &request
171                .token_identifiers
172                .iter()
173                .map(String::as_str)
174                .collect::<Vec<_>>(),
175        )
176        .await?;
177        Ok(GetTokensMetadataResponse {
178            tokens_metadata: metadata,
179        })
180    }
181
182    /// Signs a message with the wallet's identity key. The message is SHA256
183    /// hashed before signing. The returned signature will be hex encoded in
184    /// DER format by default, or compact format if specified.
185    pub async fn sign_message(
186        &self,
187        request: SignMessageRequest,
188    ) -> Result<SignMessageResponse, SdkError> {
189        use bitcoin::hex::DisplayHex;
190
191        let pubkey = self.spark_wallet.get_identity_public_key().to_string();
192        let signature = self.spark_wallet.sign_message(&request.message).await?;
193        let signature_hex = if request.compact {
194            signature.serialize_compact().to_lower_hex_string()
195        } else {
196            signature.serialize_der().to_lower_hex_string()
197        };
198
199        Ok(SignMessageResponse {
200            pubkey,
201            signature: signature_hex,
202        })
203    }
204
205    /// Verifies a message signature against the provided public key. The message
206    /// is SHA256 hashed before verification. The signature can be hex encoded
207    /// in either DER or compact format.
208    pub async fn check_message(
209        &self,
210        request: CheckMessageRequest,
211    ) -> Result<CheckMessageResponse, SdkError> {
212        let pubkey = PublicKey::from_str(&request.pubkey)
213            .map_err(|_| SdkError::InvalidInput("Invalid public key".to_string()))?;
214        let signature_bytes = hex::decode(&request.signature)
215            .map_err(|_| SdkError::InvalidInput("Not a valid hex encoded signature".to_string()))?;
216        let signature = Signature::from_der(&signature_bytes)
217            .or_else(|_| Signature::from_compact(&signature_bytes))
218            .map_err(|_| {
219                SdkError::InvalidInput("Not a valid DER or compact encoded signature".to_string())
220            })?;
221
222        let is_valid = self
223            .spark_wallet
224            .verify_message(&request.message, &signature, &pubkey)
225            .await
226            .is_ok();
227        Ok(CheckMessageResponse { is_valid })
228    }
229
230    /// Returns the user settings for the wallet.
231    ///
232    /// Some settings are fetched from the Spark network so network requests are performed.
233    pub async fn get_user_settings(&self) -> Result<UserSettings, SdkError> {
234        // Ensure spark private mode is initialized to avoid race conditions with the initialization task.
235        self.maybe_ensure_spark_private_mode_initialized().await?;
236
237        let spark_user_settings = self.spark_wallet.query_wallet_settings().await?;
238
239        let stable_balance_active_label = match &self.stable_balance {
240            Some(sb) => sb.get_active_label().await,
241            None => None,
242        };
243
244        Ok(UserSettings {
245            spark_private_mode_enabled: spark_user_settings.private_enabled,
246            stable_balance_active_label,
247        })
248    }
249
250    /// Updates the user settings for the wallet.
251    ///
252    /// Some settings are updated on the Spark network so network requests may be performed.
253    pub async fn update_user_settings(
254        &self,
255        request: UpdateUserSettingsRequest,
256    ) -> Result<(), SdkError> {
257        if let Some(spark_private_mode_enabled) = request.spark_private_mode_enabled {
258            self.spark_wallet
259                .update_wallet_settings(spark_private_mode_enabled)
260                .await?;
261        }
262
263        if let Some(active_label) = request.stable_balance_active_label {
264            let sb = self
265                .stable_balance
266                .as_ref()
267                .ok_or_else(|| SdkError::Generic("Stable balance is not configured".to_string()))?;
268            let label = if let StableBalanceActiveLabel::Set { label } = active_label {
269                Some(label)
270            } else {
271                None
272            };
273            sb.set_active_token(label).await?;
274        }
275
276        Ok(())
277    }
278
279    /// Returns an instance of the [`TokenIssuer`] for managing token issuance.
280    pub fn get_token_issuer(&self) -> TokenIssuer {
281        TokenIssuer::new(self.spark_wallet.clone(), self.storage.clone())
282    }
283
284    /// Manually drives leaf optimization, blocking until the requested work
285    /// is done.
286    ///
287    /// With [`OptimizationMode::Full`] (the default) the call runs the entire
288    /// optimization in a single invocation. With
289    /// [`OptimizationMode::SingleRound`] it executes one round and returns —
290    /// the caller drives the loop by inspecting the
291    /// [`OptimizeLeavesResponse::outcome`] and calling again until
292    /// `InProgress` no longer appears.
293    ///
294    /// Returns an error if another optimization run (auto or manual) is
295    /// already in flight ([`SdkError::OptimizationAlreadyRunning`]), or if
296    /// the SDK preempted this run to free leaves for a payment
297    /// ([`SdkError::OptimizationCancelled`]).
298    ///
299    /// Manual runs do not emit events; events ([`SdkEvent::AutoOptimization`])
300    /// are reserved for the background auto-optimizer.
301    pub async fn optimize_leaves(
302        &self,
303        request: OptimizeLeavesRequest,
304    ) -> Result<OptimizeLeavesResponse, SdkError> {
305        let max_rounds = match request.mode {
306            OptimizationMode::Full => None,
307            OptimizationMode::SingleRound => Some(1),
308        };
309        let outcome = self.spark_wallet.optimize_leaves(max_rounds).await?.into();
310        Ok(OptimizeLeavesResponse { outcome })
311    }
312
313    /// Registers a webhook to receive notifications for wallet events.
314    ///
315    /// When registered events occur (e.g., a Lightning payment is received),
316    /// the Spark service provider will send an HTTP POST to the specified URL
317    /// with a payload signed using HMAC-SHA256 with the provided secret.
318    ///
319    /// # Arguments
320    ///
321    /// * `request` - The webhook registration details including URL, secret, and event types
322    ///
323    /// # Returns
324    ///
325    /// A response containing the unique identifier of the registered webhook
326    pub async fn register_webhook(
327        &self,
328        request: RegisterWebhookRequest,
329    ) -> Result<RegisterWebhookResponse, SdkError> {
330        let event_types = request.event_types.into_iter().map(Into::into).collect();
331        let webhook_id = self
332            .spark_wallet
333            .register_wallet_webhook(&request.url, &request.secret, event_types)
334            .await
335            .map_err(|e| SdkError::Generic(format!("Failed to register webhook: {e}")))?;
336        Ok(RegisterWebhookResponse { webhook_id })
337    }
338
339    /// Unregisters a previously registered webhook.
340    ///
341    /// After unregistering, the Spark service provider will no longer send
342    /// notifications to the webhook URL.
343    ///
344    /// # Arguments
345    ///
346    /// * `request` - The unregister request containing the webhook ID
347    pub async fn unregister_webhook(
348        &self,
349        request: UnregisterWebhookRequest,
350    ) -> Result<(), SdkError> {
351        self.spark_wallet
352            .delete_wallet_webhook(&request.webhook_id)
353            .await
354            .map_err(|e| SdkError::Generic(format!("Failed to unregister webhook: {e}")))?;
355        Ok(())
356    }
357
358    /// Lists all webhooks currently registered for this wallet.
359    ///
360    /// # Returns
361    ///
362    /// A list of registered webhooks with their IDs, URLs, and subscribed event types
363    pub async fn list_webhooks(&self) -> Result<Vec<Webhook>, SdkError> {
364        let webhooks = self
365            .spark_wallet
366            .list_wallet_webhooks()
367            .await
368            .map_err(|e| SdkError::Generic(format!("Failed to list webhooks: {e}")))?;
369        Ok(webhooks.into_iter().map(Into::into).collect())
370    }
371
372    /// Initiates a Bitcoin purchase flow via an external provider.
373    ///
374    /// Returns a URL the user should open to complete the purchase.
375    /// The request variant determines the provider and its parameters:
376    ///
377    /// - [`BuyBitcoinRequest::Moonpay`]: Fiat-to-Bitcoin via on-chain deposit.
378    /// - [`BuyBitcoinRequest::CashApp`]: Lightning invoice + `cash.app` deep link (mainnet only).
379    pub async fn buy_bitcoin(
380        &self,
381        request: BuyBitcoinRequest,
382    ) -> Result<BuyBitcoinResponse, SdkError> {
383        let url = match request {
384            BuyBitcoinRequest::Moonpay {
385                locked_amount_sat,
386                redirect_url,
387            } => {
388                let address = get_deposit_address(&self.spark_wallet, true).await?;
389                self.buy_bitcoin_provider
390                    .buy_bitcoin(address, locked_amount_sat, redirect_url)
391                    .await
392                    .map_err(|e| {
393                        SdkError::Generic(format!("Failed to create buy bitcoin URL: {e}"))
394                    })?
395            }
396            BuyBitcoinRequest::CashApp { amount_sats } => {
397                if !matches!(self.config.network, Network::Mainnet) {
398                    return Err(SdkError::Generic(
399                        "CashApp is only available on mainnet".to_string(),
400                    ));
401                }
402                if amount_sats == 0 {
403                    return Err(SdkError::Generic(
404                        "CashApp requires a non-zero amount".to_string(),
405                    ));
406                }
407                let receive_response = self
408                    .receive_bolt11_invoice(
409                        "Buy Bitcoin via CashApp".to_string(),
410                        Some(amount_sats),
411                        None,
412                        None,
413                    )
414                    .await?;
415                CashAppProvider::build_url(&receive_response.payment_request)
416            }
417        };
418
419        Ok(BuyBitcoinResponse { url })
420    }
421}