1use super::{
7 AssetType, StellarDexServiceError, StellarQuoteResponse, SwapExecutionResult,
8 SwapTransactionParams,
9};
10use crate::constants::STELLAR_DEFAULT_TRANSACTION_FEE;
11use crate::domain::relayer::string_to_muxed_account;
12use crate::models::transaction::stellar::asset::AssetSpec;
13use crate::models::Address;
14use crate::services::{
15 provider::StellarProviderTrait, signer::Signer, signer::StellarSignTrait,
16 stellar_dex::StellarDexServiceTrait,
17};
18use async_trait::async_trait;
19use chrono::{Duration as ChronoDuration, Utc};
20use reqwest::Client;
21use serde::Deserialize;
22use soroban_rs::xdr::{
23 Asset, Limits, Memo, Operation, OperationBody, PathPaymentStrictSendOp, Preconditions, ReadXdr,
24 SequenceNumber, TimeBounds, TimePoint, Transaction, TransactionEnvelope, TransactionExt,
25 TransactionV1Envelope, VecM, WriteXdr,
26};
27use std::convert::TryFrom;
28use std::sync::Arc;
29use std::time::Duration;
30use tracing::{debug, info};
31
32const TRANSACTION_VALIDITY_MINUTES: i64 = 5;
34
35const HTTP_REQUEST_TIMEOUT_SECONDS: u64 = 7;
37
38const SLIPPAGE_TO_BPS_MULTIPLIER: f32 = 100.0;
40
41#[derive(Debug, Deserialize)]
43struct PathResponse {
44 #[serde(rename = "_embedded")]
45 embedded: PathEmbedded,
46}
47
48#[derive(Debug, Deserialize)]
49struct PathEmbedded {
50 records: Vec<PathRecord>,
51}
52
53#[derive(Debug, Deserialize)]
54#[allow(dead_code)]
55struct PathRecord {
56 source_amount: String,
57 destination_amount: String,
58 path: Vec<PathAsset>,
59}
60
61#[derive(Debug, Deserialize)]
62struct PathAsset {
63 #[serde(rename = "asset_code")]
64 asset_code: Option<String>,
65 #[serde(rename = "asset_issuer")]
66 asset_issuer: Option<String>,
67}
68
69pub struct OrderBookService<P, S>
71where
72 P: StellarProviderTrait + Send + Sync + 'static,
73 S: StellarSignTrait + Signer + Send + Sync + 'static,
74{
75 horizon_base_url: String,
76 client: Client,
77 provider: Arc<P>,
78 signer: Arc<S>,
79}
80
81impl<P, S> OrderBookService<P, S>
82where
83 P: StellarProviderTrait + Send + Sync + 'static,
84 S: StellarSignTrait + Signer + Send + Sync + 'static,
85{
86 pub fn new(
94 horizon_base_url: String,
95 provider: Arc<P>,
96 signer: Arc<S>,
97 ) -> Result<Self, StellarDexServiceError> {
98 let client = Client::builder()
99 .timeout(Duration::from_secs(HTTP_REQUEST_TIMEOUT_SECONDS))
100 .build()
101 .map_err(StellarDexServiceError::HttpRequestError)?;
102
103 Ok(Self {
104 horizon_base_url,
105 client,
106 provider,
107 signer,
108 })
109 }
110
111 fn parse_asset_to_spec(&self, asset_id: &str) -> Result<AssetSpec, StellarDexServiceError> {
122 if asset_id == "native" || asset_id.is_empty() {
123 return Ok(AssetSpec::Native);
124 }
125
126 let (code, issuer) = asset_id.split_once(':').ok_or_else(|| {
127 StellarDexServiceError::InvalidAssetIdentifier(format!(
128 "Invalid asset format. Expected 'native' or 'CODE:ISSUER', got: {asset_id}"
129 ))
130 })?;
131
132 let code = code.trim();
133 let issuer = issuer.trim();
134
135 if code.is_empty() || issuer.is_empty() {
136 return Err(StellarDexServiceError::InvalidAssetIdentifier(
137 "Asset code and issuer cannot be empty".to_string(),
138 ));
139 }
140
141 if code.len() <= 4 {
142 Ok(AssetSpec::Credit4 {
143 code: code.to_string(),
144 issuer: issuer.to_string(),
145 })
146 } else if code.len() <= 12 {
147 Ok(AssetSpec::Credit12 {
148 code: code.to_string(),
149 issuer: issuer.to_string(),
150 })
151 } else {
152 Err(StellarDexServiceError::InvalidAssetIdentifier(format!(
153 "Asset code too long (max 12 characters): {code}",
154 )))
155 }
156 }
157
158 fn to_decimal_string(&self, stroops: u64, decimals: u8) -> String {
160 let s = stroops.to_string();
161 let decimals_usize = decimals as usize;
162
163 if s.len() <= decimals_usize {
164 format!("0.{s:0>decimals_usize$}")
165 } else {
166 let split = s.len() - decimals_usize;
167 format!("{}.{}", &s[..split], &s[split..])
168 }
169 }
170
171 fn parse_string_amount_to_stroops(
176 &self,
177 amount_str: &str,
178 decimals: u8,
179 ) -> Result<u64, StellarDexServiceError> {
180 let parts: Vec<&str> = amount_str.split('.').collect();
181
182 if parts.len() > 2 {
184 return Err(StellarDexServiceError::UnknownError(format!(
185 "Invalid amount string: multiple decimal points: {amount_str}"
186 )));
187 }
188
189 let int_part = parts[0].parse::<u128>().map_err(|_| {
191 StellarDexServiceError::UnknownError(format!("Invalid amount string: {amount_str}"))
192 })?;
193
194 let multiplier = 10u128.pow(decimals as u32);
196
197 let mut stroops = int_part.checked_mul(multiplier).ok_or_else(|| {
199 StellarDexServiceError::UnknownError(format!(
200 "Amount overflow: integer part too large for {decimals} decimals: {amount_str}",
201 ))
202 })?;
203
204 if parts.len() > 1 && !parts[1].is_empty() {
206 let fraction_str = parts[1];
207 let frac_parsed = fraction_str.parse::<u128>().map_err(|_| {
208 StellarDexServiceError::UnknownError(format!("Invalid fraction: {amount_str}"))
209 })?;
210
211 let frac_len = fraction_str.len() as u32;
212 let target_decimals = decimals as u32;
213
214 let frac_stroops = if frac_len > target_decimals {
215 let divisor = 10u128.pow(frac_len - target_decimals);
218 frac_parsed / divisor
219 } else {
220 let padding_multiplier = 10u128.pow(target_decimals - frac_len);
223 frac_parsed.checked_mul(padding_multiplier).ok_or_else(|| {
224 StellarDexServiceError::UnknownError(format!(
225 "Amount overflow: fraction padding overflow: {amount_str}"
226 ))
227 })?
228 };
229
230 stroops = stroops.checked_add(frac_stroops).ok_or_else(|| {
232 StellarDexServiceError::UnknownError(format!(
233 "Amount overflow: total exceeds u128 maximum: {amount_str}"
234 ))
235 })?;
236 }
237
238 if stroops > u64::MAX as u128 {
240 return Err(StellarDexServiceError::UnknownError(format!(
241 "Amount overflow: value {} exceeds u64 maximum ({}): {amount_str}",
242 stroops,
243 u64::MAX
244 )));
245 }
246
247 Ok(stroops as u64)
248 }
249
250 async fn fetch_strict_send_paths(
253 &self,
254 source_asset: &AssetSpec,
255 source_amount_stroops: u64,
256 source_decimals: u8,
257 destination_asset: &AssetSpec,
258 _destination_account: &str,
259 ) -> Result<Vec<PathRecord>, StellarDexServiceError> {
260 let mut url = format!("{}/paths/strict-send", self.horizon_base_url);
261
262 let amount_decimal = self.to_decimal_string(source_amount_stroops, source_decimals);
264
265 let mut params = vec![format!("source_amount={}", amount_decimal)];
266
267 match source_asset {
269 AssetSpec::Native => {
270 params.push("source_asset_type=native".to_string());
271 }
272 AssetSpec::Credit4 { code, issuer } => {
273 params.push("source_asset_type=credit_alphanum4".to_string());
274 params.push(format!("source_asset_code={code}"));
275 params.push(format!("source_asset_issuer={issuer}"));
276 }
277 AssetSpec::Credit12 { code, issuer } => {
278 params.push("source_asset_type=credit_alphanum12".to_string());
279 params.push(format!("source_asset_code={code}"));
280 params.push(format!("source_asset_issuer={issuer}"));
281 }
282 }
283
284 match destination_asset {
288 AssetSpec::Native => {
289 params.push("destination_assets=native".to_string());
290 }
291 AssetSpec::Credit4 { code, issuer } => {
292 params.push(format!("destination_assets={code}:{issuer}"));
293 }
294 AssetSpec::Credit12 { code, issuer } => {
295 params.push(format!("destination_assets={code}:{issuer}"));
296 }
297 }
298
299 url.push('?');
305 url.push_str(¶ms.join("&"));
306
307 debug!("Fetching paths from Horizon: {}", url);
308
309 let response = self
310 .client
311 .get(&url)
312 .send()
313 .await
314 .map_err(StellarDexServiceError::HttpRequestError)?;
315
316 if !response.status().is_success() {
317 let status = response.status();
318 let error_text = response
319 .text()
320 .await
321 .unwrap_or_else(|_| "Unable to read error response".to_string());
322 return Err(StellarDexServiceError::ApiError {
323 message: format!("Horizon API returned error {status}: {error_text}"),
324 });
325 }
326
327 let path_response: PathResponse = response.json().await.map_err(|e| {
328 StellarDexServiceError::UnknownError(format!("Failed to deserialize paths: {e}"))
329 })?;
330
331 Ok(path_response.embedded.records)
332 }
333
334 fn validate_swap_params(
336 &self,
337 params: &SwapTransactionParams,
338 ) -> Result<(), StellarDexServiceError> {
339 if params.destination_asset != "native" {
340 return Err(StellarDexServiceError::UnknownError(
341 "Only swapping tokens to XLM (native) is currently supported".to_string(),
342 ));
343 }
344
345 if params.source_asset == "native" || params.source_asset.is_empty() {
346 return Err(StellarDexServiceError::InvalidAssetIdentifier(
347 "Source asset cannot be native when swapping to XLM".to_string(),
348 ));
349 }
350
351 if params.amount == 0 {
352 return Err(StellarDexServiceError::InvalidAssetIdentifier(
353 "Swap amount cannot be zero".to_string(),
354 ));
355 }
356
357 if params.slippage_percent < 0.0 || params.slippage_percent > 100.0 {
358 return Err(StellarDexServiceError::InvalidAssetIdentifier(
359 "Slippage percentage must be between 0 and 100".to_string(),
360 ));
361 }
362
363 Ok(())
364 }
365
366 async fn get_swap_quote(
369 &self,
370 params: &SwapTransactionParams,
371 ) -> Result<StellarQuoteResponse, StellarDexServiceError> {
372 if params.destination_asset == "native" {
373 self.get_token_to_xlm_quote(
375 ¶ms.source_asset,
376 params.amount,
377 params.slippage_percent,
378 params.source_asset_decimals,
379 )
380 .await
381 } else if params.source_asset == "native" {
382 self.get_xlm_to_token_quote(
384 ¶ms.destination_asset,
385 params.amount,
386 params.slippage_percent,
387 params.destination_asset_decimals,
388 )
389 .await
390 } else {
391 Err(StellarDexServiceError::UnknownError(
392 "Only swaps involving native XLM are currently supported".to_string(),
393 ))
394 }
395 }
396
397 async fn build_swap_transaction_xdr(
399 &self,
400 params: &SwapTransactionParams,
401 quote: &StellarQuoteResponse,
402 ) -> Result<String, StellarDexServiceError> {
403 let source_account = string_to_muxed_account(¶ms.source_account).map_err(|e| {
405 StellarDexServiceError::InvalidAssetIdentifier(format!("Invalid source account: {e}"))
406 })?;
407
408 let source_spec = self.parse_asset_to_spec(¶ms.source_asset)?;
410 let send_asset = Asset::try_from(source_spec).map_err(|e| {
411 StellarDexServiceError::InvalidAssetIdentifier(format!(
412 "Failed to convert source asset: {e}",
413 ))
414 })?;
415
416 let dest_spec = self.parse_asset_to_spec(¶ms.destination_asset)?;
418 let dest_asset = Asset::try_from(dest_spec).map_err(|e| {
419 StellarDexServiceError::InvalidAssetIdentifier(format!(
420 "Failed to convert destination asset: {e}",
421 ))
422 })?;
423
424 let send_amount = i64::try_from(params.amount).map_err(|_| {
426 StellarDexServiceError::UnknownError("Amount too large for i64".to_string())
427 })?;
428
429 let out_amount = quote.out_amount as u128;
433 let slippage_bps = quote.slippage_bps as u128;
434 let basis = 10000u128;
435
436 let dest_min_u128 = out_amount
439 .checked_mul(basis.saturating_sub(slippage_bps))
440 .ok_or_else(|| {
441 StellarDexServiceError::UnknownError("Overflow calculating min amount".into())
442 })?
443 .checked_div(basis)
444 .ok_or_else(|| StellarDexServiceError::UnknownError("Division error".into()))?;
445
446 let dest_min_final = if dest_min_u128 == 0 && out_amount > 0 {
449 1 } else {
451 dest_min_u128
452 };
453
454 let dest_min = i64::try_from(dest_min_final).map_err(|_| {
455 StellarDexServiceError::UnknownError("Destination amount too large for i64".to_string())
456 })?;
457
458 let path_assets: Vec<Asset> = if let Some(quote_path) = "e.path {
460 quote_path
461 .iter()
462 .map(|p| {
463 let asset_spec =
465 if let (Some(code), Some(issuer)) = (&p.asset_code, &p.asset_issuer) {
466 if code.len() <= 4 {
467 AssetSpec::Credit4 {
468 code: code.clone(),
469 issuer: issuer.clone(),
470 }
471 } else {
472 AssetSpec::Credit12 {
473 code: code.clone(),
474 issuer: issuer.clone(),
475 }
476 }
477 } else {
478 AssetSpec::Native
479 };
480 Asset::try_from(asset_spec).map_err(|e| {
481 StellarDexServiceError::InvalidAssetIdentifier(format!(
482 "Failed to convert path step to XDR asset: {e}",
483 ))
484 })
485 })
486 .collect::<Result<Vec<_>, _>>()?
487 } else {
488 Vec::new()
489 };
490
491 let path_vecm: VecM<Asset, 5> = path_assets.try_into().map_err(|_| {
493 StellarDexServiceError::UnknownError(
494 "Failed to convert path to VecM (path too long, max 5 assets)".to_string(),
495 )
496 })?;
497
498 let path_payment_op = Operation {
500 source_account: None,
501 body: OperationBody::PathPaymentStrictSend(PathPaymentStrictSendOp {
502 send_asset,
503 send_amount, destination: source_account.clone(),
505 dest_asset,
506 dest_min, path: path_vecm, }),
509 };
510
511 let now = Utc::now();
514 let valid_until = now + ChronoDuration::minutes(TRANSACTION_VALIDITY_MINUTES);
515 let time_bounds = TimeBounds {
516 min_time: TimePoint(0), max_time: TimePoint(valid_until.timestamp() as u64),
518 };
519
520 let transaction = Transaction {
525 source_account,
526 fee: STELLAR_DEFAULT_TRANSACTION_FEE,
527 seq_num: SequenceNumber(0), cond: Preconditions::Time(time_bounds),
529 memo: Memo::None,
530 operations: vec![path_payment_op].try_into().map_err(|_| {
531 StellarDexServiceError::UnknownError(
532 "Failed to create operations vector".to_string(),
533 )
534 })?,
535 ext: TransactionExt::V0,
536 };
537
538 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
540 tx: transaction,
541 signatures: VecM::default(), });
543
544 envelope.to_xdr_base64(Limits::none()).map_err(|e| {
545 StellarDexServiceError::UnknownError(format!(
546 "Failed to serialize transaction to XDR: {e}"
547 ))
548 })
549 }
550
551 async fn sign_and_submit_transaction(
553 &self,
554 xdr: &str,
555 network_passphrase: &str,
556 ) -> Result<soroban_rs::xdr::Hash, StellarDexServiceError> {
557 let signed_response = self
559 .signer
560 .sign_xdr_transaction(xdr, network_passphrase)
561 .await
562 .map_err(|e| {
563 StellarDexServiceError::UnknownError(format!("Failed to sign transaction: {e}"))
564 })?;
565
566 debug!("Transaction signed successfully, submitting to network");
567
568 let signed_envelope =
570 TransactionEnvelope::from_xdr_base64(&signed_response.signed_xdr, Limits::none())
571 .map_err(|e| {
572 StellarDexServiceError::UnknownError(format!("Failed to parse signed XDR: {e}"))
573 })?;
574
575 self.provider
577 .send_transaction(&signed_envelope)
578 .await
579 .map_err(|e| {
580 StellarDexServiceError::UnknownError(format!("Failed to send transaction: {e}"))
581 })
582 }
583}
584
585#[async_trait]
586impl<P, S> StellarDexServiceTrait for OrderBookService<P, S>
587where
588 P: StellarProviderTrait + Send + Sync + 'static,
589 S: StellarSignTrait + Signer + Send + Sync + 'static,
590{
591 fn supported_asset_types(&self) -> std::collections::HashSet<AssetType> {
592 use std::collections::HashSet;
593 let mut types = HashSet::new();
594 types.insert(AssetType::Native);
595 types.insert(AssetType::Classic);
596 types
597 }
598
599 async fn get_token_to_xlm_quote(
600 &self,
601 asset_id: &str,
602 amount: u64,
603 slippage: f32,
604 asset_decimals: Option<u8>,
605 ) -> Result<StellarQuoteResponse, StellarDexServiceError> {
606 if asset_id == "native" || asset_id.is_empty() {
607 return Ok(StellarQuoteResponse {
608 input_asset: "native".to_string(),
609 output_asset: "native".to_string(),
610 in_amount: amount,
611 out_amount: amount,
612 price_impact_pct: 0.0,
613 slippage_bps: (slippage * SLIPPAGE_TO_BPS_MULTIPLIER) as u32,
614 path: None,
615 });
616 }
617
618 let source_spec = self.parse_asset_to_spec(asset_id)?;
620 let dest_spec = AssetSpec::Native;
621
622 let signer_address = match self.signer.address().await.map_err(|e| {
625 StellarDexServiceError::UnknownError(format!("Failed to get signer address: {e}"))
626 })? {
627 Address::Stellar(addr) => addr,
628 _ => {
629 return Err(StellarDexServiceError::UnknownError(
630 "Signer address is not a Stellar address".to_string(),
631 ))
632 }
633 };
634
635 let source_decimals = asset_decimals.unwrap_or(7);
638 let paths = self
639 .fetch_strict_send_paths(
640 &source_spec,
641 amount,
642 source_decimals,
643 &dest_spec,
644 &signer_address,
645 )
646 .await?;
647
648 if paths.is_empty() {
649 return Err(StellarDexServiceError::NoPathFound);
650 }
651
652 let best_path = &paths[0];
654
655 let out_amount = self.parse_string_amount_to_stroops(&best_path.destination_amount, 7)?;
658
659 let path_steps: Vec<super::PathStep> = best_path
661 .path
662 .iter()
663 .map(|p| super::PathStep {
664 asset_code: p.asset_code.clone(),
665 asset_issuer: p.asset_issuer.clone(),
666 amount: 0, })
668 .collect();
669
670 let price_impact_pct = 0.0;
673
674 Ok(StellarQuoteResponse {
675 input_asset: asset_id.to_string(),
676 output_asset: "native".to_string(),
677 in_amount: amount,
678 out_amount,
679 price_impact_pct,
680 slippage_bps: (slippage * SLIPPAGE_TO_BPS_MULTIPLIER) as u32,
681 path: Some(path_steps),
682 })
683 }
684
685 async fn get_xlm_to_token_quote(
686 &self,
687 asset_id: &str,
688 amount: u64,
689 slippage: f32,
690 asset_decimals: Option<u8>,
691 ) -> Result<StellarQuoteResponse, StellarDexServiceError> {
692 let decimals = asset_decimals.unwrap_or(7); if asset_id == "native" || asset_id.is_empty() {
694 debug!("Getting quote for native to native: {}", amount);
695 return Ok(StellarQuoteResponse {
696 input_asset: "native".to_string(),
697 output_asset: "native".to_string(),
698 in_amount: amount,
699 out_amount: amount,
700 price_impact_pct: 0.0,
701 slippage_bps: (slippage * SLIPPAGE_TO_BPS_MULTIPLIER) as u32,
702 path: None,
703 });
704 }
705
706 let dest_spec = self.parse_asset_to_spec(asset_id)?;
708 let source_spec = AssetSpec::Native;
709
710 let signer_address = match self.signer.address().await.map_err(|e| {
713 StellarDexServiceError::UnknownError(format!("Failed to get signer address: {e}"))
714 })? {
715 Address::Stellar(addr) => addr,
716 _ => {
717 return Err(StellarDexServiceError::UnknownError(
718 "Signer address is not a Stellar address".to_string(),
719 ))
720 }
721 };
722
723 let paths = self
727 .fetch_strict_send_paths(&source_spec, amount, 7, &dest_spec, &signer_address)
728 .await?;
729
730 if paths.is_empty() {
731 return Err(StellarDexServiceError::NoPathFound);
732 }
733
734 let best_path = &paths[0];
736
737 let out_amount =
741 self.parse_string_amount_to_stroops(&best_path.destination_amount, decimals)?;
742
743 let path_steps: Vec<super::PathStep> = best_path
745 .path
746 .iter()
747 .map(|p| super::PathStep {
748 asset_code: p.asset_code.clone(),
749 asset_issuer: p.asset_issuer.clone(),
750 amount: 0, })
752 .collect();
753
754 let price_impact_pct = 0.0;
757
758 Ok(StellarQuoteResponse {
759 input_asset: "native".to_string(),
760 output_asset: asset_id.to_string(),
761 in_amount: amount, out_amount, price_impact_pct,
764 slippage_bps: (slippage * SLIPPAGE_TO_BPS_MULTIPLIER) as u32,
765 path: Some(path_steps),
766 })
767 }
768
769 async fn prepare_swap_transaction(
770 &self,
771 params: SwapTransactionParams,
772 ) -> Result<(String, StellarQuoteResponse), StellarDexServiceError> {
773 self.validate_swap_params(¶ms)?;
775
776 let quote = self.get_swap_quote(¶ms).await?;
778
779 info!(
780 "Preparing swap transaction: {} {} -> {} XLM (min receive: {})",
781 params.amount, params.source_asset, quote.out_amount, quote.out_amount
782 );
783
784 let xdr = self.build_swap_transaction_xdr(¶ms, "e).await?;
786
787 info!(
788 "Successfully prepared swap transaction XDR ({} bytes)",
789 xdr.len()
790 );
791
792 Ok((xdr, quote))
793 }
794
795 async fn execute_swap(
796 &self,
797 params: SwapTransactionParams,
798 ) -> Result<SwapExecutionResult, StellarDexServiceError> {
799 let (xdr, quote) = self.prepare_swap_transaction(params.clone()).await?;
806
807 info!(
808 "Signing and submitting swap transaction XDR ({} bytes)",
809 xdr.len()
810 );
811
812 let tx_hash = self
814 .sign_and_submit_transaction(&xdr, ¶ms.network_passphrase)
815 .await?;
816
817 let transaction_hash = hex::encode(tx_hash.0);
818
819 info!(
820 "Swap transaction submitted successfully with hash: {}, destination amount: {}",
821 transaction_hash, quote.out_amount
822 );
823
824 Ok(SwapExecutionResult {
825 transaction_hash,
826 destination_amount: quote.out_amount,
827 })
828 }
829}
830
831#[cfg(test)]
832mod tests {
833 use super::*;
834 use crate::models::SignerError;
835 use crate::services::provider::MockStellarProviderTrait;
836 use crate::services::signer::{MockStellarSignTrait, Signer};
837 use async_trait::async_trait;
838 use std::sync::Arc;
839
840 struct MockCombinedSigner {
842 stellar_mock: MockStellarSignTrait,
843 }
844
845 impl MockCombinedSigner {
846 fn new() -> Self {
847 Self {
848 stellar_mock: MockStellarSignTrait::new(),
849 }
850 }
851 }
852
853 #[async_trait]
854 impl StellarSignTrait for MockCombinedSigner {
855 async fn sign_xdr_transaction(
856 &self,
857 unsigned_xdr: &str,
858 network_passphrase: &str,
859 ) -> Result<crate::domain::relayer::SignXdrTransactionResponseStellar, SignerError>
860 {
861 self.stellar_mock
862 .sign_xdr_transaction(unsigned_xdr, network_passphrase)
863 .await
864 }
865 }
866
867 #[async_trait]
868 impl Signer for MockCombinedSigner {
869 async fn address(&self) -> Result<crate::models::Address, SignerError> {
870 Ok(crate::models::Address::Stellar(
871 "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
872 ))
873 }
874
875 async fn sign_transaction(
876 &self,
877 _transaction: crate::models::NetworkTransactionData,
878 ) -> Result<crate::domain::SignTransactionResponse, SignerError> {
879 Ok(crate::domain::SignTransactionResponse::Stellar(
880 crate::domain::SignTransactionResponseStellar {
881 signature: crate::models::DecoratedSignature {
882 hint: soroban_rs::xdr::SignatureHint([0; 4]),
883 signature: soroban_rs::xdr::Signature(
884 soroban_rs::xdr::BytesM::try_from(vec![0u8; 64]).unwrap(),
885 ),
886 },
887 },
888 ))
889 }
890 }
891
892 fn create_test_service() -> (
894 OrderBookService<MockStellarProviderTrait, MockCombinedSigner>,
895 Arc<MockStellarProviderTrait>,
896 Arc<MockCombinedSigner>,
897 ) {
898 let provider = Arc::new(MockStellarProviderTrait::new());
899 let signer = Arc::new(MockCombinedSigner::new());
900
901 let service = OrderBookService::new(
902 "https://horizon-testnet.stellar.org".to_string(),
903 provider.clone(),
904 signer.clone(),
905 )
906 .expect("Failed to create OrderBookService");
907
908 (service, provider, signer)
909 }
910
911 #[test]
912 fn test_parse_asset_to_spec_native() {
913 let (service, _, _) = create_test_service();
914
915 let result = service.parse_asset_to_spec("native");
916 assert!(result.is_ok());
917 assert!(matches!(result.unwrap(), AssetSpec::Native));
918
919 let result = service.parse_asset_to_spec("");
920 assert!(result.is_ok());
921 assert!(matches!(result.unwrap(), AssetSpec::Native));
922 }
923
924 #[test]
925 fn test_parse_asset_to_spec_credit4() {
926 let (service, _, _) = create_test_service();
927
928 let result = service
929 .parse_asset_to_spec("USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5");
930 assert!(result.is_ok());
931
932 match result.unwrap() {
933 AssetSpec::Credit4 { code, issuer } => {
934 assert_eq!(code, "USDC");
935 assert_eq!(
936 issuer,
937 "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
938 );
939 }
940 _ => panic!("Expected Credit4"),
941 }
942 }
943
944 #[test]
945 fn test_parse_asset_to_spec_credit12() {
946 let (service, _, _) = create_test_service();
947
948 let result = service.parse_asset_to_spec(
949 "LONGASSETCD:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
950 );
951 assert!(result.is_ok());
952
953 match result.unwrap() {
954 AssetSpec::Credit12 { code, issuer } => {
955 assert_eq!(code, "LONGASSETCD");
956 assert_eq!(
957 issuer,
958 "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
959 );
960 }
961 _ => panic!("Expected Credit12"),
962 }
963 }
964
965 #[test]
966 fn test_parse_asset_to_spec_invalid() {
967 let (service, _, _) = create_test_service();
968
969 let result = service
971 .parse_asset_to_spec("USDCGBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5");
972 assert!(result.is_err());
973
974 let result = service
976 .parse_asset_to_spec(":GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5");
977 assert!(result.is_err());
978
979 let result = service.parse_asset_to_spec("USDC:");
981 assert!(result.is_err());
982
983 let result = service.parse_asset_to_spec(
985 "VERYLONGASSETCODE:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
986 );
987 assert!(result.is_err());
988 }
989
990 #[test]
991 fn test_to_decimal_string() {
992 let (service, _, _) = create_test_service();
993
994 assert_eq!(service.to_decimal_string(10000000, 7), "1.0000000");
996 assert_eq!(service.to_decimal_string(12345678, 7), "1.2345678");
997 assert_eq!(service.to_decimal_string(100, 7), "0.0000100");
998 assert_eq!(service.to_decimal_string(1, 7), "0.0000001");
999
1000 assert_eq!(service.to_decimal_string(1000, 3), "1.000");
1002 assert_eq!(service.to_decimal_string(123456, 6), "0.123456");
1003
1004 assert_eq!(service.to_decimal_string(0, 7), "0.0000000");
1006 }
1007
1008 #[test]
1009 fn test_parse_string_amount_to_stroops() {
1010 let (service, _, _) = create_test_service();
1011
1012 assert_eq!(
1014 service
1015 .parse_string_amount_to_stroops("1.0000000", 7)
1016 .unwrap(),
1017 10000000
1018 );
1019 assert_eq!(
1020 service
1021 .parse_string_amount_to_stroops("1.2345678", 7)
1022 .unwrap(),
1023 12345678
1024 );
1025 assert_eq!(
1026 service
1027 .parse_string_amount_to_stroops("0.0000100", 7)
1028 .unwrap(),
1029 100
1030 );
1031 assert_eq!(
1032 service
1033 .parse_string_amount_to_stroops("0.0000001", 7)
1034 .unwrap(),
1035 1
1036 );
1037
1038 assert_eq!(
1040 service.parse_string_amount_to_stroops("100", 7).unwrap(),
1041 1000000000
1042 );
1043
1044 assert_eq!(
1046 service.parse_string_amount_to_stroops("100.", 7).unwrap(),
1047 1000000000
1048 );
1049
1050 assert_eq!(
1052 service.parse_string_amount_to_stroops("1.12", 7).unwrap(),
1053 11200000
1054 );
1055
1056 assert_eq!(
1058 service
1059 .parse_string_amount_to_stroops("1.12345678", 7)
1060 .unwrap(),
1061 11234567
1062 );
1063
1064 assert_eq!(
1066 service.parse_string_amount_to_stroops("1.234", 3).unwrap(),
1067 1234
1068 );
1069 assert_eq!(
1070 service.parse_string_amount_to_stroops("0.5", 6).unwrap(),
1071 500000
1072 );
1073 }
1074
1075 #[test]
1076 fn test_parse_string_amount_to_stroops_invalid() {
1077 let (service, _, _) = create_test_service();
1078
1079 assert!(service.parse_string_amount_to_stroops("abc", 7).is_err());
1081 assert!(service.parse_string_amount_to_stroops("1.2.3", 7).is_err());
1082 assert!(service.parse_string_amount_to_stroops("", 7).is_err());
1083 }
1084
1085 #[test]
1086 fn test_parse_string_amount_to_stroops_overflow() {
1087 let (service, _, _) = create_test_service();
1088
1089 let huge_value = format!("{}.0", u64::MAX as u128 + 1);
1091 assert!(service
1092 .parse_string_amount_to_stroops(&huge_value, 7)
1093 .is_err());
1094 }
1095
1096 #[test]
1097 fn test_validate_swap_params_valid() {
1098 let (service, _, _) = create_test_service();
1099
1100 let params = SwapTransactionParams {
1101 source_account: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1102 source_asset: "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
1103 .to_string(),
1104 destination_asset: "native".to_string(),
1105 amount: 1000000,
1106 slippage_percent: 1.0,
1107 network_passphrase: "Test SDF Network ; September 2015".to_string(),
1108 source_asset_decimals: Some(7),
1109 destination_asset_decimals: Some(7),
1110 };
1111
1112 assert!(service.validate_swap_params(¶ms).is_ok());
1113 }
1114
1115 #[test]
1116 fn test_validate_swap_params_native_source() {
1117 let (service, _, _) = create_test_service();
1118
1119 let params = SwapTransactionParams {
1120 source_account: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1121 source_asset: "native".to_string(),
1122 destination_asset: "native".to_string(),
1123 amount: 1000000,
1124 slippage_percent: 1.0,
1125 network_passphrase: "Test SDF Network ; September 2015".to_string(),
1126 source_asset_decimals: Some(7),
1127 destination_asset_decimals: Some(7),
1128 };
1129
1130 let result = service.validate_swap_params(¶ms);
1131 assert!(result.is_err());
1132 assert!(matches!(
1133 result.unwrap_err(),
1134 StellarDexServiceError::InvalidAssetIdentifier(_)
1135 ));
1136 }
1137
1138 #[test]
1139 fn test_validate_swap_params_zero_amount() {
1140 let (service, _, _) = create_test_service();
1141
1142 let params = SwapTransactionParams {
1143 source_account: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1144 source_asset: "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
1145 .to_string(),
1146 destination_asset: "native".to_string(),
1147 amount: 0,
1148 slippage_percent: 1.0,
1149 network_passphrase: "Test SDF Network ; September 2015".to_string(),
1150 source_asset_decimals: Some(7),
1151 destination_asset_decimals: Some(7),
1152 };
1153
1154 let result = service.validate_swap_params(¶ms);
1155 assert!(result.is_err());
1156 assert!(matches!(
1157 result.unwrap_err(),
1158 StellarDexServiceError::InvalidAssetIdentifier(_)
1159 ));
1160 }
1161
1162 #[test]
1163 fn test_validate_swap_params_invalid_slippage() {
1164 let (service, _, _) = create_test_service();
1165
1166 let mut params = SwapTransactionParams {
1168 source_account: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1169 source_asset: "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
1170 .to_string(),
1171 destination_asset: "native".to_string(),
1172 amount: 1000000,
1173 slippage_percent: -1.0,
1174 network_passphrase: "Test SDF Network ; September 2015".to_string(),
1175 source_asset_decimals: Some(7),
1176 destination_asset_decimals: Some(7),
1177 };
1178
1179 assert!(service.validate_swap_params(¶ms).is_err());
1180
1181 params.slippage_percent = 101.0;
1183 assert!(service.validate_swap_params(¶ms).is_err());
1184 }
1185
1186 #[test]
1187 fn test_validate_swap_params_non_native_destination() {
1188 let (service, _, _) = create_test_service();
1189
1190 let params = SwapTransactionParams {
1191 source_account: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1192 source_asset: "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
1193 .to_string(),
1194 destination_asset: "EUROC:GXXXXXXXXXXXXXX".to_string(),
1195 amount: 1000000,
1196 slippage_percent: 1.0,
1197 network_passphrase: "Test SDF Network ; September 2015".to_string(),
1198 source_asset_decimals: Some(7),
1199 destination_asset_decimals: Some(7),
1200 };
1201
1202 let result = service.validate_swap_params(¶ms);
1203 assert!(result.is_err());
1204 }
1205
1206 #[tokio::test]
1207 async fn test_get_token_to_xlm_quote_native_to_native() {
1208 let (service, _, _) = create_test_service();
1209
1210 let result = service
1211 .get_token_to_xlm_quote("native", 10000000, 1.0, Some(7))
1212 .await;
1213
1214 assert!(result.is_ok());
1215 let quote = result.unwrap();
1216 assert_eq!(quote.input_asset, "native");
1217 assert_eq!(quote.output_asset, "native");
1218 assert_eq!(quote.in_amount, 10000000);
1219 assert_eq!(quote.out_amount, 10000000);
1220 assert_eq!(quote.price_impact_pct, 0.0);
1221 assert_eq!(quote.slippage_bps, 100);
1222 assert!(quote.path.is_none());
1223 }
1224
1225 #[tokio::test]
1226 async fn test_get_xlm_to_token_quote_native_to_native() {
1227 let (service, _, _) = create_test_service();
1228
1229 let result = service
1230 .get_xlm_to_token_quote("native", 10000000, 1.0, Some(7))
1231 .await;
1232
1233 assert!(result.is_ok());
1234 let quote = result.unwrap();
1235 assert_eq!(quote.input_asset, "native");
1236 assert_eq!(quote.output_asset, "native");
1237 assert_eq!(quote.in_amount, 10000000);
1238 assert_eq!(quote.out_amount, 10000000);
1239 assert_eq!(quote.price_impact_pct, 0.0);
1240 assert_eq!(quote.slippage_bps, 100);
1241 assert!(quote.path.is_none());
1242 }
1243
1244 #[test]
1245 fn test_supported_asset_types() {
1246 let (service, _, _) = create_test_service();
1247
1248 let types = service.supported_asset_types();
1249 assert_eq!(types.len(), 2);
1250 assert!(types.contains(&AssetType::Native));
1251 assert!(types.contains(&AssetType::Classic));
1252 }
1253
1254 #[test]
1257 fn test_swap_params_validation_comprehensive() {
1258 let (service, _, _) = create_test_service();
1259
1260 let params = SwapTransactionParams {
1262 source_account: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1263 source_asset: "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
1264 .to_string(),
1265 destination_asset: "native".to_string(),
1266 amount: 100000000,
1267 slippage_percent: 1.0,
1268 network_passphrase: "Test SDF Network ; September 2015".to_string(),
1269 source_asset_decimals: Some(7),
1270 destination_asset_decimals: Some(7),
1271 };
1272
1273 let validation = service.validate_swap_params(¶ms);
1274 assert!(validation.is_ok());
1275 }
1276
1277 #[test]
1278 fn test_parse_asset_to_spec_edge_cases() {
1279 let (_service, _, _) = create_test_service();
1280
1281 let result = _service
1283 .parse_asset_to_spec("ABCD:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5");
1284 assert!(result.is_ok());
1285 match result.unwrap() {
1286 AssetSpec::Credit4 { code, .. } => assert_eq!(code, "ABCD"),
1287 _ => panic!("Expected Credit4"),
1288 }
1289
1290 let result = _service.parse_asset_to_spec(
1292 "ABCDEFGHIJKL:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1293 );
1294 assert!(result.is_ok());
1295 match result.unwrap() {
1296 AssetSpec::Credit12 { code, .. } => assert_eq!(code, "ABCDEFGHIJKL"),
1297 _ => panic!("Expected Credit12"),
1298 }
1299
1300 let result = _service
1302 .parse_asset_to_spec("ABCDE:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5");
1303 assert!(result.is_ok());
1304 match result.unwrap() {
1305 AssetSpec::Credit12 { code, .. } => assert_eq!(code, "ABCDE"),
1306 _ => panic!("Expected Credit12"),
1307 }
1308 }
1309
1310 #[test]
1311 fn test_to_decimal_string_edge_cases() {
1312 let (_service, _, _) = create_test_service();
1313
1314 assert_eq!(_service.to_decimal_string(123, 0), "123.");
1316
1317 assert_eq!(
1319 _service.to_decimal_string(u64::MAX, 7),
1320 "1844674407370.9551615"
1321 );
1322
1323 assert_eq!(_service.to_decimal_string(123, 1), "12.3");
1325
1326 assert_eq!(_service.to_decimal_string(12345, 2), "123.45");
1328 }
1329
1330 #[test]
1331 fn test_parse_string_amount_edge_cases() {
1332 let (_service, _, _) = create_test_service();
1333
1334 assert_eq!(
1336 _service.parse_string_amount_to_stroops("100", 7).unwrap(),
1337 1000000000
1338 );
1339
1340 assert_eq!(
1342 _service.parse_string_amount_to_stroops("100.", 7).unwrap(),
1343 1000000000
1344 );
1345
1346 assert_eq!(
1348 _service.parse_string_amount_to_stroops("0.1", 7).unwrap(),
1349 1000000
1350 );
1351
1352 assert_eq!(
1354 _service
1355 .parse_string_amount_to_stroops("1.123456789", 7)
1356 .unwrap(),
1357 11234567
1358 );
1359
1360 assert_eq!(
1362 _service.parse_string_amount_to_stroops("1.12", 7).unwrap(),
1363 11200000
1364 );
1365 }
1366
1367 #[test]
1375 fn test_slippage_to_bps_conversion() {
1376 let (_service, _, _) = create_test_service();
1377
1378 let params = SwapTransactionParams {
1380 source_account: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1381 source_asset: "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
1382 .to_string(),
1383 destination_asset: "native".to_string(),
1384 amount: 1000000,
1385 slippage_percent: 0.5,
1386 network_passphrase: "Test SDF Network ; September 2015".to_string(),
1387 source_asset_decimals: Some(7),
1388 destination_asset_decimals: Some(7),
1389 };
1390
1391 let expected_bps = (params.slippage_percent * SLIPPAGE_TO_BPS_MULTIPLIER) as u32;
1393 assert_eq!(expected_bps, 50);
1394 }
1395
1396 #[test]
1397 fn test_min_amount_calculation() {
1398 let out_amount = 1000000u128; let slippage_bps = 100u128; let basis = 10000u128;
1404
1405 let min_amount = out_amount * (basis - slippage_bps) / basis;
1406 assert_eq!(min_amount, 990000); let slippage_bps = 50u128;
1410 let min_amount = out_amount * (basis - slippage_bps) / basis;
1411 assert_eq!(min_amount, 995000); let slippage_bps = 10000u128;
1415 let min_amount = out_amount * (basis - slippage_bps) / basis;
1416 assert_eq!(min_amount, 0);
1417 }
1418
1419 #[test]
1420 fn test_parse_string_amount_roundtrip() {
1421 let (service, _, _) = create_test_service();
1422
1423 let test_amounts = vec![10000000u64, 12345678, 100, 1, 9999999999];
1425
1426 for amount in test_amounts {
1427 let decimal_str = service.to_decimal_string(amount, 7);
1428 let parsed = service
1429 .parse_string_amount_to_stroops(&decimal_str, 7)
1430 .unwrap();
1431 assert_eq!(parsed, amount, "Roundtrip failed for amount {}", amount);
1432 }
1433 }
1434
1435 #[test]
1436 fn test_asset_spec_conversion_roundtrip() {
1437 let (service, _, _) = create_test_service();
1438
1439 let test_cases = vec![
1440 "native",
1441 "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1442 "BTC:GCNSGHUCG5VMGLT5RIYYZSO7VQULQKAJ62QA33DBC5PPBSO57LFWVV6P",
1443 "LONGASSETCD:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1444 ];
1445
1446 for asset_id in test_cases {
1447 let spec = service.parse_asset_to_spec(asset_id).unwrap();
1448
1449 let xdr_result = Asset::try_from(spec);
1451 assert!(xdr_result.is_ok(), "Failed to convert {} to XDR", asset_id);
1452 }
1453 }
1454
1455 #[test]
1456 fn test_transaction_constants() {
1457 assert_eq!(TRANSACTION_VALIDITY_MINUTES, 5);
1459 assert_eq!(HTTP_REQUEST_TIMEOUT_SECONDS, 7);
1460 assert_eq!(SLIPPAGE_TO_BPS_MULTIPLIER, 100.0);
1461 }
1462
1463 #[tokio::test]
1466 async fn test_get_token_to_xlm_quote_with_http_mock() {
1467 let mut mock_server = mockito::Server::new_async().await;
1468
1469 let mock = mock_server
1471 .mock(
1472 "GET",
1473 mockito::Matcher::Regex(r".*/paths/strict-send.*".to_string()),
1474 )
1475 .with_status(200)
1476 .with_header("content-type", "application/json")
1477 .with_body(
1478 r#"{
1479 "_embedded": {
1480 "records": [
1481 {
1482 "source_amount": "10.0000000",
1483 "destination_amount": "9.8500000",
1484 "path": []
1485 }
1486 ]
1487 }
1488 }"#,
1489 )
1490 .create_async()
1491 .await;
1492
1493 let provider = Arc::new(MockStellarProviderTrait::new());
1494 let signer = Arc::new(MockCombinedSigner::new());
1495
1496 let service = OrderBookService::new(mock_server.url(), provider, signer)
1497 .expect("Failed to create OrderBookService");
1498
1499 let result = service
1500 .get_token_to_xlm_quote(
1501 "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1502 100000000,
1503 1.0,
1504 Some(7),
1505 )
1506 .await;
1507
1508 mock.assert_async().await;
1509 assert!(result.is_ok());
1510 let quote = result.unwrap();
1511 assert_eq!(quote.in_amount, 100000000);
1512 assert_eq!(quote.out_amount, 98500000);
1513 assert_eq!(quote.slippage_bps, 100);
1514 }
1515
1516 #[tokio::test]
1517 async fn test_get_token_to_xlm_quote_http_error_404() {
1518 let mut mock_server = mockito::Server::new_async().await;
1519
1520 let mock = mock_server
1521 .mock(
1522 "GET",
1523 mockito::Matcher::Regex(r".*/paths/strict-send.*".to_string()),
1524 )
1525 .with_status(404)
1526 .with_body("Not found")
1527 .create_async()
1528 .await;
1529
1530 let provider = Arc::new(MockStellarProviderTrait::new());
1531 let signer = Arc::new(MockCombinedSigner::new());
1532
1533 let service = OrderBookService::new(mock_server.url(), provider, signer)
1534 .expect("Failed to create OrderBookService");
1535
1536 let result = service
1537 .get_token_to_xlm_quote(
1538 "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1539 100000000,
1540 1.0,
1541 Some(7),
1542 )
1543 .await;
1544
1545 mock.assert_async().await;
1546 assert!(result.is_err());
1547 }
1548
1549 #[tokio::test]
1550 async fn test_get_token_to_xlm_quote_http_error_500() {
1551 let mut mock_server = mockito::Server::new_async().await;
1552
1553 let mock = mock_server
1554 .mock(
1555 "GET",
1556 mockito::Matcher::Regex(r".*/paths/strict-send.*".to_string()),
1557 )
1558 .with_status(500)
1559 .with_body("Internal server error")
1560 .create_async()
1561 .await;
1562
1563 let provider = Arc::new(MockStellarProviderTrait::new());
1564 let signer = Arc::new(MockCombinedSigner::new());
1565
1566 let service = OrderBookService::new(mock_server.url(), provider, signer)
1567 .expect("Failed to create OrderBookService");
1568
1569 let result = service
1570 .get_token_to_xlm_quote(
1571 "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1572 100000000,
1573 1.0,
1574 Some(7),
1575 )
1576 .await;
1577
1578 mock.assert_async().await;
1579 assert!(result.is_err());
1580 }
1581
1582 #[tokio::test]
1583 async fn test_get_token_to_xlm_quote_no_paths_found() {
1584 let mut mock_server = mockito::Server::new_async().await;
1585
1586 let mock = mock_server
1587 .mock(
1588 "GET",
1589 mockito::Matcher::Regex(r".*/paths/strict-send.*".to_string()),
1590 )
1591 .with_status(200)
1592 .with_header("content-type", "application/json")
1593 .with_body(
1594 r#"{
1595 "_embedded": {
1596 "records": []
1597 }
1598 }"#,
1599 )
1600 .create_async()
1601 .await;
1602
1603 let provider = Arc::new(MockStellarProviderTrait::new());
1604 let signer = Arc::new(MockCombinedSigner::new());
1605
1606 let service = OrderBookService::new(mock_server.url(), provider, signer)
1607 .expect("Failed to create OrderBookService");
1608
1609 let result = service
1610 .get_token_to_xlm_quote(
1611 "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1612 100000000,
1613 1.0,
1614 Some(7),
1615 )
1616 .await;
1617
1618 mock.assert_async().await;
1619 assert!(result.is_err());
1620 assert!(matches!(
1621 result.unwrap_err(),
1622 StellarDexServiceError::NoPathFound
1623 ));
1624 }
1625
1626 #[tokio::test]
1627 async fn test_get_token_to_xlm_quote_invalid_json() {
1628 let mut mock_server = mockito::Server::new_async().await;
1629
1630 let mock = mock_server
1631 .mock(
1632 "GET",
1633 mockito::Matcher::Regex(r".*/paths/strict-send.*".to_string()),
1634 )
1635 .with_status(200)
1636 .with_header("content-type", "application/json")
1637 .with_body("invalid json")
1638 .create_async()
1639 .await;
1640
1641 let provider = Arc::new(MockStellarProviderTrait::new());
1642 let signer = Arc::new(MockCombinedSigner::new());
1643
1644 let service = OrderBookService::new(mock_server.url(), provider, signer)
1645 .expect("Failed to create OrderBookService");
1646
1647 let result = service
1648 .get_token_to_xlm_quote(
1649 "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1650 100000000,
1651 1.0,
1652 Some(7),
1653 )
1654 .await;
1655
1656 mock.assert_async().await;
1657 assert!(result.is_err());
1658 }
1659
1660 #[tokio::test]
1661 async fn test_get_token_to_xlm_quote_with_multi_hop_path() {
1662 let mut mock_server = mockito::Server::new_async().await;
1663
1664 let mock = mock_server
1665 .mock("GET", mockito::Matcher::Regex(r".*/paths/strict-send.*".to_string()))
1666 .with_status(200)
1667 .with_header("content-type", "application/json")
1668 .with_body(r#"{
1669 "_embedded": {
1670 "records": [
1671 {
1672 "source_amount": "10.0000000",
1673 "destination_amount": "9.9000000",
1674 "path": [
1675 {
1676 "asset_code": "USDC",
1677 "asset_issuer": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
1678 },
1679 {
1680 "asset_code": "BTC",
1681 "asset_issuer": "GCNSGHUCG5VMGLT5RIYYZSO7VQULQKAJ62QA33DBC5PPBSO57LFWVV6P"
1682 }
1683 ]
1684 }
1685 ]
1686 }
1687 }"#)
1688 .create_async()
1689 .await;
1690
1691 let provider = Arc::new(MockStellarProviderTrait::new());
1692 let signer = Arc::new(MockCombinedSigner::new());
1693
1694 let service = OrderBookService::new(mock_server.url(), provider, signer)
1695 .expect("Failed to create OrderBookService");
1696
1697 let result = service
1698 .get_token_to_xlm_quote(
1699 "EUROC:GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2",
1700 100000000,
1701 1.0,
1702 Some(7),
1703 )
1704 .await;
1705
1706 mock.assert_async().await;
1707 assert!(result.is_ok());
1708 let quote = result.unwrap();
1709 assert!(quote.path.is_some());
1710 let path = quote.path.unwrap();
1711 assert_eq!(path.len(), 2);
1712 assert_eq!(path[0].asset_code, Some("USDC".to_string()));
1713 assert_eq!(
1714 path[0].asset_issuer,
1715 Some("GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5".to_string())
1716 );
1717 assert_eq!(path[1].asset_code, Some("BTC".to_string()));
1718 assert_eq!(
1719 path[1].asset_issuer,
1720 Some("GCNSGHUCG5VMGLT5RIYYZSO7VQULQKAJ62QA33DBC5PPBSO57LFWVV6P".to_string())
1721 );
1722 }
1723
1724 #[tokio::test]
1725 async fn test_get_xlm_to_token_quote_with_http_mock() {
1726 let mut mock_server = mockito::Server::new_async().await;
1727
1728 let mock = mock_server
1729 .mock(
1730 "GET",
1731 mockito::Matcher::Regex(r".*/paths/strict-send.*".to_string()),
1732 )
1733 .with_status(200)
1734 .with_header("content-type", "application/json")
1735 .with_body(
1736 r#"{
1737 "_embedded": {
1738 "records": [
1739 {
1740 "source_amount": "1.0000000",
1741 "destination_amount": "10.5000000",
1742 "path": []
1743 }
1744 ]
1745 }
1746 }"#,
1747 )
1748 .create_async()
1749 .await;
1750
1751 let provider = Arc::new(MockStellarProviderTrait::new());
1752 let signer = Arc::new(MockCombinedSigner::new());
1753
1754 let service = OrderBookService::new(mock_server.url(), provider, signer)
1755 .expect("Failed to create OrderBookService");
1756
1757 let result = service
1758 .get_xlm_to_token_quote(
1759 "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1760 10000000,
1761 1.0,
1762 Some(7),
1763 )
1764 .await;
1765
1766 mock.assert_async().await;
1767 assert!(result.is_ok());
1768 let quote = result.unwrap();
1769 assert_eq!(quote.input_asset, "native");
1770 assert_eq!(quote.in_amount, 10000000);
1771 assert_eq!(quote.out_amount, 105000000);
1772 }
1773
1774 #[tokio::test]
1775 async fn test_get_token_to_xlm_quote_with_different_decimals() {
1776 let mut mock_server = mockito::Server::new_async().await;
1777
1778 let mock = mock_server
1779 .mock(
1780 "GET",
1781 mockito::Matcher::Regex(r".*/paths/strict-send.*".to_string()),
1782 )
1783 .with_status(200)
1784 .with_header("content-type", "application/json")
1785 .with_body(
1786 r#"{
1787 "_embedded": {
1788 "records": [
1789 {
1790 "source_amount": "100.000000",
1791 "destination_amount": "98.500000",
1792 "path": []
1793 }
1794 ]
1795 }
1796 }"#,
1797 )
1798 .create_async()
1799 .await;
1800
1801 let provider = Arc::new(MockStellarProviderTrait::new());
1802 let signer = Arc::new(MockCombinedSigner::new());
1803
1804 let service = OrderBookService::new(mock_server.url(), provider, signer)
1805 .expect("Failed to create OrderBookService");
1806
1807 let result = service
1809 .get_token_to_xlm_quote(
1810 "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
1811 100000000,
1812 1.0,
1813 Some(6),
1814 )
1815 .await;
1816
1817 mock.assert_async().await;
1818 assert!(result.is_ok());
1819 }
1820
1821 #[tokio::test]
1822 async fn test_get_swap_quote_token_to_xlm() {
1823 let mut mock_server = mockito::Server::new_async().await;
1824
1825 let mock = mock_server
1826 .mock(
1827 "GET",
1828 mockito::Matcher::Regex(r".*/paths/strict-send.*".to_string()),
1829 )
1830 .with_status(200)
1831 .with_header("content-type", "application/json")
1832 .with_body(
1833 r#"{
1834 "_embedded": {
1835 "records": [
1836 {
1837 "source_amount": "10.0000000",
1838 "destination_amount": "9.8000000",
1839 "path": []
1840 }
1841 ]
1842 }
1843 }"#,
1844 )
1845 .create_async()
1846 .await;
1847
1848 let provider = Arc::new(MockStellarProviderTrait::new());
1849 let signer = Arc::new(MockCombinedSigner::new());
1850
1851 let service = OrderBookService::new(mock_server.url(), provider, signer)
1852 .expect("Failed to create OrderBookService");
1853
1854 let params = SwapTransactionParams {
1855 source_account: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".to_string(),
1856 source_asset: "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
1857 .to_string(),
1858 destination_asset: "native".to_string(),
1859 amount: 100000000,
1860 slippage_percent: 1.0,
1861 network_passphrase: "Test SDF Network ; September 2015".to_string(),
1862 source_asset_decimals: Some(7),
1863 destination_asset_decimals: Some(7),
1864 };
1865
1866 let result = service.get_swap_quote(¶ms).await;
1867
1868 mock.assert_async().await;
1869 assert!(result.is_ok());
1870 let quote = result.unwrap();
1871 assert_eq!(quote.in_amount, 100000000);
1872 assert_eq!(quote.out_amount, 98000000);
1873 }
1874
1875 #[test]
1876 fn test_slippage_bps_calculation() {
1877 let (_service, _, _) = create_test_service();
1878
1879 let test_cases = vec![
1881 (0.0, 0),
1882 (0.5, 50),
1883 (1.0, 100),
1884 (2.5, 250),
1885 (5.0, 500),
1886 (10.0, 1000),
1887 ];
1888
1889 for (slippage_percent, expected_bps) in test_cases {
1890 let bps = (slippage_percent * SLIPPAGE_TO_BPS_MULTIPLIER) as u32;
1891 assert_eq!(
1892 bps, expected_bps,
1893 "Failed for slippage {}%",
1894 slippage_percent
1895 );
1896 }
1897 }
1898}