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