1use crate::chains::{Balance, ChainClient, Token, Transaction};
34use crate::config::ChainsConfig;
35use crate::error::{Result, ScopeError};
36use async_trait::async_trait;
37use reqwest::Client;
38use serde::{Deserialize, Serialize};
39
40const DEFAULT_SOLANA_RPC: &str = "https://api.mainnet-beta.solana.com";
42
43#[allow(dead_code)] const SOLSCAN_API_URL: &str = "https://api.solscan.io";
46
47const SOL_DECIMALS: u8 = 9;
49
50#[derive(Debug, Clone)]
55pub struct SolanaClient {
56 client: Client,
58
59 rpc_url: String,
61
62 #[allow(dead_code)] solscan_api_key: Option<String>,
65}
66
67#[derive(Debug, Serialize)]
69struct RpcRequest<'a, T: Serialize> {
70 jsonrpc: &'a str,
71 id: u64,
72 method: &'a str,
73 params: T,
74}
75
76#[derive(Debug, Deserialize)]
78struct RpcResponse<T> {
79 result: Option<T>,
80 error: Option<RpcError>,
81}
82
83#[derive(Debug, Deserialize)]
85struct RpcError {
86 code: i64,
87 message: String,
88}
89
90#[derive(Debug, Deserialize)]
92struct BalanceResponse {
93 value: u64,
94}
95
96#[derive(Debug, Deserialize)]
98struct TokenAccountsResponse {
99 value: Vec<TokenAccountInfo>,
100}
101
102#[derive(Debug, Deserialize)]
104struct TokenAccountInfo {
105 pubkey: String,
106 account: TokenAccountData,
107}
108
109#[derive(Debug, Deserialize)]
111struct TokenAccountData {
112 data: TokenAccountParsedData,
113}
114
115#[derive(Debug, Deserialize)]
117struct TokenAccountParsedData {
118 parsed: TokenAccountParsedInfo,
119}
120
121#[derive(Debug, Deserialize)]
123struct TokenAccountParsedInfo {
124 info: TokenInfo,
125}
126
127#[derive(Debug, Deserialize)]
129#[serde(rename_all = "camelCase")]
130struct TokenInfo {
131 mint: String,
132 token_amount: TokenAmount,
133}
134
135#[derive(Debug, Deserialize)]
137#[serde(rename_all = "camelCase")]
138#[allow(dead_code)] struct TokenAmount {
140 amount: String,
141 decimals: u8,
142 ui_amount: Option<f64>,
143 ui_amount_string: String,
144}
145
146#[derive(Debug, Clone, Serialize)]
148pub struct TokenBalance {
149 pub mint: String,
151 pub token_account: String,
153 pub raw_amount: String,
155 pub ui_amount: f64,
157 pub decimals: u8,
159 pub symbol: Option<String>,
161 pub name: Option<String>,
163}
164
165#[derive(Debug, Deserialize)]
167#[serde(rename_all = "camelCase")]
168#[allow(dead_code)] struct SignatureInfo {
170 signature: String,
171 slot: u64,
172 block_time: Option<i64>,
173 err: Option<serde_json::Value>,
174}
175
176#[derive(Debug, Deserialize)]
178#[serde(rename_all = "camelCase")]
179struct SolanaTransactionResult {
180 #[serde(default)]
181 slot: Option<u64>,
182 #[serde(default)]
183 block_time: Option<i64>,
184 #[serde(default)]
185 transaction: Option<SolanaTransactionData>,
186 #[serde(default)]
187 meta: Option<SolanaTransactionMeta>,
188}
189
190#[derive(Debug, Deserialize)]
192struct SolanaTransactionData {
193 #[serde(default)]
194 message: Option<SolanaTransactionMessage>,
195}
196
197#[derive(Debug, Deserialize)]
199#[serde(rename_all = "camelCase")]
200struct SolanaTransactionMessage {
201 #[serde(default)]
202 account_keys: Option<Vec<AccountKeyEntry>>,
203}
204
205#[derive(Debug, Deserialize)]
207#[serde(untagged)]
208enum AccountKeyEntry {
209 String(String),
210 Object {
211 pubkey: String,
212 #[serde(default)]
213 #[allow(dead_code)]
214 signer: bool,
215 },
216}
217
218#[derive(Debug, Deserialize)]
220#[serde(rename_all = "camelCase")]
221struct SolanaTransactionMeta {
222 #[serde(default)]
223 fee: Option<u64>,
224 #[serde(default)]
225 pre_balances: Option<Vec<u64>>,
226 #[serde(default)]
227 post_balances: Option<Vec<u64>>,
228 #[serde(default)]
229 err: Option<serde_json::Value>,
230}
231
232#[derive(Debug, Deserialize)]
234#[allow(dead_code)] struct SolscanAccountInfo {
236 lamports: u64,
237 #[serde(rename = "type")]
238 account_type: Option<String>,
239}
240
241impl SolanaClient {
242 pub fn new(config: &ChainsConfig) -> Result<Self> {
262 let client = Client::builder()
263 .timeout(std::time::Duration::from_secs(30))
264 .build()
265 .map_err(|e| ScopeError::Chain(format!("Failed to create HTTP client: {}", e)))?;
266
267 let rpc_url = config
268 .solana_rpc
269 .as_deref()
270 .unwrap_or(DEFAULT_SOLANA_RPC)
271 .to_string();
272
273 Ok(Self {
274 client,
275 rpc_url,
276 solscan_api_key: config.api_keys.get("solscan").cloned(),
277 })
278 }
279
280 pub fn with_rpc_url(rpc_url: &str) -> Self {
286 Self {
287 client: Client::new(),
288 rpc_url: rpc_url.to_string(),
289 solscan_api_key: None,
290 }
291 }
292
293 pub fn chain_name(&self) -> &str {
295 "solana"
296 }
297
298 pub fn native_token_symbol(&self) -> &str {
300 "SOL"
301 }
302
303 pub async fn get_balance(&self, address: &str) -> Result<Balance> {
318 validate_solana_address(address)?;
320
321 let request = RpcRequest {
322 jsonrpc: "2.0",
323 id: 1,
324 method: "getBalance",
325 params: vec![address],
326 };
327
328 tracing::debug!(url = %self.rpc_url, address = %address, "Fetching Solana balance");
329
330 let response: RpcResponse<BalanceResponse> = self
331 .client
332 .post(&self.rpc_url)
333 .json(&request)
334 .send()
335 .await?
336 .json()
337 .await?;
338
339 if let Some(error) = response.error {
340 return Err(ScopeError::Chain(format!(
341 "Solana RPC error ({}): {}",
342 error.code, error.message
343 )));
344 }
345
346 let balance = response
347 .result
348 .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))?;
349
350 let lamports = balance.value;
351 let sol = lamports as f64 / 10_f64.powi(SOL_DECIMALS as i32);
352
353 Ok(Balance {
354 raw: lamports.to_string(),
355 formatted: format!("{:.9} SOL", sol),
356 decimals: SOL_DECIMALS,
357 symbol: "SOL".to_string(),
358 usd_value: None, })
360 }
361
362 pub async fn get_token_info(&self, mint_address: &str) -> Result<Token> {
367 validate_solana_address(mint_address)?;
368
369 let request = RpcRequest {
370 jsonrpc: "2.0",
371 id: 1,
372 method: "getAccountInfo",
373 params: serde_json::json!([mint_address, { "encoding": "base64" }]),
374 };
375
376 tracing::debug!(
377 url = %self.rpc_url,
378 mint = %mint_address,
379 "Fetching SPL mint info"
380 );
381
382 #[derive(Deserialize)]
383 struct AccountInfoResult {
384 value: Option<AccountInfoValue>,
385 }
386 #[derive(Deserialize)]
387 struct AccountInfoValue {
388 data: Option<Vec<String>>,
389 }
390
391 let response: RpcResponse<AccountInfoResult> = self
392 .client
393 .post(&self.rpc_url)
394 .json(&request)
395 .send()
396 .await?
397 .json()
398 .await?;
399
400 if let Some(error) = response.error {
401 return Err(ScopeError::Chain(format!(
402 "Solana RPC error ({}): {}",
403 error.code, error.message
404 )));
405 }
406
407 let account = response.result.and_then(|r| r.value).ok_or_else(|| {
408 ScopeError::NotFound(format!("Mint account not found: {}", mint_address))
409 })?;
410
411 let data_b64 = account
412 .data
413 .and_then(|d| d.into_iter().next())
414 .ok_or_else(|| {
415 ScopeError::Chain(format!("No account data for mint: {}", mint_address))
416 })?;
417
418 let data = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &data_b64)
419 .map_err(|e| ScopeError::Chain(format!("Failed to decode mint data: {}", e)))?;
420
421 const DECIMALS_OFFSET: usize = 40;
423 let decimals = if data.len() > DECIMALS_OFFSET {
424 data[DECIMALS_OFFSET]
425 } else {
426 return Err(ScopeError::Chain(format!(
427 "Invalid mint account data (too short): {}",
428 mint_address
429 )));
430 };
431
432 let short_mint = if mint_address.len() > 8 {
433 format!("{}...", &mint_address[..8])
434 } else {
435 mint_address.to_string()
436 };
437
438 Ok(Token {
439 contract_address: mint_address.to_string(),
440 symbol: short_mint,
441 name: "SPL Token".to_string(),
442 decimals,
443 })
444 }
445
446 pub async fn enrich_balance_usd(&self, balance: &mut Balance) {
448 let dex = crate::chains::DexClient::new();
449 if let Some(price) = dex.get_native_token_price("solana").await {
450 let lamports: f64 = balance.raw.parse().unwrap_or(0.0);
451 let sol = lamports / 10_f64.powi(SOL_DECIMALS as i32);
452 balance.usd_value = Some(sol * price);
453 }
454 }
455
456 pub async fn get_token_balances(&self, address: &str) -> Result<Vec<TokenBalance>> {
471 validate_solana_address(address)?;
472
473 const TOKEN_PROGRAM_ID: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
476
477 let request = serde_json::json!({
478 "jsonrpc": "2.0",
479 "id": 1,
480 "method": "getTokenAccountsByOwner",
481 "params": [
482 address,
483 { "programId": TOKEN_PROGRAM_ID },
484 { "encoding": "jsonParsed" }
485 ]
486 });
487
488 tracing::debug!(url = %self.rpc_url, address = %address, "Fetching SPL token balances");
489
490 let response: RpcResponse<TokenAccountsResponse> = self
491 .client
492 .post(&self.rpc_url)
493 .json(&request)
494 .send()
495 .await?
496 .json()
497 .await?;
498
499 if let Some(error) = response.error {
500 return Err(ScopeError::Chain(format!(
501 "Solana RPC error ({}): {}",
502 error.code, error.message
503 )));
504 }
505
506 let accounts = response
507 .result
508 .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))?;
509
510 let token_balances: Vec<TokenBalance> = accounts
511 .value
512 .into_iter()
513 .filter_map(|account| {
514 let info = &account.account.data.parsed.info;
515 let ui_amount = info.token_amount.ui_amount.unwrap_or(0.0);
516
517 if ui_amount == 0.0 {
519 return None;
520 }
521
522 Some(TokenBalance {
523 mint: info.mint.clone(),
524 token_account: account.pubkey,
525 raw_amount: info.token_amount.amount.clone(),
526 ui_amount,
527 decimals: info.token_amount.decimals,
528 symbol: None, name: None,
530 })
531 })
532 .collect();
533
534 Ok(token_balances)
535 }
536
537 pub async fn get_signatures(&self, address: &str, limit: u32) -> Result<Vec<String>> {
548 let infos = self.get_signature_infos(address, limit).await?;
549 Ok(infos.into_iter().map(|s| s.signature).collect())
550 }
551
552 async fn get_signature_infos(&self, address: &str, limit: u32) -> Result<Vec<SignatureInfo>> {
554 validate_solana_address(address)?;
555
556 #[derive(Serialize)]
557 struct GetSignaturesParams<'a> {
558 limit: u32,
559 #[serde(skip_serializing_if = "Option::is_none")]
560 before: Option<&'a str>,
561 }
562
563 let request = RpcRequest {
564 jsonrpc: "2.0",
565 id: 1,
566 method: "getSignaturesForAddress",
567 params: (
568 address,
569 GetSignaturesParams {
570 limit,
571 before: None,
572 },
573 ),
574 };
575
576 tracing::debug!(
577 url = %self.rpc_url,
578 address = %address,
579 limit = %limit,
580 "Fetching Solana transaction signatures"
581 );
582
583 let response: RpcResponse<Vec<SignatureInfo>> = self
584 .client
585 .post(&self.rpc_url)
586 .json(&request)
587 .send()
588 .await?
589 .json()
590 .await?;
591
592 if let Some(error) = response.error {
593 return Err(ScopeError::Chain(format!(
594 "Solana RPC error ({}): {}",
595 error.code, error.message
596 )));
597 }
598
599 response
600 .result
601 .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))
602 }
603
604 pub async fn get_transaction(&self, signature: &str) -> Result<Transaction> {
614 validate_solana_signature(signature)?;
616
617 let request = RpcRequest {
618 jsonrpc: "2.0",
619 id: 1,
620 method: "getTransaction",
621 params: serde_json::json!([
622 signature,
623 {
624 "encoding": "jsonParsed",
625 "maxSupportedTransactionVersion": 0
626 }
627 ]),
628 };
629
630 tracing::debug!(
631 url = %self.rpc_url,
632 signature = %signature,
633 "Fetching Solana transaction"
634 );
635
636 let response: RpcResponse<SolanaTransactionResult> = self
637 .client
638 .post(&self.rpc_url)
639 .json(&request)
640 .send()
641 .await?
642 .json()
643 .await?;
644
645 if let Some(error) = response.error {
646 return Err(ScopeError::Chain(format!(
647 "Solana RPC error ({}): {}",
648 error.code, error.message
649 )));
650 }
651
652 let tx_result = response
653 .result
654 .ok_or_else(|| ScopeError::NotFound(format!("Transaction not found: {}", signature)))?;
655
656 let from = tx_result
658 .transaction
659 .as_ref()
660 .and_then(|tx| tx.message.as_ref())
661 .and_then(|msg| msg.account_keys.as_ref())
662 .and_then(|keys| keys.first())
663 .map(|key| match key {
664 AccountKeyEntry::String(s) => s.clone(),
665 AccountKeyEntry::Object { pubkey, .. } => pubkey.clone(),
666 })
667 .unwrap_or_default();
668
669 let value = tx_result
671 .meta
672 .as_ref()
673 .and_then(|meta| {
674 let pre = meta.pre_balances.as_ref()?;
675 let post = meta.post_balances.as_ref()?;
676 if pre.len() >= 2 && post.len() >= 2 {
677 let fee = meta.fee.unwrap_or(0);
679 let sent = pre[0].saturating_sub(post[0]).saturating_sub(fee);
680 if sent > 0 {
681 let sol = sent as f64 / 10_f64.powi(SOL_DECIMALS as i32);
682 return Some(format!("{:.9}", sol));
683 }
684 }
685 None
686 })
687 .unwrap_or_else(|| "0".to_string());
688
689 let to = tx_result
691 .transaction
692 .as_ref()
693 .and_then(|tx| tx.message.as_ref())
694 .and_then(|msg| msg.account_keys.as_ref())
695 .and_then(|keys| {
696 if keys.len() >= 2 {
697 Some(match &keys[1] {
698 AccountKeyEntry::String(s) => s.clone(),
699 AccountKeyEntry::Object { pubkey, .. } => pubkey.clone(),
700 })
701 } else {
702 None
703 }
704 });
705
706 let fee = tx_result
707 .meta
708 .as_ref()
709 .and_then(|meta| meta.fee)
710 .unwrap_or(0);
711
712 let status = tx_result.meta.as_ref().map(|meta| meta.err.is_none());
713
714 Ok(Transaction {
715 hash: signature.to_string(),
716 block_number: tx_result.slot,
717 timestamp: tx_result.block_time.map(|t| t as u64),
718 from,
719 to,
720 value,
721 gas_limit: 0, gas_used: None,
723 gas_price: fee.to_string(), nonce: 0,
725 input: String::new(),
726 status,
727 })
728 }
729
730 pub async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
741 validate_solana_address(address)?;
742
743 let sig_infos = self.get_signature_infos(address, limit).await?;
745
746 let transactions: Vec<Transaction> = sig_infos
747 .into_iter()
748 .map(|info| Transaction {
749 hash: info.signature,
750 block_number: Some(info.slot),
751 timestamp: info.block_time.map(|t| t as u64),
752 from: address.to_string(),
753 to: None,
754 value: "0".to_string(),
755 gas_limit: 0,
756 gas_used: None,
757 gas_price: "0".to_string(),
758 nonce: 0,
759 input: String::new(),
760 status: Some(info.err.is_none()),
761 })
762 .collect();
763
764 Ok(transactions)
765 }
766
767 pub async fn get_slot(&self) -> Result<u64> {
769 let request = RpcRequest {
770 jsonrpc: "2.0",
771 id: 1,
772 method: "getSlot",
773 params: (),
774 };
775
776 let response: RpcResponse<u64> = self
777 .client
778 .post(&self.rpc_url)
779 .json(&request)
780 .send()
781 .await?
782 .json()
783 .await?;
784
785 if let Some(error) = response.error {
786 return Err(ScopeError::Chain(format!(
787 "Solana RPC error ({}): {}",
788 error.code, error.message
789 )));
790 }
791
792 response
793 .result
794 .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))
795 }
796}
797
798impl Default for SolanaClient {
799 fn default() -> Self {
800 Self {
801 client: Client::new(),
802 rpc_url: DEFAULT_SOLANA_RPC.to_string(),
803 solscan_api_key: None,
804 }
805 }
806}
807
808pub fn validate_solana_address(address: &str) -> Result<()> {
818 if address.is_empty() {
822 return Err(ScopeError::InvalidAddress("Address cannot be empty".into()));
823 }
824
825 if address.len() < 32 || address.len() > 44 {
827 return Err(ScopeError::InvalidAddress(format!(
828 "Solana address must be 32-44 characters, got {}: {}",
829 address.len(),
830 address
831 )));
832 }
833
834 match bs58::decode(address).into_vec() {
836 Ok(bytes) => {
837 if bytes.len() != 32 {
839 return Err(ScopeError::InvalidAddress(format!(
840 "Solana address must decode to 32 bytes, got {}: {}",
841 bytes.len(),
842 address
843 )));
844 }
845 }
846 Err(e) => {
847 return Err(ScopeError::InvalidAddress(format!(
848 "Invalid base58 encoding: {}: {}",
849 e, address
850 )));
851 }
852 }
853
854 Ok(())
855}
856
857pub fn validate_solana_signature(signature: &str) -> Result<()> {
867 if signature.is_empty() {
871 return Err(ScopeError::InvalidHash("Signature cannot be empty".into()));
872 }
873
874 if signature.len() < 80 || signature.len() > 90 {
876 return Err(ScopeError::InvalidHash(format!(
877 "Solana signature must be 80-90 characters, got {}: {}",
878 signature.len(),
879 signature
880 )));
881 }
882
883 match bs58::decode(signature).into_vec() {
885 Ok(bytes) => {
886 if bytes.len() != 64 {
888 return Err(ScopeError::InvalidHash(format!(
889 "Solana signature must decode to 64 bytes, got {}: {}",
890 bytes.len(),
891 signature
892 )));
893 }
894 }
895 Err(e) => {
896 return Err(ScopeError::InvalidHash(format!(
897 "Invalid base58 encoding: {}: {}",
898 e, signature
899 )));
900 }
901 }
902
903 Ok(())
904}
905
906#[async_trait]
911impl ChainClient for SolanaClient {
912 fn chain_name(&self) -> &str {
913 "solana"
914 }
915
916 fn native_token_symbol(&self) -> &str {
917 "SOL"
918 }
919
920 async fn get_balance(&self, address: &str) -> Result<Balance> {
921 self.get_balance(address).await
922 }
923
924 async fn enrich_balance_usd(&self, balance: &mut Balance) {
925 self.enrich_balance_usd(balance).await
926 }
927
928 async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
929 self.get_transaction(hash).await
930 }
931
932 async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
933 self.get_transactions(address, limit).await
934 }
935
936 async fn get_block_number(&self) -> Result<u64> {
937 self.get_slot().await
938 }
939
940 async fn get_token_info(&self, address: &str) -> Result<Token> {
941 self.get_token_info(address).await
942 }
943
944 async fn get_token_balances(&self, address: &str) -> Result<Vec<crate::chains::TokenBalance>> {
945 let solana_balances = self.get_token_balances(address).await?;
946 Ok(solana_balances
947 .into_iter()
948 .map(|tb| crate::chains::TokenBalance {
949 token: Token {
950 contract_address: tb.mint.clone(),
951 symbol: tb
952 .symbol
953 .unwrap_or_else(|| tb.mint[..8.min(tb.mint.len())].to_string()),
954 name: tb.name.unwrap_or_else(|| "SPL Token".to_string()),
955 decimals: tb.decimals,
956 },
957 balance: tb.raw_amount,
958 formatted_balance: format!("{:.6}", tb.ui_amount),
959 usd_value: None,
960 })
961 .collect())
962 }
963}
964
965#[cfg(test)]
970mod tests {
971 use super::*;
972
973 const VALID_ADDRESS: &str = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy";
975
976 const VALID_SIGNATURE: &str =
978 "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
979
980 #[test]
981 fn test_validate_solana_address_valid() {
982 assert!(validate_solana_address(VALID_ADDRESS).is_ok());
983 }
984
985 #[test]
986 fn test_validate_solana_address_empty() {
987 let result = validate_solana_address("");
988 assert!(result.is_err());
989 assert!(result.unwrap_err().to_string().contains("empty"));
990 }
991
992 #[test]
993 fn test_validate_solana_address_too_short() {
994 let result = validate_solana_address("DRpbCBMxVnDK7maPM5t");
995 assert!(result.is_err());
996 assert!(result.unwrap_err().to_string().contains("32-44"));
997 }
998
999 #[test]
1000 fn test_validate_solana_address_too_long() {
1001 let long_addr = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hyAAAAAAAAAAAA";
1002 let result = validate_solana_address(long_addr);
1003 assert!(result.is_err());
1004 }
1005
1006 #[test]
1007 fn test_validate_solana_address_invalid_base58() {
1008 let result = validate_solana_address("0RpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1010 assert!(result.is_err());
1011 assert!(result.unwrap_err().to_string().contains("base58"));
1012 }
1013
1014 #[test]
1015 fn test_validate_solana_address_wrong_decoded_length() {
1016 let result = validate_solana_address("abcdefghijabcdefghijabcdefghijab");
1019 assert!(result.is_err());
1020 }
1022
1023 #[test]
1024 fn test_validate_solana_signature_valid() {
1025 assert!(validate_solana_signature(VALID_SIGNATURE).is_ok());
1026 }
1027
1028 #[test]
1029 fn test_validate_solana_signature_empty() {
1030 let result = validate_solana_signature("");
1031 assert!(result.is_err());
1032 assert!(result.unwrap_err().to_string().contains("empty"));
1033 }
1034
1035 #[test]
1036 fn test_validate_solana_signature_too_short() {
1037 let result = validate_solana_signature("abc");
1038 assert!(result.is_err());
1039 assert!(result.unwrap_err().to_string().contains("80-90"));
1040 }
1041
1042 #[test]
1043 fn test_solana_client_default() {
1044 let client = SolanaClient::default();
1045 assert_eq!(client.chain_name(), "solana");
1046 assert_eq!(client.native_token_symbol(), "SOL");
1047 assert!(client.rpc_url.contains("mainnet-beta"));
1048 }
1049
1050 #[test]
1051 fn test_solana_client_with_rpc_url() {
1052 let client = SolanaClient::with_rpc_url("https://custom.rpc.com");
1053 assert_eq!(client.rpc_url, "https://custom.rpc.com");
1054 }
1055
1056 #[test]
1057 fn test_solana_client_new() {
1058 let config = ChainsConfig::default();
1059 let client = SolanaClient::new(&config);
1060 assert!(client.is_ok());
1061 }
1062
1063 #[test]
1064 fn test_solana_client_new_with_custom_rpc() {
1065 let config = ChainsConfig {
1066 solana_rpc: Some("https://my-solana-rpc.com".to_string()),
1067 ..Default::default()
1068 };
1069 let client = SolanaClient::new(&config).unwrap();
1070 assert_eq!(client.rpc_url, "https://my-solana-rpc.com");
1071 }
1072
1073 #[test]
1074 fn test_solana_client_new_with_api_key() {
1075 use std::collections::HashMap;
1076
1077 let mut api_keys = HashMap::new();
1078 api_keys.insert("solscan".to_string(), "test-key".to_string());
1079
1080 let config = ChainsConfig {
1081 api_keys,
1082 ..Default::default()
1083 };
1084
1085 let client = SolanaClient::new(&config).unwrap();
1086 assert_eq!(client.solscan_api_key, Some("test-key".to_string()));
1087 }
1088
1089 #[test]
1090 fn test_rpc_request_serialization() {
1091 let request = RpcRequest {
1092 jsonrpc: "2.0",
1093 id: 1,
1094 method: "getBalance",
1095 params: vec!["test"],
1096 };
1097
1098 let json = serde_json::to_string(&request).unwrap();
1099 assert!(json.contains("jsonrpc"));
1100 assert!(json.contains("getBalance"));
1101 }
1102
1103 #[test]
1104 fn test_rpc_response_deserialization() {
1105 let json = r#"{"jsonrpc":"2.0","result":{"value":1000000000},"id":1}"#;
1106 let response: RpcResponse<BalanceResponse> = serde_json::from_str(json).unwrap();
1107 assert!(response.result.is_some());
1108 assert_eq!(response.result.unwrap().value, 1_000_000_000);
1109 }
1110
1111 #[test]
1112 fn test_rpc_error_deserialization() {
1113 let json =
1114 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid request"},"id":1}"#;
1115 let response: RpcResponse<BalanceResponse> = serde_json::from_str(json).unwrap();
1116 assert!(response.error.is_some());
1117 let error = response.error.unwrap();
1118 assert_eq!(error.code, -32600);
1119 assert_eq!(error.message, "Invalid request");
1120 }
1121
1122 #[tokio::test]
1127 async fn test_get_balance() {
1128 let mut server = mockito::Server::new_async().await;
1129 let _mock = server
1130 .mock("POST", "/")
1131 .with_status(200)
1132 .with_header("content-type", "application/json")
1133 .with_body(r#"{"jsonrpc":"2.0","result":{"value":5000000000},"id":1}"#)
1134 .create_async()
1135 .await;
1136
1137 let client = SolanaClient::with_rpc_url(&server.url());
1138 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1139 assert_eq!(balance.raw, "5000000000");
1140 assert_eq!(balance.symbol, "SOL");
1141 assert_eq!(balance.decimals, 9);
1142 assert!(balance.formatted.contains("5.000000000"));
1143 }
1144
1145 #[tokio::test]
1146 async fn test_get_balance_zero() {
1147 let mut server = mockito::Server::new_async().await;
1148 let _mock = server
1149 .mock("POST", "/")
1150 .with_status(200)
1151 .with_header("content-type", "application/json")
1152 .with_body(r#"{"jsonrpc":"2.0","result":{"value":0},"id":1}"#)
1153 .create_async()
1154 .await;
1155
1156 let client = SolanaClient::with_rpc_url(&server.url());
1157 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1158 assert_eq!(balance.raw, "0");
1159 assert!(balance.formatted.contains("0.000000000"));
1160 }
1161
1162 #[tokio::test]
1163 async fn test_get_balance_rpc_error() {
1164 let mut server = mockito::Server::new_async().await;
1165 let _mock = server
1166 .mock("POST", "/")
1167 .with_status(200)
1168 .with_header("content-type", "application/json")
1169 .with_body(
1170 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid params"},"id":1}"#,
1171 )
1172 .create_async()
1173 .await;
1174
1175 let client = SolanaClient::with_rpc_url(&server.url());
1176 let result = client.get_balance(VALID_ADDRESS).await;
1177 assert!(result.is_err());
1178 assert!(result.unwrap_err().to_string().contains("RPC error"));
1179 }
1180
1181 #[tokio::test]
1182 async fn test_get_balance_empty_response() {
1183 let mut server = mockito::Server::new_async().await;
1184 let _mock = server
1185 .mock("POST", "/")
1186 .with_status(200)
1187 .with_header("content-type", "application/json")
1188 .with_body(r#"{"jsonrpc":"2.0","id":1}"#)
1189 .create_async()
1190 .await;
1191
1192 let client = SolanaClient::with_rpc_url(&server.url());
1193 let result = client.get_balance(VALID_ADDRESS).await;
1194 assert!(result.is_err());
1195 assert!(result.unwrap_err().to_string().contains("Empty RPC"));
1196 }
1197
1198 #[tokio::test]
1199 async fn test_get_balance_invalid_address() {
1200 let client = SolanaClient::default();
1201 let result = client.get_balance("invalid").await;
1202 assert!(result.is_err());
1203 }
1204
1205 #[tokio::test]
1206 async fn test_get_transaction() {
1207 let mut server = mockito::Server::new_async().await;
1208 let _mock = server
1209 .mock("POST", "/")
1210 .with_status(200)
1211 .with_header("content-type", "application/json")
1212 .with_body(
1213 r#"{"jsonrpc":"2.0","result":{
1214 "slot":123456789,
1215 "blockTime":1700000000,
1216 "transaction":{
1217 "message":{
1218 "accountKeys":[
1219 {"pubkey":"DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy","signer":true},
1220 {"pubkey":"9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM","signer":false}
1221 ]
1222 }
1223 },
1224 "meta":{
1225 "fee":5000,
1226 "preBalances":[10000000000,5000000000],
1227 "postBalances":[8999995000,6000000000],
1228 "err":null
1229 }
1230 },"id":1}"#,
1231 )
1232 .create_async()
1233 .await;
1234
1235 let client = SolanaClient::with_rpc_url(&server.url());
1236 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1237 assert_eq!(tx.hash, VALID_SIGNATURE);
1238 assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1239 assert_eq!(
1240 tx.to,
1241 Some("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM".to_string())
1242 );
1243 assert_eq!(tx.block_number, Some(123456789));
1244 assert_eq!(tx.timestamp, Some(1700000000));
1245 assert!(tx.status.unwrap()); assert_eq!(tx.gas_price, "5000"); }
1248
1249 #[tokio::test]
1250 async fn test_get_transaction_failed() {
1251 let mut server = mockito::Server::new_async().await;
1252 let _mock = server
1253 .mock("POST", "/")
1254 .with_status(200)
1255 .with_header("content-type", "application/json")
1256 .with_body(r#"{"jsonrpc":"2.0","result":{
1257 "slot":100,
1258 "transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"]}},
1259 "meta":{"fee":5000,"preBalances":[1000],"postBalances":[1000],"err":{"InstructionError":[0,{"Custom":1}]}}
1260 },"id":1}"#)
1261 .create_async()
1262 .await;
1263
1264 let client = SolanaClient::with_rpc_url(&server.url());
1265 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1266 assert!(!tx.status.unwrap()); }
1268
1269 #[tokio::test]
1270 async fn test_get_transaction_not_found() {
1271 let mut server = mockito::Server::new_async().await;
1272 let _mock = server
1273 .mock("POST", "/")
1274 .with_status(200)
1275 .with_header("content-type", "application/json")
1276 .with_body(r#"{"jsonrpc":"2.0","result":null,"id":1}"#)
1277 .create_async()
1278 .await;
1279
1280 let client = SolanaClient::with_rpc_url(&server.url());
1281 let result = client.get_transaction(VALID_SIGNATURE).await;
1282 assert!(result.is_err());
1283 assert!(result.unwrap_err().to_string().contains("not found"));
1284 }
1285
1286 #[tokio::test]
1287 async fn test_get_transaction_string_account_keys() {
1288 let mut server = mockito::Server::new_async().await;
1289 let _mock = server
1290 .mock("POST", "/")
1291 .with_status(200)
1292 .with_header("content-type", "application/json")
1293 .with_body(r#"{"jsonrpc":"2.0","result":{
1294 "slot":100,
1295 "transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy","9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"]}},
1296 "meta":{"fee":5000,"preBalances":[1000000000,0],"postBalances":[999995000,0],"err":null}
1297 },"id":1}"#)
1298 .create_async()
1299 .await;
1300
1301 let client = SolanaClient::with_rpc_url(&server.url());
1302 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1303 assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1304 assert_eq!(
1305 tx.to,
1306 Some("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM".to_string())
1307 );
1308 }
1309
1310 #[tokio::test]
1311 async fn test_get_signatures() {
1312 let mut server = mockito::Server::new_async().await;
1313 let _mock = server
1314 .mock("POST", "/")
1315 .with_status(200)
1316 .with_header("content-type", "application/json")
1317 .with_body(r#"{"jsonrpc":"2.0","result":[
1318 {"signature":"5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW","slot":100,"blockTime":1700000000,"err":null},
1319 {"signature":"4VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUX","slot":101,"blockTime":1700000060,"err":{"InstructionError":[0,{"Custom":1}]}}
1320 ],"id":1}"#)
1321 .create_async()
1322 .await;
1323
1324 let client = SolanaClient::with_rpc_url(&server.url());
1325 let sigs = client.get_signatures(VALID_ADDRESS, 10).await.unwrap();
1326 assert_eq!(sigs.len(), 2);
1327 assert!(sigs[0].starts_with("5VERv8"));
1328 }
1329
1330 #[tokio::test]
1331 async fn test_get_transactions() {
1332 let mut server = mockito::Server::new_async().await;
1333 let _mock = server
1334 .mock("POST", "/")
1335 .with_status(200)
1336 .with_header("content-type", "application/json")
1337 .with_body(r#"{"jsonrpc":"2.0","result":[
1338 {"signature":"5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW","slot":100,"blockTime":1700000000,"err":null},
1339 {"signature":"4VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUX","slot":101,"blockTime":1700000060,"err":{"InstructionError":[0,{"Custom":1}]}}
1340 ],"id":1}"#)
1341 .create_async()
1342 .await;
1343
1344 let client = SolanaClient::with_rpc_url(&server.url());
1345 let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1346 assert_eq!(txs.len(), 2);
1347 assert!(txs[0].status.unwrap()); assert!(!txs[1].status.unwrap()); assert_eq!(txs[0].block_number, Some(100));
1350 assert_eq!(txs[0].timestamp, Some(1700000000));
1351 }
1352
1353 #[tokio::test]
1354 async fn test_get_token_balances() {
1355 let mut server = mockito::Server::new_async().await;
1356 let _mock = server
1357 .mock("POST", "/")
1358 .with_status(200)
1359 .with_header("content-type", "application/json")
1360 .with_body(
1361 r#"{"jsonrpc":"2.0","result":{"value":[
1362 {
1363 "pubkey":"TokenAccAddr1",
1364 "account":{
1365 "data":{
1366 "parsed":{
1367 "info":{
1368 "mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
1369 "tokenAmount":{
1370 "amount":"1000000",
1371 "decimals":6,
1372 "uiAmount":1.0,
1373 "uiAmountString":"1"
1374 }
1375 }
1376 }
1377 }
1378 }
1379 },
1380 {
1381 "pubkey":"TokenAccAddr2",
1382 "account":{
1383 "data":{
1384 "parsed":{
1385 "info":{
1386 "mint":"So11111111111111111111111111111111111111112",
1387 "tokenAmount":{
1388 "amount":"0",
1389 "decimals":9,
1390 "uiAmount":0.0,
1391 "uiAmountString":"0"
1392 }
1393 }
1394 }
1395 }
1396 }
1397 }
1398 ]},"id":1}"#,
1399 )
1400 .create_async()
1401 .await;
1402
1403 let client = SolanaClient::with_rpc_url(&server.url());
1404 let balances = client.get_token_balances(VALID_ADDRESS).await.unwrap();
1405 assert_eq!(balances.len(), 1);
1407 assert_eq!(
1408 balances[0].mint,
1409 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1410 );
1411 assert_eq!(balances[0].ui_amount, 1.0);
1412 assert_eq!(balances[0].decimals, 6);
1413 }
1414
1415 #[tokio::test]
1416 async fn test_get_token_balances_rpc_error() {
1417 let mut server = mockito::Server::new_async().await;
1418 let _mock = server
1419 .mock("POST", "/")
1420 .with_status(200)
1421 .with_header("content-type", "application/json")
1422 .with_body(
1423 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid params"},"id":1}"#,
1424 )
1425 .create_async()
1426 .await;
1427
1428 let client = SolanaClient::with_rpc_url(&server.url());
1429 let result = client.get_token_balances(VALID_ADDRESS).await;
1430 assert!(result.is_err());
1431 }
1432
1433 #[tokio::test]
1434 async fn test_get_slot() {
1435 let mut server = mockito::Server::new_async().await;
1436 let _mock = server
1437 .mock("POST", "/")
1438 .with_status(200)
1439 .with_header("content-type", "application/json")
1440 .with_body(r#"{"jsonrpc":"2.0","result":256000000,"id":1}"#)
1441 .create_async()
1442 .await;
1443
1444 let client = SolanaClient::with_rpc_url(&server.url());
1445 let slot = client.get_slot().await.unwrap();
1446 assert_eq!(slot, 256000000);
1447 }
1448
1449 #[tokio::test]
1450 async fn test_get_slot_error() {
1451 let mut server = mockito::Server::new_async().await;
1452 let _mock = server
1453 .mock("POST", "/")
1454 .with_status(200)
1455 .with_header("content-type", "application/json")
1456 .with_body(
1457 r#"{"jsonrpc":"2.0","error":{"code":-32005,"message":"Node is behind"},"id":1}"#,
1458 )
1459 .create_async()
1460 .await;
1461
1462 let client = SolanaClient::with_rpc_url(&server.url());
1463 let result = client.get_slot().await;
1464 assert!(result.is_err());
1465 }
1466
1467 #[test]
1468 fn test_validate_solana_signature_invalid_base58() {
1469 let bad_sig = "0OIl00000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
1471 let result = validate_solana_signature(bad_sig);
1472 assert!(result.is_err());
1473 }
1474
1475 #[test]
1476 fn test_validate_solana_signature_wrong_decoded_length() {
1477 let short = "11111111111111111111111111111111"; let result = validate_solana_signature(short);
1481 assert!(result.is_err());
1483 }
1484
1485 #[tokio::test]
1486 async fn test_get_transaction_rpc_error() {
1487 let mut server = mockito::Server::new_async().await;
1488 let _mock = server
1489 .mock("POST", "/")
1490 .with_status(200)
1491 .with_header("content-type", "application/json")
1492 .with_body(r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Transaction not found"},"id":1}"#)
1493 .create_async()
1494 .await;
1495
1496 let client = SolanaClient::with_rpc_url(&server.url());
1497 let result = client
1498 .get_transaction("5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW")
1499 .await;
1500 assert!(result.is_err());
1501 assert!(result.unwrap_err().to_string().contains("RPC error"));
1502 }
1503
1504 #[tokio::test]
1505 async fn test_solana_chain_client_trait_chain_name() {
1506 let client = SolanaClient::with_rpc_url("http://localhost:8899");
1507 let chain_client: &dyn ChainClient = &client;
1508 assert_eq!(chain_client.chain_name(), "solana");
1509 assert_eq!(chain_client.native_token_symbol(), "SOL");
1510 }
1511
1512 #[tokio::test]
1513 async fn test_chain_client_trait_get_balance() {
1514 let mut server = mockito::Server::new_async().await;
1515 let _mock = server
1516 .mock("POST", "/")
1517 .with_status(200)
1518 .with_header("content-type", "application/json")
1519 .with_body(
1520 r#"{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":1000000000},"id":1}"#,
1521 )
1522 .create_async()
1523 .await;
1524
1525 let client = SolanaClient::with_rpc_url(&server.url());
1526 let chain_client: &dyn ChainClient = &client;
1527 let balance = chain_client.get_balance(VALID_ADDRESS).await.unwrap();
1528 assert_eq!(balance.symbol, "SOL");
1529 }
1530
1531 #[tokio::test]
1532 async fn test_chain_client_trait_get_block_number() {
1533 let mut server = mockito::Server::new_async().await;
1534 let _mock = server
1535 .mock("POST", "/")
1536 .with_status(200)
1537 .with_header("content-type", "application/json")
1538 .with_body(r#"{"jsonrpc":"2.0","result":250000000,"id":1}"#)
1539 .create_async()
1540 .await;
1541
1542 let client = SolanaClient::with_rpc_url(&server.url());
1543 let chain_client: &dyn ChainClient = &client;
1544 let slot = chain_client.get_block_number().await.unwrap();
1545 assert_eq!(slot, 250000000);
1546 }
1547
1548 #[tokio::test]
1549 async fn test_chain_client_trait_get_token_balances() {
1550 let mut server = mockito::Server::new_async().await;
1551 let _mock = server
1552 .mock("POST", "/")
1553 .with_status(200)
1554 .with_header("content-type", "application/json")
1555 .with_body(
1556 r#"{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[
1557 {
1558 "pubkey":"TokenAccAddr1",
1559 "account":{
1560 "data":{
1561 "parsed":{
1562 "info":{
1563 "mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
1564 "tokenAmount":{
1565 "amount":"1000000",
1566 "decimals":6,
1567 "uiAmount":1.0,
1568 "uiAmountString":"1"
1569 }
1570 }
1571 }
1572 }
1573 }
1574 }
1575 ]},"id":1}"#,
1576 )
1577 .create_async()
1578 .await;
1579
1580 let client = SolanaClient::with_rpc_url(&server.url());
1581 let chain_client: &dyn ChainClient = &client;
1582 let balances = chain_client
1583 .get_token_balances(VALID_ADDRESS)
1584 .await
1585 .unwrap();
1586 assert!(!balances.is_empty());
1587 assert_eq!(
1589 balances[0].token.contract_address,
1590 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1591 );
1592 }
1593
1594 #[tokio::test]
1595 async fn test_chain_client_trait_get_transaction_solana() {
1596 let mut server = mockito::Server::new_async().await;
1597 let _mock = server
1598 .mock("POST", "/")
1599 .with_status(200)
1600 .with_header("content-type", "application/json")
1601 .with_body(
1602 r#"{"jsonrpc":"2.0","result":{
1603 "slot":200000000,
1604 "blockTime":1700000000,
1605 "meta":{
1606 "fee":5000,
1607 "preBalances":[1000000000,500000000],
1608 "postBalances":[999995000,500005000],
1609 "err":null
1610 },
1611 "transaction":{
1612 "message":{
1613 "accountKeys":[
1614 "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy",
1615 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1616 ]
1617 },
1618 "signatures":["5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh"]
1619 }
1620 },"id":1}"#,
1621 )
1622 .create_async()
1623 .await;
1624
1625 let client = SolanaClient::with_rpc_url(&server.url());
1626 let chain_client: &dyn ChainClient = &client;
1627 let tx = chain_client
1628 .get_transaction("5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh")
1629 .await
1630 .unwrap();
1631 assert!(!tx.hash.is_empty());
1632 assert!(tx.timestamp.is_some());
1633 }
1634
1635 #[tokio::test]
1636 async fn test_chain_client_trait_get_transactions_solana() {
1637 let mut server = mockito::Server::new_async().await;
1638 let _mock = server
1639 .mock("POST", "/")
1640 .with_status(200)
1641 .with_header("content-type", "application/json")
1642 .with_body(
1643 r#"{"jsonrpc":"2.0","result":[
1644 {
1645 "signature":"5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh",
1646 "slot":200000000,
1647 "blockTime":1700000000,
1648 "err":null,
1649 "memo":null
1650 }
1651 ],"id":1}"#,
1652 )
1653 .create_async()
1654 .await;
1655
1656 let client = SolanaClient::with_rpc_url(&server.url());
1657 let chain_client: &dyn ChainClient = &client;
1658 let txs = chain_client
1659 .get_transactions(VALID_ADDRESS, 10)
1660 .await
1661 .unwrap();
1662 assert!(!txs.is_empty());
1663 }
1664
1665 #[test]
1666 fn test_validate_solana_signature_wrong_byte_length() {
1667 let long_sig = "1".repeat(88); let result = validate_solana_signature(&long_sig);
1675 assert!(result.is_err());
1676 let err = result.unwrap_err().to_string();
1677 assert!(err.contains("64 bytes") || err.contains("base58"));
1678 }
1679
1680 #[tokio::test]
1681 async fn test_rpc_error_response() {
1682 let mut server = mockito::Server::new_async().await;
1683 let _mock = server
1684 .mock("POST", "/")
1685 .with_status(200)
1686 .with_header("content-type", "application/json")
1687 .with_body(
1688 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid request"},"id":1}"#,
1689 )
1690 .create_async()
1691 .await;
1692
1693 let client = SolanaClient::with_rpc_url(&server.url());
1694 let result = client.get_balance(VALID_ADDRESS).await;
1695 assert!(result.is_err());
1696 assert!(result.unwrap_err().to_string().contains("RPC error"));
1697 }
1698}