1use async_trait::async_trait;
13use eyre::Result;
14#[cfg(test)]
15use mockall::automock;
16use mpl_token_metadata::accounts::Metadata;
17use reqwest::Url;
18use serde::Serialize;
19use solana_client::{
20 nonblocking::rpc_client::RpcClient,
21 rpc_response::{RpcPrioritizationFee, RpcSimulateTransactionResult},
22};
23use solana_sdk::{
24 account::Account,
25 commitment_config::CommitmentConfig,
26 hash::Hash,
27 message::Message,
28 program_pack::Pack,
29 pubkey::Pubkey,
30 signature::Signature,
31 transaction::{Transaction, VersionedTransaction},
32};
33use spl_token::state::Mint;
34use std::{str::FromStr, sync::Arc, time::Duration};
35use thiserror::Error;
36
37use crate::{models::RpcConfig, services::retry_rpc_call};
38
39use super::ProviderError;
40use super::{
41 rpc_selector::{RpcSelector, RpcSelectorError},
42 RetryConfig,
43};
44
45#[derive(Error, Debug, Serialize)]
46pub enum SolanaProviderError {
47 #[error("RPC client error: {0}")]
48 RpcError(String),
49 #[error("Invalid address: {0}")]
50 InvalidAddress(String),
51 #[error("RPC selector error: {0}")]
52 SelectorError(RpcSelectorError),
53 #[error("Network configuration error: {0}")]
54 NetworkConfiguration(String),
55}
56
57#[async_trait]
59#[cfg_attr(test, automock)]
60#[allow(dead_code)]
61pub trait SolanaProviderTrait: Send + Sync {
62 async fn get_balance(&self, address: &str) -> Result<u64, SolanaProviderError>;
64
65 async fn get_latest_blockhash(&self) -> Result<Hash, SolanaProviderError>;
67
68 async fn get_latest_blockhash_with_commitment(
70 &self,
71 commitment: CommitmentConfig,
72 ) -> Result<(Hash, u64), SolanaProviderError>;
73
74 async fn send_transaction(
76 &self,
77 transaction: &Transaction,
78 ) -> Result<Signature, SolanaProviderError>;
79
80 async fn send_versioned_transaction(
82 &self,
83 transaction: &VersionedTransaction,
84 ) -> Result<Signature, SolanaProviderError>;
85
86 async fn confirm_transaction(&self, signature: &Signature)
88 -> Result<bool, SolanaProviderError>;
89
90 async fn get_minimum_balance_for_rent_exemption(
92 &self,
93 data_size: usize,
94 ) -> Result<u64, SolanaProviderError>;
95
96 async fn simulate_transaction(
98 &self,
99 transaction: &Transaction,
100 ) -> Result<RpcSimulateTransactionResult, SolanaProviderError>;
101
102 async fn get_account_from_str(&self, account: &str) -> Result<Account, SolanaProviderError>;
104
105 async fn get_account_from_pubkey(
107 &self,
108 pubkey: &Pubkey,
109 ) -> Result<Account, SolanaProviderError>;
110
111 async fn get_token_metadata_from_pubkey(
113 &self,
114 pubkey: &str,
115 ) -> Result<TokenMetadata, SolanaProviderError>;
116
117 async fn is_blockhash_valid(
119 &self,
120 hash: &Hash,
121 commitment: CommitmentConfig,
122 ) -> Result<bool, SolanaProviderError>;
123
124 async fn get_fee_for_message(&self, message: &Message) -> Result<u64, SolanaProviderError>;
126
127 async fn get_recent_prioritization_fees(
129 &self,
130 addresses: &[Pubkey],
131 ) -> Result<Vec<RpcPrioritizationFee>, SolanaProviderError>;
132
133 async fn calculate_total_fee(&self, message: &Message) -> Result<u64, SolanaProviderError>;
135}
136
137#[derive(Debug)]
138pub struct SolanaProvider {
139 selector: RpcSelector,
141 timeout_seconds: Duration,
143 commitment: CommitmentConfig,
145 retry_config: RetryConfig,
147}
148
149impl From<String> for SolanaProviderError {
150 fn from(s: String) -> Self {
151 SolanaProviderError::RpcError(s)
152 }
153}
154
155const RETRIABLE_ERROR_SUBSTRINGS: &[&str] = &[
156 "timeout",
157 "connection",
158 "reset",
159 "temporarily unavailable",
160 "rate limit",
161 "too many requests",
162 "503",
163 "502",
164 "504",
165 "blockhash not found",
166 "node is behind",
167 "unhealthy",
168];
169
170fn is_retriable_error(msg: &str) -> bool {
171 RETRIABLE_ERROR_SUBSTRINGS
172 .iter()
173 .any(|substr| msg.contains(substr))
174}
175
176#[derive(Error, Debug, PartialEq)]
177pub struct TokenMetadata {
178 pub decimals: u8,
179 pub symbol: String,
180 pub mint: String,
181}
182
183impl std::fmt::Display for TokenMetadata {
184 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185 write!(
186 f,
187 "TokenMetadata {{ decimals: {}, symbol: {}, mint: {} }}",
188 self.decimals, self.symbol, self.mint
189 )
190 }
191}
192
193#[allow(dead_code)]
194impl SolanaProvider {
195 pub fn new(configs: Vec<RpcConfig>, timeout_seconds: u64) -> Result<Self, ProviderError> {
196 Self::new_with_commitment(configs, timeout_seconds, CommitmentConfig::confirmed())
197 }
198
199 pub fn new_with_commitment(
211 configs: Vec<RpcConfig>,
212 timeout_seconds: u64,
213 commitment: CommitmentConfig,
214 ) -> Result<Self, ProviderError> {
215 if configs.is_empty() {
216 return Err(ProviderError::NetworkConfiguration(
217 "At least one RPC configuration must be provided".to_string(),
218 ));
219 }
220
221 RpcConfig::validate_list(&configs)
222 .map_err(|e| ProviderError::NetworkConfiguration(format!("Invalid URL: {}", e)))?;
223
224 let selector = RpcSelector::new(configs).map_err(|e| {
226 ProviderError::NetworkConfiguration(format!("Failed to create RPC selector: {}", e))
227 })?;
228
229 let retry_config = RetryConfig::from_env();
230
231 Ok(Self {
232 selector,
233 timeout_seconds: Duration::from_secs(timeout_seconds),
234 commitment,
235 retry_config,
236 })
237 }
238
239 fn get_client(&self) -> Result<RpcClient, SolanaProviderError> {
248 self.selector
249 .get_client(|url| {
250 Ok(RpcClient::new_with_timeout_and_commitment(
251 url.to_string(),
252 self.timeout_seconds,
253 self.commitment,
254 ))
255 })
256 .map_err(SolanaProviderError::SelectorError)
257 }
258
259 fn initialize_provider(&self, url: &str) -> Result<Arc<RpcClient>, SolanaProviderError> {
261 let rpc_url: Url = url.parse().map_err(|e| {
262 SolanaProviderError::NetworkConfiguration(format!("Invalid URL format: {}", e))
263 })?;
264
265 let client = RpcClient::new_with_timeout_and_commitment(
266 rpc_url.to_string(),
267 self.timeout_seconds,
268 self.commitment,
269 );
270
271 Ok(Arc::new(client))
272 }
273
274 async fn retry_rpc_call<T, F, Fut>(
276 &self,
277 operation_name: &str,
278 operation: F,
279 ) -> Result<T, SolanaProviderError>
280 where
281 F: Fn(Arc<RpcClient>) -> Fut,
282 Fut: std::future::Future<Output = Result<T, SolanaProviderError>>,
283 {
284 let is_retriable = |e: &SolanaProviderError| match e {
285 SolanaProviderError::RpcError(msg) => is_retriable_error(msg),
286 _ => false,
287 };
288
289 log::debug!(
290 "Starting RPC operation '{}' with timeout: {}s",
291 operation_name,
292 self.timeout_seconds.as_secs()
293 );
294
295 retry_rpc_call(
296 &self.selector,
297 operation_name,
298 is_retriable,
299 |_| false, |url| match self.initialize_provider(url) {
301 Ok(provider) => Ok(provider),
302 Err(e) => Err(e),
303 },
304 operation,
305 Some(self.retry_config.clone()),
306 )
307 .await
308 }
309}
310
311#[async_trait]
312#[allow(dead_code)]
313impl SolanaProviderTrait for SolanaProvider {
314 async fn get_balance(&self, address: &str) -> Result<u64, SolanaProviderError> {
320 let pubkey = Pubkey::from_str(address)
321 .map_err(|e| SolanaProviderError::InvalidAddress(e.to_string()))?;
322
323 self.retry_rpc_call("get_balance", |client| async move {
324 client
325 .get_balance(&pubkey)
326 .await
327 .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
328 })
329 .await
330 }
331
332 async fn is_blockhash_valid(
334 &self,
335 hash: &Hash,
336 commitment: CommitmentConfig,
337 ) -> Result<bool, SolanaProviderError> {
338 self.retry_rpc_call("is_blockhash_valid", |client| async move {
339 client
340 .is_blockhash_valid(hash, commitment)
341 .await
342 .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
343 })
344 .await
345 }
346
347 async fn get_latest_blockhash(&self) -> Result<Hash, SolanaProviderError> {
349 self.retry_rpc_call("get_latest_blockhash", |client| async move {
350 client
351 .get_latest_blockhash()
352 .await
353 .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
354 })
355 .await
356 }
357
358 async fn get_latest_blockhash_with_commitment(
359 &self,
360 commitment: CommitmentConfig,
361 ) -> Result<(Hash, u64), SolanaProviderError> {
362 self.retry_rpc_call(
363 "get_latest_blockhash_with_commitment",
364 |client| async move {
365 client
366 .get_latest_blockhash_with_commitment(commitment)
367 .await
368 .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
369 },
370 )
371 .await
372 }
373
374 async fn send_transaction(
376 &self,
377 transaction: &Transaction,
378 ) -> Result<Signature, SolanaProviderError> {
379 self.retry_rpc_call("send_transaction", |client| async move {
380 client
381 .send_transaction(transaction)
382 .await
383 .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
384 })
385 .await
386 }
387
388 async fn send_versioned_transaction(
390 &self,
391 transaction: &VersionedTransaction,
392 ) -> Result<Signature, SolanaProviderError> {
393 self.retry_rpc_call("send_transaction", |client| async move {
394 client
395 .send_transaction(transaction)
396 .await
397 .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
398 })
399 .await
400 }
401
402 async fn confirm_transaction(
404 &self,
405 signature: &Signature,
406 ) -> Result<bool, SolanaProviderError> {
407 self.retry_rpc_call("confirm_transaction", |client| async move {
408 client
409 .confirm_transaction(signature)
410 .await
411 .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
412 })
413 .await
414 }
415
416 async fn get_minimum_balance_for_rent_exemption(
418 &self,
419 data_size: usize,
420 ) -> Result<u64, SolanaProviderError> {
421 self.retry_rpc_call(
422 "get_minimum_balance_for_rent_exemption",
423 |client| async move {
424 client
425 .get_minimum_balance_for_rent_exemption(data_size)
426 .await
427 .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
428 },
429 )
430 .await
431 }
432
433 async fn simulate_transaction(
435 &self,
436 transaction: &Transaction,
437 ) -> Result<RpcSimulateTransactionResult, SolanaProviderError> {
438 self.retry_rpc_call("simulate_transaction", |client| async move {
439 client
440 .simulate_transaction(transaction)
441 .await
442 .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
443 .map(|response| response.value)
444 })
445 .await
446 }
447
448 async fn get_account_from_str(&self, account: &str) -> Result<Account, SolanaProviderError> {
450 let address = Pubkey::from_str(account).map_err(|e| {
451 SolanaProviderError::InvalidAddress(format!("Invalid pubkey {}: {}", account, e))
452 })?;
453 self.retry_rpc_call("get_account", |client| async move {
454 client
455 .get_account(&address)
456 .await
457 .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
458 })
459 .await
460 }
461
462 async fn get_account_from_pubkey(
464 &self,
465 pubkey: &Pubkey,
466 ) -> Result<Account, SolanaProviderError> {
467 self.retry_rpc_call("get_account_from_pubkey", |client| async move {
468 client
469 .get_account(pubkey)
470 .await
471 .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
472 })
473 .await
474 }
475
476 async fn get_token_metadata_from_pubkey(
478 &self,
479 pubkey: &str,
480 ) -> Result<TokenMetadata, SolanaProviderError> {
481 let account = self.get_account_from_str(pubkey).await.map_err(|e| {
483 SolanaProviderError::RpcError(format!("Failed to fetch account for {}: {}", pubkey, e))
484 })?;
485
486 let mint_info = Mint::unpack(&account.data).map_err(|e| {
488 SolanaProviderError::RpcError(format!("Failed to unpack mint info: {}", e))
489 })?;
490 let decimals = mint_info.decimals;
491
492 let mint_pubkey = Pubkey::try_from(pubkey).map_err(|e| {
494 SolanaProviderError::RpcError(format!("Invalid pubkey {}: {}", pubkey, e))
495 })?;
496
497 let metadata_pda = Metadata::find_pda(&mint_pubkey).0;
499
500 let symbol = match self.get_account_from_pubkey(&metadata_pda).await {
501 Ok(metadata_account) => match Metadata::from_bytes(&metadata_account.data) {
502 Ok(metadata) => metadata.symbol.trim_end_matches('\u{0}').to_string(),
503 Err(_) => String::new(),
504 },
505 Err(_) => String::new(), };
507
508 Ok(TokenMetadata {
509 decimals,
510 symbol,
511 mint: pubkey.to_string(),
512 })
513 }
514
515 async fn get_fee_for_message(&self, message: &Message) -> Result<u64, SolanaProviderError> {
517 self.retry_rpc_call("get_fee_for_message", |client| async move {
518 client
519 .get_fee_for_message(message)
520 .await
521 .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
522 })
523 .await
524 }
525
526 async fn get_recent_prioritization_fees(
527 &self,
528 addresses: &[Pubkey],
529 ) -> Result<Vec<RpcPrioritizationFee>, SolanaProviderError> {
530 self.retry_rpc_call("get_recent_prioritization_fees", |client| async move {
531 client
532 .get_recent_prioritization_fees(addresses)
533 .await
534 .map_err(|e| SolanaProviderError::RpcError(e.to_string()))
535 })
536 .await
537 }
538
539 async fn calculate_total_fee(&self, message: &Message) -> Result<u64, SolanaProviderError> {
540 let base_fee = self.get_fee_for_message(message).await?;
541 let priority_fees = self.get_recent_prioritization_fees(&[]).await?;
542
543 let max_priority_fee = priority_fees
544 .iter()
545 .map(|fee| fee.prioritization_fee)
546 .max()
547 .unwrap_or(0);
548
549 Ok(base_fee + max_priority_fee)
550 }
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use lazy_static::lazy_static;
557 use solana_sdk::{
558 hash::Hash,
559 message::Message,
560 signer::{keypair::Keypair, Signer},
561 transaction::Transaction,
562 };
563 use std::sync::Mutex;
564
565 lazy_static! {
566 static ref EVM_TEST_ENV_MUTEX: Mutex<()> = Mutex::new(());
567 }
568
569 struct EvmTestEnvGuard {
570 _mutex_guard: std::sync::MutexGuard<'static, ()>,
571 }
572
573 impl EvmTestEnvGuard {
574 fn new(mutex_guard: std::sync::MutexGuard<'static, ()>) -> Self {
575 std::env::set_var(
576 "API_KEY",
577 "test_api_key_for_evm_provider_new_this_is_long_enough_32_chars",
578 );
579 std::env::set_var("REDIS_URL", "redis://test-dummy-url-for-evm-provider");
580
581 Self {
582 _mutex_guard: mutex_guard,
583 }
584 }
585 }
586
587 impl Drop for EvmTestEnvGuard {
588 fn drop(&mut self) {
589 std::env::remove_var("API_KEY");
590 std::env::remove_var("REDIS_URL");
591 }
592 }
593
594 fn setup_test_env() -> EvmTestEnvGuard {
596 let guard = EVM_TEST_ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
597 EvmTestEnvGuard::new(guard)
598 }
599
600 fn get_funded_keypair() -> Keypair {
601 Keypair::from_bytes(&[
603 120, 248, 160, 20, 225, 60, 226, 195, 68, 137, 176, 87, 21, 129, 0, 76, 144, 129, 122,
604 250, 80, 4, 247, 50, 248, 82, 146, 77, 139, 156, 40, 41, 240, 161, 15, 81, 198, 198,
605 86, 167, 90, 148, 131, 13, 184, 222, 251, 71, 229, 212, 169, 2, 72, 202, 150, 184, 176,
606 148, 75, 160, 255, 233, 73, 31,
607 ])
608 .unwrap()
609 }
610
611 async fn get_recent_blockhash(provider: &SolanaProvider) -> Hash {
613 provider
614 .get_latest_blockhash()
615 .await
616 .expect("Failed to get blockhash")
617 }
618
619 fn create_test_rpc_config() -> RpcConfig {
620 RpcConfig {
621 url: "https://api.devnet.solana.com".to_string(),
622 weight: 1,
623 }
624 }
625
626 #[tokio::test]
627 async fn test_new_with_valid_config() {
628 let _env_guard = setup_test_env();
629 let configs = vec![create_test_rpc_config()];
630 let timeout = 30;
631
632 let result = SolanaProvider::new(configs, timeout);
633
634 assert!(result.is_ok());
635 let provider = result.unwrap();
636 assert_eq!(provider.timeout_seconds, Duration::from_secs(timeout));
637 assert_eq!(provider.commitment, CommitmentConfig::confirmed());
638 }
639
640 #[tokio::test]
641 async fn test_new_with_commitment_valid_config() {
642 let _env_guard = setup_test_env();
643
644 let configs = vec![create_test_rpc_config()];
645 let timeout = 30;
646 let commitment = CommitmentConfig::finalized();
647
648 let result = SolanaProvider::new_with_commitment(configs, timeout, commitment);
649
650 assert!(result.is_ok());
651 let provider = result.unwrap();
652 assert_eq!(provider.timeout_seconds, Duration::from_secs(timeout));
653 assert_eq!(provider.commitment, commitment);
654 }
655
656 #[tokio::test]
657 async fn test_new_with_empty_configs() {
658 let _env_guard = setup_test_env();
659 let configs: Vec<RpcConfig> = vec![];
660 let timeout = 30;
661
662 let result = SolanaProvider::new(configs, timeout);
663
664 assert!(result.is_err());
665 assert!(matches!(
666 result,
667 Err(ProviderError::NetworkConfiguration(_))
668 ));
669 }
670
671 #[tokio::test]
672 async fn test_new_with_commitment_empty_configs() {
673 let _env_guard = setup_test_env();
674 let configs: Vec<RpcConfig> = vec![];
675 let timeout = 30;
676 let commitment = CommitmentConfig::finalized();
677
678 let result = SolanaProvider::new_with_commitment(configs, timeout, commitment);
679
680 assert!(result.is_err());
681 assert!(matches!(
682 result,
683 Err(ProviderError::NetworkConfiguration(_))
684 ));
685 }
686
687 #[tokio::test]
688 async fn test_new_with_invalid_url() {
689 let _env_guard = setup_test_env();
690 let configs = vec![RpcConfig {
691 url: "invalid-url".to_string(),
692 weight: 1,
693 }];
694 let timeout = 30;
695
696 let result = SolanaProvider::new(configs, timeout);
697
698 assert!(result.is_err());
699 assert!(matches!(
700 result,
701 Err(ProviderError::NetworkConfiguration(_))
702 ));
703 }
704
705 #[tokio::test]
706 async fn test_new_with_commitment_invalid_url() {
707 let _env_guard = setup_test_env();
708 let configs = vec![RpcConfig {
709 url: "invalid-url".to_string(),
710 weight: 1,
711 }];
712 let timeout = 30;
713 let commitment = CommitmentConfig::finalized();
714
715 let result = SolanaProvider::new_with_commitment(configs, timeout, commitment);
716
717 assert!(result.is_err());
718 assert!(matches!(
719 result,
720 Err(ProviderError::NetworkConfiguration(_))
721 ));
722 }
723
724 #[tokio::test]
725 async fn test_new_with_multiple_configs() {
726 let _env_guard = setup_test_env();
727 let configs = vec![
728 create_test_rpc_config(),
729 RpcConfig {
730 url: "https://api.mainnet-beta.solana.com".to_string(),
731 weight: 1,
732 },
733 ];
734 let timeout = 30;
735
736 let result = SolanaProvider::new(configs, timeout);
737
738 assert!(result.is_ok());
739 }
740
741 #[tokio::test]
742 async fn test_provider_creation() {
743 let _env_guard = setup_test_env();
744 let configs = vec![create_test_rpc_config()];
745 let timeout = 30;
746 let provider = SolanaProvider::new(configs, timeout);
747 assert!(provider.is_ok());
748 }
749
750 #[tokio::test]
751 async fn test_get_balance() {
752 let _env_guard = setup_test_env();
753 let configs = vec![create_test_rpc_config()];
754 let timeout = 30;
755 let provider = SolanaProvider::new(configs, timeout).unwrap();
756 let keypair = Keypair::new();
757 let balance = provider.get_balance(&keypair.pubkey().to_string()).await;
758 assert!(balance.is_ok());
759 assert_eq!(balance.unwrap(), 0);
760 }
761
762 #[tokio::test]
763 async fn test_get_balance_funded_account() {
764 let _env_guard = setup_test_env();
765 let configs = vec![create_test_rpc_config()];
766 let timeout = 30;
767 let provider = SolanaProvider::new(configs, timeout).unwrap();
768 let keypair = get_funded_keypair();
769 let balance = provider.get_balance(&keypair.pubkey().to_string()).await;
770 assert!(balance.is_ok());
771 assert_eq!(balance.unwrap(), 1000000000);
772 }
773
774 #[tokio::test]
775 async fn test_get_latest_blockhash() {
776 let _env_guard = setup_test_env();
777 let configs = vec![create_test_rpc_config()];
778 let timeout = 30;
779 let provider = SolanaProvider::new(configs, timeout).unwrap();
780 let blockhash = provider.get_latest_blockhash().await;
781 assert!(blockhash.is_ok());
782 }
783
784 #[tokio::test]
785 async fn test_simulate_transaction() {
786 let _env_guard = setup_test_env();
787 let configs = vec![create_test_rpc_config()];
788 let timeout = 30;
789 let provider = SolanaProvider::new(configs, timeout).expect("Failed to create provider");
790
791 let fee_payer = get_funded_keypair();
792
793 let message = Message::new(&[], Some(&fee_payer.pubkey()));
796
797 let mut tx = Transaction::new_unsigned(message);
798
799 let recent_blockhash = get_recent_blockhash(&provider).await;
800 tx.try_sign(&[&fee_payer], recent_blockhash)
801 .expect("Failed to sign transaction");
802
803 let simulation_result = provider.simulate_transaction(&tx).await;
804
805 assert!(
806 simulation_result.is_ok(),
807 "Simulation failed: {:?}",
808 simulation_result
809 );
810
811 let result = simulation_result.unwrap();
812 assert!(
815 result.err.is_none(),
816 "Simulation encountered an error: {:?}",
817 result.err
818 );
819 }
820
821 #[tokio::test]
822 async fn test_get_token_metadata_from_pubkey() {
823 let _env_guard = setup_test_env();
824 let configs = vec![RpcConfig {
825 url: "https://api.mainnet-beta.solana.com".to_string(),
826 weight: 1,
827 }];
828 let timeout = 30;
829 let provider = SolanaProvider::new(configs, timeout).unwrap();
830 let usdc_token_metadata = provider
831 .get_token_metadata_from_pubkey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
832 .await
833 .unwrap();
834
835 assert_eq!(
836 usdc_token_metadata,
837 TokenMetadata {
838 decimals: 6,
839 symbol: "USDC".to_string(),
840 mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
841 }
842 );
843
844 let usdt_token_metadata = provider
845 .get_token_metadata_from_pubkey("Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB")
846 .await
847 .unwrap();
848
849 assert_eq!(
850 usdt_token_metadata,
851 TokenMetadata {
852 decimals: 6,
853 symbol: "USDT".to_string(),
854 mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB".to_string(),
855 }
856 );
857 }
858
859 #[tokio::test]
860 async fn test_get_client_success() {
861 let _env_guard = setup_test_env();
862 let configs = vec![create_test_rpc_config()];
863 let timeout = 30;
864 let provider = SolanaProvider::new(configs, timeout).unwrap();
865
866 let client = provider.get_client();
867 assert!(client.is_ok());
868
869 let client = client.unwrap();
870 let health_result = client.get_health().await;
871 assert!(health_result.is_ok());
872 }
873
874 #[tokio::test]
875 async fn test_get_client_with_custom_commitment() {
876 let _env_guard = setup_test_env();
877 let configs = vec![create_test_rpc_config()];
878 let timeout = 30;
879 let commitment = CommitmentConfig::finalized();
880
881 let provider = SolanaProvider::new_with_commitment(configs, timeout, commitment).unwrap();
882
883 let client = provider.get_client();
884 assert!(client.is_ok());
885
886 let client = client.unwrap();
887 let health_result = client.get_health().await;
888 assert!(health_result.is_ok());
889 }
890
891 #[tokio::test]
892 async fn test_get_client_with_multiple_rpcs() {
893 let _env_guard = setup_test_env();
894 let configs = vec![
895 create_test_rpc_config(),
896 RpcConfig {
897 url: "https://api.mainnet-beta.solana.com".to_string(),
898 weight: 2,
899 },
900 ];
901 let timeout = 30;
902
903 let provider = SolanaProvider::new(configs, timeout).unwrap();
904
905 let client_result = provider.get_client();
906 assert!(client_result.is_ok());
907
908 for _ in 0..5 {
910 let client = provider.get_client();
911 assert!(client.is_ok());
912 }
913 }
914
915 #[test]
916 fn test_initialize_provider_valid_url() {
917 let _env_guard = setup_test_env();
918
919 let configs = vec![RpcConfig {
920 url: "https://api.devnet.solana.com".to_string(),
921 weight: 1,
922 }];
923 let provider = SolanaProvider::new(configs, 10).unwrap();
924 let result = provider.initialize_provider("https://api.devnet.solana.com");
925 assert!(result.is_ok());
926 let arc_client = result.unwrap();
927 let _client: &RpcClient = Arc::as_ref(&arc_client);
929 }
930
931 #[test]
932 fn test_initialize_provider_invalid_url() {
933 let _env_guard = setup_test_env();
934
935 let configs = vec![RpcConfig {
936 url: "https://api.devnet.solana.com".to_string(),
937 weight: 1,
938 }];
939 let provider = SolanaProvider::new(configs, 10).unwrap();
940 let result = provider.initialize_provider("not-a-valid-url");
941 assert!(result.is_err());
942 match result {
943 Err(SolanaProviderError::NetworkConfiguration(msg)) => {
944 assert!(msg.contains("Invalid URL format"))
945 }
946 _ => panic!("Expected NetworkConfiguration error"),
947 }
948 }
949
950 #[test]
951 fn test_from_string_for_solana_provider_error() {
952 let msg = "some rpc error".to_string();
953 let err: SolanaProviderError = msg.clone().into();
954 match err {
955 SolanaProviderError::RpcError(inner) => assert_eq!(inner, msg),
956 _ => panic!("Expected RpcError variant"),
957 }
958 }
959
960 #[test]
961 fn test_is_retriable_error_true() {
962 for msg in RETRIABLE_ERROR_SUBSTRINGS {
963 assert!(is_retriable_error(msg), "Should be retriable: {}", msg);
964 }
965 }
966
967 #[test]
968 fn test_is_retriable_error_false() {
969 let non_retriable_cases = [
970 "account not found",
971 "invalid signature",
972 "insufficient funds",
973 "unknown error",
974 ];
975 for msg in non_retriable_cases {
976 assert!(!is_retriable_error(msg), "Should NOT be retriable: {}", msg);
977 }
978 }
979
980 #[tokio::test]
981 async fn test_get_minimum_balance_for_rent_exemption() {
982 let _env_guard = super::tests::setup_test_env();
983 let configs = vec![super::tests::create_test_rpc_config()];
984 let timeout = 30;
985 let provider = SolanaProvider::new(configs, timeout).unwrap();
986
987 let result = provider.get_minimum_balance_for_rent_exemption(0).await;
989 assert!(result.is_ok());
990 }
991
992 #[tokio::test]
993 async fn test_is_blockhash_valid_for_recent_blockhash() {
994 let _env_guard = super::tests::setup_test_env();
995 let configs = vec![super::tests::create_test_rpc_config()];
996 let timeout = 30;
997 let provider = SolanaProvider::new(configs, timeout).unwrap();
998
999 let blockhash = provider.get_latest_blockhash().await.unwrap();
1001 let is_valid = provider
1002 .is_blockhash_valid(&blockhash, CommitmentConfig::confirmed())
1003 .await;
1004 assert!(is_valid.is_ok());
1005 }
1006
1007 #[tokio::test]
1008 async fn test_is_blockhash_valid_for_invalid_blockhash() {
1009 let _env_guard = super::tests::setup_test_env();
1010 let configs = vec![super::tests::create_test_rpc_config()];
1011 let timeout = 30;
1012 let provider = SolanaProvider::new(configs, timeout).unwrap();
1013
1014 let invalid_blockhash = solana_sdk::hash::Hash::new_from_array([0u8; 32]);
1015 let is_valid = provider
1016 .is_blockhash_valid(&invalid_blockhash, CommitmentConfig::confirmed())
1017 .await;
1018 assert!(is_valid.is_ok());
1019 }
1020
1021 #[tokio::test]
1022 async fn test_get_latest_blockhash_with_commitment() {
1023 let _env_guard = super::tests::setup_test_env();
1024 let configs = vec![super::tests::create_test_rpc_config()];
1025 let timeout = 30;
1026 let provider = SolanaProvider::new(configs, timeout).unwrap();
1027
1028 let commitment = CommitmentConfig::confirmed();
1029 let result = provider
1030 .get_latest_blockhash_with_commitment(commitment)
1031 .await;
1032 assert!(result.is_ok());
1033 let (blockhash, last_valid_block_height) = result.unwrap();
1034 assert_ne!(blockhash, solana_sdk::hash::Hash::new_from_array([0u8; 32]));
1036 assert!(last_valid_block_height > 0);
1037 }
1038}