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
6// Boltz is not registered as a provider (see `sdk_builder`): the service is not
7// operational, and it is unclear when or whether it will be again. The modules
8// stay compiled so the wiring can be restored in one place.
9#[allow(dead_code)]
10pub(crate) mod boltz;
11#[allow(dead_code)]
12pub(crate) mod boltz_event_listener;
13#[allow(dead_code)]
14pub(crate) mod boltz_storage_adapter;
15mod cached_fiat;
16mod orchestra;
17mod orchestra_storage_adapter;
18
19pub(crate) use cached_fiat::{CachedFiatService, DEFAULT_FIAT_CACHE_TTL};
20pub(crate) use orchestra::{BreezServerOrchestraConfigResolver, OrchestraService};
21
22use std::collections::HashMap;
23use std::str::FromStr;
24use std::sync::Arc;
25use std::time::Duration;
26
27use breez_sdk_common::fiat::FiatService;
28use serde::{Deserialize, Serialize};
29use spark_wallet::TransferId;
30
31use crate::{ConversionInfo, CrossChainAddressDetails, PaymentDetails, error::SdkError};
32
33/// SDK-level bounds for cross-chain slippage.
34pub(crate) const MIN_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 10;
35pub(crate) const MAX_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 500;
36/// Used when neither the request nor [`crate::Config::default_slippage_bps`]
37/// supplies a value.
38pub(crate) const DEFAULT_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 100;
39
40/// Bounds for the target-overpay pad applied to the user's destination amount
41/// on `FeesExcluded` conversion sends. `0` opts out. `500` caps at 5% (matches
42/// the slippage upper bound).
43pub(crate) const MIN_TARGET_OVERPAY_BPS: u32 = 0;
44pub(crate) const MAX_TARGET_OVERPAY_BPS: u32 = 500;
45/// Default pad applied when neither the request nor
46/// [`crate::CrossChainConfig::default_target_overpay_bps`] specifies one.
47/// Calibrated to the observed Orchestra delivery drift. Tune per provider
48/// as real-world data accrues.
49pub(crate) const DEFAULT_TARGET_OVERPAY_BPS: u32 = 15;
50/// Tickers treated as $1-pegged for par-value rescaling. Adding a non-USD
51/// ticker would silently misreport `fee_amount` for routes using it.
52const USD_STABLE_ASSETS: &[&str] = &["USDB", "USDC", "USDT", "USDT0"];
53
54/// Each provider's background monitor interval.
55pub(crate) const MONITOR_INTERVAL: Duration = Duration::from_secs(30);
56
57/// Attaches a cross-chain [`ConversionInfo`] to a freshly-converted
58/// [`Payment`]. The payment's top-level `status` is left as-is: it reflects
59/// the local Spark/Token/Lightning leg's settlement, while the cross-chain
60/// pending state lives inside `conversion_info.status`.
61pub(crate) fn payment_with_conversion_info(
62    mut payment: crate::Payment,
63    conversion_info: Option<ConversionInfo>,
64) -> crate::Payment {
65    payment.details = match payment.details {
66        Some(PaymentDetails::Spark {
67            invoice_details,
68            htlc_details,
69            ..
70        }) => Some(PaymentDetails::Spark {
71            invoice_details,
72            htlc_details,
73            conversion_info,
74        }),
75        Some(PaymentDetails::Token {
76            metadata,
77            tx_hash,
78            tx_type,
79            invoice_details,
80            ..
81        }) => Some(PaymentDetails::Token {
82            metadata,
83            tx_hash,
84            tx_type,
85            invoice_details,
86            conversion_info,
87        }),
88        Some(PaymentDetails::Lightning {
89            description,
90            invoice,
91            destination_pubkey,
92            htlc_details,
93            lnurl_pay_info,
94            lnurl_withdraw_info,
95            lnurl_receive_metadata,
96            ..
97        }) => Some(PaymentDetails::Lightning {
98            description,
99            invoice,
100            destination_pubkey,
101            htlc_details,
102            lnurl_pay_info,
103            lnurl_withdraw_info,
104            lnurl_receive_metadata,
105            conversion_info,
106        }),
107        other => other,
108    };
109    payment
110}
111
112/// Resolves the BTC-leg [`TransferId`] for a cross-chain send. A
113/// caller-supplied `idempotency_key` from [`crate::SendPaymentRequest`]
114/// wins so the top-level `get_payment_by_id(idempotency_key)` lookup in
115/// `orchestrate_send` can short-circuit retries; otherwise we derive a
116/// `UUIDv5` from `fallback_seed` (the provider's quote/swap id) so that
117/// re-sending the same prepared shape still hits Spark's protocol-level
118/// dedup. Mirrors the stable-balance per-receive convention. Token-source
119/// sends ignore the return value: [`spark_wallet::transfer_tokens`] has
120/// no idempotency hook.
121pub(crate) fn derive_btc_leg_transfer_id(
122    idempotency_key: Option<&str>,
123    fallback_seed: &str,
124) -> Result<TransferId, SdkError> {
125    match idempotency_key {
126        Some(key) => TransferId::from_str(key).map_err(SdkError::Generic),
127        None => Ok(TransferId::from_name(fallback_seed)),
128    }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
132#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
133pub enum CrossChainProvider {
134    Orchestra,
135    /// Not operational: no routes are currently offered under this provider.
136    Boltz,
137}
138
139impl std::fmt::Display for CrossChainProvider {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        match self {
142            Self::Orchestra => f.write_str("Orchestra"),
143            Self::Boltz => f.write_str("Boltz"),
144        }
145    }
146}
147
148/// The asset a cross-chain route accepts on the Spark side.
149#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
150#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
151pub enum SparkAsset {
152    /// Native BTC (sats).
153    Bitcoin,
154    /// A Spark token, identified by its bech32m `token_identifier` (e.g. `btkn1...`).
155    Token { token_identifier: String },
156}
157
158/// The rail a cross-chain payment is delivered over.
159#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
160#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
161pub enum DeliveryMethod {
162    /// Delivered over the Spark network.
163    Spark,
164    /// Delivered over Lightning.
165    Lightning,
166    /// Delivered on-chain over Bitcoin.
167    Bitcoin,
168}
169
170impl std::fmt::Display for DeliveryMethod {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        match self {
173            Self::Spark => f.write_str("Spark"),
174            Self::Lightning => f.write_str("Lightning"),
175            Self::Bitcoin => f.write_str("Bitcoin"),
176        }
177    }
178}
179
180/// Which side of the transfer the request `amount` sizes: what leaves the
181/// payer, or what reaches the receiver.
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
183#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
184pub enum CrossChainFeeMode {
185    /// `amount` sizes the receiving end, and fees are paid on top.
186    ///
187    /// Sending: `amount` is the provider invoice/deposit target, and the
188    /// wallet pays `amount + source_transfer_fee_sats` in total.
189    /// Receiving: `amount` is what the wallet ends up with, and the deposit
190    /// the sender is asked for is sized above it to cover fees.
191    FeesExcluded,
192    /// `amount` sizes the paying end, and fees come out of it.
193    ///
194    /// Sending: `amount` is the wallet's total sats budget, and the provider
195    /// leg is sized so `amount_in + source_transfer_fee_sats <= amount`.
196    /// Receiving: `amount` is the deposit the sender makes, and the wallet
197    /// ends up with that minus fees.
198    FeesIncluded,
199}
200
201impl From<crate::FeePolicy> for CrossChainFeeMode {
202    fn from(policy: crate::FeePolicy) -> Self {
203        match policy {
204            crate::FeePolicy::FeesExcluded => Self::FeesExcluded,
205            crate::FeePolicy::FeesIncluded => Self::FeesIncluded,
206        }
207    }
208}
209
210/// Filter for [`CrossChainService::get_routes`] and the public
211/// `get_cross_chain_routes()` API.
212#[derive(Clone, Debug, Deserialize, Serialize)]
213#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
214pub enum CrossChainRouteFilter {
215    /// Routes for sending from the Spark wallet to another chain.
216    /// Filtered by the parsed recipient address details.
217    Send {
218        address_details: CrossChainAddressDetails,
219    },
220    /// Routes for receiving to Spark from another chain.
221    /// Optionally filtered by the source token contract address.
222    Receive { contract_address: Option<String> },
223    /// Routes for a payment link that sends a stablecoin funded by an external
224    /// rail (Cash App over Lightning) rather than the Spark wallet.
225    /// Filtered by the parsed recipient address details.
226    PaymentLink {
227        address_details: CrossChainAddressDetails,
228    },
229}
230
231/// A single route available for cross-chain transfers, tagged with the provider
232/// that offers it. Returned by `get_cross_chain_routes()`.
233#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
234#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
235pub struct CrossChainRoutePair {
236    /// Which provider offers this route.
237    pub provider: CrossChainProvider,
238    /// External blockchain (e.g. `"base"`, `"solana"`, `"tron"`).
239    pub chain: String,
240    /// External chain identifier (e.g. EVM `chainId` as a decimal string).
241    /// `None` for non-EVM chains that don't expose one, or when the
242    /// provider doesn't surface it.
243    pub chain_id: Option<String>,
244    /// External asset symbol (e.g. `"USDC"`, `"USDT"`).
245    pub asset: String,
246    /// Token contract / mint address on the destination chain.
247    pub contract_address: Option<String>,
248    /// Decimal places for the destination asset.
249    pub decimals: u8,
250    /// Whether the route supports exact-out mode.
251    pub exact_out_eligible: bool,
252    /// Spark-side assets this route accepts.
253    pub accepted_assets: Vec<SparkAsset>,
254    /// Rails this route can be delivered over, orthogonal to
255    /// `accepted_assets` (the asset moved vs the rail moved on).
256    pub delivery_methods: Vec<DeliveryMethod>,
257}
258
259impl CrossChainRoutePair {
260    /// Infers the destination address family from the route's
261    /// `contract_address`. Returns `None` for native-asset routes (no
262    /// contract address) or if the address format isn't recognized; callers
263    /// should treat that as "skip the address-family validation".
264    pub(crate) fn destination_address_family(
265        &self,
266    ) -> Option<breez_sdk_common::input::CrossChainAddressFamily> {
267        self.contract_address
268            .as_deref()
269            .and_then(breez_sdk_common::input::detect_address_family)
270    }
271}
272
273/// Per-provider service registry plus shared cross-chain dependencies (today:
274/// the cached `FiatService`). Keeping the cache here scopes it to cross-chain
275/// flows; `sdk.fiat_service` stays uncached for general fiat consumers.
276#[derive(Clone)]
277pub(crate) struct CrossChainContext {
278    providers: HashMap<CrossChainProvider, Arc<dyn CrossChainService>>,
279    fiat_service: Arc<dyn FiatService>,
280}
281
282impl CrossChainContext {
283    pub fn new(fiat_service: Arc<dyn FiatService>) -> Self {
284        Self {
285            providers: HashMap::new(),
286            fiat_service,
287        }
288    }
289
290    pub fn insert(&mut self, key: CrossChainProvider, service: Arc<dyn CrossChainService>) {
291        self.providers.insert(key, service);
292    }
293
294    /// Look up a provider, returning a friendly error if missing.
295    pub fn get(
296        &self,
297        provider: CrossChainProvider,
298    ) -> Result<&Arc<dyn CrossChainService>, SdkError> {
299        self.providers.get(&provider).ok_or_else(|| {
300            SdkError::InvalidInput(format!("Cross-chain provider {provider} is not available."))
301        })
302    }
303
304    pub fn values(&self) -> impl Iterator<Item = &Arc<dyn CrossChainService>> {
305        self.providers.values()
306    }
307
308    /// Cached fiat service shared with every cross-chain provider. Read
309    /// through this on the prepare path so the TTL window is shared.
310    pub fn fiat_service(&self) -> &Arc<dyn FiatService> {
311        &self.fiat_service
312    }
313}
314
315/// Provider-internal state produced by `prepare` and consumed by `send`.
316/// Typed per provider so the send stage can resume without re-quoting and
317/// without a serde round-trip. Callers should round-trip this value as-is.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
320pub enum CrossChainProviderContext {
321    Orchestra {
322        /// Orchestra quote id, passed back on `/submit`.
323        quote_id: String,
324        /// Spark address Orchestra expects the deposit transfer to land on.
325        deposit_address: String,
326        /// Spark-side deposit amount in the route's source-asset base units.
327        #[serde(default)]
328        deposit_amount: u128,
329    },
330    Boltz {
331        /// Boltz swap id.
332        swap_id: String,
333        /// Hold invoice to pay.
334        invoice: String,
335        /// Hold invoice amount in sats.
336        #[serde(default)]
337        invoice_amount_sats: u64,
338        /// Slippage tolerance in basis points.
339        max_slippage_bps: u32,
340    },
341}
342
343/// Prepared cross-chain receive: the payment request to hand to the sender
344/// and the receive-quote details. The provider row is already persisted by
345/// the time this returns.
346#[derive(Debug, Clone)]
347pub(crate) struct CrossChainReceivePrepared {
348    /// Canonical cross-chain URI the sender can paste or scan to pay.
349    pub payment_request: String,
350    pub info: CrossChainReceiveInfo,
351}
352
353/// Information about the cross-chain receive quote.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
356pub struct CrossChainReceiveInfo {
357    /// Bare external deposit address the sender pays to.
358    pub deposit_address: String,
359    /// Amount the sender must deposit, in source-asset base units
360    /// (`route.decimals`). On `FeesExcluded` this may differ from the
361    /// request's `amount` because the SDK inflates the deposit to absorb
362    /// provider fees. Render this value to the sender.
363    pub deposit_amount: u128,
364    /// Amount the receiver will see, net of provider fees, in
365    /// destination-asset base units. Sats when receiving BTC into Spark,
366    /// or token base units when receiving a Spark token (e.g. USDB). The
367    /// final delivered amount may move within the slippage tolerance.
368    pub expected_received_amount: u128,
369    /// Symbol of the Spark-side asset `expected_received_amount` is
370    /// denominated in, as the provider reports it: `"BTC"` for sats, or the
371    /// token symbol (e.g. `"USDB"`).
372    pub destination_asset: String,
373    /// Spark token identifier when the destination is a token. Absent when
374    /// the destination is BTC and the receiver will see sats.
375    pub token_identifier: Option<String>,
376    /// Provider-quoted total fee for this receive, in `service_fee_asset`
377    /// units.
378    pub service_fee_amount: u128,
379    /// Ticker for `service_fee_amount`. Absent when the fee is denominated
380    /// in sats.
381    pub service_fee_asset: Option<String>,
382    /// Quote expiry as a unix timestamp in seconds.
383    pub expires_at: u64,
384}
385
386/// Data stashed on the prepared send payment so the provider can resume
387/// the send stage without re-quoting.
388#[derive(Debug, Clone)]
389pub(crate) struct CrossChainSendPrepared {
390    pub amount_in: u128,
391    /// `amount_in` expressed in the cross-chain (destination) asset's base
392    /// units, via the fiat rate or decimal rescale the SDK used at prepare
393    /// time.
394    pub asset_amount_in: u128,
395    /// Amount the recipient will receive, in cross-chain asset base units.
396    pub estimated_out: u128,
397    /// Total user-visible fee in cross-chain asset base units. Covers provider
398    /// spread, bridge/gas, and DEX slippage. On the token-conversion path it
399    /// also rolls in the LN routing budget; on the direct path that budget
400    /// lives separately in `source_transfer_fee_sats`. The dispatcher
401    /// overrides this on the conversion path to reflect the token-side debit.
402    pub fee_amount: u128,
403    /// Provider's own service fee/spread, in its native denomination.
404    pub service_fee_amount: u128,
405    /// Asset that the service fee is denominated in. Unset means BTC sats.
406    pub service_fee_asset: Option<String>,
407    /// Sats cost to the wallet of moving `amount_in` from the wallet to the
408    /// provider. For Boltz: the Lightning routing fee budget for paying the
409    /// hold invoice (a budget, not a central estimate — enforced as a hard
410    /// cap at send time). For Orchestra: the Spark transfer fee (0 today;
411    /// non-zero in the future).
412    ///
413    /// Semantically distinct from `fee_amount` (provider's service fee /
414    /// spread) and from destination-chain costs (baked into `estimated_out`).
415    /// Denominated in sats — the field assumes a sats-denominated source leg.
416    pub source_transfer_fee_sats: u64,
417    /// Fee mode the prepare was called with. Needed at send time so the
418    /// provider knows whether to apply FeesIncluded-style overpayment.
419    pub fee_mode: CrossChainFeeMode,
420    pub expires_at: String,
421    pub pair: CrossChainRoutePair,
422    pub recipient_address: String,
423    /// The `token_identifier` on the Spark source (e.g. USDB). `None` for BTC sats.
424    pub token_identifier: Option<String>,
425    /// Provider-internal state carried between `prepare` and `send`.
426    pub provider_context: CrossChainProviderContext,
427}
428
429/// Abstraction over cross-chain bridge/swap providers.
430///
431/// Each implementation owns its own client, caching, and background monitoring.
432/// The SDK dispatches to the provider via this trait.
433#[allow(clippy::too_many_arguments)]
434#[macros::async_trait]
435pub(crate) trait CrossChainService: Send + Sync {
436    /// Returns the available cross-chain route pairs.
437    ///
438    /// The returned [`CrossChainRoutePair`] always describes the non-Spark
439    /// side of the route. The [`CrossChainRouteFilter`] controls direction
440    /// and optional filtering.
441    async fn get_routes(
442        &self,
443        filter: &CrossChainRouteFilter,
444    ) -> Result<Vec<CrossChainRoutePair>, SdkError>;
445
446    /// Fetch a quote for a cross-chain send or Lightning onramp. `amount` is
447    /// always in the source-leg sats/token base units. The caller converts any
448    /// USD intent to sats first. `delivery_method` selects the rail the wallet
449    /// dispatches over. `None` uses the provider's default
450    /// ([`DeliveryMethod::Spark`] for Orchestra). `source_token_identifier`
451    /// is only meaningful for a Spark source (`None` = BTC sats, `Some` = a
452    /// token).
453    #[allow(clippy::too_many_arguments)]
454    async fn prepare_send(
455        &self,
456        recipient_address: &str,
457        route: &CrossChainRoutePair,
458        amount: u128,
459        delivery_method: Option<DeliveryMethod>,
460        source_token_identifier: Option<String>,
461        max_slippage_bps: u32,
462        fee_mode: CrossChainFeeMode,
463    ) -> Result<CrossChainSendPrepared, SdkError>;
464
465    /// Fetch a quote for a cross-chain receive.
466    ///
467    /// `amount` is the caller's raw ask, always:
468    /// - `FeesExcluded`: net amount the receiver wants to land on the Spark
469    ///   side, in `destination`'s base units (sats for BTC, token base units
470    ///   for tokens). The provider inflates by `target_overpay_bps` when
471    ///   sizing the deposit but drift-checks against `amount` itself, so the
472    ///   overpay gives Orchestra headroom without tightening the accept
473    ///   threshold beyond what the user asked for.
474    /// - `FeesIncluded`: the deposit the sender will pay, in the route's
475    ///   source-asset base units. The receiver lands `amount - fees`.
476    ///   `target_overpay_bps` is ignored.
477    async fn prepare_receive(
478        &self,
479        route: &CrossChainRoutePair,
480        recipient_address: &str,
481        amount: u128,
482        max_slippage_bps: u32,
483        // Pre-validated Spark-side destination: the SDK dispatch has
484        // checked this against `route.accepted_assets` and resolved any
485        // wallet-level defaults (e.g. active stable balance).
486        destination: &SparkAsset,
487        fee_mode: CrossChainFeeMode,
488        target_overpay_bps: u32,
489    ) -> Result<CrossChainReceivePrepared, SdkError>;
490
491    /// Execute the send: transfer funds to the deposit address, submit to
492    /// the provider, persist metadata, monitor to terminal, and return the
493    /// resulting [`Payment`].
494    ///
495    /// `idempotency_key` is the caller-provided key from `SendPaymentRequest`.
496    /// Providers should use it as the underlying Spark `TransferId` so the
497    /// outbound transfer is protocol-level idempotent on retry; if `None`,
498    /// the provider derives a deterministic key from its own quote/swap id
499    /// (same shape as the stable-balance per-receive convention). Only the
500    /// BTC-source branch benefits — token transfers have no upstream
501    /// idempotency hook, and the top-level dispatcher already rejects
502    /// idempotency keys for token-source sends.
503    ///
504    /// Each provider owns the polling-to-terminal step internally — the
505    /// SDK dispatcher does not wrap this with an additional wait.
506    async fn send(
507        &self,
508        prepared: &CrossChainSendPrepared,
509        idempotency_key: Option<String>,
510    ) -> Result<crate::Payment, SdkError>;
511}
512
513/// Fetches the BTC/USD rate from the Breez Server fiat feed. Errors if the
514/// feed is unreachable, missing the USD entry, or returns a non-finite value.
515pub(crate) async fn fetch_btc_usd_rate(fiat: &dyn FiatService) -> Result<f64, SdkError> {
516    let rates = fiat
517        .fetch_fiat_rates()
518        .await
519        .map_err(|e| SdkError::Generic(format!("Cross-chain: failed to fetch fiat rates: {e}")))?;
520    let btc_usd = rates
521        .iter()
522        .find(|r| r.coin.eq_ignore_ascii_case("USD"))
523        .map(|r| r.value)
524        .ok_or_else(|| {
525            SdkError::Generic("Cross-chain: BTC/USD rate not found in feed".to_string())
526        })?;
527    if !btc_usd.is_finite() || btc_usd <= 0.0 {
528        return Err(SdkError::Generic(format!(
529            "Cross-chain: invalid BTC/USD rate from feed: {btc_usd}"
530        )));
531    }
532    Ok(btc_usd)
533}
534
535/// `sats * fiat_rate * 10^dest_decimals / 10^8`. Sub-base-unit truncation
536/// is absorbed by the route's slippage tolerance.
537#[allow(
538    clippy::cast_precision_loss,
539    clippy::cast_possible_truncation,
540    clippy::cast_sign_loss
541)]
542pub(crate) fn convert_sats_to_destination_amount(
543    sats: u128,
544    fiat_rate: f64,
545    dest_decimals: u32,
546) -> Result<u128, SdkError> {
547    let dest_scale = 10f64.powi(i32::try_from(dest_decimals).unwrap_or(i32::MAX));
548    let target = (sats as f64) * fiat_rate * dest_scale / 100_000_000f64;
549    if !target.is_finite() || target < 0.0 {
550        return Err(SdkError::Generic(format!(
551            "Cross-chain: invalid sats→dest conversion result: {target}"
552        )));
553    }
554    Ok(target as u128)
555}
556
557/// Converts a source-asset amount (base units at `src_decimals`) to sats
558/// via `fiat_per_btc`. Assumes the source is denominated 1:1 in the same
559/// fiat as the rate. Inverse of [`convert_sats_to_destination_amount`].
560#[allow(
561    clippy::cast_precision_loss,
562    clippy::cast_possible_truncation,
563    clippy::cast_sign_loss
564)]
565pub(crate) fn convert_source_amount_to_sats(
566    src_base_units: u128,
567    src_decimals: u32,
568    fiat_per_btc: f64,
569) -> Result<u128, SdkError> {
570    let src_scale = 10f64.powi(i32::try_from(src_decimals).unwrap_or(i32::MAX));
571    let sats = (src_base_units as f64) * 100_000_000f64 / (src_scale * fiat_per_btc);
572    if !sats.is_finite() || sats < 0.0 {
573        return Err(SdkError::Generic(format!(
574            "Cross-chain: invalid stable→sats conversion result: {sats}"
575        )));
576    }
577    Ok(sats as u128)
578}
579
580pub(crate) fn is_usd_stable_asset(asset: &str) -> bool {
581    USD_STABLE_ASSETS
582        .iter()
583        .any(|a| asset.eq_ignore_ascii_case(a))
584}
585
586/// Builds the payment-request URI a sender pays to for a cross-chain
587/// receive. EVM destinations get an EIP-681 URI so wallets like `MetaMask`
588/// auto-fill recipient/token/chain/amount. Solana and Tron destinations
589/// fall back to the bare `deposit_address` because current wallets don't
590/// honor those schemes' parameters reliably (see
591/// [`breez_sdk_common::input::format_cross_chain_uri`]).
592pub(crate) fn build_receive_payment_request(
593    deposit_address: &str,
594    chain: &str,
595    chain_id: Option<&str>,
596    contract_address: Option<&str>,
597    amount: u128,
598) -> Result<String, SdkError> {
599    let family =
600        breez_sdk_common::input::detect_address_family(deposit_address).ok_or_else(|| {
601            SdkError::Generic(format!(
602                "Cross-chain provider returned unrecognised deposit address: {deposit_address}",
603            ))
604        })?;
605    // Guard against a provider returning an address that belongs to a
606    // different chain family than the route we requested.
607    if !family.matches_chain(chain, contract_address) {
608        return Err(SdkError::Generic(format!(
609            "Cross-chain provider returned {family:?} deposit address for {chain} route"
610        )));
611    }
612    Ok(breez_sdk_common::input::format_cross_chain_uri(
613        family,
614        deposit_address,
615        contract_address,
616        chain_id,
617        amount,
618    ))
619}
620
621/// Best-available fee: realized `asset_amount_in − delivered_amount` on
622/// `Completed`, else the prepare-time estimate. Refunded/failed keep the
623/// estimate (the realized formula would be misleading).
624pub(crate) fn compute_terminal_fee_amount(
625    new_status: &crate::ConversionStatus,
626    asset_amount_in: Option<u128>,
627    delivered_amount: Option<u128>,
628    prepare_estimate: Option<u128>,
629) -> Option<u128> {
630    match (new_status, asset_amount_in, delivered_amount) {
631        (crate::ConversionStatus::Completed, Some(a), Some(d)) => Some(a.saturating_sub(d)),
632        _ => prepare_estimate,
633    }
634}
635
636/// Rescales an amount between two base-unit precisions. Assumes
637/// `1 source unit ≈ 1 dest unit` at face value — only valid for USD-stable
638/// pairs. Errors on overflow.
639pub(crate) fn rescale_decimals(
640    amount: u128,
641    src_decimals: u32,
642    dest_decimals: u32,
643) -> Result<u128, SdkError> {
644    if dest_decimals >= src_decimals {
645        let delta = dest_decimals.saturating_sub(src_decimals);
646        let factor = 10u128
647            .checked_pow(delta)
648            .ok_or_else(|| SdkError::Generic("Cross-chain: decimal scale overflow".to_string()))?;
649        amount
650            .checked_mul(factor)
651            .ok_or_else(|| SdkError::Generic("Cross-chain: decimal rescale overflow".to_string()))
652    } else {
653        let delta = src_decimals.saturating_sub(dest_decimals);
654        let factor = 10u128
655            .checked_pow(delta)
656            .ok_or_else(|| SdkError::Generic("Cross-chain: decimal scale overflow".to_string()))?;
657        amount.checked_div(factor).ok_or_else(|| {
658            SdkError::Generic("Cross-chain: decimal rescale divisor zero".to_string())
659        })
660    }
661}
662
663/// Inverse of [`convert_sats_to_destination_amount`]: returns the sats whose
664/// fiat-equivalent matches the given USD-stable `destination_amount`.
665/// Errors on a non-positive `fiat_rate`.
666#[allow(
667    clippy::cast_precision_loss,
668    clippy::cast_possible_truncation,
669    clippy::cast_sign_loss
670)]
671pub(crate) fn convert_destination_amount_to_sats(
672    destination_amount: u128,
673    fiat_rate: f64,
674    dest_decimals: u32,
675) -> Result<u128, SdkError> {
676    if !fiat_rate.is_finite() || fiat_rate <= 0.0 {
677        return Err(SdkError::Generic(format!(
678            "Cross-chain: invalid BTC/USD rate for inversion: {fiat_rate}"
679        )));
680    }
681    let dest_scale = 10f64.powi(i32::try_from(dest_decimals).unwrap_or(i32::MAX));
682    let sats = (destination_amount as f64) * 100_000_000f64 / (fiat_rate * dest_scale);
683    if !sats.is_finite() || sats < 0.0 {
684        return Err(SdkError::Generic(format!(
685            "Cross-chain: invalid dest→sats conversion result: {sats}"
686        )));
687    }
688    Ok(sats as u128)
689}
690
691/// Resolves the target-overpay bps to apply on `FeesExcluded` cross-chain
692/// preparations (send and receive). Same precedence as slippage:
693/// caller-supplied value (bounds-checked here), then the config default, then
694/// the built-in default. Config defaults are validated at SDK startup in
695/// `Config::validate`.
696pub(crate) fn resolve_target_overpay_bps(
697    requested: Option<u32>,
698    config_default: Option<u32>,
699) -> Result<u32, SdkError> {
700    if let Some(bps) = requested
701        && !(MIN_TARGET_OVERPAY_BPS..=MAX_TARGET_OVERPAY_BPS).contains(&bps)
702    {
703        return Err(SdkError::InvalidInput(format!(
704            "target_overpay_bps {bps} must be in \
705             {MIN_TARGET_OVERPAY_BPS} to {MAX_TARGET_OVERPAY_BPS}",
706        )));
707    }
708    Ok(requested
709        .or(config_default)
710        .unwrap_or(DEFAULT_TARGET_OVERPAY_BPS))
711}
712
713/// Inflates a target amount by `overpay_bps` so the realized delivery lands at
714/// or above target despite provider slippage. `overpay_bps == 0` is identity.
715/// Used on both directions: send pads the destination target, receive pads
716/// the source-asset deposit.
717pub(crate) fn inflate_target_amount(amount: u128, overpay_bps: u32) -> u128 {
718    if overpay_bps == 0 {
719        return amount;
720    }
721    amount.saturating_add(amount.saturating_mul(u128::from(overpay_bps)) / 10_000)
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727    use macros::test_all;
728
729    #[cfg(feature = "browser-tests")]
730    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
731
732    #[test_all]
733    fn delivery_method_display_is_human_readable() {
734        assert_eq!(DeliveryMethod::Spark.to_string(), "Spark");
735        assert_eq!(DeliveryMethod::Lightning.to_string(), "Lightning");
736        assert_eq!(DeliveryMethod::Bitcoin.to_string(), "Bitcoin");
737    }
738
739    #[test_all]
740    fn derive_btc_leg_transfer_id_uses_caller_key() {
741        // A v4 UUID is a valid TransferId — using one here checks that the
742        // caller-supplied key wins outright.
743        let key = "00000000-0000-4000-8000-000000000001";
744        let id = derive_btc_leg_transfer_id(Some(key), "ignored-seed").unwrap();
745        assert_eq!(id.to_string(), key);
746    }
747
748    #[test_all]
749    fn derive_btc_leg_transfer_id_deterministic_from_seed() {
750        let a = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
751        let b = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
752        assert_eq!(
753            a, b,
754            "same seed must produce the same TransferId across calls"
755        );
756    }
757
758    #[test_all]
759    fn derive_btc_leg_transfer_id_distinct_seeds_yield_distinct_ids() {
760        let a = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
761        let b = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-2").unwrap();
762        assert_ne!(a, b);
763    }
764
765    #[test_all]
766    fn derive_btc_leg_transfer_id_orchestra_and_boltz_seeds_collide_only_on_id() {
767        // The provider tag in the seed prevents an Orchestra `quote-1` and a
768        // hypothetical Boltz `quote-1` from colliding on the same TransferId.
769        let orchestra = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:abc").unwrap();
770        let boltz = derive_btc_leg_transfer_id(None, "cross_chain:boltz:abc").unwrap();
771        assert_ne!(orchestra, boltz);
772    }
773
774    #[test_all]
775    fn derive_btc_leg_transfer_id_rejects_invalid_caller_key() {
776        let err = derive_btc_leg_transfer_id(Some("not-a-uuid"), "fallback").unwrap_err();
777        assert!(matches!(err, SdkError::Generic(_)));
778    }
779
780    #[test_all]
781    fn convert_sats_to_destination_amount_round_trip_inverts_to_sats() {
782        // 10_000 sats at $60_000/BTC → $6.00 = 6_000_000 USDC base units.
783        let dest = convert_sats_to_destination_amount(10_000, 60_000.0, 6).unwrap();
784        assert_eq!(dest, 6_000_000);
785        // Inverse must recover the source sats.
786        let sats = convert_destination_amount_to_sats(dest, 60_000.0, 6).unwrap();
787        assert_eq!(sats, 10_000);
788    }
789
790    #[test_all]
791    fn convert_destination_amount_to_sats_typical_stable() {
792        // 1 USDC ($1.00 = 1_000_000 base units) at $60_000/BTC → 1666 sats (floor).
793        let sats = convert_destination_amount_to_sats(1_000_000, 60_000.0, 6).unwrap();
794        assert_eq!(sats, 1_666);
795    }
796
797    #[test_all]
798    fn convert_destination_amount_to_sats_zero_passes_through() {
799        let sats = convert_destination_amount_to_sats(0, 60_000.0, 6).unwrap();
800        assert_eq!(sats, 0);
801    }
802
803    #[test_all]
804    fn convert_destination_amount_to_sats_rejects_non_positive_rate() {
805        let err = convert_destination_amount_to_sats(1_000_000, 0.0, 6).unwrap_err();
806        assert!(matches!(err, SdkError::Generic(ref m) if m.contains("invalid BTC/USD rate")));
807        let err = convert_destination_amount_to_sats(1_000_000, f64::NAN, 6).unwrap_err();
808        assert!(matches!(err, SdkError::Generic(_)));
809    }
810
811    #[test_all]
812    fn rescale_decimals_scales_down_when_dest_decimals_lower() {
813        assert_eq!(rescale_decimals(100_000_000, 8, 6).unwrap(), 1_000_000);
814    }
815
816    #[test_all]
817    fn rescale_decimals_same_decimals_is_identity() {
818        assert_eq!(rescale_decimals(123_456_789, 6, 6).unwrap(), 123_456_789);
819    }
820
821    #[test_all]
822    fn rescale_decimals_scales_up_when_dest_decimals_higher() {
823        assert_eq!(rescale_decimals(1_000_000, 6, 8).unwrap(), 100_000_000);
824    }
825
826    #[test_all]
827    fn rescale_decimals_zero_passes_through() {
828        assert_eq!(rescale_decimals(0, 8, 6).unwrap(), 0);
829        assert_eq!(rescale_decimals(0, 6, 8).unwrap(), 0);
830    }
831
832    #[test_all]
833    fn is_usd_stable_asset_recognizes_known_stables() {
834        for ticker in ["USDB", "USDC", "USDT", "USDT0", "usdb", "uSdC"] {
835            assert!(is_usd_stable_asset(ticker), "{ticker} should be stable");
836        }
837    }
838
839    #[test_all]
840    fn is_usd_stable_asset_rejects_btc_and_unknown() {
841        for ticker in ["BTC", "ETH", "DAI", "", "USD"] {
842            assert!(
843                !is_usd_stable_asset(ticker),
844                "{ticker} should not be a recognized USD-stable"
845            );
846        }
847    }
848
849    // ---- compute_terminal_fee_amount ----
850
851    #[test_all]
852    fn compute_terminal_fee_overwrites_estimate_on_completed() {
853        let realized = compute_terminal_fee_amount(
854            &crate::ConversionStatus::Completed,
855            Some(1_020_434), // asset_amount_in
856            Some(997_498),   // delivered_amount
857            Some(20_434),    // prepare-time estimate
858        );
859        assert_eq!(realized, Some(22_936), "= asset_amount_in − delivered");
860    }
861
862    #[test_all]
863    fn compute_terminal_fee_keeps_estimate_on_refunded() {
864        // Refunded payments don't have a realized fee semantic; the estimate
865        // is the best we can show (and the realized formula would produce
866        // garbage because delivered_amount is 0/None on a refund).
867        let realized = compute_terminal_fee_amount(
868            &crate::ConversionStatus::Refunded,
869            Some(1_020_434),
870            None,
871            Some(20_434),
872        );
873        assert_eq!(realized, Some(20_434));
874    }
875
876    #[test_all]
877    fn compute_terminal_fee_keeps_estimate_on_failed() {
878        let realized = compute_terminal_fee_amount(
879            &crate::ConversionStatus::Failed,
880            Some(1_020_434),
881            None,
882            Some(20_434),
883        );
884        assert_eq!(realized, Some(20_434));
885    }
886
887    #[test_all]
888    fn compute_terminal_fee_keeps_estimate_when_asset_amount_in_missing() {
889        // Pre-upgrade rows have no asset_amount_in; realized fee can't be
890        // computed, so the stored estimate stays as-is.
891        let realized = compute_terminal_fee_amount(
892            &crate::ConversionStatus::Completed,
893            None, // asset_amount_in missing
894            Some(997_498),
895            Some(20_434),
896        );
897        assert_eq!(realized, Some(20_434));
898    }
899
900    #[test_all]
901    fn compute_terminal_fee_keeps_estimate_when_delivered_amount_missing() {
902        // Should never happen on Completed per the contract, but defend
903        // against the edge anyway.
904        let realized = compute_terminal_fee_amount(
905            &crate::ConversionStatus::Completed,
906            Some(1_020_434),
907            None, // delivered_amount missing
908            Some(20_434),
909        );
910        assert_eq!(realized, Some(20_434));
911    }
912
913    #[test_all]
914    fn compute_terminal_fee_saturating_sub_on_over_delivery() {
915        // Rare but possible: provider over-delivers vs source.
916        let realized = compute_terminal_fee_amount(
917            &crate::ConversionStatus::Completed,
918            Some(1_000_000),
919            Some(1_005_000),
920            Some(0),
921        );
922        assert_eq!(
923            realized,
924            Some(0),
925            "saturating_sub must clamp at 0, not underflow"
926        );
927    }
928
929    /// Regression: `CrossChainProviderContext::Boltz.invoice_amount_sats` must
930    /// be the source of truth for the LN-leg amount, distinct from
931    /// `CrossChainSendPrepared::amount_in` (which can carry a user-facing display
932    /// value such as token base units after the dispatcher's conversion-path
933    /// override). Conflating the two persisted USDB base units into the
934    /// `invoice_amount_sats` field of `ConversionInfo::Boltz`, showing a
935    /// ~$1,200,000-sat "from" amount for a ~$1 send. This test asserts the
936    /// two fields are independently representable + survive a serde
937    /// round-trip with their distinct values.
938    #[test_all]
939    fn boltz_provider_context_invoice_amount_sats_is_independent_of_amount_in() {
940        let ctx = CrossChainProviderContext::Boltz {
941            swap_id: "swap_1".to_string(),
942            invoice: "lnbc19090n1pexample".to_string(),
943            invoice_amount_sats: 1_909,
944            max_slippage_bps: 100,
945        };
946        let json = serde_json::to_string(&ctx).unwrap();
947        let decoded: CrossChainProviderContext = serde_json::from_str(&json).unwrap();
948        let CrossChainProviderContext::Boltz {
949            invoice_amount_sats,
950            ..
951        } = &decoded
952        else {
953            panic!("expected Boltz variant");
954        };
955        assert_eq!(*invoice_amount_sats, 1_909);
956        assert!(
957            *invoice_amount_sats != 1_222_703,
958            "the LN invoice sats must never be conflated with a user-facing display value (e.g. USDB base units)"
959        );
960    }
961
962    /// Pre-bug-fix persisted contexts lack `invoice_amount_sats`. Serde must
963    /// default the missing field to 0 rather than failing to deserialize — the
964    /// downstream send-time error becomes obvious instead of corrupting the
965    /// stored Payment.
966    #[test_all]
967    fn boltz_provider_context_legacy_row_without_invoice_amount_sats_defaults_to_zero() {
968        let legacy = r#"{
969            "Boltz": {
970                "swap_id": "swap_legacy",
971                "invoice": "lnbc19090n1p",
972                "max_slippage_bps": 100
973            }
974        }"#;
975        let decoded: CrossChainProviderContext = serde_json::from_str(legacy).unwrap();
976        let CrossChainProviderContext::Boltz {
977            invoice_amount_sats,
978            ..
979        } = &decoded
980        else {
981            panic!("expected Boltz variant");
982        };
983        assert_eq!(*invoice_amount_sats, 0);
984    }
985
986    /// Same invariant for Orchestra: `deposit_amount` is the source of truth
987    /// for the deposit transfer size and is distinct from
988    /// `CrossChainSendPrepared::amount_in`.
989    #[test_all]
990    fn orchestra_provider_context_deposit_amount_is_independent_of_amount_in() {
991        let ctx = CrossChainProviderContext::Orchestra {
992            quote_id: "q_1".to_string(),
993            deposit_address: "spark1...".to_string(),
994            deposit_amount: 1_020_434,
995        };
996        let json = serde_json::to_string(&ctx).unwrap();
997        let decoded: CrossChainProviderContext = serde_json::from_str(&json).unwrap();
998        let CrossChainProviderContext::Orchestra { deposit_amount, .. } = &decoded else {
999            panic!("expected Orchestra variant");
1000        };
1001        assert_eq!(*deposit_amount, 1_020_434);
1002    }
1003
1004    fn boltz_info(swap_id: &str) -> ConversionInfo {
1005        ConversionInfo::Boltz {
1006            swap_id: swap_id.to_string(),
1007            invoice: "lnbc1".to_string(),
1008            invoice_amount_sats: 1_000,
1009            bridge_ref: None,
1010            max_slippage_bps: 100,
1011            quote_degraded: false,
1012            chain: "polygon".to_string(),
1013            chain_id: None,
1014            asset: "USDC".to_string(),
1015            recipient_address: "0xabc".to_string(),
1016            estimated_out: 1_000_000,
1017            delivered_amount: None,
1018            status: crate::ConversionStatus::Pending,
1019            asset_amount_in: None,
1020            fee_amount: None,
1021            service_fee_amount: None,
1022            service_fee_asset: None,
1023            asset_decimals: 6,
1024            asset_contract: None,
1025        }
1026    }
1027
1028    #[test_all]
1029    fn payment_with_conversion_info_injects_into_lightning_details() {
1030        let payment = crate::Payment {
1031            id: "p1".to_string(),
1032            payment_type: crate::PaymentType::Send,
1033            status: crate::PaymentStatus::Pending,
1034            amount: 1_000,
1035            fees: 0,
1036            timestamp: 100,
1037            method: crate::PaymentMethod::Lightning,
1038            details: Some(PaymentDetails::Lightning {
1039                description: Some("desc".to_string()),
1040                invoice: "lnbc1".to_string(),
1041                destination_pubkey: "02aa".to_string(),
1042                htlc_details: crate::SparkHtlcDetails {
1043                    payment_hash: "hash1".to_string(),
1044                    preimage: None,
1045                    expiry_time: 0,
1046                    status: crate::SparkHtlcStatus::PreimageShared,
1047                },
1048                lnurl_pay_info: None,
1049                lnurl_withdraw_info: None,
1050                lnurl_receive_metadata: None,
1051                conversion_info: None,
1052            }),
1053            conversion_details: None,
1054        };
1055
1056        let out = payment_with_conversion_info(payment, Some(boltz_info("swap1")));
1057
1058        assert_eq!(out.status, crate::PaymentStatus::Pending);
1059        let Some(PaymentDetails::Lightning {
1060            invoice,
1061            description,
1062            conversion_info,
1063            ..
1064        }) = out.details
1065        else {
1066            panic!("expected Lightning details");
1067        };
1068        // Sibling fields survive the rebuild.
1069        assert_eq!(invoice, "lnbc1");
1070        assert_eq!(description.as_deref(), Some("desc"));
1071        assert!(matches!(
1072            conversion_info,
1073            Some(ConversionInfo::Boltz { ref swap_id, .. }) if swap_id == "swap1"
1074        ));
1075    }
1076
1077    #[test_all]
1078    fn payment_with_conversion_info_passes_through_variants_without_a_slot() {
1079        let payment = crate::Payment {
1080            id: "p1".to_string(),
1081            payment_type: crate::PaymentType::Send,
1082            status: crate::PaymentStatus::Completed,
1083            amount: 1_000,
1084            fees: 0,
1085            timestamp: 100,
1086            method: crate::PaymentMethod::Withdraw,
1087            details: Some(PaymentDetails::Withdraw {
1088                tx_id: "tx1".to_string(),
1089            }),
1090            conversion_details: None,
1091        };
1092
1093        let out = payment_with_conversion_info(payment, Some(boltz_info("swap1")));
1094
1095        assert!(matches!(
1096            out.details,
1097            Some(PaymentDetails::Withdraw { tx_id }) if tx_id == "tx1"
1098        ));
1099    }
1100
1101    // ---- resolve_target_overpay_bps ----
1102
1103    #[test_all]
1104    fn resolve_target_overpay_uses_request_when_in_range() {
1105        assert_eq!(resolve_target_overpay_bps(Some(50), Some(75)).unwrap(), 50);
1106    }
1107
1108    #[test_all]
1109    fn resolve_target_overpay_falls_back_to_config_default() {
1110        assert_eq!(resolve_target_overpay_bps(None, Some(75)).unwrap(), 75);
1111    }
1112
1113    #[test_all]
1114    fn resolve_target_overpay_falls_back_to_built_in_default() {
1115        assert_eq!(
1116            resolve_target_overpay_bps(None, None).unwrap(),
1117            DEFAULT_TARGET_OVERPAY_BPS
1118        );
1119    }
1120
1121    #[test_all]
1122    fn resolve_target_overpay_request_zero_opts_out() {
1123        assert_eq!(resolve_target_overpay_bps(Some(0), Some(50)).unwrap(), 0);
1124    }
1125
1126    #[test_all]
1127    fn resolve_target_overpay_rejects_out_of_range_request() {
1128        let too_high = MAX_TARGET_OVERPAY_BPS + 1;
1129        assert!(matches!(
1130            resolve_target_overpay_bps(Some(too_high), None),
1131            Err(SdkError::InvalidInput(_))
1132        ));
1133    }
1134
1135    // ---- inflate_target_amount ----
1136
1137    #[test_all]
1138    fn inflate_target_amount_zero_bps_is_identity() {
1139        assert_eq!(inflate_target_amount(1_000_000, 0), 1_000_000);
1140    }
1141
1142    #[test_all]
1143    fn inflate_target_amount_applies_bps_pad() {
1144        // 25 bps on 1_000_000 → 2_500 pad.
1145        assert_eq!(inflate_target_amount(1_000_000, 25), 1_002_500);
1146    }
1147
1148    #[test_all]
1149    fn inflate_target_amount_truncates_sub_unit_pad() {
1150        // 25 bps on 100 → 0.25 pad, truncates to 0.
1151        assert_eq!(inflate_target_amount(100, 25), 100);
1152    }
1153
1154    // ---- convert_source_amount_to_sats ----
1155
1156    #[test_all]
1157    fn convert_stable_to_sats_at_par_6dp() {
1158        // BTC/USD = 100_000, 1 USD = 1000 sats. 1_000_000 (6dp) = $1 = 1000 sats.
1159        assert_eq!(
1160            convert_source_amount_to_sats(1_000_000, 6, 100_000.0).unwrap(),
1161            1000
1162        );
1163    }
1164
1165    #[test_all]
1166    fn convert_stable_to_sats_at_a_different_rate() {
1167        // BTC/USD = 50_000, $1 = 2000 sats.
1168        assert_eq!(
1169            convert_source_amount_to_sats(1_000_000, 6, 50_000.0).unwrap(),
1170            2000
1171        );
1172    }
1173
1174    #[test_all]
1175    fn convert_stable_to_sats_matches_across_decimals_at_same_usd_value() {
1176        // $1 at 6dp and at 18dp must produce the same sats.
1177        let sats_6 = convert_source_amount_to_sats(1_000_000, 6, 100_000.0).unwrap();
1178        let sats_18 =
1179            convert_source_amount_to_sats(1_000_000_000_000_000_000, 18, 100_000.0).unwrap();
1180        assert_eq!(sats_6, sats_18);
1181        assert_eq!(sats_6, 1000);
1182    }
1183
1184    #[test_all]
1185    fn convert_stable_to_sats_zero_input_is_zero() {
1186        assert_eq!(convert_source_amount_to_sats(0, 6, 100_000.0).unwrap(), 0);
1187    }
1188
1189    #[test_all]
1190    fn convert_stable_to_sats_rejects_nan_rate() {
1191        // Infinity is intentionally allowed (produces 0 sats, a finite result);
1192        // NaN is the pathological input we guard against.
1193        assert!(matches!(
1194            convert_source_amount_to_sats(1_000_000, 6, f64::NAN),
1195            Err(SdkError::Generic(_))
1196        ));
1197    }
1198
1199    #[test_all]
1200    fn convert_stable_to_sats_rejects_negative_rate() {
1201        assert!(matches!(
1202            convert_source_amount_to_sats(1_000_000, 6, -100_000.0),
1203            Err(SdkError::Generic(_))
1204        ));
1205    }
1206
1207    // ---- build_receive_payment_request ----
1208
1209    /// EVM destinations produce an EIP-681 URI (with `chain_id` and token
1210    /// contract when present) so wallets like `MetaMask` auto-fill.
1211    #[test_all]
1212    fn build_receive_payment_request_evm_emits_eip_681_uri() {
1213        let uri = build_receive_payment_request(
1214            "0x00Df20df75800ca8f40080505a7a802331C1321c",
1215            "arbitrum",
1216            Some("42161"),
1217            Some("0xaf88d065e77c8cC2239327C5EDb3A432268e5831"),
1218            1_000_000,
1219        )
1220        .unwrap();
1221        assert!(uri.starts_with("ethereum:"), "got {uri}");
1222        assert!(uri.contains("42161"), "chain_id must appear: {uri}");
1223        assert!(
1224            uri.contains("0xaf88d065e77c8cC2239327C5EDb3A432268e5831"),
1225            "token contract must appear: {uri}"
1226        );
1227        assert!(uri.contains("1000000"), "amount must appear: {uri}");
1228    }
1229
1230    /// Solana falls back to the bare deposit address: current wallets don't
1231    /// honor solana: URI parameters reliably.
1232    #[test_all]
1233    fn build_receive_payment_request_solana_returns_bare_address() {
1234        let addr = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM";
1235        let out = build_receive_payment_request(addr, "solana", None, None, 1_000_000).unwrap();
1236        assert_eq!(out, addr);
1237    }
1238
1239    /// Tron falls back to the bare deposit address for the same reason.
1240    #[test_all]
1241    fn build_receive_payment_request_tron_returns_bare_address() {
1242        let addr = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
1243        let out = build_receive_payment_request(addr, "tron", None, None, 1_000_000).unwrap();
1244        assert_eq!(out, addr);
1245    }
1246
1247    /// An unrecognized address surfaces a Generic error rather than silently
1248    /// returning something usable.
1249    #[test_all]
1250    fn build_receive_payment_request_rejects_unrecognized_address() {
1251        let err =
1252            build_receive_payment_request("not-an-address", "arbitrum", None, None, 1_000_000)
1253                .unwrap_err();
1254        assert!(matches!(err, SdkError::Generic(_)));
1255    }
1256
1257    /// An EVM-looking deposit address for a Solana route must be rejected:
1258    /// a provider that miswires chains cannot silently redirect funds by
1259    /// returning an EVM address where a Solana address was expected.
1260    #[test_all]
1261    fn build_receive_payment_request_rejects_family_chain_mismatch() {
1262        let evm_addr = "0x00Df20df75800ca8f40080505a7a802331C1321c";
1263        let err =
1264            build_receive_payment_request(evm_addr, "solana", None, None, 1_000_000).unwrap_err();
1265        match err {
1266            SdkError::Generic(msg) => {
1267                assert!(
1268                    msg.contains("Evm") && msg.contains("solana"),
1269                    "unexpected message: {msg}"
1270                );
1271            }
1272            other => panic!("expected Generic mismatch error, got {other:?}"),
1273        }
1274
1275        let solana_addr = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM";
1276        assert!(
1277            build_receive_payment_request(solana_addr, "tron", None, None, 1_000_000).is_err(),
1278            "solana address must not be accepted for tron route",
1279        );
1280    }
1281}