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, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
173#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
174pub struct CrossChainRouteLimits {
175 pub min_amount: Option<u128>,
177 pub max_amount: Option<u128>,
179 pub min_usd_cents: Option<u64>,
181 pub max_usd_cents: Option<u64>,
183}
184
185#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
190#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
191pub struct CrossChainAcceptedAsset {
192 pub asset: SparkAsset,
193 pub limits: Option<CrossChainRouteLimits>,
195}
196
197#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
199#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
200pub enum DeliveryMethod {
201 Spark,
203 Lightning,
205 Bitcoin,
207}
208
209impl std::fmt::Display for DeliveryMethod {
210 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 match self {
212 Self::Spark => f.write_str("Spark"),
213 Self::Lightning => f.write_str("Lightning"),
214 Self::Bitcoin => f.write_str("Bitcoin"),
215 }
216 }
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
222#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
223pub enum CrossChainFeeMode {
224 FeesExcluded,
231 FeesIncluded,
238}
239
240impl From<crate::FeePolicy> for CrossChainFeeMode {
241 fn from(policy: crate::FeePolicy) -> Self {
242 match policy {
243 crate::FeePolicy::FeesExcluded => Self::FeesExcluded,
244 crate::FeePolicy::FeesIncluded => Self::FeesIncluded,
245 }
246 }
247}
248
249#[derive(Clone, Debug, Deserialize, Serialize)]
252#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
253pub enum CrossChainRouteFilter {
254 Send {
257 address_details: CrossChainAddressDetails,
258 },
259 Receive { contract_address: Option<String> },
262 PaymentLink {
266 address_details: CrossChainAddressDetails,
267 },
268}
269
270#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
273#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
274pub struct CrossChainRoutePair {
275 pub provider: CrossChainProvider,
277 pub chain: String,
279 pub chain_id: Option<String>,
283 pub asset: String,
285 pub contract_address: Option<String>,
287 pub decimals: u8,
289 pub exact_out_eligible: bool,
291 pub accepted_assets: Vec<CrossChainAcceptedAsset>,
293 pub delivery_methods: Vec<DeliveryMethod>,
296}
297
298impl CrossChainRoutePair {
299 pub fn limits_for(&self, asset: &SparkAsset) -> Option<&CrossChainRouteLimits> {
302 self.accepted_assets
303 .iter()
304 .find(|a| &a.asset == asset)
305 .and_then(|a| a.limits.as_ref())
306 }
307
308 pub fn accepts_asset(&self, asset: &SparkAsset) -> bool {
310 self.accepted_assets.iter().any(|a| &a.asset == asset)
311 }
312
313 pub(crate) fn destination_address_family(
318 &self,
319 ) -> Option<breez_sdk_common::input::CrossChainAddressFamily> {
320 self.contract_address
321 .as_deref()
322 .and_then(breez_sdk_common::input::detect_address_family)
323 }
324}
325
326#[derive(Clone)]
330pub(crate) struct CrossChainContext {
331 providers: HashMap<CrossChainProvider, Arc<dyn CrossChainService>>,
332 fiat_service: Arc<dyn FiatService>,
333}
334
335impl CrossChainContext {
336 pub fn new(fiat_service: Arc<dyn FiatService>) -> Self {
337 Self {
338 providers: HashMap::new(),
339 fiat_service,
340 }
341 }
342
343 pub fn insert(&mut self, key: CrossChainProvider, service: Arc<dyn CrossChainService>) {
344 self.providers.insert(key, service);
345 }
346
347 pub fn get(
349 &self,
350 provider: CrossChainProvider,
351 ) -> Result<&Arc<dyn CrossChainService>, SdkError> {
352 self.providers.get(&provider).ok_or_else(|| {
353 SdkError::InvalidInput(format!("Cross-chain provider {provider} is not available."))
354 })
355 }
356
357 pub fn values(&self) -> impl Iterator<Item = &Arc<dyn CrossChainService>> {
358 self.providers.values()
359 }
360
361 pub fn fiat_service(&self) -> &Arc<dyn FiatService> {
364 &self.fiat_service
365 }
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
372#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
373pub enum CrossChainProviderContext {
374 Orchestra {
375 quote_id: String,
377 deposit_address: String,
379 #[serde(default)]
381 deposit_amount: u128,
382 },
383 Boltz {
384 swap_id: String,
386 invoice: String,
388 #[serde(default)]
390 invoice_amount_sats: u64,
391 max_slippage_bps: u32,
393 },
394}
395
396#[derive(Debug, Clone)]
400pub(crate) struct CrossChainReceivePrepared {
401 pub payment_request: String,
403 pub info: CrossChainReceiveInfo,
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize)]
408#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
409pub struct CrossChainReceiveInfo {
410 pub deposit_address: String,
412 pub deposit_amount: u128,
417 pub expected_received_amount: u128,
422 pub destination_asset: String,
426 pub token_identifier: Option<String>,
429 pub service_fee_amount: u128,
432 pub service_fee_asset: Option<String>,
435 pub service_fee_asset_decimals: Option<u32>,
438 pub expires_at: u64,
440}
441
442#[derive(Debug, Clone)]
445pub(crate) struct CrossChainSendPrepared {
446 pub amount_in: u128,
447 pub asset_amount_in: u128,
451 pub estimated_out: u128,
453 pub fee_amount: u128,
459 pub service_fee_amount: u128,
461 pub service_fee_asset: Option<String>,
463 pub service_fee_asset_decimals: Option<u32>,
465 pub source_transfer_fee_sats: u64,
475 pub fee_mode: CrossChainFeeMode,
478 pub expires_at: String,
479 pub pair: CrossChainRoutePair,
480 pub recipient_address: String,
481 pub token_identifier: Option<String>,
483 pub provider_context: CrossChainProviderContext,
485}
486
487#[allow(clippy::too_many_arguments)]
492#[macros::async_trait]
493pub(crate) trait CrossChainService: Send + Sync {
494 async fn get_routes(
500 &self,
501 filter: &CrossChainRouteFilter,
502 ) -> Result<Vec<CrossChainRoutePair>, SdkError>;
503
504 #[allow(clippy::too_many_arguments)]
512 async fn prepare_send(
513 &self,
514 recipient_address: &str,
515 route: &CrossChainRoutePair,
516 amount: u128,
517 delivery_method: Option<DeliveryMethod>,
518 source_token_identifier: Option<String>,
519 max_slippage_bps: u32,
520 fee_mode: CrossChainFeeMode,
521 ) -> Result<CrossChainSendPrepared, SdkError>;
522
523 async fn prepare_receive(
536 &self,
537 route: &CrossChainRoutePair,
538 recipient_address: &str,
539 amount: u128,
540 max_slippage_bps: u32,
541 destination: &SparkAsset,
545 fee_mode: CrossChainFeeMode,
546 target_overpay_bps: u32,
547 ) -> Result<CrossChainReceivePrepared, SdkError>;
548
549 async fn send(
565 &self,
566 prepared: &CrossChainSendPrepared,
567 idempotency_key: Option<String>,
568 ) -> Result<crate::Payment, SdkError>;
569}
570
571pub(crate) async fn fetch_btc_usd_rate(fiat: &dyn FiatService) -> Result<f64, SdkError> {
574 let rates = fiat
575 .fetch_fiat_rates()
576 .await
577 .map_err(|e| SdkError::Generic(format!("Cross-chain: failed to fetch fiat rates: {e}")))?;
578 let btc_usd = rates
579 .iter()
580 .find(|r| r.coin.eq_ignore_ascii_case("USD"))
581 .map(|r| r.value)
582 .ok_or_else(|| {
583 SdkError::Generic("Cross-chain: BTC/USD rate not found in feed".to_string())
584 })?;
585 if !btc_usd.is_finite() || btc_usd <= 0.0 {
586 return Err(SdkError::Generic(format!(
587 "Cross-chain: invalid BTC/USD rate from feed: {btc_usd}"
588 )));
589 }
590 Ok(btc_usd)
591}
592
593#[allow(
596 clippy::cast_precision_loss,
597 clippy::cast_possible_truncation,
598 clippy::cast_sign_loss
599)]
600pub(crate) fn convert_sats_to_destination_amount(
601 sats: u128,
602 fiat_rate: f64,
603 dest_decimals: u32,
604) -> Result<u128, SdkError> {
605 let dest_scale = 10f64.powi(i32::try_from(dest_decimals).unwrap_or(i32::MAX));
606 let target = (sats as f64) * fiat_rate * dest_scale / 100_000_000f64;
607 if !target.is_finite() || target < 0.0 {
608 return Err(SdkError::Generic(format!(
609 "Cross-chain: invalid sats→dest conversion result: {target}"
610 )));
611 }
612 Ok(target as u128)
613}
614
615#[allow(
619 clippy::cast_precision_loss,
620 clippy::cast_possible_truncation,
621 clippy::cast_sign_loss
622)]
623pub(crate) fn convert_source_amount_to_sats(
624 src_base_units: u128,
625 src_decimals: u32,
626 fiat_per_btc: f64,
627) -> Result<u128, SdkError> {
628 let src_scale = 10f64.powi(i32::try_from(src_decimals).unwrap_or(i32::MAX));
629 let sats = (src_base_units as f64) * 100_000_000f64 / (src_scale * fiat_per_btc);
630 if !sats.is_finite() || sats < 0.0 {
631 return Err(SdkError::Generic(format!(
632 "Cross-chain: invalid stable→sats conversion result: {sats}"
633 )));
634 }
635 Ok(sats as u128)
636}
637
638pub(crate) fn is_usd_stable_asset(asset: &str) -> bool {
639 USD_STABLE_ASSETS
640 .iter()
641 .any(|a| asset.eq_ignore_ascii_case(a))
642}
643
644pub(crate) fn build_receive_payment_request(
651 deposit_address: &str,
652 chain: &str,
653 chain_id: Option<&str>,
654 contract_address: Option<&str>,
655 amount: u128,
656) -> Result<String, SdkError> {
657 let family =
658 breez_sdk_common::input::detect_address_family(deposit_address).ok_or_else(|| {
659 SdkError::Generic(format!(
660 "Cross-chain provider returned unrecognised deposit address: {deposit_address}",
661 ))
662 })?;
663 if !family.matches_chain(chain, contract_address) {
666 return Err(SdkError::Generic(format!(
667 "Cross-chain provider returned {family:?} deposit address for {chain} route"
668 )));
669 }
670 Ok(breez_sdk_common::input::format_cross_chain_uri(
671 family,
672 deposit_address,
673 contract_address,
674 chain_id,
675 amount,
676 ))
677}
678
679pub(crate) fn compute_terminal_fee_amount(
683 new_status: &crate::ConversionStatus,
684 asset_amount_in: Option<u128>,
685 delivered_amount: Option<u128>,
686 prepare_estimate: Option<u128>,
687) -> Option<u128> {
688 match (new_status, asset_amount_in, delivered_amount) {
689 (crate::ConversionStatus::Completed, Some(a), Some(d)) => Some(a.saturating_sub(d)),
690 _ => prepare_estimate,
691 }
692}
693
694pub(crate) fn rescale_decimals(
698 amount: u128,
699 src_decimals: u32,
700 dest_decimals: u32,
701) -> Result<u128, SdkError> {
702 if dest_decimals >= src_decimals {
703 let delta = dest_decimals.saturating_sub(src_decimals);
704 let factor = 10u128
705 .checked_pow(delta)
706 .ok_or_else(|| SdkError::Generic("Cross-chain: decimal scale overflow".to_string()))?;
707 amount
708 .checked_mul(factor)
709 .ok_or_else(|| SdkError::Generic("Cross-chain: decimal rescale overflow".to_string()))
710 } else {
711 let delta = src_decimals.saturating_sub(dest_decimals);
712 let factor = 10u128
713 .checked_pow(delta)
714 .ok_or_else(|| SdkError::Generic("Cross-chain: decimal scale overflow".to_string()))?;
715 amount.checked_div(factor).ok_or_else(|| {
716 SdkError::Generic("Cross-chain: decimal rescale divisor zero".to_string())
717 })
718 }
719}
720
721#[allow(
725 clippy::cast_precision_loss,
726 clippy::cast_possible_truncation,
727 clippy::cast_sign_loss
728)]
729pub(crate) fn convert_destination_amount_to_sats(
730 destination_amount: u128,
731 fiat_rate: f64,
732 dest_decimals: u32,
733) -> Result<u128, SdkError> {
734 if !fiat_rate.is_finite() || fiat_rate <= 0.0 {
735 return Err(SdkError::Generic(format!(
736 "Cross-chain: invalid BTC/USD rate for inversion: {fiat_rate}"
737 )));
738 }
739 let dest_scale = 10f64.powi(i32::try_from(dest_decimals).unwrap_or(i32::MAX));
740 let sats = (destination_amount as f64) * 100_000_000f64 / (fiat_rate * dest_scale);
741 if !sats.is_finite() || sats < 0.0 {
742 return Err(SdkError::Generic(format!(
743 "Cross-chain: invalid dest→sats conversion result: {sats}"
744 )));
745 }
746 Ok(sats as u128)
747}
748
749pub(crate) fn resolve_target_overpay_bps(
755 requested: Option<u32>,
756 config_default: Option<u32>,
757) -> Result<u32, SdkError> {
758 if let Some(bps) = requested
759 && !(MIN_TARGET_OVERPAY_BPS..=MAX_TARGET_OVERPAY_BPS).contains(&bps)
760 {
761 return Err(SdkError::InvalidInput(format!(
762 "target_overpay_bps {bps} must be in \
763 {MIN_TARGET_OVERPAY_BPS} to {MAX_TARGET_OVERPAY_BPS}",
764 )));
765 }
766 Ok(requested
767 .or(config_default)
768 .unwrap_or(DEFAULT_TARGET_OVERPAY_BPS))
769}
770
771pub(crate) fn inflate_target_amount(amount: u128, overpay_bps: u32) -> u128 {
776 if overpay_bps == 0 {
777 return amount;
778 }
779 amount.saturating_add(amount.saturating_mul(u128::from(overpay_bps)) / 10_000)
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785 use macros::test_all;
786
787 #[cfg(feature = "browser-tests")]
788 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
789
790 #[test_all]
791 fn delivery_method_display_is_human_readable() {
792 assert_eq!(DeliveryMethod::Spark.to_string(), "Spark");
793 assert_eq!(DeliveryMethod::Lightning.to_string(), "Lightning");
794 assert_eq!(DeliveryMethod::Bitcoin.to_string(), "Bitcoin");
795 }
796
797 #[test_all]
798 fn derive_btc_leg_transfer_id_uses_caller_key() {
799 let key = "00000000-0000-4000-8000-000000000001";
802 let id = derive_btc_leg_transfer_id(Some(key), "ignored-seed").unwrap();
803 assert_eq!(id.to_string(), key);
804 }
805
806 #[test_all]
807 fn derive_btc_leg_transfer_id_deterministic_from_seed() {
808 let a = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
809 let b = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
810 assert_eq!(
811 a, b,
812 "same seed must produce the same TransferId across calls"
813 );
814 }
815
816 #[test_all]
817 fn derive_btc_leg_transfer_id_distinct_seeds_yield_distinct_ids() {
818 let a = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
819 let b = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-2").unwrap();
820 assert_ne!(a, b);
821 }
822
823 #[test_all]
824 fn derive_btc_leg_transfer_id_orchestra_and_boltz_seeds_collide_only_on_id() {
825 let orchestra = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:abc").unwrap();
828 let boltz = derive_btc_leg_transfer_id(None, "cross_chain:boltz:abc").unwrap();
829 assert_ne!(orchestra, boltz);
830 }
831
832 #[test_all]
833 fn derive_btc_leg_transfer_id_rejects_invalid_caller_key() {
834 let err = derive_btc_leg_transfer_id(Some("not-a-uuid"), "fallback").unwrap_err();
835 assert!(matches!(err, SdkError::Generic(_)));
836 }
837
838 #[test_all]
839 fn convert_sats_to_destination_amount_round_trip_inverts_to_sats() {
840 let dest = convert_sats_to_destination_amount(10_000, 60_000.0, 6).unwrap();
842 assert_eq!(dest, 6_000_000);
843 let sats = convert_destination_amount_to_sats(dest, 60_000.0, 6).unwrap();
845 assert_eq!(sats, 10_000);
846 }
847
848 #[test_all]
849 fn convert_destination_amount_to_sats_typical_stable() {
850 let sats = convert_destination_amount_to_sats(1_000_000, 60_000.0, 6).unwrap();
852 assert_eq!(sats, 1_666);
853 }
854
855 #[test_all]
856 fn convert_destination_amount_to_sats_zero_passes_through() {
857 let sats = convert_destination_amount_to_sats(0, 60_000.0, 6).unwrap();
858 assert_eq!(sats, 0);
859 }
860
861 #[test_all]
862 fn convert_destination_amount_to_sats_rejects_non_positive_rate() {
863 let err = convert_destination_amount_to_sats(1_000_000, 0.0, 6).unwrap_err();
864 assert!(matches!(err, SdkError::Generic(ref m) if m.contains("invalid BTC/USD rate")));
865 let err = convert_destination_amount_to_sats(1_000_000, f64::NAN, 6).unwrap_err();
866 assert!(matches!(err, SdkError::Generic(_)));
867 }
868
869 #[test_all]
870 fn rescale_decimals_scales_down_when_dest_decimals_lower() {
871 assert_eq!(rescale_decimals(100_000_000, 8, 6).unwrap(), 1_000_000);
872 }
873
874 #[test_all]
875 fn rescale_decimals_same_decimals_is_identity() {
876 assert_eq!(rescale_decimals(123_456_789, 6, 6).unwrap(), 123_456_789);
877 }
878
879 #[test_all]
880 fn rescale_decimals_scales_up_when_dest_decimals_higher() {
881 assert_eq!(rescale_decimals(1_000_000, 6, 8).unwrap(), 100_000_000);
882 }
883
884 #[test_all]
885 fn rescale_decimals_zero_passes_through() {
886 assert_eq!(rescale_decimals(0, 8, 6).unwrap(), 0);
887 assert_eq!(rescale_decimals(0, 6, 8).unwrap(), 0);
888 }
889
890 #[test_all]
891 fn is_usd_stable_asset_recognizes_known_stables() {
892 for ticker in ["USDB", "USDC", "USDT", "USDT0", "usdb", "uSdC"] {
893 assert!(is_usd_stable_asset(ticker), "{ticker} should be stable");
894 }
895 }
896
897 #[test_all]
898 fn is_usd_stable_asset_rejects_btc_and_unknown() {
899 for ticker in ["BTC", "ETH", "DAI", "", "USD"] {
900 assert!(
901 !is_usd_stable_asset(ticker),
902 "{ticker} should not be a recognized USD-stable"
903 );
904 }
905 }
906
907 #[test_all]
910 fn compute_terminal_fee_overwrites_estimate_on_completed() {
911 let realized = compute_terminal_fee_amount(
912 &crate::ConversionStatus::Completed,
913 Some(1_020_434), Some(997_498), Some(20_434), );
917 assert_eq!(realized, Some(22_936), "= asset_amount_in − delivered");
918 }
919
920 #[test_all]
921 fn compute_terminal_fee_keeps_estimate_on_refunded() {
922 let realized = compute_terminal_fee_amount(
926 &crate::ConversionStatus::Refunded,
927 Some(1_020_434),
928 None,
929 Some(20_434),
930 );
931 assert_eq!(realized, Some(20_434));
932 }
933
934 #[test_all]
935 fn compute_terminal_fee_keeps_estimate_on_failed() {
936 let realized = compute_terminal_fee_amount(
937 &crate::ConversionStatus::Failed,
938 Some(1_020_434),
939 None,
940 Some(20_434),
941 );
942 assert_eq!(realized, Some(20_434));
943 }
944
945 #[test_all]
946 fn compute_terminal_fee_keeps_estimate_when_asset_amount_in_missing() {
947 let realized = compute_terminal_fee_amount(
950 &crate::ConversionStatus::Completed,
951 None, Some(997_498),
953 Some(20_434),
954 );
955 assert_eq!(realized, Some(20_434));
956 }
957
958 #[test_all]
959 fn compute_terminal_fee_keeps_estimate_when_delivered_amount_missing() {
960 let realized = compute_terminal_fee_amount(
963 &crate::ConversionStatus::Completed,
964 Some(1_020_434),
965 None, Some(20_434),
967 );
968 assert_eq!(realized, Some(20_434));
969 }
970
971 #[test_all]
972 fn compute_terminal_fee_saturating_sub_on_over_delivery() {
973 let realized = compute_terminal_fee_amount(
975 &crate::ConversionStatus::Completed,
976 Some(1_000_000),
977 Some(1_005_000),
978 Some(0),
979 );
980 assert_eq!(
981 realized,
982 Some(0),
983 "saturating_sub must clamp at 0, not underflow"
984 );
985 }
986
987 #[test_all]
997 fn boltz_provider_context_invoice_amount_sats_is_independent_of_amount_in() {
998 let ctx = CrossChainProviderContext::Boltz {
999 swap_id: "swap_1".to_string(),
1000 invoice: "lnbc19090n1pexample".to_string(),
1001 invoice_amount_sats: 1_909,
1002 max_slippage_bps: 100,
1003 };
1004 let json = serde_json::to_string(&ctx).unwrap();
1005 let decoded: CrossChainProviderContext = serde_json::from_str(&json).unwrap();
1006 let CrossChainProviderContext::Boltz {
1007 invoice_amount_sats,
1008 ..
1009 } = &decoded
1010 else {
1011 panic!("expected Boltz variant");
1012 };
1013 assert_eq!(*invoice_amount_sats, 1_909);
1014 assert!(
1015 *invoice_amount_sats != 1_222_703,
1016 "the LN invoice sats must never be conflated with a user-facing display value (e.g. USDB base units)"
1017 );
1018 }
1019
1020 #[test_all]
1025 fn boltz_provider_context_legacy_row_without_invoice_amount_sats_defaults_to_zero() {
1026 let legacy = r#"{
1027 "Boltz": {
1028 "swap_id": "swap_legacy",
1029 "invoice": "lnbc19090n1p",
1030 "max_slippage_bps": 100
1031 }
1032 }"#;
1033 let decoded: CrossChainProviderContext = serde_json::from_str(legacy).unwrap();
1034 let CrossChainProviderContext::Boltz {
1035 invoice_amount_sats,
1036 ..
1037 } = &decoded
1038 else {
1039 panic!("expected Boltz variant");
1040 };
1041 assert_eq!(*invoice_amount_sats, 0);
1042 }
1043
1044 #[test_all]
1048 fn orchestra_provider_context_deposit_amount_is_independent_of_amount_in() {
1049 let ctx = CrossChainProviderContext::Orchestra {
1050 quote_id: "q_1".to_string(),
1051 deposit_address: "spark1...".to_string(),
1052 deposit_amount: 1_020_434,
1053 };
1054 let json = serde_json::to_string(&ctx).unwrap();
1055 let decoded: CrossChainProviderContext = serde_json::from_str(&json).unwrap();
1056 let CrossChainProviderContext::Orchestra { deposit_amount, .. } = &decoded else {
1057 panic!("expected Orchestra variant");
1058 };
1059 assert_eq!(*deposit_amount, 1_020_434);
1060 }
1061
1062 fn boltz_info(swap_id: &str) -> ConversionInfo {
1063 ConversionInfo::Boltz {
1064 swap_id: swap_id.to_string(),
1065 invoice: "lnbc1".to_string(),
1066 invoice_amount_sats: 1_000,
1067 bridge_ref: None,
1068 max_slippage_bps: 100,
1069 quote_degraded: false,
1070 chain: "polygon".to_string(),
1071 chain_id: None,
1072 asset: "USDC".to_string(),
1073 recipient_address: "0xabc".to_string(),
1074 estimated_out: 1_000_000,
1075 delivered_amount: None,
1076 status: crate::ConversionStatus::Pending,
1077 asset_amount_in: None,
1078 fee_amount: None,
1079 service_fee_amount: None,
1080 service_fee_asset: None,
1081 asset_decimals: 6,
1082 asset_contract: None,
1083 }
1084 }
1085
1086 #[test_all]
1087 fn payment_with_conversion_info_injects_into_lightning_details() {
1088 let payment = crate::Payment {
1089 id: "p1".to_string(),
1090 payment_type: crate::PaymentType::Send,
1091 status: crate::PaymentStatus::Pending,
1092 amount: 1_000,
1093 fees: 0,
1094 timestamp: 100,
1095 method: crate::PaymentMethod::Lightning,
1096 details: Some(PaymentDetails::Lightning {
1097 description: Some("desc".to_string()),
1098 invoice: "lnbc1".to_string(),
1099 destination_pubkey: "02aa".to_string(),
1100 htlc_details: crate::SparkHtlcDetails {
1101 payment_hash: "hash1".to_string(),
1102 preimage: None,
1103 expiry_time: 0,
1104 status: crate::SparkHtlcStatus::PreimageShared,
1105 },
1106 lnurl_pay_info: None,
1107 lnurl_withdraw_info: None,
1108 lnurl_receive_metadata: None,
1109 conversion_info: None,
1110 }),
1111 conversion_details: None,
1112 };
1113
1114 let out = payment_with_conversion_info(payment, Some(boltz_info("swap1")));
1115
1116 assert_eq!(out.status, crate::PaymentStatus::Pending);
1117 let Some(PaymentDetails::Lightning {
1118 invoice,
1119 description,
1120 conversion_info,
1121 ..
1122 }) = out.details
1123 else {
1124 panic!("expected Lightning details");
1125 };
1126 assert_eq!(invoice, "lnbc1");
1128 assert_eq!(description.as_deref(), Some("desc"));
1129 assert!(matches!(
1130 conversion_info,
1131 Some(ConversionInfo::Boltz { ref swap_id, .. }) if swap_id == "swap1"
1132 ));
1133 }
1134
1135 #[test_all]
1136 fn payment_with_conversion_info_passes_through_variants_without_a_slot() {
1137 let payment = crate::Payment {
1138 id: "p1".to_string(),
1139 payment_type: crate::PaymentType::Send,
1140 status: crate::PaymentStatus::Completed,
1141 amount: 1_000,
1142 fees: 0,
1143 timestamp: 100,
1144 method: crate::PaymentMethod::Withdraw,
1145 details: Some(PaymentDetails::Withdraw {
1146 tx_id: "tx1".to_string(),
1147 }),
1148 conversion_details: None,
1149 };
1150
1151 let out = payment_with_conversion_info(payment, Some(boltz_info("swap1")));
1152
1153 assert!(matches!(
1154 out.details,
1155 Some(PaymentDetails::Withdraw { tx_id }) if tx_id == "tx1"
1156 ));
1157 }
1158
1159 #[test_all]
1162 fn resolve_target_overpay_uses_request_when_in_range() {
1163 assert_eq!(resolve_target_overpay_bps(Some(50), Some(75)).unwrap(), 50);
1164 }
1165
1166 #[test_all]
1167 fn resolve_target_overpay_falls_back_to_config_default() {
1168 assert_eq!(resolve_target_overpay_bps(None, Some(75)).unwrap(), 75);
1169 }
1170
1171 #[test_all]
1172 fn resolve_target_overpay_falls_back_to_built_in_default() {
1173 assert_eq!(
1174 resolve_target_overpay_bps(None, None).unwrap(),
1175 DEFAULT_TARGET_OVERPAY_BPS
1176 );
1177 }
1178
1179 #[test_all]
1180 fn resolve_target_overpay_request_zero_opts_out() {
1181 assert_eq!(resolve_target_overpay_bps(Some(0), Some(50)).unwrap(), 0);
1182 }
1183
1184 #[test_all]
1185 fn resolve_target_overpay_rejects_out_of_range_request() {
1186 let too_high = MAX_TARGET_OVERPAY_BPS + 1;
1187 assert!(matches!(
1188 resolve_target_overpay_bps(Some(too_high), None),
1189 Err(SdkError::InvalidInput(_))
1190 ));
1191 }
1192
1193 #[test_all]
1196 fn inflate_target_amount_zero_bps_is_identity() {
1197 assert_eq!(inflate_target_amount(1_000_000, 0), 1_000_000);
1198 }
1199
1200 #[test_all]
1201 fn inflate_target_amount_applies_bps_pad() {
1202 assert_eq!(inflate_target_amount(1_000_000, 25), 1_002_500);
1204 }
1205
1206 #[test_all]
1207 fn inflate_target_amount_truncates_sub_unit_pad() {
1208 assert_eq!(inflate_target_amount(100, 25), 100);
1210 }
1211
1212 #[test_all]
1215 fn convert_stable_to_sats_at_par_6dp() {
1216 assert_eq!(
1218 convert_source_amount_to_sats(1_000_000, 6, 100_000.0).unwrap(),
1219 1000
1220 );
1221 }
1222
1223 #[test_all]
1224 fn convert_stable_to_sats_at_a_different_rate() {
1225 assert_eq!(
1227 convert_source_amount_to_sats(1_000_000, 6, 50_000.0).unwrap(),
1228 2000
1229 );
1230 }
1231
1232 #[test_all]
1233 fn convert_stable_to_sats_matches_across_decimals_at_same_usd_value() {
1234 let sats_6 = convert_source_amount_to_sats(1_000_000, 6, 100_000.0).unwrap();
1236 let sats_18 =
1237 convert_source_amount_to_sats(1_000_000_000_000_000_000, 18, 100_000.0).unwrap();
1238 assert_eq!(sats_6, sats_18);
1239 assert_eq!(sats_6, 1000);
1240 }
1241
1242 #[test_all]
1243 fn convert_stable_to_sats_zero_input_is_zero() {
1244 assert_eq!(convert_source_amount_to_sats(0, 6, 100_000.0).unwrap(), 0);
1245 }
1246
1247 #[test_all]
1248 fn convert_stable_to_sats_rejects_nan_rate() {
1249 assert!(matches!(
1252 convert_source_amount_to_sats(1_000_000, 6, f64::NAN),
1253 Err(SdkError::Generic(_))
1254 ));
1255 }
1256
1257 #[test_all]
1258 fn convert_stable_to_sats_rejects_negative_rate() {
1259 assert!(matches!(
1260 convert_source_amount_to_sats(1_000_000, 6, -100_000.0),
1261 Err(SdkError::Generic(_))
1262 ));
1263 }
1264
1265 #[test_all]
1270 fn build_receive_payment_request_evm_emits_eip_681_uri() {
1271 let uri = build_receive_payment_request(
1272 "0x00Df20df75800ca8f40080505a7a802331C1321c",
1273 "arbitrum",
1274 Some("42161"),
1275 Some("0xaf88d065e77c8cC2239327C5EDb3A432268e5831"),
1276 1_000_000,
1277 )
1278 .unwrap();
1279 assert!(uri.starts_with("ethereum:"), "got {uri}");
1280 assert!(uri.contains("42161"), "chain_id must appear: {uri}");
1281 assert!(
1282 uri.contains("0xaf88d065e77c8cC2239327C5EDb3A432268e5831"),
1283 "token contract must appear: {uri}"
1284 );
1285 assert!(uri.contains("1000000"), "amount must appear: {uri}");
1286 }
1287
1288 #[test_all]
1291 fn build_receive_payment_request_solana_returns_bare_address() {
1292 let addr = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM";
1293 let out = build_receive_payment_request(addr, "solana", None, None, 1_000_000).unwrap();
1294 assert_eq!(out, addr);
1295 }
1296
1297 #[test_all]
1299 fn build_receive_payment_request_tron_returns_bare_address() {
1300 let addr = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
1301 let out = build_receive_payment_request(addr, "tron", None, None, 1_000_000).unwrap();
1302 assert_eq!(out, addr);
1303 }
1304
1305 #[test_all]
1308 fn build_receive_payment_request_rejects_unrecognized_address() {
1309 let err =
1310 build_receive_payment_request("not-an-address", "arbitrum", None, None, 1_000_000)
1311 .unwrap_err();
1312 assert!(matches!(err, SdkError::Generic(_)));
1313 }
1314
1315 #[test_all]
1319 fn build_receive_payment_request_rejects_family_chain_mismatch() {
1320 let evm_addr = "0x00Df20df75800ca8f40080505a7a802331C1321c";
1321 let err =
1322 build_receive_payment_request(evm_addr, "solana", None, None, 1_000_000).unwrap_err();
1323 match err {
1324 SdkError::Generic(msg) => {
1325 assert!(
1326 msg.contains("Evm") && msg.contains("solana"),
1327 "unexpected message: {msg}"
1328 );
1329 }
1330 other => panic!("expected Generic mismatch error, got {other:?}"),
1331 }
1332
1333 let solana_addr = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM";
1334 assert!(
1335 build_receive_payment_request(solana_addr, "tron", None, None, 1_000_000).is_err(),
1336 "solana address must not be accepted for tron route",
1337 );
1338 }
1339}