1pub(crate) mod boltz;
7pub(crate) mod boltz_event_listener;
8pub(crate) mod boltz_storage_adapter;
9mod cached_fiat;
10mod orchestra;
11
12pub(crate) use boltz::BoltzService;
13pub(crate) use cached_fiat::{CachedFiatService, DEFAULT_FIAT_CACHE_TTL};
14pub(crate) use orchestra::{BreezServerOrchestraConfigResolver, OrchestraService};
15
16use std::collections::HashMap;
17use std::str::FromStr;
18use std::sync::Arc;
19use std::time::Duration;
20
21use breez_sdk_common::fiat::FiatService;
22use serde::{Deserialize, Serialize};
23use spark_wallet::TransferId;
24
25use crate::{ConversionInfo, CrossChainAddressDetails, PaymentDetails, error::SdkError};
26
27pub(crate) const MIN_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 10;
29pub(crate) const MAX_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 500;
30pub(crate) const DEFAULT_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 100;
33
34pub(crate) const MIN_TARGET_OVERPAY_BPS: u32 = 0;
38pub(crate) const MAX_TARGET_OVERPAY_BPS: u32 = 500;
39pub(crate) const DEFAULT_TARGET_OVERPAY_BPS: u32 = 15;
44const USD_STABLE_ASSETS: &[&str] = &["USDB", "USDC", "USDT", "USDT0"];
47
48pub(crate) const MONITOR_INTERVAL: Duration = Duration::from_secs(30);
50
51pub(crate) fn payment_with_conversion_info(
56 mut payment: crate::Payment,
57 conversion_info: Option<ConversionInfo>,
58) -> crate::Payment {
59 payment.details = match payment.details {
60 Some(PaymentDetails::Spark {
61 invoice_details,
62 htlc_details,
63 ..
64 }) => Some(PaymentDetails::Spark {
65 invoice_details,
66 htlc_details,
67 conversion_info,
68 }),
69 Some(PaymentDetails::Token {
70 metadata,
71 tx_hash,
72 tx_type,
73 invoice_details,
74 ..
75 }) => Some(PaymentDetails::Token {
76 metadata,
77 tx_hash,
78 tx_type,
79 invoice_details,
80 conversion_info,
81 }),
82 Some(PaymentDetails::Lightning {
83 description,
84 invoice,
85 destination_pubkey,
86 htlc_details,
87 lnurl_pay_info,
88 lnurl_withdraw_info,
89 lnurl_receive_metadata,
90 ..
91 }) => Some(PaymentDetails::Lightning {
92 description,
93 invoice,
94 destination_pubkey,
95 htlc_details,
96 lnurl_pay_info,
97 lnurl_withdraw_info,
98 lnurl_receive_metadata,
99 conversion_info,
100 }),
101 other => other,
102 };
103 payment
104}
105
106pub(crate) fn derive_btc_leg_transfer_id(
116 idempotency_key: Option<&str>,
117 fallback_seed: &str,
118) -> Result<TransferId, SdkError> {
119 match idempotency_key {
120 Some(key) => TransferId::from_str(key).map_err(SdkError::Generic),
121 None => Ok(TransferId::from_name(fallback_seed)),
122 }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
126#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
127pub enum CrossChainProvider {
128 Orchestra,
129 Boltz,
130}
131
132impl std::fmt::Display for CrossChainProvider {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 match self {
135 Self::Orchestra => f.write_str("Orchestra"),
136 Self::Boltz => f.write_str("Boltz"),
137 }
138 }
139}
140
141#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
143#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
144pub enum SourceAsset {
145 Bitcoin,
147 Token { token_identifier: String },
149}
150
151#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
154#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
155pub enum SourceChain {
156 Spark,
158 Lightning,
160 Bitcoin,
162}
163
164impl std::fmt::Display for SourceChain {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 match self {
167 Self::Spark => f.write_str("Spark"),
168 Self::Lightning => f.write_str("Lightning"),
169 Self::Bitcoin => f.write_str("Bitcoin"),
170 }
171 }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
181#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
182pub enum CrossChainFeeMode {
183 FeesExcluded,
184 FeesIncluded,
185}
186
187impl From<crate::FeePolicy> for CrossChainFeeMode {
188 fn from(policy: crate::FeePolicy) -> Self {
189 match policy {
190 crate::FeePolicy::FeesExcluded => Self::FeesExcluded,
191 crate::FeePolicy::FeesIncluded => Self::FeesIncluded,
192 }
193 }
194}
195
196#[derive(Clone, Debug, Deserialize, Serialize)]
199#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
200pub enum CrossChainRouteFilter {
201 Send {
204 address_details: CrossChainAddressDetails,
205 },
206 Receive { contract_address: Option<String> },
209 PaymentLink {
213 address_details: CrossChainAddressDetails,
214 },
215}
216
217#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
220#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
221pub struct CrossChainRoutePair {
222 pub provider: CrossChainProvider,
224 pub chain: String,
226 pub chain_id: Option<String>,
230 pub asset: String,
232 pub contract_address: Option<String>,
234 pub decimals: u8,
236 pub exact_out_eligible: bool,
238 pub supported_sources: Vec<SourceAsset>,
244 pub supported_source_chains: Vec<SourceChain>,
251}
252
253impl CrossChainRoutePair {
254 pub(crate) fn destination_address_family(
259 &self,
260 ) -> Option<breez_sdk_common::input::CrossChainAddressFamily> {
261 self.contract_address
262 .as_deref()
263 .and_then(breez_sdk_common::input::detect_address_family)
264 }
265}
266
267#[derive(Clone)]
271pub(crate) struct CrossChainContext {
272 providers: HashMap<CrossChainProvider, Arc<dyn CrossChainService>>,
273 fiat_service: Arc<dyn FiatService>,
274}
275
276impl CrossChainContext {
277 pub fn new(fiat_service: Arc<dyn FiatService>) -> Self {
278 Self {
279 providers: HashMap::new(),
280 fiat_service,
281 }
282 }
283
284 pub fn insert(&mut self, key: CrossChainProvider, service: Arc<dyn CrossChainService>) {
285 self.providers.insert(key, service);
286 }
287
288 pub fn get(
290 &self,
291 provider: CrossChainProvider,
292 ) -> Result<&Arc<dyn CrossChainService>, SdkError> {
293 self.providers.get(&provider).ok_or_else(|| {
294 SdkError::InvalidInput(format!("Cross-chain provider {provider} is not available."))
295 })
296 }
297
298 pub fn values(&self) -> impl Iterator<Item = &Arc<dyn CrossChainService>> {
299 self.providers.values()
300 }
301
302 pub fn fiat_service(&self) -> &Arc<dyn FiatService> {
305 &self.fiat_service
306 }
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
313#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
314pub enum CrossChainProviderContext {
315 Orchestra {
316 quote_id: String,
318 deposit_address: String,
320 #[serde(default)]
322 deposit_amount: u128,
323 },
324 Boltz {
325 swap_id: String,
327 invoice: String,
329 #[serde(default)]
331 invoice_amount_sats: u64,
332 max_slippage_bps: u32,
334 },
335}
336
337#[derive(Debug, Clone)]
340pub(crate) struct CrossChainPrepared {
341 pub amount_in: u128,
342 pub asset_amount_in: u128,
346 pub estimated_out: u128,
348 pub fee_amount: u128,
354 pub service_fee_amount: u128,
356 pub service_fee_asset: Option<String>,
358 pub source_transfer_fee_sats: u64,
368 pub fee_mode: CrossChainFeeMode,
371 pub expires_at: String,
372 pub pair: CrossChainRoutePair,
373 pub recipient_address: String,
374 pub token_identifier: Option<String>,
376 pub provider_context: CrossChainProviderContext,
378}
379
380#[macros::async_trait]
385pub(crate) trait CrossChainService: Send + Sync {
386 async fn get_routes(
392 &self,
393 filter: &CrossChainRouteFilter,
394 ) -> Result<Vec<CrossChainRoutePair>, SdkError>;
395
396 #[allow(clippy::too_many_arguments)]
403 async fn prepare(
404 &self,
405 recipient_address: &str,
406 route: &CrossChainRoutePair,
407 amount: u128,
408 source_chain: Option<SourceChain>,
409 source_token_identifier: Option<String>,
410 max_slippage_bps: u32,
411 fee_mode: CrossChainFeeMode,
412 ) -> Result<CrossChainPrepared, SdkError>;
413
414 async fn send(
430 &self,
431 prepared: &CrossChainPrepared,
432 idempotency_key: Option<String>,
433 ) -> Result<crate::Payment, SdkError>;
434}
435
436pub(crate) async fn fetch_btc_usd_rate(fiat: &dyn FiatService) -> Result<f64, SdkError> {
439 let rates = fiat
440 .fetch_fiat_rates()
441 .await
442 .map_err(|e| SdkError::Generic(format!("Cross-chain: failed to fetch fiat rates: {e}")))?;
443 let btc_usd = rates
444 .iter()
445 .find(|r| r.coin.eq_ignore_ascii_case("USD"))
446 .map(|r| r.value)
447 .ok_or_else(|| {
448 SdkError::Generic("Cross-chain: BTC/USD rate not found in feed".to_string())
449 })?;
450 if !btc_usd.is_finite() || btc_usd <= 0.0 {
451 return Err(SdkError::Generic(format!(
452 "Cross-chain: invalid BTC/USD rate from feed: {btc_usd}"
453 )));
454 }
455 Ok(btc_usd)
456}
457
458#[allow(
461 clippy::cast_precision_loss,
462 clippy::cast_possible_truncation,
463 clippy::cast_sign_loss
464)]
465pub(crate) fn convert_sats_to_destination_amount(
466 sats: u128,
467 fiat_rate: f64,
468 dest_decimals: u32,
469) -> Result<u128, SdkError> {
470 let dest_scale = 10f64.powi(i32::try_from(dest_decimals).unwrap_or(i32::MAX));
471 let target = (sats as f64) * fiat_rate * dest_scale / 100_000_000f64;
472 if !target.is_finite() || target < 0.0 {
473 return Err(SdkError::Generic(format!(
474 "Cross-chain: invalid sats→dest conversion result: {target}"
475 )));
476 }
477 Ok(target as u128)
478}
479
480pub(crate) fn is_usd_stable_asset(asset: &str) -> bool {
481 USD_STABLE_ASSETS
482 .iter()
483 .any(|a| asset.eq_ignore_ascii_case(a))
484}
485
486pub(crate) fn compute_terminal_fee_amount(
490 new_status: &crate::ConversionStatus,
491 asset_amount_in: Option<u128>,
492 delivered_amount: Option<u128>,
493 prepare_estimate: Option<u128>,
494) -> Option<u128> {
495 match (new_status, asset_amount_in, delivered_amount) {
496 (crate::ConversionStatus::Completed, Some(a), Some(d)) => Some(a.saturating_sub(d)),
497 _ => prepare_estimate,
498 }
499}
500
501pub(crate) fn rescale_decimals(
505 amount: u128,
506 src_decimals: u32,
507 dest_decimals: u32,
508) -> Result<u128, SdkError> {
509 if dest_decimals >= src_decimals {
510 let delta = dest_decimals.saturating_sub(src_decimals);
511 let factor = 10u128
512 .checked_pow(delta)
513 .ok_or_else(|| SdkError::Generic("Cross-chain: decimal scale overflow".to_string()))?;
514 amount
515 .checked_mul(factor)
516 .ok_or_else(|| SdkError::Generic("Cross-chain: decimal rescale overflow".to_string()))
517 } else {
518 let delta = src_decimals.saturating_sub(dest_decimals);
519 let factor = 10u128
520 .checked_pow(delta)
521 .ok_or_else(|| SdkError::Generic("Cross-chain: decimal scale overflow".to_string()))?;
522 amount.checked_div(factor).ok_or_else(|| {
523 SdkError::Generic("Cross-chain: decimal rescale divisor zero".to_string())
524 })
525 }
526}
527
528#[allow(
532 clippy::cast_precision_loss,
533 clippy::cast_possible_truncation,
534 clippy::cast_sign_loss
535)]
536pub(crate) fn convert_destination_amount_to_sats(
537 destination_amount: u128,
538 fiat_rate: f64,
539 dest_decimals: u32,
540) -> Result<u128, SdkError> {
541 if !fiat_rate.is_finite() || fiat_rate <= 0.0 {
542 return Err(SdkError::Generic(format!(
543 "Cross-chain: invalid BTC/USD rate for inversion: {fiat_rate}"
544 )));
545 }
546 let dest_scale = 10f64.powi(i32::try_from(dest_decimals).unwrap_or(i32::MAX));
547 let sats = (destination_amount as f64) * 100_000_000f64 / (fiat_rate * dest_scale);
548 if !sats.is_finite() || sats < 0.0 {
549 return Err(SdkError::Generic(format!(
550 "Cross-chain: invalid dest→sats conversion result: {sats}"
551 )));
552 }
553 Ok(sats as u128)
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559 use macros::test_all;
560
561 #[cfg(feature = "browser-tests")]
562 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
563
564 #[test_all]
565 fn source_chain_display_is_human_readable() {
566 assert_eq!(SourceChain::Spark.to_string(), "Spark");
567 assert_eq!(SourceChain::Lightning.to_string(), "Lightning");
568 assert_eq!(SourceChain::Bitcoin.to_string(), "Bitcoin");
569 }
570
571 #[test_all]
572 fn derive_btc_leg_transfer_id_uses_caller_key() {
573 let key = "00000000-0000-4000-8000-000000000001";
576 let id = derive_btc_leg_transfer_id(Some(key), "ignored-seed").unwrap();
577 assert_eq!(id.to_string(), key);
578 }
579
580 #[test_all]
581 fn derive_btc_leg_transfer_id_deterministic_from_seed() {
582 let a = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
583 let b = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
584 assert_eq!(
585 a, b,
586 "same seed must produce the same TransferId across calls"
587 );
588 }
589
590 #[test_all]
591 fn derive_btc_leg_transfer_id_distinct_seeds_yield_distinct_ids() {
592 let a = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
593 let b = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-2").unwrap();
594 assert_ne!(a, b);
595 }
596
597 #[test_all]
598 fn derive_btc_leg_transfer_id_orchestra_and_boltz_seeds_collide_only_on_id() {
599 let orchestra = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:abc").unwrap();
602 let boltz = derive_btc_leg_transfer_id(None, "cross_chain:boltz:abc").unwrap();
603 assert_ne!(orchestra, boltz);
604 }
605
606 #[test_all]
607 fn derive_btc_leg_transfer_id_rejects_invalid_caller_key() {
608 let err = derive_btc_leg_transfer_id(Some("not-a-uuid"), "fallback").unwrap_err();
609 assert!(matches!(err, SdkError::Generic(_)));
610 }
611
612 #[test_all]
613 fn convert_sats_to_destination_amount_round_trip_inverts_to_sats() {
614 let dest = convert_sats_to_destination_amount(10_000, 60_000.0, 6).unwrap();
616 assert_eq!(dest, 6_000_000);
617 let sats = convert_destination_amount_to_sats(dest, 60_000.0, 6).unwrap();
619 assert_eq!(sats, 10_000);
620 }
621
622 #[test_all]
623 fn convert_destination_amount_to_sats_typical_stable() {
624 let sats = convert_destination_amount_to_sats(1_000_000, 60_000.0, 6).unwrap();
626 assert_eq!(sats, 1_666);
627 }
628
629 #[test_all]
630 fn convert_destination_amount_to_sats_zero_passes_through() {
631 let sats = convert_destination_amount_to_sats(0, 60_000.0, 6).unwrap();
632 assert_eq!(sats, 0);
633 }
634
635 #[test_all]
636 fn convert_destination_amount_to_sats_rejects_non_positive_rate() {
637 let err = convert_destination_amount_to_sats(1_000_000, 0.0, 6).unwrap_err();
638 assert!(matches!(err, SdkError::Generic(ref m) if m.contains("invalid BTC/USD rate")));
639 let err = convert_destination_amount_to_sats(1_000_000, f64::NAN, 6).unwrap_err();
640 assert!(matches!(err, SdkError::Generic(_)));
641 }
642
643 #[test_all]
644 fn rescale_decimals_scales_down_when_dest_decimals_lower() {
645 assert_eq!(rescale_decimals(100_000_000, 8, 6).unwrap(), 1_000_000);
646 }
647
648 #[test_all]
649 fn rescale_decimals_same_decimals_is_identity() {
650 assert_eq!(rescale_decimals(123_456_789, 6, 6).unwrap(), 123_456_789);
651 }
652
653 #[test_all]
654 fn rescale_decimals_scales_up_when_dest_decimals_higher() {
655 assert_eq!(rescale_decimals(1_000_000, 6, 8).unwrap(), 100_000_000);
656 }
657
658 #[test_all]
659 fn rescale_decimals_zero_passes_through() {
660 assert_eq!(rescale_decimals(0, 8, 6).unwrap(), 0);
661 assert_eq!(rescale_decimals(0, 6, 8).unwrap(), 0);
662 }
663
664 #[test_all]
665 fn is_usd_stable_asset_recognizes_known_stables() {
666 for ticker in ["USDB", "USDC", "USDT", "USDT0", "usdb", "uSdC"] {
667 assert!(is_usd_stable_asset(ticker), "{ticker} should be stable");
668 }
669 }
670
671 #[test_all]
672 fn is_usd_stable_asset_rejects_btc_and_unknown() {
673 for ticker in ["BTC", "ETH", "DAI", "", "USD"] {
674 assert!(
675 !is_usd_stable_asset(ticker),
676 "{ticker} should not be a recognized USD-stable"
677 );
678 }
679 }
680
681 #[test_all]
684 fn compute_terminal_fee_overwrites_estimate_on_completed() {
685 let realized = compute_terminal_fee_amount(
686 &crate::ConversionStatus::Completed,
687 Some(1_020_434), Some(997_498), Some(20_434), );
691 assert_eq!(realized, Some(22_936), "= asset_amount_in − delivered");
692 }
693
694 #[test_all]
695 fn compute_terminal_fee_keeps_estimate_on_refunded() {
696 let realized = compute_terminal_fee_amount(
700 &crate::ConversionStatus::Refunded,
701 Some(1_020_434),
702 None,
703 Some(20_434),
704 );
705 assert_eq!(realized, Some(20_434));
706 }
707
708 #[test_all]
709 fn compute_terminal_fee_keeps_estimate_on_failed() {
710 let realized = compute_terminal_fee_amount(
711 &crate::ConversionStatus::Failed,
712 Some(1_020_434),
713 None,
714 Some(20_434),
715 );
716 assert_eq!(realized, Some(20_434));
717 }
718
719 #[test_all]
720 fn compute_terminal_fee_keeps_estimate_when_asset_amount_in_missing() {
721 let realized = compute_terminal_fee_amount(
724 &crate::ConversionStatus::Completed,
725 None, Some(997_498),
727 Some(20_434),
728 );
729 assert_eq!(realized, Some(20_434));
730 }
731
732 #[test_all]
733 fn compute_terminal_fee_keeps_estimate_when_delivered_amount_missing() {
734 let realized = compute_terminal_fee_amount(
737 &crate::ConversionStatus::Completed,
738 Some(1_020_434),
739 None, Some(20_434),
741 );
742 assert_eq!(realized, Some(20_434));
743 }
744
745 #[test_all]
746 fn compute_terminal_fee_saturating_sub_on_over_delivery() {
747 let realized = compute_terminal_fee_amount(
749 &crate::ConversionStatus::Completed,
750 Some(1_000_000),
751 Some(1_005_000),
752 Some(0),
753 );
754 assert_eq!(
755 realized,
756 Some(0),
757 "saturating_sub must clamp at 0, not underflow"
758 );
759 }
760
761 #[test_all]
771 fn boltz_provider_context_invoice_amount_sats_is_independent_of_amount_in() {
772 let ctx = CrossChainProviderContext::Boltz {
773 swap_id: "swap_1".to_string(),
774 invoice: "lnbc19090n1pexample".to_string(),
775 invoice_amount_sats: 1_909,
776 max_slippage_bps: 100,
777 };
778 let json = serde_json::to_string(&ctx).unwrap();
779 let decoded: CrossChainProviderContext = serde_json::from_str(&json).unwrap();
780 let CrossChainProviderContext::Boltz {
781 invoice_amount_sats,
782 ..
783 } = &decoded
784 else {
785 panic!("expected Boltz variant");
786 };
787 assert_eq!(*invoice_amount_sats, 1_909);
788 assert!(
789 *invoice_amount_sats != 1_222_703,
790 "the LN invoice sats must never be conflated with a user-facing display value (e.g. USDB base units)"
791 );
792 }
793
794 #[test_all]
799 fn boltz_provider_context_legacy_row_without_invoice_amount_sats_defaults_to_zero() {
800 let legacy = r#"{
801 "Boltz": {
802 "swap_id": "swap_legacy",
803 "invoice": "lnbc19090n1p",
804 "max_slippage_bps": 100
805 }
806 }"#;
807 let decoded: CrossChainProviderContext = serde_json::from_str(legacy).unwrap();
808 let CrossChainProviderContext::Boltz {
809 invoice_amount_sats,
810 ..
811 } = &decoded
812 else {
813 panic!("expected Boltz variant");
814 };
815 assert_eq!(*invoice_amount_sats, 0);
816 }
817
818 #[test_all]
822 fn orchestra_provider_context_deposit_amount_is_independent_of_amount_in() {
823 let ctx = CrossChainProviderContext::Orchestra {
824 quote_id: "q_1".to_string(),
825 deposit_address: "spark1...".to_string(),
826 deposit_amount: 1_020_434,
827 };
828 let json = serde_json::to_string(&ctx).unwrap();
829 let decoded: CrossChainProviderContext = serde_json::from_str(&json).unwrap();
830 let CrossChainProviderContext::Orchestra { deposit_amount, .. } = &decoded else {
831 panic!("expected Orchestra variant");
832 };
833 assert_eq!(*deposit_amount, 1_020_434);
834 }
835
836 fn boltz_info(swap_id: &str) -> ConversionInfo {
837 ConversionInfo::Boltz {
838 swap_id: swap_id.to_string(),
839 invoice: "lnbc1".to_string(),
840 invoice_amount_sats: 1_000,
841 bridge_ref: None,
842 max_slippage_bps: 100,
843 quote_degraded: false,
844 chain: "polygon".to_string(),
845 chain_id: None,
846 asset: "USDC".to_string(),
847 recipient_address: "0xabc".to_string(),
848 estimated_out: 1_000_000,
849 delivered_amount: None,
850 status: crate::ConversionStatus::Pending,
851 asset_amount_in: None,
852 fee_amount: None,
853 service_fee_amount: None,
854 service_fee_asset: None,
855 asset_decimals: 6,
856 asset_contract: None,
857 }
858 }
859
860 #[test_all]
861 fn payment_with_conversion_info_injects_into_lightning_details() {
862 let payment = crate::Payment {
863 id: "p1".to_string(),
864 payment_type: crate::PaymentType::Send,
865 status: crate::PaymentStatus::Pending,
866 amount: 1_000,
867 fees: 0,
868 timestamp: 100,
869 method: crate::PaymentMethod::Lightning,
870 details: Some(PaymentDetails::Lightning {
871 description: Some("desc".to_string()),
872 invoice: "lnbc1".to_string(),
873 destination_pubkey: "02aa".to_string(),
874 htlc_details: crate::SparkHtlcDetails {
875 payment_hash: "hash1".to_string(),
876 preimage: None,
877 expiry_time: 0,
878 status: crate::SparkHtlcStatus::PreimageShared,
879 },
880 lnurl_pay_info: None,
881 lnurl_withdraw_info: None,
882 lnurl_receive_metadata: None,
883 conversion_info: None,
884 }),
885 conversion_details: None,
886 };
887
888 let out = payment_with_conversion_info(payment, Some(boltz_info("swap1")));
889
890 assert_eq!(out.status, crate::PaymentStatus::Pending);
891 let Some(PaymentDetails::Lightning {
892 invoice,
893 description,
894 conversion_info,
895 ..
896 }) = out.details
897 else {
898 panic!("expected Lightning details");
899 };
900 assert_eq!(invoice, "lnbc1");
902 assert_eq!(description.as_deref(), Some("desc"));
903 assert!(matches!(
904 conversion_info,
905 Some(ConversionInfo::Boltz { ref swap_id, .. }) if swap_id == "swap1"
906 ));
907 }
908
909 #[test_all]
910 fn payment_with_conversion_info_passes_through_variants_without_a_slot() {
911 let payment = crate::Payment {
912 id: "p1".to_string(),
913 payment_type: crate::PaymentType::Send,
914 status: crate::PaymentStatus::Completed,
915 amount: 1_000,
916 fees: 0,
917 timestamp: 100,
918 method: crate::PaymentMethod::Withdraw,
919 details: Some(PaymentDetails::Withdraw {
920 tx_id: "tx1".to_string(),
921 }),
922 conversion_details: None,
923 };
924
925 let out = payment_with_conversion_info(payment, Some(boltz_info("swap1")));
926
927 assert!(matches!(
928 out.details,
929 Some(PaymentDetails::Withdraw { tx_id }) if tx_id == "tx1"
930 ));
931 }
932}