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