Skip to main content

scope/chains/
solana.rs

1//! # Solana Client
2//!
3//! This module provides a Solana blockchain client for querying balances,
4//! transactions, and account information on the Solana network.
5//!
6//! ## Features
7//!
8//! - Balance queries via Solana JSON-RPC (with USD valuation via DexScreener)
9//! - Transaction details lookup via `getTransaction` RPC (jsonParsed encoding)
10//! - Enriched transaction history with slot, timestamp, and status from `getSignaturesForAddress`
11//! - SPL token balance fetching via `getTokenAccountsByOwner`
12//! - Base58 address and signature validation
13//! - Support for both legacy and versioned transactions
14//!
15//! ## Usage
16//!
17//! ```rust,no_run
18//! use scope::chains::SolanaClient;
19//! use scope::config::ChainsConfig;
20//!
21//! #[tokio::main]
22//! async fn main() -> scope::Result<()> {
23//!     let config = ChainsConfig::default();
24//!     let client = SolanaClient::new(&config)?;
25//!     
26//!     let mut balance = client.get_balance("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy").await?;
27//!     client.enrich_balance_usd(&mut balance).await;
28//!     println!("Balance: {} SOL", balance.formatted);
29//!     Ok(())
30//! }
31//! ```
32
33use 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
40/// Default Solana mainnet RPC endpoint.
41const DEFAULT_SOLANA_RPC: &str = "https://api.mainnet-beta.solana.com";
42
43/// Solscan API base URL for transaction history.
44#[allow(dead_code)] // Reserved for future Solscan integration
45const SOLSCAN_API_URL: &str = "https://api.solscan.io";
46
47/// Solana native token decimals.
48const SOL_DECIMALS: u8 = 9;
49
50/// Solana blockchain client.
51///
52/// Supports balance queries via JSON-RPC and optional transaction
53/// history via Solscan API.
54#[derive(Debug, Clone)]
55pub struct SolanaClient {
56    /// HTTP client for API requests.
57    client: Client,
58
59    /// Solana JSON-RPC endpoint URL.
60    rpc_url: String,
61
62    /// Solscan API key for enhanced transaction data.
63    #[allow(dead_code)] // Reserved for future Solscan integration
64    solscan_api_key: Option<String>,
65}
66
67/// JSON-RPC request structure.
68#[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/// JSON-RPC response structure.
77#[derive(Debug, Deserialize)]
78struct RpcResponse<T> {
79    result: Option<T>,
80    error: Option<RpcError>,
81}
82
83/// JSON-RPC error structure.
84#[derive(Debug, Deserialize)]
85struct RpcError {
86    code: i64,
87    message: String,
88}
89
90/// Balance response from getBalance RPC call.
91#[derive(Debug, Deserialize)]
92struct BalanceResponse {
93    value: u64,
94}
95
96/// Response structure for getTokenAccountsByOwner.
97#[derive(Debug, Deserialize)]
98struct TokenAccountsResponse {
99    value: Vec<TokenAccountInfo>,
100}
101
102/// Individual token account info.
103#[derive(Debug, Deserialize)]
104struct TokenAccountInfo {
105    pubkey: String,
106    account: TokenAccountData,
107}
108
109/// Token account data.
110#[derive(Debug, Deserialize)]
111struct TokenAccountData {
112    data: TokenAccountParsedData,
113}
114
115/// Parsed token account data.
116#[derive(Debug, Deserialize)]
117struct TokenAccountParsedData {
118    parsed: TokenAccountParsedInfo,
119}
120
121/// Parsed info containing token details.
122#[derive(Debug, Deserialize)]
123struct TokenAccountParsedInfo {
124    info: TokenInfo,
125}
126
127/// Token balance and mint information.
128#[derive(Debug, Deserialize)]
129#[serde(rename_all = "camelCase")]
130struct TokenInfo {
131    mint: String,
132    token_amount: TokenAmount,
133}
134
135/// Token amount with UI representation.
136#[derive(Debug, Deserialize)]
137#[serde(rename_all = "camelCase")]
138#[allow(dead_code)] // ui_amount_string reserved for future use
139struct TokenAmount {
140    amount: String,
141    decimals: u8,
142    ui_amount: Option<f64>,
143    ui_amount_string: String,
144}
145
146/// SPL Token balance with metadata.
147#[derive(Debug, Clone, Serialize)]
148pub struct TokenBalance {
149    /// Token mint address.
150    pub mint: String,
151    /// Token account address.
152    pub token_account: String,
153    /// Raw balance in smallest unit.
154    pub raw_amount: String,
155    /// Human-readable balance.
156    pub ui_amount: f64,
157    /// Token decimals.
158    pub decimals: u8,
159    /// Token symbol (if known).
160    pub symbol: Option<String>,
161    /// Token name (if known).
162    pub name: Option<String>,
163}
164
165/// Transaction signature info from getSignaturesForAddress.
166#[derive(Debug, Deserialize)]
167#[serde(rename_all = "camelCase")]
168#[allow(dead_code)] // Fields used for deserialization
169struct SignatureInfo {
170    signature: String,
171    slot: u64,
172    block_time: Option<i64>,
173    err: Option<serde_json::Value>,
174}
175
176/// Solana transaction result from getTransaction RPC.
177#[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/// Transaction data from Solana RPC.
191#[derive(Debug, Deserialize)]
192struct SolanaTransactionData {
193    #[serde(default)]
194    message: Option<SolanaTransactionMessage>,
195}
196
197/// Transaction message from Solana RPC.
198#[derive(Debug, Deserialize)]
199#[serde(rename_all = "camelCase")]
200struct SolanaTransactionMessage {
201    #[serde(default)]
202    account_keys: Option<Vec<AccountKeyEntry>>,
203}
204
205/// Account key can be a string or an object with pubkey + signer fields.
206#[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/// Transaction metadata from Solana RPC.
219#[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/// Solscan account info response.
233#[derive(Debug, Deserialize)]
234#[allow(dead_code)] // Reserved for future Solscan integration
235struct SolscanAccountInfo {
236    lamports: u64,
237    #[serde(rename = "type")]
238    account_type: Option<String>,
239}
240
241impl SolanaClient {
242    /// Creates a new Solana client with the given configuration.
243    ///
244    /// # Arguments
245    ///
246    /// * `config` - Chain configuration containing RPC endpoint and API keys
247    ///
248    /// # Returns
249    ///
250    /// Returns a configured [`SolanaClient`] instance.
251    ///
252    /// # Examples
253    ///
254    /// ```rust,no_run
255    /// use scope::chains::SolanaClient;
256    /// use scope::config::ChainsConfig;
257    ///
258    /// let config = ChainsConfig::default();
259    /// let client = SolanaClient::new(&config).unwrap();
260    /// ```
261    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    /// Creates a client with a custom RPC URL.
281    ///
282    /// # Arguments
283    ///
284    /// * `rpc_url` - The Solana JSON-RPC endpoint URL
285    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    /// Returns the chain name.
294    pub fn chain_name(&self) -> &str {
295        "solana"
296    }
297
298    /// Returns the native token symbol.
299    pub fn native_token_symbol(&self) -> &str {
300        "SOL"
301    }
302
303    /// Fetches the SOL balance for an address.
304    ///
305    /// # Arguments
306    ///
307    /// * `address` - The Solana address (base58 encoded)
308    ///
309    /// # Returns
310    ///
311    /// Returns a [`Balance`] struct with the balance in multiple formats.
312    ///
313    /// # Errors
314    ///
315    /// Returns [`ScopeError::InvalidAddress`] if the address format is invalid.
316    /// Returns [`ScopeError::Request`] if the API request fails.
317    pub async fn get_balance(&self, address: &str) -> Result<Balance> {
318        // Validate address
319        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, // Populated by caller via enrich_balance_usd
359        })
360    }
361
362    /// Enriches a balance with a USD value using DexScreener price lookup.
363    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    /// Fetches all SPL token balances for an address.
373    ///
374    /// # Arguments
375    ///
376    /// * `address` - The Solana wallet address to query
377    ///
378    /// # Returns
379    ///
380    /// Returns a vector of [`TokenBalance`] containing all SPL tokens held by the address.
381    ///
382    /// # Errors
383    ///
384    /// Returns [`ScopeError::InvalidAddress`] if the address format is invalid.
385    /// Returns [`ScopeError::Request`] if the API request fails.
386    pub async fn get_token_balances(&self, address: &str) -> Result<Vec<TokenBalance>> {
387        validate_solana_address(address)?;
388
389        // Use getTokenAccountsByOwner to get all token accounts
390        // The TOKEN_PROGRAM_ID is the standard SPL Token program
391        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                // Skip zero balances
434                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, // Would need token metadata to get this
445                    name: None,
446                })
447            })
448            .collect();
449
450        Ok(token_balances)
451    }
452
453    /// Fetches recent transaction signatures for an address.
454    ///
455    /// # Arguments
456    ///
457    /// * `address` - The Solana address to query
458    /// * `limit` - Maximum number of signatures to return
459    ///
460    /// # Returns
461    ///
462    /// Returns a vector of transaction signatures.
463    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    /// Fetches recent transaction signature info (with metadata) for an address.
469    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    /// Fetches transaction details by signature.
521    ///
522    /// # Arguments
523    ///
524    /// * `signature` - The transaction signature (base58 encoded)
525    ///
526    /// # Returns
527    ///
528    /// Returns [`Transaction`] details.
529    pub async fn get_transaction(&self, signature: &str) -> Result<Transaction> {
530        // Validate signature format
531        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        // Extract the first signer (fee payer) as "from"
573        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        // Try to find the SOL transfer amount from the transaction
586        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                    // Amount sent = pre[0] - post[0] - fee (fee payer's balance change minus fee)
594                    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        // Extract "to" address (second account key, typically the recipient)
606        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, // Solana uses compute units, not gas
638            gas_used: None,
639            gas_price: fee.to_string(), // Use fee as gas_price equivalent
640            nonce: 0,
641            input: String::new(),
642            status,
643        })
644    }
645
646    /// Fetches recent transactions for an address.
647    ///
648    /// # Arguments
649    ///
650    /// * `address` - The address to query
651    /// * `limit` - Maximum number of transactions
652    ///
653    /// # Returns
654    ///
655    /// Returns a vector of [`Transaction`] objects.
656    pub async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
657        validate_solana_address(address)?;
658
659        // Get signature infos (includes slot, blockTime, err)
660        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    /// Fetches the current slot number (equivalent to block number).
684    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
724/// Validates a Solana address format (base58 encoded, 32-44 characters).
725///
726/// # Arguments
727///
728/// * `address` - The address to validate
729///
730/// # Returns
731///
732/// Returns `Ok(())` if valid, or an error describing the validation failure.
733pub fn validate_solana_address(address: &str) -> Result<()> {
734    // Solana addresses are base58 encoded ed25519 public keys
735    // They are typically 32-44 characters long
736
737    if address.is_empty() {
738        return Err(ScopeError::InvalidAddress("Address cannot be empty".into()));
739    }
740
741    // Check length (base58 encoded 32-byte keys are 32-44 chars)
742    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    // Validate base58 encoding
751    match bs58::decode(address).into_vec() {
752        Ok(bytes) => {
753            // Should decode to 32 bytes (ed25519 public key)
754            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
773/// Validates a Solana transaction signature format (base58 encoded).
774///
775/// # Arguments
776///
777/// * `signature` - The signature to validate
778///
779/// # Returns
780///
781/// Returns `Ok(())` if valid, or an error describing the validation failure.
782pub fn validate_solana_signature(signature: &str) -> Result<()> {
783    // Solana signatures are base58 encoded 64-byte signatures
784    // They are typically 87-88 characters long
785
786    if signature.is_empty() {
787        return Err(ScopeError::InvalidHash("Signature cannot be empty".into()));
788    }
789
790    // Check length (base58 encoded 64-byte signatures are ~87-88 chars)
791    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    // Validate base58 encoding
800    match bs58::decode(signature).into_vec() {
801        Ok(bytes) => {
802            // Should decode to 64 bytes (ed25519 signature)
803            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// ============================================================================
823// ChainClient Trait Implementation
824// ============================================================================
825
826#[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// ============================================================================
878// Unit Tests
879// ============================================================================
880
881#[cfg(test)]
882mod tests {
883    use super::*;
884
885    // Valid Solana address (Phantom treasury)
886    const VALID_ADDRESS: &str = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy";
887
888    // Valid Solana transaction signature
889    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        // Contains '0' which is not valid base58
921        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        // Valid base58 but decodes to wrong byte length (not 32 bytes)
929        // "abc" is valid base58 but too short when decoded
930        let result = validate_solana_address("abcdefghijabcdefghijabcdefghijab");
931        assert!(result.is_err());
932        // Should fail due to decoded length being wrong
933    }
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    // ========================================================================
1035    // HTTP mocking tests
1036    // ========================================================================
1037
1038    #[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()); // err is null → success
1158        assert_eq!(tx.gas_price, "5000"); // fee
1159    }
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()); // err is not null → failure
1179    }
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()); // err null → success
1260        assert!(!txs[1].status.unwrap()); // err present → failure
1261        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        // Second token has zero balance so it's filtered out
1318        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        // '0' and 'O' and 'I' and 'l' are not valid base58 characters
1382        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        // Valid base58 but decodes to wrong length (not 64 bytes)
1390        // "1" decodes to a single zero byte
1391        let short = "11111111111111111111111111111111"; // 32 chars of '1' = 32 zero bytes
1392        let result = validate_solana_signature(short);
1393        // This should fail: either length check or decoded-byte-count check
1394        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        // Verify the mapping from SolanaTokenBalance to TokenBalance
1500        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        // A valid base58 string that is 80-90 chars but decodes to wrong number of bytes
1580        // We use a padded version of a 32-byte key (which would be ~44 chars in base58)
1581        // Instead, let's create a signature-length string that decodes to wrong byte count
1582        // A 32-byte value encoded in base58 is ~44 chars, so we need something 80-90 chars
1583        // that decodes to != 64 bytes.
1584        // We can take a valid-length string and pad it:
1585        let long_sig = "1".repeat(88); // All '1' in base58 decodes to all zeros, but it will be 88 bytes of zeros which is != 64
1586        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}