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