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 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 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 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 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 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 let amount_override = if is_fees_included {
142 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 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 let overpayment = fees_included_fee.saturating_sub(current_fee);
162
163 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 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 amount: if has_conversion {
208 u128::from(request.prepare_response.amount_sats)
209 } else {
210 u128::from(receiver_amount_sats)
211 },
212 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 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 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 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 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 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 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 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 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 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
429impl BreezSdk {
431 #[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 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 let first_invoice = validate_lnurl_pay(
471 self.lnurl_client.as_ref(),
472 amount_sats.saturating_mul(1_000), &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 let first_fee = self
489 .spark_wallet
490 .fetch_lightning_send_fee_estimate(&first_data.pr, None)
491 .await?;
492
493 let actual_amount = amount_sats.saturating_sub(first_fee);
495
496 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 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 let actual_fee = self
522 .spark_wallet
523 .fetch_lightning_send_fee_estimate(&success_data.pr, None)
524 .await?;
525
526 if actual_fee > first_fee {
528 return Err(SdkError::Generic(
529 "Fee increased between queries. Please retry.".to_string(),
530 ));
531 }
532
533 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}