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