Skip to main content

scope/chains/
dex.rs

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