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