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