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 Self::new_with_http(config, crate::http::transport())
264 }
265
266 pub fn new_with_http(config: &ChainsConfig, http: Arc<dyn HttpClient>) -> Result<Self> {
268 let rpc_url = config
269 .solana_rpc
270 .as_deref()
271 .unwrap_or(DEFAULT_SOLANA_RPC)
272 .to_string();
273
274 Ok(Self {
275 http,
276 rpc_url,
277 solscan_api_key: config.api_keys.get("solscan").cloned(),
278 })
279 }
280
281 pub fn with_rpc_url(rpc_url: &str) -> Self {
287 Self {
288 http: crate::http::transport(),
289 rpc_url: rpc_url.to_string(),
290 solscan_api_key: None,
291 }
292 }
293
294 pub fn chain_name(&self) -> &str {
296 "solana"
297 }
298
299 pub fn native_token_symbol(&self) -> &str {
301 "SOL"
302 }
303
304 pub async fn get_balance(&self, address: &str) -> Result<Balance> {
319 validate_solana_address(address)?;
321
322 let request = RpcRequest {
323 jsonrpc: "2.0",
324 id: 1,
325 method: "getBalance",
326 params: vec![address],
327 };
328
329 tracing::debug!(url = %self.rpc_url, address = %address, "Fetching Solana balance");
330
331 let body = serde_json::to_string(&request)?;
332 let response: RpcResponse<BalanceResponse> = self
333 .http
334 .send(Request::post_json(&self.rpc_url, body))
335 .await?
336 .json()?;
337
338 if let Some(error) = response.error {
339 return Err(ScopeError::Chain(format!(
340 "Solana RPC error ({}): {}",
341 error.code, error.message
342 )));
343 }
344
345 let balance = response
346 .result
347 .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))?;
348
349 let lamports = balance.value;
350 let sol = lamports as f64 / 10_f64.powi(SOL_DECIMALS as i32);
351
352 Ok(Balance {
353 raw: lamports.to_string(),
354 formatted: format!("{:.9} SOL", sol),
355 decimals: SOL_DECIMALS,
356 symbol: "SOL".to_string(),
357 usd_value: None, })
359 }
360
361 pub async fn get_token_info(&self, mint_address: &str) -> Result<Token> {
366 validate_solana_address(mint_address)?;
367
368 let request = RpcRequest {
369 jsonrpc: "2.0",
370 id: 1,
371 method: "getAccountInfo",
372 params: serde_json::json!([mint_address, { "encoding": "base64" }]),
373 };
374
375 tracing::debug!(
376 url = %self.rpc_url,
377 mint = %mint_address,
378 "Fetching SPL mint info"
379 );
380
381 #[derive(Deserialize)]
382 struct AccountInfoResult {
383 value: Option<AccountInfoValue>,
384 }
385 #[derive(Deserialize)]
386 struct AccountInfoValue {
387 data: Option<Vec<String>>,
388 }
389
390 let body = serde_json::to_string(&request)?;
391 let response: RpcResponse<AccountInfoResult> = self
392 .http
393 .send(Request::post_json(&self.rpc_url, body))
394 .await?
395 .json()?;
396
397 if let Some(error) = response.error {
398 return Err(ScopeError::Chain(format!(
399 "Solana RPC error ({}): {}",
400 error.code, error.message
401 )));
402 }
403
404 let account = response.result.and_then(|r| r.value).ok_or_else(|| {
405 ScopeError::NotFound(format!("Mint account not found: {}", mint_address))
406 })?;
407
408 let data_b64 = account
409 .data
410 .and_then(|d| d.into_iter().next())
411 .ok_or_else(|| {
412 ScopeError::Chain(format!("No account data for mint: {}", mint_address))
413 })?;
414
415 let data = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &data_b64)
416 .map_err(|e| ScopeError::Chain(format!("Failed to decode mint data: {}", e)))?;
417
418 const DECIMALS_OFFSET: usize = 40;
420 let decimals = if data.len() > DECIMALS_OFFSET {
421 data[DECIMALS_OFFSET]
422 } else {
423 return Err(ScopeError::Chain(format!(
424 "Invalid mint account data (too short): {}",
425 mint_address
426 )));
427 };
428
429 let short_mint = crate::display::text::abbreviate(mint_address);
430
431 Ok(Token {
432 contract_address: mint_address.to_string(),
433 symbol: short_mint,
434 name: "SPL Token".to_string(),
435 decimals,
436 })
437 }
438
439 pub async fn enrich_balance_usd(&self, balance: &mut Balance) {
441 let dex = crate::chains::DexClient::new();
442 if let Some(price) = dex.get_native_token_price("solana").await {
443 let lamports: f64 = balance.raw.parse().unwrap_or(0.0);
444 let sol = lamports / 10_f64.powi(SOL_DECIMALS as i32);
445 balance.usd_value = Some(sol * price);
446 }
447 }
448
449 pub async fn get_token_balances(&self, address: &str) -> Result<Vec<TokenBalance>> {
464 validate_solana_address(address)?;
465
466 const TOKEN_PROGRAM_ID: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
469
470 let request = serde_json::json!({
471 "jsonrpc": "2.0",
472 "id": 1,
473 "method": "getTokenAccountsByOwner",
474 "params": [
475 address,
476 { "programId": TOKEN_PROGRAM_ID },
477 { "encoding": "jsonParsed" }
478 ]
479 });
480
481 tracing::debug!(url = %self.rpc_url, address = %address, "Fetching SPL token balances");
482
483 let body = serde_json::to_string(&request)?;
484 let response: RpcResponse<TokenAccountsResponse> = self
485 .http
486 .send(Request::post_json(&self.rpc_url, body))
487 .await?
488 .json()?;
489
490 if let Some(error) = response.error {
491 return Err(ScopeError::Chain(format!(
492 "Solana RPC error ({}): {}",
493 error.code, error.message
494 )));
495 }
496
497 let accounts = response
498 .result
499 .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))?;
500
501 let token_balances: Vec<TokenBalance> = accounts
502 .value
503 .into_iter()
504 .filter_map(|account| {
505 let info = &account.account.data.parsed.info;
506 let ui_amount = info.token_amount.ui_amount.unwrap_or(0.0);
507
508 if ui_amount == 0.0 {
510 return None;
511 }
512
513 Some(TokenBalance {
514 mint: info.mint.clone(),
515 token_account: account.pubkey,
516 raw_amount: info.token_amount.amount.clone(),
517 ui_amount,
518 decimals: info.token_amount.decimals,
519 symbol: None, name: None,
521 })
522 })
523 .collect();
524
525 Ok(token_balances)
526 }
527
528 pub async fn get_signatures(&self, address: &str, limit: u32) -> Result<Vec<String>> {
539 let infos = self.get_signature_infos(address, limit).await?;
540 Ok(infos.into_iter().map(|s| s.signature).collect())
541 }
542
543 async fn get_signature_infos(&self, address: &str, limit: u32) -> Result<Vec<SignatureInfo>> {
545 validate_solana_address(address)?;
546
547 #[derive(Serialize)]
548 struct GetSignaturesParams<'a> {
549 limit: u32,
550 #[serde(skip_serializing_if = "Option::is_none")]
551 before: Option<&'a str>,
552 }
553
554 let request = RpcRequest {
555 jsonrpc: "2.0",
556 id: 1,
557 method: "getSignaturesForAddress",
558 params: (
559 address,
560 GetSignaturesParams {
561 limit,
562 before: None,
563 },
564 ),
565 };
566
567 tracing::debug!(
568 url = %self.rpc_url,
569 address = %address,
570 limit = %limit,
571 "Fetching Solana transaction signatures"
572 );
573
574 let body = serde_json::to_string(&request)?;
575 let response: RpcResponse<Vec<SignatureInfo>> = self
576 .http
577 .send(Request::post_json(&self.rpc_url, body))
578 .await?
579 .json()?;
580
581 if let Some(error) = response.error {
582 return Err(ScopeError::Chain(format!(
583 "Solana RPC error ({}): {}",
584 error.code, error.message
585 )));
586 }
587
588 response
589 .result
590 .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))
591 }
592
593 pub async fn get_transaction(&self, signature: &str) -> Result<Transaction> {
603 validate_solana_signature(signature)?;
605
606 let request = RpcRequest {
607 jsonrpc: "2.0",
608 id: 1,
609 method: "getTransaction",
610 params: serde_json::json!([
611 signature,
612 {
613 "encoding": "jsonParsed",
614 "maxSupportedTransactionVersion": 0
615 }
616 ]),
617 };
618
619 tracing::debug!(
620 url = %self.rpc_url,
621 signature = %signature,
622 "Fetching Solana transaction"
623 );
624
625 let body = serde_json::to_string(&request)?;
626 let response: RpcResponse<SolanaTransactionResult> = self
627 .http
628 .send(Request::post_json(&self.rpc_url, body))
629 .await?
630 .json()?;
631
632 if let Some(error) = response.error {
633 return Err(ScopeError::Chain(format!(
634 "Solana RPC error ({}): {}",
635 error.code, error.message
636 )));
637 }
638
639 let tx_result = response
640 .result
641 .ok_or_else(|| ScopeError::NotFound(format!("Transaction not found: {}", signature)))?;
642
643 let from = tx_result
645 .transaction
646 .as_ref()
647 .and_then(|tx| tx.message.as_ref())
648 .and_then(|msg| msg.account_keys.as_ref())
649 .and_then(|keys| keys.first())
650 .map(|key| match key {
651 AccountKeyEntry::String(s) => s.clone(),
652 AccountKeyEntry::Object { pubkey, .. } => pubkey.clone(),
653 })
654 .unwrap_or_default();
655
656 let value = tx_result
658 .meta
659 .as_ref()
660 .and_then(|meta| {
661 let pre = meta.pre_balances.as_ref()?;
662 let post = meta.post_balances.as_ref()?;
663 if pre.len() >= 2 && post.len() >= 2 {
664 let fee = meta.fee.unwrap_or(0);
666 let sent = pre[0].saturating_sub(post[0]).saturating_sub(fee);
667 if sent > 0 {
668 let sol = sent as f64 / 10_f64.powi(SOL_DECIMALS as i32);
669 return Some(format!("{:.9}", sol));
670 }
671 }
672 None
673 })
674 .unwrap_or_else(|| "0".to_string());
675
676 let to = tx_result
678 .transaction
679 .as_ref()
680 .and_then(|tx| tx.message.as_ref())
681 .and_then(|msg| msg.account_keys.as_ref())
682 .and_then(|keys| {
683 if keys.len() >= 2 {
684 Some(match &keys[1] {
685 AccountKeyEntry::String(s) => s.clone(),
686 AccountKeyEntry::Object { pubkey, .. } => pubkey.clone(),
687 })
688 } else {
689 None
690 }
691 });
692
693 let fee = tx_result
694 .meta
695 .as_ref()
696 .and_then(|meta| meta.fee)
697 .unwrap_or(0);
698
699 let status = tx_result.meta.as_ref().map(|meta| meta.err.is_none());
700
701 Ok(Transaction {
702 hash: signature.to_string(),
703 block_number: tx_result.slot,
704 timestamp: tx_result.block_time.map(|t| t as u64),
705 from,
706 to,
707 value,
708 gas_limit: 0, gas_used: None,
710 gas_price: fee.to_string(), nonce: 0,
712 input: String::new(),
713 status,
714 })
715 }
716
717 pub async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
728 validate_solana_address(address)?;
729
730 let sig_infos = self.get_signature_infos(address, limit).await?;
732
733 let transactions: Vec<Transaction> = sig_infos
734 .into_iter()
735 .map(|info| Transaction {
736 hash: info.signature,
737 block_number: Some(info.slot),
738 timestamp: info.block_time.map(|t| t as u64),
739 from: address.to_string(),
740 to: None,
741 value: "0".to_string(),
742 gas_limit: 0,
743 gas_used: None,
744 gas_price: "0".to_string(),
745 nonce: 0,
746 input: String::new(),
747 status: Some(info.err.is_none()),
748 })
749 .collect();
750
751 Ok(transactions)
752 }
753
754 pub async fn get_slot(&self) -> Result<u64> {
756 let request = RpcRequest {
757 jsonrpc: "2.0",
758 id: 1,
759 method: "getSlot",
760 params: (),
761 };
762
763 let body = serde_json::to_string(&request)?;
764 let response: RpcResponse<u64> = self
765 .http
766 .send(Request::post_json(&self.rpc_url, body))
767 .await?
768 .json()?;
769
770 if let Some(error) = response.error {
771 return Err(ScopeError::Chain(format!(
772 "Solana RPC error ({}): {}",
773 error.code, error.message
774 )));
775 }
776
777 response
778 .result
779 .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))
780 }
781}
782
783impl Default for SolanaClient {
784 fn default() -> Self {
785 Self {
786 http: crate::http::transport(),
787 rpc_url: DEFAULT_SOLANA_RPC.to_string(),
788 solscan_api_key: None,
789 }
790 }
791}
792
793pub fn validate_solana_address(address: &str) -> Result<()> {
803 if address.is_empty() {
807 return Err(ScopeError::InvalidAddress("Address cannot be empty".into()));
808 }
809
810 if address.len() < 32 || address.len() > 44 {
812 return Err(ScopeError::InvalidAddress(format!(
813 "Solana address must be 32-44 characters, got {}: {}",
814 address.len(),
815 address
816 )));
817 }
818
819 match bs58::decode(address).into_vec() {
821 Ok(bytes) => {
822 if bytes.len() != 32 {
824 return Err(ScopeError::InvalidAddress(format!(
825 "Solana address must decode to 32 bytes, got {}: {}",
826 bytes.len(),
827 address
828 )));
829 }
830 }
831 Err(e) => {
832 return Err(ScopeError::InvalidAddress(format!(
833 "Invalid base58 encoding: {}: {}",
834 e, address
835 )));
836 }
837 }
838
839 Ok(())
840}
841
842pub fn validate_solana_signature(signature: &str) -> Result<()> {
852 if signature.is_empty() {
856 return Err(ScopeError::InvalidHash("Signature cannot be empty".into()));
857 }
858
859 if signature.len() < 80 || signature.len() > 90 {
861 return Err(ScopeError::InvalidHash(format!(
862 "Solana signature must be 80-90 characters, got {}: {}",
863 signature.len(),
864 signature
865 )));
866 }
867
868 match bs58::decode(signature).into_vec() {
870 Ok(bytes) => {
871 if bytes.len() != 64 {
873 return Err(ScopeError::InvalidHash(format!(
874 "Solana signature must decode to 64 bytes, got {}: {}",
875 bytes.len(),
876 signature
877 )));
878 }
879 }
880 Err(e) => {
881 return Err(ScopeError::InvalidHash(format!(
882 "Invalid base58 encoding: {}: {}",
883 e, signature
884 )));
885 }
886 }
887
888 Ok(())
889}
890
891#[async_trait]
896impl ChainClient for SolanaClient {
897 fn chain_name(&self) -> &str {
898 "solana"
899 }
900
901 fn native_token_symbol(&self) -> &str {
902 "SOL"
903 }
904
905 async fn get_balance(&self, address: &str) -> Result<Balance> {
906 self.get_balance(address).await
907 }
908
909 async fn enrich_balance_usd(&self, balance: &mut Balance) {
910 self.enrich_balance_usd(balance).await
911 }
912
913 async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
914 self.get_transaction(hash).await
915 }
916
917 async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
918 self.get_transactions(address, limit).await
919 }
920
921 async fn get_block_number(&self) -> Result<u64> {
922 self.get_slot().await
923 }
924
925 async fn get_token_info(&self, address: &str) -> Result<Token> {
926 self.get_token_info(address).await
927 }
928
929 async fn get_token_balances(&self, address: &str) -> Result<Vec<crate::chains::TokenBalance>> {
930 let solana_balances = self.get_token_balances(address).await?;
931 Ok(solana_balances
932 .into_iter()
933 .map(|tb| crate::chains::TokenBalance {
934 token: Token {
935 contract_address: tb.mint.clone(),
936 symbol: tb
937 .symbol
938 .unwrap_or_else(|| tb.mint[..8.min(tb.mint.len())].to_string()),
939 name: tb.name.unwrap_or_else(|| "SPL Token".to_string()),
940 decimals: tb.decimals,
941 },
942 balance: tb.raw_amount,
943 formatted_balance: format!("{:.6}", tb.ui_amount),
944 usd_value: None,
945 })
946 .collect())
947 }
948}
949
950#[cfg(test)]
955mod tests {
956 use super::*;
957 use crate::chains::{Balance, ChainClient};
958
959 const VALID_ADDRESS: &str = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy";
961
962 const VALID_SIGNATURE: &str =
964 "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
965
966 #[test]
967 fn test_validate_solana_address_valid() {
968 assert!(validate_solana_address(VALID_ADDRESS).is_ok());
969 }
970
971 #[test]
972 fn test_validate_solana_address_empty() {
973 let result = validate_solana_address("");
974 assert!(result.is_err());
975 assert!(result.unwrap_err().to_string().contains("empty"));
976 }
977
978 #[test]
979 fn test_validate_solana_address_too_short() {
980 let result = validate_solana_address("DRpbCBMxVnDK7maPM5t");
981 assert!(result.is_err());
982 assert!(result.unwrap_err().to_string().contains("32-44"));
983 }
984
985 #[test]
986 fn test_validate_solana_address_too_long() {
987 let long_addr = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hyAAAAAAAAAAAA";
988 let result = validate_solana_address(long_addr);
989 assert!(result.is_err());
990 }
991
992 #[test]
993 fn test_validate_solana_address_invalid_base58() {
994 let result = validate_solana_address("0RpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
996 assert!(result.is_err());
997 assert!(result.unwrap_err().to_string().contains("base58"));
998 }
999
1000 #[test]
1001 fn test_validate_solana_address_wrong_decoded_length() {
1002 let result = validate_solana_address("abcdefghijabcdefghijabcdefghijab");
1005 assert!(result.is_err());
1006 }
1008
1009 #[test]
1010 fn test_validate_solana_signature_valid() {
1011 assert!(validate_solana_signature(VALID_SIGNATURE).is_ok());
1012 }
1013
1014 #[test]
1015 fn test_validate_solana_signature_empty() {
1016 let result = validate_solana_signature("");
1017 assert!(result.is_err());
1018 assert!(result.unwrap_err().to_string().contains("empty"));
1019 }
1020
1021 #[test]
1022 fn test_validate_solana_signature_too_short() {
1023 let result = validate_solana_signature("abc");
1024 assert!(result.is_err());
1025 assert!(result.unwrap_err().to_string().contains("80-90"));
1026 }
1027
1028 #[test]
1029 fn test_solana_client_default() {
1030 let client = SolanaClient::default();
1031 assert_eq!(client.chain_name(), "solana");
1032 assert_eq!(client.native_token_symbol(), "SOL");
1033 assert!(client.rpc_url.contains("mainnet-beta"));
1034 }
1035
1036 #[test]
1037 fn test_solana_client_with_rpc_url() {
1038 let client = SolanaClient::with_rpc_url("https://custom.rpc.com");
1039 assert_eq!(client.rpc_url, "https://custom.rpc.com");
1040 }
1041
1042 #[test]
1043 fn test_solana_client_new() {
1044 let config = ChainsConfig::default();
1045 let client = SolanaClient::new(&config);
1046 assert!(client.is_ok());
1047 }
1048
1049 #[test]
1050 fn test_solana_client_new_with_custom_rpc() {
1051 let config = ChainsConfig {
1052 solana_rpc: Some("https://my-solana-rpc.com".to_string()),
1053 ..Default::default()
1054 };
1055 let client = SolanaClient::new(&config).unwrap();
1056 assert_eq!(client.rpc_url, "https://my-solana-rpc.com");
1057 }
1058
1059 #[test]
1060 fn test_solana_client_new_with_api_key() {
1061 use std::collections::HashMap;
1062
1063 let mut api_keys = HashMap::new();
1064 api_keys.insert("solscan".to_string(), "test-key".to_string());
1065
1066 let config = ChainsConfig {
1067 api_keys,
1068 ..Default::default()
1069 };
1070
1071 let client = SolanaClient::new(&config).unwrap();
1072 assert_eq!(client.solscan_api_key, Some("test-key".to_string()));
1073 }
1074
1075 #[test]
1076 fn test_rpc_request_serialization() {
1077 let request = RpcRequest {
1078 jsonrpc: "2.0",
1079 id: 1,
1080 method: "getBalance",
1081 params: vec!["test"],
1082 };
1083
1084 let json = serde_json::to_string(&request).unwrap();
1085 assert!(json.contains("jsonrpc"));
1086 assert!(json.contains("getBalance"));
1087 }
1088
1089 #[test]
1090 fn test_rpc_response_deserialization() {
1091 let json = r#"{"jsonrpc":"2.0","result":{"value":1000000000},"id":1}"#;
1092 let response: RpcResponse<BalanceResponse> = serde_json::from_str(json).unwrap();
1093 assert!(response.result.is_some());
1094 assert_eq!(response.result.unwrap().value, 1_000_000_000);
1095 }
1096
1097 #[test]
1098 fn test_rpc_error_deserialization() {
1099 let json =
1100 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid request"},"id":1}"#;
1101 let response: RpcResponse<BalanceResponse> = serde_json::from_str(json).unwrap();
1102 assert!(response.error.is_some());
1103 let error = response.error.unwrap();
1104 assert_eq!(error.code, -32600);
1105 assert_eq!(error.message, "Invalid request");
1106 }
1107
1108 #[tokio::test]
1113 async fn test_get_balance() {
1114 let mut server = mockito::Server::new_async().await;
1115 let _mock = server
1116 .mock("POST", "/")
1117 .with_status(200)
1118 .with_header("content-type", "application/json")
1119 .with_body(r#"{"jsonrpc":"2.0","result":{"value":5000000000},"id":1}"#)
1120 .create_async()
1121 .await;
1122
1123 let client = SolanaClient::with_rpc_url(&server.url());
1124 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1125 assert_eq!(balance.raw, "5000000000");
1126 assert_eq!(balance.symbol, "SOL");
1127 assert_eq!(balance.decimals, 9);
1128 assert!(balance.formatted.contains("5.000000000"));
1129 }
1130
1131 #[tokio::test]
1132 async fn test_get_balance_zero() {
1133 let mut server = mockito::Server::new_async().await;
1134 let _mock = server
1135 .mock("POST", "/")
1136 .with_status(200)
1137 .with_header("content-type", "application/json")
1138 .with_body(r#"{"jsonrpc":"2.0","result":{"value":0},"id":1}"#)
1139 .create_async()
1140 .await;
1141
1142 let client = SolanaClient::with_rpc_url(&server.url());
1143 let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1144 assert_eq!(balance.raw, "0");
1145 assert!(balance.formatted.contains("0.000000000"));
1146 }
1147
1148 #[tokio::test]
1149 async fn test_get_balance_rpc_error() {
1150 let mut server = mockito::Server::new_async().await;
1151 let _mock = server
1152 .mock("POST", "/")
1153 .with_status(200)
1154 .with_header("content-type", "application/json")
1155 .with_body(
1156 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid params"},"id":1}"#,
1157 )
1158 .create_async()
1159 .await;
1160
1161 let client = SolanaClient::with_rpc_url(&server.url());
1162 let result = client.get_balance(VALID_ADDRESS).await;
1163 assert!(result.is_err());
1164 assert!(result.unwrap_err().to_string().contains("RPC error"));
1165 }
1166
1167 #[tokio::test]
1168 async fn test_get_balance_empty_response() {
1169 let mut server = mockito::Server::new_async().await;
1170 let _mock = server
1171 .mock("POST", "/")
1172 .with_status(200)
1173 .with_header("content-type", "application/json")
1174 .with_body(r#"{"jsonrpc":"2.0","id":1}"#)
1175 .create_async()
1176 .await;
1177
1178 let client = SolanaClient::with_rpc_url(&server.url());
1179 let result = client.get_balance(VALID_ADDRESS).await;
1180 assert!(result.is_err());
1181 assert!(result.unwrap_err().to_string().contains("Empty RPC"));
1182 }
1183
1184 #[tokio::test]
1185 async fn test_get_balance_invalid_address() {
1186 let client = SolanaClient::default();
1187 let result = client.get_balance("invalid").await;
1188 assert!(result.is_err());
1189 }
1190
1191 #[tokio::test]
1192 async fn test_get_transaction() {
1193 let mut server = mockito::Server::new_async().await;
1194 let _mock = server
1195 .mock("POST", "/")
1196 .with_status(200)
1197 .with_header("content-type", "application/json")
1198 .with_body(
1199 r#"{"jsonrpc":"2.0","result":{
1200 "slot":123456789,
1201 "blockTime":1700000000,
1202 "transaction":{
1203 "message":{
1204 "accountKeys":[
1205 {"pubkey":"DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy","signer":true},
1206 {"pubkey":"9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM","signer":false}
1207 ]
1208 }
1209 },
1210 "meta":{
1211 "fee":5000,
1212 "preBalances":[10000000000,5000000000],
1213 "postBalances":[8999995000,6000000000],
1214 "err":null
1215 }
1216 },"id":1}"#,
1217 )
1218 .create_async()
1219 .await;
1220
1221 let client = SolanaClient::with_rpc_url(&server.url());
1222 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1223 assert_eq!(tx.hash, VALID_SIGNATURE);
1224 assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1225 assert_eq!(
1226 tx.to,
1227 Some("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM".to_string())
1228 );
1229 assert_eq!(tx.block_number, Some(123456789));
1230 assert_eq!(tx.timestamp, Some(1700000000));
1231 assert!(tx.status.unwrap()); assert_eq!(tx.gas_price, "5000"); }
1234
1235 #[tokio::test]
1236 async fn test_get_transaction_failed() {
1237 let mut server = mockito::Server::new_async().await;
1238 let _mock = server
1239 .mock("POST", "/")
1240 .with_status(200)
1241 .with_header("content-type", "application/json")
1242 .with_body(r#"{"jsonrpc":"2.0","result":{
1243 "slot":100,
1244 "transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"]}},
1245 "meta":{"fee":5000,"preBalances":[1000],"postBalances":[1000],"err":{"InstructionError":[0,{"Custom":1}]}}
1246 },"id":1}"#)
1247 .create_async()
1248 .await;
1249
1250 let client = SolanaClient::with_rpc_url(&server.url());
1251 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1252 assert!(!tx.status.unwrap()); }
1254
1255 #[tokio::test]
1256 async fn test_get_transaction_not_found() {
1257 let mut server = mockito::Server::new_async().await;
1258 let _mock = server
1259 .mock("POST", "/")
1260 .with_status(200)
1261 .with_header("content-type", "application/json")
1262 .with_body(r#"{"jsonrpc":"2.0","result":null,"id":1}"#)
1263 .create_async()
1264 .await;
1265
1266 let client = SolanaClient::with_rpc_url(&server.url());
1267 let result = client.get_transaction(VALID_SIGNATURE).await;
1268 assert!(result.is_err());
1269 assert!(result.unwrap_err().to_string().contains("not found"));
1270 }
1271
1272 #[tokio::test]
1273 async fn test_get_transaction_string_account_keys() {
1274 let mut server = mockito::Server::new_async().await;
1275 let _mock = server
1276 .mock("POST", "/")
1277 .with_status(200)
1278 .with_header("content-type", "application/json")
1279 .with_body(r#"{"jsonrpc":"2.0","result":{
1280 "slot":100,
1281 "transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy","9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"]}},
1282 "meta":{"fee":5000,"preBalances":[1000000000,0],"postBalances":[999995000,0],"err":null}
1283 },"id":1}"#)
1284 .create_async()
1285 .await;
1286
1287 let client = SolanaClient::with_rpc_url(&server.url());
1288 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1289 assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1290 assert_eq!(
1291 tx.to,
1292 Some("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM".to_string())
1293 );
1294 }
1295
1296 #[tokio::test]
1297 async fn test_get_signatures() {
1298 let mut server = mockito::Server::new_async().await;
1299 let _mock = server
1300 .mock("POST", "/")
1301 .with_status(200)
1302 .with_header("content-type", "application/json")
1303 .with_body(r#"{"jsonrpc":"2.0","result":[
1304 {"signature":"5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW","slot":100,"blockTime":1700000000,"err":null},
1305 {"signature":"4VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUX","slot":101,"blockTime":1700000060,"err":{"InstructionError":[0,{"Custom":1}]}}
1306 ],"id":1}"#)
1307 .create_async()
1308 .await;
1309
1310 let client = SolanaClient::with_rpc_url(&server.url());
1311 let sigs = client.get_signatures(VALID_ADDRESS, 10).await.unwrap();
1312 assert_eq!(sigs.len(), 2);
1313 assert!(sigs[0].starts_with("5VERv8"));
1314 }
1315
1316 #[tokio::test]
1317 async fn test_get_transactions() {
1318 let mut server = mockito::Server::new_async().await;
1319 let _mock = server
1320 .mock("POST", "/")
1321 .with_status(200)
1322 .with_header("content-type", "application/json")
1323 .with_body(r#"{"jsonrpc":"2.0","result":[
1324 {"signature":"5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW","slot":100,"blockTime":1700000000,"err":null},
1325 {"signature":"4VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUX","slot":101,"blockTime":1700000060,"err":{"InstructionError":[0,{"Custom":1}]}}
1326 ],"id":1}"#)
1327 .create_async()
1328 .await;
1329
1330 let client = SolanaClient::with_rpc_url(&server.url());
1331 let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1332 assert_eq!(txs.len(), 2);
1333 assert!(txs[0].status.unwrap()); assert!(!txs[1].status.unwrap()); assert_eq!(txs[0].block_number, Some(100));
1336 assert_eq!(txs[0].timestamp, Some(1700000000));
1337 }
1338
1339 #[tokio::test]
1340 async fn test_get_token_balances() {
1341 let mut server = mockito::Server::new_async().await;
1342 let _mock = server
1343 .mock("POST", "/")
1344 .with_status(200)
1345 .with_header("content-type", "application/json")
1346 .with_body(
1347 r#"{"jsonrpc":"2.0","result":{"value":[
1348 {
1349 "pubkey":"TokenAccAddr1",
1350 "account":{
1351 "data":{
1352 "parsed":{
1353 "info":{
1354 "mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
1355 "tokenAmount":{
1356 "amount":"1000000",
1357 "decimals":6,
1358 "uiAmount":1.0,
1359 "uiAmountString":"1"
1360 }
1361 }
1362 }
1363 }
1364 }
1365 },
1366 {
1367 "pubkey":"TokenAccAddr2",
1368 "account":{
1369 "data":{
1370 "parsed":{
1371 "info":{
1372 "mint":"So11111111111111111111111111111111111111112",
1373 "tokenAmount":{
1374 "amount":"0",
1375 "decimals":9,
1376 "uiAmount":0.0,
1377 "uiAmountString":"0"
1378 }
1379 }
1380 }
1381 }
1382 }
1383 }
1384 ]},"id":1}"#,
1385 )
1386 .create_async()
1387 .await;
1388
1389 let client = SolanaClient::with_rpc_url(&server.url());
1390 let balances = client.get_token_balances(VALID_ADDRESS).await.unwrap();
1391 assert_eq!(balances.len(), 1);
1393 assert_eq!(
1394 balances[0].mint,
1395 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1396 );
1397 assert_eq!(balances[0].ui_amount, 1.0);
1398 assert_eq!(balances[0].decimals, 6);
1399 }
1400
1401 #[tokio::test]
1402 async fn test_get_token_balances_rpc_error() {
1403 let mut server = mockito::Server::new_async().await;
1404 let _mock = server
1405 .mock("POST", "/")
1406 .with_status(200)
1407 .with_header("content-type", "application/json")
1408 .with_body(
1409 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid params"},"id":1}"#,
1410 )
1411 .create_async()
1412 .await;
1413
1414 let client = SolanaClient::with_rpc_url(&server.url());
1415 let result = client.get_token_balances(VALID_ADDRESS).await;
1416 assert!(result.is_err());
1417 }
1418
1419 #[tokio::test]
1420 async fn test_get_slot() {
1421 let mut server = mockito::Server::new_async().await;
1422 let _mock = server
1423 .mock("POST", "/")
1424 .with_status(200)
1425 .with_header("content-type", "application/json")
1426 .with_body(r#"{"jsonrpc":"2.0","result":256000000,"id":1}"#)
1427 .create_async()
1428 .await;
1429
1430 let client = SolanaClient::with_rpc_url(&server.url());
1431 let slot = client.get_slot().await.unwrap();
1432 assert_eq!(slot, 256000000);
1433 }
1434
1435 #[tokio::test]
1436 async fn test_get_slot_error() {
1437 let mut server = mockito::Server::new_async().await;
1438 let _mock = server
1439 .mock("POST", "/")
1440 .with_status(200)
1441 .with_header("content-type", "application/json")
1442 .with_body(
1443 r#"{"jsonrpc":"2.0","error":{"code":-32005,"message":"Node is behind"},"id":1}"#,
1444 )
1445 .create_async()
1446 .await;
1447
1448 let client = SolanaClient::with_rpc_url(&server.url());
1449 let result = client.get_slot().await;
1450 assert!(result.is_err());
1451 }
1452
1453 #[test]
1454 fn test_validate_solana_signature_invalid_base58() {
1455 let bad_sig = "0OIl00000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
1457 let result = validate_solana_signature(bad_sig);
1458 assert!(result.is_err());
1459 }
1460
1461 #[test]
1462 fn test_validate_solana_signature_wrong_decoded_length() {
1463 let short = "11111111111111111111111111111111"; let result = validate_solana_signature(short);
1467 assert!(result.is_err());
1469 }
1470
1471 #[tokio::test]
1472 async fn test_get_transaction_rpc_error() {
1473 let mut server = mockito::Server::new_async().await;
1474 let _mock = server
1475 .mock("POST", "/")
1476 .with_status(200)
1477 .with_header("content-type", "application/json")
1478 .with_body(r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Transaction not found"},"id":1}"#)
1479 .create_async()
1480 .await;
1481
1482 let client = SolanaClient::with_rpc_url(&server.url());
1483 let result = client
1484 .get_transaction("5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW")
1485 .await;
1486 assert!(result.is_err());
1487 assert!(result.unwrap_err().to_string().contains("RPC error"));
1488 }
1489
1490 #[tokio::test]
1491 async fn test_solana_chain_client_trait_chain_name() {
1492 let client = SolanaClient::with_rpc_url("http://localhost:8899");
1493 let chain_client: &dyn ChainClient = &client;
1494 assert_eq!(chain_client.chain_name(), "solana");
1495 assert_eq!(chain_client.native_token_symbol(), "SOL");
1496 }
1497
1498 #[tokio::test]
1499 async fn test_chain_client_trait_get_balance() {
1500 let mut server = mockito::Server::new_async().await;
1501 let _mock = server
1502 .mock("POST", "/")
1503 .with_status(200)
1504 .with_header("content-type", "application/json")
1505 .with_body(
1506 r#"{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":1000000000},"id":1}"#,
1507 )
1508 .create_async()
1509 .await;
1510
1511 let client = SolanaClient::with_rpc_url(&server.url());
1512 let chain_client: &dyn ChainClient = &client;
1513 let balance = chain_client.get_balance(VALID_ADDRESS).await.unwrap();
1514 assert_eq!(balance.symbol, "SOL");
1515 }
1516
1517 #[tokio::test]
1518 async fn test_chain_client_trait_get_block_number() {
1519 let mut server = mockito::Server::new_async().await;
1520 let _mock = server
1521 .mock("POST", "/")
1522 .with_status(200)
1523 .with_header("content-type", "application/json")
1524 .with_body(r#"{"jsonrpc":"2.0","result":250000000,"id":1}"#)
1525 .create_async()
1526 .await;
1527
1528 let client = SolanaClient::with_rpc_url(&server.url());
1529 let chain_client: &dyn ChainClient = &client;
1530 let slot = chain_client.get_block_number().await.unwrap();
1531 assert_eq!(slot, 250000000);
1532 }
1533
1534 #[tokio::test]
1535 async fn test_chain_client_trait_get_token_balances() {
1536 let mut server = mockito::Server::new_async().await;
1537 let _mock = server
1538 .mock("POST", "/")
1539 .with_status(200)
1540 .with_header("content-type", "application/json")
1541 .with_body(
1542 r#"{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[
1543 {
1544 "pubkey":"TokenAccAddr1",
1545 "account":{
1546 "data":{
1547 "parsed":{
1548 "info":{
1549 "mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
1550 "tokenAmount":{
1551 "amount":"1000000",
1552 "decimals":6,
1553 "uiAmount":1.0,
1554 "uiAmountString":"1"
1555 }
1556 }
1557 }
1558 }
1559 }
1560 }
1561 ]},"id":1}"#,
1562 )
1563 .create_async()
1564 .await;
1565
1566 let client = SolanaClient::with_rpc_url(&server.url());
1567 let chain_client: &dyn ChainClient = &client;
1568 let balances = chain_client
1569 .get_token_balances(VALID_ADDRESS)
1570 .await
1571 .unwrap();
1572 assert!(!balances.is_empty());
1573 assert_eq!(
1575 balances[0].token.contract_address,
1576 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1577 );
1578 }
1579
1580 #[tokio::test]
1581 async fn test_chain_client_trait_get_transaction_solana() {
1582 let mut server = mockito::Server::new_async().await;
1583 let _mock = server
1584 .mock("POST", "/")
1585 .with_status(200)
1586 .with_header("content-type", "application/json")
1587 .with_body(
1588 r#"{"jsonrpc":"2.0","result":{
1589 "slot":200000000,
1590 "blockTime":1700000000,
1591 "meta":{
1592 "fee":5000,
1593 "preBalances":[1000000000,500000000],
1594 "postBalances":[999995000,500005000],
1595 "err":null
1596 },
1597 "transaction":{
1598 "message":{
1599 "accountKeys":[
1600 "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy",
1601 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1602 ]
1603 },
1604 "signatures":["5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh"]
1605 }
1606 },"id":1}"#,
1607 )
1608 .create_async()
1609 .await;
1610
1611 let client = SolanaClient::with_rpc_url(&server.url());
1612 let chain_client: &dyn ChainClient = &client;
1613 let tx = chain_client
1614 .get_transaction("5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh")
1615 .await
1616 .unwrap();
1617 assert!(!tx.hash.is_empty());
1618 assert!(tx.timestamp.is_some());
1619 }
1620
1621 #[tokio::test]
1622 async fn test_chain_client_trait_get_transactions_solana() {
1623 let mut server = mockito::Server::new_async().await;
1624 let _mock = server
1625 .mock("POST", "/")
1626 .with_status(200)
1627 .with_header("content-type", "application/json")
1628 .with_body(
1629 r#"{"jsonrpc":"2.0","result":[
1630 {
1631 "signature":"5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh",
1632 "slot":200000000,
1633 "blockTime":1700000000,
1634 "err":null,
1635 "memo":null
1636 }
1637 ],"id":1}"#,
1638 )
1639 .create_async()
1640 .await;
1641
1642 let client = SolanaClient::with_rpc_url(&server.url());
1643 let chain_client: &dyn ChainClient = &client;
1644 let txs = chain_client
1645 .get_transactions(VALID_ADDRESS, 10)
1646 .await
1647 .unwrap();
1648 assert!(!txs.is_empty());
1649 }
1650
1651 #[test]
1652 fn test_validate_solana_signature_wrong_byte_length() {
1653 let long_sig = "1".repeat(88); let result = validate_solana_signature(&long_sig);
1661 assert!(result.is_err());
1662 let err = result.unwrap_err().to_string();
1663 assert!(err.contains("64 bytes") || err.contains("base58"));
1664 }
1665
1666 #[tokio::test]
1667 async fn test_rpc_error_response() {
1668 let mut server = mockito::Server::new_async().await;
1669 let _mock = server
1670 .mock("POST", "/")
1671 .with_status(200)
1672 .with_header("content-type", "application/json")
1673 .with_body(
1674 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid request"},"id":1}"#,
1675 )
1676 .create_async()
1677 .await;
1678
1679 let client = SolanaClient::with_rpc_url(&server.url());
1680 let result = client.get_balance(VALID_ADDRESS).await;
1681 assert!(result.is_err());
1682 assert!(result.unwrap_err().to_string().contains("RPC error"));
1683 }
1684
1685 #[tokio::test]
1690 async fn test_get_token_info_success() {
1691 let mut mint_data = vec![0u8; 41];
1694 mint_data[40] = 6;
1695 let data_b64 =
1696 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &mint_data);
1697
1698 let mut server = mockito::Server::new_async().await;
1699 let _mock = server
1700 .mock("POST", "/")
1701 .with_status(200)
1702 .with_header("content-type", "application/json")
1703 .with_body(format!(
1704 r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
1705 data_b64
1706 ))
1707 .create_async()
1708 .await;
1709
1710 let client = SolanaClient::with_rpc_url(&server.url());
1711 let token = client
1712 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1713 .await
1714 .unwrap();
1715 assert_eq!(token.decimals, 6);
1716 assert_eq!(
1717 token.contract_address,
1718 "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1719 );
1720 assert_eq!(token.symbol, "EPjFWd…Dt1v");
1721 assert_eq!(token.name, "SPL Token");
1722 }
1723
1724 #[tokio::test]
1725 async fn test_get_token_info_decimals_nine() {
1726 let mut mint_data = vec![0u8; 41];
1727 mint_data[40] = 9;
1728 let data_b64 =
1729 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &mint_data);
1730
1731 let mut server = mockito::Server::new_async().await;
1732 let _mock = server
1733 .mock("POST", "/")
1734 .with_status(200)
1735 .with_header("content-type", "application/json")
1736 .with_body(format!(
1737 r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
1738 data_b64
1739 ))
1740 .create_async()
1741 .await;
1742
1743 let client = SolanaClient::with_rpc_url(&server.url());
1744 let token = client
1745 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1746 .await
1747 .unwrap();
1748 assert_eq!(token.decimals, 9);
1749 assert_eq!(token.symbol, "EPjFWd…Dt1v"); }
1751
1752 #[tokio::test]
1753 async fn test_get_token_info_rpc_error() {
1754 let mut server = mockito::Server::new_async().await;
1755 let _mock = server
1756 .mock("POST", "/")
1757 .with_status(200)
1758 .with_header("content-type", "application/json")
1759 .with_body(
1760 r#"{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params"},"id":1}"#,
1761 )
1762 .create_async()
1763 .await;
1764
1765 let client = SolanaClient::with_rpc_url(&server.url());
1766 let result = client
1767 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1768 .await;
1769 assert!(result.is_err());
1770 assert!(result.unwrap_err().to_string().contains("RPC error"));
1771 }
1772
1773 #[tokio::test]
1774 async fn test_get_token_info_not_found() {
1775 let mut server = mockito::Server::new_async().await;
1776 let _mock = server
1777 .mock("POST", "/")
1778 .with_status(200)
1779 .with_header("content-type", "application/json")
1780 .with_body(r#"{"jsonrpc":"2.0","result":{"value":null},"id":1}"#)
1781 .create_async()
1782 .await;
1783
1784 let client = SolanaClient::with_rpc_url(&server.url());
1785 let result = client
1786 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1787 .await;
1788 assert!(result.is_err());
1789 assert!(result.unwrap_err().to_string().contains("not found"));
1790 }
1791
1792 #[tokio::test]
1793 async fn test_get_token_info_no_account_data() {
1794 let mut server = mockito::Server::new_async().await;
1795 let _mock = server
1796 .mock("POST", "/")
1797 .with_status(200)
1798 .with_header("content-type", "application/json")
1799 .with_body(r#"{"jsonrpc":"2.0","result":{"value":{"data":null}},"id":1}"#)
1800 .create_async()
1801 .await;
1802
1803 let client = SolanaClient::with_rpc_url(&server.url());
1804 let result = client
1805 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1806 .await;
1807 assert!(result.is_err());
1808 assert!(result.unwrap_err().to_string().contains("No account data"));
1809 }
1810
1811 #[tokio::test]
1812 async fn test_get_token_info_empty_data_array() {
1813 let mut server = mockito::Server::new_async().await;
1814 let _mock = server
1815 .mock("POST", "/")
1816 .with_status(200)
1817 .with_header("content-type", "application/json")
1818 .with_body(r#"{"jsonrpc":"2.0","result":{"value":{"data":[]}},"id":1}"#)
1819 .create_async()
1820 .await;
1821
1822 let client = SolanaClient::with_rpc_url(&server.url());
1823 let result = client
1824 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1825 .await;
1826 assert!(result.is_err());
1827 }
1828
1829 #[tokio::test]
1830 async fn test_get_token_info_invalid_base64() {
1831 let mut server = mockito::Server::new_async().await;
1832 let _mock = server
1833 .mock("POST", "/")
1834 .with_status(200)
1835 .with_header("content-type", "application/json")
1836 .with_body(r#"{"jsonrpc":"2.0","result":{"value":{"data":["!!!invalid!!!"]}},"id":1}"#)
1837 .create_async()
1838 .await;
1839
1840 let client = SolanaClient::with_rpc_url(&server.url());
1841 let result = client
1842 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1843 .await;
1844 assert!(result.is_err());
1845 assert!(result.unwrap_err().to_string().contains("decode"));
1846 }
1847
1848 #[tokio::test]
1849 async fn test_get_token_info_data_too_short() {
1850 let short_data = vec![0u8; 20];
1852 let data_b64 =
1853 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &short_data);
1854
1855 let mut server = mockito::Server::new_async().await;
1856 let _mock = server
1857 .mock("POST", "/")
1858 .with_status(200)
1859 .with_header("content-type", "application/json")
1860 .with_body(format!(
1861 r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
1862 data_b64
1863 ))
1864 .create_async()
1865 .await;
1866
1867 let client = SolanaClient::with_rpc_url(&server.url());
1868 let result = client
1869 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1870 .await;
1871 assert!(result.is_err());
1872 assert!(result.unwrap_err().to_string().contains("too short"));
1873 }
1874
1875 #[tokio::test]
1876 async fn test_get_token_info_invalid_address() {
1877 let client = SolanaClient::default();
1878 let result = client.get_token_info("bad").await;
1879 assert!(result.is_err());
1880 }
1881
1882 #[tokio::test]
1887 async fn test_get_transaction_minimal_no_transaction_meta() {
1888 let mut server = mockito::Server::new_async().await;
1889 let _mock = server
1890 .mock("POST", "/")
1891 .with_status(200)
1892 .with_header("content-type", "application/json")
1893 .with_body(r#"{"jsonrpc":"2.0","result":{"slot":100},"id":1}"#)
1894 .create_async()
1895 .await;
1896
1897 let client = SolanaClient::with_rpc_url(&server.url());
1898 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1899 assert_eq!(tx.hash, VALID_SIGNATURE);
1900 assert_eq!(tx.from, "");
1901 assert_eq!(tx.to, None);
1902 assert_eq!(tx.value, "0");
1903 assert_eq!(tx.gas_price, "0");
1904 assert_eq!(tx.block_number, Some(100));
1905 assert_eq!(tx.status, None);
1906 }
1907
1908 #[tokio::test]
1909 async fn test_get_transaction_single_account_key_to_none() {
1910 let mut server = mockito::Server::new_async().await;
1911 let _mock = server
1912 .mock("POST", "/")
1913 .with_status(200)
1914 .with_header("content-type", "application/json")
1915 .with_body(r#"{"jsonrpc":"2.0","result":{
1916 "slot":100,
1917 "blockTime":1700000000,
1918 "transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"]}},
1919 "meta":{"fee":5000,"preBalances":[1000],"postBalances":[500],"err":null}
1920 },"id":1}"#)
1921 .create_async()
1922 .await;
1923
1924 let client = SolanaClient::with_rpc_url(&server.url());
1925 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1926 assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1927 assert_eq!(tx.to, None); assert_eq!(tx.value, "0"); }
1930
1931 #[tokio::test]
1932 async fn test_get_transaction_value_zero_when_no_send() {
1933 let mut server = mockito::Server::new_async().await;
1934 let _mock = server
1935 .mock("POST", "/")
1936 .with_status(200)
1937 .with_header("content-type", "application/json")
1938 .with_body(
1939 r#"{"jsonrpc":"2.0","result":{
1940 "slot":100,
1941 "transaction":{"message":{"accountKeys":["A","B"]}},
1942 "meta":{"fee":5000,"preBalances":[1000,500],"postBalances":[1000,500],"err":null}
1943 },"id":1}"#,
1944 )
1945 .create_async()
1946 .await;
1947
1948 let client = SolanaClient::with_rpc_url(&server.url());
1949 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1950 assert_eq!(tx.value, "0"); }
1952
1953 #[tokio::test]
1954 async fn test_get_transaction_meta_no_fee() {
1955 let mut server = mockito::Server::new_async().await;
1956 let _mock = server
1957 .mock("POST", "/")
1958 .with_status(200)
1959 .with_header("content-type", "application/json")
1960 .with_body(r#"{"jsonrpc":"2.0","result":{
1961 "slot":100,
1962 "transaction":{"message":{"accountKeys":["A","B"]}},
1963 "meta":{"preBalances":[10000000000,0],"postBalances":[8999995000,1000005000],"err":null}
1964 },"id":1}"#)
1965 .create_async()
1966 .await;
1967
1968 let client = SolanaClient::with_rpc_url(&server.url());
1969 let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1970 assert_eq!(tx.gas_price, "0"); }
1972
1973 #[tokio::test]
1974 async fn test_get_transaction_invalid_signature() {
1975 let client = SolanaClient::default();
1976 let result = client.get_transaction("invalid").await;
1977 assert!(result.is_err());
1978 }
1979
1980 #[tokio::test]
1985 async fn test_get_signatures_empty_response() {
1986 let mut server = mockito::Server::new_async().await;
1987 let _mock = server
1988 .mock("POST", "/")
1989 .with_status(200)
1990 .with_header("content-type", "application/json")
1991 .with_body(r#"{"jsonrpc":"2.0","id":1}"#)
1992 .create_async()
1993 .await;
1994
1995 let client = SolanaClient::with_rpc_url(&server.url());
1996 let result = client.get_signatures(VALID_ADDRESS, 10).await;
1997 assert!(result.is_err());
1998 assert!(result.unwrap_err().to_string().contains("Empty RPC"));
1999 }
2000
2001 #[tokio::test]
2002 async fn test_get_signatures_invalid_address() {
2003 let client = SolanaClient::default();
2004 let result = client.get_signatures("x", 10).await;
2005 assert!(result.is_err());
2006 }
2007
2008 #[tokio::test]
2009 async fn test_get_signatures_rpc_error() {
2010 let mut server = mockito::Server::new_async().await;
2011 let _mock = server
2012 .mock("POST", "/")
2013 .with_status(200)
2014 .with_header("content-type", "application/json")
2015 .with_body(
2016 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid address"},"id":1}"#,
2017 )
2018 .create_async()
2019 .await;
2020
2021 let client = SolanaClient::with_rpc_url(&server.url());
2022 let result = client.get_signatures(VALID_ADDRESS, 10).await;
2023 assert!(result.is_err());
2024 }
2025
2026 #[tokio::test]
2027 async fn test_get_transactions_invalid_address() {
2028 let client = SolanaClient::default();
2029 let result = client.get_transactions("invalid-addr", 10).await;
2030 assert!(result.is_err());
2031 }
2032
2033 #[tokio::test]
2038 async fn test_get_slot_empty_response() {
2039 let mut server = mockito::Server::new_async().await;
2040 let _mock = server
2041 .mock("POST", "/")
2042 .with_status(200)
2043 .with_header("content-type", "application/json")
2044 .with_body(r#"{"jsonrpc":"2.0","id":1}"#)
2045 .create_async()
2046 .await;
2047
2048 let client = SolanaClient::with_rpc_url(&server.url());
2049 let result = client.get_slot().await;
2050 assert!(result.is_err());
2051 assert!(result.unwrap_err().to_string().contains("Empty RPC"));
2052 }
2053
2054 #[tokio::test]
2059 async fn test_get_token_balances_ui_amount_null() {
2060 let mut server = mockito::Server::new_async().await;
2061 let _mock = server
2062 .mock("POST", "/")
2063 .with_status(200)
2064 .with_header("content-type", "application/json")
2065 .with_body(
2066 r#"{"jsonrpc":"2.0","result":{"value":[{
2067 "pubkey":"TokenAccAddr1",
2068 "account":{
2069 "data":{
2070 "parsed":{
2071 "info":{
2072 "mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
2073 "tokenAmount":{
2074 "amount":"0",
2075 "decimals":6,
2076 "uiAmount":null,
2077 "uiAmountString":"0"
2078 }
2079 }
2080 }
2081 }
2082 }
2083 }]},"id":1}"#,
2084 )
2085 .create_async()
2086 .await;
2087
2088 let client = SolanaClient::with_rpc_url(&server.url());
2089 let balances = client.get_token_balances(VALID_ADDRESS).await.unwrap();
2090 assert_eq!(balances.len(), 0);
2092 }
2093
2094 #[test]
2095 fn test_token_balance_serialization() {
2096 let tb = TokenBalance {
2097 mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
2098 token_account: "TokenAcc1".to_string(),
2099 raw_amount: "1000000".to_string(),
2100 ui_amount: 1.5,
2101 decimals: 6,
2102 symbol: Some("USDC".to_string()),
2103 name: Some("USD Coin".to_string()),
2104 };
2105 let json = serde_json::to_string(&tb).unwrap();
2106 assert!(json.contains("USDC"));
2107 assert!(json.contains("1000000"));
2108 }
2109
2110 #[tokio::test]
2111 async fn test_chain_client_get_token_balances_short_mint() {
2112 let mut server = mockito::Server::new_async().await;
2113 let _mock = server
2114 .mock("POST", "/")
2115 .with_status(200)
2116 .with_header("content-type", "application/json")
2117 .with_body(
2118 r#"{"jsonrpc":"2.0","result":{"value":[{
2119 "pubkey":"TokenAcc1",
2120 "account":{
2121 "data":{
2122 "parsed":{
2123 "info":{
2124 "mint":"Short",
2125 "tokenAmount":{
2126 "amount":"100",
2127 "decimals":2,
2128 "uiAmount":1.0,
2129 "uiAmountString":"1"
2130 }
2131 }
2132 }
2133 }
2134 }
2135 }]},"id":1}"#,
2136 )
2137 .create_async()
2138 .await;
2139
2140 let client = SolanaClient::with_rpc_url(&server.url());
2141 let chain_client: &dyn ChainClient = &client;
2142 let balances = chain_client
2143 .get_token_balances("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy")
2144 .await
2145 .unwrap();
2146 assert_eq!(balances.len(), 1);
2147 assert_eq!(balances[0].token.symbol, "Short");
2149 }
2150
2151 #[test]
2156 fn test_validate_solana_signature_too_long() {
2157 let too_long = "1".repeat(91);
2158 let result = validate_solana_signature(&too_long);
2159 assert!(result.is_err());
2160 assert!(result.unwrap_err().to_string().contains("80-90"));
2161 }
2162
2163 #[tokio::test]
2168 async fn test_enrich_balance_usd_invalid_raw_does_not_panic() {
2169 let client = SolanaClient::default();
2170 let mut balance = Balance {
2171 raw: "not-a-number".to_string(),
2172 formatted: "0 SOL".to_string(),
2173 decimals: 9,
2174 symbol: "SOL".to_string(),
2175 usd_value: None,
2176 };
2177 client.enrich_balance_usd(&mut balance).await;
2179 assert!(balance.usd_value.is_none() || balance.usd_value == Some(0.0));
2181 }
2182
2183 #[tokio::test]
2184 async fn test_chain_client_trait_enrich_balance_usd() {
2185 let client = SolanaClient::default();
2186 let chain_client: &dyn ChainClient = &client;
2187 let mut balance = Balance {
2188 raw: "not-a-number".to_string(),
2189 formatted: "0 SOL".to_string(),
2190 decimals: 9,
2191 symbol: "SOL".to_string(),
2192 usd_value: None,
2193 };
2194 chain_client.enrich_balance_usd(&mut balance).await;
2195 assert!(balance.usd_value.is_none() || balance.usd_value == Some(0.0));
2196 }
2197
2198 #[tokio::test]
2199 async fn test_chain_client_trait_get_token_info() {
2200 let mut mint_data = vec![0u8; 41];
2201 mint_data[40] = 18;
2202 let data_b64 =
2203 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &mint_data);
2204
2205 let mut server = mockito::Server::new_async().await;
2206 let _mock = server
2207 .mock("POST", "/")
2208 .with_status(200)
2209 .with_header("content-type", "application/json")
2210 .with_body(format!(
2211 r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
2212 data_b64
2213 ))
2214 .create_async()
2215 .await;
2216
2217 let client = SolanaClient::with_rpc_url(&server.url());
2218 let chain_client: &dyn ChainClient = &client;
2219 let token = chain_client
2220 .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
2221 .await
2222 .unwrap();
2223 assert_eq!(token.decimals, 18);
2224 }
2225
2226 #[test]
2231 fn test_solscan_account_info_deserialization() {
2232 let json = r#"{"lamports":1000000,"type":"account"}"#;
2233 let info: SolscanAccountInfo = serde_json::from_str(json).unwrap();
2234 assert_eq!(info.lamports, 1000000);
2235 assert_eq!(info.account_type, Some("account".to_string()));
2236 }
2237}