Skip to main content

scope/chains/
dex.rs

1//! # DEX Aggregator Client
2//!
3//! This module provides a client for fetching token price and volume data
4//! from DEX aggregator APIs like DexScreener.
5//!
6//! ## Supported APIs
7//!
8//! - **DexScreener** (primary): Free API, no key required
9//!   - Token data: `GET https://api.dexscreener.com/latest/dex/tokens/{address}`
10//!   - Pair data: `GET https://api.dexscreener.com/latest/dex/pairs/{chain}/{pair}`
11//!   - Token search: `GET https://api.dexscreener.com/latest/dex/search?q={query}`
12//!
13//! ## Features
14//!
15//! - Comprehensive token data aggregation across all DEX pairs
16//! - Native token price lookups for USD valuation (ETH, SOL, BNB, MATIC, etc.)
17//! - Individual token price lookups by contract address
18//! - Token search with chain filtering
19//! - Historical price and volume data interpolation
20//!
21//! ## Usage
22//!
23//! ```rust,no_run
24//! use scope::chains::DexClient;
25//!
26//! #[tokio::main]
27//! async fn main() -> scope::Result<()> {
28//!     let client = DexClient::new();
29//!     
30//!     // Fetch token data by address
31//!     let data = client.get_token_data("ethereum", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").await?;
32//!     println!("Price: ${}", data.price_usd);
33//!     
34//!     // Get native token price for USD valuation
35//!     if let Some(eth_price) = client.get_native_token_price("ethereum").await {
36//!         println!("ETH: ${:.2}", eth_price);
37//!     }
38//!     
39//!     Ok(())
40//! }
41//! ```
42
43use crate::chains::{DexPair, PricePoint, VolumePoint};
44use crate::error::{Result, ScopeError};
45use async_trait::async_trait;
46use reqwest::Client;
47use serde::Deserialize;
48use std::time::Duration;
49
50/// DexScreener API base URL.
51const DEXSCREENER_API_BASE: &str = "https://api.dexscreener.com";
52
53/// Trait for DEX data providers (prices, token data, search).
54///
55/// Abstracts the DexScreener API to enable dependency injection and testing.
56#[async_trait]
57pub trait DexDataSource: Send + Sync {
58    /// Fetches the price for a specific token on a chain.
59    async fn get_token_price(&self, chain: &str, address: &str) -> Option<f64>;
60
61    /// Fetches the native token price for a chain (e.g., ETH for ethereum).
62    async fn get_native_token_price(&self, chain: &str) -> Option<f64>;
63
64    /// Fetches comprehensive token data including pairs, volume, liquidity.
65    async fn get_token_data(&self, chain: &str, address: &str) -> Result<DexTokenData>;
66
67    /// Searches for tokens by query string with optional chain filter.
68    async fn search_tokens(
69        &self,
70        query: &str,
71        chain: Option<&str>,
72    ) -> Result<Vec<TokenSearchResult>>;
73}
74
75/// Client for fetching DEX aggregator data.
76#[derive(Debug, Clone)]
77pub struct DexClient {
78    http: Client,
79    base_url: String,
80}
81
82/// Response from DexScreener token endpoint.
83#[derive(Debug, Deserialize)]
84struct DexScreenerTokenResponse {
85    pairs: Option<Vec<DexScreenerPair>>,
86}
87
88/// A trading pair from DexScreener.
89#[derive(Debug, Deserialize)]
90#[serde(rename_all = "camelCase")]
91struct DexScreenerPair {
92    chain_id: String,
93    dex_id: String,
94    pair_address: String,
95    base_token: DexScreenerToken,
96    quote_token: DexScreenerToken,
97    #[serde(default)]
98    price_usd: Option<String>,
99    #[serde(default)]
100    price_change: Option<DexScreenerPriceChange>,
101    #[serde(default)]
102    volume: Option<DexScreenerVolume>,
103    #[serde(default)]
104    liquidity: Option<DexScreenerLiquidity>,
105    #[serde(default)]
106    fdv: Option<f64>,
107    #[serde(default)]
108    market_cap: Option<f64>,
109    /// Direct URL to the pair on DexScreener.
110    #[serde(default)]
111    url: Option<String>,
112    /// Timestamp when the pair was created.
113    #[serde(default)]
114    pair_created_at: Option<i64>,
115    /// Transaction counts for buy/sell analysis.
116    #[serde(default)]
117    txns: Option<DexScreenerTxns>,
118    /// Token metadata including socials and websites.
119    #[serde(default)]
120    info: Option<DexScreenerInfo>,
121}
122
123/// Token info from DexScreener.
124#[derive(Debug, Deserialize)]
125struct DexScreenerToken {
126    address: String,
127    name: String,
128    symbol: String,
129}
130
131/// Price change percentages from DexScreener.
132#[derive(Debug, Deserialize)]
133struct DexScreenerPriceChange {
134    h24: Option<f64>,
135    h6: Option<f64>,
136    h1: Option<f64>,
137    m5: Option<f64>,
138}
139
140/// Volume data from DexScreener.
141#[derive(Debug, Deserialize)]
142#[allow(dead_code)]
143struct DexScreenerVolume {
144    h24: Option<f64>,
145    h6: Option<f64>,
146    h1: Option<f64>,
147    m5: Option<f64>,
148}
149
150/// Liquidity data from DexScreener.
151#[derive(Debug, Deserialize)]
152#[allow(dead_code)]
153struct DexScreenerLiquidity {
154    usd: Option<f64>,
155    base: Option<f64>,
156    quote: Option<f64>,
157}
158
159/// Transaction counts from DexScreener (buy/sell activity).
160#[derive(Debug, Deserialize, Default)]
161#[allow(dead_code)]
162struct DexScreenerTxns {
163    #[serde(default)]
164    h24: Option<TxnCounts>,
165    #[serde(default)]
166    h6: Option<TxnCounts>,
167    #[serde(default)]
168    h1: Option<TxnCounts>,
169    #[serde(default)]
170    m5: Option<TxnCounts>,
171}
172
173/// Buy/sell transaction counts for a time period.
174#[derive(Debug, Deserialize, Clone, Default)]
175struct TxnCounts {
176    #[serde(default)]
177    buys: u64,
178    #[serde(default)]
179    sells: u64,
180}
181
182/// Token metadata from DexScreener info endpoint.
183#[derive(Debug, Deserialize, Default)]
184#[serde(rename_all = "camelCase")]
185struct DexScreenerInfo {
186    #[serde(default)]
187    image_url: Option<String>,
188    #[serde(default)]
189    websites: Option<Vec<DexScreenerWebsite>>,
190    #[serde(default)]
191    socials: Option<Vec<DexScreenerSocial>>,
192}
193
194/// Website info from DexScreener.
195#[derive(Debug, Deserialize, Clone)]
196#[allow(dead_code)]
197struct DexScreenerWebsite {
198    #[serde(default)]
199    label: Option<String>,
200    #[serde(default)]
201    url: Option<String>,
202}
203
204/// Social media info from DexScreener.
205#[derive(Debug, Deserialize, Clone)]
206struct DexScreenerSocial {
207    #[serde(rename = "type", default)]
208    platform: Option<String>,
209    #[serde(default)]
210    url: Option<String>,
211}
212
213/// Aggregated token data from DEX sources.
214#[derive(Debug, Clone)]
215pub struct DexTokenData {
216    /// Token contract address.
217    pub address: String,
218
219    /// Token symbol.
220    pub symbol: String,
221
222    /// Token name.
223    pub name: String,
224
225    /// Current price in USD.
226    pub price_usd: f64,
227
228    /// 24-hour price change percentage.
229    pub price_change_24h: f64,
230
231    /// 6-hour price change percentage.
232    pub price_change_6h: f64,
233
234    /// 1-hour price change percentage.
235    pub price_change_1h: f64,
236
237    /// 5-minute price change percentage.
238    pub price_change_5m: f64,
239
240    /// 24-hour trading volume in USD.
241    pub volume_24h: f64,
242
243    /// 6-hour trading volume in USD.
244    pub volume_6h: f64,
245
246    /// 1-hour trading volume in USD.
247    pub volume_1h: f64,
248
249    /// Total liquidity across all pairs in USD.
250    pub liquidity_usd: f64,
251
252    /// Market capitalization (if available).
253    pub market_cap: Option<f64>,
254
255    /// Fully diluted valuation (if available).
256    pub fdv: Option<f64>,
257
258    /// All trading pairs for this token.
259    pub pairs: Vec<DexPair>,
260
261    /// Historical price points (derived from multiple time frames).
262    pub price_history: Vec<PricePoint>,
263
264    /// Historical volume points (derived from multiple time frames).
265    pub volume_history: Vec<VolumePoint>,
266
267    /// Total buy transactions in 24 hours.
268    pub total_buys_24h: u64,
269
270    /// Total sell transactions in 24 hours.
271    pub total_sells_24h: u64,
272
273    /// Total buy transactions in 6 hours.
274    pub total_buys_6h: u64,
275
276    /// Total sell transactions in 6 hours.
277    pub total_sells_6h: u64,
278
279    /// Total buy transactions in 1 hour.
280    pub total_buys_1h: u64,
281
282    /// Total sell transactions in 1 hour.
283    pub total_sells_1h: u64,
284
285    /// Earliest pair creation timestamp (token age indicator).
286    pub earliest_pair_created_at: Option<i64>,
287
288    /// Token image URL.
289    pub image_url: Option<String>,
290
291    /// Token website URLs.
292    pub websites: Vec<String>,
293
294    /// Token social media links.
295    pub socials: Vec<TokenSocial>,
296
297    /// DexScreener URL for the token.
298    pub dexscreener_url: Option<String>,
299}
300
301/// Social media link for a token.
302#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
303pub struct TokenSocial {
304    /// Platform name (twitter, telegram, discord, etc.)
305    pub platform: String,
306    /// URL or handle for the social account.
307    pub url: String,
308}
309
310/// A token search result from DEX aggregator.
311#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
312pub struct TokenSearchResult {
313    /// Token contract address.
314    pub address: String,
315
316    /// Token symbol.
317    pub symbol: String,
318
319    /// Token name.
320    pub name: String,
321
322    /// Blockchain network.
323    pub chain: String,
324
325    /// Current price in USD (if available).
326    pub price_usd: Option<f64>,
327
328    /// 24-hour trading volume in USD.
329    pub volume_24h: f64,
330
331    /// Total liquidity in USD.
332    pub liquidity_usd: f64,
333
334    /// Market cap (if available).
335    pub market_cap: Option<f64>,
336}
337
338/// A discovered token from DexScreener (profiles, boosts, etc.)
339#[derive(Debug, Clone, serde::Serialize)]
340pub struct DiscoverToken {
341    pub chain_id: String,
342    pub token_address: String,
343    pub url: String,
344    pub description: Option<String>,
345    pub links: Vec<DiscoverLink>,
346}
347
348#[derive(Debug, Clone, serde::Serialize)]
349pub struct DiscoverLink {
350    pub label: Option<String>,
351    pub link_type: Option<String>,
352    pub url: String,
353}
354
355/// Response from DexScreener search endpoint.
356#[derive(Debug, Deserialize)]
357struct DexScreenerSearchResponse {
358    pairs: Option<Vec<DexScreenerPair>>,
359}
360
361impl DexClient {
362    /// Creates a new DEX client.
363    pub fn new() -> Self {
364        let http = Client::builder()
365            .timeout(Duration::from_secs(30))
366            .build()
367            .expect("Failed to build HTTP client");
368
369        Self {
370            http,
371            base_url: DEXSCREENER_API_BASE.to_string(),
372        }
373    }
374
375    /// Creates a new DEX client with a custom base URL (for testing).
376    #[cfg(test)]
377    pub(crate) fn with_base_url(base_url: &str) -> Self {
378        Self {
379            http: Client::new(),
380            base_url: base_url.to_string(),
381        }
382    }
383
384    /// Maps chain names to DexScreener chain IDs.
385    fn map_chain_to_dexscreener(chain: &str) -> String {
386        match chain.to_lowercase().as_str() {
387            "ethereum" | "eth" => "ethereum".to_string(),
388            "polygon" | "matic" => "polygon".to_string(),
389            "arbitrum" | "arb" => "arbitrum".to_string(),
390            "optimism" | "op" => "optimism".to_string(),
391            "base" => "base".to_string(),
392            "bsc" | "bnb" => "bsc".to_string(),
393            "solana" | "sol" => "solana".to_string(),
394            "avalanche" | "avax" => "avalanche".to_string(),
395            _ => chain.to_lowercase(),
396        }
397    }
398
399    /// Fetches the USD price of a token by its address.
400    ///
401    /// Returns `None` if the token is not found or has no price data.
402    pub async fn get_token_price(&self, chain: &str, token_address: &str) -> Option<f64> {
403        let url = format!("{}/latest/dex/tokens/{}", self.base_url, token_address);
404
405        let response = self.http.get(&url).send().await.ok()?;
406        let dex_response: DexScreenerTokenResponse = response.json().await.ok()?;
407
408        let dex_chain = Self::map_chain_to_dexscreener(chain);
409
410        dex_response
411            .pairs
412            .as_ref()?
413            .iter()
414            .filter(|p| p.chain_id.to_lowercase() == dex_chain)
415            .filter_map(|p| p.price_usd.as_ref()?.parse::<f64>().ok())
416            .next()
417    }
418
419    /// Fetches the native token price for a chain.
420    ///
421    /// Uses well-known wrapped token addresses to determine the native token price.
422    pub async fn get_native_token_price(&self, chain: &str) -> Option<f64> {
423        let (search_chain, token_address) = match chain.to_lowercase().as_str() {
424            "ethereum" | "eth" => ("ethereum", "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"), // WETH
425            "polygon" | "matic" => ("polygon", "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270"), // WMATIC
426            "arbitrum" | "arb" => ("arbitrum", "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), // WETH on Arb
427            "optimism" | "op" => ("optimism", "0x4200000000000000000000000000000000000006"), // WETH on OP
428            "base" => ("base", "0x4200000000000000000000000000000000000006"), // WETH on Base
429            "bsc" | "bnb" => ("bsc", "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c"), // WBNB
430            "solana" | "sol" => ("solana", "So11111111111111111111111111111111111111112"), // Wrapped SOL
431            "tron" | "trx" => return None, // Tron wrapped token varies; skip for now
432            _ => return None,
433        };
434
435        self.get_token_price(search_chain, token_address).await
436    }
437
438    /// Fetches token data from DexScreener.
439    ///
440    /// # Arguments
441    ///
442    /// * `chain` - The blockchain name (e.g., "ethereum", "bsc")
443    /// * `token_address` - The token contract address
444    ///
445    /// # Returns
446    ///
447    /// Returns aggregated token data from all DEX pairs.
448    pub async fn get_token_data(&self, chain: &str, token_address: &str) -> Result<DexTokenData> {
449        let url = format!("{}/latest/dex/tokens/{}", self.base_url, token_address);
450
451        tracing::debug!(url = %url, "Fetching token data from DexScreener");
452
453        let response = self
454            .http
455            .get(&url)
456            .send()
457            .await
458            .map_err(|e| ScopeError::Network(e.to_string()))?;
459
460        if !response.status().is_success() {
461            return Err(ScopeError::Api(format!(
462                "DexScreener API error: {}",
463                response.status()
464            )));
465        }
466
467        let data: DexScreenerTokenResponse = response
468            .json()
469            .await
470            .map_err(|e| ScopeError::Api(format!("Failed to parse DexScreener response: {}", e)))?;
471
472        let pairs = data.pairs.unwrap_or_default();
473
474        if pairs.is_empty() {
475            return Err(ScopeError::NotFound(format!(
476                "No DEX pairs found for token {}",
477                token_address
478            )));
479        }
480
481        // Filter pairs by chain
482        let chain_id = Self::map_chain_to_dexscreener(chain);
483        let chain_pairs: Vec<_> = pairs
484            .iter()
485            .filter(|p| p.chain_id.to_lowercase() == chain_id)
486            .collect();
487
488        // Use all pairs if no chain-specific pairs found
489        let relevant_pairs = if chain_pairs.is_empty() {
490            pairs.iter().collect()
491        } else {
492            chain_pairs
493        };
494
495        // Get token info from first pair
496        let first_pair = &relevant_pairs[0];
497        let is_base_token =
498            first_pair.base_token.address.to_lowercase() == token_address.to_lowercase();
499        let token_info = if is_base_token {
500            &first_pair.base_token
501        } else {
502            &first_pair.quote_token
503        };
504
505        // Aggregate data from all pairs
506        let mut total_volume_24h = 0.0;
507        let mut total_volume_6h = 0.0;
508        let mut total_volume_1h = 0.0;
509        let mut total_liquidity = 0.0;
510        let mut weighted_price_sum = 0.0;
511        let mut liquidity_weight_sum = 0.0;
512        let mut dex_pairs = Vec::new();
513
514        for pair in &relevant_pairs {
515            let pair_liquidity = pair.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0);
516
517            let pair_price = pair
518                .price_usd
519                .as_ref()
520                .and_then(|p| p.parse::<f64>().ok())
521                .unwrap_or(0.0);
522
523            if let Some(vol) = &pair.volume {
524                total_volume_24h += vol.h24.unwrap_or(0.0);
525                total_volume_6h += vol.h6.unwrap_or(0.0);
526                total_volume_1h += vol.h1.unwrap_or(0.0);
527            }
528
529            total_liquidity += pair_liquidity;
530
531            // Weight price by liquidity for more accurate average
532            if pair_liquidity > 0.0 && pair_price > 0.0 {
533                weighted_price_sum += pair_price * pair_liquidity;
534                liquidity_weight_sum += pair_liquidity;
535            }
536
537            let price_change = pair
538                .price_change
539                .as_ref()
540                .and_then(|pc| pc.h24)
541                .unwrap_or(0.0);
542
543            // Extract transaction counts
544            let txn_counts_24h = pair.txns.as_ref().and_then(|t| t.h24.clone());
545            let txn_counts_6h = pair.txns.as_ref().and_then(|t| t.h6.clone());
546            let txn_counts_1h = pair.txns.as_ref().and_then(|t| t.h1.clone());
547
548            dex_pairs.push(DexPair {
549                dex_name: pair.dex_id.clone(),
550                pair_address: pair.pair_address.clone(),
551                base_token: pair.base_token.symbol.clone(),
552                quote_token: pair.quote_token.symbol.clone(),
553                price_usd: pair_price,
554                volume_24h: pair.volume.as_ref().and_then(|v| v.h24).unwrap_or(0.0),
555                liquidity_usd: pair_liquidity,
556                price_change_24h: price_change,
557                buys_24h: txn_counts_24h.as_ref().map(|t| t.buys).unwrap_or(0),
558                sells_24h: txn_counts_24h.as_ref().map(|t| t.sells).unwrap_or(0),
559                buys_6h: txn_counts_6h.as_ref().map(|t| t.buys).unwrap_or(0),
560                sells_6h: txn_counts_6h.as_ref().map(|t| t.sells).unwrap_or(0),
561                buys_1h: txn_counts_1h.as_ref().map(|t| t.buys).unwrap_or(0),
562                sells_1h: txn_counts_1h.as_ref().map(|t| t.sells).unwrap_or(0),
563                pair_created_at: pair.pair_created_at,
564                url: pair.url.clone(),
565            });
566        }
567
568        // Calculate weighted average price
569        let avg_price = if liquidity_weight_sum > 0.0 {
570            weighted_price_sum / liquidity_weight_sum
571        } else {
572            first_pair
573                .price_usd
574                .as_ref()
575                .and_then(|p| p.parse().ok())
576                .unwrap_or(0.0)
577        };
578
579        // Get price change and market data from the highest liquidity pair
580        let best_pair = relevant_pairs
581            .iter()
582            .max_by(|a, b| {
583                let liq_a = a.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0);
584                let liq_b = b.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0);
585                liq_a
586                    .partial_cmp(&liq_b)
587                    .unwrap_or(std::cmp::Ordering::Equal)
588            })
589            .unwrap();
590
591        let price_change_24h = best_pair
592            .price_change
593            .as_ref()
594            .and_then(|pc| pc.h24)
595            .unwrap_or(0.0);
596
597        let price_change_6h = best_pair
598            .price_change
599            .as_ref()
600            .and_then(|pc| pc.h6)
601            .unwrap_or(0.0);
602
603        let price_change_1h = best_pair
604            .price_change
605            .as_ref()
606            .and_then(|pc| pc.h1)
607            .unwrap_or(0.0);
608
609        let price_change_5m = best_pair
610            .price_change
611            .as_ref()
612            .and_then(|pc| pc.m5)
613            .unwrap_or(0.0);
614
615        // Aggregate transaction counts across all pairs
616        let total_buys_24h: u64 = dex_pairs.iter().map(|p| p.buys_24h).sum();
617        let total_sells_24h: u64 = dex_pairs.iter().map(|p| p.sells_24h).sum();
618        let total_buys_6h: u64 = dex_pairs.iter().map(|p| p.buys_6h).sum();
619        let total_sells_6h: u64 = dex_pairs.iter().map(|p| p.sells_6h).sum();
620        let total_buys_1h: u64 = dex_pairs.iter().map(|p| p.buys_1h).sum();
621        let total_sells_1h: u64 = dex_pairs.iter().map(|p| p.sells_1h).sum();
622
623        // Find earliest pair creation timestamp
624        let earliest_pair_created_at = dex_pairs.iter().filter_map(|p| p.pair_created_at).min();
625
626        // Extract token metadata from best pair
627        let image_url = best_pair.info.as_ref().and_then(|i| i.image_url.clone());
628        let websites: Vec<String> = best_pair
629            .info
630            .as_ref()
631            .and_then(|i| i.websites.as_ref())
632            .map(|ws| ws.iter().filter_map(|w| w.url.clone()).collect())
633            .unwrap_or_default();
634        let socials: Vec<TokenSocial> = best_pair
635            .info
636            .as_ref()
637            .and_then(|i| i.socials.as_ref())
638            .map(|ss| {
639                ss.iter()
640                    .filter_map(|s| {
641                        Some(TokenSocial {
642                            platform: s.platform.clone()?,
643                            url: s.url.clone()?,
644                        })
645                    })
646                    .collect()
647            })
648            .unwrap_or_default();
649        let dexscreener_url = best_pair.url.clone();
650
651        // Generate synthetic price history from change percentages
652        let now = chrono::Utc::now().timestamp();
653        let price_history = Self::generate_price_history(avg_price, best_pair, now);
654
655        // Generate synthetic volume history
656        let volume_history =
657            Self::generate_volume_history(total_volume_24h, total_volume_6h, total_volume_1h, now);
658
659        Ok(DexTokenData {
660            address: token_address.to_string(),
661            symbol: token_info.symbol.clone(),
662            name: token_info.name.clone(),
663            price_usd: avg_price,
664            price_change_24h,
665            price_change_6h,
666            price_change_1h,
667            price_change_5m,
668            volume_24h: total_volume_24h,
669            volume_6h: total_volume_6h,
670            volume_1h: total_volume_1h,
671            liquidity_usd: total_liquidity,
672            market_cap: best_pair.market_cap,
673            fdv: best_pair.fdv,
674            pairs: dex_pairs,
675            price_history,
676            volume_history,
677            total_buys_24h,
678            total_sells_24h,
679            total_buys_6h,
680            total_sells_6h,
681            total_buys_1h,
682            total_sells_1h,
683            earliest_pair_created_at,
684            image_url,
685            websites,
686            socials,
687            dexscreener_url,
688        })
689    }
690
691    /// Searches for tokens by name or symbol.
692    ///
693    /// # Arguments
694    ///
695    /// * `query` - The search query (token name or symbol)
696    /// * `chain` - Optional chain filter (e.g., "ethereum", "bsc")
697    ///
698    /// # Returns
699    ///
700    /// Returns a vector of matching tokens sorted by liquidity.
701    pub async fn search_tokens(
702        &self,
703        query: &str,
704        chain: Option<&str>,
705    ) -> Result<Vec<TokenSearchResult>> {
706        let url = format!(
707            "{}/latest/dex/search?q={}",
708            self.base_url,
709            urlencoding::encode(query)
710        );
711
712        tracing::debug!(url = %url, "Searching tokens on DexScreener");
713
714        let response = self
715            .http
716            .get(&url)
717            .send()
718            .await
719            .map_err(|e| ScopeError::Network(e.to_string()))?;
720
721        if !response.status().is_success() {
722            return Err(ScopeError::Api(format!(
723                "DexScreener search API error: {}",
724                response.status()
725            )));
726        }
727
728        let data: DexScreenerSearchResponse = response
729            .json()
730            .await
731            .map_err(|e| ScopeError::Api(format!("Failed to parse search response: {}", e)))?;
732
733        let pairs = data.pairs.unwrap_or_default();
734
735        if pairs.is_empty() {
736            return Ok(Vec::new());
737        }
738
739        // Filter by chain if specified
740        let chain_id = chain.map(Self::map_chain_to_dexscreener);
741        let filtered_pairs: Vec<_> = if let Some(ref cid) = chain_id {
742            pairs
743                .iter()
744                .filter(|p| p.chain_id.to_lowercase() == *cid)
745                .collect()
746        } else {
747            pairs.iter().collect()
748        };
749
750        // Deduplicate tokens by address and aggregate data
751        let mut token_map: std::collections::HashMap<String, TokenSearchResult> =
752            std::collections::HashMap::new();
753
754        for pair in filtered_pairs {
755            // Check if the query matches base or quote token
756            let base_matches = pair
757                .base_token
758                .symbol
759                .to_lowercase()
760                .contains(&query.to_lowercase())
761                || pair
762                    .base_token
763                    .name
764                    .to_lowercase()
765                    .contains(&query.to_lowercase());
766            let quote_matches = pair
767                .quote_token
768                .symbol
769                .to_lowercase()
770                .contains(&query.to_lowercase())
771                || pair
772                    .quote_token
773                    .name
774                    .to_lowercase()
775                    .contains(&query.to_lowercase());
776
777            let token_info = if base_matches {
778                &pair.base_token
779            } else if quote_matches {
780                &pair.quote_token
781            } else {
782                // Use base token by default
783                &pair.base_token
784            };
785
786            let key = format!("{}:{}", pair.chain_id, token_info.address.to_lowercase());
787
788            let pair_liquidity = pair.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0);
789
790            let pair_volume = pair.volume.as_ref().and_then(|v| v.h24).unwrap_or(0.0);
791
792            let pair_price = pair.price_usd.as_ref().and_then(|p| p.parse::<f64>().ok());
793
794            let entry = token_map.entry(key).or_insert_with(|| TokenSearchResult {
795                address: token_info.address.clone(),
796                symbol: token_info.symbol.clone(),
797                name: token_info.name.clone(),
798                chain: pair.chain_id.clone(),
799                price_usd: pair_price,
800                volume_24h: 0.0,
801                liquidity_usd: 0.0,
802                market_cap: pair.market_cap,
803            });
804
805            // Aggregate volume and liquidity
806            entry.volume_24h += pair_volume;
807            entry.liquidity_usd += pair_liquidity;
808
809            // Update price if better data available
810            if entry.price_usd.is_none() && pair_price.is_some() {
811                entry.price_usd = pair_price;
812            }
813
814            // Update market cap if available
815            if entry.market_cap.is_none() && pair.market_cap.is_some() {
816                entry.market_cap = pair.market_cap;
817            }
818        }
819
820        // Convert to vector and sort: exact symbol matches first, then by liquidity
821        let query_lower = query.to_lowercase();
822        let mut results: Vec<TokenSearchResult> = token_map.into_values().collect();
823        results.sort_by(|a, b| {
824            let a_exact = a.symbol.to_lowercase() == query_lower;
825            let b_exact = b.symbol.to_lowercase() == query_lower;
826            b_exact.cmp(&a_exact).then(
827                b.liquidity_usd
828                    .partial_cmp(&a.liquidity_usd)
829                    .unwrap_or(std::cmp::Ordering::Equal),
830            )
831        });
832
833        // Limit results
834        results.truncate(20);
835
836        Ok(results)
837    }
838
839    /// Fetches latest token profiles (featured tokens) from DexScreener.
840    pub async fn get_token_profiles(&self) -> Result<Vec<DiscoverToken>> {
841        let url = format!("{}/token-profiles/latest/v1", self.base_url);
842        self.fetch_discover_tokens(&url).await
843    }
844
845    /// Fetches latest boosted tokens from DexScreener.
846    pub async fn get_token_boosts(&self) -> Result<Vec<DiscoverToken>> {
847        let url = format!("{}/token-boosts/latest/v1", self.base_url);
848        self.fetch_discover_tokens(&url).await
849    }
850
851    /// Fetches top boosted tokens (most active boosts) from DexScreener.
852    pub async fn get_token_boosts_top(&self) -> Result<Vec<DiscoverToken>> {
853        let url = format!("{}/token-boosts/top/v1", self.base_url);
854        self.fetch_discover_tokens(&url).await
855    }
856
857    async fn fetch_discover_tokens(&self, url: &str) -> Result<Vec<DiscoverToken>> {
858        let response = self
859            .http
860            .get(url)
861            .send()
862            .await
863            .map_err(|e| ScopeError::Network(e.to_string()))?;
864
865        if !response.status().is_success() {
866            return Err(ScopeError::Api(format!(
867                "DexScreener API error: {}",
868                response.status()
869            )));
870        }
871
872        #[derive(Deserialize)]
873        struct TokenProfileRaw {
874            url: Option<String>,
875            #[serde(rename = "chainId")]
876            chain_id: Option<String>,
877            #[serde(rename = "tokenAddress")]
878            token_address: Option<String>,
879            description: Option<String>,
880            links: Option<Vec<LinkRaw>>,
881        }
882
883        #[derive(Deserialize)]
884        struct LinkRaw {
885            label: Option<String>,
886            #[serde(rename = "type")]
887            link_type: Option<String>,
888            url: Option<String>,
889        }
890
891        let raw: Vec<TokenProfileRaw> = response
892            .json()
893            .await
894            .map_err(|e| ScopeError::Api(format!("Failed to parse response: {}", e)))?;
895
896        let tokens: Vec<DiscoverToken> = raw
897            .into_iter()
898            .filter_map(|r| {
899                let token_address = r.token_address?;
900                let chain_id = r.chain_id.clone().unwrap_or_else(|| "unknown".to_string());
901                let url = r.url.clone().unwrap_or_else(|| {
902                    format!("https://dexscreener.com/{}/{}", chain_id, token_address)
903                });
904                let links: Vec<DiscoverLink> = r
905                    .links
906                    .unwrap_or_default()
907                    .into_iter()
908                    .filter_map(|l| {
909                        let url = l.url?;
910                        Some(DiscoverLink {
911                            label: l.label,
912                            link_type: l.link_type,
913                            url,
914                        })
915                    })
916                    .collect();
917
918                Some(DiscoverToken {
919                    chain_id,
920                    token_address,
921                    url,
922                    description: r.description,
923                    links,
924                })
925            })
926            .collect();
927
928        Ok(tokens)
929    }
930
931    /// Generates synthetic price history from change percentages.
932    fn generate_price_history(
933        current_price: f64,
934        pair: &DexScreenerPair,
935        now: i64,
936    ) -> Vec<PricePoint> {
937        let mut history = Vec::new();
938
939        // Get price changes at different intervals
940        let changes = pair.price_change.as_ref();
941        let change_24h = changes.and_then(|c| c.h24).unwrap_or(0.0) / 100.0;
942        let change_6h = changes.and_then(|c| c.h6).unwrap_or(0.0) / 100.0;
943        let change_1h = changes.and_then(|c| c.h1).unwrap_or(0.0) / 100.0;
944        let change_5m = changes.and_then(|c| c.m5).unwrap_or(0.0) / 100.0;
945
946        // Calculate historical prices (working backwards)
947        let price_24h_ago = current_price / (1.0 + change_24h);
948        let price_6h_ago = current_price / (1.0 + change_6h);
949        let price_1h_ago = current_price / (1.0 + change_1h);
950        let price_5m_ago = current_price / (1.0 + change_5m);
951
952        // Add points at known intervals
953        history.push(PricePoint {
954            timestamp: now - 86400, // 24h ago
955            price: price_24h_ago,
956        });
957        history.push(PricePoint {
958            timestamp: now - 21600, // 6h ago
959            price: price_6h_ago,
960        });
961        history.push(PricePoint {
962            timestamp: now - 3600, // 1h ago
963            price: price_1h_ago,
964        });
965        history.push(PricePoint {
966            timestamp: now - 300, // 5m ago
967            price: price_5m_ago,
968        });
969        history.push(PricePoint {
970            timestamp: now,
971            price: current_price,
972        });
973
974        // Interpolate additional points for smoother charts
975        Self::interpolate_points(&mut history, 24);
976
977        history.sort_by_key(|p| p.timestamp);
978        history
979    }
980
981    /// Generates synthetic volume history from known data points.
982    fn generate_volume_history(
983        volume_24h: f64,
984        volume_6h: f64,
985        volume_1h: f64,
986        now: i64,
987    ) -> Vec<VolumePoint> {
988        let mut history = Vec::new();
989
990        // Create hourly buckets for the last 24 hours
991        let hourly_avg = volume_24h / 24.0;
992
993        for i in 0..24 {
994            let timestamp = now - (23 - i) * 3600;
995            let hours_ago = 24 - i;
996
997            // Adjust volume based on known data points
998            let volume = if hours_ago <= 1 {
999                volume_1h
1000            } else if hours_ago <= 6 {
1001                volume_6h / 6.0
1002            } else {
1003                // Use average with some variation
1004                hourly_avg * (0.8 + (i as f64 / 24.0) * 0.4)
1005            };
1006
1007            history.push(VolumePoint { timestamp, volume });
1008        }
1009
1010        history
1011    }
1012
1013    /// Interpolates additional price points for smoother charts.
1014    fn interpolate_points(history: &mut Vec<PricePoint>, target_count: usize) {
1015        if history.len() >= target_count {
1016            return;
1017        }
1018
1019        history.sort_by_key(|p| p.timestamp);
1020
1021        let mut interpolated = Vec::new();
1022        for window in history.windows(2) {
1023            let p1 = &window[0];
1024            let p2 = &window[1];
1025
1026            interpolated.push(p1.clone());
1027
1028            // Add midpoint
1029            let mid_timestamp = (p1.timestamp + p2.timestamp) / 2;
1030            let mid_price = (p1.price + p2.price) / 2.0;
1031            interpolated.push(PricePoint {
1032                timestamp: mid_timestamp,
1033                price: mid_price,
1034            });
1035        }
1036
1037        if let Some(last) = history.last() {
1038            interpolated.push(last.clone());
1039        }
1040
1041        *history = interpolated;
1042    }
1043
1044    /// Gets the 7-day volume by extrapolating from 24h data.
1045    ///
1046    /// Note: DexScreener doesn't provide 7d volume directly,
1047    /// so we estimate based on 24h volume.
1048    pub fn estimate_7d_volume(volume_24h: f64) -> f64 {
1049        // Simple estimation: assume consistent daily volume
1050        volume_24h * 7.0
1051    }
1052}
1053
1054impl Default for DexClient {
1055    fn default() -> Self {
1056        Self::new()
1057    }
1058}
1059
1060// ============================================================================
1061// DexDataSource Trait Implementation
1062// ============================================================================
1063
1064#[async_trait]
1065impl DexDataSource for DexClient {
1066    async fn get_token_price(&self, chain: &str, address: &str) -> Option<f64> {
1067        self.get_token_price(chain, address).await
1068    }
1069
1070    async fn get_native_token_price(&self, chain: &str) -> Option<f64> {
1071        self.get_native_token_price(chain).await
1072    }
1073
1074    async fn get_token_data(&self, chain: &str, address: &str) -> Result<DexTokenData> {
1075        self.get_token_data(chain, address).await
1076    }
1077
1078    async fn search_tokens(
1079        &self,
1080        query: &str,
1081        chain: Option<&str>,
1082    ) -> Result<Vec<TokenSearchResult>> {
1083        self.search_tokens(query, chain).await
1084    }
1085}
1086
1087/// Builds a full DexScreener token response JSON string for testing.
1088#[cfg(test)]
1089fn build_test_pair_json(chain_id: &str, base_symbol: &str, base_addr: &str, price: &str) -> String {
1090    format!(
1091        r#"{{
1092        "chainId":"{}","dexId":"uniswap","pairAddress":"0xpair",
1093        "baseToken":{{"address":"{}","name":"{}","symbol":"{}"}},
1094        "quoteToken":{{"address":"0xquote","name":"USDC","symbol":"USDC"}},
1095        "priceUsd":"{}",
1096        "priceChange":{{"h24":5.2,"h6":2.1,"h1":0.5,"m5":0.1}},
1097        "volume":{{"h24":1000000,"h6":250000,"h1":50000,"m5":5000}},
1098        "liquidity":{{"usd":500000,"base":100,"quote":500000}},
1099        "fdv":10000000,"marketCap":8000000,
1100        "txns":{{"h24":{{"buys":100,"sells":80}},"h6":{{"buys":20,"sells":15}},"h1":{{"buys":5,"sells":3}}}},
1101        "pairCreatedAt":1690000000000,
1102        "url":"https://dexscreener.com/ethereum/0xpair"
1103    }}"#,
1104        chain_id, base_addr, base_symbol, base_symbol, price
1105    )
1106}
1107
1108// ============================================================================
1109// Unit Tests
1110// ============================================================================
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115
1116    #[test]
1117    fn test_chain_mapping() {
1118        assert_eq!(
1119            DexClient::map_chain_to_dexscreener("ethereum"),
1120            "ethereum".to_string()
1121        );
1122        assert_eq!(
1123            DexClient::map_chain_to_dexscreener("ETH"),
1124            "ethereum".to_string()
1125        );
1126        assert_eq!(
1127            DexClient::map_chain_to_dexscreener("bsc"),
1128            "bsc".to_string()
1129        );
1130        assert_eq!(
1131            DexClient::map_chain_to_dexscreener("BNB"),
1132            "bsc".to_string()
1133        );
1134        assert_eq!(
1135            DexClient::map_chain_to_dexscreener("polygon"),
1136            "polygon".to_string()
1137        );
1138        assert_eq!(
1139            DexClient::map_chain_to_dexscreener("solana"),
1140            "solana".to_string()
1141        );
1142    }
1143
1144    #[test]
1145    fn test_estimate_7d_volume() {
1146        assert_eq!(DexClient::estimate_7d_volume(1_000_000.0), 7_000_000.0);
1147        assert_eq!(DexClient::estimate_7d_volume(0.0), 0.0);
1148    }
1149
1150    #[test]
1151    fn test_generate_volume_history() {
1152        let now = 1700000000;
1153        let history = DexClient::generate_volume_history(24000.0, 6000.0, 1000.0, now);
1154
1155        assert_eq!(history.len(), 24);
1156        assert!(history.iter().all(|v| v.volume >= 0.0));
1157        assert!(history.iter().all(|v| v.timestamp <= now));
1158    }
1159
1160    #[test]
1161    fn test_dex_client_default() {
1162        let _client = DexClient::default();
1163        // Just verify it doesn't panic
1164    }
1165
1166    #[test]
1167    fn test_interpolate_points() {
1168        let mut history = vec![
1169            PricePoint {
1170                timestamp: 0,
1171                price: 1.0,
1172            },
1173            PricePoint {
1174                timestamp: 100,
1175                price: 2.0,
1176            },
1177        ];
1178
1179        DexClient::interpolate_points(&mut history, 10);
1180
1181        assert!(history.len() > 2);
1182        // Check midpoint was added
1183        assert!(history.iter().any(|p| p.timestamp == 50));
1184    }
1185
1186    // ========================================================================
1187    // HTTP mocking tests
1188    // ========================================================================
1189
1190    #[tokio::test]
1191    async fn test_get_token_data_success() {
1192        let mut server = mockito::Server::new_async().await;
1193        let pair = build_test_pair_json("ethereum", "WETH", "0xtoken", "2500.50");
1194        let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1195        let _mock = server
1196            .mock(
1197                "GET",
1198                mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1199            )
1200            .with_status(200)
1201            .with_header("content-type", "application/json")
1202            .with_body(&body)
1203            .create_async()
1204            .await;
1205
1206        let client = DexClient::with_base_url(&server.url());
1207        let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1208        assert_eq!(data.symbol, "WETH");
1209        assert!((data.price_usd - 2500.50).abs() < 0.01);
1210        assert!(data.volume_24h > 0.0);
1211        assert!(data.liquidity_usd > 0.0);
1212        assert_eq!(data.pairs.len(), 1);
1213        assert!(data.total_buys_24h > 0);
1214        assert!(data.total_sells_24h > 0);
1215        assert!(!data.price_history.is_empty());
1216        assert!(!data.volume_history.is_empty());
1217    }
1218
1219    #[tokio::test]
1220    async fn test_get_token_data_no_pairs() {
1221        let mut server = mockito::Server::new_async().await;
1222        let _mock = server
1223            .mock(
1224                "GET",
1225                mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1226            )
1227            .with_status(200)
1228            .with_header("content-type", "application/json")
1229            .with_body(r#"{"pairs":[]}"#)
1230            .create_async()
1231            .await;
1232
1233        let client = DexClient::with_base_url(&server.url());
1234        let result = client.get_token_data("ethereum", "0xunknown").await;
1235        assert!(result.is_err());
1236        assert!(result.unwrap_err().to_string().contains("No DEX pairs"));
1237    }
1238
1239    #[tokio::test]
1240    async fn test_get_token_data_api_error() {
1241        let mut server = mockito::Server::new_async().await;
1242        let _mock = server
1243            .mock(
1244                "GET",
1245                mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1246            )
1247            .with_status(500)
1248            .create_async()
1249            .await;
1250
1251        let client = DexClient::with_base_url(&server.url());
1252        let result = client.get_token_data("ethereum", "0xtoken").await;
1253        assert!(result.is_err());
1254    }
1255
1256    #[tokio::test]
1257    async fn test_get_token_data_fallback_to_all_pairs() {
1258        // When no chain-specific pairs found, should use all pairs
1259        let mut server = mockito::Server::new_async().await;
1260        let pair = build_test_pair_json("bsc", "TOKEN", "0xtoken", "1.00");
1261        let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1262        let _mock = server
1263            .mock(
1264                "GET",
1265                mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1266            )
1267            .with_status(200)
1268            .with_header("content-type", "application/json")
1269            .with_body(&body)
1270            .create_async()
1271            .await;
1272
1273        let client = DexClient::with_base_url(&server.url());
1274        // Request for ethereum but pair is on bsc → should still get data
1275        let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1276        assert_eq!(data.symbol, "TOKEN");
1277    }
1278
1279    #[tokio::test]
1280    async fn test_get_token_data_multiple_pairs() {
1281        let mut server = mockito::Server::new_async().await;
1282        let pair1 = build_test_pair_json("ethereum", "WETH", "0xtoken", "2500.00");
1283        let pair2 = build_test_pair_json("ethereum", "WETH", "0xtoken", "2501.00");
1284        let body = format!(r#"{{"pairs":[{},{}]}}"#, pair1, pair2);
1285        let _mock = server
1286            .mock(
1287                "GET",
1288                mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1289            )
1290            .with_status(200)
1291            .with_header("content-type", "application/json")
1292            .with_body(&body)
1293            .create_async()
1294            .await;
1295
1296        let client = DexClient::with_base_url(&server.url());
1297        let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1298        assert_eq!(data.pairs.len(), 2);
1299        // Price should be liquidity-weighted average
1300        assert!(data.price_usd > 2499.0 && data.price_usd < 2502.0);
1301    }
1302
1303    #[tokio::test]
1304    async fn test_get_token_price() {
1305        let mut server = mockito::Server::new_async().await;
1306        let pair = build_test_pair_json("ethereum", "WETH", "0xtoken", "2500.50");
1307        let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1308        let _mock = server
1309            .mock(
1310                "GET",
1311                mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1312            )
1313            .with_status(200)
1314            .with_header("content-type", "application/json")
1315            .with_body(&body)
1316            .create_async()
1317            .await;
1318
1319        let client = DexClient::with_base_url(&server.url());
1320        let price = client.get_token_price("ethereum", "0xtoken").await;
1321        assert!(price.is_some());
1322        assert!((price.unwrap() - 2500.50).abs() < 0.01);
1323    }
1324
1325    #[tokio::test]
1326    async fn test_get_token_price_not_found() {
1327        let mut server = mockito::Server::new_async().await;
1328        let _mock = server
1329            .mock(
1330                "GET",
1331                mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1332            )
1333            .with_status(200)
1334            .with_header("content-type", "application/json")
1335            .with_body(r#"{"pairs":null}"#)
1336            .create_async()
1337            .await;
1338
1339        let client = DexClient::with_base_url(&server.url());
1340        let price = client.get_token_price("ethereum", "0xunknown").await;
1341        assert!(price.is_none());
1342    }
1343
1344    #[tokio::test]
1345    async fn test_search_tokens_success() {
1346        let mut server = mockito::Server::new_async().await;
1347        let pair = build_test_pair_json("ethereum", "USDC", "0xusdc", "1.00");
1348        let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1349        let _mock = server
1350            .mock(
1351                "GET",
1352                mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1353            )
1354            .with_status(200)
1355            .with_header("content-type", "application/json")
1356            .with_body(&body)
1357            .create_async()
1358            .await;
1359
1360        let client = DexClient::with_base_url(&server.url());
1361        let results = client.search_tokens("USDC", None).await.unwrap();
1362        assert!(!results.is_empty());
1363        assert_eq!(results[0].symbol, "USDC");
1364    }
1365
1366    #[tokio::test]
1367    async fn test_search_tokens_with_chain_filter() {
1368        let mut server = mockito::Server::new_async().await;
1369        let pair_eth = build_test_pair_json("ethereum", "USDC", "0xusdc_eth", "1.00");
1370        let pair_bsc = build_test_pair_json("bsc", "USDC", "0xusdc_bsc", "1.00");
1371        let body = format!(r#"{{"pairs":[{},{}]}}"#, pair_eth, pair_bsc);
1372        let _mock = server
1373            .mock(
1374                "GET",
1375                mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1376            )
1377            .with_status(200)
1378            .with_header("content-type", "application/json")
1379            .with_body(&body)
1380            .create_async()
1381            .await;
1382
1383        let client = DexClient::with_base_url(&server.url());
1384        let results = client
1385            .search_tokens("USDC", Some("ethereum"))
1386            .await
1387            .unwrap();
1388        assert_eq!(results.len(), 1);
1389        assert_eq!(results[0].chain, "ethereum");
1390    }
1391
1392    #[tokio::test]
1393    async fn test_search_tokens_empty() {
1394        let mut server = mockito::Server::new_async().await;
1395        let _mock = server
1396            .mock(
1397                "GET",
1398                mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1399            )
1400            .with_status(200)
1401            .with_header("content-type", "application/json")
1402            .with_body(r#"{"pairs":[]}"#)
1403            .create_async()
1404            .await;
1405
1406        let client = DexClient::with_base_url(&server.url());
1407        let results = client.search_tokens("XYZNONEXIST", None).await.unwrap();
1408        assert!(results.is_empty());
1409    }
1410
1411    #[tokio::test]
1412    async fn test_search_tokens_api_error() {
1413        let mut server = mockito::Server::new_async().await;
1414        let _mock = server
1415            .mock(
1416                "GET",
1417                mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1418            )
1419            .with_status(429)
1420            .create_async()
1421            .await;
1422
1423        let client = DexClient::with_base_url(&server.url());
1424        let result = client.search_tokens("USDC", None).await;
1425        assert!(result.is_err());
1426    }
1427
1428    #[test]
1429    fn test_generate_price_history() {
1430        let pair_json = r#"{
1431            "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair",
1432            "baseToken":{"address":"0xtoken","name":"Token","symbol":"TKN"},
1433            "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1434            "priceUsd":"100.0",
1435            "priceChange":{"h24":10.0,"h6":5.0,"h1":1.0,"m5":0.5}
1436        }"#;
1437        let pair: DexScreenerPair = serde_json::from_str(pair_json).unwrap();
1438        let history = DexClient::generate_price_history(100.0, &pair, 1700000000);
1439        assert!(!history.is_empty());
1440        // Last point should be current price
1441        assert!(history.iter().any(|p| (p.price - 100.0).abs() < 0.001));
1442    }
1443
1444    #[test]
1445    fn test_chain_mapping_all_variants() {
1446        // Test all known chains
1447        assert_eq!(DexClient::map_chain_to_dexscreener("eth"), "ethereum");
1448        assert_eq!(DexClient::map_chain_to_dexscreener("matic"), "polygon");
1449        assert_eq!(DexClient::map_chain_to_dexscreener("arb"), "arbitrum");
1450        assert_eq!(DexClient::map_chain_to_dexscreener("op"), "optimism");
1451        assert_eq!(DexClient::map_chain_to_dexscreener("base"), "base");
1452        assert_eq!(DexClient::map_chain_to_dexscreener("bnb"), "bsc");
1453        assert_eq!(DexClient::map_chain_to_dexscreener("sol"), "solana");
1454        assert_eq!(DexClient::map_chain_to_dexscreener("avax"), "avalanche");
1455        assert_eq!(DexClient::map_chain_to_dexscreener("unknown"), "unknown");
1456    }
1457
1458    #[tokio::test]
1459    async fn test_get_native_token_price_ethereum() {
1460        let mut server = mockito::Server::new_async().await;
1461        let _mock = server
1462            .mock("GET", mockito::Matcher::Any)
1463            .with_status(200)
1464            .with_header("content-type", "application/json")
1465            .with_body(r#"{"pairs":[{
1466                "chainId":"ethereum",
1467                "dexId":"uniswap",
1468                "pairAddress":"0xpair",
1469                "baseToken":{"address":"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2","name":"WETH","symbol":"WETH"},
1470                "quoteToken":{"address":"0xusdt","name":"USDT","symbol":"USDT"},
1471                "priceUsd":"3500.00"
1472            }]}"#)
1473            .create_async()
1474            .await;
1475
1476        let client = DexClient::with_base_url(&server.url());
1477        let price = client.get_native_token_price("ethereum").await;
1478        assert!(price.is_some());
1479        assert!((price.unwrap() - 3500.0).abs() < 0.01);
1480    }
1481
1482    #[tokio::test]
1483    async fn test_get_native_token_price_tron_returns_none() {
1484        let client = DexClient::with_base_url("http://localhost:1");
1485        let price = client.get_native_token_price("tron").await;
1486        assert!(price.is_none());
1487    }
1488
1489    #[tokio::test]
1490    async fn test_get_native_token_price_unknown_chain() {
1491        let client = DexClient::with_base_url("http://localhost:1");
1492        let price = client.get_native_token_price("unknownchain").await;
1493        assert!(price.is_none());
1494    }
1495
1496    #[tokio::test]
1497    async fn test_search_tokens_chain_filter_ethereum_only() {
1498        let mut server = mockito::Server::new_async().await;
1499        let _mock = server
1500            .mock("GET", mockito::Matcher::Any)
1501            .with_status(200)
1502            .with_header("content-type", "application/json")
1503            .with_body(
1504                r#"{"pairs":[
1505                {
1506                    "chainId":"ethereum",
1507                    "dexId":"uniswap",
1508                    "pairAddress":"0xpair1",
1509                    "baseToken":{"address":"0xtoken1","name":"USD Coin","symbol":"USDC"},
1510                    "quoteToken":{"address":"0xweth","name":"WETH","symbol":"WETH"},
1511                    "priceUsd":"1.00",
1512                    "liquidity":{"usd":5000000.0},
1513                    "volume":{"h24":1000000.0}
1514                },
1515                {
1516                    "chainId":"bsc",
1517                    "dexId":"pancakeswap",
1518                    "pairAddress":"0xpair2",
1519                    "baseToken":{"address":"0xtoken2","name":"Binance USD","symbol":"BUSD"},
1520                    "quoteToken":{"address":"0xbnb","name":"BNB","symbol":"BNB"},
1521                    "priceUsd":"1.00",
1522                    "liquidity":{"usd":2000000.0},
1523                    "volume":{"h24":500000.0}
1524                }
1525            ]}"#,
1526            )
1527            .create_async()
1528            .await;
1529
1530        let client = DexClient::with_base_url(&server.url());
1531        // Filter to ethereum only
1532        let results = client.search_tokens("USD", Some("ethereum")).await.unwrap();
1533        assert!(!results.is_empty());
1534        // All results should be on ethereum
1535        for r in &results {
1536            assert_eq!(r.chain.to_lowercase(), "ethereum");
1537        }
1538    }
1539
1540    #[tokio::test]
1541    async fn test_search_tokens_exact_match_sorts_before_partial() {
1542        // Exact symbol match "USDC" should sort before "syrupUSDC" even if syrupUSDC has higher liquidity
1543        let mut server = mockito::Server::new_async().await;
1544        let pair_usdc = build_test_pair_json("ethereum", "USDC", "0xusdc", "1.00");
1545        let pair_syrup = r#"{
1546            "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair2",
1547            "baseToken":{"address":"0xsyrupusdc","name":"Syrup USDC","symbol":"syrupUSDC"},
1548            "quoteToken":{"address":"0xquote","name":"USDT","symbol":"USDT"},
1549            "priceUsd":"1.00",
1550            "priceChange":{"h24":0.0,"h6":0.0,"h1":0.0,"m5":0.0},
1551            "volume":{"h24":500000,"h6":125000,"h1":25000,"m5":2500},
1552            "liquidity":{"usd":5000000,"base":5000000,"quote":5000000},
1553            "fdv":null,"marketCap":null,
1554            "txns":{"h24":{"buys":100,"sells":80},"h6":{"buys":20,"sells":15},"h1":{"buys":5,"sells":3}},
1555            "pairCreatedAt":1690000000000,
1556            "url":"https://dexscreener.com/ethereum/0xpair2"
1557        }"#;
1558        let body = format!(r#"{{"pairs":[{},{}]}}"#, pair_usdc, pair_syrup);
1559        let _mock = server
1560            .mock(
1561                "GET",
1562                mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1563            )
1564            .with_status(200)
1565            .with_header("content-type", "application/json")
1566            .with_body(&body)
1567            .create_async()
1568            .await;
1569
1570        let client = DexClient::with_base_url(&server.url());
1571        let results = client.search_tokens("USDC", None).await.unwrap();
1572        assert!(results.len() >= 2);
1573        // First result must be exact match (USDC), not syrupUSDC
1574        assert_eq!(results[0].symbol, "USDC");
1575        // syrupUSDC has higher liquidity but should be second
1576        let syrup_pos = results
1577            .iter()
1578            .position(|r| r.symbol == "syrupUSDC")
1579            .unwrap();
1580        assert!(syrup_pos > 0);
1581        assert!(results[syrup_pos].liquidity_usd > results[0].liquidity_usd);
1582    }
1583
1584    #[tokio::test]
1585    async fn test_search_tokens_multiple_exact_matches_sort_by_liquidity() {
1586        // Multiple exact "USDC" matches (different chains) should sort by liquidity among themselves
1587        let mut server = mockito::Server::new_async().await;
1588        let pair_eth = r#"{
1589            "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair1",
1590            "baseToken":{"address":"0xusdc_eth","name":"USD Coin","symbol":"USDC"},
1591            "quoteToken":{"address":"0xweth","name":"WETH","symbol":"WETH"},
1592            "priceUsd":"1.00",
1593            "priceChange":{"h24":0.0,"h6":0.0,"h1":0.0,"m5":0.0},
1594            "volume":{"h24":1000000,"h6":250000,"h1":50000,"m5":5000},
1595            "liquidity":{"usd":1000000,"base":1000000,"quote":1000000},
1596            "fdv":null,"marketCap":null,
1597            "txns":{"h24":{"buys":100,"sells":80},"h6":{"buys":20,"sells":15},"h1":{"buys":5,"sells":3}},
1598            "pairCreatedAt":1690000000000,
1599            "url":"https://dexscreener.com/ethereum/0xpair1"
1600        }"#;
1601        let pair_bsc = r#"{
1602            "chainId":"bsc","dexId":"pancakeswap","pairAddress":"0xpair2",
1603            "baseToken":{"address":"0xusdc_bsc","name":"USD Coin","symbol":"USDC"},
1604            "quoteToken":{"address":"0xbnb","name":"BNB","symbol":"BNB"},
1605            "priceUsd":"1.00",
1606            "priceChange":{"h24":0.0,"h6":0.0,"h1":0.0,"m5":0.0},
1607            "volume":{"h24":2000000,"h6":500000,"h1":100000,"m5":10000},
1608            "liquidity":{"usd":3000000,"base":3000000,"quote":3000000},
1609            "fdv":null,"marketCap":null,
1610            "txns":{"h24":{"buys":200,"sells":150},"h6":{"buys":50,"sells":30},"h1":{"buys":10,"sells":6}},
1611            "pairCreatedAt":1690000000000,
1612            "url":"https://dexscreener.com/bsc/0xpair2"
1613        }"#;
1614        let body = format!(r#"{{"pairs":[{},{}]}}"#, pair_eth, pair_bsc);
1615        let _mock = server
1616            .mock(
1617                "GET",
1618                mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1619            )
1620            .with_status(200)
1621            .with_header("content-type", "application/json")
1622            .with_body(&body)
1623            .create_async()
1624            .await;
1625
1626        let client = DexClient::with_base_url(&server.url());
1627        let results = client.search_tokens("USDC", None).await.unwrap();
1628        assert_eq!(results.len(), 2);
1629        // Both exact matches - should sort by liquidity descending
1630        assert_eq!(results[0].symbol, "USDC");
1631        assert_eq!(results[1].symbol, "USDC");
1632        assert!(results[0].liquidity_usd >= results[1].liquidity_usd);
1633        assert_eq!(results[0].chain, "bsc"); // BSC has 3M liquidity, should be first
1634        assert_eq!(results[1].chain, "ethereum"); // Ethereum has 1M, second
1635    }
1636
1637    #[tokio::test]
1638    async fn test_search_tokens_aggregates_volume_and_liquidity() {
1639        let mut server = mockito::Server::new_async().await;
1640        let _mock = server
1641            .mock("GET", mockito::Matcher::Any)
1642            .with_status(200)
1643            .with_header("content-type", "application/json")
1644            .with_body(
1645                r#"{"pairs":[
1646                {
1647                    "chainId":"ethereum",
1648                    "dexId":"uniswap",
1649                    "pairAddress":"0xpair1",
1650                    "baseToken":{"address":"0xSameToken","name":"Test Token","symbol":"TEST"},
1651                    "quoteToken":{"address":"0xweth","name":"WETH","symbol":"WETH"},
1652                    "priceUsd":"10.00",
1653                    "liquidity":{"usd":1000000.0},
1654                    "volume":{"h24":100000.0}
1655                },
1656                {
1657                    "chainId":"ethereum",
1658                    "dexId":"sushiswap",
1659                    "pairAddress":"0xpair2",
1660                    "baseToken":{"address":"0xSameToken","name":"Test Token","symbol":"TEST"},
1661                    "quoteToken":{"address":"0xusdc","name":"USDC","symbol":"USDC"},
1662                    "priceUsd":"10.05",
1663                    "liquidity":{"usd":500000.0},
1664                    "volume":{"h24":50000.0}
1665                }
1666            ]}"#,
1667            )
1668            .create_async()
1669            .await;
1670
1671        let client = DexClient::with_base_url(&server.url());
1672        let results = client.search_tokens("TEST", None).await.unwrap();
1673        assert_eq!(results.len(), 1); // Same token aggregated
1674        // Volume and liquidity should be summed
1675        assert!(results[0].volume_24h > 100000.0);
1676        assert!(results[0].liquidity_usd > 1000000.0);
1677    }
1678
1679    #[tokio::test]
1680    async fn test_dex_data_source_trait_methods() {
1681        let mut server = mockito::Server::new_async().await;
1682        let _mock = server
1683            .mock("GET", mockito::Matcher::Any)
1684            .with_status(200)
1685            .with_header("content-type", "application/json")
1686            .with_body(
1687                r#"{"pairs":[{
1688                "chainId":"ethereum",
1689                "dexId":"uniswap",
1690                "pairAddress":"0xpair",
1691                "baseToken":{"address":"0xtoken","name":"Token","symbol":"TKN"},
1692                "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1693                "priceUsd":"50.0",
1694                "liquidity":{"usd":1000000.0},
1695                "volume":{"h24":100000.0}
1696            }]}"#,
1697            )
1698            .create_async()
1699            .await;
1700
1701        let client = DexClient::with_base_url(&server.url());
1702        // Test through DexDataSource trait
1703        let trait_client: &dyn DexDataSource = &client;
1704        let price = trait_client.get_token_price("ethereum", "0xtoken").await;
1705        assert!(price.is_some());
1706    }
1707
1708    #[tokio::test]
1709    async fn test_dex_data_source_trait_get_native_token_price() {
1710        let mut server = mockito::Server::new_async().await;
1711        let _mock = server
1712            .mock("GET", mockito::Matcher::Any)
1713            .with_status(200)
1714            .with_header("content-type", "application/json")
1715            .with_body(
1716                r#"{"pairs":[{
1717                "chainId":"ethereum",
1718                "dexId":"uniswap",
1719                "pairAddress":"0xpair",
1720                "baseToken":{"address":"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2","name":"WETH","symbol":"WETH"},
1721                "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1722                "priceUsd":"3500.0",
1723                "liquidity":{"usd":10000000.0},
1724                "volume":{"h24":5000000.0}
1725            }]}"#,
1726            )
1727            .create_async()
1728            .await;
1729
1730        let client = DexClient::with_base_url(&server.url());
1731        let trait_client: &dyn DexDataSource = &client;
1732        let price = trait_client.get_native_token_price("ethereum").await;
1733        assert!(price.is_some());
1734    }
1735
1736    #[tokio::test]
1737    async fn test_dex_data_source_trait_get_token_data() {
1738        let mut server = mockito::Server::new_async().await;
1739        let pair_json = build_test_pair_json("ethereum", "TKN", "0xtoken", "50.0");
1740        let _mock = server
1741            .mock("GET", mockito::Matcher::Any)
1742            .with_status(200)
1743            .with_header("content-type", "application/json")
1744            .with_body(format!(r#"{{"pairs":[{}]}}"#, pair_json))
1745            .create_async()
1746            .await;
1747
1748        let client = DexClient::with_base_url(&server.url());
1749        let trait_client: &dyn DexDataSource = &client;
1750        let data = trait_client.get_token_data("ethereum", "0xtoken").await;
1751        assert!(data.is_ok());
1752    }
1753
1754    #[tokio::test]
1755    async fn test_dex_data_source_trait_search_tokens() {
1756        let mut server = mockito::Server::new_async().await;
1757        let pair_json = build_test_pair_json("ethereum", "TKN", "0xtoken", "50.0");
1758        let _mock = server
1759            .mock("GET", mockito::Matcher::Any)
1760            .with_status(200)
1761            .with_header("content-type", "application/json")
1762            .with_body(format!(r#"{{"pairs":[{}]}}"#, pair_json))
1763            .create_async()
1764            .await;
1765
1766        let client = DexClient::with_base_url(&server.url());
1767        let trait_client: &dyn DexDataSource = &client;
1768        let results = trait_client.search_tokens("TKN", None).await;
1769        assert!(results.is_ok());
1770    }
1771
1772    #[tokio::test]
1773    async fn test_get_token_data_quote_token() {
1774        let mut server = mockito::Server::new_async().await;
1775        // Token is the quote token, not the base
1776        let _mock = server
1777            .mock("GET", mockito::Matcher::Any)
1778            .with_status(200)
1779            .with_header("content-type", "application/json")
1780            .with_body(
1781                r#"{"pairs":[{
1782                "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair",
1783                "baseToken":{"address":"0xother","name":"Other","symbol":"OTH"},
1784                "quoteToken":{"address":"0xmytoken","name":"MyToken","symbol":"MTK"},
1785                "priceUsd":"25.0",
1786                "priceChange":{"h24":1.0,"h6":0.5,"h1":0.2,"m5":0.05},
1787                "volume":{"h24":500000,"h6":100000,"h1":20000,"m5":2000},
1788                "liquidity":{"usd":0,"base":0,"quote":0},
1789                "txns":{"h24":{"buys":50,"sells":40},"h6":{"buys":10,"sells":8},"h1":{"buys":2,"sells":1}},
1790                "pairCreatedAt":1690000000000,
1791                "url":"https://dexscreener.com/ethereum/0xpair"
1792            }]}"#,
1793            )
1794            .create_async()
1795            .await;
1796
1797        let client = DexClient::with_base_url(&server.url());
1798        let data = client
1799            .get_token_data("ethereum", "0xmytoken")
1800            .await
1801            .unwrap();
1802        // Should identify the quote token
1803        assert_eq!(data.symbol, "MTK");
1804        assert_eq!(data.name, "MyToken");
1805        // Zero liquidity fallback for price: should use priceUsd from first pair
1806        assert!(data.price_usd > 0.0);
1807    }
1808
1809    #[tokio::test]
1810    async fn test_get_token_data_with_socials() {
1811        let mut server = mockito::Server::new_async().await;
1812        let _mock = server
1813            .mock("GET", mockito::Matcher::Any)
1814            .with_status(200)
1815            .with_header("content-type", "application/json")
1816            .with_body(
1817                r#"{"pairs":[{
1818                "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair",
1819                "baseToken":{"address":"0xtoken","name":"Token","symbol":"TKN"},
1820                "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1821                "priceUsd":"50.0",
1822                "priceChange":{"h24":5.0,"h6":2.0,"h1":1.0,"m5":0.1},
1823                "volume":{"h24":1000000,"h6":250000,"h1":50000,"m5":5000},
1824                "liquidity":{"usd":1000000,"base":100,"quote":1000000},
1825                "txns":{"h24":{"buys":100,"sells":80},"h6":{"buys":20,"sells":15},"h1":{"buys":5,"sells":3}},
1826                "pairCreatedAt":1690000000000,
1827                "url":"https://dexscreener.com/ethereum/0xpair",
1828                "info":{
1829                    "imageUrl":"https://example.com/logo.png",
1830                    "websites":[{"url":"https://example.com"}],
1831                    "socials":[
1832                        {"type":"twitter","url":"https://twitter.com/token"},
1833                        {"type":"telegram","url":"https://t.me/token"}
1834                    ]
1835                }
1836            }]}"#,
1837            )
1838            .create_async()
1839            .await;
1840
1841        let client = DexClient::with_base_url(&server.url());
1842        let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1843        assert_eq!(data.symbol, "TKN");
1844        assert!(data.image_url.is_some());
1845        assert!(!data.websites.is_empty());
1846        assert!(!data.socials.is_empty());
1847        assert_eq!(data.socials[0].platform, "twitter");
1848    }
1849
1850    #[tokio::test]
1851    async fn test_search_tokens_quote_match_and_updates() {
1852        let mut server = mockito::Server::new_async().await;
1853        // Token matches as quote, not base
1854        let _mock = server
1855            .mock("GET", mockito::Matcher::Any)
1856            .with_status(200)
1857            .with_header("content-type", "application/json")
1858            .with_body(
1859                r#"{"pairs":[
1860                {
1861                    "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair1",
1862                    "baseToken":{"address":"0xother","name":"Other","symbol":"OTH"},
1863                    "quoteToken":{"address":"0xmytk","name":"MySearch","symbol":"MSR"},
1864                    "liquidity":{"usd":500000.0},
1865                    "volume":{"h24":100000.0},
1866                    "marketCap":5000000
1867                },
1868                {
1869                    "chainId":"ethereum","dexId":"sushi","pairAddress":"0xpair2",
1870                    "baseToken":{"address":"0xmytk","name":"MySearch","symbol":"MSR"},
1871                    "quoteToken":{"address":"0xweth","name":"WETH","symbol":"WETH"},
1872                    "priceUsd":"10.5",
1873                    "liquidity":{"usd":800000.0},
1874                    "volume":{"h24":200000.0}
1875                }
1876            ]}"#,
1877            )
1878            .create_async()
1879            .await;
1880
1881        let client = DexClient::with_base_url(&server.url());
1882        let results = client.search_tokens("MySearch", None).await.unwrap();
1883        assert_eq!(results.len(), 1); // Same token aggregated
1884        assert_eq!(results[0].symbol, "MSR");
1885        // Volume should be aggregated
1886        assert!(results[0].volume_24h >= 300000.0);
1887        // Liquidity should be aggregated
1888        assert!(results[0].liquidity_usd >= 1300000.0);
1889        // Price should be set from the second pair
1890        assert!(results[0].price_usd.is_some());
1891        // Market cap should be carried from first pair
1892        assert!(results[0].market_cap.is_some());
1893    }
1894
1895    #[test]
1896    fn test_interpolate_points_midpoint() {
1897        let mut history = vec![
1898            PricePoint {
1899                timestamp: 1000,
1900                price: 10.0,
1901            },
1902            PricePoint {
1903                timestamp: 2000,
1904                price: 20.0,
1905            },
1906        ];
1907        // Should not interpolate if already enough points
1908        DexClient::interpolate_points(&mut history, 2);
1909        assert_eq!(history.len(), 2);
1910
1911        // Should add midpoints
1912        DexClient::interpolate_points(&mut history, 5);
1913        assert!(history.len() > 2);
1914        // Check that a midpoint was added
1915        let midpoints: Vec<_> = history.iter().filter(|p| p.timestamp == 1500).collect();
1916        assert!(!midpoints.is_empty());
1917        assert!((midpoints[0].price - 15.0).abs() < 0.01);
1918    }
1919
1920    fn discover_token_json() -> &'static str {
1921        r#"[
1922            {"chainId":"ethereum","tokenAddress":"0xabc","url":"https://dexscreener.com/ethereum/0xabc","description":"Test token","links":[{"label":"Twitter","type":"twitter","url":"https://twitter.com/test"}]},
1923            {"chainId":"solana","tokenAddress":"So11111111111111111111111111111111111111112","url":"https://dexscreener.com/solana/So11","links":[]}
1924        ]"#
1925    }
1926
1927    #[tokio::test]
1928    async fn test_get_token_profiles() {
1929        let mut server = mockito::Server::new_async().await;
1930        let _mock = server
1931            .mock("GET", "/token-profiles/latest/v1")
1932            .with_status(200)
1933            .with_header("content-type", "application/json")
1934            .with_body(discover_token_json())
1935            .create_async()
1936            .await;
1937
1938        let client = DexClient::with_base_url(&server.url());
1939        let tokens = client.get_token_profiles().await.unwrap();
1940        assert_eq!(tokens.len(), 2);
1941        assert_eq!(tokens[0].chain_id, "ethereum");
1942        assert_eq!(tokens[0].token_address, "0xabc");
1943        assert_eq!(tokens[0].description.as_deref(), Some("Test token"));
1944        assert_eq!(tokens[0].links.len(), 1);
1945        assert_eq!(tokens[1].chain_id, "solana");
1946    }
1947
1948    #[tokio::test]
1949    async fn test_get_token_boosts() {
1950        let mut server = mockito::Server::new_async().await;
1951        let _mock = server
1952            .mock("GET", "/token-boosts/latest/v1")
1953            .with_status(200)
1954            .with_header("content-type", "application/json")
1955            .with_body(discover_token_json())
1956            .create_async()
1957            .await;
1958
1959        let client = DexClient::with_base_url(&server.url());
1960        let tokens = client.get_token_boosts().await.unwrap();
1961        assert_eq!(tokens.len(), 2);
1962    }
1963
1964    #[tokio::test]
1965    async fn test_get_token_boosts_top() {
1966        let mut server = mockito::Server::new_async().await;
1967        let _mock = server
1968            .mock("GET", "/token-boosts/top/v1")
1969            .with_status(200)
1970            .with_header("content-type", "application/json")
1971            .with_body(discover_token_json())
1972            .create_async()
1973            .await;
1974
1975        let client = DexClient::with_base_url(&server.url());
1976        let tokens = client.get_token_boosts_top().await.unwrap();
1977        assert_eq!(tokens.len(), 2);
1978    }
1979
1980    #[tokio::test]
1981    async fn test_fetch_discover_tokens_api_error() {
1982        let mut server = mockito::Server::new_async().await;
1983        let _mock = server
1984            .mock("GET", mockito::Matcher::Any)
1985            .with_status(500)
1986            .create_async()
1987            .await;
1988
1989        let client = DexClient::with_base_url(&server.url());
1990        let result = client.get_token_profiles().await;
1991        assert!(result.is_err());
1992    }
1993
1994    #[tokio::test]
1995    async fn test_fetch_discover_tokens_empty_array() {
1996        let mut server = mockito::Server::new_async().await;
1997        let _mock = server
1998            .mock("GET", "/token-profiles/latest/v1")
1999            .with_status(200)
2000            .with_header("content-type", "application/json")
2001            .with_body("[]")
2002            .create_async()
2003            .await;
2004
2005        let client = DexClient::with_base_url(&server.url());
2006        let tokens = client.get_token_profiles().await.unwrap();
2007        assert!(tokens.is_empty());
2008    }
2009
2010    #[tokio::test]
2011    async fn test_fetch_discover_tokens_filters_invalid_entries() {
2012        // Entries without tokenAddress are filtered out
2013        let body = r#"[{"chainId":"ethereum","url":"https://example.com"},{"chainId":"solana","tokenAddress":"0xvalid","url":"https://dexscreener.com/solana/0xvalid"}]"#;
2014        let mut server = mockito::Server::new_async().await;
2015        let _mock = server
2016            .mock("GET", "/token-profiles/latest/v1")
2017            .with_status(200)
2018            .with_header("content-type", "application/json")
2019            .with_body(body)
2020            .create_async()
2021            .await;
2022
2023        let client = DexClient::with_base_url(&server.url());
2024        let tokens = client.get_token_profiles().await.unwrap();
2025        assert_eq!(tokens.len(), 1);
2026        assert_eq!(tokens[0].token_address, "0xvalid");
2027    }
2028
2029    // =================================================================
2030    // Token search sorting: exact symbol matches first
2031    // =================================================================
2032
2033    #[tokio::test]
2034    async fn test_search_tokens_exact_match_sorted_first() {
2035        // Set up a mock that returns two tokens: "syrupUSDC" with higher liquidity
2036        // and "USDC" with lower liquidity. The exact match "USDC" should come first.
2037        let pair_syrup = build_test_pair_json("ethereum", "syrupUSDC", "0xsyrup", "1.0");
2038        let pair_usdc = build_test_pair_json("ethereum", "USDC", "0xusdc", "1.0");
2039
2040        let body = format!(
2041            r#"{{"schemaVersion":"1.0.0","pairs":[{},{}]}}"#,
2042            pair_syrup, pair_usdc
2043        );
2044
2045        let mut server = mockito::Server::new_async().await;
2046        let _mock = server
2047            .mock("GET", "/latest/dex/search")
2048            .match_query(mockito::Matcher::UrlEncoded("q".into(), "USDC".into()))
2049            .with_status(200)
2050            .with_header("content-type", "application/json")
2051            .with_body(&body)
2052            .create_async()
2053            .await;
2054
2055        let client = DexClient::with_base_url(&server.url());
2056        let results = client.search_tokens("USDC", None).await.unwrap();
2057
2058        assert!(
2059            results.len() >= 2,
2060            "expected at least 2 results, got {}",
2061            results.len()
2062        );
2063        // First result should be exact match "USDC", not "syrupUSDC"
2064        assert_eq!(
2065            results[0].symbol, "USDC",
2066            "exact symbol match should be sorted first"
2067        );
2068        assert_eq!(results[1].symbol, "syrupUSDC");
2069    }
2070
2071    #[tokio::test]
2072    async fn test_search_tokens_no_results() {
2073        let body = r#"{"schemaVersion":"1.0.0","pairs":[]}"#;
2074        let mut server = mockito::Server::new_async().await;
2075        let _mock = server
2076            .mock("GET", "/latest/dex/search")
2077            .match_query(mockito::Matcher::UrlEncoded("q".into(), "ZZZZZ".into()))
2078            .with_status(200)
2079            .with_header("content-type", "application/json")
2080            .with_body(body)
2081            .create_async()
2082            .await;
2083
2084        let client = DexClient::with_base_url(&server.url());
2085        let results = client.search_tokens("ZZZZZ", None).await.unwrap();
2086        assert!(results.is_empty());
2087    }
2088
2089    #[tokio::test]
2090    async fn test_search_tokens_api_error_500() {
2091        let mut server = mockito::Server::new_async().await;
2092        let _mock = server
2093            .mock("GET", "/latest/dex/search")
2094            .match_query(mockito::Matcher::UrlEncoded("q".into(), "ERR".into()))
2095            .with_status(500)
2096            .create_async()
2097            .await;
2098
2099        let client = DexClient::with_base_url(&server.url());
2100        let result = client.search_tokens("ERR", None).await;
2101        assert!(result.is_err());
2102    }
2103}