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, 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    /// Validates the RPC connection by fetching the latest blockhash.
130    ///
131    /// This method sends a request to the Solana RPC to obtain the latest blockhash.
132    /// If the call fails, it returns a `RelayerError::ProviderError` containing the error message.
133    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    /// Populates the allowed tokens metadata for the Solana relayer policy.
143    ///
144    /// This method checks whether allowed tokens have been configured in the relayer's policy.
145    /// If allowed tokens are provided, it concurrently fetches token metadata from the Solana
146    /// provider for each token using its mint address, maps the metadata into instances of
147    /// `SolanaAllowedTokensPolicy`, and then updates the relayer policy with the new metadata.
148    ///
149    /// If no allowed tokens are specified, it logs an informational message and returns the policy
150    /// unchanged.
151    ///
152    /// Finally, the updated policy is stored in the repository.
153    async fn populate_allowed_tokens_metadata(&self) -> Result<RelayerSolanaPolicy, RelayerError> {
154        let mut policy = self.relayer.policies.get_solana_policy();
155        // Check if allowed_tokens is specified; if not, return the policy unchanged.
156        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            // Propagate errors from get_token_metadata_from_pubkey instead of panicking.
166            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    /// Validates the allowed programs policy.
195    ///
196    /// This method retrieves the allowed programs specified in the Solana relayer policy.
197    /// For each allowed program, it fetches the associated account data from the provider and
198    /// verifies that the program is executable.
199    /// If any of the programs are not executable, it returns a
200    /// `RelayerError::PolicyConfigurationError`.
201    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    /// Checks the relayer's balance and triggers a token swap if the balance is below the
236    /// specified threshold.
237    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    // Helper function to calculate swap amount
280    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        // Cap the swap amount at the maximum if specified
288        let mut amount = max_amount
289            .map(|max| std::cmp::min(current_balance, max))
290            .unwrap_or(current_balance);
291
292        // Adjust for retain minimum if specified
293        if let Some(retain) = retain_min {
294            if current_balance > retain {
295                amount = std::cmp::min(amount, current_balance - retain);
296            } else {
297                // Not enough to retain the minimum after swap
298                return Ok(0);
299            }
300        }
301
302        // Check if we have enough tokens to meet minimum swap requirement
303        if let Some(min) = min_amount {
304            if amount < min {
305                return Ok(0); // Not enough tokens to swap
306            }
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    /// Processes a token‐swap request for the given relayer ID:
324    ///
325    /// 1. Loads the relayer's on‐chain policy (must include swap_config & strategy).
326    /// 2. Iterates allowed tokens, fetching each SPL token account and calculating how much
327    ///    to swap based on min, max, and retain settings.
328    /// 3. Executes each swap through the DEX service (e.g. Jupiter).
329    /// 4. Collects and returns all `SwapResult`s (empty if no swaps were needed).
330    ///
331    /// Returns a `RelayerError` on any repository, provider, or swap execution failure.
332    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                        // Add the token to the list of eligible tokens for swapping
403                        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        // Execute swap for every eligible token
416        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(), // SOL mint
441                        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                        // Trigger a token swap request if the relayer has insufficient funds
572                        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        // Populate model with allowed token metadata and update DB entry
680        // Error will be thrown if any of the tokens are not found
681        self.populate_allowed_tokens_metadata().await.map_err(|_| {
682            RelayerError::PolicyConfigurationError(
683                "Error while processing allowed tokens policy".into(),
684            )
685        })?;
686
687        // Validate relayer allowed programs policy
688        // Error will be thrown if any of the programs are not executable
689        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        // disable relayer if any check fails
700        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    /// Bundles all the pieces you need to instantiate a SolanaRelayer.
765    /// Default::default gives you fresh mocks, but you can override any of them.
766    #[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            // Setup network first
872            self.setup_network().await;
873
874            // Get the network from the repository
875            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        // default ctx has no swap_config
1516        let ctx = TestCtx::default();
1517        let solana_relayer = ctx.into_relayer().await;
1518
1519        // should do nothing and succeed
1520        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        // override policy to have a swap_config with no min_balance_threshold
1529        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        // set threshold to 10
1570        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}