Skip to main content

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