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 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 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 let amount_override = if is_fees_included {
117 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 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 let overpayment = fees_included_fee.saturating_sub(current_fee);
137
138 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, },
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 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 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 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 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 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 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 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 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
376impl BreezSdk {
378 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 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 let first_invoice = validate_lnurl_pay(
416 self.lnurl_client.as_ref(),
417 amount_sats.saturating_mul(1_000), &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 let first_fee = self
434 .spark_wallet
435 .fetch_lightning_send_fee_estimate(&first_data.pr, None)
436 .await?;
437
438 let actual_amount = amount_sats.saturating_sub(first_fee);
440
441 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 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 let actual_fee = self
467 .spark_wallet
468 .fetch_lightning_send_fee_estimate(&success_data.pr, None)
469 .await?;
470
471 if actual_fee > first_fee {
473 return Err(SdkError::Generic(
474 "Fee increased between queries. Please retry.".to_string(),
475 ));
476 }
477
478 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 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 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 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 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 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 len < limit as usize {
626 break;
627 }
628 }
629
630 Ok(())
631 }
632}