breez_sdk_spark/models/
adaptors.rs

1use breez_sdk_common::input::{
2    self, InputType, PaymentRequestSource, SparkInvoiceDetails, parse_spark_address,
3};
4use spark_wallet::{
5    CoopExitFeeQuote, CoopExitSpeedFeeQuote, ExitSpeed, LightningSendPayment, LightningSendStatus,
6    Network as SparkNetwork, PreimageRequest, PreimageRequestStatus, SspUserRequest,
7    TokenTransactionStatus, TransferDirection, TransferStatus, TransferType, WalletTransfer,
8};
9use std::time::Duration;
10
11use tracing::{debug, warn};
12use web_time::UNIX_EPOCH;
13
14use crate::{
15    Fee, Network, OnchainConfirmationSpeed, OptimizationProgress, Payment, PaymentDetails,
16    PaymentMethod, PaymentStatus, PaymentType, SdkError, SendOnchainFeeQuote,
17    SendOnchainSpeedFeeQuote, SparkHtlcDetails, SparkHtlcStatus, SparkInvoicePaymentDetails,
18    TokenBalance, TokenMetadata,
19};
20
21/// Feb 1, 2026 00:00:00 UTC — transfers before this may lack HTLC data on the operator.
22const HTLC_DATA_REQUIRED_SINCE: Duration = Duration::from_secs(1_769_904_000);
23
24/// Derive HTLC details from SSP request fields when the operator lacks the
25/// `PreimageRequest`. Only allowed for old transfers (before [`HTLC_DATA_REQUIRED_SINCE`]);
26/// new transfers without HTLC data are considered an error.
27fn derive_htlc_details_from_ssp(
28    transfer: &WalletTransfer,
29    payment_hash: &str,
30    preimage: Option<&str>,
31) -> Result<SparkHtlcDetails, SdkError> {
32    let cutoff = UNIX_EPOCH
33        .checked_add(HTLC_DATA_REQUIRED_SINCE)
34        .ok_or_else(|| SdkError::Generic("HTLC cutoff time overflow".to_string()))?;
35    let is_old = transfer.created_at.is_none_or(|t| t < cutoff);
36    if !is_old {
37        return Err(SdkError::Generic(format!(
38            "Missing HTLC details for Lightning payment transfer {}",
39            transfer.id
40        )));
41    }
42
43    warn!(
44        "Missing HTLC preimage request for Lightning transfer {}, deriving from SSP data",
45        transfer.id
46    );
47
48    let status = match transfer.status {
49        TransferStatus::Completed => SparkHtlcStatus::PreimageShared,
50        TransferStatus::Expired | TransferStatus::Returned => SparkHtlcStatus::Returned,
51        _ => SparkHtlcStatus::WaitingForPreimage,
52    };
53    Ok(SparkHtlcDetails {
54        payment_hash: payment_hash.to_string(),
55        preimage: preimage.map(ToString::to_string),
56        expiry_time: transfer
57            .expiry_time
58            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
59            .map_or(0, |d| d.as_secs()),
60        status,
61    })
62}
63
64/// If the HTLC details are missing a preimage, fill it in from the given fallback and update
65/// the status to [`SparkHtlcStatus::PreimageShared`] accordingly.
66fn reconcile_htlc_preimage(details: &mut SparkHtlcDetails, preimage: Option<&str>) {
67    if details.preimage.is_none() {
68        details.preimage = preimage.map(ToString::to_string);
69    }
70    if details.preimage.is_some() {
71        details.status = SparkHtlcStatus::PreimageShared;
72    }
73}
74
75impl PaymentMethod {
76    fn from_transfer(transfer: &WalletTransfer) -> Self {
77        match transfer.transfer_type {
78            TransferType::PreimageSwap => {
79                if transfer.is_ssp_transfer {
80                    PaymentMethod::Lightning
81                } else {
82                    PaymentMethod::Spark
83                }
84            }
85            TransferType::CooperativeExit => PaymentMethod::Withdraw,
86            TransferType::UtxoSwap => PaymentMethod::Deposit,
87            TransferType::Transfer => PaymentMethod::Spark,
88            _ => PaymentMethod::Unknown,
89        }
90    }
91}
92
93impl PaymentDetails {
94    #[allow(clippy::too_many_lines)]
95    fn from_transfer(transfer: &WalletTransfer) -> Result<Option<Self>, SdkError> {
96        if !transfer.is_ssp_transfer {
97            // Check for Spark invoice payments
98            if let Some(spark_invoice) = &transfer.spark_invoice {
99                let Some(InputType::SparkInvoice(invoice_details)) =
100                    parse_spark_address(spark_invoice, &PaymentRequestSource::default())
101                else {
102                    return Err(SdkError::Generic("Invalid spark invoice".to_string()));
103                };
104
105                return Ok(Some(PaymentDetails::Spark {
106                    invoice_details: Some(invoice_details.into()),
107                    htlc_details: None,
108                    conversion_info: None,
109                }));
110            }
111
112            // Check for Spark HTLC payments (when no user request is present)
113            if let Some(htlc_preimage_request) = &transfer.htlc_preimage_request {
114                return Ok(Some(PaymentDetails::Spark {
115                    invoice_details: None,
116                    htlc_details: Some(htlc_preimage_request.clone().try_into()?),
117                    conversion_info: None,
118                }));
119            }
120
121            return Ok(Some(PaymentDetails::Spark {
122                invoice_details: None,
123                htlc_details: None,
124                conversion_info: None,
125            }));
126        }
127
128        let Some(user_request) = &transfer.user_request else {
129            return Ok(None);
130        };
131
132        let details = match user_request {
133            SspUserRequest::LightningReceiveRequest(request) => {
134                let invoice_details = input::parse_invoice(&request.invoice.encoded_invoice)
135                    .ok_or(SdkError::Generic(
136                        "Invalid invoice in SspUserRequest::LightningReceiveRequest".to_string(),
137                    ))?;
138                let htlc_details = if let Some(req) = &transfer.htlc_preimage_request {
139                    let mut details: SparkHtlcDetails = req.clone().try_into()?;
140                    reconcile_htlc_preimage(
141                        &mut details,
142                        request.lightning_receive_payment_preimage.as_deref(),
143                    );
144                    details
145                } else {
146                    derive_htlc_details_from_ssp(
147                        transfer,
148                        &request.invoice.payment_hash,
149                        request.lightning_receive_payment_preimage.as_deref(),
150                    )?
151                };
152                PaymentDetails::Lightning {
153                    description: invoice_details.description,
154                    invoice: request.invoice.encoded_invoice.clone(),
155                    destination_pubkey: invoice_details.payee_pubkey,
156                    htlc_details,
157                    lnurl_pay_info: None,
158                    lnurl_withdraw_info: None,
159                    lnurl_receive_metadata: None,
160                }
161            }
162            SspUserRequest::LightningSendRequest(request) => {
163                let invoice_details =
164                    input::parse_invoice(&request.encoded_invoice).ok_or(SdkError::Generic(
165                        "Invalid invoice in SspUserRequest::LightningSendRequest".to_string(),
166                    ))?;
167                let htlc_details = if let Some(req) = &transfer.htlc_preimage_request {
168                    let mut details: SparkHtlcDetails = req.clone().try_into()?;
169                    reconcile_htlc_preimage(
170                        &mut details,
171                        request.lightning_send_payment_preimage.as_deref(),
172                    );
173                    details
174                } else {
175                    derive_htlc_details_from_ssp(
176                        transfer,
177                        &invoice_details.payment_hash,
178                        request.lightning_send_payment_preimage.as_deref(),
179                    )?
180                };
181                PaymentDetails::Lightning {
182                    description: invoice_details.description,
183                    invoice: request.encoded_invoice.clone(),
184                    destination_pubkey: invoice_details.payee_pubkey,
185                    htlc_details,
186                    lnurl_pay_info: None,
187                    lnurl_withdraw_info: None,
188                    lnurl_receive_metadata: None,
189                }
190            }
191            SspUserRequest::CoopExitRequest(request) => PaymentDetails::Withdraw {
192                tx_id: request.coop_exit_txid.clone(),
193            },
194            SspUserRequest::LeavesSwapRequest(_) => PaymentDetails::Spark {
195                invoice_details: None,
196                htlc_details: None,
197                conversion_info: None,
198            },
199            SspUserRequest::ClaimStaticDeposit(request) => PaymentDetails::Deposit {
200                tx_id: request.transaction_id.clone(),
201            },
202        };
203
204        Ok(Some(details))
205    }
206}
207
208impl From<SparkInvoiceDetails> for SparkInvoicePaymentDetails {
209    fn from(value: SparkInvoiceDetails) -> Self {
210        Self {
211            description: value.description,
212            invoice: value.invoice,
213        }
214    }
215}
216
217impl TryFrom<WalletTransfer> for Payment {
218    type Error = SdkError;
219    fn try_from(transfer: WalletTransfer) -> Result<Self, Self::Error> {
220        if [
221            TransferType::CounterSwap,
222            TransferType::CounterSwapV3,
223            TransferType::Swap,
224            TransferType::PrimarySwapV3,
225        ]
226        .contains(&transfer.transfer_type)
227        {
228            debug!("Tried to convert swap-related transfer to payment. Transfer: {transfer:?}");
229            return Err(SdkError::Generic(
230                "Swap-related transfers are not considered payments".to_string(),
231            ));
232        }
233        let payment_type = match transfer.direction {
234            TransferDirection::Incoming => PaymentType::Receive,
235            TransferDirection::Outgoing => PaymentType::Send,
236        };
237        let mut status = match transfer.status {
238            TransferStatus::Completed => PaymentStatus::Completed,
239            TransferStatus::SenderKeyTweaked
240                if transfer.direction == TransferDirection::Outgoing =>
241            {
242                PaymentStatus::Completed
243            }
244            TransferStatus::Expired | TransferStatus::Returned => PaymentStatus::Failed,
245            _ => PaymentStatus::Pending,
246        };
247        let (fees_sat, mut amount_sat) = match transfer.clone().user_request {
248            Some(user_request) => match user_request {
249                SspUserRequest::LightningSendRequest(r) => {
250                    // TODO: if we have the preimage it is not pending. This is a workaround
251                    // until spark will implement incremental syncing based on updated time.
252                    if r.lightning_send_payment_preimage.is_some() {
253                        status = PaymentStatus::Completed;
254                    }
255                    let fee_sat = r.fee.as_sats().unwrap_or(0);
256                    (fee_sat, transfer.total_value_sat.saturating_sub(fee_sat))
257                }
258                SspUserRequest::CoopExitRequest(r) => {
259                    let fee_sat = r
260                        .fee
261                        .as_sats()
262                        .unwrap_or(0)
263                        .saturating_add(r.l1_broadcast_fee.as_sats().unwrap_or(0));
264                    (fee_sat, transfer.total_value_sat.saturating_sub(fee_sat))
265                }
266                SspUserRequest::ClaimStaticDeposit(r) => {
267                    let fee_sat = r
268                        .deposit_amount
269                        .as_sats()
270                        .unwrap_or(0)
271                        .saturating_sub(r.credit_amount.as_sats().unwrap_or(0));
272                    (fee_sat, transfer.total_value_sat)
273                }
274                _ => (0, transfer.total_value_sat),
275            },
276            None => (0, transfer.total_value_sat),
277        };
278
279        let details = PaymentDetails::from_transfer(&transfer)?;
280        if details.is_none() {
281            // in case we have a completed status without user object we want
282            // to keep syncing this payment
283            if status == PaymentStatus::Completed
284                && [
285                    TransferType::CooperativeExit,
286                    TransferType::PreimageSwap,
287                    TransferType::UtxoSwap,
288                ]
289                .contains(&transfer.transfer_type)
290            {
291                status = PaymentStatus::Pending;
292            }
293            amount_sat = transfer.total_value_sat;
294        }
295
296        Ok(Payment {
297            id: transfer.id.to_string(),
298            payment_type,
299            status,
300            amount: amount_sat.into(),
301            fees: fees_sat.into(),
302            timestamp: match transfer.created_at.map(|t| t.duration_since(UNIX_EPOCH)) {
303                Some(Ok(duration)) => duration.as_secs(),
304                _ => 0,
305            },
306            method: PaymentMethod::from_transfer(&transfer),
307            details,
308            conversion_details: None,
309        })
310    }
311}
312
313impl Payment {
314    /// Creates a [`Payment`] from a [`LightningSendPayment`] and its associated HTLC details.
315    ///
316    /// The `htlc_details` may be stale (e.g. captured at payment creation time), so this
317    /// method reconciles them with the current state of the `payment`:
318    /// - The preimage is taken from `htlc_details` if present, otherwise from the payment.
319    /// - If a preimage is available from either source, the HTLC status is set to
320    ///   [`SparkHtlcStatus::PreimageShared`].
321    pub fn from_lightning(
322        payment: LightningSendPayment,
323        amount_sat: u128,
324        transfer_id: String,
325        mut htlc_details: SparkHtlcDetails,
326    ) -> Result<Self, SdkError> {
327        let mut status = match payment.status {
328            LightningSendStatus::LightningPaymentSucceeded => PaymentStatus::Completed,
329            LightningSendStatus::LightningPaymentFailed
330            | LightningSendStatus::TransferFailed
331            | LightningSendStatus::PreimageProvidingFailed
332            | LightningSendStatus::UserSwapReturnFailed
333            | LightningSendStatus::UserSwapReturned => PaymentStatus::Failed,
334            _ => PaymentStatus::Pending,
335        };
336        if payment.payment_preimage.is_some() {
337            status = PaymentStatus::Completed;
338        }
339
340        reconcile_htlc_preimage(&mut htlc_details, payment.payment_preimage.as_deref());
341
342        let invoice_details = input::parse_invoice(&payment.encoded_invoice).ok_or(
343            SdkError::Generic("Invalid invoice in LightnintSendPayment".to_string()),
344        )?;
345        let details = PaymentDetails::Lightning {
346            description: invoice_details.description,
347            invoice: payment.encoded_invoice,
348            destination_pubkey: invoice_details.payee_pubkey,
349            htlc_details,
350            lnurl_pay_info: None,
351            lnurl_withdraw_info: None,
352            lnurl_receive_metadata: None,
353        };
354
355        Ok(Payment {
356            id: transfer_id,
357            payment_type: PaymentType::Send,
358            status,
359            amount: amount_sat,
360            fees: payment.fee_sat.into(),
361            timestamp: payment.created_at.cast_unsigned(),
362            method: PaymentMethod::Lightning,
363            details: Some(details),
364            conversion_details: None,
365        })
366    }
367}
368
369impl From<Network> for SparkNetwork {
370    fn from(network: Network) -> Self {
371        match network {
372            Network::Mainnet => SparkNetwork::Mainnet,
373            Network::Regtest => SparkNetwork::Regtest,
374        }
375    }
376}
377
378impl From<Fee> for spark_wallet::Fee {
379    fn from(fee: Fee) -> Self {
380        match fee {
381            Fee::Fixed { amount } => spark_wallet::Fee::Fixed { amount },
382            Fee::Rate { sat_per_vbyte } => spark_wallet::Fee::Rate { sat_per_vbyte },
383        }
384    }
385}
386
387impl From<spark_wallet::TokenBalance> for TokenBalance {
388    fn from(value: spark_wallet::TokenBalance) -> Self {
389        Self {
390            balance: value.balance,
391            token_metadata: value.token_metadata.into(),
392        }
393    }
394}
395
396impl From<spark_wallet::TokenMetadata> for TokenMetadata {
397    fn from(value: spark_wallet::TokenMetadata) -> Self {
398        Self {
399            identifier: value.identifier,
400            issuer_public_key: hex::encode(value.issuer_public_key.serialize()),
401            name: value.name,
402            ticker: value.ticker,
403            decimals: value.decimals,
404            max_supply: value.max_supply,
405            is_freezable: value.is_freezable,
406        }
407    }
408}
409
410impl From<CoopExitFeeQuote> for SendOnchainFeeQuote {
411    fn from(value: CoopExitFeeQuote) -> Self {
412        Self {
413            id: value.id,
414            expires_at: value.expires_at,
415            speed_fast: value.speed_fast.into(),
416            speed_medium: value.speed_medium.into(),
417            speed_slow: value.speed_slow.into(),
418        }
419    }
420}
421
422impl From<SendOnchainFeeQuote> for CoopExitFeeQuote {
423    fn from(value: SendOnchainFeeQuote) -> Self {
424        Self {
425            id: value.id,
426            expires_at: value.expires_at,
427            speed_fast: value.speed_fast.into(),
428            speed_medium: value.speed_medium.into(),
429            speed_slow: value.speed_slow.into(),
430        }
431    }
432}
433
434impl From<CoopExitSpeedFeeQuote> for SendOnchainSpeedFeeQuote {
435    fn from(value: CoopExitSpeedFeeQuote) -> Self {
436        Self {
437            user_fee_sat: value.user_fee_sat,
438            l1_broadcast_fee_sat: value.l1_broadcast_fee_sat,
439        }
440    }
441}
442
443impl From<SendOnchainSpeedFeeQuote> for CoopExitSpeedFeeQuote {
444    fn from(value: SendOnchainSpeedFeeQuote) -> Self {
445        Self {
446            user_fee_sat: value.user_fee_sat,
447            l1_broadcast_fee_sat: value.l1_broadcast_fee_sat,
448        }
449    }
450}
451
452impl From<OnchainConfirmationSpeed> for ExitSpeed {
453    fn from(speed: OnchainConfirmationSpeed) -> Self {
454        match speed {
455            OnchainConfirmationSpeed::Fast => ExitSpeed::Fast,
456            OnchainConfirmationSpeed::Medium => ExitSpeed::Medium,
457            OnchainConfirmationSpeed::Slow => ExitSpeed::Slow,
458        }
459    }
460}
461
462impl From<ExitSpeed> for OnchainConfirmationSpeed {
463    fn from(speed: ExitSpeed) -> Self {
464        match speed {
465            ExitSpeed::Fast => OnchainConfirmationSpeed::Fast,
466            ExitSpeed::Medium => OnchainConfirmationSpeed::Medium,
467            ExitSpeed::Slow => OnchainConfirmationSpeed::Slow,
468        }
469    }
470}
471
472impl PaymentStatus {
473    pub(crate) fn from_token_transaction_status(
474        status: TokenTransactionStatus,
475        is_transfer_transaction: bool,
476    ) -> Self {
477        match status {
478            TokenTransactionStatus::Started
479            | TokenTransactionStatus::Revealed
480            | TokenTransactionStatus::Unknown => PaymentStatus::Pending,
481            TokenTransactionStatus::Signed if is_transfer_transaction => PaymentStatus::Pending,
482            TokenTransactionStatus::Finalized | TokenTransactionStatus::Signed => {
483                PaymentStatus::Completed
484            }
485            TokenTransactionStatus::StartedCancelled | TokenTransactionStatus::SignedCancelled => {
486                PaymentStatus::Failed
487            }
488        }
489    }
490}
491
492impl TryFrom<PreimageRequest> for SparkHtlcDetails {
493    type Error = SdkError;
494    fn try_from(value: PreimageRequest) -> Result<Self, Self::Error> {
495        Ok(Self {
496            payment_hash: value.payment_hash.to_string(),
497            preimage: value.preimage.map(|p| p.encode_hex()),
498            expiry_time: value
499                .expiry_time
500                .duration_since(UNIX_EPOCH)
501                .map_err(|e| SdkError::Generic(format!("Invalid expiry time: {e}")))?
502                .as_secs(),
503            status: value.status.into(),
504        })
505    }
506}
507
508impl From<PreimageRequestStatus> for SparkHtlcStatus {
509    fn from(status: PreimageRequestStatus) -> Self {
510        match status {
511            PreimageRequestStatus::WaitingForPreimage => SparkHtlcStatus::WaitingForPreimage,
512            PreimageRequestStatus::PreimageShared => SparkHtlcStatus::PreimageShared,
513            PreimageRequestStatus::Returned => SparkHtlcStatus::Returned,
514        }
515    }
516}
517
518impl From<spark_wallet::OptimizationProgress> for OptimizationProgress {
519    fn from(value: spark_wallet::OptimizationProgress) -> Self {
520        Self {
521            is_running: value.is_running,
522            current_round: value.current_round,
523            total_rounds: value.total_rounds,
524        }
525    }
526}