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#[allow(clippy::duration_suboptimal_units)]
24const HTLC_DATA_REQUIRED_SINCE: Duration = Duration::from_secs(1_769_904_000);
25
26fn 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
66fn 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 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 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 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 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 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 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 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 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}