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#[allow(clippy::duration_suboptimal_units)]
27const HTLC_DATA_REQUIRED_SINCE: Duration = Duration::from_secs(1_769_904_000);
28
29fn 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
69fn 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
80fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(&[®test_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(&[®test_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(&[®test_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(&[®test_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}