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