breez_sdk_spark/sdk/
payments.rs

1use bitcoin::hashes::sha256;
2use platform_utils::time::Duration;
3use spark_wallet::{ExitSpeed, SparkAddress, TransferId, TransferTokenOutput};
4use std::str::FromStr;
5use tokio::select;
6use tokio::sync::mpsc;
7use tokio::time::timeout;
8use tracing::{Instrument, error, info, warn};
9
10use crate::{
11    BitcoinAddressDetails, Bolt11InvoiceDetails, ClaimHtlcPaymentRequest, ClaimHtlcPaymentResponse,
12    ConversionEstimate, ConversionOptions, ConversionPurpose, ConversionType, FeePolicy,
13    FetchConversionLimitsRequest, FetchConversionLimitsResponse, GetPaymentRequest,
14    GetPaymentResponse, InputType, OnchainConfirmationSpeed, PaymentStatus, SendOnchainFeeQuote,
15    SendPaymentMethod, SendPaymentOptions, SparkHtlcOptions, SparkInvoiceDetails,
16    WaitForPaymentIdentifier,
17    error::SdkError,
18    events::SdkEvent,
19    models::{
20        ConversionStatus, ListPaymentsRequest, ListPaymentsResponse, Payment, PaymentDetails,
21        PrepareSendPaymentRequest, PrepareSendPaymentResponse, ReceivePaymentMethod,
22        ReceivePaymentRequest, ReceivePaymentResponse, SendPaymentRequest, SendPaymentResponse,
23        conversion_steps_from_payments,
24    },
25    persist::PaymentMetadata,
26    token_conversion::{
27        ConversionAmount, DEFAULT_CONVERSION_TIMEOUT_SECS, TokenConversionResponse,
28    },
29    utils::{
30        payments::{get_payment_and_emit_event, get_payment_with_conversion_details},
31        send_payment_validation::{get_dust_limit_sats, validate_prepare_send_payment_request},
32        token::map_and_persist_token_transaction,
33    },
34};
35use bitcoin::secp256k1::PublicKey;
36use platform_utils::time::SystemTime;
37use platform_utils::tokio;
38use spark_wallet::{InvoiceDescription, Preimage};
39
40use super::{
41    BreezSdk, SyncType,
42    helpers::{InternalEventListener, get_deposit_address, is_payment_match},
43};
44
45#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
46#[allow(clippy::needless_pass_by_value)]
47impl BreezSdk {
48    pub async fn receive_payment(
49        &self,
50        request: ReceivePaymentRequest,
51    ) -> Result<ReceivePaymentResponse, SdkError> {
52        self.ensure_spark_private_mode_initialized().await?;
53        match request.payment_method {
54            ReceivePaymentMethod::SparkAddress => Ok(ReceivePaymentResponse {
55                fee: 0,
56                payment_request: self
57                    .spark_wallet
58                    .get_spark_address()?
59                    .to_address_string()
60                    .map_err(|e| {
61                        SdkError::Generic(format!("Failed to convert Spark address to string: {e}"))
62                    })?,
63            }),
64            ReceivePaymentMethod::SparkInvoice {
65                amount,
66                token_identifier,
67                expiry_time,
68                description,
69                sender_public_key,
70            } => {
71                let invoice = self
72                    .spark_wallet
73                    .create_spark_invoice(
74                        amount,
75                        token_identifier.clone(),
76                        expiry_time
77                            .map(|time| {
78                                SystemTime::UNIX_EPOCH
79                                    .checked_add(Duration::from_secs(time))
80                                    .ok_or(SdkError::Generic("Invalid expiry time".to_string()))
81                            })
82                            .transpose()?,
83                        description,
84                        sender_public_key.map(|key| PublicKey::from_str(&key).unwrap()),
85                    )
86                    .await?;
87                Ok(ReceivePaymentResponse {
88                    fee: 0,
89                    payment_request: invoice,
90                })
91            }
92            ReceivePaymentMethod::BitcoinAddress { new_address } => {
93                let address =
94                    get_deposit_address(&self.spark_wallet, new_address.unwrap_or(false)).await?;
95                Ok(ReceivePaymentResponse {
96                    payment_request: address,
97                    fee: 0,
98                })
99            }
100            ReceivePaymentMethod::Bolt11Invoice {
101                description,
102                amount_sats,
103                expiry_secs,
104                payment_hash,
105            } => {
106                self.receive_bolt11_invoice(description, amount_sats, expiry_secs, payment_hash)
107                    .await
108            }
109        }
110    }
111
112    pub async fn claim_htlc_payment(
113        &self,
114        request: ClaimHtlcPaymentRequest,
115    ) -> Result<ClaimHtlcPaymentResponse, SdkError> {
116        let preimage = Preimage::from_hex(&request.preimage)
117            .map_err(|_| SdkError::InvalidInput("Invalid preimage".to_string()))?;
118        let payment_hash = preimage.compute_hash();
119
120        // Check if there is a claimable HTLC with the given payment hash
121        let claimable_htlc_transfers = self
122            .spark_wallet
123            .list_claimable_htlc_transfers(None)
124            .await?;
125        if !claimable_htlc_transfers
126            .iter()
127            .filter_map(|t| t.htlc_preimage_request.as_ref())
128            .any(|p| p.payment_hash == payment_hash)
129        {
130            return Err(SdkError::InvalidInput(
131                "No claimable HTLC with the given payment hash".to_string(),
132            ));
133        }
134
135        let transfer = self.spark_wallet.claim_htlc(&preimage).await?;
136        let payment: Payment = transfer.try_into()?;
137
138        // Insert the payment into storage to make it immediately available for listing
139        self.storage.insert_payment(payment.clone()).await?;
140
141        Ok(ClaimHtlcPaymentResponse { payment })
142    }
143
144    #[allow(clippy::too_many_lines)]
145    pub async fn prepare_send_payment(
146        &self,
147        request: PrepareSendPaymentRequest,
148    ) -> Result<PrepareSendPaymentResponse, SdkError> {
149        let parsed_input = self.parse(&request.payment_request).await?;
150
151        validate_prepare_send_payment_request(
152            &parsed_input,
153            &request,
154            &self.spark_wallet.get_identity_public_key().to_string(),
155        )?;
156
157        let fee_policy = request.fee_policy.unwrap_or_default();
158        let token_identifier = request.token_identifier.clone();
159
160        match &parsed_input {
161            InputType::SparkAddress(spark_address_details) => {
162                let amount = request
163                    .amount
164                    .ok_or(SdkError::InvalidInput("Amount is required".to_string()))?;
165
166                let (amount, conversion_estimate) = self
167                    .resolve_send_amount_with_conversion_estimate(
168                        request.conversion_options.as_ref(),
169                        request.token_identifier.as_ref(),
170                        amount,
171                        fee_policy,
172                    )
173                    .await?;
174
175                // For ToBitcoin conversions, the output is sats — clear token_identifier
176                // so it reflects the output denomination, not the input.
177                let is_to_bitcoin = matches!(
178                    conversion_estimate,
179                    Some(ConversionEstimate {
180                        options: ConversionOptions {
181                            conversion_type: ConversionType::ToBitcoin { .. },
182                            ..
183                        },
184                        ..
185                    })
186                );
187                let response_token_identifier = if is_to_bitcoin {
188                    None
189                } else {
190                    token_identifier.clone()
191                };
192
193                Ok(PrepareSendPaymentResponse {
194                    payment_method: SendPaymentMethod::SparkAddress {
195                        address: spark_address_details.address.clone(),
196                        fee: 0,
197                        token_identifier: response_token_identifier.clone(),
198                    },
199                    amount,
200                    token_identifier: response_token_identifier,
201                    conversion_estimate,
202                    fee_policy,
203                })
204            }
205            InputType::SparkInvoice(spark_invoice_details) => {
206                // Use request's token_identifier if provided, otherwise fall back to invoice's
207                let effective_token_identifier =
208                    token_identifier.or_else(|| spark_invoice_details.token_identifier.clone());
209
210                let amount = spark_invoice_details
211                    .amount
212                    .or(request.amount)
213                    .ok_or(SdkError::InvalidInput("Amount is required".to_string()))?;
214
215                let (amount, conversion_estimate) = self
216                    .resolve_send_amount_with_conversion_estimate(
217                        request.conversion_options.as_ref(),
218                        effective_token_identifier.as_ref(),
219                        amount,
220                        fee_policy,
221                    )
222                    .await?;
223
224                let is_to_bitcoin = matches!(
225                    conversion_estimate,
226                    Some(ConversionEstimate {
227                        options: ConversionOptions {
228                            conversion_type: ConversionType::ToBitcoin { .. },
229                            ..
230                        },
231                        ..
232                    })
233                );
234                let response_token_identifier = if is_to_bitcoin {
235                    None
236                } else {
237                    effective_token_identifier.clone()
238                };
239
240                Ok(PrepareSendPaymentResponse {
241                    payment_method: SendPaymentMethod::SparkInvoice {
242                        spark_invoice_details: spark_invoice_details.clone(),
243                        fee: 0,
244                        token_identifier: response_token_identifier.clone(),
245                    },
246                    amount,
247                    token_identifier: response_token_identifier,
248                    conversion_estimate,
249                    fee_policy,
250                })
251            }
252            InputType::Bolt11Invoice(detailed_bolt11_invoice) => {
253                let spark_address: Option<SparkAddress> = self
254                    .spark_wallet
255                    .extract_spark_address(&request.payment_request)?;
256
257                let spark_transfer_fee_sats = if spark_address.is_some() {
258                    Some(0)
259                } else {
260                    None
261                };
262
263                if let Some(response) = self
264                    .maybe_prepare_bolt11_from_token_conversion(
265                        &request,
266                        detailed_bolt11_invoice,
267                        spark_transfer_fee_sats,
268                        token_identifier.as_ref(),
269                        fee_policy,
270                    )
271                    .await?
272                {
273                    return Ok(response);
274                }
275
276                let amount = request
277                    .amount
278                    .or(detailed_bolt11_invoice
279                        .amount_msat
280                        .map(|msat| u128::from(msat).saturating_div(1000)))
281                    .ok_or(SdkError::InvalidInput("Amount is required".to_string()))?;
282
283                // For FeesIncluded, estimate fee for user's full amount
284                let lightning_fee_sats = self
285                    .spark_wallet
286                    .fetch_lightning_send_fee_estimate(
287                        &request.payment_request,
288                        Some(amount.try_into()?),
289                    )
290                    .await?;
291
292                // Validate receiver amount is positive for FeesIncluded
293                if fee_policy == FeePolicy::FeesIncluded
294                    && detailed_bolt11_invoice.amount_msat.is_none()
295                {
296                    let amount_u64: u64 = amount.try_into()?;
297                    if amount_u64 <= lightning_fee_sats {
298                        return Err(SdkError::InvalidInput(
299                            "Amount too small to cover fees".to_string(),
300                        ));
301                    }
302                }
303
304                let conversion_estimate = self
305                    .estimate_conversion(
306                        request.conversion_options.as_ref(),
307                        token_identifier.as_ref(),
308                        ConversionAmount::MinAmountOut(
309                            amount.saturating_add(u128::from(lightning_fee_sats)),
310                        ),
311                    )
312                    .await?;
313
314                Ok(PrepareSendPaymentResponse {
315                    payment_method: SendPaymentMethod::Bolt11Invoice {
316                        invoice_details: detailed_bolt11_invoice.clone(),
317                        spark_transfer_fee_sats,
318                        lightning_fee_sats,
319                    },
320                    amount,
321                    token_identifier,
322                    conversion_estimate,
323                    fee_policy,
324                })
325            }
326            InputType::BitcoinAddress(withdrawal_address) => {
327                if let Some(response) = self
328                    .maybe_prepare_bitcoin_from_token_conversion(
329                        &request,
330                        withdrawal_address,
331                        token_identifier.as_ref(),
332                        fee_policy,
333                    )
334                    .await?
335                {
336                    return Ok(response);
337                }
338
339                let amount = request
340                    .amount
341                    .ok_or(SdkError::InvalidInput("Amount is required".to_string()))?;
342
343                // Validate the amount meets the dust limit before making any network calls.
344                // For FeesIncluded the output will be smaller after fees, but if the total
345                // amount is already below dust there's no point fetching a fee quote.
346                let dust_limit_sats = get_dust_limit_sats(&withdrawal_address.address)?;
347                let amount_u64: u64 = amount.try_into()?;
348                if amount_u64 < dust_limit_sats {
349                    return Err(SdkError::InvalidInput(format!(
350                        "Amount is below the minimum of {dust_limit_sats} sats required for this address"
351                    )));
352                }
353
354                // When stable balance is active (has an active label), sats come
355                // from token conversion so they don't exist yet — pass None to
356                // skip leaf selection.
357                let stable_balance_active = match &self.stable_balance {
358                    Some(sb) => sb.get_active_label().await.is_some(),
359                    None => false,
360                };
361                let fee_quote_amount = if stable_balance_active {
362                    None
363                } else {
364                    Some(amount.try_into()?)
365                };
366                let fee_quote: SendOnchainFeeQuote = self
367                    .spark_wallet
368                    .fetch_coop_exit_fee_quote(&withdrawal_address.address, fee_quote_amount)
369                    .await?
370                    .into();
371
372                // For FeesIncluded, validate the output after fees using the best case
373                // (slow/lowest fee). Only reject if even the cheapest option results in dust.
374                if fee_policy == FeePolicy::FeesIncluded {
375                    let min_fee_sats = fee_quote.speed_slow.total_fee_sat();
376                    let output_amount_sats = amount_u64.saturating_sub(min_fee_sats);
377                    if output_amount_sats < dust_limit_sats {
378                        return Err(SdkError::InvalidInput(format!(
379                            "Amount is below the minimum of {dust_limit_sats} sats required for this address after lowest fees of {min_fee_sats} sats"
380                        )));
381                    }
382                }
383
384                // For conversion estimate, use fast fee as worst case
385                let conversion_estimate = self
386                    .estimate_conversion(
387                        request.conversion_options.as_ref(),
388                        token_identifier.as_ref(),
389                        ConversionAmount::MinAmountOut(
390                            amount.saturating_add(u128::from(fee_quote.speed_fast.total_fee_sat())),
391                        ),
392                    )
393                    .await?;
394
395                Ok(PrepareSendPaymentResponse {
396                    payment_method: SendPaymentMethod::BitcoinAddress {
397                        address: withdrawal_address.clone(),
398                        fee_quote,
399                    },
400                    amount,
401                    token_identifier,
402                    conversion_estimate,
403                    fee_policy,
404                })
405            }
406            _ => Err(SdkError::InvalidInput(
407                "Unsupported payment method".to_string(),
408            )),
409        }
410    }
411
412    pub async fn send_payment(
413        &self,
414        request: SendPaymentRequest,
415    ) -> Result<SendPaymentResponse, SdkError> {
416        self.ensure_spark_private_mode_initialized().await?;
417        Box::pin(self.maybe_convert_token_send_payment(request, false, None)).await
418    }
419
420    pub async fn fetch_conversion_limits(
421        &self,
422        request: FetchConversionLimitsRequest,
423    ) -> Result<FetchConversionLimitsResponse, SdkError> {
424        self.token_converter
425            .fetch_limits(&request)
426            .await
427            .map_err(Into::into)
428    }
429
430    /// Lists payments from the storage with pagination
431    ///
432    /// This method provides direct access to the payment history stored in the database.
433    /// It returns payments in reverse chronological order (newest first).
434    ///
435    /// # Arguments
436    ///
437    /// * `request` - Contains pagination parameters (offset and limit)
438    ///
439    /// # Returns
440    ///
441    /// * `Ok(ListPaymentsResponse)` - Contains the list of payments if successful
442    /// * `Err(SdkError)` - If there was an error accessing the storage
443    pub async fn list_payments(
444        &self,
445        request: ListPaymentsRequest,
446    ) -> Result<ListPaymentsResponse, SdkError> {
447        let mut payments = self.storage.list_payments(request.into()).await?;
448
449        // Only query child payments for payments that have conversion_details set
450        let parent_ids: Vec<String> = payments
451            .iter()
452            .filter(|p| p.conversion_details.is_some())
453            .map(|p| p.id.clone())
454            .collect();
455
456        if !parent_ids.is_empty() {
457            let related_payments_map = self.storage.get_payments_by_parent_ids(parent_ids).await?;
458
459            for payment in &mut payments {
460                if let Some(related_payments) = related_payments_map.get(&payment.id) {
461                    match conversion_steps_from_payments(related_payments) {
462                        Ok((from, to)) => {
463                            if let Some(ref mut cd) = payment.conversion_details {
464                                cd.from = from;
465                                cd.to = to;
466                            }
467                        }
468                        Err(e) => {
469                            warn!("Failed to build conversion steps: {e}");
470                        }
471                    }
472                }
473            }
474        }
475
476        Ok(ListPaymentsResponse { payments })
477    }
478
479    pub async fn get_payment(
480        &self,
481        request: GetPaymentRequest,
482    ) -> Result<GetPaymentResponse, SdkError> {
483        let payment =
484            get_payment_with_conversion_details(request.payment_id, self.storage.clone()).await?;
485
486        Ok(GetPaymentResponse { payment })
487    }
488}
489
490// Private payment methods
491impl BreezSdk {
492    pub(crate) async fn receive_bolt11_invoice(
493        &self,
494        description: String,
495        amount_sats: Option<u64>,
496        expiry_secs: Option<u32>,
497        payment_hash: Option<String>,
498    ) -> Result<ReceivePaymentResponse, SdkError> {
499        let invoice = if let Some(payment_hash_hex) = payment_hash {
500            let hash = sha256::Hash::from_str(&payment_hash_hex)
501                .map_err(|e| SdkError::InvalidInput(format!("Invalid payment hash: {e}")))?;
502            self.spark_wallet
503                .create_hodl_lightning_invoice(
504                    amount_sats.unwrap_or_default(),
505                    Some(InvoiceDescription::Memo(description.clone())),
506                    hash,
507                    None,
508                    expiry_secs,
509                )
510                .await?
511                .invoice
512        } else {
513            self.spark_wallet
514                .create_lightning_invoice(
515                    amount_sats.unwrap_or_default(),
516                    Some(InvoiceDescription::Memo(description.clone())),
517                    None,
518                    expiry_secs,
519                    self.config.prefer_spark_over_lightning,
520                )
521                .await?
522                .invoice
523        };
524        Ok(ReceivePaymentResponse {
525            payment_request: invoice,
526            fee: 0,
527        })
528    }
529
530    pub(super) async fn maybe_convert_token_send_payment(
531        &self,
532        request: SendPaymentRequest,
533        mut suppress_payment_event: bool,
534        amount_override: Option<u64>,
535    ) -> Result<SendPaymentResponse, SdkError> {
536        let token_identifier = request.prepare_response.token_identifier.clone();
537
538        // Check the idempotency key is valid and payment doesn't already exist
539        if request.idempotency_key.is_some() && token_identifier.is_some() {
540            return Err(SdkError::InvalidInput(
541                "Idempotency key is not supported for token payments".to_string(),
542            ));
543        }
544        if let Some(idempotency_key) = &request.idempotency_key {
545            // If an idempotency key is provided, check if a payment with that id already exists
546            if let Ok(payment) = self
547                .storage
548                .get_payment_by_id(idempotency_key.clone())
549                .await
550            {
551                return Ok(SendPaymentResponse { payment });
552            }
553        }
554        let conversion_estimate = request.prepare_response.conversion_estimate.clone();
555        // Perform the send payment, with conversion if requested
556        let res = if let Some(ConversionEstimate {
557            options: conversion_options,
558            ..
559        }) = &conversion_estimate
560        {
561            Box::pin(self.convert_token_send_payment_internal(
562                conversion_options,
563                &request,
564                amount_override,
565                &mut suppress_payment_event,
566            ))
567            .await
568        } else {
569            Box::pin(self.send_payment_internal(&request, amount_override)).await
570        };
571        // Emit payment status event and trigger wallet state sync
572        if let Ok(response) = &res {
573            if !suppress_payment_event {
574                // Emit the payment with metadata already included
575                self.event_emitter
576                    .emit(&SdkEvent::from_payment(response.payment.clone()))
577                    .await;
578            }
579            self.sync_coordinator
580                .trigger_sync_no_wait(SyncType::WalletState, true)
581                .await;
582        }
583        res
584    }
585
586    async fn convert_token_send_payment_internal(
587        &self,
588        conversion_options: &ConversionOptions,
589        request: &SendPaymentRequest,
590        caller_amount_override: Option<u64>,
591        suppress_payment_event: &mut bool,
592    ) -> Result<SendPaymentResponse, SdkError> {
593        // Suppress auto-convert while this send-with-conversion is in flight
594        let _payment_guard = match &self.stable_balance {
595            Some(sb) => Some(sb.acquire_payment_guard().await),
596            None => None,
597        };
598
599        // Step 1: Execute the token conversion
600        let (conversion_response, conversion_purpose, uses_amount_in) = self
601            .execute_pre_send_conversion(conversion_options, request)
602            .await?;
603
604        // Step 2: Early-link conversion children (self-transfer only)
605        self.pre_link_conversion_children(&conversion_response, &conversion_purpose)
606            .await?;
607
608        // Step 3: Trigger sync, wait for conversion, then send
609        self.complete_conversion_and_send(
610            conversion_options,
611            &conversion_response,
612            &conversion_purpose,
613            request,
614            uses_amount_in,
615            caller_amount_override,
616            suppress_payment_event,
617        )
618        .await
619        // _payment_guard drops here, releasing the lock and waking the conversion worker
620    }
621
622    /// Executes the token conversion for the given payment method.
623    ///
624    /// Returns the conversion response, purpose (self-transfer vs ongoing payment),
625    /// and whether the conversion used `AmountIn` (needed by `complete_conversion_and_send`
626    /// to compute the amount override).
627    ///
628    /// Chooses the conversion direction based on whether the prepare used `AmountIn`
629    /// (user specified token amount, `amount == estimate.amount_out`) or `MinAmountOut`
630    /// (user specified sats). For `MinAmountOut`, the per-payment-method paths expand to
631    /// `MinAmountOut(amount + fees)` so the converter delivers enough to cover the send.
632    #[allow(clippy::too_many_lines)]
633    async fn execute_pre_send_conversion(
634        &self,
635        conversion_options: &ConversionOptions,
636        request: &SendPaymentRequest,
637    ) -> Result<(TokenConversionResponse, ConversionPurpose, bool), SdkError> {
638        let amount = request.prepare_response.amount;
639
640        // Extract from_token_identifier from conversion options for ToBitcoin conversions.
641        let from_token_identifier = match &conversion_options.conversion_type {
642            ConversionType::ToBitcoin {
643                from_token_identifier,
644            } => Some(from_token_identifier.clone()),
645            ConversionType::FromBitcoin => None,
646        };
647
648        // AmountIn vs MinAmountOut at convert time:
649        // For AmountIn (user specified token amount), the prepare's `amount` is
650        // derived from `estimate.amount_out` (+ optional sat balance), so
651        // `amount >= estimate.amount_out`.
652        // For MinAmountOut (user specified sats), the converter guarantees
653        // `estimate.amount_out >= amount`, so `amount <= estimate.amount_out`
654        // (strictly less when there's conversion slack).
655        let uses_amount_in = request
656            .prepare_response
657            .conversion_estimate
658            .as_ref()
659            .is_some_and(|e| amount >= e.amount_out);
660        let conversion_amount = if uses_amount_in {
661            let token_amount = request
662                .prepare_response
663                .conversion_estimate
664                .as_ref()
665                .map(|e| e.amount_in)
666                .ok_or(SdkError::InvalidInput(
667                    "Conversion estimate required for token conversion".to_string(),
668                ))?;
669            ConversionAmount::AmountIn(token_amount)
670        } else {
671            ConversionAmount::MinAmountOut(amount)
672        };
673
674        match &request.prepare_response.payment_method {
675            SendPaymentMethod::SparkAddress { address, .. } => {
676                let spark_address = address
677                    .parse::<SparkAddress>()
678                    .map_err(|_| SdkError::InvalidInput("Invalid spark address".to_string()))?;
679                let purpose = if spark_address.identity_public_key
680                    == self.spark_wallet.get_identity_public_key()
681                {
682                    ConversionPurpose::SelfTransfer
683                } else {
684                    ConversionPurpose::OngoingPayment {
685                        payment_request: address.clone(),
686                    }
687                };
688                let response = self
689                    .token_converter
690                    .convert(
691                        conversion_options,
692                        &purpose,
693                        from_token_identifier.as_ref(),
694                        conversion_amount,
695                        None,
696                    )
697                    .await?;
698                Ok((response, purpose, uses_amount_in))
699            }
700            SendPaymentMethod::SparkInvoice {
701                spark_invoice_details:
702                    SparkInvoiceDetails {
703                        identity_public_key,
704                        invoice,
705                        ..
706                    },
707                ..
708            } => {
709                let own_identity_public_key =
710                    self.spark_wallet.get_identity_public_key().to_string();
711                let purpose = if identity_public_key == &own_identity_public_key {
712                    ConversionPurpose::SelfTransfer
713                } else {
714                    ConversionPurpose::OngoingPayment {
715                        payment_request: invoice.clone(),
716                    }
717                };
718                let response = self
719                    .token_converter
720                    .convert(
721                        conversion_options,
722                        &purpose,
723                        from_token_identifier.as_ref(),
724                        conversion_amount,
725                        None,
726                    )
727                    .await?;
728                Ok((response, purpose, uses_amount_in))
729            }
730            SendPaymentMethod::Bolt11Invoice {
731                spark_transfer_fee_sats,
732                lightning_fee_sats,
733                invoice_details,
734                ..
735            } => {
736                let purpose = ConversionPurpose::OngoingPayment {
737                    payment_request: invoice_details.invoice.bolt11.clone(),
738                };
739                let conversion_amount_override = match &conversion_amount {
740                    ConversionAmount::AmountIn(_) => Some(conversion_amount),
741                    ConversionAmount::MinAmountOut(_) => None,
742                };
743                let response = self
744                    .convert_token_for_bolt11_invoice(
745                        conversion_options,
746                        *spark_transfer_fee_sats,
747                        *lightning_fee_sats,
748                        request,
749                        &purpose,
750                        amount,
751                        from_token_identifier.as_ref(),
752                        conversion_amount_override,
753                    )
754                    .await?;
755                Ok((response, purpose, uses_amount_in))
756            }
757            SendPaymentMethod::BitcoinAddress { address, fee_quote } => {
758                let purpose = ConversionPurpose::OngoingPayment {
759                    payment_request: address.address.clone(),
760                };
761                let conversion_amount_override = match &conversion_amount {
762                    ConversionAmount::AmountIn(_) => Some(conversion_amount),
763                    ConversionAmount::MinAmountOut(_) => None,
764                };
765                let response = self
766                    .convert_token_for_bitcoin_address(
767                        conversion_options,
768                        fee_quote,
769                        request,
770                        &purpose,
771                        amount,
772                        from_token_identifier.as_ref(),
773                        conversion_amount_override,
774                    )
775                    .await?;
776                Ok((response, purpose, uses_amount_in))
777            }
778        }
779    }
780
781    /// Links conversion child payments to their parent to hide them from `list_payments`.
782    ///
783    /// Only self-transfers are linked immediately (parent is the conversion receive, known upfront).
784    /// All other cases are deferred until after the actual send completes.
785    async fn pre_link_conversion_children(
786        &self,
787        conversion_response: &TokenConversionResponse,
788        conversion_purpose: &ConversionPurpose,
789    ) -> Result<(), SdkError> {
790        if *conversion_purpose == ConversionPurpose::SelfTransfer {
791            self.storage
792                .insert_payment_metadata(
793                    conversion_response.sent_payment_id.clone(),
794                    PaymentMetadata {
795                        parent_payment_id: Some(conversion_response.received_payment_id.clone()),
796                        ..Default::default()
797                    },
798                )
799                .await?;
800        }
801        Ok(())
802    }
803
804    /// Waits for conversion to complete, then sends the actual payment.
805    ///
806    /// For self-transfers, returns immediately after conversion completes.
807    /// For ongoing payments, sends the actual payment and links any remaining children.
808    /// For `AmountIn` conversions, computes `amount_override = converted_sats + sats_change`
809    /// where `sats_change` is the difference between the prepare amount and the estimated
810    /// conversion output (representing any existing sat balance included at prepare time).
811    /// If `caller_amount_override` is provided (e.g. from the LNURL flow which handles
812    /// its own fee logic), it takes precedence over the computed override.
813    #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
814    async fn complete_conversion_and_send(
815        &self,
816        conversion_options: &ConversionOptions,
817        conversion_response: &TokenConversionResponse,
818        conversion_purpose: &ConversionPurpose,
819        request: &SendPaymentRequest,
820        uses_amount_in: bool,
821        caller_amount_override: Option<u64>,
822        suppress_payment_event: &mut bool,
823    ) -> Result<SendPaymentResponse, SdkError> {
824        // Trigger a wallet state sync if converting from Bitcoin to token
825        if matches!(
826            conversion_options.conversion_type,
827            ConversionType::FromBitcoin
828        ) {
829            self.sync_coordinator
830                .trigger_sync_no_wait(SyncType::WalletState, true)
831                .await;
832        }
833
834        // Wait for the received conversion payment to complete
835        let payment = self
836            .wait_for_payment(
837                WaitForPaymentIdentifier::PaymentId(
838                    conversion_response.received_payment_id.clone(),
839                ),
840                conversion_options
841                    .completion_timeout_secs
842                    .unwrap_or(DEFAULT_CONVERSION_TIMEOUT_SECS),
843            )
844            .await
845            .map_err(|e| {
846                SdkError::Generic(format!("Timeout waiting for conversion to complete: {e}"))
847            })?;
848
849        // For self-transfers, suppress the event and return
850        if *conversion_purpose == ConversionPurpose::SelfTransfer {
851            *suppress_payment_event = true;
852            return Ok(SendPaymentResponse { payment });
853        }
854
855        // Determine the amount to use for the actual send.
856        //
857        // If the caller provided an amount_override (e.g. LNURL flow with its own
858        // fee logic), use it directly.
859        //
860        // For AmountIn conversions (user specified token amount), the prepare's
861        // `amount` includes estimated conversion output + any existing sat balance
862        // (sats_change). At send time, use the actual converted sats + sats_change
863        // to honor the prepare estimate while accounting for slippage.
864        // This unifies send-all (sats_change > 0) and non-send-all (sats_change = 0).
865        //
866        // For MinAmountOut conversions (user specified sats, e.g. auto stable
867        // balance), the conversion guarantees ≥ requested sats, so no override is
868        // needed — send exactly the prepared amount.
869        let amount_override = if let Some(override_amount) = caller_amount_override {
870            tracing::trace!(
871                override_amount,
872                "complete_conversion_and_send: using caller-provided amount_override"
873            );
874            Some(override_amount)
875        } else if uses_amount_in {
876            let converted_sats: u64 = payment
877                .amount
878                .try_into()
879                .map_err(|_| SdkError::Generic("Converted sats too large for u64".to_string()))?;
880            let estimated_conversion_out: u64 = request
881                .prepare_response
882                .conversion_estimate
883                .as_ref()
884                .map_or(0, |e| e.amount_out)
885                .try_into()
886                .map_err(|_| SdkError::Generic("Estimated sats too large for u64".to_string()))?;
887            let sats_change = request
888                .prepare_response
889                .amount
890                .try_into()
891                .map(|amount: u64| amount.saturating_sub(estimated_conversion_out))
892                .unwrap_or(0);
893            let total = converted_sats.saturating_add(sats_change);
894            tracing::trace!(
895                converted_sats,
896                estimated_conversion_out,
897                sats_change,
898                total,
899                prepared_amount = request.prepare_response.amount,
900                fee_policy = ?request.prepare_response.fee_policy,
901                "complete_conversion_and_send: amount_override = converted_sats + sats_change"
902            );
903            Some(total)
904        } else {
905            tracing::trace!(
906                prepared_amount = request.prepare_response.amount,
907                fee_policy = ?request.prepare_response.fee_policy,
908                "complete_conversion_and_send: no override (MinAmountOut conversion)"
909            );
910            None
911        };
912
913        // Now send the actual payment
914        let response = Box::pin(self.send_payment_internal(request, amount_override)).await?;
915
916        // Link conversion children to the send payment (deferred linking)
917        self.storage
918            .insert_payment_metadata(
919                conversion_response.sent_payment_id.clone(),
920                PaymentMetadata {
921                    parent_payment_id: Some(response.payment.id.clone()),
922                    ..Default::default()
923                },
924            )
925            .await?;
926        self.storage
927            .insert_payment_metadata(
928                conversion_response.received_payment_id.clone(),
929                PaymentMetadata {
930                    parent_payment_id: Some(response.payment.id.clone()),
931                    ..Default::default()
932                },
933            )
934            .await?;
935
936        // Persist Completed status on the actual send payment
937        self.storage
938            .insert_payment_metadata(
939                response.payment.id.clone(),
940                PaymentMetadata {
941                    conversion_status: Some(ConversionStatus::Completed),
942                    ..Default::default()
943                },
944            )
945            .await?;
946
947        // Fetch the updated payment with conversion details
948        get_payment_with_conversion_details(response.payment.id, self.storage.clone())
949            .await
950            .map(|payment| SendPaymentResponse { payment })
951    }
952
953    pub(super) async fn send_payment_internal(
954        &self,
955        request: &SendPaymentRequest,
956        amount_override: Option<u64>,
957    ) -> Result<SendPaymentResponse, SdkError> {
958        let amount = request.prepare_response.amount;
959        let token_identifier = request.prepare_response.token_identifier.clone();
960
961        match &request.prepare_response.payment_method {
962            SendPaymentMethod::SparkAddress { address, .. } => {
963                Box::pin(self.send_spark_address(
964                    address,
965                    token_identifier,
966                    amount_override.map_or(amount, u128::from),
967                    request.options.as_ref(),
968                    request.idempotency_key.clone(),
969                ))
970                .await
971            }
972            SendPaymentMethod::SparkInvoice {
973                spark_invoice_details,
974                ..
975            } => {
976                self.send_spark_invoice(
977                    &spark_invoice_details.invoice,
978                    request,
979                    amount_override.map_or(amount, u128::from),
980                )
981                .await
982            }
983            SendPaymentMethod::Bolt11Invoice {
984                invoice_details,
985                spark_transfer_fee_sats,
986                lightning_fee_sats,
987                ..
988            } => {
989                Box::pin(self.send_bolt11_invoice(
990                    invoice_details,
991                    *spark_transfer_fee_sats,
992                    *lightning_fee_sats,
993                    request,
994                    amount_override,
995                    amount,
996                ))
997                .await
998            }
999            SendPaymentMethod::BitcoinAddress { address, fee_quote } => {
1000                self.send_bitcoin_address(address, fee_quote, request, amount_override)
1001                    .await
1002            }
1003        }
1004    }
1005
1006    async fn send_spark_address(
1007        &self,
1008        address: &str,
1009        token_identifier: Option<String>,
1010        amount: u128,
1011        options: Option<&SendPaymentOptions>,
1012        idempotency_key: Option<String>,
1013    ) -> Result<SendPaymentResponse, SdkError> {
1014        let spark_address = address
1015            .parse::<SparkAddress>()
1016            .map_err(|_| SdkError::InvalidInput("Invalid spark address".to_string()))?;
1017
1018        // If HTLC options are provided, send an HTLC transfer
1019        if let Some(SendPaymentOptions::SparkAddress { htlc_options }) = options
1020            && let Some(htlc_options) = htlc_options
1021        {
1022            if token_identifier.is_some() {
1023                return Err(SdkError::InvalidInput(
1024                    "Can't provide both token identifier and HTLC options".to_string(),
1025                ));
1026            }
1027
1028            return self
1029                .send_spark_htlc(
1030                    &spark_address,
1031                    amount.try_into()?,
1032                    htlc_options,
1033                    idempotency_key,
1034                )
1035                .await;
1036        }
1037
1038        let payment = if let Some(identifier) = token_identifier {
1039            self.send_spark_token_address(identifier, amount, spark_address)
1040                .await?
1041        } else {
1042            let transfer_id = idempotency_key
1043                .as_ref()
1044                .map(|key| TransferId::from_str(key))
1045                .transpose()?;
1046            let transfer = self
1047                .spark_wallet
1048                .transfer(amount.try_into()?, &spark_address, transfer_id)
1049                .await?;
1050            transfer.try_into()?
1051        };
1052
1053        // Insert the payment into storage to make it immediately available for listing
1054        self.storage.insert_payment(payment.clone()).await?;
1055
1056        Ok(SendPaymentResponse { payment })
1057    }
1058
1059    async fn send_spark_htlc(
1060        &self,
1061        address: &SparkAddress,
1062        amount_sat: u64,
1063        htlc_options: &SparkHtlcOptions,
1064        idempotency_key: Option<String>,
1065    ) -> Result<SendPaymentResponse, SdkError> {
1066        let payment_hash = sha256::Hash::from_str(&htlc_options.payment_hash)
1067            .map_err(|_| SdkError::InvalidInput("Invalid payment hash".to_string()))?;
1068
1069        if htlc_options.expiry_duration_secs == 0 {
1070            return Err(SdkError::InvalidInput(
1071                "Expiry duration must be greater than 0".to_string(),
1072            ));
1073        }
1074        let expiry_duration = Duration::from_secs(htlc_options.expiry_duration_secs);
1075
1076        let transfer_id = idempotency_key
1077            .as_ref()
1078            .map(|key| TransferId::from_str(key))
1079            .transpose()?;
1080        let transfer = self
1081            .spark_wallet
1082            .create_htlc(
1083                amount_sat,
1084                address,
1085                &payment_hash,
1086                expiry_duration,
1087                transfer_id,
1088            )
1089            .await?;
1090
1091        let payment: Payment = transfer.try_into()?;
1092
1093        // Insert the payment into storage to make it immediately available for listing
1094        self.storage.insert_payment(payment.clone()).await?;
1095
1096        Ok(SendPaymentResponse { payment })
1097    }
1098
1099    async fn send_spark_token_address(
1100        &self,
1101        token_identifier: String,
1102        amount: u128,
1103        receiver_address: SparkAddress,
1104    ) -> Result<Payment, SdkError> {
1105        let token_transaction = self
1106            .spark_wallet
1107            .transfer_tokens(
1108                vec![TransferTokenOutput {
1109                    token_id: token_identifier,
1110                    amount,
1111                    receiver_address: receiver_address.clone(),
1112                    spark_invoice: None,
1113                }],
1114                None,
1115                None,
1116            )
1117            .await?;
1118
1119        map_and_persist_token_transaction(&self.spark_wallet, &self.storage, &token_transaction)
1120            .await
1121    }
1122
1123    async fn send_spark_invoice(
1124        &self,
1125        invoice: &str,
1126        request: &SendPaymentRequest,
1127        amount: u128,
1128    ) -> Result<SendPaymentResponse, SdkError> {
1129        let transfer_id = request
1130            .idempotency_key
1131            .as_ref()
1132            .map(|key| TransferId::from_str(key))
1133            .transpose()?;
1134
1135        let payment = match self
1136            .spark_wallet
1137            .fulfill_spark_invoice(invoice, Some(amount), transfer_id)
1138            .await?
1139        {
1140            spark_wallet::FulfillSparkInvoiceResult::Transfer(wallet_transfer) => {
1141                (*wallet_transfer).try_into()?
1142            }
1143            spark_wallet::FulfillSparkInvoiceResult::TokenTransaction(token_transaction) => {
1144                map_and_persist_token_transaction(
1145                    &self.spark_wallet,
1146                    &self.storage,
1147                    &token_transaction,
1148                )
1149                .await?
1150            }
1151        };
1152
1153        // Insert the payment into storage to make it immediately available for listing
1154        self.storage.insert_payment(payment.clone()).await?;
1155
1156        Ok(SendPaymentResponse { payment })
1157    }
1158
1159    /// For `FeesIncluded` + amountless Bolt11: calculates the amount to send
1160    /// (`receiver_amount` + any overpayment from fee decrease).
1161    async fn calculate_fees_included_bolt11_amount(
1162        &self,
1163        invoice: &str,
1164        user_amount: u64,
1165        stored_fee: u64,
1166    ) -> Result<u64, SdkError> {
1167        let receiver_amount = user_amount.saturating_sub(stored_fee);
1168        if receiver_amount == 0 {
1169            return Err(SdkError::InvalidInput(
1170                "Amount too small to cover fees".to_string(),
1171            ));
1172        }
1173
1174        // Re-estimate current fee for receiver amount
1175        let current_fee = self
1176            .spark_wallet
1177            .fetch_lightning_send_fee_estimate(invoice, Some(receiver_amount))
1178            .await?;
1179
1180        // If current fee exceeds stored fee, fail
1181        if current_fee > stored_fee {
1182            return Err(SdkError::Generic(
1183                "Fee increased since prepare. Please retry.".to_string(),
1184            ));
1185        }
1186
1187        // Calculate overpayment
1188        let overpayment = stored_fee.saturating_sub(current_fee);
1189
1190        // Protect against excessive fee overpayment.
1191        // Allow overpayment up to 100% of actual fee, with a minimum of 1 sat.
1192        let max_allowed_overpayment = current_fee.max(1);
1193        if overpayment > max_allowed_overpayment {
1194            return Err(SdkError::Generic(format!(
1195                "Fee overpayment ({overpayment} sats) exceeds allowed maximum ({max_allowed_overpayment} sats)"
1196            )));
1197        }
1198
1199        if overpayment > 0 {
1200            info!(
1201                overpayment_sats = overpayment,
1202                stored_fee_sats = stored_fee,
1203                current_fee_sats = current_fee,
1204                "FeesIncluded fee overpayment applied for Bolt11"
1205            );
1206        }
1207
1208        Ok(receiver_amount.saturating_add(overpayment))
1209    }
1210
1211    async fn send_bolt11_invoice(
1212        &self,
1213        invoice_details: &Bolt11InvoiceDetails,
1214        spark_transfer_fee_sats: Option<u64>,
1215        lightning_fee_sats: u64,
1216        request: &SendPaymentRequest,
1217        amount_override: Option<u64>,
1218        amount: u128,
1219    ) -> Result<SendPaymentResponse, SdkError> {
1220        // Determine routing preference and actual fee before calculating the send amount,
1221        // so FeesIncluded deducts the correct fee (Spark=0 vs Lightning).
1222        let (prefer_spark, completion_timeout_secs) = match request.options {
1223            Some(SendPaymentOptions::Bolt11Invoice {
1224                prefer_spark,
1225                completion_timeout_secs,
1226            }) => (prefer_spark, completion_timeout_secs),
1227            _ => (self.config.prefer_spark_over_lightning, None),
1228        };
1229        let is_spark_route = prefer_spark && spark_transfer_fee_sats.is_some();
1230        let fee_sats = if is_spark_route {
1231            spark_transfer_fee_sats.unwrap_or(0)
1232        } else {
1233            lightning_fee_sats
1234        };
1235
1236        // Handle FeesIncluded: deduct fees from the total balance.
1237        // Applies to both amountless invoices and fixed-amount invoices with amount_override
1238        // (send-all-with-conversion via LNURL — overpays the invoice to drain the wallet).
1239        let is_fees_included = request.prepare_response.fee_policy == FeePolicy::FeesIncluded;
1240        let amount_to_send = if is_fees_included
1241            && (invoice_details.amount_msat.is_none() || amount_override.is_some())
1242        {
1243            let total_sats: u64 = match amount_override {
1244                Some(sat_balance) => sat_balance,
1245                None => amount.try_into()?,
1246            };
1247            // Spark route: deduct known fee directly (often 0).
1248            // Lightning route: re-estimate fees via calculate_fees_included_bolt11_amount
1249            // which handles fee changes between prepare and send.
1250            let amt = if is_spark_route {
1251                total_sats.saturating_sub(fee_sats)
1252            } else {
1253                self.calculate_fees_included_bolt11_amount(
1254                    &invoice_details.invoice.bolt11,
1255                    total_sats,
1256                    fee_sats,
1257                )
1258                .await?
1259            };
1260            Some(u128::from(amt))
1261        } else {
1262            match amount_override {
1263                Some(amt) => Some(amt.into()),
1264                None => match invoice_details.amount_msat {
1265                    Some(_) => None,
1266                    None => Some(amount),
1267                },
1268            }
1269        };
1270        let transfer_id = request
1271            .idempotency_key
1272            .as_ref()
1273            .map(|idempotency_key| TransferId::from_str(idempotency_key))
1274            .transpose()?;
1275
1276        let payment_response = Box::pin(
1277            self.spark_wallet.pay_lightning_invoice(
1278                &invoice_details.invoice.bolt11,
1279                amount_to_send
1280                    .map(|a| Ok::<u64, SdkError>(a.try_into()?))
1281                    .transpose()?,
1282                Some(fee_sats),
1283                prefer_spark,
1284                transfer_id,
1285            ),
1286        )
1287        .await?;
1288        let payment = match payment_response.lightning_payment {
1289            Some(lightning_payment) => {
1290                let ssp_id = lightning_payment.id.clone();
1291                let htlc_details = payment_response
1292                    .transfer
1293                    .htlc_preimage_request
1294                    .ok_or_else(|| {
1295                        SdkError::Generic(
1296                            "Missing HTLC details for Lightning send payment".to_string(),
1297                        )
1298                    })?
1299                    .try_into()?;
1300                let payment = Payment::from_lightning(
1301                    lightning_payment,
1302                    amount,
1303                    payment_response.transfer.id.to_string(),
1304                    htlc_details,
1305                )?;
1306                self.poll_lightning_send_payment(&payment, ssp_id);
1307                payment
1308            }
1309            None => payment_response.transfer.try_into()?,
1310        };
1311
1312        let completion_timeout_secs = completion_timeout_secs.unwrap_or(0);
1313
1314        if completion_timeout_secs == 0 {
1315            // Insert the payment into storage to make it immediately available for listing
1316            self.storage.insert_payment(payment.clone()).await?;
1317
1318            return Ok(SendPaymentResponse { payment });
1319        }
1320
1321        let payment = self
1322            .wait_for_payment(
1323                WaitForPaymentIdentifier::PaymentId(payment.id.clone()),
1324                completion_timeout_secs,
1325            )
1326            .await
1327            .unwrap_or(payment);
1328
1329        // Insert the payment into storage to make it immediately available for listing
1330        self.storage.insert_payment(payment.clone()).await?;
1331
1332        Ok(SendPaymentResponse { payment })
1333    }
1334
1335    async fn send_bitcoin_address(
1336        &self,
1337        address: &BitcoinAddressDetails,
1338        fee_quote: &SendOnchainFeeQuote,
1339        request: &SendPaymentRequest,
1340        amount_override: Option<u64>,
1341    ) -> Result<SendPaymentResponse, SdkError> {
1342        // Extract confirmation speed from options
1343        let confirmation_speed = match &request.options {
1344            Some(SendPaymentOptions::BitcoinAddress { confirmation_speed }) => {
1345                confirmation_speed.clone()
1346            }
1347            None => OnchainConfirmationSpeed::Fast, // Default to fast
1348            _ => {
1349                return Err(SdkError::InvalidInput(
1350                    "Invalid options for Bitcoin address payment".to_string(),
1351                ));
1352            }
1353        };
1354
1355        let exit_speed: ExitSpeed = confirmation_speed.clone().into();
1356
1357        // Calculate fee based on selected speed
1358        let fee_sats = match confirmation_speed {
1359            OnchainConfirmationSpeed::Fast => fee_quote.speed_fast.total_fee_sat(),
1360            OnchainConfirmationSpeed::Medium => fee_quote.speed_medium.total_fee_sat(),
1361            OnchainConfirmationSpeed::Slow => fee_quote.speed_slow.total_fee_sat(),
1362        };
1363
1364        // Compute amount - for FeesIncluded, receiver gets total minus fees.
1365        // amount_override (send-all post-conversion) is always FeesIncluded.
1366        let total_sats: u64 =
1367            amount_override.unwrap_or(request.prepare_response.amount.try_into()?);
1368        let amount_sats = if request.prepare_response.fee_policy == FeePolicy::FeesIncluded {
1369            total_sats.saturating_sub(fee_sats)
1370        } else {
1371            total_sats
1372        };
1373
1374        // Validate the output amount meets the dust limit for this address type
1375        let dust_limit_sats = get_dust_limit_sats(&address.address)?;
1376        if amount_sats < dust_limit_sats {
1377            return Err(SdkError::InvalidInput(format!(
1378                "Amount is below the minimum of {dust_limit_sats} sats required for this address"
1379            )));
1380        }
1381
1382        let transfer_id = request
1383            .idempotency_key
1384            .as_ref()
1385            .map(|idempotency_key| TransferId::from_str(idempotency_key))
1386            .transpose()?;
1387        let response = self
1388            .spark_wallet
1389            .withdraw(
1390                &address.address,
1391                Some(amount_sats),
1392                exit_speed,
1393                fee_quote.clone().into(),
1394                transfer_id,
1395            )
1396            .await?;
1397
1398        let payment: Payment = response.try_into()?;
1399
1400        self.storage.insert_payment(payment.clone()).await?;
1401
1402        Ok(SendPaymentResponse { payment })
1403    }
1404
1405    pub(super) async fn wait_for_payment(
1406        &self,
1407        identifier: WaitForPaymentIdentifier,
1408        completion_timeout_secs: u32,
1409    ) -> Result<Payment, SdkError> {
1410        let (tx, mut rx) = mpsc::channel(20);
1411        // Use internal listener to see raw events before middleware processing.
1412        // This is critical because TokenConversionMiddleware suppresses conversion
1413        // child events, but wait_for_payment needs to see them.
1414        let id = self
1415            .event_emitter
1416            .add_internal_listener(Box::new(InternalEventListener::new(tx)))
1417            .await;
1418
1419        // Run the main logic in a closure so cleanup always happens,
1420        // even if an early `?` exits (e.g. get_payment_by_invoice failure).
1421        let result = async {
1422            // First check if we already have the completed payment in storage
1423            let payment = match &identifier {
1424                WaitForPaymentIdentifier::PaymentId(payment_id) => self
1425                    .storage
1426                    .get_payment_by_id(payment_id.clone())
1427                    .await
1428                    .ok(),
1429                WaitForPaymentIdentifier::PaymentRequest(payment_request) => {
1430                    self.storage
1431                        .get_payment_by_invoice(payment_request.clone())
1432                        .await?
1433                }
1434            };
1435            if let Some(payment) = payment
1436                && payment.status == PaymentStatus::Completed
1437            {
1438                return Ok(payment);
1439            }
1440
1441            timeout(Duration::from_secs(completion_timeout_secs.into()), async {
1442                loop {
1443                    let Some(event) = rx.recv().await else {
1444                        return Err(SdkError::Generic("Event channel closed".to_string()));
1445                    };
1446
1447                    let SdkEvent::PaymentSucceeded { payment } = event else {
1448                        continue;
1449                    };
1450
1451                    if is_payment_match(&payment, &identifier) {
1452                        return Ok(payment);
1453                    }
1454                }
1455            })
1456            .await
1457            .map_err(|_| SdkError::Generic("Timeout waiting for payment".to_string()))?
1458        }
1459        .await;
1460
1461        self.event_emitter.remove_internal_listener(&id).await;
1462        result
1463    }
1464
1465    // Pools the lightning send payment until it is in completed state.
1466    fn poll_lightning_send_payment(&self, payment: &Payment, ssp_id: String) {
1467        const MAX_POLL_ATTEMPTS: u32 = 20;
1468        let payment_id = payment.id.clone();
1469        info!("Polling lightning send payment {}", payment_id);
1470
1471        let Some(htlc_details) = payment.details.as_ref().and_then(|d| match d {
1472            PaymentDetails::Lightning { htlc_details, .. } => Some(htlc_details.clone()),
1473            _ => None,
1474        }) else {
1475            error!(
1476                "Missing HTLC details for lightning send payment {payment_id}, skipping polling"
1477            );
1478            return;
1479        };
1480        let spark_wallet = self.spark_wallet.clone();
1481        let storage = self.storage.clone();
1482        let sync_coordinator = self.sync_coordinator.clone();
1483        let event_emitter = self.event_emitter.clone();
1484        let payment = payment.clone();
1485        let payment_id = payment_id.clone();
1486        let mut shutdown = self.shutdown_sender.subscribe();
1487        let span = tracing::Span::current();
1488
1489        tokio::spawn(async move {
1490            for i in 0..MAX_POLL_ATTEMPTS {
1491                info!(
1492                    "Polling lightning send payment {} attempt {}",
1493                    payment_id, i
1494                );
1495                select! {
1496                    _ = shutdown.changed() => {
1497                        info!("Shutdown signal received");
1498                        return;
1499                    },
1500                    p = spark_wallet.fetch_lightning_send_payment(&ssp_id) => {
1501                        if let Ok(Some(p)) = p && let Ok(payment) = Payment::from_lightning(p.clone(), payment.amount, payment.id.clone(), htlc_details.clone()) {
1502                            info!("Polling payment status = {} {:?}", payment.status, p.status);
1503                            if payment.status != PaymentStatus::Pending {
1504                                info!("Polling payment completed status = {}", payment.status);
1505                                // Update storage before emitting event so that
1506                                // get_payment returns the correct status immediately.
1507                                if let Err(e) = storage.insert_payment(payment.clone()).await {
1508                                    error!("Failed to update payment in storage: {e:?}");
1509                                }
1510                                // Fetch the payment to include already stored metadata
1511                                get_payment_and_emit_event(&storage, &event_emitter, payment.clone()).await;
1512                                sync_coordinator
1513                                    .trigger_sync_no_wait(SyncType::WalletState, true)
1514                                    .await;
1515                                return;
1516                            }
1517                        }
1518
1519                        let sleep_time = if i < 5 {
1520                            Duration::from_secs(1)
1521                        } else {
1522                            Duration::from_secs(i.into())
1523                        };
1524                        tokio::time::sleep(sleep_time).await;
1525                    }
1526                }
1527            }
1528        }.instrument(span));
1529    }
1530
1531    #[expect(clippy::too_many_arguments)]
1532    async fn convert_token_for_bolt11_invoice(
1533        &self,
1534        conversion_options: &ConversionOptions,
1535        spark_transfer_fee_sats: Option<u64>,
1536        lightning_fee_sats: u64,
1537        request: &SendPaymentRequest,
1538        conversion_purpose: &ConversionPurpose,
1539        amount: u128,
1540        token_identifier: Option<&String>,
1541        conversion_amount_override: Option<ConversionAmount>,
1542    ) -> Result<TokenConversionResponse, SdkError> {
1543        let conversion_amount = if let Some(ca) = conversion_amount_override {
1544            ca
1545        } else {
1546            // Determine the fee to be used based on preference
1547            let fee_sats = match request.options {
1548                Some(SendPaymentOptions::Bolt11Invoice { prefer_spark, .. }) => {
1549                    match (prefer_spark, spark_transfer_fee_sats) {
1550                        (true, Some(fee)) => fee,
1551                        _ => lightning_fee_sats,
1552                    }
1553                }
1554                _ => lightning_fee_sats,
1555            };
1556            // The absolute minimum amount out is the lightning invoice amount plus fee
1557            let min_amount_out = amount.saturating_add(u128::from(fee_sats));
1558            ConversionAmount::MinAmountOut(min_amount_out)
1559        };
1560
1561        self.token_converter
1562            .convert(
1563                conversion_options,
1564                conversion_purpose,
1565                token_identifier,
1566                conversion_amount,
1567                None,
1568            )
1569            .await
1570            .map_err(Into::into)
1571    }
1572
1573    /// Gets conversion options for a payment, auto-populating from stable balance config if needed.
1574    ///
1575    /// Returns the provided options if set, or auto-populates from stable balance config
1576    /// if configured and there's not enough sats balance to cover the payment.
1577    async fn get_conversion_options_for_payment(
1578        &self,
1579        options: Option<&ConversionOptions>,
1580        token_identifier: Option<&String>,
1581        payment_amount: u128,
1582    ) -> Result<Option<ConversionOptions>, SdkError> {
1583        if let Some(stable_balance) = &self.stable_balance {
1584            stable_balance
1585                .get_conversion_options(options, token_identifier, payment_amount)
1586                .await
1587                .map_err(Into::into)
1588        } else {
1589            Ok(options.cloned())
1590        }
1591    }
1592
1593    /// Estimates a conversion for a payment, returning `None` when no conversion is needed.
1594    ///
1595    /// For `AmountIn`: validates with the given options directly (caller knows what to convert).
1596    /// For `MinAmountOut`: auto-populates conversion options from stable balance config when applicable.
1597    pub(super) async fn estimate_conversion(
1598        &self,
1599        request_options: Option<&ConversionOptions>,
1600        token_identifier: Option<&String>,
1601        conversion_amount: ConversionAmount,
1602    ) -> Result<Option<ConversionEstimate>, SdkError> {
1603        match conversion_amount {
1604            ConversionAmount::AmountIn(_) => self
1605                .token_converter
1606                .validate(request_options, token_identifier, conversion_amount)
1607                .await
1608                .map_err(Into::into),
1609            ConversionAmount::MinAmountOut(amount) => {
1610                let options = self
1611                    .get_conversion_options_for_payment(request_options, token_identifier, amount)
1612                    .await?;
1613                self.token_converter
1614                    .validate(options.as_ref(), token_identifier, conversion_amount)
1615                    .await
1616                    .map_err(Into::into)
1617            }
1618        }
1619    }
1620
1621    // Returns `(is_token_conversion, is_send_all)`.
1622    pub(super) async fn is_token_conversion(
1623        &self,
1624        conversion_options: Option<&ConversionOptions>,
1625        token_identifier: Option<&String>,
1626        amount: Option<u128>,
1627        fee_policy: FeePolicy,
1628    ) -> Result<(bool, bool), SdkError> {
1629        let (
1630            Some(amount),
1631            Some(ConversionOptions {
1632                conversion_type:
1633                    ConversionType::ToBitcoin {
1634                        from_token_identifier,
1635                    },
1636                ..
1637            }),
1638        ) = (amount, conversion_options)
1639        else {
1640            return Ok((false, false));
1641        };
1642
1643        // If the caller passed a token_identifier it must match conversion options.
1644        // If they omitted it, we can't compare against the balance, so is_send_all=false.
1645        // Send-all also requires stable balance to be active with a matching active token,
1646        // otherwise we shouldn't sweep the existing sat balance.
1647        let is_send_all = match token_identifier {
1648            Some(token_id) => {
1649                if token_id != from_token_identifier {
1650                    return Err(SdkError::Generic(
1651                        "Request token identifier must match conversion options".to_string(),
1652                    ));
1653                }
1654                let token_balances = self.spark_wallet.get_token_balances().await?;
1655                let token_balance = token_balances.get(token_id).map_or(0, |tb| tb.balance);
1656                let has_active_stable_token = match &self.stable_balance {
1657                    Some(sb) => sb.get_active_token_identifier().await.as_ref() == Some(token_id),
1658                    None => false,
1659                };
1660                amount == token_balance
1661                    && fee_policy == FeePolicy::FeesIncluded
1662                    && has_active_stable_token
1663            }
1664            None => false,
1665        };
1666
1667        Ok((true, is_send_all))
1668    }
1669
1670    /// Estimates the sats available for a send that may go through a token→BTC conversion.
1671    ///
1672    /// Branches on `token_identifier`:
1673    /// - **Set** → `amount` is in token base units; uses `AmountIn(amount)` (variable
1674    ///   sat output). For send-all, adds the existing sat balance to the conversion
1675    ///   output.
1676    /// - **Not set** → `amount` is already in sats; uses `MinAmountOut(amount)` so
1677    ///   the converter is guaranteed to deliver at least `amount` sats or fail.
1678    ///   `estimated_sats == amount` in this case.
1679    ///
1680    /// Returns `(estimated_sats, conversion_estimate)`. When the request is not a
1681    /// token conversion, `estimated_sats == amount` and `conversion_estimate` is None,
1682    /// so callers can use `conversion_estimate.is_some()` to detect the conversion path.
1683    /// The returned `estimated_sats` is the *raw* expected conversion output — callers
1684    /// that need a defensive lower bound (e.g. LNURL invoice sizing on the `AmountIn`
1685    /// path) should apply their own slippage buffer.
1686    pub(super) async fn estimate_sats_from_token_conversion(
1687        &self,
1688        conversion_options: Option<&ConversionOptions>,
1689        token_identifier: Option<&String>,
1690        amount: u128,
1691        fee_policy: FeePolicy,
1692    ) -> Result<(u128, Option<ConversionEstimate>), SdkError> {
1693        let (is_token_conversion, is_send_all) = self
1694            .is_token_conversion(
1695                conversion_options,
1696                token_identifier,
1697                Some(amount),
1698                fee_policy,
1699            )
1700            .await?;
1701        if !is_token_conversion {
1702            return Ok((amount, None));
1703        }
1704
1705        // When token_identifier is provided, `amount` is in token units → AmountIn.
1706        // When it's omitted, `amount` is in sats → MinAmountOut (we want at least
1707        // that many sats out of the conversion).
1708        let (conversion_amount, estimated_sats_from_conversion) = if token_identifier.is_some() {
1709            let estimate = self
1710                .estimate_conversion(
1711                    conversion_options,
1712                    token_identifier,
1713                    ConversionAmount::AmountIn(amount),
1714                )
1715                .await?;
1716            let sats = estimate.as_ref().map_or(0, |e| e.amount_out);
1717            (estimate, sats)
1718        } else {
1719            let estimate = self
1720                .estimate_conversion(
1721                    conversion_options,
1722                    token_identifier,
1723                    ConversionAmount::MinAmountOut(amount),
1724                )
1725                .await?;
1726            // For MinAmountOut, the requested sats is the amount we asked for.
1727            (estimate, amount)
1728        };
1729
1730        // For send-all, include existing sats balance — the actual send at execution
1731        // time will use the full post-conversion balance.
1732        let estimated_sats = if is_send_all {
1733            let sat_balance = u128::from(self.spark_wallet.get_balance().await?);
1734            estimated_sats_from_conversion.saturating_add(sat_balance)
1735        } else {
1736            estimated_sats_from_conversion
1737        };
1738
1739        Ok((estimated_sats, conversion_amount))
1740    }
1741
1742    /// Resolves the effective send amount and conversion estimate for a prepare flow
1743    /// where the destination accepts sats directly (Spark address, Spark invoice).
1744    ///
1745    /// - **Token conversion** (`token_identifier` set + `ToBitcoin` options): substitutes
1746    ///   `amount` with the post-conversion estimated sats, returns the `AmountIn` estimate.
1747    /// - **Plain send with conversion options** (no `token_identifier`, sats `amount` +
1748    ///   options): keeps `amount` as-is, attaches a `MinAmountOut` estimate for display.
1749    /// - **Plain send (no options)**: passes through unchanged with `None` estimate.
1750    async fn resolve_send_amount_with_conversion_estimate(
1751        &self,
1752        conversion_options: Option<&ConversionOptions>,
1753        token_identifier: Option<&String>,
1754        amount: u128,
1755        fee_policy: FeePolicy,
1756    ) -> Result<(u128, Option<ConversionEstimate>), SdkError> {
1757        let (estimated_sats, conversion_estimate) = self
1758            .estimate_sats_from_token_conversion(
1759                conversion_options,
1760                token_identifier,
1761                amount,
1762                fee_policy,
1763            )
1764            .await?;
1765        if conversion_estimate.is_some() {
1766            return Ok((estimated_sats, conversion_estimate));
1767        }
1768        let estimate = self
1769            .estimate_conversion(
1770                conversion_options,
1771                token_identifier,
1772                ConversionAmount::MinAmountOut(amount),
1773            )
1774            .await?;
1775        Ok((amount, estimate))
1776    }
1777
1778    /// Prepares a Bolt11 invoice payment for token-to-Bitcoin conversion (send-all
1779    /// or non-send-all). Returns `Ok(None)` when the request is not a token conversion
1780    /// so the caller can fall through to the regular bolt11 prepare path.
1781    ///
1782    /// Estimates the conversion, fetches lightning fees based on the estimated sats,
1783    /// and validates the receiver amount covers fees.
1784    async fn maybe_prepare_bolt11_from_token_conversion(
1785        &self,
1786        request: &PrepareSendPaymentRequest,
1787        invoice: &Bolt11InvoiceDetails,
1788        spark_transfer_fee_sats: Option<u64>,
1789        token_identifier: Option<&String>,
1790        fee_policy: FeePolicy,
1791    ) -> Result<Option<PrepareSendPaymentResponse>, SdkError> {
1792        let Some(token_amount) = request.amount else {
1793            return Ok(None);
1794        };
1795        let (estimated_sats, conversion_estimate) = self
1796            .estimate_sats_from_token_conversion(
1797                request.conversion_options.as_ref(),
1798                token_identifier,
1799                token_amount,
1800                fee_policy,
1801            )
1802            .await?;
1803        if conversion_estimate.is_none() {
1804            return Ok(None);
1805        }
1806
1807        let lightning_fee_sats = self
1808            .spark_wallet
1809            .fetch_lightning_send_fee_estimate(
1810                &request.payment_request,
1811                Some(estimated_sats.try_into()?),
1812            )
1813            .await?;
1814
1815        let total_u64: u64 = estimated_sats.try_into()?;
1816        // For fixed-amount invoices, the converted sats must cover invoice amount + fees.
1817        // For amountless invoices (send-all), just check fees are covered.
1818        let min_required = if let Some(amount_msat) = invoice.amount_msat {
1819            (amount_msat / 1000).saturating_add(lightning_fee_sats)
1820        } else {
1821            lightning_fee_sats
1822        };
1823        if total_u64 <= min_required {
1824            return Err(SdkError::InvalidInput(
1825                "Token conversion amount too small to cover invoice amount and fees".to_string(),
1826            ));
1827        }
1828
1829        Ok(Some(PrepareSendPaymentResponse {
1830            payment_method: SendPaymentMethod::Bolt11Invoice {
1831                invoice_details: invoice.clone(),
1832                spark_transfer_fee_sats,
1833                lightning_fee_sats,
1834            },
1835            amount: estimated_sats,
1836            // ToBitcoin conversion outputs sats — token_identifier is None
1837            token_identifier: None,
1838            conversion_estimate,
1839            fee_policy,
1840        }))
1841    }
1842
1843    /// Prepares a Bitcoin address payment for token-to-Bitcoin conversion (send-all
1844    /// or non-send-all). Returns `Ok(None)` when the request is not a token conversion
1845    /// so the caller can fall through to the regular bitcoin address prepare path.
1846    ///
1847    /// Estimates the conversion, fetches onchain fee quote based on the estimated
1848    /// sats, and validates the output after fees meets the dust limit.
1849    async fn maybe_prepare_bitcoin_from_token_conversion(
1850        &self,
1851        request: &PrepareSendPaymentRequest,
1852        withdrawal_address: &BitcoinAddressDetails,
1853        token_identifier: Option<&String>,
1854        fee_policy: FeePolicy,
1855    ) -> Result<Option<PrepareSendPaymentResponse>, SdkError> {
1856        let Some(token_amount) = request.amount else {
1857            return Ok(None);
1858        };
1859        let (estimated_sats, conversion_estimate) = self
1860            .estimate_sats_from_token_conversion(
1861                request.conversion_options.as_ref(),
1862                token_identifier,
1863                token_amount,
1864                fee_policy,
1865            )
1866            .await?;
1867        if conversion_estimate.is_none() {
1868            return Ok(None);
1869        }
1870
1871        let dust_limit_sats = get_dust_limit_sats(&withdrawal_address.address)?;
1872        let total_u64: u64 = estimated_sats.try_into()?;
1873        if total_u64 < dust_limit_sats {
1874            return Err(SdkError::InvalidInput(format!(
1875                "Amount is below the minimum of {dust_limit_sats} sats required for this address"
1876            )));
1877        }
1878
1879        // Pass None for amount — the sats don't exist yet (still tokens),
1880        // so leaf selection would fail. Get a generic fee estimate instead.
1881        let fee_quote: SendOnchainFeeQuote = self
1882            .spark_wallet
1883            .fetch_coop_exit_fee_quote(&withdrawal_address.address, None)
1884            .await?
1885            .into();
1886
1887        let min_fee_sats = fee_quote.speed_slow.total_fee_sat();
1888        let output_amount_sats = total_u64.saturating_sub(min_fee_sats);
1889        if output_amount_sats < dust_limit_sats {
1890            return Err(SdkError::InvalidInput(format!(
1891                "Amount is below the minimum of {dust_limit_sats} sats required for this address after lowest fees of {min_fee_sats} sats"
1892            )));
1893        }
1894
1895        Ok(Some(PrepareSendPaymentResponse {
1896            payment_method: SendPaymentMethod::BitcoinAddress {
1897                address: withdrawal_address.clone(),
1898                fee_quote,
1899            },
1900            amount: estimated_sats,
1901            // ToBitcoin conversion outputs sats — token_identifier is None
1902            token_identifier: None,
1903            conversion_estimate,
1904            fee_policy,
1905        }))
1906    }
1907
1908    #[allow(clippy::too_many_arguments)]
1909    async fn convert_token_for_bitcoin_address(
1910        &self,
1911        conversion_options: &ConversionOptions,
1912        fee_quote: &SendOnchainFeeQuote,
1913        request: &SendPaymentRequest,
1914        conversion_purpose: &ConversionPurpose,
1915        amount: u128,
1916        token_identifier: Option<&String>,
1917        conversion_amount_override: Option<ConversionAmount>,
1918    ) -> Result<TokenConversionResponse, SdkError> {
1919        let conversion_amount = if let Some(ca) = conversion_amount_override {
1920            ca
1921        } else {
1922            // Derive fee_sats from request.options confirmation speed
1923            let fee_sats = match &request.options {
1924                Some(SendPaymentOptions::BitcoinAddress { confirmation_speed }) => {
1925                    match confirmation_speed {
1926                        OnchainConfirmationSpeed::Slow => fee_quote.speed_slow.total_fee_sat(),
1927                        OnchainConfirmationSpeed::Medium => fee_quote.speed_medium.total_fee_sat(),
1928                        OnchainConfirmationSpeed::Fast => fee_quote.speed_fast.total_fee_sat(),
1929                    }
1930                }
1931                _ => fee_quote.speed_fast.total_fee_sat(), // Default to fast
1932            };
1933            // The absolute minimum amount out is the amount plus fee
1934            let min_amount_out = amount.saturating_add(u128::from(fee_sats));
1935            ConversionAmount::MinAmountOut(min_amount_out)
1936        };
1937
1938        self.token_converter
1939            .convert(
1940                conversion_options,
1941                conversion_purpose,
1942                token_identifier,
1943                conversion_amount,
1944                None,
1945            )
1946            .await
1947            .map_err(Into::into)
1948    }
1949}