Skip to main content

breez_sdk_spark/sdk/
payments.rs

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