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 crate::http::{HttpClient, Request};
37use async_trait::async_trait;
38use serde::{Deserialize, Serialize};
39use std::sync::Arc;
40
41/// Default Solana mainnet RPC endpoint.
42const DEFAULT_SOLANA_RPC: &str = "https://api.mainnet-beta.solana.com";
43
44/// Solscan API base URL for transaction history.
45#[allow(dead_code)] // Reserved for future Solscan integration
46const SOLSCAN_API_URL: &str = "https://api.solscan.io";
47
48/// Solana native token decimals.
49const SOL_DECIMALS: u8 = 9;
50
51/// Solana blockchain client.
52///
53/// Supports balance queries via JSON-RPC and optional transaction
54/// history via Solscan API.
55#[derive(Clone)]
56pub struct SolanaClient {
57    /// HTTP client for API requests.
58    http: Arc<dyn HttpClient>,
59
60    /// Solana JSON-RPC endpoint URL.
61    rpc_url: String,
62
63    /// Solscan API key for enhanced transaction data.
64    #[allow(dead_code)] // Reserved for future Solscan integration
65    solscan_api_key: Option<String>,
66}
67
68/// JSON-RPC request structure.
69#[derive(Debug, Serialize)]
70struct RpcRequest<'a, T: Serialize> {
71    jsonrpc: &'a str,
72    id: u64,
73    method: &'a str,
74    params: T,
75}
76
77/// JSON-RPC response structure.
78#[derive(Debug, Deserialize)]
79struct RpcResponse<T> {
80    result: Option<T>,
81    error: Option<RpcError>,
82}
83
84/// JSON-RPC error structure.
85#[derive(Debug, Deserialize)]
86struct RpcError {
87    code: i64,
88    message: String,
89}
90
91/// Balance response from getBalance RPC call.
92#[derive(Debug, Deserialize)]
93struct BalanceResponse {
94    value: u64,
95}
96
97/// Response structure for getTokenAccountsByOwner.
98#[derive(Debug, Deserialize)]
99struct TokenAccountsResponse {
100    value: Vec<TokenAccountInfo>,
101}
102
103/// Individual token account info.
104#[derive(Debug, Deserialize)]
105struct TokenAccountInfo {
106    pubkey: String,
107    account: TokenAccountData,
108}
109
110/// Token account data.
111#[derive(Debug, Deserialize)]
112struct TokenAccountData {
113    data: TokenAccountParsedData,
114}
115
116/// Parsed token account data.
117#[derive(Debug, Deserialize)]
118struct TokenAccountParsedData {
119    parsed: TokenAccountParsedInfo,
120}
121
122/// Parsed info containing token details.
123#[derive(Debug, Deserialize)]
124struct TokenAccountParsedInfo {
125    info: TokenInfo,
126}
127
128/// Token balance and mint information.
129#[derive(Debug, Deserialize)]
130#[serde(rename_all = "camelCase")]
131struct TokenInfo {
132    mint: String,
133    token_amount: TokenAmount,
134}
135
136/// Token amount with UI representation.
137#[derive(Debug, Deserialize)]
138#[serde(rename_all = "camelCase")]
139#[allow(dead_code)] // ui_amount_string reserved for future use
140struct TokenAmount {
141    amount: String,
142    decimals: u8,
143    ui_amount: Option<f64>,
144    ui_amount_string: String,
145}
146
147/// SPL Token balance with metadata.
148#[derive(Debug, Clone, Serialize)]
149pub struct TokenBalance {
150    /// Token mint address.
151    pub mint: String,
152    /// Token account address.
153    pub token_account: String,
154    /// Raw balance in smallest unit.
155    pub raw_amount: String,
156    /// Human-readable balance.
157    pub ui_amount: f64,
158    /// Token decimals.
159    pub decimals: u8,
160    /// Token symbol (if known).
161    pub symbol: Option<String>,
162    /// Token name (if known).
163    pub name: Option<String>,
164}
165
166/// Transaction signature info from getSignaturesForAddress.
167#[derive(Debug, Deserialize)]
168#[serde(rename_all = "camelCase")]
169#[allow(dead_code)] // Fields used for deserialization
170struct SignatureInfo {
171    signature: String,
172    slot: u64,
173    block_time: Option<i64>,
174    err: Option<serde_json::Value>,
175}
176
177/// Solana transaction result from getTransaction RPC.
178#[derive(Debug, Deserialize)]
179#[serde(rename_all = "camelCase")]
180struct SolanaTransactionResult {
181    #[serde(default)]
182    slot: Option<u64>,
183    #[serde(default)]
184    block_time: Option<i64>,
185    #[serde(default)]
186    transaction: Option<SolanaTransactionData>,
187    #[serde(default)]
188    meta: Option<SolanaTransactionMeta>,
189}
190
191/// Transaction data from Solana RPC.
192#[derive(Debug, Deserialize)]
193struct SolanaTransactionData {
194    #[serde(default)]
195    message: Option<SolanaTransactionMessage>,
196}
197
198/// Transaction message from Solana RPC.
199#[derive(Debug, Deserialize)]
200#[serde(rename_all = "camelCase")]
201struct SolanaTransactionMessage {
202    #[serde(default)]
203    account_keys: Option<Vec<AccountKeyEntry>>,
204}
205
206/// Account key can be a string or an object with pubkey + signer fields.
207#[derive(Debug, Deserialize)]
208#[serde(untagged)]
209enum AccountKeyEntry {
210    String(String),
211    Object {
212        pubkey: String,
213        #[serde(default)]
214        #[allow(dead_code)]
215        signer: bool,
216    },
217}
218
219/// Transaction metadata from Solana RPC.
220#[derive(Debug, Deserialize)]
221#[serde(rename_all = "camelCase")]
222struct SolanaTransactionMeta {
223    #[serde(default)]
224    fee: Option<u64>,
225    #[serde(default)]
226    pre_balances: Option<Vec<u64>>,
227    #[serde(default)]
228    post_balances: Option<Vec<u64>>,
229    #[serde(default)]
230    err: Option<serde_json::Value>,
231}
232
233/// Solscan account info response.
234#[derive(Debug, Deserialize)]
235#[allow(dead_code)] // Reserved for future Solscan integration
236struct SolscanAccountInfo {
237    lamports: u64,
238    #[serde(rename = "type")]
239    account_type: Option<String>,
240}
241
242impl SolanaClient {
243    /// Creates a new Solana client with the given configuration.
244    ///
245    /// # Arguments
246    ///
247    /// * `config` - Chain configuration containing RPC endpoint and API keys
248    ///
249    /// # Returns
250    ///
251    /// Returns a configured [`SolanaClient`] instance.
252    ///
253    /// # Examples
254    ///
255    /// ```rust,no_run
256    /// use scope::chains::SolanaClient;
257    /// use scope::config::ChainsConfig;
258    ///
259    /// let config = ChainsConfig::default();
260    /// let client = SolanaClient::new(&config).unwrap();
261    /// ```
262    #[deprecated(
263        since = "0.8.0",
264        note = "pass the transport: use `SolanaClient::new_with_http`"
265    )]
266    pub fn new(config: &ChainsConfig) -> Result<Self> {
267        Self::new_with_http(config, crate::http::shared::fallback())
268    }
269
270    /// Creates a new Solana client with a pre-built HTTP transport.
271    pub fn new_with_http(config: &ChainsConfig, http: Arc<dyn HttpClient>) -> Result<Self> {
272        let rpc_url = config
273            .solana_rpc
274            .as_deref()
275            .unwrap_or(DEFAULT_SOLANA_RPC)
276            .to_string();
277
278        Ok(Self {
279            http,
280            rpc_url,
281            solscan_api_key: config.api_keys.get("solscan").cloned(),
282        })
283    }
284
285    /// Creates a client with a custom RPC URL.
286    ///
287    /// # Arguments
288    ///
289    /// * `rpc_url` - The Solana JSON-RPC endpoint URL
290    pub fn with_rpc_url(rpc_url: &str) -> Self {
291        Self {
292            http: crate::http::shared::fallback(),
293            rpc_url: rpc_url.to_string(),
294            solscan_api_key: None,
295        }
296    }
297
298    /// Returns the chain name.
299    pub fn chain_name(&self) -> &str {
300        "solana"
301    }
302
303    /// Returns the native token symbol.
304    pub fn native_token_symbol(&self) -> &str {
305        "SOL"
306    }
307
308    /// Fetches the SOL balance for an address.
309    ///
310    /// # Arguments
311    ///
312    /// * `address` - The Solana address (base58 encoded)
313    ///
314    /// # Returns
315    ///
316    /// Returns a [`Balance`] struct with the balance in multiple formats.
317    ///
318    /// # Errors
319    ///
320    /// Returns [`ScopeError::InvalidAddress`] if the address format is invalid.
321    /// Returns [`ScopeError::Request`] if the API request fails.
322    pub async fn get_balance(&self, address: &str) -> Result<Balance> {
323        // Validate address
324        validate_solana_address(address)?;
325
326        let request = RpcRequest {
327            jsonrpc: "2.0",
328            id: 1,
329            method: "getBalance",
330            params: vec![address],
331        };
332
333        tracing::debug!(url = %self.rpc_url, address = %address, "Fetching Solana balance");
334
335        let body = serde_json::to_string(&request)?;
336        let response: RpcResponse<BalanceResponse> = self
337            .http
338            .send(Request::post_json(&self.rpc_url, body))
339            .await?
340            .json()?;
341
342        if let Some(error) = response.error {
343            return Err(ScopeError::Chain(format!(
344                "Solana RPC error ({}): {}",
345                error.code, error.message
346            )));
347        }
348
349        let balance = response
350            .result
351            .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))?;
352
353        let lamports = balance.value;
354        let sol = lamports as f64 / 10_f64.powi(SOL_DECIMALS as i32);
355
356        Ok(Balance {
357            raw: lamports.to_string(),
358            formatted: format!("{:.9} SOL", sol),
359            decimals: SOL_DECIMALS,
360            symbol: "SOL".to_string(),
361            usd_value: None, // Populated by caller via enrich_balance_usd
362        })
363    }
364
365    /// Fetches SPL token (mint) info from RPC.
366    ///
367    /// Parses decimals from the mint account data. Symbol and name use
368    /// placeholders (Solscan Pro API would provide full metadata).
369    pub async fn get_token_info(&self, mint_address: &str) -> Result<Token> {
370        validate_solana_address(mint_address)?;
371
372        let request = RpcRequest {
373            jsonrpc: "2.0",
374            id: 1,
375            method: "getAccountInfo",
376            params: serde_json::json!([mint_address, { "encoding": "base64" }]),
377        };
378
379        tracing::debug!(
380            url = %self.rpc_url,
381            mint = %mint_address,
382            "Fetching SPL mint info"
383        );
384
385        #[derive(Deserialize)]
386        struct AccountInfoResult {
387            value: Option<AccountInfoValue>,
388        }
389        #[derive(Deserialize)]
390        struct AccountInfoValue {
391            data: Option<Vec<String>>,
392        }
393
394        let body = serde_json::to_string(&request)?;
395        let response: RpcResponse<AccountInfoResult> = self
396            .http
397            .send(Request::post_json(&self.rpc_url, body))
398            .await?
399            .json()?;
400
401        if let Some(error) = response.error {
402            return Err(ScopeError::Chain(format!(
403                "Solana RPC error ({}): {}",
404                error.code, error.message
405            )));
406        }
407
408        let account = response.result.and_then(|r| r.value).ok_or_else(|| {
409            ScopeError::NotFound(format!("Mint account not found: {}", mint_address))
410        })?;
411
412        let data_b64 = account
413            .data
414            .and_then(|d| d.into_iter().next())
415            .ok_or_else(|| {
416                ScopeError::Chain(format!("No account data for mint: {}", mint_address))
417            })?;
418
419        let data = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &data_b64)
420            .map_err(|e| ScopeError::Chain(format!("Failed to decode mint data: {}", e)))?;
421
422        // SPL Token mint layout: 32 (mint_authority) + 8 (supply) + 1 (decimals) + ...
423        const DECIMALS_OFFSET: usize = 40;
424        let decimals = if data.len() > DECIMALS_OFFSET {
425            data[DECIMALS_OFFSET]
426        } else {
427            return Err(ScopeError::Chain(format!(
428                "Invalid mint account data (too short): {}",
429                mint_address
430            )));
431        };
432
433        let short_mint = crate::display::text::abbreviate(mint_address);
434
435        Ok(Token {
436            contract_address: mint_address.to_string(),
437            symbol: short_mint,
438            name: "SPL Token".to_string(),
439            decimals,
440        })
441    }
442
443    /// Enriches a balance with a USD value using DexScreener price lookup.
444    pub async fn enrich_balance_usd(&self, balance: &mut Balance) {
445        let dex = crate::chains::DexClient::new_with_http(self.http.clone());
446        if let Some(price) = dex.get_native_token_price("solana").await {
447            let lamports: f64 = balance.raw.parse().unwrap_or(0.0);
448            let sol = lamports / 10_f64.powi(SOL_DECIMALS as i32);
449            balance.usd_value = Some(sol * price);
450        }
451    }
452
453    /// Fetches all SPL token balances for an address.
454    ///
455    /// # Arguments
456    ///
457    /// * `address` - The Solana wallet address to query
458    ///
459    /// # Returns
460    ///
461    /// Returns a vector of [`TokenBalance`] containing all SPL tokens held by the address.
462    ///
463    /// # Errors
464    ///
465    /// Returns [`ScopeError::InvalidAddress`] if the address format is invalid.
466    /// Returns [`ScopeError::Request`] if the API request fails.
467    pub async fn get_token_balances(&self, address: &str) -> Result<Vec<TokenBalance>> {
468        validate_solana_address(address)?;
469
470        // Use getTokenAccountsByOwner to get all token accounts
471        // The TOKEN_PROGRAM_ID is the standard SPL Token program
472        const TOKEN_PROGRAM_ID: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
473
474        let request = serde_json::json!({
475            "jsonrpc": "2.0",
476            "id": 1,
477            "method": "getTokenAccountsByOwner",
478            "params": [
479                address,
480                { "programId": TOKEN_PROGRAM_ID },
481                { "encoding": "jsonParsed" }
482            ]
483        });
484
485        tracing::debug!(url = %self.rpc_url, address = %address, "Fetching SPL token balances");
486
487        let body = serde_json::to_string(&request)?;
488        let response: RpcResponse<TokenAccountsResponse> = self
489            .http
490            .send(Request::post_json(&self.rpc_url, body))
491            .await?
492            .json()?;
493
494        if let Some(error) = response.error {
495            return Err(ScopeError::Chain(format!(
496                "Solana RPC error ({}): {}",
497                error.code, error.message
498            )));
499        }
500
501        let accounts = response
502            .result
503            .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))?;
504
505        let token_balances: Vec<TokenBalance> = accounts
506            .value
507            .into_iter()
508            .filter_map(|account| {
509                let info = &account.account.data.parsed.info;
510                let ui_amount = info.token_amount.ui_amount.unwrap_or(0.0);
511
512                // Skip zero balances
513                if ui_amount == 0.0 {
514                    return None;
515                }
516
517                Some(TokenBalance {
518                    mint: info.mint.clone(),
519                    token_account: account.pubkey,
520                    raw_amount: info.token_amount.amount.clone(),
521                    ui_amount,
522                    decimals: info.token_amount.decimals,
523                    symbol: None, // Would need token metadata to get this
524                    name: None,
525                })
526            })
527            .collect();
528
529        Ok(token_balances)
530    }
531
532    /// Fetches recent transaction signatures for an address.
533    ///
534    /// # Arguments
535    ///
536    /// * `address` - The Solana address to query
537    /// * `limit` - Maximum number of signatures to return
538    ///
539    /// # Returns
540    ///
541    /// Returns a vector of transaction signatures.
542    pub async fn get_signatures(&self, address: &str, limit: u32) -> Result<Vec<String>> {
543        let infos = self.get_signature_infos(address, limit).await?;
544        Ok(infos.into_iter().map(|s| s.signature).collect())
545    }
546
547    /// Fetches recent transaction signature info (with metadata) for an address.
548    async fn get_signature_infos(&self, address: &str, limit: u32) -> Result<Vec<SignatureInfo>> {
549        validate_solana_address(address)?;
550
551        #[derive(Serialize)]
552        struct GetSignaturesParams<'a> {
553            limit: u32,
554            #[serde(skip_serializing_if = "Option::is_none")]
555            before: Option<&'a str>,
556        }
557
558        let request = RpcRequest {
559            jsonrpc: "2.0",
560            id: 1,
561            method: "getSignaturesForAddress",
562            params: (
563                address,
564                GetSignaturesParams {
565                    limit,
566                    before: None,
567                },
568            ),
569        };
570
571        tracing::debug!(
572            url = %self.rpc_url,
573            address = %address,
574            limit = %limit,
575            "Fetching Solana transaction signatures"
576        );
577
578        let body = serde_json::to_string(&request)?;
579        let response: RpcResponse<Vec<SignatureInfo>> = self
580            .http
581            .send(Request::post_json(&self.rpc_url, body))
582            .await?
583            .json()?;
584
585        if let Some(error) = response.error {
586            return Err(ScopeError::Chain(format!(
587                "Solana RPC error ({}): {}",
588                error.code, error.message
589            )));
590        }
591
592        response
593            .result
594            .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))
595    }
596
597    /// Fetches transaction details by signature.
598    ///
599    /// # Arguments
600    ///
601    /// * `signature` - The transaction signature (base58 encoded)
602    ///
603    /// # Returns
604    ///
605    /// Returns [`Transaction`] details.
606    pub async fn get_transaction(&self, signature: &str) -> Result<Transaction> {
607        // Validate signature format
608        validate_solana_signature(signature)?;
609
610        let request = RpcRequest {
611            jsonrpc: "2.0",
612            id: 1,
613            method: "getTransaction",
614            params: serde_json::json!([
615                signature,
616                {
617                    "encoding": "jsonParsed",
618                    "maxSupportedTransactionVersion": 0
619                }
620            ]),
621        };
622
623        tracing::debug!(
624            url = %self.rpc_url,
625            signature = %signature,
626            "Fetching Solana transaction"
627        );
628
629        let body = serde_json::to_string(&request)?;
630        let response: RpcResponse<SolanaTransactionResult> = self
631            .http
632            .send(Request::post_json(&self.rpc_url, body))
633            .await?
634            .json()?;
635
636        if let Some(error) = response.error {
637            return Err(ScopeError::Chain(format!(
638                "Solana RPC error ({}): {}",
639                error.code, error.message
640            )));
641        }
642
643        let tx_result = response
644            .result
645            .ok_or_else(|| ScopeError::NotFound(format!("Transaction not found: {}", signature)))?;
646
647        // Extract the first signer (fee payer) as "from"
648        let from = tx_result
649            .transaction
650            .as_ref()
651            .and_then(|tx| tx.message.as_ref())
652            .and_then(|msg| msg.account_keys.as_ref())
653            .and_then(|keys| keys.first())
654            .map(|key| match key {
655                AccountKeyEntry::String(s) => s.clone(),
656                AccountKeyEntry::Object { pubkey, .. } => pubkey.clone(),
657            })
658            .unwrap_or_default();
659
660        // Try to find the SOL transfer amount from the transaction
661        let value = tx_result
662            .meta
663            .as_ref()
664            .and_then(|meta| {
665                let pre = meta.pre_balances.as_ref()?;
666                let post = meta.post_balances.as_ref()?;
667                if pre.len() >= 2 && post.len() >= 2 {
668                    // Amount sent = pre[0] - post[0] - fee (fee payer's balance change minus fee)
669                    let fee = meta.fee.unwrap_or(0);
670                    let sent = pre[0].saturating_sub(post[0]).saturating_sub(fee);
671                    if sent > 0 {
672                        let sol = sent as f64 / 10_f64.powi(SOL_DECIMALS as i32);
673                        return Some(format!("{:.9}", sol));
674                    }
675                }
676                None
677            })
678            .unwrap_or_else(|| "0".to_string());
679
680        // Extract "to" address (second account key, typically the recipient)
681        let to = tx_result
682            .transaction
683            .as_ref()
684            .and_then(|tx| tx.message.as_ref())
685            .and_then(|msg| msg.account_keys.as_ref())
686            .and_then(|keys| {
687                if keys.len() >= 2 {
688                    Some(match &keys[1] {
689                        AccountKeyEntry::String(s) => s.clone(),
690                        AccountKeyEntry::Object { pubkey, .. } => pubkey.clone(),
691                    })
692                } else {
693                    None
694                }
695            });
696
697        let fee = tx_result
698            .meta
699            .as_ref()
700            .and_then(|meta| meta.fee)
701            .unwrap_or(0);
702
703        let status = tx_result.meta.as_ref().map(|meta| meta.err.is_none());
704
705        Ok(Transaction {
706            hash: signature.to_string(),
707            block_number: tx_result.slot,
708            timestamp: tx_result.block_time.map(|t| t as u64),
709            from,
710            to,
711            value,
712            gas_limit: 0, // Solana uses compute units, not gas
713            gas_used: None,
714            gas_price: fee.to_string(), // Use fee as gas_price equivalent
715            nonce: 0,
716            input: String::new(),
717            status,
718        })
719    }
720
721    /// Fetches recent transactions for an address.
722    ///
723    /// # Arguments
724    ///
725    /// * `address` - The address to query
726    /// * `limit` - Maximum number of transactions
727    ///
728    /// # Returns
729    ///
730    /// Returns a vector of [`Transaction`] objects.
731    pub async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
732        validate_solana_address(address)?;
733
734        // Get signature infos (includes slot, blockTime, err)
735        let sig_infos = self.get_signature_infos(address, limit).await?;
736
737        let transactions: Vec<Transaction> = sig_infos
738            .into_iter()
739            .map(|info| Transaction {
740                hash: info.signature,
741                block_number: Some(info.slot),
742                timestamp: info.block_time.map(|t| t as u64),
743                from: address.to_string(),
744                to: None,
745                value: "0".to_string(),
746                gas_limit: 0,
747                gas_used: None,
748                gas_price: "0".to_string(),
749                nonce: 0,
750                input: String::new(),
751                status: Some(info.err.is_none()),
752            })
753            .collect();
754
755        Ok(transactions)
756    }
757
758    /// Fetches the current slot number (equivalent to block number).
759    pub async fn get_slot(&self) -> Result<u64> {
760        let request = RpcRequest {
761            jsonrpc: "2.0",
762            id: 1,
763            method: "getSlot",
764            params: (),
765        };
766
767        let body = serde_json::to_string(&request)?;
768        let response: RpcResponse<u64> = self
769            .http
770            .send(Request::post_json(&self.rpc_url, body))
771            .await?
772            .json()?;
773
774        if let Some(error) = response.error {
775            return Err(ScopeError::Chain(format!(
776                "Solana RPC error ({}): {}",
777                error.code, error.message
778            )));
779        }
780
781        response
782            .result
783            .ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))
784    }
785}
786
787impl Default for SolanaClient {
788    fn default() -> Self {
789        Self {
790            http: crate::http::shared::fallback(),
791            rpc_url: DEFAULT_SOLANA_RPC.to_string(),
792            solscan_api_key: None,
793        }
794    }
795}
796
797/// Validates a Solana address format (base58 encoded, 32-44 characters).
798///
799/// # Arguments
800///
801/// * `address` - The address to validate
802///
803/// # Returns
804///
805/// Returns `Ok(())` if valid, or an error describing the validation failure.
806pub fn validate_solana_address(address: &str) -> Result<()> {
807    // Solana addresses are base58 encoded ed25519 public keys
808    // They are typically 32-44 characters long
809
810    if address.is_empty() {
811        return Err(ScopeError::InvalidAddress("Address cannot be empty".into()));
812    }
813
814    // Check length (base58 encoded 32-byte keys are 32-44 chars)
815    if address.len() < 32 || address.len() > 44 {
816        return Err(ScopeError::InvalidAddress(format!(
817            "Solana address must be 32-44 characters, got {}: {}",
818            address.len(),
819            address
820        )));
821    }
822
823    // Validate base58 encoding
824    match bs58::decode(address).into_vec() {
825        Ok(bytes) => {
826            // Should decode to 32 bytes (ed25519 public key)
827            if bytes.len() != 32 {
828                return Err(ScopeError::InvalidAddress(format!(
829                    "Solana address must decode to 32 bytes, got {}: {}",
830                    bytes.len(),
831                    address
832                )));
833            }
834        }
835        Err(e) => {
836            return Err(ScopeError::InvalidAddress(format!(
837                "Invalid base58 encoding: {}: {}",
838                e, address
839            )));
840        }
841    }
842
843    Ok(())
844}
845
846/// Validates a Solana transaction signature format (base58 encoded).
847///
848/// # Arguments
849///
850/// * `signature` - The signature to validate
851///
852/// # Returns
853///
854/// Returns `Ok(())` if valid, or an error describing the validation failure.
855pub fn validate_solana_signature(signature: &str) -> Result<()> {
856    // Solana signatures are base58 encoded 64-byte signatures
857    // They are typically 87-88 characters long
858
859    if signature.is_empty() {
860        return Err(ScopeError::InvalidHash("Signature cannot be empty".into()));
861    }
862
863    // Check length (base58 encoded 64-byte signatures are ~87-88 chars)
864    if signature.len() < 80 || signature.len() > 90 {
865        return Err(ScopeError::InvalidHash(format!(
866            "Solana signature must be 80-90 characters, got {}: {}",
867            signature.len(),
868            signature
869        )));
870    }
871
872    // Validate base58 encoding
873    match bs58::decode(signature).into_vec() {
874        Ok(bytes) => {
875            // Should decode to 64 bytes (ed25519 signature)
876            if bytes.len() != 64 {
877                return Err(ScopeError::InvalidHash(format!(
878                    "Solana signature must decode to 64 bytes, got {}: {}",
879                    bytes.len(),
880                    signature
881                )));
882            }
883        }
884        Err(e) => {
885            return Err(ScopeError::InvalidHash(format!(
886                "Invalid base58 encoding: {}: {}",
887                e, signature
888            )));
889        }
890    }
891
892    Ok(())
893}
894
895// ============================================================================
896// ChainClient Trait Implementation
897// ============================================================================
898
899#[async_trait]
900impl ChainClient for SolanaClient {
901    fn chain_name(&self) -> &str {
902        "solana"
903    }
904
905    fn native_token_symbol(&self) -> &str {
906        "SOL"
907    }
908
909    async fn get_balance(&self, address: &str) -> Result<Balance> {
910        self.get_balance(address).await
911    }
912
913    async fn enrich_balance_usd(&self, balance: &mut Balance) {
914        self.enrich_balance_usd(balance).await
915    }
916
917    async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
918        self.get_transaction(hash).await
919    }
920
921    async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
922        self.get_transactions(address, limit).await
923    }
924
925    async fn get_block_number(&self) -> Result<u64> {
926        self.get_slot().await
927    }
928
929    async fn get_token_info(&self, address: &str) -> Result<Token> {
930        self.get_token_info(address).await
931    }
932
933    async fn get_token_balances(&self, address: &str) -> Result<Vec<crate::chains::TokenBalance>> {
934        let solana_balances = self.get_token_balances(address).await?;
935        Ok(solana_balances
936            .into_iter()
937            .map(|tb| crate::chains::TokenBalance {
938                token: Token {
939                    contract_address: tb.mint.clone(),
940                    symbol: tb
941                        .symbol
942                        .unwrap_or_else(|| tb.mint[..8.min(tb.mint.len())].to_string()),
943                    name: tb.name.unwrap_or_else(|| "SPL Token".to_string()),
944                    decimals: tb.decimals,
945                },
946                balance: tb.raw_amount,
947                formatted_balance: format!("{:.6}", tb.ui_amount),
948                usd_value: None,
949            })
950            .collect())
951    }
952}
953
954// ============================================================================
955// Unit Tests
956// ============================================================================
957
958#[cfg(test)]
959mod tests {
960    use super::*;
961    use crate::chains::{Balance, ChainClient};
962
963    // Valid Solana address (Phantom treasury)
964    const VALID_ADDRESS: &str = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy";
965
966    // Valid Solana transaction signature
967    const VALID_SIGNATURE: &str =
968        "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
969
970    #[test]
971    fn test_validate_solana_address_valid() {
972        assert!(validate_solana_address(VALID_ADDRESS).is_ok());
973    }
974
975    #[test]
976    fn test_validate_solana_address_empty() {
977        let result = validate_solana_address("");
978        assert!(result.is_err());
979        assert!(result.unwrap_err().to_string().contains("empty"));
980    }
981
982    #[test]
983    fn test_validate_solana_address_too_short() {
984        let result = validate_solana_address("DRpbCBMxVnDK7maPM5t");
985        assert!(result.is_err());
986        assert!(result.unwrap_err().to_string().contains("32-44"));
987    }
988
989    #[test]
990    fn test_validate_solana_address_too_long() {
991        let long_addr = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hyAAAAAAAAAAAA";
992        let result = validate_solana_address(long_addr);
993        assert!(result.is_err());
994    }
995
996    #[test]
997    fn test_validate_solana_address_invalid_base58() {
998        // Contains '0' which is not valid base58
999        let result = validate_solana_address("0RpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1000        assert!(result.is_err());
1001        assert!(result.unwrap_err().to_string().contains("base58"));
1002    }
1003
1004    #[test]
1005    fn test_validate_solana_address_wrong_decoded_length() {
1006        // Valid base58 but decodes to wrong byte length (not 32 bytes)
1007        // "abc" is valid base58 but too short when decoded
1008        let result = validate_solana_address("abcdefghijabcdefghijabcdefghijab");
1009        assert!(result.is_err());
1010        // Should fail due to decoded length being wrong
1011    }
1012
1013    #[test]
1014    fn test_validate_solana_signature_valid() {
1015        assert!(validate_solana_signature(VALID_SIGNATURE).is_ok());
1016    }
1017
1018    #[test]
1019    fn test_validate_solana_signature_empty() {
1020        let result = validate_solana_signature("");
1021        assert!(result.is_err());
1022        assert!(result.unwrap_err().to_string().contains("empty"));
1023    }
1024
1025    #[test]
1026    fn test_validate_solana_signature_too_short() {
1027        let result = validate_solana_signature("abc");
1028        assert!(result.is_err());
1029        assert!(result.unwrap_err().to_string().contains("80-90"));
1030    }
1031
1032    #[test]
1033    fn test_solana_client_default() {
1034        let client = SolanaClient::default();
1035        assert_eq!(client.chain_name(), "solana");
1036        assert_eq!(client.native_token_symbol(), "SOL");
1037        assert!(client.rpc_url.contains("mainnet-beta"));
1038    }
1039
1040    #[test]
1041    fn test_solana_client_with_rpc_url() {
1042        let client = SolanaClient::with_rpc_url("https://custom.rpc.com");
1043        assert_eq!(client.rpc_url, "https://custom.rpc.com");
1044    }
1045
1046    #[test]
1047    fn test_solana_client_new() {
1048        let config = ChainsConfig::default();
1049        let client = SolanaClient::new_with_http(&config, crate::http::test_transport());
1050        assert!(client.is_ok());
1051    }
1052
1053    #[test]
1054    fn test_solana_client_new_with_custom_rpc() {
1055        let config = ChainsConfig {
1056            solana_rpc: Some("https://my-solana-rpc.com".to_string()),
1057            ..Default::default()
1058        };
1059        let client = SolanaClient::new_with_http(&config, crate::http::test_transport()).unwrap();
1060        assert_eq!(client.rpc_url, "https://my-solana-rpc.com");
1061    }
1062
1063    #[test]
1064    fn test_solana_client_new_with_api_key() {
1065        use std::collections::HashMap;
1066
1067        let mut api_keys = HashMap::new();
1068        api_keys.insert("solscan".to_string(), "test-key".to_string());
1069
1070        let config = ChainsConfig {
1071            api_keys,
1072            ..Default::default()
1073        };
1074
1075        let client = SolanaClient::new_with_http(&config, crate::http::test_transport()).unwrap();
1076        assert_eq!(client.solscan_api_key, Some("test-key".to_string()));
1077    }
1078
1079    #[test]
1080    fn test_rpc_request_serialization() {
1081        let request = RpcRequest {
1082            jsonrpc: "2.0",
1083            id: 1,
1084            method: "getBalance",
1085            params: vec!["test"],
1086        };
1087
1088        let json = serde_json::to_string(&request).unwrap();
1089        assert!(json.contains("jsonrpc"));
1090        assert!(json.contains("getBalance"));
1091    }
1092
1093    #[test]
1094    fn test_rpc_response_deserialization() {
1095        let json = r#"{"jsonrpc":"2.0","result":{"value":1000000000},"id":1}"#;
1096        let response: RpcResponse<BalanceResponse> = serde_json::from_str(json).unwrap();
1097        assert!(response.result.is_some());
1098        assert_eq!(response.result.unwrap().value, 1_000_000_000);
1099    }
1100
1101    #[test]
1102    fn test_rpc_error_deserialization() {
1103        let json =
1104            r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid request"},"id":1}"#;
1105        let response: RpcResponse<BalanceResponse> = serde_json::from_str(json).unwrap();
1106        assert!(response.error.is_some());
1107        let error = response.error.unwrap();
1108        assert_eq!(error.code, -32600);
1109        assert_eq!(error.message, "Invalid request");
1110    }
1111
1112    // ========================================================================
1113    // HTTP mocking tests
1114    // ========================================================================
1115
1116    #[tokio::test]
1117    async fn test_get_balance() {
1118        let mut server = mockito::Server::new_async().await;
1119        let _mock = server
1120            .mock("POST", "/")
1121            .with_status(200)
1122            .with_header("content-type", "application/json")
1123            .with_body(r#"{"jsonrpc":"2.0","result":{"value":5000000000},"id":1}"#)
1124            .create_async()
1125            .await;
1126
1127        let client = SolanaClient::with_rpc_url(&server.url());
1128        let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1129        assert_eq!(balance.raw, "5000000000");
1130        assert_eq!(balance.symbol, "SOL");
1131        assert_eq!(balance.decimals, 9);
1132        assert!(balance.formatted.contains("5.000000000"));
1133    }
1134
1135    #[tokio::test]
1136    async fn test_get_balance_zero() {
1137        let mut server = mockito::Server::new_async().await;
1138        let _mock = server
1139            .mock("POST", "/")
1140            .with_status(200)
1141            .with_header("content-type", "application/json")
1142            .with_body(r#"{"jsonrpc":"2.0","result":{"value":0},"id":1}"#)
1143            .create_async()
1144            .await;
1145
1146        let client = SolanaClient::with_rpc_url(&server.url());
1147        let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
1148        assert_eq!(balance.raw, "0");
1149        assert!(balance.formatted.contains("0.000000000"));
1150    }
1151
1152    #[tokio::test]
1153    async fn test_get_balance_rpc_error() {
1154        let mut server = mockito::Server::new_async().await;
1155        let _mock = server
1156            .mock("POST", "/")
1157            .with_status(200)
1158            .with_header("content-type", "application/json")
1159            .with_body(
1160                r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid params"},"id":1}"#,
1161            )
1162            .create_async()
1163            .await;
1164
1165        let client = SolanaClient::with_rpc_url(&server.url());
1166        let result = client.get_balance(VALID_ADDRESS).await;
1167        assert!(result.is_err());
1168        assert!(result.unwrap_err().to_string().contains("RPC error"));
1169    }
1170
1171    #[tokio::test]
1172    async fn test_get_balance_empty_response() {
1173        let mut server = mockito::Server::new_async().await;
1174        let _mock = server
1175            .mock("POST", "/")
1176            .with_status(200)
1177            .with_header("content-type", "application/json")
1178            .with_body(r#"{"jsonrpc":"2.0","id":1}"#)
1179            .create_async()
1180            .await;
1181
1182        let client = SolanaClient::with_rpc_url(&server.url());
1183        let result = client.get_balance(VALID_ADDRESS).await;
1184        assert!(result.is_err());
1185        assert!(result.unwrap_err().to_string().contains("Empty RPC"));
1186    }
1187
1188    #[tokio::test]
1189    async fn test_get_balance_invalid_address() {
1190        let client = SolanaClient::default();
1191        let result = client.get_balance("invalid").await;
1192        assert!(result.is_err());
1193    }
1194
1195    #[tokio::test]
1196    async fn test_get_transaction() {
1197        let mut server = mockito::Server::new_async().await;
1198        let _mock = server
1199            .mock("POST", "/")
1200            .with_status(200)
1201            .with_header("content-type", "application/json")
1202            .with_body(
1203                r#"{"jsonrpc":"2.0","result":{
1204                "slot":123456789,
1205                "blockTime":1700000000,
1206                "transaction":{
1207                    "message":{
1208                        "accountKeys":[
1209                            {"pubkey":"DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy","signer":true},
1210                            {"pubkey":"9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM","signer":false}
1211                        ]
1212                    }
1213                },
1214                "meta":{
1215                    "fee":5000,
1216                    "preBalances":[10000000000,5000000000],
1217                    "postBalances":[8999995000,6000000000],
1218                    "err":null
1219                }
1220            },"id":1}"#,
1221            )
1222            .create_async()
1223            .await;
1224
1225        let client = SolanaClient::with_rpc_url(&server.url());
1226        let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1227        assert_eq!(tx.hash, VALID_SIGNATURE);
1228        assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1229        assert_eq!(
1230            tx.to,
1231            Some("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM".to_string())
1232        );
1233        assert_eq!(tx.block_number, Some(123456789));
1234        assert_eq!(tx.timestamp, Some(1700000000));
1235        assert!(tx.status.unwrap()); // err is null → success
1236        assert_eq!(tx.gas_price, "5000"); // fee
1237    }
1238
1239    #[tokio::test]
1240    async fn test_get_transaction_failed() {
1241        let mut server = mockito::Server::new_async().await;
1242        let _mock = server
1243            .mock("POST", "/")
1244            .with_status(200)
1245            .with_header("content-type", "application/json")
1246            .with_body(r#"{"jsonrpc":"2.0","result":{
1247                "slot":100,
1248                "transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"]}},
1249                "meta":{"fee":5000,"preBalances":[1000],"postBalances":[1000],"err":{"InstructionError":[0,{"Custom":1}]}}
1250            },"id":1}"#)
1251            .create_async()
1252            .await;
1253
1254        let client = SolanaClient::with_rpc_url(&server.url());
1255        let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1256        assert!(!tx.status.unwrap()); // err is not null → failure
1257    }
1258
1259    #[tokio::test]
1260    async fn test_get_transaction_not_found() {
1261        let mut server = mockito::Server::new_async().await;
1262        let _mock = server
1263            .mock("POST", "/")
1264            .with_status(200)
1265            .with_header("content-type", "application/json")
1266            .with_body(r#"{"jsonrpc":"2.0","result":null,"id":1}"#)
1267            .create_async()
1268            .await;
1269
1270        let client = SolanaClient::with_rpc_url(&server.url());
1271        let result = client.get_transaction(VALID_SIGNATURE).await;
1272        assert!(result.is_err());
1273        assert!(result.unwrap_err().to_string().contains("not found"));
1274    }
1275
1276    #[tokio::test]
1277    async fn test_get_transaction_string_account_keys() {
1278        let mut server = mockito::Server::new_async().await;
1279        let _mock = server
1280            .mock("POST", "/")
1281            .with_status(200)
1282            .with_header("content-type", "application/json")
1283            .with_body(r#"{"jsonrpc":"2.0","result":{
1284                "slot":100,
1285                "transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy","9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"]}},
1286                "meta":{"fee":5000,"preBalances":[1000000000,0],"postBalances":[999995000,0],"err":null}
1287            },"id":1}"#)
1288            .create_async()
1289            .await;
1290
1291        let client = SolanaClient::with_rpc_url(&server.url());
1292        let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1293        assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1294        assert_eq!(
1295            tx.to,
1296            Some("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM".to_string())
1297        );
1298    }
1299
1300    #[tokio::test]
1301    async fn test_get_signatures() {
1302        let mut server = mockito::Server::new_async().await;
1303        let _mock = server
1304            .mock("POST", "/")
1305            .with_status(200)
1306            .with_header("content-type", "application/json")
1307            .with_body(r#"{"jsonrpc":"2.0","result":[
1308                {"signature":"5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW","slot":100,"blockTime":1700000000,"err":null},
1309                {"signature":"4VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUX","slot":101,"blockTime":1700000060,"err":{"InstructionError":[0,{"Custom":1}]}}
1310            ],"id":1}"#)
1311            .create_async()
1312            .await;
1313
1314        let client = SolanaClient::with_rpc_url(&server.url());
1315        let sigs = client.get_signatures(VALID_ADDRESS, 10).await.unwrap();
1316        assert_eq!(sigs.len(), 2);
1317        assert!(sigs[0].starts_with("5VERv8"));
1318    }
1319
1320    #[tokio::test]
1321    async fn test_get_transactions() {
1322        let mut server = mockito::Server::new_async().await;
1323        let _mock = server
1324            .mock("POST", "/")
1325            .with_status(200)
1326            .with_header("content-type", "application/json")
1327            .with_body(r#"{"jsonrpc":"2.0","result":[
1328                {"signature":"5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW","slot":100,"blockTime":1700000000,"err":null},
1329                {"signature":"4VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUX","slot":101,"blockTime":1700000060,"err":{"InstructionError":[0,{"Custom":1}]}}
1330            ],"id":1}"#)
1331            .create_async()
1332            .await;
1333
1334        let client = SolanaClient::with_rpc_url(&server.url());
1335        let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
1336        assert_eq!(txs.len(), 2);
1337        assert!(txs[0].status.unwrap()); // err null → success
1338        assert!(!txs[1].status.unwrap()); // err present → failure
1339        assert_eq!(txs[0].block_number, Some(100));
1340        assert_eq!(txs[0].timestamp, Some(1700000000));
1341    }
1342
1343    #[tokio::test]
1344    async fn test_get_token_balances() {
1345        let mut server = mockito::Server::new_async().await;
1346        let _mock = server
1347            .mock("POST", "/")
1348            .with_status(200)
1349            .with_header("content-type", "application/json")
1350            .with_body(
1351                r#"{"jsonrpc":"2.0","result":{"value":[
1352                {
1353                    "pubkey":"TokenAccAddr1",
1354                    "account":{
1355                        "data":{
1356                            "parsed":{
1357                                "info":{
1358                                    "mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
1359                                    "tokenAmount":{
1360                                        "amount":"1000000",
1361                                        "decimals":6,
1362                                        "uiAmount":1.0,
1363                                        "uiAmountString":"1"
1364                                    }
1365                                }
1366                            }
1367                        }
1368                    }
1369                },
1370                {
1371                    "pubkey":"TokenAccAddr2",
1372                    "account":{
1373                        "data":{
1374                            "parsed":{
1375                                "info":{
1376                                    "mint":"So11111111111111111111111111111111111111112",
1377                                    "tokenAmount":{
1378                                        "amount":"0",
1379                                        "decimals":9,
1380                                        "uiAmount":0.0,
1381                                        "uiAmountString":"0"
1382                                    }
1383                                }
1384                            }
1385                        }
1386                    }
1387                }
1388            ]},"id":1}"#,
1389            )
1390            .create_async()
1391            .await;
1392
1393        let client = SolanaClient::with_rpc_url(&server.url());
1394        let balances = client.get_token_balances(VALID_ADDRESS).await.unwrap();
1395        // Second token has zero balance so it's filtered out
1396        assert_eq!(balances.len(), 1);
1397        assert_eq!(
1398            balances[0].mint,
1399            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1400        );
1401        assert_eq!(balances[0].ui_amount, 1.0);
1402        assert_eq!(balances[0].decimals, 6);
1403    }
1404
1405    #[tokio::test]
1406    async fn test_get_token_balances_rpc_error() {
1407        let mut server = mockito::Server::new_async().await;
1408        let _mock = server
1409            .mock("POST", "/")
1410            .with_status(200)
1411            .with_header("content-type", "application/json")
1412            .with_body(
1413                r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid params"},"id":1}"#,
1414            )
1415            .create_async()
1416            .await;
1417
1418        let client = SolanaClient::with_rpc_url(&server.url());
1419        let result = client.get_token_balances(VALID_ADDRESS).await;
1420        assert!(result.is_err());
1421    }
1422
1423    #[tokio::test]
1424    async fn test_get_slot() {
1425        let mut server = mockito::Server::new_async().await;
1426        let _mock = server
1427            .mock("POST", "/")
1428            .with_status(200)
1429            .with_header("content-type", "application/json")
1430            .with_body(r#"{"jsonrpc":"2.0","result":256000000,"id":1}"#)
1431            .create_async()
1432            .await;
1433
1434        let client = SolanaClient::with_rpc_url(&server.url());
1435        let slot = client.get_slot().await.unwrap();
1436        assert_eq!(slot, 256000000);
1437    }
1438
1439    #[tokio::test]
1440    async fn test_get_slot_error() {
1441        let mut server = mockito::Server::new_async().await;
1442        let _mock = server
1443            .mock("POST", "/")
1444            .with_status(200)
1445            .with_header("content-type", "application/json")
1446            .with_body(
1447                r#"{"jsonrpc":"2.0","error":{"code":-32005,"message":"Node is behind"},"id":1}"#,
1448            )
1449            .create_async()
1450            .await;
1451
1452        let client = SolanaClient::with_rpc_url(&server.url());
1453        let result = client.get_slot().await;
1454        assert!(result.is_err());
1455    }
1456
1457    #[test]
1458    fn test_validate_solana_signature_invalid_base58() {
1459        // '0' and 'O' and 'I' and 'l' are not valid base58 characters
1460        let bad_sig = "0OIl00000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
1461        let result = validate_solana_signature(bad_sig);
1462        assert!(result.is_err());
1463    }
1464
1465    #[test]
1466    fn test_validate_solana_signature_wrong_decoded_length() {
1467        // Valid base58 but decodes to wrong length (not 64 bytes)
1468        // "1" decodes to a single zero byte
1469        let short = "11111111111111111111111111111111"; // 32 chars of '1' = 32 zero bytes
1470        let result = validate_solana_signature(short);
1471        // This should fail: either length check or decoded-byte-count check
1472        assert!(result.is_err());
1473    }
1474
1475    #[tokio::test]
1476    async fn test_get_transaction_rpc_error() {
1477        let mut server = mockito::Server::new_async().await;
1478        let _mock = server
1479            .mock("POST", "/")
1480            .with_status(200)
1481            .with_header("content-type", "application/json")
1482            .with_body(r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Transaction not found"},"id":1}"#)
1483            .create_async()
1484            .await;
1485
1486        let client = SolanaClient::with_rpc_url(&server.url());
1487        let result = client
1488            .get_transaction("5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW")
1489            .await;
1490        assert!(result.is_err());
1491        assert!(result.unwrap_err().to_string().contains("RPC error"));
1492    }
1493
1494    #[tokio::test]
1495    async fn test_solana_chain_client_trait_chain_name() {
1496        let client = SolanaClient::with_rpc_url("http://localhost:8899");
1497        let chain_client: &dyn ChainClient = &client;
1498        assert_eq!(chain_client.chain_name(), "solana");
1499        assert_eq!(chain_client.native_token_symbol(), "SOL");
1500    }
1501
1502    #[tokio::test]
1503    async fn test_chain_client_trait_get_balance() {
1504        let mut server = mockito::Server::new_async().await;
1505        let _mock = server
1506            .mock("POST", "/")
1507            .with_status(200)
1508            .with_header("content-type", "application/json")
1509            .with_body(
1510                r#"{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":1000000000},"id":1}"#,
1511            )
1512            .create_async()
1513            .await;
1514
1515        let client = SolanaClient::with_rpc_url(&server.url());
1516        let chain_client: &dyn ChainClient = &client;
1517        let balance = chain_client.get_balance(VALID_ADDRESS).await.unwrap();
1518        assert_eq!(balance.symbol, "SOL");
1519    }
1520
1521    #[tokio::test]
1522    async fn test_chain_client_trait_get_block_number() {
1523        let mut server = mockito::Server::new_async().await;
1524        let _mock = server
1525            .mock("POST", "/")
1526            .with_status(200)
1527            .with_header("content-type", "application/json")
1528            .with_body(r#"{"jsonrpc":"2.0","result":250000000,"id":1}"#)
1529            .create_async()
1530            .await;
1531
1532        let client = SolanaClient::with_rpc_url(&server.url());
1533        let chain_client: &dyn ChainClient = &client;
1534        let slot = chain_client.get_block_number().await.unwrap();
1535        assert_eq!(slot, 250000000);
1536    }
1537
1538    #[tokio::test]
1539    async fn test_chain_client_trait_get_token_balances() {
1540        let mut server = mockito::Server::new_async().await;
1541        let _mock = server
1542            .mock("POST", "/")
1543            .with_status(200)
1544            .with_header("content-type", "application/json")
1545            .with_body(
1546                r#"{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[
1547                {
1548                    "pubkey":"TokenAccAddr1",
1549                    "account":{
1550                        "data":{
1551                            "parsed":{
1552                                "info":{
1553                                    "mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
1554                                    "tokenAmount":{
1555                                        "amount":"1000000",
1556                                        "decimals":6,
1557                                        "uiAmount":1.0,
1558                                        "uiAmountString":"1"
1559                                    }
1560                                }
1561                            }
1562                        }
1563                    }
1564                }
1565            ]},"id":1}"#,
1566            )
1567            .create_async()
1568            .await;
1569
1570        let client = SolanaClient::with_rpc_url(&server.url());
1571        let chain_client: &dyn ChainClient = &client;
1572        let balances = chain_client
1573            .get_token_balances(VALID_ADDRESS)
1574            .await
1575            .unwrap();
1576        assert!(!balances.is_empty());
1577        // Verify the mapping from SolanaTokenBalance to TokenBalance
1578        assert_eq!(
1579            balances[0].token.contract_address,
1580            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1581        );
1582    }
1583
1584    #[tokio::test]
1585    async fn test_chain_client_trait_get_transaction_solana() {
1586        let mut server = mockito::Server::new_async().await;
1587        let _mock = server
1588            .mock("POST", "/")
1589            .with_status(200)
1590            .with_header("content-type", "application/json")
1591            .with_body(
1592                r#"{"jsonrpc":"2.0","result":{
1593                "slot":200000000,
1594                "blockTime":1700000000,
1595                "meta":{
1596                    "fee":5000,
1597                    "preBalances":[1000000000,500000000],
1598                    "postBalances":[999995000,500005000],
1599                    "err":null
1600                },
1601                "transaction":{
1602                    "message":{
1603                        "accountKeys":[
1604                            "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy",
1605                            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1606                        ]
1607                    },
1608                    "signatures":["5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh"]
1609                }
1610            },"id":1}"#,
1611            )
1612            .create_async()
1613            .await;
1614
1615        let client = SolanaClient::with_rpc_url(&server.url());
1616        let chain_client: &dyn ChainClient = &client;
1617        let tx = chain_client
1618            .get_transaction("5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh")
1619            .await
1620            .unwrap();
1621        assert!(!tx.hash.is_empty());
1622        assert!(tx.timestamp.is_some());
1623    }
1624
1625    #[tokio::test]
1626    async fn test_chain_client_trait_get_transactions_solana() {
1627        let mut server = mockito::Server::new_async().await;
1628        let _mock = server
1629            .mock("POST", "/")
1630            .with_status(200)
1631            .with_header("content-type", "application/json")
1632            .with_body(
1633                r#"{"jsonrpc":"2.0","result":[
1634                {
1635                    "signature":"5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh",
1636                    "slot":200000000,
1637                    "blockTime":1700000000,
1638                    "err":null,
1639                    "memo":null
1640                }
1641            ],"id":1}"#,
1642            )
1643            .create_async()
1644            .await;
1645
1646        let client = SolanaClient::with_rpc_url(&server.url());
1647        let chain_client: &dyn ChainClient = &client;
1648        let txs = chain_client
1649            .get_transactions(VALID_ADDRESS, 10)
1650            .await
1651            .unwrap();
1652        assert!(!txs.is_empty());
1653    }
1654
1655    #[test]
1656    fn test_validate_solana_signature_wrong_byte_length() {
1657        // A valid base58 string that is 80-90 chars but decodes to wrong number of bytes
1658        // We use a padded version of a 32-byte key (which would be ~44 chars in base58)
1659        // Instead, let's create a signature-length string that decodes to wrong byte count
1660        // A 32-byte value encoded in base58 is ~44 chars, so we need something 80-90 chars
1661        // that decodes to != 64 bytes.
1662        // We can take a valid-length string and pad it:
1663        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
1664        let result = validate_solana_signature(&long_sig);
1665        assert!(result.is_err());
1666        let err = result.unwrap_err().to_string();
1667        assert!(err.contains("64 bytes") || err.contains("base58"));
1668    }
1669
1670    #[tokio::test]
1671    async fn test_rpc_error_response() {
1672        let mut server = mockito::Server::new_async().await;
1673        let _mock = server
1674            .mock("POST", "/")
1675            .with_status(200)
1676            .with_header("content-type", "application/json")
1677            .with_body(
1678                r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid request"},"id":1}"#,
1679            )
1680            .create_async()
1681            .await;
1682
1683        let client = SolanaClient::with_rpc_url(&server.url());
1684        let result = client.get_balance(VALID_ADDRESS).await;
1685        assert!(result.is_err());
1686        assert!(result.unwrap_err().to_string().contains("RPC error"));
1687    }
1688
1689    // ========================================================================
1690    // get_token_info tests
1691    // ========================================================================
1692
1693    #[tokio::test]
1694    async fn test_get_token_info_success() {
1695        // SPL Token mint layout: 32 (mint_authority) + 8 (supply) + 1 (decimals)
1696        // Decimals at offset 40. Create 41 bytes with decimals=6.
1697        let mut mint_data = vec![0u8; 41];
1698        mint_data[40] = 6;
1699        let data_b64 =
1700            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &mint_data);
1701
1702        let mut server = mockito::Server::new_async().await;
1703        let _mock = server
1704            .mock("POST", "/")
1705            .with_status(200)
1706            .with_header("content-type", "application/json")
1707            .with_body(format!(
1708                r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
1709                data_b64
1710            ))
1711            .create_async()
1712            .await;
1713
1714        let client = SolanaClient::with_rpc_url(&server.url());
1715        let token = client
1716            .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1717            .await
1718            .unwrap();
1719        assert_eq!(token.decimals, 6);
1720        assert_eq!(
1721            token.contract_address,
1722            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1723        );
1724        assert_eq!(token.symbol, "EPjFWd…Dt1v");
1725        assert_eq!(token.name, "SPL Token");
1726    }
1727
1728    #[tokio::test]
1729    async fn test_get_token_info_decimals_nine() {
1730        let mut mint_data = vec![0u8; 41];
1731        mint_data[40] = 9;
1732        let data_b64 =
1733            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &mint_data);
1734
1735        let mut server = mockito::Server::new_async().await;
1736        let _mock = server
1737            .mock("POST", "/")
1738            .with_status(200)
1739            .with_header("content-type", "application/json")
1740            .with_body(format!(
1741                r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
1742                data_b64
1743            ))
1744            .create_async()
1745            .await;
1746
1747        let client = SolanaClient::with_rpc_url(&server.url());
1748        let token = client
1749            .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1750            .await
1751            .unwrap();
1752        assert_eq!(token.decimals, 9);
1753        assert_eq!(token.symbol, "EPjFWd…Dt1v"); // the one abbreviation
1754    }
1755
1756    #[tokio::test]
1757    async fn test_get_token_info_rpc_error() {
1758        let mut server = mockito::Server::new_async().await;
1759        let _mock = server
1760            .mock("POST", "/")
1761            .with_status(200)
1762            .with_header("content-type", "application/json")
1763            .with_body(
1764                r#"{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params"},"id":1}"#,
1765            )
1766            .create_async()
1767            .await;
1768
1769        let client = SolanaClient::with_rpc_url(&server.url());
1770        let result = client
1771            .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1772            .await;
1773        assert!(result.is_err());
1774        assert!(result.unwrap_err().to_string().contains("RPC error"));
1775    }
1776
1777    #[tokio::test]
1778    async fn test_get_token_info_not_found() {
1779        let mut server = mockito::Server::new_async().await;
1780        let _mock = server
1781            .mock("POST", "/")
1782            .with_status(200)
1783            .with_header("content-type", "application/json")
1784            .with_body(r#"{"jsonrpc":"2.0","result":{"value":null},"id":1}"#)
1785            .create_async()
1786            .await;
1787
1788        let client = SolanaClient::with_rpc_url(&server.url());
1789        let result = client
1790            .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1791            .await;
1792        assert!(result.is_err());
1793        assert!(result.unwrap_err().to_string().contains("not found"));
1794    }
1795
1796    #[tokio::test]
1797    async fn test_get_token_info_no_account_data() {
1798        let mut server = mockito::Server::new_async().await;
1799        let _mock = server
1800            .mock("POST", "/")
1801            .with_status(200)
1802            .with_header("content-type", "application/json")
1803            .with_body(r#"{"jsonrpc":"2.0","result":{"value":{"data":null}},"id":1}"#)
1804            .create_async()
1805            .await;
1806
1807        let client = SolanaClient::with_rpc_url(&server.url());
1808        let result = client
1809            .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1810            .await;
1811        assert!(result.is_err());
1812        assert!(result.unwrap_err().to_string().contains("No account data"));
1813    }
1814
1815    #[tokio::test]
1816    async fn test_get_token_info_empty_data_array() {
1817        let mut server = mockito::Server::new_async().await;
1818        let _mock = server
1819            .mock("POST", "/")
1820            .with_status(200)
1821            .with_header("content-type", "application/json")
1822            .with_body(r#"{"jsonrpc":"2.0","result":{"value":{"data":[]}},"id":1}"#)
1823            .create_async()
1824            .await;
1825
1826        let client = SolanaClient::with_rpc_url(&server.url());
1827        let result = client
1828            .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1829            .await;
1830        assert!(result.is_err());
1831    }
1832
1833    #[tokio::test]
1834    async fn test_get_token_info_invalid_base64() {
1835        let mut server = mockito::Server::new_async().await;
1836        let _mock = server
1837            .mock("POST", "/")
1838            .with_status(200)
1839            .with_header("content-type", "application/json")
1840            .with_body(r#"{"jsonrpc":"2.0","result":{"value":{"data":["!!!invalid!!!"]}},"id":1}"#)
1841            .create_async()
1842            .await;
1843
1844        let client = SolanaClient::with_rpc_url(&server.url());
1845        let result = client
1846            .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1847            .await;
1848        assert!(result.is_err());
1849        assert!(result.unwrap_err().to_string().contains("decode"));
1850    }
1851
1852    #[tokio::test]
1853    async fn test_get_token_info_data_too_short() {
1854        // Only 20 bytes - not enough for decimals at offset 40
1855        let short_data = vec![0u8; 20];
1856        let data_b64 =
1857            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &short_data);
1858
1859        let mut server = mockito::Server::new_async().await;
1860        let _mock = server
1861            .mock("POST", "/")
1862            .with_status(200)
1863            .with_header("content-type", "application/json")
1864            .with_body(format!(
1865                r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
1866                data_b64
1867            ))
1868            .create_async()
1869            .await;
1870
1871        let client = SolanaClient::with_rpc_url(&server.url());
1872        let result = client
1873            .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
1874            .await;
1875        assert!(result.is_err());
1876        assert!(result.unwrap_err().to_string().contains("too short"));
1877    }
1878
1879    #[tokio::test]
1880    async fn test_get_token_info_invalid_address() {
1881        let client = SolanaClient::default();
1882        let result = client.get_token_info("bad").await;
1883        assert!(result.is_err());
1884    }
1885
1886    // ========================================================================
1887    // get_transaction edge cases
1888    // ========================================================================
1889
1890    #[tokio::test]
1891    async fn test_get_transaction_minimal_no_transaction_meta() {
1892        let mut server = mockito::Server::new_async().await;
1893        let _mock = server
1894            .mock("POST", "/")
1895            .with_status(200)
1896            .with_header("content-type", "application/json")
1897            .with_body(r#"{"jsonrpc":"2.0","result":{"slot":100},"id":1}"#)
1898            .create_async()
1899            .await;
1900
1901        let client = SolanaClient::with_rpc_url(&server.url());
1902        let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1903        assert_eq!(tx.hash, VALID_SIGNATURE);
1904        assert_eq!(tx.from, "");
1905        assert_eq!(tx.to, None);
1906        assert_eq!(tx.value, "0");
1907        assert_eq!(tx.gas_price, "0");
1908        assert_eq!(tx.block_number, Some(100));
1909        assert_eq!(tx.status, None);
1910    }
1911
1912    #[tokio::test]
1913    async fn test_get_transaction_single_account_key_to_none() {
1914        let mut server = mockito::Server::new_async().await;
1915        let _mock = server
1916            .mock("POST", "/")
1917            .with_status(200)
1918            .with_header("content-type", "application/json")
1919            .with_body(r#"{"jsonrpc":"2.0","result":{
1920                "slot":100,
1921                "blockTime":1700000000,
1922                "transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"]}},
1923                "meta":{"fee":5000,"preBalances":[1000],"postBalances":[500],"err":null}
1924            },"id":1}"#)
1925            .create_async()
1926            .await;
1927
1928        let client = SolanaClient::with_rpc_url(&server.url());
1929        let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1930        assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
1931        assert_eq!(tx.to, None); // Only one account key
1932        assert_eq!(tx.value, "0"); // pre/post len < 2
1933    }
1934
1935    #[tokio::test]
1936    async fn test_get_transaction_value_zero_when_no_send() {
1937        let mut server = mockito::Server::new_async().await;
1938        let _mock = server
1939            .mock("POST", "/")
1940            .with_status(200)
1941            .with_header("content-type", "application/json")
1942            .with_body(
1943                r#"{"jsonrpc":"2.0","result":{
1944                "slot":100,
1945                "transaction":{"message":{"accountKeys":["A","B"]}},
1946                "meta":{"fee":5000,"preBalances":[1000,500],"postBalances":[1000,500],"err":null}
1947            },"id":1}"#,
1948            )
1949            .create_async()
1950            .await;
1951
1952        let client = SolanaClient::with_rpc_url(&server.url());
1953        let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1954        assert_eq!(tx.value, "0"); // pre[0]-post[0]-fee = 1000-1000-500 = 0 (saturating)
1955    }
1956
1957    #[tokio::test]
1958    async fn test_get_transaction_meta_no_fee() {
1959        let mut server = mockito::Server::new_async().await;
1960        let _mock = server
1961            .mock("POST", "/")
1962            .with_status(200)
1963            .with_header("content-type", "application/json")
1964            .with_body(r#"{"jsonrpc":"2.0","result":{
1965                "slot":100,
1966                "transaction":{"message":{"accountKeys":["A","B"]}},
1967                "meta":{"preBalances":[10000000000,0],"postBalances":[8999995000,1000005000],"err":null}
1968            },"id":1}"#)
1969            .create_async()
1970            .await;
1971
1972        let client = SolanaClient::with_rpc_url(&server.url());
1973        let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
1974        assert_eq!(tx.gas_price, "0"); // fee is None, default 0
1975    }
1976
1977    #[tokio::test]
1978    async fn test_get_transaction_invalid_signature() {
1979        let client = SolanaClient::default();
1980        let result = client.get_transaction("invalid").await;
1981        assert!(result.is_err());
1982    }
1983
1984    // ========================================================================
1985    // get_signatures and get_transactions error paths
1986    // ========================================================================
1987
1988    #[tokio::test]
1989    async fn test_get_signatures_empty_response() {
1990        let mut server = mockito::Server::new_async().await;
1991        let _mock = server
1992            .mock("POST", "/")
1993            .with_status(200)
1994            .with_header("content-type", "application/json")
1995            .with_body(r#"{"jsonrpc":"2.0","id":1}"#)
1996            .create_async()
1997            .await;
1998
1999        let client = SolanaClient::with_rpc_url(&server.url());
2000        let result = client.get_signatures(VALID_ADDRESS, 10).await;
2001        assert!(result.is_err());
2002        assert!(result.unwrap_err().to_string().contains("Empty RPC"));
2003    }
2004
2005    #[tokio::test]
2006    async fn test_get_signatures_invalid_address() {
2007        let client = SolanaClient::default();
2008        let result = client.get_signatures("x", 10).await;
2009        assert!(result.is_err());
2010    }
2011
2012    #[tokio::test]
2013    async fn test_get_signatures_rpc_error() {
2014        let mut server = mockito::Server::new_async().await;
2015        let _mock = server
2016            .mock("POST", "/")
2017            .with_status(200)
2018            .with_header("content-type", "application/json")
2019            .with_body(
2020                r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid address"},"id":1}"#,
2021            )
2022            .create_async()
2023            .await;
2024
2025        let client = SolanaClient::with_rpc_url(&server.url());
2026        let result = client.get_signatures(VALID_ADDRESS, 10).await;
2027        assert!(result.is_err());
2028    }
2029
2030    #[tokio::test]
2031    async fn test_get_transactions_invalid_address() {
2032        let client = SolanaClient::default();
2033        let result = client.get_transactions("invalid-addr", 10).await;
2034        assert!(result.is_err());
2035    }
2036
2037    // ========================================================================
2038    // get_slot empty response
2039    // ========================================================================
2040
2041    #[tokio::test]
2042    async fn test_get_slot_empty_response() {
2043        let mut server = mockito::Server::new_async().await;
2044        let _mock = server
2045            .mock("POST", "/")
2046            .with_status(200)
2047            .with_header("content-type", "application/json")
2048            .with_body(r#"{"jsonrpc":"2.0","id":1}"#)
2049            .create_async()
2050            .await;
2051
2052        let client = SolanaClient::with_rpc_url(&server.url());
2053        let result = client.get_slot().await;
2054        assert!(result.is_err());
2055        assert!(result.unwrap_err().to_string().contains("Empty RPC"));
2056    }
2057
2058    // ========================================================================
2059    // Token balance uiAmount null and struct tests
2060    // ========================================================================
2061
2062    #[tokio::test]
2063    async fn test_get_token_balances_ui_amount_null() {
2064        let mut server = mockito::Server::new_async().await;
2065        let _mock = server
2066            .mock("POST", "/")
2067            .with_status(200)
2068            .with_header("content-type", "application/json")
2069            .with_body(
2070                r#"{"jsonrpc":"2.0","result":{"value":[{
2071                    "pubkey":"TokenAccAddr1",
2072                    "account":{
2073                        "data":{
2074                            "parsed":{
2075                                "info":{
2076                                    "mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
2077                                    "tokenAmount":{
2078                                        "amount":"0",
2079                                        "decimals":6,
2080                                        "uiAmount":null,
2081                                        "uiAmountString":"0"
2082                                    }
2083                                }
2084                            }
2085                        }
2086                    }
2087                }]},"id":1}"#,
2088            )
2089            .create_async()
2090            .await;
2091
2092        let client = SolanaClient::with_rpc_url(&server.url());
2093        let balances = client.get_token_balances(VALID_ADDRESS).await.unwrap();
2094        // uiAmount null -> unwrap_or(0.0) -> filtered out
2095        assert_eq!(balances.len(), 0);
2096    }
2097
2098    #[test]
2099    fn test_token_balance_serialization() {
2100        let tb = TokenBalance {
2101            mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
2102            token_account: "TokenAcc1".to_string(),
2103            raw_amount: "1000000".to_string(),
2104            ui_amount: 1.5,
2105            decimals: 6,
2106            symbol: Some("USDC".to_string()),
2107            name: Some("USD Coin".to_string()),
2108        };
2109        let json = serde_json::to_string(&tb).unwrap();
2110        assert!(json.contains("USDC"));
2111        assert!(json.contains("1000000"));
2112    }
2113
2114    #[tokio::test]
2115    async fn test_chain_client_get_token_balances_short_mint() {
2116        let mut server = mockito::Server::new_async().await;
2117        let _mock = server
2118            .mock("POST", "/")
2119            .with_status(200)
2120            .with_header("content-type", "application/json")
2121            .with_body(
2122                r#"{"jsonrpc":"2.0","result":{"value":[{
2123                    "pubkey":"TokenAcc1",
2124                    "account":{
2125                        "data":{
2126                            "parsed":{
2127                                "info":{
2128                                    "mint":"Short",
2129                                    "tokenAmount":{
2130                                        "amount":"100",
2131                                        "decimals":2,
2132                                        "uiAmount":1.0,
2133                                        "uiAmountString":"1"
2134                                    }
2135                                }
2136                            }
2137                        }
2138                    }
2139                }]},"id":1}"#,
2140            )
2141            .create_async()
2142            .await;
2143
2144        let client = SolanaClient::with_rpc_url(&server.url());
2145        let chain_client: &dyn ChainClient = &client;
2146        let balances = chain_client
2147            .get_token_balances("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy")
2148            .await
2149            .unwrap();
2150        assert_eq!(balances.len(), 1);
2151        // Short mint "Short" (5 chars) -> symbol uses mint[..5] = "Short"
2152        assert_eq!(balances[0].token.symbol, "Short");
2153    }
2154
2155    // ========================================================================
2156    // validate_solana_signature too_long
2157    // ========================================================================
2158
2159    #[test]
2160    fn test_validate_solana_signature_too_long() {
2161        let too_long = "1".repeat(91);
2162        let result = validate_solana_signature(&too_long);
2163        assert!(result.is_err());
2164        assert!(result.unwrap_err().to_string().contains("80-90"));
2165    }
2166
2167    // ========================================================================
2168    // enrich_balance_usd (balance.raw parse failure)
2169    // ========================================================================
2170
2171    #[tokio::test]
2172    async fn test_enrich_balance_usd_invalid_raw_does_not_panic() {
2173        let client = SolanaClient::default();
2174        let mut balance = Balance {
2175            raw: "not-a-number".to_string(),
2176            formatted: "0 SOL".to_string(),
2177            decimals: 9,
2178            symbol: "SOL".to_string(),
2179            usd_value: None,
2180        };
2181        // Should not panic - unwrap_or(0.0) handles parse failure
2182        client.enrich_balance_usd(&mut balance).await;
2183        // Balance unchanged when parse fails (or DexScreener fails)
2184        assert!(balance.usd_value.is_none() || balance.usd_value == Some(0.0));
2185    }
2186
2187    #[tokio::test]
2188    async fn test_chain_client_trait_enrich_balance_usd() {
2189        let client = SolanaClient::default();
2190        let chain_client: &dyn ChainClient = &client;
2191        let mut balance = Balance {
2192            raw: "not-a-number".to_string(),
2193            formatted: "0 SOL".to_string(),
2194            decimals: 9,
2195            symbol: "SOL".to_string(),
2196            usd_value: None,
2197        };
2198        chain_client.enrich_balance_usd(&mut balance).await;
2199        assert!(balance.usd_value.is_none() || balance.usd_value == Some(0.0));
2200    }
2201
2202    #[tokio::test]
2203    async fn test_chain_client_trait_get_token_info() {
2204        let mut mint_data = vec![0u8; 41];
2205        mint_data[40] = 18;
2206        let data_b64 =
2207            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &mint_data);
2208
2209        let mut server = mockito::Server::new_async().await;
2210        let _mock = server
2211            .mock("POST", "/")
2212            .with_status(200)
2213            .with_header("content-type", "application/json")
2214            .with_body(format!(
2215                r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
2216                data_b64
2217            ))
2218            .create_async()
2219            .await;
2220
2221        let client = SolanaClient::with_rpc_url(&server.url());
2222        let chain_client: &dyn ChainClient = &client;
2223        let token = chain_client
2224            .get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
2225            .await
2226            .unwrap();
2227        assert_eq!(token.decimals, 18);
2228    }
2229
2230    // ========================================================================
2231    // SolscanAccountInfo deserialization (dead_code struct)
2232    // ========================================================================
2233
2234    #[test]
2235    fn test_solscan_account_info_deserialization() {
2236        let json = r#"{"lamports":1000000,"type":"account"}"#;
2237        let info: SolscanAccountInfo = serde_json::from_str(json).unwrap();
2238        assert_eq!(info.lamports, 1000000);
2239        assert_eq!(info.account_type, Some("account".to_string()));
2240    }
2241}