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