1use std::{str::FromStr, sync::Arc};
11
12use crate::{
13 constants::{
14 DEFAULT_CONVERSION_SLIPPAGE_PERCENTAGE, SOLANA_SMALLEST_UNIT_NAME, WRAPPED_SOL_MINT,
15 },
16 domain::{
17 relayer::RelayerError, BalanceResponse, DexStrategy, SolanaRelayerDexTrait,
18 SolanaRelayerTrait, SwapParams,
19 },
20 jobs::{JobProducerTrait, SolanaTokenSwapRequest},
21 models::{
22 produce_relayer_disabled_payload, produce_solana_dex_webhook_payload, JsonRpcRequest,
23 JsonRpcResponse, NetworkRpcRequest, NetworkRpcResult, NetworkType, RelayerNetworkPolicy,
24 RelayerRepoModel, RelayerSolanaPolicy, SolanaAllowedTokensPolicy, SolanaDexPayload,
25 SolanaNetwork, TransactionRepoModel,
26 },
27 repositories::{
28 InMemoryNetworkRepository, InMemoryRelayerRepository, InMemoryTransactionRepository,
29 RelayerRepository, RelayerRepositoryStorage, Repository,
30 },
31 services::{
32 JupiterService, JupiterServiceTrait, SolanaProvider, SolanaProviderTrait, SolanaSignTrait,
33 SolanaSigner,
34 },
35};
36use async_trait::async_trait;
37use eyre::Result;
38use futures::future::try_join_all;
39use log::{error, info, warn};
40use solana_sdk::{account::Account, pubkey::Pubkey};
41
42use super::{
43 NetworkDex, SolanaRpcError, SolanaRpcHandler, SolanaRpcMethodsImpl, SolanaTokenProgram,
44 SwapResult, TokenAccount,
45};
46
47#[allow(dead_code)]
48struct TokenSwapCandidate<'a> {
49 policy: &'a SolanaAllowedTokensPolicy,
50 account: TokenAccount,
51 swap_amount: u64,
52}
53
54#[allow(dead_code)]
55pub struct SolanaRelayer<R, T, J, S, JS, SP>
56where
57 R: Repository<RelayerRepoModel, String> + RelayerRepository + Send + Sync,
58 T: Repository<TransactionRepoModel, String> + Send + Sync,
59 J: JobProducerTrait + Send + Sync + 'static,
60 S: SolanaSignTrait + Send + Sync + 'static,
61 JS: JupiterServiceTrait + Send + Sync + 'static,
62 SP: SolanaProviderTrait + Send + Sync + 'static,
63{
64 relayer: RelayerRepoModel,
65 signer: Arc<S>,
66 network: SolanaNetwork,
67 provider: Arc<SP>,
68 rpc_handler: Arc<SolanaRpcHandler<SolanaRpcMethodsImpl<SP, S, JS, J>>>,
69 relayer_repository: Arc<R>,
70 transaction_repository: Arc<T>,
71 job_producer: Arc<J>,
72 dex_service: Arc<NetworkDex<SP, S, JS>>,
73}
74
75pub type DefaultSolanaRelayer<J> = SolanaRelayer<
76 RelayerRepositoryStorage<InMemoryRelayerRepository>,
77 InMemoryTransactionRepository,
78 J,
79 SolanaSigner,
80 JupiterService,
81 SolanaProvider,
82>;
83
84impl<R, T, J, S, JS, SP> SolanaRelayer<R, T, J, S, JS, SP>
85where
86 R: Repository<RelayerRepoModel, String> + RelayerRepository + Send + Sync,
87 T: Repository<TransactionRepoModel, String> + Send + Sync,
88 J: JobProducerTrait + Send + Sync,
89 S: SolanaSignTrait + Send + Sync,
90 JS: JupiterServiceTrait + Send + Sync,
91 SP: SolanaProviderTrait + Send + Sync,
92{
93 #[allow(clippy::too_many_arguments)]
94 pub async fn new(
95 relayer: RelayerRepoModel,
96 signer: Arc<S>,
97 relayer_repository: Arc<R>,
98 network_repository: Arc<InMemoryNetworkRepository>,
99 provider: Arc<SP>,
100 rpc_handler: Arc<SolanaRpcHandler<SolanaRpcMethodsImpl<SP, S, JS, J>>>,
101 transaction_repository: Arc<T>,
102 job_producer: Arc<J>,
103 dex_service: Arc<NetworkDex<SP, S, JS>>,
104 ) -> Result<Self, RelayerError> {
105 let network_repo = network_repository
106 .get(NetworkType::Solana, &relayer.network)
107 .await
108 .ok()
109 .flatten()
110 .ok_or_else(|| {
111 RelayerError::NetworkConfiguration(format!("Network {} not found", relayer.network))
112 })?;
113
114 let network = SolanaNetwork::try_from(network_repo)?;
115
116 Ok(Self {
117 relayer,
118 signer,
119 network,
120 provider,
121 rpc_handler,
122 relayer_repository,
123 transaction_repository,
124 job_producer,
125 dex_service,
126 })
127 }
128
129 async fn validate_rpc(&self) -> Result<(), RelayerError> {
134 self.provider
135 .get_latest_blockhash()
136 .await
137 .map_err(|e| RelayerError::ProviderError(e.to_string()))?;
138
139 Ok(())
140 }
141
142 async fn populate_allowed_tokens_metadata(&self) -> Result<RelayerSolanaPolicy, RelayerError> {
154 let mut policy = self.relayer.policies.get_solana_policy();
155 let allowed_tokens = match policy.allowed_tokens.as_ref() {
157 Some(tokens) if !tokens.is_empty() => tokens,
158 _ => {
159 info!("No allowed tokens specified; skipping token metadata population.");
160 return Ok(policy);
161 }
162 };
163
164 let token_metadata_futures = allowed_tokens.iter().map(|token| async {
165 let token_metadata = self
167 .provider
168 .get_token_metadata_from_pubkey(&token.mint)
169 .await
170 .map_err(|e| RelayerError::ProviderError(e.to_string()))?;
171 Ok::<SolanaAllowedTokensPolicy, RelayerError>(SolanaAllowedTokensPolicy::new(
172 token_metadata.mint,
173 Some(token_metadata.decimals),
174 Some(token_metadata.symbol.to_string()),
175 token.max_allowed_fee,
176 token.swap_config.clone(),
177 ))
178 });
179
180 let updated_allowed_tokens = try_join_all(token_metadata_futures).await?;
181
182 policy.allowed_tokens = Some(updated_allowed_tokens);
183
184 self.relayer_repository
185 .update_policy(
186 self.relayer.id.clone(),
187 RelayerNetworkPolicy::Solana(policy.clone()),
188 )
189 .await?;
190
191 Ok(policy)
192 }
193
194 async fn validate_program_policy(&self) -> Result<(), RelayerError> {
202 let policy = self.relayer.policies.get_solana_policy();
203 let allowed_programs = match policy.allowed_programs.as_ref() {
204 Some(programs) if !programs.is_empty() => programs,
205 _ => {
206 info!("No allowed programs specified; skipping program validation.");
207 return Ok(());
208 }
209 };
210 let account_info_futures = allowed_programs.iter().map(|program| {
211 let program = program.clone();
212 async move {
213 let account = self
214 .provider
215 .get_account_from_str(&program)
216 .await
217 .map_err(|e| RelayerError::ProviderError(e.to_string()))?;
218 Ok::<Account, RelayerError>(account)
219 }
220 });
221
222 let accounts = try_join_all(account_info_futures).await?;
223
224 for account in accounts {
225 if !account.executable {
226 return Err(RelayerError::PolicyConfigurationError(
227 "Policy Program is not executable".to_string(),
228 ));
229 }
230 }
231
232 Ok(())
233 }
234
235 async fn check_balance_and_trigger_token_swap_if_needed(&self) -> Result<(), RelayerError> {
238 let policy = self.relayer.policies.get_solana_policy();
239 let swap_config = match policy.get_swap_config() {
240 Some(config) => config,
241 None => {
242 info!("No swap configuration specified; skipping validation.");
243 return Ok(());
244 }
245 };
246 let swap_min_balance_threshold = match swap_config.min_balance_threshold {
247 Some(threshold) => threshold,
248 None => {
249 info!("No swap min balance threshold specified; skipping validation.");
250 return Ok(());
251 }
252 };
253
254 let balance = self
255 .provider
256 .get_balance(&self.relayer.address)
257 .await
258 .map_err(|e| RelayerError::ProviderError(e.to_string()))?;
259
260 if balance < swap_min_balance_threshold {
261 info!(
262 "Sending job request for for relayer {} swapping tokens due to relayer swap_min_balance_threshold: Balance: {}, swap_min_balance_threshold: {}",
263 self.relayer.id, balance, swap_min_balance_threshold
264 );
265
266 self.job_producer
267 .produce_solana_token_swap_request_job(
268 SolanaTokenSwapRequest {
269 relayer_id: self.relayer.id.clone(),
270 },
271 None,
272 )
273 .await?;
274 }
275
276 Ok(())
277 }
278
279 fn calculate_swap_amount(
281 &self,
282 current_balance: u64,
283 min_amount: Option<u64>,
284 max_amount: Option<u64>,
285 retain_min: Option<u64>,
286 ) -> Result<u64, RelayerError> {
287 let mut amount = max_amount
289 .map(|max| std::cmp::min(current_balance, max))
290 .unwrap_or(current_balance);
291
292 if let Some(retain) = retain_min {
294 if current_balance > retain {
295 amount = std::cmp::min(amount, current_balance - retain);
296 } else {
297 return Ok(0);
299 }
300 }
301
302 if let Some(min) = min_amount {
304 if amount < min {
305 return Ok(0); }
307 }
308
309 Ok(amount)
310 }
311}
312
313#[async_trait]
314impl<R, T, J, S, JS, SP> SolanaRelayerDexTrait for SolanaRelayer<R, T, J, S, JS, SP>
315where
316 R: Repository<RelayerRepoModel, String> + RelayerRepository + Send + Sync,
317 T: Repository<TransactionRepoModel, String> + Send + Sync,
318 J: JobProducerTrait + Send + Sync,
319 S: SolanaSignTrait + Send + Sync,
320 JS: JupiterServiceTrait + Send + Sync,
321 SP: SolanaProviderTrait + Send + Sync,
322{
323 async fn handle_token_swap_request(
333 &self,
334 relayer_id: String,
335 ) -> Result<Vec<SwapResult>, RelayerError> {
336 info!("Handling token swap request for relayer: {}", relayer_id);
337 let relayer = self
338 .relayer_repository
339 .get_by_id(relayer_id.clone())
340 .await?;
341
342 let policy = relayer.policies.get_solana_policy();
343
344 let swap_config = match policy.get_swap_config() {
345 Some(config) => config,
346 None => {
347 info!("No swap configuration specified; Exiting.");
348 return Ok(vec![]);
349 }
350 };
351
352 match swap_config.strategy {
353 Some(strategy) => strategy,
354 None => {
355 info!("No swap strategy specified; Exiting.");
356 return Ok(vec![]);
357 }
358 };
359
360 let relayer_pubkey = Pubkey::from_str(&relayer.address)
361 .map_err(|e| RelayerError::ProviderError(format!("Invalid relayer address: {}", e)))?;
362
363 let tokens_to_swap = {
364 let mut eligible_tokens = Vec::<TokenSwapCandidate>::new();
365
366 if let Some(allowed_tokens) = policy.allowed_tokens.as_ref() {
367 for token in allowed_tokens {
368 let token_mint = Pubkey::from_str(&token.mint).map_err(|e| {
369 RelayerError::ProviderError(format!("Invalid token mint: {}", e))
370 })?;
371 let token_account = SolanaTokenProgram::get_and_unpack_token_account(
372 &*self.provider,
373 &relayer_pubkey,
374 &token_mint,
375 )
376 .await
377 .map_err(|e| {
378 RelayerError::ProviderError(format!("Failed to get token account: {}", e))
379 })?;
380
381 let swap_amount = self
382 .calculate_swap_amount(
383 token_account.amount,
384 token
385 .swap_config
386 .as_ref()
387 .and_then(|config| config.min_amount),
388 token
389 .swap_config
390 .as_ref()
391 .and_then(|config| config.max_amount),
392 token
393 .swap_config
394 .as_ref()
395 .and_then(|config| config.retain_min_amount),
396 )
397 .unwrap_or(0);
398
399 if swap_amount > 0 {
400 info!("Token swap eligible for token: {:?}", token);
401
402 eligible_tokens.push(TokenSwapCandidate {
404 policy: token,
405 account: token_account,
406 swap_amount,
407 });
408 }
409 }
410 }
411
412 eligible_tokens
413 };
414
415 let swap_futures = tokens_to_swap.iter().map(|candidate| {
417 let token = candidate.policy;
418 let swap_amount = candidate.swap_amount;
419 let dex = &self.dex_service;
420 let relayer_address = self.relayer.address.clone();
421 let token_mint = token.mint.clone();
422 let relayer_id_clone = relayer_id.clone();
423 let slippage_percent = token
424 .swap_config
425 .as_ref()
426 .and_then(|config| config.slippage_percentage)
427 .unwrap_or(DEFAULT_CONVERSION_SLIPPAGE_PERCENTAGE)
428 as f64;
429
430 async move {
431 info!(
432 "Swapping {} tokens of type {} for relayer: {}",
433 swap_amount, token_mint, relayer_id_clone
434 );
435
436 let swap_result = dex
437 .execute_swap(SwapParams {
438 owner_address: relayer_address,
439 source_mint: token_mint.clone(),
440 destination_mint: WRAPPED_SOL_MINT.to_string(), amount: swap_amount,
442 slippage_percent,
443 })
444 .await;
445
446 match swap_result {
447 Ok(swap_result) => {
448 info!(
449 "Swap successful for relayer: {}. Amount: {}, Destination amount: {}",
450 relayer_id_clone, swap_amount, swap_result.destination_amount
451 );
452 Ok::<SwapResult, RelayerError>(swap_result)
453 }
454 Err(e) => {
455 error!(
456 "Error during token swap for relayer: {}. Error: {}",
457 relayer_id_clone, e
458 );
459 Ok::<SwapResult, RelayerError>(SwapResult {
460 mint: token_mint.clone(),
461 source_amount: swap_amount,
462 destination_amount: 0,
463 transaction_signature: "".to_string(),
464 error: Some(e.to_string()),
465 })
466 }
467 }
468 }
469 });
470
471 let swap_results = try_join_all(swap_futures).await?;
472
473 if !swap_results.is_empty() {
474 let total_sol_received: u64 = swap_results
475 .iter()
476 .map(|result| result.destination_amount)
477 .sum();
478
479 info!(
480 "Completed {} token swaps for relayer {}, total SOL received: {}",
481 swap_results.len(),
482 relayer_id,
483 total_sol_received
484 );
485
486 if let Some(notification_id) = &self.relayer.notification_id {
487 let webhook_result = self
488 .job_producer
489 .produce_send_notification_job(
490 produce_solana_dex_webhook_payload(
491 notification_id,
492 "solana_dex".to_string(),
493 SolanaDexPayload {
494 swap_results: swap_results.clone(),
495 },
496 ),
497 None,
498 )
499 .await;
500
501 if let Err(e) = webhook_result {
502 error!("Failed to produce notification job: {}", e);
503 }
504 }
505 }
506
507 Ok(swap_results)
508 }
509}
510
511#[async_trait]
512impl<R, T, J, S, JS, SP> SolanaRelayerTrait for SolanaRelayer<R, T, J, S, JS, SP>
513where
514 R: Repository<RelayerRepoModel, String> + RelayerRepository + Send + Sync,
515 T: Repository<TransactionRepoModel, String> + Send + Sync,
516 J: JobProducerTrait + Send + Sync,
517 S: SolanaSignTrait + Send + Sync,
518 JS: JupiterServiceTrait + Send + Sync,
519 SP: SolanaProviderTrait + Send + Sync,
520{
521 async fn get_balance(&self) -> Result<BalanceResponse, RelayerError> {
522 let address = &self.relayer.address;
523 let balance = self.provider.get_balance(address).await?;
524
525 Ok(BalanceResponse {
526 balance: balance as u128,
527 unit: SOLANA_SMALLEST_UNIT_NAME.to_string(),
528 })
529 }
530
531 async fn rpc(
532 &self,
533 request: JsonRpcRequest<NetworkRpcRequest>,
534 ) -> Result<JsonRpcResponse<NetworkRpcResult>, RelayerError> {
535 let response = self.rpc_handler.handle_request(request).await;
536
537 match response {
538 Ok(response) => Ok(response),
539 Err(e) => {
540 error!("Error while processing RPC request: {}", e);
541 let error_response = match e {
542 SolanaRpcError::UnsupportedMethod(msg) => {
543 JsonRpcResponse::error(32000, "UNSUPPORTED_METHOD", &msg)
544 }
545 SolanaRpcError::FeatureFetch(msg) => JsonRpcResponse::error(
546 -32008,
547 "FEATURE_FETCH_ERROR",
548 &format!("Failed to retrieve the list of enabled features: {}", msg),
549 ),
550 SolanaRpcError::InvalidParams(msg) => {
551 JsonRpcResponse::error(-32602, "INVALID_PARAMS", &msg)
552 }
553 SolanaRpcError::UnsupportedFeeToken(msg) => JsonRpcResponse::error(
554 -32000,
555 "UNSUPPORTED
556 FEE_TOKEN",
557 &format!(
558 "The provided fee_token is not supported by the relayer: {}",
559 msg
560 ),
561 ),
562 SolanaRpcError::Estimation(msg) => JsonRpcResponse::error(
563 -32001,
564 "ESTIMATION_ERROR",
565 &format!(
566 "Failed to estimate the fee due to internal or network issues: {}",
567 msg
568 ),
569 ),
570 SolanaRpcError::InsufficientFunds(msg) => {
571 self.check_balance_and_trigger_token_swap_if_needed()
573 .await?;
574
575 JsonRpcResponse::error(
576 -32002,
577 "INSUFFICIENT_FUNDS",
578 &format!(
579 "The sender does not have enough funds for the transfer: {}",
580 msg
581 ),
582 )
583 }
584 SolanaRpcError::TransactionPreparation(msg) => JsonRpcResponse::error(
585 -32003,
586 "TRANSACTION_PREPARATION_ERROR",
587 &format!("Failed to prepare the transfer transaction: {}", msg),
588 ),
589 SolanaRpcError::Preparation(msg) => JsonRpcResponse::error(
590 -32013,
591 "PREPARATION_ERROR",
592 &format!("Failed to prepare the transfer transaction: {}", msg),
593 ),
594 SolanaRpcError::Signature(msg) => JsonRpcResponse::error(
595 -32005,
596 "SIGNATURE_ERROR",
597 &format!("Failed to sign the transaction: {}", msg),
598 ),
599 SolanaRpcError::Signing(msg) => JsonRpcResponse::error(
600 -32005,
601 "SIGNATURE_ERROR",
602 &format!("Failed to sign the transaction: {}", msg),
603 ),
604 SolanaRpcError::TokenFetch(msg) => JsonRpcResponse::error(
605 -32007,
606 "TOKEN_FETCH_ERROR",
607 &format!("Failed to retrieve the list of supported tokens: {}", msg),
608 ),
609 SolanaRpcError::BadRequest(msg) => JsonRpcResponse::error(
610 -32007,
611 "BAD_REQUEST",
612 &format!("Bad request: {}", msg),
613 ),
614 SolanaRpcError::Send(msg) => JsonRpcResponse::error(
615 -32006,
616 "SEND_ERROR",
617 &format!(
618 "Failed to submit the transaction to the blockchain: {}",
619 msg
620 ),
621 ),
622 SolanaRpcError::SolanaTransactionValidation(msg) => JsonRpcResponse::error(
623 -32013,
624 "PREPARATION_ERROR",
625 &format!("Failed to prepare the transfer transaction: {}", msg),
626 ),
627 SolanaRpcError::Encoding(msg) => JsonRpcResponse::error(
628 -32601,
629 "INVALID_PARAMS",
630 &format!("The transaction parameter is invalid or missing: {}", msg),
631 ),
632 SolanaRpcError::TokenAccount(msg) => JsonRpcResponse::error(
633 -32601,
634 "PREPARATION_ERROR",
635 &format!("Invalid Token Account: {}", msg),
636 ),
637 SolanaRpcError::Token(msg) => JsonRpcResponse::error(
638 -32601,
639 "PREPARATION_ERROR",
640 &format!("Invalid Token Account: {}", msg),
641 ),
642 SolanaRpcError::Provider(msg) => JsonRpcResponse::error(
643 -32006,
644 "PREPARATION_ERROR",
645 &format!("Failed to prepare the transfer transaction: {}", msg),
646 ),
647 SolanaRpcError::Internal(_) => {
648 JsonRpcResponse::error(-32000, "INTERNAL_ERROR", "Internal error")
649 }
650 };
651 Ok(error_response)
652 }
653 }
654 }
655
656 async fn validate_min_balance(&self) -> Result<(), RelayerError> {
657 let balance = self
658 .provider
659 .get_balance(&self.relayer.address)
660 .await
661 .map_err(|e| RelayerError::ProviderError(e.to_string()))?;
662
663 info!("Balance : {} for relayer: {}", balance, self.relayer.id);
664
665 let policy = self.relayer.policies.get_solana_policy();
666
667 if balance < policy.min_balance {
668 return Err(RelayerError::InsufficientBalanceError(
669 "Insufficient balance".to_string(),
670 ));
671 }
672
673 Ok(())
674 }
675
676 async fn initialize_relayer(&self) -> Result<(), RelayerError> {
677 info!("Initializing relayer: {}", self.relayer.id);
678
679 self.populate_allowed_tokens_metadata().await.map_err(|_| {
682 RelayerError::PolicyConfigurationError(
683 "Error while processing allowed tokens policy".into(),
684 )
685 })?;
686
687 self.validate_program_policy().await.map_err(|_| {
690 RelayerError::PolicyConfigurationError(
691 "Error while validating allowed programs policy".into(),
692 )
693 })?;
694
695 let validate_rpc_result = self.validate_rpc().await;
696
697 let validate_min_balance_result = self.validate_min_balance().await;
698
699 if validate_rpc_result.is_err() || validate_min_balance_result.is_err() {
701 let reason = vec![
702 validate_rpc_result
703 .err()
704 .map(|e| format!("RPC validation failed: {}", e)),
705 validate_min_balance_result
706 .err()
707 .map(|e| format!("Balance check failed: {}", e)),
708 ]
709 .into_iter()
710 .flatten()
711 .collect::<Vec<String>>()
712 .join(", ");
713
714 warn!("Disabling relayer: {} due to: {}", self.relayer.id, reason);
715 let updated_relayer = self
716 .relayer_repository
717 .disable_relayer(self.relayer.id.clone())
718 .await?;
719 if let Some(notification_id) = &self.relayer.notification_id {
720 self.job_producer
721 .produce_send_notification_job(
722 produce_relayer_disabled_payload(
723 notification_id,
724 &updated_relayer,
725 &reason,
726 ),
727 None,
728 )
729 .await?;
730 }
731 }
732
733 self.check_balance_and_trigger_token_swap_if_needed()
734 .await?;
735
736 Ok(())
737 }
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743 use crate::{
744 config::{NetworkConfigCommon, SolanaNetworkConfig},
745 domain::create_network_dex_generic,
746 jobs::MockJobProducerTrait,
747 models::{
748 EncodedSerializedTransaction, FeeEstimateRequestParams,
749 GetFeaturesEnabledRequestParams, JsonRpcId, NetworkConfigData, NetworkRepoModel,
750 RelayerSolanaSwapConfig, SolanaAllowedTokensSwapConfig, SolanaRpcResult,
751 SolanaSwapStrategy,
752 },
753 repositories::{MockRelayerRepository, MockRepository},
754 services::{
755 MockJupiterServiceTrait, MockSolanaProviderTrait, MockSolanaSignTrait, QuoteResponse,
756 RoutePlan, SolanaProviderError, SwapEvents, SwapInfo, SwapResponse,
757 UltraExecuteResponse, UltraOrderResponse,
758 },
759 };
760 use mockall::predicate::*;
761 use solana_sdk::{hash::Hash, program_pack::Pack, signature::Signature};
762 use spl_token::state::Account as SplAccount;
763
764 #[allow(dead_code)]
767 struct TestCtx {
768 relayer_model: RelayerRepoModel,
769 mock_repo: MockRelayerRepository,
770 network_repository: Arc<InMemoryNetworkRepository>,
771 provider: Arc<MockSolanaProviderTrait>,
772 signer: Arc<MockSolanaSignTrait>,
773 jupiter: Arc<MockJupiterServiceTrait>,
774 job_producer: Arc<MockJobProducerTrait>,
775 tx_repo: Arc<MockRepository<TransactionRepoModel, String>>,
776 dex: Arc<NetworkDex<MockSolanaProviderTrait, MockSolanaSignTrait, MockJupiterServiceTrait>>,
777 rpc_handler: Arc<
778 SolanaRpcHandler<
779 SolanaRpcMethodsImpl<
780 MockSolanaProviderTrait,
781 MockSolanaSignTrait,
782 MockJupiterServiceTrait,
783 MockJobProducerTrait,
784 >,
785 >,
786 >,
787 }
788
789 impl Default for TestCtx {
790 fn default() -> Self {
791 let mock_repo = MockRelayerRepository::new();
792 let provider = Arc::new(MockSolanaProviderTrait::new());
793 let signer = Arc::new(MockSolanaSignTrait::new());
794 let jupiter = Arc::new(MockJupiterServiceTrait::new());
795 let job = Arc::new(MockJobProducerTrait::new());
796 let tx_repo = Arc::new(MockRepository::<TransactionRepoModel, String>::new());
797 let network_repository = Arc::new(InMemoryNetworkRepository::new());
798
799 let relayer_model = RelayerRepoModel {
800 id: "test-id".to_string(),
801 address: "...".to_string(),
802 network: "devnet".to_string(),
803 ..Default::default()
804 };
805
806 let dex = Arc::new(
807 create_network_dex_generic(
808 &relayer_model,
809 provider.clone(),
810 signer.clone(),
811 jupiter.clone(),
812 )
813 .unwrap(),
814 );
815
816 let rpc_handler = Arc::new(SolanaRpcHandler::new(SolanaRpcMethodsImpl::new_mock(
817 relayer_model.clone(),
818 provider.clone(),
819 signer.clone(),
820 jupiter.clone(),
821 job.clone(),
822 )));
823
824 TestCtx {
825 relayer_model,
826 mock_repo,
827 network_repository,
828 provider,
829 signer,
830 jupiter,
831 job_producer: job,
832 tx_repo,
833 dex,
834 rpc_handler,
835 }
836 }
837 }
838
839 impl TestCtx {
840 async fn setup_network(&self) {
841 let test_network = NetworkRepoModel {
842 id: "solana:devnet".to_string(),
843 name: "devnet".to_string(),
844 network_type: NetworkType::Solana,
845 config: NetworkConfigData::Solana(SolanaNetworkConfig {
846 common: NetworkConfigCommon {
847 network: "devnet".to_string(),
848 from: None,
849 rpc_urls: Some(vec!["https://api.devnet.solana.com".to_string()]),
850 explorer_urls: None,
851 average_blocktime_ms: Some(400),
852 is_testnet: Some(true),
853 tags: None,
854 },
855 }),
856 };
857
858 self.network_repository.create(test_network).await.unwrap();
859 }
860
861 async fn into_relayer(
862 self,
863 ) -> SolanaRelayer<
864 MockRelayerRepository,
865 MockRepository<TransactionRepoModel, String>,
866 MockJobProducerTrait,
867 MockSolanaSignTrait,
868 MockJupiterServiceTrait,
869 MockSolanaProviderTrait,
870 > {
871 self.setup_network().await;
873
874 let network_repo = self
876 .network_repository
877 .get(NetworkType::Solana, "devnet")
878 .await
879 .unwrap()
880 .unwrap();
881 let network = SolanaNetwork::try_from(network_repo).unwrap();
882
883 SolanaRelayer {
884 relayer: self.relayer_model.clone(),
885 signer: self.signer,
886 network,
887 provider: self.provider,
888 rpc_handler: self.rpc_handler,
889 relayer_repository: Arc::new(self.mock_repo),
890 transaction_repository: self.tx_repo,
891 job_producer: self.job_producer,
892 dex_service: self.dex,
893 }
894 }
895 }
896
897 fn create_test_relayer() -> RelayerRepoModel {
898 RelayerRepoModel {
899 id: "test-relayer-id".to_string(),
900 address: "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin".to_string(),
901 notification_id: Some("test-notification-id".to_string()),
902 ..Default::default()
903 }
904 }
905
906 fn create_token_policy(
907 mint: &str,
908 min_amount: Option<u64>,
909 max_amount: Option<u64>,
910 retain_min: Option<u64>,
911 slippage: Option<u64>,
912 ) -> SolanaAllowedTokensPolicy {
913 let mut token = SolanaAllowedTokensPolicy {
914 mint: mint.to_string(),
915 max_allowed_fee: Some(0),
916 swap_config: None,
917 decimals: Some(9),
918 symbol: Some("SOL".to_string()),
919 };
920
921 let swap_config = SolanaAllowedTokensSwapConfig {
922 min_amount,
923 max_amount,
924 retain_min_amount: retain_min,
925 slippage_percentage: slippage.map(|s| s as f32),
926 };
927
928 token.swap_config = Some(swap_config);
929 token
930 }
931
932 #[tokio::test]
933 async fn test_calculate_swap_amount_no_limits() {
934 let ctx = TestCtx::default();
935 let solana_relayer = ctx.into_relayer().await;
936
937 assert_eq!(
938 solana_relayer
939 .calculate_swap_amount(100, None, None, None)
940 .unwrap(),
941 100
942 );
943 }
944
945 #[tokio::test]
946 async fn test_calculate_swap_amount_with_max() {
947 let ctx = TestCtx::default();
948 let solana_relayer = ctx.into_relayer().await;
949
950 assert_eq!(
951 solana_relayer
952 .calculate_swap_amount(100, None, Some(60), None)
953 .unwrap(),
954 60
955 );
956 }
957
958 #[tokio::test]
959 async fn test_calculate_swap_amount_with_retain() {
960 let ctx = TestCtx::default();
961 let solana_relayer = ctx.into_relayer().await;
962
963 assert_eq!(
964 solana_relayer
965 .calculate_swap_amount(100, None, None, Some(30))
966 .unwrap(),
967 70
968 );
969
970 assert_eq!(
971 solana_relayer
972 .calculate_swap_amount(20, None, None, Some(30))
973 .unwrap(),
974 0
975 );
976 }
977
978 #[tokio::test]
979 async fn test_calculate_swap_amount_with_min() {
980 let ctx = TestCtx::default();
981 let solana_relayer = ctx.into_relayer().await;
982
983 assert_eq!(
984 solana_relayer
985 .calculate_swap_amount(40, Some(50), None, None)
986 .unwrap(),
987 0
988 );
989
990 assert_eq!(
991 solana_relayer
992 .calculate_swap_amount(100, Some(50), None, None)
993 .unwrap(),
994 100
995 );
996 }
997
998 #[tokio::test]
999 async fn test_calculate_swap_amount_combined() {
1000 let ctx = TestCtx::default();
1001 let solana_relayer = ctx.into_relayer().await;
1002
1003 assert_eq!(
1004 solana_relayer
1005 .calculate_swap_amount(100, None, Some(50), Some(30))
1006 .unwrap(),
1007 50
1008 );
1009
1010 assert_eq!(
1011 solana_relayer
1012 .calculate_swap_amount(100, Some(20), Some(50), Some(30))
1013 .unwrap(),
1014 50
1015 );
1016
1017 assert_eq!(
1018 solana_relayer
1019 .calculate_swap_amount(100, Some(60), Some(50), Some(30))
1020 .unwrap(),
1021 0
1022 );
1023 }
1024
1025 #[tokio::test]
1026 async fn test_handle_token_swap_request_successful_swap_jupiter_swap_strategy() {
1027 let mut relayer_model = create_test_relayer();
1028
1029 let mut mock_relayer_repo = MockRelayerRepository::new();
1030 let id = relayer_model.id.clone();
1031
1032 relayer_model.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy {
1033 swap_config: Some(RelayerSolanaSwapConfig {
1034 strategy: Some(SolanaSwapStrategy::JupiterSwap),
1035 cron_schedule: None,
1036 min_balance_threshold: None,
1037 jupiter_swap_options: None,
1038 }),
1039 allowed_tokens: Some(vec![create_token_policy(
1040 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
1041 Some(1),
1042 None,
1043 None,
1044 Some(50),
1045 )]),
1046 ..Default::default()
1047 });
1048 let cloned = relayer_model.clone();
1049
1050 mock_relayer_repo
1051 .expect_get_by_id()
1052 .with(eq(id.clone()))
1053 .times(1)
1054 .returning(move |_| Ok(cloned.clone()));
1055
1056 let mut raw_provider = MockSolanaProviderTrait::new();
1057
1058 raw_provider
1059 .expect_get_account_from_pubkey()
1060 .returning(|_| {
1061 Box::pin(async {
1062 let mut account_data = vec![0; SplAccount::LEN];
1063
1064 let token_account = spl_token::state::Account {
1065 mint: Pubkey::new_unique(),
1066 owner: Pubkey::new_unique(),
1067 amount: 10000000,
1068 state: spl_token::state::AccountState::Initialized,
1069 ..Default::default()
1070 };
1071 spl_token::state::Account::pack(token_account, &mut account_data).unwrap();
1072
1073 Ok(solana_sdk::account::Account {
1074 lamports: 1_000_000,
1075 data: account_data,
1076 owner: spl_token::id(),
1077 executable: false,
1078 rent_epoch: 0,
1079 })
1080 })
1081 });
1082
1083 let mut jupiter_mock = MockJupiterServiceTrait::new();
1084
1085 jupiter_mock.expect_get_quote().returning(|_| {
1086 Box::pin(async {
1087 Ok(QuoteResponse {
1088 input_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
1089 output_mint: WRAPPED_SOL_MINT.to_string(),
1090 in_amount: 10,
1091 out_amount: 10,
1092 other_amount_threshold: 1,
1093 swap_mode: "ExactIn".to_string(),
1094 price_impact_pct: 0.0,
1095 route_plan: vec![RoutePlan {
1096 percent: 100,
1097 swap_info: SwapInfo {
1098 amm_key: "mock_amm_key".to_string(),
1099 label: "mock_label".to_string(),
1100 input_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
1101 output_mint: WRAPPED_SOL_MINT.to_string(),
1102 in_amount: "1000".to_string(),
1103 out_amount: "1000".to_string(),
1104 fee_amount: "0".to_string(),
1105 fee_mint: "mock_fee_mint".to_string(),
1106 },
1107 }],
1108 slippage_bps: 0,
1109 })
1110 })
1111 });
1112
1113 jupiter_mock.expect_get_swap_transaction().returning(|_| {
1114 Box::pin(async {
1115 Ok(SwapResponse {
1116 swap_transaction: "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAKEZhsMunBegjHhwObzSrJeKhnl3sehIwqA8OCTejBJ/Z+O7sAR2gDS0+R1HXkqqjr0Wo3+auYeJQtq0il4DAumgiiHZpJZ1Uy9xq1yiOta3BcBOI7Dv+jmETs0W7Leny+AsVIwZWPN51bjn3Xk4uSzTFeAEom3HHY/EcBBpOfm7HkzWyukBvmNY5l9pnNxB/lTC52M7jy0Pxg6NhYJ37e1WXRYOFdoHOThs0hoFy/UG3+mVBbkR4sB9ywdKopv6IHO9+wuF/sV/02h9w+AjIBszK2bmCBPIrCZH4mqBdRcBFVAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABPS2wOQQj9KmokeOrgrMWdshu07fURwWLPYC0eDAkB+1Jh0UqsxbwO7GNdqHBaH3CjnuNams8L+PIsxs5JAZ16jJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FmsH4P9uc5VDeldVYzceVRhzPQ3SsaI7BOphAAiCnjaBgMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAtD/6J/XX9kp0wJsfKVh53ksJqzbfyd1RSzIap7OM5ejnStls42Wf0xNRAChL93gEW4UQqPNOSYySLu5vwwX4aQR51VvyMcBu7nTFbs5oFQf9sbLeo/SOUQKxzaJWvBOPBt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKkGtJJ5s3DlXjsp517KoA8Lg71wC+tMHoDO9HDeQbotrwUMAAUCwFwVAAwACQOhzhsAAAAAAAoGAAQAIgcQAQEPOxAIAAUGAgQgIg8PDQ8hEg4JExEGARQUFAgQKAgmKgEDFhgXFSUnJCkQIywQIysIHSIqAh8DHhkbGhwLL8EgmzNB1pyBBwMAAAA6AWQAAU9kAQIvAABkAgNAQg8AAAAAAE3WYgAAAAAADwAAEAMEAAABCQMW8exZwhONJLLrrr9eKTOouI7XVrRLBjytPl3cL6rziwS+v7vCBB+8CQctooGHnRbQ3aoExfOLSH0uJhZijTPAKrJbYSJJ5hP1VwRmY2FlBkRkC2JtQsJRwDIR3Tbag/HLEdZxTPfqLWdCCyd0nco65bHdIoy/ByorMycoLzADMiYs".to_string(),
1117 last_valid_block_height: 100,
1118 prioritization_fee_lamports: None,
1119 compute_unit_limit: None,
1120 simulation_error: None,
1121 })
1122 })
1123 });
1124
1125 let mut signer = MockSolanaSignTrait::new();
1126 let test_signature = Signature::from_str("2jg9xbGLtZRsiJBrDWQnz33JuLjDkiKSZuxZPdjJ3qrJbMeTEerXFAKynkPW63J88nq63cvosDNRsg9VqHtGixvP").unwrap();
1127
1128 signer
1129 .expect_sign()
1130 .times(1)
1131 .returning(move |_| Box::pin(async move { Ok(test_signature) }));
1132
1133 raw_provider
1134 .expect_send_versioned_transaction()
1135 .times(1)
1136 .returning(move |_| Box::pin(async move { Ok(test_signature) }));
1137
1138 raw_provider
1139 .expect_confirm_transaction()
1140 .times(1)
1141 .returning(move |_| Box::pin(async move { Ok(true) }));
1142
1143 let provider_arc = Arc::new(raw_provider);
1144 let jupiter_arc = Arc::new(jupiter_mock);
1145 let signer_arc = Arc::new(signer);
1146
1147 let dex = Arc::new(
1148 create_network_dex_generic(
1149 &relayer_model,
1150 provider_arc.clone(),
1151 signer_arc.clone(),
1152 jupiter_arc.clone(),
1153 )
1154 .unwrap(),
1155 );
1156
1157 let mut job_producer = MockJobProducerTrait::new();
1158 job_producer
1159 .expect_produce_send_notification_job()
1160 .times(1)
1161 .returning(|_, _| Box::pin(async { Ok(()) }));
1162
1163 let job_producer_arc = Arc::new(job_producer);
1164
1165 let ctx = TestCtx {
1166 relayer_model,
1167 mock_repo: mock_relayer_repo,
1168 provider: provider_arc.clone(),
1169 jupiter: jupiter_arc.clone(),
1170 signer: signer_arc.clone(),
1171 dex,
1172 job_producer: job_producer_arc.clone(),
1173 ..Default::default()
1174 };
1175 let solana_relayer = ctx.into_relayer().await;
1176 let res = solana_relayer
1177 .handle_token_swap_request(create_test_relayer().id)
1178 .await
1179 .unwrap();
1180 assert_eq!(res.len(), 1);
1181 let swap = &res[0];
1182 assert_eq!(swap.source_amount, 10000000);
1183 assert_eq!(swap.destination_amount, 10);
1184 assert_eq!(swap.transaction_signature, test_signature.to_string());
1185 }
1186
1187 #[tokio::test]
1188 async fn test_handle_token_swap_request_successful_swap_jupiter_ultra_strategy() {
1189 let mut relayer_model = create_test_relayer();
1190
1191 let mut mock_relayer_repo = MockRelayerRepository::new();
1192 let id = relayer_model.id.clone();
1193
1194 relayer_model.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy {
1195 swap_config: Some(RelayerSolanaSwapConfig {
1196 strategy: Some(SolanaSwapStrategy::JupiterUltra),
1197 cron_schedule: None,
1198 min_balance_threshold: None,
1199 jupiter_swap_options: None,
1200 }),
1201 allowed_tokens: Some(vec![create_token_policy(
1202 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
1203 Some(1),
1204 None,
1205 None,
1206 Some(50),
1207 )]),
1208 ..Default::default()
1209 });
1210 let cloned = relayer_model.clone();
1211
1212 mock_relayer_repo
1213 .expect_get_by_id()
1214 .with(eq(id.clone()))
1215 .times(1)
1216 .returning(move |_| Ok(cloned.clone()));
1217
1218 let mut raw_provider = MockSolanaProviderTrait::new();
1219
1220 raw_provider
1221 .expect_get_account_from_pubkey()
1222 .returning(|_| {
1223 Box::pin(async {
1224 let mut account_data = vec![0; SplAccount::LEN];
1225
1226 let token_account = spl_token::state::Account {
1227 mint: Pubkey::new_unique(),
1228 owner: Pubkey::new_unique(),
1229 amount: 10000000,
1230 state: spl_token::state::AccountState::Initialized,
1231 ..Default::default()
1232 };
1233 spl_token::state::Account::pack(token_account, &mut account_data).unwrap();
1234
1235 Ok(solana_sdk::account::Account {
1236 lamports: 1_000_000,
1237 data: account_data,
1238 owner: spl_token::id(),
1239 executable: false,
1240 rent_epoch: 0,
1241 })
1242 })
1243 });
1244
1245 let mut jupiter_mock = MockJupiterServiceTrait::new();
1246 jupiter_mock.expect_get_ultra_order().returning(|_| {
1247 Box::pin(async {
1248 Ok(UltraOrderResponse {
1249 transaction: Some("AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAKEZhsMunBegjHhwObzSrJeKhnl3sehIwqA8OCTejBJ/Z+O7sAR2gDS0+R1HXkqqjr0Wo3+auYeJQtq0il4DAumgiiHZpJZ1Uy9xq1yiOta3BcBOI7Dv+jmETs0W7Leny+AsVIwZWPN51bjn3Xk4uSzTFeAEom3HHY/EcBBpOfm7HkzWyukBvmNY5l9pnNxB/lTC52M7jy0Pxg6NhYJ37e1WXRYOFdoHOThs0hoFy/UG3+mVBbkR4sB9ywdKopv6IHO9+wuF/sV/02h9w+AjIBszK2bmCBPIrCZH4mqBdRcBFVAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABPS2wOQQj9KmokeOrgrMWdshu07fURwWLPYC0eDAkB+1Jh0UqsxbwO7GNdqHBaH3CjnuNams8L+PIsxs5JAZ16jJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FmsH4P9uc5VDeldVYzceVRhzPQ3SsaI7BOphAAiCnjaBgMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAtD/6J/XX9kp0wJsfKVh53ksJqzbfyd1RSzIap7OM5ejnStls42Wf0xNRAChL93gEW4UQqPNOSYySLu5vwwX4aQR51VvyMcBu7nTFbs5oFQf9sbLeo/SOUQKxzaJWvBOPBt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKkGtJJ5s3DlXjsp517KoA8Lg71wC+tMHoDO9HDeQbotrwUMAAUCwFwVAAwACQOhzhsAAAAAAAoGAAQAIgcQAQEPOxAIAAUGAgQgIg8PDQ8hEg4JExEGARQUFAgQKAgmKgEDFhgXFSUnJCkQIywQIysIHSIqAh8DHhkbGhwLL8EgmzNB1pyBBwMAAAA6AWQAAU9kAQIvAABkAgNAQg8AAAAAAE3WYgAAAAAADwAAEAMEAAABCQMW8exZwhONJLLrrr9eKTOouI7XVrRLBjytPl3cL6rziwS+v7vCBB+8CQctooGHnRbQ3aoExfOLSH0uJhZijTPAKrJbYSJJ5hP1VwRmY2FlBkRkC2JtQsJRwDIR3Tbag/HLEdZxTPfqLWdCCyd0nco65bHdIoy/ByorMycoLzADMiYs".to_string()),
1250 input_mint: "PjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
1251 output_mint: WRAPPED_SOL_MINT.to_string(),
1252 in_amount: 10,
1253 out_amount: 10,
1254 other_amount_threshold: 1,
1255 swap_mode: "ExactIn".to_string(),
1256 price_impact_pct: 0.0,
1257 route_plan: vec![RoutePlan {
1258 percent: 100,
1259 swap_info: SwapInfo {
1260 amm_key: "mock_amm_key".to_string(),
1261 label: "mock_label".to_string(),
1262 input_mint: "PjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
1263 output_mint: WRAPPED_SOL_MINT.to_string(),
1264 in_amount: "1000".to_string(),
1265 out_amount: "1000".to_string(),
1266 fee_amount: "0".to_string(),
1267 fee_mint: "mock_fee_mint".to_string(),
1268 },
1269 }],
1270 prioritization_fee_lamports: 0,
1271 request_id: "mock_request_id".to_string(),
1272 slippage_bps: 0,
1273 })
1274 })
1275 });
1276
1277 jupiter_mock.expect_execute_ultra_order().returning(|_| {
1278 Box::pin(async {
1279 Ok(UltraExecuteResponse {
1280 signature: Some("2jg9xbGLtZRsiJBrDWQnz33JuLjDkiKSZuxZPdjJ3qrJbMeTEerXFAKynkPW63J88nq63cvosDNRsg9VqHtGixvP".to_string()),
1281 status: "success".to_string(),
1282 slot: Some("123456789".to_string()),
1283 error: None,
1284 code: 0,
1285 total_input_amount: Some("1000000".to_string()),
1286 total_output_amount: Some("1000000".to_string()),
1287 input_amount_result: Some("1000000".to_string()),
1288 output_amount_result: Some("1000000".to_string()),
1289 swap_events: Some(vec![SwapEvents {
1290 input_mint: "mock_input_mint".to_string(),
1291 output_mint: "mock_output_mint".to_string(),
1292 input_amount: "1000000".to_string(),
1293 output_amount: "1000000".to_string(),
1294 }]),
1295 })
1296 })
1297 });
1298
1299 let mut signer = MockSolanaSignTrait::new();
1300 let test_signature = Signature::from_str("2jg9xbGLtZRsiJBrDWQnz33JuLjDkiKSZuxZPdjJ3qrJbMeTEerXFAKynkPW63J88nq63cvosDNRsg9VqHtGixvP").unwrap();
1301
1302 signer
1303 .expect_sign()
1304 .times(1)
1305 .returning(move |_| Box::pin(async move { Ok(test_signature) }));
1306
1307 let provider_arc = Arc::new(raw_provider);
1308 let jupiter_arc = Arc::new(jupiter_mock);
1309 let signer_arc = Arc::new(signer);
1310
1311 let dex = Arc::new(
1312 create_network_dex_generic(
1313 &relayer_model,
1314 provider_arc.clone(),
1315 signer_arc.clone(),
1316 jupiter_arc.clone(),
1317 )
1318 .unwrap(),
1319 );
1320 let mut job_producer = MockJobProducerTrait::new();
1321 job_producer
1322 .expect_produce_send_notification_job()
1323 .times(1)
1324 .returning(|_, _| Box::pin(async { Ok(()) }));
1325
1326 let job_producer_arc = Arc::new(job_producer);
1327
1328 let ctx = TestCtx {
1329 relayer_model,
1330 mock_repo: mock_relayer_repo,
1331 provider: provider_arc.clone(),
1332 jupiter: jupiter_arc.clone(),
1333 signer: signer_arc.clone(),
1334 dex,
1335 job_producer: job_producer_arc.clone(),
1336 ..Default::default()
1337 };
1338 let solana_relayer = ctx.into_relayer().await;
1339
1340 let res = solana_relayer
1341 .handle_token_swap_request(create_test_relayer().id)
1342 .await
1343 .unwrap();
1344 assert_eq!(res.len(), 1);
1345 let swap = &res[0];
1346 assert_eq!(swap.source_amount, 10000000);
1347 assert_eq!(swap.destination_amount, 10);
1348 assert_eq!(swap.transaction_signature, test_signature.to_string());
1349 }
1350
1351 #[tokio::test]
1352 async fn test_handle_token_swap_request_no_swap_config() {
1353 let mut relayer_model = create_test_relayer();
1354
1355 let mut mock_relayer_repo = MockRelayerRepository::new();
1356 let id = relayer_model.id.clone();
1357 let cloned = relayer_model.clone();
1358 mock_relayer_repo
1359 .expect_get_by_id()
1360 .with(eq(id.clone()))
1361 .times(1)
1362 .returning(move |_| Ok(cloned.clone()));
1363
1364 relayer_model.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy {
1365 swap_config: Some(RelayerSolanaSwapConfig {
1366 strategy: Some(SolanaSwapStrategy::JupiterSwap),
1367 cron_schedule: None,
1368 min_balance_threshold: None,
1369 jupiter_swap_options: None,
1370 }),
1371 allowed_tokens: Some(vec![create_token_policy(
1372 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
1373 Some(1),
1374 None,
1375 None,
1376 Some(50),
1377 )]),
1378 ..Default::default()
1379 });
1380 let mut job_producer = MockJobProducerTrait::new();
1381 job_producer.expect_produce_send_notification_job().times(0);
1382
1383 let job_producer_arc = Arc::new(job_producer);
1384
1385 let ctx = TestCtx {
1386 relayer_model,
1387 mock_repo: mock_relayer_repo,
1388 job_producer: job_producer_arc,
1389 ..Default::default()
1390 };
1391 let solana_relayer = ctx.into_relayer().await;
1392
1393 let res = solana_relayer.handle_token_swap_request(id).await;
1394 assert!(res.is_ok());
1395 assert!(res.unwrap().is_empty());
1396 }
1397
1398 #[tokio::test]
1399 async fn test_handle_token_swap_request_no_strategy() {
1400 let mut relayer_model: RelayerRepoModel = create_test_relayer();
1401
1402 let mut mock_relayer_repo = MockRelayerRepository::new();
1403 let id = relayer_model.id.clone();
1404 let cloned = relayer_model.clone();
1405 mock_relayer_repo
1406 .expect_get_by_id()
1407 .with(eq(id.clone()))
1408 .times(1)
1409 .returning(move |_| Ok(cloned.clone()));
1410
1411 relayer_model.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy {
1412 swap_config: Some(RelayerSolanaSwapConfig {
1413 strategy: None,
1414 cron_schedule: None,
1415 min_balance_threshold: Some(1),
1416 jupiter_swap_options: None,
1417 }),
1418 ..Default::default()
1419 });
1420
1421 let ctx = TestCtx {
1422 relayer_model,
1423 mock_repo: mock_relayer_repo,
1424 ..Default::default()
1425 };
1426 let solana_relayer = ctx.into_relayer().await;
1427
1428 let res = solana_relayer.handle_token_swap_request(id).await.unwrap();
1429 assert!(res.is_empty(), "should return empty when no strategy");
1430 }
1431
1432 #[tokio::test]
1433 async fn test_handle_token_swap_request_no_allowed_tokens() {
1434 let mut relayer_model: RelayerRepoModel = create_test_relayer();
1435 let mut mock_relayer_repo = MockRelayerRepository::new();
1436 let id = relayer_model.id.clone();
1437 let cloned = relayer_model.clone();
1438 mock_relayer_repo
1439 .expect_get_by_id()
1440 .with(eq(id.clone()))
1441 .times(1)
1442 .returning(move |_| Ok(cloned.clone()));
1443
1444 relayer_model.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy {
1445 swap_config: Some(RelayerSolanaSwapConfig {
1446 strategy: Some(SolanaSwapStrategy::JupiterSwap),
1447 cron_schedule: None,
1448 min_balance_threshold: Some(1),
1449 jupiter_swap_options: None,
1450 }),
1451 allowed_tokens: None,
1452 ..Default::default()
1453 });
1454
1455 let ctx = TestCtx {
1456 relayer_model,
1457 mock_repo: mock_relayer_repo,
1458 ..Default::default()
1459 };
1460 let solana_relayer = ctx.into_relayer().await;
1461
1462 let res = solana_relayer.handle_token_swap_request(id).await.unwrap();
1463 assert!(res.is_empty(), "should return empty when no allowed_tokens");
1464 }
1465
1466 #[tokio::test]
1467 async fn test_validate_rpc_success() {
1468 let mut raw_provider = MockSolanaProviderTrait::new();
1469 raw_provider
1470 .expect_get_latest_blockhash()
1471 .times(1)
1472 .returning(|| Box::pin(async { Ok(Hash::new_unique()) }));
1473
1474 let ctx = TestCtx {
1475 provider: Arc::new(raw_provider),
1476 ..Default::default()
1477 };
1478 let solana_relayer = ctx.into_relayer().await;
1479 let res = solana_relayer.validate_rpc().await;
1480
1481 assert!(
1482 res.is_ok(),
1483 "validate_rpc should succeed when blockhash fetch succeeds"
1484 );
1485 }
1486
1487 #[tokio::test]
1488 async fn test_validate_rpc_provider_error() {
1489 let mut raw_provider = MockSolanaProviderTrait::new();
1490 raw_provider
1491 .expect_get_latest_blockhash()
1492 .times(1)
1493 .returning(|| {
1494 Box::pin(async { Err(SolanaProviderError::RpcError("rpc failure".to_string())) })
1495 });
1496
1497 let ctx = TestCtx {
1498 provider: Arc::new(raw_provider),
1499 ..Default::default()
1500 };
1501
1502 let solana_relayer = ctx.into_relayer().await;
1503 let err = solana_relayer.validate_rpc().await.unwrap_err();
1504
1505 match err {
1506 RelayerError::ProviderError(msg) => {
1507 assert!(msg.contains("rpc failure"));
1508 }
1509 other => panic!("expected ProviderError, got {:?}", other),
1510 }
1511 }
1512
1513 #[tokio::test]
1514 async fn test_check_balance_no_swap_config() {
1515 let ctx = TestCtx::default();
1517 let solana_relayer = ctx.into_relayer().await;
1518
1519 assert!(solana_relayer
1521 .check_balance_and_trigger_token_swap_if_needed()
1522 .await
1523 .is_ok());
1524 }
1525
1526 #[tokio::test]
1527 async fn test_check_balance_no_threshold() {
1528 let mut ctx = TestCtx::default();
1530 let mut model = ctx.relayer_model.clone();
1531 model.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy {
1532 swap_config: Some(RelayerSolanaSwapConfig {
1533 strategy: Some(SolanaSwapStrategy::JupiterSwap),
1534 cron_schedule: None,
1535 min_balance_threshold: None,
1536 jupiter_swap_options: None,
1537 }),
1538 ..Default::default()
1539 });
1540 ctx.relayer_model = model;
1541 let solana_relayer = ctx.into_relayer().await;
1542
1543 assert!(solana_relayer
1544 .check_balance_and_trigger_token_swap_if_needed()
1545 .await
1546 .is_ok());
1547 }
1548
1549 #[tokio::test]
1550 async fn test_check_balance_above_threshold() {
1551 let mut raw_provider = MockSolanaProviderTrait::new();
1552 raw_provider
1553 .expect_get_balance()
1554 .times(1)
1555 .returning(|_| Box::pin(async { Ok(20_u64) }));
1556 let provider = Arc::new(raw_provider);
1557 let mut raw_job = MockJobProducerTrait::new();
1558 raw_job
1559 .expect_produce_solana_token_swap_request_job()
1560 .withf(move |req, _opts| req.relayer_id == "test-id")
1561 .times(0);
1562 let job_producer = Arc::new(raw_job);
1563
1564 let ctx = TestCtx {
1565 provider,
1566 job_producer,
1567 ..Default::default()
1568 };
1569 let mut model = ctx.relayer_model.clone();
1571 model.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy {
1572 swap_config: Some(RelayerSolanaSwapConfig {
1573 strategy: Some(SolanaSwapStrategy::JupiterSwap),
1574 cron_schedule: None,
1575 min_balance_threshold: Some(10),
1576 jupiter_swap_options: None,
1577 }),
1578 ..Default::default()
1579 });
1580 let mut ctx = ctx;
1581 ctx.relayer_model = model;
1582
1583 let solana_relayer = ctx.into_relayer().await;
1584 assert!(solana_relayer
1585 .check_balance_and_trigger_token_swap_if_needed()
1586 .await
1587 .is_ok());
1588 }
1589
1590 #[tokio::test]
1591 async fn test_check_balance_below_threshold_triggers_job() {
1592 let mut raw_provider = MockSolanaProviderTrait::new();
1593 raw_provider
1594 .expect_get_balance()
1595 .times(1)
1596 .returning(|_| Box::pin(async { Ok(5_u64) }));
1597
1598 let mut raw_job = MockJobProducerTrait::new();
1599 raw_job
1600 .expect_produce_solana_token_swap_request_job()
1601 .times(1)
1602 .returning(|_, _| Box::pin(async { Ok(()) }));
1603 let job_producer = Arc::new(raw_job);
1604
1605 let mut model = create_test_relayer();
1606 model.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy {
1607 swap_config: Some(RelayerSolanaSwapConfig {
1608 strategy: Some(SolanaSwapStrategy::JupiterSwap),
1609 cron_schedule: None,
1610 min_balance_threshold: Some(10),
1611 jupiter_swap_options: None,
1612 }),
1613 ..Default::default()
1614 });
1615
1616 let ctx = TestCtx {
1617 relayer_model: model,
1618 provider: Arc::new(raw_provider),
1619 job_producer,
1620 ..Default::default()
1621 };
1622
1623 let solana_relayer = ctx.into_relayer().await;
1624 assert!(solana_relayer
1625 .check_balance_and_trigger_token_swap_if_needed()
1626 .await
1627 .is_ok());
1628 }
1629
1630 #[tokio::test]
1631 async fn test_get_balance_success() {
1632 let mut raw_provider = MockSolanaProviderTrait::new();
1633 raw_provider
1634 .expect_get_balance()
1635 .times(1)
1636 .returning(|_| Box::pin(async { Ok(42_u64) }));
1637 let ctx = TestCtx {
1638 provider: Arc::new(raw_provider),
1639 ..Default::default()
1640 };
1641 let solana_relayer = ctx.into_relayer().await;
1642
1643 let res = solana_relayer.get_balance().await.unwrap();
1644
1645 assert_eq!(res.balance, 42_u128);
1646 assert_eq!(res.unit, SOLANA_SMALLEST_UNIT_NAME);
1647 }
1648
1649 #[tokio::test]
1650 async fn test_get_balance_provider_error() {
1651 let mut raw_provider = MockSolanaProviderTrait::new();
1652 raw_provider
1653 .expect_get_balance()
1654 .times(1)
1655 .returning(|_| Box::pin(async { Err(SolanaProviderError::RpcError("oops".into())) }));
1656 let ctx = TestCtx {
1657 provider: Arc::new(raw_provider),
1658 ..Default::default()
1659 };
1660 let solana_relayer = ctx.into_relayer().await;
1661
1662 let err = solana_relayer.get_balance().await.unwrap_err();
1663
1664 match err {
1665 RelayerError::UnderlyingSolanaProvider(err) => {
1666 assert!(err.to_string().contains("oops"));
1667 }
1668 other => panic!("expected ProviderError, got {:?}", other),
1669 }
1670 }
1671
1672 #[tokio::test]
1673 async fn test_validate_min_balance_success() {
1674 let mut raw_provider = MockSolanaProviderTrait::new();
1675 raw_provider
1676 .expect_get_balance()
1677 .times(1)
1678 .returning(|_| Box::pin(async { Ok(100_u64) }));
1679
1680 let mut model = create_test_relayer();
1681 model.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy {
1682 min_balance: 50,
1683 ..Default::default()
1684 });
1685
1686 let ctx = TestCtx {
1687 relayer_model: model,
1688 provider: Arc::new(raw_provider),
1689 ..Default::default()
1690 };
1691
1692 let solana_relayer = ctx.into_relayer().await;
1693 assert!(solana_relayer.validate_min_balance().await.is_ok());
1694 }
1695
1696 #[tokio::test]
1697 async fn test_validate_min_balance_insufficient() {
1698 let mut raw_provider = MockSolanaProviderTrait::new();
1699 raw_provider
1700 .expect_get_balance()
1701 .times(1)
1702 .returning(|_| Box::pin(async { Ok(10_u64) }));
1703
1704 let mut model = create_test_relayer();
1705 model.policies = RelayerNetworkPolicy::Solana(RelayerSolanaPolicy {
1706 min_balance: 50,
1707 ..Default::default()
1708 });
1709
1710 let ctx = TestCtx {
1711 relayer_model: model,
1712 provider: Arc::new(raw_provider),
1713 ..Default::default()
1714 };
1715
1716 let solana_relayer = ctx.into_relayer().await;
1717 let err = solana_relayer.validate_min_balance().await.unwrap_err();
1718 match err {
1719 RelayerError::InsufficientBalanceError(msg) => {
1720 assert_eq!(msg, "Insufficient balance");
1721 }
1722 other => panic!("expected InsufficientBalanceError, got {:?}", other),
1723 }
1724 }
1725
1726 #[tokio::test]
1727 async fn test_validate_min_balance_provider_error() {
1728 let mut raw_provider = MockSolanaProviderTrait::new();
1729 raw_provider
1730 .expect_get_balance()
1731 .times(1)
1732 .returning(|_| Box::pin(async { Err(SolanaProviderError::RpcError("fail".into())) }));
1733 let ctx = TestCtx {
1734 provider: Arc::new(raw_provider),
1735 ..Default::default()
1736 };
1737
1738 let solana_relayer = ctx.into_relayer().await;
1739 let err = solana_relayer.validate_min_balance().await.unwrap_err();
1740 match err {
1741 RelayerError::ProviderError(msg) => {
1742 assert!(msg.contains("fail"));
1743 }
1744 other => panic!("expected ProviderError, got {:?}", other),
1745 }
1746 }
1747
1748 #[tokio::test]
1749 async fn test_rpc_invalid_params() {
1750 let ctx = TestCtx::default();
1751 let solana_relayer = ctx.into_relayer().await;
1752
1753 let req = JsonRpcRequest {
1754 jsonrpc: "2.0".to_string(),
1755 params: NetworkRpcRequest::Solana(crate::models::SolanaRpcRequest::FeeEstimate(
1756 FeeEstimateRequestParams {
1757 transaction: EncodedSerializedTransaction::new("".to_string()),
1758 fee_token: "".to_string(),
1759 },
1760 )),
1761 id: Some(JsonRpcId::Number(1)),
1762 };
1763 let resp = solana_relayer.rpc(req).await.unwrap();
1764
1765 assert!(resp.error.is_some(), "expected an error object");
1766 let err = resp.error.unwrap();
1767 assert_eq!(err.code, -32601);
1768 assert_eq!(err.message, "INVALID_PARAMS");
1769 }
1770
1771 #[tokio::test]
1772 async fn test_rpc_success() {
1773 let ctx = TestCtx::default();
1774 let solana_relayer = ctx.into_relayer().await;
1775
1776 let req = JsonRpcRequest {
1777 jsonrpc: "2.0".to_string(),
1778 params: NetworkRpcRequest::Solana(crate::models::SolanaRpcRequest::GetFeaturesEnabled(
1779 GetFeaturesEnabledRequestParams {},
1780 )),
1781 id: Some(JsonRpcId::Number(1)),
1782 };
1783 let resp = solana_relayer.rpc(req).await.unwrap();
1784
1785 assert!(resp.error.is_none(), "error should be None");
1786 let data = resp.result.unwrap();
1787 let sol_res = match data {
1788 NetworkRpcResult::Solana(inner) => inner,
1789 other => panic!("expected Solana, got {:?}", other),
1790 };
1791 let features = match sol_res {
1792 SolanaRpcResult::GetFeaturesEnabled(f) => f,
1793 other => panic!("expected GetFeaturesEnabled, got {:?}", other),
1794 };
1795 assert_eq!(features.features, vec!["gasless".to_string()]);
1796 }
1797}