1#[cfg(feature = "boltz")]
11#[allow(dead_code)]
12pub(crate) mod boltz;
13mod cached_fiat;
14mod orchestra;
15
16pub(crate) use cached_fiat::{CachedFiatService, DEFAULT_FIAT_CACHE_TTL};
17pub(crate) use orchestra::{
18 BreezServerOrchestraConfigResolver, DepositDestination, OrchestraQuoter, OrchestraService,
19 delivery_method_from_wire, delivery_method_to_wire, parse_rfc3339_to_unix_seconds,
20 to_route_limits,
21};
22
23use std::collections::HashMap;
24use std::str::FromStr;
25use std::sync::Arc;
26use std::time::Duration;
27
28use breez_sdk_common::fiat::FiatService;
29use serde::{Deserialize, Serialize};
30use spark_wallet::TransferId;
31
32use crate::{
33 ConversionInfo, CrossChainAddressDetails, PaymentDetails, error::SdkError,
34 utils::conversions::extract_conversion_info,
35};
36
37pub(crate) const MIN_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 10;
39pub(crate) const MAX_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 500;
40pub(crate) const DEFAULT_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 100;
43
44pub(crate) const MIN_TARGET_OVERPAY_BPS: u32 = 0;
48pub(crate) const MAX_TARGET_OVERPAY_BPS: u32 = 500;
49pub(crate) const DEFAULT_TARGET_OVERPAY_BPS: u32 = 15;
54const USD_STABLE_ASSETS: &[&str] = &["USDB", "USDC", "USDT", "USDT0"];
57
58pub(crate) const MONITOR_INTERVAL: Duration = Duration::from_secs(30);
60
61static RECEIVE_CONVERSION_WRITES: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
67
68pub(crate) async fn attach_receive_metadata_for_payment(
73 storage: &Arc<dyn crate::Storage>,
74 payment: &crate::Payment,
75) -> Result<bool, SdkError> {
76 if payment.payment_type != crate::PaymentType::Receive {
77 return Ok(false);
78 }
79 let invoice = match &payment.details {
80 Some(
81 PaymentDetails::Spark {
82 invoice_details: Some(details),
83 ..
84 }
85 | PaymentDetails::Token {
86 invoice_details: Some(details),
87 ..
88 },
89 ) => &details.invoice,
90 _ => return Ok(false),
91 };
92
93 let _serialized = RECEIVE_CONVERSION_WRITES.lock().await;
94 let already_attached = storage
95 .get_payment_by_id(payment.id.clone())
96 .await
97 .is_ok_and(|stored| extract_conversion_info(stored.details).is_some());
98 if already_attached {
99 return Ok(false);
100 }
101 let Some(conversion_info) = orchestra::link_receive_for_invoice(storage, invoice).await? else {
102 return Ok(false);
103 };
104 storage
105 .insert_payment_metadata(
106 payment.id.clone(),
107 crate::PaymentMetadata {
108 conversion_info: Some(conversion_info),
109 ..Default::default()
110 },
111 )
112 .await?;
113 Ok(true)
114}
115
116pub(crate) fn payment_with_conversion_info(
121 mut payment: crate::Payment,
122 conversion_info: Option<ConversionInfo>,
123) -> crate::Payment {
124 payment.details = match payment.details {
125 Some(PaymentDetails::Spark {
126 invoice_details,
127 htlc_details,
128 ..
129 }) => Some(PaymentDetails::Spark {
130 invoice_details,
131 htlc_details,
132 conversion_info,
133 }),
134 Some(PaymentDetails::Token {
135 metadata,
136 tx_hash,
137 tx_type,
138 invoice_details,
139 ..
140 }) => Some(PaymentDetails::Token {
141 metadata,
142 tx_hash,
143 tx_type,
144 invoice_details,
145 conversion_info,
146 }),
147 Some(PaymentDetails::Lightning {
148 description,
149 invoice,
150 destination_pubkey,
151 htlc_details,
152 lnurl_pay_info,
153 lnurl_withdraw_info,
154 lnurl_receive_metadata,
155 ..
156 }) => Some(PaymentDetails::Lightning {
157 description,
158 invoice,
159 destination_pubkey,
160 htlc_details,
161 lnurl_pay_info,
162 lnurl_withdraw_info,
163 lnurl_receive_metadata,
164 conversion_info,
165 }),
166 other => other,
167 };
168 payment
169}
170
171pub(crate) fn derive_btc_leg_transfer_id(
181 idempotency_key: Option<&str>,
182 fallback_seed: &str,
183) -> Result<TransferId, SdkError> {
184 match idempotency_key {
185 Some(key) => TransferId::from_str(key).map_err(SdkError::Generic),
186 None => Ok(TransferId::from_name(fallback_seed)),
187 }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
191#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
192pub enum CrossChainProvider {
193 Orchestra,
194 Boltz,
196}
197
198impl std::fmt::Display for CrossChainProvider {
199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200 match self {
201 Self::Orchestra => f.write_str("Orchestra"),
202 Self::Boltz => f.write_str("Boltz"),
203 }
204 }
205}
206
207#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
209#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
210pub enum SparkAsset {
211 Bitcoin,
213 Token { token_identifier: String },
215}
216
217#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
232#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
233pub struct CrossChainRouteLimits {
234 pub min_amount: Option<u128>,
236 pub max_amount: Option<u128>,
238 pub min_usd_cents: Option<u64>,
240 pub max_usd_cents: Option<u64>,
242}
243
244#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
249#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
250pub struct CrossChainAcceptedAsset {
251 pub asset: SparkAsset,
252 pub limits: Option<CrossChainRouteLimits>,
254}
255
256#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
258#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
259pub enum DeliveryMethod {
260 Spark,
262 Lightning,
264 Bitcoin,
266}
267
268impl std::fmt::Display for DeliveryMethod {
269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270 match self {
271 Self::Spark => f.write_str("Spark"),
272 Self::Lightning => f.write_str("Lightning"),
273 Self::Bitcoin => f.write_str("Bitcoin"),
274 }
275 }
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
281#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
282pub enum CrossChainFeeMode {
283 FeesExcluded,
290 FeesIncluded,
297}
298
299impl From<crate::FeePolicy> for CrossChainFeeMode {
300 fn from(policy: crate::FeePolicy) -> Self {
301 match policy {
302 crate::FeePolicy::FeesExcluded => Self::FeesExcluded,
303 crate::FeePolicy::FeesIncluded => Self::FeesIncluded,
304 }
305 }
306}
307
308#[derive(Clone, Debug, Deserialize, Serialize)]
311#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
312pub enum CrossChainRouteFilter {
313 Send {
316 address_details: CrossChainAddressDetails,
317 delivery_method: Option<DeliveryMethod>,
321 },
322 Receive {
325 contract_address: Option<String>,
326 delivery_method: Option<DeliveryMethod>,
330 },
331 PaymentLink {
336 address_details: CrossChainAddressDetails,
337 },
338}
339
340impl CrossChainRouteFilter {
341 pub(crate) fn normalized(&self) -> Self {
343 match self {
344 Self::PaymentLink { address_details } => Self::Send {
345 address_details: address_details.clone(),
346 delivery_method: Some(DeliveryMethod::Lightning),
347 },
348 other => other.clone(),
349 }
350 }
351}
352
353#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
356#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
357pub struct CrossChainRoutePair {
358 pub provider: CrossChainProvider,
360 pub chain: String,
362 pub chain_id: Option<String>,
366 pub asset: String,
368 pub contract_address: Option<String>,
370 pub decimals: u8,
372 pub exact_out_eligible: bool,
374 pub accepted_assets: Vec<CrossChainAcceptedAsset>,
376 pub delivery_methods: Vec<DeliveryMethod>,
379}
380
381impl CrossChainRoutePair {
382 pub fn limits_for(&self, asset: &SparkAsset) -> Option<&CrossChainRouteLimits> {
385 self.accepted_assets
386 .iter()
387 .find(|a| &a.asset == asset)
388 .and_then(|a| a.limits.as_ref())
389 }
390
391 pub fn accepts_asset(&self, asset: &SparkAsset) -> bool {
393 self.accepted_assets.iter().any(|a| &a.asset == asset)
394 }
395
396 pub(crate) fn destination_address_family(
401 &self,
402 ) -> Option<breez_sdk_common::input::CrossChainAddressFamily> {
403 self.contract_address
404 .as_deref()
405 .and_then(breez_sdk_common::input::detect_address_family)
406 }
407}
408
409#[derive(Clone)]
413pub(crate) struct CrossChainContext {
414 providers: HashMap<CrossChainProvider, Arc<dyn CrossChainService>>,
415 fiat_service: Arc<dyn FiatService>,
416 orchestra_quoter: Option<Arc<OrchestraQuoter>>,
418}
419
420impl CrossChainContext {
421 pub fn new(fiat_service: Arc<dyn FiatService>) -> Self {
422 Self {
423 providers: HashMap::new(),
424 fiat_service,
425 orchestra_quoter: None,
426 }
427 }
428
429 pub fn insert(&mut self, key: CrossChainProvider, service: Arc<dyn CrossChainService>) {
430 self.providers.insert(key, service);
431 }
432
433 pub fn set_orchestra_quoter(&mut self, quoter: Arc<OrchestraQuoter>) {
434 self.orchestra_quoter = Some(quoter);
435 }
436
437 pub fn orchestra_quoter(&self) -> Option<&Arc<OrchestraQuoter>> {
439 self.orchestra_quoter.as_ref()
440 }
441
442 pub fn ensure_enabled(&self) -> Result<(), SdkError> {
445 if self.providers.is_empty() {
446 return Err(SdkError::cross_chain_disabled());
447 }
448 Ok(())
449 }
450
451 pub fn get(
453 &self,
454 provider: CrossChainProvider,
455 ) -> Result<&Arc<dyn CrossChainService>, SdkError> {
456 self.ensure_enabled()?;
457 self.providers.get(&provider).ok_or_else(|| {
458 SdkError::InvalidInput(format!("Cross-chain provider {provider} is not available."))
459 })
460 }
461
462 pub fn values(&self) -> impl Iterator<Item = &Arc<dyn CrossChainService>> {
463 self.providers.values()
464 }
465
466 pub async fn list_routes(
469 &self,
470 filter: &CrossChainRouteFilter,
471 ) -> Result<Vec<CrossChainRoutePair>, SdkError> {
472 self.ensure_enabled()?;
473 let filter = &filter.normalized();
474 let mut all_routes = Vec::new();
475 for svc in self.values() {
476 match svc.get_routes(filter).await {
477 Ok(routes) => all_routes.extend(routes),
478 Err(e) => tracing::warn!("Cross-chain provider route fetch failed: {e}"),
479 }
480 }
481
482 all_routes.retain(|r| is_usd_stable_asset(&r.asset));
484
485 all_routes.sort_by(|a, b| {
486 a.asset
487 .cmp(&b.asset)
488 .then_with(|| a.chain.cmp(&b.chain))
489 .then_with(|| a.provider.cmp(&b.provider))
490 });
491 Ok(all_routes)
492 }
493
494 pub fn fiat_service(&self) -> &Arc<dyn FiatService> {
497 &self.fiat_service
498 }
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize)]
505#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
506pub enum CrossChainProviderContext {
507 Orchestra {
508 quote_id: String,
510 deposit_address: String,
512 #[serde(default)]
514 deposit_amount: u128,
515 },
516 Boltz {
517 swap_id: String,
519 invoice: String,
521 #[serde(default)]
523 invoice_amount_sats: u64,
524 max_slippage_bps: u32,
526 },
527}
528
529#[derive(Debug, Clone)]
533pub(crate) struct CrossChainReceivePrepared {
534 pub payment_request: String,
536 pub info: CrossChainReceiveInfo,
537 pub quote_read_token: Option<String>,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize)]
544#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
545pub struct CrossChainReceiveInfo {
546 pub quote_id: String,
549 pub deposit_address: String,
552 pub deposit_amount: u128,
557 pub expected_received_amount: u128,
563 pub destination_asset: String,
567 pub token_identifier: Option<String>,
570 pub service_fee_amount: u128,
573 pub service_fee_asset: Option<String>,
576 pub service_fee_asset_decimals: Option<u32>,
579 pub expires_at: u64,
581}
582
583#[derive(Debug, Clone)]
586pub(crate) struct CrossChainSendPrepared {
587 pub amount_in: u128,
588 pub asset_amount_in: u128,
592 pub estimated_out: u128,
594 pub fee_amount: u128,
600 pub service_fee_amount: u128,
603 pub service_fee_asset: Option<String>,
605 pub service_fee_asset_decimals: Option<u32>,
607 pub source_transfer_fee_sats: u64,
617 pub fee_mode: CrossChainFeeMode,
620 pub expires_at: String,
621 pub pair: CrossChainRoutePair,
622 pub recipient_address: String,
623 pub token_identifier: Option<String>,
625 pub provider_context: CrossChainProviderContext,
627 pub quote_read_token: Option<String>,
629}
630
631#[allow(clippy::too_many_arguments)]
636#[macros::async_trait]
637pub(crate) trait CrossChainService: Send + Sync {
638 async fn get_routes(
644 &self,
645 filter: &CrossChainRouteFilter,
646 ) -> Result<Vec<CrossChainRoutePair>, SdkError>;
647
648 #[allow(clippy::too_many_arguments)]
656 async fn prepare_send(
657 &self,
658 recipient_address: &str,
659 route: &CrossChainRoutePair,
660 amount: u128,
661 delivery_method: Option<DeliveryMethod>,
662 source_token_identifier: Option<String>,
663 max_slippage_bps: u32,
664 fee_mode: CrossChainFeeMode,
665 ) -> Result<CrossChainSendPrepared, SdkError>;
666
667 #[allow(clippy::too_many_arguments)]
687 async fn prepare_receive(
688 &self,
689 route: &CrossChainRoutePair,
690 recipient_address: &str,
691 amount: u128,
692 max_slippage_bps: u32,
693 destination: &SparkAsset,
697 fee_mode: CrossChainFeeMode,
698 target_overpay_bps: u32,
699 delivery_method: DeliveryMethod,
700 refund_address: Option<&str>,
701 ) -> Result<CrossChainReceivePrepared, SdkError>;
702
703 async fn send(
719 &self,
720 prepared: &CrossChainSendPrepared,
721 idempotency_key: Option<String>,
722 ) -> Result<crate::Payment, SdkError>;
723}
724
725pub(crate) async fn fetch_btc_usd_rate(fiat: &dyn FiatService) -> Result<f64, SdkError> {
728 let rates = fiat
729 .fetch_fiat_rates()
730 .await
731 .map_err(|e| SdkError::Generic(format!("Cross-chain: failed to fetch fiat rates: {e}")))?;
732 let btc_usd = rates
733 .iter()
734 .find(|r| r.coin.eq_ignore_ascii_case("USD"))
735 .map(|r| r.value)
736 .ok_or_else(|| {
737 SdkError::Generic("Cross-chain: BTC/USD rate not found in feed".to_string())
738 })?;
739 if !btc_usd.is_finite() || btc_usd <= 0.0 {
740 return Err(SdkError::Generic(format!(
741 "Cross-chain: invalid BTC/USD rate from feed: {btc_usd}"
742 )));
743 }
744 Ok(btc_usd)
745}
746
747#[allow(
750 clippy::cast_precision_loss,
751 clippy::cast_possible_truncation,
752 clippy::cast_sign_loss
753)]
754pub(crate) fn convert_sats_to_destination_amount(
755 sats: u128,
756 fiat_rate: f64,
757 dest_decimals: u32,
758) -> Result<u128, SdkError> {
759 let dest_scale = 10f64.powi(i32::try_from(dest_decimals).unwrap_or(i32::MAX));
760 let target = (sats as f64) * fiat_rate * dest_scale / 100_000_000f64;
761 if !target.is_finite() || target < 0.0 {
762 return Err(SdkError::Generic(format!(
763 "Cross-chain: invalid sats→dest conversion result: {target}"
764 )));
765 }
766 Ok(target as u128)
767}
768
769#[allow(
773 clippy::cast_precision_loss,
774 clippy::cast_possible_truncation,
775 clippy::cast_sign_loss
776)]
777pub(crate) fn convert_source_amount_to_sats(
778 src_base_units: u128,
779 src_decimals: u32,
780 fiat_per_btc: f64,
781) -> Result<u128, SdkError> {
782 let src_scale = 10f64.powi(i32::try_from(src_decimals).unwrap_or(i32::MAX));
783 let sats = (src_base_units as f64) * 100_000_000f64 / (src_scale * fiat_per_btc);
784 if !sats.is_finite() || sats < 0.0 {
785 return Err(SdkError::Generic(format!(
786 "Cross-chain: invalid stable→sats conversion result: {sats}"
787 )));
788 }
789 Ok(sats as u128)
790}
791
792pub(crate) fn ensure_route_delivers_over(
795 route: &CrossChainRoutePair,
796 delivery_method: DeliveryMethod,
797) -> Result<(), SdkError> {
798 if !route.delivery_methods.is_empty() && !route.delivery_methods.contains(&delivery_method) {
799 return Err(SdkError::InvalidInput(format!(
800 "The selected route doesn't support {delivery_method}"
801 )));
802 }
803 Ok(())
804}
805
806pub(crate) fn is_usd_stable_asset(asset: &str) -> bool {
807 USD_STABLE_ASSETS
808 .iter()
809 .any(|a| asset.eq_ignore_ascii_case(a))
810}
811
812pub(crate) fn build_receive_payment_request(
819 deposit_address: &str,
820 chain: &str,
821 chain_id: Option<&str>,
822 contract_address: Option<&str>,
823 amount: u128,
824) -> Result<String, SdkError> {
825 let family =
826 breez_sdk_common::input::detect_address_family(deposit_address).ok_or_else(|| {
827 SdkError::Generic(format!(
828 "Cross-chain provider returned unrecognised deposit address: {deposit_address}",
829 ))
830 })?;
831 if !family.matches_chain(chain, contract_address) {
834 return Err(SdkError::Generic(format!(
835 "Cross-chain provider returned {family:?} deposit address for {chain} route"
836 )));
837 }
838 Ok(breez_sdk_common::input::format_cross_chain_uri(
839 family,
840 deposit_address,
841 contract_address,
842 chain_id,
843 amount,
844 ))
845}
846
847pub(crate) fn compute_terminal_fee_amount(
851 new_status: &crate::ConversionStatus,
852 asset_amount_in: Option<u128>,
853 delivered_amount: Option<u128>,
854 prepare_estimate: Option<u128>,
855) -> Option<u128> {
856 match (new_status, asset_amount_in, delivered_amount) {
857 (crate::ConversionStatus::Completed, Some(a), Some(d)) => Some(a.saturating_sub(d)),
858 _ => prepare_estimate,
859 }
860}
861
862pub(crate) fn rescale_decimals(
866 amount: u128,
867 src_decimals: u32,
868 dest_decimals: u32,
869) -> Result<u128, SdkError> {
870 if dest_decimals >= src_decimals {
871 let delta = dest_decimals.saturating_sub(src_decimals);
872 let factor = 10u128
873 .checked_pow(delta)
874 .ok_or_else(|| SdkError::Generic("Cross-chain: decimal scale overflow".to_string()))?;
875 amount
876 .checked_mul(factor)
877 .ok_or_else(|| SdkError::Generic("Cross-chain: decimal rescale overflow".to_string()))
878 } else {
879 let delta = src_decimals.saturating_sub(dest_decimals);
880 let factor = 10u128
881 .checked_pow(delta)
882 .ok_or_else(|| SdkError::Generic("Cross-chain: decimal scale overflow".to_string()))?;
883 amount.checked_div(factor).ok_or_else(|| {
884 SdkError::Generic("Cross-chain: decimal rescale divisor zero".to_string())
885 })
886 }
887}
888
889#[allow(
893 clippy::cast_precision_loss,
894 clippy::cast_possible_truncation,
895 clippy::cast_sign_loss
896)]
897pub(crate) fn convert_destination_amount_to_sats(
898 destination_amount: u128,
899 fiat_rate: f64,
900 dest_decimals: u32,
901) -> Result<u128, SdkError> {
902 if !fiat_rate.is_finite() || fiat_rate <= 0.0 {
903 return Err(SdkError::Generic(format!(
904 "Cross-chain: invalid BTC/USD rate for inversion: {fiat_rate}"
905 )));
906 }
907 let dest_scale = 10f64.powi(i32::try_from(dest_decimals).unwrap_or(i32::MAX));
908 let sats = (destination_amount as f64) * 100_000_000f64 / (fiat_rate * dest_scale);
909 if !sats.is_finite() || sats < 0.0 {
910 return Err(SdkError::Generic(format!(
911 "Cross-chain: invalid dest→sats conversion result: {sats}"
912 )));
913 }
914 Ok(sats as u128)
915}
916
917pub(crate) fn resolve_target_overpay_bps(
923 requested: Option<u32>,
924 config_default: Option<u32>,
925) -> Result<u32, SdkError> {
926 if let Some(bps) = requested
927 && !(MIN_TARGET_OVERPAY_BPS..=MAX_TARGET_OVERPAY_BPS).contains(&bps)
928 {
929 return Err(SdkError::InvalidInput(format!(
930 "target_overpay_bps {bps} must be in \
931 {MIN_TARGET_OVERPAY_BPS} to {MAX_TARGET_OVERPAY_BPS}",
932 )));
933 }
934 Ok(requested
935 .or(config_default)
936 .unwrap_or(DEFAULT_TARGET_OVERPAY_BPS))
937}
938
939pub(crate) fn inflate_target_amount(amount: u128, overpay_bps: u32) -> u128 {
944 if overpay_bps == 0 {
945 return amount;
946 }
947 amount.saturating_add(amount.saturating_mul(u128::from(overpay_bps)) / 10_000)
948}
949
950#[cfg(test)]
951mod tests {
952 use super::*;
953 use macros::test_all;
954
955 #[cfg(feature = "browser-tests")]
956 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
957
958 struct NoFiat;
959
960 #[macros::async_trait]
961 impl FiatService for NoFiat {
962 async fn fetch_fiat_currencies(
963 &self,
964 ) -> Result<
965 Vec<breez_sdk_common::fiat::FiatCurrency>,
966 breez_sdk_common::error::ServiceConnectivityError,
967 > {
968 Ok(Vec::new())
969 }
970
971 async fn fetch_fiat_rates(
972 &self,
973 ) -> Result<
974 Vec<breez_sdk_common::fiat::Rate>,
975 breez_sdk_common::error::ServiceConnectivityError,
976 > {
977 Ok(Vec::new())
978 }
979 }
980
981 struct StubProvider;
982
983 #[macros::async_trait]
984 impl CrossChainService for StubProvider {
985 async fn get_routes(
986 &self,
987 _filter: &CrossChainRouteFilter,
988 ) -> Result<Vec<CrossChainRoutePair>, SdkError> {
989 unimplemented!()
990 }
991
992 async fn prepare_send(
993 &self,
994 _recipient_address: &str,
995 _route: &CrossChainRoutePair,
996 _amount: u128,
997 _delivery_method: Option<DeliveryMethod>,
998 _source_token_identifier: Option<String>,
999 _max_slippage_bps: u32,
1000 _fee_mode: CrossChainFeeMode,
1001 ) -> Result<CrossChainSendPrepared, SdkError> {
1002 unimplemented!()
1003 }
1004
1005 async fn prepare_receive(
1006 &self,
1007 _route: &CrossChainRoutePair,
1008 _recipient_address: &str,
1009 _amount: u128,
1010 _max_slippage_bps: u32,
1011 _destination: &SparkAsset,
1012 _fee_mode: CrossChainFeeMode,
1013 _target_overpay_bps: u32,
1014 _delivery_method: DeliveryMethod,
1015 _refund_address: Option<&str>,
1016 ) -> Result<CrossChainReceivePrepared, SdkError> {
1017 unimplemented!()
1018 }
1019
1020 async fn send(
1021 &self,
1022 _prepared: &CrossChainSendPrepared,
1023 _idempotency_key: Option<String>,
1024 ) -> Result<crate::Payment, SdkError> {
1025 unimplemented!()
1026 }
1027 }
1028
1029 fn evm_address_details() -> crate::CrossChainAddressDetails {
1030 crate::CrossChainAddressDetails {
1031 address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e".to_string(),
1032 address_family: crate::CrossChainAddressFamily::Evm,
1033 contract_address: None,
1034 chain_id: None,
1035 amount: None,
1036 }
1037 }
1038
1039 #[test_all]
1040 fn empty_context_reports_cross_chain_disabled() {
1041 let context = CrossChainContext::new(Arc::new(NoFiat));
1042 assert!(matches!(
1043 context.ensure_enabled(),
1044 Err(SdkError::CrossChainDisabled { .. })
1045 ));
1046 assert!(matches!(
1047 context.get(CrossChainProvider::Orchestra),
1048 Err(SdkError::CrossChainDisabled { .. })
1049 ));
1050 }
1051
1052 #[test_all]
1053 fn enabled_context_reports_missing_provider() {
1054 let mut context = CrossChainContext::new(Arc::new(NoFiat));
1055 context.insert(CrossChainProvider::Orchestra, Arc::new(StubProvider));
1056 assert!(context.ensure_enabled().is_ok());
1057 assert!(context.get(CrossChainProvider::Orchestra).is_ok());
1058 assert!(matches!(
1059 context.get(CrossChainProvider::Boltz),
1060 Err(SdkError::InvalidInput(_))
1061 ));
1062 }
1063
1064 #[test_all]
1065 fn route_filter_normalizes_payment_link_to_lightning_send() {
1066 let filter = CrossChainRouteFilter::PaymentLink {
1067 address_details: evm_address_details(),
1068 };
1069 let CrossChainRouteFilter::Send {
1070 address_details,
1071 delivery_method,
1072 } = filter.normalized()
1073 else {
1074 panic!("expected a send filter");
1075 };
1076 assert_eq!(address_details.address, evm_address_details().address);
1077 assert_eq!(delivery_method, Some(DeliveryMethod::Lightning));
1078 }
1079
1080 #[test_all]
1081 fn ensure_route_delivers_over_checks_listed_methods() {
1082 let route = |delivery_methods: Vec<DeliveryMethod>| CrossChainRoutePair {
1083 provider: CrossChainProvider::Orchestra,
1084 chain: "base".to_string(),
1085 chain_id: None,
1086 asset: "USDC".to_string(),
1087 contract_address: None,
1088 decimals: 6,
1089 exact_out_eligible: false,
1090 accepted_assets: Vec::new(),
1091 delivery_methods,
1092 };
1093 let spark = route(vec![DeliveryMethod::Spark]);
1094 let lightning = route(vec![DeliveryMethod::Lightning]);
1095 let unlisted = route(Vec::new());
1096
1097 assert!(ensure_route_delivers_over(&spark, DeliveryMethod::Spark).is_ok());
1098 assert!(matches!(
1099 ensure_route_delivers_over(&lightning, DeliveryMethod::Spark),
1100 Err(SdkError::InvalidInput(_))
1101 ));
1102 assert!(ensure_route_delivers_over(&lightning, DeliveryMethod::Lightning).is_ok());
1103 assert!(ensure_route_delivers_over(&unlisted, DeliveryMethod::Spark).is_ok());
1104 }
1105
1106 #[test_all]
1107 fn delivery_method_display_is_human_readable() {
1108 assert_eq!(DeliveryMethod::Spark.to_string(), "Spark");
1109 assert_eq!(DeliveryMethod::Lightning.to_string(), "Lightning");
1110 assert_eq!(DeliveryMethod::Bitcoin.to_string(), "Bitcoin");
1111 }
1112
1113 #[test_all]
1114 fn derive_btc_leg_transfer_id_uses_caller_key() {
1115 let key = "00000000-0000-4000-8000-000000000001";
1118 let id = derive_btc_leg_transfer_id(Some(key), "ignored-seed").unwrap();
1119 assert_eq!(id.to_string(), key);
1120 }
1121
1122 #[test_all]
1123 fn derive_btc_leg_transfer_id_deterministic_from_seed() {
1124 let a = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
1125 let b = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
1126 assert_eq!(
1127 a, b,
1128 "same seed must produce the same TransferId across calls"
1129 );
1130 }
1131
1132 #[test_all]
1133 fn derive_btc_leg_transfer_id_distinct_seeds_yield_distinct_ids() {
1134 let a = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
1135 let b = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-2").unwrap();
1136 assert_ne!(a, b);
1137 }
1138
1139 #[test_all]
1140 fn derive_btc_leg_transfer_id_orchestra_and_boltz_seeds_collide_only_on_id() {
1141 let orchestra = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:abc").unwrap();
1144 let boltz = derive_btc_leg_transfer_id(None, "cross_chain:boltz:abc").unwrap();
1145 assert_ne!(orchestra, boltz);
1146 }
1147
1148 #[test_all]
1149 fn derive_btc_leg_transfer_id_rejects_invalid_caller_key() {
1150 let err = derive_btc_leg_transfer_id(Some("not-a-uuid"), "fallback").unwrap_err();
1151 assert!(matches!(err, SdkError::Generic(_)));
1152 }
1153
1154 #[test_all]
1155 fn convert_sats_to_destination_amount_round_trip_inverts_to_sats() {
1156 let dest = convert_sats_to_destination_amount(10_000, 60_000.0, 6).unwrap();
1158 assert_eq!(dest, 6_000_000);
1159 let sats = convert_destination_amount_to_sats(dest, 60_000.0, 6).unwrap();
1161 assert_eq!(sats, 10_000);
1162 }
1163
1164 #[test_all]
1165 fn convert_destination_amount_to_sats_typical_stable() {
1166 let sats = convert_destination_amount_to_sats(1_000_000, 60_000.0, 6).unwrap();
1168 assert_eq!(sats, 1_666);
1169 }
1170
1171 #[test_all]
1172 fn convert_destination_amount_to_sats_zero_passes_through() {
1173 let sats = convert_destination_amount_to_sats(0, 60_000.0, 6).unwrap();
1174 assert_eq!(sats, 0);
1175 }
1176
1177 #[test_all]
1178 fn convert_destination_amount_to_sats_rejects_non_positive_rate() {
1179 let err = convert_destination_amount_to_sats(1_000_000, 0.0, 6).unwrap_err();
1180 assert!(matches!(err, SdkError::Generic(ref m) if m.contains("invalid BTC/USD rate")));
1181 let err = convert_destination_amount_to_sats(1_000_000, f64::NAN, 6).unwrap_err();
1182 assert!(matches!(err, SdkError::Generic(_)));
1183 }
1184
1185 #[test_all]
1186 fn rescale_decimals_scales_down_when_dest_decimals_lower() {
1187 assert_eq!(rescale_decimals(100_000_000, 8, 6).unwrap(), 1_000_000);
1188 }
1189
1190 #[test_all]
1191 fn rescale_decimals_same_decimals_is_identity() {
1192 assert_eq!(rescale_decimals(123_456_789, 6, 6).unwrap(), 123_456_789);
1193 }
1194
1195 #[test_all]
1196 fn rescale_decimals_scales_up_when_dest_decimals_higher() {
1197 assert_eq!(rescale_decimals(1_000_000, 6, 8).unwrap(), 100_000_000);
1198 }
1199
1200 #[test_all]
1201 fn rescale_decimals_zero_passes_through() {
1202 assert_eq!(rescale_decimals(0, 8, 6).unwrap(), 0);
1203 assert_eq!(rescale_decimals(0, 6, 8).unwrap(), 0);
1204 }
1205
1206 #[test_all]
1207 fn is_usd_stable_asset_recognizes_known_stables() {
1208 for ticker in ["USDB", "USDC", "USDT", "USDT0", "usdb", "uSdC"] {
1209 assert!(is_usd_stable_asset(ticker), "{ticker} should be stable");
1210 }
1211 }
1212
1213 #[test_all]
1214 fn is_usd_stable_asset_rejects_btc_and_unknown() {
1215 for ticker in ["BTC", "ETH", "DAI", "", "USD"] {
1216 assert!(
1217 !is_usd_stable_asset(ticker),
1218 "{ticker} should not be a recognized USD-stable"
1219 );
1220 }
1221 }
1222
1223 #[test_all]
1226 fn compute_terminal_fee_overwrites_estimate_on_completed() {
1227 let realized = compute_terminal_fee_amount(
1228 &crate::ConversionStatus::Completed,
1229 Some(1_020_434), Some(997_498), Some(20_434), );
1233 assert_eq!(realized, Some(22_936), "= asset_amount_in − delivered");
1234 }
1235
1236 #[test_all]
1237 fn compute_terminal_fee_keeps_estimate_on_refunded() {
1238 let realized = compute_terminal_fee_amount(
1242 &crate::ConversionStatus::Refunded,
1243 Some(1_020_434),
1244 None,
1245 Some(20_434),
1246 );
1247 assert_eq!(realized, Some(20_434));
1248 }
1249
1250 #[test_all]
1251 fn compute_terminal_fee_keeps_estimate_on_failed() {
1252 let realized = compute_terminal_fee_amount(
1253 &crate::ConversionStatus::Failed,
1254 Some(1_020_434),
1255 None,
1256 Some(20_434),
1257 );
1258 assert_eq!(realized, Some(20_434));
1259 }
1260
1261 #[test_all]
1262 fn compute_terminal_fee_keeps_estimate_when_asset_amount_in_missing() {
1263 let realized = compute_terminal_fee_amount(
1266 &crate::ConversionStatus::Completed,
1267 None, Some(997_498),
1269 Some(20_434),
1270 );
1271 assert_eq!(realized, Some(20_434));
1272 }
1273
1274 #[test_all]
1275 fn compute_terminal_fee_keeps_estimate_when_delivered_amount_missing() {
1276 let realized = compute_terminal_fee_amount(
1279 &crate::ConversionStatus::Completed,
1280 Some(1_020_434),
1281 None, Some(20_434),
1283 );
1284 assert_eq!(realized, Some(20_434));
1285 }
1286
1287 #[test_all]
1288 fn compute_terminal_fee_saturating_sub_on_over_delivery() {
1289 let realized = compute_terminal_fee_amount(
1291 &crate::ConversionStatus::Completed,
1292 Some(1_000_000),
1293 Some(1_005_000),
1294 Some(0),
1295 );
1296 assert_eq!(
1297 realized,
1298 Some(0),
1299 "saturating_sub must clamp at 0, not underflow"
1300 );
1301 }
1302
1303 #[test_all]
1313 fn boltz_provider_context_invoice_amount_sats_is_independent_of_amount_in() {
1314 let ctx = CrossChainProviderContext::Boltz {
1315 swap_id: "swap_1".to_string(),
1316 invoice: "lnbc19090n1pexample".to_string(),
1317 invoice_amount_sats: 1_909,
1318 max_slippage_bps: 100,
1319 };
1320 let json = serde_json::to_string(&ctx).unwrap();
1321 let decoded: CrossChainProviderContext = serde_json::from_str(&json).unwrap();
1322 let CrossChainProviderContext::Boltz {
1323 invoice_amount_sats,
1324 ..
1325 } = &decoded
1326 else {
1327 panic!("expected Boltz variant");
1328 };
1329 assert_eq!(*invoice_amount_sats, 1_909);
1330 assert!(
1331 *invoice_amount_sats != 1_222_703,
1332 "the LN invoice sats must never be conflated with a user-facing display value (e.g. USDB base units)"
1333 );
1334 }
1335
1336 #[test_all]
1341 fn boltz_provider_context_legacy_row_without_invoice_amount_sats_defaults_to_zero() {
1342 let legacy = r#"{
1343 "Boltz": {
1344 "swap_id": "swap_legacy",
1345 "invoice": "lnbc19090n1p",
1346 "max_slippage_bps": 100
1347 }
1348 }"#;
1349 let decoded: CrossChainProviderContext = serde_json::from_str(legacy).unwrap();
1350 let CrossChainProviderContext::Boltz {
1351 invoice_amount_sats,
1352 ..
1353 } = &decoded
1354 else {
1355 panic!("expected Boltz variant");
1356 };
1357 assert_eq!(*invoice_amount_sats, 0);
1358 }
1359
1360 #[test_all]
1364 fn orchestra_provider_context_deposit_amount_is_independent_of_amount_in() {
1365 let ctx = CrossChainProviderContext::Orchestra {
1366 quote_id: "q_1".to_string(),
1367 deposit_address: "spark1...".to_string(),
1368 deposit_amount: 1_020_434,
1369 };
1370 let json = serde_json::to_string(&ctx).unwrap();
1371 let decoded: CrossChainProviderContext = serde_json::from_str(&json).unwrap();
1372 let CrossChainProviderContext::Orchestra { deposit_amount, .. } = &decoded else {
1373 panic!("expected Orchestra variant");
1374 };
1375 assert_eq!(*deposit_amount, 1_020_434);
1376 }
1377
1378 fn boltz_info(swap_id: &str) -> ConversionInfo {
1379 ConversionInfo::Boltz {
1380 swap_id: swap_id.to_string(),
1381 invoice: "lnbc1".to_string(),
1382 invoice_amount_sats: 1_000,
1383 bridge_ref: None,
1384 max_slippage_bps: 100,
1385 quote_degraded: false,
1386 chain: "polygon".to_string(),
1387 chain_id: None,
1388 asset: "USDC".to_string(),
1389 recipient_address: "0xabc".to_string(),
1390 estimated_out: 1_000_000,
1391 delivered_amount: None,
1392 status: crate::ConversionStatus::Pending,
1393 asset_amount_in: None,
1394 fee_amount: None,
1395 service_fee_amount: None,
1396 service_fee_asset: None,
1397 asset_decimals: 6,
1398 asset_contract: None,
1399 }
1400 }
1401
1402 #[test_all]
1403 fn payment_with_conversion_info_injects_into_lightning_details() {
1404 let payment = crate::Payment {
1405 id: "p1".to_string(),
1406 payment_type: crate::PaymentType::Send,
1407 status: crate::PaymentStatus::Pending,
1408 amount: 1_000,
1409 fees: 0,
1410 timestamp: 100,
1411 method: crate::PaymentMethod::Lightning,
1412 details: Some(PaymentDetails::Lightning {
1413 description: Some("desc".to_string()),
1414 invoice: "lnbc1".to_string(),
1415 destination_pubkey: "02aa".to_string(),
1416 htlc_details: crate::SparkHtlcDetails {
1417 payment_hash: "hash1".to_string(),
1418 preimage: None,
1419 expiry_time: 0,
1420 status: crate::SparkHtlcStatus::PreimageShared,
1421 },
1422 lnurl_pay_info: None,
1423 lnurl_withdraw_info: None,
1424 lnurl_receive_metadata: None,
1425 conversion_info: None,
1426 }),
1427 conversion_details: None,
1428 };
1429
1430 let out = payment_with_conversion_info(payment, Some(boltz_info("swap1")));
1431
1432 assert_eq!(out.status, crate::PaymentStatus::Pending);
1433 let Some(PaymentDetails::Lightning {
1434 invoice,
1435 description,
1436 conversion_info,
1437 ..
1438 }) = out.details
1439 else {
1440 panic!("expected Lightning details");
1441 };
1442 assert_eq!(invoice, "lnbc1");
1444 assert_eq!(description.as_deref(), Some("desc"));
1445 assert!(matches!(
1446 conversion_info,
1447 Some(ConversionInfo::Boltz { ref swap_id, .. }) if swap_id == "swap1"
1448 ));
1449 }
1450
1451 #[test_all]
1452 fn payment_with_conversion_info_passes_through_variants_without_a_slot() {
1453 let payment = crate::Payment {
1454 id: "p1".to_string(),
1455 payment_type: crate::PaymentType::Send,
1456 status: crate::PaymentStatus::Completed,
1457 amount: 1_000,
1458 fees: 0,
1459 timestamp: 100,
1460 method: crate::PaymentMethod::Withdraw,
1461 details: Some(PaymentDetails::Withdraw {
1462 tx_id: "tx1".to_string(),
1463 vout: 0,
1464 destination_address: "bcrt1p_destination".to_string(),
1465 }),
1466 conversion_details: None,
1467 };
1468
1469 let out = payment_with_conversion_info(payment, Some(boltz_info("swap1")));
1470
1471 assert!(matches!(
1472 out.details,
1473 Some(PaymentDetails::Withdraw { tx_id, .. }) if tx_id == "tx1"
1474 ));
1475 }
1476
1477 #[test_all]
1480 fn resolve_target_overpay_uses_request_when_in_range() {
1481 assert_eq!(resolve_target_overpay_bps(Some(50), Some(75)).unwrap(), 50);
1482 }
1483
1484 #[test_all]
1485 fn resolve_target_overpay_falls_back_to_config_default() {
1486 assert_eq!(resolve_target_overpay_bps(None, Some(75)).unwrap(), 75);
1487 }
1488
1489 #[test_all]
1490 fn resolve_target_overpay_falls_back_to_built_in_default() {
1491 assert_eq!(
1492 resolve_target_overpay_bps(None, None).unwrap(),
1493 DEFAULT_TARGET_OVERPAY_BPS
1494 );
1495 }
1496
1497 #[test_all]
1498 fn resolve_target_overpay_request_zero_opts_out() {
1499 assert_eq!(resolve_target_overpay_bps(Some(0), Some(50)).unwrap(), 0);
1500 }
1501
1502 #[test_all]
1503 fn resolve_target_overpay_rejects_out_of_range_request() {
1504 let too_high = MAX_TARGET_OVERPAY_BPS + 1;
1505 assert!(matches!(
1506 resolve_target_overpay_bps(Some(too_high), None),
1507 Err(SdkError::InvalidInput(_))
1508 ));
1509 }
1510
1511 #[test_all]
1514 fn inflate_target_amount_zero_bps_is_identity() {
1515 assert_eq!(inflate_target_amount(1_000_000, 0), 1_000_000);
1516 }
1517
1518 #[test_all]
1519 fn inflate_target_amount_applies_bps_pad() {
1520 assert_eq!(inflate_target_amount(1_000_000, 25), 1_002_500);
1522 }
1523
1524 #[test_all]
1525 fn inflate_target_amount_truncates_sub_unit_pad() {
1526 assert_eq!(inflate_target_amount(100, 25), 100);
1528 }
1529
1530 #[test_all]
1533 fn convert_stable_to_sats_at_par_6dp() {
1534 assert_eq!(
1536 convert_source_amount_to_sats(1_000_000, 6, 100_000.0).unwrap(),
1537 1000
1538 );
1539 }
1540
1541 #[test_all]
1542 fn convert_stable_to_sats_at_a_different_rate() {
1543 assert_eq!(
1545 convert_source_amount_to_sats(1_000_000, 6, 50_000.0).unwrap(),
1546 2000
1547 );
1548 }
1549
1550 #[test_all]
1551 fn convert_stable_to_sats_matches_across_decimals_at_same_usd_value() {
1552 let sats_6 = convert_source_amount_to_sats(1_000_000, 6, 100_000.0).unwrap();
1554 let sats_18 =
1555 convert_source_amount_to_sats(1_000_000_000_000_000_000, 18, 100_000.0).unwrap();
1556 assert_eq!(sats_6, sats_18);
1557 assert_eq!(sats_6, 1000);
1558 }
1559
1560 #[test_all]
1561 fn convert_stable_to_sats_zero_input_is_zero() {
1562 assert_eq!(convert_source_amount_to_sats(0, 6, 100_000.0).unwrap(), 0);
1563 }
1564
1565 #[test_all]
1566 fn convert_stable_to_sats_rejects_nan_rate() {
1567 assert!(matches!(
1570 convert_source_amount_to_sats(1_000_000, 6, f64::NAN),
1571 Err(SdkError::Generic(_))
1572 ));
1573 }
1574
1575 #[test_all]
1576 fn convert_stable_to_sats_rejects_negative_rate() {
1577 assert!(matches!(
1578 convert_source_amount_to_sats(1_000_000, 6, -100_000.0),
1579 Err(SdkError::Generic(_))
1580 ));
1581 }
1582
1583 #[test_all]
1588 fn build_receive_payment_request_evm_emits_eip_681_uri() {
1589 let uri = build_receive_payment_request(
1590 "0x00Df20df75800ca8f40080505a7a802331C1321c",
1591 "arbitrum",
1592 Some("42161"),
1593 Some("0xaf88d065e77c8cC2239327C5EDb3A432268e5831"),
1594 1_000_000,
1595 )
1596 .unwrap();
1597 assert!(uri.starts_with("ethereum:"), "got {uri}");
1598 assert!(uri.contains("42161"), "chain_id must appear: {uri}");
1599 assert!(
1600 uri.contains("0xaf88d065e77c8cC2239327C5EDb3A432268e5831"),
1601 "token contract must appear: {uri}"
1602 );
1603 assert!(uri.contains("1000000"), "amount must appear: {uri}");
1604 }
1605
1606 #[test_all]
1609 fn build_receive_payment_request_solana_returns_bare_address() {
1610 let addr = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM";
1611 let out = build_receive_payment_request(addr, "solana", None, None, 1_000_000).unwrap();
1612 assert_eq!(out, addr);
1613 }
1614
1615 #[test_all]
1617 fn build_receive_payment_request_tron_returns_bare_address() {
1618 let addr = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
1619 let out = build_receive_payment_request(addr, "tron", None, None, 1_000_000).unwrap();
1620 assert_eq!(out, addr);
1621 }
1622
1623 #[test_all]
1626 fn build_receive_payment_request_rejects_unrecognized_address() {
1627 let err =
1628 build_receive_payment_request("not-an-address", "arbitrum", None, None, 1_000_000)
1629 .unwrap_err();
1630 assert!(matches!(err, SdkError::Generic(_)));
1631 }
1632
1633 #[test_all]
1637 fn build_receive_payment_request_rejects_family_chain_mismatch() {
1638 let evm_addr = "0x00Df20df75800ca8f40080505a7a802331C1321c";
1639 let err =
1640 build_receive_payment_request(evm_addr, "solana", None, None, 1_000_000).unwrap_err();
1641 match err {
1642 SdkError::Generic(msg) => {
1643 assert!(
1644 msg.contains("Evm") && msg.contains("solana"),
1645 "unexpected message: {msg}"
1646 );
1647 }
1648 other => panic!("expected Generic mismatch error, got {other:?}"),
1649 }
1650
1651 let solana_addr = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM";
1652 assert!(
1653 build_receive_payment_request(solana_addr, "tron", None, None, 1_000_000).is_err(),
1654 "solana address must not be accepted for tron route",
1655 );
1656 }
1657
1658 #[cfg(feature = "sqlite")]
1659 mod receive_matching {
1660 use super::*;
1661 use crate::{
1662 PaymentDetails, PaymentMethod, PaymentStatus, PaymentType, SparkInvoicePaymentDetails,
1663 TokenMetadata, TokenTransactionType,
1664 };
1665
1666 fn sqlite_storage() -> Arc<dyn crate::Storage> {
1667 let mut dir = std::env::temp_dir();
1668 dir.push(format!(
1669 "breez-xchain-receive-match-{}",
1670 uuid::Uuid::new_v4()
1671 ));
1672 std::fs::create_dir_all(&dir).unwrap();
1673 Arc::new(crate::SqliteStorage::new(&dir).unwrap())
1674 }
1675
1676 async fn storage_with_open_receive(invoice: &str) -> Arc<dyn crate::Storage> {
1677 let storage = sqlite_storage();
1678 let data = orchestra::storage_adapter::OrchestraSwapData {
1679 quote_id: "q_match".to_string(),
1680 order_id: None,
1681 read_token: None,
1682 quote_read_token: None,
1683 recipient_address: "sp1rcv".to_string(),
1684 spark_invoice: Some(invoice.to_string()),
1685 paid: false,
1686 source_chain: "arbitrum".to_string(),
1687 source_asset: "USDC".to_string(),
1688 source_chain_id: Some("42161".to_string()),
1689 source_contract_address: Some("0xUSDC".to_string()),
1690 source_decimals: 6,
1691 destination_chain: "spark".to_string(),
1692 destination_asset: "BTC".to_string(),
1693 destination_decimals: 8,
1694 token_identifier: None,
1695 amount_in: "1000000".to_string(),
1696 expected_amount_out: "1146".to_string(),
1697 fee_amount: Some("10000".to_string()),
1698 fee_asset: Some("USDC".to_string()),
1699 fee_asset_decimals: Some(6),
1700 expires_at: 1_700_000_120,
1701 };
1702 orchestra::storage_adapter::OrchestraStorageAdapter::new(Arc::clone(&storage))
1703 .upsert(&data)
1704 .await
1705 .unwrap();
1706 storage
1707 }
1708
1709 fn invoice_details(invoice: &str) -> SparkInvoicePaymentDetails {
1710 SparkInvoicePaymentDetails {
1711 description: None,
1712 invoice: invoice.to_string(),
1713 }
1714 }
1715
1716 fn payment(payment_type: PaymentType, details: PaymentDetails) -> crate::Payment {
1717 crate::Payment {
1718 id: "p1".to_string(),
1719 payment_type,
1720 status: PaymentStatus::Completed,
1721 amount: 1146,
1722 fees: 0,
1723 timestamp: 100,
1724 method: PaymentMethod::Spark,
1725 details: Some(details),
1726 conversion_details: None,
1727 }
1728 }
1729
1730 fn spark_details(invoice: Option<&str>) -> PaymentDetails {
1731 PaymentDetails::Spark {
1732 invoice_details: invoice.map(invoice_details),
1733 htlc_details: None,
1734 conversion_info: None,
1735 }
1736 }
1737
1738 fn token_details(invoice: &str) -> PaymentDetails {
1739 PaymentDetails::Token {
1740 metadata: TokenMetadata {
1741 identifier: "btkn1usdb".to_string(),
1742 issuer_public_key: "02aa".to_string(),
1743 name: "Bitcoin USD".to_string(),
1744 ticker: "USDB".to_string(),
1745 decimals: 6,
1746 max_supply: 0,
1747 is_freezable: true,
1748 },
1749 tx_hash: "abcd".to_string(),
1750 tx_type: TokenTransactionType::Transfer,
1751 invoice_details: Some(invoice_details(invoice)),
1752 conversion_info: None,
1753 }
1754 }
1755
1756 async fn stored_conversion_status(
1757 storage: &Arc<dyn crate::Storage>,
1758 payment_id: &str,
1759 ) -> Option<crate::ConversionStatus> {
1760 let stored = storage
1761 .get_payment_by_id(payment_id.to_string())
1762 .await
1763 .unwrap();
1764 match extract_conversion_info(stored.details) {
1765 Some(ConversionInfo::Orchestra { status, .. }) => Some(status),
1766 _ => None,
1767 }
1768 }
1769
1770 #[tokio::test]
1771 async fn spark_receive_fulfilling_an_open_invoice_gets_pending_conversion_info() {
1772 let storage = storage_with_open_receive("spark1inv").await;
1773 let payment = payment(PaymentType::Receive, spark_details(Some("spark1inv")));
1774 storage.apply_payment_update(payment.clone()).await.unwrap();
1775
1776 let attached = attach_receive_metadata_for_payment(&storage, &payment)
1777 .await
1778 .unwrap();
1779
1780 assert!(attached);
1781 assert_eq!(
1782 stored_conversion_status(&storage, &payment.id).await,
1783 Some(crate::ConversionStatus::Pending)
1784 );
1785 }
1786
1787 #[tokio::test]
1788 async fn token_receive_fulfilling_an_open_invoice_gets_pending_conversion_info() {
1789 let storage = storage_with_open_receive("spark1inv").await;
1790 let payment = payment(PaymentType::Receive, token_details("spark1inv"));
1791 storage.apply_payment_update(payment.clone()).await.unwrap();
1792
1793 let attached = attach_receive_metadata_for_payment(&storage, &payment)
1794 .await
1795 .unwrap();
1796
1797 assert!(attached);
1798 assert_eq!(
1799 stored_conversion_status(&storage, &payment.id).await,
1800 Some(crate::ConversionStatus::Pending)
1801 );
1802 }
1803
1804 #[tokio::test]
1805 async fn a_payment_already_carrying_a_conversion_is_left_alone() {
1806 let storage = storage_with_open_receive("spark1inv").await;
1807 let payment = payment(PaymentType::Receive, spark_details(Some("spark1inv")));
1808 storage.apply_payment_update(payment.clone()).await.unwrap();
1809 let mut completed = orchestra_receive_info_placeholder();
1810 if let ConversionInfo::Orchestra { status, .. } = &mut completed {
1811 *status = crate::ConversionStatus::Completed;
1812 }
1813 storage
1814 .insert_payment_metadata(
1815 payment.id.clone(),
1816 crate::PaymentMetadata {
1817 conversion_info: Some(completed),
1818 ..Default::default()
1819 },
1820 )
1821 .await
1822 .unwrap();
1823
1824 let attached = attach_receive_metadata_for_payment(&storage, &payment)
1825 .await
1826 .unwrap();
1827
1828 assert!(!attached);
1829 assert_eq!(
1830 stored_conversion_status(&storage, &payment.id).await,
1831 Some(crate::ConversionStatus::Completed)
1832 );
1833 }
1834
1835 #[tokio::test]
1836 async fn other_payments_get_nothing() {
1837 let storage = storage_with_open_receive("spark1inv").await;
1838
1839 for payment in [
1840 payment(PaymentType::Receive, spark_details(Some("spark1other"))),
1841 payment(PaymentType::Receive, spark_details(None)),
1842 payment(PaymentType::Send, spark_details(Some("spark1inv"))),
1843 ] {
1844 let attached = attach_receive_metadata_for_payment(&storage, &payment)
1845 .await
1846 .unwrap();
1847 assert!(!attached, "{payment:?}");
1848 }
1849 }
1850
1851 fn orchestra_receive_info_placeholder() -> ConversionInfo {
1852 ConversionInfo::Orchestra {
1853 order_id: "ord".to_string(),
1854 quote_id: "q_match".to_string(),
1855 read_token: None,
1856 chain: "arbitrum".to_string(),
1857 chain_id: Some("42161".to_string()),
1858 asset: "USDC".to_string(),
1859 recipient_address: "sp1rcv".to_string(),
1860 asset_amount_in: Some(1_000_000),
1861 estimated_out: 1146,
1862 delivered_amount: Some(1146),
1863 external_tx_hash: Some("0xdeposit".to_string()),
1864 status: crate::ConversionStatus::Pending,
1865 fee_amount: Some(10_000),
1866 service_fee_amount: Some(10_000),
1867 service_fee_asset: Some("USDC".to_string()),
1868 service_fee_asset_decimals: Some(6),
1869 asset_decimals: 6,
1870 asset_contract: Some("0xUSDC".to_string()),
1871 }
1872 }
1873 }
1874}