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 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 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 let amount_override = if is_fees_included {
116 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 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 let overpayment = fees_included_fee.saturating_sub(current_fee);
136
137 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, },
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 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 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 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 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 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 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
367impl BreezSdk {
369 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 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 let first_invoice = validate_lnurl_pay(
407 self.lnurl_client.as_ref(),
408 amount_sats.saturating_mul(1_000), &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 let first_fee = self
425 .spark_wallet
426 .fetch_lightning_send_fee_estimate(&first_data.pr, None)
427 .await?;
428
429 let actual_amount = amount_sats.saturating_sub(first_fee);
431
432 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 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 let actual_fee = self
458 .spark_wallet
459 .fetch_lightning_send_fee_estimate(&success_data.pr, None)
460 .await?;
461
462 if actual_fee > first_fee {
464 return Err(SdkError::Generic(
465 "Fee increased between queries. Please retry.".to_string(),
466 ));
467 }
468
469 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 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 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 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 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 len < limit as usize {
608 break;
609 }
610 }
611
612 Ok(())
613 }
614}