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 use crate::chains::{Balance, ChainClient};
973
974 const VALID_ADDRESS: &str = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy";
976
977 const VALID_SIGNATURE: &str =
979 "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
980
981 #[test]
982 fn test_validate_solana_address_valid() {
983 assert!(validate_solana_address(VALID_ADDRESS).is_ok());
984 }
985
986 #[test]
987 fn test_validate_solana_address_empty() {
988 let result = validate_solana_address("");
989 assert!(result.is_err());
990 assert!(result.unwrap_err().to_string().contains("empty"));
991 }
992
993 #[test]
994 fn test_validate_solana_address_too_short() {
995 let result = validate_solana_address("DRpbCBMxVnDK7maPM5t");
996 assert!(result.is_err());
997 assert!(result.unwrap_err().to_string().contains("32-44"));
998 }
999
1000 #[test]
1001 fn test_validate_solana_address_too_long() {
1002 let long_addr = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hyAAAAAAAAAAAA";
1003 let result = validate_solana_address(long_addr);
1004 assert!(result.is_err());
1005 }
1006
1007 #[test]
1008 fn test_validate_solana_address_invalid_base58() {
1009 let result = validate_solana_address("0RpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1011 assert!(result.is_err());
1012 assert!(result.unwrap_err().to_string().contains("base58"));
1013 }
1014
1015 #[test]
1016 fn test_validate_solana_address_wrong_decoded_length() {
1017 let result = validate_solana_address("abcdefghijabcdefghijabcdefghijab");
1020 assert!(result.is_err());
1021 }
1023
1024 #[test]
1025 fn test_validate_solana_signature_valid() {
1026 assert!(validate_solana_signature(VALID_SIGNATURE).is_ok());
1027 }
1028
1029 #[test]
1030 fn test_validate_solana_signature_empty() {
1031 let result = validate_solana_signature("");
1032 assert!(result.is_err());
1033 assert!(result.unwrap_err().to_string().contains("empty"));
1034 }
1035
1036 #[test]
1037 fn test_validate_solana_signature_too_short() {
1038 let result = validate_solana_signature("abc");
1039 assert!(result.is_err());
1040 assert!(result.unwrap_err().to_string().contains("80-90"));
1041 }
1042
1043 #[test]
1044 fn test_solana_client_default() {
1045 let client = SolanaClient::default();
1046 assert_eq!(client.chain_name(), "solana");
1047 assert_eq!(client.native_token_symbol(), "SOL");
1048 assert!(client.rpc_url.contains("mainnet-beta"));
1049 }
1050
1051 #[test]
1052 fn test_solana_client_with_rpc_url() {
1053 let client = SolanaClient::with_rpc_url("https://custom.rpc.com");
1054 assert_eq!(client.rpc_url, "https://custom.rpc.com");
1055 }
1056
1057 #[test]
1058 fn test_solana_client_new() {
1059 let config = ChainsConfig::default();
1060 let client = SolanaClient::new(&config);
1061 assert!(client.is_ok());
1062 }
1063
1064 #[test]
1065 fn test_solana_client_new_with_custom_rpc() {
1066 let config = ChainsConfig {
1067 solana_rpc: Some("https://my-solana-rpc.com".to_string()),
1068 ..Default::default()
1069 };
1070 let client = SolanaClient::new(&config).unwrap();
1071 assert_eq!(client.rpc_url, "https://my-solana-rpc.com");
1072 }
1073
1074 #[test]
1075 fn test_solana_client_new_with_api_key() {
1076 use std::collections::HashMap;
1077
1078 let mut api_keys = HashMap::new();
1079 api_keys.insert("solscan".to_string(), "test-key".to_string());
1080
1081 let config = ChainsConfig {
1082 api_keys,
1083 ..Default::default()
1084 };
1085
1086 let client = SolanaClient::new(&config).unwrap();
1087 assert_eq!(client.solscan_api_key, Some("test-key".to_string()));
1088 }
1089
1090 #[test]
1091 fn test_rpc_request_serialization() {
1092 let request = RpcRequest {
1093 jsonrpc: "2.0",
1094 id: 1,
1095 method: "getBalance",
1096 params: vec!["test"],
1097 };
1098
1099 let json = serde_json::to_string(&request).unwrap();
1100 assert!(json.contains("jsonrpc"));
1101 assert!(json.contains("getBalance"));
1102 }
1103
1104 #[test]
1105 fn test_rpc_response_deserialization() {
1106 let json = r#"{"jsonrpc":"2.0","result":{"value":1000000000},"id":1}"#;
1107 let response: RpcResponse<BalanceResponse> = serde_json::from_str(json).unwrap();
1108 assert!(response.result.is_some());
1109 assert_eq!(response.result.unwrap().value, 1_000_000_000);
1110 }
1111
1112 #[test]
1113 fn test_rpc_error_deserialization() {
1114 let json =
1115 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid request"},"id":1}"#;
1116 let response: RpcResponse<BalanceResponse> = serde_json::from_str(json).unwrap();
1117 assert!(response.error.is_some());
1118 let error = response.error.unwrap();
1119 assert_eq!(error.code, -32600);
1120 assert_eq!(error.message, "Invalid request");
1121 }
1122
1123 #[tokio::test]
1128 async fn test_get_balance() {
1129 let mut server = mockito::Server::new_async().await;
1130 let _mock = server
1131 .mock("POST", "/")
1132 .with_status(200)
1133 .with_header("content-type", "application/json")
1134 .with_body(r#"{"jsonrpc":"2.0","result":{"value":5000000000},"id":1}"#)
1135 .create_async()
1136 .await;
1137
1138 let client = SolanaClient::with_rpc_url(&server.url());
1139 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1140 assert_eq!(balance.raw, "5000000000");
1141 assert_eq!(balance.symbol, "SOL");
1142 assert_eq!(balance.decimals, 9);
1143 assert!(balance.formatted.contains("5.000000000"));
1144 }
1145
1146 #[tokio::test]
1147 async fn test_get_balance_zero() {
1148 let mut server = mockito::Server::new_async().await;
1149 let _mock = server
1150 .mock("POST", "/")
1151 .with_status(200)
1152 .with_header("content-type", "application/json")
1153 .with_body(r#"{"jsonrpc":"2.0","result":{"value":0},"id":1}"#)
1154 .create_async()
1155 .await;
1156
1157 let client = SolanaClient::with_rpc_url(&server.url());
1158 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1159 assert_eq!(balance.raw, "0");
1160 assert!(balance.formatted.contains("0.000000000"));
1161 }
1162
1163 #[tokio::test]
1164 async fn test_get_balance_rpc_error() {
1165 let mut server = mockito::Server::new_async().await;
1166 let _mock = server
1167 .mock("POST", "/")
1168 .with_status(200)
1169 .with_header("content-type", "application/json")
1170 .with_body(
1171 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid params"},"id":1}"#,
1172 )
1173 .create_async()
1174 .await;
1175
1176 let client = SolanaClient::with_rpc_url(&server.url());
1177 let result = client.get_balance(VALID_ADDRESS).await;
1178 assert!(result.is_err());
1179 assert!(result.unwrap_err().to_string().contains("RPC error"));
1180 }
1181
1182 #[tokio::test]
1183 async fn test_get_balance_empty_response() {
1184 let mut server = mockito::Server::new_async().await;
1185 let _mock = server
1186 .mock("POST", "/")
1187 .with_status(200)
1188 .with_header("content-type", "application/json")
1189 .with_body(r#"{"jsonrpc":"2.0","id":1}"#)
1190 .create_async()
1191 .await;
1192
1193 let client = SolanaClient::with_rpc_url(&server.url());
1194 let result = client.get_balance(VALID_ADDRESS).await;
1195 assert!(result.is_err());
1196 assert!(result.unwrap_err().to_string().contains("Empty RPC"));
1197 }
1198
1199 #[tokio::test]
1200 async fn test_get_balance_invalid_address() {
1201 let client = SolanaClient::default();
1202 let result = client.get_balance("invalid").await;
1203 assert!(result.is_err());
1204 }
1205
1206 #[tokio::test]
1207 async fn test_get_transaction() {
1208 let mut server = mockito::Server::new_async().await;
1209 let _mock = server
1210 .mock("POST", "/")
1211 .with_status(200)
1212 .with_header("content-type", "application/json")
1213 .with_body(
1214 r#"{"jsonrpc":"2.0","result":{
1215 "slot":123456789,
1216 "blockTime":1700000000,
1217 "transaction":{
1218 "message":{
1219 "accountKeys":[
1220 {"pubkey":"DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy","signer":true},
1221 {"pubkey":"9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM","signer":false}
1222 ]
1223 }
1224 },
1225 "meta":{
1226 "fee":5000,
1227 "preBalances":[10000000000,5000000000],
1228 "postBalances":[8999995000,6000000000],
1229 "err":null
1230 }
1231 },"id":1}"#,
1232 )
1233 .create_async()
1234 .await;
1235
1236 let client = SolanaClient::with_rpc_url(&server.url());
1237 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1238 assert_eq!(tx.hash, VALID_SIGNATURE);
1239 assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1240 assert_eq!(
1241 tx.to,
1242 Some("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM".to_string())
1243 );
1244 assert_eq!(tx.block_number, Some(123456789));
1245 assert_eq!(tx.timestamp, Some(1700000000));
1246 assert!(tx.status.unwrap()); assert_eq!(tx.gas_price, "5000"); }
1249
1250 #[tokio::test]
1251 async fn test_get_transaction_failed() {
1252 let mut server = mockito::Server::new_async().await;
1253 let _mock = server
1254 .mock("POST", "/")
1255 .with_status(200)
1256 .with_header("content-type", "application/json")
1257 .with_body(r#"{"jsonrpc":"2.0","result":{
1258 "slot":100,
1259 "transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"]}},
1260 "meta":{"fee":5000,"preBalances":[1000],"postBalances":[1000],"err":{"InstructionError":[0,{"Custom":1}]}}
1261 },"id":1}"#)
1262 .create_async()
1263 .await;
1264
1265 let client = SolanaClient::with_rpc_url(&server.url());
1266 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1267 assert!(!tx.status.unwrap()); }
1269
1270 #[tokio::test]
1271 async fn test_get_transaction_not_found() {
1272 let mut server = mockito::Server::new_async().await;
1273 let _mock = server
1274 .mock("POST", "/")
1275 .with_status(200)
1276 .with_header("content-type", "application/json")
1277 .with_body(r#"{"jsonrpc":"2.0","result":null,"id":1}"#)
1278 .create_async()
1279 .await;
1280
1281 let client = SolanaClient::with_rpc_url(&server.url());
1282 let result = client.get_transaction(VALID_SIGNATURE).await;
1283 assert!(result.is_err());
1284 assert!(result.unwrap_err().to_string().contains("not found"));
1285 }
1286
1287 #[tokio::test]
1288 async fn test_get_transaction_string_account_keys() {
1289 let mut server = mockito::Server::new_async().await;
1290 let _mock = server
1291 .mock("POST", "/")
1292 .with_status(200)
1293 .with_header("content-type", "application/json")
1294 .with_body(r#"{"jsonrpc":"2.0","result":{
1295 "slot":100,
1296 "transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy","9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"]}},
1297 "meta":{"fee":5000,"preBalances":[1000000000,0],"postBalances":[999995000,0],"err":null}
1298 },"id":1}"#)
1299 .create_async()
1300 .await;
1301
1302 let client = SolanaClient::with_rpc_url(&server.url());
1303 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1304 assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1305 assert_eq!(
1306 tx.to,
1307 Some("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM".to_string())
1308 );
1309 }
1310
1311 #[tokio::test]
1312 async fn test_get_signatures() {
1313 let mut server = mockito::Server::new_async().await;
1314 let _mock = server
1315 .mock("POST", "/")
1316 .with_status(200)
1317 .with_header("content-type", "application/json")
1318 .with_body(r#"{"jsonrpc":"2.0","result":[
1319 {"signature":"5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW","slot":100,"blockTime":1700000000,"err":null},
1320 {"signature":"4VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUX","slot":101,"blockTime":1700000060,"err":{"InstructionError":[0,{"Custom":1}]}}
1321 ],"id":1}"#)
1322 .create_async()
1323 .await;
1324
1325 let client = SolanaClient::with_rpc_url(&server.url());
1326 let sigs = client.get_signatures(VALID_ADDRESS, 10).await.unwrap();
1327 assert_eq!(sigs.len(), 2);
1328 assert!(sigs[0].starts_with("5VERv8"));
1329 }
1330
1331 #[tokio::test]
1332 async fn test_get_transactions() {
1333 let mut server = mockito::Server::new_async().await;
1334 let _mock = server
1335 .mock("POST", "/")
1336 .with_status(200)
1337 .with_header("content-type", "application/json")
1338 .with_body(r#"{"jsonrpc":"2.0","result":[
1339 {"signature":"5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW","slot":100,"blockTime":1700000000,"err":null},
1340 {"signature":"4VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUX","slot":101,"blockTime":1700000060,"err":{"InstructionError":[0,{"Custom":1}]}}
1341 ],"id":1}"#)
1342 .create_async()
1343 .await;
1344
1345 let client = SolanaClient::with_rpc_url(&server.url());
1346 let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1347 assert_eq!(txs.len(), 2);
1348 assert!(txs[0].status.unwrap()); assert!(!txs[1].status.unwrap()); assert_eq!(txs[0].block_number, Some(100));
1351 assert_eq!(txs[0].timestamp, Some(1700000000));
1352 }
1353
1354 #[tokio::test]
1355 async fn test_get_token_balances() {
1356 let mut server = mockito::Server::new_async().await;
1357 let _mock = server
1358 .mock("POST", "/")
1359 .with_status(200)
1360 .with_header("content-type", "application/json")
1361 .with_body(
1362 r#"{"jsonrpc":"2.0","result":{"value":[
1363 {
1364 "pubkey":"TokenAccAddr1",
1365 "account":{
1366 "data":{
1367 "parsed":{
1368 "info":{
1369 "mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
1370 "tokenAmount":{
1371 "amount":"1000000",
1372 "decimals":6,
1373 "uiAmount":1.0,
1374 "uiAmountString":"1"
1375 }
1376 }
1377 }
1378 }
1379 }
1380 },
1381 {
1382 "pubkey":"TokenAccAddr2",
1383 "account":{
1384 "data":{
1385 "parsed":{
1386 "info":{
1387 "mint":"So11111111111111111111111111111111111111112",
1388 "tokenAmount":{
1389 "amount":"0",
1390 "decimals":9,
1391 "uiAmount":0.0,
1392 "uiAmountString":"0"
1393 }
1394 }
1395 }
1396 }
1397 }
1398 }
1399 ]},"id":1}"#,
1400 )
1401 .create_async()
1402 .await;
1403
1404 let client = SolanaClient::with_rpc_url(&server.url());
1405 let balances = client.get_token_balances(VALID_ADDRESS).await.unwrap();
1406 assert_eq!(balances.len(), 1);
1408 assert_eq!(
1409 balances[0].mint,
1410 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1411 );
1412 assert_eq!(balances[0].ui_amount, 1.0);
1413 assert_eq!(balances[0].decimals, 6);
1414 }
1415
1416 #[tokio::test]
1417 async fn test_get_token_balances_rpc_error() {
1418 let mut server = mockito::Server::new_async().await;
1419 let _mock = server
1420 .mock("POST", "/")
1421 .with_status(200)
1422 .with_header("content-type", "application/json")
1423 .with_body(
1424 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid params"},"id":1}"#,
1425 )
1426 .create_async()
1427 .await;
1428
1429 let client = SolanaClient::with_rpc_url(&server.url());
1430 let result = client.get_token_balances(VALID_ADDRESS).await;
1431 assert!(result.is_err());
1432 }
1433
1434 #[tokio::test]
1435 async fn test_get_slot() {
1436 let mut server = mockito::Server::new_async().await;
1437 let _mock = server
1438 .mock("POST", "/")
1439 .with_status(200)
1440 .with_header("content-type", "application/json")
1441 .with_body(r#"{"jsonrpc":"2.0","result":256000000,"id":1}"#)
1442 .create_async()
1443 .await;
1444
1445 let client = SolanaClient::with_rpc_url(&server.url());
1446 let slot = client.get_slot().await.unwrap();
1447 assert_eq!(slot, 256000000);
1448 }
1449
1450 #[tokio::test]
1451 async fn test_get_slot_error() {
1452 let mut server = mockito::Server::new_async().await;
1453 let _mock = server
1454 .mock("POST", "/")
1455 .with_status(200)
1456 .with_header("content-type", "application/json")
1457 .with_body(
1458 r#"{"jsonrpc":"2.0","error":{"code":-32005,"message":"Node is behind"},"id":1}"#,
1459 )
1460 .create_async()
1461 .await;
1462
1463 let client = SolanaClient::with_rpc_url(&server.url());
1464 let result = client.get_slot().await;
1465 assert!(result.is_err());
1466 }
1467
1468 #[test]
1469 fn test_validate_solana_signature_invalid_base58() {
1470 let bad_sig = "0OIl00000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
1472 let result = validate_solana_signature(bad_sig);
1473 assert!(result.is_err());
1474 }
1475
1476 #[test]
1477 fn test_validate_solana_signature_wrong_decoded_length() {
1478 let short = "11111111111111111111111111111111"; let result = validate_solana_signature(short);
1482 assert!(result.is_err());
1484 }
1485
1486 #[tokio::test]
1487 async fn test_get_transaction_rpc_error() {
1488 let mut server = mockito::Server::new_async().await;
1489 let _mock = server
1490 .mock("POST", "/")
1491 .with_status(200)
1492 .with_header("content-type", "application/json")
1493 .with_body(r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Transaction not found"},"id":1}"#)
1494 .create_async()
1495 .await;
1496
1497 let client = SolanaClient::with_rpc_url(&server.url());
1498 let result = client
1499 .get_transaction("5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW")
1500 .await;
1501 assert!(result.is_err());
1502 assert!(result.unwrap_err().to_string().contains("RPC error"));
1503 }
1504
1505 #[tokio::test]
1506 async fn test_solana_chain_client_trait_chain_name() {
1507 let client = SolanaClient::with_rpc_url("http://localhost:8899");
1508 let chain_client: &dyn ChainClient = &client;
1509 assert_eq!(chain_client.chain_name(), "solana");
1510 assert_eq!(chain_client.native_token_symbol(), "SOL");
1511 }
1512
1513 #[tokio::test]
1514 async fn test_chain_client_trait_get_balance() {
1515 let mut server = mockito::Server::new_async().await;
1516 let _mock = server
1517 .mock("POST", "/")
1518 .with_status(200)
1519 .with_header("content-type", "application/json")
1520 .with_body(
1521 r#"{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":1000000000},"id":1}"#,
1522 )
1523 .create_async()
1524 .await;
1525
1526 let client = SolanaClient::with_rpc_url(&server.url());
1527 let chain_client: &dyn ChainClient = &client;
1528 let balance = chain_client.get_balance(VALID_ADDRESS).await.unwrap();
1529 assert_eq!(balance.symbol, "SOL");
1530 }
1531
1532 #[tokio::test]
1533 async fn test_chain_client_trait_get_block_number() {
1534 let mut server = mockito::Server::new_async().await;
1535 let _mock = server
1536 .mock("POST", "/")
1537 .with_status(200)
1538 .with_header("content-type", "application/json")
1539 .with_body(r#"{"jsonrpc":"2.0","result":250000000,"id":1}"#)
1540 .create_async()
1541 .await;
1542
1543 let client = SolanaClient::with_rpc_url(&server.url());
1544 let chain_client: &dyn ChainClient = &client;
1545 let slot = chain_client.get_block_number().await.unwrap();
1546 assert_eq!(slot, 250000000);
1547 }
1548
1549 #[tokio::test]
1550 async fn test_chain_client_trait_get_token_balances() {
1551 let mut server = mockito::Server::new_async().await;
1552 let _mock = server
1553 .mock("POST", "/")
1554 .with_status(200)
1555 .with_header("content-type", "application/json")
1556 .with_body(
1557 r#"{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[
1558 {
1559 "pubkey":"TokenAccAddr1",
1560 "account":{
1561 "data":{
1562 "parsed":{
1563 "info":{
1564 "mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
1565 "tokenAmount":{
1566 "amount":"1000000",
1567 "decimals":6,
1568 "uiAmount":1.0,
1569 "uiAmountString":"1"
1570 }
1571 }
1572 }
1573 }
1574 }
1575 }
1576 ]},"id":1}"#,
1577 )
1578 .create_async()
1579 .await;
1580
1581 let client = SolanaClient::with_rpc_url(&server.url());
1582 let chain_client: &dyn ChainClient = &client;
1583 let balances = chain_client
1584 .get_token_balances(VALID_ADDRESS)
1585 .await
1586 .unwrap();
1587 assert!(!balances.is_empty());
1588 assert_eq!(
1590 balances[0].token.contract_address,
1591 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1592 );
1593 }
1594
1595 #[tokio::test]
1596 async fn test_chain_client_trait_get_transaction_solana() {
1597 let mut server = mockito::Server::new_async().await;
1598 let _mock = server
1599 .mock("POST", "/")
1600 .with_status(200)
1601 .with_header("content-type", "application/json")
1602 .with_body(
1603 r#"{"jsonrpc":"2.0","result":{
1604 "slot":200000000,
1605 "blockTime":1700000000,
1606 "meta":{
1607 "fee":5000,
1608 "preBalances":[1000000000,500000000],
1609 "postBalances":[999995000,500005000],
1610 "err":null
1611 },
1612 "transaction":{
1613 "message":{
1614 "accountKeys":[
1615 "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy",
1616 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1617 ]
1618 },
1619 "signatures":["5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh"]
1620 }
1621 },"id":1}"#,
1622 )
1623 .create_async()
1624 .await;
1625
1626 let client = SolanaClient::with_rpc_url(&server.url());
1627 let chain_client: &dyn ChainClient = &client;
1628 let tx = chain_client
1629 .get_transaction("5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh")
1630 .await
1631 .unwrap();
1632 assert!(!tx.hash.is_empty());
1633 assert!(tx.timestamp.is_some());
1634 }
1635
1636 #[tokio::test]
1637 async fn test_chain_client_trait_get_transactions_solana() {
1638 let mut server = mockito::Server::new_async().await;
1639 let _mock = server
1640 .mock("POST", "/")
1641 .with_status(200)
1642 .with_header("content-type", "application/json")
1643 .with_body(
1644 r#"{"jsonrpc":"2.0","result":[
1645 {
1646 "signature":"5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh",
1647 "slot":200000000,
1648 "blockTime":1700000000,
1649 "err":null,
1650 "memo":null
1651 }
1652 ],"id":1}"#,
1653 )
1654 .create_async()
1655 .await;
1656
1657 let client = SolanaClient::with_rpc_url(&server.url());
1658 let chain_client: &dyn ChainClient = &client;
1659 let txs = chain_client
1660 .get_transactions(VALID_ADDRESS, 10)
1661 .await
1662 .unwrap();
1663 assert!(!txs.is_empty());
1664 }
1665
1666 #[test]
1667 fn test_validate_solana_signature_wrong_byte_length() {
1668 let long_sig = "1".repeat(88); let result = validate_solana_signature(&long_sig);
1676 assert!(result.is_err());
1677 let err = result.unwrap_err().to_string();
1678 assert!(err.contains("64 bytes") || err.contains("base58"));
1679 }
1680
1681 #[tokio::test]
1682 async fn test_rpc_error_response() {
1683 let mut server = mockito::Server::new_async().await;
1684 let _mock = server
1685 .mock("POST", "/")
1686 .with_status(200)
1687 .with_header("content-type", "application/json")
1688 .with_body(
1689 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid request"},"id":1}"#,
1690 )
1691 .create_async()
1692 .await;
1693
1694 let client = SolanaClient::with_rpc_url(&server.url());
1695 let result = client.get_balance(VALID_ADDRESS).await;
1696 assert!(result.is_err());
1697 assert!(result.unwrap_err().to_string().contains("RPC error"));
1698 }
1699
1700 #[tokio::test]
1705 async fn test_get_token_info_success() {
1706 let mut mint_data = vec![0u8; 41];
1709 mint_data[40] = 6;
1710 let data_b64 =
1711 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &mint_data);
1712
1713 let mut server = mockito::Server::new_async().await;
1714 let _mock = server
1715 .mock("POST", "/")
1716 .with_status(200)
1717 .with_header("content-type", "application/json")
1718 .with_body(format!(
1719 r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
1720 data_b64
1721 ))
1722 .create_async()
1723 .await;
1724
1725 let client = SolanaClient::with_rpc_url(&server.url());
1726 let token = client
1727 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1728 .await
1729 .unwrap();
1730 assert_eq!(token.decimals, 6);
1731 assert_eq!(
1732 token.contract_address,
1733 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1734 );
1735 assert!(token.symbol.starts_with("EPjFWdd5"));
1736 assert!(token.symbol.ends_with("..."));
1737 assert_eq!(token.name, "SPL Token");
1738 }
1739
1740 #[tokio::test]
1741 async fn test_get_token_info_decimals_nine() {
1742 let mut mint_data = vec![0u8; 41];
1743 mint_data[40] = 9;
1744 let data_b64 =
1745 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &mint_data);
1746
1747 let mut server = mockito::Server::new_async().await;
1748 let _mock = server
1749 .mock("POST", "/")
1750 .with_status(200)
1751 .with_header("content-type", "application/json")
1752 .with_body(format!(
1753 r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
1754 data_b64
1755 ))
1756 .create_async()
1757 .await;
1758
1759 let client = SolanaClient::with_rpc_url(&server.url());
1760 let token = client
1761 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1762 .await
1763 .unwrap();
1764 assert_eq!(token.decimals, 9);
1765 assert_eq!(token.symbol, "EPjFWdd5..."); }
1767
1768 #[tokio::test]
1769 async fn test_get_token_info_rpc_error() {
1770 let mut server = mockito::Server::new_async().await;
1771 let _mock = server
1772 .mock("POST", "/")
1773 .with_status(200)
1774 .with_header("content-type", "application/json")
1775 .with_body(
1776 r#"{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params"},"id":1}"#,
1777 )
1778 .create_async()
1779 .await;
1780
1781 let client = SolanaClient::with_rpc_url(&server.url());
1782 let result = client
1783 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1784 .await;
1785 assert!(result.is_err());
1786 assert!(result.unwrap_err().to_string().contains("RPC error"));
1787 }
1788
1789 #[tokio::test]
1790 async fn test_get_token_info_not_found() {
1791 let mut server = mockito::Server::new_async().await;
1792 let _mock = server
1793 .mock("POST", "/")
1794 .with_status(200)
1795 .with_header("content-type", "application/json")
1796 .with_body(r#"{"jsonrpc":"2.0","result":{"value":null},"id":1}"#)
1797 .create_async()
1798 .await;
1799
1800 let client = SolanaClient::with_rpc_url(&server.url());
1801 let result = client
1802 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1803 .await;
1804 assert!(result.is_err());
1805 assert!(result.unwrap_err().to_string().contains("not found"));
1806 }
1807
1808 #[tokio::test]
1809 async fn test_get_token_info_no_account_data() {
1810 let mut server = mockito::Server::new_async().await;
1811 let _mock = server
1812 .mock("POST", "/")
1813 .with_status(200)
1814 .with_header("content-type", "application/json")
1815 .with_body(r#"{"jsonrpc":"2.0","result":{"value":{"data":null}},"id":1}"#)
1816 .create_async()
1817 .await;
1818
1819 let client = SolanaClient::with_rpc_url(&server.url());
1820 let result = client
1821 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1822 .await;
1823 assert!(result.is_err());
1824 assert!(result.unwrap_err().to_string().contains("No account data"));
1825 }
1826
1827 #[tokio::test]
1828 async fn test_get_token_info_empty_data_array() {
1829 let mut server = mockito::Server::new_async().await;
1830 let _mock = server
1831 .mock("POST", "/")
1832 .with_status(200)
1833 .with_header("content-type", "application/json")
1834 .with_body(r#"{"jsonrpc":"2.0","result":{"value":{"data":[]}},"id":1}"#)
1835 .create_async()
1836 .await;
1837
1838 let client = SolanaClient::with_rpc_url(&server.url());
1839 let result = client
1840 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1841 .await;
1842 assert!(result.is_err());
1843 }
1844
1845 #[tokio::test]
1846 async fn test_get_token_info_invalid_base64() {
1847 let mut server = mockito::Server::new_async().await;
1848 let _mock = server
1849 .mock("POST", "/")
1850 .with_status(200)
1851 .with_header("content-type", "application/json")
1852 .with_body(r#"{"jsonrpc":"2.0","result":{"value":{"data":["!!!invalid!!!"]}},"id":1}"#)
1853 .create_async()
1854 .await;
1855
1856 let client = SolanaClient::with_rpc_url(&server.url());
1857 let result = client
1858 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1859 .await;
1860 assert!(result.is_err());
1861 assert!(result.unwrap_err().to_string().contains("decode"));
1862 }
1863
1864 #[tokio::test]
1865 async fn test_get_token_info_data_too_short() {
1866 let short_data = vec![0u8; 20];
1868 let data_b64 =
1869 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &short_data);
1870
1871 let mut server = mockito::Server::new_async().await;
1872 let _mock = server
1873 .mock("POST", "/")
1874 .with_status(200)
1875 .with_header("content-type", "application/json")
1876 .with_body(format!(
1877 r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
1878 data_b64
1879 ))
1880 .create_async()
1881 .await;
1882
1883 let client = SolanaClient::with_rpc_url(&server.url());
1884 let result = client
1885 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1886 .await;
1887 assert!(result.is_err());
1888 assert!(result.unwrap_err().to_string().contains("too short"));
1889 }
1890
1891 #[tokio::test]
1892 async fn test_get_token_info_invalid_address() {
1893 let client = SolanaClient::default();
1894 let result = client.get_token_info("bad").await;
1895 assert!(result.is_err());
1896 }
1897
1898 #[tokio::test]
1903 async fn test_get_transaction_minimal_no_transaction_meta() {
1904 let mut server = mockito::Server::new_async().await;
1905 let _mock = server
1906 .mock("POST", "/")
1907 .with_status(200)
1908 .with_header("content-type", "application/json")
1909 .with_body(r#"{"jsonrpc":"2.0","result":{"slot":100},"id":1}"#)
1910 .create_async()
1911 .await;
1912
1913 let client = SolanaClient::with_rpc_url(&server.url());
1914 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1915 assert_eq!(tx.hash, VALID_SIGNATURE);
1916 assert_eq!(tx.from, "");
1917 assert_eq!(tx.to, None);
1918 assert_eq!(tx.value, "0");
1919 assert_eq!(tx.gas_price, "0");
1920 assert_eq!(tx.block_number, Some(100));
1921 assert_eq!(tx.status, None);
1922 }
1923
1924 #[tokio::test]
1925 async fn test_get_transaction_single_account_key_to_none() {
1926 let mut server = mockito::Server::new_async().await;
1927 let _mock = server
1928 .mock("POST", "/")
1929 .with_status(200)
1930 .with_header("content-type", "application/json")
1931 .with_body(r#"{"jsonrpc":"2.0","result":{
1932 "slot":100,
1933 "blockTime":1700000000,
1934 "transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"]}},
1935 "meta":{"fee":5000,"preBalances":[1000],"postBalances":[500],"err":null}
1936 },"id":1}"#)
1937 .create_async()
1938 .await;
1939
1940 let client = SolanaClient::with_rpc_url(&server.url());
1941 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1942 assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1943 assert_eq!(tx.to, None); assert_eq!(tx.value, "0"); }
1946
1947 #[tokio::test]
1948 async fn test_get_transaction_value_zero_when_no_send() {
1949 let mut server = mockito::Server::new_async().await;
1950 let _mock = server
1951 .mock("POST", "/")
1952 .with_status(200)
1953 .with_header("content-type", "application/json")
1954 .with_body(
1955 r#"{"jsonrpc":"2.0","result":{
1956 "slot":100,
1957 "transaction":{"message":{"accountKeys":["A","B"]}},
1958 "meta":{"fee":5000,"preBalances":[1000,500],"postBalances":[1000,500],"err":null}
1959 },"id":1}"#,
1960 )
1961 .create_async()
1962 .await;
1963
1964 let client = SolanaClient::with_rpc_url(&server.url());
1965 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1966 assert_eq!(tx.value, "0"); }
1968
1969 #[tokio::test]
1970 async fn test_get_transaction_meta_no_fee() {
1971 let mut server = mockito::Server::new_async().await;
1972 let _mock = server
1973 .mock("POST", "/")
1974 .with_status(200)
1975 .with_header("content-type", "application/json")
1976 .with_body(r#"{"jsonrpc":"2.0","result":{
1977 "slot":100,
1978 "transaction":{"message":{"accountKeys":["A","B"]}},
1979 "meta":{"preBalances":[10000000000,0],"postBalances":[8999995000,1000005000],"err":null}
1980 },"id":1}"#)
1981 .create_async()
1982 .await;
1983
1984 let client = SolanaClient::with_rpc_url(&server.url());
1985 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1986 assert_eq!(tx.gas_price, "0"); }
1988
1989 #[tokio::test]
1990 async fn test_get_transaction_invalid_signature() {
1991 let client = SolanaClient::default();
1992 let result = client.get_transaction("invalid").await;
1993 assert!(result.is_err());
1994 }
1995
1996 #[tokio::test]
2001 async fn test_get_signatures_empty_response() {
2002 let mut server = mockito::Server::new_async().await;
2003 let _mock = server
2004 .mock("POST", "/")
2005 .with_status(200)
2006 .with_header("content-type", "application/json")
2007 .with_body(r#"{"jsonrpc":"2.0","id":1}"#)
2008 .create_async()
2009 .await;
2010
2011 let client = SolanaClient::with_rpc_url(&server.url());
2012 let result = client.get_signatures(VALID_ADDRESS, 10).await;
2013 assert!(result.is_err());
2014 assert!(result.unwrap_err().to_string().contains("Empty RPC"));
2015 }
2016
2017 #[tokio::test]
2018 async fn test_get_signatures_invalid_address() {
2019 let client = SolanaClient::default();
2020 let result = client.get_signatures("x", 10).await;
2021 assert!(result.is_err());
2022 }
2023
2024 #[tokio::test]
2025 async fn test_get_signatures_rpc_error() {
2026 let mut server = mockito::Server::new_async().await;
2027 let _mock = server
2028 .mock("POST", "/")
2029 .with_status(200)
2030 .with_header("content-type", "application/json")
2031 .with_body(
2032 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid address"},"id":1}"#,
2033 )
2034 .create_async()
2035 .await;
2036
2037 let client = SolanaClient::with_rpc_url(&server.url());
2038 let result = client.get_signatures(VALID_ADDRESS, 10).await;
2039 assert!(result.is_err());
2040 }
2041
2042 #[tokio::test]
2043 async fn test_get_transactions_invalid_address() {
2044 let client = SolanaClient::default();
2045 let result = client.get_transactions("invalid-addr", 10).await;
2046 assert!(result.is_err());
2047 }
2048
2049 #[tokio::test]
2054 async fn test_get_slot_empty_response() {
2055 let mut server = mockito::Server::new_async().await;
2056 let _mock = server
2057 .mock("POST", "/")
2058 .with_status(200)
2059 .with_header("content-type", "application/json")
2060 .with_body(r#"{"jsonrpc":"2.0","id":1}"#)
2061 .create_async()
2062 .await;
2063
2064 let client = SolanaClient::with_rpc_url(&server.url());
2065 let result = client.get_slot().await;
2066 assert!(result.is_err());
2067 assert!(result.unwrap_err().to_string().contains("Empty RPC"));
2068 }
2069
2070 #[tokio::test]
2075 async fn test_get_token_balances_ui_amount_null() {
2076 let mut server = mockito::Server::new_async().await;
2077 let _mock = server
2078 .mock("POST", "/")
2079 .with_status(200)
2080 .with_header("content-type", "application/json")
2081 .with_body(
2082 r#"{"jsonrpc":"2.0","result":{"value":[{
2083 "pubkey":"TokenAccAddr1",
2084 "account":{
2085 "data":{
2086 "parsed":{
2087 "info":{
2088 "mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
2089 "tokenAmount":{
2090 "amount":"0",
2091 "decimals":6,
2092 "uiAmount":null,
2093 "uiAmountString":"0"
2094 }
2095 }
2096 }
2097 }
2098 }
2099 }]},"id":1}"#,
2100 )
2101 .create_async()
2102 .await;
2103
2104 let client = SolanaClient::with_rpc_url(&server.url());
2105 let balances = client.get_token_balances(VALID_ADDRESS).await.unwrap();
2106 assert_eq!(balances.len(), 0);
2108 }
2109
2110 #[test]
2111 fn test_token_balance_serialization() {
2112 let tb = TokenBalance {
2113 mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
2114 token_account: "TokenAcc1".to_string(),
2115 raw_amount: "1000000".to_string(),
2116 ui_amount: 1.5,
2117 decimals: 6,
2118 symbol: Some("USDC".to_string()),
2119 name: Some("USD Coin".to_string()),
2120 };
2121 let json = serde_json::to_string(&tb).unwrap();
2122 assert!(json.contains("USDC"));
2123 assert!(json.contains("1000000"));
2124 }
2125
2126 #[tokio::test]
2127 async fn test_chain_client_get_token_balances_short_mint() {
2128 let mut server = mockito::Server::new_async().await;
2129 let _mock = server
2130 .mock("POST", "/")
2131 .with_status(200)
2132 .with_header("content-type", "application/json")
2133 .with_body(
2134 r#"{"jsonrpc":"2.0","result":{"value":[{
2135 "pubkey":"TokenAcc1",
2136 "account":{
2137 "data":{
2138 "parsed":{
2139 "info":{
2140 "mint":"Short",
2141 "tokenAmount":{
2142 "amount":"100",
2143 "decimals":2,
2144 "uiAmount":1.0,
2145 "uiAmountString":"1"
2146 }
2147 }
2148 }
2149 }
2150 }
2151 }]},"id":1}"#,
2152 )
2153 .create_async()
2154 .await;
2155
2156 let client = SolanaClient::with_rpc_url(&server.url());
2157 let chain_client: &dyn ChainClient = &client;
2158 let balances = chain_client
2159 .get_token_balances("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy")
2160 .await
2161 .unwrap();
2162 assert_eq!(balances.len(), 1);
2163 assert_eq!(balances[0].token.symbol, "Short");
2165 }
2166
2167 #[test]
2172 fn test_validate_solana_signature_too_long() {
2173 let too_long = "1".repeat(91);
2174 let result = validate_solana_signature(&too_long);
2175 assert!(result.is_err());
2176 assert!(result.unwrap_err().to_string().contains("80-90"));
2177 }
2178
2179 #[tokio::test]
2184 async fn test_enrich_balance_usd_invalid_raw_does_not_panic() {
2185 let client = SolanaClient::default();
2186 let mut balance = Balance {
2187 raw: "not-a-number".to_string(),
2188 formatted: "0 SOL".to_string(),
2189 decimals: 9,
2190 symbol: "SOL".to_string(),
2191 usd_value: None,
2192 };
2193 client.enrich_balance_usd(&mut balance).await;
2195 assert!(balance.usd_value.is_none() || balance.usd_value == Some(0.0));
2197 }
2198
2199 #[tokio::test]
2200 async fn test_chain_client_trait_enrich_balance_usd() {
2201 let client = SolanaClient::default();
2202 let chain_client: &dyn ChainClient = &client;
2203 let mut balance = Balance {
2204 raw: "not-a-number".to_string(),
2205 formatted: "0 SOL".to_string(),
2206 decimals: 9,
2207 symbol: "SOL".to_string(),
2208 usd_value: None,
2209 };
2210 chain_client.enrich_balance_usd(&mut balance).await;
2211 assert!(balance.usd_value.is_none() || balance.usd_value == Some(0.0));
2212 }
2213
2214 #[tokio::test]
2215 async fn test_chain_client_trait_get_token_info() {
2216 let mut mint_data = vec![0u8; 41];
2217 mint_data[40] = 18;
2218 let data_b64 =
2219 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &mint_data);
2220
2221 let mut server = mockito::Server::new_async().await;
2222 let _mock = server
2223 .mock("POST", "/")
2224 .with_status(200)
2225 .with_header("content-type", "application/json")
2226 .with_body(format!(
2227 r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
2228 data_b64
2229 ))
2230 .create_async()
2231 .await;
2232
2233 let client = SolanaClient::with_rpc_url(&server.url());
2234 let chain_client: &dyn ChainClient = &client;
2235 let token = chain_client
2236 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
2237 .await
2238 .unwrap();
2239 assert_eq!(token.decimals, 18);
2240 }
2241
2242 #[test]
2247 fn test_solscan_account_info_deserialization() {
2248 let json = r#"{"lamports":1000000,"type":"account"}"#;
2249 let info: SolscanAccountInfo = serde_json::from_str(json).unwrap();
2250 assert_eq!(info.lamports, 1000000);
2251 assert_eq!(info.account_type, Some("account".to_string()));
2252 }
2253}