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        /// Asset decimals (e.g. 6 for USDC).
263        asset_decimals: u32,
264        /// Token contract / mint address on `chain`. Unset when that side is
265        /// the chain's native asset.
266        #[serde(default)]
267        asset_contract: Option<String>,
268    },
269    /// Boltz reverse swap: cross-chain conversion via Lightning hold invoice.
270    ///
271    /// The swap's secrets and lifecycle state live on the synced Boltz swap row
272    /// keyed by `swap_id`, which also drives cross-instance recovery.
273    #[serde(rename = "boltz")]
274    Boltz {
275        /// The Boltz swap id returned by `POST /swap/reverse`.
276        swap_id: String,
277        /// The BOLT11 hold invoice paid on the Spark/Lightning side.
278        invoice: String,
279        /// Amount of the hold invoice in sats.
280        invoice_amount_sats: u64,
281        /// Cross-chain bridge tracking handle for bridged swaps: a `LayerZero`
282        /// message GUID for OFT (USDT0) routes, or a CCTP reference for USDC
283        /// routes. `None` for same-chain (Arbitrum-direct) delivery.
284        #[serde(default, alias = "lz_guid")]
285        bridge_ref: Option<String>,
286        /// DEX slippage tolerance (basis points) committed at prepare time.
287        max_slippage_bps: u32,
288        /// Whether the claim-time DEX quote drifted beyond `max_slippage_bps`.
289        #[serde(default)]
290        quote_degraded: bool,
291
292        /// Chain name (e.g. `"arbitrum"`, `"solana"`, `"tron"`).
293        chain: String,
294        /// Stable chain identifier (e.g. EVM `chainId` decimal string `"42161"`
295        /// for Arbitrum). `None` if the provider doesn't expose one for this
296        /// route.
297        #[serde(default)]
298        chain_id: Option<String>,
299        /// Asset ticker (e.g. `"USDT"`, `"USDT0"`).
300        #[serde(default)]
301        asset: String,
302        /// Recipient address on the target chain.
303        recipient_address: String,
304        /// Estimated amount in the asset's base units, frozen at prepare time.
305        #[serde(with = "serde_u128_as_string")]
306        estimated_out: u128,
307        /// Actual amount delivered. `None` until the claim receipt is processed.
308        #[serde(default, with = "serde_option_u128_as_string")]
309        delivered_amount: Option<u128>,
310        /// Current status of the reverse swap.
311        status: ConversionStatus,
312        /// Amount in expressed in the cross-chain asset's base units, via the
313        /// BTC/USD rate the SDK used at prepare time.
314        #[serde(default, with = "serde_option_u128_as_string")]
315        asset_amount_in: Option<u128>,
316        /// Best-available total fee in destination asset base units.
317        /// Prepare-time estimate while pending, realized fee on Completed.
318        #[serde(default, with = "serde_option_u128_as_string")]
319        fee_amount: Option<u128>,
320        /// Boltz spread in sats.
321        #[serde(
322            default,
323            alias = "fee",
324            rename = "service_fee_amount",
325            with = "serde_option_u128_as_string"
326        )]
327        service_fee_amount: Option<u128>,
328        /// Asset service fee is denominated in. Unset means BTC sats.
329        #[serde(default)]
330        service_fee_asset: Option<String>,
331        /// Asset decimals (e.g. 6 for USDT).
332        asset_decimals: u32,
333        /// Token contract / mint address on `chain`. Unset when that side is
334        /// the chain's native asset.
335        #[serde(default)]
336        asset_contract: Option<String>,
337    },
338}
339
340impl fmt::Debug for ConversionInfo {
341    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
342        match self {
343            ConversionInfo::Amm {
344                pool_id,
345                conversion_id,
346                status,
347                fee,
348                purpose,
349                amount_adjustment,
350                degradation,
351            } => f
352                .debug_struct("Amm")
353                .field("pool_id", pool_id)
354                .field("conversion_id", conversion_id)
355                .field("status", status)
356                .field("fee", fee)
357                .field("purpose", purpose)
358                .field("amount_adjustment", amount_adjustment)
359                .field("degradation", degradation)
360                .finish(),
361            ConversionInfo::Orchestra {
362                order_id,
363                quote_id,
364                read_token,
365                chain,
366                chain_id,
367                asset,
368                recipient_address,
369                asset_amount_in,
370                estimated_out,
371                delivered_amount,
372                external_tx_hash,
373                status,
374                fee_amount,
375                service_fee_amount,
376                service_fee_asset,
377                asset_decimals,
378                asset_contract,
379            } => f
380                .debug_struct("Orchestra")
381                .field("order_id", order_id)
382                .field("quote_id", quote_id)
383                .field("read_token", &read_token.as_ref().map(|_| "<redacted>"))
384                .field("chain", chain)
385                .field("chain_id", chain_id)
386                .field("asset", asset)
387                .field("recipient_address", recipient_address)
388                .field("asset_amount_in", asset_amount_in)
389                .field("estimated_out", estimated_out)
390                .field("delivered_amount", delivered_amount)
391                .field("external_tx_hash", external_tx_hash)
392                .field("status", status)
393                .field("fee_amount", fee_amount)
394                .field("service_fee_amount", service_fee_amount)
395                .field("service_fee_asset", service_fee_asset)
396                .field("asset_decimals", asset_decimals)
397                .field("asset_contract", asset_contract)
398                .finish(),
399            ConversionInfo::Boltz {
400                swap_id,
401                invoice,
402                invoice_amount_sats,
403                bridge_ref,
404                max_slippage_bps,
405                quote_degraded,
406                chain,
407                chain_id,
408                asset,
409                recipient_address,
410                estimated_out,
411                delivered_amount,
412                status,
413                asset_amount_in,
414                fee_amount,
415                service_fee_amount,
416                service_fee_asset,
417                asset_decimals,
418                asset_contract,
419            } => f
420                .debug_struct("Boltz")
421                .field("swap_id", swap_id)
422                .field("invoice", invoice)
423                .field("invoice_amount_sats", invoice_amount_sats)
424                .field("bridge_ref", bridge_ref)
425                .field("max_slippage_bps", max_slippage_bps)
426                .field("quote_degraded", quote_degraded)
427                .field("chain", chain)
428                .field("chain_id", chain_id)
429                .field("asset", asset)
430                .field("recipient_address", recipient_address)
431                .field("estimated_out", estimated_out)
432                .field("delivered_amount", delivered_amount)
433                .field("status", status)
434                .field("asset_amount_in", asset_amount_in)
435                .field("fee_amount", fee_amount)
436                .field("service_fee_amount", service_fee_amount)
437                .field("service_fee_asset", service_fee_asset)
438                .field("asset_decimals", asset_decimals)
439                .field("asset_contract", asset_contract)
440                .finish(),
441        }
442    }
443}
444
445impl ConversionInfo {
446    /// The current status, regardless of conversion type.
447    pub fn status(&self) -> &ConversionStatus {
448        match self {
449            ConversionInfo::Amm { status, .. }
450            | ConversionInfo::Orchestra { status, .. }
451            | ConversionInfo::Boltz { status, .. } => status,
452        }
453    }
454
455    /// A mutable reference to the status, for in-place updates.
456    pub fn status_mut(&mut self) -> &mut ConversionStatus {
457        match self {
458            ConversionInfo::Amm { status, .. }
459            | ConversionInfo::Orchestra { status, .. }
460            | ConversionInfo::Boltz { status, .. } => status,
461        }
462    }
463
464    /// Headline fee: AMM pool fee in source units, or the cross-chain total
465    /// in destination-asset base units.
466    pub fn fee(&self) -> Option<u128> {
467        match self {
468            ConversionInfo::Amm { fee, .. } => *fee,
469            ConversionInfo::Orchestra { fee_amount, .. }
470            | ConversionInfo::Boltz { fee_amount, .. } => *fee_amount,
471        }
472    }
473
474    /// Whether this is an AMM (Flashnet pool) conversion.
475    pub fn is_amm(&self) -> bool {
476        matches!(self, ConversionInfo::Amm { .. })
477    }
478
479    /// Whether this is an Orchestra (cross-chain) conversion.
480    pub fn is_orchestra(&self) -> bool {
481        matches!(self, ConversionInfo::Orchestra { .. })
482    }
483
484    /// Whether this is a Boltz reverse swap.
485    pub fn is_boltz(&self) -> bool {
486        matches!(self, ConversionInfo::Boltz { .. })
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn every_degradation_keeps_its_meaning_across_the_boundary() {
496        // A hand-written mapping: swapping two arms would silently relabel a
497        // short delivery as a malformed response, which reads as a server bug
498        // rather than a shortfall.
499        for (from, want) in [
500            (
501                flashnet::SwapDegradation::BelowMinimum,
502                SwapDegradation::BelowMinimum,
503            ),
504            (
505                flashnet::SwapDegradation::UnexpectedAsset,
506                SwapDegradation::UnexpectedAsset,
507            ),
508            (
509                flashnet::SwapDegradation::MissingInfo,
510                SwapDegradation::MissingInfo,
511            ),
512        ] {
513            assert_eq!(SwapDegradation::from(from), want, "{from:?} was relabelled");
514        }
515    }
516
517    #[test]
518    fn boltz_conversion_info_roundtrip() {
519        let original = ConversionInfo::Boltz {
520            swap_id: "boltz_swap_abc".to_string(),
521            chain: "solana".to_string(),
522            chain_id: None,
523            asset: "USDT0".to_string(),
524            recipient_address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
525            invoice: "lnbc1000n1pexample".to_string(),
526            invoice_amount_sats: 150_000,
527            asset_amount_in: Some(100_500_000),
528            estimated_out: 99_000_000,
529            delivered_amount: Some(98_750_000),
530            bridge_ref: Some("0xdeadbeef".to_string()),
531            status: ConversionStatus::Pending,
532            fee_amount: Some(1_500_000),
533            service_fee_amount: Some(2_500),
534            service_fee_asset: None,
535            max_slippage_bps: 100,
536            quote_degraded: false,
537            asset_decimals: 6,
538            asset_contract: Some("0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string()),
539        };
540
541        let json = serde_json::to_string(&original).unwrap();
542        let decoded: ConversionInfo = serde_json::from_str(&json).unwrap();
543        assert_eq!(decoded, original);
544        assert!(decoded.is_boltz());
545        assert!(!decoded.is_orchestra());
546        assert!(!decoded.is_amm());
547        assert_eq!(decoded.status(), &ConversionStatus::Pending);
548        assert_eq!(decoded.fee(), Some(1_500_000));
549
550        // The `"type"` tag discriminator must match the rename attribute.
551        assert!(json.contains(r#""type":"boltz""#));
552        // u128 fields serialize as strings, not JSON numbers.
553        assert!(json.contains(r#""estimated_out":"99000000""#));
554    }
555
556    /// Pre-rename rows persisted in the wild only carried a `fee` key (the
557    /// provider service fee), and lacked `asset_amount_in`, `fee_amount`,
558    /// `service_fee_amount`, `service_fee_asset`. Reading such a row must:
559    /// - route the legacy `fee` value into `service_fee_amount`,
560    /// - default every new field to `None`,
561    /// - succeed for both provider variants.
562    #[test]
563    fn boltz_legacy_fee_alias_deserializes_into_service_fee_amount() {
564        let legacy = r#"{
565            "type": "boltz",
566            "swap_id": "swap_legacy",
567            "invoice": "lnbc1000n1pold",
568            "invoice_amount_sats": 100000,
569            "max_slippage_bps": 100,
570            "chain": "arbitrum",
571            "asset": "USDT",
572            "recipient_address": "0xrecipient",
573            "estimated_out": "1450000",
574            "status": "Pending",
575            "fee": "1500",
576            "asset_decimals": 6
577        }"#;
578        let decoded: ConversionInfo = serde_json::from_str(legacy).unwrap();
579        let ConversionInfo::Boltz {
580            asset_amount_in,
581            fee_amount,
582            service_fee_amount,
583            service_fee_asset,
584            ..
585        } = &decoded
586        else {
587            panic!("expected Boltz variant, got: {decoded:?}");
588        };
589        assert_eq!(*asset_amount_in, None);
590        assert_eq!(*fee_amount, None);
591        assert_eq!(
592            *service_fee_amount,
593            Some(1500),
594            "legacy `fee` key must map to `service_fee_amount`"
595        );
596        assert_eq!(*service_fee_asset, None);
597    }
598
599    #[test]
600    fn orchestra_legacy_fee_alias_deserializes_into_service_fee_amount() {
601        let legacy = r#"{
602            "type": "orchestra",
603            "order_id": "ord_legacy",
604            "quote_id": "q_legacy",
605            "chain": "base",
606            "asset": "USDC",
607            "recipient_address": "0xrecipient",
608            "estimated_out": "99500000",
609            "status": "Completed",
610            "fee": "500",
611            "asset_decimals": 6
612        }"#;
613        let decoded: ConversionInfo = serde_json::from_str(legacy).unwrap();
614        let ConversionInfo::Orchestra {
615            asset_amount_in,
616            fee_amount,
617            service_fee_amount,
618            service_fee_asset,
619            ..
620        } = &decoded
621        else {
622            panic!("expected Orchestra variant, got: {decoded:?}");
623        };
624        assert_eq!(*asset_amount_in, None);
625        assert_eq!(*fee_amount, None);
626        assert_eq!(
627            *service_fee_amount,
628            Some(500),
629            "legacy `fee` key must map to `service_fee_amount`"
630        );
631        assert_eq!(*service_fee_asset, None);
632    }
633
634    /// New writes must use the renamed `service_fee_amount` key (never the
635    /// legacy `fee`), and must emit the three new fields when populated.
636    #[test]
637    fn new_rows_use_renamed_keys_on_serialize() {
638        let info = ConversionInfo::Boltz {
639            swap_id: "s".to_string(),
640            chain: "arbitrum".to_string(),
641            chain_id: None,
642            asset: "USDT".to_string(),
643            recipient_address: "0xr".to_string(),
644            invoice: "lnbc".to_string(),
645            invoice_amount_sats: 100,
646            asset_amount_in: Some(1_500_000),
647            estimated_out: 1_450_000,
648            delivered_amount: None,
649            bridge_ref: None,
650            status: ConversionStatus::Pending,
651            fee_amount: Some(50_000),
652            service_fee_amount: Some(1_500),
653            service_fee_asset: Some("USD".to_string()),
654            max_slippage_bps: 100,
655            quote_degraded: false,
656            asset_decimals: 6,
657            asset_contract: None,
658        };
659        let json = serde_json::to_string(&info).unwrap();
660        assert!(
661            json.contains(r#""service_fee_amount":"1500""#),
662            "must serialize under the new key, got: {json}"
663        );
664        assert!(
665            !json.contains(r#""fee":"#),
666            "must not emit the legacy `fee` key, got: {json}"
667        );
668        assert!(json.contains(r#""asset_amount_in":"1500000""#));
669        assert!(json.contains(r#""fee_amount":"50000""#));
670        assert!(json.contains(r#""service_fee_asset":"USD""#));
671    }
672
673    /// Backward-compat round-trip — make sure a legacy row, once deserialized
674    /// and re-serialized, stays self-consistent (the upgrade path doesn't lose
675    /// the legacy value).
676    #[test]
677    fn legacy_row_roundtrip_after_upgrade() {
678        let legacy = r#"{
679            "type": "boltz",
680            "swap_id": "swap_legacy",
681            "invoice": "lnbc",
682            "invoice_amount_sats": 100000,
683            "max_slippage_bps": 100,
684            "chain": "arbitrum",
685            "asset": "USDT",
686            "recipient_address": "0xr",
687            "estimated_out": "1450000",
688            "status": "Pending",
689            "fee": "1500",
690            "asset_decimals": 6
691        }"#;
692        let decoded: ConversionInfo = serde_json::from_str(legacy).unwrap();
693        let re_encoded = serde_json::to_string(&decoded).unwrap();
694        let re_decoded: ConversionInfo = serde_json::from_str(&re_encoded).unwrap();
695        assert_eq!(
696            decoded, re_decoded,
697            "upgraded row must round-trip identically"
698        );
699    }
700
701    #[test]
702    fn boltz_status_mut_updates_status_in_place() {
703        let mut info = ConversionInfo::Boltz {
704            swap_id: "s1".to_string(),
705            chain: "arbitrum".to_string(),
706            chain_id: Some("42161".to_string()),
707            asset: "USDT".to_string(),
708            recipient_address: "0xdest".to_string(),
709            invoice: "lnbc".to_string(),
710            invoice_amount_sats: 100,
711            asset_amount_in: None,
712            estimated_out: 1,
713            delivered_amount: None,
714            bridge_ref: None,
715            status: ConversionStatus::Pending,
716            fee_amount: None,
717            service_fee_amount: None,
718            service_fee_asset: None,
719            max_slippage_bps: 100,
720            quote_degraded: false,
721            asset_decimals: 6,
722            asset_contract: None,
723        };
724        *info.status_mut() = ConversionStatus::Completed;
725        assert_eq!(info.status(), &ConversionStatus::Completed);
726    }
727}
728
729pub(crate) struct TokenConversionPool {
730    pub(crate) asset_in_address: String,
731    pub(crate) asset_out_address: String,
732    pub(crate) pool: Pool,
733}
734
735/// A priced conversion, together with the pool it was priced against.
736pub(crate) struct ResolvedConversion {
737    pub(crate) conversion_pool: TokenConversionPool,
738    /// The floor the swap intent will carry. For a requested output it is that
739    /// request, scaled by any rise of the input above the one it derived,
740    /// rather than the simulated output, which may be higher and would fail for
741    /// no reason. For a supplied input it is the simulated output less slippage.
742    pub(crate) min_amount_out: u128,
743    pub(crate) estimate: ConversionEstimate,
744}
745
746pub(crate) struct TokenConversionResponse {
747    /// The sent payment id for the conversion
748    pub(crate) sent_payment_id: String,
749    /// The received payment id for the conversion
750    pub(crate) received_payment_id: String,
751}
752
753/// Options for conversion when fulfilling a payment. When set, the SDK will
754/// perform a conversion before fulfilling the payment. If not set, the payment
755/// will only be fulfilled if the wallet has sufficient balance of the required asset.
756#[derive(Debug, Clone, Serialize, PartialEq)]
757#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
758pub struct ConversionOptions {
759    /// The type of conversion to perform when fulfilling the payment
760    pub conversion_type: ConversionType,
761    /// The optional maximum slippage in basis points (1/100 of a percent) allowed when
762    /// a conversion is needed to fulfill the payment. Defaults to 10 bps (0.1%) if not set.
763    /// The conversion will fail if the actual amount received is less than
764    /// `estimated_amount * (1 - max_slippage_bps / 10_000)`.
765    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
766    pub max_slippage_bps: Option<u32>,
767    /// The optional timeout in seconds to wait for the conversion to complete
768    /// when fulfilling the payment. This timeout only concerns waiting for the received
769    /// payment of the conversion. If the timeout is reached before the conversion
770    /// is complete, the payment will fail. Defaults to 30 seconds if not set.
771    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
772    pub completion_timeout_secs: Option<u32>,
773}
774
775#[derive(Debug, Clone, Serialize, PartialEq)]
776#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
777pub enum ConversionType {
778    /// Converting from Bitcoin to a token
779    FromBitcoin,
780    /// Converting from a token to Bitcoin
781    ToBitcoin { from_token_identifier: String },
782}
783
784impl ConversionType {
785    /// Returns the asset addresses for the conversion type
786    ///
787    /// # Arguments
788    ///
789    /// * `token_identifier` - The token identifier when converting from Bitcoin to a token
790    ///
791    /// # Returns
792    ///
793    /// Result containing:
794    /// * (String, String): A tuple containing the asset in address and asset out address
795    /// * `SdkError`: If the token identifier is required but not provided
796    pub(crate) fn as_asset_addresses(
797        &self,
798        token_identifier: Option<&String>,
799    ) -> Result<(String, String), SdkError> {
800        Ok(match self {
801            ConversionType::FromBitcoin => (
802                BTC_ASSET_ADDRESS.to_string(),
803                token_identifier
804                    .ok_or(SdkError::InvalidInput(
805                        "Token identifier is required for from Bitcoin conversion".to_string(),
806                    ))?
807                    .clone(),
808            ),
809            ConversionType::ToBitcoin {
810                from_token_identifier,
811            } => (from_token_identifier.clone(), BTC_ASSET_ADDRESS.to_string()),
812        })
813    }
814}
815
816#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
817pub struct FetchConversionLimitsRequest {
818    /// The type of conversion, either from or to Bitcoin.
819    pub conversion_type: ConversionType,
820    /// The token identifier when converting to a token.
821    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
822    pub token_identifier: Option<String>,
823}
824
825#[derive(Debug, Clone, Serialize)]
826#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
827pub struct FetchConversionLimitsResponse {
828    /// The minimum amount to be converted.
829    /// Denominated in satoshis if converting from Bitcoin, otherwise in the token base units.
830    pub min_from_amount: Option<u128>,
831    /// The minimum amount to be received from the conversion.
832    /// Denominated in satoshis if converting to Bitcoin, otherwise in the token base units.
833    pub min_to_amount: Option<u128>,
834}