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