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 in the tree behind the `boltz` feature so the wiring can be restored in
9// one place.
10#[cfg(feature = "boltz")]
11#[allow(dead_code)]
12pub(crate) mod boltz;
13mod cached_fiat;
14mod orchestra;
15
16pub(crate) use cached_fiat::{CachedFiatService, DEFAULT_FIAT_CACHE_TTL};
17pub(crate) use orchestra::{
18    BreezServerOrchestraConfigResolver, DepositDestination, OrchestraQuoter, OrchestraService,
19    delivery_method_from_wire, delivery_method_to_wire, parse_rfc3339_to_unix_seconds,
20    to_route_limits,
21};
22
23use std::collections::HashMap;
24use std::str::FromStr;
25use std::sync::Arc;
26use std::time::Duration;
27
28use breez_sdk_common::fiat::FiatService;
29use serde::{Deserialize, Serialize};
30use spark_wallet::TransferId;
31
32use crate::{
33    ConversionInfo, CrossChainAddressDetails, PaymentDetails, error::SdkError,
34    utils::conversions::extract_conversion_info,
35};
36
37/// SDK-level bounds for cross-chain slippage.
38pub(crate) const MIN_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 10;
39pub(crate) const MAX_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 500;
40/// Used when neither the request nor [`crate::Config::default_slippage_bps`]
41/// supplies a value.
42pub(crate) const DEFAULT_CROSS_CHAIN_SLIPPAGE_BPS: u32 = 100;
43
44/// Bounds for the target-overpay pad applied to the user's destination amount
45/// on `FeesExcluded` conversion sends. `0` opts out. `500` caps at 5% (matches
46/// the slippage upper bound).
47pub(crate) const MIN_TARGET_OVERPAY_BPS: u32 = 0;
48pub(crate) const MAX_TARGET_OVERPAY_BPS: u32 = 500;
49/// Default pad applied when neither the request nor
50/// [`crate::CrossChainConfig::default_target_overpay_bps`] specifies one.
51/// Calibrated to the observed Orchestra delivery drift. Tune per provider
52/// as real-world data accrues.
53pub(crate) const DEFAULT_TARGET_OVERPAY_BPS: u32 = 15;
54/// Tickers treated as $1-pegged for par-value rescaling. Adding a non-USD
55/// ticker would silently misreport `fee_amount` for routes using it.
56const USD_STABLE_ASSETS: &[&str] = &["USDB", "USDC", "USDT", "USDT0"];
57
58/// Each provider's background monitor interval.
59pub(crate) const MONITOR_INTERVAL: Duration = Duration::from_secs(30);
60
61/// Serializes the conversion writes onto an inbound cross-chain receive. The
62/// invoice match writes a Pending conversion when the payment is seen; the
63/// provider monitor writes the Completed one and closes the receive row in
64/// the same critical section. A match racing the monitor would otherwise
65/// put Pending back for good.
66static RECEIVE_CONVERSION_WRITES: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
67
68/// Links an inbound payment to the cross-chain receive whose Spark invoice it
69/// fulfilled, writing a Pending conversion onto it. Returns whether it did.
70/// Runs before the payment's status event so the event already carries the
71/// conversion. A payment that already carries one is left alone.
72pub(crate) async fn attach_receive_metadata_for_payment(
73    storage: &Arc<dyn crate::Storage>,
74    payment: &crate::Payment,
75) -> Result<bool, SdkError> {
76    if payment.payment_type != crate::PaymentType::Receive {
77        return Ok(false);
78    }
79    let invoice = match &payment.details {
80        Some(
81            PaymentDetails::Spark {
82                invoice_details: Some(details),
83                ..
84            }
85            | PaymentDetails::Token {
86                invoice_details: Some(details),
87                ..
88            },
89        ) => &details.invoice,
90        _ => return Ok(false),
91    };
92
93    let _serialized = RECEIVE_CONVERSION_WRITES.lock().await;
94    let already_attached = storage
95        .get_payment_by_id(payment.id.clone())
96        .await
97        .is_ok_and(|stored| extract_conversion_info(stored.details).is_some());
98    if already_attached {
99        return Ok(false);
100    }
101    let Some(conversion_info) = orchestra::link_receive_for_invoice(storage, invoice).await? else {
102        return Ok(false);
103    };
104    storage
105        .insert_payment_metadata(
106            payment.id.clone(),
107            crate::PaymentMetadata {
108                conversion_info: Some(conversion_info),
109                ..Default::default()
110            },
111        )
112        .await?;
113    Ok(true)
114}
115
116/// Attaches a cross-chain [`ConversionInfo`] to a freshly-converted
117/// [`Payment`]. The payment's top-level `status` is left as-is: it reflects
118/// the local Spark/Token/Lightning leg's settlement, while the cross-chain
119/// pending state lives inside `conversion_info.status`.
120pub(crate) fn payment_with_conversion_info(
121    mut payment: crate::Payment,
122    conversion_info: Option<ConversionInfo>,
123) -> crate::Payment {
124    payment.details = match payment.details {
125        Some(PaymentDetails::Spark {
126            invoice_details,
127            htlc_details,
128            ..
129        }) => Some(PaymentDetails::Spark {
130            invoice_details,
131            htlc_details,
132            conversion_info,
133        }),
134        Some(PaymentDetails::Token {
135            metadata,
136            tx_hash,
137            tx_type,
138            invoice_details,
139            ..
140        }) => Some(PaymentDetails::Token {
141            metadata,
142            tx_hash,
143            tx_type,
144            invoice_details,
145            conversion_info,
146        }),
147        Some(PaymentDetails::Lightning {
148            description,
149            invoice,
150            destination_pubkey,
151            htlc_details,
152            lnurl_pay_info,
153            lnurl_withdraw_info,
154            lnurl_receive_metadata,
155            ..
156        }) => Some(PaymentDetails::Lightning {
157            description,
158            invoice,
159            destination_pubkey,
160            htlc_details,
161            lnurl_pay_info,
162            lnurl_withdraw_info,
163            lnurl_receive_metadata,
164            conversion_info,
165        }),
166        other => other,
167    };
168    payment
169}
170
171/// Resolves the BTC-leg [`TransferId`] for a cross-chain send. A
172/// caller-supplied `idempotency_key` from [`crate::SendPaymentRequest`]
173/// wins so the top-level `get_payment_by_id(idempotency_key)` lookup in
174/// `orchestrate_send` can short-circuit retries; otherwise we derive a
175/// `UUIDv5` from `fallback_seed` (the provider's quote/swap id) so that
176/// re-sending the same prepared shape still hits Spark's protocol-level
177/// dedup. Mirrors the stable-balance per-receive convention. Token-source
178/// sends ignore the return value: [`spark_wallet::transfer_tokens`] has
179/// no idempotency hook.
180pub(crate) fn derive_btc_leg_transfer_id(
181    idempotency_key: Option<&str>,
182    fallback_seed: &str,
183) -> Result<TransferId, SdkError> {
184    match idempotency_key {
185        Some(key) => TransferId::from_str(key).map_err(SdkError::Generic),
186        None => Ok(TransferId::from_name(fallback_seed)),
187    }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
191#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
192pub enum CrossChainProvider {
193    Orchestra,
194    /// Not operational: no routes are currently offered under this provider.
195    Boltz,
196}
197
198impl std::fmt::Display for CrossChainProvider {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        match self {
201            Self::Orchestra => f.write_str("Orchestra"),
202            Self::Boltz => f.write_str("Boltz"),
203        }
204    }
205}
206
207/// The asset a cross-chain route accepts on the Spark side.
208#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
209#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
210pub enum SparkAsset {
211    /// Native BTC (sats).
212    Bitcoin,
213    /// A Spark token, identified by its bech32m `token_identifier` (e.g. `btkn1...`).
214    Token { token_identifier: String },
215}
216
217/// Amount bounds a provider publishes for moving a route with one Spark-side
218/// asset.
219///
220/// A route can enforce a tighter bound than it publishes, so an amount inside
221/// these can still be rejected when the payment is prepared.
222///
223/// The two groups are independent, and either can be absent: a route may
224/// publish a base-unit floor (a dust minimum on a sats-funded route), a USD
225/// notional band, both, or neither.
226///
227/// `min_amount` / `max_amount` bound the asset that is paid in, so which asset
228/// they are denominated in follows the direction: the Spark-side asset on a
229/// send, the external asset on a receive. The USD band bounds the order's
230/// value and reads the same in both directions.
231#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
232#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
233pub struct CrossChainRouteLimits {
234    /// Smallest amount accepted, in the base units of the asset paid in.
235    pub min_amount: Option<u128>,
236    /// Largest amount accepted, in the base units of the asset paid in.
237    pub max_amount: Option<u128>,
238    /// Smallest order value accepted, in USD cents.
239    pub min_usd_cents: Option<u64>,
240    /// Largest order value accepted, in USD cents.
241    pub max_usd_cents: Option<u64>,
242}
243
244/// A Spark-side asset a route accepts, with the amount bounds that apply to it.
245///
246/// Bounds are per asset rather than per route: the same external endpoint can
247/// carry a dust floor when moved as sats and none when moved as a token.
248#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
249#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
250pub struct CrossChainAcceptedAsset {
251    pub asset: SparkAsset,
252    /// Unset when the provider publishes no bounds for this pairing.
253    pub limits: Option<CrossChainRouteLimits>,
254}
255
256/// The rail a cross-chain payment is delivered over.
257#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
258#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
259pub enum DeliveryMethod {
260    /// Delivered over the Spark network.
261    Spark,
262    /// Delivered over Lightning.
263    Lightning,
264    /// Delivered on-chain over Bitcoin.
265    Bitcoin,
266}
267
268impl std::fmt::Display for DeliveryMethod {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        match self {
271            Self::Spark => f.write_str("Spark"),
272            Self::Lightning => f.write_str("Lightning"),
273            Self::Bitcoin => f.write_str("Bitcoin"),
274        }
275    }
276}
277
278/// Which side of the transfer the request `amount` sizes: what leaves the
279/// payer, or what reaches the receiver.
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
281#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
282pub enum CrossChainFeeMode {
283    /// `amount` sizes the receiving end, and fees are paid on top.
284    ///
285    /// Sending: `amount` is the provider invoice/deposit target, and the
286    /// wallet pays `amount + source_transfer_fee_sats` in total.
287    /// Receiving: `amount` is what the wallet ends up with, and the deposit
288    /// the sender is asked for is sized above it to cover fees.
289    FeesExcluded,
290    /// `amount` sizes the paying end, and fees come out of it.
291    ///
292    /// Sending: `amount` is the wallet's total sats budget, and the provider
293    /// leg is sized so `amount_in + source_transfer_fee_sats <= amount`.
294    /// Receiving: `amount` is the deposit the sender makes, and the wallet
295    /// ends up with that minus fees.
296    FeesIncluded,
297}
298
299impl From<crate::FeePolicy> for CrossChainFeeMode {
300    fn from(policy: crate::FeePolicy) -> Self {
301        match policy {
302            crate::FeePolicy::FeesExcluded => Self::FeesExcluded,
303            crate::FeePolicy::FeesIncluded => Self::FeesIncluded,
304        }
305    }
306}
307
308/// Filter for [`CrossChainService::get_routes`] and the public
309/// `get_cross_chain_routes()` API.
310#[derive(Clone, Debug, Deserialize, Serialize)]
311#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
312pub enum CrossChainRouteFilter {
313    /// Routes for sending to another chain, filtered by the parsed recipient
314    /// address details.
315    Send {
316        address_details: CrossChainAddressDetails,
317        /// Unset or `Spark` lists the routes paid from the Spark balance.
318        /// `Lightning` lists the routes an external payer funds over
319        /// Lightning. `Bitcoin` lists nothing.
320        delivery_method: Option<DeliveryMethod>,
321    },
322    /// Routes for receiving from another chain, optionally filtered by the
323    /// source token contract address.
324    Receive {
325        contract_address: Option<String>,
326        /// Unset or `Spark` lists the routes that deliver to the Spark balance.
327        /// `Lightning` lists the routes that pay out to a Lightning address.
328        /// `Bitcoin` lists nothing.
329        delivery_method: Option<DeliveryMethod>,
330    },
331    /// **Deprecated.** Use `Send` with the `Lightning` delivery method.
332    ///
333    /// Routes for a payment link that sends a stablecoin funded by Cash App
334    /// over Lightning rather than the Spark wallet.
335    PaymentLink {
336        address_details: CrossChainAddressDetails,
337    },
338}
339
340impl CrossChainRouteFilter {
341    /// The equivalent filter without the deprecated `PaymentLink` variant.
342    pub(crate) fn normalized(&self) -> Self {
343        match self {
344            Self::PaymentLink { address_details } => Self::Send {
345                address_details: address_details.clone(),
346                delivery_method: Some(DeliveryMethod::Lightning),
347            },
348            other => other.clone(),
349        }
350    }
351}
352
353/// A single route available for cross-chain transfers, tagged with the provider
354/// that offers it. Returned by `get_cross_chain_routes()`.
355#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
356#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
357pub struct CrossChainRoutePair {
358    /// Which provider offers this route.
359    pub provider: CrossChainProvider,
360    /// External blockchain (e.g. `"base"`, `"solana"`, `"tron"`).
361    pub chain: String,
362    /// External chain identifier (e.g. EVM `chainId` as a decimal string).
363    /// `None` for non-EVM chains that don't expose one, or when the
364    /// provider doesn't surface it.
365    pub chain_id: Option<String>,
366    /// External asset symbol (e.g. `"USDC"`, `"USDT"`).
367    pub asset: String,
368    /// Token contract / mint address on the destination chain.
369    pub contract_address: Option<String>,
370    /// Decimal places for the destination asset.
371    pub decimals: u8,
372    /// Whether the route supports exact-out mode.
373    pub exact_out_eligible: bool,
374    /// Spark-side assets this route accepts, each with its own amount bounds.
375    pub accepted_assets: Vec<CrossChainAcceptedAsset>,
376    /// Rails this route can be delivered over, orthogonal to
377    /// `accepted_assets` (the asset moved vs the rail moved on).
378    pub delivery_methods: Vec<DeliveryMethod>,
379}
380
381impl CrossChainRoutePair {
382    /// The bounds published for moving this route with `asset`, if the route
383    /// accepts it at all.
384    pub fn limits_for(&self, asset: &SparkAsset) -> Option<&CrossChainRouteLimits> {
385        self.accepted_assets
386            .iter()
387            .find(|a| &a.asset == asset)
388            .and_then(|a| a.limits.as_ref())
389    }
390
391    /// Whether `asset` is one of the Spark-side assets this route accepts.
392    pub fn accepts_asset(&self, asset: &SparkAsset) -> bool {
393        self.accepted_assets.iter().any(|a| &a.asset == asset)
394    }
395
396    /// Infers the destination address family from the route's
397    /// `contract_address`. Returns `None` for native-asset routes (no
398    /// contract address) or if the address format isn't recognized; callers
399    /// should treat that as "skip the address-family validation".
400    pub(crate) fn destination_address_family(
401        &self,
402    ) -> Option<breez_sdk_common::input::CrossChainAddressFamily> {
403        self.contract_address
404            .as_deref()
405            .and_then(breez_sdk_common::input::detect_address_family)
406    }
407}
408
409/// Per-provider service registry plus shared cross-chain dependencies (today:
410/// the cached `FiatService`). Keeping the cache here scopes it to cross-chain
411/// flows; `sdk.fiat_service` stays uncached for general fiat consumers.
412#[derive(Clone)]
413pub(crate) struct CrossChainContext {
414    providers: HashMap<CrossChainProvider, Arc<dyn CrossChainService>>,
415    fiat_service: Arc<dyn FiatService>,
416    /// The Orchestra quoter, if one was set.
417    orchestra_quoter: Option<Arc<OrchestraQuoter>>,
418}
419
420impl CrossChainContext {
421    pub fn new(fiat_service: Arc<dyn FiatService>) -> Self {
422        Self {
423            providers: HashMap::new(),
424            fiat_service,
425            orchestra_quoter: None,
426        }
427    }
428
429    pub fn insert(&mut self, key: CrossChainProvider, service: Arc<dyn CrossChainService>) {
430        self.providers.insert(key, service);
431    }
432
433    pub fn set_orchestra_quoter(&mut self, quoter: Arc<OrchestraQuoter>) {
434        self.orchestra_quoter = Some(quoter);
435    }
436
437    /// The Orchestra quoter, if one was set.
438    pub fn orchestra_quoter(&self) -> Option<&Arc<OrchestraQuoter>> {
439        self.orchestra_quoter.as_ref()
440    }
441
442    /// Fails with [`SdkError::CrossChainDisabled`] when no provider is
443    /// registered, which happens only when `cross_chain_config` is unset.
444    pub fn ensure_enabled(&self) -> Result<(), SdkError> {
445        if self.providers.is_empty() {
446            return Err(SdkError::cross_chain_disabled());
447        }
448        Ok(())
449    }
450
451    /// Look up a provider, returning a friendly error if missing.
452    pub fn get(
453        &self,
454        provider: CrossChainProvider,
455    ) -> Result<&Arc<dyn CrossChainService>, SdkError> {
456        self.ensure_enabled()?;
457        self.providers.get(&provider).ok_or_else(|| {
458            SdkError::InvalidInput(format!("Cross-chain provider {provider} is not available."))
459        })
460    }
461
462    pub fn values(&self) -> impl Iterator<Item = &Arc<dyn CrossChainService>> {
463        self.providers.values()
464    }
465
466    /// Every provider's routes for `filter`, limited to USD-pegged assets and
467    /// sorted. A provider whose listing fails is logged and left out.
468    pub async fn list_routes(
469        &self,
470        filter: &CrossChainRouteFilter,
471    ) -> Result<Vec<CrossChainRoutePair>, SdkError> {
472        self.ensure_enabled()?;
473        let filter = &filter.normalized();
474        let mut all_routes = Vec::new();
475        for svc in self.values() {
476            match svc.get_routes(filter).await {
477                Ok(routes) => all_routes.extend(routes),
478                Err(e) => tracing::warn!("Cross-chain provider route fetch failed: {e}"),
479            }
480        }
481
482        // Filter to USD-pegged destinations only.
483        all_routes.retain(|r| is_usd_stable_asset(&r.asset));
484
485        all_routes.sort_by(|a, b| {
486            a.asset
487                .cmp(&b.asset)
488                .then_with(|| a.chain.cmp(&b.chain))
489                .then_with(|| a.provider.cmp(&b.provider))
490        });
491        Ok(all_routes)
492    }
493
494    /// Cached fiat service shared with every cross-chain provider. Read
495    /// through this on the prepare path so the TTL window is shared.
496    pub fn fiat_service(&self) -> &Arc<dyn FiatService> {
497        &self.fiat_service
498    }
499}
500
501/// Provider-internal state produced by `prepare` and consumed by `send`.
502/// Typed per provider so the send stage can resume without re-quoting and
503/// without a serde round-trip. Callers should round-trip this value as-is.
504#[derive(Debug, Clone, Serialize, Deserialize)]
505#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
506pub enum CrossChainProviderContext {
507    Orchestra {
508        /// Orchestra quote id, passed back on `/submit`.
509        quote_id: String,
510        /// Spark address Orchestra expects the deposit transfer to land on.
511        deposit_address: String,
512        /// Spark-side deposit amount in the route's source-asset base units.
513        #[serde(default)]
514        deposit_amount: u128,
515    },
516    Boltz {
517        /// Boltz swap id.
518        swap_id: String,
519        /// Hold invoice to pay.
520        invoice: String,
521        /// Hold invoice amount in sats.
522        #[serde(default)]
523        invoice_amount_sats: u64,
524        /// Slippage tolerance in basis points.
525        max_slippage_bps: u32,
526    },
527}
528
529/// Prepared cross-chain receive: the payment request to hand to the sender
530/// and the receive-quote details. For a receive into this wallet, the provider
531/// row is already persisted by the time this returns.
532#[derive(Debug, Clone)]
533pub(crate) struct CrossChainReceivePrepared {
534    /// Canonical cross-chain URI the sender can paste or scan to pay.
535    pub payment_request: String,
536    pub info: CrossChainReceiveInfo,
537    /// Provider token for reading the quote's status, when it issued one.
538    pub quote_read_token: Option<String>,
539}
540
541/// Information about the cross-chain receive quote: what the sender deposits
542/// and what the receiver is expected to get.
543#[derive(Debug, Clone, Serialize, Deserialize)]
544#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
545pub struct CrossChainReceiveInfo {
546    /// The provider's id for this receive. The incoming payment carries the
547    /// same id in its conversion info, so it can be matched to this receive.
548    pub quote_id: String,
549    /// Bare deposit address the sender pays to on the source chain, or the
550    /// BOLT11 invoice when the sender pays over Lightning.
551    pub deposit_address: String,
552    /// Amount the sender must deposit, in source-asset base units
553    /// (`route.decimals`, or sats over Lightning). On `FeesExcluded` this may
554    /// differ from the request's `amount` because the SDK inflates the deposit
555    /// to absorb provider fees. Render this value to the sender.
556    pub deposit_amount: u128,
557    /// Amount the receiver will see, net of provider fees, in
558    /// destination-asset base units. Sats when receiving BTC, token base
559    /// units when receiving a Spark token (e.g. USDB), or stablecoin base
560    /// units when the receiver is on another chain. The final delivered
561    /// amount may move within the slippage tolerance.
562    pub expected_received_amount: u128,
563    /// Symbol of the asset `expected_received_amount` is denominated in, as
564    /// the provider reports it: `"BTC"` for sats, a Spark token symbol (e.g.
565    /// `"USDB"`), or the stablecoin when the receiver is on another chain.
566    pub destination_asset: String,
567    /// Spark token identifier when the destination is a Spark token. Absent
568    /// otherwise.
569    pub token_identifier: Option<String>,
570    /// Provider-quoted total fee for this receive, including the partner fee
571    /// when one is set, in `service_fee_asset` units.
572    pub service_fee_amount: u128,
573    /// Ticker for `service_fee_amount`. Absent when the fee is denominated
574    /// in sats.
575    pub service_fee_asset: Option<String>,
576    /// Decimals of `service_fee_asset`, for formatting `service_fee_amount`.
577    /// Unset when the fee is in sats or the provider did not report them.
578    pub service_fee_asset_decimals: Option<u32>,
579    /// Quote expiry as a unix timestamp in seconds.
580    pub expires_at: u64,
581}
582
583/// Data stashed on the prepared send payment so the provider can resume
584/// the send stage without re-quoting.
585#[derive(Debug, Clone)]
586pub(crate) struct CrossChainSendPrepared {
587    pub amount_in: u128,
588    /// `amount_in` expressed in the cross-chain (destination) asset's base
589    /// units, via the fiat rate or decimal rescale the SDK used at prepare
590    /// time.
591    pub asset_amount_in: u128,
592    /// Amount the recipient will receive, in cross-chain asset base units.
593    pub estimated_out: u128,
594    /// Total user-visible fee in cross-chain asset base units. Covers provider
595    /// spread, bridge/gas, and DEX slippage. On the token-conversion path it
596    /// also rolls in the LN routing budget; on the direct path that budget
597    /// lives separately in `source_transfer_fee_sats`. The dispatcher
598    /// overrides this on the conversion path to reflect the token-side debit.
599    pub fee_amount: u128,
600    /// Provider service fee, including the partner fee when one is set, in
601    /// `service_fee_asset` base units.
602    pub service_fee_amount: u128,
603    /// Asset that the service fee is denominated in. Unset means BTC sats.
604    pub service_fee_asset: Option<String>,
605    /// Decimals of `service_fee_asset`. Unset for sats or when unreported.
606    pub service_fee_asset_decimals: Option<u32>,
607    /// Sats cost to the wallet of moving `amount_in` from the wallet to the
608    /// provider. For Boltz: the Lightning routing fee budget for paying the
609    /// hold invoice (a budget, not a central estimate — enforced as a hard
610    /// cap at send time). For Orchestra: the Spark transfer fee (0 today;
611    /// non-zero in the future).
612    ///
613    /// Semantically distinct from `fee_amount` (provider's service fee /
614    /// spread) and from destination-chain costs (baked into `estimated_out`).
615    /// Denominated in sats — the field assumes a sats-denominated source leg.
616    pub source_transfer_fee_sats: u64,
617    /// Fee mode the prepare was called with. Needed at send time so the
618    /// provider knows whether to apply FeesIncluded-style overpayment.
619    pub fee_mode: CrossChainFeeMode,
620    pub expires_at: String,
621    pub pair: CrossChainRoutePair,
622    pub recipient_address: String,
623    /// The `token_identifier` on the Spark source (e.g. USDB). `None` for BTC sats.
624    pub token_identifier: Option<String>,
625    /// Provider-internal state carried between `prepare` and `send`.
626    pub provider_context: CrossChainProviderContext,
627    /// Provider token for reading the quote's status, when it issued one.
628    pub quote_read_token: Option<String>,
629}
630
631/// Abstraction over cross-chain bridge/swap providers.
632///
633/// Each implementation owns its own client, caching, and background monitoring.
634/// The SDK dispatches to the provider via this trait.
635#[allow(clippy::too_many_arguments)]
636#[macros::async_trait]
637pub(crate) trait CrossChainService: Send + Sync {
638    /// Returns the available cross-chain route pairs.
639    ///
640    /// The returned [`CrossChainRoutePair`] always describes the non-Spark
641    /// side of the route. The [`CrossChainRouteFilter`] controls direction
642    /// and optional filtering.
643    async fn get_routes(
644        &self,
645        filter: &CrossChainRouteFilter,
646    ) -> Result<Vec<CrossChainRoutePair>, SdkError>;
647
648    /// Fetch a quote for a cross-chain send or Lightning onramp. `amount` is
649    /// always in the source-leg sats/token base units. The caller converts any
650    /// USD intent to sats first. `delivery_method` selects the rail the wallet
651    /// dispatches over. `None` uses the provider's default
652    /// ([`DeliveryMethod::Spark`] for Orchestra). `source_token_identifier`
653    /// is only meaningful for a Spark source (`None` = BTC sats, `Some` = a
654    /// token).
655    #[allow(clippy::too_many_arguments)]
656    async fn prepare_send(
657        &self,
658        recipient_address: &str,
659        route: &CrossChainRoutePair,
660        amount: u128,
661        delivery_method: Option<DeliveryMethod>,
662        source_token_identifier: Option<String>,
663        max_slippage_bps: u32,
664        fee_mode: CrossChainFeeMode,
665    ) -> Result<CrossChainSendPrepared, SdkError>;
666
667    /// Fetch a quote for a cross-chain receive.
668    ///
669    /// `amount` is the caller's raw ask, always:
670    /// - `FeesExcluded`: net amount the receiver wants to land on the Spark
671    ///   side, in `destination`'s base units (sats for BTC, token base units
672    ///   for tokens). The provider inflates by `target_overpay_bps` when
673    ///   sizing the deposit but drift-checks against `amount` itself, so the
674    ///   overpay gives Orchestra headroom without tightening the accept
675    ///   threshold beyond what the user asked for.
676    /// - `FeesIncluded`: the deposit the sender will pay, in the route's
677    ///   source-asset base units. The receiver lands `amount - fees`.
678    ///   `target_overpay_bps` is ignored.
679    ///
680    /// `delivery_method` is the rail the funds arrive on:
681    /// [`DeliveryMethod::Spark`] pays `recipient_address` as a Spark address,
682    /// [`DeliveryMethod::Lightning`] as a Lightning address. A wallet-bound
683    /// implementation receives over Spark into its own wallet and tracks the
684    /// receive. `refund_address` is where the provider returns the deposit if
685    /// delivery fails.
686    #[allow(clippy::too_many_arguments)]
687    async fn prepare_receive(
688        &self,
689        route: &CrossChainRoutePair,
690        recipient_address: &str,
691        amount: u128,
692        max_slippage_bps: u32,
693        // Pre-validated Spark-side destination: the SDK dispatch has
694        // checked this against `route.accepted_assets` and resolved any
695        // wallet-level defaults (e.g. active stable balance).
696        destination: &SparkAsset,
697        fee_mode: CrossChainFeeMode,
698        target_overpay_bps: u32,
699        delivery_method: DeliveryMethod,
700        refund_address: Option<&str>,
701    ) -> Result<CrossChainReceivePrepared, SdkError>;
702
703    /// Execute the send: transfer funds to the deposit address, submit to
704    /// the provider, persist metadata, monitor to terminal, and return the
705    /// resulting [`Payment`].
706    ///
707    /// `idempotency_key` is the caller-provided key from `SendPaymentRequest`.
708    /// Providers should use it as the underlying Spark `TransferId` so the
709    /// outbound transfer is protocol-level idempotent on retry; if `None`,
710    /// the provider derives a deterministic key from its own quote/swap id
711    /// (same shape as the stable-balance per-receive convention). Only the
712    /// BTC-source branch benefits — token transfers have no upstream
713    /// idempotency hook, and the top-level dispatcher already rejects
714    /// idempotency keys for token-source sends.
715    ///
716    /// Each provider owns the polling-to-terminal step internally — the
717    /// SDK dispatcher does not wrap this with an additional wait.
718    async fn send(
719        &self,
720        prepared: &CrossChainSendPrepared,
721        idempotency_key: Option<String>,
722    ) -> Result<crate::Payment, SdkError>;
723}
724
725/// Fetches the BTC/USD rate from the Breez Server fiat feed. Errors if the
726/// feed is unreachable, missing the USD entry, or returns a non-finite value.
727pub(crate) async fn fetch_btc_usd_rate(fiat: &dyn FiatService) -> Result<f64, SdkError> {
728    let rates = fiat
729        .fetch_fiat_rates()
730        .await
731        .map_err(|e| SdkError::Generic(format!("Cross-chain: failed to fetch fiat rates: {e}")))?;
732    let btc_usd = rates
733        .iter()
734        .find(|r| r.coin.eq_ignore_ascii_case("USD"))
735        .map(|r| r.value)
736        .ok_or_else(|| {
737            SdkError::Generic("Cross-chain: BTC/USD rate not found in feed".to_string())
738        })?;
739    if !btc_usd.is_finite() || btc_usd <= 0.0 {
740        return Err(SdkError::Generic(format!(
741            "Cross-chain: invalid BTC/USD rate from feed: {btc_usd}"
742        )));
743    }
744    Ok(btc_usd)
745}
746
747/// `sats * fiat_rate * 10^dest_decimals / 10^8`. Sub-base-unit truncation
748/// is absorbed by the route's slippage tolerance.
749#[allow(
750    clippy::cast_precision_loss,
751    clippy::cast_possible_truncation,
752    clippy::cast_sign_loss
753)]
754pub(crate) fn convert_sats_to_destination_amount(
755    sats: u128,
756    fiat_rate: f64,
757    dest_decimals: u32,
758) -> Result<u128, SdkError> {
759    let dest_scale = 10f64.powi(i32::try_from(dest_decimals).unwrap_or(i32::MAX));
760    let target = (sats as f64) * fiat_rate * dest_scale / 100_000_000f64;
761    if !target.is_finite() || target < 0.0 {
762        return Err(SdkError::Generic(format!(
763            "Cross-chain: invalid sats→dest conversion result: {target}"
764        )));
765    }
766    Ok(target as u128)
767}
768
769/// Converts a source-asset amount (base units at `src_decimals`) to sats
770/// via `fiat_per_btc`. Assumes the source is denominated 1:1 in the same
771/// fiat as the rate. Inverse of [`convert_sats_to_destination_amount`].
772#[allow(
773    clippy::cast_precision_loss,
774    clippy::cast_possible_truncation,
775    clippy::cast_sign_loss
776)]
777pub(crate) fn convert_source_amount_to_sats(
778    src_base_units: u128,
779    src_decimals: u32,
780    fiat_per_btc: f64,
781) -> Result<u128, SdkError> {
782    let src_scale = 10f64.powi(i32::try_from(src_decimals).unwrap_or(i32::MAX));
783    let sats = (src_base_units as f64) * 100_000_000f64 / (src_scale * fiat_per_btc);
784    if !sats.is_finite() || sats < 0.0 {
785        return Err(SdkError::Generic(format!(
786            "Cross-chain: invalid stable→sats conversion result: {sats}"
787        )));
788    }
789    Ok(sats as u128)
790}
791
792/// Rejects a route that lists its delivery methods without `delivery_method`.
793/// A hand-built route without delivery methods is accepted.
794pub(crate) fn ensure_route_delivers_over(
795    route: &CrossChainRoutePair,
796    delivery_method: DeliveryMethod,
797) -> Result<(), SdkError> {
798    if !route.delivery_methods.is_empty() && !route.delivery_methods.contains(&delivery_method) {
799        return Err(SdkError::InvalidInput(format!(
800            "The selected route doesn't support {delivery_method}"
801        )));
802    }
803    Ok(())
804}
805
806pub(crate) fn is_usd_stable_asset(asset: &str) -> bool {
807    USD_STABLE_ASSETS
808        .iter()
809        .any(|a| asset.eq_ignore_ascii_case(a))
810}
811
812/// Builds the payment-request URI a sender pays to for a cross-chain
813/// receive. EVM destinations get an EIP-681 URI so wallets like `MetaMask`
814/// auto-fill recipient/token/chain/amount. Solana and Tron destinations
815/// fall back to the bare `deposit_address` because current wallets don't
816/// honor those schemes' parameters reliably (see
817/// [`breez_sdk_common::input::format_cross_chain_uri`]).
818pub(crate) fn build_receive_payment_request(
819    deposit_address: &str,
820    chain: &str,
821    chain_id: Option<&str>,
822    contract_address: Option<&str>,
823    amount: u128,
824) -> Result<String, SdkError> {
825    let family =
826        breez_sdk_common::input::detect_address_family(deposit_address).ok_or_else(|| {
827            SdkError::Generic(format!(
828                "Cross-chain provider returned unrecognised deposit address: {deposit_address}",
829            ))
830        })?;
831    // Guard against a provider returning an address that belongs to a
832    // different chain family than the route we requested.
833    if !family.matches_chain(chain, contract_address) {
834        return Err(SdkError::Generic(format!(
835            "Cross-chain provider returned {family:?} deposit address for {chain} route"
836        )));
837    }
838    Ok(breez_sdk_common::input::format_cross_chain_uri(
839        family,
840        deposit_address,
841        contract_address,
842        chain_id,
843        amount,
844    ))
845}
846
847/// Best-available fee: realized `asset_amount_in − delivered_amount` on
848/// `Completed`, else the prepare-time estimate. Refunded/failed keep the
849/// estimate (the realized formula would be misleading).
850pub(crate) fn compute_terminal_fee_amount(
851    new_status: &crate::ConversionStatus,
852    asset_amount_in: Option<u128>,
853    delivered_amount: Option<u128>,
854    prepare_estimate: Option<u128>,
855) -> Option<u128> {
856    match (new_status, asset_amount_in, delivered_amount) {
857        (crate::ConversionStatus::Completed, Some(a), Some(d)) => Some(a.saturating_sub(d)),
858        _ => prepare_estimate,
859    }
860}
861
862/// Rescales an amount between two base-unit precisions. Assumes
863/// `1 source unit ≈ 1 dest unit` at face value — only valid for USD-stable
864/// pairs. Errors on overflow.
865pub(crate) fn rescale_decimals(
866    amount: u128,
867    src_decimals: u32,
868    dest_decimals: u32,
869) -> Result<u128, SdkError> {
870    if dest_decimals >= src_decimals {
871        let delta = dest_decimals.saturating_sub(src_decimals);
872        let factor = 10u128
873            .checked_pow(delta)
874            .ok_or_else(|| SdkError::Generic("Cross-chain: decimal scale overflow".to_string()))?;
875        amount
876            .checked_mul(factor)
877            .ok_or_else(|| SdkError::Generic("Cross-chain: decimal rescale overflow".to_string()))
878    } else {
879        let delta = src_decimals.saturating_sub(dest_decimals);
880        let factor = 10u128
881            .checked_pow(delta)
882            .ok_or_else(|| SdkError::Generic("Cross-chain: decimal scale overflow".to_string()))?;
883        amount.checked_div(factor).ok_or_else(|| {
884            SdkError::Generic("Cross-chain: decimal rescale divisor zero".to_string())
885        })
886    }
887}
888
889/// Inverse of [`convert_sats_to_destination_amount`]: returns the sats whose
890/// fiat-equivalent matches the given USD-stable `destination_amount`.
891/// Errors on a non-positive `fiat_rate`.
892#[allow(
893    clippy::cast_precision_loss,
894    clippy::cast_possible_truncation,
895    clippy::cast_sign_loss
896)]
897pub(crate) fn convert_destination_amount_to_sats(
898    destination_amount: u128,
899    fiat_rate: f64,
900    dest_decimals: u32,
901) -> Result<u128, SdkError> {
902    if !fiat_rate.is_finite() || fiat_rate <= 0.0 {
903        return Err(SdkError::Generic(format!(
904            "Cross-chain: invalid BTC/USD rate for inversion: {fiat_rate}"
905        )));
906    }
907    let dest_scale = 10f64.powi(i32::try_from(dest_decimals).unwrap_or(i32::MAX));
908    let sats = (destination_amount as f64) * 100_000_000f64 / (fiat_rate * dest_scale);
909    if !sats.is_finite() || sats < 0.0 {
910        return Err(SdkError::Generic(format!(
911            "Cross-chain: invalid dest→sats conversion result: {sats}"
912        )));
913    }
914    Ok(sats as u128)
915}
916
917/// Resolves the target-overpay bps to apply on `FeesExcluded` cross-chain
918/// preparations (send and receive). Same precedence as slippage:
919/// caller-supplied value (bounds-checked here), then the config default, then
920/// the built-in default. Config defaults are validated at SDK startup in
921/// `Config::validate`.
922pub(crate) fn resolve_target_overpay_bps(
923    requested: Option<u32>,
924    config_default: Option<u32>,
925) -> Result<u32, SdkError> {
926    if let Some(bps) = requested
927        && !(MIN_TARGET_OVERPAY_BPS..=MAX_TARGET_OVERPAY_BPS).contains(&bps)
928    {
929        return Err(SdkError::InvalidInput(format!(
930            "target_overpay_bps {bps} must be in \
931             {MIN_TARGET_OVERPAY_BPS} to {MAX_TARGET_OVERPAY_BPS}",
932        )));
933    }
934    Ok(requested
935        .or(config_default)
936        .unwrap_or(DEFAULT_TARGET_OVERPAY_BPS))
937}
938
939/// Inflates a target amount by `overpay_bps` so the realized delivery lands at
940/// or above target despite provider slippage. `overpay_bps == 0` is identity.
941/// Used on both directions: send pads the destination target, receive pads
942/// the source-asset deposit.
943pub(crate) fn inflate_target_amount(amount: u128, overpay_bps: u32) -> u128 {
944    if overpay_bps == 0 {
945        return amount;
946    }
947    amount.saturating_add(amount.saturating_mul(u128::from(overpay_bps)) / 10_000)
948}
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953    use macros::test_all;
954
955    #[cfg(feature = "browser-tests")]
956    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
957
958    struct NoFiat;
959
960    #[macros::async_trait]
961    impl FiatService for NoFiat {
962        async fn fetch_fiat_currencies(
963            &self,
964        ) -> Result<
965            Vec<breez_sdk_common::fiat::FiatCurrency>,
966            breez_sdk_common::error::ServiceConnectivityError,
967        > {
968            Ok(Vec::new())
969        }
970
971        async fn fetch_fiat_rates(
972            &self,
973        ) -> Result<
974            Vec<breez_sdk_common::fiat::Rate>,
975            breez_sdk_common::error::ServiceConnectivityError,
976        > {
977            Ok(Vec::new())
978        }
979    }
980
981    struct StubProvider;
982
983    #[macros::async_trait]
984    impl CrossChainService for StubProvider {
985        async fn get_routes(
986            &self,
987            _filter: &CrossChainRouteFilter,
988        ) -> Result<Vec<CrossChainRoutePair>, SdkError> {
989            unimplemented!()
990        }
991
992        async fn prepare_send(
993            &self,
994            _recipient_address: &str,
995            _route: &CrossChainRoutePair,
996            _amount: u128,
997            _delivery_method: Option<DeliveryMethod>,
998            _source_token_identifier: Option<String>,
999            _max_slippage_bps: u32,
1000            _fee_mode: CrossChainFeeMode,
1001        ) -> Result<CrossChainSendPrepared, SdkError> {
1002            unimplemented!()
1003        }
1004
1005        async fn prepare_receive(
1006            &self,
1007            _route: &CrossChainRoutePair,
1008            _recipient_address: &str,
1009            _amount: u128,
1010            _max_slippage_bps: u32,
1011            _destination: &SparkAsset,
1012            _fee_mode: CrossChainFeeMode,
1013            _target_overpay_bps: u32,
1014            _delivery_method: DeliveryMethod,
1015            _refund_address: Option<&str>,
1016        ) -> Result<CrossChainReceivePrepared, SdkError> {
1017            unimplemented!()
1018        }
1019
1020        async fn send(
1021            &self,
1022            _prepared: &CrossChainSendPrepared,
1023            _idempotency_key: Option<String>,
1024        ) -> Result<crate::Payment, SdkError> {
1025            unimplemented!()
1026        }
1027    }
1028
1029    fn evm_address_details() -> crate::CrossChainAddressDetails {
1030        crate::CrossChainAddressDetails {
1031            address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e".to_string(),
1032            address_family: crate::CrossChainAddressFamily::Evm,
1033            contract_address: None,
1034            chain_id: None,
1035            amount: None,
1036        }
1037    }
1038
1039    #[test_all]
1040    fn empty_context_reports_cross_chain_disabled() {
1041        let context = CrossChainContext::new(Arc::new(NoFiat));
1042        assert!(matches!(
1043            context.ensure_enabled(),
1044            Err(SdkError::CrossChainDisabled { .. })
1045        ));
1046        assert!(matches!(
1047            context.get(CrossChainProvider::Orchestra),
1048            Err(SdkError::CrossChainDisabled { .. })
1049        ));
1050    }
1051
1052    #[test_all]
1053    fn enabled_context_reports_missing_provider() {
1054        let mut context = CrossChainContext::new(Arc::new(NoFiat));
1055        context.insert(CrossChainProvider::Orchestra, Arc::new(StubProvider));
1056        assert!(context.ensure_enabled().is_ok());
1057        assert!(context.get(CrossChainProvider::Orchestra).is_ok());
1058        assert!(matches!(
1059            context.get(CrossChainProvider::Boltz),
1060            Err(SdkError::InvalidInput(_))
1061        ));
1062    }
1063
1064    #[test_all]
1065    fn route_filter_normalizes_payment_link_to_lightning_send() {
1066        let filter = CrossChainRouteFilter::PaymentLink {
1067            address_details: evm_address_details(),
1068        };
1069        let CrossChainRouteFilter::Send {
1070            address_details,
1071            delivery_method,
1072        } = filter.normalized()
1073        else {
1074            panic!("expected a send filter");
1075        };
1076        assert_eq!(address_details.address, evm_address_details().address);
1077        assert_eq!(delivery_method, Some(DeliveryMethod::Lightning));
1078    }
1079
1080    #[test_all]
1081    fn ensure_route_delivers_over_checks_listed_methods() {
1082        let route = |delivery_methods: Vec<DeliveryMethod>| CrossChainRoutePair {
1083            provider: CrossChainProvider::Orchestra,
1084            chain: "base".to_string(),
1085            chain_id: None,
1086            asset: "USDC".to_string(),
1087            contract_address: None,
1088            decimals: 6,
1089            exact_out_eligible: false,
1090            accepted_assets: Vec::new(),
1091            delivery_methods,
1092        };
1093        let spark = route(vec![DeliveryMethod::Spark]);
1094        let lightning = route(vec![DeliveryMethod::Lightning]);
1095        let unlisted = route(Vec::new());
1096
1097        assert!(ensure_route_delivers_over(&spark, DeliveryMethod::Spark).is_ok());
1098        assert!(matches!(
1099            ensure_route_delivers_over(&lightning, DeliveryMethod::Spark),
1100            Err(SdkError::InvalidInput(_))
1101        ));
1102        assert!(ensure_route_delivers_over(&lightning, DeliveryMethod::Lightning).is_ok());
1103        assert!(ensure_route_delivers_over(&unlisted, DeliveryMethod::Spark).is_ok());
1104    }
1105
1106    #[test_all]
1107    fn delivery_method_display_is_human_readable() {
1108        assert_eq!(DeliveryMethod::Spark.to_string(), "Spark");
1109        assert_eq!(DeliveryMethod::Lightning.to_string(), "Lightning");
1110        assert_eq!(DeliveryMethod::Bitcoin.to_string(), "Bitcoin");
1111    }
1112
1113    #[test_all]
1114    fn derive_btc_leg_transfer_id_uses_caller_key() {
1115        // A v4 UUID is a valid TransferId — using one here checks that the
1116        // caller-supplied key wins outright.
1117        let key = "00000000-0000-4000-8000-000000000001";
1118        let id = derive_btc_leg_transfer_id(Some(key), "ignored-seed").unwrap();
1119        assert_eq!(id.to_string(), key);
1120    }
1121
1122    #[test_all]
1123    fn derive_btc_leg_transfer_id_deterministic_from_seed() {
1124        let a = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
1125        let b = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
1126        assert_eq!(
1127            a, b,
1128            "same seed must produce the same TransferId across calls"
1129        );
1130    }
1131
1132    #[test_all]
1133    fn derive_btc_leg_transfer_id_distinct_seeds_yield_distinct_ids() {
1134        let a = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-1").unwrap();
1135        let b = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:quote-2").unwrap();
1136        assert_ne!(a, b);
1137    }
1138
1139    #[test_all]
1140    fn derive_btc_leg_transfer_id_orchestra_and_boltz_seeds_collide_only_on_id() {
1141        // The provider tag in the seed prevents an Orchestra `quote-1` and a
1142        // hypothetical Boltz `quote-1` from colliding on the same TransferId.
1143        let orchestra = derive_btc_leg_transfer_id(None, "cross_chain:orchestra:abc").unwrap();
1144        let boltz = derive_btc_leg_transfer_id(None, "cross_chain:boltz:abc").unwrap();
1145        assert_ne!(orchestra, boltz);
1146    }
1147
1148    #[test_all]
1149    fn derive_btc_leg_transfer_id_rejects_invalid_caller_key() {
1150        let err = derive_btc_leg_transfer_id(Some("not-a-uuid"), "fallback").unwrap_err();
1151        assert!(matches!(err, SdkError::Generic(_)));
1152    }
1153
1154    #[test_all]
1155    fn convert_sats_to_destination_amount_round_trip_inverts_to_sats() {
1156        // 10_000 sats at $60_000/BTC → $6.00 = 6_000_000 USDC base units.
1157        let dest = convert_sats_to_destination_amount(10_000, 60_000.0, 6).unwrap();
1158        assert_eq!(dest, 6_000_000);
1159        // Inverse must recover the source sats.
1160        let sats = convert_destination_amount_to_sats(dest, 60_000.0, 6).unwrap();
1161        assert_eq!(sats, 10_000);
1162    }
1163
1164    #[test_all]
1165    fn convert_destination_amount_to_sats_typical_stable() {
1166        // 1 USDC ($1.00 = 1_000_000 base units) at $60_000/BTC → 1666 sats (floor).
1167        let sats = convert_destination_amount_to_sats(1_000_000, 60_000.0, 6).unwrap();
1168        assert_eq!(sats, 1_666);
1169    }
1170
1171    #[test_all]
1172    fn convert_destination_amount_to_sats_zero_passes_through() {
1173        let sats = convert_destination_amount_to_sats(0, 60_000.0, 6).unwrap();
1174        assert_eq!(sats, 0);
1175    }
1176
1177    #[test_all]
1178    fn convert_destination_amount_to_sats_rejects_non_positive_rate() {
1179        let err = convert_destination_amount_to_sats(1_000_000, 0.0, 6).unwrap_err();
1180        assert!(matches!(err, SdkError::Generic(ref m) if m.contains("invalid BTC/USD rate")));
1181        let err = convert_destination_amount_to_sats(1_000_000, f64::NAN, 6).unwrap_err();
1182        assert!(matches!(err, SdkError::Generic(_)));
1183    }
1184
1185    #[test_all]
1186    fn rescale_decimals_scales_down_when_dest_decimals_lower() {
1187        assert_eq!(rescale_decimals(100_000_000, 8, 6).unwrap(), 1_000_000);
1188    }
1189
1190    #[test_all]
1191    fn rescale_decimals_same_decimals_is_identity() {
1192        assert_eq!(rescale_decimals(123_456_789, 6, 6).unwrap(), 123_456_789);
1193    }
1194
1195    #[test_all]
1196    fn rescale_decimals_scales_up_when_dest_decimals_higher() {
1197        assert_eq!(rescale_decimals(1_000_000, 6, 8).unwrap(), 100_000_000);
1198    }
1199
1200    #[test_all]
1201    fn rescale_decimals_zero_passes_through() {
1202        assert_eq!(rescale_decimals(0, 8, 6).unwrap(), 0);
1203        assert_eq!(rescale_decimals(0, 6, 8).unwrap(), 0);
1204    }
1205
1206    #[test_all]
1207    fn is_usd_stable_asset_recognizes_known_stables() {
1208        for ticker in ["USDB", "USDC", "USDT", "USDT0", "usdb", "uSdC"] {
1209            assert!(is_usd_stable_asset(ticker), "{ticker} should be stable");
1210        }
1211    }
1212
1213    #[test_all]
1214    fn is_usd_stable_asset_rejects_btc_and_unknown() {
1215        for ticker in ["BTC", "ETH", "DAI", "", "USD"] {
1216            assert!(
1217                !is_usd_stable_asset(ticker),
1218                "{ticker} should not be a recognized USD-stable"
1219            );
1220        }
1221    }
1222
1223    // ---- compute_terminal_fee_amount ----
1224
1225    #[test_all]
1226    fn compute_terminal_fee_overwrites_estimate_on_completed() {
1227        let realized = compute_terminal_fee_amount(
1228            &crate::ConversionStatus::Completed,
1229            Some(1_020_434), // asset_amount_in
1230            Some(997_498),   // delivered_amount
1231            Some(20_434),    // prepare-time estimate
1232        );
1233        assert_eq!(realized, Some(22_936), "= asset_amount_in − delivered");
1234    }
1235
1236    #[test_all]
1237    fn compute_terminal_fee_keeps_estimate_on_refunded() {
1238        // Refunded payments don't have a realized fee semantic; the estimate
1239        // is the best we can show (and the realized formula would produce
1240        // garbage because delivered_amount is 0/None on a refund).
1241        let realized = compute_terminal_fee_amount(
1242            &crate::ConversionStatus::Refunded,
1243            Some(1_020_434),
1244            None,
1245            Some(20_434),
1246        );
1247        assert_eq!(realized, Some(20_434));
1248    }
1249
1250    #[test_all]
1251    fn compute_terminal_fee_keeps_estimate_on_failed() {
1252        let realized = compute_terminal_fee_amount(
1253            &crate::ConversionStatus::Failed,
1254            Some(1_020_434),
1255            None,
1256            Some(20_434),
1257        );
1258        assert_eq!(realized, Some(20_434));
1259    }
1260
1261    #[test_all]
1262    fn compute_terminal_fee_keeps_estimate_when_asset_amount_in_missing() {
1263        // Pre-upgrade rows have no asset_amount_in; realized fee can't be
1264        // computed, so the stored estimate stays as-is.
1265        let realized = compute_terminal_fee_amount(
1266            &crate::ConversionStatus::Completed,
1267            None, // asset_amount_in missing
1268            Some(997_498),
1269            Some(20_434),
1270        );
1271        assert_eq!(realized, Some(20_434));
1272    }
1273
1274    #[test_all]
1275    fn compute_terminal_fee_keeps_estimate_when_delivered_amount_missing() {
1276        // Should never happen on Completed per the contract, but defend
1277        // against the edge anyway.
1278        let realized = compute_terminal_fee_amount(
1279            &crate::ConversionStatus::Completed,
1280            Some(1_020_434),
1281            None, // delivered_amount missing
1282            Some(20_434),
1283        );
1284        assert_eq!(realized, Some(20_434));
1285    }
1286
1287    #[test_all]
1288    fn compute_terminal_fee_saturating_sub_on_over_delivery() {
1289        // Rare but possible: provider over-delivers vs source.
1290        let realized = compute_terminal_fee_amount(
1291            &crate::ConversionStatus::Completed,
1292            Some(1_000_000),
1293            Some(1_005_000),
1294            Some(0),
1295        );
1296        assert_eq!(
1297            realized,
1298            Some(0),
1299            "saturating_sub must clamp at 0, not underflow"
1300        );
1301    }
1302
1303    /// Regression: `CrossChainProviderContext::Boltz.invoice_amount_sats` must
1304    /// be the source of truth for the LN-leg amount, distinct from
1305    /// `CrossChainSendPrepared::amount_in` (which can carry a user-facing display
1306    /// value such as token base units after the dispatcher's conversion-path
1307    /// override). Conflating the two persisted USDB base units into the
1308    /// `invoice_amount_sats` field of `ConversionInfo::Boltz`, showing a
1309    /// ~$1,200,000-sat "from" amount for a ~$1 send. This test asserts the
1310    /// two fields are independently representable + survive a serde
1311    /// round-trip with their distinct values.
1312    #[test_all]
1313    fn boltz_provider_context_invoice_amount_sats_is_independent_of_amount_in() {
1314        let ctx = CrossChainProviderContext::Boltz {
1315            swap_id: "swap_1".to_string(),
1316            invoice: "lnbc19090n1pexample".to_string(),
1317            invoice_amount_sats: 1_909,
1318            max_slippage_bps: 100,
1319        };
1320        let json = serde_json::to_string(&ctx).unwrap();
1321        let decoded: CrossChainProviderContext = serde_json::from_str(&json).unwrap();
1322        let CrossChainProviderContext::Boltz {
1323            invoice_amount_sats,
1324            ..
1325        } = &decoded
1326        else {
1327            panic!("expected Boltz variant");
1328        };
1329        assert_eq!(*invoice_amount_sats, 1_909);
1330        assert!(
1331            *invoice_amount_sats != 1_222_703,
1332            "the LN invoice sats must never be conflated with a user-facing display value (e.g. USDB base units)"
1333        );
1334    }
1335
1336    /// Pre-bug-fix persisted contexts lack `invoice_amount_sats`. Serde must
1337    /// default the missing field to 0 rather than failing to deserialize — the
1338    /// downstream send-time error becomes obvious instead of corrupting the
1339    /// stored Payment.
1340    #[test_all]
1341    fn boltz_provider_context_legacy_row_without_invoice_amount_sats_defaults_to_zero() {
1342        let legacy = r#"{
1343            "Boltz": {
1344                "swap_id": "swap_legacy",
1345                "invoice": "lnbc19090n1p",
1346                "max_slippage_bps": 100
1347            }
1348        }"#;
1349        let decoded: CrossChainProviderContext = serde_json::from_str(legacy).unwrap();
1350        let CrossChainProviderContext::Boltz {
1351            invoice_amount_sats,
1352            ..
1353        } = &decoded
1354        else {
1355            panic!("expected Boltz variant");
1356        };
1357        assert_eq!(*invoice_amount_sats, 0);
1358    }
1359
1360    /// Same invariant for Orchestra: `deposit_amount` is the source of truth
1361    /// for the deposit transfer size and is distinct from
1362    /// `CrossChainSendPrepared::amount_in`.
1363    #[test_all]
1364    fn orchestra_provider_context_deposit_amount_is_independent_of_amount_in() {
1365        let ctx = CrossChainProviderContext::Orchestra {
1366            quote_id: "q_1".to_string(),
1367            deposit_address: "spark1...".to_string(),
1368            deposit_amount: 1_020_434,
1369        };
1370        let json = serde_json::to_string(&ctx).unwrap();
1371        let decoded: CrossChainProviderContext = serde_json::from_str(&json).unwrap();
1372        let CrossChainProviderContext::Orchestra { deposit_amount, .. } = &decoded else {
1373            panic!("expected Orchestra variant");
1374        };
1375        assert_eq!(*deposit_amount, 1_020_434);
1376    }
1377
1378    fn boltz_info(swap_id: &str) -> ConversionInfo {
1379        ConversionInfo::Boltz {
1380            swap_id: swap_id.to_string(),
1381            invoice: "lnbc1".to_string(),
1382            invoice_amount_sats: 1_000,
1383            bridge_ref: None,
1384            max_slippage_bps: 100,
1385            quote_degraded: false,
1386            chain: "polygon".to_string(),
1387            chain_id: None,
1388            asset: "USDC".to_string(),
1389            recipient_address: "0xabc".to_string(),
1390            estimated_out: 1_000_000,
1391            delivered_amount: None,
1392            status: crate::ConversionStatus::Pending,
1393            asset_amount_in: None,
1394            fee_amount: None,
1395            service_fee_amount: None,
1396            service_fee_asset: None,
1397            asset_decimals: 6,
1398            asset_contract: None,
1399        }
1400    }
1401
1402    #[test_all]
1403    fn payment_with_conversion_info_injects_into_lightning_details() {
1404        let payment = crate::Payment {
1405            id: "p1".to_string(),
1406            payment_type: crate::PaymentType::Send,
1407            status: crate::PaymentStatus::Pending,
1408            amount: 1_000,
1409            fees: 0,
1410            timestamp: 100,
1411            method: crate::PaymentMethod::Lightning,
1412            details: Some(PaymentDetails::Lightning {
1413                description: Some("desc".to_string()),
1414                invoice: "lnbc1".to_string(),
1415                destination_pubkey: "02aa".to_string(),
1416                htlc_details: crate::SparkHtlcDetails {
1417                    payment_hash: "hash1".to_string(),
1418                    preimage: None,
1419                    expiry_time: 0,
1420                    status: crate::SparkHtlcStatus::PreimageShared,
1421                },
1422                lnurl_pay_info: None,
1423                lnurl_withdraw_info: None,
1424                lnurl_receive_metadata: None,
1425                conversion_info: None,
1426            }),
1427            conversion_details: None,
1428        };
1429
1430        let out = payment_with_conversion_info(payment, Some(boltz_info("swap1")));
1431
1432        assert_eq!(out.status, crate::PaymentStatus::Pending);
1433        let Some(PaymentDetails::Lightning {
1434            invoice,
1435            description,
1436            conversion_info,
1437            ..
1438        }) = out.details
1439        else {
1440            panic!("expected Lightning details");
1441        };
1442        // Sibling fields survive the rebuild.
1443        assert_eq!(invoice, "lnbc1");
1444        assert_eq!(description.as_deref(), Some("desc"));
1445        assert!(matches!(
1446            conversion_info,
1447            Some(ConversionInfo::Boltz { ref swap_id, .. }) if swap_id == "swap1"
1448        ));
1449    }
1450
1451    #[test_all]
1452    fn payment_with_conversion_info_passes_through_variants_without_a_slot() {
1453        let payment = crate::Payment {
1454            id: "p1".to_string(),
1455            payment_type: crate::PaymentType::Send,
1456            status: crate::PaymentStatus::Completed,
1457            amount: 1_000,
1458            fees: 0,
1459            timestamp: 100,
1460            method: crate::PaymentMethod::Withdraw,
1461            details: Some(PaymentDetails::Withdraw {
1462                tx_id: "tx1".to_string(),
1463                vout: 0,
1464                destination_address: "bcrt1p_destination".to_string(),
1465            }),
1466            conversion_details: None,
1467        };
1468
1469        let out = payment_with_conversion_info(payment, Some(boltz_info("swap1")));
1470
1471        assert!(matches!(
1472            out.details,
1473            Some(PaymentDetails::Withdraw { tx_id, .. }) if tx_id == "tx1"
1474        ));
1475    }
1476
1477    // ---- resolve_target_overpay_bps ----
1478
1479    #[test_all]
1480    fn resolve_target_overpay_uses_request_when_in_range() {
1481        assert_eq!(resolve_target_overpay_bps(Some(50), Some(75)).unwrap(), 50);
1482    }
1483
1484    #[test_all]
1485    fn resolve_target_overpay_falls_back_to_config_default() {
1486        assert_eq!(resolve_target_overpay_bps(None, Some(75)).unwrap(), 75);
1487    }
1488
1489    #[test_all]
1490    fn resolve_target_overpay_falls_back_to_built_in_default() {
1491        assert_eq!(
1492            resolve_target_overpay_bps(None, None).unwrap(),
1493            DEFAULT_TARGET_OVERPAY_BPS
1494        );
1495    }
1496
1497    #[test_all]
1498    fn resolve_target_overpay_request_zero_opts_out() {
1499        assert_eq!(resolve_target_overpay_bps(Some(0), Some(50)).unwrap(), 0);
1500    }
1501
1502    #[test_all]
1503    fn resolve_target_overpay_rejects_out_of_range_request() {
1504        let too_high = MAX_TARGET_OVERPAY_BPS + 1;
1505        assert!(matches!(
1506            resolve_target_overpay_bps(Some(too_high), None),
1507            Err(SdkError::InvalidInput(_))
1508        ));
1509    }
1510
1511    // ---- inflate_target_amount ----
1512
1513    #[test_all]
1514    fn inflate_target_amount_zero_bps_is_identity() {
1515        assert_eq!(inflate_target_amount(1_000_000, 0), 1_000_000);
1516    }
1517
1518    #[test_all]
1519    fn inflate_target_amount_applies_bps_pad() {
1520        // 25 bps on 1_000_000 → 2_500 pad.
1521        assert_eq!(inflate_target_amount(1_000_000, 25), 1_002_500);
1522    }
1523
1524    #[test_all]
1525    fn inflate_target_amount_truncates_sub_unit_pad() {
1526        // 25 bps on 100 → 0.25 pad, truncates to 0.
1527        assert_eq!(inflate_target_amount(100, 25), 100);
1528    }
1529
1530    // ---- convert_source_amount_to_sats ----
1531
1532    #[test_all]
1533    fn convert_stable_to_sats_at_par_6dp() {
1534        // BTC/USD = 100_000, 1 USD = 1000 sats. 1_000_000 (6dp) = $1 = 1000 sats.
1535        assert_eq!(
1536            convert_source_amount_to_sats(1_000_000, 6, 100_000.0).unwrap(),
1537            1000
1538        );
1539    }
1540
1541    #[test_all]
1542    fn convert_stable_to_sats_at_a_different_rate() {
1543        // BTC/USD = 50_000, $1 = 2000 sats.
1544        assert_eq!(
1545            convert_source_amount_to_sats(1_000_000, 6, 50_000.0).unwrap(),
1546            2000
1547        );
1548    }
1549
1550    #[test_all]
1551    fn convert_stable_to_sats_matches_across_decimals_at_same_usd_value() {
1552        // $1 at 6dp and at 18dp must produce the same sats.
1553        let sats_6 = convert_source_amount_to_sats(1_000_000, 6, 100_000.0).unwrap();
1554        let sats_18 =
1555            convert_source_amount_to_sats(1_000_000_000_000_000_000, 18, 100_000.0).unwrap();
1556        assert_eq!(sats_6, sats_18);
1557        assert_eq!(sats_6, 1000);
1558    }
1559
1560    #[test_all]
1561    fn convert_stable_to_sats_zero_input_is_zero() {
1562        assert_eq!(convert_source_amount_to_sats(0, 6, 100_000.0).unwrap(), 0);
1563    }
1564
1565    #[test_all]
1566    fn convert_stable_to_sats_rejects_nan_rate() {
1567        // Infinity is intentionally allowed (produces 0 sats, a finite result);
1568        // NaN is the pathological input we guard against.
1569        assert!(matches!(
1570            convert_source_amount_to_sats(1_000_000, 6, f64::NAN),
1571            Err(SdkError::Generic(_))
1572        ));
1573    }
1574
1575    #[test_all]
1576    fn convert_stable_to_sats_rejects_negative_rate() {
1577        assert!(matches!(
1578            convert_source_amount_to_sats(1_000_000, 6, -100_000.0),
1579            Err(SdkError::Generic(_))
1580        ));
1581    }
1582
1583    // ---- build_receive_payment_request ----
1584
1585    /// EVM destinations produce an EIP-681 URI (with `chain_id` and token
1586    /// contract when present) so wallets like `MetaMask` auto-fill.
1587    #[test_all]
1588    fn build_receive_payment_request_evm_emits_eip_681_uri() {
1589        let uri = build_receive_payment_request(
1590            "0x00Df20df75800ca8f40080505a7a802331C1321c",
1591            "arbitrum",
1592            Some("42161"),
1593            Some("0xaf88d065e77c8cC2239327C5EDb3A432268e5831"),
1594            1_000_000,
1595        )
1596        .unwrap();
1597        assert!(uri.starts_with("ethereum:"), "got {uri}");
1598        assert!(uri.contains("42161"), "chain_id must appear: {uri}");
1599        assert!(
1600            uri.contains("0xaf88d065e77c8cC2239327C5EDb3A432268e5831"),
1601            "token contract must appear: {uri}"
1602        );
1603        assert!(uri.contains("1000000"), "amount must appear: {uri}");
1604    }
1605
1606    /// Solana falls back to the bare deposit address: current wallets don't
1607    /// honor solana: URI parameters reliably.
1608    #[test_all]
1609    fn build_receive_payment_request_solana_returns_bare_address() {
1610        let addr = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM";
1611        let out = build_receive_payment_request(addr, "solana", None, None, 1_000_000).unwrap();
1612        assert_eq!(out, addr);
1613    }
1614
1615    /// Tron falls back to the bare deposit address for the same reason.
1616    #[test_all]
1617    fn build_receive_payment_request_tron_returns_bare_address() {
1618        let addr = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
1619        let out = build_receive_payment_request(addr, "tron", None, None, 1_000_000).unwrap();
1620        assert_eq!(out, addr);
1621    }
1622
1623    /// An unrecognized address surfaces a Generic error rather than silently
1624    /// returning something usable.
1625    #[test_all]
1626    fn build_receive_payment_request_rejects_unrecognized_address() {
1627        let err =
1628            build_receive_payment_request("not-an-address", "arbitrum", None, None, 1_000_000)
1629                .unwrap_err();
1630        assert!(matches!(err, SdkError::Generic(_)));
1631    }
1632
1633    /// An EVM-looking deposit address for a Solana route must be rejected:
1634    /// a provider that miswires chains cannot silently redirect funds by
1635    /// returning an EVM address where a Solana address was expected.
1636    #[test_all]
1637    fn build_receive_payment_request_rejects_family_chain_mismatch() {
1638        let evm_addr = "0x00Df20df75800ca8f40080505a7a802331C1321c";
1639        let err =
1640            build_receive_payment_request(evm_addr, "solana", None, None, 1_000_000).unwrap_err();
1641        match err {
1642            SdkError::Generic(msg) => {
1643                assert!(
1644                    msg.contains("Evm") && msg.contains("solana"),
1645                    "unexpected message: {msg}"
1646                );
1647            }
1648            other => panic!("expected Generic mismatch error, got {other:?}"),
1649        }
1650
1651        let solana_addr = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM";
1652        assert!(
1653            build_receive_payment_request(solana_addr, "tron", None, None, 1_000_000).is_err(),
1654            "solana address must not be accepted for tron route",
1655        );
1656    }
1657
1658    #[cfg(feature = "sqlite")]
1659    mod receive_matching {
1660        use super::*;
1661        use crate::{
1662            PaymentDetails, PaymentMethod, PaymentStatus, PaymentType, SparkInvoicePaymentDetails,
1663            TokenMetadata, TokenTransactionType,
1664        };
1665
1666        fn sqlite_storage() -> Arc<dyn crate::Storage> {
1667            let mut dir = std::env::temp_dir();
1668            dir.push(format!(
1669                "breez-xchain-receive-match-{}",
1670                uuid::Uuid::new_v4()
1671            ));
1672            std::fs::create_dir_all(&dir).unwrap();
1673            Arc::new(crate::SqliteStorage::new(&dir).unwrap())
1674        }
1675
1676        async fn storage_with_open_receive(invoice: &str) -> Arc<dyn crate::Storage> {
1677            let storage = sqlite_storage();
1678            let data = orchestra::storage_adapter::OrchestraSwapData {
1679                quote_id: "q_match".to_string(),
1680                order_id: None,
1681                read_token: None,
1682                quote_read_token: None,
1683                recipient_address: "sp1rcv".to_string(),
1684                spark_invoice: Some(invoice.to_string()),
1685                paid: false,
1686                source_chain: "arbitrum".to_string(),
1687                source_asset: "USDC".to_string(),
1688                source_chain_id: Some("42161".to_string()),
1689                source_contract_address: Some("0xUSDC".to_string()),
1690                source_decimals: 6,
1691                destination_chain: "spark".to_string(),
1692                destination_asset: "BTC".to_string(),
1693                destination_decimals: 8,
1694                token_identifier: None,
1695                amount_in: "1000000".to_string(),
1696                expected_amount_out: "1146".to_string(),
1697                fee_amount: Some("10000".to_string()),
1698                fee_asset: Some("USDC".to_string()),
1699                fee_asset_decimals: Some(6),
1700                expires_at: 1_700_000_120,
1701            };
1702            orchestra::storage_adapter::OrchestraStorageAdapter::new(Arc::clone(&storage))
1703                .upsert(&data)
1704                .await
1705                .unwrap();
1706            storage
1707        }
1708
1709        fn invoice_details(invoice: &str) -> SparkInvoicePaymentDetails {
1710            SparkInvoicePaymentDetails {
1711                description: None,
1712                invoice: invoice.to_string(),
1713            }
1714        }
1715
1716        fn payment(payment_type: PaymentType, details: PaymentDetails) -> crate::Payment {
1717            crate::Payment {
1718                id: "p1".to_string(),
1719                payment_type,
1720                status: PaymentStatus::Completed,
1721                amount: 1146,
1722                fees: 0,
1723                timestamp: 100,
1724                method: PaymentMethod::Spark,
1725                details: Some(details),
1726                conversion_details: None,
1727            }
1728        }
1729
1730        fn spark_details(invoice: Option<&str>) -> PaymentDetails {
1731            PaymentDetails::Spark {
1732                invoice_details: invoice.map(invoice_details),
1733                htlc_details: None,
1734                conversion_info: None,
1735            }
1736        }
1737
1738        fn token_details(invoice: &str) -> PaymentDetails {
1739            PaymentDetails::Token {
1740                metadata: TokenMetadata {
1741                    identifier: "btkn1usdb".to_string(),
1742                    issuer_public_key: "02aa".to_string(),
1743                    name: "Bitcoin USD".to_string(),
1744                    ticker: "USDB".to_string(),
1745                    decimals: 6,
1746                    max_supply: 0,
1747                    is_freezable: true,
1748                },
1749                tx_hash: "abcd".to_string(),
1750                tx_type: TokenTransactionType::Transfer,
1751                invoice_details: Some(invoice_details(invoice)),
1752                conversion_info: None,
1753            }
1754        }
1755
1756        async fn stored_conversion_status(
1757            storage: &Arc<dyn crate::Storage>,
1758            payment_id: &str,
1759        ) -> Option<crate::ConversionStatus> {
1760            let stored = storage
1761                .get_payment_by_id(payment_id.to_string())
1762                .await
1763                .unwrap();
1764            match extract_conversion_info(stored.details) {
1765                Some(ConversionInfo::Orchestra { status, .. }) => Some(status),
1766                _ => None,
1767            }
1768        }
1769
1770        #[tokio::test]
1771        async fn spark_receive_fulfilling_an_open_invoice_gets_pending_conversion_info() {
1772            let storage = storage_with_open_receive("spark1inv").await;
1773            let payment = payment(PaymentType::Receive, spark_details(Some("spark1inv")));
1774            storage.apply_payment_update(payment.clone()).await.unwrap();
1775
1776            let attached = attach_receive_metadata_for_payment(&storage, &payment)
1777                .await
1778                .unwrap();
1779
1780            assert!(attached);
1781            assert_eq!(
1782                stored_conversion_status(&storage, &payment.id).await,
1783                Some(crate::ConversionStatus::Pending)
1784            );
1785        }
1786
1787        #[tokio::test]
1788        async fn token_receive_fulfilling_an_open_invoice_gets_pending_conversion_info() {
1789            let storage = storage_with_open_receive("spark1inv").await;
1790            let payment = payment(PaymentType::Receive, token_details("spark1inv"));
1791            storage.apply_payment_update(payment.clone()).await.unwrap();
1792
1793            let attached = attach_receive_metadata_for_payment(&storage, &payment)
1794                .await
1795                .unwrap();
1796
1797            assert!(attached);
1798            assert_eq!(
1799                stored_conversion_status(&storage, &payment.id).await,
1800                Some(crate::ConversionStatus::Pending)
1801            );
1802        }
1803
1804        #[tokio::test]
1805        async fn a_payment_already_carrying_a_conversion_is_left_alone() {
1806            let storage = storage_with_open_receive("spark1inv").await;
1807            let payment = payment(PaymentType::Receive, spark_details(Some("spark1inv")));
1808            storage.apply_payment_update(payment.clone()).await.unwrap();
1809            let mut completed = orchestra_receive_info_placeholder();
1810            if let ConversionInfo::Orchestra { status, .. } = &mut completed {
1811                *status = crate::ConversionStatus::Completed;
1812            }
1813            storage
1814                .insert_payment_metadata(
1815                    payment.id.clone(),
1816                    crate::PaymentMetadata {
1817                        conversion_info: Some(completed),
1818                        ..Default::default()
1819                    },
1820                )
1821                .await
1822                .unwrap();
1823
1824            let attached = attach_receive_metadata_for_payment(&storage, &payment)
1825                .await
1826                .unwrap();
1827
1828            assert!(!attached);
1829            assert_eq!(
1830                stored_conversion_status(&storage, &payment.id).await,
1831                Some(crate::ConversionStatus::Completed)
1832            );
1833        }
1834
1835        #[tokio::test]
1836        async fn other_payments_get_nothing() {
1837            let storage = storage_with_open_receive("spark1inv").await;
1838
1839            for payment in [
1840                payment(PaymentType::Receive, spark_details(Some("spark1other"))),
1841                payment(PaymentType::Receive, spark_details(None)),
1842                payment(PaymentType::Send, spark_details(Some("spark1inv"))),
1843            ] {
1844                let attached = attach_receive_metadata_for_payment(&storage, &payment)
1845                    .await
1846                    .unwrap();
1847                assert!(!attached, "{payment:?}");
1848            }
1849        }
1850
1851        fn orchestra_receive_info_placeholder() -> ConversionInfo {
1852            ConversionInfo::Orchestra {
1853                order_id: "ord".to_string(),
1854                quote_id: "q_match".to_string(),
1855                read_token: None,
1856                chain: "arbitrum".to_string(),
1857                chain_id: Some("42161".to_string()),
1858                asset: "USDC".to_string(),
1859                recipient_address: "sp1rcv".to_string(),
1860                asset_amount_in: Some(1_000_000),
1861                estimated_out: 1146,
1862                delivered_amount: Some(1146),
1863                external_tx_hash: Some("0xdeposit".to_string()),
1864                status: crate::ConversionStatus::Pending,
1865                fee_amount: Some(10_000),
1866                service_fee_amount: Some(10_000),
1867                service_fee_asset: Some("USDC".to_string()),
1868                service_fee_asset_decimals: Some(6),
1869                asset_decimals: 6,
1870                asset_contract: Some("0xUSDC".to_string()),
1871            }
1872        }
1873    }
1874}