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 sends via Flashnet
165///   Orchestra (Spark → external chain).
166/// - [`ConversionInfo::Boltz`] for sats → stable-coin reverse swaps via Boltz.
167#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
169#[serde(tag = "type")]
170pub enum ConversionInfo {
171    /// AMM (Flashnet pool-based) conversion — Spark ↔ Spark token swaps.
172    #[serde(rename = "amm")]
173    Amm {
174        /// The pool id associated with the conversion
175        pool_id: String,
176        /// The conversion id shared by both sides of the conversion
177        conversion_id: String,
178        /// The status of the conversion
179        status: ConversionStatus,
180        /// The fee paid for the conversion.
181        /// Denominated in satoshis if converting from Bitcoin, otherwise in the token base units.
182        #[serde(default, with = "serde_option_u128_as_string")]
183        fee: Option<u128>,
184        /// The purpose of the conversion
185        purpose: Option<ConversionPurpose>,
186        /// The reason the conversion amount was adjusted, if applicable.
187        #[serde(default)]
188        amount_adjustment: Option<AmountAdjustmentReason>,
189        /// How the swap departed from the signed terms, if it did. Set on a
190        /// conversion that completed without delivering what was signed for.
191        #[serde(default)]
192        degradation: Option<SwapDegradation>,
193    },
194    /// Orchestra cross-chain conversion via the Flashnet orchestration API.
195    #[serde(rename = "orchestra")]
196    Orchestra {
197        /// The Orchestra order id returned by `/v1/orchestration/submit`.
198        order_id: String,
199        /// The Orchestra quote id used to create this order.
200        quote_id: String,
201        /// Opaque token required for querying order status.
202        #[serde(default)]
203        read_token: Option<String>,
204
205        /// Chain name (e.g. `"base"`, `"solana"`, `"tron"`).
206        chain: String,
207        /// Stable chain identifier (e.g. EVM `chainId` decimal string `"8453"`
208        /// for Base, SLIP-44 or similar for other chains). `None` if the
209        /// provider doesn't expose one for this route.
210        #[serde(default)]
211        chain_id: Option<String>,
212        /// Asset ticker (e.g. `"USDC"`, `"USDT"`).
213        #[serde(default)]
214        asset: String,
215        /// Recipient address on the target chain.
216        recipient_address: String,
217        /// Amount in expressed in the cross-chain asset's base units, via
218        /// the rate the SDK used at prepare time.
219        #[serde(default, with = "serde_option_u128_as_string")]
220        asset_amount_in: Option<u128>,
221        /// Estimated recipient amount, frozen at prepare time.
222        #[serde(with = "serde_u128_as_string")]
223        estimated_out: u128,
224        /// Actual delivered amount, Unset until the order reaches a terminal state.
225        #[serde(default, with = "serde_option_u128_as_string")]
226        delivered_amount: Option<u128>,
227        status: ConversionStatus,
228        /// Best-available total fee in destination asset base units.
229        /// Prepare-time estimate while pending, realized fee when Completed.
230        #[serde(default, with = "serde_option_u128_as_string")]
231        fee_amount: Option<u128>,
232        /// Orchestra service fee.
233        #[serde(
234            default,
235            alias = "fee",
236            rename = "service_fee_amount",
237            with = "serde_option_u128_as_string"
238        )]
239        service_fee_amount: Option<u128>,
240        /// Asset the service fee is denominated in. Unset means BTC sats.
241        #[serde(default)]
242        service_fee_asset: Option<String>,
243        /// Asset decimals (e.g. 6 for USDC).
244        asset_decimals: u32,
245        /// Token contract / mint address. Unset for native-asset destinations.
246        #[serde(default)]
247        asset_contract: Option<String>,
248    },
249    /// Boltz reverse swap: cross-chain conversion via Lightning hold invoice.
250    ///
251    /// The swap's secrets and lifecycle state live on the synced Boltz swap row
252    /// keyed by `swap_id`, which also drives cross-instance recovery.
253    #[serde(rename = "boltz")]
254    Boltz {
255        /// The Boltz swap id returned by `POST /swap/reverse`.
256        swap_id: String,
257        /// The BOLT11 hold invoice paid on the Spark/Lightning side.
258        invoice: String,
259        /// Amount of the hold invoice in sats.
260        invoice_amount_sats: u64,
261        /// Cross-chain bridge tracking handle for bridged swaps: a `LayerZero`
262        /// message GUID for OFT (USDT0) routes, or a CCTP reference for USDC
263        /// routes. `None` for same-chain (Arbitrum-direct) delivery.
264        #[serde(default, alias = "lz_guid")]
265        bridge_ref: Option<String>,
266        /// DEX slippage tolerance (basis points) committed at prepare time.
267        max_slippage_bps: u32,
268        /// Whether the claim-time DEX quote drifted beyond `max_slippage_bps`.
269        #[serde(default)]
270        quote_degraded: bool,
271
272        /// Chain name (e.g. `"arbitrum"`, `"solana"`, `"tron"`).
273        chain: String,
274        /// Stable chain identifier (e.g. EVM `chainId` decimal string `"42161"`
275        /// for Arbitrum). `None` if the provider doesn't expose one for this
276        /// route.
277        #[serde(default)]
278        chain_id: Option<String>,
279        /// Asset ticker (e.g. `"USDT"`, `"USDT0"`).
280        #[serde(default)]
281        asset: String,
282        /// Recipient address on the target chain.
283        recipient_address: String,
284        /// Estimated amount in the asset's base units, frozen at prepare time.
285        #[serde(with = "serde_u128_as_string")]
286        estimated_out: u128,
287        /// Actual amount delivered. `None` until the claim receipt is processed.
288        #[serde(default, with = "serde_option_u128_as_string")]
289        delivered_amount: Option<u128>,
290        /// Current status of the reverse swap.
291        status: ConversionStatus,
292        /// Amount in expressed in the cross-chain asset's base units, via the
293        /// BTC/USD rate the SDK used at prepare time.
294        #[serde(default, with = "serde_option_u128_as_string")]
295        asset_amount_in: Option<u128>,
296        /// Best-available total fee in destination asset base units.
297        /// Prepare-time estimate while pending, realized fee on Completed.
298        #[serde(default, with = "serde_option_u128_as_string")]
299        fee_amount: Option<u128>,
300        /// Boltz spread in sats.
301        #[serde(
302            default,
303            alias = "fee",
304            rename = "service_fee_amount",
305            with = "serde_option_u128_as_string"
306        )]
307        service_fee_amount: Option<u128>,
308        /// Asset service fee is denominated in. Unset means BTC sats.
309        #[serde(default)]
310        service_fee_asset: Option<String>,
311        /// Asset decimals (e.g. 6 for USDT).
312        asset_decimals: u32,
313        /// Token contract / mint address. Unset for native-asset destinations.
314        #[serde(default)]
315        asset_contract: Option<String>,
316    },
317}
318
319impl ConversionInfo {
320    /// The current status, regardless of conversion type.
321    pub fn status(&self) -> &ConversionStatus {
322        match self {
323            ConversionInfo::Amm { status, .. }
324            | ConversionInfo::Orchestra { status, .. }
325            | ConversionInfo::Boltz { status, .. } => status,
326        }
327    }
328
329    /// A mutable reference to the status, for in-place updates.
330    pub fn status_mut(&mut self) -> &mut ConversionStatus {
331        match self {
332            ConversionInfo::Amm { status, .. }
333            | ConversionInfo::Orchestra { status, .. }
334            | ConversionInfo::Boltz { status, .. } => status,
335        }
336    }
337
338    /// Headline fee: AMM pool fee in source units, or the cross-chain total
339    /// in destination-asset base units.
340    pub fn fee(&self) -> Option<u128> {
341        match self {
342            ConversionInfo::Amm { fee, .. } => *fee,
343            ConversionInfo::Orchestra { fee_amount, .. }
344            | ConversionInfo::Boltz { fee_amount, .. } => *fee_amount,
345        }
346    }
347
348    /// Whether this is an AMM (Flashnet pool) conversion.
349    pub fn is_amm(&self) -> bool {
350        matches!(self, ConversionInfo::Amm { .. })
351    }
352
353    /// Whether this is an Orchestra (cross-chain) conversion.
354    pub fn is_orchestra(&self) -> bool {
355        matches!(self, ConversionInfo::Orchestra { .. })
356    }
357
358    /// Whether this is a Boltz reverse swap.
359    pub fn is_boltz(&self) -> bool {
360        matches!(self, ConversionInfo::Boltz { .. })
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn every_degradation_keeps_its_meaning_across_the_boundary() {
370        // A hand-written mapping: swapping two arms would silently relabel a
371        // short delivery as a malformed response, which reads as a server bug
372        // rather than a shortfall.
373        for (from, want) in [
374            (
375                flashnet::SwapDegradation::BelowMinimum,
376                SwapDegradation::BelowMinimum,
377            ),
378            (
379                flashnet::SwapDegradation::UnexpectedAsset,
380                SwapDegradation::UnexpectedAsset,
381            ),
382            (
383                flashnet::SwapDegradation::MissingInfo,
384                SwapDegradation::MissingInfo,
385            ),
386        ] {
387            assert_eq!(SwapDegradation::from(from), want, "{from:?} was relabelled");
388        }
389    }
390
391    #[test]
392    fn boltz_conversion_info_roundtrip() {
393        let original = ConversionInfo::Boltz {
394            swap_id: "boltz_swap_abc".to_string(),
395            chain: "solana".to_string(),
396            chain_id: None,
397            asset: "USDT0".to_string(),
398            recipient_address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
399            invoice: "lnbc1000n1pexample".to_string(),
400            invoice_amount_sats: 150_000,
401            asset_amount_in: Some(100_500_000),
402            estimated_out: 99_000_000,
403            delivered_amount: Some(98_750_000),
404            bridge_ref: Some("0xdeadbeef".to_string()),
405            status: ConversionStatus::Pending,
406            fee_amount: Some(1_500_000),
407            service_fee_amount: Some(2_500),
408            service_fee_asset: None,
409            max_slippage_bps: 100,
410            quote_degraded: false,
411            asset_decimals: 6,
412            asset_contract: Some("0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string()),
413        };
414
415        let json = serde_json::to_string(&original).unwrap();
416        let decoded: ConversionInfo = serde_json::from_str(&json).unwrap();
417        assert_eq!(decoded, original);
418        assert!(decoded.is_boltz());
419        assert!(!decoded.is_orchestra());
420        assert!(!decoded.is_amm());
421        assert_eq!(decoded.status(), &ConversionStatus::Pending);
422        assert_eq!(decoded.fee(), Some(1_500_000));
423
424        // The `"type"` tag discriminator must match the rename attribute.
425        assert!(json.contains(r#""type":"boltz""#));
426        // u128 fields serialize as strings, not JSON numbers.
427        assert!(json.contains(r#""estimated_out":"99000000""#));
428    }
429
430    /// Pre-rename rows persisted in the wild only carried a `fee` key (the
431    /// provider service fee), and lacked `asset_amount_in`, `fee_amount`,
432    /// `service_fee_amount`, `service_fee_asset`. Reading such a row must:
433    /// - route the legacy `fee` value into `service_fee_amount`,
434    /// - default every new field to `None`,
435    /// - succeed for both provider variants.
436    #[test]
437    fn boltz_legacy_fee_alias_deserializes_into_service_fee_amount() {
438        let legacy = r#"{
439            "type": "boltz",
440            "swap_id": "swap_legacy",
441            "invoice": "lnbc1000n1pold",
442            "invoice_amount_sats": 100000,
443            "max_slippage_bps": 100,
444            "chain": "arbitrum",
445            "asset": "USDT",
446            "recipient_address": "0xrecipient",
447            "estimated_out": "1450000",
448            "status": "Pending",
449            "fee": "1500",
450            "asset_decimals": 6
451        }"#;
452        let decoded: ConversionInfo = serde_json::from_str(legacy).unwrap();
453        let ConversionInfo::Boltz {
454            asset_amount_in,
455            fee_amount,
456            service_fee_amount,
457            service_fee_asset,
458            ..
459        } = &decoded
460        else {
461            panic!("expected Boltz variant, got: {decoded:?}");
462        };
463        assert_eq!(*asset_amount_in, None);
464        assert_eq!(*fee_amount, None);
465        assert_eq!(
466            *service_fee_amount,
467            Some(1500),
468            "legacy `fee` key must map to `service_fee_amount`"
469        );
470        assert_eq!(*service_fee_asset, None);
471    }
472
473    #[test]
474    fn orchestra_legacy_fee_alias_deserializes_into_service_fee_amount() {
475        let legacy = r#"{
476            "type": "orchestra",
477            "order_id": "ord_legacy",
478            "quote_id": "q_legacy",
479            "chain": "base",
480            "asset": "USDC",
481            "recipient_address": "0xrecipient",
482            "estimated_out": "99500000",
483            "status": "Completed",
484            "fee": "500",
485            "asset_decimals": 6
486        }"#;
487        let decoded: ConversionInfo = serde_json::from_str(legacy).unwrap();
488        let ConversionInfo::Orchestra {
489            asset_amount_in,
490            fee_amount,
491            service_fee_amount,
492            service_fee_asset,
493            ..
494        } = &decoded
495        else {
496            panic!("expected Orchestra variant, got: {decoded:?}");
497        };
498        assert_eq!(*asset_amount_in, None);
499        assert_eq!(*fee_amount, None);
500        assert_eq!(
501            *service_fee_amount,
502            Some(500),
503            "legacy `fee` key must map to `service_fee_amount`"
504        );
505        assert_eq!(*service_fee_asset, None);
506    }
507
508    /// New writes must use the renamed `service_fee_amount` key (never the
509    /// legacy `fee`), and must emit the three new fields when populated.
510    #[test]
511    fn new_rows_use_renamed_keys_on_serialize() {
512        let info = ConversionInfo::Boltz {
513            swap_id: "s".to_string(),
514            chain: "arbitrum".to_string(),
515            chain_id: None,
516            asset: "USDT".to_string(),
517            recipient_address: "0xr".to_string(),
518            invoice: "lnbc".to_string(),
519            invoice_amount_sats: 100,
520            asset_amount_in: Some(1_500_000),
521            estimated_out: 1_450_000,
522            delivered_amount: None,
523            bridge_ref: None,
524            status: ConversionStatus::Pending,
525            fee_amount: Some(50_000),
526            service_fee_amount: Some(1_500),
527            service_fee_asset: Some("USD".to_string()),
528            max_slippage_bps: 100,
529            quote_degraded: false,
530            asset_decimals: 6,
531            asset_contract: None,
532        };
533        let json = serde_json::to_string(&info).unwrap();
534        assert!(
535            json.contains(r#""service_fee_amount":"1500""#),
536            "must serialize under the new key, got: {json}"
537        );
538        assert!(
539            !json.contains(r#""fee":"#),
540            "must not emit the legacy `fee` key, got: {json}"
541        );
542        assert!(json.contains(r#""asset_amount_in":"1500000""#));
543        assert!(json.contains(r#""fee_amount":"50000""#));
544        assert!(json.contains(r#""service_fee_asset":"USD""#));
545    }
546
547    /// Backward-compat round-trip — make sure a legacy row, once deserialized
548    /// and re-serialized, stays self-consistent (the upgrade path doesn't lose
549    /// the legacy value).
550    #[test]
551    fn legacy_row_roundtrip_after_upgrade() {
552        let legacy = r#"{
553            "type": "boltz",
554            "swap_id": "swap_legacy",
555            "invoice": "lnbc",
556            "invoice_amount_sats": 100000,
557            "max_slippage_bps": 100,
558            "chain": "arbitrum",
559            "asset": "USDT",
560            "recipient_address": "0xr",
561            "estimated_out": "1450000",
562            "status": "Pending",
563            "fee": "1500",
564            "asset_decimals": 6
565        }"#;
566        let decoded: ConversionInfo = serde_json::from_str(legacy).unwrap();
567        let re_encoded = serde_json::to_string(&decoded).unwrap();
568        let re_decoded: ConversionInfo = serde_json::from_str(&re_encoded).unwrap();
569        assert_eq!(
570            decoded, re_decoded,
571            "upgraded row must round-trip identically"
572        );
573    }
574
575    #[test]
576    fn boltz_status_mut_updates_status_in_place() {
577        let mut info = ConversionInfo::Boltz {
578            swap_id: "s1".to_string(),
579            chain: "arbitrum".to_string(),
580            chain_id: Some("42161".to_string()),
581            asset: "USDT".to_string(),
582            recipient_address: "0xdest".to_string(),
583            invoice: "lnbc".to_string(),
584            invoice_amount_sats: 100,
585            asset_amount_in: None,
586            estimated_out: 1,
587            delivered_amount: None,
588            bridge_ref: None,
589            status: ConversionStatus::Pending,
590            fee_amount: None,
591            service_fee_amount: None,
592            service_fee_asset: None,
593            max_slippage_bps: 100,
594            quote_degraded: false,
595            asset_decimals: 6,
596            asset_contract: None,
597        };
598        *info.status_mut() = ConversionStatus::Completed;
599        assert_eq!(info.status(), &ConversionStatus::Completed);
600    }
601}
602
603pub(crate) struct TokenConversionPool {
604    pub(crate) asset_in_address: String,
605    pub(crate) asset_out_address: String,
606    pub(crate) pool: Pool,
607}
608
609/// A priced conversion, together with the pool it was priced against.
610pub(crate) struct ResolvedConversion {
611    pub(crate) conversion_pool: TokenConversionPool,
612    /// The floor the swap intent will carry. For a requested output it is that
613    /// request, scaled by any rise of the input above the one it derived,
614    /// rather than the simulated output, which may be higher and would fail for
615    /// no reason. For a supplied input it is the simulated output less slippage.
616    pub(crate) min_amount_out: u128,
617    pub(crate) estimate: ConversionEstimate,
618}
619
620pub(crate) struct TokenConversionResponse {
621    /// The sent payment id for the conversion
622    pub(crate) sent_payment_id: String,
623    /// The received payment id for the conversion
624    pub(crate) received_payment_id: String,
625}
626
627/// Options for conversion when fulfilling a payment. When set, the SDK will
628/// perform a conversion before fulfilling the payment. If not set, the payment
629/// will only be fulfilled if the wallet has sufficient balance of the required asset.
630#[derive(Debug, Clone, Serialize, PartialEq)]
631#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
632pub struct ConversionOptions {
633    /// The type of conversion to perform when fulfilling the payment
634    pub conversion_type: ConversionType,
635    /// The optional maximum slippage in basis points (1/100 of a percent) allowed when
636    /// a conversion is needed to fulfill the payment. Defaults to 10 bps (0.1%) if not set.
637    /// The conversion will fail if the actual amount received is less than
638    /// `estimated_amount * (1 - max_slippage_bps / 10_000)`.
639    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
640    pub max_slippage_bps: Option<u32>,
641    /// The optional timeout in seconds to wait for the conversion to complete
642    /// when fulfilling the payment. This timeout only concerns waiting for the received
643    /// payment of the conversion. If the timeout is reached before the conversion
644    /// is complete, the payment will fail. Defaults to 30 seconds if not set.
645    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
646    pub completion_timeout_secs: Option<u32>,
647}
648
649#[derive(Debug, Clone, Serialize, PartialEq)]
650#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
651pub enum ConversionType {
652    /// Converting from Bitcoin to a token
653    FromBitcoin,
654    /// Converting from a token to Bitcoin
655    ToBitcoin { from_token_identifier: String },
656}
657
658impl ConversionType {
659    /// Returns the asset addresses for the conversion type
660    ///
661    /// # Arguments
662    ///
663    /// * `token_identifier` - The token identifier when converting from Bitcoin to a token
664    ///
665    /// # Returns
666    ///
667    /// Result containing:
668    /// * (String, String): A tuple containing the asset in address and asset out address
669    /// * `SdkError`: If the token identifier is required but not provided
670    pub(crate) fn as_asset_addresses(
671        &self,
672        token_identifier: Option<&String>,
673    ) -> Result<(String, String), SdkError> {
674        Ok(match self {
675            ConversionType::FromBitcoin => (
676                BTC_ASSET_ADDRESS.to_string(),
677                token_identifier
678                    .ok_or(SdkError::InvalidInput(
679                        "Token identifier is required for from Bitcoin conversion".to_string(),
680                    ))?
681                    .clone(),
682            ),
683            ConversionType::ToBitcoin {
684                from_token_identifier,
685            } => (from_token_identifier.clone(), BTC_ASSET_ADDRESS.to_string()),
686        })
687    }
688}
689
690#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
691pub struct FetchConversionLimitsRequest {
692    /// The type of conversion, either from or to Bitcoin.
693    pub conversion_type: ConversionType,
694    /// The token identifier when converting to a token.
695    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
696    pub token_identifier: Option<String>,
697}
698
699#[derive(Debug, Clone, Serialize)]
700#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
701pub struct FetchConversionLimitsResponse {
702    /// The minimum amount to be converted.
703    /// Denominated in satoshis if converting from Bitcoin, otherwise in the token base units.
704    pub min_from_amount: Option<u128>,
705    /// The minimum amount to be received from the conversion.
706    /// Denominated in satoshis if converting to Bitcoin, otherwise in the token base units.
707    pub min_to_amount: Option<u128>,
708}