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