1use async_trait::async_trait;
12use eyre::Result;
13#[cfg(test)]
14use mockall::automock;
15use mpl_token_metadata::accounts::Metadata;
16use reqwest::Url;
17use serde::Serialize;
18use solana_client::{
19 client_error::{ClientError, ClientErrorKind},
20 nonblocking::rpc_client::RpcClient,
21 rpc_request::RpcRequest,
22 rpc_response::{RpcPrioritizationFee, RpcSimulateTransactionResult},
23};
24use solana_commitment_config::CommitmentConfig;
25use solana_sdk::{
26 account::Account,
27 hash::Hash,
28 message::Message,
29 program_pack::Pack,
30 pubkey::Pubkey,
31 signature::Signature,
32 transaction::{Transaction, VersionedTransaction},
33};
34use spl_token_interface::state::Mint;
35use std::{str::FromStr, sync::Arc, time::Duration};
36use thiserror::Error;
37
38use once_cell::sync::Lazy;
39
40use crate::{
41 models::{RpcConfig, SolanaTransactionStatus},
42 services::{
43 client_cache::SyncClientCache,
44 provider::{retry_rpc_call, should_mark_provider_failed_by_status_code},
45 },
46};
47
48use super::ProviderError;
49use super::{
50 rpc_selector::{RpcSelector, RpcSelectorError},
51 ProviderConfig, RetryConfig,
52};
53
54use crate::utils::validate_safe_url;
55
56#[derive(Clone, Debug, Eq, PartialEq, Hash)]
57struct SolanaRpcClientKey {
58 url: String,
59 timeout_ms: u128,
60 commitment: CommitmentConfig,
61}
62
63static SOLANA_RPC_CLIENT_CACHE: Lazy<SyncClientCache<SolanaRpcClientKey, RpcClient>> =
64 Lazy::new(SyncClientCache::new);
65
66fn matches_error_pattern(error_msg: &str, pattern: &str) -> bool {
72 let normalized_msg = error_msg.to_lowercase().replace(' ', "");
73 let normalized_pattern = pattern.to_lowercase().replace(' ', "");
74 normalized_msg.contains(&normalized_pattern)
75}
76
77#[derive(Error, Debug, Serialize)]
81pub enum SolanaProviderError {
82 #[error("Network error: {0}")]
84 NetworkError(String),
85
86 #[error("RPC error: {0}")]
88 RpcError(String),
89
90 #[error("Request error (HTTP {status_code}): {error}")]
92 RequestError { error: String, status_code: u16 },
93
94 #[error("Invalid address: {0}")]
96 InvalidAddress(String),
97
98 #[error("RPC selector error: {0}")]
100 SelectorError(RpcSelectorError),
101
102 #[error("Network configuration error: {0}")]
104 NetworkConfiguration(String),
105
106 #[error("Insufficient funds for transaction: {0}")]
108 InsufficientFunds(String),
109
110 #[error("Blockhash not found or expired: {0}")]
112 BlockhashNotFound(String),
113
114 #[error("Invalid transaction: {0}")]
116 InvalidTransaction(String),
117
118 #[error("Transaction already processed: {0}")]
120 AlreadyProcessed(String),
121}
122
123impl SolanaProviderError {
124 pub fn is_transient(&self) -> bool {
144 match self {
145 SolanaProviderError::NetworkError(_) => true,
147 SolanaProviderError::RpcError(_) => true,
148 SolanaProviderError::BlockhashNotFound(_) => true,
149 SolanaProviderError::SelectorError(_) => true,
150
151 SolanaProviderError::RequestError { status_code, .. } => match *status_code {
153 501 | 505 => false, 500 | 502..=504 | 506..=599 => true,
158
159 408 | 425 | 429 => true,
161
162 400..=499 => false,
164
165 _ => false,
167 },
168
169 SolanaProviderError::InsufficientFunds(_) => false,
171 SolanaProviderError::InvalidTransaction(_) => false,
172 SolanaProviderError::AlreadyProcessed(_) => false,
173 SolanaProviderError::InvalidAddress(_) => false,
174 SolanaProviderError::NetworkConfiguration(_) => false,
175 }
176 }
177
178 pub fn from_rpc_error(error: ClientError) -> Self {
183 match error.kind() {
184 ClientErrorKind::Io(_) => SolanaProviderError::NetworkError(error.to_string()),
186
187 ClientErrorKind::Reqwest(reqwest_err) => {
189 if let Some(status) = reqwest_err.status() {
190 SolanaProviderError::RequestError {
191 error: error.to_string(),
192 status_code: status.as_u16(),
193 }
194 } else {
195 SolanaProviderError::NetworkError(error.to_string())
197 }
198 }
199
200 ClientErrorKind::RpcError(rpc_err) => {
202 let rpc_err_str = format!("{rpc_err}");
203 Self::from_rpc_response_error(&rpc_err_str, &error)
204 }
205
206 ClientErrorKind::TransactionError(tx_error) => {
208 Self::from_transaction_error(tx_error, &error)
209 }
210
211 ClientErrorKind::Custom(msg) => {
213 Self::from_rpc_response_error(msg, &error)
215 }
216
217 _ => SolanaProviderError::RpcError(error.to_string()),
219 }
220 }
221
222 fn from_rpc_response_error(rpc_err: &str, full_error: &ClientError) -> Self {
243 let error_str = rpc_err;
244
245 if error_str.contains("-32002") {
247 if matches_error_pattern(error_str, "blockhash not found") {
249 SolanaProviderError::BlockhashNotFound(full_error.to_string())
250 } else if matches_error_pattern(error_str, "insufficient funds") {
251 SolanaProviderError::InsufficientFunds(full_error.to_string())
252 } else {
253 SolanaProviderError::InvalidTransaction(full_error.to_string())
255 }
256 } else if error_str.contains("-32003") {
257 SolanaProviderError::InvalidTransaction(full_error.to_string())
259 } else if error_str.contains("-32004") {
260 SolanaProviderError::RpcError(full_error.to_string())
262 } else if error_str.contains("-32005") {
263 SolanaProviderError::RpcError(full_error.to_string())
265 } else if error_str.contains("-32007") {
266 SolanaProviderError::NetworkConfiguration(full_error.to_string())
268 } else if error_str.contains("-32008") {
269 SolanaProviderError::BlockhashNotFound(full_error.to_string())
271 } else if error_str.contains("-32009") {
272 SolanaProviderError::AlreadyProcessed(full_error.to_string())
274 } else if error_str.contains("-32010") {
275 SolanaProviderError::NetworkConfiguration(full_error.to_string())
277 } else if error_str.contains("-32013") {
278 SolanaProviderError::InvalidTransaction(full_error.to_string())
280 } else if error_str.contains("-32014") {
281 SolanaProviderError::RpcError(full_error.to_string())
283 } else if error_str.contains("-32015") {
284 SolanaProviderError::InvalidTransaction(full_error.to_string())
286 } else if error_str.contains("-32016") {
287 SolanaProviderError::RpcError(full_error.to_string())
289 } else if error_str.contains("-32602") {
290 SolanaProviderError::InvalidTransaction(full_error.to_string())
292 } else {
293 if matches_error_pattern(error_str, "insufficient funds") {
295 SolanaProviderError::InsufficientFunds(full_error.to_string())
296 } else if matches_error_pattern(error_str, "blockhash not found") {
297 SolanaProviderError::BlockhashNotFound(full_error.to_string())
298 } else if matches_error_pattern(error_str, "already processed") {
299 SolanaProviderError::AlreadyProcessed(full_error.to_string())
300 } else {
301 SolanaProviderError::RpcError(full_error.to_string())
303 }
304 }
305 }
306
307 fn from_transaction_error(
309 tx_error: &solana_sdk::transaction::TransactionError,
310 full_error: &ClientError,
311 ) -> Self {
312 use solana_sdk::transaction::TransactionError as TxErr;
313
314 match tx_error {
315 TxErr::InsufficientFundsForFee | TxErr::InsufficientFundsForRent { .. } => {
317 SolanaProviderError::InsufficientFunds(full_error.to_string())
318 }
319
320 TxErr::BlockhashNotFound => {
322 SolanaProviderError::BlockhashNotFound(full_error.to_string())
323 }
324
325 TxErr::AlreadyProcessed => {
327 SolanaProviderError::AlreadyProcessed(full_error.to_string())
328 }
329
330 TxErr::SignatureFailure
332 | TxErr::MissingSignatureForFee
333 | TxErr::InvalidAccountForFee
334 | TxErr::AccountNotFound
335 | TxErr::InvalidAccountIndex
336 | TxErr::InvalidProgramForExecution
337 | TxErr::ProgramAccountNotFound
338 | TxErr::InstructionError(_, _)
339 | TxErr::CallChainTooDeep
340 | TxErr::InvalidWritableAccount
341 | TxErr::InvalidRentPayingAccount
342 | TxErr::WouldExceedMaxBlockCostLimit
343 | TxErr::WouldExceedMaxAccountCostLimit
344 | TxErr::WouldExceedMaxVoteCostLimit
345 | TxErr::WouldExceedAccountDataBlockLimit
346 | TxErr::TooManyAccountLocks
347 | TxErr::AddressLookupTableNotFound
348 | TxErr::InvalidAddressLookupTableOwner
349 | TxErr::InvalidAddressLookupTableData
350 | TxErr::InvalidAddressLookupTableIndex
351 | TxErr::MaxLoadedAccountsDataSizeExceeded
352 | TxErr::InvalidLoadedAccountsDataSizeLimit
353 | TxErr::ResanitizationNeeded
354 | TxErr::ProgramExecutionTemporarilyRestricted { .. }
355 | TxErr::AccountBorrowOutstanding => {
356 SolanaProviderError::InvalidTransaction(full_error.to_string())
357 }
358
359 TxErr::AccountInUse | TxErr::AccountLoadedTwice | TxErr::ClusterMaintenance => {
361 SolanaProviderError::RpcError(full_error.to_string())
362 }
363
364 _ => SolanaProviderError::RpcError(full_error.to_string()),
366 }
367 }
368}
369
370#[async_trait]
372#[cfg_attr(test, automock)]
373#[allow(dead_code)]
374pub trait SolanaProviderTrait: Send + Sync {
375 fn get_configs(&self) -> Vec<RpcConfig>;
376 async fn get_balance(&self, address: &str) -> Result<u64, SolanaProviderError>;
378
379 async fn get_latest_blockhash(&self) -> Result<Hash, SolanaProviderError>;
381
382 async fn get_latest_blockhash_with_commitment(
384 &self,
385 commitment: CommitmentConfig,
386 ) -> Result<(Hash, u64), SolanaProviderError>;
387
388 async fn send_transaction(
390 &self,
391 transaction: &Transaction,
392 ) -> Result<Signature, SolanaProviderError>;
393
394 async fn send_versioned_transaction(
396 &self,
397 transaction: &VersionedTransaction,
398 ) -> Result<Signature, SolanaProviderError>;
399
400 async fn confirm_transaction(&self, signature: &Signature)
402 -> Result<bool, SolanaProviderError>;
403
404 async fn get_minimum_balance_for_rent_exemption(
406 &self,
407 data_size: usize,
408 ) -> Result<u64, SolanaProviderError>;
409
410 async fn simulate_transaction(
412 &self,
413 transaction: &Transaction,
414 ) -> Result<RpcSimulateTransactionResult, SolanaProviderError>;
415
416 async fn get_account_from_str(&self, account: &str) -> Result<Account, SolanaProviderError>;
418
419 async fn get_account_from_pubkey(
421 &self,
422 pubkey: &Pubkey,
423 ) -> Result<Account, SolanaProviderError>;
424
425 async fn get_token_metadata_from_pubkey(
427 &self,
428 pubkey: &str,
429 ) -> Result<TokenMetadata, SolanaProviderError>;
430
431 async fn is_blockhash_valid(
433 &self,
434 hash: &Hash,
435 commitment: CommitmentConfig,
436 ) -> Result<bool, SolanaProviderError>;
437
438 async fn get_fee_for_message(&self, message: &Message) -> Result<u64, SolanaProviderError>;
440
441 async fn get_recent_prioritization_fees(
443 &self,
444 addresses: &[Pubkey],
445 ) -> Result<Vec<RpcPrioritizationFee>, SolanaProviderError>;
446
447 async fn calculate_total_fee(&self, message: &Message) -> Result<u64, SolanaProviderError>;
449
450 async fn get_transaction_status(
452 &self,
453 signature: &Signature,
454 ) -> Result<SolanaTransactionStatus, SolanaProviderError>;
455
456 async fn raw_request_dyn(
458 &self,
459 method: &str,
460 params: serde_json::Value,
461 ) -> Result<serde_json::Value, SolanaProviderError>;
462}
463
464#[derive(Debug)]
465pub struct SolanaProvider {
466 selector: RpcSelector,
468 timeout_seconds: Duration,
470 commitment: CommitmentConfig,
472 retry_config: RetryConfig,
474}
475
476impl From<String> for SolanaProviderError {
477 fn from(s: String) -> Self {
478 SolanaProviderError::RpcError(s)
479 }
480}
481
482fn should_mark_solana_provider_failed(error: &SolanaProviderError) -> bool {
489 match error {
490 SolanaProviderError::RequestError { status_code, .. } => {
491 should_mark_provider_failed_by_status_code(*status_code)
492 }
493 _ => false,
494 }
495}
496
497#[derive(Error, Debug, PartialEq)]
498pub struct TokenMetadata {
499 pub decimals: u8,
500 pub symbol: String,
501 pub mint: String,
502}
503
504impl std::fmt::Display for TokenMetadata {
505 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
506 write!(
507 f,
508 "TokenMetadata {{ decimals: {}, symbol: {}, mint: {} }}",
509 self.decimals, self.symbol, self.mint
510 )
511 }
512}
513
514#[allow(dead_code)]
515impl SolanaProvider {
516 pub fn new(config: ProviderConfig) -> Result<Self, ProviderError> {
517 Self::new_with_commitment_and_health(
518 config.rpc_configs,
519 config.timeout_seconds,
520 CommitmentConfig::confirmed(),
521 config.failure_threshold,
522 config.pause_duration_secs,
523 config.failure_expiration_secs,
524 )
525 }
526
527 pub fn new_with_commitment_and_health(
542 configs: Vec<RpcConfig>,
543 timeout_seconds: u64,
544 commitment: CommitmentConfig,
545 failure_threshold: u32,
546 pause_duration_secs: u64,
547 failure_expiration_secs: u64,
548 ) -> Result<Self, ProviderError> {
549 if configs.is_empty() {
550 return Err(ProviderError::NetworkConfiguration(
551 "At least one RPC configuration must be provided".to_string(),
552 ));
553 }
554
555 RpcConfig::validate_list(&configs)
556 .map_err(|e| ProviderError::NetworkConfiguration(format!("Invalid URL: {e}")))?;
557
558 let selector = RpcSelector::new(
560 configs,
561 failure_threshold,
562 pause_duration_secs,
563 failure_expiration_secs,
564 )
565 .map_err(|e| {
566 ProviderError::NetworkConfiguration(format!("Failed to create RPC selector: {e}"))
567 })?;
568
569 let retry_config = RetryConfig::from_env();
570
571 Ok(Self {
572 selector,
573 timeout_seconds: Duration::from_secs(timeout_seconds),
574 commitment,
575 retry_config,
576 })
577 }
578
579 pub fn get_configs(&self) -> Vec<RpcConfig> {
584 self.selector.get_configs()
585 }
586
587 fn get_client(&self) -> Result<RpcClient, SolanaProviderError> {
596 self.selector
597 .get_client(
598 |url| {
599 Ok(RpcClient::new_with_timeout_and_commitment(
600 url.to_string(),
601 self.timeout_seconds,
602 self.commitment,
603 ))
604 },
605 &std::collections::HashSet::new(),
606 )
607 .map_err(SolanaProviderError::SelectorError)
608 }
609
610 fn initialize_provider(&self, url: &str) -> Result<Arc<RpcClient>, SolanaProviderError> {
620 let allowed_hosts = crate::config::ServerConfig::get_rpc_allowed_hosts();
621 let block_private_ips = crate::config::ServerConfig::get_rpc_block_private_ips();
622 validate_safe_url(url, &allowed_hosts, block_private_ips).map_err(|e| {
623 SolanaProviderError::NetworkConfiguration(format!(
624 "RPC URL security validation failed: {e}"
625 ))
626 })?;
627
628 let timeout = self.timeout_seconds;
629 let commitment = self.commitment;
630 let cache_key = SolanaRpcClientKey {
631 url: url.to_string(),
632 timeout_ms: timeout.as_millis(),
633 commitment,
634 };
635 SOLANA_RPC_CLIENT_CACHE.get_or_try_init(cache_key, || {
636 let rpc_url: Url = url.parse().map_err(|e| {
637 SolanaProviderError::NetworkConfiguration(format!("Invalid URL format: {e}"))
638 })?;
639 Ok(RpcClient::new_with_timeout_and_commitment(
640 rpc_url.to_string(),
641 timeout,
642 commitment,
643 ))
644 })
645 }
646
647 async fn retry_rpc_call<T, F, Fut>(
649 &self,
650 operation_name: &str,
651 operation: F,
652 ) -> Result<T, SolanaProviderError>
653 where
654 F: Fn(Arc<RpcClient>) -> Fut,
655 Fut: std::future::Future<Output = Result<T, SolanaProviderError>>,
656 {
657 let is_retriable = |e: &SolanaProviderError| e.is_transient();
658
659 tracing::debug!(
660 "Starting RPC operation '{}' with timeout: {}s",
661 operation_name,
662 self.timeout_seconds.as_secs()
663 );
664
665 retry_rpc_call(
666 &self.selector,
667 operation_name,
668 is_retriable,
669 should_mark_solana_provider_failed,
670 |url| match self.initialize_provider(url) {
671 Ok(provider) => Ok(provider),
672 Err(e) => Err(e),
673 },
674 operation,
675 Some(self.retry_config.clone()),
676 )
677 .await
678 }
679}
680
681#[async_trait]
682#[allow(dead_code)]
683impl SolanaProviderTrait for SolanaProvider {
684 fn get_configs(&self) -> Vec<RpcConfig> {
685 self.get_configs()
686 }
687
688 async fn get_balance(&self, address: &str) -> Result<u64, SolanaProviderError> {
694 let pubkey = Pubkey::from_str(address)
695 .map_err(|e| SolanaProviderError::InvalidAddress(e.to_string()))?;
696
697 self.retry_rpc_call("get_balance", |client| async move {
698 client
699 .get_balance(&pubkey)
700 .await
701 .map_err(SolanaProviderError::from_rpc_error)
702 })
703 .await
704 }
705
706 async fn is_blockhash_valid(
708 &self,
709 hash: &Hash,
710 commitment: CommitmentConfig,
711 ) -> Result<bool, SolanaProviderError> {
712 self.retry_rpc_call("is_blockhash_valid", |client| async move {
713 client
714 .is_blockhash_valid(hash, commitment)
715 .await
716 .map_err(SolanaProviderError::from_rpc_error)
717 })
718 .await
719 }
720
721 async fn get_latest_blockhash(&self) -> Result<Hash, SolanaProviderError> {
723 self.retry_rpc_call("get_latest_blockhash", |client| async move {
724 client
725 .get_latest_blockhash()
726 .await
727 .map_err(SolanaProviderError::from_rpc_error)
728 })
729 .await
730 }
731
732 async fn get_latest_blockhash_with_commitment(
733 &self,
734 commitment: CommitmentConfig,
735 ) -> Result<(Hash, u64), SolanaProviderError> {
736 self.retry_rpc_call(
737 "get_latest_blockhash_with_commitment",
738 |client| async move {
739 client
740 .get_latest_blockhash_with_commitment(commitment)
741 .await
742 .map_err(SolanaProviderError::from_rpc_error)
743 },
744 )
745 .await
746 }
747
748 async fn send_transaction(
750 &self,
751 transaction: &Transaction,
752 ) -> Result<Signature, SolanaProviderError> {
753 self.retry_rpc_call("send_transaction", |client| async move {
754 client
755 .send_transaction(transaction)
756 .await
757 .map_err(SolanaProviderError::from_rpc_error)
758 })
759 .await
760 }
761
762 async fn send_versioned_transaction(
764 &self,
765 transaction: &VersionedTransaction,
766 ) -> Result<Signature, SolanaProviderError> {
767 self.retry_rpc_call("send_transaction", |client| async move {
768 client
769 .send_transaction(transaction)
770 .await
771 .map_err(SolanaProviderError::from_rpc_error)
772 })
773 .await
774 }
775
776 async fn confirm_transaction(
778 &self,
779 signature: &Signature,
780 ) -> Result<bool, SolanaProviderError> {
781 self.retry_rpc_call("confirm_transaction", |client| async move {
782 client
783 .confirm_transaction(signature)
784 .await
785 .map_err(SolanaProviderError::from_rpc_error)
786 })
787 .await
788 }
789
790 async fn get_minimum_balance_for_rent_exemption(
792 &self,
793 data_size: usize,
794 ) -> Result<u64, SolanaProviderError> {
795 self.retry_rpc_call(
796 "get_minimum_balance_for_rent_exemption",
797 |client| async move {
798 client
799 .get_minimum_balance_for_rent_exemption(data_size)
800 .await
801 .map_err(SolanaProviderError::from_rpc_error)
802 },
803 )
804 .await
805 }
806
807 async fn simulate_transaction(
809 &self,
810 transaction: &Transaction,
811 ) -> Result<RpcSimulateTransactionResult, SolanaProviderError> {
812 self.retry_rpc_call("simulate_transaction", |client| async move {
813 client
814 .simulate_transaction(transaction)
815 .await
816 .map_err(SolanaProviderError::from_rpc_error)
817 .map(|response| response.value)
818 })
819 .await
820 }
821
822 async fn get_account_from_str(&self, account: &str) -> Result<Account, SolanaProviderError> {
824 let address = Pubkey::from_str(account).map_err(|e| {
825 SolanaProviderError::InvalidAddress(format!("Invalid pubkey {account}: {e}"))
826 })?;
827 self.retry_rpc_call("get_account", |client| async move {
828 client
829 .get_account(&address)
830 .await
831 .map_err(SolanaProviderError::from_rpc_error)
832 })
833 .await
834 }
835
836 async fn get_account_from_pubkey(
838 &self,
839 pubkey: &Pubkey,
840 ) -> Result<Account, SolanaProviderError> {
841 self.retry_rpc_call("get_account_from_pubkey", |client| async move {
842 client
843 .get_account(pubkey)
844 .await
845 .map_err(SolanaProviderError::from_rpc_error)
846 })
847 .await
848 }
849
850 async fn get_token_metadata_from_pubkey(
852 &self,
853 pubkey: &str,
854 ) -> Result<TokenMetadata, SolanaProviderError> {
855 let mint_pubkey = Pubkey::from_str(pubkey).map_err(|e| {
857 SolanaProviderError::InvalidAddress(format!("Invalid pubkey {pubkey}: {e}"))
858 })?;
859
860 let account = self.get_account_from_pubkey(&mint_pubkey).await?;
862
863 let decimals = Mint::unpack(&account.data)
865 .map_err(|e| {
866 SolanaProviderError::InvalidTransaction(format!(
867 "Failed to unpack mint info for {pubkey}: {e}"
868 ))
869 })?
870 .decimals;
871
872 let mint_pubkey_program =
875 solana_program::pubkey::Pubkey::new_from_array(mint_pubkey.to_bytes());
876 let metadata_pda_program = Metadata::find_pda(&mint_pubkey_program).0;
877
878 let metadata_pda = Pubkey::new_from_array(metadata_pda_program.to_bytes());
880
881 let symbol = match self.get_account_from_pubkey(&metadata_pda).await {
882 Ok(metadata_account) => match Metadata::from_bytes(&metadata_account.data) {
883 Ok(metadata) => metadata.symbol.trim_end_matches('\u{0}').to_string(),
884 Err(_) => String::new(),
885 },
886 Err(_) => String::new(), };
888
889 Ok(TokenMetadata {
890 decimals,
891 symbol,
892 mint: pubkey.to_string(),
893 })
894 }
895
896 async fn get_fee_for_message(&self, message: &Message) -> Result<u64, SolanaProviderError> {
898 self.retry_rpc_call("get_fee_for_message", |client| async move {
899 client
900 .get_fee_for_message(message)
901 .await
902 .map_err(SolanaProviderError::from_rpc_error)
903 })
904 .await
905 }
906
907 async fn get_recent_prioritization_fees(
908 &self,
909 addresses: &[Pubkey],
910 ) -> Result<Vec<RpcPrioritizationFee>, SolanaProviderError> {
911 self.retry_rpc_call("get_recent_prioritization_fees", |client| async move {
912 client
913 .get_recent_prioritization_fees(addresses)
914 .await
915 .map_err(SolanaProviderError::from_rpc_error)
916 })
917 .await
918 }
919
920 async fn calculate_total_fee(&self, message: &Message) -> Result<u64, SolanaProviderError> {
921 let base_fee = self.get_fee_for_message(message).await?;
922 let priority_fees = self.get_recent_prioritization_fees(&[]).await?;
923
924 let max_priority_fee = priority_fees
925 .iter()
926 .map(|fee| fee.prioritization_fee)
927 .max()
928 .unwrap_or(0);
929
930 Ok(base_fee + max_priority_fee)
931 }
932
933 async fn get_transaction_status(
934 &self,
935 signature: &Signature,
936 ) -> Result<SolanaTransactionStatus, SolanaProviderError> {
937 let result = self
938 .retry_rpc_call("get_transaction_status", |client| async move {
939 client
940 .get_signature_statuses_with_history(&[*signature])
941 .await
942 .map_err(SolanaProviderError::from_rpc_error)
943 })
944 .await?;
945
946 let status = result.value.first();
947
948 match status {
949 Some(Some(v)) => {
950 if v.err.is_some() {
951 Ok(SolanaTransactionStatus::Failed)
952 } else if v.satisfies_commitment(CommitmentConfig::finalized()) {
953 Ok(SolanaTransactionStatus::Finalized)
954 } else if v.satisfies_commitment(CommitmentConfig::confirmed()) {
955 Ok(SolanaTransactionStatus::Confirmed)
956 } else {
957 Ok(SolanaTransactionStatus::Processed)
958 }
959 }
960 Some(None) => Err(SolanaProviderError::RpcError(
961 "Transaction confirmation status not available".to_string(),
962 )),
963 None => Err(SolanaProviderError::RpcError(
964 "Transaction confirmation status not available".to_string(),
965 )),
966 }
967 }
968
969 async fn raw_request_dyn(
971 &self,
972 method: &str,
973 params: serde_json::Value,
974 ) -> Result<serde_json::Value, SolanaProviderError> {
975 let params_owned = params.clone();
976 let method_static: &'static str = Box::leak(method.to_string().into_boxed_str());
977 self.retry_rpc_call("raw_request_dyn", move |client| {
978 let params_for_call = params_owned.clone();
979 async move {
980 client
981 .send(
982 RpcRequest::Custom {
983 method: method_static,
984 },
985 params_for_call,
986 )
987 .await
988 .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
989 }
990 })
991 .await
992 }
993}
994
995#[cfg(test)]
996mod tests {
997 use super::*;
998 use lazy_static::lazy_static;
999 use solana_sdk::{
1000 hash::Hash,
1001 message::Message,
1002 signer::{keypair::Keypair, Signer},
1003 transaction::Transaction,
1004 };
1005 use std::sync::Mutex;
1006
1007 lazy_static! {
1008 static ref EVM_TEST_ENV_MUTEX: Mutex<()> = Mutex::new(());
1009 }
1010
1011 struct EvmTestEnvGuard {
1012 _mutex_guard: std::sync::MutexGuard<'static, ()>,
1013 }
1014
1015 impl EvmTestEnvGuard {
1016 fn new(mutex_guard: std::sync::MutexGuard<'static, ()>) -> Self {
1017 std::env::set_var(
1018 "API_KEY",
1019 "test_api_key_for_evm_provider_new_this_is_long_enough_32_chars",
1020 );
1021 std::env::set_var("REDIS_URL", "redis://test-dummy-url-for-evm-provider");
1022
1023 Self {
1024 _mutex_guard: mutex_guard,
1025 }
1026 }
1027 }
1028
1029 impl Drop for EvmTestEnvGuard {
1030 fn drop(&mut self) {
1031 std::env::remove_var("API_KEY");
1032 std::env::remove_var("REDIS_URL");
1033 }
1034 }
1035
1036 fn setup_test_env() -> EvmTestEnvGuard {
1038 let guard = EVM_TEST_ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
1039 EvmTestEnvGuard::new(guard)
1040 }
1041
1042 fn get_funded_keypair() -> Keypair {
1043 Keypair::try_from(
1045 [
1046 120, 248, 160, 20, 225, 60, 226, 195, 68, 137, 176, 87, 21, 129, 0, 76, 144, 129,
1047 122, 250, 80, 4, 247, 50, 248, 82, 146, 77, 139, 156, 40, 41, 240, 161, 15, 81,
1048 198, 198, 86, 167, 90, 148, 131, 13, 184, 222, 251, 71, 229, 212, 169, 2, 72, 202,
1049 150, 184, 176, 148, 75, 160, 255, 233, 73, 31,
1050 ]
1051 .as_slice(),
1052 )
1053 .unwrap()
1054 }
1055
1056 async fn get_recent_blockhash(provider: &SolanaProvider) -> Hash {
1058 provider
1059 .get_latest_blockhash()
1060 .await
1061 .expect("Failed to get blockhash")
1062 }
1063
1064 fn create_test_rpc_config() -> RpcConfig {
1065 RpcConfig {
1066 url: "https://api.devnet.solana.com".to_string(),
1067 weight: 1,
1068 ..Default::default()
1069 }
1070 }
1071
1072 fn create_test_provider_config(configs: Vec<RpcConfig>, timeout: u64) -> ProviderConfig {
1073 ProviderConfig::new(configs, timeout, 3, 60, 60)
1074 }
1075
1076 #[tokio::test]
1077 async fn test_new_with_valid_config() {
1078 let _env_guard = setup_test_env();
1079 let configs = vec![create_test_rpc_config()];
1080 let timeout = 30;
1081
1082 let result = SolanaProvider::new(create_test_provider_config(configs, timeout));
1083
1084 assert!(result.is_ok());
1085 let provider = result.unwrap();
1086 assert_eq!(provider.timeout_seconds, Duration::from_secs(timeout));
1087 assert_eq!(provider.commitment, CommitmentConfig::confirmed());
1088 }
1089
1090 #[tokio::test]
1091 async fn test_new_with_commitment_valid_config() {
1092 let _env_guard = setup_test_env();
1093
1094 let configs = vec![create_test_rpc_config()];
1095 let timeout = 30;
1096 let commitment = CommitmentConfig::finalized();
1097
1098 let result =
1099 SolanaProvider::new_with_commitment_and_health(configs, timeout, commitment, 3, 60, 60);
1100
1101 assert!(result.is_ok());
1102 let provider = result.unwrap();
1103 assert_eq!(provider.timeout_seconds, Duration::from_secs(timeout));
1104 assert_eq!(provider.commitment, commitment);
1105 }
1106
1107 #[tokio::test]
1108 async fn test_new_with_empty_configs() {
1109 let _env_guard = setup_test_env();
1110 let configs: Vec<RpcConfig> = vec![];
1111 let timeout = 30;
1112
1113 let result = SolanaProvider::new(create_test_provider_config(configs, timeout));
1114
1115 assert!(result.is_err());
1116 assert!(matches!(
1117 result,
1118 Err(ProviderError::NetworkConfiguration(_))
1119 ));
1120 }
1121
1122 #[tokio::test]
1123 async fn test_new_with_commitment_empty_configs() {
1124 let _env_guard = setup_test_env();
1125 let configs: Vec<RpcConfig> = vec![];
1126 let timeout = 30;
1127 let commitment = CommitmentConfig::finalized();
1128
1129 let result =
1130 SolanaProvider::new_with_commitment_and_health(configs, timeout, commitment, 3, 60, 60);
1131
1132 assert!(result.is_err());
1133 assert!(matches!(
1134 result,
1135 Err(ProviderError::NetworkConfiguration(_))
1136 ));
1137 }
1138
1139 #[tokio::test]
1140 async fn test_new_with_invalid_url() {
1141 let _env_guard = setup_test_env();
1142 let configs = vec![RpcConfig {
1143 url: "invalid-url".to_string(),
1144 weight: 1,
1145 ..Default::default()
1146 }];
1147 let timeout = 30;
1148
1149 let result = SolanaProvider::new(create_test_provider_config(configs, timeout));
1150
1151 assert!(result.is_err());
1152 assert!(matches!(
1153 result,
1154 Err(ProviderError::NetworkConfiguration(_))
1155 ));
1156 }
1157
1158 #[tokio::test]
1159 async fn test_new_with_commitment_invalid_url() {
1160 let _env_guard = setup_test_env();
1161 let configs = vec![RpcConfig {
1162 url: "invalid-url".to_string(),
1163 weight: 1,
1164 ..Default::default()
1165 }];
1166 let timeout = 30;
1167 let commitment = CommitmentConfig::finalized();
1168
1169 let result =
1170 SolanaProvider::new_with_commitment_and_health(configs, timeout, commitment, 3, 60, 60);
1171
1172 assert!(result.is_err());
1173 assert!(matches!(
1174 result,
1175 Err(ProviderError::NetworkConfiguration(_))
1176 ));
1177 }
1178
1179 #[tokio::test]
1180 async fn test_new_with_multiple_configs() {
1181 let _env_guard = setup_test_env();
1182 let configs = vec![
1183 create_test_rpc_config(),
1184 RpcConfig {
1185 url: "https://api.mainnet-beta.solana.com".to_string(),
1186 weight: 1,
1187 ..Default::default()
1188 },
1189 ];
1190 let timeout = 30;
1191
1192 let result = SolanaProvider::new(create_test_provider_config(configs, timeout));
1193
1194 assert!(result.is_ok());
1195 }
1196
1197 #[tokio::test]
1198 async fn test_provider_creation() {
1199 let _env_guard = setup_test_env();
1200 let configs = vec![create_test_rpc_config()];
1201 let timeout = 30;
1202 let provider = SolanaProvider::new(create_test_provider_config(configs, timeout));
1203 assert!(provider.is_ok());
1204 }
1205
1206 #[tokio::test]
1207 async fn test_get_balance() {
1208 let _env_guard = setup_test_env();
1209 let configs = vec![create_test_rpc_config()];
1210 let timeout = 30;
1211 let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1212 let keypair = Keypair::new();
1213 let balance = provider.get_balance(&keypair.pubkey().to_string()).await;
1214 assert!(balance.is_ok());
1215 assert_eq!(balance.unwrap(), 0);
1216 }
1217
1218 #[tokio::test]
1219 async fn test_get_balance_funded_account() {
1220 let _env_guard = setup_test_env();
1221 let configs = vec![create_test_rpc_config()];
1222 let timeout = 30;
1223 let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1224 let keypair = get_funded_keypair();
1225 let balance = provider.get_balance(&keypair.pubkey().to_string()).await;
1226 assert!(balance.is_ok());
1227 assert_eq!(balance.unwrap(), 1000000000);
1228 }
1229
1230 #[tokio::test]
1231 async fn test_get_latest_blockhash() {
1232 let _env_guard = setup_test_env();
1233 let configs = vec![create_test_rpc_config()];
1234 let timeout = 30;
1235 let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1236 let blockhash = provider.get_latest_blockhash().await;
1237 assert!(blockhash.is_ok());
1238 }
1239
1240 #[tokio::test]
1241 async fn test_simulate_transaction() {
1242 let _env_guard = setup_test_env();
1243 let configs = vec![create_test_rpc_config()];
1244 let timeout = 30;
1245 let provider = SolanaProvider::new(create_test_provider_config(configs, timeout))
1246 .expect("Failed to create provider");
1247
1248 let fee_payer = get_funded_keypair();
1249
1250 let message = Message::new(&[], Some(&fee_payer.pubkey()));
1253
1254 let mut tx = Transaction::new_unsigned(message);
1255
1256 let recent_blockhash = get_recent_blockhash(&provider).await;
1257 tx.try_sign(&[&fee_payer], recent_blockhash)
1258 .expect("Failed to sign transaction");
1259
1260 let simulation_result = provider.simulate_transaction(&tx).await;
1261
1262 assert!(
1263 simulation_result.is_ok(),
1264 "Simulation failed: {simulation_result:?}"
1265 );
1266
1267 let result = simulation_result.unwrap();
1268 assert!(
1271 result.err.is_none(),
1272 "Simulation encountered an error: {:?}",
1273 result.err
1274 );
1275 }
1276
1277 #[tokio::test]
1278 async fn test_get_token_metadata_from_pubkey() {
1279 let _env_guard = setup_test_env();
1280 let configs = vec![RpcConfig {
1281 url: "https://api.mainnet-beta.solana.com".to_string(),
1282 weight: 1,
1283 ..Default::default()
1284 }];
1285 let timeout = 30;
1286 let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1287 let usdc_token_metadata = provider
1288 .get_token_metadata_from_pubkey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1289 .await
1290 .unwrap();
1291
1292 assert_eq!(
1293 usdc_token_metadata,
1294 TokenMetadata {
1295 decimals: 6,
1296 symbol: "USDC".to_string(),
1297 mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
1298 }
1299 );
1300
1301 let usdt_token_metadata = provider
1302 .get_token_metadata_from_pubkey("Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB")
1303 .await
1304 .unwrap();
1305
1306 assert_eq!(
1307 usdt_token_metadata,
1308 TokenMetadata {
1309 decimals: 6,
1310 symbol: "USDT".to_string(),
1311 mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(),
1312 }
1313 );
1314 }
1315
1316 #[tokio::test]
1317 async fn test_get_client_success() {
1318 let _env_guard = setup_test_env();
1319 let configs = vec![create_test_rpc_config()];
1320 let timeout = 30;
1321 let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1322
1323 let client = provider.get_client();
1324 assert!(client.is_ok());
1325
1326 let client = client.unwrap();
1327 let health_result = client.get_health().await;
1328 assert!(health_result.is_ok());
1329 }
1330
1331 #[tokio::test]
1332 async fn test_get_client_with_custom_commitment() {
1333 let _env_guard = setup_test_env();
1334 let configs = vec![create_test_rpc_config()];
1335 let timeout = 30;
1336 let commitment = CommitmentConfig::finalized();
1337
1338 let provider =
1339 SolanaProvider::new_with_commitment_and_health(configs, timeout, commitment, 3, 60, 60)
1340 .unwrap();
1341
1342 let client = provider.get_client();
1343 assert!(client.is_ok());
1344
1345 let client = client.unwrap();
1346 let health_result = client.get_health().await;
1347 assert!(health_result.is_ok());
1348 }
1349
1350 #[tokio::test]
1351 async fn test_get_client_with_multiple_rpcs() {
1352 let _env_guard = setup_test_env();
1353 let configs = vec![
1354 create_test_rpc_config(),
1355 RpcConfig {
1356 url: "https://api.mainnet-beta.solana.com".to_string(),
1357 weight: 2,
1358 ..Default::default()
1359 },
1360 ];
1361 let timeout = 30;
1362
1363 let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1364
1365 let client_result = provider.get_client();
1366 assert!(client_result.is_ok());
1367
1368 for _ in 0..5 {
1370 let client = provider.get_client();
1371 assert!(client.is_ok());
1372 }
1373 }
1374
1375 #[test]
1376 fn test_initialize_provider_valid_url() {
1377 let _env_guard = setup_test_env();
1378
1379 let configs = vec![RpcConfig {
1380 url: "https://api.devnet.solana.com".to_string(),
1381 weight: 1,
1382 ..Default::default()
1383 }];
1384 let provider = SolanaProvider::new(create_test_provider_config(configs, 10)).unwrap();
1385 let result = provider.initialize_provider("https://api.devnet.solana.com");
1386 assert!(result.is_ok());
1387 let arc_client = result.unwrap();
1388 let _client: &RpcClient = Arc::as_ref(&arc_client);
1390 }
1391
1392 #[test]
1393 fn test_initialize_provider_invalid_url() {
1394 let _env_guard = setup_test_env();
1395
1396 let configs = vec![RpcConfig {
1397 url: "https://api.devnet.solana.com".to_string(),
1398 weight: 1,
1399 ..Default::default()
1400 }];
1401 let provider = SolanaProvider::new(create_test_provider_config(configs, 10)).unwrap();
1402 let result = provider.initialize_provider("not-a-valid-url");
1403 assert!(result.is_err());
1404 match result {
1405 Err(SolanaProviderError::NetworkConfiguration(msg)) => {
1406 assert!(msg.contains("Invalid URL format"))
1407 }
1408 _ => panic!("Expected NetworkConfiguration error"),
1409 }
1410 }
1411
1412 #[test]
1413 fn test_from_string_for_solana_provider_error() {
1414 let msg = "some rpc error".to_string();
1415 let err: SolanaProviderError = msg.clone().into();
1416 match err {
1417 SolanaProviderError::RpcError(inner) => assert_eq!(inner, msg),
1418 _ => panic!("Expected RpcError variant"),
1419 }
1420 }
1421
1422 #[test]
1423 fn test_matches_error_pattern() {
1424 assert!(matches_error_pattern(
1426 "blockhash not found",
1427 "blockhash not found"
1428 ));
1429 assert!(matches_error_pattern(
1430 "insufficient funds",
1431 "insufficient funds"
1432 ));
1433
1434 assert!(matches_error_pattern(
1436 "BLOCKHASH NOT FOUND",
1437 "blockhash not found"
1438 ));
1439 assert!(matches_error_pattern(
1440 "blockhash not found",
1441 "BLOCKHASH NOT FOUND"
1442 ));
1443 assert!(matches_error_pattern(
1444 "BlockHash Not Found",
1445 "blockhash not found"
1446 ));
1447
1448 assert!(matches_error_pattern(
1450 "blockhashnotfound",
1451 "blockhash not found"
1452 ));
1453 assert!(matches_error_pattern(
1454 "blockhash not found",
1455 "blockhashnotfound"
1456 ));
1457 assert!(matches_error_pattern(
1458 "insufficientfunds",
1459 "insufficient funds"
1460 ));
1461
1462 assert!(matches_error_pattern(
1464 "BLOCKHASHNOTFOUND",
1465 "blockhash not found"
1466 ));
1467 assert!(matches_error_pattern(
1468 "blockhash not found",
1469 "BLOCKHASHNOTFOUND"
1470 ));
1471 assert!(matches_error_pattern(
1472 "BlockHashNotFound",
1473 "blockhash not found"
1474 ));
1475 assert!(matches_error_pattern(
1476 "INSUFFICIENTFUNDS",
1477 "insufficient funds"
1478 ));
1479
1480 assert!(matches_error_pattern(
1482 "transaction failed: blockhash not found",
1483 "blockhash not found"
1484 ));
1485 assert!(matches_error_pattern(
1486 "error: insufficient funds for transaction",
1487 "insufficient funds"
1488 ));
1489 assert!(matches_error_pattern(
1490 "BLOCKHASHNOTFOUND in simulation",
1491 "blockhash not found"
1492 ));
1493
1494 assert!(matches_error_pattern(
1496 "blockhash not found",
1497 "blockhash not found"
1498 ));
1499 assert!(matches_error_pattern(
1500 "insufficient funds",
1501 "insufficient funds"
1502 ));
1503
1504 assert!(!matches_error_pattern(
1506 "account not found",
1507 "blockhash not found"
1508 ));
1509 assert!(!matches_error_pattern(
1510 "invalid signature",
1511 "insufficient funds"
1512 ));
1513 assert!(!matches_error_pattern(
1514 "timeout error",
1515 "blockhash not found"
1516 ));
1517
1518 assert!(matches_error_pattern("", ""));
1520 assert!(matches_error_pattern("blockhash not found", "")); assert!(!matches_error_pattern("", "blockhash not found"));
1522
1523 assert!(matches_error_pattern(
1525 "error code -32008: blockhash not found",
1526 "-32008"
1527 ));
1528 assert!(matches_error_pattern("slot 123456 skipped", "slot"));
1529 assert!(matches_error_pattern("RPC_ERROR_503", "rpc_error_503"));
1530 }
1531
1532 #[test]
1533 fn test_solana_provider_error_is_transient() {
1534 assert!(SolanaProviderError::NetworkError("connection timeout".to_string()).is_transient());
1536 assert!(SolanaProviderError::RpcError("node is behind".to_string()).is_transient());
1537 assert!(
1538 SolanaProviderError::BlockhashNotFound("blockhash expired".to_string()).is_transient()
1539 );
1540 assert!(
1541 SolanaProviderError::SelectorError(RpcSelectorError::AllProvidersFailed).is_transient()
1542 );
1543
1544 assert!(
1546 !SolanaProviderError::InsufficientFunds("not enough balance".to_string())
1547 .is_transient()
1548 );
1549 assert!(
1550 !SolanaProviderError::InvalidTransaction("invalid signature".to_string())
1551 .is_transient()
1552 );
1553 assert!(
1554 !SolanaProviderError::AlreadyProcessed("duplicate transaction".to_string())
1555 .is_transient()
1556 );
1557 assert!(
1558 !SolanaProviderError::InvalidAddress("invalid pubkey format".to_string())
1559 .is_transient()
1560 );
1561 assert!(
1562 !SolanaProviderError::NetworkConfiguration("unsupported operation".to_string())
1563 .is_transient()
1564 );
1565 }
1566
1567 #[tokio::test]
1568 async fn test_get_minimum_balance_for_rent_exemption() {
1569 let _env_guard = super::tests::setup_test_env();
1570 let configs = vec![super::tests::create_test_rpc_config()];
1571 let timeout = 30;
1572 let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1573
1574 let result = provider.get_minimum_balance_for_rent_exemption(0).await;
1576 assert!(result.is_ok());
1577 }
1578
1579 #[tokio::test]
1580 async fn test_is_blockhash_valid_for_recent_blockhash() {
1581 let _env_guard = super::tests::setup_test_env();
1582 let configs = vec![super::tests::create_test_rpc_config()];
1583 let timeout = 30;
1584 let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1585
1586 let blockhash = provider.get_latest_blockhash().await.unwrap();
1588 let is_valid = provider
1589 .is_blockhash_valid(&blockhash, CommitmentConfig::confirmed())
1590 .await;
1591 assert!(is_valid.is_ok());
1592 }
1593
1594 #[tokio::test]
1595 async fn test_is_blockhash_valid_for_invalid_blockhash() {
1596 let _env_guard = super::tests::setup_test_env();
1597 let configs = vec![super::tests::create_test_rpc_config()];
1598 let timeout = 30;
1599 let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1600
1601 let invalid_blockhash = solana_sdk::hash::Hash::new_from_array([0u8; 32]);
1602 let is_valid = provider
1603 .is_blockhash_valid(&invalid_blockhash, CommitmentConfig::confirmed())
1604 .await;
1605 assert!(is_valid.is_ok());
1606 }
1607
1608 #[tokio::test]
1609 async fn test_get_latest_blockhash_with_commitment() {
1610 let _env_guard = super::tests::setup_test_env();
1611 let configs = vec![super::tests::create_test_rpc_config()];
1612 let timeout = 30;
1613 let provider = SolanaProvider::new(create_test_provider_config(configs, timeout)).unwrap();
1614
1615 let commitment = CommitmentConfig::confirmed();
1616 let result = provider
1617 .get_latest_blockhash_with_commitment(commitment)
1618 .await;
1619 assert!(result.is_ok());
1620 let (blockhash, last_valid_block_height) = result.unwrap();
1621 assert_ne!(blockhash, solana_sdk::hash::Hash::new_from_array([0u8; 32]));
1623 assert!(last_valid_block_height > 0);
1624 }
1625
1626 #[test]
1627 fn test_from_rpc_response_error_transaction_simulation_failed() {
1628 let mock_error = create_mock_client_error();
1630
1631 let error_str =
1633 r#"{"code": -32002, "message": "Transaction simulation failed: Blockhash not found"}"#;
1634 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1635 assert!(matches!(result, SolanaProviderError::BlockhashNotFound(_)));
1636
1637 let error_str =
1639 r#"{"code": -32002, "message": "Transaction simulation failed: Insufficient funds"}"#;
1640 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1641 assert!(matches!(result, SolanaProviderError::InsufficientFunds(_)));
1642
1643 let error_str = r#"{"code": -32002, "message": "Transaction simulation failed: Invalid instruction data"}"#;
1645 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1646 assert!(matches!(result, SolanaProviderError::InvalidTransaction(_)));
1647 }
1648
1649 #[test]
1650 fn test_from_rpc_response_error_signature_verification() {
1651 let mock_error = create_mock_client_error();
1652
1653 let error_str = r#"{"code": -32003, "message": "Signature verification failure"}"#;
1655 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1656 assert!(matches!(result, SolanaProviderError::InvalidTransaction(_)));
1657 }
1658
1659 #[test]
1660 fn test_from_rpc_response_error_transient_errors() {
1661 let mock_error = create_mock_client_error();
1662
1663 let error_str = r#"{"code": -32004, "message": "Block not available for slot"}"#;
1665 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1666 assert!(matches!(result, SolanaProviderError::RpcError(_)));
1667
1668 let error_str = r#"{"code": -32005, "message": "Node is behind"}"#;
1670 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1671 assert!(matches!(result, SolanaProviderError::RpcError(_)));
1672
1673 let error_str = r#"{"code": -32008, "message": "Blockhash not found"}"#;
1675 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1676 assert!(matches!(result, SolanaProviderError::BlockhashNotFound(_)));
1677
1678 let error_str = r#"{"code": -32014, "message": "Block status not yet available"}"#;
1680 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1681 assert!(matches!(result, SolanaProviderError::RpcError(_)));
1682
1683 let error_str = r#"{"code": -32016, "message": "Minimum context slot not reached"}"#;
1685 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1686 assert!(matches!(result, SolanaProviderError::RpcError(_)));
1687 }
1688
1689 #[test]
1690 fn test_from_rpc_response_error_permanent_errors() {
1691 let mock_error = create_mock_client_error();
1692
1693 let error_str = r#"{"code": -32007, "message": "Slot skipped"}"#;
1695 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1696 assert!(matches!(
1697 result,
1698 SolanaProviderError::NetworkConfiguration(_)
1699 ));
1700
1701 let error_str = r#"{"code": -32009, "message": "Already processed"}"#;
1703 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1704 assert!(matches!(result, SolanaProviderError::AlreadyProcessed(_)));
1705
1706 let error_str = r#"{"code": -32010, "message": "Key excluded from secondary indexes"}"#;
1708 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1709 assert!(matches!(
1710 result,
1711 SolanaProviderError::NetworkConfiguration(_)
1712 ));
1713
1714 let error_str = r#"{"code": -32013, "message": "Transaction signature length mismatch"}"#;
1716 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1717 assert!(matches!(result, SolanaProviderError::InvalidTransaction(_)));
1718
1719 let error_str = r#"{"code": -32015, "message": "Transaction version not supported"}"#;
1721 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1722 assert!(matches!(result, SolanaProviderError::InvalidTransaction(_)));
1723
1724 let error_str = r#"{"code": -32602, "message": "Invalid params"}"#;
1726 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1727 assert!(matches!(result, SolanaProviderError::InvalidTransaction(_)));
1728 }
1729
1730 #[test]
1731 fn test_from_rpc_response_error_string_pattern_matching() {
1732 let mock_error = create_mock_client_error();
1733
1734 let error_str = r#"{"code": -32000, "message": "INSUFFICIENTFUNDS for transaction"}"#;
1736 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1737 assert!(matches!(result, SolanaProviderError::InsufficientFunds(_)));
1738
1739 let error_str = r#"{"code": -32000, "message": "BlockhashNotFound"}"#;
1740 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1741 assert!(matches!(result, SolanaProviderError::BlockhashNotFound(_)));
1742
1743 let error_str = r#"{"code": -32000, "message": "AlreadyProcessed"}"#;
1744 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1745 assert!(matches!(result, SolanaProviderError::AlreadyProcessed(_)));
1746 }
1747
1748 #[test]
1749 fn test_from_rpc_response_error_unknown_code() {
1750 let mock_error = create_mock_client_error();
1751
1752 let error_str = r#"{"code": -99999, "message": "Unknown error"}"#;
1754 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1755 assert!(matches!(result, SolanaProviderError::RpcError(_)));
1756 }
1757
1758 fn create_mock_client_error() -> ClientError {
1760 use solana_client::rpc_request::RpcRequest;
1761 ClientError::new_with_request(
1763 ClientErrorKind::RpcError(solana_client::rpc_request::RpcError::RpcRequestError(
1764 "test".to_string(),
1765 )),
1766 RpcRequest::GetHealth,
1767 )
1768 }
1769
1770 #[test]
1771 fn test_from_rpc_error_integration() {
1772 let mock_error = create_mock_client_error();
1774
1775 let error_str = r#"{"code": -32000, "message": "Account has insufficient funds"}"#;
1777 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1778 assert!(matches!(result, SolanaProviderError::InsufficientFunds(_)));
1779
1780 let error_str = r#"{"code": -32000, "message": "Blockhash not found"}"#;
1782 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1783 assert!(matches!(result, SolanaProviderError::BlockhashNotFound(_)));
1784
1785 let error_str = r#"{"code": -32000, "message": "Transaction was already processed"}"#;
1787 let result = SolanaProviderError::from_rpc_response_error(error_str, &mock_error);
1788 assert!(matches!(result, SolanaProviderError::AlreadyProcessed(_)));
1789 }
1790
1791 #[test]
1792 fn test_request_error_is_transient() {
1793 let error = SolanaProviderError::RequestError {
1795 error: "Server error".to_string(),
1796 status_code: 500,
1797 };
1798 assert!(error.is_transient());
1799
1800 let error = SolanaProviderError::RequestError {
1801 error: "Bad gateway".to_string(),
1802 status_code: 502,
1803 };
1804 assert!(error.is_transient());
1805
1806 let error = SolanaProviderError::RequestError {
1807 error: "Service unavailable".to_string(),
1808 status_code: 503,
1809 };
1810 assert!(error.is_transient());
1811
1812 let error = SolanaProviderError::RequestError {
1813 error: "Gateway timeout".to_string(),
1814 status_code: 504,
1815 };
1816 assert!(error.is_transient());
1817
1818 let error = SolanaProviderError::RequestError {
1820 error: "Request timeout".to_string(),
1821 status_code: 408,
1822 };
1823 assert!(error.is_transient());
1824
1825 let error = SolanaProviderError::RequestError {
1826 error: "Too early".to_string(),
1827 status_code: 425,
1828 };
1829 assert!(error.is_transient());
1830
1831 let error = SolanaProviderError::RequestError {
1832 error: "Too many requests".to_string(),
1833 status_code: 429,
1834 };
1835 assert!(error.is_transient());
1836
1837 let error = SolanaProviderError::RequestError {
1839 error: "Not implemented".to_string(),
1840 status_code: 501,
1841 };
1842 assert!(!error.is_transient());
1843
1844 let error = SolanaProviderError::RequestError {
1845 error: "HTTP version not supported".to_string(),
1846 status_code: 505,
1847 };
1848 assert!(!error.is_transient());
1849
1850 let error = SolanaProviderError::RequestError {
1852 error: "Bad request".to_string(),
1853 status_code: 400,
1854 };
1855 assert!(!error.is_transient());
1856
1857 let error = SolanaProviderError::RequestError {
1858 error: "Unauthorized".to_string(),
1859 status_code: 401,
1860 };
1861 assert!(!error.is_transient());
1862
1863 let error = SolanaProviderError::RequestError {
1864 error: "Forbidden".to_string(),
1865 status_code: 403,
1866 };
1867 assert!(!error.is_transient());
1868
1869 let error = SolanaProviderError::RequestError {
1870 error: "Not found".to_string(),
1871 status_code: 404,
1872 };
1873 assert!(!error.is_transient());
1874 }
1875
1876 #[test]
1877 fn test_request_error_display() {
1878 let error = SolanaProviderError::RequestError {
1879 error: "Server error".to_string(),
1880 status_code: 500,
1881 };
1882 let error_str = format!("{error}");
1883 assert!(error_str.contains("HTTP 500"));
1884 assert!(error_str.contains("Server error"));
1885 }
1886}