openzeppelin_relayer/domain/relayer/stellar/
token_swap.rs

1//! Token swap implementation for Stellar relayers.
2//!
3//! This module implements the `StellarRelayerDexTrait` for Stellar relayers, providing
4//! token swap functionality for managing relayer token balances.
5
6use async_trait::async_trait;
7use futures::future::join_all;
8use tracing::{debug, error, info};
9
10use crate::constants::DEFAULT_CONVERSION_SLIPPAGE_PERCENTAGE;
11use crate::domain::relayer::{
12    Relayer, RelayerError, StellarRelayer, StellarRelayerDexTrait, SwapResult,
13};
14use crate::domain::transaction::stellar::token::get_token_balance;
15use crate::jobs::JobProducerTrait;
16use crate::models::transaction::request::StellarTransactionRequest;
17use crate::models::{
18    produce_stellar_dex_webhook_payload, NetworkTransactionRequest, RelayerRepoModel,
19    StellarDexPayload, StellarFeePaymentStrategy,
20};
21use crate::models::{NetworkRepoModel, TransactionRepoModel};
22use crate::repositories::{
23    NetworkRepository, RelayerRepository, Repository, TransactionRepository,
24};
25use crate::services::provider::StellarProviderTrait;
26use crate::services::signer::StellarSignTrait;
27use crate::services::stellar_dex::{StellarDexServiceTrait, SwapTransactionParams};
28use crate::services::TransactionCounterServiceTrait;
29
30#[async_trait]
31impl<P, RR, NR, TR, J, TCS, S, D> StellarRelayerDexTrait
32    for StellarRelayer<P, RR, NR, TR, J, TCS, S, D>
33where
34    P: StellarProviderTrait + Send + Sync,
35    D: StellarDexServiceTrait + Send + Sync + 'static,
36    RR: Repository<RelayerRepoModel, String> + RelayerRepository + Send + Sync + 'static,
37    NR: NetworkRepository + Repository<NetworkRepoModel, String> + Send + Sync + 'static,
38    TR: Repository<TransactionRepoModel, String> + TransactionRepository + Send + Sync + 'static,
39    J: JobProducerTrait + Send + Sync + 'static,
40    TCS: TransactionCounterServiceTrait + Send + Sync + 'static,
41    S: StellarSignTrait + Send + Sync + 'static,
42{
43    /// Processes a token swap request for the given relayer ID:
44    ///
45    /// 1. Loads the relayer's policy (must include swap_config & strategy).
46    /// 2. Iterates allowed tokens, checking balances and calculating swap amounts.
47    /// 3. Executes swaps through the DEX service (Paths service).
48    /// 4. Collects and returns all `SwapResult`s (empty if no swaps were needed).
49    ///
50    /// Returns a `RelayerError` on any repository, provider, or swap execution failure.
51    async fn handle_token_swap_request(
52        &self,
53        relayer_id: String,
54    ) -> Result<Vec<SwapResult>, RelayerError> {
55        debug!("handling token swap request for relayer {}", relayer_id);
56        let relayer = self
57            .relayer_repository
58            .get_by_id(relayer_id.clone())
59            .await?;
60
61        let policy = relayer.policies.get_stellar_policy();
62
63        // Token swaps are only supported for user fee payment strategy
64        // This ensures swaps are only performed when users pay fees in tokens
65        if !matches!(
66            policy.fee_payment_strategy,
67            Some(StellarFeePaymentStrategy::User)
68        ) {
69            debug!(
70                %relayer_id,
71                "Token swap is only supported for user fee payment strategy; Exiting."
72            );
73            return Ok(vec![]);
74        }
75
76        let swap_config = match policy.get_swap_config() {
77            Some(config) => config,
78            None => {
79                debug!(%relayer_id, "No swap configuration specified for relayer; Exiting.");
80                return Ok(vec![]);
81            }
82        };
83
84        let strategies = &swap_config.strategies;
85        if strategies.is_empty() {
86            debug!(%relayer_id, "No swap strategies specified for relayer; Exiting.");
87            return Ok(vec![]);
88        }
89
90        // Get allowed tokens and calculate swap amounts
91        let tokens_to_swap = {
92            let mut eligible_tokens = Vec::new();
93
94            let allowed_tokens = policy.get_allowed_tokens();
95            if allowed_tokens.is_empty() {
96                debug!(%relayer_id, "No allowed tokens configured for swap");
97                return Ok(vec![]);
98            }
99
100            for token in &allowed_tokens {
101                // Fetch token balance - continue on error for individual tokens
102                let token_balance =
103                    match get_token_balance(&self.provider, &relayer.address, &token.asset).await {
104                        Ok(balance) => balance,
105                        Err(e) => {
106                            error!(
107                                %relayer_id,
108                                token = %token.asset,
109                                error = %e,
110                                "Failed to get token balance, skipping this token"
111                            );
112                            continue;
113                        }
114                    };
115
116                // Calculate swap amount based on configuration
117                let swap_amount = calculate_swap_amount(
118                    token_balance,
119                    token
120                        .swap_config
121                        .as_ref()
122                        .and_then(|config| config.min_amount),
123                    token
124                        .swap_config
125                        .as_ref()
126                        .and_then(|config| config.max_amount),
127                    token
128                        .swap_config
129                        .as_ref()
130                        .and_then(|config| config.retain_min_amount),
131                )
132                .unwrap_or(0);
133
134                if swap_amount > 0 {
135                    debug!(%relayer_id, token = ?token.asset, "token swap eligible for token");
136
137                    // Store token asset and swap amount (clone necessary data)
138                    eligible_tokens.push((
139                        token.asset.clone(),
140                        swap_amount,
141                        token
142                            .swap_config
143                            .as_ref()
144                            .and_then(|config| config.slippage_percentage)
145                            .unwrap_or(DEFAULT_CONVERSION_SLIPPAGE_PERCENTAGE),
146                    ));
147                }
148            }
149
150            eligible_tokens
151        };
152        let network_passphrase = self.network.passphrase.clone();
153        let relayer_network = relayer.network.clone();
154
155        // Prepare swap transactions for every eligible token
156        // Transactions are queued for background processing through the gate mechanism
157        // Sequence numbers will be managed by the transaction pipeline during preparation
158        // This ensures swaps don't conflict with other transactions in the pipeline
159        // The strategy router will automatically select the appropriate DEX service
160        // based on asset type and configured strategies
161        let swap_prep_futures: Vec<_> = tokens_to_swap
162            .iter()
163            .filter_map(|(token_asset, swap_amount, slippage_percent)| {
164                // Check if any configured strategy can handle this asset
165                if !self.dex_service.can_handle_asset(token_asset) {
166                    debug!(
167                        %relayer_id,
168                        token = ?token_asset,
169                        "Skipping token swap - no configured strategy can handle this asset type"
170                    );
171                    return None;
172                }
173
174                let token_asset = token_asset.clone();
175                let dex_service = self.dex_service.clone();
176                let relayer_address = relayer.address.clone();
177                let relayer_id_clone = relayer_id.clone();
178                let slippage_percent = *slippage_percent;
179                let network_passphrase = network_passphrase.clone();
180                let token_decimals = policy.get_allowed_token_decimals(&token_asset);
181                let swap_amount_clone = *swap_amount;
182
183                Some(async move {
184                    info!(
185                        "Preparing swap transaction for {} tokens of type {} for relayer: {}",
186                        swap_amount_clone, token_asset, relayer_id_clone
187                    );
188
189                    // Prepare swap transaction parameters
190                    // Note: Sequence number is not set here - it will be managed by the transaction pipeline
191                    // when the transaction goes through the prepare phase via the gate mechanism
192                    let swap_params = SwapTransactionParams {
193                        source_account: relayer_address.clone(),
194                        source_asset: token_asset.clone(),
195                        destination_asset: "native".to_string(), // Always swap to XLM
196                        amount: swap_amount_clone,
197                        slippage_percent,
198                        network_passphrase: network_passphrase.clone(),
199                        source_asset_decimals: token_decimals,
200                        destination_asset_decimals: Some(7), // XLM always has 7 decimals
201                    };
202
203                    // Prepare swap transaction (get quote and build XDR) without executing
204                    // The transaction will be queued for background processing through the gate mechanism
205                    dex_service
206                        .prepare_swap_transaction(swap_params)
207                        .await
208                        .map(|(xdr, quote)| (token_asset.clone(), swap_amount_clone, quote, xdr))
209                        .map_err(|e| {
210                            // Convert error and include token info for better error handling
211                            RelayerError::Internal(format!(
212                                "Failed to prepare swap transaction for token {token_asset} (amount {swap_amount_clone}): {e}",
213                            ))
214                        })
215                })
216            })
217            .collect();
218
219        // Prepare all swap transactions concurrently
220        // Use join_all instead of try_join_all to collect all results (successes and failures)
221        // This allows processing to continue even if some swaps fail
222        let swap_prep_results = join_all(swap_prep_futures).await;
223
224        // Queue each prepared swap transaction for background processing
225        // This ensures swaps go through the same gate mechanism as regular transactions
226        let mut swap_results = Vec::new();
227        for result in swap_prep_results {
228            match result {
229                Ok((token_asset, swap_amount, quote, xdr)) => {
230                    // Create transaction request and queue for background processing
231                    let stellar_request = StellarTransactionRequest {
232                        source_account: Some(relayer.address.clone()),
233                        network: relayer_network.clone(),
234                        operations: None,
235                        memo: None,
236                        valid_until: None,
237                        transaction_xdr: Some(xdr),
238                        fee_bump: None,
239                        max_fee: None,
240                        signed_auth_entry: None,
241                    };
242
243                    let network_request = NetworkTransactionRequest::Stellar(stellar_request);
244
245                    // Queue the swap transaction for background processing
246                    // This will go through the gate mechanism and be processed by the transaction handler
247                    match self.process_transaction_request(network_request).await {
248                        Ok(transaction_model) => {
249                            info!(
250                                "Swap transaction queued for relayer: {}. Token: {}, Amount: {}, Destination: {}, Transaction ID: {}",
251                                relayer_id, token_asset, swap_amount, quote.out_amount, transaction_model.id
252                            );
253
254                            swap_results.push(SwapResult {
255                                mint: token_asset,
256                                source_amount: swap_amount,
257                                destination_amount: quote.out_amount,
258                                transaction_signature: transaction_model.id, // Use transaction ID instead of hash
259                                error: None,
260                            });
261                        }
262                        Err(e) => {
263                            error!(
264                                "Error queueing swap transaction for relayer: {}. Token: {}, Error: {}",
265                                relayer_id, token_asset, e
266                            );
267                            swap_results.push(SwapResult {
268                                mint: token_asset,
269                                source_amount: swap_amount,
270                                destination_amount: 0,
271                                transaction_signature: "".to_string(),
272                                error: Some(format!("Failed to queue transaction: {e}")),
273                            });
274                        }
275                    }
276                }
277                Err(e) => {
278                    // Log error but continue processing other swaps
279                    // The error message already includes token and amount info from map_err above
280                    error!(
281                        %relayer_id,
282                        error = %e,
283                        "Failed to prepare swap transaction, skipping this token"
284                    );
285                    // Extract token and amount from error message
286                    // Error format: "Failed to prepare swap transaction for token {token} (amount {amount}): {error}"
287                    let error_msg = e.to_string();
288                    let token_asset = error_msg
289                        .split("token ")
290                        .nth(1)
291                        .and_then(|s| s.split(" (amount ").next())
292                        .unwrap_or("unknown")
293                        .to_string();
294                    let swap_amount = error_msg
295                        .split("(amount ")
296                        .nth(1)
297                        .and_then(|s| s.split(")").next())
298                        .and_then(|s| s.parse::<u64>().ok())
299                        .unwrap_or(0);
300
301                    swap_results.push(SwapResult {
302                        mint: token_asset,
303                        source_amount: swap_amount,
304                        destination_amount: 0,
305                        transaction_signature: String::new(),
306                        error: Some(error_msg),
307                    });
308                }
309            }
310        }
311
312        if !swap_results.is_empty() {
313            let queued_count = swap_results
314                .iter()
315                .filter(|result| result.error.is_none())
316                .count();
317            let failed_count = swap_results.len() - queued_count;
318
319            info!(
320                "Queued {} swap transactions for relayer {} ({} successful, {} failed). \
321                 Each transaction will send its own status notification when processed.",
322                swap_results.len(),
323                relayer_id,
324                queued_count,
325                failed_count
326            );
327
328            // Send notification with transaction IDs for tracking queued swaps
329            // Transaction IDs are included in SwapResult.transaction_signature field
330            // This allows users to track which transactions were queued
331            // Each transaction will also send its own status notification when processed
332            if let Some(notification_id) = &relayer.notification_id {
333                // Only send notification if we have at least one successfully queued swap
334                let has_queued_swaps = swap_results.iter().any(|result| {
335                    result.error.is_none() && !result.transaction_signature.is_empty()
336                });
337
338                if has_queued_swaps {
339                    let webhook_result = self
340                        .job_producer
341                        .produce_send_notification_job(
342                            produce_stellar_dex_webhook_payload(
343                                notification_id,
344                                "stellar_dex_queued".to_string(),
345                                StellarDexPayload {
346                                    swap_results: swap_results.clone(),
347                                },
348                            ),
349                            None,
350                        )
351                        .await;
352
353                    if let Err(e) = webhook_result {
354                        error!(error = %e, "failed to produce swap queued notification job");
355                    }
356                }
357            }
358        }
359
360        Ok(swap_results)
361    }
362}
363
364/// Calculate swap amount based on current balance and swap configuration
365///
366/// This function determines how much of a token should be swapped based on:
367/// - Maximum swap amount (caps the swap)
368/// - Retain minimum amount (ensures minimum balance is retained)
369/// - Minimum swap amount (ensures swap meets minimum requirement)
370///
371/// Returns 0 if swap should not be performed (e.g., balance too low, below minimum)
372fn calculate_swap_amount(
373    current_balance: u64,
374    min_amount: Option<u64>,
375    max_amount: Option<u64>,
376    retain_min: Option<u64>,
377) -> Result<u64, RelayerError> {
378    // Cap the swap amount at the maximum if specified
379    let mut amount = max_amount
380        .map(|max| std::cmp::min(current_balance, max))
381        .unwrap_or(current_balance);
382
383    // Adjust for retain minimum if specified
384    if let Some(retain) = retain_min {
385        if current_balance > retain {
386            amount = std::cmp::min(amount, current_balance - retain);
387        } else {
388            // Not enough to retain the minimum after swap
389            return Ok(0);
390        }
391    }
392
393    // Check if we have enough tokens to meet minimum swap requirement
394    if let Some(min) = min_amount {
395        if amount < min {
396            return Ok(0); // Not enough tokens to swap
397        }
398    }
399
400    Ok(amount)
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::{
407        config::{NetworkConfigCommon, StellarNetworkConfig},
408        domain::stellar::parse_account_id,
409        jobs::MockJobProducerTrait,
410        models::{
411            NetworkConfigData, NetworkRepoModel, NetworkType, RelayerNetworkPolicy,
412            RelayerRepoModel, RelayerStellarPolicy, RelayerStellarSwapConfig, RpcConfig,
413            StellarAllowedTokensPolicy, StellarAllowedTokensSwapConfig, StellarFeePaymentStrategy,
414            StellarSwapStrategy,
415        },
416        repositories::{
417            InMemoryNetworkRepository, MockRelayerRepository, MockTransactionRepository,
418        },
419        services::{
420            provider::MockStellarProviderTrait, signer::MockStellarSignTrait,
421            stellar_dex::MockStellarDexServiceTrait, MockTransactionCounterServiceTrait,
422        },
423    };
424    use mockall::predicate::*;
425    use soroban_rs::xdr::{
426        AccountEntry, AccountEntryExt, AccountId, PublicKey, SequenceNumber, String32, Thresholds,
427        Uint256, VecM, WriteXdr,
428    };
429    use std::future::ready;
430    use std::sync::Arc;
431
432    const TEST_PK: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
433    const TEST_NETWORK_PASSPHRASE: &str = "Test SDF Network ; September 2015";
434    const USDC_ASSET: &str = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
435
436    /// Helper function to create a mock provider with trustline balance for USDC
437    fn create_mock_provider_with_usdc_balance(balance: i64) -> MockStellarProviderTrait {
438        let mut provider = MockStellarProviderTrait::new();
439        provider.expect_get_ledger_entries().returning(move |keys| {
440            use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
441            use soroban_rs::xdr::{
442                LedgerEntry, LedgerEntryData, LedgerEntryExt, LedgerKey, TrustLineAsset,
443                TrustLineEntry, TrustLineEntryExt, WriteXdr,
444            };
445
446            // Extract account_id and asset from the ledger key
447            let (account_id, asset) = if let Some(LedgerKey::Trustline(trustline_key)) =
448                keys.first()
449            {
450                (
451                    trustline_key.account_id.clone(),
452                    trustline_key.asset.clone(),
453                )
454            } else {
455                // Fallback
456                let fallback_account = parse_account_id(TEST_PK).unwrap_or_else(|_| {
457                    AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
458                });
459                let fallback_issuer =
460                    parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
461                        .unwrap_or_else(|_| {
462                            AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])))
463                        });
464                let fallback_asset = TrustLineAsset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
465                    asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
466                    issuer: fallback_issuer,
467                });
468                (fallback_account, fallback_asset)
469            };
470
471            let trustline_entry = TrustLineEntry {
472                account_id,
473                asset,
474                balance,
475                limit: i64::MAX,
476                flags: 0,
477                ext: TrustLineEntryExt::V0,
478            };
479
480            let ledger_entry = LedgerEntry {
481                last_modified_ledger_seq: 0,
482                data: LedgerEntryData::Trustline(trustline_entry),
483                ext: LedgerEntryExt::V0,
484            };
485
486            // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
487            let xdr_base64 = ledger_entry
488                .data
489                .to_xdr_base64(soroban_rs::xdr::Limits::none())
490                .expect("Failed to encode trustline entry data to XDR");
491
492            Box::pin(ready(Ok(GetLedgerEntriesResponse {
493                entries: Some(vec![LedgerEntryResult {
494                    key: String::new(),
495                    xdr: xdr_base64,
496                    last_modified_ledger: 1000,
497                    live_until_ledger_seq_ledger_seq: None,
498                }]),
499                latest_ledger: 1000,
500            })))
501        });
502
503        // Mock get_account for sequence sync and XLM balance check
504        provider.expect_get_account().returning(|_| {
505            Box::pin(ready(Ok(AccountEntry {
506                account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
507                balance: 100_000_000,         // 10 XLM (with 7 decimals)
508                seq_num: SequenceNumber(100), // Non-zero sequence for sync_sequence
509                num_sub_entries: 0,
510                inflation_dest: None,
511                flags: 0,
512                home_domain: String32::default(),
513                thresholds: Thresholds([0; 4]),
514                signers: VecM::default(),
515                ext: AccountEntryExt::V0,
516            })))
517        });
518
519        provider
520    }
521
522    /// Helper function to create a test relayer with user fee payment strategy and swap config
523    fn create_test_relayer_with_swap_config() -> RelayerRepoModel {
524        let mut policy = RelayerStellarPolicy::default();
525        policy.fee_payment_strategy = Some(StellarFeePaymentStrategy::User);
526        policy.swap_config = Some(RelayerStellarSwapConfig {
527            strategies: vec![StellarSwapStrategy::OrderBook],
528            min_balance_threshold: None,
529            cron_schedule: None,
530        });
531        policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
532            asset: USDC_ASSET.to_string(),
533            metadata: None,
534            max_allowed_fee: None,
535            swap_config: Some(StellarAllowedTokensSwapConfig {
536                min_amount: Some(1000000),
537                max_amount: Some(100000000),
538                retain_min_amount: Some(1000000),
539                slippage_percentage: Some(1.0),
540            }),
541        }]);
542
543        RelayerRepoModel {
544            id: "test-relayer-id".to_string(),
545            name: "Test Relayer".to_string(),
546            network: "testnet".to_string(),
547            paused: false,
548            network_type: NetworkType::Stellar,
549            signer_id: "signer-id".to_string(),
550            policies: RelayerNetworkPolicy::Stellar(policy),
551            address: TEST_PK.to_string(),
552            notification_id: Some("notification-id".to_string()),
553            system_disabled: false,
554            custom_rpc_urls: None,
555            ..Default::default()
556        }
557    }
558
559    /// Helper function to create a mock DEX service
560    fn create_mock_dex_service() -> Arc<MockStellarDexServiceTrait> {
561        let mut mock_dex = MockStellarDexServiceTrait::new();
562        mock_dex.expect_supported_asset_types().returning(|| {
563            use crate::services::stellar_dex::AssetType;
564            std::collections::HashSet::from([AssetType::Native, AssetType::Classic])
565        });
566        mock_dex
567            .expect_can_handle_asset()
568            .returning(|asset| asset == USDC_ASSET || asset == "native");
569        Arc::new(mock_dex)
570    }
571
572    /// Helper function to create a test network
573    fn create_test_network() -> NetworkRepoModel {
574        NetworkRepoModel {
575            id: "stellar:testnet".to_string(),
576            name: "testnet".to_string(),
577            network_type: NetworkType::Stellar,
578            config: NetworkConfigData::Stellar(StellarNetworkConfig {
579                common: NetworkConfigCommon {
580                    network: "testnet".to_string(),
581                    from: None,
582                    rpc_urls: Some(vec![RpcConfig::new(
583                        "https://horizon-testnet.stellar.org".to_string(),
584                    )]),
585                    explorer_urls: None,
586                    average_blocktime_ms: Some(5000),
587                    is_testnet: Some(true),
588                    tags: None,
589                },
590                passphrase: Some(TEST_NETWORK_PASSPHRASE.to_string()),
591                horizon_url: Some("https://horizon-testnet.stellar.org".to_string()),
592            }),
593        }
594    }
595
596    /// Helper function to create a Stellar relayer instance for testing with customizable mocks
597    async fn create_test_relayer_with_mocks(
598        relayer_model: RelayerRepoModel,
599        provider: MockStellarProviderTrait,
600        dex_service: Arc<MockStellarDexServiceTrait>,
601        tx_job_result: Result<(), crate::jobs::JobProducerError>,
602        notification_job_result: Result<(), crate::jobs::JobProducerError>,
603    ) -> crate::domain::relayer::stellar::StellarRelayer<
604        MockStellarProviderTrait,
605        MockRelayerRepository,
606        InMemoryNetworkRepository,
607        MockTransactionRepository,
608        MockJobProducerTrait,
609        MockTransactionCounterServiceTrait,
610        MockStellarSignTrait,
611        MockStellarDexServiceTrait,
612    > {
613        let network_repository = Arc::new(InMemoryNetworkRepository::new());
614        let test_network = create_test_network();
615        network_repository.create(test_network).await.unwrap();
616
617        let mut relayer_repo = MockRelayerRepository::new();
618        let relayer_model_clone = relayer_model.clone();
619        relayer_repo
620            .expect_get_by_id()
621            .returning(move |_| Ok(relayer_model_clone.clone()));
622
623        // Mock update_policy for populate_allowed_tokens_metadata
624        let relayer_model_clone2 = relayer_model.clone();
625        relayer_repo
626            .expect_update_policy()
627            .returning(move |_, _| Ok(relayer_model_clone2.clone()));
628
629        // Mock enable_relayer and disable_relayer for check_health
630        let relayer_model_clone3 = relayer_model.clone();
631        relayer_repo
632            .expect_enable_relayer()
633            .returning(move |_| Ok(relayer_model_clone3.clone()));
634        let relayer_model_clone4 = relayer_model.clone();
635        relayer_repo
636            .expect_disable_relayer()
637            .returning(move |_, _| Ok(relayer_model_clone4.clone()));
638
639        let mut tx_repo = MockTransactionRepository::new();
640        tx_repo.expect_create().returning(|t| Ok(t.clone()));
641
642        let mut job_producer = MockJobProducerTrait::new();
643        job_producer
644            .expect_produce_transaction_request_job()
645            .returning({
646                let tx_job_result = tx_job_result.clone();
647                move |_, _| {
648                    let result = tx_job_result.clone();
649                    Box::pin(async move { result })
650                }
651            });
652        job_producer
653            .expect_produce_send_notification_job()
654            .returning({
655                let notification_job_result = notification_job_result.clone();
656                move |_, _| {
657                    let result = notification_job_result.clone();
658                    Box::pin(async move { result })
659                }
660            });
661        job_producer
662            .expect_produce_relayer_health_check_job()
663            .returning(|_, _| Box::pin(async { Ok(()) }));
664        job_producer
665            .expect_produce_check_transaction_status_job()
666            .returning(|_, _| Box::pin(async { Ok(()) }));
667
668        let mut counter = MockTransactionCounterServiceTrait::new();
669        counter
670            .expect_set()
671            .returning(|_| Box::pin(async { Ok(()) }));
672        let counter = Arc::new(counter);
673        let signer = Arc::new(MockStellarSignTrait::new());
674
675        crate::domain::relayer::stellar::StellarRelayer::new(
676            relayer_model,
677            signer,
678            provider,
679            crate::domain::relayer::stellar::StellarRelayerDependencies::new(
680                Arc::new(relayer_repo),
681                network_repository,
682                Arc::new(tx_repo),
683                counter,
684                Arc::new(job_producer),
685            ),
686            dex_service,
687        )
688        .await
689        .unwrap()
690    }
691
692    /// Helper function to create a Stellar relayer instance for testing
693    async fn create_test_relayer_instance(
694        relayer_model: RelayerRepoModel,
695        provider: MockStellarProviderTrait,
696        dex_service: Arc<MockStellarDexServiceTrait>,
697    ) -> crate::domain::relayer::stellar::StellarRelayer<
698        MockStellarProviderTrait,
699        MockRelayerRepository,
700        InMemoryNetworkRepository,
701        MockTransactionRepository,
702        MockJobProducerTrait,
703        MockTransactionCounterServiceTrait,
704        MockStellarSignTrait,
705        MockStellarDexServiceTrait,
706    > {
707        create_test_relayer_with_mocks(relayer_model, provider, dex_service, Ok(()), Ok(())).await
708    }
709
710    #[tokio::test]
711    async fn test_handle_token_swap_request_with_user_fee_strategy() {
712        let relayer_model = create_test_relayer_with_swap_config();
713        let provider = create_mock_provider_with_usdc_balance(5000000); // 5 USDC
714
715        let mut dex_service = MockStellarDexServiceTrait::new();
716        dex_service.expect_supported_asset_types().returning(|| {
717            use crate::services::stellar_dex::AssetType;
718            std::collections::HashSet::from([AssetType::Native, AssetType::Classic])
719        });
720        dex_service
721            .expect_can_handle_asset()
722            .returning(|asset| asset == USDC_ASSET || asset == "native");
723
724        // Mock prepare_swap_transaction
725        dex_service.expect_prepare_swap_transaction().returning(|_| {
726            Box::pin(ready(Ok((
727                "AAAAAgAAAACige4lTdwSB/sto4SniEdJ2kOa2X65s5bqkd40J4DjSwAAAAEAAHAkAAAADwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAKKB7iVN3BIH+y2jhKeIR0naQ5rZfrmzluqR3jQngONLAAAAAAAAAAAAD0JAAAAAAAAAAAA=".to_string(),
728                crate::services::stellar_dex::StellarQuoteResponse {
729                    input_asset: USDC_ASSET.to_string(),
730                    output_asset: "native".to_string(),
731                    in_amount: 40000000,
732                    out_amount: 10000000,
733                    price_impact_pct: 0.0,
734                    slippage_bps: 100,
735                    path: None,
736                },
737            ))))
738        });
739
740        let dex_service = Arc::new(dex_service);
741        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
742
743        let result = relayer
744            .handle_token_swap_request("test-relayer-id".to_string())
745            .await;
746
747        assert!(result.is_ok());
748        let swap_results = result.unwrap();
749
750        // Verify we have exactly one swap result
751        assert_eq!(swap_results.len(), 1);
752
753        let swap_result = &swap_results[0];
754
755        // Verify swap result content
756        assert_eq!(swap_result.mint, USDC_ASSET);
757        assert_eq!(swap_result.source_amount, 4000000); // 5M balance - 1M retain = 4M
758        assert_eq!(swap_result.destination_amount, 10000000);
759        assert!(swap_result.error.is_none());
760        assert!(!swap_result.transaction_signature.is_empty());
761
762        // Verify transaction signature format (should be a UUID-like string)
763        assert!(swap_result.transaction_signature.len() > 0);
764    }
765
766    #[tokio::test]
767    async fn test_handle_token_swap_request_with_relayer_fee_strategy() {
768        let mut relayer_model = create_test_relayer_with_swap_config();
769        // Change to Relayer fee payment strategy
770        if let RelayerNetworkPolicy::Stellar(ref mut policy) = relayer_model.policies {
771            policy.fee_payment_strategy = Some(StellarFeePaymentStrategy::Relayer);
772        }
773
774        let provider = MockStellarProviderTrait::new();
775        let dex_service = create_mock_dex_service();
776        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
777
778        let result = relayer
779            .handle_token_swap_request("test-relayer-id".to_string())
780            .await;
781
782        assert!(result.is_ok());
783        let swap_results = result.unwrap();
784        // Should return empty vector when fee payment strategy is not User
785        assert!(swap_results.is_empty());
786    }
787
788    #[tokio::test]
789    async fn test_handle_token_swap_request_no_swap_config() {
790        let mut relayer_model = create_test_relayer_with_swap_config();
791        // Remove swap config
792        if let RelayerNetworkPolicy::Stellar(ref mut policy) = relayer_model.policies {
793            policy.swap_config = None;
794        }
795
796        let provider = MockStellarProviderTrait::new();
797        let dex_service = create_mock_dex_service();
798        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
799
800        let result = relayer
801            .handle_token_swap_request("test-relayer-id".to_string())
802            .await;
803
804        assert!(result.is_ok());
805        let swap_results = result.unwrap();
806        // Should return empty vector when no swap config
807        assert!(swap_results.is_empty());
808    }
809
810    #[tokio::test]
811    async fn test_handle_token_swap_request_no_allowed_tokens() {
812        let mut relayer_model = create_test_relayer_with_swap_config();
813        // Remove allowed tokens
814        if let RelayerNetworkPolicy::Stellar(ref mut policy) = relayer_model.policies {
815            policy.allowed_tokens = Some(vec![]);
816        }
817
818        let provider = MockStellarProviderTrait::new();
819        let dex_service = create_mock_dex_service();
820        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
821
822        let result = relayer
823            .handle_token_swap_request("test-relayer-id".to_string())
824            .await;
825
826        assert!(result.is_ok());
827        let swap_results = result.unwrap();
828        // Should return empty vector when no allowed tokens
829        assert!(swap_results.is_empty());
830    }
831
832    #[tokio::test]
833    async fn test_handle_token_swap_request_balance_below_minimum() {
834        let relayer_model = create_test_relayer_with_swap_config();
835        let provider = create_mock_provider_with_usdc_balance(500000); // 0.5 USDC (below min_amount of 1 USDC)
836
837        let dex_service = create_mock_dex_service();
838        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
839
840        let result = relayer
841            .handle_token_swap_request("test-relayer-id".to_string())
842            .await;
843
844        assert!(result.is_ok());
845        let swap_results = result.unwrap();
846        // Should return empty vector when balance is below minimum
847        assert!(swap_results.is_empty());
848    }
849
850    #[tokio::test]
851    async fn test_handle_token_swap_request_token_balance_fetch_failure() {
852        let relayer_model = create_test_relayer_with_swap_config();
853        let mut provider = MockStellarProviderTrait::new();
854
855        // Mock get_ledger_entries to return an error
856        provider.expect_get_ledger_entries().returning(|_| {
857            Box::pin(ready(Err(crate::services::provider::ProviderError::Other(
858                "Connection failed".to_string(),
859            ))))
860        });
861
862        let dex_service = create_mock_dex_service();
863        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
864
865        let result = relayer
866            .handle_token_swap_request("test-relayer-id".to_string())
867            .await;
868
869        assert!(result.is_ok());
870        let swap_results = result.unwrap();
871        // Should return empty vector when token balance fetch fails
872        assert!(swap_results.is_empty());
873    }
874
875    #[tokio::test]
876    async fn test_handle_token_swap_request_dex_service_prepare_failure() {
877        let relayer_model = create_test_relayer_with_swap_config();
878        let provider = create_mock_provider_with_usdc_balance(50000000); // 5 USDC
879
880        let mut dex_service = MockStellarDexServiceTrait::new();
881        dex_service.expect_supported_asset_types().returning(|| {
882            use crate::services::stellar_dex::AssetType;
883            std::collections::HashSet::from([AssetType::Native, AssetType::Classic])
884        });
885        dex_service
886            .expect_can_handle_asset()
887            .returning(|asset| asset == USDC_ASSET || asset == "native");
888
889        // Mock prepare_swap_transaction to fail
890        dex_service
891            .expect_prepare_swap_transaction()
892            .returning(|_| {
893                Box::pin(ready(Err(
894                    crate::services::stellar_dex::StellarDexServiceError::ApiError {
895                        message: "Insufficient liquidity".to_string(),
896                    },
897                )))
898            });
899
900        let dex_service = Arc::new(dex_service);
901        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
902
903        let result = relayer
904            .handle_token_swap_request("test-relayer-id".to_string())
905            .await;
906
907        assert!(result.is_ok());
908        let swap_results = result.unwrap();
909        // Should have one failed swap result
910        assert_eq!(swap_results.len(), 1);
911        assert!(swap_results[0].error.is_some());
912        assert_eq!(swap_results[0].source_amount, 49000000); // 50M - 1M retain = 49M
913        assert_eq!(swap_results[0].destination_amount, 0);
914        assert!(swap_results[0].transaction_signature.is_empty());
915    }
916
917    #[tokio::test]
918    async fn test_handle_token_swap_request_transaction_processing_failure() {
919        let relayer_model = create_test_relayer_with_swap_config();
920        let provider = create_mock_provider_with_usdc_balance(5000000); // 5 USDC
921
922        let mut dex_service = MockStellarDexServiceTrait::new();
923        dex_service.expect_supported_asset_types().returning(|| {
924            use crate::services::stellar_dex::AssetType;
925            std::collections::HashSet::from([AssetType::Native, AssetType::Classic])
926        });
927        dex_service
928            .expect_can_handle_asset()
929            .returning(|asset| asset == USDC_ASSET || asset == "native");
930
931        // Mock prepare_swap_transaction
932        dex_service.expect_prepare_swap_transaction().returning(|_| {
933            Box::pin(ready(Ok((
934                "AAAAAgAAAACige4lTdwSB/sto4SniEdJ2kOa2X65s5bqkd40J4DjSwAAAAEAAHAkAAAADwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAKKB7iVN3BIH+y2jhKeIR0naQ5rZfrmzluqR3jQngONLAAAAAAAAAAAAD0JAAAAAAAAAAAA=".to_string(),
935                crate::services::stellar_dex::StellarQuoteResponse {
936                    input_asset: USDC_ASSET.to_string(),
937                    output_asset: "native".to_string(),
938                    in_amount: 40000000,
939                    out_amount: 10000000,
940                    price_impact_pct: 0.0,
941                    slippage_bps: 100,
942                    path: None,
943                },
944            ))))
945        });
946
947        let dex_service = Arc::new(dex_service);
948        let relayer = create_test_relayer_with_mocks(
949            relayer_model,
950            provider,
951            dex_service,
952            Err(crate::jobs::JobProducerError::QueueError(
953                "Queue full".to_string(),
954            )),
955            Ok(()),
956        )
957        .await;
958
959        let result = relayer
960            .handle_token_swap_request("test-relayer-id".to_string())
961            .await;
962
963        assert!(result.is_ok());
964        let swap_results = result.unwrap();
965        // Should have one failed swap result
966        assert_eq!(swap_results.len(), 1);
967        assert!(swap_results[0].error.is_some());
968        assert!(swap_results[0]
969            .error
970            .as_ref()
971            .unwrap()
972            .contains("Failed to queue transaction"));
973        assert_eq!(swap_results[0].source_amount, 4000000); // 5M - 1M retain = 4M
974        assert_eq!(swap_results[0].destination_amount, 0);
975        assert!(swap_results[0].transaction_signature.is_empty());
976    }
977
978    #[tokio::test]
979    async fn test_handle_token_swap_request_notification_failure() {
980        let relayer_model = create_test_relayer_with_swap_config();
981        let provider = create_mock_provider_with_usdc_balance(5000000); // 5 USDC
982
983        let mut dex_service = MockStellarDexServiceTrait::new();
984        dex_service.expect_supported_asset_types().returning(|| {
985            use crate::services::stellar_dex::AssetType;
986            std::collections::HashSet::from([AssetType::Native, AssetType::Classic])
987        });
988        dex_service
989            .expect_can_handle_asset()
990            .returning(|asset| asset == USDC_ASSET || asset == "native");
991
992        // Mock prepare_swap_transaction
993        dex_service.expect_prepare_swap_transaction().returning(|_| {
994            Box::pin(ready(Ok((
995                "AAAAAgAAAACige4lTdwSB/sto4SniEdJ2kOa2X65s5bqkd40J4DjSwAAAAEAAHAkAAAADwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAKKB7iVN3BIH+y2jhKeIR0naQ5rZfrmzluqR3jQngONLAAAAAAAAAAAAD0JAAAAAAAAAAAA=".to_string(),
996                crate::services::stellar_dex::StellarQuoteResponse {
997                    input_asset: USDC_ASSET.to_string(),
998                    output_asset: "native".to_string(),
999                    in_amount: 40000000,
1000                    out_amount: 10000000,
1001                    price_impact_pct: 0.0,
1002                    slippage_bps: 100,
1003                    path: None,
1004                },
1005            ))))
1006        });
1007
1008        let dex_service = Arc::new(dex_service);
1009        let relayer = create_test_relayer_with_mocks(
1010            relayer_model,
1011            provider,
1012            dex_service,
1013            Ok(()),
1014            Err(crate::jobs::JobProducerError::QueueError(
1015                "Notification queue full".to_string(),
1016            )),
1017        )
1018        .await;
1019
1020        let result = relayer
1021            .handle_token_swap_request("test-relayer-id".to_string())
1022            .await;
1023
1024        // Should still succeed even if notification fails
1025        assert!(result.is_ok());
1026        let swap_results = result.unwrap();
1027        assert_eq!(swap_results.len(), 1);
1028        assert!(swap_results[0].error.is_none());
1029        assert!(!swap_results[0].transaction_signature.is_empty());
1030    }
1031
1032    #[tokio::test]
1033    async fn test_handle_token_swap_request_multiple_tokens() {
1034        let mut relayer_model = create_test_relayer_with_swap_config();
1035        // Add a second token
1036        if let RelayerNetworkPolicy::Stellar(ref mut policy) = relayer_model.policies {
1037            policy.allowed_tokens = Some(vec![
1038                StellarAllowedTokensPolicy {
1039                    asset: USDC_ASSET.to_string(),
1040                    metadata: None,
1041                    max_allowed_fee: None,
1042                    swap_config: Some(StellarAllowedTokensSwapConfig {
1043                        min_amount: Some(1000000),
1044                        max_amount: Some(100000000),
1045                        retain_min_amount: Some(1000000),
1046                        slippage_percentage: Some(1.0),
1047                    }),
1048                },
1049                StellarAllowedTokensPolicy {
1050                    asset: "EURC:GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2"
1051                        .to_string(),
1052                    metadata: None,
1053                    max_allowed_fee: None,
1054                    swap_config: Some(StellarAllowedTokensSwapConfig {
1055                        min_amount: Some(2000000),
1056                        max_amount: Some(50000000),
1057                        retain_min_amount: Some(500000),
1058                        slippage_percentage: Some(0.5),
1059                    }),
1060                },
1061            ]);
1062        }
1063
1064        // Mock get_ledger_entries for both tokens - will be called twice
1065        let provider = create_mock_provider_with_usdc_balance(5000000); // 5 units for both tokens
1066
1067        let mut dex_service = MockStellarDexServiceTrait::new();
1068        dex_service.expect_supported_asset_types().returning(|| {
1069            use crate::services::stellar_dex::AssetType;
1070            std::collections::HashSet::from([AssetType::Native, AssetType::Classic])
1071        });
1072        dex_service.expect_can_handle_asset().returning(|asset| {
1073            asset == USDC_ASSET
1074                || asset == "EURC:GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2"
1075                || asset == "native"
1076        });
1077
1078        // Mock prepare_swap_transaction for both tokens
1079        dex_service.expect_prepare_swap_transaction().returning(|_| {
1080            Box::pin(ready(Ok((
1081                "AAAAAgAAAACige4lTdwSB/sto4SniEdJ2kOa2X65s5bqkd40J4DjSwAAAAEAAHAkAAAADwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAKKB7iVN3BIH+y2jhKeIR0naQ5rZfrmzluqR3jQngONLAAAAAAAAAAAAD0JAAAAAAAAAAAA=".to_string(),
1082                crate::services::stellar_dex::StellarQuoteResponse {
1083                    input_asset: USDC_ASSET.to_string(),
1084                    output_asset: "native".to_string(),
1085                    in_amount: 40000000,
1086                    out_amount: 10000000,
1087                    price_impact_pct: 0.0,
1088                    slippage_bps: 100,
1089                    path: None,
1090                },
1091            ))))
1092        });
1093
1094        let dex_service = Arc::new(dex_service);
1095        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1096
1097        let result = relayer
1098            .handle_token_swap_request("test-relayer-id".to_string())
1099            .await;
1100
1101        assert!(result.is_ok());
1102        let swap_results = result.unwrap();
1103        // Should have queued swap transactions for both tokens
1104        assert_eq!(swap_results.len(), 2);
1105        assert!(swap_results.iter().all(|r| r.error.is_none()));
1106        assert!(swap_results
1107            .iter()
1108            .all(|r| !r.transaction_signature.is_empty()));
1109    }
1110
1111    #[tokio::test]
1112    async fn test_handle_token_swap_request_partial_failure() {
1113        let mut relayer_model = create_test_relayer_with_swap_config();
1114        // Add a second token
1115        if let RelayerNetworkPolicy::Stellar(ref mut policy) = relayer_model.policies {
1116            policy.allowed_tokens = Some(vec![
1117                StellarAllowedTokensPolicy {
1118                    asset: USDC_ASSET.to_string(),
1119                    metadata: None,
1120                    max_allowed_fee: None,
1121                    swap_config: Some(StellarAllowedTokensSwapConfig {
1122                        min_amount: Some(1000000),
1123                        max_amount: Some(100000000),
1124                        retain_min_amount: Some(1000000),
1125                        slippage_percentage: Some(1.0),
1126                    }),
1127                },
1128                StellarAllowedTokensPolicy {
1129                    asset: "EURC:GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2"
1130                        .to_string(),
1131                    metadata: None,
1132                    max_allowed_fee: None,
1133                    swap_config: Some(StellarAllowedTokensSwapConfig {
1134                        min_amount: Some(2000000),
1135                        max_amount: Some(50000000),
1136                        retain_min_amount: Some(500000),
1137                        slippage_percentage: Some(0.5),
1138                    }),
1139                },
1140            ]);
1141        }
1142
1143        let mut provider = MockStellarProviderTrait::new();
1144
1145        // Mock get_ledger_entries - first call succeeds, second fails
1146        let mut call_count = 0;
1147        provider.expect_get_ledger_entries().returning(move |_| {
1148            call_count += 1;
1149            if call_count == 1 {
1150                // First token (USDC) - return balance
1151                use soroban_rs::stellar_rpc_client::{GetLedgerEntriesResponse, LedgerEntryResult};
1152                use soroban_rs::xdr::{
1153                    LedgerEntry, LedgerEntryData, TrustLineAsset, TrustLineEntry, TrustLineEntryExt,
1154                };
1155
1156                let trustline_entry = TrustLineEntry {
1157                    account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
1158                    asset: TrustLineAsset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
1159                        asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
1160                        issuer: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([
1161                            0x3b, 0x99, 0x11, 0x38, 0x0e, 0xfe, 0x98, 0x8b, 0xa0, 0xa8, 0x90, 0x0e,
1162                            0xb1, 0xcf, 0xe4, 0x4f, 0x36, 0x6f, 0x7d, 0xbe, 0x94, 0x6b, 0xed, 0x07,
1163                            0x72, 0x40, 0xf7, 0xf6, 0x24, 0xdf, 0x15, 0xc5,
1164                        ]))),
1165                    }),
1166                    balance: 5000000,
1167                    limit: i64::MAX,
1168                    flags: 0,
1169                    ext: TrustLineEntryExt::V0,
1170                };
1171
1172                let ledger_entry = LedgerEntry {
1173                    last_modified_ledger_seq: 0,
1174                    data: LedgerEntryData::Trustline(trustline_entry),
1175                    ext: soroban_rs::xdr::LedgerEntryExt::V0,
1176                };
1177
1178                // Encode LedgerEntryData to XDR base64 (not the full LedgerEntry)
1179                let xdr_base64 = ledger_entry
1180                    .data
1181                    .to_xdr_base64(soroban_rs::xdr::Limits::none())
1182                    .unwrap();
1183
1184                Box::pin(ready(Ok(GetLedgerEntriesResponse {
1185                    entries: Some(vec![LedgerEntryResult {
1186                        key: String::new(),
1187                        xdr: xdr_base64,
1188                        last_modified_ledger: 1000,
1189                        live_until_ledger_seq_ledger_seq: None,
1190                    }]),
1191                    latest_ledger: 1000,
1192                })))
1193            } else {
1194                // Second token (EURC) - return error
1195                Box::pin(ready(Err(crate::services::provider::ProviderError::Other(
1196                    "Connection failed".to_string(),
1197                ))))
1198            }
1199        });
1200
1201        let mut dex_service = MockStellarDexServiceTrait::new();
1202        dex_service.expect_supported_asset_types().returning(|| {
1203            use crate::services::stellar_dex::AssetType;
1204            std::collections::HashSet::from([AssetType::Native, AssetType::Classic])
1205        });
1206        dex_service.expect_can_handle_asset().returning(|asset| {
1207            asset == USDC_ASSET
1208                || asset == "EURC:GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2"
1209                || asset == "native"
1210        });
1211
1212        // Mock prepare_swap_transaction - only called once due to balance fetch failure for second token
1213        dex_service.expect_prepare_swap_transaction().returning(|_| {
1214            Box::pin(ready(Ok((
1215                "AAAAAgAAAACige4lTdwSB/sto4SniEdJ2kOa2X65s5bqkd40J4DjSwAAAAEAAHAkAAAADwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAKKB7iVN3BIH+y2jhKeIR0naQ5rZfrmzluqR3jQngONLAAAAAAAAAAAAD0JAAAAAAAAAAAA=".to_string(),
1216                crate::services::stellar_dex::StellarQuoteResponse {
1217                    input_asset: USDC_ASSET.to_string(),
1218                    output_asset: "native".to_string(),
1219                    in_amount: 40000000,
1220                    out_amount: 10000000,
1221                    price_impact_pct: 0.0,
1222                    slippage_bps: 100,
1223                    path: None,
1224                },
1225            ))))
1226        });
1227
1228        let dex_service = Arc::new(dex_service);
1229        let relayer = create_test_relayer_instance(relayer_model, provider, dex_service).await;
1230
1231        let result = relayer
1232            .handle_token_swap_request("test-relayer-id".to_string())
1233            .await;
1234
1235        assert!(result.is_ok());
1236        let swap_results = result.unwrap();
1237        // Should have only one successful swap (first token succeeds, second fails balance fetch)
1238        assert_eq!(swap_results.len(), 1);
1239        assert!(swap_results[0].error.is_none());
1240        assert!(!swap_results[0].transaction_signature.is_empty());
1241    }
1242
1243    #[test]
1244    fn test_calculate_swap_amount_no_constraints() {
1245        let result = calculate_swap_amount(10000000, None, None, None).unwrap();
1246        assert_eq!(result, 10000000);
1247    }
1248
1249    #[test]
1250    fn test_calculate_swap_amount_with_max_amount() {
1251        let result = calculate_swap_amount(10000000, None, Some(5000000), None).unwrap();
1252        assert_eq!(result, 5000000);
1253    }
1254
1255    #[test]
1256    fn test_calculate_swap_amount_with_retain_min() {
1257        let result = calculate_swap_amount(10000000, None, None, Some(2000000)).unwrap();
1258        assert_eq!(result, 8000000); // 10M - 2M = 8M
1259    }
1260
1261    #[test]
1262    fn test_calculate_swap_amount_with_max_and_retain() {
1263        let result = calculate_swap_amount(10000000, None, Some(5000000), Some(2000000)).unwrap();
1264        assert_eq!(result, 5000000); // min(5M, 8M) = 5M
1265    }
1266
1267    #[test]
1268    fn test_calculate_swap_amount_below_minimum() {
1269        let result = calculate_swap_amount(500000, Some(1000000), None, None).unwrap();
1270        assert_eq!(result, 0); // Below minimum, should return 0
1271    }
1272
1273    #[test]
1274    fn test_calculate_swap_amount_insufficient_for_retain() {
1275        let result = calculate_swap_amount(1000000, None, None, Some(2000000)).unwrap();
1276        assert_eq!(result, 0); // Can't retain minimum, should return 0
1277    }
1278
1279    #[test]
1280    fn test_calculate_swap_amount_exact_minimum() {
1281        let result = calculate_swap_amount(1000000, Some(1000000), None, None).unwrap();
1282        assert_eq!(result, 1000000); // Exactly at minimum
1283    }
1284
1285    #[test]
1286    fn test_calculate_swap_amount_all_constraints() {
1287        // Balance: 10M, Max: 5M, Retain: 2M, Min: 1M
1288        // Available: 10M - 2M = 8M
1289        // Capped by max: min(8M, 5M) = 5M
1290        // Above minimum: 5M >= 1M
1291        let result =
1292            calculate_swap_amount(10000000, Some(1000000), Some(5000000), Some(2000000)).unwrap();
1293        assert_eq!(result, 5000000);
1294    }
1295
1296    #[test]
1297    fn test_calculate_swap_amount_balance_equals_retain_min() {
1298        // Balance exactly equals retain minimum - should return 0
1299        let result = calculate_swap_amount(2000000, None, None, Some(2000000)).unwrap();
1300        assert_eq!(result, 0);
1301    }
1302
1303    #[test]
1304    fn test_calculate_swap_amount_balance_below_retain_min() {
1305        // Balance below retain minimum - should return 0
1306        let result = calculate_swap_amount(1000000, None, None, Some(2000000)).unwrap();
1307        assert_eq!(result, 0);
1308    }
1309
1310    #[test]
1311    fn test_calculate_swap_amount_max_amount_larger_than_available() {
1312        // Max amount larger than available balance after retain
1313        let result = calculate_swap_amount(10000000, None, Some(15000000), Some(2000000)).unwrap();
1314        assert_eq!(result, 8000000); // 10M - 2M = 8M (not capped by max)
1315    }
1316
1317    #[test]
1318    fn test_calculate_swap_amount_very_large_numbers() {
1319        // Test with very large numbers to ensure no overflow
1320        let large_balance = u64::MAX / 2;
1321        let large_max = u64::MAX / 4;
1322        let result = calculate_swap_amount(large_balance, None, Some(large_max), None).unwrap();
1323        assert_eq!(result, large_max); // Should be capped by max_amount
1324    }
1325
1326    #[test]
1327    fn test_calculate_swap_amount_zero_balance() {
1328        let result = calculate_swap_amount(0, None, None, None).unwrap();
1329        assert_eq!(result, 0);
1330    }
1331
1332    #[test]
1333    fn test_calculate_swap_amount_minimum_at_boundary() {
1334        // Amount exactly equals minimum after all calculations
1335        let result = calculate_swap_amount(3000000, Some(1000000), None, Some(2000000)).unwrap();
1336        assert_eq!(result, 1000000); // 3M - 2M = 1M (exactly at minimum)
1337    }
1338
1339    #[test]
1340    fn test_calculate_swap_amount_max_capped_by_balance() {
1341        // Max amount larger than balance
1342        let result = calculate_swap_amount(5000000, None, Some(10000000), None).unwrap();
1343        assert_eq!(result, 5000000); // Capped by balance
1344    }
1345
1346    #[test]
1347    fn test_calculate_swap_amount_complex_scenario() {
1348        // Complex scenario: Balance 15M, Max 10M, Retain 3M, Min 2M
1349        // Available: 15M - 3M = 12M
1350        // Capped by max: min(12M, 10M) = 10M
1351        // Above minimum: 10M >= 2M ✓
1352        let result =
1353            calculate_swap_amount(15000000, Some(2000000), Some(10000000), Some(3000000)).unwrap();
1354        assert_eq!(result, 10000000);
1355    }
1356}