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))]
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
81pub enum AmountAdjustmentReason {
82 FlooredToMinLimit,
84 IncreasedToAvoidDust,
87}
88
89#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
92pub enum ConversionStatus {
93 Pending,
95 Completed,
97 Failed,
99 RefundNeeded,
102 Refunded,
104}
105
106impl fmt::Display for ConversionStatus {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 match self {
109 ConversionStatus::Pending => write!(f, "pending"),
110 ConversionStatus::Completed => write!(f, "completed"),
111 ConversionStatus::Failed => write!(f, "failed"),
112 ConversionStatus::RefundNeeded => write!(f, "refund_needed"),
113 ConversionStatus::Refunded => write!(f, "refunded"),
114 }
115 }
116}
117
118impl FromStr for ConversionStatus {
119 type Err = String;
120
121 fn from_str(s: &str) -> Result<Self, Self::Err> {
122 match s {
123 "pending" => Ok(ConversionStatus::Pending),
124 "completed" => Ok(ConversionStatus::Completed),
125 "failed" => Ok(ConversionStatus::Failed),
126 "refund_needed" => Ok(ConversionStatus::RefundNeeded),
127 "refunded" => Ok(ConversionStatus::Refunded),
128 _ => Err(format!("Invalid conversion status '{s}'")),
129 }
130 }
131}
132
133#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
143#[serde(tag = "type")]
144pub enum ConversionInfo {
145 #[serde(rename = "amm")]
147 Amm {
148 pool_id: String,
150 conversion_id: String,
152 status: ConversionStatus,
154 #[serde(default, with = "serde_option_u128_as_string")]
157 fee: Option<u128>,
158 purpose: Option<ConversionPurpose>,
160 #[serde(default)]
162 amount_adjustment: Option<AmountAdjustmentReason>,
163 },
164 #[serde(rename = "orchestra")]
166 Orchestra {
167 order_id: String,
169 quote_id: String,
171 #[serde(default)]
173 read_token: Option<String>,
174
175 chain: String,
177 #[serde(default)]
181 chain_id: Option<String>,
182 #[serde(default)]
184 asset: String,
185 recipient_address: String,
187 #[serde(default, with = "serde_option_u128_as_string")]
190 asset_amount_in: Option<u128>,
191 #[serde(with = "serde_u128_as_string")]
193 estimated_out: u128,
194 #[serde(default, with = "serde_option_u128_as_string")]
196 delivered_amount: Option<u128>,
197 status: ConversionStatus,
198 #[serde(default, with = "serde_option_u128_as_string")]
201 fee_amount: Option<u128>,
202 #[serde(
204 default,
205 alias = "fee",
206 rename = "service_fee_amount",
207 with = "serde_option_u128_as_string"
208 )]
209 service_fee_amount: Option<u128>,
210 #[serde(default)]
212 service_fee_asset: Option<String>,
213 asset_decimals: u32,
215 #[serde(default)]
217 asset_contract: Option<String>,
218 },
219 #[serde(rename = "boltz")]
224 Boltz {
225 swap_id: String,
227 invoice: String,
229 invoice_amount_sats: u64,
231 #[serde(default, alias = "lz_guid")]
235 bridge_ref: Option<String>,
236 max_slippage_bps: u32,
238 #[serde(default)]
240 quote_degraded: bool,
241
242 chain: String,
244 #[serde(default)]
248 chain_id: Option<String>,
249 #[serde(default)]
251 asset: String,
252 recipient_address: String,
254 #[serde(with = "serde_u128_as_string")]
256 estimated_out: u128,
257 #[serde(default, with = "serde_option_u128_as_string")]
259 delivered_amount: Option<u128>,
260 status: ConversionStatus,
262 #[serde(default, with = "serde_option_u128_as_string")]
265 asset_amount_in: Option<u128>,
266 #[serde(default, with = "serde_option_u128_as_string")]
269 fee_amount: Option<u128>,
270 #[serde(
272 default,
273 alias = "fee",
274 rename = "service_fee_amount",
275 with = "serde_option_u128_as_string"
276 )]
277 service_fee_amount: Option<u128>,
278 #[serde(default)]
280 service_fee_asset: Option<String>,
281 asset_decimals: u32,
283 #[serde(default)]
285 asset_contract: Option<String>,
286 },
287}
288
289impl ConversionInfo {
290 pub fn status(&self) -> &ConversionStatus {
292 match self {
293 ConversionInfo::Amm { status, .. }
294 | ConversionInfo::Orchestra { status, .. }
295 | ConversionInfo::Boltz { status, .. } => status,
296 }
297 }
298
299 pub fn status_mut(&mut self) -> &mut ConversionStatus {
301 match self {
302 ConversionInfo::Amm { status, .. }
303 | ConversionInfo::Orchestra { status, .. }
304 | ConversionInfo::Boltz { status, .. } => status,
305 }
306 }
307
308 pub fn fee(&self) -> Option<u128> {
311 match self {
312 ConversionInfo::Amm { fee, .. } => *fee,
313 ConversionInfo::Orchestra { fee_amount, .. }
314 | ConversionInfo::Boltz { fee_amount, .. } => *fee_amount,
315 }
316 }
317
318 pub fn is_amm(&self) -> bool {
320 matches!(self, ConversionInfo::Amm { .. })
321 }
322
323 pub fn is_orchestra(&self) -> bool {
325 matches!(self, ConversionInfo::Orchestra { .. })
326 }
327
328 pub fn is_boltz(&self) -> bool {
330 matches!(self, ConversionInfo::Boltz { .. })
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn boltz_conversion_info_roundtrip() {
340 let original = ConversionInfo::Boltz {
341 swap_id: "boltz_swap_abc".to_string(),
342 chain: "solana".to_string(),
343 chain_id: None,
344 asset: "USDT0".to_string(),
345 recipient_address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
346 invoice: "lnbc1000n1pexample".to_string(),
347 invoice_amount_sats: 150_000,
348 asset_amount_in: Some(100_500_000),
349 estimated_out: 99_000_000,
350 delivered_amount: Some(98_750_000),
351 bridge_ref: Some("0xdeadbeef".to_string()),
352 status: ConversionStatus::Pending,
353 fee_amount: Some(1_500_000),
354 service_fee_amount: Some(2_500),
355 service_fee_asset: None,
356 max_slippage_bps: 100,
357 quote_degraded: false,
358 asset_decimals: 6,
359 asset_contract: Some("0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string()),
360 };
361
362 let json = serde_json::to_string(&original).unwrap();
363 let decoded: ConversionInfo = serde_json::from_str(&json).unwrap();
364 assert_eq!(decoded, original);
365 assert!(decoded.is_boltz());
366 assert!(!decoded.is_orchestra());
367 assert!(!decoded.is_amm());
368 assert_eq!(decoded.status(), &ConversionStatus::Pending);
369 assert_eq!(decoded.fee(), Some(1_500_000));
370
371 assert!(json.contains(r#""type":"boltz""#));
373 assert!(json.contains(r#""estimated_out":"99000000""#));
375 }
376
377 #[test]
384 fn boltz_legacy_fee_alias_deserializes_into_service_fee_amount() {
385 let legacy = r#"{
386 "type": "boltz",
387 "swap_id": "swap_legacy",
388 "invoice": "lnbc1000n1pold",
389 "invoice_amount_sats": 100000,
390 "max_slippage_bps": 100,
391 "chain": "arbitrum",
392 "asset": "USDT",
393 "recipient_address": "0xrecipient",
394 "estimated_out": "1450000",
395 "status": "Pending",
396 "fee": "1500",
397 "asset_decimals": 6
398 }"#;
399 let decoded: ConversionInfo = serde_json::from_str(legacy).unwrap();
400 let ConversionInfo::Boltz {
401 asset_amount_in,
402 fee_amount,
403 service_fee_amount,
404 service_fee_asset,
405 ..
406 } = &decoded
407 else {
408 panic!("expected Boltz variant, got: {decoded:?}");
409 };
410 assert_eq!(*asset_amount_in, None);
411 assert_eq!(*fee_amount, None);
412 assert_eq!(
413 *service_fee_amount,
414 Some(1500),
415 "legacy `fee` key must map to `service_fee_amount`"
416 );
417 assert_eq!(*service_fee_asset, None);
418 }
419
420 #[test]
421 fn orchestra_legacy_fee_alias_deserializes_into_service_fee_amount() {
422 let legacy = r#"{
423 "type": "orchestra",
424 "order_id": "ord_legacy",
425 "quote_id": "q_legacy",
426 "chain": "base",
427 "asset": "USDC",
428 "recipient_address": "0xrecipient",
429 "estimated_out": "99500000",
430 "status": "Completed",
431 "fee": "500",
432 "asset_decimals": 6
433 }"#;
434 let decoded: ConversionInfo = serde_json::from_str(legacy).unwrap();
435 let ConversionInfo::Orchestra {
436 asset_amount_in,
437 fee_amount,
438 service_fee_amount,
439 service_fee_asset,
440 ..
441 } = &decoded
442 else {
443 panic!("expected Orchestra variant, got: {decoded:?}");
444 };
445 assert_eq!(*asset_amount_in, None);
446 assert_eq!(*fee_amount, None);
447 assert_eq!(
448 *service_fee_amount,
449 Some(500),
450 "legacy `fee` key must map to `service_fee_amount`"
451 );
452 assert_eq!(*service_fee_asset, None);
453 }
454
455 #[test]
458 fn new_rows_use_renamed_keys_on_serialize() {
459 let info = ConversionInfo::Boltz {
460 swap_id: "s".to_string(),
461 chain: "arbitrum".to_string(),
462 chain_id: None,
463 asset: "USDT".to_string(),
464 recipient_address: "0xr".to_string(),
465 invoice: "lnbc".to_string(),
466 invoice_amount_sats: 100,
467 asset_amount_in: Some(1_500_000),
468 estimated_out: 1_450_000,
469 delivered_amount: None,
470 bridge_ref: None,
471 status: ConversionStatus::Pending,
472 fee_amount: Some(50_000),
473 service_fee_amount: Some(1_500),
474 service_fee_asset: Some("USD".to_string()),
475 max_slippage_bps: 100,
476 quote_degraded: false,
477 asset_decimals: 6,
478 asset_contract: None,
479 };
480 let json = serde_json::to_string(&info).unwrap();
481 assert!(
482 json.contains(r#""service_fee_amount":"1500""#),
483 "must serialize under the new key, got: {json}"
484 );
485 assert!(
486 !json.contains(r#""fee":"#),
487 "must not emit the legacy `fee` key, got: {json}"
488 );
489 assert!(json.contains(r#""asset_amount_in":"1500000""#));
490 assert!(json.contains(r#""fee_amount":"50000""#));
491 assert!(json.contains(r#""service_fee_asset":"USD""#));
492 }
493
494 #[test]
498 fn legacy_row_roundtrip_after_upgrade() {
499 let legacy = r#"{
500 "type": "boltz",
501 "swap_id": "swap_legacy",
502 "invoice": "lnbc",
503 "invoice_amount_sats": 100000,
504 "max_slippage_bps": 100,
505 "chain": "arbitrum",
506 "asset": "USDT",
507 "recipient_address": "0xr",
508 "estimated_out": "1450000",
509 "status": "Pending",
510 "fee": "1500",
511 "asset_decimals": 6
512 }"#;
513 let decoded: ConversionInfo = serde_json::from_str(legacy).unwrap();
514 let re_encoded = serde_json::to_string(&decoded).unwrap();
515 let re_decoded: ConversionInfo = serde_json::from_str(&re_encoded).unwrap();
516 assert_eq!(
517 decoded, re_decoded,
518 "upgraded row must round-trip identically"
519 );
520 }
521
522 #[test]
523 fn boltz_status_mut_updates_status_in_place() {
524 let mut info = ConversionInfo::Boltz {
525 swap_id: "s1".to_string(),
526 chain: "arbitrum".to_string(),
527 chain_id: Some("42161".to_string()),
528 asset: "USDT".to_string(),
529 recipient_address: "0xdest".to_string(),
530 invoice: "lnbc".to_string(),
531 invoice_amount_sats: 100,
532 asset_amount_in: None,
533 estimated_out: 1,
534 delivered_amount: None,
535 bridge_ref: None,
536 status: ConversionStatus::Pending,
537 fee_amount: None,
538 service_fee_amount: None,
539 service_fee_asset: None,
540 max_slippage_bps: 100,
541 quote_degraded: false,
542 asset_decimals: 6,
543 asset_contract: None,
544 };
545 *info.status_mut() = ConversionStatus::Completed;
546 assert_eq!(info.status(), &ConversionStatus::Completed);
547 }
548}
549
550pub(crate) struct TokenConversionPool {
551 pub(crate) asset_in_address: String,
552 pub(crate) asset_out_address: String,
553 pub(crate) pool: Pool,
554}
555
556pub(crate) struct TokenConversionResponse {
557 pub(crate) sent_payment_id: String,
559 pub(crate) received_payment_id: String,
561}
562
563#[derive(Debug, Clone, Serialize, PartialEq)]
567#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
568pub struct ConversionOptions {
569 pub conversion_type: ConversionType,
571 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
576 pub max_slippage_bps: Option<u32>,
577 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
582 pub completion_timeout_secs: Option<u32>,
583}
584
585#[derive(Debug, Clone, Serialize, PartialEq)]
586#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
587pub enum ConversionType {
588 FromBitcoin,
590 ToBitcoin { from_token_identifier: String },
592}
593
594impl ConversionType {
595 pub(crate) fn as_asset_addresses(
607 &self,
608 token_identifier: Option<&String>,
609 ) -> Result<(String, String), SdkError> {
610 Ok(match self {
611 ConversionType::FromBitcoin => (
612 BTC_ASSET_ADDRESS.to_string(),
613 token_identifier
614 .ok_or(SdkError::InvalidInput(
615 "Token identifier is required for from Bitcoin conversion".to_string(),
616 ))?
617 .clone(),
618 ),
619 ConversionType::ToBitcoin {
620 from_token_identifier,
621 } => (from_token_identifier.clone(), BTC_ASSET_ADDRESS.to_string()),
622 })
623 }
624}
625
626#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
627pub struct FetchConversionLimitsRequest {
628 pub conversion_type: ConversionType,
630 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
632 pub token_identifier: Option<String>,
633}
634
635#[derive(Debug, Clone, Serialize)]
636#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
637pub struct FetchConversionLimitsResponse {
638 pub min_from_amount: Option<u128>,
641 pub min_to_amount: Option<u128>,
644}