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 Fee, Network, OnchainConfirmationSpeed, OptimizationProgress, Payment, PaymentDetails,
16 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 }
162 }
163 SspUserRequest::LightningSendRequest(request) => {
164 let invoice_details =
165 input::parse_invoice(&request.encoded_invoice).ok_or(SdkError::Generic(
166 "Invalid invoice in SspUserRequest::LightningSendRequest".to_string(),
167 ))?;
168 let htlc_details = if let Some(req) = &transfer.htlc_preimage_request {
169 let mut details: SparkHtlcDetails = req.clone().try_into()?;
170 reconcile_htlc_preimage(
171 &mut details,
172 request.lightning_send_payment_preimage.as_deref(),
173 );
174 details
175 } else {
176 derive_htlc_details_from_ssp(
177 transfer,
178 &invoice_details.payment_hash,
179 request.lightning_send_payment_preimage.as_deref(),
180 )?
181 };
182 PaymentDetails::Lightning {
183 description: invoice_details.description,
184 invoice: request.encoded_invoice.clone(),
185 destination_pubkey: invoice_details.payee_pubkey,
186 htlc_details,
187 lnurl_pay_info: None,
188 lnurl_withdraw_info: None,
189 lnurl_receive_metadata: None,
190 }
191 }
192 SspUserRequest::CoopExitRequest(request) => PaymentDetails::Withdraw {
193 tx_id: request.coop_exit_txid.clone(),
194 },
195 SspUserRequest::LeavesSwapRequest(_) => PaymentDetails::Spark {
196 invoice_details: None,
197 htlc_details: None,
198 conversion_info: None,
199 },
200 SspUserRequest::ClaimStaticDeposit(request) => PaymentDetails::Deposit {
201 tx_id: request.transaction_id.clone(),
202 },
203 };
204
205 Ok(Some(details))
206 }
207}
208
209impl From<SparkInvoiceDetails> for SparkInvoicePaymentDetails {
210 fn from(value: SparkInvoiceDetails) -> Self {
211 Self {
212 description: value.description,
213 invoice: value.invoice,
214 }
215 }
216}
217
218impl TryFrom<WalletTransfer> for Payment {
219 type Error = SdkError;
220 fn try_from(transfer: WalletTransfer) -> Result<Self, Self::Error> {
221 if [
222 TransferType::CounterSwap,
223 TransferType::CounterSwapV3,
224 TransferType::Swap,
225 TransferType::PrimarySwapV3,
226 ]
227 .contains(&transfer.transfer_type)
228 {
229 debug!("Tried to convert swap-related transfer to payment. Transfer: {transfer:?}");
230 return Err(SdkError::Generic(
231 "Swap-related transfers are not considered payments".to_string(),
232 ));
233 }
234 let payment_type = match transfer.direction {
235 TransferDirection::Incoming => PaymentType::Receive,
236 TransferDirection::Outgoing => PaymentType::Send,
237 };
238 let mut status = match transfer.status {
239 TransferStatus::Completed => PaymentStatus::Completed,
240 TransferStatus::SenderKeyTweaked
241 if transfer.direction == TransferDirection::Outgoing =>
242 {
243 PaymentStatus::Completed
244 }
245 TransferStatus::Expired | TransferStatus::Returned => PaymentStatus::Failed,
246 _ => PaymentStatus::Pending,
247 };
248 let (fees_sat, mut amount_sat) = match transfer.clone().user_request {
249 Some(user_request) => match user_request {
250 SspUserRequest::LightningSendRequest(r) => {
251 if r.lightning_send_payment_preimage.is_some() {
254 status = PaymentStatus::Completed;
255 }
256 let fee_sat = r.fee.as_sats().unwrap_or(0);
257 (fee_sat, transfer.total_value_sat.saturating_sub(fee_sat))
258 }
259 SspUserRequest::CoopExitRequest(r) => {
260 let fee_sat = r
261 .fee
262 .as_sats()
263 .unwrap_or(0)
264 .saturating_add(r.l1_broadcast_fee.as_sats().unwrap_or(0));
265 (fee_sat, transfer.total_value_sat.saturating_sub(fee_sat))
266 }
267 SspUserRequest::ClaimStaticDeposit(r) => {
268 let fee_sat = r
269 .deposit_amount
270 .as_sats()
271 .unwrap_or(0)
272 .saturating_sub(r.credit_amount.as_sats().unwrap_or(0));
273 (fee_sat, transfer.total_value_sat)
274 }
275 _ => (0, transfer.total_value_sat),
276 },
277 None => (0, transfer.total_value_sat),
278 };
279
280 let details = PaymentDetails::from_transfer(&transfer)?;
281 if details.is_none() {
282 if status == PaymentStatus::Completed
285 && [
286 TransferType::CooperativeExit,
287 TransferType::PreimageSwap,
288 TransferType::UtxoSwap,
289 ]
290 .contains(&transfer.transfer_type)
291 {
292 status = PaymentStatus::Pending;
293 }
294 amount_sat = transfer.total_value_sat;
295 }
296
297 Ok(Payment {
298 id: transfer.id.to_string(),
299 payment_type,
300 status,
301 amount: amount_sat.into(),
302 fees: fees_sat.into(),
303 timestamp: match transfer.created_at.map(|t| t.duration_since(UNIX_EPOCH)) {
304 Some(Ok(duration)) => duration.as_secs(),
305 _ => 0,
306 },
307 method: PaymentMethod::from_transfer(&transfer),
308 details,
309 conversion_details: None,
310 })
311 }
312}
313
314impl Payment {
315 pub fn from_lightning(
323 payment: LightningSendPayment,
324 amount_sat: u128,
325 transfer_id: String,
326 mut htlc_details: SparkHtlcDetails,
327 ) -> Result<Self, SdkError> {
328 let mut status = match payment.status {
329 LightningSendStatus::LightningPaymentSucceeded => PaymentStatus::Completed,
330 LightningSendStatus::LightningPaymentFailed
331 | LightningSendStatus::TransferFailed
332 | LightningSendStatus::PreimageProvidingFailed
333 | LightningSendStatus::UserSwapReturnFailed
334 | LightningSendStatus::UserSwapReturned => PaymentStatus::Failed,
335 _ => PaymentStatus::Pending,
336 };
337 if payment.payment_preimage.is_some() {
338 status = PaymentStatus::Completed;
339 }
340
341 reconcile_htlc_preimage(&mut htlc_details, payment.payment_preimage.as_deref());
342
343 let invoice_details = input::parse_invoice(&payment.encoded_invoice).ok_or(
344 SdkError::Generic("Invalid invoice in LightnintSendPayment".to_string()),
345 )?;
346 let details = PaymentDetails::Lightning {
347 description: invoice_details.description,
348 invoice: payment.encoded_invoice,
349 destination_pubkey: invoice_details.payee_pubkey,
350 htlc_details,
351 lnurl_pay_info: None,
352 lnurl_withdraw_info: None,
353 lnurl_receive_metadata: None,
354 };
355
356 Ok(Payment {
357 id: transfer_id,
358 payment_type: PaymentType::Send,
359 status,
360 amount: amount_sat,
361 fees: payment.fee_sat.into(),
362 timestamp: payment.created_at.cast_unsigned(),
363 method: PaymentMethod::Lightning,
364 details: Some(details),
365 conversion_details: None,
366 })
367 }
368}
369
370impl From<Network> for SparkNetwork {
371 fn from(network: Network) -> Self {
372 match network {
373 Network::Mainnet => SparkNetwork::Mainnet,
374 Network::Regtest => SparkNetwork::Regtest,
375 }
376 }
377}
378
379impl From<Fee> for spark_wallet::Fee {
380 fn from(fee: Fee) -> Self {
381 match fee {
382 Fee::Fixed { amount } => spark_wallet::Fee::Fixed { amount },
383 Fee::Rate { sat_per_vbyte } => spark_wallet::Fee::Rate { sat_per_vbyte },
384 }
385 }
386}
387
388impl From<spark_wallet::TokenBalance> for TokenBalance {
389 fn from(value: spark_wallet::TokenBalance) -> Self {
390 Self {
391 balance: value.balance,
392 token_metadata: value.token_metadata.into(),
393 }
394 }
395}
396
397impl From<spark_wallet::TokenMetadata> for TokenMetadata {
398 fn from(value: spark_wallet::TokenMetadata) -> Self {
399 Self {
400 identifier: value.identifier,
401 issuer_public_key: hex::encode(value.issuer_public_key.serialize()),
402 name: value.name,
403 ticker: value.ticker,
404 decimals: value.decimals,
405 max_supply: value.max_supply,
406 is_freezable: value.is_freezable,
407 }
408 }
409}
410
411impl From<CoopExitFeeQuote> for SendOnchainFeeQuote {
412 fn from(value: CoopExitFeeQuote) -> Self {
413 Self {
414 id: value.id,
415 expires_at: value.expires_at,
416 speed_fast: value.speed_fast.into(),
417 speed_medium: value.speed_medium.into(),
418 speed_slow: value.speed_slow.into(),
419 }
420 }
421}
422
423impl From<SendOnchainFeeQuote> for CoopExitFeeQuote {
424 fn from(value: SendOnchainFeeQuote) -> Self {
425 Self {
426 id: value.id,
427 expires_at: value.expires_at,
428 speed_fast: value.speed_fast.into(),
429 speed_medium: value.speed_medium.into(),
430 speed_slow: value.speed_slow.into(),
431 }
432 }
433}
434
435impl From<CoopExitSpeedFeeQuote> for SendOnchainSpeedFeeQuote {
436 fn from(value: CoopExitSpeedFeeQuote) -> Self {
437 Self {
438 user_fee_sat: value.user_fee_sat,
439 l1_broadcast_fee_sat: value.l1_broadcast_fee_sat,
440 }
441 }
442}
443
444impl From<SendOnchainSpeedFeeQuote> for CoopExitSpeedFeeQuote {
445 fn from(value: SendOnchainSpeedFeeQuote) -> Self {
446 Self {
447 user_fee_sat: value.user_fee_sat,
448 l1_broadcast_fee_sat: value.l1_broadcast_fee_sat,
449 }
450 }
451}
452
453impl From<OnchainConfirmationSpeed> for ExitSpeed {
454 fn from(speed: OnchainConfirmationSpeed) -> Self {
455 match speed {
456 OnchainConfirmationSpeed::Fast => ExitSpeed::Fast,
457 OnchainConfirmationSpeed::Medium => ExitSpeed::Medium,
458 OnchainConfirmationSpeed::Slow => ExitSpeed::Slow,
459 }
460 }
461}
462
463impl From<ExitSpeed> for OnchainConfirmationSpeed {
464 fn from(speed: ExitSpeed) -> Self {
465 match speed {
466 ExitSpeed::Fast => OnchainConfirmationSpeed::Fast,
467 ExitSpeed::Medium => OnchainConfirmationSpeed::Medium,
468 ExitSpeed::Slow => OnchainConfirmationSpeed::Slow,
469 }
470 }
471}
472
473impl PaymentStatus {
474 pub(crate) fn from_token_transaction_status(
475 status: TokenTransactionStatus,
476 is_transfer_transaction: bool,
477 ) -> Self {
478 match status {
479 TokenTransactionStatus::Started
480 | TokenTransactionStatus::Revealed
481 | TokenTransactionStatus::Unknown => PaymentStatus::Pending,
482 TokenTransactionStatus::Signed if is_transfer_transaction => PaymentStatus::Pending,
483 TokenTransactionStatus::Finalized | TokenTransactionStatus::Signed => {
484 PaymentStatus::Completed
485 }
486 TokenTransactionStatus::StartedCancelled | TokenTransactionStatus::SignedCancelled => {
487 PaymentStatus::Failed
488 }
489 }
490 }
491}
492
493impl TryFrom<PreimageRequest> for SparkHtlcDetails {
494 type Error = SdkError;
495 fn try_from(value: PreimageRequest) -> Result<Self, Self::Error> {
496 Ok(Self {
497 payment_hash: value.payment_hash.to_string(),
498 preimage: value.preimage.map(|p| p.encode_hex()),
499 expiry_time: value
500 .expiry_time
501 .duration_since(UNIX_EPOCH)
502 .map_err(|e| SdkError::Generic(format!("Invalid expiry time: {e}")))?
503 .as_secs(),
504 status: value.status.into(),
505 })
506 }
507}
508
509impl From<PreimageRequestStatus> for SparkHtlcStatus {
510 fn from(status: PreimageRequestStatus) -> Self {
511 match status {
512 PreimageRequestStatus::WaitingForPreimage => SparkHtlcStatus::WaitingForPreimage,
513 PreimageRequestStatus::PreimageShared => SparkHtlcStatus::PreimageShared,
514 PreimageRequestStatus::Returned => SparkHtlcStatus::Returned,
515 }
516 }
517}
518
519impl From<spark_wallet::OptimizationProgress> for OptimizationProgress {
520 fn from(value: spark_wallet::OptimizationProgress) -> Self {
521 Self {
522 is_running: value.is_running,
523 current_round: value.current_round,
524 total_rounds: value.total_rounds,
525 }
526 }
527}
528
529impl From<crate::WebhookEventType> for spark_wallet::SparkWalletWebhookEventType {
530 fn from(value: crate::WebhookEventType) -> Self {
531 match value {
532 crate::WebhookEventType::LightningReceiveFinished => {
533 Self::SparkLightningReceiveFinished
534 }
535 crate::WebhookEventType::LightningSendFinished => Self::SparkLightningSendFinished,
536 crate::WebhookEventType::CoopExitFinished => Self::SparkCoopExitFinished,
537 crate::WebhookEventType::StaticDepositFinished => Self::SparkStaticDepositFinished,
538 crate::WebhookEventType::Unknown(s) => Self::Unknown(s),
539 }
540 }
541}
542
543impl From<spark_wallet::SparkWalletWebhookEventType> for crate::WebhookEventType {
544 fn from(value: spark_wallet::SparkWalletWebhookEventType) -> Self {
545 match value {
546 spark_wallet::SparkWalletWebhookEventType::SparkLightningReceiveFinished => {
547 Self::LightningReceiveFinished
548 }
549 spark_wallet::SparkWalletWebhookEventType::SparkLightningSendFinished => {
550 Self::LightningSendFinished
551 }
552 spark_wallet::SparkWalletWebhookEventType::SparkCoopExitFinished => {
553 Self::CoopExitFinished
554 }
555 spark_wallet::SparkWalletWebhookEventType::SparkStaticDepositFinished => {
556 Self::StaticDepositFinished
557 }
558 spark_wallet::SparkWalletWebhookEventType::Unknown(s) => Self::Unknown(s),
559 }
560 }
561}
562
563impl From<spark_wallet::WebhookEntry> for crate::Webhook {
564 fn from(value: spark_wallet::WebhookEntry) -> Self {
565 Self {
566 id: value.webhook_id,
567 url: value.url,
568 event_types: value.event_types.into_iter().map(Into::into).collect(),
569 }
570 }
571}