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))]
169#[derive(Clone, Serialize, Deserialize, PartialEq)]
170#[serde(tag = "type")]
171pub enum ConversionInfo {
172 #[serde(rename = "amm")]
174 Amm {
175 pool_id: String,
177 conversion_id: String,
179 status: ConversionStatus,
181 #[serde(default, with = "serde_option_u128_as_string")]
184 fee: Option<u128>,
185 purpose: Option<ConversionPurpose>,
187 #[serde(default)]
189 amount_adjustment: Option<AmountAdjustmentReason>,
190 #[serde(default)]
193 degradation: Option<SwapDegradation>,
194 },
195 #[serde(rename = "orchestra")]
203 Orchestra {
204 order_id: String,
206 quote_id: String,
208 #[serde(default)]
210 read_token: Option<String>,
211
212 chain: String,
214 #[serde(default)]
218 chain_id: Option<String>,
219 #[serde(default)]
221 asset: String,
222 recipient_address: String,
225 #[serde(default, with = "serde_option_u128_as_string")]
229 asset_amount_in: Option<u128>,
230 #[serde(with = "serde_u128_as_string")]
234 estimated_out: u128,
235 #[serde(default, with = "serde_option_u128_as_string")]
238 delivered_amount: Option<u128>,
239 #[serde(default)]
245 external_tx_hash: Option<String>,
246 status: ConversionStatus,
247 #[serde(default, with = "serde_option_u128_as_string")]
250 fee_amount: Option<u128>,
251 #[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 #[serde(default)]
261 service_fee_asset: Option<String>,
262 asset_decimals: u32,
264 #[serde(default)]
267 asset_contract: Option<String>,
268 },
269 #[serde(rename = "boltz")]
274 Boltz {
275 swap_id: String,
277 invoice: String,
279 invoice_amount_sats: u64,
281 #[serde(default, alias = "lz_guid")]
285 bridge_ref: Option<String>,
286 max_slippage_bps: u32,
288 #[serde(default)]
290 quote_degraded: bool,
291
292 chain: String,
294 #[serde(default)]
298 chain_id: Option<String>,
299 #[serde(default)]
301 asset: String,
302 recipient_address: String,
304 #[serde(with = "serde_u128_as_string")]
306 estimated_out: u128,
307 #[serde(default, with = "serde_option_u128_as_string")]
309 delivered_amount: Option<u128>,
310 status: ConversionStatus,
312 #[serde(default, with = "serde_option_u128_as_string")]
315 asset_amount_in: Option<u128>,
316 #[serde(default, with = "serde_option_u128_as_string")]
319 fee_amount: Option<u128>,
320 #[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 #[serde(default)]
330 service_fee_asset: Option<String>,
331 asset_decimals: u32,
333 #[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 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 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 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 pub fn is_amm(&self) -> bool {
476 matches!(self, ConversionInfo::Amm { .. })
477 }
478
479 pub fn is_orchestra(&self) -> bool {
481 matches!(self, ConversionInfo::Orchestra { .. })
482 }
483
484 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 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 assert!(json.contains(r#""type":"boltz""#));
552 assert!(json.contains(r#""estimated_out":"99000000""#));
554 }
555
556 #[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 #[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 #[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
735pub(crate) struct ResolvedConversion {
737 pub(crate) conversion_pool: TokenConversionPool,
738 pub(crate) min_amount_out: u128,
743 pub(crate) estimate: ConversionEstimate,
744}
745
746pub(crate) struct TokenConversionResponse {
747 pub(crate) sent_payment_id: String,
749 pub(crate) received_payment_id: String,
751}
752
753#[derive(Debug, Clone, Serialize, PartialEq)]
757#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
758pub struct ConversionOptions {
759 pub conversion_type: ConversionType,
761 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
766 pub max_slippage_bps: Option<u32>,
767 #[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 FromBitcoin,
780 ToBitcoin { from_token_identifier: String },
782}
783
784impl ConversionType {
785 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 pub conversion_type: ConversionType,
820 #[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 pub min_from_amount: Option<u128>,
831 pub min_to_amount: Option<u128>,
834}