Skip to main content

scope/chains/
solana.rs

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