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