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