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
11pub const DEFAULT_CONVERSION_MAX_SLIPPAGE_BPS: u32 = 10;
13pub const DEFAULT_CONVERSION_TIMEOUT_SECS: u32 = 30;
15pub const DEFAULT_INTEGRATOR_PUBKEY: &str =
17 "037e26d9d62e0b3df2d3e66805f61de2a33914465297abf76817296a92ac3f2379";
18pub const DEFAULT_INTEGRATOR_FEE_BPS: u32 = 5;
20
21pub(crate) enum FeeSplit {
25 Sent(u128),
27 Received(u128),
29}
30
31#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
33#[derive(Debug, Clone, Serialize)]
34pub struct ConversionEstimate {
35 pub options: ConversionOptions,
37 pub amount_in: u128,
41 pub amount_out: u128,
45 pub fee: u128,
48 pub amount_adjustment: Option<AmountAdjustmentReason>,
50}
51
52#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub enum ConversionPurpose {
57 OngoingPayment {
59 payment_request: String,
61 },
62 SelfTransfer,
64 AutoConversion,
66}
67
68#[derive(Debug, Clone)]
70pub(crate) enum ConversionAmount {
71 MinAmountOut(u128),
74 AmountIn(u128),
76}
77
78#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
83#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
84pub enum SwapDegradation {
85 BelowMinimum,
87 UnexpectedAsset,
89 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#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
107pub enum AmountAdjustmentReason {
108 FlooredToMinLimit,
110 IncreasedToAvoidDust,
113}
114
115#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
118pub enum ConversionStatus {
119 Pending,
121 Completed,
123 Failed,
125 RefundNeeded,
128 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#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
169#[serde(tag = "type")]
170pub enum ConversionInfo {
171 #[serde(rename = "amm")]
173 Amm {
174 pool_id: String,
176 conversion_id: String,
178 status: ConversionStatus,
180 #[serde(default, with = "serde_option_u128_as_string")]
183 fee: Option<u128>,
184 purpose: Option<ConversionPurpose>,
186 #[serde(default)]
188 amount_adjustment: Option<AmountAdjustmentReason>,
189 #[serde(default)]
192 degradation: Option<SwapDegradation>,
193 },
194 #[serde(rename = "orchestra")]
196 Orchestra {
197 order_id: String,
199 quote_id: String,
201 #[serde(default)]
203 read_token: Option<String>,
204
205 chain: String,
207 #[serde(default)]
211 chain_id: Option<String>,
212 #[serde(default)]
214 asset: String,
215 recipient_address: String,
217 #[serde(default, with = "serde_option_u128_as_string")]
220 asset_amount_in: Option<u128>,
221 #[serde(with = "serde_u128_as_string")]
223 estimated_out: u128,
224 #[serde(default, with = "serde_option_u128_as_string")]
226 delivered_amount: Option<u128>,
227 status: ConversionStatus,
228 #[serde(default, with = "serde_option_u128_as_string")]
231 fee_amount: Option<u128>,
232 #[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 #[serde(default)]
242 service_fee_asset: Option<String>,
243 asset_decimals: u32,
245 #[serde(default)]
247 asset_contract: Option<String>,
248 },
249 #[serde(rename = "boltz")]
254 Boltz {
255 swap_id: String,
257 invoice: String,
259 invoice_amount_sats: u64,
261 #[serde(default, alias = "lz_guid")]
265 bridge_ref: Option<String>,
266 max_slippage_bps: u32,
268 #[serde(default)]
270 quote_degraded: bool,
271
272 chain: String,
274 #[serde(default)]
278 chain_id: Option<String>,
279 #[serde(default)]
281 asset: String,
282 recipient_address: String,
284 #[serde(with = "serde_u128_as_string")]
286 estimated_out: u128,
287 #[serde(default, with = "serde_option_u128_as_string")]
289 delivered_amount: Option<u128>,
290 status: ConversionStatus,
292 #[serde(default, with = "serde_option_u128_as_string")]
295 asset_amount_in: Option<u128>,
296 #[serde(default, with = "serde_option_u128_as_string")]
299 fee_amount: Option<u128>,
300 #[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 #[serde(default)]
310 service_fee_asset: Option<String>,
311 asset_decimals: u32,
313 #[serde(default)]
315 asset_contract: Option<String>,
316 },
317}
318
319impl ConversionInfo {
320 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 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 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 pub fn is_amm(&self) -> bool {
350 matches!(self, ConversionInfo::Amm { .. })
351 }
352
353 pub fn is_orchestra(&self) -> bool {
355 matches!(self, ConversionInfo::Orchestra { .. })
356 }
357
358 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 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 assert!(json.contains(r#""type":"boltz""#));
426 assert!(json.contains(r#""estimated_out":"99000000""#));
428 }
429
430 #[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 #[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 #[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
609pub(crate) struct ResolvedConversion {
611 pub(crate) conversion_pool: TokenConversionPool,
612 pub(crate) min_amount_out: u128,
617 pub(crate) estimate: ConversionEstimate,
618}
619
620pub(crate) struct TokenConversionResponse {
621 pub(crate) sent_payment_id: String,
623 pub(crate) received_payment_id: String,
625}
626
627#[derive(Debug, Clone, Serialize, PartialEq)]
631#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
632pub struct ConversionOptions {
633 pub conversion_type: ConversionType,
635 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
640 pub max_slippage_bps: Option<u32>,
641 #[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 FromBitcoin,
654 ToBitcoin { from_token_identifier: String },
656}
657
658impl ConversionType {
659 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 pub conversion_type: ConversionType,
694 #[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 pub min_from_amount: Option<u128>,
705 pub min_to_amount: Option<u128>,
708}