1#[allow(dead_code)]
10pub(crate) mod boltz;
11#[allow(dead_code)]
12pub(crate) mod boltz_event_listener;
13#[allow(dead_code)]
14pub(crate) mod boltz_storage_adapter;
15mod cached_fiat;
16mod orchestra;
17mod orchestra_storage_adapter;
18
19pub(crate) use cached_fiat::{CachedFiatService, DEFAULT_FIAT_CACHE_TTL};
20pub(crate) use orchestra::{BreezServerOrchestraConfigResolver, OrchestraService};
21
22use std::collections::HashMap;
23use std::str::FromStr;
24use std::sync::Arc;
25use std::time::Duration;
26
27use breez_sdk_common::fiat::FiatService;
28use serde::{Deserialize, Serialize};
29use spark_wallet::TransferId;
30
31use crate::{ConversionInfo, CrossChainAddressDetails, PaymentDetails, error::SdkError};
32
33pub(crate) const MIN_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 10;
35pub(crate) const MAX_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 500;
36pub(crate) const DEFAULT_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 100;
39
40pub(crate) const MIN_TARGET_OVERPAY_BPS: u32 = 0;
44pub(crate) const MAX_TARGET_OVERPAY_BPS: u32 = 500;
45pub(crate) const DEFAULT_TARGET_OVERPAY_BPS: u32 = 15;
50const USD_STABLE_ASSETS: &[&str] = &["USDB", "USDC", "USDT", "USDT0"];
53
54pub(crate) const MONITOR_INTERVAL: Duration = Duration::from_secs(30);
56
57pub(crate) fn payment_with_conversion_info(
62 mut payment: crate::Payment,
63 conversion_info: Option<ConversionInfo>,
64) -> crate::Payment {
65 payment.details = match payment.details {
66 Some(PaymentDetails::Spark {
67 invoice_details,
68 htlc_details,
69 ..
70 }) => Some(PaymentDetails::Spark {
71 invoice_details,
72 htlc_details,
73 conversion_info,
74 }),
75 Some(PaymentDetails::Token {
76 metadata,
77 tx_hash,
78 tx_type,
79 invoice_details,
80 ..
81 }) => Some(PaymentDetails::Token {
82 metadata,
83 tx_hash,
84 tx_type,
85 invoice_details,
86 conversion_info,
87 }),
88 Some(PaymentDetails::Lightning {
89 description,
90 invoice,
91 destination_pubkey,
92 htlc_details,
93 lnurl_pay_info,
94 lnurl_withdraw_info,
95 lnurl_receive_metadata,
96 ..
97 }) => Some(PaymentDetails::Lightning {
98 description,
99 invoice,
100 destination_pubkey,
101 htlc_details,
102 lnurl_pay_info,
103 lnurl_withdraw_info,
104 lnurl_receive_metadata,
105 conversion_info,
106 }),
107 other => other,
108 };
109 payment
110}
111
112pub(crate) fn derive_btc_leg_transfer_id(
122 idempotency_key: Option<&str>,
123 fallback_seed: &str,
124) -> Result<TransferId, SdkError> {
125 match idempotency_key {
126 Some(key) => TransferId::from_str(key).map_err(SdkError::Generic),
127 None => Ok(TransferId::from_name(fallback_seed)),
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
132#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
133pub enum CrossChainProvider {
134 Orchestra,
135 Boltz,
137}
138
139impl std::fmt::Display for CrossChainProvider {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 match self {
142 Self::Orchestra => f.write_str("Orchestra"),
143 Self::Boltz => f.write_str("Boltz"),
144 }
145 }
146}
147
148#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
150#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
151pub enum SparkAsset {
152 Bitcoin,
154 Token { token_identifier: String },
156}
157
158#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
160#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
161pub enum DeliveryMethod {
162 Spark,
164 Lightning,
166 Bitcoin,
168}
169
170impl std::fmt::Display for DeliveryMethod {
171 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172 match self {
173 Self::Spark => f.write_str("Spark"),
174 Self::Lightning => f.write_str("Lightning"),
175 Self::Bitcoin => f.write_str("Bitcoin"),
176 }
177 }
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
183#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
184pub enum CrossChainFeeMode {
185 FeesExcluded,
192 FeesIncluded,
199}
200
201impl From<crate::FeePolicy> for CrossChainFeeMode {
202 fn from(policy: crate::FeePolicy) -> Self {
203 match policy {
204 crate::FeePolicy::FeesExcluded => Self::FeesExcluded,
205 crate::FeePolicy::FeesIncluded => Self::FeesIncluded,
206 }
207 }
208}
209
210#[derive(Clone, Debug, Deserialize, Serialize)]
213#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
214pub enum CrossChainRouteFilter {
215 Send {
218 address_details: CrossChainAddressDetails,
219 },
220 Receive { contract_address: Option<String> },
223 PaymentLink {
227 address_details: CrossChainAddressDetails,
228 },
229}
230
231#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
234#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
235pub struct CrossChainRoutePair {
236 pub provider: CrossChainProvider,
238 pub chain: String,
240 pub chain_id: Option<String>,
244 pub asset: String,
246 pub contract_address: Option<String>,
248 pub decimals: u8,
250 pub exact_out_eligible: bool,
252 pub accepted_assets: Vec<SparkAsset>,
254 pub delivery_methods: Vec<DeliveryMethod>,
257}
258
259impl CrossChainRoutePair {
260 pub(crate) fn destination_address_family(
265 &self,
266 ) -> Option<breez_sdk_common::input::CrossChainAddressFamily> {
267 self.contract_address
268 .as_deref()
269 .and_then(breez_sdk_common::input::detect_address_family)
270 }
271}
272
273#[derive(Clone)]
277pub(crate) struct CrossChainContext {
278 providers: HashMap<CrossChainProvider, Arc<dyn CrossChainService>>,
279 fiat_service: Arc<dyn FiatService>,
280}
281
282impl CrossChainContext {
283 pub fn new(fiat_service: Arc<dyn FiatService>) -> Self {
284 Self {
285 providers: HashMap::new(),
286 fiat_service,
287 }
288 }
289
290 pub fn insert(&mut self, key: CrossChainProvider, service: Arc<dyn CrossChainService>) {
291 self.providers.insert(key, service);
292 }
293
294 pub fn get(
296 &self,
297 provider: CrossChainProvider,
298 ) -> Result<&Arc<dyn CrossChainService>, SdkError> {
299 self.providers.get(&provider).ok_or_else(|| {
300 SdkError::InvalidInput(format!("Cross-chain provider {provider} is not available."))
301 })
302 }
303
304 pub fn values(&self) -> impl Iterator<Item = &Arc<dyn CrossChainService>> {
305 self.providers.values()
306 }
307
308 pub fn fiat_service(&self) -> &Arc<dyn FiatService> {
311 &self.fiat_service
312 }
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize)]
319#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
320pub enum CrossChainProviderContext {
321 Orchestra {
322 quote_id: String,
324 deposit_address: String,
326 #[serde(default)]
328 deposit_amount: u128,
329 },
330 Boltz {
331 swap_id: String,
333 invoice: String,
335 #[serde(default)]
337 invoice_amount_sats: u64,
338 max_slippage_bps: u32,
340 },
341}
342
343#[derive(Debug, Clone)]
347pub(crate) struct CrossChainReceivePrepared {
348 pub payment_request: String,
350 pub info: CrossChainReceiveInfo,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
355#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
356pub struct CrossChainReceiveInfo {
357 pub deposit_address: String,
359 pub deposit_amount: u128,
364 pub expected_received_amount: u128,
369 pub destination_asset: String,
373 pub token_identifier: Option<String>,
376 pub service_fee_amount: u128,
379 pub service_fee_asset: Option<String>,
382 pub expires_at: u64,
384}
385
386#[derive(Debug, Clone)]
389pub(crate) struct CrossChainSendPrepared {
390 pub amount_in: u128,
391 pub asset_amount_in: u128,
395 pub estimated_out: u128,
397 pub fee_amount: u128,
403 pub service_fee_amount: u128,
405 pub service_fee_asset: Option<String>,
407 pub source_transfer_fee_sats: u64,
417 pub fee_mode: CrossChainFeeMode,
420 pub expires_at: String,
421 pub pair: CrossChainRoutePair,
422 pub recipient_address: String,
423 pub token_identifier: Option<String>,
425 pub provider_context: CrossChainProviderContext,
427}
428
429#[allow(clippy::too_many_arguments)]
434#[macros::async_trait]
435pub(crate) trait CrossChainService: Send + Sync {
436 async fn get_routes(
442 &self,
443 filter: &CrossChainRouteFilter,
444 ) -> Result<Vec<CrossChainRoutePair>, SdkError>;
445
446 #[allow(clippy::too_many_arguments)]
454 async fn prepare_send(
455 &self,
456 recipient_address: &str,
457 route: &CrossChainRoutePair,
458 amount: u128,
459 delivery_method: Option<DeliveryMethod>,
460 source_token_identifier: Option<String>,
461 max_slippage_bps: u32,
462 fee_mode: CrossChainFeeMode,
463 ) -> Result<CrossChainSendPrepared, SdkError>;
464
465 async fn prepare_receive(
478 &self,
479 route: &CrossChainRoutePair,
480 recipient_address: &str,
481 amount: u128,
482 max_slippage_bps: u32,
483 destination: &SparkAsset,
487 fee_mode: CrossChainFeeMode,
488 target_overpay_bps: u32,
489 ) -> Result<CrossChainReceivePrepared, SdkError>;
490
491 async fn send(
507 &self,
508 prepared: &CrossChainSendPrepared,
509 idempotency_key: Option<String>,
510 ) -> Result<crate::Payment, SdkError>;
511}
512
513pub(crate) async fn fetch_btc_usd_rate(fiat: &dyn FiatService) -> Result<f64, SdkError> {
516 let rates = fiat
517 .fetch_fiat_rates()
518 .await
519 .map_err(|e| SdkError::Generic(format!("Cross-chain: failed to fetch fiat rates: {e}")))?;
520 let btc_usd = rates
521 .iter()
522 .find(|r| r.coin.eq_ignore_ascii_case("USD"))
523 .map(|r| r.value)
524 .ok_or_else(|| {
525 SdkError::Generic("Cross-chain: BTC/USD rate not found in feed".to_string())
526 })?;
527 if !btc_usd.is_finite() || btc_usd <= 0.0 {
528 return Err(SdkError::Generic(format!(
529 "Cross-chain: invalid BTC/USD rate from feed: {btc_usd}"
530 )));
531 }
532 Ok(btc_usd)
533}
534
535#[allow(
538 clippy::cast_precision_loss,
539 clippy::cast_possible_truncation,
540 clippy::cast_sign_loss
541)]
542pub(crate) fn convert_sats_to_destination_amount(
543 sats: u128,
544 fiat_rate: f64,
545 dest_decimals: u32,
546) -> Result<u128, SdkError> {
547 let dest_scale = 10f64.powi(i32::try_from(dest_decimals).unwrap_or(i32::MAX));
548 let target = (sats as f64) * fiat_rate * dest_scale / 100_000_000f64;
549 if !target.is_finite() || target < 0.0 {
550 return Err(SdkError::Generic(format!(
551 "Cross-chain: invalid sats→dest conversion result: {target}"
552 )));
553 }
554 Ok(target as u128)
555}
556
557#[allow(
561 clippy::cast_precision_loss,
562 clippy::cast_possible_truncation,
563 clippy::cast_sign_loss
564)]
565pub(crate) fn convert_source_amount_to_sats(
566 src_base_units: u128,
567 src_decimals: u32,
568 fiat_per_btc: f64,
569) -> Result<u128, SdkError> {
570 let src_scale = 10f64.powi(i32::try_from(src_decimals).unwrap_or(i32::MAX));
571 let sats = (src_base_units as f64) * 100_000_000f64 / (src_scale * fiat_per_btc);
572 if !sats.is_finite() || sats < 0.0 {
573 return Err(SdkError::Generic(format!(
574 "Cross-chain: invalid stable→sats conversion result: {sats}"
575 )));
576 }
577 Ok(sats as u128)
578}
579
580pub(crate) fn is_usd_stable_asset(asset: &str) -> bool {
581 USD_STABLE_ASSETS
582 .iter()
583 .any(|a| asset.eq_ignore_ascii_case(a))
584}
585
586pub(crate) fn build_receive_payment_request(
593 deposit_address: &str,
594 chain: &str,
595 chain_id: Option<&str>,
596 contract_address: Option<&str>,
597 amount: u128,
598) -> Result<String, SdkError> {
599 let family =
600 breez_sdk_common::input::detect_address_family(deposit_address).ok_or_else(|| {
601 SdkError::Generic(format!(
602 "Cross-chain provider returned unrecognised deposit address: {deposit_address}",
603 ))
604 })?;
605 if !family.matches_chain(chain, contract_address) {
608 return Err(SdkError::Generic(format!(
609 "Cross-chain provider returned {family:?} deposit address for {chain} route"
610 )));
611 }
612 Ok(breez_sdk_common::input::format_cross_chain_uri(
613 family,
614 deposit_address,
615 contract_address,
616 chain_id,
617 amount,
618 ))
619}
620
621pub(crate) fn compute_terminal_fee_amount(
625 new_status: &crate::ConversionStatus,
626 asset_amount_in: Option<u128>,
627 delivered_amount: Option<u128>,
628 prepare_estimate: Option<u128>,
629) -> Option<u128> {
630 match (new_status, asset_amount_in, delivered_amount) {
631 (crate::ConversionStatus::Completed, Some(a), Some(d)) => Some(a.saturating_sub(d)),
632 _ => prepare_estimate,
633 }
634}
635
636pub(crate) fn rescale_decimals(
640 amount: u128,
641 src_decimals: u32,
642 dest_decimals: u32,
643) -> Result<u128, SdkError> {
644 if dest_decimals >= src_decimals {
645 let delta = dest_decimals.saturating_sub(src_decimals);
646 let factor = 10u128
647 .checked_pow(delta)
648 .ok_or_else(|| SdkError::Generic("Cross-chain: decimal scale overflow".to_string()))?;
649 amount
650 .checked_mul(factor)
651 .ok_or_else(|| SdkError::Generic("Cross-chain: decimal rescale overflow".to_string()))
652 } else {
653 let delta = src_decimals.saturating_sub(dest_decimals);
654 let factor = 10u128
655 .checked_pow(delta)
656 .ok_or_else(|| SdkError::Generic("Cross-chain: decimal scale overflow".to_string()))?;
657 amount.checked_div(factor).ok_or_else(|| {
658 SdkError::Generic("Cross-chain: decimal rescale divisor zero".to_string())
659 })
660 }
661}
662
663#[allow(
667 clippy::cast_precision_loss,
668 clippy::cast_possible_truncation,
669 clippy::cast_sign_loss
670)]
671pub(crate) fn convert_destination_amount_to_sats(
672 destination_amount: u128,
673 fiat_rate: f64,
674 dest_decimals: u32,
675) -> Result<u128, SdkError> {
676 if !fiat_rate.is_finite() || fiat_rate <= 0.0 {
677 return Err(SdkError::Generic(format!(
678 "Cross-chain: invalid BTC/USD rate for inversion: {fiat_rate}"
679 )));
680 }
681 let dest_scale = 10f64.powi(i32::try_from(dest_decimals).unwrap_or(i32::MAX));
682 let sats = (destination_amount as f64) * 100_000_000f64 / (fiat_rate * dest_scale);
683 if !sats.is_finite() || sats < 0.0 {
684 return Err(SdkError::Generic(format!(
685 "Cross-chain: invalid dest→sats conversion result: {sats}"
686 )));
687 }
688 Ok(sats as u128)
689}
690
691pub(crate) fn resolve_target_overpay_bps(
697 requested: Option<u32>,
698 config_default: Option<u32>,
699) -> Result<u32, SdkError> {
700 if let Some(bps) = requested
701 && !(MIN_TARGET_OVERPAY_BPS..=MAX_TARGET_OVERPAY_BPS).contains(&bps)
702 {
703 return Err(SdkError::InvalidInput(format!(
704 "target_overpay_bps {bps} must be in \
705 {MIN_TARGET_OVERPAY_BPS} to {MAX_TARGET_OVERPAY_BPS}",
706 )));
707 }
708 Ok(requested
709 .or(config_default)
710 .unwrap_or(DEFAULT_TARGET_OVERPAY_BPS))
711}
712
713pub(crate) fn inflate_target_amount(amount: u128, overpay_bps: u32) -> u128 {
718 if overpay_bps == 0 {
719 return amount;
720 }
721 amount.saturating_add(amount.saturating_mul(u128::from(overpay_bps)) / 10_000)
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727 use macros::test_all;
728
729 #[cfg(feature = "browser-tests")]
730 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
731
732 #[test_all]
733 fn delivery_method_display_is_human_readable() {
734 assert_eq!(DeliveryMethod::Spark.to_string(), "Spark");
735 assert_eq!(DeliveryMethod::Lightning.to_string(), "Lightning");
736 assert_eq!(DeliveryMethod::Bitcoin.to_string(), "Bitcoin");
737 }
738
739 #[test_all]
740 fn derive_btc_leg_transfer_id_uses_caller_key() {
741 let key = "00000000-0000-4000-8000-000000000001";
744 let id = derive_btc_leg_transfer_id(Some(key), "ignored-seed").unwrap();
745 assert_eq!(id.to_string(), key);
746 }
747
748 #[test_all]
749 fn derive_btc_leg_transfer_id_deterministic_from_seed() {
750 let a = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
751 let b = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
752 assert_eq!(
753 a, b,
754 "same seed must produce the same TransferId across calls"
755 );
756 }
757
758 #[test_all]
759 fn derive_btc_leg_transfer_id_distinct_seeds_yield_distinct_ids() {
760 let a = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
761 let b = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-2").unwrap();
762 assert_ne!(a, b);
763 }
764
765 #[test_all]
766 fn derive_btc_leg_transfer_id_orchestra_and_boltz_seeds_collide_only_on_id() {
767 let orchestra = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:abc").unwrap();
770 let boltz = derive_btc_leg_transfer_id(None, "cross_chain:boltz:abc").unwrap();
771 assert_ne!(orchestra, boltz);
772 }
773
774 #[test_all]
775 fn derive_btc_leg_transfer_id_rejects_invalid_caller_key() {
776 let err = derive_btc_leg_transfer_id(Some("not-a-uuid"), "fallback").unwrap_err();
777 assert!(matches!(err, SdkError::Generic(_)));
778 }
779
780 #[test_all]
781 fn convert_sats_to_destination_amount_round_trip_inverts_to_sats() {
782 let dest = convert_sats_to_destination_amount(10_000, 60_000.0, 6).unwrap();
784 assert_eq!(dest, 6_000_000);
785 let sats = convert_destination_amount_to_sats(dest, 60_000.0, 6).unwrap();
787 assert_eq!(sats, 10_000);
788 }
789
790 #[test_all]
791 fn convert_destination_amount_to_sats_typical_stable() {
792 let sats = convert_destination_amount_to_sats(1_000_000, 60_000.0, 6).unwrap();
794 assert_eq!(sats, 1_666);
795 }
796
797 #[test_all]
798 fn convert_destination_amount_to_sats_zero_passes_through() {
799 let sats = convert_destination_amount_to_sats(0, 60_000.0, 6).unwrap();
800 assert_eq!(sats, 0);
801 }
802
803 #[test_all]
804 fn convert_destination_amount_to_sats_rejects_non_positive_rate() {
805 let err = convert_destination_amount_to_sats(1_000_000, 0.0, 6).unwrap_err();
806 assert!(matches!(err, SdkError::Generic(ref m) if m.contains("invalid BTC/USD rate")));
807 let err = convert_destination_amount_to_sats(1_000_000, f64::NAN, 6).unwrap_err();
808 assert!(matches!(err, SdkError::Generic(_)));
809 }
810
811 #[test_all]
812 fn rescale_decimals_scales_down_when_dest_decimals_lower() {
813 assert_eq!(rescale_decimals(100_000_000, 8, 6).unwrap(), 1_000_000);
814 }
815
816 #[test_all]
817 fn rescale_decimals_same_decimals_is_identity() {
818 assert_eq!(rescale_decimals(123_456_789, 6, 6).unwrap(), 123_456_789);
819 }
820
821 #[test_all]
822 fn rescale_decimals_scales_up_when_dest_decimals_higher() {
823 assert_eq!(rescale_decimals(1_000_000, 6, 8).unwrap(), 100_000_000);
824 }
825
826 #[test_all]
827 fn rescale_decimals_zero_passes_through() {
828 assert_eq!(rescale_decimals(0, 8, 6).unwrap(), 0);
829 assert_eq!(rescale_decimals(0, 6, 8).unwrap(), 0);
830 }
831
832 #[test_all]
833 fn is_usd_stable_asset_recognizes_known_stables() {
834 for ticker in ["USDB", "USDC", "USDT", "USDT0", "usdb", "uSdC"] {
835 assert!(is_usd_stable_asset(ticker), "{ticker} should be stable");
836 }
837 }
838
839 #[test_all]
840 fn is_usd_stable_asset_rejects_btc_and_unknown() {
841 for ticker in ["BTC", "ETH", "DAI", "", "USD"] {
842 assert!(
843 !is_usd_stable_asset(ticker),
844 "{ticker} should not be a recognized USD-stable"
845 );
846 }
847 }
848
849 #[test_all]
852 fn compute_terminal_fee_overwrites_estimate_on_completed() {
853 let realized = compute_terminal_fee_amount(
854 &crate::ConversionStatus::Completed,
855 Some(1_020_434), Some(997_498), Some(20_434), );
859 assert_eq!(realized, Some(22_936), "= asset_amount_in − delivered");
860 }
861
862 #[test_all]
863 fn compute_terminal_fee_keeps_estimate_on_refunded() {
864 let realized = compute_terminal_fee_amount(
868 &crate::ConversionStatus::Refunded,
869 Some(1_020_434),
870 None,
871 Some(20_434),
872 );
873 assert_eq!(realized, Some(20_434));
874 }
875
876 #[test_all]
877 fn compute_terminal_fee_keeps_estimate_on_failed() {
878 let realized = compute_terminal_fee_amount(
879 &crate::ConversionStatus::Failed,
880 Some(1_020_434),
881 None,
882 Some(20_434),
883 );
884 assert_eq!(realized, Some(20_434));
885 }
886
887 #[test_all]
888 fn compute_terminal_fee_keeps_estimate_when_asset_amount_in_missing() {
889 let realized = compute_terminal_fee_amount(
892 &crate::ConversionStatus::Completed,
893 None, Some(997_498),
895 Some(20_434),
896 );
897 assert_eq!(realized, Some(20_434));
898 }
899
900 #[test_all]
901 fn compute_terminal_fee_keeps_estimate_when_delivered_amount_missing() {
902 let realized = compute_terminal_fee_amount(
905 &crate::ConversionStatus::Completed,
906 Some(1_020_434),
907 None, Some(20_434),
909 );
910 assert_eq!(realized, Some(20_434));
911 }
912
913 #[test_all]
914 fn compute_terminal_fee_saturating_sub_on_over_delivery() {
915 let realized = compute_terminal_fee_amount(
917 &crate::ConversionStatus::Completed,
918 Some(1_000_000),
919 Some(1_005_000),
920 Some(0),
921 );
922 assert_eq!(
923 realized,
924 Some(0),
925 "saturating_sub must clamp at 0, not underflow"
926 );
927 }
928
929 #[test_all]
939 fn boltz_provider_context_invoice_amount_sats_is_independent_of_amount_in() {
940 let ctx = CrossChainProviderContext::Boltz {
941 swap_id: "swap_1".to_string(),
942 invoice: "lnbc19090n1pexample".to_string(),
943 invoice_amount_sats: 1_909,
944 max_slippage_bps: 100,
945 };
946 let json = serde_json::to_string(&ctx).unwrap();
947 let decoded: CrossChainProviderContext = serde_json::from_str(&json).unwrap();
948 let CrossChainProviderContext::Boltz {
949 invoice_amount_sats,
950 ..
951 } = &decoded
952 else {
953 panic!("expected Boltz variant");
954 };
955 assert_eq!(*invoice_amount_sats, 1_909);
956 assert!(
957 *invoice_amount_sats != 1_222_703,
958 "the LN invoice sats must never be conflated with a user-facing display value (e.g. USDB base units)"
959 );
960 }
961
962 #[test_all]
967 fn boltz_provider_context_legacy_row_without_invoice_amount_sats_defaults_to_zero() {
968 let legacy = r#"{
969 "Boltz": {
970 "swap_id": "swap_legacy",
971 "invoice": "lnbc19090n1p",
972 "max_slippage_bps": 100
973 }
974 }"#;
975 let decoded: CrossChainProviderContext = serde_json::from_str(legacy).unwrap();
976 let CrossChainProviderContext::Boltz {
977 invoice_amount_sats,
978 ..
979 } = &decoded
980 else {
981 panic!("expected Boltz variant");
982 };
983 assert_eq!(*invoice_amount_sats, 0);
984 }
985
986 #[test_all]
990 fn orchestra_provider_context_deposit_amount_is_independent_of_amount_in() {
991 let ctx = CrossChainProviderContext::Orchestra {
992 quote_id: "q_1".to_string(),
993 deposit_address: "spark1...".to_string(),
994 deposit_amount: 1_020_434,
995 };
996 let json = serde_json::to_string(&ctx).unwrap();
997 let decoded: CrossChainProviderContext = serde_json::from_str(&json).unwrap();
998 let CrossChainProviderContext::Orchestra { deposit_amount, .. } = &decoded else {
999 panic!("expected Orchestra variant");
1000 };
1001 assert_eq!(*deposit_amount, 1_020_434);
1002 }
1003
1004 fn boltz_info(swap_id: &str) -> ConversionInfo {
1005 ConversionInfo::Boltz {
1006 swap_id: swap_id.to_string(),
1007 invoice: "lnbc1".to_string(),
1008 invoice_amount_sats: 1_000,
1009 bridge_ref: None,
1010 max_slippage_bps: 100,
1011 quote_degraded: false,
1012 chain: "polygon".to_string(),
1013 chain_id: None,
1014 asset: "USDC".to_string(),
1015 recipient_address: "0xabc".to_string(),
1016 estimated_out: 1_000_000,
1017 delivered_amount: None,
1018 status: crate::ConversionStatus::Pending,
1019 asset_amount_in: None,
1020 fee_amount: None,
1021 service_fee_amount: None,
1022 service_fee_asset: None,
1023 asset_decimals: 6,
1024 asset_contract: None,
1025 }
1026 }
1027
1028 #[test_all]
1029 fn payment_with_conversion_info_injects_into_lightning_details() {
1030 let payment = crate::Payment {
1031 id: "p1".to_string(),
1032 payment_type: crate::PaymentType::Send,
1033 status: crate::PaymentStatus::Pending,
1034 amount: 1_000,
1035 fees: 0,
1036 timestamp: 100,
1037 method: crate::PaymentMethod::Lightning,
1038 details: Some(PaymentDetails::Lightning {
1039 description: Some("desc".to_string()),
1040 invoice: "lnbc1".to_string(),
1041 destination_pubkey: "02aa".to_string(),
1042 htlc_details: crate::SparkHtlcDetails {
1043 payment_hash: "hash1".to_string(),
1044 preimage: None,
1045 expiry_time: 0,
1046 status: crate::SparkHtlcStatus::PreimageShared,
1047 },
1048 lnurl_pay_info: None,
1049 lnurl_withdraw_info: None,
1050 lnurl_receive_metadata: None,
1051 conversion_info: None,
1052 }),
1053 conversion_details: None,
1054 };
1055
1056 let out = payment_with_conversion_info(payment, Some(boltz_info("swap1")));
1057
1058 assert_eq!(out.status, crate::PaymentStatus::Pending);
1059 let Some(PaymentDetails::Lightning {
1060 invoice,
1061 description,
1062 conversion_info,
1063 ..
1064 }) = out.details
1065 else {
1066 panic!("expected Lightning details");
1067 };
1068 assert_eq!(invoice, "lnbc1");
1070 assert_eq!(description.as_deref(), Some("desc"));
1071 assert!(matches!(
1072 conversion_info,
1073 Some(ConversionInfo::Boltz { ref swap_id, .. }) if swap_id == "swap1"
1074 ));
1075 }
1076
1077 #[test_all]
1078 fn payment_with_conversion_info_passes_through_variants_without_a_slot() {
1079 let payment = crate::Payment {
1080 id: "p1".to_string(),
1081 payment_type: crate::PaymentType::Send,
1082 status: crate::PaymentStatus::Completed,
1083 amount: 1_000,
1084 fees: 0,
1085 timestamp: 100,
1086 method: crate::PaymentMethod::Withdraw,
1087 details: Some(PaymentDetails::Withdraw {
1088 tx_id: "tx1".to_string(),
1089 }),
1090 conversion_details: None,
1091 };
1092
1093 let out = payment_with_conversion_info(payment, Some(boltz_info("swap1")));
1094
1095 assert!(matches!(
1096 out.details,
1097 Some(PaymentDetails::Withdraw { tx_id }) if tx_id == "tx1"
1098 ));
1099 }
1100
1101 #[test_all]
1104 fn resolve_target_overpay_uses_request_when_in_range() {
1105 assert_eq!(resolve_target_overpay_bps(Some(50), Some(75)).unwrap(), 50);
1106 }
1107
1108 #[test_all]
1109 fn resolve_target_overpay_falls_back_to_config_default() {
1110 assert_eq!(resolve_target_overpay_bps(None, Some(75)).unwrap(), 75);
1111 }
1112
1113 #[test_all]
1114 fn resolve_target_overpay_falls_back_to_built_in_default() {
1115 assert_eq!(
1116 resolve_target_overpay_bps(None, None).unwrap(),
1117 DEFAULT_TARGET_OVERPAY_BPS
1118 );
1119 }
1120
1121 #[test_all]
1122 fn resolve_target_overpay_request_zero_opts_out() {
1123 assert_eq!(resolve_target_overpay_bps(Some(0), Some(50)).unwrap(), 0);
1124 }
1125
1126 #[test_all]
1127 fn resolve_target_overpay_rejects_out_of_range_request() {
1128 let too_high = MAX_TARGET_OVERPAY_BPS + 1;
1129 assert!(matches!(
1130 resolve_target_overpay_bps(Some(too_high), None),
1131 Err(SdkError::InvalidInput(_))
1132 ));
1133 }
1134
1135 #[test_all]
1138 fn inflate_target_amount_zero_bps_is_identity() {
1139 assert_eq!(inflate_target_amount(1_000_000, 0), 1_000_000);
1140 }
1141
1142 #[test_all]
1143 fn inflate_target_amount_applies_bps_pad() {
1144 assert_eq!(inflate_target_amount(1_000_000, 25), 1_002_500);
1146 }
1147
1148 #[test_all]
1149 fn inflate_target_amount_truncates_sub_unit_pad() {
1150 assert_eq!(inflate_target_amount(100, 25), 100);
1152 }
1153
1154 #[test_all]
1157 fn convert_stable_to_sats_at_par_6dp() {
1158 assert_eq!(
1160 convert_source_amount_to_sats(1_000_000, 6, 100_000.0).unwrap(),
1161 1000
1162 );
1163 }
1164
1165 #[test_all]
1166 fn convert_stable_to_sats_at_a_different_rate() {
1167 assert_eq!(
1169 convert_source_amount_to_sats(1_000_000, 6, 50_000.0).unwrap(),
1170 2000
1171 );
1172 }
1173
1174 #[test_all]
1175 fn convert_stable_to_sats_matches_across_decimals_at_same_usd_value() {
1176 let sats_6 = convert_source_amount_to_sats(1_000_000, 6, 100_000.0).unwrap();
1178 let sats_18 =
1179 convert_source_amount_to_sats(1_000_000_000_000_000_000, 18, 100_000.0).unwrap();
1180 assert_eq!(sats_6, sats_18);
1181 assert_eq!(sats_6, 1000);
1182 }
1183
1184 #[test_all]
1185 fn convert_stable_to_sats_zero_input_is_zero() {
1186 assert_eq!(convert_source_amount_to_sats(0, 6, 100_000.0).unwrap(), 0);
1187 }
1188
1189 #[test_all]
1190 fn convert_stable_to_sats_rejects_nan_rate() {
1191 assert!(matches!(
1194 convert_source_amount_to_sats(1_000_000, 6, f64::NAN),
1195 Err(SdkError::Generic(_))
1196 ));
1197 }
1198
1199 #[test_all]
1200 fn convert_stable_to_sats_rejects_negative_rate() {
1201 assert!(matches!(
1202 convert_source_amount_to_sats(1_000_000, 6, -100_000.0),
1203 Err(SdkError::Generic(_))
1204 ));
1205 }
1206
1207 #[test_all]
1212 fn build_receive_payment_request_evm_emits_eip_681_uri() {
1213 let uri = build_receive_payment_request(
1214 "0x00Df20df75800ca8f40080505a7a802331C1321c",
1215 "arbitrum",
1216 Some("42161"),
1217 Some("0xaf88d065e77c8cC2239327C5EDb3A432268e5831"),
1218 1_000_000,
1219 )
1220 .unwrap();
1221 assert!(uri.starts_with("ethereum:"), "got {uri}");
1222 assert!(uri.contains("42161"), "chain_id must appear: {uri}");
1223 assert!(
1224 uri.contains("0xaf88d065e77c8cC2239327C5EDb3A432268e5831"),
1225 "token contract must appear: {uri}"
1226 );
1227 assert!(uri.contains("1000000"), "amount must appear: {uri}");
1228 }
1229
1230 #[test_all]
1233 fn build_receive_payment_request_solana_returns_bare_address() {
1234 let addr = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM";
1235 let out = build_receive_payment_request(addr, "solana", None, None, 1_000_000).unwrap();
1236 assert_eq!(out, addr);
1237 }
1238
1239 #[test_all]
1241 fn build_receive_payment_request_tron_returns_bare_address() {
1242 let addr = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
1243 let out = build_receive_payment_request(addr, "tron", None, None, 1_000_000).unwrap();
1244 assert_eq!(out, addr);
1245 }
1246
1247 #[test_all]
1250 fn build_receive_payment_request_rejects_unrecognized_address() {
1251 let err =
1252 build_receive_payment_request("not-an-address", "arbitrum", None, None, 1_000_000)
1253 .unwrap_err();
1254 assert!(matches!(err, SdkError::Generic(_)));
1255 }
1256
1257 #[test_all]
1261 fn build_receive_payment_request_rejects_family_chain_mismatch() {
1262 let evm_addr = "0x00Df20df75800ca8f40080505a7a802331C1321c";
1263 let err =
1264 build_receive_payment_request(evm_addr, "solana", None, None, 1_000_000).unwrap_err();
1265 match err {
1266 SdkError::Generic(msg) => {
1267 assert!(
1268 msg.contains("Evm") && msg.contains("solana"),
1269 "unexpected message: {msg}"
1270 );
1271 }
1272 other => panic!("expected Generic mismatch error, got {other:?}"),
1273 }
1274
1275 let solana_addr = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM";
1276 assert!(
1277 build_receive_payment_request(solana_addr, "tron", None, None, 1_000_000).is_err(),
1278 "solana address must not be accepted for tron route",
1279 );
1280 }
1281}