Skip to main content

breez_sdk_spark/cross_chain/
mod.rs

1//! Cross-chain payment providers.
2//!
3//! The [`CrossChainService`] trait abstracts route discovery, quoting, and
4//! sending. Each provider module (e.g. `orchestra`, `boltz`) implements it.
5
6pub(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
27/// SDK-level bounds for cross-chain slippage.
28pub(crate) const MIN_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 10;
29pub(crate) const MAX_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 500;
30/// Used when neither the request nor [`crate::Config::default_slippage_bps`]
31/// supplies a value.
32pub(crate) const DEFAULT_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 100;
33
34/// Bounds for the target-overpay pad applied to the user's destination amount
35/// on `FeesExcluded` conversion sends. `0` opts out; `500` caps at 5% (matches
36/// the slippage upper bound).
37pub(crate) const MIN_TARGET_OVERPAY_BPS: u32 = 0;
38pub(crate) const MAX_TARGET_OVERPAY_BPS: u32 = 500;
39/// Default pad applied when neither the request nor
40/// [`crate::CrossChainConfig::default_target_overpay_bps`] specifies one.
41/// Calibrated to the observed Orchestra delivery drift; tune per provider as
42/// real-world data accrues.
43pub(crate) const DEFAULT_TARGET_OVERPAY_BPS: u32 = 15;
44/// Tickers treated as $1-pegged for par-value rescaling. Adding a non-USD
45/// ticker would silently misreport `fee_amount` for routes using it.
46const USD_STABLE_ASSETS: &[&str] = &["USDB", "USDC", "USDT", "USDT0"];
47
48/// Each provider's background monitor interval.
49pub(crate) const MONITOR_INTERVAL: Duration = Duration::from_secs(30);
50
51/// Resolves the BTC-leg [`TransferId`] for a cross-chain send. A
52/// caller-supplied `idempotency_key` from [`crate::SendPaymentRequest`]
53/// wins so the top-level `get_payment_by_id(idempotency_key)` lookup in
54/// `orchestrate_send` can short-circuit retries; otherwise we derive a
55/// `UUIDv5` from `fallback_seed` (the provider's quote/swap id) so that
56/// re-sending the same prepared shape still hits Spark's protocol-level
57/// dedup. Mirrors the stable-balance per-receive convention. Token-source
58/// sends ignore the return value: [`spark_wallet::transfer_tokens`] has
59/// no idempotency hook.
60pub(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/// The source asset a cross-chain route accepts as input on the Spark side.
87#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
88#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
89pub enum SourceAsset {
90    /// Native BTC (sats).
91    Bitcoin,
92    /// A Spark token, identified by its bech32m `token_identifier` (e.g. `btkn1...`).
93    Token { token_identifier: String },
94}
95
96/// How the caller wants fees handled against the request `amount`.
97///
98/// - `FeesExcluded`: `amount` is the provider invoice/deposit target; the
99///   wallet pays `amount + source_transfer_fee_sats` in total.
100/// - `FeesIncluded`: `amount` is the wallet's total sats budget; the provider
101///   leg is sized so `amount_in + source_transfer_fee_sats <= amount`.
102#[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/// Filter for [`CrossChainService::get_routes`] and the public
119/// `get_cross_chain_routes()` API.
120#[derive(Clone, Debug, Deserialize, Serialize)]
121#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
122pub enum CrossChainRouteFilter {
123    /// Routes for sending from Spark to another chain.
124    /// Filtered by the parsed recipient address details.
125    Send {
126        address_details: CrossChainAddressDetails,
127    },
128    /// Routes for receiving to Spark from another chain.
129    /// Optionally filtered by the source token contract address.
130    Receive { contract_address: Option<String> },
131}
132
133/// A single route available for cross-chain transfers, tagged with the provider
134/// that offers it. Returned by `get_cross_chain_routes()`.
135#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
136#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
137pub struct CrossChainRoutePair {
138    /// Which provider offers this route.
139    pub provider: CrossChainProvider,
140    /// Destination blockchain (e.g. `"base"`, `"solana"`, `"tron"`).
141    pub chain: String,
142    /// Stable chain identifier (e.g. EVM `chainId` as a decimal string).
143    /// `None` for non-EVM chains that don't expose one, or when the
144    /// provider doesn't surface it.
145    pub chain_id: Option<String>,
146    /// Destination asset symbol (e.g. `"USDC"`, `"USDT"`).
147    pub asset: String,
148    /// Token contract / mint address on the destination chain.
149    pub contract_address: Option<String>,
150    /// Decimal places for the destination asset.
151    pub decimals: u8,
152    /// Whether the route supports exact-out mode.
153    pub exact_out_eligible: bool,
154    /// The source assets this route accepts on the Spark side.
155    ///
156    /// Boltz routes accept `[SourceAsset::Bitcoin]`. Orchestra routes accept
157    /// one or more of `Bitcoin` / `Token(...)` (a given destination endpoint
158    /// may be fronted by multiple source variants on Orchestra).
159    pub supported_sources: Vec<SourceAsset>,
160}
161
162impl CrossChainRoutePair {
163    /// Infers the destination address family from the route's
164    /// `contract_address`. Returns `None` for native-asset routes (no
165    /// contract address) or if the address format isn't recognized; callers
166    /// should treat that as "skip the address-family validation".
167    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/// Per-provider service registry plus shared cross-chain dependencies (today:
177/// the cached `FiatService`). Keeping the cache here scopes it to cross-chain
178/// flows; `sdk.fiat_service` stays uncached for general fiat consumers.
179#[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    /// Look up a provider, returning a friendly error if missing.
198    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    /// Cached fiat service shared with every cross-chain provider. Read
212    /// through this on the prepare path so the TTL window is shared.
213    pub fn fiat_service(&self) -> &Arc<dyn FiatService> {
214        &self.fiat_service
215    }
216}
217
218/// Provider-internal state produced by `prepare` and consumed by `send`.
219/// Typed per provider so the send stage can resume without re-quoting and
220/// without a serde round-trip. Callers should round-trip this value as-is.
221#[derive(Debug, Clone, Serialize, Deserialize)]
222#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
223pub enum CrossChainProviderContext {
224    Orchestra {
225        /// Orchestra quote id, passed back on `/submit`.
226        quote_id: String,
227        /// Spark address Orchestra expects the deposit transfer to land on.
228        deposit_address: String,
229        /// Spark-side deposit amount in the route's source-asset base units.
230        #[serde(default)]
231        deposit_amount: u128,
232    },
233    Boltz {
234        /// Boltz swap id.
235        swap_id: String,
236        /// Hold invoice to pay.
237        invoice: String,
238        /// Hold invoice amount in sats.
239        #[serde(default)]
240        invoice_amount_sats: u64,
241        /// Slippage tolerance in basis points.
242        max_slippage_bps: u32,
243    },
244}
245
246/// Data stashed on the prepared send payment so the provider can resume
247/// the send stage without re-quoting.
248#[derive(Debug, Clone)]
249pub(crate) struct CrossChainPrepared {
250    pub amount_in: u128,
251    /// `amount_in` expressed in the cross-chain (destination) asset's base
252    /// units, via the fiat rate or decimal rescale the SDK used at prepare
253    /// time.
254    pub asset_amount_in: u128,
255    /// Amount the recipient will receive, in cross-chain asset base units.
256    pub estimated_out: u128,
257    /// Total user-visible fee in cross-chain asset base units. Covers provider
258    /// spread, bridge/gas, and DEX slippage. On the token-conversion path it
259    /// also rolls in the LN routing budget; on the direct path that budget
260    /// lives separately in `source_transfer_fee_sats`. The dispatcher
261    /// overrides this on the conversion path to reflect the token-side debit.
262    pub fee_amount: u128,
263    /// Provider's own service fee/spread, in its native denomination.
264    pub service_fee_amount: u128,
265    /// Asset that the service fee is denominated in. Unset means BTC sats.
266    pub service_fee_asset: Option<String>,
267    /// Sats cost to the wallet of moving `amount_in` from the wallet to the
268    /// provider. For Boltz: the Lightning routing fee budget for paying the
269    /// hold invoice (a budget, not a central estimate — enforced as a hard
270    /// cap at send time). For Orchestra: the Spark transfer fee (0 today;
271    /// non-zero in the future).
272    ///
273    /// Semantically distinct from `fee_amount` (provider's service fee /
274    /// spread) and from destination-chain costs (baked into `estimated_out`).
275    /// Denominated in sats — the field assumes a sats-denominated source leg.
276    pub source_transfer_fee_sats: u64,
277    /// Fee mode the prepare was called with. Needed at send time so the
278    /// provider knows whether to apply FeesIncluded-style overpayment.
279    pub fee_mode: CrossChainFeeMode,
280    pub expires_at: String,
281    pub pair: CrossChainRoutePair,
282    pub recipient_address: String,
283    /// The `token_identifier` on the Spark source (e.g. USDB). `None` for BTC sats.
284    pub token_identifier: Option<String>,
285    /// Provider-internal state carried between `prepare` and `send`.
286    pub provider_context: CrossChainProviderContext,
287}
288
289/// Abstraction over cross-chain bridge/swap providers.
290///
291/// Each implementation owns its own client, caching, and background monitoring.
292/// The SDK dispatches to the provider via this trait.
293#[macros::async_trait]
294pub(crate) trait CrossChainService: Send + Sync {
295    /// Returns the available cross-chain route pairs.
296    ///
297    /// The returned [`CrossChainRoutePair`] always describes the non-Spark
298    /// side of the route. The [`CrossChainRouteFilter`] controls direction
299    /// and optional filtering.
300    async fn get_routes(
301        &self,
302        filter: &CrossChainRouteFilter,
303    ) -> Result<Vec<CrossChainRoutePair>, SdkError>;
304
305    /// Fetch a quote for a cross-chain send.
306    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    /// Execute the send: transfer funds to the deposit address, submit to
317    /// the provider, persist metadata, monitor to terminal, and return the
318    /// resulting [`Payment`].
319    ///
320    /// `idempotency_key` is the caller-provided key from `SendPaymentRequest`.
321    /// Providers should use it as the underlying Spark `TransferId` so the
322    /// outbound transfer is protocol-level idempotent on retry; if `None`,
323    /// the provider derives a deterministic key from its own quote/swap id
324    /// (same shape as the stable-balance per-receive convention). Only the
325    /// BTC-source branch benefits — token transfers have no upstream
326    /// idempotency hook, and the top-level dispatcher already rejects
327    /// idempotency keys for token-source sends.
328    ///
329    /// Each provider owns the polling-to-terminal step internally — the
330    /// SDK dispatcher does not wrap this with an additional wait.
331    async fn send(
332        &self,
333        prepared: &CrossChainPrepared,
334        idempotency_key: Option<String>,
335    ) -> Result<crate::Payment, SdkError>;
336}
337
338/// Fetches the BTC/USD rate from the Breez Server fiat feed. Errors if the
339/// feed is unreachable, missing the USD entry, or returns a non-finite value.
340pub(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/// `sats * fiat_rate * 10^dest_decimals / 10^8`. Sub-base-unit truncation
361/// is absorbed by the route's slippage tolerance.
362#[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
388/// Best-available fee: realized `asset_amount_in − delivered_amount` on
389/// `Completed`, else the prepare-time estimate. Refunded/failed keep the
390/// estimate (the realized formula would be misleading).
391pub(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
403/// Rescales an amount between two base-unit precisions. Assumes
404/// `1 source unit ≈ 1 dest unit` at face value — only valid for USD-stable
405/// pairs. Errors on overflow.
406pub(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/// Inverse of [`convert_sats_to_destination_amount`]: returns the sats whose
431/// fiat-equivalent matches the given USD-stable `destination_amount`.
432/// Errors on a non-positive `fiat_rate`.
433#[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        // A v4 UUID is a valid TransferId — using one here checks that the
469        // caller-supplied key wins outright.
470        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        // The provider tag in the seed prevents an Orchestra `quote-1` and a
495        // hypothetical Boltz `quote-1` from colliding on the same TransferId.
496        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        // 10_000 sats at $60_000/BTC → $6.00 = 6_000_000 USDC base units.
510        let dest = convert_sats_to_destination_amount(10_000, 60_000.0, 6).unwrap();
511        assert_eq!(dest, 6_000_000);
512        // Inverse must recover the source sats.
513        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        // 1 USDC ($1.00 = 1_000_000 base units) at $60_000/BTC → 1666 sats (floor).
520        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    // ---- compute_terminal_fee_amount ----
577
578    #[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), // asset_amount_in
583            Some(997_498),   // delivered_amount
584            Some(20_434),    // prepare-time estimate
585        );
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        // Refunded payments don't have a realized fee semantic; the estimate
592        // is the best we can show (and the realized formula would produce
593        // garbage because delivered_amount is 0/None on a refund).
594        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        // Pre-upgrade rows have no asset_amount_in; realized fee can't be
617        // computed, so the stored estimate stays as-is.
618        let realized = compute_terminal_fee_amount(
619            &crate::ConversionStatus::Completed,
620            None, // asset_amount_in missing
621            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        // Should never happen on Completed per the contract, but defend
630        // against the edge anyway.
631        let realized = compute_terminal_fee_amount(
632            &crate::ConversionStatus::Completed,
633            Some(1_020_434),
634            None, // delivered_amount missing
635            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        // Rare but possible: provider over-delivers vs source.
643        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    /// Regression: `CrossChainProviderContext::Boltz.invoice_amount_sats` must
657    /// be the source of truth for the LN-leg amount, distinct from
658    /// `CrossChainPrepared::amount_in` (which can carry a user-facing display
659    /// value such as token base units after the dispatcher's conversion-path
660    /// override). Conflating the two persisted USDB base units into the
661    /// `invoice_amount_sats` field of `ConversionInfo::Boltz`, showing a
662    /// ~$1,200,000-sat "from" amount for a ~$1 send. This test asserts the
663    /// two fields are independently representable + survive a serde
664    /// round-trip with their distinct values.
665    #[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    /// Pre-bug-fix persisted contexts lack `invoice_amount_sats`. Serde must
690    /// default the missing field to 0 rather than failing to deserialize — the
691    /// downstream send-time error becomes obvious instead of corrupting the
692    /// stored Payment.
693    #[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    /// Same invariant for Orchestra: `deposit_amount` is the source of truth
714    /// for the deposit transfer size and is distinct from
715    /// `CrossChainPrepared::amount_in`.
716    #[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}