1use 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 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 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 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 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 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 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 let swap_prep_futures: Vec<_> = tokens_to_swap
162 .iter()
163 .filter_map(|(token_asset, swap_amount, slippage_percent)| {
164 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 let swap_params = SwapTransactionParams {
193 source_account: relayer_address.clone(),
194 source_asset: token_asset.clone(),
195 destination_asset: "native".to_string(), 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), };
202
203 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 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 let swap_prep_results = join_all(swap_prep_futures).await;
223
224 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 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 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, 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 error!(
281 %relayer_id,
282 error = %e,
283 "Failed to prepare swap transaction, skipping this token"
284 );
285 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 if let Some(notification_id) = &relayer.notification_id {
333 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
364fn 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 let mut amount = max_amount
380 .map(|max| std::cmp::min(current_balance, max))
381 .unwrap_or(current_balance);
382
383 if let Some(retain) = retain_min {
385 if current_balance > retain {
386 amount = std::cmp::min(amount, current_balance - retain);
387 } else {
388 return Ok(0);
390 }
391 }
392
393 if let Some(min) = min_amount {
395 if amount < min {
396 return Ok(0); }
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 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 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 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 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 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, seq_num: SequenceNumber(100), 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 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 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 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 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 let relayer_model_clone2 = relayer_model.clone();
625 relayer_repo
626 .expect_update_policy()
627 .returning(move |_, _| Ok(relayer_model_clone2.clone()));
628
629 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 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); 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 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 assert_eq!(swap_results.len(), 1);
752
753 let swap_result = &swap_results[0];
754
755 assert_eq!(swap_result.mint, USDC_ASSET);
757 assert_eq!(swap_result.source_amount, 4000000); assert_eq!(swap_result.destination_amount, 10000000);
759 assert!(swap_result.error.is_none());
760 assert!(!swap_result.transaction_signature.is_empty());
761
762 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 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 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 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 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 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 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); 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 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 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 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); 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 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 assert_eq!(swap_results.len(), 1);
911 assert!(swap_results[0].error.is_some());
912 assert_eq!(swap_results[0].source_amount, 49000000); 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); 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 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 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); 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); 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 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 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 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 let provider = create_mock_provider_with_usdc_balance(5000000); 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 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 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 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 let mut call_count = 0;
1147 provider.expect_get_ledger_entries().returning(move |_| {
1148 call_count += 1;
1149 if call_count == 1 {
1150 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 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 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 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 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); }
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); }
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); }
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); }
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); }
1284
1285 #[test]
1286 fn test_calculate_swap_amount_all_constraints() {
1287 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 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 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 let result = calculate_swap_amount(10000000, None, Some(15000000), Some(2000000)).unwrap();
1314 assert_eq!(result, 8000000); }
1316
1317 #[test]
1318 fn test_calculate_swap_amount_very_large_numbers() {
1319 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); }
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 let result = calculate_swap_amount(3000000, Some(1000000), None, Some(2000000)).unwrap();
1336 assert_eq!(result, 1000000); }
1338
1339 #[test]
1340 fn test_calculate_swap_amount_max_capped_by_balance() {
1341 let result = calculate_swap_amount(5000000, None, Some(10000000), None).unwrap();
1343 assert_eq!(result, 5000000); }
1345
1346 #[test]
1347 fn test_calculate_swap_amount_complex_scenario() {
1348 let result =
1353 calculate_swap_amount(15000000, Some(2000000), Some(10000000), Some(3000000)).unwrap();
1354 assert_eq!(result, 10000000);
1355 }
1356}