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 #[serde(default)]
265 service_fee_asset_decimals: Option<u32>,
266 asset_decimals: u32,
268 #[serde(default)]
271 asset_contract: Option<String>,
272 },
273 #[serde(rename = "boltz")]
278 Boltz {
279 swap_id: String,
281 invoice: String,
283 invoice_amount_sats: u64,
285 #[serde(default, alias = "lz_guid")]
289 bridge_ref: Option<String>,
290 max_slippage_bps: u32,
292 #[serde(default)]
294 quote_degraded: bool,
295
296 chain: String,
298 #[serde(default)]
302 chain_id: Option<String>,
303 #[serde(default)]
305 asset: String,
306 recipient_address: String,
308 #[serde(with = "serde_u128_as_string")]
310 estimated_out: u128,
311 #[serde(default, with = "serde_option_u128_as_string")]
313 delivered_amount: Option<u128>,
314 status: ConversionStatus,
316 #[serde(default, with = "serde_option_u128_as_string")]
319 asset_amount_in: Option<u128>,
320 #[serde(default, with = "serde_option_u128_as_string")]
323 fee_amount: Option<u128>,
324 #[serde(
326 default,
327 alias = "fee",
328 rename = "service_fee_amount",
329 with = "serde_option_u128_as_string"
330 )]
331 service_fee_amount: Option<u128>,
332 #[serde(default)]
334 service_fee_asset: Option<String>,
335 asset_decimals: u32,
337 #[serde(default)]
340 asset_contract: Option<String>,
341 },
342}
343
344impl fmt::Debug for ConversionInfo {
345 #[allow(clippy::too_many_lines)]
346 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347 match self {
348 ConversionInfo::Amm {
349 pool_id,
350 conversion_id,
351 status,
352 fee,
353 purpose,
354 amount_adjustment,
355 degradation,
356 } => f
357 .debug_struct("Amm")
358 .field("pool_id", pool_id)
359 .field("conversion_id", conversion_id)
360 .field("status", status)
361 .field("fee", fee)
362 .field("purpose", purpose)
363 .field("amount_adjustment", amount_adjustment)
364 .field("degradation", degradation)
365 .finish(),
366 ConversionInfo::Orchestra {
367 order_id,
368 quote_id,
369 read_token,
370 chain,
371 chain_id,
372 asset,
373 recipient_address,
374 asset_amount_in,
375 estimated_out,
376 delivered_amount,
377 external_tx_hash,
378 status,
379 fee_amount,
380 service_fee_amount,
381 service_fee_asset,
382 service_fee_asset_decimals,
383 asset_decimals,
384 asset_contract,
385 } => f
386 .debug_struct("Orchestra")
387 .field("order_id", order_id)
388 .field("quote_id", quote_id)
389 .field("read_token", &read_token.as_ref().map(|_| "<redacted>"))
390 .field("chain", chain)
391 .field("chain_id", chain_id)
392 .field("asset", asset)
393 .field("recipient_address", recipient_address)
394 .field("asset_amount_in", asset_amount_in)
395 .field("estimated_out", estimated_out)
396 .field("delivered_amount", delivered_amount)
397 .field("external_tx_hash", external_tx_hash)
398 .field("status", status)
399 .field("fee_amount", fee_amount)
400 .field("service_fee_amount", service_fee_amount)
401 .field("service_fee_asset", service_fee_asset)
402 .field("service_fee_asset_decimals", service_fee_asset_decimals)
403 .field("asset_decimals", asset_decimals)
404 .field("asset_contract", asset_contract)
405 .finish(),
406 ConversionInfo::Boltz {
407 swap_id,
408 invoice,
409 invoice_amount_sats,
410 bridge_ref,
411 max_slippage_bps,
412 quote_degraded,
413 chain,
414 chain_id,
415 asset,
416 recipient_address,
417 estimated_out,
418 delivered_amount,
419 status,
420 asset_amount_in,
421 fee_amount,
422 service_fee_amount,
423 service_fee_asset,
424 asset_decimals,
425 asset_contract,
426 } => f
427 .debug_struct("Boltz")
428 .field("swap_id", swap_id)
429 .field("invoice", invoice)
430 .field("invoice_amount_sats", invoice_amount_sats)
431 .field("bridge_ref", bridge_ref)
432 .field("max_slippage_bps", max_slippage_bps)
433 .field("quote_degraded", quote_degraded)
434 .field("chain", chain)
435 .field("chain_id", chain_id)
436 .field("asset", asset)
437 .field("recipient_address", recipient_address)
438 .field("estimated_out", estimated_out)
439 .field("delivered_amount", delivered_amount)
440 .field("status", status)
441 .field("asset_amount_in", asset_amount_in)
442 .field("fee_amount", fee_amount)
443 .field("service_fee_amount", service_fee_amount)
444 .field("service_fee_asset", service_fee_asset)
445 .field("asset_decimals", asset_decimals)
446 .field("asset_contract", asset_contract)
447 .finish(),
448 }
449 }
450}
451
452impl ConversionInfo {
453 pub fn status(&self) -> &ConversionStatus {
455 match self {
456 ConversionInfo::Amm { status, .. }
457 | ConversionInfo::Orchestra { status, .. }
458 | ConversionInfo::Boltz { status, .. } => status,
459 }
460 }
461
462 pub fn status_mut(&mut self) -> &mut ConversionStatus {
464 match self {
465 ConversionInfo::Amm { status, .. }
466 | ConversionInfo::Orchestra { status, .. }
467 | ConversionInfo::Boltz { status, .. } => status,
468 }
469 }
470
471 pub fn fee(&self) -> Option<u128> {
474 match self {
475 ConversionInfo::Amm { fee, .. } => *fee,
476 ConversionInfo::Orchestra { fee_amount, .. }
477 | ConversionInfo::Boltz { fee_amount, .. } => *fee_amount,
478 }
479 }
480
481 pub fn is_amm(&self) -> bool {
483 matches!(self, ConversionInfo::Amm { .. })
484 }
485
486 pub fn is_orchestra(&self) -> bool {
488 matches!(self, ConversionInfo::Orchestra { .. })
489 }
490
491 pub fn is_boltz(&self) -> bool {
493 matches!(self, ConversionInfo::Boltz { .. })
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 #[test]
502 fn every_degradation_keeps_its_meaning_across_the_boundary() {
503 for (from, want) in [
507 (
508 flashnet::SwapDegradation::BelowMinimum,
509 SwapDegradation::BelowMinimum,
510 ),
511 (
512 flashnet::SwapDegradation::UnexpectedAsset,
513 SwapDegradation::UnexpectedAsset,
514 ),
515 (
516 flashnet::SwapDegradation::MissingInfo,
517 SwapDegradation::MissingInfo,
518 ),
519 ] {
520 assert_eq!(SwapDegradation::from(from), want, "{from:?} was relabelled");
521 }
522 }
523
524 #[test]
525 fn boltz_conversion_info_roundtrip() {
526 let original = ConversionInfo::Boltz {
527 swap_id: "boltz_swap_abc".to_string(),
528 chain: "solana".to_string(),
529 chain_id: None,
530 asset: "USDT0".to_string(),
531 recipient_address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
532 invoice: "lnbc1000n1pexample".to_string(),
533 invoice_amount_sats: 150_000,
534 asset_amount_in: Some(100_500_000),
535 estimated_out: 99_000_000,
536 delivered_amount: Some(98_750_000),
537 bridge_ref: Some("0xdeadbeef".to_string()),
538 status: ConversionStatus::Pending,
539 fee_amount: Some(1_500_000),
540 service_fee_amount: Some(2_500),
541 service_fee_asset: None,
542 max_slippage_bps: 100,
543 quote_degraded: false,
544 asset_decimals: 6,
545 asset_contract: Some("0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string()),
546 };
547
548 let json = serde_json::to_string(&original).unwrap();
549 let decoded: ConversionInfo = serde_json::from_str(&json).unwrap();
550 assert_eq!(decoded, original);
551 assert!(decoded.is_boltz());
552 assert!(!decoded.is_orchestra());
553 assert!(!decoded.is_amm());
554 assert_eq!(decoded.status(), &ConversionStatus::Pending);
555 assert_eq!(decoded.fee(), Some(1_500_000));
556
557 assert!(json.contains(r#""type":"boltz""#));
559 assert!(json.contains(r#""estimated_out":"99000000""#));
561 }
562
563 #[test]
570 fn boltz_legacy_fee_alias_deserializes_into_service_fee_amount() {
571 let legacy = r#"{
572 "type": "boltz",
573 "swap_id": "swap_legacy",
574 "invoice": "lnbc1000n1pold",
575 "invoice_amount_sats": 100000,
576 "max_slippage_bps": 100,
577 "chain": "arbitrum",
578 "asset": "USDT",
579 "recipient_address": "0xrecipient",
580 "estimated_out": "1450000",
581 "status": "Pending",
582 "fee": "1500",
583 "asset_decimals": 6
584 }"#;
585 let decoded: ConversionInfo = serde_json::from_str(legacy).unwrap();
586 let ConversionInfo::Boltz {
587 asset_amount_in,
588 fee_amount,
589 service_fee_amount,
590 service_fee_asset,
591 ..
592 } = &decoded
593 else {
594 panic!("expected Boltz variant, got: {decoded:?}");
595 };
596 assert_eq!(*asset_amount_in, None);
597 assert_eq!(*fee_amount, None);
598 assert_eq!(
599 *service_fee_amount,
600 Some(1500),
601 "legacy `fee` key must map to `service_fee_amount`"
602 );
603 assert_eq!(*service_fee_asset, None);
604 }
605
606 #[test]
607 fn orchestra_legacy_fee_alias_deserializes_into_service_fee_amount() {
608 let legacy = r#"{
609 "type": "orchestra",
610 "order_id": "ord_legacy",
611 "quote_id": "q_legacy",
612 "chain": "base",
613 "asset": "USDC",
614 "recipient_address": "0xrecipient",
615 "estimated_out": "99500000",
616 "status": "Completed",
617 "fee": "500",
618 "asset_decimals": 6
619 }"#;
620 let decoded: ConversionInfo = serde_json::from_str(legacy).unwrap();
621 let ConversionInfo::Orchestra {
622 asset_amount_in,
623 fee_amount,
624 service_fee_amount,
625 service_fee_asset,
626 service_fee_asset_decimals,
627 ..
628 } = &decoded
629 else {
630 panic!("expected Orchestra variant, got: {decoded:?}");
631 };
632 assert_eq!(*asset_amount_in, None);
633 assert_eq!(*fee_amount, None);
634 assert_eq!(
635 *service_fee_amount,
636 Some(500),
637 "legacy `fee` key must map to `service_fee_amount`"
638 );
639 assert_eq!(*service_fee_asset, None);
640 assert_eq!(*service_fee_asset_decimals, None);
641 }
642
643 #[test]
646 fn new_rows_use_renamed_keys_on_serialize() {
647 let info = ConversionInfo::Boltz {
648 swap_id: "s".to_string(),
649 chain: "arbitrum".to_string(),
650 chain_id: None,
651 asset: "USDT".to_string(),
652 recipient_address: "0xr".to_string(),
653 invoice: "lnbc".to_string(),
654 invoice_amount_sats: 100,
655 asset_amount_in: Some(1_500_000),
656 estimated_out: 1_450_000,
657 delivered_amount: None,
658 bridge_ref: None,
659 status: ConversionStatus::Pending,
660 fee_amount: Some(50_000),
661 service_fee_amount: Some(1_500),
662 service_fee_asset: Some("USD".to_string()),
663 max_slippage_bps: 100,
664 quote_degraded: false,
665 asset_decimals: 6,
666 asset_contract: None,
667 };
668 let json = serde_json::to_string(&info).unwrap();
669 assert!(
670 json.contains(r#""service_fee_amount":"1500""#),
671 "must serialize under the new key, got: {json}"
672 );
673 assert!(
674 !json.contains(r#""fee":"#),
675 "must not emit the legacy `fee` key, got: {json}"
676 );
677 assert!(json.contains(r#""asset_amount_in":"1500000""#));
678 assert!(json.contains(r#""fee_amount":"50000""#));
679 assert!(json.contains(r#""service_fee_asset":"USD""#));
680 }
681
682 #[test]
686 fn legacy_row_roundtrip_after_upgrade() {
687 let legacy = r#"{
688 "type": "boltz",
689 "swap_id": "swap_legacy",
690 "invoice": "lnbc",
691 "invoice_amount_sats": 100000,
692 "max_slippage_bps": 100,
693 "chain": "arbitrum",
694 "asset": "USDT",
695 "recipient_address": "0xr",
696 "estimated_out": "1450000",
697 "status": "Pending",
698 "fee": "1500",
699 "asset_decimals": 6
700 }"#;
701 let decoded: ConversionInfo = serde_json::from_str(legacy).unwrap();
702 let re_encoded = serde_json::to_string(&decoded).unwrap();
703 let re_decoded: ConversionInfo = serde_json::from_str(&re_encoded).unwrap();
704 assert_eq!(
705 decoded, re_decoded,
706 "upgraded row must round-trip identically"
707 );
708 }
709
710 #[test]
711 fn boltz_status_mut_updates_status_in_place() {
712 let mut info = ConversionInfo::Boltz {
713 swap_id: "s1".to_string(),
714 chain: "arbitrum".to_string(),
715 chain_id: Some("42161".to_string()),
716 asset: "USDT".to_string(),
717 recipient_address: "0xdest".to_string(),
718 invoice: "lnbc".to_string(),
719 invoice_amount_sats: 100,
720 asset_amount_in: None,
721 estimated_out: 1,
722 delivered_amount: None,
723 bridge_ref: None,
724 status: ConversionStatus::Pending,
725 fee_amount: None,
726 service_fee_amount: None,
727 service_fee_asset: None,
728 max_slippage_bps: 100,
729 quote_degraded: false,
730 asset_decimals: 6,
731 asset_contract: None,
732 };
733 *info.status_mut() = ConversionStatus::Completed;
734 assert_eq!(info.status(), &ConversionStatus::Completed);
735 }
736}
737
738pub(crate) struct TokenConversionPool {
739 pub(crate) asset_in_address: String,
740 pub(crate) asset_out_address: String,
741 pub(crate) pool: Pool,
742}
743
744pub(crate) struct ResolvedConversion {
746 pub(crate) conversion_pool: TokenConversionPool,
747 pub(crate) min_amount_out: u128,
752 pub(crate) estimate: ConversionEstimate,
753}
754
755pub(crate) struct TokenConversionResponse {
756 pub(crate) sent_payment_id: String,
758 pub(crate) received_payment_id: String,
760}
761
762#[derive(Debug, Clone, Serialize, PartialEq)]
766#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
767pub struct ConversionOptions {
768 pub conversion_type: ConversionType,
770 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
775 pub max_slippage_bps: Option<u32>,
776 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
781 pub completion_timeout_secs: Option<u32>,
782}
783
784#[derive(Debug, Clone, Serialize, PartialEq)]
785#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
786pub enum ConversionType {
787 FromBitcoin,
789 ToBitcoin { from_token_identifier: String },
791}
792
793impl ConversionType {
794 pub(crate) fn as_asset_addresses(
806 &self,
807 token_identifier: Option<&String>,
808 ) -> Result<(String, String), SdkError> {
809 Ok(match self {
810 ConversionType::FromBitcoin => (
811 BTC_ASSET_ADDRESS.to_string(),
812 token_identifier
813 .ok_or(SdkError::InvalidInput(
814 "Token identifier is required for from Bitcoin conversion".to_string(),
815 ))?
816 .clone(),
817 ),
818 ConversionType::ToBitcoin {
819 from_token_identifier,
820 } => (from_token_identifier.clone(), BTC_ASSET_ADDRESS.to_string()),
821 })
822 }
823}
824
825#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
826pub struct FetchConversionLimitsRequest {
827 pub conversion_type: ConversionType,
829 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
831 pub token_identifier: Option<String>,
832}
833
834#[derive(Debug, Clone, Serialize)]
835#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
836pub struct FetchConversionLimitsResponse {
837 pub min_from_amount: Option<u128>,
840 pub min_to_amount: Option<u128>,
843}