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