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