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