breez_sdk_spark/sdk/
lnurl.rs

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