breez_sdk_spark/sdk/
lnurl.rs

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