breez_sdk_spark/sdk/
payments.rs

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