1use bitcoin::hashes::sha256;
2use spark_wallet::{ExitSpeed, SparkAddress, TransferId, TransferTokenOutput};
3use std::str::FromStr;
4use tokio::select;
5use tokio::sync::mpsc;
6use tokio::time::timeout;
7use tracing::{Instrument, error, info, warn};
8use web_time::Duration;
9
10use crate::{
11 BitcoinAddressDetails, Bolt11InvoiceDetails, ClaimHtlcPaymentRequest, ClaimHtlcPaymentResponse,
12 ConversionEstimate, ConversionOptions, ConversionPurpose, ConversionType, FeePolicy,
13 FetchConversionLimitsRequest, FetchConversionLimitsResponse, GetPaymentRequest,
14 GetPaymentResponse, InputType, OnchainConfirmationSpeed, PaymentStatus, SendOnchainFeeQuote,
15 SendPaymentMethod, SendPaymentOptions, SparkHtlcOptions, SparkInvoiceDetails,
16 WaitForPaymentIdentifier,
17 error::SdkError,
18 events::SdkEvent,
19 models::{
20 ListPaymentsRequest, ListPaymentsResponse, Payment, PaymentDetails,
21 PrepareSendPaymentRequest, PrepareSendPaymentResponse, ReceivePaymentMethod,
22 ReceivePaymentRequest, ReceivePaymentResponse, SendPaymentRequest, SendPaymentResponse,
23 },
24 persist::PaymentMetadata,
25 token_conversion::{
26 ConversionAmount, DEFAULT_CONVERSION_TIMEOUT_SECS, TokenConversionResponse,
27 },
28 utils::{
29 send_payment_validation::validate_prepare_send_payment_request,
30 token::map_and_persist_token_transaction,
31 },
32};
33use bitcoin::secp256k1::PublicKey;
34use spark_wallet::{InvoiceDescription, Preimage};
35use tokio_with_wasm::alias as tokio;
36use web_time::SystemTime;
37
38use super::{
39 BreezSdk, SyncType,
40 helpers::{InternalEventListener, get_or_create_deposit_address, is_payment_match},
41};
42
43#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
44#[allow(clippy::needless_pass_by_value)]
45impl BreezSdk {
46 pub async fn receive_payment(
47 &self,
48 request: ReceivePaymentRequest,
49 ) -> Result<ReceivePaymentResponse, SdkError> {
50 self.ensure_spark_private_mode_initialized().await?;
51 match request.payment_method {
52 ReceivePaymentMethod::SparkAddress => Ok(ReceivePaymentResponse {
53 fee: 0,
54 payment_request: self
55 .spark_wallet
56 .get_spark_address()?
57 .to_address_string()
58 .map_err(|e| {
59 SdkError::Generic(format!("Failed to convert Spark address to string: {e}"))
60 })?,
61 }),
62 ReceivePaymentMethod::SparkInvoice {
63 amount,
64 token_identifier,
65 expiry_time,
66 description,
67 sender_public_key,
68 } => {
69 let invoice = self
70 .spark_wallet
71 .create_spark_invoice(
72 amount,
73 token_identifier.clone(),
74 expiry_time
75 .map(|time| {
76 SystemTime::UNIX_EPOCH
77 .checked_add(Duration::from_secs(time))
78 .ok_or(SdkError::Generic("Invalid expiry time".to_string()))
79 })
80 .transpose()?,
81 description,
82 sender_public_key.map(|key| PublicKey::from_str(&key).unwrap()),
83 )
84 .await?;
85 Ok(ReceivePaymentResponse {
86 fee: 0,
87 payment_request: invoice,
88 })
89 }
90 ReceivePaymentMethod::BitcoinAddress => {
91 let address =
92 get_or_create_deposit_address(&self.spark_wallet, self.storage.clone(), true)
93 .await?;
94 Ok(ReceivePaymentResponse {
95 payment_request: address,
96 fee: 0,
97 })
98 }
99 ReceivePaymentMethod::Bolt11Invoice {
100 description,
101 amount_sats,
102 expiry_secs,
103 payment_hash,
104 } => {
105 self.receive_bolt11_invoice(description, amount_sats, expiry_secs, payment_hash)
106 .await
107 }
108 }
109 }
110
111 pub async fn claim_htlc_payment(
112 &self,
113 request: ClaimHtlcPaymentRequest,
114 ) -> Result<ClaimHtlcPaymentResponse, SdkError> {
115 let preimage = Preimage::from_hex(&request.preimage)
116 .map_err(|_| SdkError::InvalidInput("Invalid preimage".to_string()))?;
117 let payment_hash = preimage.compute_hash();
118
119 let claimable_htlc_transfers = self
121 .spark_wallet
122 .list_claimable_htlc_transfers(None)
123 .await?;
124 if !claimable_htlc_transfers
125 .iter()
126 .filter_map(|t| t.htlc_preimage_request.as_ref())
127 .any(|p| p.payment_hash == payment_hash)
128 {
129 return Err(SdkError::InvalidInput(
130 "No claimable HTLC with the given payment hash".to_string(),
131 ));
132 }
133
134 let transfer = self.spark_wallet.claim_htlc(&preimage).await?;
135 let payment: Payment = transfer.try_into()?;
136
137 self.storage.insert_payment(payment.clone()).await?;
139
140 Ok(ClaimHtlcPaymentResponse { payment })
141 }
142
143 #[allow(clippy::too_many_lines)]
144 pub async fn prepare_send_payment(
145 &self,
146 request: PrepareSendPaymentRequest,
147 ) -> Result<PrepareSendPaymentResponse, SdkError> {
148 let parsed_input = self.parse(&request.payment_request).await?;
149
150 validate_prepare_send_payment_request(
151 &parsed_input,
152 &request,
153 &self.spark_wallet.get_identity_public_key().to_string(),
154 )?;
155
156 let fee_policy = request.fee_policy.unwrap_or_default();
157 let token_identifier = request.token_identifier.clone();
158
159 match &parsed_input {
160 InputType::SparkAddress(spark_address_details) => {
161 let amount = request
162 .amount
163 .ok_or(SdkError::InvalidInput("Amount is required".to_string()))?;
164
165 let conversion_estimate = if fee_policy == FeePolicy::FeesIncluded {
167 None
168 } else {
169 let conversion_options = self
170 .get_conversion_options_for_payment(
171 request.conversion_options.as_ref(),
172 token_identifier.as_ref(),
173 amount,
174 )
175 .await?;
176 self.token_converter
177 .validate(
178 conversion_options.as_ref(),
179 token_identifier.as_ref(),
180 amount,
181 )
182 .await?
183 };
184
185 Ok(PrepareSendPaymentResponse {
186 payment_method: SendPaymentMethod::SparkAddress {
187 address: spark_address_details.address.clone(),
188 fee: 0,
189 token_identifier: token_identifier.clone(),
190 },
191 amount,
192 token_identifier,
193 conversion_estimate,
194 fee_policy,
195 })
196 }
197 InputType::SparkInvoice(spark_invoice_details) => {
198 let effective_token_identifier =
200 token_identifier.or_else(|| spark_invoice_details.token_identifier.clone());
201
202 let amount = spark_invoice_details
203 .amount
204 .or(request.amount)
205 .ok_or(SdkError::InvalidInput("Amount is required".to_string()))?;
206
207 let conversion_estimate = if fee_policy == FeePolicy::FeesIncluded {
209 None
210 } else {
211 let conversion_options = self
212 .get_conversion_options_for_payment(
213 request.conversion_options.as_ref(),
214 effective_token_identifier.as_ref(),
215 amount,
216 )
217 .await?;
218 self.token_converter
219 .validate(
220 conversion_options.as_ref(),
221 effective_token_identifier.as_ref(),
222 amount,
223 )
224 .await?
225 };
226
227 Ok(PrepareSendPaymentResponse {
228 payment_method: SendPaymentMethod::SparkInvoice {
229 spark_invoice_details: spark_invoice_details.clone(),
230 fee: 0,
231 token_identifier: effective_token_identifier.clone(),
232 },
233 amount,
234 token_identifier: effective_token_identifier,
235 conversion_estimate,
236 fee_policy,
237 })
238 }
239 InputType::Bolt11Invoice(detailed_bolt11_invoice) => {
240 let spark_address: Option<SparkAddress> = self
241 .spark_wallet
242 .extract_spark_address(&request.payment_request)?;
243
244 let spark_transfer_fee_sats = if spark_address.is_some() {
245 Some(0)
246 } else {
247 None
248 };
249
250 let amount = request
251 .amount
252 .or(detailed_bolt11_invoice
253 .amount_msat
254 .map(|msat| u128::from(msat).saturating_div(1000)))
255 .ok_or(SdkError::InvalidInput("Amount is required".to_string()))?;
256
257 let lightning_fee_sats = self
259 .spark_wallet
260 .fetch_lightning_send_fee_estimate(
261 &request.payment_request,
262 Some(amount.try_into()?),
263 )
264 .await?;
265
266 if fee_policy == FeePolicy::FeesIncluded
268 && detailed_bolt11_invoice.amount_msat.is_none()
269 {
270 let amount_u64: u64 = amount.try_into()?;
271 if amount_u64 <= lightning_fee_sats {
272 return Err(SdkError::InvalidInput(
273 "Amount too small to cover fees".to_string(),
274 ));
275 }
276 }
277
278 let conversion_estimate = if fee_policy == FeePolicy::FeesIncluded {
280 None
281 } else {
282 let total_amount = amount.saturating_add(u128::from(lightning_fee_sats));
283 let conversion_options = self
284 .get_conversion_options_for_payment(
285 request.conversion_options.as_ref(),
286 token_identifier.as_ref(),
287 total_amount,
288 )
289 .await?;
290 self.token_converter
291 .validate(
292 conversion_options.as_ref(),
293 token_identifier.as_ref(),
294 total_amount,
295 )
296 .await?
297 };
298
299 Ok(PrepareSendPaymentResponse {
300 payment_method: SendPaymentMethod::Bolt11Invoice {
301 invoice_details: detailed_bolt11_invoice.clone(),
302 spark_transfer_fee_sats,
303 lightning_fee_sats,
304 },
305 amount,
306 token_identifier,
307 conversion_estimate,
308 fee_policy,
309 })
310 }
311 InputType::BitcoinAddress(withdrawal_address) => {
312 let amount = request
313 .amount
314 .ok_or(SdkError::InvalidInput("Amount is required".to_string()))?;
315
316 let fee_quote: SendOnchainFeeQuote = self
317 .spark_wallet
318 .fetch_coop_exit_fee_quote(
319 &withdrawal_address.address,
320 Some(amount.try_into()?),
321 )
322 .await?
323 .into();
324
325 let conversion_estimate = if fee_policy == FeePolicy::FeesIncluded {
327 None
328 } else {
329 let total_amount =
331 amount.saturating_add(u128::from(fee_quote.speed_fast.total_fee_sat()));
332 let conversion_options = self
333 .get_conversion_options_for_payment(
334 request.conversion_options.as_ref(),
335 token_identifier.as_ref(),
336 total_amount,
337 )
338 .await?;
339 self.token_converter
340 .validate(
341 conversion_options.as_ref(),
342 token_identifier.as_ref(),
343 total_amount,
344 )
345 .await?
346 };
347
348 Ok(PrepareSendPaymentResponse {
349 payment_method: SendPaymentMethod::BitcoinAddress {
350 address: withdrawal_address.clone(),
351 fee_quote,
352 },
353 amount,
354 token_identifier,
355 conversion_estimate,
356 fee_policy,
357 })
358 }
359 _ => Err(SdkError::InvalidInput(
360 "Unsupported payment method".to_string(),
361 )),
362 }
363 }
364
365 pub async fn send_payment(
366 &self,
367 request: SendPaymentRequest,
368 ) -> Result<SendPaymentResponse, SdkError> {
369 self.ensure_spark_private_mode_initialized().await?;
370 Box::pin(self.maybe_convert_token_send_payment(request, false, None)).await
371 }
372
373 pub async fn fetch_conversion_limits(
374 &self,
375 request: FetchConversionLimitsRequest,
376 ) -> Result<FetchConversionLimitsResponse, SdkError> {
377 self.token_converter
378 .fetch_limits(&request)
379 .await
380 .map_err(Into::into)
381 }
382
383 pub async fn list_payments(
397 &self,
398 request: ListPaymentsRequest,
399 ) -> Result<ListPaymentsResponse, SdkError> {
400 let mut payments = self.storage.list_payments(request.into()).await?;
401
402 let parent_ids: Vec<String> = payments.iter().map(|p| p.id.clone()).collect();
404
405 if !parent_ids.is_empty() {
406 let related_payments_map = self.storage.get_payments_by_parent_ids(parent_ids).await?;
407
408 for payment in &mut payments {
410 if let Some(related_payments) = related_payments_map.get(&payment.id) {
411 match related_payments.try_into() {
412 Ok(conversion_details) => {
413 payment.conversion_details = Some(conversion_details);
414 }
415 Err(e) => {
416 warn!("Found payments couldn't be converted to ConversionDetails: {e}");
417 }
418 }
419 }
420 }
421 }
422
423 Ok(ListPaymentsResponse { payments })
424 }
425
426 pub async fn get_payment(
427 &self,
428 request: GetPaymentRequest,
429 ) -> Result<GetPaymentResponse, SdkError> {
430 let mut payment = self.storage.get_payment_by_id(request.payment_id).await?;
431
432 let related_payments_map = self
434 .storage
435 .get_payments_by_parent_ids(vec![payment.id.clone()])
436 .await?;
437
438 if let Some(related_payments) = related_payments_map.get(&payment.id) {
439 match related_payments.try_into() {
440 Ok(conversion_details) => payment.conversion_details = Some(conversion_details),
441 Err(e) => {
442 warn!("Related payments not convertable to ConversionDetails: {e}");
443 }
444 }
445 }
446
447 Ok(GetPaymentResponse { payment })
448 }
449}
450
451impl BreezSdk {
453 async fn receive_bolt11_invoice(
454 &self,
455 description: String,
456 amount_sats: Option<u64>,
457 expiry_secs: Option<u32>,
458 payment_hash: Option<String>,
459 ) -> Result<ReceivePaymentResponse, SdkError> {
460 let invoice = if let Some(payment_hash_hex) = payment_hash {
461 let hash = sha256::Hash::from_str(&payment_hash_hex)
462 .map_err(|e| SdkError::InvalidInput(format!("Invalid payment hash: {e}")))?;
463 self.spark_wallet
464 .create_hodl_lightning_invoice(
465 amount_sats.unwrap_or_default(),
466 Some(InvoiceDescription::Memo(description.clone())),
467 hash,
468 None,
469 expiry_secs,
470 )
471 .await?
472 .invoice
473 } else {
474 self.spark_wallet
475 .create_lightning_invoice(
476 amount_sats.unwrap_or_default(),
477 Some(InvoiceDescription::Memo(description.clone())),
478 None,
479 expiry_secs,
480 self.config.prefer_spark_over_lightning,
481 )
482 .await?
483 .invoice
484 };
485 Ok(ReceivePaymentResponse {
486 payment_request: invoice,
487 fee: 0,
488 })
489 }
490
491 pub(super) async fn maybe_convert_token_send_payment(
492 &self,
493 request: SendPaymentRequest,
494 mut suppress_payment_event: bool,
495 amount_override: Option<u64>,
496 ) -> Result<SendPaymentResponse, SdkError> {
497 let token_identifier = request.prepare_response.token_identifier.clone();
498
499 if request.idempotency_key.is_some() && token_identifier.is_some() {
501 return Err(SdkError::InvalidInput(
502 "Idempotency key is not supported for token payments".to_string(),
503 ));
504 }
505 if let Some(idempotency_key) = &request.idempotency_key {
506 if let Ok(payment) = self
508 .storage
509 .get_payment_by_id(idempotency_key.clone())
510 .await
511 {
512 return Ok(SendPaymentResponse { payment });
513 }
514 }
515 let res = if let Some(ConversionEstimate {
517 options: conversion_options,
518 ..
519 }) = &request.prepare_response.conversion_estimate
520 {
521 Box::pin(self.convert_token_send_payment_internal(
522 conversion_options,
523 &request,
524 &mut suppress_payment_event,
525 ))
526 .await
527 } else {
528 Box::pin(self.send_payment_internal(&request, amount_override)).await
529 };
530 if let Ok(response) = &res {
532 if !suppress_payment_event {
533 self.event_emitter
534 .emit(&SdkEvent::from_payment(response.payment.clone()))
535 .await;
536 }
537 self.sync_coordinator
538 .trigger_sync_no_wait(SyncType::WalletState, true)
539 .await;
540 }
541 res
542 }
543
544 #[allow(clippy::too_many_lines)]
545 async fn convert_token_send_payment_internal(
546 &self,
547 conversion_options: &ConversionOptions,
548 request: &SendPaymentRequest,
549 suppress_payment_event: &mut bool,
550 ) -> Result<SendPaymentResponse, SdkError> {
551 if request.prepare_response.fee_policy == FeePolicy::FeesIncluded {
553 return Err(SdkError::InvalidInput(
554 "FeesIncluded not supported with token conversion".to_string(),
555 ));
556 }
557
558 let _lock_guard = match (
560 &request.prepare_response.token_identifier,
561 &self.stable_balance,
562 ) {
563 (None, Some(sb)) => Some(sb.create_payment_lock_guard()),
564 _ => None,
565 };
566
567 let amount = request.prepare_response.amount;
568 let token_identifier = request.prepare_response.token_identifier.clone();
569
570 let (conversion_response, conversion_purpose) =
572 match &request.prepare_response.payment_method {
573 SendPaymentMethod::SparkAddress { address, .. } => {
574 let spark_address = address
575 .parse::<SparkAddress>()
576 .map_err(|_| SdkError::InvalidInput("Invalid spark address".to_string()))?;
577 let conversion_purpose = if spark_address.identity_public_key
578 == self.spark_wallet.get_identity_public_key()
579 {
580 ConversionPurpose::SelfTransfer
581 } else {
582 ConversionPurpose::OngoingPayment {
583 payment_request: address.clone(),
584 }
585 };
586 let conversion_response = self
587 .token_converter
588 .convert(
589 conversion_options,
590 &conversion_purpose,
591 token_identifier.as_ref(),
592 ConversionAmount::MinAmountOut(amount),
593 )
594 .await?;
595 (conversion_response, conversion_purpose)
596 }
597 SendPaymentMethod::SparkInvoice {
598 spark_invoice_details:
599 SparkInvoiceDetails {
600 identity_public_key,
601 invoice,
602 ..
603 },
604 ..
605 } => {
606 let own_identity_public_key =
607 self.spark_wallet.get_identity_public_key().to_string();
608 let conversion_purpose = if identity_public_key == &own_identity_public_key {
609 ConversionPurpose::SelfTransfer
610 } else {
611 ConversionPurpose::OngoingPayment {
612 payment_request: invoice.clone(),
613 }
614 };
615 let conversion_response = self
616 .token_converter
617 .convert(
618 conversion_options,
619 &conversion_purpose,
620 token_identifier.as_ref(),
621 ConversionAmount::MinAmountOut(amount),
622 )
623 .await?;
624 (conversion_response, conversion_purpose)
625 }
626 SendPaymentMethod::Bolt11Invoice {
627 spark_transfer_fee_sats,
628 lightning_fee_sats,
629 invoice_details,
630 ..
631 } => {
632 let conversion_purpose = ConversionPurpose::OngoingPayment {
633 payment_request: invoice_details.invoice.bolt11.clone(),
634 };
635 let conversion_response = self
636 .convert_token_for_bolt11_invoice(
637 conversion_options,
638 *spark_transfer_fee_sats,
639 *lightning_fee_sats,
640 request,
641 &conversion_purpose,
642 amount,
643 token_identifier.as_ref(),
644 )
645 .await?;
646 (conversion_response, conversion_purpose)
647 }
648 SendPaymentMethod::BitcoinAddress { address, fee_quote } => {
649 let conversion_purpose = ConversionPurpose::OngoingPayment {
650 payment_request: address.address.clone(),
651 };
652 let conversion_response = self
653 .convert_token_for_bitcoin_address(
654 conversion_options,
655 fee_quote,
656 request,
657 &conversion_purpose,
658 amount,
659 token_identifier.as_ref(),
660 )
661 .await?;
662 (conversion_response, conversion_purpose)
663 }
664 };
665 if matches!(
667 conversion_options.conversion_type,
668 ConversionType::FromBitcoin
669 ) {
670 self.sync_coordinator
671 .trigger_sync_no_wait(SyncType::WalletState, true)
672 .await;
673 }
674 let payment = self
676 .wait_for_payment(
677 WaitForPaymentIdentifier::PaymentId(
678 conversion_response.received_payment_id.clone(),
679 ),
680 conversion_options
681 .completion_timeout_secs
682 .unwrap_or(DEFAULT_CONVERSION_TIMEOUT_SECS),
683 )
684 .await
685 .map_err(|e| {
686 SdkError::Generic(format!("Timeout waiting for conversion to complete: {e}"))
687 })?;
688 if conversion_purpose == ConversionPurpose::SelfTransfer {
690 *suppress_payment_event = true;
691 return Ok(SendPaymentResponse { payment });
692 }
693 let response = Box::pin(self.send_payment_internal(request, None)).await?;
695 self.storage
697 .insert_payment_metadata(
698 conversion_response.sent_payment_id,
699 PaymentMetadata {
700 parent_payment_id: Some(response.payment.id.clone()),
701 ..Default::default()
702 },
703 )
704 .await?;
705 self.storage
706 .insert_payment_metadata(
707 conversion_response.received_payment_id,
708 PaymentMetadata {
709 parent_payment_id: Some(response.payment.id.clone()),
710 ..Default::default()
711 },
712 )
713 .await?;
714 self.get_payment(GetPaymentRequest {
716 payment_id: response.payment.id,
717 })
718 .await
719 .map(|res| SendPaymentResponse {
720 payment: res.payment,
721 })
722 }
724
725 pub(super) async fn send_payment_internal(
726 &self,
727 request: &SendPaymentRequest,
728 amount_override: Option<u64>,
729 ) -> Result<SendPaymentResponse, SdkError> {
730 let amount = request.prepare_response.amount;
731 let token_identifier = request.prepare_response.token_identifier.clone();
732
733 match &request.prepare_response.payment_method {
734 SendPaymentMethod::SparkAddress { address, .. } => {
735 self.send_spark_address(
736 address,
737 token_identifier,
738 amount,
739 request.options.as_ref(),
740 request.idempotency_key.clone(),
741 )
742 .await
743 }
744 SendPaymentMethod::SparkInvoice {
745 spark_invoice_details,
746 ..
747 } => {
748 self.send_spark_invoice(&spark_invoice_details.invoice, request, amount)
749 .await
750 }
751 SendPaymentMethod::Bolt11Invoice {
752 invoice_details,
753 spark_transfer_fee_sats,
754 lightning_fee_sats,
755 ..
756 } => {
757 Box::pin(self.send_bolt11_invoice(
758 invoice_details,
759 *spark_transfer_fee_sats,
760 *lightning_fee_sats,
761 request,
762 amount_override,
763 amount,
764 ))
765 .await
766 }
767 SendPaymentMethod::BitcoinAddress { address, fee_quote } => {
768 self.send_bitcoin_address(address, fee_quote, request).await
769 }
770 }
771 }
772
773 async fn send_spark_address(
774 &self,
775 address: &str,
776 token_identifier: Option<String>,
777 amount: u128,
778 options: Option<&SendPaymentOptions>,
779 idempotency_key: Option<String>,
780 ) -> Result<SendPaymentResponse, SdkError> {
781 let spark_address = address
782 .parse::<SparkAddress>()
783 .map_err(|_| SdkError::InvalidInput("Invalid spark address".to_string()))?;
784
785 if let Some(SendPaymentOptions::SparkAddress { htlc_options }) = options
787 && let Some(htlc_options) = htlc_options
788 {
789 if token_identifier.is_some() {
790 return Err(SdkError::InvalidInput(
791 "Can't provide both token identifier and HTLC options".to_string(),
792 ));
793 }
794
795 return self
796 .send_spark_htlc(
797 &spark_address,
798 amount.try_into()?,
799 htlc_options,
800 idempotency_key,
801 )
802 .await;
803 }
804
805 let payment = if let Some(identifier) = token_identifier {
806 self.send_spark_token_address(identifier, amount, spark_address)
807 .await?
808 } else {
809 let transfer_id = idempotency_key
810 .as_ref()
811 .map(|key| TransferId::from_str(key))
812 .transpose()?;
813 let transfer = self
814 .spark_wallet
815 .transfer(amount.try_into()?, &spark_address, transfer_id)
816 .await?;
817 transfer.try_into()?
818 };
819
820 self.storage.insert_payment(payment.clone()).await?;
822
823 Ok(SendPaymentResponse { payment })
824 }
825
826 async fn send_spark_htlc(
827 &self,
828 address: &SparkAddress,
829 amount_sat: u64,
830 htlc_options: &SparkHtlcOptions,
831 idempotency_key: Option<String>,
832 ) -> Result<SendPaymentResponse, SdkError> {
833 let payment_hash = sha256::Hash::from_str(&htlc_options.payment_hash)
834 .map_err(|_| SdkError::InvalidInput("Invalid payment hash".to_string()))?;
835
836 if htlc_options.expiry_duration_secs == 0 {
837 return Err(SdkError::InvalidInput(
838 "Expiry duration must be greater than 0".to_string(),
839 ));
840 }
841 let expiry_duration = Duration::from_secs(htlc_options.expiry_duration_secs);
842
843 let transfer_id = idempotency_key
844 .as_ref()
845 .map(|key| TransferId::from_str(key))
846 .transpose()?;
847 let transfer = self
848 .spark_wallet
849 .create_htlc(
850 amount_sat,
851 address,
852 &payment_hash,
853 expiry_duration,
854 transfer_id,
855 )
856 .await?;
857
858 let payment: Payment = transfer.try_into()?;
859
860 self.storage.insert_payment(payment.clone()).await?;
862
863 Ok(SendPaymentResponse { payment })
864 }
865
866 async fn send_spark_token_address(
867 &self,
868 token_identifier: String,
869 amount: u128,
870 receiver_address: SparkAddress,
871 ) -> Result<Payment, SdkError> {
872 let token_transaction = self
873 .spark_wallet
874 .transfer_tokens(
875 vec![TransferTokenOutput {
876 token_id: token_identifier,
877 amount,
878 receiver_address: receiver_address.clone(),
879 spark_invoice: None,
880 }],
881 None,
882 None,
883 )
884 .await?;
885
886 map_and_persist_token_transaction(&self.spark_wallet, &self.storage, &token_transaction)
887 .await
888 }
889
890 async fn send_spark_invoice(
891 &self,
892 invoice: &str,
893 request: &SendPaymentRequest,
894 amount: u128,
895 ) -> Result<SendPaymentResponse, SdkError> {
896 let transfer_id = request
897 .idempotency_key
898 .as_ref()
899 .map(|key| TransferId::from_str(key))
900 .transpose()?;
901
902 let payment = match self
903 .spark_wallet
904 .fulfill_spark_invoice(invoice, Some(amount), transfer_id)
905 .await?
906 {
907 spark_wallet::FulfillSparkInvoiceResult::Transfer(wallet_transfer) => {
908 (*wallet_transfer).try_into()?
909 }
910 spark_wallet::FulfillSparkInvoiceResult::TokenTransaction(token_transaction) => {
911 map_and_persist_token_transaction(
912 &self.spark_wallet,
913 &self.storage,
914 &token_transaction,
915 )
916 .await?
917 }
918 };
919
920 self.storage.insert_payment(payment.clone()).await?;
922
923 Ok(SendPaymentResponse { payment })
924 }
925
926 async fn calculate_fees_included_bolt11_amount(
929 &self,
930 invoice: &str,
931 user_amount: u64,
932 stored_fee: u64,
933 ) -> Result<u64, SdkError> {
934 let receiver_amount = user_amount.saturating_sub(stored_fee);
935 if receiver_amount == 0 {
936 return Err(SdkError::InvalidInput(
937 "Amount too small to cover fees".to_string(),
938 ));
939 }
940
941 let current_fee = self
943 .spark_wallet
944 .fetch_lightning_send_fee_estimate(invoice, Some(receiver_amount))
945 .await?;
946
947 if current_fee > stored_fee {
949 return Err(SdkError::Generic(
950 "Fee increased since prepare. Please retry.".to_string(),
951 ));
952 }
953
954 let overpayment = stored_fee.saturating_sub(current_fee);
956
957 let max_allowed_overpayment = current_fee.max(1);
960 if overpayment > max_allowed_overpayment {
961 return Err(SdkError::Generic(format!(
962 "Fee overpayment ({overpayment} sats) exceeds allowed maximum ({max_allowed_overpayment} sats)"
963 )));
964 }
965
966 if overpayment > 0 {
967 info!(
968 overpayment_sats = overpayment,
969 stored_fee_sats = stored_fee,
970 current_fee_sats = current_fee,
971 "FeesIncluded fee overpayment applied for Bolt11"
972 );
973 }
974
975 Ok(receiver_amount.saturating_add(overpayment))
976 }
977
978 async fn send_bolt11_invoice(
979 &self,
980 invoice_details: &Bolt11InvoiceDetails,
981 spark_transfer_fee_sats: Option<u64>,
982 lightning_fee_sats: u64,
983 request: &SendPaymentRequest,
984 amount_override: Option<u64>,
985 amount: u128,
986 ) -> Result<SendPaymentResponse, SdkError> {
987 let amount_to_send = if request.prepare_response.fee_policy == FeePolicy::FeesIncluded
989 && invoice_details.amount_msat.is_none()
990 && amount_override.is_none()
991 {
992 let amt = self
993 .calculate_fees_included_bolt11_amount(
994 &invoice_details.invoice.bolt11,
995 amount.try_into()?,
996 lightning_fee_sats,
997 )
998 .await?;
999 Some(u128::from(amt))
1000 } else {
1001 match amount_override {
1002 Some(amt) => Some(amt.into()),
1004 None => match invoice_details.amount_msat {
1005 Some(_) => None,
1007 None => Some(amount),
1009 },
1010 }
1011 };
1012 let (prefer_spark, completion_timeout_secs) = match request.options {
1013 Some(SendPaymentOptions::Bolt11Invoice {
1014 prefer_spark,
1015 completion_timeout_secs,
1016 }) => (prefer_spark, completion_timeout_secs),
1017 _ => (self.config.prefer_spark_over_lightning, None),
1018 };
1019 let fee_sats = match (prefer_spark, spark_transfer_fee_sats, lightning_fee_sats) {
1020 (true, Some(fee), _) => fee,
1021 _ => lightning_fee_sats,
1022 };
1023 let transfer_id = request
1024 .idempotency_key
1025 .as_ref()
1026 .map(|idempotency_key| TransferId::from_str(idempotency_key))
1027 .transpose()?;
1028
1029 let payment_response = Box::pin(
1030 self.spark_wallet.pay_lightning_invoice(
1031 &invoice_details.invoice.bolt11,
1032 amount_to_send
1033 .map(|a| Ok::<u64, SdkError>(a.try_into()?))
1034 .transpose()?,
1035 Some(fee_sats),
1036 prefer_spark,
1037 transfer_id,
1038 ),
1039 )
1040 .await?;
1041 let payment = match payment_response.lightning_payment {
1042 Some(lightning_payment) => {
1043 let ssp_id = lightning_payment.id.clone();
1044 let htlc_details = payment_response
1045 .transfer
1046 .htlc_preimage_request
1047 .ok_or_else(|| {
1048 SdkError::Generic(
1049 "Missing HTLC details for Lightning send payment".to_string(),
1050 )
1051 })?
1052 .try_into()?;
1053 let payment = Payment::from_lightning(
1054 lightning_payment,
1055 amount,
1056 payment_response.transfer.id.to_string(),
1057 htlc_details,
1058 )?;
1059 self.poll_lightning_send_payment(&payment, ssp_id);
1060 payment
1061 }
1062 None => payment_response.transfer.try_into()?,
1063 };
1064
1065 let completion_timeout_secs = completion_timeout_secs.unwrap_or(0);
1066
1067 if completion_timeout_secs == 0 {
1068 self.storage.insert_payment(payment.clone()).await?;
1070
1071 return Ok(SendPaymentResponse { payment });
1072 }
1073
1074 let payment = self
1075 .wait_for_payment(
1076 WaitForPaymentIdentifier::PaymentId(payment.id.clone()),
1077 completion_timeout_secs,
1078 )
1079 .await
1080 .unwrap_or(payment);
1081
1082 self.storage.insert_payment(payment.clone()).await?;
1084
1085 Ok(SendPaymentResponse { payment })
1086 }
1087
1088 async fn send_bitcoin_address(
1089 &self,
1090 address: &BitcoinAddressDetails,
1091 fee_quote: &SendOnchainFeeQuote,
1092 request: &SendPaymentRequest,
1093 ) -> Result<SendPaymentResponse, SdkError> {
1094 let confirmation_speed = match &request.options {
1096 Some(SendPaymentOptions::BitcoinAddress { confirmation_speed }) => {
1097 confirmation_speed.clone()
1098 }
1099 None => OnchainConfirmationSpeed::Fast, _ => {
1101 return Err(SdkError::InvalidInput(
1102 "Invalid options for Bitcoin address payment".to_string(),
1103 ));
1104 }
1105 };
1106
1107 let exit_speed: ExitSpeed = confirmation_speed.clone().into();
1108
1109 let fee_sats = match confirmation_speed {
1111 OnchainConfirmationSpeed::Fast => fee_quote.speed_fast.total_fee_sat(),
1112 OnchainConfirmationSpeed::Medium => fee_quote.speed_medium.total_fee_sat(),
1113 OnchainConfirmationSpeed::Slow => fee_quote.speed_slow.total_fee_sat(),
1114 };
1115
1116 let amount_sats: u64 = if request.prepare_response.fee_policy == FeePolicy::FeesIncluded {
1118 let total_sats: u64 = request.prepare_response.amount.try_into()?;
1119 total_sats.saturating_sub(fee_sats)
1120 } else {
1121 request.prepare_response.amount.try_into()?
1122 };
1123
1124 let transfer_id = request
1125 .idempotency_key
1126 .as_ref()
1127 .map(|idempotency_key| TransferId::from_str(idempotency_key))
1128 .transpose()?;
1129 let response = self
1130 .spark_wallet
1131 .withdraw(
1132 &address.address,
1133 Some(amount_sats),
1134 exit_speed,
1135 fee_quote.clone().into(),
1136 transfer_id,
1137 )
1138 .await?;
1139
1140 let payment: Payment = response.try_into()?;
1141
1142 self.storage.insert_payment(payment.clone()).await?;
1143
1144 Ok(SendPaymentResponse { payment })
1145 }
1146
1147 pub(super) async fn wait_for_payment(
1148 &self,
1149 identifier: WaitForPaymentIdentifier,
1150 completion_timeout_secs: u32,
1151 ) -> Result<Payment, SdkError> {
1152 let (tx, mut rx) = mpsc::channel(20);
1153 let id = self
1154 .add_event_listener(Box::new(InternalEventListener::new(tx)))
1155 .await;
1156
1157 let payment = match &identifier {
1159 WaitForPaymentIdentifier::PaymentId(payment_id) => self
1160 .storage
1161 .get_payment_by_id(payment_id.clone())
1162 .await
1163 .ok(),
1164 WaitForPaymentIdentifier::PaymentRequest(payment_request) => {
1165 self.storage
1166 .get_payment_by_invoice(payment_request.clone())
1167 .await?
1168 }
1169 };
1170 if let Some(payment) = payment
1171 && payment.status == PaymentStatus::Completed
1172 {
1173 self.remove_event_listener(&id).await;
1174 return Ok(payment);
1175 }
1176
1177 let timeout_res = timeout(Duration::from_secs(completion_timeout_secs.into()), async {
1178 loop {
1179 let Some(event) = rx.recv().await else {
1180 return Err(SdkError::Generic("Event channel closed".to_string()));
1181 };
1182
1183 let SdkEvent::PaymentSucceeded { payment } = event else {
1184 continue;
1185 };
1186
1187 if is_payment_match(&payment, &identifier) {
1188 return Ok(payment);
1189 }
1190 }
1191 })
1192 .await
1193 .map_err(|_| SdkError::Generic("Timeout waiting for payment".to_string()));
1194
1195 self.remove_event_listener(&id).await;
1196 timeout_res?
1197 }
1198
1199 fn poll_lightning_send_payment(&self, payment: &Payment, ssp_id: String) {
1201 const MAX_POLL_ATTEMPTS: u32 = 20;
1202 let payment_id = payment.id.clone();
1203 info!("Polling lightning send payment {}", payment_id);
1204
1205 let Some(htlc_details) = payment.details.as_ref().and_then(|d| match d {
1206 PaymentDetails::Lightning { htlc_details, .. } => Some(htlc_details.clone()),
1207 _ => None,
1208 }) else {
1209 error!(
1210 "Missing HTLC details for lightning send payment {payment_id}, skipping polling"
1211 );
1212 return;
1213 };
1214 let spark_wallet = self.spark_wallet.clone();
1215 let storage = self.storage.clone();
1216 let sync_coordinator = self.sync_coordinator.clone();
1217 let event_emitter = self.event_emitter.clone();
1218 let payment = payment.clone();
1219 let payment_id = payment_id.clone();
1220 let mut shutdown = self.shutdown_sender.subscribe();
1221 let span = tracing::Span::current();
1222
1223 tokio::spawn(async move {
1224 for i in 0..MAX_POLL_ATTEMPTS {
1225 info!(
1226 "Polling lightning send payment {} attempt {}",
1227 payment_id, i
1228 );
1229 select! {
1230 _ = shutdown.changed() => {
1231 info!("Shutdown signal received");
1232 return;
1233 },
1234 p = spark_wallet.fetch_lightning_send_payment(&ssp_id) => {
1235 if let Ok(Some(p)) = p && let Ok(payment) = Payment::from_lightning(p.clone(), payment.amount, payment.id.clone(), htlc_details.clone()) {
1236 info!("Polling payment status = {} {:?}", payment.status, p.status);
1237 if payment.status != PaymentStatus::Pending {
1238 info!("Polling payment completed status = {}", payment.status);
1239 if let Err(e) = storage.insert_payment(payment.clone()).await {
1242 error!("Failed to update payment in storage: {e:?}");
1243 }
1244 event_emitter.emit(&SdkEvent::from_payment(payment.clone())).await;
1245 sync_coordinator
1246 .trigger_sync_no_wait(SyncType::WalletState, true)
1247 .await;
1248 return;
1249 }
1250 }
1251
1252 let sleep_time = if i < 5 {
1253 Duration::from_secs(1)
1254 } else {
1255 Duration::from_secs(i.into())
1256 };
1257 tokio::time::sleep(sleep_time).await;
1258 }
1259 }
1260 }
1261 }.instrument(span));
1262 }
1263
1264 #[expect(clippy::too_many_arguments)]
1265 async fn convert_token_for_bolt11_invoice(
1266 &self,
1267 conversion_options: &ConversionOptions,
1268 spark_transfer_fee_sats: Option<u64>,
1269 lightning_fee_sats: u64,
1270 request: &SendPaymentRequest,
1271 conversion_purpose: &ConversionPurpose,
1272 amount: u128,
1273 token_identifier: Option<&String>,
1274 ) -> Result<TokenConversionResponse, SdkError> {
1275 let fee_sats = match request.options {
1277 Some(SendPaymentOptions::Bolt11Invoice { prefer_spark, .. }) => {
1278 match (prefer_spark, spark_transfer_fee_sats) {
1279 (true, Some(fee)) => fee,
1280 _ => lightning_fee_sats,
1281 }
1282 }
1283 _ => lightning_fee_sats,
1284 };
1285 let min_amount_out = amount.saturating_add(u128::from(fee_sats));
1287
1288 self.token_converter
1289 .convert(
1290 conversion_options,
1291 conversion_purpose,
1292 token_identifier,
1293 ConversionAmount::MinAmountOut(min_amount_out),
1294 )
1295 .await
1296 .map_err(Into::into)
1297 }
1298
1299 async fn get_conversion_options_for_payment(
1304 &self,
1305 options: Option<&ConversionOptions>,
1306 token_identifier: Option<&String>,
1307 payment_amount: u128,
1308 ) -> Result<Option<ConversionOptions>, SdkError> {
1309 if let Some(stable_balance) = &self.stable_balance {
1310 stable_balance
1311 .get_conversion_options(options, token_identifier, payment_amount)
1312 .await
1313 .map_err(Into::into)
1314 } else {
1315 Ok(options.cloned())
1316 }
1317 }
1318
1319 async fn convert_token_for_bitcoin_address(
1320 &self,
1321 conversion_options: &ConversionOptions,
1322 fee_quote: &SendOnchainFeeQuote,
1323 request: &SendPaymentRequest,
1324 conversion_purpose: &ConversionPurpose,
1325 amount: u128,
1326 token_identifier: Option<&String>,
1327 ) -> Result<TokenConversionResponse, SdkError> {
1328 let fee_sats = match &request.options {
1330 Some(SendPaymentOptions::BitcoinAddress { confirmation_speed }) => {
1331 match confirmation_speed {
1332 OnchainConfirmationSpeed::Slow => fee_quote.speed_slow.total_fee_sat(),
1333 OnchainConfirmationSpeed::Medium => fee_quote.speed_medium.total_fee_sat(),
1334 OnchainConfirmationSpeed::Fast => fee_quote.speed_fast.total_fee_sat(),
1335 }
1336 }
1337 _ => fee_quote.speed_fast.total_fee_sat(), };
1339
1340 let min_amount_out = amount.saturating_add(u128::from(fee_sats));
1342
1343 self.token_converter
1344 .convert(
1345 conversion_options,
1346 conversion_purpose,
1347 token_identifier,
1348 ConversionAmount::MinAmountOut(min_amount_out),
1349 )
1350 .await
1351 .map_err(Into::into)
1352 }
1353}