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)
457 .unwrap_or(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))));
458 let fallback_issuer =
459 parse_account_id("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")
460 .unwrap_or(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))));
461 let fallback_asset = TrustLineAsset::CreditAlphanum4(soroban_rs::xdr::AlphaNum4 {
462 asset_code: soroban_rs::xdr::AssetCode4(*b"USDC"),
463 issuer: fallback_issuer,
464 });
465 (fallback_account, fallback_asset)
466 };
467
468 let trustline_entry = TrustLineEntry {
469 account_id,
470 asset,
471 balance,
472 limit: i64::MAX,
473 flags: 0,
474 ext: TrustLineEntryExt::V0,
475 };
476
477 let ledger_entry = LedgerEntry {
478 last_modified_ledger_seq: 0,
479 data: LedgerEntryData::Trustline(trustline_entry),
480 ext: LedgerEntryExt::V0,
481 };
482
483 let xdr_base64 = ledger_entry
485 .data
486 .to_xdr_base64(soroban_rs::xdr::Limits::none())
487 .expect("Failed to encode trustline entry data to XDR");
488
489 Box::pin(ready(Ok(GetLedgerEntriesResponse {
490 entries: Some(vec![LedgerEntryResult {
491 key: String::new(),
492 xdr: xdr_base64,
493 last_modified_ledger: 1000,
494 live_until_ledger_seq_ledger_seq: None,
495 }]),
496 latest_ledger: 1000,
497 })))
498 });
499
500 provider.expect_get_account().returning(|_| {
502 Box::pin(ready(Ok(AccountEntry {
503 account_id: AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32]))),
504 balance: 100_000_000, seq_num: SequenceNumber(100), num_sub_entries: 0,
507 inflation_dest: None,
508 flags: 0,
509 home_domain: String32::default(),
510 thresholds: Thresholds([0; 4]),
511 signers: VecM::default(),
512 ext: AccountEntryExt::V0,
513 })))
514 });
515
516 provider
517 }
518
519 fn create_test_relayer_with_swap_config() -> RelayerRepoModel {
521 let mut policy = RelayerStellarPolicy::default();
522 policy.fee_payment_strategy = Some(StellarFeePaymentStrategy::User);
523 policy.swap_config = Some(RelayerStellarSwapConfig {
524 strategies: vec![StellarSwapStrategy::OrderBook],
525 min_balance_threshold: None,
526 cron_schedule: None,
527 });
528 policy.allowed_tokens = Some(vec![StellarAllowedTokensPolicy {
529 asset: USDC_ASSET.to_string(),
530 metadata: None,
531 max_allowed_fee: None,
532 swap_config: Some(StellarAllowedTokensSwapConfig {
533 min_amount: Some(1000000),
534 max_amount: Some(100000000),
535 retain_min_amount: Some(1000000),
536 slippage_percentage: Some(1.0),
537 }),
538 }]);
539
540 RelayerRepoModel {
541 id: "test-relayer-id".to_string(),
542 name: "Test Relayer".to_string(),
543 network: "testnet".to_string(),
544 paused: false,
545 network_type: NetworkType::Stellar,
546 signer_id: "signer-id".to_string(),
547 policies: RelayerNetworkPolicy::Stellar(policy),
548 address: TEST_PK.to_string(),
549 notification_id: Some("notification-id".to_string()),
550 system_disabled: false,
551 custom_rpc_urls: None,
552 ..Default::default()
553 }
554 }
555
556 fn create_mock_dex_service() -> Arc<MockStellarDexServiceTrait> {
558 let mut mock_dex = MockStellarDexServiceTrait::new();
559 mock_dex.expect_supported_asset_types().returning(|| {
560 use crate::services::stellar_dex::AssetType;
561 std::collections::HashSet::from([AssetType::Native, AssetType::Classic])
562 });
563 mock_dex
564 .expect_can_handle_asset()
565 .returning(|asset| asset == USDC_ASSET || asset == "native");
566 Arc::new(mock_dex)
567 }
568
569 fn create_test_network() -> NetworkRepoModel {
571 NetworkRepoModel {
572 id: "stellar:testnet".to_string(),
573 name: "testnet".to_string(),
574 network_type: NetworkType::Stellar,
575 config: NetworkConfigData::Stellar(StellarNetworkConfig {
576 common: NetworkConfigCommon {
577 network: "testnet".to_string(),
578 from: None,
579 rpc_urls: Some(vec![RpcConfig::new(
580 "https://horizon-testnet.stellar.org".to_string(),
581 )]),
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 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 let relayer_model_clone2 = relayer_model.clone();
622 relayer_repo
623 .expect_update_policy()
624 .returning(move |_, _| Ok(relayer_model_clone2.clone()));
625
626 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 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); 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 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 assert_eq!(swap_results.len(), 1);
749
750 let swap_result = &swap_results[0];
751
752 assert_eq!(swap_result.mint, USDC_ASSET);
754 assert_eq!(swap_result.source_amount, 4000000); assert_eq!(swap_result.destination_amount, 10000000);
756 assert!(swap_result.error.is_none());
757 assert!(!swap_result.transaction_signature.is_empty());
758
759 assert!(!swap_result.transaction_signature.is_empty());
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 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 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 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 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 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 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); 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 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 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 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); 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 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 assert_eq!(swap_results.len(), 1);
908 assert!(swap_results[0].error.is_some());
909 assert_eq!(swap_results[0].source_amount, 49000000); 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); 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 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 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); 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); 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 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 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 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 let provider = create_mock_provider_with_usdc_balance(5000000); 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 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 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 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 let mut call_count = 0;
1144 provider.expect_get_ledger_entries().returning(move |_| {
1145 call_count += 1;
1146 if call_count == 1 {
1147 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 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 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 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 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); }
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); }
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); }
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); }
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); }
1281
1282 #[test]
1283 fn test_calculate_swap_amount_all_constraints() {
1284 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 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 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 let result = calculate_swap_amount(10000000, None, Some(15000000), Some(2000000)).unwrap();
1311 assert_eq!(result, 8000000); }
1313
1314 #[test]
1315 fn test_calculate_swap_amount_very_large_numbers() {
1316 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); }
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 let result = calculate_swap_amount(3000000, Some(1000000), None, Some(2000000)).unwrap();
1333 assert_eq!(result, 1000000); }
1335
1336 #[test]
1337 fn test_calculate_swap_amount_max_capped_by_balance() {
1338 let result = calculate_swap_amount(5000000, None, Some(10000000), None).unwrap();
1340 assert_eq!(result, 5000000); }
1342
1343 #[test]
1344 fn test_calculate_swap_amount_complex_scenario() {
1345 let result =
1350 calculate_swap_amount(15000000, Some(2000000), Some(10000000), Some(3000000)).unwrap();
1351 assert_eq!(result, 10000000);
1352 }
1353}