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