1use bitcoin::hashes::sha256;
2use platform_utils::time::Duration;
3use spark_wallet::{ExitSpeed, SparkAddress, TransferId, TransferTokenOutput};
4use std::str::FromStr;
5use tokio::select;
6use tokio::sync::mpsc;
7use tokio::time::timeout;
8use tracing::{Instrument, error, info, warn};
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 ConversionStatus, ListPaymentsRequest, ListPaymentsResponse, Payment, PaymentDetails,
21 PrepareSendPaymentRequest, PrepareSendPaymentResponse, ReceivePaymentMethod,
22 ReceivePaymentRequest, ReceivePaymentResponse, SendPaymentRequest, SendPaymentResponse,
23 conversion_steps_from_payments,
24 },
25 persist::PaymentMetadata,
26 token_conversion::{
27 ConversionAmount, DEFAULT_CONVERSION_TIMEOUT_SECS, TokenConversionResponse,
28 },
29 utils::{
30 payments::{get_payment_and_emit_event, get_payment_with_conversion_details},
31 send_payment_validation::{get_dust_limit_sats, validate_prepare_send_payment_request},
32 token::map_and_persist_token_transaction,
33 },
34};
35use bitcoin::secp256k1::PublicKey;
36use platform_utils::time::SystemTime;
37use platform_utils::tokio;
38use spark_wallet::{InvoiceDescription, Preimage};
39
40use super::{
41 BreezSdk, SyncType,
42 helpers::{InternalEventListener, get_deposit_address, is_payment_match},
43};
44
45#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
46#[allow(clippy::needless_pass_by_value)]
47impl BreezSdk {
48 pub async fn receive_payment(
49 &self,
50 request: ReceivePaymentRequest,
51 ) -> Result<ReceivePaymentResponse, SdkError> {
52 self.ensure_spark_private_mode_initialized().await?;
53 match request.payment_method {
54 ReceivePaymentMethod::SparkAddress => Ok(ReceivePaymentResponse {
55 fee: 0,
56 payment_request: self
57 .spark_wallet
58 .get_spark_address()?
59 .to_address_string()
60 .map_err(|e| {
61 SdkError::Generic(format!("Failed to convert Spark address to string: {e}"))
62 })?,
63 }),
64 ReceivePaymentMethod::SparkInvoice {
65 amount,
66 token_identifier,
67 expiry_time,
68 description,
69 sender_public_key,
70 } => {
71 let invoice = self
72 .spark_wallet
73 .create_spark_invoice(
74 amount,
75 token_identifier.clone(),
76 expiry_time
77 .map(|time| {
78 SystemTime::UNIX_EPOCH
79 .checked_add(Duration::from_secs(time))
80 .ok_or(SdkError::Generic("Invalid expiry time".to_string()))
81 })
82 .transpose()?,
83 description,
84 sender_public_key.map(|key| PublicKey::from_str(&key).unwrap()),
85 )
86 .await?;
87 Ok(ReceivePaymentResponse {
88 fee: 0,
89 payment_request: invoice,
90 })
91 }
92 ReceivePaymentMethod::BitcoinAddress { new_address } => {
93 let address =
94 get_deposit_address(&self.spark_wallet, new_address.unwrap_or(false)).await?;
95 Ok(ReceivePaymentResponse {
96 payment_request: address,
97 fee: 0,
98 })
99 }
100 ReceivePaymentMethod::Bolt11Invoice {
101 description,
102 amount_sats,
103 expiry_secs,
104 payment_hash,
105 } => {
106 self.receive_bolt11_invoice(description, amount_sats, expiry_secs, payment_hash)
107 .await
108 }
109 }
110 }
111
112 pub async fn claim_htlc_payment(
113 &self,
114 request: ClaimHtlcPaymentRequest,
115 ) -> Result<ClaimHtlcPaymentResponse, SdkError> {
116 let preimage = Preimage::from_hex(&request.preimage)
117 .map_err(|_| SdkError::InvalidInput("Invalid preimage".to_string()))?;
118 let payment_hash = preimage.compute_hash();
119
120 let claimable_htlc_transfers = self
122 .spark_wallet
123 .list_claimable_htlc_transfers(None)
124 .await?;
125 if !claimable_htlc_transfers
126 .iter()
127 .filter_map(|t| t.htlc_preimage_request.as_ref())
128 .any(|p| p.payment_hash == payment_hash)
129 {
130 return Err(SdkError::InvalidInput(
131 "No claimable HTLC with the given payment hash".to_string(),
132 ));
133 }
134
135 let transfer = self.spark_wallet.claim_htlc(&preimage).await?;
136 let payment: Payment = transfer.try_into()?;
137
138 self.storage.insert_payment(payment.clone()).await?;
140
141 Ok(ClaimHtlcPaymentResponse { payment })
142 }
143
144 #[allow(clippy::too_many_lines)]
145 pub async fn prepare_send_payment(
146 &self,
147 request: PrepareSendPaymentRequest,
148 ) -> Result<PrepareSendPaymentResponse, SdkError> {
149 let parsed_input = self.parse(&request.payment_request).await?;
150
151 validate_prepare_send_payment_request(
152 &parsed_input,
153 &request,
154 &self.spark_wallet.get_identity_public_key().to_string(),
155 )?;
156
157 let fee_policy = request.fee_policy.unwrap_or_default();
158 let token_identifier = request.token_identifier.clone();
159
160 match &parsed_input {
161 InputType::SparkAddress(spark_address_details) => {
162 let amount = request
163 .amount
164 .ok_or(SdkError::InvalidInput("Amount is required".to_string()))?;
165
166 let (amount, conversion_estimate) = self
167 .resolve_send_amount_with_conversion_estimate(
168 request.conversion_options.as_ref(),
169 request.token_identifier.as_ref(),
170 amount,
171 fee_policy,
172 )
173 .await?;
174
175 let is_to_bitcoin = matches!(
178 conversion_estimate,
179 Some(ConversionEstimate {
180 options: ConversionOptions {
181 conversion_type: ConversionType::ToBitcoin { .. },
182 ..
183 },
184 ..
185 })
186 );
187 let response_token_identifier = if is_to_bitcoin {
188 None
189 } else {
190 token_identifier.clone()
191 };
192
193 Ok(PrepareSendPaymentResponse {
194 payment_method: SendPaymentMethod::SparkAddress {
195 address: spark_address_details.address.clone(),
196 fee: 0,
197 token_identifier: response_token_identifier.clone(),
198 },
199 amount,
200 token_identifier: response_token_identifier,
201 conversion_estimate,
202 fee_policy,
203 })
204 }
205 InputType::SparkInvoice(spark_invoice_details) => {
206 let effective_token_identifier =
208 token_identifier.or_else(|| spark_invoice_details.token_identifier.clone());
209
210 let amount = spark_invoice_details
211 .amount
212 .or(request.amount)
213 .ok_or(SdkError::InvalidInput("Amount is required".to_string()))?;
214
215 let (amount, conversion_estimate) = self
216 .resolve_send_amount_with_conversion_estimate(
217 request.conversion_options.as_ref(),
218 effective_token_identifier.as_ref(),
219 amount,
220 fee_policy,
221 )
222 .await?;
223
224 let is_to_bitcoin = matches!(
225 conversion_estimate,
226 Some(ConversionEstimate {
227 options: ConversionOptions {
228 conversion_type: ConversionType::ToBitcoin { .. },
229 ..
230 },
231 ..
232 })
233 );
234 let response_token_identifier = if is_to_bitcoin {
235 None
236 } else {
237 effective_token_identifier.clone()
238 };
239
240 Ok(PrepareSendPaymentResponse {
241 payment_method: SendPaymentMethod::SparkInvoice {
242 spark_invoice_details: spark_invoice_details.clone(),
243 fee: 0,
244 token_identifier: response_token_identifier.clone(),
245 },
246 amount,
247 token_identifier: response_token_identifier,
248 conversion_estimate,
249 fee_policy,
250 })
251 }
252 InputType::Bolt11Invoice(detailed_bolt11_invoice) => {
253 let spark_address: Option<SparkAddress> = self
254 .spark_wallet
255 .extract_spark_address(&request.payment_request)?;
256
257 let spark_transfer_fee_sats = if spark_address.is_some() {
258 Some(0)
259 } else {
260 None
261 };
262
263 if let Some(response) = self
264 .maybe_prepare_bolt11_from_token_conversion(
265 &request,
266 detailed_bolt11_invoice,
267 spark_transfer_fee_sats,
268 token_identifier.as_ref(),
269 fee_policy,
270 )
271 .await?
272 {
273 return Ok(response);
274 }
275
276 let amount = request
277 .amount
278 .or(detailed_bolt11_invoice
279 .amount_msat
280 .map(|msat| u128::from(msat).saturating_div(1000)))
281 .ok_or(SdkError::InvalidInput("Amount is required".to_string()))?;
282
283 let lightning_fee_sats = self
285 .spark_wallet
286 .fetch_lightning_send_fee_estimate(
287 &request.payment_request,
288 Some(amount.try_into()?),
289 )
290 .await?;
291
292 if fee_policy == FeePolicy::FeesIncluded
294 && detailed_bolt11_invoice.amount_msat.is_none()
295 {
296 let amount_u64: u64 = amount.try_into()?;
297 if amount_u64 <= lightning_fee_sats {
298 return Err(SdkError::InvalidInput(
299 "Amount too small to cover fees".to_string(),
300 ));
301 }
302 }
303
304 let conversion_estimate = self
305 .estimate_conversion(
306 request.conversion_options.as_ref(),
307 token_identifier.as_ref(),
308 ConversionAmount::MinAmountOut(
309 amount.saturating_add(u128::from(lightning_fee_sats)),
310 ),
311 )
312 .await?;
313
314 Ok(PrepareSendPaymentResponse {
315 payment_method: SendPaymentMethod::Bolt11Invoice {
316 invoice_details: detailed_bolt11_invoice.clone(),
317 spark_transfer_fee_sats,
318 lightning_fee_sats,
319 },
320 amount,
321 token_identifier,
322 conversion_estimate,
323 fee_policy,
324 })
325 }
326 InputType::BitcoinAddress(withdrawal_address) => {
327 if let Some(response) = self
328 .maybe_prepare_bitcoin_from_token_conversion(
329 &request,
330 withdrawal_address,
331 token_identifier.as_ref(),
332 fee_policy,
333 )
334 .await?
335 {
336 return Ok(response);
337 }
338
339 let amount = request
340 .amount
341 .ok_or(SdkError::InvalidInput("Amount is required".to_string()))?;
342
343 let dust_limit_sats = get_dust_limit_sats(&withdrawal_address.address)?;
347 let amount_u64: u64 = amount.try_into()?;
348 if amount_u64 < dust_limit_sats {
349 return Err(SdkError::InvalidInput(format!(
350 "Amount is below the minimum of {dust_limit_sats} sats required for this address"
351 )));
352 }
353
354 let stable_balance_active = match &self.stable_balance {
358 Some(sb) => sb.get_active_label().await.is_some(),
359 None => false,
360 };
361 let fee_quote_amount = if stable_balance_active {
362 None
363 } else {
364 Some(amount.try_into()?)
365 };
366 let fee_quote: SendOnchainFeeQuote = self
367 .spark_wallet
368 .fetch_coop_exit_fee_quote(&withdrawal_address.address, fee_quote_amount)
369 .await?
370 .into();
371
372 if fee_policy == FeePolicy::FeesIncluded {
375 let min_fee_sats = fee_quote.speed_slow.total_fee_sat();
376 let output_amount_sats = amount_u64.saturating_sub(min_fee_sats);
377 if output_amount_sats < dust_limit_sats {
378 return Err(SdkError::InvalidInput(format!(
379 "Amount is below the minimum of {dust_limit_sats} sats required for this address after lowest fees of {min_fee_sats} sats"
380 )));
381 }
382 }
383
384 let conversion_estimate = self
386 .estimate_conversion(
387 request.conversion_options.as_ref(),
388 token_identifier.as_ref(),
389 ConversionAmount::MinAmountOut(
390 amount.saturating_add(u128::from(fee_quote.speed_fast.total_fee_sat())),
391 ),
392 )
393 .await?;
394
395 Ok(PrepareSendPaymentResponse {
396 payment_method: SendPaymentMethod::BitcoinAddress {
397 address: withdrawal_address.clone(),
398 fee_quote,
399 },
400 amount,
401 token_identifier,
402 conversion_estimate,
403 fee_policy,
404 })
405 }
406 _ => Err(SdkError::InvalidInput(
407 "Unsupported payment method".to_string(),
408 )),
409 }
410 }
411
412 pub async fn send_payment(
413 &self,
414 request: SendPaymentRequest,
415 ) -> Result<SendPaymentResponse, SdkError> {
416 self.ensure_spark_private_mode_initialized().await?;
417 Box::pin(self.maybe_convert_token_send_payment(request, false, None)).await
418 }
419
420 pub async fn fetch_conversion_limits(
421 &self,
422 request: FetchConversionLimitsRequest,
423 ) -> Result<FetchConversionLimitsResponse, SdkError> {
424 self.token_converter
425 .fetch_limits(&request)
426 .await
427 .map_err(Into::into)
428 }
429
430 pub async fn list_payments(
444 &self,
445 request: ListPaymentsRequest,
446 ) -> Result<ListPaymentsResponse, SdkError> {
447 let mut payments = self.storage.list_payments(request.into()).await?;
448
449 let parent_ids: Vec<String> = payments
451 .iter()
452 .filter(|p| p.conversion_details.is_some())
453 .map(|p| p.id.clone())
454 .collect();
455
456 if !parent_ids.is_empty() {
457 let related_payments_map = self.storage.get_payments_by_parent_ids(parent_ids).await?;
458
459 for payment in &mut payments {
460 if let Some(related_payments) = related_payments_map.get(&payment.id) {
461 match conversion_steps_from_payments(related_payments) {
462 Ok((from, to)) => {
463 if let Some(ref mut cd) = payment.conversion_details {
464 cd.from = from;
465 cd.to = to;
466 }
467 }
468 Err(e) => {
469 warn!("Failed to build conversion steps: {e}");
470 }
471 }
472 }
473 }
474 }
475
476 Ok(ListPaymentsResponse { payments })
477 }
478
479 pub async fn get_payment(
480 &self,
481 request: GetPaymentRequest,
482 ) -> Result<GetPaymentResponse, SdkError> {
483 let payment =
484 get_payment_with_conversion_details(request.payment_id, self.storage.clone()).await?;
485
486 Ok(GetPaymentResponse { payment })
487 }
488}
489
490impl BreezSdk {
492 pub(crate) async fn receive_bolt11_invoice(
493 &self,
494 description: String,
495 amount_sats: Option<u64>,
496 expiry_secs: Option<u32>,
497 payment_hash: Option<String>,
498 ) -> Result<ReceivePaymentResponse, SdkError> {
499 let invoice = if let Some(payment_hash_hex) = payment_hash {
500 let hash = sha256::Hash::from_str(&payment_hash_hex)
501 .map_err(|e| SdkError::InvalidInput(format!("Invalid payment hash: {e}")))?;
502 self.spark_wallet
503 .create_hodl_lightning_invoice(
504 amount_sats.unwrap_or_default(),
505 Some(InvoiceDescription::Memo(description.clone())),
506 hash,
507 None,
508 expiry_secs,
509 )
510 .await?
511 .invoice
512 } else {
513 self.spark_wallet
514 .create_lightning_invoice(
515 amount_sats.unwrap_or_default(),
516 Some(InvoiceDescription::Memo(description.clone())),
517 None,
518 expiry_secs,
519 self.config.prefer_spark_over_lightning,
520 )
521 .await?
522 .invoice
523 };
524 Ok(ReceivePaymentResponse {
525 payment_request: invoice,
526 fee: 0,
527 })
528 }
529
530 pub(super) async fn maybe_convert_token_send_payment(
531 &self,
532 request: SendPaymentRequest,
533 mut suppress_payment_event: bool,
534 amount_override: Option<u64>,
535 ) -> Result<SendPaymentResponse, SdkError> {
536 let token_identifier = request.prepare_response.token_identifier.clone();
537
538 if request.idempotency_key.is_some() && token_identifier.is_some() {
540 return Err(SdkError::InvalidInput(
541 "Idempotency key is not supported for token payments".to_string(),
542 ));
543 }
544 if let Some(idempotency_key) = &request.idempotency_key {
545 if let Ok(payment) = self
547 .storage
548 .get_payment_by_id(idempotency_key.clone())
549 .await
550 {
551 return Ok(SendPaymentResponse { payment });
552 }
553 }
554 let conversion_estimate = request.prepare_response.conversion_estimate.clone();
555 let res = if let Some(ConversionEstimate {
557 options: conversion_options,
558 ..
559 }) = &conversion_estimate
560 {
561 Box::pin(self.convert_token_send_payment_internal(
562 conversion_options,
563 &request,
564 amount_override,
565 &mut suppress_payment_event,
566 ))
567 .await
568 } else {
569 Box::pin(self.send_payment_internal(&request, amount_override)).await
570 };
571 if let Ok(response) = &res {
573 if !suppress_payment_event {
574 self.event_emitter
576 .emit(&SdkEvent::from_payment(response.payment.clone()))
577 .await;
578 }
579 self.sync_coordinator
580 .trigger_sync_no_wait(SyncType::WalletState, true)
581 .await;
582 }
583 res
584 }
585
586 async fn convert_token_send_payment_internal(
587 &self,
588 conversion_options: &ConversionOptions,
589 request: &SendPaymentRequest,
590 caller_amount_override: Option<u64>,
591 suppress_payment_event: &mut bool,
592 ) -> Result<SendPaymentResponse, SdkError> {
593 let _payment_guard = match &self.stable_balance {
595 Some(sb) => Some(sb.acquire_payment_guard().await),
596 None => None,
597 };
598
599 let (conversion_response, conversion_purpose, uses_amount_in) = self
601 .execute_pre_send_conversion(conversion_options, request)
602 .await?;
603
604 self.pre_link_conversion_children(&conversion_response, &conversion_purpose)
606 .await?;
607
608 self.complete_conversion_and_send(
610 conversion_options,
611 &conversion_response,
612 &conversion_purpose,
613 request,
614 uses_amount_in,
615 caller_amount_override,
616 suppress_payment_event,
617 )
618 .await
619 }
621
622 #[allow(clippy::too_many_lines)]
633 async fn execute_pre_send_conversion(
634 &self,
635 conversion_options: &ConversionOptions,
636 request: &SendPaymentRequest,
637 ) -> Result<(TokenConversionResponse, ConversionPurpose, bool), SdkError> {
638 let amount = request.prepare_response.amount;
639
640 let from_token_identifier = match &conversion_options.conversion_type {
642 ConversionType::ToBitcoin {
643 from_token_identifier,
644 } => Some(from_token_identifier.clone()),
645 ConversionType::FromBitcoin => None,
646 };
647
648 let uses_amount_in = request
656 .prepare_response
657 .conversion_estimate
658 .as_ref()
659 .is_some_and(|e| amount >= e.amount_out);
660 let conversion_amount = if uses_amount_in {
661 let token_amount = request
662 .prepare_response
663 .conversion_estimate
664 .as_ref()
665 .map(|e| e.amount_in)
666 .ok_or(SdkError::InvalidInput(
667 "Conversion estimate required for token conversion".to_string(),
668 ))?;
669 ConversionAmount::AmountIn(token_amount)
670 } else {
671 ConversionAmount::MinAmountOut(amount)
672 };
673
674 match &request.prepare_response.payment_method {
675 SendPaymentMethod::SparkAddress { address, .. } => {
676 let spark_address = address
677 .parse::<SparkAddress>()
678 .map_err(|_| SdkError::InvalidInput("Invalid spark address".to_string()))?;
679 let purpose = if spark_address.identity_public_key
680 == self.spark_wallet.get_identity_public_key()
681 {
682 ConversionPurpose::SelfTransfer
683 } else {
684 ConversionPurpose::OngoingPayment {
685 payment_request: address.clone(),
686 }
687 };
688 let response = self
689 .token_converter
690 .convert(
691 conversion_options,
692 &purpose,
693 from_token_identifier.as_ref(),
694 conversion_amount,
695 None,
696 )
697 .await?;
698 Ok((response, purpose, uses_amount_in))
699 }
700 SendPaymentMethod::SparkInvoice {
701 spark_invoice_details:
702 SparkInvoiceDetails {
703 identity_public_key,
704 invoice,
705 ..
706 },
707 ..
708 } => {
709 let own_identity_public_key =
710 self.spark_wallet.get_identity_public_key().to_string();
711 let purpose = if identity_public_key == &own_identity_public_key {
712 ConversionPurpose::SelfTransfer
713 } else {
714 ConversionPurpose::OngoingPayment {
715 payment_request: invoice.clone(),
716 }
717 };
718 let response = self
719 .token_converter
720 .convert(
721 conversion_options,
722 &purpose,
723 from_token_identifier.as_ref(),
724 conversion_amount,
725 None,
726 )
727 .await?;
728 Ok((response, purpose, uses_amount_in))
729 }
730 SendPaymentMethod::Bolt11Invoice {
731 spark_transfer_fee_sats,
732 lightning_fee_sats,
733 invoice_details,
734 ..
735 } => {
736 let purpose = ConversionPurpose::OngoingPayment {
737 payment_request: invoice_details.invoice.bolt11.clone(),
738 };
739 let conversion_amount_override = match &conversion_amount {
740 ConversionAmount::AmountIn(_) => Some(conversion_amount),
741 ConversionAmount::MinAmountOut(_) => None,
742 };
743 let response = self
744 .convert_token_for_bolt11_invoice(
745 conversion_options,
746 *spark_transfer_fee_sats,
747 *lightning_fee_sats,
748 request,
749 &purpose,
750 amount,
751 from_token_identifier.as_ref(),
752 conversion_amount_override,
753 )
754 .await?;
755 Ok((response, purpose, uses_amount_in))
756 }
757 SendPaymentMethod::BitcoinAddress { address, fee_quote } => {
758 let purpose = ConversionPurpose::OngoingPayment {
759 payment_request: address.address.clone(),
760 };
761 let conversion_amount_override = match &conversion_amount {
762 ConversionAmount::AmountIn(_) => Some(conversion_amount),
763 ConversionAmount::MinAmountOut(_) => None,
764 };
765 let response = self
766 .convert_token_for_bitcoin_address(
767 conversion_options,
768 fee_quote,
769 request,
770 &purpose,
771 amount,
772 from_token_identifier.as_ref(),
773 conversion_amount_override,
774 )
775 .await?;
776 Ok((response, purpose, uses_amount_in))
777 }
778 }
779 }
780
781 async fn pre_link_conversion_children(
786 &self,
787 conversion_response: &TokenConversionResponse,
788 conversion_purpose: &ConversionPurpose,
789 ) -> Result<(), SdkError> {
790 if *conversion_purpose == ConversionPurpose::SelfTransfer {
791 self.storage
792 .insert_payment_metadata(
793 conversion_response.sent_payment_id.clone(),
794 PaymentMetadata {
795 parent_payment_id: Some(conversion_response.received_payment_id.clone()),
796 ..Default::default()
797 },
798 )
799 .await?;
800 }
801 Ok(())
802 }
803
804 #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
814 async fn complete_conversion_and_send(
815 &self,
816 conversion_options: &ConversionOptions,
817 conversion_response: &TokenConversionResponse,
818 conversion_purpose: &ConversionPurpose,
819 request: &SendPaymentRequest,
820 uses_amount_in: bool,
821 caller_amount_override: Option<u64>,
822 suppress_payment_event: &mut bool,
823 ) -> Result<SendPaymentResponse, SdkError> {
824 if matches!(
826 conversion_options.conversion_type,
827 ConversionType::FromBitcoin
828 ) {
829 self.sync_coordinator
830 .trigger_sync_no_wait(SyncType::WalletState, true)
831 .await;
832 }
833
834 let payment = self
836 .wait_for_payment(
837 WaitForPaymentIdentifier::PaymentId(
838 conversion_response.received_payment_id.clone(),
839 ),
840 conversion_options
841 .completion_timeout_secs
842 .unwrap_or(DEFAULT_CONVERSION_TIMEOUT_SECS),
843 )
844 .await
845 .map_err(|e| {
846 SdkError::Generic(format!("Timeout waiting for conversion to complete: {e}"))
847 })?;
848
849 if *conversion_purpose == ConversionPurpose::SelfTransfer {
851 *suppress_payment_event = true;
852 return Ok(SendPaymentResponse { payment });
853 }
854
855 let amount_override = if let Some(override_amount) = caller_amount_override {
870 tracing::trace!(
871 override_amount,
872 "complete_conversion_and_send: using caller-provided amount_override"
873 );
874 Some(override_amount)
875 } else if uses_amount_in {
876 let converted_sats: u64 = payment
877 .amount
878 .try_into()
879 .map_err(|_| SdkError::Generic("Converted sats too large for u64".to_string()))?;
880 let estimated_conversion_out: u64 = request
881 .prepare_response
882 .conversion_estimate
883 .as_ref()
884 .map_or(0, |e| e.amount_out)
885 .try_into()
886 .map_err(|_| SdkError::Generic("Estimated sats too large for u64".to_string()))?;
887 let sats_change = request
888 .prepare_response
889 .amount
890 .try_into()
891 .map(|amount: u64| amount.saturating_sub(estimated_conversion_out))
892 .unwrap_or(0);
893 let total = converted_sats.saturating_add(sats_change);
894 tracing::trace!(
895 converted_sats,
896 estimated_conversion_out,
897 sats_change,
898 total,
899 prepared_amount = request.prepare_response.amount,
900 fee_policy = ?request.prepare_response.fee_policy,
901 "complete_conversion_and_send: amount_override = converted_sats + sats_change"
902 );
903 Some(total)
904 } else {
905 tracing::trace!(
906 prepared_amount = request.prepare_response.amount,
907 fee_policy = ?request.prepare_response.fee_policy,
908 "complete_conversion_and_send: no override (MinAmountOut conversion)"
909 );
910 None
911 };
912
913 let response = Box::pin(self.send_payment_internal(request, amount_override)).await?;
915
916 self.storage
918 .insert_payment_metadata(
919 conversion_response.sent_payment_id.clone(),
920 PaymentMetadata {
921 parent_payment_id: Some(response.payment.id.clone()),
922 ..Default::default()
923 },
924 )
925 .await?;
926 self.storage
927 .insert_payment_metadata(
928 conversion_response.received_payment_id.clone(),
929 PaymentMetadata {
930 parent_payment_id: Some(response.payment.id.clone()),
931 ..Default::default()
932 },
933 )
934 .await?;
935
936 self.storage
938 .insert_payment_metadata(
939 response.payment.id.clone(),
940 PaymentMetadata {
941 conversion_status: Some(ConversionStatus::Completed),
942 ..Default::default()
943 },
944 )
945 .await?;
946
947 get_payment_with_conversion_details(response.payment.id, self.storage.clone())
949 .await
950 .map(|payment| SendPaymentResponse { payment })
951 }
952
953 pub(super) async fn send_payment_internal(
954 &self,
955 request: &SendPaymentRequest,
956 amount_override: Option<u64>,
957 ) -> Result<SendPaymentResponse, SdkError> {
958 let amount = request.prepare_response.amount;
959 let token_identifier = request.prepare_response.token_identifier.clone();
960
961 match &request.prepare_response.payment_method {
962 SendPaymentMethod::SparkAddress { address, .. } => {
963 Box::pin(self.send_spark_address(
964 address,
965 token_identifier,
966 amount_override.map_or(amount, u128::from),
967 request.options.as_ref(),
968 request.idempotency_key.clone(),
969 ))
970 .await
971 }
972 SendPaymentMethod::SparkInvoice {
973 spark_invoice_details,
974 ..
975 } => {
976 self.send_spark_invoice(
977 &spark_invoice_details.invoice,
978 request,
979 amount_override.map_or(amount, u128::from),
980 )
981 .await
982 }
983 SendPaymentMethod::Bolt11Invoice {
984 invoice_details,
985 spark_transfer_fee_sats,
986 lightning_fee_sats,
987 ..
988 } => {
989 Box::pin(self.send_bolt11_invoice(
990 invoice_details,
991 *spark_transfer_fee_sats,
992 *lightning_fee_sats,
993 request,
994 amount_override,
995 amount,
996 ))
997 .await
998 }
999 SendPaymentMethod::BitcoinAddress { address, fee_quote } => {
1000 self.send_bitcoin_address(address, fee_quote, request, amount_override)
1001 .await
1002 }
1003 }
1004 }
1005
1006 async fn send_spark_address(
1007 &self,
1008 address: &str,
1009 token_identifier: Option<String>,
1010 amount: u128,
1011 options: Option<&SendPaymentOptions>,
1012 idempotency_key: Option<String>,
1013 ) -> Result<SendPaymentResponse, SdkError> {
1014 let spark_address = address
1015 .parse::<SparkAddress>()
1016 .map_err(|_| SdkError::InvalidInput("Invalid spark address".to_string()))?;
1017
1018 if let Some(SendPaymentOptions::SparkAddress { htlc_options }) = options
1020 && let Some(htlc_options) = htlc_options
1021 {
1022 if token_identifier.is_some() {
1023 return Err(SdkError::InvalidInput(
1024 "Can't provide both token identifier and HTLC options".to_string(),
1025 ));
1026 }
1027
1028 return self
1029 .send_spark_htlc(
1030 &spark_address,
1031 amount.try_into()?,
1032 htlc_options,
1033 idempotency_key,
1034 )
1035 .await;
1036 }
1037
1038 let payment = if let Some(identifier) = token_identifier {
1039 self.send_spark_token_address(identifier, amount, spark_address)
1040 .await?
1041 } else {
1042 let transfer_id = idempotency_key
1043 .as_ref()
1044 .map(|key| TransferId::from_str(key))
1045 .transpose()?;
1046 let transfer = self
1047 .spark_wallet
1048 .transfer(amount.try_into()?, &spark_address, transfer_id)
1049 .await?;
1050 transfer.try_into()?
1051 };
1052
1053 self.storage.insert_payment(payment.clone()).await?;
1055
1056 Ok(SendPaymentResponse { payment })
1057 }
1058
1059 async fn send_spark_htlc(
1060 &self,
1061 address: &SparkAddress,
1062 amount_sat: u64,
1063 htlc_options: &SparkHtlcOptions,
1064 idempotency_key: Option<String>,
1065 ) -> Result<SendPaymentResponse, SdkError> {
1066 let payment_hash = sha256::Hash::from_str(&htlc_options.payment_hash)
1067 .map_err(|_| SdkError::InvalidInput("Invalid payment hash".to_string()))?;
1068
1069 if htlc_options.expiry_duration_secs == 0 {
1070 return Err(SdkError::InvalidInput(
1071 "Expiry duration must be greater than 0".to_string(),
1072 ));
1073 }
1074 let expiry_duration = Duration::from_secs(htlc_options.expiry_duration_secs);
1075
1076 let transfer_id = idempotency_key
1077 .as_ref()
1078 .map(|key| TransferId::from_str(key))
1079 .transpose()?;
1080 let transfer = self
1081 .spark_wallet
1082 .create_htlc(
1083 amount_sat,
1084 address,
1085 &payment_hash,
1086 expiry_duration,
1087 transfer_id,
1088 )
1089 .await?;
1090
1091 let payment: Payment = transfer.try_into()?;
1092
1093 self.storage.insert_payment(payment.clone()).await?;
1095
1096 Ok(SendPaymentResponse { payment })
1097 }
1098
1099 async fn send_spark_token_address(
1100 &self,
1101 token_identifier: String,
1102 amount: u128,
1103 receiver_address: SparkAddress,
1104 ) -> Result<Payment, SdkError> {
1105 let token_transaction = self
1106 .spark_wallet
1107 .transfer_tokens(
1108 vec![TransferTokenOutput {
1109 token_id: token_identifier,
1110 amount,
1111 receiver_address: receiver_address.clone(),
1112 spark_invoice: None,
1113 }],
1114 None,
1115 None,
1116 )
1117 .await?;
1118
1119 map_and_persist_token_transaction(&self.spark_wallet, &self.storage, &token_transaction)
1120 .await
1121 }
1122
1123 async fn send_spark_invoice(
1124 &self,
1125 invoice: &str,
1126 request: &SendPaymentRequest,
1127 amount: u128,
1128 ) -> Result<SendPaymentResponse, SdkError> {
1129 let transfer_id = request
1130 .idempotency_key
1131 .as_ref()
1132 .map(|key| TransferId::from_str(key))
1133 .transpose()?;
1134
1135 let payment = match self
1136 .spark_wallet
1137 .fulfill_spark_invoice(invoice, Some(amount), transfer_id)
1138 .await?
1139 {
1140 spark_wallet::FulfillSparkInvoiceResult::Transfer(wallet_transfer) => {
1141 (*wallet_transfer).try_into()?
1142 }
1143 spark_wallet::FulfillSparkInvoiceResult::TokenTransaction(token_transaction) => {
1144 map_and_persist_token_transaction(
1145 &self.spark_wallet,
1146 &self.storage,
1147 &token_transaction,
1148 )
1149 .await?
1150 }
1151 };
1152
1153 self.storage.insert_payment(payment.clone()).await?;
1155
1156 Ok(SendPaymentResponse { payment })
1157 }
1158
1159 async fn calculate_fees_included_bolt11_amount(
1162 &self,
1163 invoice: &str,
1164 user_amount: u64,
1165 stored_fee: u64,
1166 ) -> Result<u64, SdkError> {
1167 let receiver_amount = user_amount.saturating_sub(stored_fee);
1168 if receiver_amount == 0 {
1169 return Err(SdkError::InvalidInput(
1170 "Amount too small to cover fees".to_string(),
1171 ));
1172 }
1173
1174 let current_fee = self
1176 .spark_wallet
1177 .fetch_lightning_send_fee_estimate(invoice, Some(receiver_amount))
1178 .await?;
1179
1180 if current_fee > stored_fee {
1182 return Err(SdkError::Generic(
1183 "Fee increased since prepare. Please retry.".to_string(),
1184 ));
1185 }
1186
1187 let overpayment = stored_fee.saturating_sub(current_fee);
1189
1190 let max_allowed_overpayment = current_fee.max(1);
1193 if overpayment > max_allowed_overpayment {
1194 return Err(SdkError::Generic(format!(
1195 "Fee overpayment ({overpayment} sats) exceeds allowed maximum ({max_allowed_overpayment} sats)"
1196 )));
1197 }
1198
1199 if overpayment > 0 {
1200 info!(
1201 overpayment_sats = overpayment,
1202 stored_fee_sats = stored_fee,
1203 current_fee_sats = current_fee,
1204 "FeesIncluded fee overpayment applied for Bolt11"
1205 );
1206 }
1207
1208 Ok(receiver_amount.saturating_add(overpayment))
1209 }
1210
1211 async fn send_bolt11_invoice(
1212 &self,
1213 invoice_details: &Bolt11InvoiceDetails,
1214 spark_transfer_fee_sats: Option<u64>,
1215 lightning_fee_sats: u64,
1216 request: &SendPaymentRequest,
1217 amount_override: Option<u64>,
1218 amount: u128,
1219 ) -> Result<SendPaymentResponse, SdkError> {
1220 let (prefer_spark, completion_timeout_secs) = match request.options {
1223 Some(SendPaymentOptions::Bolt11Invoice {
1224 prefer_spark,
1225 completion_timeout_secs,
1226 }) => (prefer_spark, completion_timeout_secs),
1227 _ => (self.config.prefer_spark_over_lightning, None),
1228 };
1229 let is_spark_route = prefer_spark && spark_transfer_fee_sats.is_some();
1230 let fee_sats = if is_spark_route {
1231 spark_transfer_fee_sats.unwrap_or(0)
1232 } else {
1233 lightning_fee_sats
1234 };
1235
1236 let is_fees_included = request.prepare_response.fee_policy == FeePolicy::FeesIncluded;
1240 let amount_to_send = if is_fees_included
1241 && (invoice_details.amount_msat.is_none() || amount_override.is_some())
1242 {
1243 let total_sats: u64 = match amount_override {
1244 Some(sat_balance) => sat_balance,
1245 None => amount.try_into()?,
1246 };
1247 let amt = if is_spark_route {
1251 total_sats.saturating_sub(fee_sats)
1252 } else {
1253 self.calculate_fees_included_bolt11_amount(
1254 &invoice_details.invoice.bolt11,
1255 total_sats,
1256 fee_sats,
1257 )
1258 .await?
1259 };
1260 Some(u128::from(amt))
1261 } else {
1262 match amount_override {
1263 Some(amt) => Some(amt.into()),
1264 None => match invoice_details.amount_msat {
1265 Some(_) => None,
1266 None => Some(amount),
1267 },
1268 }
1269 };
1270 let transfer_id = request
1271 .idempotency_key
1272 .as_ref()
1273 .map(|idempotency_key| TransferId::from_str(idempotency_key))
1274 .transpose()?;
1275
1276 let payment_response = Box::pin(
1277 self.spark_wallet.pay_lightning_invoice(
1278 &invoice_details.invoice.bolt11,
1279 amount_to_send
1280 .map(|a| Ok::<u64, SdkError>(a.try_into()?))
1281 .transpose()?,
1282 Some(fee_sats),
1283 prefer_spark,
1284 transfer_id,
1285 ),
1286 )
1287 .await?;
1288 let payment = match payment_response.lightning_payment {
1289 Some(lightning_payment) => {
1290 let ssp_id = lightning_payment.id.clone();
1291 let htlc_details = payment_response
1292 .transfer
1293 .htlc_preimage_request
1294 .ok_or_else(|| {
1295 SdkError::Generic(
1296 "Missing HTLC details for Lightning send payment".to_string(),
1297 )
1298 })?
1299 .try_into()?;
1300 let payment = Payment::from_lightning(
1301 lightning_payment,
1302 amount,
1303 payment_response.transfer.id.to_string(),
1304 htlc_details,
1305 )?;
1306 self.poll_lightning_send_payment(&payment, ssp_id);
1307 payment
1308 }
1309 None => payment_response.transfer.try_into()?,
1310 };
1311
1312 let completion_timeout_secs = completion_timeout_secs.unwrap_or(0);
1313
1314 if completion_timeout_secs == 0 {
1315 self.storage.insert_payment(payment.clone()).await?;
1317
1318 return Ok(SendPaymentResponse { payment });
1319 }
1320
1321 let payment = self
1322 .wait_for_payment(
1323 WaitForPaymentIdentifier::PaymentId(payment.id.clone()),
1324 completion_timeout_secs,
1325 )
1326 .await
1327 .unwrap_or(payment);
1328
1329 self.storage.insert_payment(payment.clone()).await?;
1331
1332 Ok(SendPaymentResponse { payment })
1333 }
1334
1335 async fn send_bitcoin_address(
1336 &self,
1337 address: &BitcoinAddressDetails,
1338 fee_quote: &SendOnchainFeeQuote,
1339 request: &SendPaymentRequest,
1340 amount_override: Option<u64>,
1341 ) -> Result<SendPaymentResponse, SdkError> {
1342 let confirmation_speed = match &request.options {
1344 Some(SendPaymentOptions::BitcoinAddress { confirmation_speed }) => {
1345 confirmation_speed.clone()
1346 }
1347 None => OnchainConfirmationSpeed::Fast, _ => {
1349 return Err(SdkError::InvalidInput(
1350 "Invalid options for Bitcoin address payment".to_string(),
1351 ));
1352 }
1353 };
1354
1355 let exit_speed: ExitSpeed = confirmation_speed.clone().into();
1356
1357 let fee_sats = match confirmation_speed {
1359 OnchainConfirmationSpeed::Fast => fee_quote.speed_fast.total_fee_sat(),
1360 OnchainConfirmationSpeed::Medium => fee_quote.speed_medium.total_fee_sat(),
1361 OnchainConfirmationSpeed::Slow => fee_quote.speed_slow.total_fee_sat(),
1362 };
1363
1364 let total_sats: u64 =
1367 amount_override.unwrap_or(request.prepare_response.amount.try_into()?);
1368 let amount_sats = if request.prepare_response.fee_policy == FeePolicy::FeesIncluded {
1369 total_sats.saturating_sub(fee_sats)
1370 } else {
1371 total_sats
1372 };
1373
1374 let dust_limit_sats = get_dust_limit_sats(&address.address)?;
1376 if amount_sats < dust_limit_sats {
1377 return Err(SdkError::InvalidInput(format!(
1378 "Amount is below the minimum of {dust_limit_sats} sats required for this address"
1379 )));
1380 }
1381
1382 let transfer_id = request
1383 .idempotency_key
1384 .as_ref()
1385 .map(|idempotency_key| TransferId::from_str(idempotency_key))
1386 .transpose()?;
1387 let response = self
1388 .spark_wallet
1389 .withdraw(
1390 &address.address,
1391 Some(amount_sats),
1392 exit_speed,
1393 fee_quote.clone().into(),
1394 transfer_id,
1395 )
1396 .await?;
1397
1398 let payment: Payment = response.try_into()?;
1399
1400 self.storage.insert_payment(payment.clone()).await?;
1401
1402 Ok(SendPaymentResponse { payment })
1403 }
1404
1405 pub(super) async fn wait_for_payment(
1406 &self,
1407 identifier: WaitForPaymentIdentifier,
1408 completion_timeout_secs: u32,
1409 ) -> Result<Payment, SdkError> {
1410 let (tx, mut rx) = mpsc::channel(20);
1411 let id = self
1415 .event_emitter
1416 .add_internal_listener(Box::new(InternalEventListener::new(tx)))
1417 .await;
1418
1419 let result = async {
1422 let payment = match &identifier {
1424 WaitForPaymentIdentifier::PaymentId(payment_id) => self
1425 .storage
1426 .get_payment_by_id(payment_id.clone())
1427 .await
1428 .ok(),
1429 WaitForPaymentIdentifier::PaymentRequest(payment_request) => {
1430 self.storage
1431 .get_payment_by_invoice(payment_request.clone())
1432 .await?
1433 }
1434 };
1435 if let Some(payment) = payment
1436 && payment.status == PaymentStatus::Completed
1437 {
1438 return Ok(payment);
1439 }
1440
1441 timeout(Duration::from_secs(completion_timeout_secs.into()), async {
1442 loop {
1443 let Some(event) = rx.recv().await else {
1444 return Err(SdkError::Generic("Event channel closed".to_string()));
1445 };
1446
1447 let SdkEvent::PaymentSucceeded { payment } = event else {
1448 continue;
1449 };
1450
1451 if is_payment_match(&payment, &identifier) {
1452 return Ok(payment);
1453 }
1454 }
1455 })
1456 .await
1457 .map_err(|_| SdkError::Generic("Timeout waiting for payment".to_string()))?
1458 }
1459 .await;
1460
1461 self.event_emitter.remove_internal_listener(&id).await;
1462 result
1463 }
1464
1465 fn poll_lightning_send_payment(&self, payment: &Payment, ssp_id: String) {
1467 const MAX_POLL_ATTEMPTS: u32 = 20;
1468 let payment_id = payment.id.clone();
1469 info!("Polling lightning send payment {}", payment_id);
1470
1471 let Some(htlc_details) = payment.details.as_ref().and_then(|d| match d {
1472 PaymentDetails::Lightning { htlc_details, .. } => Some(htlc_details.clone()),
1473 _ => None,
1474 }) else {
1475 error!(
1476 "Missing HTLC details for lightning send payment {payment_id}, skipping polling"
1477 );
1478 return;
1479 };
1480 let spark_wallet = self.spark_wallet.clone();
1481 let storage = self.storage.clone();
1482 let sync_coordinator = self.sync_coordinator.clone();
1483 let event_emitter = self.event_emitter.clone();
1484 let payment = payment.clone();
1485 let payment_id = payment_id.clone();
1486 let mut shutdown = self.shutdown_sender.subscribe();
1487 let span = tracing::Span::current();
1488
1489 tokio::spawn(async move {
1490 for i in 0..MAX_POLL_ATTEMPTS {
1491 info!(
1492 "Polling lightning send payment {} attempt {}",
1493 payment_id, i
1494 );
1495 select! {
1496 _ = shutdown.changed() => {
1497 info!("Shutdown signal received");
1498 return;
1499 },
1500 p = spark_wallet.fetch_lightning_send_payment(&ssp_id) => {
1501 if let Ok(Some(p)) = p && let Ok(payment) = Payment::from_lightning(p.clone(), payment.amount, payment.id.clone(), htlc_details.clone()) {
1502 info!("Polling payment status = {} {:?}", payment.status, p.status);
1503 if payment.status != PaymentStatus::Pending {
1504 info!("Polling payment completed status = {}", payment.status);
1505 if let Err(e) = storage.insert_payment(payment.clone()).await {
1508 error!("Failed to update payment in storage: {e:?}");
1509 }
1510 get_payment_and_emit_event(&storage, &event_emitter, payment.clone()).await;
1512 sync_coordinator
1513 .trigger_sync_no_wait(SyncType::WalletState, true)
1514 .await;
1515 return;
1516 }
1517 }
1518
1519 let sleep_time = if i < 5 {
1520 Duration::from_secs(1)
1521 } else {
1522 Duration::from_secs(i.into())
1523 };
1524 tokio::time::sleep(sleep_time).await;
1525 }
1526 }
1527 }
1528 }.instrument(span));
1529 }
1530
1531 #[expect(clippy::too_many_arguments)]
1532 async fn convert_token_for_bolt11_invoice(
1533 &self,
1534 conversion_options: &ConversionOptions,
1535 spark_transfer_fee_sats: Option<u64>,
1536 lightning_fee_sats: u64,
1537 request: &SendPaymentRequest,
1538 conversion_purpose: &ConversionPurpose,
1539 amount: u128,
1540 token_identifier: Option<&String>,
1541 conversion_amount_override: Option<ConversionAmount>,
1542 ) -> Result<TokenConversionResponse, SdkError> {
1543 let conversion_amount = if let Some(ca) = conversion_amount_override {
1544 ca
1545 } else {
1546 let fee_sats = match request.options {
1548 Some(SendPaymentOptions::Bolt11Invoice { prefer_spark, .. }) => {
1549 match (prefer_spark, spark_transfer_fee_sats) {
1550 (true, Some(fee)) => fee,
1551 _ => lightning_fee_sats,
1552 }
1553 }
1554 _ => lightning_fee_sats,
1555 };
1556 let min_amount_out = amount.saturating_add(u128::from(fee_sats));
1558 ConversionAmount::MinAmountOut(min_amount_out)
1559 };
1560
1561 self.token_converter
1562 .convert(
1563 conversion_options,
1564 conversion_purpose,
1565 token_identifier,
1566 conversion_amount,
1567 None,
1568 )
1569 .await
1570 .map_err(Into::into)
1571 }
1572
1573 async fn get_conversion_options_for_payment(
1578 &self,
1579 options: Option<&ConversionOptions>,
1580 token_identifier: Option<&String>,
1581 payment_amount: u128,
1582 ) -> Result<Option<ConversionOptions>, SdkError> {
1583 if let Some(stable_balance) = &self.stable_balance {
1584 stable_balance
1585 .get_conversion_options(options, token_identifier, payment_amount)
1586 .await
1587 .map_err(Into::into)
1588 } else {
1589 Ok(options.cloned())
1590 }
1591 }
1592
1593 pub(super) async fn estimate_conversion(
1598 &self,
1599 request_options: Option<&ConversionOptions>,
1600 token_identifier: Option<&String>,
1601 conversion_amount: ConversionAmount,
1602 ) -> Result<Option<ConversionEstimate>, SdkError> {
1603 match conversion_amount {
1604 ConversionAmount::AmountIn(_) => self
1605 .token_converter
1606 .validate(request_options, token_identifier, conversion_amount)
1607 .await
1608 .map_err(Into::into),
1609 ConversionAmount::MinAmountOut(amount) => {
1610 let options = self
1611 .get_conversion_options_for_payment(request_options, token_identifier, amount)
1612 .await?;
1613 self.token_converter
1614 .validate(options.as_ref(), token_identifier, conversion_amount)
1615 .await
1616 .map_err(Into::into)
1617 }
1618 }
1619 }
1620
1621 pub(super) async fn is_token_conversion(
1623 &self,
1624 conversion_options: Option<&ConversionOptions>,
1625 token_identifier: Option<&String>,
1626 amount: Option<u128>,
1627 fee_policy: FeePolicy,
1628 ) -> Result<(bool, bool), SdkError> {
1629 let (
1630 Some(amount),
1631 Some(ConversionOptions {
1632 conversion_type:
1633 ConversionType::ToBitcoin {
1634 from_token_identifier,
1635 },
1636 ..
1637 }),
1638 ) = (amount, conversion_options)
1639 else {
1640 return Ok((false, false));
1641 };
1642
1643 let is_send_all = match token_identifier {
1648 Some(token_id) => {
1649 if token_id != from_token_identifier {
1650 return Err(SdkError::Generic(
1651 "Request token identifier must match conversion options".to_string(),
1652 ));
1653 }
1654 let token_balances = self.spark_wallet.get_token_balances().await?;
1655 let token_balance = token_balances.get(token_id).map_or(0, |tb| tb.balance);
1656 let has_active_stable_token = match &self.stable_balance {
1657 Some(sb) => sb.get_active_token_identifier().await.as_ref() == Some(token_id),
1658 None => false,
1659 };
1660 amount == token_balance
1661 && fee_policy == FeePolicy::FeesIncluded
1662 && has_active_stable_token
1663 }
1664 None => false,
1665 };
1666
1667 Ok((true, is_send_all))
1668 }
1669
1670 pub(super) async fn estimate_sats_from_token_conversion(
1687 &self,
1688 conversion_options: Option<&ConversionOptions>,
1689 token_identifier: Option<&String>,
1690 amount: u128,
1691 fee_policy: FeePolicy,
1692 ) -> Result<(u128, Option<ConversionEstimate>), SdkError> {
1693 let (is_token_conversion, is_send_all) = self
1694 .is_token_conversion(
1695 conversion_options,
1696 token_identifier,
1697 Some(amount),
1698 fee_policy,
1699 )
1700 .await?;
1701 if !is_token_conversion {
1702 return Ok((amount, None));
1703 }
1704
1705 let (conversion_amount, estimated_sats_from_conversion) = if token_identifier.is_some() {
1709 let estimate = self
1710 .estimate_conversion(
1711 conversion_options,
1712 token_identifier,
1713 ConversionAmount::AmountIn(amount),
1714 )
1715 .await?;
1716 let sats = estimate.as_ref().map_or(0, |e| e.amount_out);
1717 (estimate, sats)
1718 } else {
1719 let estimate = self
1720 .estimate_conversion(
1721 conversion_options,
1722 token_identifier,
1723 ConversionAmount::MinAmountOut(amount),
1724 )
1725 .await?;
1726 (estimate, amount)
1728 };
1729
1730 let estimated_sats = if is_send_all {
1733 let sat_balance = u128::from(self.spark_wallet.get_balance().await?);
1734 estimated_sats_from_conversion.saturating_add(sat_balance)
1735 } else {
1736 estimated_sats_from_conversion
1737 };
1738
1739 Ok((estimated_sats, conversion_amount))
1740 }
1741
1742 async fn resolve_send_amount_with_conversion_estimate(
1751 &self,
1752 conversion_options: Option<&ConversionOptions>,
1753 token_identifier: Option<&String>,
1754 amount: u128,
1755 fee_policy: FeePolicy,
1756 ) -> Result<(u128, Option<ConversionEstimate>), SdkError> {
1757 let (estimated_sats, conversion_estimate) = self
1758 .estimate_sats_from_token_conversion(
1759 conversion_options,
1760 token_identifier,
1761 amount,
1762 fee_policy,
1763 )
1764 .await?;
1765 if conversion_estimate.is_some() {
1766 return Ok((estimated_sats, conversion_estimate));
1767 }
1768 let estimate = self
1769 .estimate_conversion(
1770 conversion_options,
1771 token_identifier,
1772 ConversionAmount::MinAmountOut(amount),
1773 )
1774 .await?;
1775 Ok((amount, estimate))
1776 }
1777
1778 async fn maybe_prepare_bolt11_from_token_conversion(
1785 &self,
1786 request: &PrepareSendPaymentRequest,
1787 invoice: &Bolt11InvoiceDetails,
1788 spark_transfer_fee_sats: Option<u64>,
1789 token_identifier: Option<&String>,
1790 fee_policy: FeePolicy,
1791 ) -> Result<Option<PrepareSendPaymentResponse>, SdkError> {
1792 let Some(token_amount) = request.amount else {
1793 return Ok(None);
1794 };
1795 let (estimated_sats, conversion_estimate) = self
1796 .estimate_sats_from_token_conversion(
1797 request.conversion_options.as_ref(),
1798 token_identifier,
1799 token_amount,
1800 fee_policy,
1801 )
1802 .await?;
1803 if conversion_estimate.is_none() {
1804 return Ok(None);
1805 }
1806
1807 let lightning_fee_sats = self
1808 .spark_wallet
1809 .fetch_lightning_send_fee_estimate(
1810 &request.payment_request,
1811 Some(estimated_sats.try_into()?),
1812 )
1813 .await?;
1814
1815 let total_u64: u64 = estimated_sats.try_into()?;
1816 let min_required = if let Some(amount_msat) = invoice.amount_msat {
1819 (amount_msat / 1000).saturating_add(lightning_fee_sats)
1820 } else {
1821 lightning_fee_sats
1822 };
1823 if total_u64 <= min_required {
1824 return Err(SdkError::InvalidInput(
1825 "Token conversion amount too small to cover invoice amount and fees".to_string(),
1826 ));
1827 }
1828
1829 Ok(Some(PrepareSendPaymentResponse {
1830 payment_method: SendPaymentMethod::Bolt11Invoice {
1831 invoice_details: invoice.clone(),
1832 spark_transfer_fee_sats,
1833 lightning_fee_sats,
1834 },
1835 amount: estimated_sats,
1836 token_identifier: None,
1838 conversion_estimate,
1839 fee_policy,
1840 }))
1841 }
1842
1843 async fn maybe_prepare_bitcoin_from_token_conversion(
1850 &self,
1851 request: &PrepareSendPaymentRequest,
1852 withdrawal_address: &BitcoinAddressDetails,
1853 token_identifier: Option<&String>,
1854 fee_policy: FeePolicy,
1855 ) -> Result<Option<PrepareSendPaymentResponse>, SdkError> {
1856 let Some(token_amount) = request.amount else {
1857 return Ok(None);
1858 };
1859 let (estimated_sats, conversion_estimate) = self
1860 .estimate_sats_from_token_conversion(
1861 request.conversion_options.as_ref(),
1862 token_identifier,
1863 token_amount,
1864 fee_policy,
1865 )
1866 .await?;
1867 if conversion_estimate.is_none() {
1868 return Ok(None);
1869 }
1870
1871 let dust_limit_sats = get_dust_limit_sats(&withdrawal_address.address)?;
1872 let total_u64: u64 = estimated_sats.try_into()?;
1873 if total_u64 < dust_limit_sats {
1874 return Err(SdkError::InvalidInput(format!(
1875 "Amount is below the minimum of {dust_limit_sats} sats required for this address"
1876 )));
1877 }
1878
1879 let fee_quote: SendOnchainFeeQuote = self
1882 .spark_wallet
1883 .fetch_coop_exit_fee_quote(&withdrawal_address.address, None)
1884 .await?
1885 .into();
1886
1887 let min_fee_sats = fee_quote.speed_slow.total_fee_sat();
1888 let output_amount_sats = total_u64.saturating_sub(min_fee_sats);
1889 if output_amount_sats < dust_limit_sats {
1890 return Err(SdkError::InvalidInput(format!(
1891 "Amount is below the minimum of {dust_limit_sats} sats required for this address after lowest fees of {min_fee_sats} sats"
1892 )));
1893 }
1894
1895 Ok(Some(PrepareSendPaymentResponse {
1896 payment_method: SendPaymentMethod::BitcoinAddress {
1897 address: withdrawal_address.clone(),
1898 fee_quote,
1899 },
1900 amount: estimated_sats,
1901 token_identifier: None,
1903 conversion_estimate,
1904 fee_policy,
1905 }))
1906 }
1907
1908 #[allow(clippy::too_many_arguments)]
1909 async fn convert_token_for_bitcoin_address(
1910 &self,
1911 conversion_options: &ConversionOptions,
1912 fee_quote: &SendOnchainFeeQuote,
1913 request: &SendPaymentRequest,
1914 conversion_purpose: &ConversionPurpose,
1915 amount: u128,
1916 token_identifier: Option<&String>,
1917 conversion_amount_override: Option<ConversionAmount>,
1918 ) -> Result<TokenConversionResponse, SdkError> {
1919 let conversion_amount = if let Some(ca) = conversion_amount_override {
1920 ca
1921 } else {
1922 let fee_sats = match &request.options {
1924 Some(SendPaymentOptions::BitcoinAddress { confirmation_speed }) => {
1925 match confirmation_speed {
1926 OnchainConfirmationSpeed::Slow => fee_quote.speed_slow.total_fee_sat(),
1927 OnchainConfirmationSpeed::Medium => fee_quote.speed_medium.total_fee_sat(),
1928 OnchainConfirmationSpeed::Fast => fee_quote.speed_fast.total_fee_sat(),
1929 }
1930 }
1931 _ => fee_quote.speed_fast.total_fee_sat(), };
1933 let min_amount_out = amount.saturating_add(u128::from(fee_sats));
1935 ConversionAmount::MinAmountOut(min_amount_out)
1936 };
1937
1938 self.token_converter
1939 .convert(
1940 conversion_options,
1941 conversion_purpose,
1942 token_identifier,
1943 conversion_amount,
1944 None,
1945 )
1946 .await
1947 .map_err(Into::into)
1948 }
1949}