1use crate::chains::{Balance, ChainClient, Token, Transaction};
34use crate::config::ChainsConfig;
35use crate::error::{Result, ScopeError};
36use crate::http::{HttpClient, Request};
37use async_trait::async_trait;
38use serde::{Deserialize, Serialize};
39use std::sync::Arc;
40
41const DEFAULT_SOLANA_RPC: &str = "https://api.mainnet-beta.solana.com";
43
44#[allow(dead_code)] const SOLSCAN_API_URL: &str = "https://api.solscan.io";
47
48const SOL_DECIMALS: u8 = 9;
50
51#[derive(Clone)]
56pub struct SolanaClient {
57 http: Arc<dyn HttpClient>,
59
60 rpc_url: String,
62
63 #[allow(dead_code)] solscan_api_key: Option<String>,
66}
67
68#[derive(Debug, Serialize)]
70struct RpcRequest<'a, T: Serialize> {
71 jsonrpc: &'a str,
72 id: u64,
73 method: &'a str,
74 params: T,
75}
76
77#[derive(Debug, Deserialize)]
79struct RpcResponse<T> {
80 result: Option<T>,
81 error: Option<RpcError>,
82}
83
84#[derive(Debug, Deserialize)]
86struct RpcError {
87 code: i64,
88 message: String,
89}
90
91#[derive(Debug, Deserialize)]
93struct BalanceResponse {
94 value: u64,
95}
96
97#[derive(Debug, Deserialize)]
99struct TokenAccountsResponse {
100 value: Vec<TokenAccountInfo>,
101}
102
103#[derive(Debug, Deserialize)]
105struct TokenAccountInfo {
106 pubkey: String,
107 account: TokenAccountData,
108}
109
110#[derive(Debug, Deserialize)]
112struct TokenAccountData {
113 data: TokenAccountParsedData,
114}
115
116#[derive(Debug, Deserialize)]
118struct TokenAccountParsedData {
119 parsed: TokenAccountParsedInfo,
120}
121
122#[derive(Debug, Deserialize)]
124struct TokenAccountParsedInfo {
125 info: TokenInfo,
126}
127
128#[derive(Debug, Deserialize)]
130#[serde(rename_all = "camelCase")]
131struct TokenInfo {
132 mint: String,
133 token_amount: TokenAmount,
134}
135
136#[derive(Debug, Deserialize)]
138#[serde(rename_all = "camelCase")]
139#[allow(dead_code)] struct TokenAmount {
141 amount: String,
142 decimals: u8,
143 ui_amount: Option<f64>,
144 ui_amount_string: String,
145}
146
147#[derive(Debug, Clone, Serialize)]
149pub struct TokenBalance {
150 pub mint: String,
152 pub token_account: String,
154 pub raw_amount: String,
156 pub ui_amount: f64,
158 pub decimals: u8,
160 pub symbol: Option<String>,
162 pub name: Option<String>,
164}
165
166#[derive(Debug, Deserialize)]
168#[serde(rename_all = "camelCase")]
169#[allow(dead_code)] struct SignatureInfo {
171 signature: String,
172 slot: u64,
173 block_time: Option<i64>,
174 err: Option<serde_json::Value>,
175}
176
177#[derive(Debug, Deserialize)]
179#[serde(rename_all = "camelCase")]
180struct SolanaTransactionResult {
181 #[serde(default)]
182 slot: Option<u64>,
183 #[serde(default)]
184 block_time: Option<i64>,
185 #[serde(default)]
186 transaction: Option<SolanaTransactionData>,
187 #[serde(default)]
188 meta: Option<SolanaTransactionMeta>,
189}
190
191#[derive(Debug, Deserialize)]
193struct SolanaTransactionData {
194 #[serde(default)]
195 message: Option<SolanaTransactionMessage>,
196}
197
198#[derive(Debug, Deserialize)]
200#[serde(rename_all = "camelCase")]
201struct SolanaTransactionMessage {
202 #[serde(default)]
203 account_keys: Option<Vec<AccountKeyEntry>>,
204}
205
206#[derive(Debug, Deserialize)]
208#[serde(untagged)]
209enum AccountKeyEntry {
210 String(String),
211 Object {
212 pubkey: String,
213 #[serde(default)]
214 #[allow(dead_code)]
215 signer: bool,
216 },
217}
218
219#[derive(Debug, Deserialize)]
221#[serde(rename_all = "camelCase")]
222struct SolanaTransactionMeta {
223 #[serde(default)]
224 fee: Option<u64>,
225 #[serde(default)]
226 pre_balances: Option<Vec<u64>>,
227 #[serde(default)]
228 post_balances: Option<Vec<u64>>,
229 #[serde(default)]
230 err: Option<serde_json::Value>,
231}
232
233#[derive(Debug, Deserialize)]
235#[allow(dead_code)] struct SolscanAccountInfo {
237 lamports: u64,
238 #[serde(rename = "type")]
239 account_type: Option<String>,
240}
241
242impl SolanaClient {
243 pub fn new(config: &ChainsConfig) -> Result<Self> {
263 let http: Arc<dyn HttpClient> = Arc::new(crate::http::NativeHttpClient::new()?);
264 Self::new_with_http(config, http)
265 }
266
267 pub fn new_with_http(config: &ChainsConfig, http: Arc<dyn HttpClient>) -> Result<Self> {
269 let rpc_url = config
270 .solana_rpc
271 .as_deref()
272 .unwrap_or(DEFAULT_SOLANA_RPC)
273 .to_string();
274
275 Ok(Self {
276 http,
277 rpc_url,
278 solscan_api_key: config.api_keys.get("solscan").cloned(),
279 })
280 }
281
282 pub fn with_rpc_url(rpc_url: &str) -> Self {
288 Self {
289 http: Arc::new(
290 crate::http::NativeHttpClient::new().expect("failed to create HTTP client"),
291 ),
292 rpc_url: rpc_url.to_string(),
293 solscan_api_key: None,
294 }
295 }
296
297 pub fn chain_name(&self) -> &str {
299 "solana"
300 }
301
302 pub fn native_token_symbol(&self) -> &str {
304 "SOL"
305 }
306
307 pub async fn get_balance(&self, address: &str) -> Result<Balance> {
322 validate_solana_address(address)?;
324
325 let request = RpcRequest {
326 jsonrpc: "2.0",
327 id: 1,
328 method: "getBalance",
329 params: vec![address],
330 };
331
332 tracing::debug!(url = %self.rpc_url, address = %address, "Fetching Solana balance");
333
334 let body = serde_json::to_string(&request)?;
335 let response: RpcResponse<BalanceResponse> = self
336 .http
337 .send(Request::post_json(&self.rpc_url, body))
338 .await?
339 .json()?;
340
341 if let Some(error) = response.error {
342 return Err(ScopeError::Chain(format!(
343 "Solana RPC error ({}): {}",
344 error.code, error.message
345 )));
346 }
347
348 let balance = response
349 .result
350 .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))?;
351
352 let lamports = balance.value;
353 let sol = lamports as f64 / 10_f64.powi(SOL_DECIMALS as i32);
354
355 Ok(Balance {
356 raw: lamports.to_string(),
357 formatted: format!("{:.9} SOL", sol),
358 decimals: SOL_DECIMALS,
359 symbol: "SOL".to_string(),
360 usd_value: None, })
362 }
363
364 pub async fn get_token_info(&self, mint_address: &str) -> Result<Token> {
369 validate_solana_address(mint_address)?;
370
371 let request = RpcRequest {
372 jsonrpc: "2.0",
373 id: 1,
374 method: "getAccountInfo",
375 params: serde_json::json!([mint_address, { "encoding": "base64" }]),
376 };
377
378 tracing::debug!(
379 url = %self.rpc_url,
380 mint = %mint_address,
381 "Fetching SPL mint info"
382 );
383
384 #[derive(Deserialize)]
385 struct AccountInfoResult {
386 value: Option<AccountInfoValue>,
387 }
388 #[derive(Deserialize)]
389 struct AccountInfoValue {
390 data: Option<Vec<String>>,
391 }
392
393 let body = serde_json::to_string(&request)?;
394 let response: RpcResponse<AccountInfoResult> = self
395 .http
396 .send(Request::post_json(&self.rpc_url, body))
397 .await?
398 .json()?;
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 body = serde_json::to_string(&request)?;
491 let response: RpcResponse<TokenAccountsResponse> = self
492 .http
493 .send(Request::post_json(&self.rpc_url, body))
494 .await?
495 .json()?;
496
497 if let Some(error) = response.error {
498 return Err(ScopeError::Chain(format!(
499 "Solana RPC error ({}): {}",
500 error.code, error.message
501 )));
502 }
503
504 let accounts = response
505 .result
506 .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))?;
507
508 let token_balances: Vec<TokenBalance> = accounts
509 .value
510 .into_iter()
511 .filter_map(|account| {
512 let info = &account.account.data.parsed.info;
513 let ui_amount = info.token_amount.ui_amount.unwrap_or(0.0);
514
515 if ui_amount == 0.0 {
517 return None;
518 }
519
520 Some(TokenBalance {
521 mint: info.mint.clone(),
522 token_account: account.pubkey,
523 raw_amount: info.token_amount.amount.clone(),
524 ui_amount,
525 decimals: info.token_amount.decimals,
526 symbol: None, name: None,
528 })
529 })
530 .collect();
531
532 Ok(token_balances)
533 }
534
535 pub async fn get_signatures(&self, address: &str, limit: u32) -> Result<Vec<String>> {
546 let infos = self.get_signature_infos(address, limit).await?;
547 Ok(infos.into_iter().map(|s| s.signature).collect())
548 }
549
550 async fn get_signature_infos(&self, address: &str, limit: u32) -> Result<Vec<SignatureInfo>> {
552 validate_solana_address(address)?;
553
554 #[derive(Serialize)]
555 struct GetSignaturesParams<'a> {
556 limit: u32,
557 #[serde(skip_serializing_if = "Option::is_none")]
558 before: Option<&'a str>,
559 }
560
561 let request = RpcRequest {
562 jsonrpc: "2.0",
563 id: 1,
564 method: "getSignaturesForAddress",
565 params: (
566 address,
567 GetSignaturesParams {
568 limit,
569 before: None,
570 },
571 ),
572 };
573
574 tracing::debug!(
575 url = %self.rpc_url,
576 address = %address,
577 limit = %limit,
578 "Fetching Solana transaction signatures"
579 );
580
581 let body = serde_json::to_string(&request)?;
582 let response: RpcResponse<Vec<SignatureInfo>> = self
583 .http
584 .send(Request::post_json(&self.rpc_url, body))
585 .await?
586 .json()?;
587
588 if let Some(error) = response.error {
589 return Err(ScopeError::Chain(format!(
590 "Solana RPC error ({}): {}",
591 error.code, error.message
592 )));
593 }
594
595 response
596 .result
597 .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))
598 }
599
600 pub async fn get_transaction(&self, signature: &str) -> Result<Transaction> {
610 validate_solana_signature(signature)?;
612
613 let request = RpcRequest {
614 jsonrpc: "2.0",
615 id: 1,
616 method: "getTransaction",
617 params: serde_json::json!([
618 signature,
619 {
620 "encoding": "jsonParsed",
621 "maxSupportedTransactionVersion": 0
622 }
623 ]),
624 };
625
626 tracing::debug!(
627 url = %self.rpc_url,
628 signature = %signature,
629 "Fetching Solana transaction"
630 );
631
632 let body = serde_json::to_string(&request)?;
633 let response: RpcResponse<SolanaTransactionResult> = self
634 .http
635 .send(Request::post_json(&self.rpc_url, body))
636 .await?
637 .json()?;
638
639 if let Some(error) = response.error {
640 return Err(ScopeError::Chain(format!(
641 "Solana RPC error ({}): {}",
642 error.code, error.message
643 )));
644 }
645
646 let tx_result = response
647 .result
648 .ok_or_else(|| ScopeError::NotFound(format!("Transaction not found: {}", signature)))?;
649
650 let from = tx_result
652 .transaction
653 .as_ref()
654 .and_then(|tx| tx.message.as_ref())
655 .and_then(|msg| msg.account_keys.as_ref())
656 .and_then(|keys| keys.first())
657 .map(|key| match key {
658 AccountKeyEntry::String(s) => s.clone(),
659 AccountKeyEntry::Object { pubkey, .. } => pubkey.clone(),
660 })
661 .unwrap_or_default();
662
663 let value = tx_result
665 .meta
666 .as_ref()
667 .and_then(|meta| {
668 let pre = meta.pre_balances.as_ref()?;
669 let post = meta.post_balances.as_ref()?;
670 if pre.len() >= 2 && post.len() >= 2 {
671 let fee = meta.fee.unwrap_or(0);
673 let sent = pre[0].saturating_sub(post[0]).saturating_sub(fee);
674 if sent > 0 {
675 let sol = sent as f64 / 10_f64.powi(SOL_DECIMALS as i32);
676 return Some(format!("{:.9}", sol));
677 }
678 }
679 None
680 })
681 .unwrap_or_else(|| "0".to_string());
682
683 let to = tx_result
685 .transaction
686 .as_ref()
687 .and_then(|tx| tx.message.as_ref())
688 .and_then(|msg| msg.account_keys.as_ref())
689 .and_then(|keys| {
690 if keys.len() >= 2 {
691 Some(match &keys[1] {
692 AccountKeyEntry::String(s) => s.clone(),
693 AccountKeyEntry::Object { pubkey, .. } => pubkey.clone(),
694 })
695 } else {
696 None
697 }
698 });
699
700 let fee = tx_result
701 .meta
702 .as_ref()
703 .and_then(|meta| meta.fee)
704 .unwrap_or(0);
705
706 let status = tx_result.meta.as_ref().map(|meta| meta.err.is_none());
707
708 Ok(Transaction {
709 hash: signature.to_string(),
710 block_number: tx_result.slot,
711 timestamp: tx_result.block_time.map(|t| t as u64),
712 from,
713 to,
714 value,
715 gas_limit: 0, gas_used: None,
717 gas_price: fee.to_string(), nonce: 0,
719 input: String::new(),
720 status,
721 })
722 }
723
724 pub async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
735 validate_solana_address(address)?;
736
737 let sig_infos = self.get_signature_infos(address, limit).await?;
739
740 let transactions: Vec<Transaction> = sig_infos
741 .into_iter()
742 .map(|info| Transaction {
743 hash: info.signature,
744 block_number: Some(info.slot),
745 timestamp: info.block_time.map(|t| t as u64),
746 from: address.to_string(),
747 to: None,
748 value: "0".to_string(),
749 gas_limit: 0,
750 gas_used: None,
751 gas_price: "0".to_string(),
752 nonce: 0,
753 input: String::new(),
754 status: Some(info.err.is_none()),
755 })
756 .collect();
757
758 Ok(transactions)
759 }
760
761 pub async fn get_slot(&self) -> Result<u64> {
763 let request = RpcRequest {
764 jsonrpc: "2.0",
765 id: 1,
766 method: "getSlot",
767 params: (),
768 };
769
770 let body = serde_json::to_string(&request)?;
771 let response: RpcResponse<u64> = self
772 .http
773 .send(Request::post_json(&self.rpc_url, body))
774 .await?
775 .json()?;
776
777 if let Some(error) = response.error {
778 return Err(ScopeError::Chain(format!(
779 "Solana RPC error ({}): {}",
780 error.code, error.message
781 )));
782 }
783
784 response
785 .result
786 .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))
787 }
788}
789
790impl Default for SolanaClient {
791 fn default() -> Self {
792 Self {
793 http: Arc::new(
794 crate::http::NativeHttpClient::new().expect("failed to create HTTP client"),
795 ),
796 rpc_url: DEFAULT_SOLANA_RPC.to_string(),
797 solscan_api_key: None,
798 }
799 }
800}
801
802pub fn validate_solana_address(address: &str) -> Result<()> {
812 if address.is_empty() {
816 return Err(ScopeError::InvalidAddress("Address cannot be empty".into()));
817 }
818
819 if address.len() < 32 || address.len() > 44 {
821 return Err(ScopeError::InvalidAddress(format!(
822 "Solana address must be 32-44 characters, got {}: {}",
823 address.len(),
824 address
825 )));
826 }
827
828 match bs58::decode(address).into_vec() {
830 Ok(bytes) => {
831 if bytes.len() != 32 {
833 return Err(ScopeError::InvalidAddress(format!(
834 "Solana address must decode to 32 bytes, got {}: {}",
835 bytes.len(),
836 address
837 )));
838 }
839 }
840 Err(e) => {
841 return Err(ScopeError::InvalidAddress(format!(
842 "Invalid base58 encoding: {}: {}",
843 e, address
844 )));
845 }
846 }
847
848 Ok(())
849}
850
851pub fn validate_solana_signature(signature: &str) -> Result<()> {
861 if signature.is_empty() {
865 return Err(ScopeError::InvalidHash("Signature cannot be empty".into()));
866 }
867
868 if signature.len() < 80 || signature.len() > 90 {
870 return Err(ScopeError::InvalidHash(format!(
871 "Solana signature must be 80-90 characters, got {}: {}",
872 signature.len(),
873 signature
874 )));
875 }
876
877 match bs58::decode(signature).into_vec() {
879 Ok(bytes) => {
880 if bytes.len() != 64 {
882 return Err(ScopeError::InvalidHash(format!(
883 "Solana signature must decode to 64 bytes, got {}: {}",
884 bytes.len(),
885 signature
886 )));
887 }
888 }
889 Err(e) => {
890 return Err(ScopeError::InvalidHash(format!(
891 "Invalid base58 encoding: {}: {}",
892 e, signature
893 )));
894 }
895 }
896
897 Ok(())
898}
899
900#[async_trait]
905impl ChainClient for SolanaClient {
906 fn chain_name(&self) -> &str {
907 "solana"
908 }
909
910 fn native_token_symbol(&self) -> &str {
911 "SOL"
912 }
913
914 async fn get_balance(&self, address: &str) -> Result<Balance> {
915 self.get_balance(address).await
916 }
917
918 async fn enrich_balance_usd(&self, balance: &mut Balance) {
919 self.enrich_balance_usd(balance).await
920 }
921
922 async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
923 self.get_transaction(hash).await
924 }
925
926 async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
927 self.get_transactions(address, limit).await
928 }
929
930 async fn get_block_number(&self) -> Result<u64> {
931 self.get_slot().await
932 }
933
934 async fn get_token_info(&self, address: &str) -> Result<Token> {
935 self.get_token_info(address).await
936 }
937
938 async fn get_token_balances(&self, address: &str) -> Result<Vec<crate::chains::TokenBalance>> {
939 let solana_balances = self.get_token_balances(address).await?;
940 Ok(solana_balances
941 .into_iter()
942 .map(|tb| crate::chains::TokenBalance {
943 token: Token {
944 contract_address: tb.mint.clone(),
945 symbol: tb
946 .symbol
947 .unwrap_or_else(|| tb.mint[..8.min(tb.mint.len())].to_string()),
948 name: tb.name.unwrap_or_else(|| "SPL Token".to_string()),
949 decimals: tb.decimals,
950 },
951 balance: tb.raw_amount,
952 formatted_balance: format!("{:.6}", tb.ui_amount),
953 usd_value: None,
954 })
955 .collect())
956 }
957}
958
959#[cfg(test)]
964mod tests {
965 use super::*;
966 use crate::chains::{Balance, ChainClient};
967
968 const VALID_ADDRESS: &str = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy";
970
971 const VALID_SIGNATURE: &str =
973 "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
974
975 #[test]
976 fn test_validate_solana_address_valid() {
977 assert!(validate_solana_address(VALID_ADDRESS).is_ok());
978 }
979
980 #[test]
981 fn test_validate_solana_address_empty() {
982 let result = validate_solana_address("");
983 assert!(result.is_err());
984 assert!(result.unwrap_err().to_string().contains("empty"));
985 }
986
987 #[test]
988 fn test_validate_solana_address_too_short() {
989 let result = validate_solana_address("DRpbCBMxVnDK7maPM5t");
990 assert!(result.is_err());
991 assert!(result.unwrap_err().to_string().contains("32-44"));
992 }
993
994 #[test]
995 fn test_validate_solana_address_too_long() {
996 let long_addr = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hyAAAAAAAAAAAA";
997 let result = validate_solana_address(long_addr);
998 assert!(result.is_err());
999 }
1000
1001 #[test]
1002 fn test_validate_solana_address_invalid_base58() {
1003 let result = validate_solana_address("0RpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1005 assert!(result.is_err());
1006 assert!(result.unwrap_err().to_string().contains("base58"));
1007 }
1008
1009 #[test]
1010 fn test_validate_solana_address_wrong_decoded_length() {
1011 let result = validate_solana_address("abcdefghijabcdefghijabcdefghijab");
1014 assert!(result.is_err());
1015 }
1017
1018 #[test]
1019 fn test_validate_solana_signature_valid() {
1020 assert!(validate_solana_signature(VALID_SIGNATURE).is_ok());
1021 }
1022
1023 #[test]
1024 fn test_validate_solana_signature_empty() {
1025 let result = validate_solana_signature("");
1026 assert!(result.is_err());
1027 assert!(result.unwrap_err().to_string().contains("empty"));
1028 }
1029
1030 #[test]
1031 fn test_validate_solana_signature_too_short() {
1032 let result = validate_solana_signature("abc");
1033 assert!(result.is_err());
1034 assert!(result.unwrap_err().to_string().contains("80-90"));
1035 }
1036
1037 #[test]
1038 fn test_solana_client_default() {
1039 let client = SolanaClient::default();
1040 assert_eq!(client.chain_name(), "solana");
1041 assert_eq!(client.native_token_symbol(), "SOL");
1042 assert!(client.rpc_url.contains("mainnet-beta"));
1043 }
1044
1045 #[test]
1046 fn test_solana_client_with_rpc_url() {
1047 let client = SolanaClient::with_rpc_url("https://custom.rpc.com");
1048 assert_eq!(client.rpc_url, "https://custom.rpc.com");
1049 }
1050
1051 #[test]
1052 fn test_solana_client_new() {
1053 let config = ChainsConfig::default();
1054 let client = SolanaClient::new(&config);
1055 assert!(client.is_ok());
1056 }
1057
1058 #[test]
1059 fn test_solana_client_new_with_custom_rpc() {
1060 let config = ChainsConfig {
1061 solana_rpc: Some("https://my-solana-rpc.com".to_string()),
1062 ..Default::default()
1063 };
1064 let client = SolanaClient::new(&config).unwrap();
1065 assert_eq!(client.rpc_url, "https://my-solana-rpc.com");
1066 }
1067
1068 #[test]
1069 fn test_solana_client_new_with_api_key() {
1070 use std::collections::HashMap;
1071
1072 let mut api_keys = HashMap::new();
1073 api_keys.insert("solscan".to_string(), "test-key".to_string());
1074
1075 let config = ChainsConfig {
1076 api_keys,
1077 ..Default::default()
1078 };
1079
1080 let client = SolanaClient::new(&config).unwrap();
1081 assert_eq!(client.solscan_api_key, Some("test-key".to_string()));
1082 }
1083
1084 #[test]
1085 fn test_rpc_request_serialization() {
1086 let request = RpcRequest {
1087 jsonrpc: "2.0",
1088 id: 1,
1089 method: "getBalance",
1090 params: vec!["test"],
1091 };
1092
1093 let json = serde_json::to_string(&request).unwrap();
1094 assert!(json.contains("jsonrpc"));
1095 assert!(json.contains("getBalance"));
1096 }
1097
1098 #[test]
1099 fn test_rpc_response_deserialization() {
1100 let json = r#"{"jsonrpc":"2.0","result":{"value":1000000000},"id":1}"#;
1101 let response: RpcResponse<BalanceResponse> = serde_json::from_str(json).unwrap();
1102 assert!(response.result.is_some());
1103 assert_eq!(response.result.unwrap().value, 1_000_000_000);
1104 }
1105
1106 #[test]
1107 fn test_rpc_error_deserialization() {
1108 let json =
1109 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid request"},"id":1}"#;
1110 let response: RpcResponse<BalanceResponse> = serde_json::from_str(json).unwrap();
1111 assert!(response.error.is_some());
1112 let error = response.error.unwrap();
1113 assert_eq!(error.code, -32600);
1114 assert_eq!(error.message, "Invalid request");
1115 }
1116
1117 #[tokio::test]
1122 async fn test_get_balance() {
1123 let mut server = mockito::Server::new_async().await;
1124 let _mock = server
1125 .mock("POST", "/")
1126 .with_status(200)
1127 .with_header("content-type", "application/json")
1128 .with_body(r#"{"jsonrpc":"2.0","result":{"value":5000000000},"id":1}"#)
1129 .create_async()
1130 .await;
1131
1132 let client = SolanaClient::with_rpc_url(&server.url());
1133 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1134 assert_eq!(balance.raw, "5000000000");
1135 assert_eq!(balance.symbol, "SOL");
1136 assert_eq!(balance.decimals, 9);
1137 assert!(balance.formatted.contains("5.000000000"));
1138 }
1139
1140 #[tokio::test]
1141 async fn test_get_balance_zero() {
1142 let mut server = mockito::Server::new_async().await;
1143 let _mock = server
1144 .mock("POST", "/")
1145 .with_status(200)
1146 .with_header("content-type", "application/json")
1147 .with_body(r#"{"jsonrpc":"2.0","result":{"value":0},"id":1}"#)
1148 .create_async()
1149 .await;
1150
1151 let client = SolanaClient::with_rpc_url(&server.url());
1152 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1153 assert_eq!(balance.raw, "0");
1154 assert!(balance.formatted.contains("0.000000000"));
1155 }
1156
1157 #[tokio::test]
1158 async fn test_get_balance_rpc_error() {
1159 let mut server = mockito::Server::new_async().await;
1160 let _mock = server
1161 .mock("POST", "/")
1162 .with_status(200)
1163 .with_header("content-type", "application/json")
1164 .with_body(
1165 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid params"},"id":1}"#,
1166 )
1167 .create_async()
1168 .await;
1169
1170 let client = SolanaClient::with_rpc_url(&server.url());
1171 let result = client.get_balance(VALID_ADDRESS).await;
1172 assert!(result.is_err());
1173 assert!(result.unwrap_err().to_string().contains("RPC error"));
1174 }
1175
1176 #[tokio::test]
1177 async fn test_get_balance_empty_response() {
1178 let mut server = mockito::Server::new_async().await;
1179 let _mock = server
1180 .mock("POST", "/")
1181 .with_status(200)
1182 .with_header("content-type", "application/json")
1183 .with_body(r#"{"jsonrpc":"2.0","id":1}"#)
1184 .create_async()
1185 .await;
1186
1187 let client = SolanaClient::with_rpc_url(&server.url());
1188 let result = client.get_balance(VALID_ADDRESS).await;
1189 assert!(result.is_err());
1190 assert!(result.unwrap_err().to_string().contains("Empty RPC"));
1191 }
1192
1193 #[tokio::test]
1194 async fn test_get_balance_invalid_address() {
1195 let client = SolanaClient::default();
1196 let result = client.get_balance("invalid").await;
1197 assert!(result.is_err());
1198 }
1199
1200 #[tokio::test]
1201 async fn test_get_transaction() {
1202 let mut server = mockito::Server::new_async().await;
1203 let _mock = server
1204 .mock("POST", "/")
1205 .with_status(200)
1206 .with_header("content-type", "application/json")
1207 .with_body(
1208 r#"{"jsonrpc":"2.0","result":{
1209 "slot":123456789,
1210 "blockTime":1700000000,
1211 "transaction":{
1212 "message":{
1213 "accountKeys":[
1214 {"pubkey":"DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy","signer":true},
1215 {"pubkey":"9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM","signer":false}
1216 ]
1217 }
1218 },
1219 "meta":{
1220 "fee":5000,
1221 "preBalances":[10000000000,5000000000],
1222 "postBalances":[8999995000,6000000000],
1223 "err":null
1224 }
1225 },"id":1}"#,
1226 )
1227 .create_async()
1228 .await;
1229
1230 let client = SolanaClient::with_rpc_url(&server.url());
1231 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1232 assert_eq!(tx.hash, VALID_SIGNATURE);
1233 assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1234 assert_eq!(
1235 tx.to,
1236 Some("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM".to_string())
1237 );
1238 assert_eq!(tx.block_number, Some(123456789));
1239 assert_eq!(tx.timestamp, Some(1700000000));
1240 assert!(tx.status.unwrap()); assert_eq!(tx.gas_price, "5000"); }
1243
1244 #[tokio::test]
1245 async fn test_get_transaction_failed() {
1246 let mut server = mockito::Server::new_async().await;
1247 let _mock = server
1248 .mock("POST", "/")
1249 .with_status(200)
1250 .with_header("content-type", "application/json")
1251 .with_body(r#"{"jsonrpc":"2.0","result":{
1252 "slot":100,
1253 "transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"]}},
1254 "meta":{"fee":5000,"preBalances":[1000],"postBalances":[1000],"err":{"InstructionError":[0,{"Custom":1}]}}
1255 },"id":1}"#)
1256 .create_async()
1257 .await;
1258
1259 let client = SolanaClient::with_rpc_url(&server.url());
1260 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1261 assert!(!tx.status.unwrap()); }
1263
1264 #[tokio::test]
1265 async fn test_get_transaction_not_found() {
1266 let mut server = mockito::Server::new_async().await;
1267 let _mock = server
1268 .mock("POST", "/")
1269 .with_status(200)
1270 .with_header("content-type", "application/json")
1271 .with_body(r#"{"jsonrpc":"2.0","result":null,"id":1}"#)
1272 .create_async()
1273 .await;
1274
1275 let client = SolanaClient::with_rpc_url(&server.url());
1276 let result = client.get_transaction(VALID_SIGNATURE).await;
1277 assert!(result.is_err());
1278 assert!(result.unwrap_err().to_string().contains("not found"));
1279 }
1280
1281 #[tokio::test]
1282 async fn test_get_transaction_string_account_keys() {
1283 let mut server = mockito::Server::new_async().await;
1284 let _mock = server
1285 .mock("POST", "/")
1286 .with_status(200)
1287 .with_header("content-type", "application/json")
1288 .with_body(r#"{"jsonrpc":"2.0","result":{
1289 "slot":100,
1290 "transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy","9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"]}},
1291 "meta":{"fee":5000,"preBalances":[1000000000,0],"postBalances":[999995000,0],"err":null}
1292 },"id":1}"#)
1293 .create_async()
1294 .await;
1295
1296 let client = SolanaClient::with_rpc_url(&server.url());
1297 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1298 assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1299 assert_eq!(
1300 tx.to,
1301 Some("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM".to_string())
1302 );
1303 }
1304
1305 #[tokio::test]
1306 async fn test_get_signatures() {
1307 let mut server = mockito::Server::new_async().await;
1308 let _mock = server
1309 .mock("POST", "/")
1310 .with_status(200)
1311 .with_header("content-type", "application/json")
1312 .with_body(r#"{"jsonrpc":"2.0","result":[
1313 {"signature":"5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW","slot":100,"blockTime":1700000000,"err":null},
1314 {"signature":"4VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUX","slot":101,"blockTime":1700000060,"err":{"InstructionError":[0,{"Custom":1}]}}
1315 ],"id":1}"#)
1316 .create_async()
1317 .await;
1318
1319 let client = SolanaClient::with_rpc_url(&server.url());
1320 let sigs = client.get_signatures(VALID_ADDRESS, 10).await.unwrap();
1321 assert_eq!(sigs.len(), 2);
1322 assert!(sigs[0].starts_with("5VERv8"));
1323 }
1324
1325 #[tokio::test]
1326 async fn test_get_transactions() {
1327 let mut server = mockito::Server::new_async().await;
1328 let _mock = server
1329 .mock("POST", "/")
1330 .with_status(200)
1331 .with_header("content-type", "application/json")
1332 .with_body(r#"{"jsonrpc":"2.0","result":[
1333 {"signature":"5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW","slot":100,"blockTime":1700000000,"err":null},
1334 {"signature":"4VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUX","slot":101,"blockTime":1700000060,"err":{"InstructionError":[0,{"Custom":1}]}}
1335 ],"id":1}"#)
1336 .create_async()
1337 .await;
1338
1339 let client = SolanaClient::with_rpc_url(&server.url());
1340 let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1341 assert_eq!(txs.len(), 2);
1342 assert!(txs[0].status.unwrap()); assert!(!txs[1].status.unwrap()); assert_eq!(txs[0].block_number, Some(100));
1345 assert_eq!(txs[0].timestamp, Some(1700000000));
1346 }
1347
1348 #[tokio::test]
1349 async fn test_get_token_balances() {
1350 let mut server = mockito::Server::new_async().await;
1351 let _mock = server
1352 .mock("POST", "/")
1353 .with_status(200)
1354 .with_header("content-type", "application/json")
1355 .with_body(
1356 r#"{"jsonrpc":"2.0","result":{"value":[
1357 {
1358 "pubkey":"TokenAccAddr1",
1359 "account":{
1360 "data":{
1361 "parsed":{
1362 "info":{
1363 "mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
1364 "tokenAmount":{
1365 "amount":"1000000",
1366 "decimals":6,
1367 "uiAmount":1.0,
1368 "uiAmountString":"1"
1369 }
1370 }
1371 }
1372 }
1373 }
1374 },
1375 {
1376 "pubkey":"TokenAccAddr2",
1377 "account":{
1378 "data":{
1379 "parsed":{
1380 "info":{
1381 "mint":"So11111111111111111111111111111111111111112",
1382 "tokenAmount":{
1383 "amount":"0",
1384 "decimals":9,
1385 "uiAmount":0.0,
1386 "uiAmountString":"0"
1387 }
1388 }
1389 }
1390 }
1391 }
1392 }
1393 ]},"id":1}"#,
1394 )
1395 .create_async()
1396 .await;
1397
1398 let client = SolanaClient::with_rpc_url(&server.url());
1399 let balances = client.get_token_balances(VALID_ADDRESS).await.unwrap();
1400 assert_eq!(balances.len(), 1);
1402 assert_eq!(
1403 balances[0].mint,
1404 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1405 );
1406 assert_eq!(balances[0].ui_amount, 1.0);
1407 assert_eq!(balances[0].decimals, 6);
1408 }
1409
1410 #[tokio::test]
1411 async fn test_get_token_balances_rpc_error() {
1412 let mut server = mockito::Server::new_async().await;
1413 let _mock = server
1414 .mock("POST", "/")
1415 .with_status(200)
1416 .with_header("content-type", "application/json")
1417 .with_body(
1418 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid params"},"id":1}"#,
1419 )
1420 .create_async()
1421 .await;
1422
1423 let client = SolanaClient::with_rpc_url(&server.url());
1424 let result = client.get_token_balances(VALID_ADDRESS).await;
1425 assert!(result.is_err());
1426 }
1427
1428 #[tokio::test]
1429 async fn test_get_slot() {
1430 let mut server = mockito::Server::new_async().await;
1431 let _mock = server
1432 .mock("POST", "/")
1433 .with_status(200)
1434 .with_header("content-type", "application/json")
1435 .with_body(r#"{"jsonrpc":"2.0","result":256000000,"id":1}"#)
1436 .create_async()
1437 .await;
1438
1439 let client = SolanaClient::with_rpc_url(&server.url());
1440 let slot = client.get_slot().await.unwrap();
1441 assert_eq!(slot, 256000000);
1442 }
1443
1444 #[tokio::test]
1445 async fn test_get_slot_error() {
1446 let mut server = mockito::Server::new_async().await;
1447 let _mock = server
1448 .mock("POST", "/")
1449 .with_status(200)
1450 .with_header("content-type", "application/json")
1451 .with_body(
1452 r#"{"jsonrpc":"2.0","error":{"code":-32005,"message":"Node is behind"},"id":1}"#,
1453 )
1454 .create_async()
1455 .await;
1456
1457 let client = SolanaClient::with_rpc_url(&server.url());
1458 let result = client.get_slot().await;
1459 assert!(result.is_err());
1460 }
1461
1462 #[test]
1463 fn test_validate_solana_signature_invalid_base58() {
1464 let bad_sig = "0OIl00000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
1466 let result = validate_solana_signature(bad_sig);
1467 assert!(result.is_err());
1468 }
1469
1470 #[test]
1471 fn test_validate_solana_signature_wrong_decoded_length() {
1472 let short = "11111111111111111111111111111111"; let result = validate_solana_signature(short);
1476 assert!(result.is_err());
1478 }
1479
1480 #[tokio::test]
1481 async fn test_get_transaction_rpc_error() {
1482 let mut server = mockito::Server::new_async().await;
1483 let _mock = server
1484 .mock("POST", "/")
1485 .with_status(200)
1486 .with_header("content-type", "application/json")
1487 .with_body(r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Transaction not found"},"id":1}"#)
1488 .create_async()
1489 .await;
1490
1491 let client = SolanaClient::with_rpc_url(&server.url());
1492 let result = client
1493 .get_transaction("5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW")
1494 .await;
1495 assert!(result.is_err());
1496 assert!(result.unwrap_err().to_string().contains("RPC error"));
1497 }
1498
1499 #[tokio::test]
1500 async fn test_solana_chain_client_trait_chain_name() {
1501 let client = SolanaClient::with_rpc_url("http://localhost:8899");
1502 let chain_client: &dyn ChainClient = &client;
1503 assert_eq!(chain_client.chain_name(), "solana");
1504 assert_eq!(chain_client.native_token_symbol(), "SOL");
1505 }
1506
1507 #[tokio::test]
1508 async fn test_chain_client_trait_get_balance() {
1509 let mut server = mockito::Server::new_async().await;
1510 let _mock = server
1511 .mock("POST", "/")
1512 .with_status(200)
1513 .with_header("content-type", "application/json")
1514 .with_body(
1515 r#"{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":1000000000},"id":1}"#,
1516 )
1517 .create_async()
1518 .await;
1519
1520 let client = SolanaClient::with_rpc_url(&server.url());
1521 let chain_client: &dyn ChainClient = &client;
1522 let balance = chain_client.get_balance(VALID_ADDRESS).await.unwrap();
1523 assert_eq!(balance.symbol, "SOL");
1524 }
1525
1526 #[tokio::test]
1527 async fn test_chain_client_trait_get_block_number() {
1528 let mut server = mockito::Server::new_async().await;
1529 let _mock = server
1530 .mock("POST", "/")
1531 .with_status(200)
1532 .with_header("content-type", "application/json")
1533 .with_body(r#"{"jsonrpc":"2.0","result":250000000,"id":1}"#)
1534 .create_async()
1535 .await;
1536
1537 let client = SolanaClient::with_rpc_url(&server.url());
1538 let chain_client: &dyn ChainClient = &client;
1539 let slot = chain_client.get_block_number().await.unwrap();
1540 assert_eq!(slot, 250000000);
1541 }
1542
1543 #[tokio::test]
1544 async fn test_chain_client_trait_get_token_balances() {
1545 let mut server = mockito::Server::new_async().await;
1546 let _mock = server
1547 .mock("POST", "/")
1548 .with_status(200)
1549 .with_header("content-type", "application/json")
1550 .with_body(
1551 r#"{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[
1552 {
1553 "pubkey":"TokenAccAddr1",
1554 "account":{
1555 "data":{
1556 "parsed":{
1557 "info":{
1558 "mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
1559 "tokenAmount":{
1560 "amount":"1000000",
1561 "decimals":6,
1562 "uiAmount":1.0,
1563 "uiAmountString":"1"
1564 }
1565 }
1566 }
1567 }
1568 }
1569 }
1570 ]},"id":1}"#,
1571 )
1572 .create_async()
1573 .await;
1574
1575 let client = SolanaClient::with_rpc_url(&server.url());
1576 let chain_client: &dyn ChainClient = &client;
1577 let balances = chain_client
1578 .get_token_balances(VALID_ADDRESS)
1579 .await
1580 .unwrap();
1581 assert!(!balances.is_empty());
1582 assert_eq!(
1584 balances[0].token.contract_address,
1585 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1586 );
1587 }
1588
1589 #[tokio::test]
1590 async fn test_chain_client_trait_get_transaction_solana() {
1591 let mut server = mockito::Server::new_async().await;
1592 let _mock = server
1593 .mock("POST", "/")
1594 .with_status(200)
1595 .with_header("content-type", "application/json")
1596 .with_body(
1597 r#"{"jsonrpc":"2.0","result":{
1598 "slot":200000000,
1599 "blockTime":1700000000,
1600 "meta":{
1601 "fee":5000,
1602 "preBalances":[1000000000,500000000],
1603 "postBalances":[999995000,500005000],
1604 "err":null
1605 },
1606 "transaction":{
1607 "message":{
1608 "accountKeys":[
1609 "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy",
1610 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1611 ]
1612 },
1613 "signatures":["5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh"]
1614 }
1615 },"id":1}"#,
1616 )
1617 .create_async()
1618 .await;
1619
1620 let client = SolanaClient::with_rpc_url(&server.url());
1621 let chain_client: &dyn ChainClient = &client;
1622 let tx = chain_client
1623 .get_transaction("5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh")
1624 .await
1625 .unwrap();
1626 assert!(!tx.hash.is_empty());
1627 assert!(tx.timestamp.is_some());
1628 }
1629
1630 #[tokio::test]
1631 async fn test_chain_client_trait_get_transactions_solana() {
1632 let mut server = mockito::Server::new_async().await;
1633 let _mock = server
1634 .mock("POST", "/")
1635 .with_status(200)
1636 .with_header("content-type", "application/json")
1637 .with_body(
1638 r#"{"jsonrpc":"2.0","result":[
1639 {
1640 "signature":"5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh",
1641 "slot":200000000,
1642 "blockTime":1700000000,
1643 "err":null,
1644 "memo":null
1645 }
1646 ],"id":1}"#,
1647 )
1648 .create_async()
1649 .await;
1650
1651 let client = SolanaClient::with_rpc_url(&server.url());
1652 let chain_client: &dyn ChainClient = &client;
1653 let txs = chain_client
1654 .get_transactions(VALID_ADDRESS, 10)
1655 .await
1656 .unwrap();
1657 assert!(!txs.is_empty());
1658 }
1659
1660 #[test]
1661 fn test_validate_solana_signature_wrong_byte_length() {
1662 let long_sig = "1".repeat(88); let result = validate_solana_signature(&long_sig);
1670 assert!(result.is_err());
1671 let err = result.unwrap_err().to_string();
1672 assert!(err.contains("64 bytes") || err.contains("base58"));
1673 }
1674
1675 #[tokio::test]
1676 async fn test_rpc_error_response() {
1677 let mut server = mockito::Server::new_async().await;
1678 let _mock = server
1679 .mock("POST", "/")
1680 .with_status(200)
1681 .with_header("content-type", "application/json")
1682 .with_body(
1683 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid request"},"id":1}"#,
1684 )
1685 .create_async()
1686 .await;
1687
1688 let client = SolanaClient::with_rpc_url(&server.url());
1689 let result = client.get_balance(VALID_ADDRESS).await;
1690 assert!(result.is_err());
1691 assert!(result.unwrap_err().to_string().contains("RPC error"));
1692 }
1693
1694 #[tokio::test]
1699 async fn test_get_token_info_success() {
1700 let mut mint_data = vec![0u8; 41];
1703 mint_data[40] = 6;
1704 let data_b64 =
1705 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &mint_data);
1706
1707 let mut server = mockito::Server::new_async().await;
1708 let _mock = server
1709 .mock("POST", "/")
1710 .with_status(200)
1711 .with_header("content-type", "application/json")
1712 .with_body(format!(
1713 r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
1714 data_b64
1715 ))
1716 .create_async()
1717 .await;
1718
1719 let client = SolanaClient::with_rpc_url(&server.url());
1720 let token = client
1721 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1722 .await
1723 .unwrap();
1724 assert_eq!(token.decimals, 6);
1725 assert_eq!(
1726 token.contract_address,
1727 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1728 );
1729 assert!(token.symbol.starts_with("EPjFWdd5"));
1730 assert!(token.symbol.ends_with("..."));
1731 assert_eq!(token.name, "SPL Token");
1732 }
1733
1734 #[tokio::test]
1735 async fn test_get_token_info_decimals_nine() {
1736 let mut mint_data = vec![0u8; 41];
1737 mint_data[40] = 9;
1738 let data_b64 =
1739 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &mint_data);
1740
1741 let mut server = mockito::Server::new_async().await;
1742 let _mock = server
1743 .mock("POST", "/")
1744 .with_status(200)
1745 .with_header("content-type", "application/json")
1746 .with_body(format!(
1747 r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
1748 data_b64
1749 ))
1750 .create_async()
1751 .await;
1752
1753 let client = SolanaClient::with_rpc_url(&server.url());
1754 let token = client
1755 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1756 .await
1757 .unwrap();
1758 assert_eq!(token.decimals, 9);
1759 assert_eq!(token.symbol, "EPjFWdd5..."); }
1761
1762 #[tokio::test]
1763 async fn test_get_token_info_rpc_error() {
1764 let mut server = mockito::Server::new_async().await;
1765 let _mock = server
1766 .mock("POST", "/")
1767 .with_status(200)
1768 .with_header("content-type", "application/json")
1769 .with_body(
1770 r#"{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params"},"id":1}"#,
1771 )
1772 .create_async()
1773 .await;
1774
1775 let client = SolanaClient::with_rpc_url(&server.url());
1776 let result = client
1777 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1778 .await;
1779 assert!(result.is_err());
1780 assert!(result.unwrap_err().to_string().contains("RPC error"));
1781 }
1782
1783 #[tokio::test]
1784 async fn test_get_token_info_not_found() {
1785 let mut server = mockito::Server::new_async().await;
1786 let _mock = server
1787 .mock("POST", "/")
1788 .with_status(200)
1789 .with_header("content-type", "application/json")
1790 .with_body(r#"{"jsonrpc":"2.0","result":{"value":null},"id":1}"#)
1791 .create_async()
1792 .await;
1793
1794 let client = SolanaClient::with_rpc_url(&server.url());
1795 let result = client
1796 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1797 .await;
1798 assert!(result.is_err());
1799 assert!(result.unwrap_err().to_string().contains("not found"));
1800 }
1801
1802 #[tokio::test]
1803 async fn test_get_token_info_no_account_data() {
1804 let mut server = mockito::Server::new_async().await;
1805 let _mock = server
1806 .mock("POST", "/")
1807 .with_status(200)
1808 .with_header("content-type", "application/json")
1809 .with_body(r#"{"jsonrpc":"2.0","result":{"value":{"data":null}},"id":1}"#)
1810 .create_async()
1811 .await;
1812
1813 let client = SolanaClient::with_rpc_url(&server.url());
1814 let result = client
1815 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1816 .await;
1817 assert!(result.is_err());
1818 assert!(result.unwrap_err().to_string().contains("No account data"));
1819 }
1820
1821 #[tokio::test]
1822 async fn test_get_token_info_empty_data_array() {
1823 let mut server = mockito::Server::new_async().await;
1824 let _mock = server
1825 .mock("POST", "/")
1826 .with_status(200)
1827 .with_header("content-type", "application/json")
1828 .with_body(r#"{"jsonrpc":"2.0","result":{"value":{"data":[]}},"id":1}"#)
1829 .create_async()
1830 .await;
1831
1832 let client = SolanaClient::with_rpc_url(&server.url());
1833 let result = client
1834 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1835 .await;
1836 assert!(result.is_err());
1837 }
1838
1839 #[tokio::test]
1840 async fn test_get_token_info_invalid_base64() {
1841 let mut server = mockito::Server::new_async().await;
1842 let _mock = server
1843 .mock("POST", "/")
1844 .with_status(200)
1845 .with_header("content-type", "application/json")
1846 .with_body(r#"{"jsonrpc":"2.0","result":{"value":{"data":["!!!invalid!!!"]}},"id":1}"#)
1847 .create_async()
1848 .await;
1849
1850 let client = SolanaClient::with_rpc_url(&server.url());
1851 let result = client
1852 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1853 .await;
1854 assert!(result.is_err());
1855 assert!(result.unwrap_err().to_string().contains("decode"));
1856 }
1857
1858 #[tokio::test]
1859 async fn test_get_token_info_data_too_short() {
1860 let short_data = vec![0u8; 20];
1862 let data_b64 =
1863 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &short_data);
1864
1865 let mut server = mockito::Server::new_async().await;
1866 let _mock = server
1867 .mock("POST", "/")
1868 .with_status(200)
1869 .with_header("content-type", "application/json")
1870 .with_body(format!(
1871 r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
1872 data_b64
1873 ))
1874 .create_async()
1875 .await;
1876
1877 let client = SolanaClient::with_rpc_url(&server.url());
1878 let result = client
1879 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1880 .await;
1881 assert!(result.is_err());
1882 assert!(result.unwrap_err().to_string().contains("too short"));
1883 }
1884
1885 #[tokio::test]
1886 async fn test_get_token_info_invalid_address() {
1887 let client = SolanaClient::default();
1888 let result = client.get_token_info("bad").await;
1889 assert!(result.is_err());
1890 }
1891
1892 #[tokio::test]
1897 async fn test_get_transaction_minimal_no_transaction_meta() {
1898 let mut server = mockito::Server::new_async().await;
1899 let _mock = server
1900 .mock("POST", "/")
1901 .with_status(200)
1902 .with_header("content-type", "application/json")
1903 .with_body(r#"{"jsonrpc":"2.0","result":{"slot":100},"id":1}"#)
1904 .create_async()
1905 .await;
1906
1907 let client = SolanaClient::with_rpc_url(&server.url());
1908 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1909 assert_eq!(tx.hash, VALID_SIGNATURE);
1910 assert_eq!(tx.from, "");
1911 assert_eq!(tx.to, None);
1912 assert_eq!(tx.value, "0");
1913 assert_eq!(tx.gas_price, "0");
1914 assert_eq!(tx.block_number, Some(100));
1915 assert_eq!(tx.status, None);
1916 }
1917
1918 #[tokio::test]
1919 async fn test_get_transaction_single_account_key_to_none() {
1920 let mut server = mockito::Server::new_async().await;
1921 let _mock = server
1922 .mock("POST", "/")
1923 .with_status(200)
1924 .with_header("content-type", "application/json")
1925 .with_body(r#"{"jsonrpc":"2.0","result":{
1926 "slot":100,
1927 "blockTime":1700000000,
1928 "transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"]}},
1929 "meta":{"fee":5000,"preBalances":[1000],"postBalances":[500],"err":null}
1930 },"id":1}"#)
1931 .create_async()
1932 .await;
1933
1934 let client = SolanaClient::with_rpc_url(&server.url());
1935 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1936 assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1937 assert_eq!(tx.to, None); assert_eq!(tx.value, "0"); }
1940
1941 #[tokio::test]
1942 async fn test_get_transaction_value_zero_when_no_send() {
1943 let mut server = mockito::Server::new_async().await;
1944 let _mock = server
1945 .mock("POST", "/")
1946 .with_status(200)
1947 .with_header("content-type", "application/json")
1948 .with_body(
1949 r#"{"jsonrpc":"2.0","result":{
1950 "slot":100,
1951 "transaction":{"message":{"accountKeys":["A","B"]}},
1952 "meta":{"fee":5000,"preBalances":[1000,500],"postBalances":[1000,500],"err":null}
1953 },"id":1}"#,
1954 )
1955 .create_async()
1956 .await;
1957
1958 let client = SolanaClient::with_rpc_url(&server.url());
1959 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1960 assert_eq!(tx.value, "0"); }
1962
1963 #[tokio::test]
1964 async fn test_get_transaction_meta_no_fee() {
1965 let mut server = mockito::Server::new_async().await;
1966 let _mock = server
1967 .mock("POST", "/")
1968 .with_status(200)
1969 .with_header("content-type", "application/json")
1970 .with_body(r#"{"jsonrpc":"2.0","result":{
1971 "slot":100,
1972 "transaction":{"message":{"accountKeys":["A","B"]}},
1973 "meta":{"preBalances":[10000000000,0],"postBalances":[8999995000,1000005000],"err":null}
1974 },"id":1}"#)
1975 .create_async()
1976 .await;
1977
1978 let client = SolanaClient::with_rpc_url(&server.url());
1979 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1980 assert_eq!(tx.gas_price, "0"); }
1982
1983 #[tokio::test]
1984 async fn test_get_transaction_invalid_signature() {
1985 let client = SolanaClient::default();
1986 let result = client.get_transaction("invalid").await;
1987 assert!(result.is_err());
1988 }
1989
1990 #[tokio::test]
1995 async fn test_get_signatures_empty_response() {
1996 let mut server = mockito::Server::new_async().await;
1997 let _mock = server
1998 .mock("POST", "/")
1999 .with_status(200)
2000 .with_header("content-type", "application/json")
2001 .with_body(r#"{"jsonrpc":"2.0","id":1}"#)
2002 .create_async()
2003 .await;
2004
2005 let client = SolanaClient::with_rpc_url(&server.url());
2006 let result = client.get_signatures(VALID_ADDRESS, 10).await;
2007 assert!(result.is_err());
2008 assert!(result.unwrap_err().to_string().contains("Empty RPC"));
2009 }
2010
2011 #[tokio::test]
2012 async fn test_get_signatures_invalid_address() {
2013 let client = SolanaClient::default();
2014 let result = client.get_signatures("x", 10).await;
2015 assert!(result.is_err());
2016 }
2017
2018 #[tokio::test]
2019 async fn test_get_signatures_rpc_error() {
2020 let mut server = mockito::Server::new_async().await;
2021 let _mock = server
2022 .mock("POST", "/")
2023 .with_status(200)
2024 .with_header("content-type", "application/json")
2025 .with_body(
2026 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid address"},"id":1}"#,
2027 )
2028 .create_async()
2029 .await;
2030
2031 let client = SolanaClient::with_rpc_url(&server.url());
2032 let result = client.get_signatures(VALID_ADDRESS, 10).await;
2033 assert!(result.is_err());
2034 }
2035
2036 #[tokio::test]
2037 async fn test_get_transactions_invalid_address() {
2038 let client = SolanaClient::default();
2039 let result = client.get_transactions("invalid-addr", 10).await;
2040 assert!(result.is_err());
2041 }
2042
2043 #[tokio::test]
2048 async fn test_get_slot_empty_response() {
2049 let mut server = mockito::Server::new_async().await;
2050 let _mock = server
2051 .mock("POST", "/")
2052 .with_status(200)
2053 .with_header("content-type", "application/json")
2054 .with_body(r#"{"jsonrpc":"2.0","id":1}"#)
2055 .create_async()
2056 .await;
2057
2058 let client = SolanaClient::with_rpc_url(&server.url());
2059 let result = client.get_slot().await;
2060 assert!(result.is_err());
2061 assert!(result.unwrap_err().to_string().contains("Empty RPC"));
2062 }
2063
2064 #[tokio::test]
2069 async fn test_get_token_balances_ui_amount_null() {
2070 let mut server = mockito::Server::new_async().await;
2071 let _mock = server
2072 .mock("POST", "/")
2073 .with_status(200)
2074 .with_header("content-type", "application/json")
2075 .with_body(
2076 r#"{"jsonrpc":"2.0","result":{"value":[{
2077 "pubkey":"TokenAccAddr1",
2078 "account":{
2079 "data":{
2080 "parsed":{
2081 "info":{
2082 "mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
2083 "tokenAmount":{
2084 "amount":"0",
2085 "decimals":6,
2086 "uiAmount":null,
2087 "uiAmountString":"0"
2088 }
2089 }
2090 }
2091 }
2092 }
2093 }]},"id":1}"#,
2094 )
2095 .create_async()
2096 .await;
2097
2098 let client = SolanaClient::with_rpc_url(&server.url());
2099 let balances = client.get_token_balances(VALID_ADDRESS).await.unwrap();
2100 assert_eq!(balances.len(), 0);
2102 }
2103
2104 #[test]
2105 fn test_token_balance_serialization() {
2106 let tb = TokenBalance {
2107 mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
2108 token_account: "TokenAcc1".to_string(),
2109 raw_amount: "1000000".to_string(),
2110 ui_amount: 1.5,
2111 decimals: 6,
2112 symbol: Some("USDC".to_string()),
2113 name: Some("USD Coin".to_string()),
2114 };
2115 let json = serde_json::to_string(&tb).unwrap();
2116 assert!(json.contains("USDC"));
2117 assert!(json.contains("1000000"));
2118 }
2119
2120 #[tokio::test]
2121 async fn test_chain_client_get_token_balances_short_mint() {
2122 let mut server = mockito::Server::new_async().await;
2123 let _mock = server
2124 .mock("POST", "/")
2125 .with_status(200)
2126 .with_header("content-type", "application/json")
2127 .with_body(
2128 r#"{"jsonrpc":"2.0","result":{"value":[{
2129 "pubkey":"TokenAcc1",
2130 "account":{
2131 "data":{
2132 "parsed":{
2133 "info":{
2134 "mint":"Short",
2135 "tokenAmount":{
2136 "amount":"100",
2137 "decimals":2,
2138 "uiAmount":1.0,
2139 "uiAmountString":"1"
2140 }
2141 }
2142 }
2143 }
2144 }
2145 }]},"id":1}"#,
2146 )
2147 .create_async()
2148 .await;
2149
2150 let client = SolanaClient::with_rpc_url(&server.url());
2151 let chain_client: &dyn ChainClient = &client;
2152 let balances = chain_client
2153 .get_token_balances("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy")
2154 .await
2155 .unwrap();
2156 assert_eq!(balances.len(), 1);
2157 assert_eq!(balances[0].token.symbol, "Short");
2159 }
2160
2161 #[test]
2166 fn test_validate_solana_signature_too_long() {
2167 let too_long = "1".repeat(91);
2168 let result = validate_solana_signature(&too_long);
2169 assert!(result.is_err());
2170 assert!(result.unwrap_err().to_string().contains("80-90"));
2171 }
2172
2173 #[tokio::test]
2178 async fn test_enrich_balance_usd_invalid_raw_does_not_panic() {
2179 let client = SolanaClient::default();
2180 let mut balance = Balance {
2181 raw: "not-a-number".to_string(),
2182 formatted: "0 SOL".to_string(),
2183 decimals: 9,
2184 symbol: "SOL".to_string(),
2185 usd_value: None,
2186 };
2187 client.enrich_balance_usd(&mut balance).await;
2189 assert!(balance.usd_value.is_none() || balance.usd_value == Some(0.0));
2191 }
2192
2193 #[tokio::test]
2194 async fn test_chain_client_trait_enrich_balance_usd() {
2195 let client = SolanaClient::default();
2196 let chain_client: &dyn ChainClient = &client;
2197 let mut balance = Balance {
2198 raw: "not-a-number".to_string(),
2199 formatted: "0 SOL".to_string(),
2200 decimals: 9,
2201 symbol: "SOL".to_string(),
2202 usd_value: None,
2203 };
2204 chain_client.enrich_balance_usd(&mut balance).await;
2205 assert!(balance.usd_value.is_none() || balance.usd_value == Some(0.0));
2206 }
2207
2208 #[tokio::test]
2209 async fn test_chain_client_trait_get_token_info() {
2210 let mut mint_data = vec![0u8; 41];
2211 mint_data[40] = 18;
2212 let data_b64 =
2213 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &mint_data);
2214
2215 let mut server = mockito::Server::new_async().await;
2216 let _mock = server
2217 .mock("POST", "/")
2218 .with_status(200)
2219 .with_header("content-type", "application/json")
2220 .with_body(format!(
2221 r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
2222 data_b64
2223 ))
2224 .create_async()
2225 .await;
2226
2227 let client = SolanaClient::with_rpc_url(&server.url());
2228 let chain_client: &dyn ChainClient = &client;
2229 let token = chain_client
2230 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
2231 .await
2232 .unwrap();
2233 assert_eq!(token.decimals, 18);
2234 }
2235
2236 #[test]
2241 fn test_solscan_account_info_deserialization() {
2242 let json = r#"{"lamports":1000000,"type":"account"}"#;
2243 let info: SolscanAccountInfo = serde_json::from_str(json).unwrap();
2244 assert_eq!(info.lamports, 1000000);
2245 assert_eq!(info.account_type, Some("account".to_string()));
2246 }
2247}