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    /// Fetches SPL token (mint) info from RPC.
363    ///
364    /// Parses decimals from the mint account data. Symbol and name use
365    /// placeholders (Solscan Pro API would provide full metadata).
366    pub async fn get_token_info(&self, mint_address: &str) -> Result<Token> {
367        validate_solana_address(mint_address)?;
368
369        let request = RpcRequest {
370            jsonrpc: "2.0",
371            id: 1,
372            method: "getAccountInfo",
373            params: serde_json::json!([mint_address, { "encoding": "base64" }]),
374        };
375
376        tracing::debug!(
377            url = %self.rpc_url,
378            mint = %mint_address,
379            "Fetching SPL mint info"
380        );
381
382        #[derive(Deserialize)]
383        struct AccountInfoResult {
384            value: Option<AccountInfoValue>,
385        }
386        #[derive(Deserialize)]
387        struct AccountInfoValue {
388            data: Option<Vec<String>>,
389        }
390
391        let response: RpcResponse<AccountInfoResult> = self
392            .client
393            .post(&self.rpc_url)
394            .json(&request)
395            .send()
396            .await?
397            .json()
398            .await?;
399
400        if let Some(error) = response.error {
401            return Err(ScopeError::Chain(format!(
402                "Solana RPC error ({}): {}",
403                error.code, error.message
404            )));
405        }
406
407        let account = response.result.and_then(|r| r.value).ok_or_else(|| {
408            ScopeError::NotFound(format!("Mint account not found: {}", mint_address))
409        })?;
410
411        let data_b64 = account
412            .data
413            .and_then(|d| d.into_iter().next())
414            .ok_or_else(|| {
415                ScopeError::Chain(format!("No account data for mint: {}", mint_address))
416            })?;
417
418        let data = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &data_b64)
419            .map_err(|e| ScopeError::Chain(format!("Failed to decode mint data: {}", e)))?;
420
421        // SPL Token mint layout: 32 (mint_authority) + 8 (supply) + 1 (decimals) + ...
422        const DECIMALS_OFFSET: usize = 40;
423        let decimals = if data.len() > DECIMALS_OFFSET {
424            data[DECIMALS_OFFSET]
425        } else {
426            return Err(ScopeError::Chain(format!(
427                "Invalid mint account data (too short): {}",
428                mint_address
429            )));
430        };
431
432        let short_mint = if mint_address.len() > 8 {
433            format!("{}...", &mint_address[..8])
434        } else {
435            mint_address.to_string()
436        };
437
438        Ok(Token {
439            contract_address: mint_address.to_string(),
440            symbol: short_mint,
441            name: "SPL Token".to_string(),
442            decimals,
443        })
444    }
445
446    /// Enriches a balance with a USD value using DexScreener price lookup.
447    pub async fn enrich_balance_usd(&self, balance: &mut Balance) {
448        let dex = crate::chains::DexClient::new();
449        if let Some(price) = dex.get_native_token_price("solana").await {
450            let lamports: f64 = balance.raw.parse().unwrap_or(0.0);
451            let sol = lamports / 10_f64.powi(SOL_DECIMALS as i32);
452            balance.usd_value = Some(sol * price);
453        }
454    }
455
456    /// Fetches all SPL token balances for an address.
457    ///
458    /// # Arguments
459    ///
460    /// * `address` - The Solana wallet address to query
461    ///
462    /// # Returns
463    ///
464    /// Returns a vector of [`TokenBalance`] containing all SPL tokens held by the address.
465    ///
466    /// # Errors
467    ///
468    /// Returns [`ScopeError::InvalidAddress`] if the address format is invalid.
469    /// Returns [`ScopeError::Request`] if the API request fails.
470    pub async fn get_token_balances(&self, address: &str) -> Result<Vec<TokenBalance>> {
471        validate_solana_address(address)?;
472
473        // Use getTokenAccountsByOwner to get all token accounts
474        // The TOKEN_PROGRAM_ID is the standard SPL Token program
475        const TOKEN_PROGRAM_ID: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
476
477        let request = serde_json::json!({
478            "jsonrpc": "2.0",
479            "id": 1,
480            "method": "getTokenAccountsByOwner",
481            "params": [
482                address,
483                { "programId": TOKEN_PROGRAM_ID },
484                { "encoding": "jsonParsed" }
485            ]
486        });
487
488        tracing::debug!(url = %self.rpc_url, address = %address, "Fetching SPL token balances");
489
490        let response: RpcResponse<TokenAccountsResponse> = self
491            .client
492            .post(&self.rpc_url)
493            .json(&request)
494            .send()
495            .await?
496            .json()
497            .await?;
498
499        if let Some(error) = response.error {
500            return Err(ScopeError::Chain(format!(
501                "Solana RPC error ({}): {}",
502                error.code, error.message
503            )));
504        }
505
506        let accounts = response
507            .result
508            .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))?;
509
510        let token_balances: Vec<TokenBalance> = accounts
511            .value
512            .into_iter()
513            .filter_map(|account| {
514                let info = &account.account.data.parsed.info;
515                let ui_amount = info.token_amount.ui_amount.unwrap_or(0.0);
516
517                // Skip zero balances
518                if ui_amount == 0.0 {
519                    return None;
520                }
521
522                Some(TokenBalance {
523                    mint: info.mint.clone(),
524                    token_account: account.pubkey,
525                    raw_amount: info.token_amount.amount.clone(),
526                    ui_amount,
527                    decimals: info.token_amount.decimals,
528                    symbol: None, // Would need token metadata to get this
529                    name: None,
530                })
531            })
532            .collect();
533
534        Ok(token_balances)
535    }
536
537    /// Fetches recent transaction signatures for an address.
538    ///
539    /// # Arguments
540    ///
541    /// * `address` - The Solana address to query
542    /// * `limit` - Maximum number of signatures to return
543    ///
544    /// # Returns
545    ///
546    /// Returns a vector of transaction signatures.
547    pub async fn get_signatures(&self, address: &str, limit: u32) -> Result<Vec<String>> {
548        let infos = self.get_signature_infos(address, limit).await?;
549        Ok(infos.into_iter().map(|s| s.signature).collect())
550    }
551
552    /// Fetches recent transaction signature info (with metadata) for an address.
553    async fn get_signature_infos(&self, address: &str, limit: u32) -> Result<Vec<SignatureInfo>> {
554        validate_solana_address(address)?;
555
556        #[derive(Serialize)]
557        struct GetSignaturesParams<'a> {
558            limit: u32,
559            #[serde(skip_serializing_if = "Option::is_none")]
560            before: Option<&'a str>,
561        }
562
563        let request = RpcRequest {
564            jsonrpc: "2.0",
565            id: 1,
566            method: "getSignaturesForAddress",
567            params: (
568                address,
569                GetSignaturesParams {
570                    limit,
571                    before: None,
572                },
573            ),
574        };
575
576        tracing::debug!(
577            url = %self.rpc_url,
578            address = %address,
579            limit = %limit,
580            "Fetching Solana transaction signatures"
581        );
582
583        let response: RpcResponse<Vec<SignatureInfo>> = self
584            .client
585            .post(&self.rpc_url)
586            .json(&request)
587            .send()
588            .await?
589            .json()
590            .await?;
591
592        if let Some(error) = response.error {
593            return Err(ScopeError::Chain(format!(
594                "Solana RPC error ({}): {}",
595                error.code, error.message
596            )));
597        }
598
599        response
600            .result
601            .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))
602    }
603
604    /// Fetches transaction details by signature.
605    ///
606    /// # Arguments
607    ///
608    /// * `signature` - The transaction signature (base58 encoded)
609    ///
610    /// # Returns
611    ///
612    /// Returns [`Transaction`] details.
613    pub async fn get_transaction(&self, signature: &str) -> Result<Transaction> {
614        // Validate signature format
615        validate_solana_signature(signature)?;
616
617        let request = RpcRequest {
618            jsonrpc: "2.0",
619            id: 1,
620            method: "getTransaction",
621            params: serde_json::json!([
622                signature,
623                {
624                    "encoding": "jsonParsed",
625                    "maxSupportedTransactionVersion": 0
626                }
627            ]),
628        };
629
630        tracing::debug!(
631            url = %self.rpc_url,
632            signature = %signature,
633            "Fetching Solana transaction"
634        );
635
636        let response: RpcResponse<SolanaTransactionResult> = self
637            .client
638            .post(&self.rpc_url)
639            .json(&request)
640            .send()
641            .await?
642            .json()
643            .await?;
644
645        if let Some(error) = response.error {
646            return Err(ScopeError::Chain(format!(
647                "Solana RPC error ({}): {}",
648                error.code, error.message
649            )));
650        }
651
652        let tx_result = response
653            .result
654            .ok_or_else(|| ScopeError::NotFound(format!("Transaction not found: {}", signature)))?;
655
656        // Extract the first signer (fee payer) as "from"
657        let from = tx_result
658            .transaction
659            .as_ref()
660            .and_then(|tx| tx.message.as_ref())
661            .and_then(|msg| msg.account_keys.as_ref())
662            .and_then(|keys| keys.first())
663            .map(|key| match key {
664                AccountKeyEntry::String(s) => s.clone(),
665                AccountKeyEntry::Object { pubkey, .. } => pubkey.clone(),
666            })
667            .unwrap_or_default();
668
669        // Try to find the SOL transfer amount from the transaction
670        let value = tx_result
671            .meta
672            .as_ref()
673            .and_then(|meta| {
674                let pre = meta.pre_balances.as_ref()?;
675                let post = meta.post_balances.as_ref()?;
676                if pre.len() >= 2 && post.len() >= 2 {
677                    // Amount sent = pre[0] - post[0] - fee (fee payer's balance change minus fee)
678                    let fee = meta.fee.unwrap_or(0);
679                    let sent = pre[0].saturating_sub(post[0]).saturating_sub(fee);
680                    if sent > 0 {
681                        let sol = sent as f64 / 10_f64.powi(SOL_DECIMALS as i32);
682                        return Some(format!("{:.9}", sol));
683                    }
684                }
685                None
686            })
687            .unwrap_or_else(|| "0".to_string());
688
689        // Extract "to" address (second account key, typically the recipient)
690        let to = tx_result
691            .transaction
692            .as_ref()
693            .and_then(|tx| tx.message.as_ref())
694            .and_then(|msg| msg.account_keys.as_ref())
695            .and_then(|keys| {
696                if keys.len() >= 2 {
697                    Some(match &keys[1] {
698                        AccountKeyEntry::String(s) => s.clone(),
699                        AccountKeyEntry::Object { pubkey, .. } => pubkey.clone(),
700                    })
701                } else {
702                    None
703                }
704            });
705
706        let fee = tx_result
707            .meta
708            .as_ref()
709            .and_then(|meta| meta.fee)
710            .unwrap_or(0);
711
712        let status = tx_result.meta.as_ref().map(|meta| meta.err.is_none());
713
714        Ok(Transaction {
715            hash: signature.to_string(),
716            block_number: tx_result.slot,
717            timestamp: tx_result.block_time.map(|t| t as u64),
718            from,
719            to,
720            value,
721            gas_limit: 0, // Solana uses compute units, not gas
722            gas_used: None,
723            gas_price: fee.to_string(), // Use fee as gas_price equivalent
724            nonce: 0,
725            input: String::new(),
726            status,
727        })
728    }
729
730    /// Fetches recent transactions for an address.
731    ///
732    /// # Arguments
733    ///
734    /// * `address` - The address to query
735    /// * `limit` - Maximum number of transactions
736    ///
737    /// # Returns
738    ///
739    /// Returns a vector of [`Transaction`] objects.
740    pub async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
741        validate_solana_address(address)?;
742
743        // Get signature infos (includes slot, blockTime, err)
744        let sig_infos = self.get_signature_infos(address, limit).await?;
745
746        let transactions: Vec<Transaction> = sig_infos
747            .into_iter()
748            .map(|info| Transaction {
749                hash: info.signature,
750                block_number: Some(info.slot),
751                timestamp: info.block_time.map(|t| t as u64),
752                from: address.to_string(),
753                to: None,
754                value: "0".to_string(),
755                gas_limit: 0,
756                gas_used: None,
757                gas_price: "0".to_string(),
758                nonce: 0,
759                input: String::new(),
760                status: Some(info.err.is_none()),
761            })
762            .collect();
763
764        Ok(transactions)
765    }
766
767    /// Fetches the current slot number (equivalent to block number).
768    pub async fn get_slot(&self) -> Result<u64> {
769        let request = RpcRequest {
770            jsonrpc: "2.0",
771            id: 1,
772            method: "getSlot",
773            params: (),
774        };
775
776        let response: RpcResponse<u64> = self
777            .client
778            .post(&self.rpc_url)
779            .json(&request)
780            .send()
781            .await?
782            .json()
783            .await?;
784
785        if let Some(error) = response.error {
786            return Err(ScopeError::Chain(format!(
787                "Solana RPC error ({}): {}",
788                error.code, error.message
789            )));
790        }
791
792        response
793            .result
794            .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))
795    }
796}
797
798impl Default for SolanaClient {
799    fn default() -> Self {
800        Self {
801            client: Client::new(),
802            rpc_url: DEFAULT_SOLANA_RPC.to_string(),
803            solscan_api_key: None,
804        }
805    }
806}
807
808/// Validates a Solana address format (base58 encoded, 32-44 characters).
809///
810/// # Arguments
811///
812/// * `address` - The address to validate
813///
814/// # Returns
815///
816/// Returns `Ok(())` if valid, or an error describing the validation failure.
817pub fn validate_solana_address(address: &str) -> Result<()> {
818    // Solana addresses are base58 encoded ed25519 public keys
819    // They are typically 32-44 characters long
820
821    if address.is_empty() {
822        return Err(ScopeError::InvalidAddress("Address cannot be empty".into()));
823    }
824
825    // Check length (base58 encoded 32-byte keys are 32-44 chars)
826    if address.len() < 32 || address.len() > 44 {
827        return Err(ScopeError::InvalidAddress(format!(
828            "Solana address must be 32-44 characters, got {}: {}",
829            address.len(),
830            address
831        )));
832    }
833
834    // Validate base58 encoding
835    match bs58::decode(address).into_vec() {
836        Ok(bytes) => {
837            // Should decode to 32 bytes (ed25519 public key)
838            if bytes.len() != 32 {
839                return Err(ScopeError::InvalidAddress(format!(
840                    "Solana address must decode to 32 bytes, got {}: {}",
841                    bytes.len(),
842                    address
843                )));
844            }
845        }
846        Err(e) => {
847            return Err(ScopeError::InvalidAddress(format!(
848                "Invalid base58 encoding: {}: {}",
849                e, address
850            )));
851        }
852    }
853
854    Ok(())
855}
856
857/// Validates a Solana transaction signature format (base58 encoded).
858///
859/// # Arguments
860///
861/// * `signature` - The signature to validate
862///
863/// # Returns
864///
865/// Returns `Ok(())` if valid, or an error describing the validation failure.
866pub fn validate_solana_signature(signature: &str) -> Result<()> {
867    // Solana signatures are base58 encoded 64-byte signatures
868    // They are typically 87-88 characters long
869
870    if signature.is_empty() {
871        return Err(ScopeError::InvalidHash("Signature cannot be empty".into()));
872    }
873
874    // Check length (base58 encoded 64-byte signatures are ~87-88 chars)
875    if signature.len() < 80 || signature.len() > 90 {
876        return Err(ScopeError::InvalidHash(format!(
877            "Solana signature must be 80-90 characters, got {}: {}",
878            signature.len(),
879            signature
880        )));
881    }
882
883    // Validate base58 encoding
884    match bs58::decode(signature).into_vec() {
885        Ok(bytes) => {
886            // Should decode to 64 bytes (ed25519 signature)
887            if bytes.len() != 64 {
888                return Err(ScopeError::InvalidHash(format!(
889                    "Solana signature must decode to 64 bytes, got {}: {}",
890                    bytes.len(),
891                    signature
892                )));
893            }
894        }
895        Err(e) => {
896            return Err(ScopeError::InvalidHash(format!(
897                "Invalid base58 encoding: {}: {}",
898                e, signature
899            )));
900        }
901    }
902
903    Ok(())
904}
905
906// ============================================================================
907// ChainClient Trait Implementation
908// ============================================================================
909
910#[async_trait]
911impl ChainClient for SolanaClient {
912    fn chain_name(&self) -> &str {
913        "solana"
914    }
915
916    fn native_token_symbol(&self) -> &str {
917        "SOL"
918    }
919
920    async fn get_balance(&self, address: &str) -> Result<Balance> {
921        self.get_balance(address).await
922    }
923
924    async fn enrich_balance_usd(&self, balance: &mut Balance) {
925        self.enrich_balance_usd(balance).await
926    }
927
928    async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
929        self.get_transaction(hash).await
930    }
931
932    async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
933        self.get_transactions(address, limit).await
934    }
935
936    async fn get_block_number(&self) -> Result<u64> {
937        self.get_slot().await
938    }
939
940    async fn get_token_info(&self, address: &str) -> Result<Token> {
941        self.get_token_info(address).await
942    }
943
944    async fn get_token_balances(&self, address: &str) -> Result<Vec<crate::chains::TokenBalance>> {
945        let solana_balances = self.get_token_balances(address).await?;
946        Ok(solana_balances
947            .into_iter()
948            .map(|tb| crate::chains::TokenBalance {
949                token: Token {
950                    contract_address: tb.mint.clone(),
951                    symbol: tb
952                        .symbol
953                        .unwrap_or_else(|| tb.mint[..8.min(tb.mint.len())].to_string()),
954                    name: tb.name.unwrap_or_else(|| "SPL Token".to_string()),
955                    decimals: tb.decimals,
956                },
957                balance: tb.raw_amount,
958                formatted_balance: format!("{:.6}", tb.ui_amount),
959                usd_value: None,
960            })
961            .collect())
962    }
963}
964
965// ============================================================================
966// Unit Tests
967// ============================================================================
968
969#[cfg(test)]
970mod tests {
971    use super::*;
972
973    // Valid Solana address (Phantom treasury)
974    const VALID_ADDRESS: &str = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy";
975
976    // Valid Solana transaction signature
977    const VALID_SIGNATURE: &str =
978        "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
979
980    #[test]
981    fn test_validate_solana_address_valid() {
982        assert!(validate_solana_address(VALID_ADDRESS).is_ok());
983    }
984
985    #[test]
986    fn test_validate_solana_address_empty() {
987        let result = validate_solana_address("");
988        assert!(result.is_err());
989        assert!(result.unwrap_err().to_string().contains("empty"));
990    }
991
992    #[test]
993    fn test_validate_solana_address_too_short() {
994        let result = validate_solana_address("DRpbCBMxVnDK7maPM5t");
995        assert!(result.is_err());
996        assert!(result.unwrap_err().to_string().contains("32-44"));
997    }
998
999    #[test]
1000    fn test_validate_solana_address_too_long() {
1001        let long_addr = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hyAAAAAAAAAAAA";
1002        let result = validate_solana_address(long_addr);
1003        assert!(result.is_err());
1004    }
1005
1006    #[test]
1007    fn test_validate_solana_address_invalid_base58() {
1008        // Contains '0' which is not valid base58
1009        let result = validate_solana_address("0RpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1010        assert!(result.is_err());
1011        assert!(result.unwrap_err().to_string().contains("base58"));
1012    }
1013
1014    #[test]
1015    fn test_validate_solana_address_wrong_decoded_length() {
1016        // Valid base58 but decodes to wrong byte length (not 32 bytes)
1017        // "abc" is valid base58 but too short when decoded
1018        let result = validate_solana_address("abcdefghijabcdefghijabcdefghijab");
1019        assert!(result.is_err());
1020        // Should fail due to decoded length being wrong
1021    }
1022
1023    #[test]
1024    fn test_validate_solana_signature_valid() {
1025        assert!(validate_solana_signature(VALID_SIGNATURE).is_ok());
1026    }
1027
1028    #[test]
1029    fn test_validate_solana_signature_empty() {
1030        let result = validate_solana_signature("");
1031        assert!(result.is_err());
1032        assert!(result.unwrap_err().to_string().contains("empty"));
1033    }
1034
1035    #[test]
1036    fn test_validate_solana_signature_too_short() {
1037        let result = validate_solana_signature("abc");
1038        assert!(result.is_err());
1039        assert!(result.unwrap_err().to_string().contains("80-90"));
1040    }
1041
1042    #[test]
1043    fn test_solana_client_default() {
1044        let client = SolanaClient::default();
1045        assert_eq!(client.chain_name(), "solana");
1046        assert_eq!(client.native_token_symbol(), "SOL");
1047        assert!(client.rpc_url.contains("mainnet-beta"));
1048    }
1049
1050    #[test]
1051    fn test_solana_client_with_rpc_url() {
1052        let client = SolanaClient::with_rpc_url("https://custom.rpc.com");
1053        assert_eq!(client.rpc_url, "https://custom.rpc.com");
1054    }
1055
1056    #[test]
1057    fn test_solana_client_new() {
1058        let config = ChainsConfig::default();
1059        let client = SolanaClient::new(&config);
1060        assert!(client.is_ok());
1061    }
1062
1063    #[test]
1064    fn test_solana_client_new_with_custom_rpc() {
1065        let config = ChainsConfig {
1066            solana_rpc: Some("https://my-solana-rpc.com".to_string()),
1067            ..Default::default()
1068        };
1069        let client = SolanaClient::new(&config).unwrap();
1070        assert_eq!(client.rpc_url, "https://my-solana-rpc.com");
1071    }
1072
1073    #[test]
1074    fn test_solana_client_new_with_api_key() {
1075        use std::collections::HashMap;
1076
1077        let mut api_keys = HashMap::new();
1078        api_keys.insert("solscan".to_string(), "test-key".to_string());
1079
1080        let config = ChainsConfig {
1081            api_keys,
1082            ..Default::default()
1083        };
1084
1085        let client = SolanaClient::new(&config).unwrap();
1086        assert_eq!(client.solscan_api_key, Some("test-key".to_string()));
1087    }
1088
1089    #[test]
1090    fn test_rpc_request_serialization() {
1091        let request = RpcRequest {
1092            jsonrpc: "2.0",
1093            id: 1,
1094            method: "getBalance",
1095            params: vec!["test"],
1096        };
1097
1098        let json = serde_json::to_string(&request).unwrap();
1099        assert!(json.contains("jsonrpc"));
1100        assert!(json.contains("getBalance"));
1101    }
1102
1103    #[test]
1104    fn test_rpc_response_deserialization() {
1105        let json = r#"{"jsonrpc":"2.0","result":{"value":1000000000},"id":1}"#;
1106        let response: RpcResponse<BalanceResponse> = serde_json::from_str(json).unwrap();
1107        assert!(response.result.is_some());
1108        assert_eq!(response.result.unwrap().value, 1_000_000_000);
1109    }
1110
1111    #[test]
1112    fn test_rpc_error_deserialization() {
1113        let json =
1114            r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid request"},"id":1}"#;
1115        let response: RpcResponse<BalanceResponse> = serde_json::from_str(json).unwrap();
1116        assert!(response.error.is_some());
1117        let error = response.error.unwrap();
1118        assert_eq!(error.code, -32600);
1119        assert_eq!(error.message, "Invalid request");
1120    }
1121
1122    // ========================================================================
1123    // HTTP mocking tests
1124    // ========================================================================
1125
1126    #[tokio::test]
1127    async fn test_get_balance() {
1128        let mut server = mockito::Server::new_async().await;
1129        let _mock = server
1130            .mock("POST", "/")
1131            .with_status(200)
1132            .with_header("content-type", "application/json")
1133            .with_body(r#"{"jsonrpc":"2.0","result":{"value":5000000000},"id":1}"#)
1134            .create_async()
1135            .await;
1136
1137        let client = SolanaClient::with_rpc_url(&server.url());
1138        let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1139        assert_eq!(balance.raw, "5000000000");
1140        assert_eq!(balance.symbol, "SOL");
1141        assert_eq!(balance.decimals, 9);
1142        assert!(balance.formatted.contains("5.000000000"));
1143    }
1144
1145    #[tokio::test]
1146    async fn test_get_balance_zero() {
1147        let mut server = mockito::Server::new_async().await;
1148        let _mock = server
1149            .mock("POST", "/")
1150            .with_status(200)
1151            .with_header("content-type", "application/json")
1152            .with_body(r#"{"jsonrpc":"2.0","result":{"value":0},"id":1}"#)
1153            .create_async()
1154            .await;
1155
1156        let client = SolanaClient::with_rpc_url(&server.url());
1157        let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1158        assert_eq!(balance.raw, "0");
1159        assert!(balance.formatted.contains("0.000000000"));
1160    }
1161
1162    #[tokio::test]
1163    async fn test_get_balance_rpc_error() {
1164        let mut server = mockito::Server::new_async().await;
1165        let _mock = server
1166            .mock("POST", "/")
1167            .with_status(200)
1168            .with_header("content-type", "application/json")
1169            .with_body(
1170                r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid params"},"id":1}"#,
1171            )
1172            .create_async()
1173            .await;
1174
1175        let client = SolanaClient::with_rpc_url(&server.url());
1176        let result = client.get_balance(VALID_ADDRESS).await;
1177        assert!(result.is_err());
1178        assert!(result.unwrap_err().to_string().contains("RPC error"));
1179    }
1180
1181    #[tokio::test]
1182    async fn test_get_balance_empty_response() {
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","id":1}"#)
1189            .create_async()
1190            .await;
1191
1192        let client = SolanaClient::with_rpc_url(&server.url());
1193        let result = client.get_balance(VALID_ADDRESS).await;
1194        assert!(result.is_err());
1195        assert!(result.unwrap_err().to_string().contains("Empty RPC"));
1196    }
1197
1198    #[tokio::test]
1199    async fn test_get_balance_invalid_address() {
1200        let client = SolanaClient::default();
1201        let result = client.get_balance("invalid").await;
1202        assert!(result.is_err());
1203    }
1204
1205    #[tokio::test]
1206    async fn test_get_transaction() {
1207        let mut server = mockito::Server::new_async().await;
1208        let _mock = server
1209            .mock("POST", "/")
1210            .with_status(200)
1211            .with_header("content-type", "application/json")
1212            .with_body(
1213                r#"{"jsonrpc":"2.0","result":{
1214                "slot":123456789,
1215                "blockTime":1700000000,
1216                "transaction":{
1217                    "message":{
1218                        "accountKeys":[
1219                            {"pubkey":"DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy","signer":true},
1220                            {"pubkey":"9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM","signer":false}
1221                        ]
1222                    }
1223                },
1224                "meta":{
1225                    "fee":5000,
1226                    "preBalances":[10000000000,5000000000],
1227                    "postBalances":[8999995000,6000000000],
1228                    "err":null
1229                }
1230            },"id":1}"#,
1231            )
1232            .create_async()
1233            .await;
1234
1235        let client = SolanaClient::with_rpc_url(&server.url());
1236        let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1237        assert_eq!(tx.hash, VALID_SIGNATURE);
1238        assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1239        assert_eq!(
1240            tx.to,
1241            Some("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM".to_string())
1242        );
1243        assert_eq!(tx.block_number, Some(123456789));
1244        assert_eq!(tx.timestamp, Some(1700000000));
1245        assert!(tx.status.unwrap()); // err is null → success
1246        assert_eq!(tx.gas_price, "5000"); // fee
1247    }
1248
1249    #[tokio::test]
1250    async fn test_get_transaction_failed() {
1251        let mut server = mockito::Server::new_async().await;
1252        let _mock = server
1253            .mock("POST", "/")
1254            .with_status(200)
1255            .with_header("content-type", "application/json")
1256            .with_body(r#"{"jsonrpc":"2.0","result":{
1257                "slot":100,
1258                "transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"]}},
1259                "meta":{"fee":5000,"preBalances":[1000],"postBalances":[1000],"err":{"InstructionError":[0,{"Custom":1}]}}
1260            },"id":1}"#)
1261            .create_async()
1262            .await;
1263
1264        let client = SolanaClient::with_rpc_url(&server.url());
1265        let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1266        assert!(!tx.status.unwrap()); // err is not null → failure
1267    }
1268
1269    #[tokio::test]
1270    async fn test_get_transaction_not_found() {
1271        let mut server = mockito::Server::new_async().await;
1272        let _mock = server
1273            .mock("POST", "/")
1274            .with_status(200)
1275            .with_header("content-type", "application/json")
1276            .with_body(r#"{"jsonrpc":"2.0","result":null,"id":1}"#)
1277            .create_async()
1278            .await;
1279
1280        let client = SolanaClient::with_rpc_url(&server.url());
1281        let result = client.get_transaction(VALID_SIGNATURE).await;
1282        assert!(result.is_err());
1283        assert!(result.unwrap_err().to_string().contains("not found"));
1284    }
1285
1286    #[tokio::test]
1287    async fn test_get_transaction_string_account_keys() {
1288        let mut server = mockito::Server::new_async().await;
1289        let _mock = server
1290            .mock("POST", "/")
1291            .with_status(200)
1292            .with_header("content-type", "application/json")
1293            .with_body(r#"{"jsonrpc":"2.0","result":{
1294                "slot":100,
1295                "transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy","9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"]}},
1296                "meta":{"fee":5000,"preBalances":[1000000000,0],"postBalances":[999995000,0],"err":null}
1297            },"id":1}"#)
1298            .create_async()
1299            .await;
1300
1301        let client = SolanaClient::with_rpc_url(&server.url());
1302        let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1303        assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1304        assert_eq!(
1305            tx.to,
1306            Some("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM".to_string())
1307        );
1308    }
1309
1310    #[tokio::test]
1311    async fn test_get_signatures() {
1312        let mut server = mockito::Server::new_async().await;
1313        let _mock = server
1314            .mock("POST", "/")
1315            .with_status(200)
1316            .with_header("content-type", "application/json")
1317            .with_body(r#"{"jsonrpc":"2.0","result":[
1318                {"signature":"5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW","slot":100,"blockTime":1700000000,"err":null},
1319                {"signature":"4VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUX","slot":101,"blockTime":1700000060,"err":{"InstructionError":[0,{"Custom":1}]}}
1320            ],"id":1}"#)
1321            .create_async()
1322            .await;
1323
1324        let client = SolanaClient::with_rpc_url(&server.url());
1325        let sigs = client.get_signatures(VALID_ADDRESS, 10).await.unwrap();
1326        assert_eq!(sigs.len(), 2);
1327        assert!(sigs[0].starts_with("5VERv8"));
1328    }
1329
1330    #[tokio::test]
1331    async fn test_get_transactions() {
1332        let mut server = mockito::Server::new_async().await;
1333        let _mock = server
1334            .mock("POST", "/")
1335            .with_status(200)
1336            .with_header("content-type", "application/json")
1337            .with_body(r#"{"jsonrpc":"2.0","result":[
1338                {"signature":"5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW","slot":100,"blockTime":1700000000,"err":null},
1339                {"signature":"4VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUX","slot":101,"blockTime":1700000060,"err":{"InstructionError":[0,{"Custom":1}]}}
1340            ],"id":1}"#)
1341            .create_async()
1342            .await;
1343
1344        let client = SolanaClient::with_rpc_url(&server.url());
1345        let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1346        assert_eq!(txs.len(), 2);
1347        assert!(txs[0].status.unwrap()); // err null → success
1348        assert!(!txs[1].status.unwrap()); // err present → failure
1349        assert_eq!(txs[0].block_number, Some(100));
1350        assert_eq!(txs[0].timestamp, Some(1700000000));
1351    }
1352
1353    #[tokio::test]
1354    async fn test_get_token_balances() {
1355        let mut server = mockito::Server::new_async().await;
1356        let _mock = server
1357            .mock("POST", "/")
1358            .with_status(200)
1359            .with_header("content-type", "application/json")
1360            .with_body(
1361                r#"{"jsonrpc":"2.0","result":{"value":[
1362                {
1363                    "pubkey":"TokenAccAddr1",
1364                    "account":{
1365                        "data":{
1366                            "parsed":{
1367                                "info":{
1368                                    "mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
1369                                    "tokenAmount":{
1370                                        "amount":"1000000",
1371                                        "decimals":6,
1372                                        "uiAmount":1.0,
1373                                        "uiAmountString":"1"
1374                                    }
1375                                }
1376                            }
1377                        }
1378                    }
1379                },
1380                {
1381                    "pubkey":"TokenAccAddr2",
1382                    "account":{
1383                        "data":{
1384                            "parsed":{
1385                                "info":{
1386                                    "mint":"So11111111111111111111111111111111111111112",
1387                                    "tokenAmount":{
1388                                        "amount":"0",
1389                                        "decimals":9,
1390                                        "uiAmount":0.0,
1391                                        "uiAmountString":"0"
1392                                    }
1393                                }
1394                            }
1395                        }
1396                    }
1397                }
1398            ]},"id":1}"#,
1399            )
1400            .create_async()
1401            .await;
1402
1403        let client = SolanaClient::with_rpc_url(&server.url());
1404        let balances = client.get_token_balances(VALID_ADDRESS).await.unwrap();
1405        // Second token has zero balance so it's filtered out
1406        assert_eq!(balances.len(), 1);
1407        assert_eq!(
1408            balances[0].mint,
1409            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1410        );
1411        assert_eq!(balances[0].ui_amount, 1.0);
1412        assert_eq!(balances[0].decimals, 6);
1413    }
1414
1415    #[tokio::test]
1416    async fn test_get_token_balances_rpc_error() {
1417        let mut server = mockito::Server::new_async().await;
1418        let _mock = server
1419            .mock("POST", "/")
1420            .with_status(200)
1421            .with_header("content-type", "application/json")
1422            .with_body(
1423                r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid params"},"id":1}"#,
1424            )
1425            .create_async()
1426            .await;
1427
1428        let client = SolanaClient::with_rpc_url(&server.url());
1429        let result = client.get_token_balances(VALID_ADDRESS).await;
1430        assert!(result.is_err());
1431    }
1432
1433    #[tokio::test]
1434    async fn test_get_slot() {
1435        let mut server = mockito::Server::new_async().await;
1436        let _mock = server
1437            .mock("POST", "/")
1438            .with_status(200)
1439            .with_header("content-type", "application/json")
1440            .with_body(r#"{"jsonrpc":"2.0","result":256000000,"id":1}"#)
1441            .create_async()
1442            .await;
1443
1444        let client = SolanaClient::with_rpc_url(&server.url());
1445        let slot = client.get_slot().await.unwrap();
1446        assert_eq!(slot, 256000000);
1447    }
1448
1449    #[tokio::test]
1450    async fn test_get_slot_error() {
1451        let mut server = mockito::Server::new_async().await;
1452        let _mock = server
1453            .mock("POST", "/")
1454            .with_status(200)
1455            .with_header("content-type", "application/json")
1456            .with_body(
1457                r#"{"jsonrpc":"2.0","error":{"code":-32005,"message":"Node is behind"},"id":1}"#,
1458            )
1459            .create_async()
1460            .await;
1461
1462        let client = SolanaClient::with_rpc_url(&server.url());
1463        let result = client.get_slot().await;
1464        assert!(result.is_err());
1465    }
1466
1467    #[test]
1468    fn test_validate_solana_signature_invalid_base58() {
1469        // '0' and 'O' and 'I' and 'l' are not valid base58 characters
1470        let bad_sig = "0OIl00000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
1471        let result = validate_solana_signature(bad_sig);
1472        assert!(result.is_err());
1473    }
1474
1475    #[test]
1476    fn test_validate_solana_signature_wrong_decoded_length() {
1477        // Valid base58 but decodes to wrong length (not 64 bytes)
1478        // "1" decodes to a single zero byte
1479        let short = "11111111111111111111111111111111"; // 32 chars of '1' = 32 zero bytes
1480        let result = validate_solana_signature(short);
1481        // This should fail: either length check or decoded-byte-count check
1482        assert!(result.is_err());
1483    }
1484
1485    #[tokio::test]
1486    async fn test_get_transaction_rpc_error() {
1487        let mut server = mockito::Server::new_async().await;
1488        let _mock = server
1489            .mock("POST", "/")
1490            .with_status(200)
1491            .with_header("content-type", "application/json")
1492            .with_body(r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Transaction not found"},"id":1}"#)
1493            .create_async()
1494            .await;
1495
1496        let client = SolanaClient::with_rpc_url(&server.url());
1497        let result = client
1498            .get_transaction("5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW")
1499            .await;
1500        assert!(result.is_err());
1501        assert!(result.unwrap_err().to_string().contains("RPC error"));
1502    }
1503
1504    #[tokio::test]
1505    async fn test_solana_chain_client_trait_chain_name() {
1506        let client = SolanaClient::with_rpc_url("http://localhost:8899");
1507        let chain_client: &dyn ChainClient = &client;
1508        assert_eq!(chain_client.chain_name(), "solana");
1509        assert_eq!(chain_client.native_token_symbol(), "SOL");
1510    }
1511
1512    #[tokio::test]
1513    async fn test_chain_client_trait_get_balance() {
1514        let mut server = mockito::Server::new_async().await;
1515        let _mock = server
1516            .mock("POST", "/")
1517            .with_status(200)
1518            .with_header("content-type", "application/json")
1519            .with_body(
1520                r#"{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":1000000000},"id":1}"#,
1521            )
1522            .create_async()
1523            .await;
1524
1525        let client = SolanaClient::with_rpc_url(&server.url());
1526        let chain_client: &dyn ChainClient = &client;
1527        let balance = chain_client.get_balance(VALID_ADDRESS).await.unwrap();
1528        assert_eq!(balance.symbol, "SOL");
1529    }
1530
1531    #[tokio::test]
1532    async fn test_chain_client_trait_get_block_number() {
1533        let mut server = mockito::Server::new_async().await;
1534        let _mock = server
1535            .mock("POST", "/")
1536            .with_status(200)
1537            .with_header("content-type", "application/json")
1538            .with_body(r#"{"jsonrpc":"2.0","result":250000000,"id":1}"#)
1539            .create_async()
1540            .await;
1541
1542        let client = SolanaClient::with_rpc_url(&server.url());
1543        let chain_client: &dyn ChainClient = &client;
1544        let slot = chain_client.get_block_number().await.unwrap();
1545        assert_eq!(slot, 250000000);
1546    }
1547
1548    #[tokio::test]
1549    async fn test_chain_client_trait_get_token_balances() {
1550        let mut server = mockito::Server::new_async().await;
1551        let _mock = server
1552            .mock("POST", "/")
1553            .with_status(200)
1554            .with_header("content-type", "application/json")
1555            .with_body(
1556                r#"{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[
1557                {
1558                    "pubkey":"TokenAccAddr1",
1559                    "account":{
1560                        "data":{
1561                            "parsed":{
1562                                "info":{
1563                                    "mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
1564                                    "tokenAmount":{
1565                                        "amount":"1000000",
1566                                        "decimals":6,
1567                                        "uiAmount":1.0,
1568                                        "uiAmountString":"1"
1569                                    }
1570                                }
1571                            }
1572                        }
1573                    }
1574                }
1575            ]},"id":1}"#,
1576            )
1577            .create_async()
1578            .await;
1579
1580        let client = SolanaClient::with_rpc_url(&server.url());
1581        let chain_client: &dyn ChainClient = &client;
1582        let balances = chain_client
1583            .get_token_balances(VALID_ADDRESS)
1584            .await
1585            .unwrap();
1586        assert!(!balances.is_empty());
1587        // Verify the mapping from SolanaTokenBalance to TokenBalance
1588        assert_eq!(
1589            balances[0].token.contract_address,
1590            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1591        );
1592    }
1593
1594    #[tokio::test]
1595    async fn test_chain_client_trait_get_transaction_solana() {
1596        let mut server = mockito::Server::new_async().await;
1597        let _mock = server
1598            .mock("POST", "/")
1599            .with_status(200)
1600            .with_header("content-type", "application/json")
1601            .with_body(
1602                r#"{"jsonrpc":"2.0","result":{
1603                "slot":200000000,
1604                "blockTime":1700000000,
1605                "meta":{
1606                    "fee":5000,
1607                    "preBalances":[1000000000,500000000],
1608                    "postBalances":[999995000,500005000],
1609                    "err":null
1610                },
1611                "transaction":{
1612                    "message":{
1613                        "accountKeys":[
1614                            "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy",
1615                            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1616                        ]
1617                    },
1618                    "signatures":["5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh"]
1619                }
1620            },"id":1}"#,
1621            )
1622            .create_async()
1623            .await;
1624
1625        let client = SolanaClient::with_rpc_url(&server.url());
1626        let chain_client: &dyn ChainClient = &client;
1627        let tx = chain_client
1628            .get_transaction("5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh")
1629            .await
1630            .unwrap();
1631        assert!(!tx.hash.is_empty());
1632        assert!(tx.timestamp.is_some());
1633    }
1634
1635    #[tokio::test]
1636    async fn test_chain_client_trait_get_transactions_solana() {
1637        let mut server = mockito::Server::new_async().await;
1638        let _mock = server
1639            .mock("POST", "/")
1640            .with_status(200)
1641            .with_header("content-type", "application/json")
1642            .with_body(
1643                r#"{"jsonrpc":"2.0","result":[
1644                {
1645                    "signature":"5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh",
1646                    "slot":200000000,
1647                    "blockTime":1700000000,
1648                    "err":null,
1649                    "memo":null
1650                }
1651            ],"id":1}"#,
1652            )
1653            .create_async()
1654            .await;
1655
1656        let client = SolanaClient::with_rpc_url(&server.url());
1657        let chain_client: &dyn ChainClient = &client;
1658        let txs = chain_client
1659            .get_transactions(VALID_ADDRESS, 10)
1660            .await
1661            .unwrap();
1662        assert!(!txs.is_empty());
1663    }
1664
1665    #[test]
1666    fn test_validate_solana_signature_wrong_byte_length() {
1667        // A valid base58 string that is 80-90 chars but decodes to wrong number of bytes
1668        // We use a padded version of a 32-byte key (which would be ~44 chars in base58)
1669        // Instead, let's create a signature-length string that decodes to wrong byte count
1670        // A 32-byte value encoded in base58 is ~44 chars, so we need something 80-90 chars
1671        // that decodes to != 64 bytes.
1672        // We can take a valid-length string and pad it:
1673        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
1674        let result = validate_solana_signature(&long_sig);
1675        assert!(result.is_err());
1676        let err = result.unwrap_err().to_string();
1677        assert!(err.contains("64 bytes") || err.contains("base58"));
1678    }
1679
1680    #[tokio::test]
1681    async fn test_rpc_error_response() {
1682        let mut server = mockito::Server::new_async().await;
1683        let _mock = server
1684            .mock("POST", "/")
1685            .with_status(200)
1686            .with_header("content-type", "application/json")
1687            .with_body(
1688                r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid request"},"id":1}"#,
1689            )
1690            .create_async()
1691            .await;
1692
1693        let client = SolanaClient::with_rpc_url(&server.url());
1694        let result = client.get_balance(VALID_ADDRESS).await;
1695        assert!(result.is_err());
1696        assert!(result.unwrap_err().to_string().contains("RPC error"));
1697    }
1698}