openzeppelin_relayer/services/stellar_dex/
order_book_service.rs

1//! Stellar DEX Service implementation
2//! Uses Stellar Horizon API `/paths/strict-send` for ALL swaps (Sell Logic).
3//! This unifies the Transaction Builder logic and guarantees input amounts.
4//! Provides better rates by considering liquidity pools (AMMs) and multi-hop paths
5
6use 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
32/// Transaction validity window in minutes
33const TRANSACTION_VALIDITY_MINUTES: i64 = 5;
34
35/// HTTP request timeout in seconds
36const HTTP_REQUEST_TIMEOUT_SECONDS: u64 = 7;
37
38/// Slippage percentage multiplier for basis points conversion
39const SLIPPAGE_TO_BPS_MULTIPLIER: f32 = 100.0;
40
41/// Stellar Horizon API path finding response
42#[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
69/// Service for getting quotes from Stellar Horizon Order Book API
70pub 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    /// Create a new OrderBookService instance
87    ///
88    /// # Arguments
89    ///
90    /// * `horizon_base_url` - Base URL for Stellar Horizon API (e.g., "https://horizon.stellar.org")
91    /// * `provider` - Stellar provider for sending transactions
92    /// * `signer` - Stellar signer for signing transactions
93    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    /// Parse asset identifier to AssetSpec for XDR conversion
112    /// Handles both native and non-native assets
113    ///
114    /// # Arguments
115    ///
116    /// * `asset_id` - Asset identifier (e.g., "native", "USDC:GA5Z...")
117    ///
118    /// # Returns
119    ///
120    /// AssetSpec that can be converted to XDR Asset
121    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    /// Helper to convert stroops to decimal string
159    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    /// Helper to safely parse decimal string to stroops without f64
172    /// e.g., "123.4567890" (decimals 7) -> 1234567890
173    /// This avoids IEEE 754 floating-point precision errors
174    /// Handles edge cases: "100." (trailing decimal), "100" (no decimal)
175    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        // Validate: at most one decimal point
183        if parts.len() > 2 {
184            return Err(StellarDexServiceError::UnknownError(format!(
185                "Invalid amount string: multiple decimal points: {amount_str}"
186            )));
187        }
188
189        // Parse integer part into u128 for safe arithmetic
190        let int_part = parts[0].parse::<u128>().map_err(|_| {
191            StellarDexServiceError::UnknownError(format!("Invalid amount string: {amount_str}"))
192        })?;
193
194        // Compute multiplier as u128 to avoid overflow
195        let multiplier = 10u128.pow(decimals as u32);
196
197        // Calculate integer part contribution in stroops (u128)
198        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        // Check if we have a decimal part AND it's not empty (handles "100." case)
205        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                // Truncate if too many decimals (e.g. 1.12345678 -> 7 decimals -> 1.1234567)
216                // Compute divisor as u128
217                let divisor = 10u128.pow(frac_len - target_decimals);
218                frac_parsed / divisor
219            } else {
220                // Pad if fewer decimals (e.g. 1.12 -> 7 decimals -> 1.1200000)
221                // Compute padding multiplier as u128
222                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            // Add fraction contribution to total (u128)
231            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        // Check if final total fits into u64 before casting
239        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    /// Fetch strict-send paths from Horizon
251    /// strict-send = "I am sending exactly X source asset, how much Y dest can I get?"
252    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        // Convert stroops to decimal string for the API using source asset decimals
263        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        // Add Source Asset Params
268        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        // Add Destination Asset Params
285        // Note: Horizon API doesn't allow both destination_account and destination_assets
286        // We use destination_account to check trustlines, but specify the asset separately
287        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        // Add destination_account as a separate parameter (Horizon will filter by trustlines)
300        // Actually, Horizon doesn't support both. We'll omit destination_account when we have a specific asset.
301        // The path finder will still work, but won't check trustlines automatically.
302        // For trustline checking, the caller should verify separately.
303
304        url.push('?');
305        url.push_str(&params.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    /// Validate swap parameters before processing
335    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    /// Get quote for swap operation
367    /// Supports both Token->XLM and XLM->Token swaps
368    async fn get_swap_quote(
369        &self,
370        params: &SwapTransactionParams,
371    ) -> Result<StellarQuoteResponse, StellarDexServiceError> {
372        if params.destination_asset == "native" {
373            // Token -> XLM: Sell token, receive XLM
374            self.get_token_to_xlm_quote(
375                &params.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            // XLM -> Token: Sell XLM, receive token
383            self.get_xlm_to_token_quote(
384                &params.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    /// Build XDR string for swap transaction
398    async fn build_swap_transaction_xdr(
399        &self,
400        params: &SwapTransactionParams,
401        quote: &StellarQuoteResponse,
402    ) -> Result<String, StellarDexServiceError> {
403        // Parse source account to MuxedAccount
404        let source_account = string_to_muxed_account(&params.source_account).map_err(|e| {
405            StellarDexServiceError::InvalidAssetIdentifier(format!("Invalid source account: {e}"))
406        })?;
407
408        // Parse source asset to Asset using shared helper
409        let source_spec = self.parse_asset_to_spec(&params.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        // Parse destination asset dynamically (supports both native and tokens)
417        let dest_spec = self.parse_asset_to_spec(&params.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        // 1. Define exact amount to SEND
425        let send_amount = i64::try_from(params.amount).map_err(|_| {
426            StellarDexServiceError::UnknownError("Amount too large for i64".to_string())
427        })?;
428
429        // 2. Define minimum amount to RECEIVE (Integer Math)
430        // Formula: out_amount * (10000 - bps) / 10000
431        // Cast to u128 to prevent overflow during multiplication
432        let out_amount = quote.out_amount as u128;
433        let slippage_bps = quote.slippage_bps as u128;
434        let basis = 10000u128;
435
436        // Calculate minimum acceptable amount
437        // e.g., If slippage is 100bps (1%), we want 99% of the output
438        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        // Ensure we don't request 0 (unless the quote was actually 0)
447        // Note: Asking for 0 min receive is technically valid (accept anything), but risky.
448        let dest_min_final = if dest_min_u128 == 0 && out_amount > 0 {
449            1 // Require at least 1 stroop if the quote wasn't zero
450        } 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        // 3. Extract Path from Quote (if it exists)
459        let path_assets: Vec<Asset> = if let Some(quote_path) = &quote.path {
460            quote_path
461                .iter()
462                .map(|p| {
463                    // Convert PathStep to AssetSpec, then to Asset
464                    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        // Convert Vec<Asset> to VecM<Asset, 5> (Stellar supports up to 5 intermediate assets in a path)
492        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        // Create PathPaymentStrictSend operation
499        let path_payment_op = Operation {
500            source_account: None,
501            body: OperationBody::PathPaymentStrictSend(PathPaymentStrictSendOp {
502                send_asset,
503                send_amount, // We strictly send this amount
504                destination: source_account.clone(),
505                dest_asset,
506                dest_min,        // We accept at least this much
507                path: path_vecm, // Populate the path here!
508            }),
509        };
510
511        // Set time bounds: valid immediately (min_time = 0) until configured validity window
512        // Using min_time = 0 prevents TxTooEarly errors when transaction goes through pipeline
513        let now = Utc::now();
514        let valid_until = now + ChronoDuration::minutes(TRANSACTION_VALIDITY_MINUTES);
515        let time_bounds = TimeBounds {
516            min_time: TimePoint(0), // Valid immediately, prevents TxTooEarly errors
517            max_time: TimePoint(valid_until.timestamp() as u64),
518        };
519
520        // Build Transaction
521        // Note: Sequence number is set to 0 as a placeholder
522        // The transaction pipeline will update it with the correct sequence number
523        // when processing the transaction through the gate mechanism
524        let transaction = Transaction {
525            source_account,
526            fee: STELLAR_DEFAULT_TRANSACTION_FEE,
527            seq_num: SequenceNumber(0), // Placeholder - will be updated by transaction pipeline
528            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        // Create TransactionEnvelope and serialize to XDR base64
539        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
540            tx: transaction,
541            signatures: VecM::default(), // Unsigned transaction
542        });
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    /// Sign and submit transaction, returning the transaction hash
552    async fn sign_and_submit_transaction(
553        &self,
554        xdr: &str,
555        network_passphrase: &str,
556    ) -> Result<soroban_rs::xdr::Hash, StellarDexServiceError> {
557        // Sign the transaction
558        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        // Parse the signed XDR to get the envelope
569        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        // Send the transaction
576        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        // Parse source asset
619        let source_spec = self.parse_asset_to_spec(asset_id)?;
620        let dest_spec = AssetSpec::Native;
621
622        // Use signer's address as destination account for path finding
623        // This ensures the path finder checks trustlines
624        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        // Fetch paths using strict-send path finding
636        // Use source asset decimals (default to 7 for tokens if not provided)
637        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        // The paths are typically sorted by best price (highest destination_amount)
653        let best_path = &paths[0];
654
655        // SAFE PARSING: Use integer-based parsing to avoid IEEE 754 floating-point errors
656        // Horizon returns strings like "123.4567890" - parsing as f64 can introduce precision errors
657        let out_amount = self.parse_string_amount_to_stroops(&best_path.destination_amount, 7)?;
658
659        // Convert path to PathStep format for the response
660        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, // Path steps don't include amounts in Horizon response
667            })
668            .collect();
669
670        // Calculate price impact (simplified - could be improved with more data)
671        // For now, set to 0 as path finding already finds the best path
672        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); // Default to 7 for Stellar standard
693        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        // Parse destination asset (token we want to receive)
707        let dest_spec = self.parse_asset_to_spec(asset_id)?;
708        let source_spec = AssetSpec::Native;
709
710        // Use signer's address as destination account for path finding
711        // This ensures the path finder checks trustlines
712        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        // REFACTORED: Use strict-send (Sell XLM) instead of strict-receive
724        // This unifies the transaction builder logic and guarantees input amounts
725        // We are selling exactly `amount` XLM (which has 7 decimals)
726        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        // The paths are typically sorted by best price (highest destination_amount)
735        let best_path = &paths[0];
736
737        // SAFE PARSING: Use integer-based parsing to avoid IEEE 754 floating-point errors
738        // Horizon returns strings like "123.4567890" - parsing as f64 can introduce precision errors
739        // Parse destination amount (token) using its specific decimals
740        let out_amount =
741            self.parse_string_amount_to_stroops(&best_path.destination_amount, decimals)?;
742
743        // Convert path to PathStep format for the response
744        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, // Path steps don't include amounts in Horizon response
751            })
752            .collect();
753
754        // Calculate price impact (simplified - could be improved with more data)
755        // For now, set to 0 as path finding already finds the best path
756        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, // We are selling exactly this amount of XLM
762            out_amount,        // We receive this amount of tokens
763            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        // Validate parameters upfront
774        self.validate_swap_params(&params)?;
775
776        // Get a quote first to determine destination amount
777        let quote = self.get_swap_quote(&params).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        // Build the transaction XDR (unsigned)
785        let xdr = self.build_swap_transaction_xdr(&params, &quote).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        // Note: execute_swap is kept for backward compatibility but should generally not be used
800        // Swaps should go through the transaction pipeline via prepare_swap_transaction + process_transaction_request
801        // which properly manages sequence numbers through the gate mechanism
802
803        // Prepare the swap transaction (get quote and build XDR)
804        // Sequence number will be 0 (placeholder) - caller should use pipeline instead
805        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        // Sign and submit the transaction
813        let tx_hash = self
814            .sign_and_submit_transaction(&xdr, &params.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    // Combined mock that implements both StellarSignTrait and Signer
841    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    // Helper function to create a test service with mocks
893    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        // Missing colon
970        let result = service
971            .parse_asset_to_spec("USDCGBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5");
972        assert!(result.is_err());
973
974        // Empty code
975        let result = service
976            .parse_asset_to_spec(":GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5");
977        assert!(result.is_err());
978
979        // Empty issuer
980        let result = service.parse_asset_to_spec("USDC:");
981        assert!(result.is_err());
982
983        // Code too long (>12 chars)
984        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        // Standard 7 decimals (stroops)
995        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        // Different decimals
1001        assert_eq!(service.to_decimal_string(1000, 3), "1.000");
1002        assert_eq!(service.to_decimal_string(123456, 6), "0.123456");
1003
1004        // Zero
1005        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        // Standard conversions with 7 decimals
1013        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        // Integer (no decimal)
1039        assert_eq!(
1040            service.parse_string_amount_to_stroops("100", 7).unwrap(),
1041            1000000000
1042        );
1043
1044        // Trailing decimal point
1045        assert_eq!(
1046            service.parse_string_amount_to_stroops("100.", 7).unwrap(),
1047            1000000000
1048        );
1049
1050        // Fewer decimals than expected (padding)
1051        assert_eq!(
1052            service.parse_string_amount_to_stroops("1.12", 7).unwrap(),
1053            11200000
1054        );
1055
1056        // More decimals than expected (truncation)
1057        assert_eq!(
1058            service
1059                .parse_string_amount_to_stroops("1.12345678", 7)
1060                .unwrap(),
1061            11234567
1062        );
1063
1064        // Different decimal precision
1065        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        // Invalid format
1080        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        // Value exceeds u64::MAX
1090        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(&params).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(&params);
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(&params);
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        // Negative slippage
1167        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(&params).is_err());
1180
1181        // Slippage > 100%
1182        params.slippage_percent = 101.0;
1183        assert!(service.validate_swap_params(&params).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(&params);
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    // Integration tests with mocked provider and signer
1255
1256    #[test]
1257    fn test_swap_params_validation_comprehensive() {
1258        let (service, _, _) = create_test_service();
1259
1260        // Test valid params
1261        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(&params);
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        // Test asset with exactly 4 characters
1282        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        // Test asset with exactly 12 characters
1291        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        // Test asset with 5 characters (should be Credit12)
1301        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        // Test with 0 decimals (note: function adds trailing ".")
1315        assert_eq!(_service.to_decimal_string(123, 0), "123.");
1316
1317        // Test with very large number (u64::MAX with 7 decimals)
1318        assert_eq!(
1319            _service.to_decimal_string(u64::MAX, 7),
1320            "1844674407370.9551615"
1321        );
1322
1323        // Test with 1 decimal
1324        assert_eq!(_service.to_decimal_string(123, 1), "12.3");
1325
1326        // Test with 2 decimals
1327        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        // Test with no decimal point
1335        assert_eq!(
1336            _service.parse_string_amount_to_stroops("100", 7).unwrap(),
1337            1000000000
1338        );
1339
1340        // Test with trailing decimal point
1341        assert_eq!(
1342            _service.parse_string_amount_to_stroops("100.", 7).unwrap(),
1343            1000000000
1344        );
1345
1346        // Test with leading zero
1347        assert_eq!(
1348            _service.parse_string_amount_to_stroops("0.1", 7).unwrap(),
1349            1000000
1350        );
1351
1352        // Test with many decimal places (truncation)
1353        assert_eq!(
1354            _service
1355                .parse_string_amount_to_stroops("1.123456789", 7)
1356                .unwrap(),
1357            11234567
1358        );
1359
1360        // Test with few decimal places (padding)
1361        assert_eq!(
1362            _service.parse_string_amount_to_stroops("1.12", 7).unwrap(),
1363            11200000
1364        );
1365    }
1366
1367    // Note: Integration tests for sign_and_submit_transaction, prepare_swap_transaction,
1368    // and other async functions that require HTTP mocking would need a more sophisticated
1369    // setup with wiremock or similar HTTP mocking libraries. The current test structure
1370    // focuses on unit-testable logic (parsing, validation, conversion) which provides
1371    // excellent coverage for the core functionality.
1372
1373    // Edge case tests
1374    #[test]
1375    fn test_slippage_to_bps_conversion() {
1376        let (_service, _, _) = create_test_service();
1377
1378        // Test various slippage percentages
1379        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        // Verify slippage conversion (0.5% = 50 bps)
1392        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        // Test minimum receive amount calculation with slippage
1399        // out_amount * (10000 - bps) / 10000
1400
1401        let out_amount = 1000000u128; // 1 XLM in stroops
1402        let slippage_bps = 100u128; // 1%
1403        let basis = 10000u128;
1404
1405        let min_amount = out_amount * (basis - slippage_bps) / basis;
1406        assert_eq!(min_amount, 990000); // 0.99 XLM
1407
1408        // Test with 0.5% slippage
1409        let slippage_bps = 50u128;
1410        let min_amount = out_amount * (basis - slippage_bps) / basis;
1411        assert_eq!(min_amount, 995000); // 0.995 XLM
1412
1413        // Test edge case: 100% slippage (accept anything)
1414        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        // Test that to_decimal_string and parse_string_amount_to_stroops are inverses
1424        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            // Try to convert to XDR Asset
1450            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        // Verify constants are reasonable
1458        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    // HTTP Integration tests with mockito
1464
1465    #[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        // Mock successful Horizon API response
1470        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        // Test with 6 decimals (USDC on some chains)
1808        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(&params).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        // Test slippage to BPS conversion
1880        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}