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::{CrossChainAddressDetails, 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 derive_btc_leg_transfer_id(
61 idempotency_key: Option<&str>,
62 fallback_seed: &str,
63) -> Result<TransferId, SdkError> {
64 match idempotency_key {
65 Some(key) => TransferId::from_str(key).map_err(SdkError::Generic),
66 None => Ok(TransferId::from_name(fallback_seed)),
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
71#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
72pub enum CrossChainProvider {
73 Orchestra,
74 Boltz,
75}
76
77impl std::fmt::Display for CrossChainProvider {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 match self {
80 Self::Orchestra => f.write_str("Orchestra"),
81 Self::Boltz => f.write_str("Boltz"),
82 }
83 }
84}
85
86#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
88#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
89pub enum SourceAsset {
90 Bitcoin,
92 Token { token_identifier: String },
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
104pub enum CrossChainFeeMode {
105 FeesExcluded,
106 FeesIncluded,
107}
108
109impl From<crate::FeePolicy> for CrossChainFeeMode {
110 fn from(policy: crate::FeePolicy) -> Self {
111 match policy {
112 crate::FeePolicy::FeesExcluded => Self::FeesExcluded,
113 crate::FeePolicy::FeesIncluded => Self::FeesIncluded,
114 }
115 }
116}
117
118#[derive(Clone, Debug, Deserialize, Serialize)]
121#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
122pub enum CrossChainRouteFilter {
123 Send {
126 address_details: CrossChainAddressDetails,
127 },
128 Receive { contract_address: Option<String> },
131}
132
133#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
136#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
137pub struct CrossChainRoutePair {
138 pub provider: CrossChainProvider,
140 pub chain: String,
142 pub chain_id: Option<String>,
146 pub asset: String,
148 pub contract_address: Option<String>,
150 pub decimals: u8,
152 pub exact_out_eligible: bool,
154 pub supported_sources: Vec<SourceAsset>,
160}
161
162impl CrossChainRoutePair {
163 pub(crate) fn destination_address_family(
168 &self,
169 ) -> Option<breez_sdk_common::input::CrossChainAddressFamily> {
170 self.contract_address
171 .as_deref()
172 .and_then(breez_sdk_common::input::detect_address_family)
173 }
174}
175
176#[derive(Clone)]
180pub(crate) struct CrossChainContext {
181 providers: HashMap<CrossChainProvider, Arc<dyn CrossChainService>>,
182 fiat_service: Arc<dyn FiatService>,
183}
184
185impl CrossChainContext {
186 pub fn new(fiat_service: Arc<dyn FiatService>) -> Self {
187 Self {
188 providers: HashMap::new(),
189 fiat_service,
190 }
191 }
192
193 pub fn insert(&mut self, key: CrossChainProvider, service: Arc<dyn CrossChainService>) {
194 self.providers.insert(key, service);
195 }
196
197 pub fn get(
199 &self,
200 provider: CrossChainProvider,
201 ) -> Result<&Arc<dyn CrossChainService>, SdkError> {
202 self.providers.get(&provider).ok_or_else(|| {
203 SdkError::InvalidInput(format!("Cross-chain provider {provider} is not available."))
204 })
205 }
206
207 pub fn values(&self) -> impl Iterator<Item = &Arc<dyn CrossChainService>> {
208 self.providers.values()
209 }
210
211 pub fn fiat_service(&self) -> &Arc<dyn FiatService> {
214 &self.fiat_service
215 }
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
222#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
223pub enum CrossChainProviderContext {
224 Orchestra {
225 quote_id: String,
227 deposit_address: String,
229 #[serde(default)]
231 deposit_amount: u128,
232 },
233 Boltz {
234 swap_id: String,
236 invoice: String,
238 #[serde(default)]
240 invoice_amount_sats: u64,
241 max_slippage_bps: u32,
243 },
244}
245
246#[derive(Debug, Clone)]
249pub(crate) struct CrossChainPrepared {
250 pub amount_in: u128,
251 pub asset_amount_in: u128,
255 pub estimated_out: u128,
257 pub fee_amount: u128,
263 pub service_fee_amount: u128,
265 pub service_fee_asset: Option<String>,
267 pub source_transfer_fee_sats: u64,
277 pub fee_mode: CrossChainFeeMode,
280 pub expires_at: String,
281 pub pair: CrossChainRoutePair,
282 pub recipient_address: String,
283 pub token_identifier: Option<String>,
285 pub provider_context: CrossChainProviderContext,
287}
288
289#[macros::async_trait]
294pub(crate) trait CrossChainService: Send + Sync {
295 async fn get_routes(
301 &self,
302 filter: &CrossChainRouteFilter,
303 ) -> Result<Vec<CrossChainRoutePair>, SdkError>;
304
305 async fn prepare(
307 &self,
308 recipient_address: &str,
309 route: &CrossChainRoutePair,
310 amount: u128,
311 source_token_identifier: Option<String>,
312 max_slippage_bps: u32,
313 fee_mode: CrossChainFeeMode,
314 ) -> Result<CrossChainPrepared, SdkError>;
315
316 async fn send(
332 &self,
333 prepared: &CrossChainPrepared,
334 idempotency_key: Option<String>,
335 ) -> Result<crate::Payment, SdkError>;
336}
337
338pub(crate) async fn fetch_btc_usd_rate(fiat: &dyn FiatService) -> Result<f64, SdkError> {
341 let rates = fiat
342 .fetch_fiat_rates()
343 .await
344 .map_err(|e| SdkError::Generic(format!("Cross-chain: failed to fetch fiat rates: {e}")))?;
345 let btc_usd = rates
346 .iter()
347 .find(|r| r.coin.eq_ignore_ascii_case("USD"))
348 .map(|r| r.value)
349 .ok_or_else(|| {
350 SdkError::Generic("Cross-chain: BTC/USD rate not found in feed".to_string())
351 })?;
352 if !btc_usd.is_finite() || btc_usd <= 0.0 {
353 return Err(SdkError::Generic(format!(
354 "Cross-chain: invalid BTC/USD rate from feed: {btc_usd}"
355 )));
356 }
357 Ok(btc_usd)
358}
359
360#[allow(
363 clippy::cast_precision_loss,
364 clippy::cast_possible_truncation,
365 clippy::cast_sign_loss
366)]
367pub(crate) fn convert_sats_to_destination_amount(
368 sats: u128,
369 fiat_rate: f64,
370 dest_decimals: u32,
371) -> Result<u128, SdkError> {
372 let dest_scale = 10f64.powi(i32::try_from(dest_decimals).unwrap_or(i32::MAX));
373 let target = (sats as f64) * fiat_rate * dest_scale / 100_000_000f64;
374 if !target.is_finite() || target < 0.0 {
375 return Err(SdkError::Generic(format!(
376 "Cross-chain: invalid sats→dest conversion result: {target}"
377 )));
378 }
379 Ok(target as u128)
380}
381
382pub(crate) fn is_usd_stable_asset(asset: &str) -> bool {
383 USD_STABLE_ASSETS
384 .iter()
385 .any(|a| asset.eq_ignore_ascii_case(a))
386}
387
388pub(crate) fn compute_terminal_fee_amount(
392 new_status: &crate::ConversionStatus,
393 asset_amount_in: Option<u128>,
394 delivered_amount: Option<u128>,
395 prepare_estimate: Option<u128>,
396) -> Option<u128> {
397 match (new_status, asset_amount_in, delivered_amount) {
398 (crate::ConversionStatus::Completed, Some(a), Some(d)) => Some(a.saturating_sub(d)),
399 _ => prepare_estimate,
400 }
401}
402
403pub(crate) fn rescale_decimals(
407 amount: u128,
408 src_decimals: u32,
409 dest_decimals: u32,
410) -> Result<u128, SdkError> {
411 if dest_decimals >= src_decimals {
412 let delta = dest_decimals.saturating_sub(src_decimals);
413 let factor = 10u128
414 .checked_pow(delta)
415 .ok_or_else(|| SdkError::Generic("Cross-chain: decimal scale overflow".to_string()))?;
416 amount
417 .checked_mul(factor)
418 .ok_or_else(|| SdkError::Generic("Cross-chain: decimal rescale overflow".to_string()))
419 } else {
420 let delta = src_decimals.saturating_sub(dest_decimals);
421 let factor = 10u128
422 .checked_pow(delta)
423 .ok_or_else(|| SdkError::Generic("Cross-chain: decimal scale overflow".to_string()))?;
424 amount.checked_div(factor).ok_or_else(|| {
425 SdkError::Generic("Cross-chain: decimal rescale divisor zero".to_string())
426 })
427 }
428}
429
430#[allow(
434 clippy::cast_precision_loss,
435 clippy::cast_possible_truncation,
436 clippy::cast_sign_loss
437)]
438pub(crate) fn convert_destination_amount_to_sats(
439 destination_amount: u128,
440 fiat_rate: f64,
441 dest_decimals: u32,
442) -> Result<u128, SdkError> {
443 if !fiat_rate.is_finite() || fiat_rate <= 0.0 {
444 return Err(SdkError::Generic(format!(
445 "Cross-chain: invalid BTC/USD rate for inversion: {fiat_rate}"
446 )));
447 }
448 let dest_scale = 10f64.powi(i32::try_from(dest_decimals).unwrap_or(i32::MAX));
449 let sats = (destination_amount as f64) * 100_000_000f64 / (fiat_rate * dest_scale);
450 if !sats.is_finite() || sats < 0.0 {
451 return Err(SdkError::Generic(format!(
452 "Cross-chain: invalid dest→sats conversion result: {sats}"
453 )));
454 }
455 Ok(sats as u128)
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461 use macros::test_all;
462
463 #[cfg(feature = "browser-tests")]
464 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
465
466 #[test_all]
467 fn derive_btc_leg_transfer_id_uses_caller_key() {
468 let key = "00000000-0000-4000-8000-000000000001";
471 let id = derive_btc_leg_transfer_id(Some(key), "ignored-seed").unwrap();
472 assert_eq!(id.to_string(), key);
473 }
474
475 #[test_all]
476 fn derive_btc_leg_transfer_id_deterministic_from_seed() {
477 let a = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
478 let b = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
479 assert_eq!(
480 a, b,
481 "same seed must produce the same TransferId across calls"
482 );
483 }
484
485 #[test_all]
486 fn derive_btc_leg_transfer_id_distinct_seeds_yield_distinct_ids() {
487 let a = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
488 let b = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-2").unwrap();
489 assert_ne!(a, b);
490 }
491
492 #[test_all]
493 fn derive_btc_leg_transfer_id_orchestra_and_boltz_seeds_collide_only_on_id() {
494 let orchestra = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:abc").unwrap();
497 let boltz = derive_btc_leg_transfer_id(None, "cross_chain:boltz:abc").unwrap();
498 assert_ne!(orchestra, boltz);
499 }
500
501 #[test_all]
502 fn derive_btc_leg_transfer_id_rejects_invalid_caller_key() {
503 let err = derive_btc_leg_transfer_id(Some("not-a-uuid"), "fallback").unwrap_err();
504 assert!(matches!(err, SdkError::Generic(_)));
505 }
506
507 #[test_all]
508 fn convert_sats_to_destination_amount_round_trip_inverts_to_sats() {
509 let dest = convert_sats_to_destination_amount(10_000, 60_000.0, 6).unwrap();
511 assert_eq!(dest, 6_000_000);
512 let sats = convert_destination_amount_to_sats(dest, 60_000.0, 6).unwrap();
514 assert_eq!(sats, 10_000);
515 }
516
517 #[test_all]
518 fn convert_destination_amount_to_sats_typical_stable() {
519 let sats = convert_destination_amount_to_sats(1_000_000, 60_000.0, 6).unwrap();
521 assert_eq!(sats, 1_666);
522 }
523
524 #[test_all]
525 fn convert_destination_amount_to_sats_zero_passes_through() {
526 let sats = convert_destination_amount_to_sats(0, 60_000.0, 6).unwrap();
527 assert_eq!(sats, 0);
528 }
529
530 #[test_all]
531 fn convert_destination_amount_to_sats_rejects_non_positive_rate() {
532 let err = convert_destination_amount_to_sats(1_000_000, 0.0, 6).unwrap_err();
533 assert!(matches!(err, SdkError::Generic(ref m) if m.contains("invalid BTC/USD rate")));
534 let err = convert_destination_amount_to_sats(1_000_000, f64::NAN, 6).unwrap_err();
535 assert!(matches!(err, SdkError::Generic(_)));
536 }
537
538 #[test_all]
539 fn rescale_decimals_scales_down_when_dest_decimals_lower() {
540 assert_eq!(rescale_decimals(100_000_000, 8, 6).unwrap(), 1_000_000);
541 }
542
543 #[test_all]
544 fn rescale_decimals_same_decimals_is_identity() {
545 assert_eq!(rescale_decimals(123_456_789, 6, 6).unwrap(), 123_456_789);
546 }
547
548 #[test_all]
549 fn rescale_decimals_scales_up_when_dest_decimals_higher() {
550 assert_eq!(rescale_decimals(1_000_000, 6, 8).unwrap(), 100_000_000);
551 }
552
553 #[test_all]
554 fn rescale_decimals_zero_passes_through() {
555 assert_eq!(rescale_decimals(0, 8, 6).unwrap(), 0);
556 assert_eq!(rescale_decimals(0, 6, 8).unwrap(), 0);
557 }
558
559 #[test_all]
560 fn is_usd_stable_asset_recognizes_known_stables() {
561 for ticker in ["USDB", "USDC", "USDT", "USDT0", "usdb", "uSdC"] {
562 assert!(is_usd_stable_asset(ticker), "{ticker} should be stable");
563 }
564 }
565
566 #[test_all]
567 fn is_usd_stable_asset_rejects_btc_and_unknown() {
568 for ticker in ["BTC", "ETH", "DAI", "", "USD"] {
569 assert!(
570 !is_usd_stable_asset(ticker),
571 "{ticker} should not be a recognized USD-stable"
572 );
573 }
574 }
575
576 #[test_all]
579 fn compute_terminal_fee_overwrites_estimate_on_completed() {
580 let realized = compute_terminal_fee_amount(
581 &crate::ConversionStatus::Completed,
582 Some(1_020_434), Some(997_498), Some(20_434), );
586 assert_eq!(realized, Some(22_936), "= asset_amount_in − delivered");
587 }
588
589 #[test_all]
590 fn compute_terminal_fee_keeps_estimate_on_refunded() {
591 let realized = compute_terminal_fee_amount(
595 &crate::ConversionStatus::Refunded,
596 Some(1_020_434),
597 None,
598 Some(20_434),
599 );
600 assert_eq!(realized, Some(20_434));
601 }
602
603 #[test_all]
604 fn compute_terminal_fee_keeps_estimate_on_failed() {
605 let realized = compute_terminal_fee_amount(
606 &crate::ConversionStatus::Failed,
607 Some(1_020_434),
608 None,
609 Some(20_434),
610 );
611 assert_eq!(realized, Some(20_434));
612 }
613
614 #[test_all]
615 fn compute_terminal_fee_keeps_estimate_when_asset_amount_in_missing() {
616 let realized = compute_terminal_fee_amount(
619 &crate::ConversionStatus::Completed,
620 None, Some(997_498),
622 Some(20_434),
623 );
624 assert_eq!(realized, Some(20_434));
625 }
626
627 #[test_all]
628 fn compute_terminal_fee_keeps_estimate_when_delivered_amount_missing() {
629 let realized = compute_terminal_fee_amount(
632 &crate::ConversionStatus::Completed,
633 Some(1_020_434),
634 None, Some(20_434),
636 );
637 assert_eq!(realized, Some(20_434));
638 }
639
640 #[test_all]
641 fn compute_terminal_fee_saturating_sub_on_over_delivery() {
642 let realized = compute_terminal_fee_amount(
644 &crate::ConversionStatus::Completed,
645 Some(1_000_000),
646 Some(1_005_000),
647 Some(0),
648 );
649 assert_eq!(
650 realized,
651 Some(0),
652 "saturating_sub must clamp at 0, not underflow"
653 );
654 }
655
656 #[test_all]
666 fn boltz_provider_context_invoice_amount_sats_is_independent_of_amount_in() {
667 let ctx = CrossChainProviderContext::Boltz {
668 swap_id: "swap_1".to_string(),
669 invoice: "lnbc19090n1pexample".to_string(),
670 invoice_amount_sats: 1_909,
671 max_slippage_bps: 100,
672 };
673 let json = serde_json::to_string(&ctx).unwrap();
674 let decoded: CrossChainProviderContext = serde_json::from_str(&json).unwrap();
675 let CrossChainProviderContext::Boltz {
676 invoice_amount_sats,
677 ..
678 } = &decoded
679 else {
680 panic!("expected Boltz variant");
681 };
682 assert_eq!(*invoice_amount_sats, 1_909);
683 assert!(
684 *invoice_amount_sats != 1_222_703,
685 "the LN invoice sats must never be conflated with a user-facing display value (e.g. USDB base units)"
686 );
687 }
688
689 #[test_all]
694 fn boltz_provider_context_legacy_row_without_invoice_amount_sats_defaults_to_zero() {
695 let legacy = r#"{
696 "Boltz": {
697 "swap_id": "swap_legacy",
698 "invoice": "lnbc19090n1p",
699 "max_slippage_bps": 100
700 }
701 }"#;
702 let decoded: CrossChainProviderContext = serde_json::from_str(legacy).unwrap();
703 let CrossChainProviderContext::Boltz {
704 invoice_amount_sats,
705 ..
706 } = &decoded
707 else {
708 panic!("expected Boltz variant");
709 };
710 assert_eq!(*invoice_amount_sats, 0);
711 }
712
713 #[test_all]
717 fn orchestra_provider_context_deposit_amount_is_independent_of_amount_in() {
718 let ctx = CrossChainProviderContext::Orchestra {
719 quote_id: "q_1".to_string(),
720 deposit_address: "spark1...".to_string(),
721 deposit_amount: 1_020_434,
722 };
723 let json = serde_json::to_string(&ctx).unwrap();
724 let decoded: CrossChainProviderContext = serde_json::from_str(&json).unwrap();
725 let CrossChainProviderContext::Orchestra { deposit_amount, .. } = &decoded else {
726 panic!("expected Orchestra variant");
727 };
728 assert_eq!(*deposit_amount, 1_020_434);
729 }
730}