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::{ConversionInfo, CrossChainAddressDetails, PaymentDetails, 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/// Attaches a cross-chain [`ConversionInfo`] to a freshly-converted
52/// [`Payment`]. The payment's top-level `status` is left as-is: it reflects
53/// the local Spark/Token/Lightning leg's settlement, while the cross-chain
54/// pending state lives inside `conversion_info.status`.
55pub(crate) fn payment_with_conversion_info(
56    mut payment: crate::Payment,
57    conversion_info: Option<ConversionInfo>,
58) -> crate::Payment {
59    payment.details = match payment.details {
60        Some(PaymentDetails::Spark {
61            invoice_details,
62            htlc_details,
63            ..
64        }) => Some(PaymentDetails::Spark {
65            invoice_details,
66            htlc_details,
67            conversion_info,
68        }),
69        Some(PaymentDetails::Token {
70            metadata,
71            tx_hash,
72            tx_type,
73            invoice_details,
74            ..
75        }) => Some(PaymentDetails::Token {
76            metadata,
77            tx_hash,
78            tx_type,
79            invoice_details,
80            conversion_info,
81        }),
82        Some(PaymentDetails::Lightning {
83            description,
84            invoice,
85            destination_pubkey,
86            htlc_details,
87            lnurl_pay_info,
88            lnurl_withdraw_info,
89            lnurl_receive_metadata,
90            ..
91        }) => Some(PaymentDetails::Lightning {
92            description,
93            invoice,
94            destination_pubkey,
95            htlc_details,
96            lnurl_pay_info,
97            lnurl_withdraw_info,
98            lnurl_receive_metadata,
99            conversion_info,
100        }),
101        other => other,
102    };
103    payment
104}
105
106/// Resolves the BTC-leg [`TransferId`] for a cross-chain send. A
107/// caller-supplied `idempotency_key` from [`crate::SendPaymentRequest`]
108/// wins so the top-level `get_payment_by_id(idempotency_key)` lookup in
109/// `orchestrate_send` can short-circuit retries; otherwise we derive a
110/// `UUIDv5` from `fallback_seed` (the provider's quote/swap id) so that
111/// re-sending the same prepared shape still hits Spark's protocol-level
112/// dedup. Mirrors the stable-balance per-receive convention. Token-source
113/// sends ignore the return value: [`spark_wallet::transfer_tokens`] has
114/// no idempotency hook.
115pub(crate) fn derive_btc_leg_transfer_id(
116    idempotency_key: Option<&str>,
117    fallback_seed: &str,
118) -> Result<TransferId, SdkError> {
119    match idempotency_key {
120        Some(key) => TransferId::from_str(key).map_err(SdkError::Generic),
121        None => Ok(TransferId::from_name(fallback_seed)),
122    }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
126#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
127pub enum CrossChainProvider {
128    Orchestra,
129    Boltz,
130}
131
132impl std::fmt::Display for CrossChainProvider {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        match self {
135            Self::Orchestra => f.write_str("Orchestra"),
136            Self::Boltz => f.write_str("Boltz"),
137        }
138    }
139}
140
141/// The source asset a cross-chain route accepts as input on the Spark side.
142#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
143#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
144pub enum SourceAsset {
145    /// Native BTC (sats).
146    Bitcoin,
147    /// A Spark token, identified by its bech32m `token_identifier` (e.g. `btkn1...`).
148    Token { token_identifier: String },
149}
150
151/// The chain a cross-chain route is funded from, orthogonal to the
152/// [`SourceAsset`] that moves.
153#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
154#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
155pub enum SourceChain {
156    /// Paid over Spark, using a Bitcoin or token source asset.
157    Spark,
158    /// Paid over Lightning, using a Bitcoin source asset.
159    Lightning,
160    /// Paid on-chain to Bitcoin (L1), using a Bitcoin source asset.
161    Bitcoin,
162}
163
164impl std::fmt::Display for SourceChain {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        match self {
167            Self::Spark => f.write_str("Spark"),
168            Self::Lightning => f.write_str("Lightning"),
169            Self::Bitcoin => f.write_str("Bitcoin"),
170        }
171    }
172}
173
174/// How the caller wants fees handled against the request `amount`.
175///
176/// - `FeesExcluded`: `amount` is the provider invoice/deposit target; the
177///   wallet pays `amount + source_transfer_fee_sats` in total.
178/// - `FeesIncluded`: `amount` is the wallet's total sats budget; the provider
179///   leg is sized so `amount_in + source_transfer_fee_sats <= amount`.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
181#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
182pub enum CrossChainFeeMode {
183    FeesExcluded,
184    FeesIncluded,
185}
186
187impl From<crate::FeePolicy> for CrossChainFeeMode {
188    fn from(policy: crate::FeePolicy) -> Self {
189        match policy {
190            crate::FeePolicy::FeesExcluded => Self::FeesExcluded,
191            crate::FeePolicy::FeesIncluded => Self::FeesIncluded,
192        }
193    }
194}
195
196/// Filter for [`CrossChainService::get_routes`] and the public
197/// `get_cross_chain_routes()` API.
198#[derive(Clone, Debug, Deserialize, Serialize)]
199#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
200pub enum CrossChainRouteFilter {
201    /// Routes for sending from the Spark wallet to another chain.
202    /// Filtered by the parsed recipient address details.
203    Send {
204        address_details: CrossChainAddressDetails,
205    },
206    /// Routes for receiving to Spark from another chain.
207    /// Optionally filtered by the source token contract address.
208    Receive { contract_address: Option<String> },
209    /// Routes for a payment link that sends a stablecoin funded by an external
210    /// rail (Cash App over Lightning) rather than the Spark wallet.
211    /// Filtered by the parsed recipient address details.
212    PaymentLink {
213        address_details: CrossChainAddressDetails,
214    },
215}
216
217/// A single route available for cross-chain transfers, tagged with the provider
218/// that offers it. Returned by `get_cross_chain_routes()`.
219#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
220#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
221pub struct CrossChainRoutePair {
222    /// Which provider offers this route.
223    pub provider: CrossChainProvider,
224    /// Destination blockchain (e.g. `"base"`, `"solana"`, `"tron"`).
225    pub chain: String,
226    /// Stable chain identifier (e.g. EVM `chainId` as a decimal string).
227    /// `None` for non-EVM chains that don't expose one, or when the
228    /// provider doesn't surface it.
229    pub chain_id: Option<String>,
230    /// Destination asset symbol (e.g. `"USDC"`, `"USDT"`).
231    pub asset: String,
232    /// Token contract / mint address on the destination chain.
233    pub contract_address: Option<String>,
234    /// Decimal places for the destination asset.
235    pub decimals: u8,
236    /// Whether the route supports exact-out mode.
237    pub exact_out_eligible: bool,
238    /// The source assets this route accepts on the Spark side.
239    ///
240    /// Boltz routes accept `[SourceAsset::Bitcoin]`. Orchestra routes accept
241    /// one or more of `Bitcoin` / `Token(...)` (a given destination endpoint
242    /// may be fronted by multiple source variants on Orchestra).
243    pub supported_sources: Vec<SourceAsset>,
244    /// The chains this route can be paid over, orthogonal to
245    /// `supported_sources` (the asset moved).
246    ///
247    /// This is the actual funding rail, which differs by provider: Boltz routes
248    /// are always paid over Lightning. Orchestra send routes report Spark, and
249    /// Orchestra payment-link routes report Lightning.
250    pub supported_source_chains: Vec<SourceChain>,
251}
252
253impl CrossChainRoutePair {
254    /// Infers the destination address family from the route's
255    /// `contract_address`. Returns `None` for native-asset routes (no
256    /// contract address) or if the address format isn't recognized; callers
257    /// should treat that as "skip the address-family validation".
258    pub(crate) fn destination_address_family(
259        &self,
260    ) -> Option<breez_sdk_common::input::CrossChainAddressFamily> {
261        self.contract_address
262            .as_deref()
263            .and_then(breez_sdk_common::input::detect_address_family)
264    }
265}
266
267/// Per-provider service registry plus shared cross-chain dependencies (today:
268/// the cached `FiatService`). Keeping the cache here scopes it to cross-chain
269/// flows; `sdk.fiat_service` stays uncached for general fiat consumers.
270#[derive(Clone)]
271pub(crate) struct CrossChainContext {
272    providers: HashMap<CrossChainProvider, Arc<dyn CrossChainService>>,
273    fiat_service: Arc<dyn FiatService>,
274}
275
276impl CrossChainContext {
277    pub fn new(fiat_service: Arc<dyn FiatService>) -> Self {
278        Self {
279            providers: HashMap::new(),
280            fiat_service,
281        }
282    }
283
284    pub fn insert(&mut self, key: CrossChainProvider, service: Arc<dyn CrossChainService>) {
285        self.providers.insert(key, service);
286    }
287
288    /// Look up a provider, returning a friendly error if missing.
289    pub fn get(
290        &self,
291        provider: CrossChainProvider,
292    ) -> Result<&Arc<dyn CrossChainService>, SdkError> {
293        self.providers.get(&provider).ok_or_else(|| {
294            SdkError::InvalidInput(format!("Cross-chain provider {provider} is not available."))
295        })
296    }
297
298    pub fn values(&self) -> impl Iterator<Item = &Arc<dyn CrossChainService>> {
299        self.providers.values()
300    }
301
302    /// Cached fiat service shared with every cross-chain provider. Read
303    /// through this on the prepare path so the TTL window is shared.
304    pub fn fiat_service(&self) -> &Arc<dyn FiatService> {
305        &self.fiat_service
306    }
307}
308
309/// Provider-internal state produced by `prepare` and consumed by `send`.
310/// Typed per provider so the send stage can resume without re-quoting and
311/// without a serde round-trip. Callers should round-trip this value as-is.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
314pub enum CrossChainProviderContext {
315    Orchestra {
316        /// Orchestra quote id, passed back on `/submit`.
317        quote_id: String,
318        /// Spark address Orchestra expects the deposit transfer to land on.
319        deposit_address: String,
320        /// Spark-side deposit amount in the route's source-asset base units.
321        #[serde(default)]
322        deposit_amount: u128,
323    },
324    Boltz {
325        /// Boltz swap id.
326        swap_id: String,
327        /// Hold invoice to pay.
328        invoice: String,
329        /// Hold invoice amount in sats.
330        #[serde(default)]
331        invoice_amount_sats: u64,
332        /// Slippage tolerance in basis points.
333        max_slippage_bps: u32,
334    },
335}
336
337/// Data stashed on the prepared send payment so the provider can resume
338/// the send stage without re-quoting.
339#[derive(Debug, Clone)]
340pub(crate) struct CrossChainPrepared {
341    pub amount_in: u128,
342    /// `amount_in` expressed in the cross-chain (destination) asset's base
343    /// units, via the fiat rate or decimal rescale the SDK used at prepare
344    /// time.
345    pub asset_amount_in: u128,
346    /// Amount the recipient will receive, in cross-chain asset base units.
347    pub estimated_out: u128,
348    /// Total user-visible fee in cross-chain asset base units. Covers provider
349    /// spread, bridge/gas, and DEX slippage. On the token-conversion path it
350    /// also rolls in the LN routing budget; on the direct path that budget
351    /// lives separately in `source_transfer_fee_sats`. The dispatcher
352    /// overrides this on the conversion path to reflect the token-side debit.
353    pub fee_amount: u128,
354    /// Provider's own service fee/spread, in its native denomination.
355    pub service_fee_amount: u128,
356    /// Asset that the service fee is denominated in. Unset means BTC sats.
357    pub service_fee_asset: Option<String>,
358    /// Sats cost to the wallet of moving `amount_in` from the wallet to the
359    /// provider. For Boltz: the Lightning routing fee budget for paying the
360    /// hold invoice (a budget, not a central estimate — enforced as a hard
361    /// cap at send time). For Orchestra: the Spark transfer fee (0 today;
362    /// non-zero in the future).
363    ///
364    /// Semantically distinct from `fee_amount` (provider's service fee /
365    /// spread) and from destination-chain costs (baked into `estimated_out`).
366    /// Denominated in sats — the field assumes a sats-denominated source leg.
367    pub source_transfer_fee_sats: u64,
368    /// Fee mode the prepare was called with. Needed at send time so the
369    /// provider knows whether to apply FeesIncluded-style overpayment.
370    pub fee_mode: CrossChainFeeMode,
371    pub expires_at: String,
372    pub pair: CrossChainRoutePair,
373    pub recipient_address: String,
374    /// The `token_identifier` on the Spark source (e.g. USDB). `None` for BTC sats.
375    pub token_identifier: Option<String>,
376    /// Provider-internal state carried between `prepare` and `send`.
377    pub provider_context: CrossChainProviderContext,
378}
379
380/// Abstraction over cross-chain bridge/swap providers.
381///
382/// Each implementation owns its own client, caching, and background monitoring.
383/// The SDK dispatches to the provider via this trait.
384#[macros::async_trait]
385pub(crate) trait CrossChainService: Send + Sync {
386    /// Returns the available cross-chain route pairs.
387    ///
388    /// The returned [`CrossChainRoutePair`] always describes the non-Spark
389    /// side of the route. The [`CrossChainRouteFilter`] controls direction
390    /// and optional filtering.
391    async fn get_routes(
392        &self,
393        filter: &CrossChainRouteFilter,
394    ) -> Result<Vec<CrossChainRoutePair>, SdkError>;
395
396    /// Fetch a quote for a cross-chain send or Lightning onramp. `amount` is
397    /// always in the source-leg sats/token base units; the caller converts any
398    /// USD intent to sats first. `source_chain` selects the funding chain;
399    /// `None` uses the provider's default ([`SourceChain::Spark`] for Orchestra).
400    /// `source_token_identifier` is only meaningful for a Spark source
401    /// (`None` = BTC sats, `Some` = a token).
402    #[allow(clippy::too_many_arguments)]
403    async fn prepare(
404        &self,
405        recipient_address: &str,
406        route: &CrossChainRoutePair,
407        amount: u128,
408        source_chain: Option<SourceChain>,
409        source_token_identifier: Option<String>,
410        max_slippage_bps: u32,
411        fee_mode: CrossChainFeeMode,
412    ) -> Result<CrossChainPrepared, SdkError>;
413
414    /// Execute the send: transfer funds to the deposit address, submit to
415    /// the provider, persist metadata, monitor to terminal, and return the
416    /// resulting [`Payment`].
417    ///
418    /// `idempotency_key` is the caller-provided key from `SendPaymentRequest`.
419    /// Providers should use it as the underlying Spark `TransferId` so the
420    /// outbound transfer is protocol-level idempotent on retry; if `None`,
421    /// the provider derives a deterministic key from its own quote/swap id
422    /// (same shape as the stable-balance per-receive convention). Only the
423    /// BTC-source branch benefits — token transfers have no upstream
424    /// idempotency hook, and the top-level dispatcher already rejects
425    /// idempotency keys for token-source sends.
426    ///
427    /// Each provider owns the polling-to-terminal step internally — the
428    /// SDK dispatcher does not wrap this with an additional wait.
429    async fn send(
430        &self,
431        prepared: &CrossChainPrepared,
432        idempotency_key: Option<String>,
433    ) -> Result<crate::Payment, SdkError>;
434}
435
436/// Fetches the BTC/USD rate from the Breez Server fiat feed. Errors if the
437/// feed is unreachable, missing the USD entry, or returns a non-finite value.
438pub(crate) async fn fetch_btc_usd_rate(fiat: &dyn FiatService) -> Result<f64, SdkError> {
439    let rates = fiat
440        .fetch_fiat_rates()
441        .await
442        .map_err(|e| SdkError::Generic(format!("Cross-chain: failed to fetch fiat rates: {e}")))?;
443    let btc_usd = rates
444        .iter()
445        .find(|r| r.coin.eq_ignore_ascii_case("USD"))
446        .map(|r| r.value)
447        .ok_or_else(|| {
448            SdkError::Generic("Cross-chain: BTC/USD rate not found in feed".to_string())
449        })?;
450    if !btc_usd.is_finite() || btc_usd <= 0.0 {
451        return Err(SdkError::Generic(format!(
452            "Cross-chain: invalid BTC/USD rate from feed: {btc_usd}"
453        )));
454    }
455    Ok(btc_usd)
456}
457
458/// `sats * fiat_rate * 10^dest_decimals / 10^8`. Sub-base-unit truncation
459/// is absorbed by the route's slippage tolerance.
460#[allow(
461    clippy::cast_precision_loss,
462    clippy::cast_possible_truncation,
463    clippy::cast_sign_loss
464)]
465pub(crate) fn convert_sats_to_destination_amount(
466    sats: u128,
467    fiat_rate: f64,
468    dest_decimals: u32,
469) -> Result<u128, SdkError> {
470    let dest_scale = 10f64.powi(i32::try_from(dest_decimals).unwrap_or(i32::MAX));
471    let target = (sats as f64) * fiat_rate * dest_scale / 100_000_000f64;
472    if !target.is_finite() || target < 0.0 {
473        return Err(SdkError::Generic(format!(
474            "Cross-chain: invalid sats→dest conversion result: {target}"
475        )));
476    }
477    Ok(target as u128)
478}
479
480pub(crate) fn is_usd_stable_asset(asset: &str) -> bool {
481    USD_STABLE_ASSETS
482        .iter()
483        .any(|a| asset.eq_ignore_ascii_case(a))
484}
485
486/// Best-available fee: realized `asset_amount_in − delivered_amount` on
487/// `Completed`, else the prepare-time estimate. Refunded/failed keep the
488/// estimate (the realized formula would be misleading).
489pub(crate) fn compute_terminal_fee_amount(
490    new_status: &crate::ConversionStatus,
491    asset_amount_in: Option<u128>,
492    delivered_amount: Option<u128>,
493    prepare_estimate: Option<u128>,
494) -> Option<u128> {
495    match (new_status, asset_amount_in, delivered_amount) {
496        (crate::ConversionStatus::Completed, Some(a), Some(d)) => Some(a.saturating_sub(d)),
497        _ => prepare_estimate,
498    }
499}
500
501/// Rescales an amount between two base-unit precisions. Assumes
502/// `1 source unit ≈ 1 dest unit` at face value — only valid for USD-stable
503/// pairs. Errors on overflow.
504pub(crate) fn rescale_decimals(
505    amount: u128,
506    src_decimals: u32,
507    dest_decimals: u32,
508) -> Result<u128, SdkError> {
509    if dest_decimals >= src_decimals {
510        let delta = dest_decimals.saturating_sub(src_decimals);
511        let factor = 10u128
512            .checked_pow(delta)
513            .ok_or_else(|| SdkError::Generic("Cross-chain: decimal scale overflow".to_string()))?;
514        amount
515            .checked_mul(factor)
516            .ok_or_else(|| SdkError::Generic("Cross-chain: decimal rescale overflow".to_string()))
517    } else {
518        let delta = src_decimals.saturating_sub(dest_decimals);
519        let factor = 10u128
520            .checked_pow(delta)
521            .ok_or_else(|| SdkError::Generic("Cross-chain: decimal scale overflow".to_string()))?;
522        amount.checked_div(factor).ok_or_else(|| {
523            SdkError::Generic("Cross-chain: decimal rescale divisor zero".to_string())
524        })
525    }
526}
527
528/// Inverse of [`convert_sats_to_destination_amount`]: returns the sats whose
529/// fiat-equivalent matches the given USD-stable `destination_amount`.
530/// Errors on a non-positive `fiat_rate`.
531#[allow(
532    clippy::cast_precision_loss,
533    clippy::cast_possible_truncation,
534    clippy::cast_sign_loss
535)]
536pub(crate) fn convert_destination_amount_to_sats(
537    destination_amount: u128,
538    fiat_rate: f64,
539    dest_decimals: u32,
540) -> Result<u128, SdkError> {
541    if !fiat_rate.is_finite() || fiat_rate <= 0.0 {
542        return Err(SdkError::Generic(format!(
543            "Cross-chain: invalid BTC/USD rate for inversion: {fiat_rate}"
544        )));
545    }
546    let dest_scale = 10f64.powi(i32::try_from(dest_decimals).unwrap_or(i32::MAX));
547    let sats = (destination_amount as f64) * 100_000_000f64 / (fiat_rate * dest_scale);
548    if !sats.is_finite() || sats < 0.0 {
549        return Err(SdkError::Generic(format!(
550            "Cross-chain: invalid dest→sats conversion result: {sats}"
551        )));
552    }
553    Ok(sats as u128)
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use macros::test_all;
560
561    #[cfg(feature = "browser-tests")]
562    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
563
564    #[test_all]
565    fn source_chain_display_is_human_readable() {
566        assert_eq!(SourceChain::Spark.to_string(), "Spark");
567        assert_eq!(SourceChain::Lightning.to_string(), "Lightning");
568        assert_eq!(SourceChain::Bitcoin.to_string(), "Bitcoin");
569    }
570
571    #[test_all]
572    fn derive_btc_leg_transfer_id_uses_caller_key() {
573        // A v4 UUID is a valid TransferId — using one here checks that the
574        // caller-supplied key wins outright.
575        let key = "00000000-0000-4000-8000-000000000001";
576        let id = derive_btc_leg_transfer_id(Some(key), "ignored-seed").unwrap();
577        assert_eq!(id.to_string(), key);
578    }
579
580    #[test_all]
581    fn derive_btc_leg_transfer_id_deterministic_from_seed() {
582        let a = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
583        let b = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
584        assert_eq!(
585            a, b,
586            "same seed must produce the same TransferId across calls"
587        );
588    }
589
590    #[test_all]
591    fn derive_btc_leg_transfer_id_distinct_seeds_yield_distinct_ids() {
592        let a = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
593        let b = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-2").unwrap();
594        assert_ne!(a, b);
595    }
596
597    #[test_all]
598    fn derive_btc_leg_transfer_id_orchestra_and_boltz_seeds_collide_only_on_id() {
599        // The provider tag in the seed prevents an Orchestra `quote-1` and a
600        // hypothetical Boltz `quote-1` from colliding on the same TransferId.
601        let orchestra = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:abc").unwrap();
602        let boltz = derive_btc_leg_transfer_id(None, "cross_chain:boltz:abc").unwrap();
603        assert_ne!(orchestra, boltz);
604    }
605
606    #[test_all]
607    fn derive_btc_leg_transfer_id_rejects_invalid_caller_key() {
608        let err = derive_btc_leg_transfer_id(Some("not-a-uuid"), "fallback").unwrap_err();
609        assert!(matches!(err, SdkError::Generic(_)));
610    }
611
612    #[test_all]
613    fn convert_sats_to_destination_amount_round_trip_inverts_to_sats() {
614        // 10_000 sats at $60_000/BTC → $6.00 = 6_000_000 USDC base units.
615        let dest = convert_sats_to_destination_amount(10_000, 60_000.0, 6).unwrap();
616        assert_eq!(dest, 6_000_000);
617        // Inverse must recover the source sats.
618        let sats = convert_destination_amount_to_sats(dest, 60_000.0, 6).unwrap();
619        assert_eq!(sats, 10_000);
620    }
621
622    #[test_all]
623    fn convert_destination_amount_to_sats_typical_stable() {
624        // 1 USDC ($1.00 = 1_000_000 base units) at $60_000/BTC → 1666 sats (floor).
625        let sats = convert_destination_amount_to_sats(1_000_000, 60_000.0, 6).unwrap();
626        assert_eq!(sats, 1_666);
627    }
628
629    #[test_all]
630    fn convert_destination_amount_to_sats_zero_passes_through() {
631        let sats = convert_destination_amount_to_sats(0, 60_000.0, 6).unwrap();
632        assert_eq!(sats, 0);
633    }
634
635    #[test_all]
636    fn convert_destination_amount_to_sats_rejects_non_positive_rate() {
637        let err = convert_destination_amount_to_sats(1_000_000, 0.0, 6).unwrap_err();
638        assert!(matches!(err, SdkError::Generic(ref m) if m.contains("invalid BTC/USD rate")));
639        let err = convert_destination_amount_to_sats(1_000_000, f64::NAN, 6).unwrap_err();
640        assert!(matches!(err, SdkError::Generic(_)));
641    }
642
643    #[test_all]
644    fn rescale_decimals_scales_down_when_dest_decimals_lower() {
645        assert_eq!(rescale_decimals(100_000_000, 8, 6).unwrap(), 1_000_000);
646    }
647
648    #[test_all]
649    fn rescale_decimals_same_decimals_is_identity() {
650        assert_eq!(rescale_decimals(123_456_789, 6, 6).unwrap(), 123_456_789);
651    }
652
653    #[test_all]
654    fn rescale_decimals_scales_up_when_dest_decimals_higher() {
655        assert_eq!(rescale_decimals(1_000_000, 6, 8).unwrap(), 100_000_000);
656    }
657
658    #[test_all]
659    fn rescale_decimals_zero_passes_through() {
660        assert_eq!(rescale_decimals(0, 8, 6).unwrap(), 0);
661        assert_eq!(rescale_decimals(0, 6, 8).unwrap(), 0);
662    }
663
664    #[test_all]
665    fn is_usd_stable_asset_recognizes_known_stables() {
666        for ticker in ["USDB", "USDC", "USDT", "USDT0", "usdb", "uSdC"] {
667            assert!(is_usd_stable_asset(ticker), "{ticker} should be stable");
668        }
669    }
670
671    #[test_all]
672    fn is_usd_stable_asset_rejects_btc_and_unknown() {
673        for ticker in ["BTC", "ETH", "DAI", "", "USD"] {
674            assert!(
675                !is_usd_stable_asset(ticker),
676                "{ticker} should not be a recognized USD-stable"
677            );
678        }
679    }
680
681    // ---- compute_terminal_fee_amount ----
682
683    #[test_all]
684    fn compute_terminal_fee_overwrites_estimate_on_completed() {
685        let realized = compute_terminal_fee_amount(
686            &crate::ConversionStatus::Completed,
687            Some(1_020_434), // asset_amount_in
688            Some(997_498),   // delivered_amount
689            Some(20_434),    // prepare-time estimate
690        );
691        assert_eq!(realized, Some(22_936), "= asset_amount_in − delivered");
692    }
693
694    #[test_all]
695    fn compute_terminal_fee_keeps_estimate_on_refunded() {
696        // Refunded payments don't have a realized fee semantic; the estimate
697        // is the best we can show (and the realized formula would produce
698        // garbage because delivered_amount is 0/None on a refund).
699        let realized = compute_terminal_fee_amount(
700            &crate::ConversionStatus::Refunded,
701            Some(1_020_434),
702            None,
703            Some(20_434),
704        );
705        assert_eq!(realized, Some(20_434));
706    }
707
708    #[test_all]
709    fn compute_terminal_fee_keeps_estimate_on_failed() {
710        let realized = compute_terminal_fee_amount(
711            &crate::ConversionStatus::Failed,
712            Some(1_020_434),
713            None,
714            Some(20_434),
715        );
716        assert_eq!(realized, Some(20_434));
717    }
718
719    #[test_all]
720    fn compute_terminal_fee_keeps_estimate_when_asset_amount_in_missing() {
721        // Pre-upgrade rows have no asset_amount_in; realized fee can't be
722        // computed, so the stored estimate stays as-is.
723        let realized = compute_terminal_fee_amount(
724            &crate::ConversionStatus::Completed,
725            None, // asset_amount_in missing
726            Some(997_498),
727            Some(20_434),
728        );
729        assert_eq!(realized, Some(20_434));
730    }
731
732    #[test_all]
733    fn compute_terminal_fee_keeps_estimate_when_delivered_amount_missing() {
734        // Should never happen on Completed per the contract, but defend
735        // against the edge anyway.
736        let realized = compute_terminal_fee_amount(
737            &crate::ConversionStatus::Completed,
738            Some(1_020_434),
739            None, // delivered_amount missing
740            Some(20_434),
741        );
742        assert_eq!(realized, Some(20_434));
743    }
744
745    #[test_all]
746    fn compute_terminal_fee_saturating_sub_on_over_delivery() {
747        // Rare but possible: provider over-delivers vs source.
748        let realized = compute_terminal_fee_amount(
749            &crate::ConversionStatus::Completed,
750            Some(1_000_000),
751            Some(1_005_000),
752            Some(0),
753        );
754        assert_eq!(
755            realized,
756            Some(0),
757            "saturating_sub must clamp at 0, not underflow"
758        );
759    }
760
761    /// Regression: `CrossChainProviderContext::Boltz.invoice_amount_sats` must
762    /// be the source of truth for the LN-leg amount, distinct from
763    /// `CrossChainPrepared::amount_in` (which can carry a user-facing display
764    /// value such as token base units after the dispatcher's conversion-path
765    /// override). Conflating the two persisted USDB base units into the
766    /// `invoice_amount_sats` field of `ConversionInfo::Boltz`, showing a
767    /// ~$1,200,000-sat "from" amount for a ~$1 send. This test asserts the
768    /// two fields are independently representable + survive a serde
769    /// round-trip with their distinct values.
770    #[test_all]
771    fn boltz_provider_context_invoice_amount_sats_is_independent_of_amount_in() {
772        let ctx = CrossChainProviderContext::Boltz {
773            swap_id: "swap_1".to_string(),
774            invoice: "lnbc19090n1pexample".to_string(),
775            invoice_amount_sats: 1_909,
776            max_slippage_bps: 100,
777        };
778        let json = serde_json::to_string(&ctx).unwrap();
779        let decoded: CrossChainProviderContext = serde_json::from_str(&json).unwrap();
780        let CrossChainProviderContext::Boltz {
781            invoice_amount_sats,
782            ..
783        } = &decoded
784        else {
785            panic!("expected Boltz variant");
786        };
787        assert_eq!(*invoice_amount_sats, 1_909);
788        assert!(
789            *invoice_amount_sats != 1_222_703,
790            "the LN invoice sats must never be conflated with a user-facing display value (e.g. USDB base units)"
791        );
792    }
793
794    /// Pre-bug-fix persisted contexts lack `invoice_amount_sats`. Serde must
795    /// default the missing field to 0 rather than failing to deserialize — the
796    /// downstream send-time error becomes obvious instead of corrupting the
797    /// stored Payment.
798    #[test_all]
799    fn boltz_provider_context_legacy_row_without_invoice_amount_sats_defaults_to_zero() {
800        let legacy = r#"{
801            "Boltz": {
802                "swap_id": "swap_legacy",
803                "invoice": "lnbc19090n1p",
804                "max_slippage_bps": 100
805            }
806        }"#;
807        let decoded: CrossChainProviderContext = serde_json::from_str(legacy).unwrap();
808        let CrossChainProviderContext::Boltz {
809            invoice_amount_sats,
810            ..
811        } = &decoded
812        else {
813            panic!("expected Boltz variant");
814        };
815        assert_eq!(*invoice_amount_sats, 0);
816    }
817
818    /// Same invariant for Orchestra: `deposit_amount` is the source of truth
819    /// for the deposit transfer size and is distinct from
820    /// `CrossChainPrepared::amount_in`.
821    #[test_all]
822    fn orchestra_provider_context_deposit_amount_is_independent_of_amount_in() {
823        let ctx = CrossChainProviderContext::Orchestra {
824            quote_id: "q_1".to_string(),
825            deposit_address: "spark1...".to_string(),
826            deposit_amount: 1_020_434,
827        };
828        let json = serde_json::to_string(&ctx).unwrap();
829        let decoded: CrossChainProviderContext = serde_json::from_str(&json).unwrap();
830        let CrossChainProviderContext::Orchestra { deposit_amount, .. } = &decoded else {
831            panic!("expected Orchestra variant");
832        };
833        assert_eq!(*deposit_amount, 1_020_434);
834    }
835
836    fn boltz_info(swap_id: &str) -> ConversionInfo {
837        ConversionInfo::Boltz {
838            swap_id: swap_id.to_string(),
839            invoice: "lnbc1".to_string(),
840            invoice_amount_sats: 1_000,
841            bridge_ref: None,
842            max_slippage_bps: 100,
843            quote_degraded: false,
844            chain: "polygon".to_string(),
845            chain_id: None,
846            asset: "USDC".to_string(),
847            recipient_address: "0xabc".to_string(),
848            estimated_out: 1_000_000,
849            delivered_amount: None,
850            status: crate::ConversionStatus::Pending,
851            asset_amount_in: None,
852            fee_amount: None,
853            service_fee_amount: None,
854            service_fee_asset: None,
855            asset_decimals: 6,
856            asset_contract: None,
857        }
858    }
859
860    #[test_all]
861    fn payment_with_conversion_info_injects_into_lightning_details() {
862        let payment = crate::Payment {
863            id: "p1".to_string(),
864            payment_type: crate::PaymentType::Send,
865            status: crate::PaymentStatus::Pending,
866            amount: 1_000,
867            fees: 0,
868            timestamp: 100,
869            method: crate::PaymentMethod::Lightning,
870            details: Some(PaymentDetails::Lightning {
871                description: Some("desc".to_string()),
872                invoice: "lnbc1".to_string(),
873                destination_pubkey: "02aa".to_string(),
874                htlc_details: crate::SparkHtlcDetails {
875                    payment_hash: "hash1".to_string(),
876                    preimage: None,
877                    expiry_time: 0,
878                    status: crate::SparkHtlcStatus::PreimageShared,
879                },
880                lnurl_pay_info: None,
881                lnurl_withdraw_info: None,
882                lnurl_receive_metadata: None,
883                conversion_info: None,
884            }),
885            conversion_details: None,
886        };
887
888        let out = payment_with_conversion_info(payment, Some(boltz_info("swap1")));
889
890        assert_eq!(out.status, crate::PaymentStatus::Pending);
891        let Some(PaymentDetails::Lightning {
892            invoice,
893            description,
894            conversion_info,
895            ..
896        }) = out.details
897        else {
898            panic!("expected Lightning details");
899        };
900        // Sibling fields survive the rebuild.
901        assert_eq!(invoice, "lnbc1");
902        assert_eq!(description.as_deref(), Some("desc"));
903        assert!(matches!(
904            conversion_info,
905            Some(ConversionInfo::Boltz { ref swap_id, .. }) if swap_id == "swap1"
906        ));
907    }
908
909    #[test_all]
910    fn payment_with_conversion_info_passes_through_variants_without_a_slot() {
911        let payment = crate::Payment {
912            id: "p1".to_string(),
913            payment_type: crate::PaymentType::Send,
914            status: crate::PaymentStatus::Completed,
915            amount: 1_000,
916            fees: 0,
917            timestamp: 100,
918            method: crate::PaymentMethod::Withdraw,
919            details: Some(PaymentDetails::Withdraw {
920                tx_id: "tx1".to_string(),
921            }),
922            conversion_details: None,
923        };
924
925        let out = payment_with_conversion_info(payment, Some(boltz_info("swap1")));
926
927        assert!(matches!(
928            out.details,
929            Some(PaymentDetails::Withdraw { tx_id }) if tx_id == "tx1"
930        ));
931    }
932}