Skip to main content

breez_sdk_spark/sdk/
api.rs

1use bitcoin::secp256k1::{PublicKey, ecdsa::Signature};
2use breez_sdk_common::{buy::cashapp::CashAppProvider, input::CrossChainAddressFamily};
3use spark_wallet::MasterIdentityPublicKeyUpdate;
4use std::str::FromStr;
5use tracing::{debug, info};
6
7use crate::{
8    BuyBitcoinRequest, BuyBitcoinResponse, CheckMessageRequest, CheckMessageResponse,
9    CrossChainProvider, CrossChainRouteFilter, CrossChainRoutePair, GetTokensMetadataRequest,
10    GetTokensMetadataResponse, InputType, ListFiatCurrenciesResponse, ListFiatRatesResponse,
11    Network, OptimizationMode, OptimizeLeavesRequest, OptimizeLeavesResponse,
12    PreparePaymentLinkRequest, PreparePaymentLinkResponse, RegisterWebhookRequest,
13    RegisterWebhookResponse, SignMessageRequest, SignMessageResponse, SourceChain,
14    UnregisterWebhookRequest, UpdateUserSettingsRequest, UserSettings, Webhook,
15    chain::RecommendedFees,
16    cross_chain::{
17        CrossChainProviderContext, convert_destination_amount_to_sats, fetch_btc_usd_rate,
18    },
19    error::SdkError,
20    events::EventListener,
21    issuer::TokenIssuer,
22    models::{
23        GetInfoRequest, GetInfoResponse, SparkMasterIdentityPublicKey, StableBalanceActiveLabel,
24    },
25    persist::ObjectCacheRepository,
26    utils::token::get_tokens_metadata_cached_or_query,
27};
28
29use super::payments::validation::{
30    known_token_contracts, resolve_direct_overpay_amount, resolve_slippage_bps,
31    resolve_target_overpay_bps, validate_address_family_against_route, validate_amount,
32    validate_recipient_not_contract_address,
33};
34use super::{BreezSdk, helpers::get_deposit_address, parse_input};
35
36#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
37#[allow(clippy::needless_pass_by_value)]
38impl BreezSdk {
39    /// Registers a listener to receive SDK events
40    ///
41    /// The SDK holds the listener until it is removed with
42    /// `remove_event_listener` or until `disconnect` unregisters all
43    /// listeners. A held listener that references the SDK instance keeps
44    /// that instance alive.
45    ///
46    /// # Arguments
47    ///
48    /// * `listener` - An implementation of the `EventListener` trait
49    ///
50    /// # Returns
51    ///
52    /// A unique identifier for the listener, which can be used to remove it later
53    pub async fn add_event_listener(&self, listener: Box<dyn EventListener>) -> String {
54        self.event_emitter.add_external_listener(listener).await
55    }
56
57    /// Removes a previously registered event listener
58    ///
59    /// # Arguments
60    ///
61    /// * `id` - The listener ID returned from `add_event_listener`
62    ///
63    /// # Returns
64    ///
65    /// `true` if the listener was found and removed, `false` otherwise
66    pub async fn remove_event_listener(&self, id: &str) -> bool {
67        self.event_emitter.remove_external_listener(id).await
68    }
69
70    /// Stops the SDK's background tasks
71    ///
72    /// This method stops the background tasks started by the `start()` method.
73    /// It should be called before your application terminates to ensure proper cleanup.
74    ///
75    /// It also unregisters all event listeners, so listeners that reference
76    /// the SDK no longer keep it alive after this call.
77    ///
78    /// # Returns
79    ///
80    /// Result containing either success or an `SdkError` if the background task couldn't be stopped
81    pub async fn disconnect(&self) -> Result<(), SdkError> {
82        info!("Disconnecting Breez SDK");
83        self.event_emitter.clear_external_listeners().await;
84        if self.shutdown_sender.send(()).is_err() {
85            // A `watch::Sender::send` error means every receiver has been
86            // dropped, i.e. no background task is listening. This is the
87            // expected steady state for a server-mode SDK
88            // (`background_tasks_enabled = false`): there is nothing to
89            // stop, so disconnecting is a successful no-op.
90            debug!("No shutdown receivers; SDK has no background tasks to stop");
91            return Ok(());
92        }
93
94        self.shutdown_sender.closed().await;
95        info!("Breez SDK disconnected");
96        Ok(())
97    }
98
99    pub async fn parse(&self, input: &str) -> Result<InputType, SdkError> {
100        parse_input(input, Some(self.external_input_parsers.clone())).await
101    }
102
103    /// Returns the available cross-chain routes.
104    ///
105    /// Use [`CrossChainRouteFilter::Send`] to get routes for sending from Spark
106    /// (filtered by the parsed recipient address),
107    /// [`CrossChainRouteFilter::PaymentLink`] for routes fundable by an external
108    /// fiat rail (`prepare_payment_link`), or
109    /// [`CrossChainRouteFilter::Receive`] to get routes for receiving into Spark
110    /// (optionally filtered by a source contract address).
111    pub async fn get_cross_chain_routes(
112        &self,
113        filter: &CrossChainRouteFilter,
114    ) -> Result<Vec<CrossChainRoutePair>, SdkError> {
115        let mut all_routes = Vec::new();
116        for svc in self.cross_chain_context.values() {
117            match svc.get_routes(filter).await {
118                Ok(routes) => all_routes.extend(routes),
119                Err(e) => tracing::warn!("Cross-chain provider route fetch failed: {e}"),
120            }
121        }
122
123        // Filter to USD-pegged destinations only.
124        all_routes.retain(|r| crate::cross_chain::is_usd_stable_asset(&r.asset));
125
126        all_routes.sort_by(|a, b| {
127            a.asset
128                .cmp(&b.asset)
129                .then_with(|| a.chain.cmp(&b.chain))
130                .then_with(|| a.provider.cmp(&b.provider))
131        });
132        Ok(all_routes)
133    }
134
135    /// Returns the balance of the wallet in satoshis
136    #[allow(unused_variables)]
137    pub async fn get_info(&self, request: GetInfoRequest) -> Result<GetInfoResponse, SdkError> {
138        self.runtime.get_info(self, request).await
139    }
140
141    /// List fiat currencies for which there is a known exchange rate,
142    /// sorted by the canonical name of the currency.
143    pub async fn list_fiat_currencies(&self) -> Result<ListFiatCurrenciesResponse, SdkError> {
144        let currencies = self
145            .fiat_service
146            .fetch_fiat_currencies()
147            .await?
148            .into_iter()
149            .map(From::from)
150            .collect();
151        Ok(ListFiatCurrenciesResponse { currencies })
152    }
153
154    /// List the latest rates of fiat currencies, sorted by name.
155    pub async fn list_fiat_rates(&self) -> Result<ListFiatRatesResponse, SdkError> {
156        let rates = self
157            .fiat_service
158            .fetch_fiat_rates()
159            .await?
160            .into_iter()
161            .map(From::from)
162            .collect();
163        Ok(ListFiatRatesResponse { rates })
164    }
165
166    /// Get the recommended BTC fees based on the configured chain service.
167    pub async fn recommended_fees(&self) -> Result<RecommendedFees, SdkError> {
168        Ok(self.chain_service.recommended_fees().await?)
169    }
170
171    /// Returns the metadata for the given token identifiers.
172    ///
173    /// Results are not guaranteed to be in the same order as the input token identifiers.
174    ///
175    /// If the metadata is not found locally in cache, it will be queried from
176    /// the Spark network and then cached.
177    pub async fn get_tokens_metadata(
178        &self,
179        request: GetTokensMetadataRequest,
180    ) -> Result<GetTokensMetadataResponse, SdkError> {
181        let metadata = get_tokens_metadata_cached_or_query(
182            &self.spark_wallet,
183            &ObjectCacheRepository::new(self.storage.clone()),
184            &request
185                .token_identifiers
186                .iter()
187                .map(String::as_str)
188                .collect::<Vec<_>>(),
189        )
190        .await?;
191        Ok(GetTokensMetadataResponse {
192            tokens_metadata: metadata,
193        })
194    }
195
196    /// Signs a message with the wallet's identity key. The message is SHA256
197    /// hashed before signing. The returned signature will be hex encoded in
198    /// DER format by default, or compact format if specified.
199    ///
200    /// Messages in the `breez-lnurl:` namespace are refused: it is reserved for
201    /// the SDK's own requests to the Lightning address server.
202    pub async fn sign_message(
203        &self,
204        request: SignMessageRequest,
205    ) -> Result<SignMessageResponse, SdkError> {
206        use bitcoin::hex::DisplayHex;
207
208        reject_reserved_namespace(&request.message)?;
209
210        let pubkey = self.spark_wallet.get_identity_public_key().to_string();
211        let signature = self.spark_wallet.sign_message(&request.message).await?;
212        let signature_hex = if request.compact {
213            signature.serialize_compact().to_lower_hex_string()
214        } else {
215            signature.serialize_der().to_lower_hex_string()
216        };
217
218        Ok(SignMessageResponse {
219            pubkey,
220            signature: signature_hex,
221        })
222    }
223
224    /// Verifies a message signature against the provided public key. The message
225    /// is SHA256 hashed before verification. The signature can be hex encoded
226    /// in either DER or compact format.
227    pub async fn check_message(
228        &self,
229        request: CheckMessageRequest,
230    ) -> Result<CheckMessageResponse, SdkError> {
231        let pubkey = PublicKey::from_str(&request.pubkey)
232            .map_err(|_| SdkError::InvalidInput("Invalid public key".to_string()))?;
233        let signature_bytes = hex::decode(&request.signature)
234            .map_err(|_| SdkError::InvalidInput("Not a valid hex encoded signature".to_string()))?;
235        let signature = Signature::from_der(&signature_bytes)
236            .or_else(|_| Signature::from_compact(&signature_bytes))
237            .map_err(|_| {
238                SdkError::InvalidInput("Not a valid DER or compact encoded signature".to_string())
239            })?;
240
241        let is_valid = self
242            .spark_wallet
243            .verify_message(&request.message, &signature, &pubkey)
244            .await
245            .is_ok();
246        Ok(CheckMessageResponse { is_valid })
247    }
248
249    /// Returns the user settings for the wallet.
250    ///
251    /// Some settings are fetched from the Spark network so network requests are performed.
252    pub async fn get_user_settings(&self) -> Result<UserSettings, SdkError> {
253        // Ensure spark private mode is initialized to avoid race conditions with the initialization task.
254        self.maybe_ensure_spark_private_mode_initialized().await?;
255
256        let spark_user_settings = self.spark_wallet.query_wallet_settings().await?;
257
258        let stable_balance_active_label = match &self.stable_balance {
259            Some(sb) => sb.get_active_label().await,
260            None => None,
261        };
262
263        Ok(UserSettings {
264            spark_private_mode_enabled: spark_user_settings.private_enabled,
265            stable_balance_active_label,
266            spark_master_identity_public_key: spark_user_settings
267                .master_identity_public_key
268                .map(|key| key.to_string()),
269        })
270    }
271
272    /// Updates the user settings for the wallet.
273    ///
274    /// Some settings are updated on the Spark network so network requests may be performed.
275    pub async fn update_user_settings(
276        &self,
277        request: UpdateUserSettingsRequest,
278    ) -> Result<(), SdkError> {
279        let master_identity_public_key = request
280            .spark_master_identity_public_key
281            .map(|update| match update {
282                SparkMasterIdentityPublicKey::Set { public_key } => {
283                    parse_compressed_public_key(&public_key).map(MasterIdentityPublicKeyUpdate::Set)
284                }
285                SparkMasterIdentityPublicKey::Unset => Ok(MasterIdentityPublicKeyUpdate::Clear),
286            })
287            .transpose()?;
288
289        self.spark_wallet
290            .update_wallet_settings(
291                request.spark_private_mode_enabled,
292                master_identity_public_key,
293            )
294            .await?;
295
296        if let Some(active_label) = request.stable_balance_active_label {
297            let sb = self
298                .stable_balance
299                .as_ref()
300                .ok_or_else(|| SdkError::Generic("Stable balance is not configured".to_string()))?;
301            let label = if let StableBalanceActiveLabel::Set { label } = active_label {
302                Some(label)
303            } else {
304                None
305            };
306            sb.set_active_token(label).await?;
307        }
308
309        Ok(())
310    }
311
312    /// Returns an instance of the [`TokenIssuer`] for managing token issuance.
313    pub fn get_token_issuer(&self) -> TokenIssuer {
314        TokenIssuer::new(self.spark_wallet.clone(), self.storage.clone())
315    }
316
317    /// Manually drives leaf optimization, blocking until the requested work
318    /// is done.
319    ///
320    /// With [`OptimizationMode::Full`] (the default) the call runs the entire
321    /// optimization in a single invocation. With
322    /// [`OptimizationMode::SingleRound`] it executes one round and returns —
323    /// the caller drives the loop by inspecting the
324    /// [`OptimizeLeavesResponse::outcome`] and calling again until
325    /// `InProgress` no longer appears.
326    ///
327    /// Returns an error if another optimization run (auto or manual) is
328    /// already in flight ([`SdkError::OptimizationAlreadyRunning`]), or if
329    /// the SDK preempted this run to free leaves for a payment
330    /// ([`SdkError::OptimizationCancelled`]).
331    ///
332    /// Manual runs do not emit events; events ([`SdkEvent::AutoOptimization`])
333    /// are reserved for the background auto-optimizer.
334    pub async fn optimize_leaves(
335        &self,
336        request: OptimizeLeavesRequest,
337    ) -> Result<OptimizeLeavesResponse, SdkError> {
338        let max_rounds = match request.mode {
339            OptimizationMode::Full => None,
340            OptimizationMode::SingleRound => Some(1),
341        };
342        let result = self.spark_wallet.optimize_leaves(max_rounds).await;
343        Ok(OptimizeLeavesResponse {
344            outcome: result?.into(),
345        })
346    }
347
348    /// Registers a webhook to receive notifications for wallet events.
349    ///
350    /// When registered events occur (e.g., a Lightning payment is received),
351    /// the Spark service provider will send an HTTP POST to the specified URL
352    /// with a payload signed using HMAC-SHA256 with the provided secret.
353    ///
354    /// # Arguments
355    ///
356    /// * `request` - The webhook registration details including URL, secret, and event types
357    ///
358    /// # Returns
359    ///
360    /// A response containing the unique identifier of the registered webhook
361    pub async fn register_webhook(
362        &self,
363        request: RegisterWebhookRequest,
364    ) -> Result<RegisterWebhookResponse, SdkError> {
365        let event_types = request.event_types.into_iter().map(Into::into).collect();
366        let webhook_id = self
367            .spark_wallet
368            .register_wallet_webhook(&request.url, &request.secret, event_types)
369            .await
370            .map_err(|e| SdkError::Generic(format!("Failed to register webhook: {e}")))?;
371        Ok(RegisterWebhookResponse { webhook_id })
372    }
373
374    /// Unregisters a previously registered webhook.
375    ///
376    /// After unregistering, the Spark service provider will no longer send
377    /// notifications to the webhook URL.
378    ///
379    /// # Arguments
380    ///
381    /// * `request` - The unregister request containing the webhook ID
382    pub async fn unregister_webhook(
383        &self,
384        request: UnregisterWebhookRequest,
385    ) -> Result<(), SdkError> {
386        self.spark_wallet
387            .delete_wallet_webhook(&request.webhook_id)
388            .await
389            .map_err(|e| SdkError::Generic(format!("Failed to unregister webhook: {e}")))?;
390        Ok(())
391    }
392
393    /// Lists all webhooks currently registered for this wallet.
394    ///
395    /// # Returns
396    ///
397    /// A list of registered webhooks with their IDs, URLs, and subscribed event types
398    pub async fn list_webhooks(&self) -> Result<Vec<Webhook>, SdkError> {
399        let webhooks = self
400            .spark_wallet
401            .list_wallet_webhooks()
402            .await
403            .map_err(|e| SdkError::Generic(format!("Failed to list webhooks: {e}")))?;
404        Ok(webhooks.into_iter().map(Into::into).collect())
405    }
406
407    /// Initiates a Bitcoin purchase flow via an external provider.
408    ///
409    /// Returns a URL the user should open to complete the purchase.
410    /// The request variant determines the provider and its parameters:
411    ///
412    /// - [`BuyBitcoinRequest::Moonpay`]: Fiat-to-Bitcoin via on-chain deposit.
413    /// - [`BuyBitcoinRequest::CashApp`]: Lightning invoice + `cash.app` deep link (mainnet only).
414    pub async fn buy_bitcoin(
415        &self,
416        request: BuyBitcoinRequest,
417    ) -> Result<BuyBitcoinResponse, SdkError> {
418        let url = match request {
419            BuyBitcoinRequest::Moonpay {
420                locked_amount_sat,
421                redirect_url,
422            } => {
423                let address = get_deposit_address(&self.spark_wallet, true).await?;
424                self.buy_bitcoin_provider
425                    .buy_bitcoin(address, locked_amount_sat, redirect_url)
426                    .await
427                    .map_err(|e| {
428                        SdkError::Generic(format!("Failed to create buy bitcoin URL: {e}"))
429                    })?
430            }
431            BuyBitcoinRequest::CashApp { amount_sats } => {
432                if !matches!(self.config.network, Network::Mainnet) {
433                    return Err(SdkError::Generic(
434                        "CashApp is only available on mainnet".to_string(),
435                    ));
436                }
437                if amount_sats == 0 {
438                    return Err(SdkError::Generic(
439                        "CashApp requires a non-zero amount".to_string(),
440                    ));
441                }
442                let receive_response = self
443                    .receive_bolt11_invoice(
444                        "Buy Bitcoin via CashApp".to_string(),
445                        Some(amount_sats),
446                        None,
447                        None,
448                        None,
449                    )
450                    .await?;
451                CashAppProvider::build_url(&receive_response.payment_request)
452            }
453        };
454
455        Ok(BuyBitcoinResponse { url })
456    }
457
458    /// Prepare a payment link that sends USDC/USDT to an external-chain
459    /// recipient, funded by Cash App over Lightning.
460    ///
461    /// Creates a cross-chain order and returns a `cash.app` deep link the payer
462    /// opens. Once paid, the provider delivers the stablecoin to `address`. No
463    /// funds move through the Spark wallet. Only available on mainnet.
464    pub async fn prepare_payment_link(
465        &self,
466        request: PreparePaymentLinkRequest,
467    ) -> Result<PreparePaymentLinkResponse, SdkError> {
468        if !matches!(self.config.network, Network::Mainnet) {
469            return Err(SdkError::Generic("Only available on mainnet".to_string()));
470        }
471        validate_amount(Some(request.amount))?;
472
473        let PreparePaymentLinkRequest {
474            address,
475            route,
476            amount,
477            fee_policy,
478            max_slippage_bps,
479        } = request;
480
481        // Payment links are Orchestra-only. A Boltz reverse swap needs this
482        // wallet online to claim before the payer's HTLC settles, so it can't
483        // back a link someone else pays later. Orchestra orders are submitless
484        // and deliver without the SDK.
485        if !matches!(route.provider, CrossChainProvider::Orchestra) {
486            return Err(SdkError::InvalidInput(
487                "Payment links are only supported on Orchestra routes".to_string(),
488            ));
489        }
490
491        // Cash App funds the deposit over Lightning. Fail fast before quoting if
492        // the route can't be funded that way. Rejecting an empty
493        // `supported_source_chains` stops a hand-built route from reaching
494        // `prepare` and committing provider state it can't fund.
495        if !route_supports_source_chain(&route, SourceChain::Lightning) {
496            return Err(SdkError::InvalidInput(
497                "The selected route can't be funded over Lightning".to_string(),
498            ));
499        }
500
501        // Validate the recipient before creating the order, using the same guards
502        // as the send path.
503        let InputType::CrossChainAddress(recipient) = self.parse(&address).await? else {
504            return Err(SdkError::InvalidInput(
505                "Recipient must be a cross-chain (EVM/Solana/Tron) address".to_string(),
506            ));
507        };
508        let recipient_family: CrossChainAddressFamily = recipient.address_family.into();
509        validate_address_family_against_route(recipient_family, &route)?;
510        let known_contracts =
511            known_token_contracts(self, &recipient.address, recipient_family, &route).await;
512        validate_recipient_not_contract_address(
513            &recipient.address,
514            recipient_family,
515            &known_contracts,
516        )?;
517
518        let service = self.cross_chain_context.get(route.provider)?;
519        let slippage_bps = resolve_slippage_bps(
520            max_slippage_bps,
521            self.config
522                .cross_chain_config
523                .as_ref()
524                .and_then(|c| c.default_slippage_bps),
525        )?;
526
527        // `amount` is in the destination asset's base units (the route's
528        // `decimals`); for these USD-pegged stablecoins it equals the USD value
529        // at parity. Convert it to the sats the provider's `prepare` expects
530        // (for both fee modes) via the live rate.
531        let btc_usd = fetch_btc_usd_rate(self.cross_chain_context.fiat_service().as_ref()).await?;
532        let source_sats =
533            convert_destination_amount_to_sats(amount, btc_usd, route.decimals.into())?;
534        if source_sats == 0 {
535            return Err(SdkError::InvalidInput(
536                "Amount is too small to fund over Lightning".to_string(),
537            ));
538        }
539
540        // Match the send path: on `FeesExcluded` pad the source so the recipient
541        // lands at or above target despite provider slippage. The pad comes from
542        // config (no per-request override on a payment link).
543        let fee_policy = fee_policy.unwrap_or_default();
544        let overpay_bps = resolve_target_overpay_bps(
545            None,
546            self.config
547                .cross_chain_config
548                .as_ref()
549                .and_then(|c| c.default_target_overpay_bps),
550        )?;
551        let source_sats = resolve_direct_overpay_amount(source_sats, fee_policy, overpay_bps);
552
553        let prepared = service
554            .prepare(
555                &recipient.address,
556                &route,
557                source_sats,
558                Some(SourceChain::Lightning),
559                None,
560                slippage_bps,
561                fee_policy.into(),
562            )
563            .await?;
564
565        let amount_sats = u64::try_from(prepared.amount_in).map_err(|_| {
566            SdkError::Generic(format!(
567                "Deposit amount {} sats exceeds u64::MAX",
568                prepared.amount_in
569            ))
570        })?;
571
572        // The provider returns the BOLT11 the external payer funds in its
573        // context. We build the `cash.app` deep link ourselves and never call
574        // the send stage: the external payer funds the provider's deposit
575        // directly, so there is no Spark-side transfer to make and the provider
576        // delivers autonomously once the payer settles.
577        let deposit_target = deposit_target(&prepared.provider_context);
578
579        // Verify the provider's target is a BOLT11 for the quoted amount before
580        // sending the payer to it.
581        let parsed_target = self.parse(&deposit_target).await;
582        check_deposit_target(&parsed_target, amount_sats)?;
583
584        let url = CashAppProvider::build_url(&deposit_target);
585
586        Ok(prepare_payment_link_response(prepared, url, amount_sats))
587    }
588}
589
590/// Whether `route` advertises `required` as a fundable source chain. An empty
591/// `supported_source_chains` (e.g. a hand-built route) matches nothing and is
592/// rejected.
593fn route_supports_source_chain(route: &CrossChainRoutePair, required: SourceChain) -> bool {
594    route.supported_source_chains.contains(&required)
595}
596
597/// Verifies the provider's deposit `target` is a BOLT11 invoice requesting
598/// exactly `expected_sats`, the Lightning payment request Cash App funds. A
599/// wrong type, wrong amount, or unparseable target indicates a provider bug.
600fn check_deposit_target(
601    parsed_target: &Result<InputType, SdkError>,
602    expected_sats: u64,
603) -> Result<(), SdkError> {
604    let Ok(InputType::Bolt11Invoice(details)) = parsed_target else {
605        return Err(SdkError::Generic(
606            "The provider returned a deposit target that is not a valid Lightning \
607             payment request"
608                .to_string(),
609        ));
610    };
611    // The invoice must request the quoted deposit: a mismatch would have the
612    // payer fund the wrong amount.
613    if details.amount_msat.map(u128::from) != Some(u128::from(expected_sats) * 1000) {
614        return Err(SdkError::Generic(format!(
615            "The provider's deposit invoice does not request the quoted \
616             {expected_sats} sats"
617        )));
618    }
619    Ok(())
620}
621
622/// The BOLT11 an external payer funds, read from the provider context
623/// (Orchestra `deposit_address` or Boltz `invoice`).
624fn deposit_target(context: &CrossChainProviderContext) -> String {
625    match context {
626        CrossChainProviderContext::Orchestra {
627            deposit_address, ..
628        } => deposit_address.clone(),
629        CrossChainProviderContext::Boltz { invoice, .. } => invoice.clone(),
630    }
631}
632
633/// Maps a [`crate::cross_chain::CrossChainPrepared`] + funding `url` to a
634/// [`PreparePaymentLinkResponse`].
635///
636/// `estimated_out` is in the destination `asset`'s base units. The fee mirrors
637/// the cross-chain send response: `service_fee_amount` in `service_fee_asset`
638/// base units, where `None` means sats (Boltz denominates its fee in sats;
639/// Orchestra in the stablecoin).
640fn prepare_payment_link_response(
641    prepared: crate::cross_chain::CrossChainPrepared,
642    url: String,
643    amount_sats: u64,
644) -> PreparePaymentLinkResponse {
645    PreparePaymentLinkResponse {
646        url,
647        amount_sats,
648        estimated_out: prepared.estimated_out,
649        asset: prepared.pair.asset,
650        service_fee_amount: prepared.service_fee_amount,
651        service_fee_asset: prepared.service_fee_asset,
652        expires_at: normalize_expires_at(&prepared.expires_at),
653    }
654}
655
656/// Normalizes a provider `expires_at` to RFC3339. Orchestra already returns
657/// RFC3339; Boltz returns a unix-seconds string, which we convert so callers
658/// see a single format.
659fn normalize_expires_at(raw: &str) -> String {
660    if let Ok(secs) = raw.parse::<i64>()
661        && let Some(dt) = chrono::DateTime::from_timestamp(secs, 0)
662    {
663        return dt.to_rfc3339();
664    }
665    raw.to_string()
666}
667
668/// Refuses a message in the namespace reserved for the LNURL server.
669///
670/// The identity key signs both user messages and the SDK's own requests to that
671/// server, and this API is for the former. Matching is ASCII case-insensitive:
672/// the server compares exact bytes, so a caller arriving here with another case
673/// did not compose the message either way.
674fn reject_reserved_namespace(message: &str) -> Result<(), SdkError> {
675    let namespace = lnurl_models::signed_message::RESERVED_NAMESPACE;
676    let reserved = message
677        .as_bytes()
678        .get(..namespace.len())
679        .is_some_and(|prefix| prefix.eq_ignore_ascii_case(namespace.as_bytes()));
680    if reserved {
681        return Err(SdkError::InvalidInput(format!(
682            "messages starting with '{namespace}' are reserved for Lightning address requests and cannot be signed here"
683        )));
684    }
685    Ok(())
686}
687
688/// Parses a 33-byte compressed public key from hex.
689///
690/// Rejects the uncompressed encoding, which `PublicKey::from_str` also accepts:
691/// Spark serializes identity keys compressed, so accepting it would make the
692/// key read back differ from the one that was set.
693fn parse_compressed_public_key(hex_encoded: &str) -> Result<PublicKey, SdkError> {
694    let invalid = || SdkError::InvalidInput("Invalid master identity public key".to_string());
695    let bytes: [u8; 33] = hex::decode(hex_encoded)
696        .map_err(|_| invalid())?
697        .try_into()
698        .map_err(|_| invalid())?;
699    PublicKey::from_slice(&bytes).map_err(|_| invalid())
700}
701
702#[cfg(test)]
703mod tests {
704    use super::{
705        CashAppProvider, SdkError, deposit_target, parse_compressed_public_key,
706        prepare_payment_link_response, reject_reserved_namespace, route_supports_source_chain,
707    };
708    use crate::cross_chain::{CrossChainPrepared, CrossChainProviderContext};
709    use crate::{CrossChainFeeMode, CrossChainProvider, CrossChainRoutePair, SourceChain};
710    use macros::test_all;
711
712    #[cfg(feature = "browser-tests")]
713    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
714
715    fn orchestra_context() -> CrossChainProviderContext {
716        CrossChainProviderContext::Orchestra {
717            quote_id: "q1".to_string(),
718            deposit_address: "lnbc100u1pxyz".to_string(),
719            deposit_amount: 5000,
720        }
721    }
722
723    fn route_with_source_chains(chains: Vec<SourceChain>) -> CrossChainRoutePair {
724        CrossChainRoutePair {
725            provider: CrossChainProvider::Orchestra,
726            chain: "base".to_string(),
727            chain_id: Some("8453".to_string()),
728            asset: "USDC".to_string(),
729            contract_address: None,
730            decimals: 6,
731            exact_out_eligible: false,
732            supported_sources: vec![],
733            supported_source_chains: chains,
734        }
735    }
736
737    #[test_all]
738    fn route_supports_source_chain_checks_membership() {
739        let lightning_only = route_with_source_chains(vec![SourceChain::Lightning]);
740        // Cash App (Lightning) is supported; MoonPay (Bitcoin) is not.
741        assert!(route_supports_source_chain(
742            &lightning_only,
743            SourceChain::Lightning
744        ));
745        assert!(!route_supports_source_chain(
746            &lightning_only,
747            SourceChain::Bitcoin
748        ));
749
750        let both = route_with_source_chains(vec![SourceChain::Lightning, SourceChain::Bitcoin]);
751        assert!(route_supports_source_chain(&both, SourceChain::Bitcoin));
752
753        // An empty list matches nothing: a route must advertise its rails, so a
754        // hand-built route can't slip a bad rail through to `prepare`.
755        let empty = route_with_source_chains(vec![]);
756        assert!(!route_supports_source_chain(&empty, SourceChain::Bitcoin));
757    }
758
759    fn prepared(provider_context: CrossChainProviderContext) -> CrossChainPrepared {
760        CrossChainPrepared {
761            amount_in: 5000,
762            asset_amount_in: 6_500_000,
763            estimated_out: 6_450_000,
764            fee_amount: 50_000,
765            service_fee_amount: 47_684,
766            service_fee_asset: Some("USDC".to_string()),
767            source_transfer_fee_sats: 0,
768            fee_mode: CrossChainFeeMode::FeesExcluded,
769            expires_at: "2026-07-25T08:09:26.770Z".to_string(),
770            pair: CrossChainRoutePair {
771                provider: CrossChainProvider::Orchestra,
772                chain: "base".to_string(),
773                chain_id: Some("8453".to_string()),
774                asset: "USDC".to_string(),
775                contract_address: None,
776                decimals: 6,
777                exact_out_eligible: false,
778                supported_sources: vec![],
779                supported_source_chains: vec![],
780            },
781            recipient_address: "0xabc".to_string(),
782            token_identifier: None,
783            provider_context,
784        }
785    }
786
787    #[test_all]
788    fn deposit_target_reads_orchestra_address_and_boltz_invoice() {
789        assert_eq!(deposit_target(&orchestra_context()), "lnbc100u1pxyz");
790        assert_eq!(
791            deposit_target(&CrossChainProviderContext::Boltz {
792                swap_id: "s1".to_string(),
793                invoice: "lnbc200u1pboltz".to_string(),
794                invoice_amount_sats: 5000,
795                max_slippage_bps: 100,
796            }),
797            "lnbc200u1pboltz"
798        );
799    }
800
801    #[test_all]
802    fn cashapp_url_built_from_deposit_target() {
803        let url = CashAppProvider::build_url(&deposit_target(&orchestra_context()));
804        assert_eq!(url, "https://cash.app/launch/lightning/lnbc100u1pxyz");
805    }
806
807    #[test_all]
808    fn orchestra_response_maps_stablecoin_fee() {
809        let resp = prepare_payment_link_response(
810            prepared(orchestra_context()),
811            "https://x".to_string(),
812            5000,
813        );
814        assert_eq!(resp.url, "https://x");
815        assert_eq!(resp.amount_sats, 5000);
816        assert_eq!(resp.estimated_out, 6_450_000);
817        assert_eq!(resp.asset, "USDC");
818        // Orchestra denominates its service fee in the stablecoin.
819        assert_eq!(resp.service_fee_amount, 47_684);
820        assert_eq!(resp.service_fee_asset.as_deref(), Some("USDC"));
821    }
822
823    #[test_all]
824    fn boltz_response_reports_native_sats_fee() {
825        let mut prepared = prepared(CrossChainProviderContext::Boltz {
826            swap_id: "s1".to_string(),
827            invoice: "lnbc200u1pboltz".to_string(),
828            invoice_amount_sats: 5000,
829            max_slippage_bps: 100,
830        });
831        // Boltz denominates its service fee in sats (service_fee_asset = None).
832        prepared.service_fee_amount = 17;
833        prepared.service_fee_asset = None;
834        let resp = prepare_payment_link_response(prepared, "https://x".to_string(), 5000);
835        assert_eq!(resp.asset, "USDC");
836        assert_eq!(resp.service_fee_amount, 17);
837        assert_eq!(resp.service_fee_asset, None);
838    }
839
840    #[test_all]
841    fn normalize_expires_at_converts_unix_and_passes_rfc3339() {
842        // Boltz-style unix seconds get converted to a parseable RFC3339 string.
843        let converted = super::normalize_expires_at("1784895588");
844        assert_ne!(converted, "1784895588");
845        assert!(chrono::DateTime::parse_from_rfc3339(&converted).is_ok());
846        // Orchestra-style RFC3339 passes through unchanged.
847        let iso = "2026-07-25T08:09:26.770Z";
848        assert_eq!(super::normalize_expires_at(iso), iso);
849    }
850
851    const COMPRESSED: &str = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
852    const UNCOMPRESSED: &str = "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179\
853        8483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8";
854
855    #[test]
856    fn parse_compressed_public_key_round_trips() {
857        let key = parse_compressed_public_key(COMPRESSED).unwrap();
858        assert_eq!(key.to_string(), COMPRESSED);
859    }
860
861    #[test]
862    fn reject_reserved_namespace_refuses_lnurl_messages() {
863        let messages = [
864            lnurl_models::signed_message::register("lnurl.example.com", "alice", "d", 1),
865            lnurl_models::signed_message::unregister("lnurl.example.com", "alice", 1),
866            lnurl_models::signed_message::recover("lnurl.example.com", COMPRESSED, 1),
867            lnurl_models::signed_message::metadata("lnurl.example.com", COMPRESSED, 1),
868            "BREEZ-LNURL:v2\nregister\n...".to_string(),
869            "breez-lnurl:".to_string(),
870            "breez-lnurl:v3 whatever comes next".to_string(),
871        ];
872        for message in messages {
873            assert!(
874                matches!(
875                    reject_reserved_namespace(&message),
876                    Err(SdkError::InvalidInput(_))
877                ),
878                "expected {message:?} to be refused"
879            );
880        }
881    }
882
883    #[test]
884    fn reject_reserved_namespace_allows_everything_else() {
885        for message in [
886            "",
887            "hello",
888            "breez-lnur",
889            "breez lnurl:v2",
890            // Only a leading namespace authorizes anything.
891            "please sign breez-lnurl:v2\nregister",
892            // A multi-byte first character must not panic the prefix check.
893            "🥖",
894        ] {
895            assert!(
896                reject_reserved_namespace(message).is_ok(),
897                "expected {message:?} to be allowed"
898            );
899        }
900    }
901
902    #[test]
903    fn parse_compressed_public_key_rejects_invalid() {
904        for input in ["", "not-a-public-key", UNCOMPRESSED, &COMPRESSED[..64]] {
905            assert!(
906                matches!(
907                    parse_compressed_public_key(input),
908                    Err(SdkError::InvalidInput(_))
909                ),
910                "expected {input} to be rejected"
911            );
912        }
913    }
914}