Skip to main content

breez_sdk_spark/sdk/
api.rs

1use bitcoin::secp256k1::{PublicKey, ecdsa::Signature};
2use breez_sdk_common::buy::cashapp::CashAppProvider;
3use spark_wallet::MasterIdentityPublicKeyUpdate;
4use std::str::FromStr;
5use tracing::{debug, info};
6
7use crate::{
8    BuyBitcoinRequest, BuyBitcoinResponse, CheckMessageRequest, CheckMessageResponse,
9    CrossChainRouteFilter, CrossChainRoutePair, GetTokensMetadataRequest,
10    GetTokensMetadataResponse, InputType, ListFiatCurrenciesResponse, ListFiatRatesResponse,
11    Network, OptimizationMode, OptimizeLeavesRequest, OptimizeLeavesResponse,
12    RegisterWebhookRequest, RegisterWebhookResponse, SignMessageRequest, SignMessageResponse,
13    UnregisterWebhookRequest, UpdateUserSettingsRequest, UserSettings, Webhook,
14    chain::RecommendedFees,
15    error::SdkError,
16    events::EventListener,
17    issuer::TokenIssuer,
18    models::{
19        GetInfoRequest, GetInfoResponse, SparkMasterIdentityPublicKey, StableBalanceActiveLabel,
20    },
21    persist::ObjectCacheRepository,
22    utils::token::get_tokens_metadata_cached_or_query,
23};
24
25use super::{BreezSdk, helpers::get_deposit_address, parse_input};
26
27#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
28#[allow(clippy::needless_pass_by_value)]
29impl BreezSdk {
30    /// Registers a listener to receive SDK events
31    ///
32    /// The SDK holds the listener until it is removed with
33    /// `remove_event_listener` or until `disconnect` unregisters all
34    /// listeners. A held listener that references the SDK instance keeps
35    /// that instance alive.
36    ///
37    /// # Arguments
38    ///
39    /// * `listener` - An implementation of the `EventListener` trait
40    ///
41    /// # Returns
42    ///
43    /// A unique identifier for the listener, which can be used to remove it later
44    pub async fn add_event_listener(&self, listener: Box<dyn EventListener>) -> String {
45        self.event_emitter.add_external_listener(listener).await
46    }
47
48    /// Removes a previously registered event listener
49    ///
50    /// # Arguments
51    ///
52    /// * `id` - The listener ID returned from `add_event_listener`
53    ///
54    /// # Returns
55    ///
56    /// `true` if the listener was found and removed, `false` otherwise
57    pub async fn remove_event_listener(&self, id: &str) -> bool {
58        self.event_emitter.remove_external_listener(id).await
59    }
60
61    /// Stops the SDK's background tasks
62    ///
63    /// This method stops the background tasks started by the `start()` method.
64    /// It should be called before your application terminates to ensure proper cleanup.
65    ///
66    /// It also unregisters all event listeners, so listeners that reference
67    /// the SDK no longer keep it alive after this call.
68    ///
69    /// # Returns
70    ///
71    /// Result containing either success or an `SdkError` if the background task couldn't be stopped
72    pub async fn disconnect(&self) -> Result<(), SdkError> {
73        info!("Disconnecting Breez SDK");
74        self.event_emitter.clear_external_listeners().await;
75        if self.shutdown_sender.send(()).is_err() {
76            // A `watch::Sender::send` error means every receiver has been
77            // dropped, i.e. no background task is listening. This is the
78            // expected steady state for a server-mode SDK
79            // (`background_tasks_enabled = false`): there is nothing to
80            // stop, so disconnecting is a successful no-op.
81            debug!("No shutdown receivers; SDK has no background tasks to stop");
82            return Ok(());
83        }
84
85        self.shutdown_sender.closed().await;
86        info!("Breez SDK disconnected");
87        Ok(())
88    }
89
90    pub async fn parse(&self, input: &str) -> Result<InputType, SdkError> {
91        parse_input(input, Some(self.external_input_parsers.clone())).await
92    }
93
94    /// Returns the available cross-chain routes.
95    ///
96    /// Use [`CrossChainRouteFilter::Send`] to get routes for sending from Spark
97    /// (filtered by the parsed recipient address), or
98    /// [`CrossChainRouteFilter::Receive`] to get routes for receiving into Spark
99    /// (optionally filtered by a source contract address).
100    pub async fn get_cross_chain_routes(
101        &self,
102        filter: &CrossChainRouteFilter,
103    ) -> Result<Vec<CrossChainRoutePair>, SdkError> {
104        let mut all_routes = Vec::new();
105        for svc in self.cross_chain_context.values() {
106            match svc.get_routes(filter).await {
107                Ok(routes) => all_routes.extend(routes),
108                Err(e) => tracing::warn!("Cross-chain provider route fetch failed: {e}"),
109            }
110        }
111
112        // Filter to USD-pegged destinations only.
113        all_routes.retain(|r| crate::cross_chain::is_usd_stable_asset(&r.asset));
114
115        all_routes.sort_by(|a, b| {
116            a.asset
117                .cmp(&b.asset)
118                .then_with(|| a.chain.cmp(&b.chain))
119                .then_with(|| a.provider.cmp(&b.provider))
120        });
121        Ok(all_routes)
122    }
123
124    /// Returns the balance of the wallet in satoshis
125    #[allow(unused_variables)]
126    pub async fn get_info(&self, request: GetInfoRequest) -> Result<GetInfoResponse, SdkError> {
127        self.runtime.get_info(self, request).await
128    }
129
130    /// List fiat currencies for which there is a known exchange rate,
131    /// sorted by the canonical name of the currency.
132    pub async fn list_fiat_currencies(&self) -> Result<ListFiatCurrenciesResponse, SdkError> {
133        let currencies = self
134            .fiat_service
135            .fetch_fiat_currencies()
136            .await?
137            .into_iter()
138            .map(From::from)
139            .collect();
140        Ok(ListFiatCurrenciesResponse { currencies })
141    }
142
143    /// List the latest rates of fiat currencies, sorted by name.
144    pub async fn list_fiat_rates(&self) -> Result<ListFiatRatesResponse, SdkError> {
145        let rates = self
146            .fiat_service
147            .fetch_fiat_rates()
148            .await?
149            .into_iter()
150            .map(From::from)
151            .collect();
152        Ok(ListFiatRatesResponse { rates })
153    }
154
155    /// Get the recommended BTC fees based on the configured chain service.
156    pub async fn recommended_fees(&self) -> Result<RecommendedFees, SdkError> {
157        Ok(self.chain_service.recommended_fees().await?)
158    }
159
160    /// Returns the metadata for the given token identifiers.
161    ///
162    /// Results are not guaranteed to be in the same order as the input token identifiers.
163    ///
164    /// If the metadata is not found locally in cache, it will be queried from
165    /// the Spark network and then cached.
166    pub async fn get_tokens_metadata(
167        &self,
168        request: GetTokensMetadataRequest,
169    ) -> Result<GetTokensMetadataResponse, SdkError> {
170        let metadata = get_tokens_metadata_cached_or_query(
171            &self.spark_wallet,
172            &ObjectCacheRepository::new(self.storage.clone()),
173            &request
174                .token_identifiers
175                .iter()
176                .map(String::as_str)
177                .collect::<Vec<_>>(),
178        )
179        .await?;
180        Ok(GetTokensMetadataResponse {
181            tokens_metadata: metadata,
182        })
183    }
184
185    /// Signs a message with the wallet's identity key. The message is SHA256
186    /// hashed before signing. The returned signature will be hex encoded in
187    /// DER format by default, or compact format if specified.
188    pub async fn sign_message(
189        &self,
190        request: SignMessageRequest,
191    ) -> Result<SignMessageResponse, SdkError> {
192        use bitcoin::hex::DisplayHex;
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 outcome = self.spark_wallet.optimize_leaves(max_rounds).await?.into();
327        Ok(OptimizeLeavesResponse { outcome })
328    }
329
330    /// Registers a webhook to receive notifications for wallet events.
331    ///
332    /// When registered events occur (e.g., a Lightning payment is received),
333    /// the Spark service provider will send an HTTP POST to the specified URL
334    /// with a payload signed using HMAC-SHA256 with the provided secret.
335    ///
336    /// # Arguments
337    ///
338    /// * `request` - The webhook registration details including URL, secret, and event types
339    ///
340    /// # Returns
341    ///
342    /// A response containing the unique identifier of the registered webhook
343    pub async fn register_webhook(
344        &self,
345        request: RegisterWebhookRequest,
346    ) -> Result<RegisterWebhookResponse, SdkError> {
347        let event_types = request.event_types.into_iter().map(Into::into).collect();
348        let webhook_id = self
349            .spark_wallet
350            .register_wallet_webhook(&request.url, &request.secret, event_types)
351            .await
352            .map_err(|e| SdkError::Generic(format!("Failed to register webhook: {e}")))?;
353        Ok(RegisterWebhookResponse { webhook_id })
354    }
355
356    /// Unregisters a previously registered webhook.
357    ///
358    /// After unregistering, the Spark service provider will no longer send
359    /// notifications to the webhook URL.
360    ///
361    /// # Arguments
362    ///
363    /// * `request` - The unregister request containing the webhook ID
364    pub async fn unregister_webhook(
365        &self,
366        request: UnregisterWebhookRequest,
367    ) -> Result<(), SdkError> {
368        self.spark_wallet
369            .delete_wallet_webhook(&request.webhook_id)
370            .await
371            .map_err(|e| SdkError::Generic(format!("Failed to unregister webhook: {e}")))?;
372        Ok(())
373    }
374
375    /// Lists all webhooks currently registered for this wallet.
376    ///
377    /// # Returns
378    ///
379    /// A list of registered webhooks with their IDs, URLs, and subscribed event types
380    pub async fn list_webhooks(&self) -> Result<Vec<Webhook>, SdkError> {
381        let webhooks = self
382            .spark_wallet
383            .list_wallet_webhooks()
384            .await
385            .map_err(|e| SdkError::Generic(format!("Failed to list webhooks: {e}")))?;
386        Ok(webhooks.into_iter().map(Into::into).collect())
387    }
388
389    /// Initiates a Bitcoin purchase flow via an external provider.
390    ///
391    /// Returns a URL the user should open to complete the purchase.
392    /// The request variant determines the provider and its parameters:
393    ///
394    /// - [`BuyBitcoinRequest::Moonpay`]: Fiat-to-Bitcoin via on-chain deposit.
395    /// - [`BuyBitcoinRequest::CashApp`]: Lightning invoice + `cash.app` deep link (mainnet only).
396    pub async fn buy_bitcoin(
397        &self,
398        request: BuyBitcoinRequest,
399    ) -> Result<BuyBitcoinResponse, SdkError> {
400        let url = match request {
401            BuyBitcoinRequest::Moonpay {
402                locked_amount_sat,
403                redirect_url,
404            } => {
405                let address = get_deposit_address(&self.spark_wallet, true).await?;
406                self.buy_bitcoin_provider
407                    .buy_bitcoin(address, locked_amount_sat, redirect_url)
408                    .await
409                    .map_err(|e| {
410                        SdkError::Generic(format!("Failed to create buy bitcoin URL: {e}"))
411                    })?
412            }
413            BuyBitcoinRequest::CashApp { amount_sats } => {
414                if !matches!(self.config.network, Network::Mainnet) {
415                    return Err(SdkError::Generic(
416                        "CashApp is only available on mainnet".to_string(),
417                    ));
418                }
419                if amount_sats == 0 {
420                    return Err(SdkError::Generic(
421                        "CashApp requires a non-zero amount".to_string(),
422                    ));
423                }
424                let receive_response = self
425                    .receive_bolt11_invoice(
426                        "Buy Bitcoin via CashApp".to_string(),
427                        Some(amount_sats),
428                        None,
429                        None,
430                    )
431                    .await?;
432                CashAppProvider::build_url(&receive_response.payment_request)
433            }
434        };
435
436        Ok(BuyBitcoinResponse { url })
437    }
438}
439
440/// Parses a 33-byte compressed public key from hex.
441///
442/// Rejects the uncompressed encoding, which `PublicKey::from_str` also accepts:
443/// Spark serializes identity keys compressed, so accepting it would make the
444/// key read back differ from the one that was set.
445fn parse_compressed_public_key(hex_encoded: &str) -> Result<PublicKey, SdkError> {
446    let invalid = || SdkError::InvalidInput("Invalid master identity public key".to_string());
447    let bytes: [u8; 33] = hex::decode(hex_encoded)
448        .map_err(|_| invalid())?
449        .try_into()
450        .map_err(|_| invalid())?;
451    PublicKey::from_slice(&bytes).map_err(|_| invalid())
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    const COMPRESSED: &str = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
459    const UNCOMPRESSED: &str = "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179\
460        8483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8";
461
462    #[test]
463    fn parse_compressed_public_key_round_trips() {
464        let key = parse_compressed_public_key(COMPRESSED).unwrap();
465        assert_eq!(key.to_string(), COMPRESSED);
466    }
467
468    #[test]
469    fn parse_compressed_public_key_rejects_invalid() {
470        for input in ["", "not-a-public-key", UNCOMPRESSED, &COMPRESSED[..64]] {
471            assert!(
472                matches!(
473                    parse_compressed_public_key(input),
474                    Err(SdkError::InvalidInput(_))
475                ),
476                "expected {input} to be rejected"
477            );
478        }
479    }
480}