breez_sdk_spark/sdk/
lnurl.rs

1use breez_sdk_common::lnurl::{self, error::LnurlError, pay::validate_lnurl_pay};
2use lnurl_models::PaidInvoice;
3use platform_utils::tokio;
4use tracing::{Instrument, debug, error, info};
5
6use crate::{
7    FeePolicy, InputType, LnurlAuthRequestDetails, LnurlCallbackStatus, LnurlPayInfo,
8    LnurlPayRequest, LnurlPayResponse, LnurlWithdrawInfo, LnurlWithdrawRequest,
9    LnurlWithdrawResponse, PaymentDetails, PaymentStatus, PaymentType, PrepareLnurlPayRequest,
10    PrepareLnurlPayResponse, SendPaymentMethod, SetLnurlMetadataItem, WaitForPaymentIdentifier,
11    error::SdkError,
12    events::SdkEvent,
13    models::{
14        PrepareSendPaymentResponse, ReceivePaymentMethod, ReceivePaymentRequest, SendPaymentRequest,
15    },
16    persist::{
17        ObjectCacheRepository, PaymentMetadata, StorageListPaymentsRequest,
18        StoragePaymentDetailsFilter,
19    },
20};
21use breez_sdk_common::lnurl::withdraw::execute_lnurl_withdraw;
22
23use super::{BreezSdk, helpers::process_success_action};
24
25#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
26#[allow(clippy::needless_pass_by_value)]
27impl BreezSdk {
28    pub async fn prepare_lnurl_pay(
29        &self,
30        request: PrepareLnurlPayRequest,
31    ) -> Result<PrepareLnurlPayResponse, SdkError> {
32        let fee_policy = request.fee_policy.unwrap_or_default();
33        let amount_sats = request.amount_sats;
34
35        if fee_policy == FeePolicy::FeesIncluded && request.conversion_options.is_some() {
36            return Err(SdkError::InvalidInput(
37                "FeesIncluded cannot be combined with token conversion".to_string(),
38            ));
39        }
40
41        // FeesIncluded uses the double-query approach
42        if fee_policy == FeePolicy::FeesIncluded {
43            return self
44                .prepare_lnurl_pay_fees_included(request, amount_sats)
45                .await;
46        }
47
48        let success_data = match validate_lnurl_pay(
49            self.lnurl_client.as_ref(),
50            amount_sats.saturating_mul(1_000),
51            &request.comment,
52            &request.pay_request.clone().into(),
53            self.config.network.into(),
54            request.validate_success_action_url,
55        )
56        .await?
57        {
58            lnurl::pay::ValidatedCallbackResponse::EndpointError { data } => {
59                return Err(LnurlError::EndpointError(data.reason).into());
60            }
61            lnurl::pay::ValidatedCallbackResponse::EndpointSuccess { data } => data,
62        };
63
64        let prepare_response = self
65            .prepare_send_payment(crate::PrepareSendPaymentRequest {
66                payment_request: success_data.pr,
67                amount: Some(u128::from(amount_sats)),
68                token_identifier: None,
69                conversion_options: request.conversion_options.clone(),
70                fee_policy: None,
71            })
72            .await?;
73
74        let SendPaymentMethod::Bolt11Invoice {
75            invoice_details,
76            lightning_fee_sats,
77            ..
78        } = prepare_response.payment_method
79        else {
80            return Err(SdkError::Generic(
81                "Expected Bolt11Invoice payment method".to_string(),
82            ));
83        };
84
85        Ok(PrepareLnurlPayResponse {
86            amount_sats,
87            comment: request.comment,
88            pay_request: request.pay_request,
89            invoice_details,
90            fee_sats: lightning_fee_sats,
91            success_action: success_data.success_action.map(From::from),
92            conversion_estimate: prepare_response.conversion_estimate,
93            fee_policy,
94        })
95    }
96
97    #[allow(clippy::too_many_lines)]
98    pub async fn lnurl_pay(&self, request: LnurlPayRequest) -> Result<LnurlPayResponse, SdkError> {
99        self.ensure_spark_private_mode_initialized().await?;
100
101        let is_fees_included = request.prepare_response.fee_policy == FeePolicy::FeesIncluded;
102
103        // For FeesIncluded, extract amount from the invoice (set during prepare)
104        let receiver_amount_sats: u64 = if is_fees_included {
105            request
106                .prepare_response
107                .invoice_details
108                .amount_msat
109                .ok_or_else(|| SdkError::Generic("Missing invoice amount".to_string()))?
110                / 1000
111        } else {
112            request.prepare_response.amount_sats
113        };
114
115        // Calculate amount override for FeesIncluded operations
116        let amount_override = if is_fees_included {
117            // Re-estimate current fee for the invoice
118            let current_fee = self
119                .spark_wallet
120                .fetch_lightning_send_fee_estimate(
121                    &request.prepare_response.invoice_details.invoice.bolt11,
122                    None,
123                )
124                .await?;
125
126            // fees_included_fee = first_fee (from prepare), which is the total we need to pay in fees
127            let fees_included_fee = request.prepare_response.fee_sats;
128
129            if current_fee > fees_included_fee {
130                return Err(SdkError::Generic(
131                    "Fee increased since prepare. Please retry.".to_string(),
132                ));
133            }
134
135            // Overpay by the difference to respect prepared amount
136            let overpayment = fees_included_fee.saturating_sub(current_fee);
137
138            // Protect against excessive fee overpayment.
139            // Allow overpayment up to 100% of actual fee, with a minimum of 1 sat.
140            let max_allowed_overpayment = current_fee.max(1);
141            if overpayment > max_allowed_overpayment {
142                return Err(SdkError::Generic(format!(
143                    "Fee overpayment ({overpayment} sats) exceeds allowed maximum ({max_allowed_overpayment} sats)"
144                )));
145            }
146
147            if overpayment > 0 {
148                tracing::info!(
149                    overpayment_sats = overpayment,
150                    fees_included_fee_sats = fees_included_fee,
151                    current_fee_sats = current_fee,
152                    "FeesIncluded fee overpayment applied"
153                );
154            }
155            Some(receiver_amount_sats.saturating_add(overpayment))
156        } else {
157            None
158        };
159
160        let mut payment = Box::pin(self.maybe_convert_token_send_payment(
161            SendPaymentRequest {
162                prepare_response: PrepareSendPaymentResponse {
163                    payment_method: SendPaymentMethod::Bolt11Invoice {
164                        invoice_details: request.prepare_response.invoice_details,
165                        spark_transfer_fee_sats: None,
166                        lightning_fee_sats: request.prepare_response.fee_sats,
167                    },
168                    amount: u128::from(receiver_amount_sats),
169                    token_identifier: None,
170                    conversion_estimate: request.prepare_response.conversion_estimate,
171                    fee_policy: FeePolicy::FeesExcluded, // Always FeesExcluded for internal handling
172                },
173                options: None,
174                idempotency_key: request.idempotency_key,
175            },
176            true,
177            amount_override,
178        ))
179        .await?
180        .payment;
181
182        let success_action = process_success_action(
183            &payment,
184            request
185                .prepare_response
186                .success_action
187                .clone()
188                .map(Into::into)
189                .as_ref(),
190        )?;
191
192        let lnurl_info = LnurlPayInfo {
193            ln_address: request.prepare_response.pay_request.address,
194            comment: request.prepare_response.comment,
195            domain: Some(request.prepare_response.pay_request.domain),
196            metadata: Some(request.prepare_response.pay_request.metadata_str),
197            processed_success_action: success_action.clone().map(From::from),
198            raw_success_action: request.prepare_response.success_action,
199        };
200        let lnurl_description = lnurl_info.extract_description();
201
202        match &mut payment.details {
203            Some(crate::PaymentDetails::Lightning {
204                lnurl_pay_info,
205                description,
206                ..
207            }) => {
208                *lnurl_pay_info = Some(lnurl_info.clone());
209                description.clone_from(&lnurl_description);
210            }
211            // When the LNURL server includes a Spark routing hint, the payment
212            // is routed via Spark transfer. The Spark variant doesn't carry
213            // lnurl fields, so we just persist the metadata separately below.
214            Some(crate::PaymentDetails::Spark { .. }) => {}
215            _ => {
216                return Err(SdkError::Generic(
217                    "Expected Lightning or Spark payment details".to_string(),
218                ));
219            }
220        }
221
222        self.storage
223            .insert_payment_metadata(
224                payment.id.clone(),
225                PaymentMetadata {
226                    lnurl_pay_info: Some(lnurl_info),
227                    lnurl_description,
228                    ..Default::default()
229                },
230            )
231            .await?;
232
233        // Emit the payment with metadata already included
234        self.event_emitter
235            .emit(&SdkEvent::from_payment(payment.clone()))
236            .await;
237        Ok(LnurlPayResponse {
238            payment,
239            success_action: success_action.map(From::from),
240        })
241    }
242
243    /// Performs an LNURL withdraw operation for the amount of satoshis to
244    /// withdraw and the LNURL withdraw request details. The LNURL withdraw request
245    /// details can be obtained from calling [`BreezSdk::parse`].
246    ///
247    /// The method generates a Lightning invoice for the withdraw amount, stores
248    /// the LNURL withdraw metadata, and performs the LNURL withdraw using  the generated
249    /// invoice.
250    ///
251    /// If the `completion_timeout_secs` parameter is provided and greater than 0, the
252    /// method will wait for the payment to be completed within that period. If the
253    /// withdraw is completed within the timeout, the `payment` field in the response
254    /// will be set with the payment details. If the `completion_timeout_secs`
255    /// parameter is not provided or set to 0, the method will not wait for the payment
256    /// to be completed. If the withdraw is not completed within the
257    /// timeout, the `payment` field will be empty.
258    ///
259    /// # Arguments
260    ///
261    /// * `request` - The LNURL withdraw request
262    ///
263    /// # Returns
264    ///
265    /// Result containing either:
266    /// * `LnurlWithdrawResponse` - The payment details if the withdraw request was successful
267    /// * `SdkError` - If there was an error during the withdraw process
268    pub async fn lnurl_withdraw(
269        &self,
270        request: LnurlWithdrawRequest,
271    ) -> Result<LnurlWithdrawResponse, SdkError> {
272        self.ensure_spark_private_mode_initialized().await?;
273        let LnurlWithdrawRequest {
274            amount_sats,
275            withdraw_request,
276            completion_timeout_secs,
277        } = request;
278        let withdraw_request: breez_sdk_common::lnurl::withdraw::LnurlWithdrawRequestDetails =
279            withdraw_request.into();
280        if !withdraw_request.is_amount_valid(amount_sats) {
281            return Err(SdkError::InvalidInput(
282                "Amount must be within min/max LNURL withdrawable limits".to_string(),
283            ));
284        }
285
286        // Generate a Lightning invoice for the withdraw
287        let payment_request = self
288            .receive_payment(ReceivePaymentRequest {
289                payment_method: ReceivePaymentMethod::Bolt11Invoice {
290                    description: withdraw_request.default_description.clone(),
291                    amount_sats: Some(amount_sats),
292                    expiry_secs: None,
293                    payment_hash: None,
294                },
295            })
296            .await?
297            .payment_request;
298
299        // Store the LNURL withdraw metadata before executing the withdraw
300        let cache = ObjectCacheRepository::new(self.storage.clone());
301        cache
302            .save_payment_metadata(
303                &payment_request,
304                &PaymentMetadata {
305                    lnurl_withdraw_info: Some(LnurlWithdrawInfo {
306                        withdraw_url: withdraw_request.callback.clone(),
307                    }),
308                    lnurl_description: Some(withdraw_request.default_description.clone()),
309                    ..Default::default()
310                },
311            )
312            .await?;
313
314        // Perform the LNURL withdraw using the generated invoice
315        let withdraw_response = execute_lnurl_withdraw(
316            self.lnurl_client.as_ref(),
317            &withdraw_request,
318            &payment_request,
319        )
320        .await?;
321        if let lnurl::withdraw::ValidatedCallbackResponse::EndpointError { data } =
322            withdraw_response
323        {
324            return Err(LnurlError::EndpointError(data.reason).into());
325        }
326
327        let completion_timeout_secs = match completion_timeout_secs {
328            Some(secs) if secs > 0 => secs,
329            _ => {
330                return Ok(LnurlWithdrawResponse {
331                    payment_request,
332                    payment: None,
333                });
334            }
335        };
336
337        // Wait for the payment to be completed
338        let payment = self
339            .wait_for_payment(
340                WaitForPaymentIdentifier::PaymentRequest(payment_request.clone()),
341                completion_timeout_secs,
342            )
343            .await
344            .ok();
345        Ok(LnurlWithdrawResponse {
346            payment_request,
347            payment,
348        })
349    }
350
351    /// Performs LNURL-auth with the service.
352    ///
353    /// This method implements the LNURL-auth protocol as specified in LUD-04 and LUD-05.
354    /// It derives a domain-specific linking key, signs the challenge, and sends the
355    /// authentication request to the service.
356    pub async fn lnurl_auth(
357        &self,
358        request_data: LnurlAuthRequestDetails,
359    ) -> Result<LnurlCallbackStatus, SdkError> {
360        let request: breez_sdk_common::lnurl::auth::LnurlAuthRequestDetails = request_data.into();
361        let status = breez_sdk_common::lnurl::auth::perform_lnurl_auth(
362            self.lnurl_client.as_ref(),
363            &request,
364            self.lnurl_auth_signer.as_ref(),
365        )
366        .await
367        .map_err(|e| match e {
368            LnurlError::ServiceConnectivity(msg) => SdkError::NetworkError(msg.to_string()),
369            LnurlError::InvalidUri(msg) => SdkError::InvalidInput(msg),
370            _ => SdkError::Generic(e.to_string()),
371        })?;
372        Ok(status.into())
373    }
374}
375
376// Private LNURL methods
377impl BreezSdk {
378    /// Prepares an LNURL pay `FeesIncluded` operation using a double-query approach.
379    ///
380    /// This method:
381    /// 1. Validates amount doesn't exceed LNURL `max_sendable`
382    /// 2. First query: gets invoice for full amount to estimate fees
383    /// 3. Calculates actual send amount (amount - estimated fee)
384    /// 4. Second query: gets invoice for actual amount
385    /// 5. Returns the prepare response with the second invoice
386    pub(super) async fn prepare_lnurl_pay_fees_included(
387        &self,
388        request: PrepareLnurlPayRequest,
389        amount_sats: u64,
390    ) -> Result<PrepareLnurlPayResponse, SdkError> {
391        if amount_sats == 0 {
392            return Err(SdkError::InvalidInput(
393                "Amount must be greater than 0".to_string(),
394            ));
395        }
396
397        // 1. Validate amount is within LNURL limits
398        let min_sendable_sats = request.pay_request.min_sendable.div_ceil(1000);
399        let max_sendable_sats = request.pay_request.max_sendable / 1000;
400
401        if amount_sats < min_sendable_sats {
402            return Err(SdkError::InvalidInput(format!(
403                "Amount ({amount_sats} sats) is below LNURL minimum ({min_sendable_sats} sats)"
404            )));
405        }
406
407        if amount_sats > max_sendable_sats {
408            return Err(SdkError::InvalidInput(format!(
409                "Amount ({amount_sats} sats) exceeds LNURL maximum ({max_sendable_sats} sats)"
410            )));
411        }
412
413        // 2. First query: get invoice for full amount to estimate fees
414        // Note: We don't intend to pay this invoice. It's only for fee estimation.
415        let first_invoice = validate_lnurl_pay(
416            self.lnurl_client.as_ref(),
417            amount_sats.saturating_mul(1_000), // convert to msats
418            &request.comment,
419            &request.pay_request.clone().into(),
420            self.config.network.into(),
421            request.validate_success_action_url,
422        )
423        .await?;
424
425        let first_data = match first_invoice {
426            lnurl::pay::ValidatedCallbackResponse::EndpointError { data } => {
427                return Err(LnurlError::EndpointError(data.reason).into());
428            }
429            lnurl::pay::ValidatedCallbackResponse::EndpointSuccess { data } => data,
430        };
431
432        // 3. Get fee estimate for first invoice
433        let first_fee = self
434            .spark_wallet
435            .fetch_lightning_send_fee_estimate(&first_data.pr, None)
436            .await?;
437
438        // 4. Calculate actual send amount (amount - fee)
439        let actual_amount = amount_sats.saturating_sub(first_fee);
440
441        // Validate against LNURL minimum
442        if actual_amount < min_sendable_sats {
443            return Err(SdkError::InvalidInput(format!(
444                "Amount after fees ({actual_amount} sats) is below LNURL minimum ({min_sendable_sats} sats)"
445            )));
446        }
447
448        // 5. Second query: get invoice for actual amount (back-to-back, no delay)
449        let success_data = match validate_lnurl_pay(
450            self.lnurl_client.as_ref(),
451            actual_amount.saturating_mul(1_000),
452            &request.comment,
453            &request.pay_request.clone().into(),
454            self.config.network.into(),
455            request.validate_success_action_url,
456        )
457        .await?
458        {
459            lnurl::pay::ValidatedCallbackResponse::EndpointError { data } => {
460                return Err(LnurlError::EndpointError(data.reason).into());
461            }
462            lnurl::pay::ValidatedCallbackResponse::EndpointSuccess { data } => data,
463        };
464
465        // 6. Get actual fee for the smaller invoice
466        let actual_fee = self
467            .spark_wallet
468            .fetch_lightning_send_fee_estimate(&success_data.pr, None)
469            .await?;
470
471        // If fee increased between queries, fail (user must retry)
472        if actual_fee > first_fee {
473            return Err(SdkError::Generic(
474                "Fee increased between queries. Please retry.".to_string(),
475            ));
476        }
477
478        // Parse the invoice to get details
479        let parsed = self.parse(&success_data.pr).await?;
480        let InputType::Bolt11Invoice(invoice_details) = parsed else {
481            return Err(SdkError::Generic(
482                "Expected Bolt11 invoice from LNURL".to_string(),
483            ));
484        };
485
486        info!(
487            "LNURL FeesIncluded prepared: amount={amount_sats}, receiver_amount={actual_amount}, fee={first_fee}"
488        );
489
490        Ok(PrepareLnurlPayResponse {
491            amount_sats,
492            comment: request.comment,
493            pay_request: request.pay_request,
494            invoice_details,
495            fee_sats: first_fee,
496            success_action: success_data.success_action.map(From::from),
497            conversion_estimate: None,
498            fee_policy: FeePolicy::FeesIncluded,
499        })
500    }
501
502    /// Background task that publishes lnurl preimages for received lnurl payments for nostr zaps
503    /// and LNURL verify. Triggered on startup and after syncing lnurl metadata.
504    pub(super) fn spawn_lnurl_preimage_publisher(&self) {
505        if !self.config.support_lnurl_verify {
506            debug!("LNURL verify support is disabled. Not enabling LNURL payment status.");
507            return;
508        }
509
510        let sdk = self.clone();
511        let mut shutdown_receiver = sdk.shutdown_sender.subscribe();
512        let mut trigger_receiver = sdk.lnurl_preimage_trigger.clone().subscribe();
513        let span = tracing::Span::current();
514
515        tokio::spawn(
516            async move {
517                if let Err(e) = Self::process_pending_lnurl_preimages(&sdk).await {
518                    error!("Failed to process pending LNURL preimages on startup: {e:?}");
519                }
520
521                loop {
522                    tokio::select! {
523                        _ = shutdown_receiver.changed() => {
524                            info!("LNURL preimage publisher shutdown signal received");
525                            return;
526                        }
527                        _ = trigger_receiver.recv() => {
528                            if let Err(e) = Self::process_pending_lnurl_preimages(&sdk).await {
529                                error!("Failed to process pending LNURL preimages: {e:?}");
530                            }
531                        }
532                    }
533                }
534            }
535            .instrument(span),
536        );
537    }
538
539    async fn process_pending_lnurl_preimages(&self) -> Result<(), SdkError> {
540        let Some(lnurl_server_client) = self.lnurl_server_client.clone() else {
541            return Ok(());
542        };
543
544        let limit = 100;
545        loop {
546            // Query only payments that need their preimage sent to the server
547            let pending = self
548                .storage
549                .list_payments(StorageListPaymentsRequest {
550                    type_filter: Some(vec![PaymentType::Receive]),
551                    status_filter: Some(vec![PaymentStatus::Completed]),
552                    payment_details_filter: Some(vec![StoragePaymentDetailsFilter::Lightning {
553                        htlc_status: None,
554                        has_lnurl_preimage: Some(false),
555                    }]),
556                    limit: Some(limit),
557                    ..Default::default()
558                })
559                .await?;
560
561            debug!("Got {} pending lnurl preimages", pending.len());
562            if pending.is_empty() {
563                break;
564            }
565
566            let len = pending.len();
567
568            // Collect preimages, invoices, and metadata for batch notification
569            let mut batch_items = Vec::new();
570            let mut batch_metadata = Vec::new();
571
572            for payment in &pending {
573                let Some(PaymentDetails::Lightning {
574                    htlc_details,
575                    invoice,
576                    lnurl_receive_metadata: Some(metadata),
577                    ..
578                }) = &payment.details
579                else {
580                    continue;
581                };
582
583                let Some(preimage) = &htlc_details.preimage else {
584                    continue;
585                };
586
587                batch_items.push(PaidInvoice {
588                    preimage: preimage.clone(),
589                    invoice: invoice.clone(),
590                });
591                batch_metadata.push((htlc_details.clone(), metadata.clone()));
592            }
593
594            if !batch_items.is_empty() {
595                // Notify the LNURL server about all paid invoices in one request
596                if let Err(e) = lnurl_server_client.notify_invoices_paid(&batch_items).await {
597                    error!("Failed to notify invoices paid: {}", e);
598                    break;
599                }
600
601                debug!(
602                    "Notified LNURL server about {} paid invoices",
603                    batch_items.len()
604                );
605
606                // Update the LNURL metadata to mark all preimages as sent
607                let metadata_updates: Vec<SetLnurlMetadataItem> = batch_items
608                    .iter()
609                    .zip(&batch_metadata)
610                    .map(|(item, (htlc_details, metadata))| SetLnurlMetadataItem {
611                        payment_hash: htlc_details.payment_hash.clone(),
612                        sender_comment: metadata.sender_comment.clone(),
613                        nostr_zap_request: metadata.nostr_zap_request.clone(),
614                        nostr_zap_receipt: metadata.nostr_zap_receipt.clone(),
615                        preimage: Some(item.preimage.clone()),
616                    })
617                    .collect();
618
619                if let Err(e) = self.storage.set_lnurl_metadata(metadata_updates).await {
620                    error!("Failed to update LNURL metadata: {}", e);
621                }
622            }
623
624            // If we got fewer than the limit, we're done
625            if len < limit as usize {
626                break;
627            }
628        }
629
630        Ok(())
631    }
632}