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;
19
20use breez_sdk_common::fiat::FiatService;
21use serde::{Deserialize, Serialize};
22use spark_wallet::TransferId;
23
24use crate::{CrossChainAddressDetails, error::SdkError};
25
26pub(crate) const MIN_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 10;
28pub(crate) const MAX_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 500;
29pub(crate) const DEFAULT_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 100;
32
33pub(crate) const MIN_TARGET_OVERPAY_BPS: u32 = 0;
37pub(crate) const MAX_TARGET_OVERPAY_BPS: u32 = 500;
38pub(crate) const DEFAULT_TARGET_OVERPAY_BPS: u32 = 15;
43const USD_STABLE_ASSETS: &[&str] = &["USDB", "USDC", "USDT", "USDT0"];
46
47pub(crate) fn derive_btc_leg_transfer_id(
57 idempotency_key: Option<&str>,
58 fallback_seed: &str,
59) -> Result<TransferId, SdkError> {
60 match idempotency_key {
61 Some(key) => TransferId::from_str(key).map_err(SdkError::Generic),
62 None => Ok(TransferId::from_name(fallback_seed)),
63 }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
67#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
68pub enum CrossChainProvider {
69 Orchestra,
70 Boltz,
71}
72
73impl std::fmt::Display for CrossChainProvider {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 match self {
76 Self::Orchestra => f.write_str("Orchestra"),
77 Self::Boltz => f.write_str("Boltz"),
78 }
79 }
80}
81
82#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
84#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
85pub enum SourceAsset {
86 Bitcoin,
88 Token { token_identifier: String },
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
100pub enum CrossChainFeeMode {
101 FeesExcluded,
102 FeesIncluded,
103}
104
105impl From<crate::FeePolicy> for CrossChainFeeMode {
106 fn from(policy: crate::FeePolicy) -> Self {
107 match policy {
108 crate::FeePolicy::FeesExcluded => Self::FeesExcluded,
109 crate::FeePolicy::FeesIncluded => Self::FeesIncluded,
110 }
111 }
112}
113
114#[derive(Clone, Debug, Deserialize, Serialize)]
117#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
118pub enum CrossChainRouteFilter {
119 Send {
122 address_details: CrossChainAddressDetails,
123 },
124 Receive { contract_address: Option<String> },
127}
128
129#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
132#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
133pub struct CrossChainRoutePair {
134 pub provider: CrossChainProvider,
136 pub chain: String,
138 pub chain_id: Option<String>,
142 pub asset: String,
144 pub contract_address: Option<String>,
146 pub decimals: u8,
148 pub exact_out_eligible: bool,
150 pub supported_sources: Vec<SourceAsset>,
156}
157
158impl CrossChainRoutePair {
159 pub(crate) fn destination_address_family(
164 &self,
165 ) -> Option<breez_sdk_common::input::CrossChainAddressFamily> {
166 self.contract_address
167 .as_deref()
168 .and_then(breez_sdk_common::input::detect_address_family)
169 }
170}
171
172#[derive(Clone)]
176pub(crate) struct CrossChainContext {
177 providers: HashMap<CrossChainProvider, Arc<dyn CrossChainService>>,
178 fiat_service: Arc<dyn FiatService>,
179}
180
181impl CrossChainContext {
182 pub fn new(fiat_service: Arc<dyn FiatService>) -> Self {
183 Self {
184 providers: HashMap::new(),
185 fiat_service,
186 }
187 }
188
189 pub fn insert(&mut self, key: CrossChainProvider, service: Arc<dyn CrossChainService>) {
190 self.providers.insert(key, service);
191 }
192
193 pub fn get(
195 &self,
196 provider: CrossChainProvider,
197 ) -> Result<&Arc<dyn CrossChainService>, SdkError> {
198 self.providers.get(&provider).ok_or_else(|| {
199 SdkError::InvalidInput(format!("Cross-chain provider {provider} is not available."))
200 })
201 }
202
203 pub fn values(&self) -> impl Iterator<Item = &Arc<dyn CrossChainService>> {
204 self.providers.values()
205 }
206
207 pub fn fiat_service(&self) -> &Arc<dyn FiatService> {
210 &self.fiat_service
211 }
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
218#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
219pub enum CrossChainProviderContext {
220 Orchestra {
221 quote_id: String,
223 deposit_address: String,
225 #[serde(default)]
227 deposit_amount: u128,
228 },
229 Boltz {
230 swap_id: String,
232 invoice: String,
234 #[serde(default)]
236 invoice_amount_sats: u64,
237 max_slippage_bps: u32,
239 },
240}
241
242#[derive(Debug, Clone)]
245pub(crate) struct CrossChainPrepared {
246 pub amount_in: u128,
247 pub asset_amount_in: u128,
251 pub estimated_out: u128,
253 pub fee_amount: u128,
259 pub service_fee_amount: u128,
261 pub service_fee_asset: Option<String>,
263 pub source_transfer_fee_sats: u64,
273 pub fee_mode: CrossChainFeeMode,
276 pub expires_at: String,
277 pub pair: CrossChainRoutePair,
278 pub recipient_address: String,
279 pub token_identifier: Option<String>,
281 pub provider_context: CrossChainProviderContext,
283}
284
285#[macros::async_trait]
290pub(crate) trait CrossChainService: Send + Sync {
291 async fn get_routes(
297 &self,
298 filter: &CrossChainRouteFilter,
299 ) -> Result<Vec<CrossChainRoutePair>, SdkError>;
300
301 async fn prepare(
303 &self,
304 recipient_address: &str,
305 route: &CrossChainRoutePair,
306 amount: u128,
307 source_token_identifier: Option<String>,
308 max_slippage_bps: u32,
309 fee_mode: CrossChainFeeMode,
310 ) -> Result<CrossChainPrepared, SdkError>;
311
312 async fn send(
328 &self,
329 prepared: &CrossChainPrepared,
330 idempotency_key: Option<String>,
331 ) -> Result<crate::Payment, SdkError>;
332}
333
334pub(crate) async fn fetch_btc_usd_rate(fiat: &dyn FiatService) -> Result<f64, SdkError> {
337 let rates = fiat
338 .fetch_fiat_rates()
339 .await
340 .map_err(|e| SdkError::Generic(format!("Cross-chain: failed to fetch fiat rates: {e}")))?;
341 let btc_usd = rates
342 .iter()
343 .find(|r| r.coin.eq_ignore_ascii_case("USD"))
344 .map(|r| r.value)
345 .ok_or_else(|| {
346 SdkError::Generic("Cross-chain: BTC/USD rate not found in feed".to_string())
347 })?;
348 if !btc_usd.is_finite() || btc_usd <= 0.0 {
349 return Err(SdkError::Generic(format!(
350 "Cross-chain: invalid BTC/USD rate from feed: {btc_usd}"
351 )));
352 }
353 Ok(btc_usd)
354}
355
356#[allow(
359 clippy::cast_precision_loss,
360 clippy::cast_possible_truncation,
361 clippy::cast_sign_loss
362)]
363pub(crate) fn convert_sats_to_destination_amount(
364 sats: u128,
365 fiat_rate: f64,
366 dest_decimals: u32,
367) -> Result<u128, SdkError> {
368 let dest_scale = 10f64.powi(i32::try_from(dest_decimals).unwrap_or(i32::MAX));
369 let target = (sats as f64) * fiat_rate * dest_scale / 100_000_000f64;
370 if !target.is_finite() || target < 0.0 {
371 return Err(SdkError::Generic(format!(
372 "Cross-chain: invalid sats→dest conversion result: {target}"
373 )));
374 }
375 Ok(target as u128)
376}
377
378pub(crate) fn is_usd_stable_asset(asset: &str) -> bool {
379 USD_STABLE_ASSETS
380 .iter()
381 .any(|a| asset.eq_ignore_ascii_case(a))
382}
383
384pub(crate) fn compute_terminal_fee_amount(
388 new_status: &crate::ConversionStatus,
389 asset_amount_in: Option<u128>,
390 delivered_amount: Option<u128>,
391 prepare_estimate: Option<u128>,
392) -> Option<u128> {
393 match (new_status, asset_amount_in, delivered_amount) {
394 (crate::ConversionStatus::Completed, Some(a), Some(d)) => Some(a.saturating_sub(d)),
395 _ => prepare_estimate,
396 }
397}
398
399pub(crate) fn rescale_decimals(
403 amount: u128,
404 src_decimals: u32,
405 dest_decimals: u32,
406) -> Result<u128, SdkError> {
407 if dest_decimals >= src_decimals {
408 let delta = dest_decimals.saturating_sub(src_decimals);
409 let factor = 10u128
410 .checked_pow(delta)
411 .ok_or_else(|| SdkError::Generic("Cross-chain: decimal scale overflow".to_string()))?;
412 amount
413 .checked_mul(factor)
414 .ok_or_else(|| SdkError::Generic("Cross-chain: decimal rescale overflow".to_string()))
415 } else {
416 let delta = src_decimals.saturating_sub(dest_decimals);
417 let factor = 10u128
418 .checked_pow(delta)
419 .ok_or_else(|| SdkError::Generic("Cross-chain: decimal scale overflow".to_string()))?;
420 amount.checked_div(factor).ok_or_else(|| {
421 SdkError::Generic("Cross-chain: decimal rescale divisor zero".to_string())
422 })
423 }
424}
425
426#[allow(
430 clippy::cast_precision_loss,
431 clippy::cast_possible_truncation,
432 clippy::cast_sign_loss
433)]
434pub(crate) fn convert_destination_amount_to_sats(
435 destination_amount: u128,
436 fiat_rate: f64,
437 dest_decimals: u32,
438) -> Result<u128, SdkError> {
439 if !fiat_rate.is_finite() || fiat_rate <= 0.0 {
440 return Err(SdkError::Generic(format!(
441 "Cross-chain: invalid BTC/USD rate for inversion: {fiat_rate}"
442 )));
443 }
444 let dest_scale = 10f64.powi(i32::try_from(dest_decimals).unwrap_or(i32::MAX));
445 let sats = (destination_amount as f64) * 100_000_000f64 / (fiat_rate * dest_scale);
446 if !sats.is_finite() || sats < 0.0 {
447 return Err(SdkError::Generic(format!(
448 "Cross-chain: invalid dest→sats conversion result: {sats}"
449 )));
450 }
451 Ok(sats as u128)
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use macros::test_all;
458
459 #[cfg(feature = "browser-tests")]
460 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
461
462 #[test_all]
463 fn derive_btc_leg_transfer_id_uses_caller_key() {
464 let key = "00000000-0000-4000-8000-000000000001";
467 let id = derive_btc_leg_transfer_id(Some(key), "ignored-seed").unwrap();
468 assert_eq!(id.to_string(), key);
469 }
470
471 #[test_all]
472 fn derive_btc_leg_transfer_id_deterministic_from_seed() {
473 let a = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
474 let b = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
475 assert_eq!(
476 a, b,
477 "same seed must produce the same TransferId across calls"
478 );
479 }
480
481 #[test_all]
482 fn derive_btc_leg_transfer_id_distinct_seeds_yield_distinct_ids() {
483 let a = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
484 let b = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-2").unwrap();
485 assert_ne!(a, b);
486 }
487
488 #[test_all]
489 fn derive_btc_leg_transfer_id_orchestra_and_boltz_seeds_collide_only_on_id() {
490 let orchestra = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:abc").unwrap();
493 let boltz = derive_btc_leg_transfer_id(None, "cross_chain:boltz:abc").unwrap();
494 assert_ne!(orchestra, boltz);
495 }
496
497 #[test_all]
498 fn derive_btc_leg_transfer_id_rejects_invalid_caller_key() {
499 let err = derive_btc_leg_transfer_id(Some("not-a-uuid"), "fallback").unwrap_err();
500 assert!(matches!(err, SdkError::Generic(_)));
501 }
502
503 #[test_all]
504 fn convert_sats_to_destination_amount_round_trip_inverts_to_sats() {
505 let dest = convert_sats_to_destination_amount(10_000, 60_000.0, 6).unwrap();
507 assert_eq!(dest, 6_000_000);
508 let sats = convert_destination_amount_to_sats(dest, 60_000.0, 6).unwrap();
510 assert_eq!(sats, 10_000);
511 }
512
513 #[test_all]
514 fn convert_destination_amount_to_sats_typical_stable() {
515 let sats = convert_destination_amount_to_sats(1_000_000, 60_000.0, 6).unwrap();
517 assert_eq!(sats, 1_666);
518 }
519
520 #[test_all]
521 fn convert_destination_amount_to_sats_zero_passes_through() {
522 let sats = convert_destination_amount_to_sats(0, 60_000.0, 6).unwrap();
523 assert_eq!(sats, 0);
524 }
525
526 #[test_all]
527 fn convert_destination_amount_to_sats_rejects_non_positive_rate() {
528 let err = convert_destination_amount_to_sats(1_000_000, 0.0, 6).unwrap_err();
529 assert!(matches!(err, SdkError::Generic(ref m) if m.contains("invalid BTC/USD rate")));
530 let err = convert_destination_amount_to_sats(1_000_000, f64::NAN, 6).unwrap_err();
531 assert!(matches!(err, SdkError::Generic(_)));
532 }
533
534 #[test_all]
535 fn rescale_decimals_scales_down_when_dest_decimals_lower() {
536 assert_eq!(rescale_decimals(100_000_000, 8, 6).unwrap(), 1_000_000);
537 }
538
539 #[test_all]
540 fn rescale_decimals_same_decimals_is_identity() {
541 assert_eq!(rescale_decimals(123_456_789, 6, 6).unwrap(), 123_456_789);
542 }
543
544 #[test_all]
545 fn rescale_decimals_scales_up_when_dest_decimals_higher() {
546 assert_eq!(rescale_decimals(1_000_000, 6, 8).unwrap(), 100_000_000);
547 }
548
549 #[test_all]
550 fn rescale_decimals_zero_passes_through() {
551 assert_eq!(rescale_decimals(0, 8, 6).unwrap(), 0);
552 assert_eq!(rescale_decimals(0, 6, 8).unwrap(), 0);
553 }
554
555 #[test_all]
556 fn is_usd_stable_asset_recognizes_known_stables() {
557 for ticker in ["USDB", "USDC", "USDT", "USDT0", "usdb", "uSdC"] {
558 assert!(is_usd_stable_asset(ticker), "{ticker} should be stable");
559 }
560 }
561
562 #[test_all]
563 fn is_usd_stable_asset_rejects_btc_and_unknown() {
564 for ticker in ["BTC", "ETH", "DAI", "", "USD"] {
565 assert!(
566 !is_usd_stable_asset(ticker),
567 "{ticker} should not be a recognized USD-stable"
568 );
569 }
570 }
571
572 #[test_all]
575 fn compute_terminal_fee_overwrites_estimate_on_completed() {
576 let realized = compute_terminal_fee_amount(
577 &crate::ConversionStatus::Completed,
578 Some(1_020_434), Some(997_498), Some(20_434), );
582 assert_eq!(realized, Some(22_936), "= asset_amount_in − delivered");
583 }
584
585 #[test_all]
586 fn compute_terminal_fee_keeps_estimate_on_refunded() {
587 let realized = compute_terminal_fee_amount(
591 &crate::ConversionStatus::Refunded,
592 Some(1_020_434),
593 None,
594 Some(20_434),
595 );
596 assert_eq!(realized, Some(20_434));
597 }
598
599 #[test_all]
600 fn compute_terminal_fee_keeps_estimate_on_failed() {
601 let realized = compute_terminal_fee_amount(
602 &crate::ConversionStatus::Failed,
603 Some(1_020_434),
604 None,
605 Some(20_434),
606 );
607 assert_eq!(realized, Some(20_434));
608 }
609
610 #[test_all]
611 fn compute_terminal_fee_keeps_estimate_when_asset_amount_in_missing() {
612 let realized = compute_terminal_fee_amount(
615 &crate::ConversionStatus::Completed,
616 None, Some(997_498),
618 Some(20_434),
619 );
620 assert_eq!(realized, Some(20_434));
621 }
622
623 #[test_all]
624 fn compute_terminal_fee_keeps_estimate_when_delivered_amount_missing() {
625 let realized = compute_terminal_fee_amount(
628 &crate::ConversionStatus::Completed,
629 Some(1_020_434),
630 None, Some(20_434),
632 );
633 assert_eq!(realized, Some(20_434));
634 }
635
636 #[test_all]
637 fn compute_terminal_fee_saturating_sub_on_over_delivery() {
638 let realized = compute_terminal_fee_amount(
640 &crate::ConversionStatus::Completed,
641 Some(1_000_000),
642 Some(1_005_000),
643 Some(0),
644 );
645 assert_eq!(
646 realized,
647 Some(0),
648 "saturating_sub must clamp at 0, not underflow"
649 );
650 }
651
652 #[test_all]
662 fn boltz_provider_context_invoice_amount_sats_is_independent_of_amount_in() {
663 let ctx = CrossChainProviderContext::Boltz {
664 swap_id: "swap_1".to_string(),
665 invoice: "lnbc19090n1pexample".to_string(),
666 invoice_amount_sats: 1_909,
667 max_slippage_bps: 100,
668 };
669 let json = serde_json::to_string(&ctx).unwrap();
670 let decoded: CrossChainProviderContext = serde_json::from_str(&json).unwrap();
671 let CrossChainProviderContext::Boltz {
672 invoice_amount_sats,
673 ..
674 } = &decoded
675 else {
676 panic!("expected Boltz variant");
677 };
678 assert_eq!(*invoice_amount_sats, 1_909);
679 assert!(
680 *invoice_amount_sats != 1_222_703,
681 "the LN invoice sats must never be conflated with a user-facing display value (e.g. USDB base units)"
682 );
683 }
684
685 #[test_all]
690 fn boltz_provider_context_legacy_row_without_invoice_amount_sats_defaults_to_zero() {
691 let legacy = r#"{
692 "Boltz": {
693 "swap_id": "swap_legacy",
694 "invoice": "lnbc19090n1p",
695 "max_slippage_bps": 100
696 }
697 }"#;
698 let decoded: CrossChainProviderContext = serde_json::from_str(legacy).unwrap();
699 let CrossChainProviderContext::Boltz {
700 invoice_amount_sats,
701 ..
702 } = &decoded
703 else {
704 panic!("expected Boltz variant");
705 };
706 assert_eq!(*invoice_amount_sats, 0);
707 }
708
709 #[test_all]
713 fn orchestra_provider_context_deposit_amount_is_independent_of_amount_in() {
714 let ctx = CrossChainProviderContext::Orchestra {
715 quote_id: "q_1".to_string(),
716 deposit_address: "spark1...".to_string(),
717 deposit_amount: 1_020_434,
718 };
719 let json = serde_json::to_string(&ctx).unwrap();
720 let decoded: CrossChainProviderContext = serde_json::from_str(&json).unwrap();
721 let CrossChainProviderContext::Orchestra { deposit_amount, .. } = &decoded else {
722 panic!("expected Orchestra variant");
723 };
724 assert_eq!(*deposit_amount, 1_020_434);
725 }
726}