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 tracing::{debug, warn};
12use web_time::UNIX_EPOCH;
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
21const HTLC_DATA_REQUIRED_SINCE: Duration = Duration::from_secs(1_769_904_000);
23
24fn derive_htlc_details_from_ssp(
28 transfer: &WalletTransfer,
29 payment_hash: &str,
30 preimage: Option<&str>,
31) -> Result<SparkHtlcDetails, SdkError> {
32 let cutoff = UNIX_EPOCH
33 .checked_add(HTLC_DATA_REQUIRED_SINCE)
34 .ok_or_else(|| SdkError::Generic("HTLC cutoff time overflow".to_string()))?;
35 let is_old = transfer.created_at.is_none_or(|t| t < cutoff);
36 if !is_old {
37 return Err(SdkError::Generic(format!(
38 "Missing HTLC details for Lightning payment transfer {}",
39 transfer.id
40 )));
41 }
42
43 warn!(
44 "Missing HTLC preimage request for Lightning transfer {}, deriving from SSP data",
45 transfer.id
46 );
47
48 let status = match transfer.status {
49 TransferStatus::Completed => SparkHtlcStatus::PreimageShared,
50 TransferStatus::Expired | TransferStatus::Returned => SparkHtlcStatus::Returned,
51 _ => SparkHtlcStatus::WaitingForPreimage,
52 };
53 Ok(SparkHtlcDetails {
54 payment_hash: payment_hash.to_string(),
55 preimage: preimage.map(ToString::to_string),
56 expiry_time: transfer
57 .expiry_time
58 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
59 .map_or(0, |d| d.as_secs()),
60 status,
61 })
62}
63
64fn reconcile_htlc_preimage(details: &mut SparkHtlcDetails, preimage: Option<&str>) {
67 if details.preimage.is_none() {
68 details.preimage = preimage.map(ToString::to_string);
69 }
70 if details.preimage.is_some() {
71 details.status = SparkHtlcStatus::PreimageShared;
72 }
73}
74
75impl PaymentMethod {
76 fn from_transfer(transfer: &WalletTransfer) -> Self {
77 match transfer.transfer_type {
78 TransferType::PreimageSwap => {
79 if transfer.is_ssp_transfer {
80 PaymentMethod::Lightning
81 } else {
82 PaymentMethod::Spark
83 }
84 }
85 TransferType::CooperativeExit => PaymentMethod::Withdraw,
86 TransferType::UtxoSwap => PaymentMethod::Deposit,
87 TransferType::Transfer => PaymentMethod::Spark,
88 _ => PaymentMethod::Unknown,
89 }
90 }
91}
92
93impl PaymentDetails {
94 #[allow(clippy::too_many_lines)]
95 fn from_transfer(transfer: &WalletTransfer) -> Result<Option<Self>, SdkError> {
96 if !transfer.is_ssp_transfer {
97 if let Some(spark_invoice) = &transfer.spark_invoice {
99 let Some(InputType::SparkInvoice(invoice_details)) =
100 parse_spark_address(spark_invoice, &PaymentRequestSource::default())
101 else {
102 return Err(SdkError::Generic("Invalid spark invoice".to_string()));
103 };
104
105 return Ok(Some(PaymentDetails::Spark {
106 invoice_details: Some(invoice_details.into()),
107 htlc_details: None,
108 conversion_info: None,
109 }));
110 }
111
112 if let Some(htlc_preimage_request) = &transfer.htlc_preimage_request {
114 return Ok(Some(PaymentDetails::Spark {
115 invoice_details: None,
116 htlc_details: Some(htlc_preimage_request.clone().try_into()?),
117 conversion_info: None,
118 }));
119 }
120
121 return Ok(Some(PaymentDetails::Spark {
122 invoice_details: None,
123 htlc_details: None,
124 conversion_info: None,
125 }));
126 }
127
128 let Some(user_request) = &transfer.user_request else {
129 return Ok(None);
130 };
131
132 let details = match user_request {
133 SspUserRequest::LightningReceiveRequest(request) => {
134 let invoice_details = input::parse_invoice(&request.invoice.encoded_invoice)
135 .ok_or(SdkError::Generic(
136 "Invalid invoice in SspUserRequest::LightningReceiveRequest".to_string(),
137 ))?;
138 let htlc_details = if let Some(req) = &transfer.htlc_preimage_request {
139 let mut details: SparkHtlcDetails = req.clone().try_into()?;
140 reconcile_htlc_preimage(
141 &mut details,
142 request.lightning_receive_payment_preimage.as_deref(),
143 );
144 details
145 } else {
146 derive_htlc_details_from_ssp(
147 transfer,
148 &request.invoice.payment_hash,
149 request.lightning_receive_payment_preimage.as_deref(),
150 )?
151 };
152 PaymentDetails::Lightning {
153 description: invoice_details.description,
154 invoice: request.invoice.encoded_invoice.clone(),
155 destination_pubkey: invoice_details.payee_pubkey,
156 htlc_details,
157 lnurl_pay_info: None,
158 lnurl_withdraw_info: None,
159 lnurl_receive_metadata: None,
160 }
161 }
162 SspUserRequest::LightningSendRequest(request) => {
163 let invoice_details =
164 input::parse_invoice(&request.encoded_invoice).ok_or(SdkError::Generic(
165 "Invalid invoice in SspUserRequest::LightningSendRequest".to_string(),
166 ))?;
167 let htlc_details = if let Some(req) = &transfer.htlc_preimage_request {
168 let mut details: SparkHtlcDetails = req.clone().try_into()?;
169 reconcile_htlc_preimage(
170 &mut details,
171 request.lightning_send_payment_preimage.as_deref(),
172 );
173 details
174 } else {
175 derive_htlc_details_from_ssp(
176 transfer,
177 &invoice_details.payment_hash,
178 request.lightning_send_payment_preimage.as_deref(),
179 )?
180 };
181 PaymentDetails::Lightning {
182 description: invoice_details.description,
183 invoice: request.encoded_invoice.clone(),
184 destination_pubkey: invoice_details.payee_pubkey,
185 htlc_details,
186 lnurl_pay_info: None,
187 lnurl_withdraw_info: None,
188 lnurl_receive_metadata: None,
189 }
190 }
191 SspUserRequest::CoopExitRequest(request) => PaymentDetails::Withdraw {
192 tx_id: request.coop_exit_txid.clone(),
193 },
194 SspUserRequest::LeavesSwapRequest(_) => PaymentDetails::Spark {
195 invoice_details: None,
196 htlc_details: None,
197 conversion_info: None,
198 },
199 SspUserRequest::ClaimStaticDeposit(request) => PaymentDetails::Deposit {
200 tx_id: request.transaction_id.clone(),
201 },
202 };
203
204 Ok(Some(details))
205 }
206}
207
208impl From<SparkInvoiceDetails> for SparkInvoicePaymentDetails {
209 fn from(value: SparkInvoiceDetails) -> Self {
210 Self {
211 description: value.description,
212 invoice: value.invoice,
213 }
214 }
215}
216
217impl TryFrom<WalletTransfer> for Payment {
218 type Error = SdkError;
219 fn try_from(transfer: WalletTransfer) -> Result<Self, Self::Error> {
220 if [
221 TransferType::CounterSwap,
222 TransferType::CounterSwapV3,
223 TransferType::Swap,
224 TransferType::PrimarySwapV3,
225 ]
226 .contains(&transfer.transfer_type)
227 {
228 debug!("Tried to convert swap-related transfer to payment. Transfer: {transfer:?}");
229 return Err(SdkError::Generic(
230 "Swap-related transfers are not considered payments".to_string(),
231 ));
232 }
233 let payment_type = match transfer.direction {
234 TransferDirection::Incoming => PaymentType::Receive,
235 TransferDirection::Outgoing => PaymentType::Send,
236 };
237 let mut status = match transfer.status {
238 TransferStatus::Completed => PaymentStatus::Completed,
239 TransferStatus::SenderKeyTweaked
240 if transfer.direction == TransferDirection::Outgoing =>
241 {
242 PaymentStatus::Completed
243 }
244 TransferStatus::Expired | TransferStatus::Returned => PaymentStatus::Failed,
245 _ => PaymentStatus::Pending,
246 };
247 let (fees_sat, mut amount_sat) = match transfer.clone().user_request {
248 Some(user_request) => match user_request {
249 SspUserRequest::LightningSendRequest(r) => {
250 if r.lightning_send_payment_preimage.is_some() {
253 status = PaymentStatus::Completed;
254 }
255 let fee_sat = r.fee.as_sats().unwrap_or(0);
256 (fee_sat, transfer.total_value_sat.saturating_sub(fee_sat))
257 }
258 SspUserRequest::CoopExitRequest(r) => {
259 let fee_sat = r
260 .fee
261 .as_sats()
262 .unwrap_or(0)
263 .saturating_add(r.l1_broadcast_fee.as_sats().unwrap_or(0));
264 (fee_sat, transfer.total_value_sat.saturating_sub(fee_sat))
265 }
266 SspUserRequest::ClaimStaticDeposit(r) => {
267 let fee_sat = r
268 .deposit_amount
269 .as_sats()
270 .unwrap_or(0)
271 .saturating_sub(r.credit_amount.as_sats().unwrap_or(0));
272 (fee_sat, transfer.total_value_sat)
273 }
274 _ => (0, transfer.total_value_sat),
275 },
276 None => (0, transfer.total_value_sat),
277 };
278
279 let details = PaymentDetails::from_transfer(&transfer)?;
280 if details.is_none() {
281 if status == PaymentStatus::Completed
284 && [
285 TransferType::CooperativeExit,
286 TransferType::PreimageSwap,
287 TransferType::UtxoSwap,
288 ]
289 .contains(&transfer.transfer_type)
290 {
291 status = PaymentStatus::Pending;
292 }
293 amount_sat = transfer.total_value_sat;
294 }
295
296 Ok(Payment {
297 id: transfer.id.to_string(),
298 payment_type,
299 status,
300 amount: amount_sat.into(),
301 fees: fees_sat.into(),
302 timestamp: match transfer.created_at.map(|t| t.duration_since(UNIX_EPOCH)) {
303 Some(Ok(duration)) => duration.as_secs(),
304 _ => 0,
305 },
306 method: PaymentMethod::from_transfer(&transfer),
307 details,
308 conversion_details: None,
309 })
310 }
311}
312
313impl Payment {
314 pub fn from_lightning(
322 payment: LightningSendPayment,
323 amount_sat: u128,
324 transfer_id: String,
325 mut htlc_details: SparkHtlcDetails,
326 ) -> Result<Self, SdkError> {
327 let mut status = match payment.status {
328 LightningSendStatus::LightningPaymentSucceeded => PaymentStatus::Completed,
329 LightningSendStatus::LightningPaymentFailed
330 | LightningSendStatus::TransferFailed
331 | LightningSendStatus::PreimageProvidingFailed
332 | LightningSendStatus::UserSwapReturnFailed
333 | LightningSendStatus::UserSwapReturned => PaymentStatus::Failed,
334 _ => PaymentStatus::Pending,
335 };
336 if payment.payment_preimage.is_some() {
337 status = PaymentStatus::Completed;
338 }
339
340 reconcile_htlc_preimage(&mut htlc_details, payment.payment_preimage.as_deref());
341
342 let invoice_details = input::parse_invoice(&payment.encoded_invoice).ok_or(
343 SdkError::Generic("Invalid invoice in LightnintSendPayment".to_string()),
344 )?;
345 let details = PaymentDetails::Lightning {
346 description: invoice_details.description,
347 invoice: payment.encoded_invoice,
348 destination_pubkey: invoice_details.payee_pubkey,
349 htlc_details,
350 lnurl_pay_info: None,
351 lnurl_withdraw_info: None,
352 lnurl_receive_metadata: None,
353 };
354
355 Ok(Payment {
356 id: transfer_id,
357 payment_type: PaymentType::Send,
358 status,
359 amount: amount_sat,
360 fees: payment.fee_sat.into(),
361 timestamp: payment.created_at.cast_unsigned(),
362 method: PaymentMethod::Lightning,
363 details: Some(details),
364 conversion_details: None,
365 })
366 }
367}
368
369impl From<Network> for SparkNetwork {
370 fn from(network: Network) -> Self {
371 match network {
372 Network::Mainnet => SparkNetwork::Mainnet,
373 Network::Regtest => SparkNetwork::Regtest,
374 }
375 }
376}
377
378impl From<Fee> for spark_wallet::Fee {
379 fn from(fee: Fee) -> Self {
380 match fee {
381 Fee::Fixed { amount } => spark_wallet::Fee::Fixed { amount },
382 Fee::Rate { sat_per_vbyte } => spark_wallet::Fee::Rate { sat_per_vbyte },
383 }
384 }
385}
386
387impl From<spark_wallet::TokenBalance> for TokenBalance {
388 fn from(value: spark_wallet::TokenBalance) -> Self {
389 Self {
390 balance: value.balance,
391 token_metadata: value.token_metadata.into(),
392 }
393 }
394}
395
396impl From<spark_wallet::TokenMetadata> for TokenMetadata {
397 fn from(value: spark_wallet::TokenMetadata) -> Self {
398 Self {
399 identifier: value.identifier,
400 issuer_public_key: hex::encode(value.issuer_public_key.serialize()),
401 name: value.name,
402 ticker: value.ticker,
403 decimals: value.decimals,
404 max_supply: value.max_supply,
405 is_freezable: value.is_freezable,
406 }
407 }
408}
409
410impl From<CoopExitFeeQuote> for SendOnchainFeeQuote {
411 fn from(value: CoopExitFeeQuote) -> Self {
412 Self {
413 id: value.id,
414 expires_at: value.expires_at,
415 speed_fast: value.speed_fast.into(),
416 speed_medium: value.speed_medium.into(),
417 speed_slow: value.speed_slow.into(),
418 }
419 }
420}
421
422impl From<SendOnchainFeeQuote> for CoopExitFeeQuote {
423 fn from(value: SendOnchainFeeQuote) -> Self {
424 Self {
425 id: value.id,
426 expires_at: value.expires_at,
427 speed_fast: value.speed_fast.into(),
428 speed_medium: value.speed_medium.into(),
429 speed_slow: value.speed_slow.into(),
430 }
431 }
432}
433
434impl From<CoopExitSpeedFeeQuote> for SendOnchainSpeedFeeQuote {
435 fn from(value: CoopExitSpeedFeeQuote) -> Self {
436 Self {
437 user_fee_sat: value.user_fee_sat,
438 l1_broadcast_fee_sat: value.l1_broadcast_fee_sat,
439 }
440 }
441}
442
443impl From<SendOnchainSpeedFeeQuote> for CoopExitSpeedFeeQuote {
444 fn from(value: SendOnchainSpeedFeeQuote) -> Self {
445 Self {
446 user_fee_sat: value.user_fee_sat,
447 l1_broadcast_fee_sat: value.l1_broadcast_fee_sat,
448 }
449 }
450}
451
452impl From<OnchainConfirmationSpeed> for ExitSpeed {
453 fn from(speed: OnchainConfirmationSpeed) -> Self {
454 match speed {
455 OnchainConfirmationSpeed::Fast => ExitSpeed::Fast,
456 OnchainConfirmationSpeed::Medium => ExitSpeed::Medium,
457 OnchainConfirmationSpeed::Slow => ExitSpeed::Slow,
458 }
459 }
460}
461
462impl From<ExitSpeed> for OnchainConfirmationSpeed {
463 fn from(speed: ExitSpeed) -> Self {
464 match speed {
465 ExitSpeed::Fast => OnchainConfirmationSpeed::Fast,
466 ExitSpeed::Medium => OnchainConfirmationSpeed::Medium,
467 ExitSpeed::Slow => OnchainConfirmationSpeed::Slow,
468 }
469 }
470}
471
472impl PaymentStatus {
473 pub(crate) fn from_token_transaction_status(
474 status: TokenTransactionStatus,
475 is_transfer_transaction: bool,
476 ) -> Self {
477 match status {
478 TokenTransactionStatus::Started
479 | TokenTransactionStatus::Revealed
480 | TokenTransactionStatus::Unknown => PaymentStatus::Pending,
481 TokenTransactionStatus::Signed if is_transfer_transaction => PaymentStatus::Pending,
482 TokenTransactionStatus::Finalized | TokenTransactionStatus::Signed => {
483 PaymentStatus::Completed
484 }
485 TokenTransactionStatus::StartedCancelled | TokenTransactionStatus::SignedCancelled => {
486 PaymentStatus::Failed
487 }
488 }
489 }
490}
491
492impl TryFrom<PreimageRequest> for SparkHtlcDetails {
493 type Error = SdkError;
494 fn try_from(value: PreimageRequest) -> Result<Self, Self::Error> {
495 Ok(Self {
496 payment_hash: value.payment_hash.to_string(),
497 preimage: value.preimage.map(|p| p.encode_hex()),
498 expiry_time: value
499 .expiry_time
500 .duration_since(UNIX_EPOCH)
501 .map_err(|e| SdkError::Generic(format!("Invalid expiry time: {e}")))?
502 .as_secs(),
503 status: value.status.into(),
504 })
505 }
506}
507
508impl From<PreimageRequestStatus> for SparkHtlcStatus {
509 fn from(status: PreimageRequestStatus) -> Self {
510 match status {
511 PreimageRequestStatus::WaitingForPreimage => SparkHtlcStatus::WaitingForPreimage,
512 PreimageRequestStatus::PreimageShared => SparkHtlcStatus::PreimageShared,
513 PreimageRequestStatus::Returned => SparkHtlcStatus::Returned,
514 }
515 }
516}
517
518impl From<spark_wallet::OptimizationProgress> for OptimizationProgress {
519 fn from(value: spark_wallet::OptimizationProgress) -> Self {
520 Self {
521 is_running: value.is_running,
522 current_round: value.current_round,
523 total_rounds: value.total_rounds,
524 }
525 }
526}