openzeppelin_relayer/services/stellar_dex/
soroswap_service.rs

1//! Soroswap DEX Service implementation
2//!
3//! Uses Soroswap AMM router contract for token swaps on Soroban.
4//! This service handles swaps between Soroban token contracts (C... addresses) and XLM.
5//!
6//! The router contract provides `get_amounts_out` for quotes and
7//! `swap_exact_tokens_for_tokens` for executing swaps.
8
9use super::{
10    AssetType, PathStep, StellarDexServiceError, StellarDexServiceTrait, StellarQuoteResponse,
11    SwapExecutionResult, SwapTransactionParams,
12};
13use crate::constants::STELLAR_DEFAULT_TRANSACTION_FEE;
14use crate::domain::relayer::string_to_muxed_account;
15use crate::domain::transaction::stellar::utils::{parse_account_id, parse_contract_address};
16use crate::services::provider::StellarProviderTrait;
17use async_trait::async_trait;
18use chrono::{Duration as ChronoDuration, Utc};
19use soroban_rs::xdr::{
20    ContractId, HostFunction, Int128Parts, InvokeContractArgs, InvokeHostFunctionOp, Limits, Memo,
21    Operation, OperationBody, Preconditions, ScAddress, ScSymbol, ScVal, ScVec, SequenceNumber,
22    TimeBounds, TimePoint, Transaction, TransactionEnvelope, TransactionExt, TransactionV1Envelope,
23    VecM, WriteXdr,
24};
25use std::collections::HashSet;
26use std::sync::Arc;
27use tracing::{debug, info, warn};
28
29/// Transaction validity window in minutes
30const TRANSACTION_VALIDITY_MINUTES: i64 = 5;
31
32/// Soroswap AMM DEX service for Soroban token swaps
33///
34/// This service uses Soroswap's router contract to:
35/// - Get quotes by simulating `get_amounts_out`
36/// - Execute swaps via `swap_exact_tokens_for_tokens`
37pub struct SoroswapService<P>
38where
39    P: StellarProviderTrait + Send + Sync + 'static,
40{
41    /// Soroswap router contract address
42    router_address: String,
43    /// Soroswap factory contract address (required for get_amounts_out)
44    factory_address: String,
45    /// Native XLM wrapper token address
46    native_wrapper_address: String,
47    /// Stellar provider for contract calls
48    provider: Arc<P>,
49    /// Network passphrase for signing (used for swap execution)
50    #[allow(dead_code)]
51    network_passphrase: String,
52}
53
54impl<P> SoroswapService<P>
55where
56    P: StellarProviderTrait + Send + Sync + 'static,
57{
58    /// Create a new SoroswapService instance
59    ///
60    /// # Arguments
61    ///
62    /// * `router_address` - Soroswap router contract address
63    /// * `factory_address` - Soroswap factory contract address (required for get_amounts_out)
64    /// * `native_wrapper_address` - Native XLM wrapper token address
65    /// * `provider` - Stellar provider for contract calls
66    /// * `network_passphrase` - Network passphrase
67    pub fn new(
68        router_address: String,
69        factory_address: String,
70        native_wrapper_address: String,
71        provider: Arc<P>,
72        network_passphrase: String,
73    ) -> Self {
74        Self {
75            router_address,
76            factory_address,
77            native_wrapper_address,
78            provider,
79            network_passphrase,
80        }
81    }
82
83    /// Parse a Soroban contract address (C...) to ScAddress
84    fn parse_contract_to_sc_address(address: &str) -> Result<ScAddress, StellarDexServiceError> {
85        let hash = parse_contract_address(address).map_err(|e| {
86            StellarDexServiceError::InvalidAssetIdentifier(format!(
87                "Invalid Soroban contract address '{address}': {e}"
88            ))
89        })?;
90
91        Ok(ScAddress::Contract(ContractId(hash)))
92    }
93
94    /// Build a Vec<ScVal> path for router calls
95    fn build_path(
96        &self,
97        from_token: &str,
98        to_token: &str,
99    ) -> Result<ScVal, StellarDexServiceError> {
100        let from_addr = Self::parse_contract_to_sc_address(from_token)?;
101        let to_addr = Self::parse_contract_to_sc_address(to_token)?;
102
103        // Simple direct path: [from_token, to_token]
104        let path_vec: ScVec = vec![ScVal::Address(from_addr), ScVal::Address(to_addr)]
105            .try_into()
106            .map_err(|_| {
107                StellarDexServiceError::UnknownError("Failed to create path vector".to_string())
108            })?;
109
110        Ok(ScVal::Vec(Some(path_vec)))
111    }
112
113    /// Convert i128 to ScVal::I128
114    fn i128_to_scval(amount: i128) -> ScVal {
115        let hi = (amount >> 64) as i64;
116        let lo = amount as u64;
117        ScVal::I128(Int128Parts { hi, lo })
118    }
119
120    /// Extract i128 from ScVal::I128
121    fn scval_to_i128(val: &ScVal) -> Result<i128, StellarDexServiceError> {
122        match val {
123            ScVal::I128(parts) => {
124                let result = ((parts.hi as i128) << 64) | (parts.lo as i128);
125                Ok(result)
126            }
127            _ => Err(StellarDexServiceError::UnknownError(
128                "Expected I128 value from router".to_string(),
129            )),
130        }
131    }
132
133    /// Extract Vec<i128> from ScVal::Vec of I128s
134    fn scval_to_amounts_vec(val: &ScVal) -> Result<Vec<i128>, StellarDexServiceError> {
135        match val {
136            ScVal::Vec(Some(sc_vec)) => {
137                let mut amounts = Vec::new();
138                for item in sc_vec.iter() {
139                    amounts.push(Self::scval_to_i128(item)?);
140                }
141                Ok(amounts)
142            }
143            _ => Err(StellarDexServiceError::UnknownError(
144                "Expected Vec of I128 values from router".to_string(),
145            )),
146        }
147    }
148
149    /// Call router.get_amounts_out to get quote
150    ///
151    /// Returns the expected output amounts for each step in the path
152    /// Soroswap's get_amounts_out requires: (factory_address, amount_in, path)
153    async fn call_get_amounts_out(
154        &self,
155        amount_in: i128,
156        path: ScVal,
157    ) -> Result<Vec<i128>, StellarDexServiceError> {
158        let function_name = ScSymbol::try_from("get_amounts_out").map_err(|_| {
159            StellarDexServiceError::UnknownError("Failed to create function symbol".to_string())
160        })?;
161
162        // Soroswap's get_amounts_out requires factory address as first argument
163        let factory_addr = Self::parse_contract_to_sc_address(&self.factory_address)?;
164        let args = vec![
165            ScVal::Address(factory_addr),
166            Self::i128_to_scval(amount_in),
167            path,
168        ];
169
170        debug!(
171            router = %self.router_address,
172            factory = %self.factory_address,
173            amount_in = amount_in,
174            "Calling Soroswap router get_amounts_out"
175        );
176
177        let result = self
178            .provider
179            .call_contract(&self.router_address, &function_name, args)
180            .await
181            .map_err(|e| StellarDexServiceError::ApiError {
182                message: format!("Soroswap router call failed: {e}"),
183            })?;
184
185        Self::scval_to_amounts_vec(&result)
186    }
187
188    /// Parse a Stellar account address (G...) to ScAddress::Account
189    fn parse_account_to_sc_address(address: &str) -> Result<ScAddress, StellarDexServiceError> {
190        let account_id = parse_account_id(address).map_err(|e| {
191            StellarDexServiceError::InvalidAssetIdentifier(format!(
192                "Invalid Stellar account address '{address}': {e}"
193            ))
194        })?;
195        Ok(ScAddress::Account(account_id))
196    }
197
198    /// Build the Soroswap router swap transaction XDR (unsigned)
199    ///
200    /// Creates an `InvokeHostFunction` transaction that calls the Soroswap router's
201    /// `swap_exact_tokens_for_tokens` function. The transaction is returned as unsigned
202    /// base64-encoded XDR with placeholder sequence number (0). The transaction pipeline
203    /// will handle simulation (to get resources, footprint, and auth entries), sequence
204    /// number assignment, fee calculation, signing, and submission.
205    ///
206    /// Soroswap router function signature:
207    /// ```text
208    /// swap_exact_tokens_for_tokens(
209    ///     amount_in: i128,
210    ///     amount_out_min: i128,
211    ///     path: Vec<Address>,
212    ///     to: Address,
213    ///     deadline: u64
214    /// ) -> Vec<i128>
215    /// ```
216    fn build_swap_transaction_xdr(
217        &self,
218        params: &SwapTransactionParams,
219        quote: &StellarQuoteResponse,
220    ) -> Result<String, StellarDexServiceError> {
221        // Step 1: Parse source account to MuxedAccount (for transaction source)
222        let source_account = string_to_muxed_account(&params.source_account).map_err(|e| {
223            StellarDexServiceError::InvalidAssetIdentifier(format!("Invalid source account: {e}"))
224        })?;
225
226        // Step 2: Parse source account to ScAddress (for the `to` parameter — relayer swaps to itself)
227        let to_address = Self::parse_account_to_sc_address(&params.source_account)?;
228
229        // Step 3: Calculate amount_out_min with slippage protection
230        // Formula: out_amount * (10000 - slippage_bps) / 10000
231        let out_amount = quote.out_amount as u128;
232        let slippage_bps = quote.slippage_bps as u128;
233        let basis = 10000u128;
234
235        let amount_out_min_u128 = out_amount
236            .checked_mul(basis.saturating_sub(slippage_bps))
237            .ok_or_else(|| {
238                StellarDexServiceError::UnknownError(
239                    "Overflow calculating minimum output amount".to_string(),
240                )
241            })?
242            .checked_div(basis)
243            .ok_or_else(|| StellarDexServiceError::UnknownError("Division error".to_string()))?;
244
245        // Ensure we don't request 0 if the quote was non-zero
246        let amount_out_min = if amount_out_min_u128 == 0 && out_amount > 0 {
247            1i128
248        } else {
249            amount_out_min_u128 as i128
250        };
251
252        // Step 4: Resolve token addresses (replace "native" with wrapper contract address)
253        let from_token = if params.source_asset == "native" || params.source_asset.is_empty() {
254            self.native_wrapper_address.clone()
255        } else {
256            params.source_asset.clone()
257        };
258
259        let to_token =
260            if params.destination_asset == "native" || params.destination_asset.is_empty() {
261                self.native_wrapper_address.clone()
262            } else {
263                params.destination_asset.clone()
264            };
265
266        // Step 5: Build the path as Vec<ScVal::Address>
267        let path = self.build_path(&from_token, &to_token)?;
268
269        // Step 6: Calculate deadline (Unix timestamp, now + validity window)
270        let now = Utc::now();
271        let deadline = now + ChronoDuration::minutes(TRANSACTION_VALIDITY_MINUTES);
272        let deadline_timestamp = deadline.timestamp() as u64;
273
274        // Step 7: Build router contract invocation args
275        let router_addr = Self::parse_contract_to_sc_address(&self.router_address)?;
276        let function_name = ScSymbol::try_from("swap_exact_tokens_for_tokens").map_err(|_| {
277            StellarDexServiceError::UnknownError(
278                "Failed to create swap function symbol".to_string(),
279            )
280        })?;
281
282        let args: VecM<ScVal> = vec![
283            Self::i128_to_scval(params.amount as i128), // amount_in
284            Self::i128_to_scval(amount_out_min),        // amount_out_min
285            path,                                       // path: Vec<Address>
286            ScVal::Address(to_address),                 // to: relayer address
287            ScVal::U64(deadline_timestamp),             // deadline: Unix timestamp
288        ]
289        .try_into()
290        .map_err(|_| {
291            StellarDexServiceError::UnknownError("Failed to create swap function args".to_string())
292        })?;
293
294        // Step 8: Create InvokeHostFunction operation
295        let host_function = HostFunction::InvokeContract(InvokeContractArgs {
296            contract_address: router_addr,
297            function_name,
298            args,
299        });
300
301        let invoke_op = Operation {
302            source_account: None,
303            body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
304                host_function,
305                auth: VecM::default(), // Empty — simulation will populate auth entries
306            }),
307        };
308
309        // Step 9: Build time bounds
310        let time_bounds = TimeBounds {
311            min_time: TimePoint(0),
312            max_time: TimePoint(deadline_timestamp),
313        };
314
315        // Step 10: Build Transaction with placeholder sequence and fee
316        let transaction = Transaction {
317            source_account,
318            fee: STELLAR_DEFAULT_TRANSACTION_FEE,
319            seq_num: SequenceNumber(0), // Placeholder — pipeline updates
320            cond: Preconditions::Time(time_bounds),
321            memo: Memo::None,
322            operations: vec![invoke_op].try_into().map_err(|_| {
323                StellarDexServiceError::UnknownError(
324                    "Failed to create operations vector".to_string(),
325                )
326            })?,
327            ext: TransactionExt::V0,
328        };
329
330        // Step 11: Create TransactionEnvelope and serialize
331        let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
332            tx: transaction,
333            signatures: VecM::default(), // Unsigned
334        });
335
336        envelope.to_xdr_base64(Limits::none()).map_err(|e| {
337            StellarDexServiceError::UnknownError(format!(
338                "Failed to serialize transaction to XDR: {e}"
339            ))
340        })
341    }
342}
343
344#[async_trait]
345impl<P> StellarDexServiceTrait for SoroswapService<P>
346where
347    P: StellarProviderTrait + Send + Sync + 'static,
348{
349    fn supported_asset_types(&self) -> HashSet<AssetType> {
350        // Soroswap supports Soroban contract tokens and Native XLM (via wrapper)
351        HashSet::from([AssetType::Native, AssetType::Contract])
352    }
353
354    fn can_handle_asset(&self, asset_id: &str) -> bool {
355        // Handle native XLM (will use wrapper)
356        if asset_id == "native" || asset_id.is_empty() {
357            return true;
358        }
359
360        // Handle Soroban contract tokens (C... format, 56 chars)
361        if asset_id.starts_with('C')
362            && asset_id.len() == 56
363            && !asset_id.contains(':')
364            && stellar_strkey::Contract::from_string(asset_id).is_ok()
365        {
366            return true;
367        }
368
369        false
370    }
371
372    async fn get_token_to_xlm_quote(
373        &self,
374        asset_id: &str,
375        amount: u64,
376        slippage: f32,
377        _asset_decimals: Option<u8>,
378    ) -> Result<StellarQuoteResponse, StellarDexServiceError> {
379        // For native XLM, return 1:1
380        if asset_id == "native" || asset_id.is_empty() {
381            return Ok(StellarQuoteResponse {
382                input_asset: "native".to_string(),
383                output_asset: "native".to_string(),
384                in_amount: amount,
385                out_amount: amount,
386                price_impact_pct: 0.0,
387                slippage_bps: (slippage * 100.0) as u32,
388                path: None,
389            });
390        }
391
392        // Build path: [token, native_wrapper]
393        let path = self.build_path(asset_id, &self.native_wrapper_address)?;
394
395        // Call router to get quote
396        let amounts = self.call_get_amounts_out(amount as i128, path).await?;
397
398        // Last amount is the output
399        let out_amount = amounts
400            .last()
401            .copied()
402            .ok_or_else(|| StellarDexServiceError::NoPathFound)?;
403
404        if out_amount <= 0 {
405            return Err(StellarDexServiceError::NoPathFound);
406        }
407
408        // Safe conversion from i128 to u64 - we already checked out_amount > 0 above
409        let out_amount_u64 = u64::try_from(out_amount).map_err(|_| {
410            StellarDexServiceError::UnknownError(format!(
411                "Output amount {out_amount} exceeds u64::MAX"
412            ))
413        })?;
414
415        debug!(
416            asset = %asset_id,
417            in_amount = amount,
418            out_amount = out_amount_u64,
419            "Soroswap quote: token -> XLM"
420        );
421
422        Ok(StellarQuoteResponse {
423            input_asset: asset_id.to_string(),
424            output_asset: "native".to_string(),
425            in_amount: amount,
426            out_amount: out_amount_u64,
427            price_impact_pct: 0.0,
428            slippage_bps: (slippage * 100.0) as u32,
429            path: Some(vec![
430                PathStep {
431                    asset_code: Some(asset_id.to_string()),
432                    asset_issuer: None,
433                    amount,
434                },
435                PathStep {
436                    asset_code: Some("native".to_string()),
437                    asset_issuer: None,
438                    amount: out_amount_u64,
439                },
440            ]),
441        })
442    }
443
444    async fn get_xlm_to_token_quote(
445        &self,
446        asset_id: &str,
447        amount: u64,
448        slippage: f32,
449        _asset_decimals: Option<u8>,
450    ) -> Result<StellarQuoteResponse, StellarDexServiceError> {
451        // For native XLM, return 1:1
452        if asset_id == "native" || asset_id.is_empty() {
453            return Ok(StellarQuoteResponse {
454                input_asset: "native".to_string(),
455                output_asset: "native".to_string(),
456                in_amount: amount,
457                out_amount: amount,
458                price_impact_pct: 0.0,
459                slippage_bps: (slippage * 100.0) as u32,
460                path: None,
461            });
462        }
463
464        // Build path: [native_wrapper, token]
465        let path = self.build_path(&self.native_wrapper_address, asset_id)?;
466
467        // Call router to get quote
468        let amounts = self.call_get_amounts_out(amount as i128, path).await?;
469
470        // Last amount is the output
471        let out_amount = amounts
472            .last()
473            .copied()
474            .ok_or_else(|| StellarDexServiceError::NoPathFound)?;
475
476        if out_amount <= 0 {
477            return Err(StellarDexServiceError::NoPathFound);
478        }
479
480        // Safe conversion from i128 to u64 - we already checked out_amount > 0 above
481        let out_amount_u64 = u64::try_from(out_amount).map_err(|_| {
482            StellarDexServiceError::UnknownError(format!(
483                "Output amount {out_amount} exceeds u64::MAX"
484            ))
485        })?;
486
487        // Calculate price impact (simplified - assumes 1:1 expected ratio)
488        // TODO: Use pool reserves for accurate price impact calculation
489        let price_impact = if amount > 0 && out_amount_u64 > 0 {
490            let expected_ratio = 1.0;
491            let actual_ratio = out_amount_u64 as f64 / amount as f64;
492            ((expected_ratio - actual_ratio).abs() / expected_ratio * 100.0).min(100.0)
493        } else {
494            0.0
495        };
496
497        debug!(
498            asset = %asset_id,
499            in_amount = amount,
500            out_amount = out_amount_u64,
501            "Soroswap quote: XLM -> token"
502        );
503
504        Ok(StellarQuoteResponse {
505            input_asset: "native".to_string(),
506            output_asset: asset_id.to_string(),
507            in_amount: amount,
508            out_amount: out_amount_u64,
509            price_impact_pct: price_impact,
510            slippage_bps: (slippage * 100.0) as u32,
511            path: Some(vec![
512                PathStep {
513                    asset_code: Some("native".to_string()),
514                    asset_issuer: None,
515                    amount,
516                },
517                PathStep {
518                    asset_code: Some(asset_id.to_string()),
519                    asset_issuer: None,
520                    amount: out_amount_u64,
521                },
522            ]),
523        })
524    }
525
526    async fn prepare_swap_transaction(
527        &self,
528        params: SwapTransactionParams,
529    ) -> Result<(String, StellarQuoteResponse), StellarDexServiceError> {
530        // Get a quote for the swap
531        let quote = if params.destination_asset == "native" {
532            self.get_token_to_xlm_quote(
533                &params.source_asset,
534                params.amount,
535                params.slippage_percent,
536                params.source_asset_decimals,
537            )
538            .await?
539        } else if params.source_asset == "native" {
540            self.get_xlm_to_token_quote(
541                &params.destination_asset,
542                params.amount,
543                params.slippage_percent,
544                params.destination_asset_decimals,
545            )
546            .await?
547        } else {
548            return Err(StellarDexServiceError::InvalidAssetIdentifier(
549                "Soroswap currently only supports swaps involving native XLM".to_string(),
550            ));
551        };
552
553        info!(
554            "Preparing Soroswap swap transaction: {} {} -> {} (min receive: {})",
555            params.amount, params.source_asset, params.destination_asset, quote.out_amount
556        );
557
558        // Build the unsigned swap transaction XDR
559        let xdr = self.build_swap_transaction_xdr(&params, &quote)?;
560
561        info!(
562            "Successfully prepared Soroswap swap transaction XDR ({} bytes)",
563            xdr.len()
564        );
565
566        Ok((xdr, quote))
567    }
568
569    /// Swap execution is not yet implemented.
570    ///
571    /// Required by [`StellarDexServiceTrait`]. `params` is intentionally unused and will be
572    /// used when building and submitting the Soroswap swap transaction.
573    async fn execute_swap(
574        &self,
575        _params: SwapTransactionParams,
576    ) -> Result<SwapExecutionResult, StellarDexServiceError> {
577        warn!("Soroswap execute_swap is not yet implemented");
578
579        Err(StellarDexServiceError::UnknownError(
580            "Soroswap swap execution is not yet implemented".to_string(),
581        ))
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use crate::constants::STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER;
589    use crate::services::provider::MockStellarProviderTrait;
590    use futures::FutureExt;
591    use soroban_rs::xdr::ReadXdr;
592
593    const TEST_NATIVE_WRAPPER: &str = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";
594
595    fn create_mock_provider() -> Arc<MockStellarProviderTrait> {
596        Arc::new(MockStellarProviderTrait::new())
597    }
598
599    fn create_test_service(
600        provider: Arc<MockStellarProviderTrait>,
601    ) -> SoroswapService<MockStellarProviderTrait> {
602        SoroswapService::new(
603            "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(), // router
604            "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA".to_string(), // factory
605            TEST_NATIVE_WRAPPER.to_string(),
606            provider,
607            "Test SDF Network ; September 2015".to_string(),
608        )
609    }
610
611    // ==================== Constructor Tests ====================
612
613    #[test]
614    fn test_new_stores_provided_native_wrapper() {
615        let provider = create_mock_provider();
616        let service = create_test_service(provider);
617        assert_eq!(service.native_wrapper_address, TEST_NATIVE_WRAPPER);
618    }
619
620    #[test]
621    fn test_new_with_mainnet_native_wrapper() {
622        let provider = create_mock_provider();
623        let service = SoroswapService::new(
624            "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
625            "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA".to_string(),
626            STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER.to_string(),
627            provider,
628            "Public Global Stellar Network ; September 2015".to_string(),
629        );
630        assert_eq!(
631            service.native_wrapper_address,
632            STELLAR_SOROSWAP_MAINNET_NATIVE_WRAPPER
633        );
634    }
635
636    #[test]
637    fn test_new_with_custom_native_wrapper() {
638        let provider = create_mock_provider();
639        let custom_wrapper = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M".to_string();
640        let service = SoroswapService::new(
641            "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
642            "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA".to_string(),
643            custom_wrapper.clone(),
644            provider,
645            "Test SDF Network ; September 2015".to_string(),
646        );
647        assert_eq!(service.native_wrapper_address, custom_wrapper);
648    }
649
650    // ==================== parse_contract_to_sc_address Tests ====================
651
652    #[test]
653    fn test_parse_contract_to_sc_address_valid() {
654        let addr = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";
655        let result =
656            SoroswapService::<MockStellarProviderTrait>::parse_contract_to_sc_address(addr);
657        assert!(result.is_ok());
658        match result.unwrap() {
659            ScAddress::Contract(_) => {}
660            _ => panic!("Expected Contract address"),
661        }
662    }
663
664    #[test]
665    fn test_parse_contract_to_sc_address_invalid_format() {
666        let addr = "INVALID_ADDRESS";
667        let result =
668            SoroswapService::<MockStellarProviderTrait>::parse_contract_to_sc_address(addr);
669        assert!(result.is_err());
670        match result.unwrap_err() {
671            StellarDexServiceError::InvalidAssetIdentifier(msg) => {
672                assert!(msg.contains("Invalid Soroban contract address"));
673            }
674            _ => panic!("Expected InvalidAssetIdentifier error"),
675        }
676    }
677
678    #[test]
679    fn test_parse_contract_to_sc_address_stellar_account_not_contract() {
680        // A valid Stellar account address (G...) but not a contract (C...)
681        let addr = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
682        let result =
683            SoroswapService::<MockStellarProviderTrait>::parse_contract_to_sc_address(addr);
684        assert!(result.is_err());
685    }
686
687    // ==================== can_handle_asset Tests ====================
688
689    #[test]
690    fn test_can_handle_asset_native() {
691        let provider = create_mock_provider();
692        let service = create_test_service(provider);
693        assert!(service.can_handle_asset("native"));
694    }
695
696    #[test]
697    fn test_can_handle_asset_empty_string() {
698        let provider = create_mock_provider();
699        let service = create_test_service(provider);
700        assert!(service.can_handle_asset(""));
701    }
702
703    #[test]
704    fn test_can_handle_asset_valid_contract() {
705        let provider = create_mock_provider();
706        let service = create_test_service(provider);
707        let contract_addr = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";
708        assert!(service.can_handle_asset(contract_addr));
709    }
710
711    #[test]
712    fn test_cannot_handle_classic_asset() {
713        let provider = create_mock_provider();
714        let service = create_test_service(provider);
715        let classic_asset = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
716        assert!(!service.can_handle_asset(classic_asset));
717    }
718
719    #[test]
720    fn test_cannot_handle_short_address() {
721        let provider = create_mock_provider();
722        let service = create_test_service(provider);
723        assert!(!service.can_handle_asset("CSHORT"));
724    }
725
726    #[test]
727    fn test_cannot_handle_non_c_prefix() {
728        let provider = create_mock_provider();
729        let service = create_test_service(provider);
730        // Stellar account address (G prefix)
731        let addr = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
732        assert!(!service.can_handle_asset(addr));
733    }
734
735    #[test]
736    fn test_cannot_handle_invalid_contract_checksum() {
737        let provider = create_mock_provider();
738        let service = create_test_service(provider);
739        // Valid format but invalid checksum
740        let invalid_addr = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
741        assert!(!service.can_handle_asset(invalid_addr));
742    }
743
744    // ==================== supported_asset_types Tests ====================
745
746    #[test]
747    fn test_supported_asset_types() {
748        let provider = create_mock_provider();
749        let service = create_test_service(provider);
750        let types = service.supported_asset_types();
751        assert!(types.contains(&AssetType::Native));
752        assert!(types.contains(&AssetType::Contract));
753        assert_eq!(types.len(), 2);
754    }
755
756    // ==================== i128 Conversion Tests ====================
757
758    #[test]
759    fn test_i128_to_scval_and_back_positive() {
760        let original: i128 = 1_000_000_000;
761        let scval = SoroswapService::<MockStellarProviderTrait>::i128_to_scval(original);
762        let recovered = SoroswapService::<MockStellarProviderTrait>::scval_to_i128(&scval).unwrap();
763        assert_eq!(original, recovered);
764    }
765
766    #[test]
767    fn test_i128_to_scval_and_back_zero() {
768        let original: i128 = 0;
769        let scval = SoroswapService::<MockStellarProviderTrait>::i128_to_scval(original);
770        let recovered = SoroswapService::<MockStellarProviderTrait>::scval_to_i128(&scval).unwrap();
771        assert_eq!(original, recovered);
772    }
773
774    #[test]
775    fn test_i128_to_scval_and_back_negative() {
776        let original: i128 = -1_000_000_000;
777        let scval = SoroswapService::<MockStellarProviderTrait>::i128_to_scval(original);
778        let recovered = SoroswapService::<MockStellarProviderTrait>::scval_to_i128(&scval).unwrap();
779        assert_eq!(original, recovered);
780    }
781
782    #[test]
783    fn test_i128_to_scval_and_back_large_positive() {
784        let original: i128 = i128::MAX / 2;
785        let scval = SoroswapService::<MockStellarProviderTrait>::i128_to_scval(original);
786        let recovered = SoroswapService::<MockStellarProviderTrait>::scval_to_i128(&scval).unwrap();
787        assert_eq!(original, recovered);
788    }
789
790    #[test]
791    fn test_i128_to_scval_and_back_large_negative() {
792        let original: i128 = i128::MIN / 2;
793        let scval = SoroswapService::<MockStellarProviderTrait>::i128_to_scval(original);
794        let recovered = SoroswapService::<MockStellarProviderTrait>::scval_to_i128(&scval).unwrap();
795        assert_eq!(original, recovered);
796    }
797
798    #[test]
799    fn test_scval_to_i128_wrong_type() {
800        let scval = ScVal::Bool(true);
801        let result = SoroswapService::<MockStellarProviderTrait>::scval_to_i128(&scval);
802        assert!(result.is_err());
803        match result.unwrap_err() {
804            StellarDexServiceError::UnknownError(msg) => {
805                assert!(msg.contains("Expected I128 value"));
806            }
807            _ => panic!("Expected UnknownError"),
808        }
809    }
810
811    // ==================== scval_to_amounts_vec Tests ====================
812
813    #[test]
814    fn test_scval_to_amounts_vec_valid() {
815        let amounts: Vec<i128> = vec![100, 200, 300];
816        let sc_vals: Vec<ScVal> = amounts
817            .iter()
818            .map(|&a| SoroswapService::<MockStellarProviderTrait>::i128_to_scval(a))
819            .collect();
820        let sc_vec: ScVec = sc_vals.try_into().unwrap();
821        let scval = ScVal::Vec(Some(sc_vec));
822
823        let result =
824            SoroswapService::<MockStellarProviderTrait>::scval_to_amounts_vec(&scval).unwrap();
825        assert_eq!(result, vec![100, 200, 300]);
826    }
827
828    #[test]
829    fn test_scval_to_amounts_vec_empty() {
830        let sc_vec: ScVec = vec![].try_into().unwrap();
831        let scval = ScVal::Vec(Some(sc_vec));
832
833        let result =
834            SoroswapService::<MockStellarProviderTrait>::scval_to_amounts_vec(&scval).unwrap();
835        assert!(result.is_empty());
836    }
837
838    #[test]
839    fn test_scval_to_amounts_vec_wrong_type() {
840        let scval = ScVal::Bool(true);
841        let result = SoroswapService::<MockStellarProviderTrait>::scval_to_amounts_vec(&scval);
842        assert!(result.is_err());
843        match result.unwrap_err() {
844            StellarDexServiceError::UnknownError(msg) => {
845                assert!(msg.contains("Expected Vec of I128 values"));
846            }
847            _ => panic!("Expected UnknownError"),
848        }
849    }
850
851    #[test]
852    fn test_scval_to_amounts_vec_none() {
853        let scval = ScVal::Vec(None);
854        let result = SoroswapService::<MockStellarProviderTrait>::scval_to_amounts_vec(&scval);
855        assert!(result.is_err());
856    }
857
858    #[test]
859    fn test_scval_to_amounts_vec_mixed_types() {
860        // Vec containing a non-I128 value
861        let sc_vec: ScVec = vec![ScVal::Bool(true)].try_into().unwrap();
862        let scval = ScVal::Vec(Some(sc_vec));
863
864        let result = SoroswapService::<MockStellarProviderTrait>::scval_to_amounts_vec(&scval);
865        assert!(result.is_err());
866    }
867
868    // ==================== build_path Tests ====================
869
870    #[test]
871    fn test_build_path_valid() {
872        let provider = create_mock_provider();
873        let service = create_test_service(provider);
874        let from = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";
875        let to = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA";
876
877        let result = service.build_path(from, to);
878        assert!(result.is_ok());
879        match result.unwrap() {
880            ScVal::Vec(Some(vec)) => {
881                assert_eq!(vec.len(), 2);
882            }
883            _ => panic!("Expected Vec"),
884        }
885    }
886
887    #[test]
888    fn test_build_path_invalid_from() {
889        let provider = create_mock_provider();
890        let service = create_test_service(provider);
891        let result = service.build_path(
892            "INVALID",
893            "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA",
894        );
895        assert!(result.is_err());
896    }
897
898    #[test]
899    fn test_build_path_invalid_to() {
900        let provider = create_mock_provider();
901        let service = create_test_service(provider);
902        let result = service.build_path(
903            "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
904            "INVALID",
905        );
906        assert!(result.is_err());
907    }
908
909    // ==================== Async Quote Tests ====================
910
911    #[tokio::test]
912    async fn test_get_token_to_xlm_quote_native_returns_1_to_1() {
913        let provider = create_mock_provider();
914        let service = create_test_service(provider);
915
916        let quote = service
917            .get_token_to_xlm_quote("native", 1_000_000, 0.5, None)
918            .await
919            .unwrap();
920
921        assert_eq!(quote.input_asset, "native");
922        assert_eq!(quote.output_asset, "native");
923        assert_eq!(quote.in_amount, 1_000_000);
924        assert_eq!(quote.out_amount, 1_000_000);
925        assert_eq!(quote.price_impact_pct, 0.0);
926        assert_eq!(quote.slippage_bps, 50);
927        assert!(quote.path.is_none());
928    }
929
930    #[tokio::test]
931    async fn test_get_token_to_xlm_quote_empty_returns_1_to_1() {
932        let provider = create_mock_provider();
933        let service = create_test_service(provider);
934
935        let quote = service
936            .get_token_to_xlm_quote("", 1_000_000, 1.0, None)
937            .await
938            .unwrap();
939
940        assert_eq!(quote.input_asset, "native");
941        assert_eq!(quote.output_asset, "native");
942        assert_eq!(quote.in_amount, quote.out_amount);
943    }
944
945    #[tokio::test]
946    async fn test_get_xlm_to_token_quote_native_returns_1_to_1() {
947        let provider = create_mock_provider();
948        let service = create_test_service(provider);
949
950        let quote = service
951            .get_xlm_to_token_quote("native", 1_000_000, 0.5, None)
952            .await
953            .unwrap();
954
955        assert_eq!(quote.input_asset, "native");
956        assert_eq!(quote.output_asset, "native");
957        assert_eq!(quote.in_amount, 1_000_000);
958        assert_eq!(quote.out_amount, 1_000_000);
959    }
960
961    #[tokio::test]
962    async fn test_get_xlm_to_token_quote_empty_returns_1_to_1() {
963        let provider = create_mock_provider();
964        let service = create_test_service(provider);
965
966        let quote = service
967            .get_xlm_to_token_quote("", 500_000, 0.25, None)
968            .await
969            .unwrap();
970
971        assert_eq!(quote.input_asset, "native");
972        assert_eq!(quote.output_asset, "native");
973        assert_eq!(quote.slippage_bps, 25);
974    }
975
976    #[tokio::test]
977    async fn test_get_token_to_xlm_quote_with_mock_provider() {
978        let mut mock = MockStellarProviderTrait::new();
979
980        // Build expected output - amounts vec with input and output
981        let amounts: Vec<i128> = vec![1_000_000, 950_000];
982        let sc_vals: Vec<ScVal> = amounts
983            .iter()
984            .map(|&a| SoroswapService::<MockStellarProviderTrait>::i128_to_scval(a))
985            .collect();
986        let sc_vec: ScVec = sc_vals.try_into().unwrap();
987        let result_scval = ScVal::Vec(Some(sc_vec));
988
989        mock.expect_call_contract().returning(move |_, _, _| {
990            let result = result_scval.clone();
991            async move { Ok(result) }.boxed()
992        });
993
994        let provider = Arc::new(mock);
995        let service = create_test_service(provider);
996
997        let quote = service
998            .get_token_to_xlm_quote(
999                "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
1000                1_000_000,
1001                0.5,
1002                None,
1003            )
1004            .await
1005            .unwrap();
1006
1007        assert_eq!(quote.in_amount, 1_000_000);
1008        assert_eq!(quote.out_amount, 950_000);
1009        assert_eq!(quote.output_asset, "native");
1010        assert!(quote.path.is_some());
1011        assert_eq!(quote.path.as_ref().unwrap().len(), 2);
1012    }
1013
1014    #[tokio::test]
1015    async fn test_get_xlm_to_token_quote_with_mock_provider() {
1016        let mut mock = MockStellarProviderTrait::new();
1017
1018        let amounts: Vec<i128> = vec![1_000_000, 1_050_000];
1019        let sc_vals: Vec<ScVal> = amounts
1020            .iter()
1021            .map(|&a| SoroswapService::<MockStellarProviderTrait>::i128_to_scval(a))
1022            .collect();
1023        let sc_vec: ScVec = sc_vals.try_into().unwrap();
1024        let result_scval = ScVal::Vec(Some(sc_vec));
1025
1026        mock.expect_call_contract().returning(move |_, _, _| {
1027            let result = result_scval.clone();
1028            async move { Ok(result) }.boxed()
1029        });
1030
1031        let provider = Arc::new(mock);
1032        let service = create_test_service(provider);
1033
1034        let quote = service
1035            .get_xlm_to_token_quote(
1036                "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
1037                1_000_000,
1038                0.5,
1039                None,
1040            )
1041            .await
1042            .unwrap();
1043
1044        assert_eq!(quote.in_amount, 1_000_000);
1045        assert_eq!(quote.out_amount, 1_050_000);
1046        assert_eq!(quote.input_asset, "native");
1047    }
1048
1049    #[tokio::test]
1050    async fn test_get_token_to_xlm_quote_empty_amounts_returns_no_path() {
1051        let mut mock = MockStellarProviderTrait::new();
1052
1053        // Return empty amounts vec
1054        let sc_vec: ScVec = vec![].try_into().unwrap();
1055        let result_scval = ScVal::Vec(Some(sc_vec));
1056
1057        mock.expect_call_contract().returning(move |_, _, _| {
1058            let result = result_scval.clone();
1059            async move { Ok(result) }.boxed()
1060        });
1061
1062        let provider = Arc::new(mock);
1063        let service = create_test_service(provider);
1064
1065        let result = service
1066            .get_token_to_xlm_quote(
1067                "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
1068                1_000_000,
1069                0.5,
1070                None,
1071            )
1072            .await;
1073
1074        assert!(result.is_err());
1075        match result.unwrap_err() {
1076            StellarDexServiceError::NoPathFound => {}
1077            e => panic!("Expected NoPathFound error, got {:?}", e),
1078        }
1079    }
1080
1081    #[tokio::test]
1082    async fn test_get_token_to_xlm_quote_zero_output_returns_no_path() {
1083        let mut mock = MockStellarProviderTrait::new();
1084
1085        let amounts: Vec<i128> = vec![1_000_000, 0];
1086        let sc_vals: Vec<ScVal> = amounts
1087            .iter()
1088            .map(|&a| SoroswapService::<MockStellarProviderTrait>::i128_to_scval(a))
1089            .collect();
1090        let sc_vec: ScVec = sc_vals.try_into().unwrap();
1091        let result_scval = ScVal::Vec(Some(sc_vec));
1092
1093        mock.expect_call_contract().returning(move |_, _, _| {
1094            let result = result_scval.clone();
1095            async move { Ok(result) }.boxed()
1096        });
1097
1098        let provider = Arc::new(mock);
1099        let service = create_test_service(provider);
1100
1101        let result = service
1102            .get_token_to_xlm_quote(
1103                "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
1104                1_000_000,
1105                0.5,
1106                None,
1107            )
1108            .await;
1109
1110        assert!(result.is_err());
1111        match result.unwrap_err() {
1112            StellarDexServiceError::NoPathFound => {}
1113            e => panic!("Expected NoPathFound error, got {:?}", e),
1114        }
1115    }
1116
1117    #[tokio::test]
1118    async fn test_get_token_to_xlm_quote_negative_output_returns_no_path() {
1119        let mut mock = MockStellarProviderTrait::new();
1120
1121        let amounts: Vec<i128> = vec![1_000_000, -100];
1122        let sc_vals: Vec<ScVal> = amounts
1123            .iter()
1124            .map(|&a| SoroswapService::<MockStellarProviderTrait>::i128_to_scval(a))
1125            .collect();
1126        let sc_vec: ScVec = sc_vals.try_into().unwrap();
1127        let result_scval = ScVal::Vec(Some(sc_vec));
1128
1129        mock.expect_call_contract().returning(move |_, _, _| {
1130            let result = result_scval.clone();
1131            async move { Ok(result) }.boxed()
1132        });
1133
1134        let provider = Arc::new(mock);
1135        let service = create_test_service(provider);
1136
1137        let result = service
1138            .get_token_to_xlm_quote(
1139                "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
1140                1_000_000,
1141                0.5,
1142                None,
1143            )
1144            .await;
1145
1146        assert!(result.is_err());
1147        match result.unwrap_err() {
1148            StellarDexServiceError::NoPathFound => {}
1149            e => panic!("Expected NoPathFound error, got {:?}", e),
1150        }
1151    }
1152
1153    #[tokio::test]
1154    async fn test_get_token_to_xlm_quote_provider_error() {
1155        let mut mock = MockStellarProviderTrait::new();
1156
1157        mock.expect_call_contract().returning(|_, _, _| {
1158            async move {
1159                Err(crate::services::provider::ProviderError::Other(
1160                    "Connection failed".to_string(),
1161                ))
1162            }
1163            .boxed()
1164        });
1165
1166        let provider = Arc::new(mock);
1167        let service = create_test_service(provider);
1168
1169        let result = service
1170            .get_token_to_xlm_quote(
1171                "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
1172                1_000_000,
1173                0.5,
1174                None,
1175            )
1176            .await;
1177
1178        assert!(result.is_err());
1179        match result.unwrap_err() {
1180            StellarDexServiceError::ApiError { message } => {
1181                assert!(message.contains("router call failed"));
1182            }
1183            e => panic!("Expected ApiError, got {:?}", e),
1184        }
1185    }
1186
1187    // ==================== prepare_swap_transaction Tests ====================
1188
1189    #[tokio::test]
1190    async fn test_prepare_swap_transaction_token_to_native() {
1191        let mut mock = MockStellarProviderTrait::new();
1192
1193        let amounts: Vec<i128> = vec![1_000_000, 950_000];
1194        let sc_vals: Vec<ScVal> = amounts
1195            .iter()
1196            .map(|&a| SoroswapService::<MockStellarProviderTrait>::i128_to_scval(a))
1197            .collect();
1198        let sc_vec: ScVec = sc_vals.try_into().unwrap();
1199        let result_scval = ScVal::Vec(Some(sc_vec));
1200
1201        mock.expect_call_contract().returning(move |_, _, _| {
1202            let result = result_scval.clone();
1203            async move { Ok(result) }.boxed()
1204        });
1205
1206        let provider = Arc::new(mock);
1207        let service = create_test_service(provider);
1208
1209        let params = SwapTransactionParams {
1210            source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
1211            source_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
1212            destination_asset: "native".to_string(),
1213            amount: 1_000_000,
1214            slippage_percent: 0.5,
1215            network_passphrase: "Test SDF Network ; September 2015".to_string(),
1216            source_asset_decimals: Some(7),
1217            destination_asset_decimals: None,
1218        };
1219
1220        let (xdr, quote) = service.prepare_swap_transaction(params).await.unwrap();
1221
1222        assert!(!xdr.is_empty());
1223        assert_eq!(quote.out_amount, 950_000);
1224
1225        // Verify XDR is a valid TransactionEnvelope with InvokeHostFunction
1226        let envelope = TransactionEnvelope::from_xdr_base64(&xdr, Limits::none()).unwrap();
1227        match &envelope {
1228            TransactionEnvelope::Tx(env) => {
1229                assert_eq!(env.tx.operations.len(), 1);
1230                assert!(matches!(
1231                    env.tx.operations[0].body,
1232                    OperationBody::InvokeHostFunction(_)
1233                ));
1234                // Sequence should be 0 (placeholder)
1235                assert_eq!(env.tx.seq_num.0, 0);
1236            }
1237            _ => panic!("Expected Tx envelope"),
1238        }
1239    }
1240
1241    #[tokio::test]
1242    async fn test_prepare_swap_transaction_native_to_token() {
1243        let mut mock = MockStellarProviderTrait::new();
1244
1245        let amounts: Vec<i128> = vec![1_000_000, 1_050_000];
1246        let sc_vals: Vec<ScVal> = amounts
1247            .iter()
1248            .map(|&a| SoroswapService::<MockStellarProviderTrait>::i128_to_scval(a))
1249            .collect();
1250        let sc_vec: ScVec = sc_vals.try_into().unwrap();
1251        let result_scval = ScVal::Vec(Some(sc_vec));
1252
1253        mock.expect_call_contract().returning(move |_, _, _| {
1254            let result = result_scval.clone();
1255            async move { Ok(result) }.boxed()
1256        });
1257
1258        let provider = Arc::new(mock);
1259        let service = create_test_service(provider);
1260
1261        let params = SwapTransactionParams {
1262            source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
1263            source_asset: "native".to_string(),
1264            destination_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
1265                .to_string(),
1266            amount: 1_000_000,
1267            slippage_percent: 0.5,
1268            network_passphrase: "Test SDF Network ; September 2015".to_string(),
1269            source_asset_decimals: None,
1270            destination_asset_decimals: Some(7),
1271        };
1272
1273        let (xdr, quote) = service.prepare_swap_transaction(params).await.unwrap();
1274
1275        assert!(!xdr.is_empty());
1276        assert_eq!(quote.out_amount, 1_050_000);
1277
1278        // Verify XDR is valid
1279        let envelope = TransactionEnvelope::from_xdr_base64(&xdr, Limits::none()).unwrap();
1280        match &envelope {
1281            TransactionEnvelope::Tx(env) => {
1282                assert_eq!(env.tx.operations.len(), 1);
1283                assert!(matches!(
1284                    env.tx.operations[0].body,
1285                    OperationBody::InvokeHostFunction(_)
1286                ));
1287            }
1288            _ => panic!("Expected Tx envelope"),
1289        }
1290    }
1291
1292    #[tokio::test]
1293    async fn test_prepare_swap_transaction_token_to_token_not_supported() {
1294        let provider = create_mock_provider();
1295        let service = create_test_service(provider);
1296
1297        let params = SwapTransactionParams {
1298            source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
1299            source_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
1300            destination_asset: "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"
1301                .to_string(),
1302            amount: 1_000_000,
1303            slippage_percent: 0.5,
1304            network_passphrase: "Test SDF Network ; September 2015".to_string(),
1305            source_asset_decimals: Some(7),
1306            destination_asset_decimals: Some(7),
1307        };
1308
1309        let result = service.prepare_swap_transaction(params).await;
1310
1311        assert!(result.is_err());
1312        match result.unwrap_err() {
1313            StellarDexServiceError::InvalidAssetIdentifier(msg) => {
1314                assert!(msg.contains("only supports swaps involving native XLM"));
1315            }
1316            e => panic!("Expected InvalidAssetIdentifier, got {:?}", e),
1317        }
1318    }
1319
1320    // ==================== parse_account_to_sc_address Tests ====================
1321
1322    #[test]
1323    fn test_parse_account_to_sc_address_valid() {
1324        let addr = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
1325        let result = SoroswapService::<MockStellarProviderTrait>::parse_account_to_sc_address(addr);
1326        assert!(result.is_ok());
1327        match result.unwrap() {
1328            ScAddress::Account(_) => {}
1329            _ => panic!("Expected Account address"),
1330        }
1331    }
1332
1333    #[test]
1334    fn test_parse_account_to_sc_address_invalid() {
1335        let addr = "INVALID";
1336        let result = SoroswapService::<MockStellarProviderTrait>::parse_account_to_sc_address(addr);
1337        assert!(result.is_err());
1338    }
1339
1340    // ==================== build_swap_transaction_xdr Tests ====================
1341
1342    #[test]
1343    fn test_build_swap_transaction_xdr_token_to_xlm() {
1344        let provider = create_mock_provider();
1345        let service = create_test_service(provider);
1346
1347        let quote = StellarQuoteResponse {
1348            input_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
1349            output_asset: "native".to_string(),
1350            in_amount: 1_000_000,
1351            out_amount: 950_000,
1352            price_impact_pct: 0.0,
1353            slippage_bps: 50,
1354            path: None,
1355        };
1356
1357        let params = SwapTransactionParams {
1358            source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
1359            source_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
1360            destination_asset: "native".to_string(),
1361            amount: 1_000_000,
1362            slippage_percent: 0.5,
1363            network_passphrase: "Test SDF Network ; September 2015".to_string(),
1364            source_asset_decimals: Some(7),
1365            destination_asset_decimals: None,
1366        };
1367
1368        let xdr = service.build_swap_transaction_xdr(&params, &quote).unwrap();
1369
1370        // Verify the XDR parses correctly
1371        let envelope = TransactionEnvelope::from_xdr_base64(&xdr, Limits::none()).unwrap();
1372        match &envelope {
1373            TransactionEnvelope::Tx(env) => {
1374                // Verify transaction structure
1375                assert_eq!(env.tx.operations.len(), 1);
1376                assert_eq!(env.tx.seq_num.0, 0); // Placeholder
1377                assert_eq!(env.tx.fee, STELLAR_DEFAULT_TRANSACTION_FEE);
1378                assert!(env.signatures.is_empty()); // Unsigned
1379
1380                // Verify it's an InvokeHostFunction with InvokeContract
1381                match &env.tx.operations[0].body {
1382                    OperationBody::InvokeHostFunction(op) => {
1383                        match &op.host_function {
1384                            HostFunction::InvokeContract(args) => {
1385                                // Verify router contract address
1386                                match &args.contract_address {
1387                                    ScAddress::Contract(_) => {}
1388                                    _ => panic!("Expected Contract address for router"),
1389                                }
1390                                // Verify function name
1391                                assert_eq!(
1392                                    args.function_name.to_string(),
1393                                    "swap_exact_tokens_for_tokens"
1394                                );
1395                                // Verify 5 arguments
1396                                assert_eq!(args.args.len(), 5);
1397                            }
1398                            _ => panic!("Expected InvokeContract"),
1399                        }
1400                        // Auth should be empty (simulation fills it)
1401                        assert!(op.auth.is_empty());
1402                    }
1403                    _ => panic!("Expected InvokeHostFunction"),
1404                }
1405
1406                // Verify time bounds
1407                match &env.tx.cond {
1408                    Preconditions::Time(tb) => {
1409                        assert_eq!(tb.min_time.0, 0);
1410                        assert!(tb.max_time.0 > 0);
1411                    }
1412                    _ => panic!("Expected Time preconditions"),
1413                }
1414            }
1415            _ => panic!("Expected Tx envelope"),
1416        }
1417    }
1418
1419    #[test]
1420    fn test_build_swap_transaction_xdr_native_to_token() {
1421        let provider = create_mock_provider();
1422        let service = create_test_service(provider);
1423
1424        let quote = StellarQuoteResponse {
1425            input_asset: "native".to_string(),
1426            output_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
1427            in_amount: 1_000_000,
1428            out_amount: 1_050_000,
1429            price_impact_pct: 0.0,
1430            slippage_bps: 50,
1431            path: None,
1432        };
1433
1434        let params = SwapTransactionParams {
1435            source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
1436            source_asset: "native".to_string(),
1437            destination_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
1438                .to_string(),
1439            amount: 1_000_000,
1440            slippage_percent: 0.5,
1441            network_passphrase: "Test SDF Network ; September 2015".to_string(),
1442            source_asset_decimals: None,
1443            destination_asset_decimals: Some(7),
1444        };
1445
1446        let xdr = service.build_swap_transaction_xdr(&params, &quote).unwrap();
1447
1448        // Verify valid XDR with InvokeHostFunction
1449        let envelope = TransactionEnvelope::from_xdr_base64(&xdr, Limits::none()).unwrap();
1450        match &envelope {
1451            TransactionEnvelope::Tx(env) => {
1452                assert_eq!(env.tx.operations.len(), 1);
1453                match &env.tx.operations[0].body {
1454                    OperationBody::InvokeHostFunction(op) => match &op.host_function {
1455                        HostFunction::InvokeContract(args) => {
1456                            assert_eq!(
1457                                args.function_name.to_string(),
1458                                "swap_exact_tokens_for_tokens"
1459                            );
1460                            assert_eq!(args.args.len(), 5);
1461                        }
1462                        _ => panic!("Expected InvokeContract"),
1463                    },
1464                    _ => panic!("Expected InvokeHostFunction"),
1465                }
1466            }
1467            _ => panic!("Expected Tx envelope"),
1468        }
1469    }
1470
1471    #[test]
1472    fn test_build_swap_transaction_xdr_invalid_source_account() {
1473        let provider = create_mock_provider();
1474        let service = create_test_service(provider);
1475
1476        let quote = StellarQuoteResponse {
1477            input_asset: "native".to_string(),
1478            output_asset: "native".to_string(),
1479            in_amount: 1_000_000,
1480            out_amount: 1_000_000,
1481            price_impact_pct: 0.0,
1482            slippage_bps: 50,
1483            path: None,
1484        };
1485
1486        let params = SwapTransactionParams {
1487            source_account: "INVALID_ACCOUNT".to_string(),
1488            source_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
1489            destination_asset: "native".to_string(),
1490            amount: 1_000_000,
1491            slippage_percent: 0.5,
1492            network_passphrase: "Test SDF Network ; September 2015".to_string(),
1493            source_asset_decimals: None,
1494            destination_asset_decimals: None,
1495        };
1496
1497        let result = service.build_swap_transaction_xdr(&params, &quote);
1498        assert!(result.is_err());
1499    }
1500
1501    #[test]
1502    fn test_build_swap_transaction_xdr_slippage_calculation() {
1503        let provider = create_mock_provider();
1504        let service = create_test_service(provider);
1505
1506        // With 100 bps (1%) slippage on 1_000_000 output, min should be 990_000
1507        let quote = StellarQuoteResponse {
1508            input_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
1509            output_asset: "native".to_string(),
1510            in_amount: 1_000_000,
1511            out_amount: 1_000_000,
1512            price_impact_pct: 0.0,
1513            slippage_bps: 100, // 1%
1514            path: None,
1515        };
1516
1517        let params = SwapTransactionParams {
1518            source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
1519            source_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC".to_string(),
1520            destination_asset: "native".to_string(),
1521            amount: 1_000_000,
1522            slippage_percent: 1.0,
1523            network_passphrase: "Test SDF Network ; September 2015".to_string(),
1524            source_asset_decimals: Some(7),
1525            destination_asset_decimals: None,
1526        };
1527
1528        let xdr = service.build_swap_transaction_xdr(&params, &quote).unwrap();
1529
1530        // Parse and verify amount_out_min argument
1531        let envelope = TransactionEnvelope::from_xdr_base64(&xdr, Limits::none()).unwrap();
1532        match &envelope {
1533            TransactionEnvelope::Tx(env) => {
1534                match &env.tx.operations[0].body {
1535                    OperationBody::InvokeHostFunction(op) => {
1536                        match &op.host_function {
1537                            HostFunction::InvokeContract(args) => {
1538                                // args[1] is amount_out_min
1539                                let amount_out_min =
1540                                    SoroswapService::<MockStellarProviderTrait>::scval_to_i128(
1541                                        &args.args[1],
1542                                    )
1543                                    .unwrap();
1544                                // 1_000_000 * (10000 - 100) / 10000 = 990_000
1545                                assert_eq!(amount_out_min, 990_000);
1546                            }
1547                            _ => panic!("Expected InvokeContract"),
1548                        }
1549                    }
1550                    _ => panic!("Expected InvokeHostFunction"),
1551                }
1552            }
1553            _ => panic!("Expected Tx envelope"),
1554        }
1555    }
1556
1557    // ==================== execute_swap Tests ====================
1558
1559    #[tokio::test]
1560    async fn test_execute_swap_not_implemented() {
1561        let provider = create_mock_provider();
1562        let service = create_test_service(provider);
1563
1564        let params = SwapTransactionParams {
1565            source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".to_string(),
1566            source_asset: "native".to_string(),
1567            destination_asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
1568                .to_string(),
1569            amount: 1_000_000,
1570            slippage_percent: 0.5,
1571            network_passphrase: "Test SDF Network ; September 2015".to_string(),
1572            source_asset_decimals: None,
1573            destination_asset_decimals: Some(7),
1574        };
1575
1576        let result = service.execute_swap(params).await;
1577
1578        assert!(result.is_err());
1579        match result.unwrap_err() {
1580            StellarDexServiceError::UnknownError(msg) => {
1581                assert!(msg.contains("not yet implemented"));
1582            }
1583            e => panic!("Expected UnknownError, got {:?}", e),
1584        }
1585    }
1586
1587    // ==================== Price Impact Calculation Tests ====================
1588
1589    #[tokio::test]
1590    async fn test_price_impact_calculation() {
1591        let mut mock = MockStellarProviderTrait::new();
1592
1593        // 10% price impact: in 1_000_000, out 900_000
1594        let amounts: Vec<i128> = vec![1_000_000, 900_000];
1595        let sc_vals: Vec<ScVal> = amounts
1596            .iter()
1597            .map(|&a| SoroswapService::<MockStellarProviderTrait>::i128_to_scval(a))
1598            .collect();
1599        let sc_vec: ScVec = sc_vals.try_into().unwrap();
1600        let result_scval = ScVal::Vec(Some(sc_vec));
1601
1602        mock.expect_call_contract().returning(move |_, _, _| {
1603            let result = result_scval.clone();
1604            async move { Ok(result) }.boxed()
1605        });
1606
1607        let provider = Arc::new(mock);
1608        let service = create_test_service(provider);
1609
1610        let quote = service
1611            .get_token_to_xlm_quote(
1612                "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
1613                1_000_000,
1614                0.5,
1615                None,
1616            )
1617            .await
1618            .unwrap();
1619
1620        // Price impact is not calculated for token -> XLM quotes (returns 0.0)
1621        assert_eq!(quote.price_impact_pct, 0.0);
1622    }
1623}