Skip to main content

breez_sdk_spark/token_conversion/
models.rs

1use std::fmt;
2use std::str::FromStr;
3
4use flashnet::{BTC_ASSET_ADDRESS, Pool};
5use serde::{Deserialize, Serialize};
6
7use crate::SdkError;
8
9use crate::utils::serde_helpers::{serde_option_u128_as_string, serde_u128_as_string};
10
11/// Default maximum slippage for conversions in basis points (0.1%)
12pub const DEFAULT_CONVERSION_MAX_SLIPPAGE_BPS: u32 = 10;
13/// Default timeout for conversion operations in seconds
14pub const DEFAULT_CONVERSION_TIMEOUT_SECS: u32 = 30;
15/// Default integrator pubkey used when executing conversions
16pub const DEFAULT_INTEGRATOR_PUBKEY: &str =
17    "037e26d9d62e0b3df2d3e66805f61de2a33914465297abf76817296a92ac3f2379";
18/// Default integrator fee BPS used when simulating/executing conversions
19pub const DEFAULT_INTEGRATOR_FEE_BPS: u32 = 5;
20
21/// Fee attribution for a conversion, indicating which side of the conversion
22/// (sent or received) the pool fee is denominated in. The two variants are
23/// mutually exclusive — a pool fee is always denominated in one asset.
24pub(crate) enum FeeSplit {
25    /// Fee is on the sent (outbound/`asset_in`) payment, denominated in `asset_in`.
26    Sent(u128),
27    /// Fee is on the received (inbound/`asset_out`) payment, denominated in `asset_out`.
28    Received(u128),
29}
30
31/// Response from estimating a conversion, used when preparing a payment that requires conversion
32#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
33#[derive(Debug, Clone, Serialize)]
34pub struct ConversionEstimate {
35    /// The conversion options used for the estimate
36    pub options: ConversionOptions,
37    /// The input amount for the conversion.
38    /// For `FromBitcoin`: the satoshis required to produce the desired token output.
39    /// For `ToBitcoin`: the token amount being converted.
40    pub amount_in: u128,
41    /// The estimated output amount from the conversion.
42    /// For `FromBitcoin`: the estimated token amount received.
43    /// For `ToBitcoin`: the estimated satoshis received.
44    pub amount_out: u128,
45    /// The fee estimated for the conversion.
46    /// Denominated in satoshis if converting from Bitcoin, otherwise in the token base units.
47    pub fee: u128,
48    /// The reason the conversion amount was adjusted, if applicable.
49    pub amount_adjustment: Option<AmountAdjustmentReason>,
50}
51
52/// The purpose of the conversion, which is used to provide context for the conversion
53/// if its related to an ongoing payment or a self-transfer.
54#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub enum ConversionPurpose {
57    /// Conversion is associated with an ongoing payment
58    OngoingPayment {
59        /// The payment request of the ongoing payment
60        payment_request: String,
61    },
62    /// Conversion is for self-transfer
63    SelfTransfer,
64    /// Conversion triggered automatically
65    AutoConversion,
66}
67
68/// Specifies how to determine the conversion amount.
69#[derive(Debug, Clone)]
70pub(crate) enum ConversionAmount {
71    /// Specify the minimum output amount - the input will be calculated.
72    /// Used for payment conversions where we know the required output.
73    MinAmountOut(u128),
74    /// Specify the exact input amount - used for auto-conversion where we know the sats balance.
75    AmountIn(u128),
76}
77
78/// How an executed swap departed from the terms the client signed.
79///
80/// The input is spent either way, so the conversion completes rather than being
81/// refunded. This records that it did not deliver what was signed for.
82#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
83#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
84pub enum SwapDegradation {
85    /// Delivered less than the minimum the intent signed.
86    BelowMinimum,
87    /// Delivered an asset other than the one the intent named.
88    UnexpectedAsset,
89    /// Accepted without naming the amount, the asset, or the transfer carrying
90    /// it.
91    MissingInfo,
92}
93
94impl From<flashnet::SwapDegradation> for SwapDegradation {
95    fn from(d: flashnet::SwapDegradation) -> Self {
96        match d {
97            flashnet::SwapDegradation::BelowMinimum => Self::BelowMinimum,
98            flashnet::SwapDegradation::UnexpectedAsset => Self::UnexpectedAsset,
99            flashnet::SwapDegradation::MissingInfo => Self::MissingInfo,
100        }
101    }
102}
103
104/// The reason why a conversion amount was adjusted from the originally requested value.
105#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
107pub enum AmountAdjustmentReason {
108    /// The amount was increased to meet the minimum conversion limit.
109    FlooredToMinLimit,
110    /// The amount was increased to convert the full token balance,
111    /// avoiding a remaining balance below the minimum conversion limit (token dust).
112    IncreasedToAvoidDust,
113}
114
115/// The status of the conversion
116#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
118pub enum ConversionStatus {
119    /// Conversion is in-flight (queued or started, not yet completed)
120    Pending,
121    /// The conversion was successful
122    Completed,
123    /// The conversion failed (e.g., the initial send payment failed)
124    Failed,
125    /// The conversion failed and no refund was made yet, which requires action by the SDK to
126    /// perform the refund. This can happen if there was a failure during the conversion process.
127    RefundNeeded,
128    /// The conversion failed and a refund was made
129    Refunded,
130}
131
132impl fmt::Display for ConversionStatus {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        match self {
135            ConversionStatus::Pending => write!(f, "pending"),
136            ConversionStatus::Completed => write!(f, "completed"),
137            ConversionStatus::Failed => write!(f, "failed"),
138            ConversionStatus::RefundNeeded => write!(f, "refund_needed"),
139            ConversionStatus::Refunded => write!(f, "refunded"),
140        }
141    }
142}
143
144impl FromStr for ConversionStatus {
145    type Err = String;
146
147    fn from_str(s: &str) -> Result<Self, Self::Err> {
148        match s {
149            "pending" => Ok(ConversionStatus::Pending),
150            "completed" => Ok(ConversionStatus::Completed),
151            "failed" => Ok(ConversionStatus::Failed),
152            "refund_needed" => Ok(ConversionStatus::RefundNeeded),
153            "refunded" => Ok(ConversionStatus::Refunded),
154            _ => Err(format!("Invalid conversion status '{s}'")),
155        }
156    }
157}
158
159/// Details of the asset conversion attached to a payment, when the payment
160/// involves a swap or cross-chain bridge in addition to the on-Spark transfer.
161///
162/// The variant identifies which provider handled the conversion:
163/// - [`ConversionInfo::Amm`] for Spark token swaps via Flashnet AMM pools.
164/// - [`ConversionInfo::Orchestra`] for cross-chain transfers via Flashnet
165///   Orchestra, in either direction (Spark → external chain, or external
166///   chain → Spark).
167/// - [`ConversionInfo::Boltz`] for sats → stable-coin reverse swaps via Boltz.
168#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
169#[derive(Clone, Serialize, Deserialize, PartialEq)]
170#[serde(tag = "type")]
171pub enum ConversionInfo {
172    /// AMM (Flashnet pool-based) conversion — Spark ↔ Spark token swaps.
173    #[serde(rename = "amm")]
174    Amm {
175        /// The pool id associated with the conversion
176        pool_id: String,
177        /// The conversion id shared by both sides of the conversion
178        conversion_id: String,
179        /// The status of the conversion
180        status: ConversionStatus,
181        /// The fee paid for the conversion.
182        /// Denominated in satoshis if converting from Bitcoin, otherwise in the token base units.
183        #[serde(default, with = "serde_option_u128_as_string")]
184        fee: Option<u128>,
185        /// The purpose of the conversion
186        purpose: Option<ConversionPurpose>,
187        /// The reason the conversion amount was adjusted, if applicable.
188        #[serde(default)]
189        amount_adjustment: Option<AmountAdjustmentReason>,
190        /// How the swap departed from the signed terms, if it did. Set on a
191        /// conversion that completed without delivering what was signed for.
192        #[serde(default)]
193        degradation: Option<SwapDegradation>,
194    },
195    /// Orchestra cross-chain conversion via the Flashnet orchestration API,
196    /// in either direction.
197    ///
198    /// `chain`, `asset`, `asset_decimals` and `asset_contract` always describe
199    /// the external (non-Spark) side: the destination on a send, the source on
200    /// a receive. Amounts follow the direction of the transfer, so read each
201    /// amount field's own denomination.
202    #[serde(rename = "orchestra")]
203    Orchestra {
204        /// The Orchestra order id returned by `/v1/orchestration/submit`.
205        order_id: String,
206        /// The Orchestra quote id used to create this order.
207        quote_id: String,
208        /// Opaque token required for querying order status.
209        #[serde(default)]
210        read_token: Option<String>,
211
212        /// Chain name (e.g. `"base"`, `"solana"`, `"tron"`).
213        chain: String,
214        /// Stable chain identifier (e.g. EVM `chainId` decimal string `"8453"`
215        /// for Base, SLIP-44 or similar for other chains). `None` if the
216        /// provider doesn't expose one for this route.
217        #[serde(default)]
218        chain_id: Option<String>,
219        /// Asset ticker (e.g. `"USDC"`, `"USDT"`).
220        #[serde(default)]
221        asset: String,
222        /// The target-chain address on a send, the receiving Spark address
223        /// on a receive.
224        recipient_address: String,
225        /// Amount paid in, in `asset` base units. On a send it is the Spark
226        /// amount expressed in `asset` via the rate the SDK used at prepare
227        /// time. On a receive it is the deposit the sender made on `chain`.
228        #[serde(default, with = "serde_option_u128_as_string")]
229        asset_amount_in: Option<u128>,
230        /// Estimated amount delivered to the receiving end, frozen at prepare
231        /// time. In `asset` base units on a send, and in Spark-side units on
232        /// a receive (sats for Bitcoin, token base units for a token).
233        #[serde(with = "serde_u128_as_string")]
234        estimated_out: u128,
235        /// Actual delivered amount, in the same units as `estimated_out`.
236        /// Unset until the order reaches a terminal state.
237        #[serde(default, with = "serde_option_u128_as_string")]
238        delivered_amount: Option<u128>,
239        /// Transaction on `chain`, the non-Spark side of the conversion: the
240        /// delivery on a send, the funding deposit on a receive. Format follows
241        /// the chain (e.g. `0x`-prefixed hex on EVM, a base58 signature on
242        /// Solana). Unset until that transaction exists, and on orders that
243        /// failed or were refunded.
244        #[serde(default)]
245        external_tx_hash: Option<String>,
246        status: ConversionStatus,
247        /// Best-available total fee, in `asset` base units.
248        /// Prepare-time estimate while pending, realized fee when Completed.
249        #[serde(default, with = "serde_option_u128_as_string")]
250        fee_amount: Option<u128>,
251        /// Orchestra service fee.
252        #[serde(
253            default,
254            alias = "fee",
255            rename = "service_fee_amount",
256            with = "serde_option_u128_as_string"
257        )]
258        service_fee_amount: Option<u128>,
259        /// Asset the service fee is denominated in. Unset means BTC sats.
260        #[serde(default)]
261        service_fee_asset: Option<String>,
262        /// Decimals of `service_fee_asset`, for formatting `service_fee_amount`.
263        /// Unset when the fee is in sats or the provider did not report them.
264        #[serde(default)]
265        service_fee_asset_decimals: Option<u32>,
266        /// Asset decimals (e.g. 6 for USDC).
267        asset_decimals: u32,
268        /// Token contract / mint address on `chain`. Unset when that side is
269        /// the chain's native asset.
270        #[serde(default)]
271        asset_contract: Option<String>,
272    },
273    /// Boltz reverse swap: cross-chain conversion via Lightning hold invoice.
274    ///
275    /// The swap's secrets and lifecycle state live on the synced Boltz swap row
276    /// keyed by `swap_id`, which also drives cross-instance recovery.
277    #[serde(rename = "boltz")]
278    Boltz {
279        /// The Boltz swap id returned by `POST /swap/reverse`.
280        swap_id: String,
281        /// The BOLT11 hold invoice paid on the Spark/Lightning side.
282        invoice: String,
283        /// Amount of the hold invoice in sats.
284        invoice_amount_sats: u64,
285        /// Cross-chain bridge tracking handle for bridged swaps: a `LayerZero`
286        /// message GUID for OFT (USDT0) routes, or a CCTP reference for USDC
287        /// routes. `None` for same-chain (Arbitrum-direct) delivery.
288        #[serde(default, alias = "lz_guid")]
289        bridge_ref: Option<String>,
290        /// DEX slippage tolerance (basis points) committed at prepare time.
291        max_slippage_bps: u32,
292        /// Whether the claim-time DEX quote drifted beyond `max_slippage_bps`.
293        #[serde(default)]
294        quote_degraded: bool,
295
296        /// Chain name (e.g. `"arbitrum"`, `"solana"`, `"tron"`).
297        chain: String,
298        /// Stable chain identifier (e.g. EVM `chainId` decimal string `"42161"`
299        /// for Arbitrum). `None` if the provider doesn't expose one for this
300        /// route.
301        #[serde(default)]
302        chain_id: Option<String>,
303        /// Asset ticker (e.g. `"USDT"`, `"USDT0"`).
304        #[serde(default)]
305        asset: String,
306        /// Recipient address on the target chain.
307        recipient_address: String,
308        /// Estimated amount in the asset's base units, frozen at prepare time.
309        #[serde(with = "serde_u128_as_string")]
310        estimated_out: u128,
311        /// Actual amount delivered. `None` until the claim receipt is processed.
312        #[serde(default, with = "serde_option_u128_as_string")]
313        delivered_amount: Option<u128>,
314        /// Current status of the reverse swap.
315        status: ConversionStatus,
316        /// Amount in expressed in the cross-chain asset's base units, via the
317        /// BTC/USD rate the SDK used at prepare time.
318        #[serde(default, with = "serde_option_u128_as_string")]
319        asset_amount_in: Option<u128>,
320        /// Best-available total fee in destination asset base units.
321        /// Prepare-time estimate while pending, realized fee on Completed.
322        #[serde(default, with = "serde_option_u128_as_string")]
323        fee_amount: Option<u128>,
324        /// Boltz spread in sats.
325        #[serde(
326            default,
327            alias = "fee",
328            rename = "service_fee_amount",
329            with = "serde_option_u128_as_string"
330        )]
331        service_fee_amount: Option<u128>,
332        /// Asset service fee is denominated in. Unset means BTC sats.
333        #[serde(default)]
334        service_fee_asset: Option<String>,
335        /// Asset decimals (e.g. 6 for USDT).
336        asset_decimals: u32,
337        /// Token contract / mint address on `chain`. Unset when that side is
338        /// the chain's native asset.
339        #[serde(default)]
340        asset_contract: Option<String>,
341    },
342}
343
344impl fmt::Debug for ConversionInfo {
345    #[allow(clippy::too_many_lines)]
346    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        match self {
348            ConversionInfo::Amm {
349                pool_id,
350                conversion_id,
351                status,
352                fee,
353                purpose,
354                amount_adjustment,
355                degradation,
356            } => f
357                .debug_struct("Amm")
358                .field("pool_id", pool_id)
359                .field("conversion_id", conversion_id)
360                .field("status", status)
361                .field("fee", fee)
362                .field("purpose", purpose)
363                .field("amount_adjustment", amount_adjustment)
364                .field("degradation", degradation)
365                .finish(),
366            ConversionInfo::Orchestra {
367                order_id,
368                quote_id,
369                read_token,
370                chain,
371                chain_id,
372                asset,
373                recipient_address,
374                asset_amount_in,
375                estimated_out,
376                delivered_amount,
377                external_tx_hash,
378                status,
379                fee_amount,
380                service_fee_amount,
381                service_fee_asset,
382                service_fee_asset_decimals,
383                asset_decimals,
384                asset_contract,
385            } => f
386                .debug_struct("Orchestra")
387                .field("order_id", order_id)
388                .field("quote_id", quote_id)
389                .field("read_token", &read_token.as_ref().map(|_| "<redacted>"))
390                .field("chain", chain)
391                .field("chain_id", chain_id)
392                .field("asset", asset)
393                .field("recipient_address", recipient_address)
394                .field("asset_amount_in", asset_amount_in)
395                .field("estimated_out", estimated_out)
396                .field("delivered_amount", delivered_amount)
397                .field("external_tx_hash", external_tx_hash)
398                .field("status", status)
399                .field("fee_amount", fee_amount)
400                .field("service_fee_amount", service_fee_amount)
401                .field("service_fee_asset", service_fee_asset)
402                .field("service_fee_asset_decimals", service_fee_asset_decimals)
403                .field("asset_decimals", asset_decimals)
404                .field("asset_contract", asset_contract)
405                .finish(),
406            ConversionInfo::Boltz {
407                swap_id,
408                invoice,
409                invoice_amount_sats,
410                bridge_ref,
411                max_slippage_bps,
412                quote_degraded,
413                chain,
414                chain_id,
415                asset,
416                recipient_address,
417                estimated_out,
418                delivered_amount,
419                status,
420                asset_amount_in,
421                fee_amount,
422                service_fee_amount,
423                service_fee_asset,
424                asset_decimals,
425                asset_contract,
426            } => f
427                .debug_struct("Boltz")
428                .field("swap_id", swap_id)
429                .field("invoice", invoice)
430                .field("invoice_amount_sats", invoice_amount_sats)
431                .field("bridge_ref", bridge_ref)
432                .field("max_slippage_bps", max_slippage_bps)
433                .field("quote_degraded", quote_degraded)
434                .field("chain", chain)
435                .field("chain_id", chain_id)
436                .field("asset", asset)
437                .field("recipient_address", recipient_address)
438                .field("estimated_out", estimated_out)
439                .field("delivered_amount", delivered_amount)
440                .field("status", status)
441                .field("asset_amount_in", asset_amount_in)
442                .field("fee_amount", fee_amount)
443                .field("service_fee_amount", service_fee_amount)
444                .field("service_fee_asset", service_fee_asset)
445                .field("asset_decimals", asset_decimals)
446                .field("asset_contract", asset_contract)
447                .finish(),
448        }
449    }
450}
451
452impl ConversionInfo {
453    /// The current status, regardless of conversion type.
454    pub fn status(&self) -> &ConversionStatus {
455        match self {
456            ConversionInfo::Amm { status, .. }
457            | ConversionInfo::Orchestra { status, .. }
458            | ConversionInfo::Boltz { status, .. } => status,
459        }
460    }
461
462    /// A mutable reference to the status, for in-place updates.
463    pub fn status_mut(&mut self) -> &mut ConversionStatus {
464        match self {
465            ConversionInfo::Amm { status, .. }
466            | ConversionInfo::Orchestra { status, .. }
467            | ConversionInfo::Boltz { status, .. } => status,
468        }
469    }
470
471    /// Headline fee: AMM pool fee in source units, or the cross-chain total
472    /// in destination-asset base units.
473    pub fn fee(&self) -> Option<u128> {
474        match self {
475            ConversionInfo::Amm { fee, .. } => *fee,
476            ConversionInfo::Orchestra { fee_amount, .. }
477            | ConversionInfo::Boltz { fee_amount, .. } => *fee_amount,
478        }
479    }
480
481    /// Whether this is an AMM (Flashnet pool) conversion.
482    pub fn is_amm(&self) -> bool {
483        matches!(self, ConversionInfo::Amm { .. })
484    }
485
486    /// Whether this is an Orchestra (cross-chain) conversion.
487    pub fn is_orchestra(&self) -> bool {
488        matches!(self, ConversionInfo::Orchestra { .. })
489    }
490
491    /// Whether this is a Boltz reverse swap.
492    pub fn is_boltz(&self) -> bool {
493        matches!(self, ConversionInfo::Boltz { .. })
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    #[test]
502    fn every_degradation_keeps_its_meaning_across_the_boundary() {
503        // A hand-written mapping: swapping two arms would silently relabel a
504        // short delivery as a malformed response, which reads as a server bug
505        // rather than a shortfall.
506        for (from, want) in [
507            (
508                flashnet::SwapDegradation::BelowMinimum,
509                SwapDegradation::BelowMinimum,
510            ),
511            (
512                flashnet::SwapDegradation::UnexpectedAsset,
513                SwapDegradation::UnexpectedAsset,
514            ),
515            (
516                flashnet::SwapDegradation::MissingInfo,
517                SwapDegradation::MissingInfo,
518            ),
519        ] {
520            assert_eq!(SwapDegradation::from(from), want, "{from:?} was relabelled");
521        }
522    }
523
524    #[test]
525    fn boltz_conversion_info_roundtrip() {
526        let original = ConversionInfo::Boltz {
527            swap_id: "boltz_swap_abc".to_string(),
528            chain: "solana".to_string(),
529            chain_id: None,
530            asset: "USDT0".to_string(),
531            recipient_address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
532            invoice: "lnbc1000n1pexample".to_string(),
533            invoice_amount_sats: 150_000,
534            asset_amount_in: Some(100_500_000),
535            estimated_out: 99_000_000,
536            delivered_amount: Some(98_750_000),
537            bridge_ref: Some("0xdeadbeef".to_string()),
538            status: ConversionStatus::Pending,
539            fee_amount: Some(1_500_000),
540            service_fee_amount: Some(2_500),
541            service_fee_asset: None,
542            max_slippage_bps: 100,
543            quote_degraded: false,
544            asset_decimals: 6,
545            asset_contract: Some("0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string()),
546        };
547
548        let json = serde_json::to_string(&original).unwrap();
549        let decoded: ConversionInfo = serde_json::from_str(&json).unwrap();
550        assert_eq!(decoded, original);
551        assert!(decoded.is_boltz());
552        assert!(!decoded.is_orchestra());
553        assert!(!decoded.is_amm());
554        assert_eq!(decoded.status(), &ConversionStatus::Pending);
555        assert_eq!(decoded.fee(), Some(1_500_000));
556
557        // The `"type"` tag discriminator must match the rename attribute.
558        assert!(json.contains(r#""type":"boltz""#));
559        // u128 fields serialize as strings, not JSON numbers.
560        assert!(json.contains(r#""estimated_out":"99000000""#));
561    }
562
563    /// Pre-rename rows persisted in the wild only carried a `fee` key (the
564    /// provider service fee), and lacked `asset_amount_in`, `fee_amount`,
565    /// `service_fee_amount`, `service_fee_asset`. Reading such a row must:
566    /// - route the legacy `fee` value into `service_fee_amount`,
567    /// - default every new field to `None`,
568    /// - succeed for both provider variants.
569    #[test]
570    fn boltz_legacy_fee_alias_deserializes_into_service_fee_amount() {
571        let legacy = r#"{
572            "type": "boltz",
573            "swap_id": "swap_legacy",
574            "invoice": "lnbc1000n1pold",
575            "invoice_amount_sats": 100000,
576            "max_slippage_bps": 100,
577            "chain": "arbitrum",
578            "asset": "USDT",
579            "recipient_address": "0xrecipient",
580            "estimated_out": "1450000",
581            "status": "Pending",
582            "fee": "1500",
583            "asset_decimals": 6
584        }"#;
585        let decoded: ConversionInfo = serde_json::from_str(legacy).unwrap();
586        let ConversionInfo::Boltz {
587            asset_amount_in,
588            fee_amount,
589            service_fee_amount,
590            service_fee_asset,
591            ..
592        } = &decoded
593        else {
594            panic!("expected Boltz variant, got: {decoded:?}");
595        };
596        assert_eq!(*asset_amount_in, None);
597        assert_eq!(*fee_amount, None);
598        assert_eq!(
599            *service_fee_amount,
600            Some(1500),
601            "legacy `fee` key must map to `service_fee_amount`"
602        );
603        assert_eq!(*service_fee_asset, None);
604    }
605
606    #[test]
607    fn orchestra_legacy_fee_alias_deserializes_into_service_fee_amount() {
608        let legacy = r#"{
609            "type": "orchestra",
610            "order_id": "ord_legacy",
611            "quote_id": "q_legacy",
612            "chain": "base",
613            "asset": "USDC",
614            "recipient_address": "0xrecipient",
615            "estimated_out": "99500000",
616            "status": "Completed",
617            "fee": "500",
618            "asset_decimals": 6
619        }"#;
620        let decoded: ConversionInfo = serde_json::from_str(legacy).unwrap();
621        let ConversionInfo::Orchestra {
622            asset_amount_in,
623            fee_amount,
624            service_fee_amount,
625            service_fee_asset,
626            service_fee_asset_decimals,
627            ..
628        } = &decoded
629        else {
630            panic!("expected Orchestra variant, got: {decoded:?}");
631        };
632        assert_eq!(*asset_amount_in, None);
633        assert_eq!(*fee_amount, None);
634        assert_eq!(
635            *service_fee_amount,
636            Some(500),
637            "legacy `fee` key must map to `service_fee_amount`"
638        );
639        assert_eq!(*service_fee_asset, None);
640        assert_eq!(*service_fee_asset_decimals, None);
641    }
642
643    /// New writes must use the renamed `service_fee_amount` key (never the
644    /// legacy `fee`), and must emit the three new fields when populated.
645    #[test]
646    fn new_rows_use_renamed_keys_on_serialize() {
647        let info = ConversionInfo::Boltz {
648            swap_id: "s".to_string(),
649            chain: "arbitrum".to_string(),
650            chain_id: None,
651            asset: "USDT".to_string(),
652            recipient_address: "0xr".to_string(),
653            invoice: "lnbc".to_string(),
654            invoice_amount_sats: 100,
655            asset_amount_in: Some(1_500_000),
656            estimated_out: 1_450_000,
657            delivered_amount: None,
658            bridge_ref: None,
659            status: ConversionStatus::Pending,
660            fee_amount: Some(50_000),
661            service_fee_amount: Some(1_500),
662            service_fee_asset: Some("USD".to_string()),
663            max_slippage_bps: 100,
664            quote_degraded: false,
665            asset_decimals: 6,
666            asset_contract: None,
667        };
668        let json = serde_json::to_string(&info).unwrap();
669        assert!(
670            json.contains(r#""service_fee_amount":"1500""#),
671            "must serialize under the new key, got: {json}"
672        );
673        assert!(
674            !json.contains(r#""fee":"#),
675            "must not emit the legacy `fee` key, got: {json}"
676        );
677        assert!(json.contains(r#""asset_amount_in":"1500000""#));
678        assert!(json.contains(r#""fee_amount":"50000""#));
679        assert!(json.contains(r#""service_fee_asset":"USD""#));
680    }
681
682    /// Backward-compat round-trip — make sure a legacy row, once deserialized
683    /// and re-serialized, stays self-consistent (the upgrade path doesn't lose
684    /// the legacy value).
685    #[test]
686    fn legacy_row_roundtrip_after_upgrade() {
687        let legacy = r#"{
688            "type": "boltz",
689            "swap_id": "swap_legacy",
690            "invoice": "lnbc",
691            "invoice_amount_sats": 100000,
692            "max_slippage_bps": 100,
693            "chain": "arbitrum",
694            "asset": "USDT",
695            "recipient_address": "0xr",
696            "estimated_out": "1450000",
697            "status": "Pending",
698            "fee": "1500",
699            "asset_decimals": 6
700        }"#;
701        let decoded: ConversionInfo = serde_json::from_str(legacy).unwrap();
702        let re_encoded = serde_json::to_string(&decoded).unwrap();
703        let re_decoded: ConversionInfo = serde_json::from_str(&re_encoded).unwrap();
704        assert_eq!(
705            decoded, re_decoded,
706            "upgraded row must round-trip identically"
707        );
708    }
709
710    #[test]
711    fn boltz_status_mut_updates_status_in_place() {
712        let mut info = ConversionInfo::Boltz {
713            swap_id: "s1".to_string(),
714            chain: "arbitrum".to_string(),
715            chain_id: Some("42161".to_string()),
716            asset: "USDT".to_string(),
717            recipient_address: "0xdest".to_string(),
718            invoice: "lnbc".to_string(),
719            invoice_amount_sats: 100,
720            asset_amount_in: None,
721            estimated_out: 1,
722            delivered_amount: None,
723            bridge_ref: None,
724            status: ConversionStatus::Pending,
725            fee_amount: None,
726            service_fee_amount: None,
727            service_fee_asset: None,
728            max_slippage_bps: 100,
729            quote_degraded: false,
730            asset_decimals: 6,
731            asset_contract: None,
732        };
733        *info.status_mut() = ConversionStatus::Completed;
734        assert_eq!(info.status(), &ConversionStatus::Completed);
735    }
736}
737
738pub(crate) struct TokenConversionPool {
739    pub(crate) asset_in_address: String,
740    pub(crate) asset_out_address: String,
741    pub(crate) pool: Pool,
742}
743
744/// A priced conversion, together with the pool it was priced against.
745pub(crate) struct ResolvedConversion {
746    pub(crate) conversion_pool: TokenConversionPool,
747    /// The floor the swap intent will carry. For a requested output it is that
748    /// request, scaled by any rise of the input above the one it derived,
749    /// rather than the simulated output, which may be higher and would fail for
750    /// no reason. For a supplied input it is the simulated output less slippage.
751    pub(crate) min_amount_out: u128,
752    pub(crate) estimate: ConversionEstimate,
753}
754
755pub(crate) struct TokenConversionResponse {
756    /// The sent payment id for the conversion
757    pub(crate) sent_payment_id: String,
758    /// The received payment id for the conversion
759    pub(crate) received_payment_id: String,
760}
761
762/// Options for conversion when fulfilling a payment. When set, the SDK will
763/// perform a conversion before fulfilling the payment. If not set, the payment
764/// will only be fulfilled if the wallet has sufficient balance of the required asset.
765#[derive(Debug, Clone, Serialize, PartialEq)]
766#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
767pub struct ConversionOptions {
768    /// The type of conversion to perform when fulfilling the payment
769    pub conversion_type: ConversionType,
770    /// The optional maximum slippage in basis points (1/100 of a percent) allowed when
771    /// a conversion is needed to fulfill the payment. Defaults to 10 bps (0.1%) if not set.
772    /// The conversion will fail if the actual amount received is less than
773    /// `estimated_amount * (1 - max_slippage_bps / 10_000)`.
774    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
775    pub max_slippage_bps: Option<u32>,
776    /// The optional timeout in seconds to wait for the conversion to complete
777    /// when fulfilling the payment. This timeout only concerns waiting for the received
778    /// payment of the conversion. If the timeout is reached before the conversion
779    /// is complete, the payment will fail. Defaults to 30 seconds if not set.
780    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
781    pub completion_timeout_secs: Option<u32>,
782}
783
784#[derive(Debug, Clone, Serialize, PartialEq)]
785#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
786pub enum ConversionType {
787    /// Converting from Bitcoin to a token
788    FromBitcoin,
789    /// Converting from a token to Bitcoin
790    ToBitcoin { from_token_identifier: String },
791}
792
793impl ConversionType {
794    /// Returns the asset addresses for the conversion type
795    ///
796    /// # Arguments
797    ///
798    /// * `token_identifier` - The token identifier when converting from Bitcoin to a token
799    ///
800    /// # Returns
801    ///
802    /// Result containing:
803    /// * (String, String): A tuple containing the asset in address and asset out address
804    /// * `SdkError`: If the token identifier is required but not provided
805    pub(crate) fn as_asset_addresses(
806        &self,
807        token_identifier: Option<&String>,
808    ) -> Result<(String, String), SdkError> {
809        Ok(match self {
810            ConversionType::FromBitcoin => (
811                BTC_ASSET_ADDRESS.to_string(),
812                token_identifier
813                    .ok_or(SdkError::InvalidInput(
814                        "Token identifier is required for from Bitcoin conversion".to_string(),
815                    ))?
816                    .clone(),
817            ),
818            ConversionType::ToBitcoin {
819                from_token_identifier,
820            } => (from_token_identifier.clone(), BTC_ASSET_ADDRESS.to_string()),
821        })
822    }
823}
824
825#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
826pub struct FetchConversionLimitsRequest {
827    /// The type of conversion, either from or to Bitcoin.
828    pub conversion_type: ConversionType,
829    /// The token identifier when converting to a token.
830    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
831    pub token_identifier: Option<String>,
832}
833
834#[derive(Debug, Clone, Serialize)]
835#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
836pub struct FetchConversionLimitsResponse {
837    /// The minimum amount to be converted.
838    /// Denominated in satoshis if converting from Bitcoin, otherwise in the token base units.
839    pub min_from_amount: Option<u128>,
840    /// The minimum amount to be received from the conversion.
841    /// Denominated in satoshis if converting to Bitcoin, otherwise in the token base units.
842    pub min_to_amount: Option<u128>,
843}