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