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