openzeppelin_relayer/domain/relayer/solana/
solana_relayer.rs

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