Skip to main content

breez_sdk_spark/models/
adaptors.rs

1use bitcoin::{
2    Address, Transaction, address::NetworkUnchecked, consensus::encode::deserialize_hex,
3};
4use breez_sdk_common::input::{
5    self, InputType, PaymentRequestSource, SparkInvoiceDetails, parse_spark_address,
6};
7use spark_wallet::{
8    CoopExitAcceptance, CoopExitFeeQuote, CoopExitRequest, CoopExitSpeedFeeQuote, ExitSpeed,
9    LightningSendPayment, LightningSendStatus, Network as SparkNetwork, PreimageRequest,
10    PreimageRequestStatus, SilentPaymentAddress, SspUserRequest, TokenTransactionStatus,
11    TransferDirection, TransferStatus, TransferType, WalletTransfer,
12};
13use std::time::Duration;
14
15use platform_utils::time::UNIX_EPOCH;
16use tracing::{debug, warn};
17
18use crate::{
19    AutoOptimizationEvent, Fee, Network, OnchainConfirmationSpeed, OptimizationOutcome, Payment,
20    PaymentDetails, PaymentMethod, PaymentStatus, PaymentType, SdkError, SendOnchainFeeQuote,
21    SendOnchainSpeedFeeQuote, SparkHtlcDetails, SparkHtlcStatus, SparkInvoicePaymentDetails,
22    TokenBalance, TokenMetadata,
23};
24
25/// Feb 1, 2026 00:00:00 UTC — transfers before this may lack HTLC data on the operator.
26#[allow(clippy::duration_suboptimal_units)]
27const HTLC_DATA_REQUIRED_SINCE: Duration = Duration::from_secs(1_769_904_000);
28
29/// Derive HTLC details from SSP request fields when the operator lacks the
30/// `PreimageRequest`. Only allowed for old transfers (before [`HTLC_DATA_REQUIRED_SINCE`]);
31/// new transfers without HTLC data are considered an error.
32fn derive_htlc_details_from_ssp(
33    transfer: &WalletTransfer,
34    payment_hash: &str,
35    preimage: Option<&str>,
36) -> Result<SparkHtlcDetails, SdkError> {
37    let cutoff = UNIX_EPOCH
38        .checked_add(HTLC_DATA_REQUIRED_SINCE)
39        .ok_or_else(|| SdkError::Generic("HTLC cutoff time overflow".to_string()))?;
40    let is_old = transfer.created_at.is_none_or(|t| t < cutoff);
41    if !is_old {
42        return Err(SdkError::Generic(format!(
43            "Missing HTLC details for Lightning payment transfer {}",
44            transfer.id
45        )));
46    }
47
48    warn!(
49        "Missing HTLC preimage request for Lightning transfer {}, deriving from SSP data",
50        transfer.id
51    );
52
53    let status = match transfer.status {
54        TransferStatus::Completed => SparkHtlcStatus::PreimageShared,
55        TransferStatus::Expired | TransferStatus::Returned => SparkHtlcStatus::Returned,
56        _ => SparkHtlcStatus::WaitingForPreimage,
57    };
58    Ok(SparkHtlcDetails {
59        payment_hash: payment_hash.to_string(),
60        preimage: preimage.map(ToString::to_string),
61        expiry_time: transfer
62            .expiry_time
63            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
64            .map_or(0, |d| d.as_secs()),
65        status,
66    })
67}
68
69/// If the HTLC details are missing a preimage, fill it in from the given fallback and update
70/// the status to [`SparkHtlcStatus::PreimageShared`] accordingly.
71fn reconcile_htlc_preimage(details: &mut SparkHtlcDetails, preimage: Option<&str>) {
72    if details.preimage.is_none() {
73        details.preimage = preimage.map(ToString::to_string);
74    }
75    if details.preimage.is_some() {
76        details.status = SparkHtlcStatus::PreimageShared;
77    }
78}
79
80/// Builds HTLC details from the operator's preimage request, taking a missing preimage from
81/// `fallback_preimage`. A known preimage means [`SparkHtlcStatus::PreimageShared`], so an
82/// unrecognized status is only an error when no preimage is known.
83fn htlc_details_from_preimage_request(
84    request: &PreimageRequest,
85    fallback_preimage: Option<&str>,
86) -> Result<SparkHtlcDetails, SdkError> {
87    let preimage = request
88        .preimage
89        .as_ref()
90        .map(spark_wallet::Preimage::encode_hex)
91        .or_else(|| fallback_preimage.map(ToString::to_string));
92    let status = if preimage.is_some() {
93        SparkHtlcStatus::PreimageShared
94    } else {
95        request.status.try_into()?
96    };
97    Ok(SparkHtlcDetails {
98        payment_hash: request.payment_hash.to_string(),
99        preimage,
100        expiry_time: request
101            .expiry_time
102            .duration_since(UNIX_EPOCH)
103            .map_err(|e| SdkError::Generic(format!("Invalid expiry time: {e}")))?
104            .as_secs(),
105        status,
106    })
107}
108
109fn withdrawal_vout(request: &CoopExitRequest) -> Option<u32> {
110    let address = request.withdrawal_address.as_str();
111    let transaction: Transaction = deserialize_hex(&request.raw_coop_exit_transaction).ok()?;
112    let Ok(bitcoin_address) = address.parse::<Address<NetworkUnchecked>>() else {
113        // The SDK cannot derive a silent payment output's script from the address,
114        // because the script also depends on the transaction's inputs. Under
115        // Spark's rules the payout to a silent payment address is output 0.
116        return address.parse::<SilentPaymentAddress>().is_ok().then_some(0);
117    };
118    let script_pubkey = bitcoin_address.assume_checked().script_pubkey();
119    let index = transaction
120        .output
121        .iter()
122        .position(|output| output.script_pubkey == script_pubkey)?;
123    u32::try_from(index).ok()
124}
125
126impl PaymentMethod {
127    fn from_transfer(transfer: &WalletTransfer) -> Self {
128        match transfer.transfer_type {
129            TransferType::PreimageSwap => {
130                if transfer.is_ssp_transfer {
131                    PaymentMethod::Lightning
132                } else {
133                    PaymentMethod::Spark
134                }
135            }
136            TransferType::CooperativeExit => PaymentMethod::Withdraw,
137            TransferType::UtxoSwap => PaymentMethod::Deposit,
138            TransferType::Transfer => PaymentMethod::Spark,
139            _ => PaymentMethod::Unknown,
140        }
141    }
142}
143
144impl PaymentDetails {
145    #[allow(clippy::too_many_lines)]
146    fn from_transfer(transfer: &WalletTransfer) -> Result<Option<Self>, SdkError> {
147        if !transfer.is_ssp_transfer {
148            // Check for Spark invoice payments
149            if let Some(spark_invoice) = &transfer.spark_invoice {
150                let Some(InputType::SparkInvoice(invoice_details)) =
151                    parse_spark_address(spark_invoice, &PaymentRequestSource::default())
152                else {
153                    return Err(SdkError::Generic("Invalid spark invoice".to_string()));
154                };
155
156                return Ok(Some(PaymentDetails::Spark {
157                    invoice_details: Some(invoice_details.into()),
158                    htlc_details: None,
159                    conversion_info: None,
160                }));
161            }
162
163            // Check for Spark HTLC payments (when no user request is present)
164            if let Some(htlc_preimage_request) = &transfer.htlc_preimage_request {
165                return Ok(Some(PaymentDetails::Spark {
166                    invoice_details: None,
167                    htlc_details: Some(htlc_details_from_preimage_request(
168                        htlc_preimage_request,
169                        None,
170                    )?),
171                    conversion_info: None,
172                }));
173            }
174
175            return Ok(Some(PaymentDetails::Spark {
176                invoice_details: None,
177                htlc_details: None,
178                conversion_info: None,
179            }));
180        }
181
182        let Some(user_request) = &transfer.user_request else {
183            return Ok(None);
184        };
185
186        let details = match user_request {
187            SspUserRequest::LightningReceiveRequest(request) => {
188                let invoice_details = input::parse_invoice(&request.invoice.encoded_invoice)
189                    .ok_or(SdkError::Generic(
190                        "Invalid invoice in SspUserRequest::LightningReceiveRequest".to_string(),
191                    ))?;
192                let htlc_details = if let Some(req) = &transfer.htlc_preimage_request {
193                    htlc_details_from_preimage_request(
194                        req,
195                        request.lightning_receive_payment_preimage.as_deref(),
196                    )?
197                } else {
198                    derive_htlc_details_from_ssp(
199                        transfer,
200                        &request.invoice.payment_hash,
201                        request.lightning_receive_payment_preimage.as_deref(),
202                    )?
203                };
204                PaymentDetails::Lightning {
205                    description: invoice_details.description,
206                    invoice: request.invoice.encoded_invoice.clone(),
207                    destination_pubkey: invoice_details.payee_pubkey,
208                    htlc_details,
209                    lnurl_pay_info: None,
210                    lnurl_withdraw_info: None,
211                    lnurl_receive_metadata: None,
212                    conversion_info: None,
213                }
214            }
215            SspUserRequest::LightningSendRequest(request) => {
216                let invoice_details =
217                    input::parse_invoice(&request.encoded_invoice).ok_or(SdkError::Generic(
218                        "Invalid invoice in SspUserRequest::LightningSendRequest".to_string(),
219                    ))?;
220                let htlc_details = if let Some(req) = &transfer.htlc_preimage_request {
221                    htlc_details_from_preimage_request(
222                        req,
223                        request.lightning_send_payment_preimage.as_deref(),
224                    )?
225                } else {
226                    derive_htlc_details_from_ssp(
227                        transfer,
228                        &invoice_details.payment_hash,
229                        request.lightning_send_payment_preimage.as_deref(),
230                    )?
231                };
232                PaymentDetails::Lightning {
233                    description: invoice_details.description,
234                    invoice: request.encoded_invoice.clone(),
235                    destination_pubkey: invoice_details.payee_pubkey,
236                    htlc_details,
237                    lnurl_pay_info: None,
238                    lnurl_withdraw_info: None,
239                    lnurl_receive_metadata: None,
240                    conversion_info: None,
241                }
242            }
243            SspUserRequest::CoopExitRequest(request) => {
244                let Some(vout) = withdrawal_vout(request) else {
245                    // An error here would stall the sync, so the payment is
246                    // stored without details instead.
247                    warn!(
248                        "The SSP's record of coop exit {} has no payout output; storing the payment without details",
249                        request.id
250                    );
251                    return Ok(None);
252                };
253                PaymentDetails::Withdraw {
254                    tx_id: request.coop_exit_txid.clone(),
255                    vout,
256                    destination_address: request.withdrawal_address.clone(),
257                }
258            }
259            SspUserRequest::LeavesSwapRequest(_) => PaymentDetails::Spark {
260                invoice_details: None,
261                htlc_details: None,
262                conversion_info: None,
263            },
264            SspUserRequest::ClaimStaticDeposit(request) => {
265                let Ok(vout) = u32::try_from(request.output_index) else {
266                    // vout=0 is a valid output index, so we can't safely default.
267                    // Returning Ok(None) mirrors the early return for a missing
268                    // user_request above: the downstream branch records the deposit
269                    // as Pending without details and the sync loop keeps advancing
270                    // the offset. Erroring here would deadlock the whole sync until
271                    // the offending transfer leaves the SSP's response.
272                    warn!(
273                        "Invalid output_index {} in SspUserRequest::ClaimStaticDeposit; skipping details",
274                        request.output_index
275                    );
276                    return Ok(None);
277                };
278                PaymentDetails::Deposit {
279                    tx_id: request.transaction_id.clone(),
280                    vout,
281                }
282            }
283        };
284
285        Ok(Some(details))
286    }
287}
288
289impl From<SparkInvoiceDetails> for SparkInvoicePaymentDetails {
290    fn from(value: SparkInvoiceDetails) -> Self {
291        Self {
292            description: value.description,
293            invoice: value.invoice,
294        }
295    }
296}
297
298impl TryFrom<WalletTransfer> for Payment {
299    type Error = SdkError;
300    fn try_from(transfer: WalletTransfer) -> Result<Self, Self::Error> {
301        if [
302            TransferType::CounterSwap,
303            TransferType::CounterSwapV3,
304            TransferType::Swap,
305            TransferType::PrimarySwapV3,
306        ]
307        .contains(&transfer.transfer_type)
308        {
309            debug!("Tried to convert swap-related transfer to payment. Transfer: {transfer:?}");
310            return Err(SdkError::Generic(
311                "Swap-related transfers are not considered payments".to_string(),
312            ));
313        }
314        let payment_type = match transfer.direction {
315            TransferDirection::Incoming => PaymentType::Receive,
316            TransferDirection::Outgoing => PaymentType::Send,
317        };
318        let mut status = match transfer.status {
319            TransferStatus::Completed => PaymentStatus::Completed,
320            TransferStatus::SenderKeyTweaked
321                if transfer.direction == TransferDirection::Outgoing =>
322            {
323                PaymentStatus::Completed
324            }
325            TransferStatus::Expired | TransferStatus::Returned => PaymentStatus::Failed,
326            _ => PaymentStatus::Pending,
327        };
328        let (fees_sat, mut amount_sat) = match transfer.clone().user_request {
329            Some(user_request) => match user_request {
330                SspUserRequest::LightningSendRequest(r) => {
331                    // TODO: if we have the preimage it is not pending. This is a workaround
332                    // until spark will implement incremental syncing based on updated time.
333                    if r.lightning_send_payment_preimage.is_some() {
334                        status = PaymentStatus::Completed;
335                    }
336                    let fee_sat = r.fee.as_sats().unwrap_or(0);
337                    (fee_sat, transfer.total_value_sat.saturating_sub(fee_sat))
338                }
339                SspUserRequest::CoopExitRequest(r) => {
340                    // A committed transfer hands the funds to the provider; only
341                    // the provider broadcasts the payout, and only once it has
342                    // accepted the exit. Until then the withdrawal is still in
343                    // flight, however far the transfer itself got.
344                    status = match r.exit_status.acceptance() {
345                        CoopExitAcceptance::Accepted => status,
346                        CoopExitAcceptance::AwaitingCompletion => PaymentStatus::Pending,
347                        CoopExitAcceptance::Failed => PaymentStatus::Failed,
348                    };
349                    let fee_sat = r
350                        .fee
351                        .as_sats()
352                        .unwrap_or(0)
353                        .saturating_add(r.l1_broadcast_fee.as_sats().unwrap_or(0));
354                    (fee_sat, transfer.total_value_sat.saturating_sub(fee_sat))
355                }
356                SspUserRequest::ClaimStaticDeposit(r) => {
357                    let fee_sat = r
358                        .deposit_amount
359                        .as_sats()
360                        .unwrap_or(0)
361                        .saturating_sub(r.credit_amount.as_sats().unwrap_or(0));
362                    (fee_sat, transfer.total_value_sat)
363                }
364                _ => (0, transfer.total_value_sat),
365            },
366            None => (0, transfer.total_value_sat),
367        };
368
369        let details = match PaymentDetails::from_transfer(&transfer) {
370            Ok(Some(details)) => Some(details),
371            Ok(None) => {
372                // in case we have a completed status without user object we want
373                // to keep syncing this payment
374                if status == PaymentStatus::Completed
375                    && [
376                        TransferType::CooperativeExit,
377                        TransferType::PreimageSwap,
378                        TransferType::UtxoSwap,
379                    ]
380                    .contains(&transfer.transfer_type)
381                {
382                    status = PaymentStatus::Pending;
383                }
384                amount_sat = transfer.total_value_sat;
385                None
386            }
387            // Failing here would stop sync at this transfer on every pass. Recording it as
388            // Pending lets sync move on while reconciliation keeps re-fetching it, so the
389            // details fill in once the operator or SSP serves data that maps again.
390            Err(e) => {
391                warn!(
392                    "Recording transfer {} as pending without payment details: {e:?}",
393                    transfer.id
394                );
395                status = PaymentStatus::Pending;
396                None
397            }
398        };
399
400        Ok(Payment {
401            id: transfer.id.to_string(),
402            payment_type,
403            status,
404            amount: amount_sat.into(),
405            fees: fees_sat.into(),
406            timestamp: match transfer.created_at.map(|t| t.duration_since(UNIX_EPOCH)) {
407                Some(Ok(duration)) => duration.as_secs(),
408                _ => 0,
409            },
410            method: PaymentMethod::from_transfer(&transfer),
411            details,
412            conversion_details: None,
413        })
414    }
415}
416
417impl Payment {
418    /// Creates a [`Payment`] from a [`LightningSendPayment`] and its associated HTLC details.
419    ///
420    /// The `htlc_details` may be stale (e.g. captured at payment creation time), so this
421    /// method reconciles them with the current state of the `payment`:
422    /// - The preimage is taken from `htlc_details` if present, otherwise from the payment.
423    /// - If a preimage is available from either source, the HTLC status is set to
424    ///   [`SparkHtlcStatus::PreimageShared`].
425    pub fn from_lightning(
426        payment: LightningSendPayment,
427        amount_sat: u128,
428        transfer_id: String,
429        mut htlc_details: SparkHtlcDetails,
430    ) -> Result<Self, SdkError> {
431        let mut status = match payment.status {
432            LightningSendStatus::LightningPaymentSucceeded => PaymentStatus::Completed,
433            LightningSendStatus::LightningPaymentFailed
434            | LightningSendStatus::TransferFailed
435            | LightningSendStatus::PreimageProvidingFailed
436            | LightningSendStatus::UserSwapReturnFailed
437            | LightningSendStatus::UserSwapReturned => PaymentStatus::Failed,
438            _ => PaymentStatus::Pending,
439        };
440        if payment.payment_preimage.is_some() {
441            status = PaymentStatus::Completed;
442        }
443
444        reconcile_htlc_preimage(&mut htlc_details, payment.payment_preimage.as_deref());
445
446        let invoice_details = input::parse_invoice(&payment.encoded_invoice).ok_or(
447            SdkError::Generic("Invalid invoice in LightnintSendPayment".to_string()),
448        )?;
449        let details = PaymentDetails::Lightning {
450            description: invoice_details.description,
451            invoice: payment.encoded_invoice,
452            destination_pubkey: invoice_details.payee_pubkey,
453            htlc_details,
454            lnurl_pay_info: None,
455            lnurl_withdraw_info: None,
456            lnurl_receive_metadata: None,
457            conversion_info: None,
458        };
459
460        Ok(Payment {
461            id: transfer_id,
462            payment_type: PaymentType::Send,
463            status,
464            amount: amount_sat,
465            fees: payment.fee_sat.into(),
466            timestamp: payment.created_at.cast_unsigned(),
467            method: PaymentMethod::Lightning,
468            details: Some(details),
469            conversion_details: None,
470        })
471    }
472}
473
474impl From<Network> for SparkNetwork {
475    fn from(network: Network) -> Self {
476        match network {
477            Network::Mainnet => SparkNetwork::Mainnet,
478            Network::Regtest => SparkNetwork::Regtest,
479            Network::Signet => SparkNetwork::Signet,
480        }
481    }
482}
483
484impl From<Fee> for spark_wallet::Fee {
485    fn from(fee: Fee) -> Self {
486        match fee {
487            Fee::Fixed { amount } => spark_wallet::Fee::Fixed { amount },
488            Fee::Rate { sat_per_vbyte } => spark_wallet::Fee::Rate { sat_per_vbyte },
489        }
490    }
491}
492
493impl From<spark_wallet::TokenBalance> for TokenBalance {
494    fn from(value: spark_wallet::TokenBalance) -> Self {
495        Self {
496            balance: value.balance,
497            token_metadata: value.token_metadata.into(),
498        }
499    }
500}
501
502impl From<spark_wallet::TokenMetadata> for TokenMetadata {
503    fn from(value: spark_wallet::TokenMetadata) -> Self {
504        Self {
505            identifier: value.identifier,
506            issuer_public_key: hex::encode(value.issuer_public_key.serialize()),
507            name: value.name,
508            ticker: value.ticker,
509            decimals: value.decimals,
510            max_supply: value.max_supply,
511            is_freezable: value.is_freezable,
512        }
513    }
514}
515
516impl From<CoopExitFeeQuote> for SendOnchainFeeQuote {
517    fn from(value: CoopExitFeeQuote) -> Self {
518        Self {
519            id: value.id,
520            expires_at: value.expires_at,
521            speed_fast: value.speed_fast.into(),
522            speed_medium: value.speed_medium.into(),
523            speed_slow: value.speed_slow.into(),
524            is_estimate: false,
525        }
526    }
527}
528
529impl From<SendOnchainFeeQuote> for CoopExitFeeQuote {
530    fn from(value: SendOnchainFeeQuote) -> Self {
531        Self {
532            id: value.id,
533            expires_at: value.expires_at,
534            speed_fast: value.speed_fast.into(),
535            speed_medium: value.speed_medium.into(),
536            speed_slow: value.speed_slow.into(),
537        }
538    }
539}
540
541impl From<CoopExitSpeedFeeQuote> for SendOnchainSpeedFeeQuote {
542    fn from(value: CoopExitSpeedFeeQuote) -> Self {
543        Self {
544            user_fee_sat: value.user_fee_sat,
545            l1_broadcast_fee_sat: value.l1_broadcast_fee_sat,
546        }
547    }
548}
549
550impl From<SendOnchainSpeedFeeQuote> for CoopExitSpeedFeeQuote {
551    fn from(value: SendOnchainSpeedFeeQuote) -> Self {
552        Self {
553            user_fee_sat: value.user_fee_sat,
554            l1_broadcast_fee_sat: value.l1_broadcast_fee_sat,
555        }
556    }
557}
558
559impl From<OnchainConfirmationSpeed> for ExitSpeed {
560    fn from(speed: OnchainConfirmationSpeed) -> Self {
561        match speed {
562            OnchainConfirmationSpeed::Fast => ExitSpeed::Fast,
563            OnchainConfirmationSpeed::Medium => ExitSpeed::Medium,
564            OnchainConfirmationSpeed::Slow => ExitSpeed::Slow,
565        }
566    }
567}
568
569impl From<ExitSpeed> for OnchainConfirmationSpeed {
570    fn from(speed: ExitSpeed) -> Self {
571        match speed {
572            ExitSpeed::Fast => OnchainConfirmationSpeed::Fast,
573            ExitSpeed::Medium => OnchainConfirmationSpeed::Medium,
574            ExitSpeed::Slow => OnchainConfirmationSpeed::Slow,
575        }
576    }
577}
578
579impl PaymentStatus {
580    pub(crate) fn from_token_transaction_status(
581        status: TokenTransactionStatus,
582        is_transfer_transaction: bool,
583    ) -> Self {
584        match status {
585            TokenTransactionStatus::Started
586            | TokenTransactionStatus::Revealed
587            | TokenTransactionStatus::Unknown => PaymentStatus::Pending,
588            TokenTransactionStatus::Signed if is_transfer_transaction => PaymentStatus::Pending,
589            TokenTransactionStatus::Finalized | TokenTransactionStatus::Signed => {
590                PaymentStatus::Completed
591            }
592            TokenTransactionStatus::StartedCancelled | TokenTransactionStatus::SignedCancelled => {
593                PaymentStatus::Failed
594            }
595        }
596    }
597}
598
599impl TryFrom<PreimageRequest> for SparkHtlcDetails {
600    type Error = SdkError;
601    fn try_from(value: PreimageRequest) -> Result<Self, Self::Error> {
602        htlc_details_from_preimage_request(&value, None)
603    }
604}
605
606impl TryFrom<PreimageRequestStatus> for SparkHtlcStatus {
607    type Error = SdkError;
608    fn try_from(status: PreimageRequestStatus) -> Result<Self, Self::Error> {
609        match status {
610            PreimageRequestStatus::WaitingForPreimage => Ok(SparkHtlcStatus::WaitingForPreimage),
611            PreimageRequestStatus::PreimageShared => Ok(SparkHtlcStatus::PreimageShared),
612            PreimageRequestStatus::Returned => Ok(SparkHtlcStatus::Returned),
613            // No public status fits: any of them would claim a state we don't know,
614            // e.g. `WaitingForPreimage` would list the HTLC as claimable.
615            PreimageRequestStatus::Unknown => Err(SdkError::Generic(
616                "Unrecognized preimage request status".to_string(),
617            )),
618        }
619    }
620}
621
622impl From<spark_wallet::AutoOptimizationEvent> for AutoOptimizationEvent {
623    fn from(value: spark_wallet::AutoOptimizationEvent) -> Self {
624        match value {
625            spark_wallet::AutoOptimizationEvent::Started { total_rounds } => {
626                Self::Started { total_rounds }
627            }
628            spark_wallet::AutoOptimizationEvent::RoundCompleted {
629                current_round,
630                total_rounds,
631            } => Self::RoundCompleted {
632                current_round,
633                total_rounds,
634            },
635            spark_wallet::AutoOptimizationEvent::Completed => Self::Completed,
636            spark_wallet::AutoOptimizationEvent::Cancelled => Self::Cancelled,
637            spark_wallet::AutoOptimizationEvent::Failed { error } => Self::Failed { error },
638            spark_wallet::AutoOptimizationEvent::Skipped => Self::Skipped,
639        }
640    }
641}
642
643impl From<spark_wallet::OptimizationOutcome> for OptimizationOutcome {
644    fn from(value: spark_wallet::OptimizationOutcome) -> Self {
645        match value {
646            spark_wallet::OptimizationOutcome::Completed { rounds_executed } => {
647                Self::Completed { rounds_executed }
648            }
649            spark_wallet::OptimizationOutcome::InProgress => Self::InProgress,
650        }
651    }
652}
653
654impl From<crate::CpfpFundingStrategy> for spark_wallet::CpfpFundingStrategy {
655    fn from(value: crate::CpfpFundingStrategy) -> Self {
656        match value {
657            crate::CpfpFundingStrategy::PerBranch => Self::PerBranch,
658            crate::CpfpFundingStrategy::PerNode => Self::PerNode,
659        }
660    }
661}
662
663impl From<crate::WebhookEventType> for spark_wallet::SparkWalletWebhookEventType {
664    fn from(value: crate::WebhookEventType) -> Self {
665        match value {
666            crate::WebhookEventType::LightningReceiveFinished => {
667                Self::SparkLightningReceiveFinished
668            }
669            crate::WebhookEventType::LightningSendFinished => Self::SparkLightningSendFinished,
670            crate::WebhookEventType::CoopExitFinished => Self::SparkCoopExitFinished,
671            crate::WebhookEventType::StaticDepositFinished => Self::SparkStaticDepositFinished,
672            crate::WebhookEventType::Unknown(s) => Self::Unknown(s),
673        }
674    }
675}
676
677impl From<spark_wallet::SparkWalletWebhookEventType> for crate::WebhookEventType {
678    fn from(value: spark_wallet::SparkWalletWebhookEventType) -> Self {
679        match value {
680            spark_wallet::SparkWalletWebhookEventType::SparkLightningReceiveFinished => {
681                Self::LightningReceiveFinished
682            }
683            spark_wallet::SparkWalletWebhookEventType::SparkLightningSendFinished => {
684                Self::LightningSendFinished
685            }
686            spark_wallet::SparkWalletWebhookEventType::SparkCoopExitFinished => {
687                Self::CoopExitFinished
688            }
689            spark_wallet::SparkWalletWebhookEventType::SparkStaticDepositFinished => {
690                Self::StaticDepositFinished
691            }
692            spark_wallet::SparkWalletWebhookEventType::Unknown(s) => Self::Unknown(s),
693        }
694    }
695}
696
697impl From<spark_wallet::WebhookEntry> for crate::Webhook {
698    fn from(value: spark_wallet::WebhookEntry) -> Self {
699        Self {
700            id: value.webhook_id,
701            url: value.url,
702            event_types: value.event_types.into_iter().map(Into::into).collect(),
703        }
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710    use crate::SendOnchainFeeQuote;
711    use chrono::Utc;
712    use macros::test_all;
713    use platform_utils::time::SystemTime;
714    use spark_wallet::{
715        BitcoinNetwork, CoopExitFeeQuote, CoopExitSpeedFeeQuote, CurrencyAmount, CurrencyUnit,
716        LightningSendRequest, LightningSendRequestStatus, PublicKey, SparkCoopExitRequestStatus,
717        SspTransfer, Transfer, TransferId,
718    };
719
720    #[cfg(feature = "browser-tests")]
721    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
722
723    #[test_all]
724    fn test_a_provider_quote_is_never_an_estimate() {
725        // What keeps a real quote distinguishable from a locally derived one.
726        // Marking it an estimate would give it an estimate's fee headroom and
727        // stop it being refreshed near expiry.
728        let speed = || CoopExitSpeedFeeQuote {
729            user_fee_sat: 750,
730            l1_broadcast_fee_sat: 960,
731        };
732        let quote: SendOnchainFeeQuote = CoopExitFeeQuote {
733            id: "SparkCoopExitFeeQuote:test".to_string(),
734            expires_at: 1_787_907_773,
735            speed_fast: speed(),
736            speed_medium: speed(),
737            speed_slow: speed(),
738        }
739        .into();
740
741        assert!(!quote.is_estimate);
742        assert_eq!(quote.id, "SparkCoopExitFeeQuote:test");
743        assert_eq!(quote.expires_at, 1_787_907_773);
744    }
745
746    fn pk(fill_byte: u8) -> PublicKey {
747        let mut bytes = [fill_byte; 33];
748        bytes[0] = 2;
749        PublicKey::from_slice(&bytes).unwrap()
750    }
751
752    /// A completed transfer created now, sent from `sender` to `receiver`.
753    fn transfer(sender: PublicKey, receiver: PublicKey, transfer_type: TransferType) -> Transfer {
754        let now = SystemTime::now()
755            .duration_since(UNIX_EPOCH)
756            .unwrap()
757            .as_secs();
758        Transfer {
759            id: TransferId::generate(),
760            sender_identity_public_key: sender,
761            receiver_identity_public_key: receiver,
762            status: TransferStatus::Completed,
763            total_value: 1_010,
764            expiry_time: None,
765            leaves: Vec::new(),
766            created_time: Some(now),
767            updated_time: Some(now),
768            transfer_type,
769            spark_invoice: None,
770        }
771    }
772
773    #[test_all]
774    fn a_lightning_send_whose_details_fail_to_map_is_pending_with_its_amount() {
775        let (ours, ssp) = (pk(1), pk(2));
776        let request = LightningSendRequest {
777            id: "request".to_string(),
778            created_at: Utc::now(),
779            updated_at: Utc::now(),
780            network: BitcoinNetwork::Regtest,
781            encoded_invoice: "not an invoice".to_string(),
782            fee: CurrencyAmount {
783                original_value: 10,
784                original_unit: CurrencyUnit::Satoshi,
785                preferred_currency_unit: CurrencyUnit::Satoshi,
786                preferred_currency_value_rounded: 10,
787            },
788            idempotency_key: "key".to_string(),
789            status: LightningSendRequestStatus::LightningPaymentSucceeded,
790            transfer: None,
791            lightning_send_payment_preimage: None,
792        };
793        let wallet_transfer = WalletTransfer::from_transfer(
794            transfer(ours, ssp, TransferType::PreimageSwap),
795            Some(SspTransfer {
796                total_amount: request.fee.clone(),
797                spark_id: None,
798                user_request: Some(SspUserRequest::LightningSendRequest(request)),
799            }),
800            None,
801            ours,
802            ssp,
803        );
804
805        let payment = Payment::try_from(wallet_transfer).unwrap();
806
807        assert!(payment.details.is_none());
808        assert_eq!(payment.method, PaymentMethod::Lightning);
809        assert_eq!(payment.payment_type, PaymentType::Send);
810        // Pending so reconciliation re-fetches it until its details map.
811        assert_eq!(payment.status, PaymentStatus::Pending);
812        assert_eq!(payment.amount, 1_000);
813        assert_eq!(payment.fees, 10);
814    }
815
816    #[test_all]
817    fn a_spark_transfer_with_an_invalid_invoice_is_a_pending_payment() {
818        let (ours, other, ssp) = (pk(1), pk(7), pk(2));
819        let mut raw = transfer(other, ours, TransferType::Transfer);
820        raw.spark_invoice = Some("not an invoice".to_string());
821        let wallet_transfer = WalletTransfer::from_transfer(raw, None, None, ours, ssp);
822
823        let payment = Payment::try_from(wallet_transfer).unwrap();
824
825        assert!(payment.details.is_none());
826        assert_eq!(payment.method, PaymentMethod::Spark);
827        assert_eq!(payment.payment_type, PaymentType::Receive);
828        assert_eq!(payment.status, PaymentStatus::Pending);
829        assert_eq!(payment.amount, 1_010);
830    }
831
832    #[test_all]
833    fn a_spark_htlc_with_an_unknown_preimage_status_is_pending_without_details() {
834        use bitcoin::hashes::{Hash, sha256};
835
836        let (ours, other, ssp) = (pk(1), pk(7), pk(2));
837        let preimage_request = PreimageRequest {
838            payment_hash: sha256::Hash::hash(b"preimage"),
839            status: PreimageRequestStatus::Unknown,
840            created_time: SystemTime::now(),
841            expiry_time: SystemTime::now(),
842            preimage: None,
843        };
844        let wallet_transfer = WalletTransfer::from_transfer(
845            transfer(other, ours, TransferType::PreimageSwap),
846            None,
847            Some(preimage_request),
848            ours,
849            ssp,
850        );
851
852        let payment = Payment::try_from(wallet_transfer).unwrap();
853
854        // No HTLC details, so an `htlc_status` filter can't list it as claimable.
855        assert!(payment.details.is_none());
856        assert_eq!(payment.method, PaymentMethod::Spark);
857        assert_eq!(payment.status, PaymentStatus::Pending);
858    }
859
860    #[test_all]
861    fn an_unknown_preimage_status_with_a_known_preimage_is_preimage_shared() {
862        use bitcoin::hashes::{Hash, sha256};
863
864        let preimage_request = PreimageRequest {
865            payment_hash: sha256::Hash::hash(b"preimage"),
866            status: PreimageRequestStatus::Unknown,
867            created_time: SystemTime::now(),
868            expiry_time: SystemTime::now(),
869            preimage: None,
870        };
871        let preimage = "00".repeat(32);
872
873        let details =
874            htlc_details_from_preimage_request(&preimage_request, Some(&preimage)).unwrap();
875
876        assert_eq!(details.status, SparkHtlcStatus::PreimageShared);
877        assert_eq!(details.preimage, Some(preimage));
878    }
879
880    fn coop_exit(withdrawal_address: &str, raw_coop_exit_transaction: &str) -> CoopExitRequest {
881        CoopExitRequest {
882            id: "request".to_string(),
883            created_at: Utc::now(),
884            updated_at: Utc::now(),
885            network: BitcoinNetwork::Regtest,
886            fee: CurrencyAmount::default(),
887            withdrawal_address: withdrawal_address.to_string(),
888            l1_broadcast_fee: CurrencyAmount::default(),
889            exit_status: SparkCoopExitRequestStatus::Succeeded,
890            exit_expired_at: Utc::now(),
891            raw_connector_transaction: String::new(),
892            raw_coop_exit_transaction: raw_coop_exit_transaction.to_string(),
893            coop_exit_txid: "exit_txid".to_string(),
894            transfer: None,
895        }
896    }
897
898    fn regtest_address(key: PublicKey) -> Address {
899        Address::p2wpkh(
900            &bitcoin::CompressedPublicKey(key),
901            bitcoin::Network::Regtest,
902        )
903    }
904
905    fn exit_transaction_hex(addresses: &[&Address]) -> String {
906        let transaction = Transaction {
907            version: bitcoin::transaction::Version::TWO,
908            lock_time: bitcoin::absolute::LockTime::ZERO,
909            input: vec![bitcoin::TxIn::default()],
910            output: addresses
911                .iter()
912                .map(|address| bitcoin::TxOut {
913                    value: bitcoin::Amount::from_sat(1_000),
914                    script_pubkey: address.script_pubkey(),
915                })
916                .collect(),
917        };
918        bitcoin::consensus::encode::serialize_hex(&transaction)
919    }
920
921    #[test_all]
922    fn a_withdrawal_carries_its_address_and_the_index_of_its_payout_output() {
923        let (ours, ssp) = (pk(1), pk(2));
924        let address = regtest_address(pk(7));
925        let raw_transaction = exit_transaction_hex(&[&regtest_address(ssp), &address]);
926        let wallet_transfer = WalletTransfer::from_transfer(
927            transfer(ours, ssp, TransferType::CooperativeExit),
928            Some(SspTransfer {
929                total_amount: CurrencyAmount::default(),
930                spark_id: None,
931                user_request: Some(SspUserRequest::CoopExitRequest(coop_exit(
932                    &address.to_string(),
933                    &raw_transaction,
934                ))),
935            }),
936            None,
937            ours,
938            ssp,
939        );
940
941        let payment = Payment::try_from(wallet_transfer).unwrap();
942
943        let Some(PaymentDetails::Withdraw {
944            tx_id,
945            vout,
946            destination_address,
947        }) = payment.details
948        else {
949            panic!("expected withdraw details, got {:?}", payment.details);
950        };
951        assert_eq!(tx_id, "exit_txid");
952        assert_eq!(vout, 1);
953        assert_eq!(destination_address, address.to_string());
954    }
955
956    #[test_all]
957    fn a_withdrawal_without_a_payout_output_has_no_details() {
958        let (ours, ssp) = (pk(1), pk(2));
959        let address = regtest_address(pk(7));
960        let raw_transaction = exit_transaction_hex(&[&regtest_address(ssp)]);
961        let wallet_transfer = WalletTransfer::from_transfer(
962            transfer(ours, ssp, TransferType::CooperativeExit),
963            Some(SspTransfer {
964                total_amount: CurrencyAmount::default(),
965                spark_id: None,
966                user_request: Some(SspUserRequest::CoopExitRequest(coop_exit(
967                    &address.to_string(),
968                    &raw_transaction,
969                ))),
970            }),
971            None,
972            ours,
973            ssp,
974        );
975
976        let payment = Payment::try_from(wallet_transfer).unwrap();
977
978        assert_eq!(payment.method, PaymentMethod::Withdraw);
979        assert!(payment.details.is_none(), "{:?}", payment.details);
980    }
981
982    #[test_all]
983    fn no_output_index_without_an_output_to_the_withdrawal_address() {
984        let address = regtest_address(pk(7));
985        let raw_transaction = exit_transaction_hex(&[&regtest_address(pk(2))]);
986
987        let vout = withdrawal_vout(&coop_exit(&address.to_string(), &raw_transaction));
988
989        assert_eq!(vout, None);
990    }
991
992    #[test_all]
993    fn the_payout_to_a_silent_payment_address_is_output_zero() {
994        let address = SilentPaymentAddress::new(pk(1), pk(7), SparkNetwork::Regtest);
995        let raw_transaction = exit_transaction_hex(&[&regtest_address(pk(2))]);
996
997        let vout = withdrawal_vout(&coop_exit(&address.to_string(), &raw_transaction));
998
999        assert_eq!(vout, Some(0));
1000    }
1001
1002    #[test_all]
1003    fn no_output_index_when_the_exit_transaction_does_not_decode() {
1004        let address = SilentPaymentAddress::new(pk(1), pk(7), SparkNetwork::Regtest);
1005
1006        let vout = withdrawal_vout(&coop_exit(&address.to_string(), "not a transaction"));
1007
1008        assert_eq!(vout, None);
1009    }
1010}