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/// Response from DexScreener search endpoint.
339#[derive(Debug, Deserialize)]
340struct DexScreenerSearchResponse {
341    pairs: Option<Vec<DexScreenerPair>>,
342}
343
344impl DexClient {
345    /// Creates a new DEX client.
346    pub fn new() -> Self {
347        let http = Client::builder()
348            .timeout(Duration::from_secs(30))
349            .build()
350            .expect("Failed to build HTTP client");
351
352        Self {
353            http,
354            base_url: DEXSCREENER_API_BASE.to_string(),
355        }
356    }
357
358    /// Creates a new DEX client with a custom base URL (for testing).
359    #[cfg(test)]
360    fn with_base_url(base_url: &str) -> Self {
361        Self {
362            http: Client::new(),
363            base_url: base_url.to_string(),
364        }
365    }
366
367    /// Maps chain names to DexScreener chain IDs.
368    fn map_chain_to_dexscreener(chain: &str) -> String {
369        match chain.to_lowercase().as_str() {
370            "ethereum" | "eth" => "ethereum".to_string(),
371            "polygon" | "matic" => "polygon".to_string(),
372            "arbitrum" | "arb" => "arbitrum".to_string(),
373            "optimism" | "op" => "optimism".to_string(),
374            "base" => "base".to_string(),
375            "bsc" | "bnb" => "bsc".to_string(),
376            "solana" | "sol" => "solana".to_string(),
377            "avalanche" | "avax" => "avalanche".to_string(),
378            _ => chain.to_lowercase(),
379        }
380    }
381
382    /// Fetches the USD price of a token by its address.
383    ///
384    /// Returns `None` if the token is not found or has no price data.
385    pub async fn get_token_price(&self, chain: &str, token_address: &str) -> Option<f64> {
386        let url = format!("{}/latest/dex/tokens/{}", self.base_url, token_address);
387
388        let response = self.http.get(&url).send().await.ok()?;
389        let dex_response: DexScreenerTokenResponse = response.json().await.ok()?;
390
391        let dex_chain = Self::map_chain_to_dexscreener(chain);
392
393        dex_response
394            .pairs
395            .as_ref()?
396            .iter()
397            .filter(|p| p.chain_id.to_lowercase() == dex_chain)
398            .filter_map(|p| p.price_usd.as_ref()?.parse::<f64>().ok())
399            .next()
400    }
401
402    /// Fetches the native token price for a chain.
403    ///
404    /// Uses well-known wrapped token addresses to determine the native token price.
405    pub async fn get_native_token_price(&self, chain: &str) -> Option<f64> {
406        let (search_chain, token_address) = match chain.to_lowercase().as_str() {
407            "ethereum" | "eth" => ("ethereum", "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"), // WETH
408            "polygon" | "matic" => ("polygon", "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270"), // WMATIC
409            "arbitrum" | "arb" => ("arbitrum", "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), // WETH on Arb
410            "optimism" | "op" => ("optimism", "0x4200000000000000000000000000000000000006"), // WETH on OP
411            "base" => ("base", "0x4200000000000000000000000000000000000006"), // WETH on Base
412            "bsc" | "bnb" => ("bsc", "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c"), // WBNB
413            "solana" | "sol" => ("solana", "So11111111111111111111111111111111111111112"), // Wrapped SOL
414            "tron" | "trx" => return None, // Tron wrapped token varies; skip for now
415            _ => return None,
416        };
417
418        self.get_token_price(search_chain, token_address).await
419    }
420
421    /// Fetches token data from DexScreener.
422    ///
423    /// # Arguments
424    ///
425    /// * `chain` - The blockchain name (e.g., "ethereum", "bsc")
426    /// * `token_address` - The token contract address
427    ///
428    /// # Returns
429    ///
430    /// Returns aggregated token data from all DEX pairs.
431    pub async fn get_token_data(&self, chain: &str, token_address: &str) -> Result<DexTokenData> {
432        let url = format!("{}/latest/dex/tokens/{}", self.base_url, token_address);
433
434        tracing::debug!(url = %url, "Fetching token data from DexScreener");
435
436        let response = self
437            .http
438            .get(&url)
439            .send()
440            .await
441            .map_err(|e| ScopeError::Network(e.to_string()))?;
442
443        if !response.status().is_success() {
444            return Err(ScopeError::Api(format!(
445                "DexScreener API error: {}",
446                response.status()
447            )));
448        }
449
450        let data: DexScreenerTokenResponse = response
451            .json()
452            .await
453            .map_err(|e| ScopeError::Api(format!("Failed to parse DexScreener response: {}", e)))?;
454
455        let pairs = data.pairs.unwrap_or_default();
456
457        if pairs.is_empty() {
458            return Err(ScopeError::NotFound(format!(
459                "No DEX pairs found for token {}",
460                token_address
461            )));
462        }
463
464        // Filter pairs by chain
465        let chain_id = Self::map_chain_to_dexscreener(chain);
466        let chain_pairs: Vec<_> = pairs
467            .iter()
468            .filter(|p| p.chain_id.to_lowercase() == chain_id)
469            .collect();
470
471        // Use all pairs if no chain-specific pairs found
472        let relevant_pairs = if chain_pairs.is_empty() {
473            pairs.iter().collect()
474        } else {
475            chain_pairs
476        };
477
478        // Get token info from first pair
479        let first_pair = &relevant_pairs[0];
480        let is_base_token =
481            first_pair.base_token.address.to_lowercase() == token_address.to_lowercase();
482        let token_info = if is_base_token {
483            &first_pair.base_token
484        } else {
485            &first_pair.quote_token
486        };
487
488        // Aggregate data from all pairs
489        let mut total_volume_24h = 0.0;
490        let mut total_volume_6h = 0.0;
491        let mut total_volume_1h = 0.0;
492        let mut total_liquidity = 0.0;
493        let mut weighted_price_sum = 0.0;
494        let mut liquidity_weight_sum = 0.0;
495        let mut dex_pairs = Vec::new();
496
497        for pair in &relevant_pairs {
498            let pair_liquidity = pair.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0);
499
500            let pair_price = pair
501                .price_usd
502                .as_ref()
503                .and_then(|p| p.parse::<f64>().ok())
504                .unwrap_or(0.0);
505
506            if let Some(vol) = &pair.volume {
507                total_volume_24h += vol.h24.unwrap_or(0.0);
508                total_volume_6h += vol.h6.unwrap_or(0.0);
509                total_volume_1h += vol.h1.unwrap_or(0.0);
510            }
511
512            total_liquidity += pair_liquidity;
513
514            // Weight price by liquidity for more accurate average
515            if pair_liquidity > 0.0 && pair_price > 0.0 {
516                weighted_price_sum += pair_price * pair_liquidity;
517                liquidity_weight_sum += pair_liquidity;
518            }
519
520            let price_change = pair
521                .price_change
522                .as_ref()
523                .and_then(|pc| pc.h24)
524                .unwrap_or(0.0);
525
526            // Extract transaction counts
527            let txn_counts_24h = pair.txns.as_ref().and_then(|t| t.h24.clone());
528            let txn_counts_6h = pair.txns.as_ref().and_then(|t| t.h6.clone());
529            let txn_counts_1h = pair.txns.as_ref().and_then(|t| t.h1.clone());
530
531            dex_pairs.push(DexPair {
532                dex_name: pair.dex_id.clone(),
533                pair_address: pair.pair_address.clone(),
534                base_token: pair.base_token.symbol.clone(),
535                quote_token: pair.quote_token.symbol.clone(),
536                price_usd: pair_price,
537                volume_24h: pair.volume.as_ref().and_then(|v| v.h24).unwrap_or(0.0),
538                liquidity_usd: pair_liquidity,
539                price_change_24h: price_change,
540                buys_24h: txn_counts_24h.as_ref().map(|t| t.buys).unwrap_or(0),
541                sells_24h: txn_counts_24h.as_ref().map(|t| t.sells).unwrap_or(0),
542                buys_6h: txn_counts_6h.as_ref().map(|t| t.buys).unwrap_or(0),
543                sells_6h: txn_counts_6h.as_ref().map(|t| t.sells).unwrap_or(0),
544                buys_1h: txn_counts_1h.as_ref().map(|t| t.buys).unwrap_or(0),
545                sells_1h: txn_counts_1h.as_ref().map(|t| t.sells).unwrap_or(0),
546                pair_created_at: pair.pair_created_at,
547                url: pair.url.clone(),
548            });
549        }
550
551        // Calculate weighted average price
552        let avg_price = if liquidity_weight_sum > 0.0 {
553            weighted_price_sum / liquidity_weight_sum
554        } else {
555            first_pair
556                .price_usd
557                .as_ref()
558                .and_then(|p| p.parse().ok())
559                .unwrap_or(0.0)
560        };
561
562        // Get price change and market data from the highest liquidity pair
563        let best_pair = relevant_pairs
564            .iter()
565            .max_by(|a, b| {
566                let liq_a = a.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0);
567                let liq_b = b.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0);
568                liq_a
569                    .partial_cmp(&liq_b)
570                    .unwrap_or(std::cmp::Ordering::Equal)
571            })
572            .unwrap();
573
574        let price_change_24h = best_pair
575            .price_change
576            .as_ref()
577            .and_then(|pc| pc.h24)
578            .unwrap_or(0.0);
579
580        let price_change_6h = best_pair
581            .price_change
582            .as_ref()
583            .and_then(|pc| pc.h6)
584            .unwrap_or(0.0);
585
586        let price_change_1h = best_pair
587            .price_change
588            .as_ref()
589            .and_then(|pc| pc.h1)
590            .unwrap_or(0.0);
591
592        let price_change_5m = best_pair
593            .price_change
594            .as_ref()
595            .and_then(|pc| pc.m5)
596            .unwrap_or(0.0);
597
598        // Aggregate transaction counts across all pairs
599        let total_buys_24h: u64 = dex_pairs.iter().map(|p| p.buys_24h).sum();
600        let total_sells_24h: u64 = dex_pairs.iter().map(|p| p.sells_24h).sum();
601        let total_buys_6h: u64 = dex_pairs.iter().map(|p| p.buys_6h).sum();
602        let total_sells_6h: u64 = dex_pairs.iter().map(|p| p.sells_6h).sum();
603        let total_buys_1h: u64 = dex_pairs.iter().map(|p| p.buys_1h).sum();
604        let total_sells_1h: u64 = dex_pairs.iter().map(|p| p.sells_1h).sum();
605
606        // Find earliest pair creation timestamp
607        let earliest_pair_created_at = dex_pairs.iter().filter_map(|p| p.pair_created_at).min();
608
609        // Extract token metadata from best pair
610        let image_url = best_pair.info.as_ref().and_then(|i| i.image_url.clone());
611        let websites: Vec<String> = best_pair
612            .info
613            .as_ref()
614            .and_then(|i| i.websites.as_ref())
615            .map(|ws| ws.iter().filter_map(|w| w.url.clone()).collect())
616            .unwrap_or_default();
617        let socials: Vec<TokenSocial> = best_pair
618            .info
619            .as_ref()
620            .and_then(|i| i.socials.as_ref())
621            .map(|ss| {
622                ss.iter()
623                    .filter_map(|s| {
624                        Some(TokenSocial {
625                            platform: s.platform.clone()?,
626                            url: s.url.clone()?,
627                        })
628                    })
629                    .collect()
630            })
631            .unwrap_or_default();
632        let dexscreener_url = best_pair.url.clone();
633
634        // Generate synthetic price history from change percentages
635        let now = chrono::Utc::now().timestamp();
636        let price_history = Self::generate_price_history(avg_price, best_pair, now);
637
638        // Generate synthetic volume history
639        let volume_history =
640            Self::generate_volume_history(total_volume_24h, total_volume_6h, total_volume_1h, now);
641
642        Ok(DexTokenData {
643            address: token_address.to_string(),
644            symbol: token_info.symbol.clone(),
645            name: token_info.name.clone(),
646            price_usd: avg_price,
647            price_change_24h,
648            price_change_6h,
649            price_change_1h,
650            price_change_5m,
651            volume_24h: total_volume_24h,
652            volume_6h: total_volume_6h,
653            volume_1h: total_volume_1h,
654            liquidity_usd: total_liquidity,
655            market_cap: best_pair.market_cap,
656            fdv: best_pair.fdv,
657            pairs: dex_pairs,
658            price_history,
659            volume_history,
660            total_buys_24h,
661            total_sells_24h,
662            total_buys_6h,
663            total_sells_6h,
664            total_buys_1h,
665            total_sells_1h,
666            earliest_pair_created_at,
667            image_url,
668            websites,
669            socials,
670            dexscreener_url,
671        })
672    }
673
674    /// Searches for tokens by name or symbol.
675    ///
676    /// # Arguments
677    ///
678    /// * `query` - The search query (token name or symbol)
679    /// * `chain` - Optional chain filter (e.g., "ethereum", "bsc")
680    ///
681    /// # Returns
682    ///
683    /// Returns a vector of matching tokens sorted by liquidity.
684    pub async fn search_tokens(
685        &self,
686        query: &str,
687        chain: Option<&str>,
688    ) -> Result<Vec<TokenSearchResult>> {
689        let url = format!(
690            "{}/latest/dex/search?q={}",
691            self.base_url,
692            urlencoding::encode(query)
693        );
694
695        tracing::debug!(url = %url, "Searching tokens on DexScreener");
696
697        let response = self
698            .http
699            .get(&url)
700            .send()
701            .await
702            .map_err(|e| ScopeError::Network(e.to_string()))?;
703
704        if !response.status().is_success() {
705            return Err(ScopeError::Api(format!(
706                "DexScreener search API error: {}",
707                response.status()
708            )));
709        }
710
711        let data: DexScreenerSearchResponse = response
712            .json()
713            .await
714            .map_err(|e| ScopeError::Api(format!("Failed to parse search response: {}", e)))?;
715
716        let pairs = data.pairs.unwrap_or_default();
717
718        if pairs.is_empty() {
719            return Ok(Vec::new());
720        }
721
722        // Filter by chain if specified
723        let chain_id = chain.map(Self::map_chain_to_dexscreener);
724        let filtered_pairs: Vec<_> = if let Some(ref cid) = chain_id {
725            pairs
726                .iter()
727                .filter(|p| p.chain_id.to_lowercase() == *cid)
728                .collect()
729        } else {
730            pairs.iter().collect()
731        };
732
733        // Deduplicate tokens by address and aggregate data
734        let mut token_map: std::collections::HashMap<String, TokenSearchResult> =
735            std::collections::HashMap::new();
736
737        for pair in filtered_pairs {
738            // Check if the query matches base or quote token
739            let base_matches = pair
740                .base_token
741                .symbol
742                .to_lowercase()
743                .contains(&query.to_lowercase())
744                || pair
745                    .base_token
746                    .name
747                    .to_lowercase()
748                    .contains(&query.to_lowercase());
749            let quote_matches = pair
750                .quote_token
751                .symbol
752                .to_lowercase()
753                .contains(&query.to_lowercase())
754                || pair
755                    .quote_token
756                    .name
757                    .to_lowercase()
758                    .contains(&query.to_lowercase());
759
760            let token_info = if base_matches {
761                &pair.base_token
762            } else if quote_matches {
763                &pair.quote_token
764            } else {
765                // Use base token by default
766                &pair.base_token
767            };
768
769            let key = format!("{}:{}", pair.chain_id, token_info.address.to_lowercase());
770
771            let pair_liquidity = pair.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0);
772
773            let pair_volume = pair.volume.as_ref().and_then(|v| v.h24).unwrap_or(0.0);
774
775            let pair_price = pair.price_usd.as_ref().and_then(|p| p.parse::<f64>().ok());
776
777            let entry = token_map.entry(key).or_insert_with(|| TokenSearchResult {
778                address: token_info.address.clone(),
779                symbol: token_info.symbol.clone(),
780                name: token_info.name.clone(),
781                chain: pair.chain_id.clone(),
782                price_usd: pair_price,
783                volume_24h: 0.0,
784                liquidity_usd: 0.0,
785                market_cap: pair.market_cap,
786            });
787
788            // Aggregate volume and liquidity
789            entry.volume_24h += pair_volume;
790            entry.liquidity_usd += pair_liquidity;
791
792            // Update price if better data available
793            if entry.price_usd.is_none() && pair_price.is_some() {
794                entry.price_usd = pair_price;
795            }
796
797            // Update market cap if available
798            if entry.market_cap.is_none() && pair.market_cap.is_some() {
799                entry.market_cap = pair.market_cap;
800            }
801        }
802
803        // Convert to vector and sort by liquidity (descending)
804        let mut results: Vec<TokenSearchResult> = token_map.into_values().collect();
805        results.sort_by(|a, b| {
806            b.liquidity_usd
807                .partial_cmp(&a.liquidity_usd)
808                .unwrap_or(std::cmp::Ordering::Equal)
809        });
810
811        // Limit results
812        results.truncate(20);
813
814        Ok(results)
815    }
816
817    /// Generates synthetic price history from change percentages.
818    fn generate_price_history(
819        current_price: f64,
820        pair: &DexScreenerPair,
821        now: i64,
822    ) -> Vec<PricePoint> {
823        let mut history = Vec::new();
824
825        // Get price changes at different intervals
826        let changes = pair.price_change.as_ref();
827        let change_24h = changes.and_then(|c| c.h24).unwrap_or(0.0) / 100.0;
828        let change_6h = changes.and_then(|c| c.h6).unwrap_or(0.0) / 100.0;
829        let change_1h = changes.and_then(|c| c.h1).unwrap_or(0.0) / 100.0;
830        let change_5m = changes.and_then(|c| c.m5).unwrap_or(0.0) / 100.0;
831
832        // Calculate historical prices (working backwards)
833        let price_24h_ago = current_price / (1.0 + change_24h);
834        let price_6h_ago = current_price / (1.0 + change_6h);
835        let price_1h_ago = current_price / (1.0 + change_1h);
836        let price_5m_ago = current_price / (1.0 + change_5m);
837
838        // Add points at known intervals
839        history.push(PricePoint {
840            timestamp: now - 86400, // 24h ago
841            price: price_24h_ago,
842        });
843        history.push(PricePoint {
844            timestamp: now - 21600, // 6h ago
845            price: price_6h_ago,
846        });
847        history.push(PricePoint {
848            timestamp: now - 3600, // 1h ago
849            price: price_1h_ago,
850        });
851        history.push(PricePoint {
852            timestamp: now - 300, // 5m ago
853            price: price_5m_ago,
854        });
855        history.push(PricePoint {
856            timestamp: now,
857            price: current_price,
858        });
859
860        // Interpolate additional points for smoother charts
861        Self::interpolate_points(&mut history, 24);
862
863        history.sort_by_key(|p| p.timestamp);
864        history
865    }
866
867    /// Generates synthetic volume history from known data points.
868    fn generate_volume_history(
869        volume_24h: f64,
870        volume_6h: f64,
871        volume_1h: f64,
872        now: i64,
873    ) -> Vec<VolumePoint> {
874        let mut history = Vec::new();
875
876        // Create hourly buckets for the last 24 hours
877        let hourly_avg = volume_24h / 24.0;
878
879        for i in 0..24 {
880            let timestamp = now - (23 - i) * 3600;
881            let hours_ago = 24 - i;
882
883            // Adjust volume based on known data points
884            let volume = if hours_ago <= 1 {
885                volume_1h
886            } else if hours_ago <= 6 {
887                volume_6h / 6.0
888            } else {
889                // Use average with some variation
890                hourly_avg * (0.8 + (i as f64 / 24.0) * 0.4)
891            };
892
893            history.push(VolumePoint { timestamp, volume });
894        }
895
896        history
897    }
898
899    /// Interpolates additional price points for smoother charts.
900    fn interpolate_points(history: &mut Vec<PricePoint>, target_count: usize) {
901        if history.len() >= target_count {
902            return;
903        }
904
905        history.sort_by_key(|p| p.timestamp);
906
907        let mut interpolated = Vec::new();
908        for window in history.windows(2) {
909            let p1 = &window[0];
910            let p2 = &window[1];
911
912            interpolated.push(p1.clone());
913
914            // Add midpoint
915            let mid_timestamp = (p1.timestamp + p2.timestamp) / 2;
916            let mid_price = (p1.price + p2.price) / 2.0;
917            interpolated.push(PricePoint {
918                timestamp: mid_timestamp,
919                price: mid_price,
920            });
921        }
922
923        if let Some(last) = history.last() {
924            interpolated.push(last.clone());
925        }
926
927        *history = interpolated;
928    }
929
930    /// Gets the 7-day volume by extrapolating from 24h data.
931    ///
932    /// Note: DexScreener doesn't provide 7d volume directly,
933    /// so we estimate based on 24h volume.
934    pub fn estimate_7d_volume(volume_24h: f64) -> f64 {
935        // Simple estimation: assume consistent daily volume
936        volume_24h * 7.0
937    }
938}
939
940impl Default for DexClient {
941    fn default() -> Self {
942        Self::new()
943    }
944}
945
946// ============================================================================
947// DexDataSource Trait Implementation
948// ============================================================================
949
950#[async_trait]
951impl DexDataSource for DexClient {
952    async fn get_token_price(&self, chain: &str, address: &str) -> Option<f64> {
953        self.get_token_price(chain, address).await
954    }
955
956    async fn get_native_token_price(&self, chain: &str) -> Option<f64> {
957        self.get_native_token_price(chain).await
958    }
959
960    async fn get_token_data(&self, chain: &str, address: &str) -> Result<DexTokenData> {
961        self.get_token_data(chain, address).await
962    }
963
964    async fn search_tokens(
965        &self,
966        query: &str,
967        chain: Option<&str>,
968    ) -> Result<Vec<TokenSearchResult>> {
969        self.search_tokens(query, chain).await
970    }
971}
972
973/// Builds a full DexScreener token response JSON string for testing.
974#[cfg(test)]
975fn build_test_pair_json(chain_id: &str, base_symbol: &str, base_addr: &str, price: &str) -> String {
976    format!(
977        r#"{{
978        "chainId":"{}","dexId":"uniswap","pairAddress":"0xpair",
979        "baseToken":{{"address":"{}","name":"{}","symbol":"{}"}},
980        "quoteToken":{{"address":"0xquote","name":"USDC","symbol":"USDC"}},
981        "priceUsd":"{}",
982        "priceChange":{{"h24":5.2,"h6":2.1,"h1":0.5,"m5":0.1}},
983        "volume":{{"h24":1000000,"h6":250000,"h1":50000,"m5":5000}},
984        "liquidity":{{"usd":500000,"base":100,"quote":500000}},
985        "fdv":10000000,"marketCap":8000000,
986        "txns":{{"h24":{{"buys":100,"sells":80}},"h6":{{"buys":20,"sells":15}},"h1":{{"buys":5,"sells":3}}}},
987        "pairCreatedAt":1690000000000,
988        "url":"https://dexscreener.com/ethereum/0xpair"
989    }}"#,
990        chain_id, base_addr, base_symbol, base_symbol, price
991    )
992}
993
994// ============================================================================
995// Unit Tests
996// ============================================================================
997
998#[cfg(test)]
999mod tests {
1000    use super::*;
1001
1002    #[test]
1003    fn test_chain_mapping() {
1004        assert_eq!(
1005            DexClient::map_chain_to_dexscreener("ethereum"),
1006            "ethereum".to_string()
1007        );
1008        assert_eq!(
1009            DexClient::map_chain_to_dexscreener("ETH"),
1010            "ethereum".to_string()
1011        );
1012        assert_eq!(
1013            DexClient::map_chain_to_dexscreener("bsc"),
1014            "bsc".to_string()
1015        );
1016        assert_eq!(
1017            DexClient::map_chain_to_dexscreener("BNB"),
1018            "bsc".to_string()
1019        );
1020        assert_eq!(
1021            DexClient::map_chain_to_dexscreener("polygon"),
1022            "polygon".to_string()
1023        );
1024        assert_eq!(
1025            DexClient::map_chain_to_dexscreener("solana"),
1026            "solana".to_string()
1027        );
1028    }
1029
1030    #[test]
1031    fn test_estimate_7d_volume() {
1032        assert_eq!(DexClient::estimate_7d_volume(1_000_000.0), 7_000_000.0);
1033        assert_eq!(DexClient::estimate_7d_volume(0.0), 0.0);
1034    }
1035
1036    #[test]
1037    fn test_generate_volume_history() {
1038        let now = 1700000000;
1039        let history = DexClient::generate_volume_history(24000.0, 6000.0, 1000.0, now);
1040
1041        assert_eq!(history.len(), 24);
1042        assert!(history.iter().all(|v| v.volume >= 0.0));
1043        assert!(history.iter().all(|v| v.timestamp <= now));
1044    }
1045
1046    #[test]
1047    fn test_dex_client_default() {
1048        let _client = DexClient::default();
1049        // Just verify it doesn't panic
1050    }
1051
1052    #[test]
1053    fn test_interpolate_points() {
1054        let mut history = vec![
1055            PricePoint {
1056                timestamp: 0,
1057                price: 1.0,
1058            },
1059            PricePoint {
1060                timestamp: 100,
1061                price: 2.0,
1062            },
1063        ];
1064
1065        DexClient::interpolate_points(&mut history, 10);
1066
1067        assert!(history.len() > 2);
1068        // Check midpoint was added
1069        assert!(history.iter().any(|p| p.timestamp == 50));
1070    }
1071
1072    // ========================================================================
1073    // HTTP mocking tests
1074    // ========================================================================
1075
1076    #[tokio::test]
1077    async fn test_get_token_data_success() {
1078        let mut server = mockito::Server::new_async().await;
1079        let pair = build_test_pair_json("ethereum", "WETH", "0xtoken", "2500.50");
1080        let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1081        let _mock = server
1082            .mock(
1083                "GET",
1084                mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1085            )
1086            .with_status(200)
1087            .with_header("content-type", "application/json")
1088            .with_body(&body)
1089            .create_async()
1090            .await;
1091
1092        let client = DexClient::with_base_url(&server.url());
1093        let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1094        assert_eq!(data.symbol, "WETH");
1095        assert!((data.price_usd - 2500.50).abs() < 0.01);
1096        assert!(data.volume_24h > 0.0);
1097        assert!(data.liquidity_usd > 0.0);
1098        assert_eq!(data.pairs.len(), 1);
1099        assert!(data.total_buys_24h > 0);
1100        assert!(data.total_sells_24h > 0);
1101        assert!(!data.price_history.is_empty());
1102        assert!(!data.volume_history.is_empty());
1103    }
1104
1105    #[tokio::test]
1106    async fn test_get_token_data_no_pairs() {
1107        let mut server = mockito::Server::new_async().await;
1108        let _mock = server
1109            .mock(
1110                "GET",
1111                mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1112            )
1113            .with_status(200)
1114            .with_header("content-type", "application/json")
1115            .with_body(r#"{"pairs":[]}"#)
1116            .create_async()
1117            .await;
1118
1119        let client = DexClient::with_base_url(&server.url());
1120        let result = client.get_token_data("ethereum", "0xunknown").await;
1121        assert!(result.is_err());
1122        assert!(result.unwrap_err().to_string().contains("No DEX pairs"));
1123    }
1124
1125    #[tokio::test]
1126    async fn test_get_token_data_api_error() {
1127        let mut server = mockito::Server::new_async().await;
1128        let _mock = server
1129            .mock(
1130                "GET",
1131                mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1132            )
1133            .with_status(500)
1134            .create_async()
1135            .await;
1136
1137        let client = DexClient::with_base_url(&server.url());
1138        let result = client.get_token_data("ethereum", "0xtoken").await;
1139        assert!(result.is_err());
1140    }
1141
1142    #[tokio::test]
1143    async fn test_get_token_data_fallback_to_all_pairs() {
1144        // When no chain-specific pairs found, should use all pairs
1145        let mut server = mockito::Server::new_async().await;
1146        let pair = build_test_pair_json("bsc", "TOKEN", "0xtoken", "1.00");
1147        let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1148        let _mock = server
1149            .mock(
1150                "GET",
1151                mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1152            )
1153            .with_status(200)
1154            .with_header("content-type", "application/json")
1155            .with_body(&body)
1156            .create_async()
1157            .await;
1158
1159        let client = DexClient::with_base_url(&server.url());
1160        // Request for ethereum but pair is on bsc → should still get data
1161        let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1162        assert_eq!(data.symbol, "TOKEN");
1163    }
1164
1165    #[tokio::test]
1166    async fn test_get_token_data_multiple_pairs() {
1167        let mut server = mockito::Server::new_async().await;
1168        let pair1 = build_test_pair_json("ethereum", "WETH", "0xtoken", "2500.00");
1169        let pair2 = build_test_pair_json("ethereum", "WETH", "0xtoken", "2501.00");
1170        let body = format!(r#"{{"pairs":[{},{}]}}"#, pair1, pair2);
1171        let _mock = server
1172            .mock(
1173                "GET",
1174                mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1175            )
1176            .with_status(200)
1177            .with_header("content-type", "application/json")
1178            .with_body(&body)
1179            .create_async()
1180            .await;
1181
1182        let client = DexClient::with_base_url(&server.url());
1183        let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1184        assert_eq!(data.pairs.len(), 2);
1185        // Price should be liquidity-weighted average
1186        assert!(data.price_usd > 2499.0 && data.price_usd < 2502.0);
1187    }
1188
1189    #[tokio::test]
1190    async fn test_get_token_price() {
1191        let mut server = mockito::Server::new_async().await;
1192        let pair = build_test_pair_json("ethereum", "WETH", "0xtoken", "2500.50");
1193        let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1194        let _mock = server
1195            .mock(
1196                "GET",
1197                mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1198            )
1199            .with_status(200)
1200            .with_header("content-type", "application/json")
1201            .with_body(&body)
1202            .create_async()
1203            .await;
1204
1205        let client = DexClient::with_base_url(&server.url());
1206        let price = client.get_token_price("ethereum", "0xtoken").await;
1207        assert!(price.is_some());
1208        assert!((price.unwrap() - 2500.50).abs() < 0.01);
1209    }
1210
1211    #[tokio::test]
1212    async fn test_get_token_price_not_found() {
1213        let mut server = mockito::Server::new_async().await;
1214        let _mock = server
1215            .mock(
1216                "GET",
1217                mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1218            )
1219            .with_status(200)
1220            .with_header("content-type", "application/json")
1221            .with_body(r#"{"pairs":null}"#)
1222            .create_async()
1223            .await;
1224
1225        let client = DexClient::with_base_url(&server.url());
1226        let price = client.get_token_price("ethereum", "0xunknown").await;
1227        assert!(price.is_none());
1228    }
1229
1230    #[tokio::test]
1231    async fn test_search_tokens_success() {
1232        let mut server = mockito::Server::new_async().await;
1233        let pair = build_test_pair_json("ethereum", "USDC", "0xusdc", "1.00");
1234        let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1235        let _mock = server
1236            .mock(
1237                "GET",
1238                mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1239            )
1240            .with_status(200)
1241            .with_header("content-type", "application/json")
1242            .with_body(&body)
1243            .create_async()
1244            .await;
1245
1246        let client = DexClient::with_base_url(&server.url());
1247        let results = client.search_tokens("USDC", None).await.unwrap();
1248        assert!(!results.is_empty());
1249        assert_eq!(results[0].symbol, "USDC");
1250    }
1251
1252    #[tokio::test]
1253    async fn test_search_tokens_with_chain_filter() {
1254        let mut server = mockito::Server::new_async().await;
1255        let pair_eth = build_test_pair_json("ethereum", "USDC", "0xusdc_eth", "1.00");
1256        let pair_bsc = build_test_pair_json("bsc", "USDC", "0xusdc_bsc", "1.00");
1257        let body = format!(r#"{{"pairs":[{},{}]}}"#, pair_eth, pair_bsc);
1258        let _mock = server
1259            .mock(
1260                "GET",
1261                mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1262            )
1263            .with_status(200)
1264            .with_header("content-type", "application/json")
1265            .with_body(&body)
1266            .create_async()
1267            .await;
1268
1269        let client = DexClient::with_base_url(&server.url());
1270        let results = client
1271            .search_tokens("USDC", Some("ethereum"))
1272            .await
1273            .unwrap();
1274        assert_eq!(results.len(), 1);
1275        assert_eq!(results[0].chain, "ethereum");
1276    }
1277
1278    #[tokio::test]
1279    async fn test_search_tokens_empty() {
1280        let mut server = mockito::Server::new_async().await;
1281        let _mock = server
1282            .mock(
1283                "GET",
1284                mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1285            )
1286            .with_status(200)
1287            .with_header("content-type", "application/json")
1288            .with_body(r#"{"pairs":[]}"#)
1289            .create_async()
1290            .await;
1291
1292        let client = DexClient::with_base_url(&server.url());
1293        let results = client.search_tokens("XYZNONEXIST", None).await.unwrap();
1294        assert!(results.is_empty());
1295    }
1296
1297    #[tokio::test]
1298    async fn test_search_tokens_api_error() {
1299        let mut server = mockito::Server::new_async().await;
1300        let _mock = server
1301            .mock(
1302                "GET",
1303                mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1304            )
1305            .with_status(429)
1306            .create_async()
1307            .await;
1308
1309        let client = DexClient::with_base_url(&server.url());
1310        let result = client.search_tokens("USDC", None).await;
1311        assert!(result.is_err());
1312    }
1313
1314    #[test]
1315    fn test_generate_price_history() {
1316        let pair_json = r#"{
1317            "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair",
1318            "baseToken":{"address":"0xtoken","name":"Token","symbol":"TKN"},
1319            "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1320            "priceUsd":"100.0",
1321            "priceChange":{"h24":10.0,"h6":5.0,"h1":1.0,"m5":0.5}
1322        }"#;
1323        let pair: DexScreenerPair = serde_json::from_str(pair_json).unwrap();
1324        let history = DexClient::generate_price_history(100.0, &pair, 1700000000);
1325        assert!(!history.is_empty());
1326        // Last point should be current price
1327        assert!(history.iter().any(|p| (p.price - 100.0).abs() < 0.001));
1328    }
1329
1330    #[test]
1331    fn test_chain_mapping_all_variants() {
1332        // Test all known chains
1333        assert_eq!(DexClient::map_chain_to_dexscreener("eth"), "ethereum");
1334        assert_eq!(DexClient::map_chain_to_dexscreener("matic"), "polygon");
1335        assert_eq!(DexClient::map_chain_to_dexscreener("arb"), "arbitrum");
1336        assert_eq!(DexClient::map_chain_to_dexscreener("op"), "optimism");
1337        assert_eq!(DexClient::map_chain_to_dexscreener("base"), "base");
1338        assert_eq!(DexClient::map_chain_to_dexscreener("bnb"), "bsc");
1339        assert_eq!(DexClient::map_chain_to_dexscreener("sol"), "solana");
1340        assert_eq!(DexClient::map_chain_to_dexscreener("avax"), "avalanche");
1341        assert_eq!(DexClient::map_chain_to_dexscreener("unknown"), "unknown");
1342    }
1343
1344    #[tokio::test]
1345    async fn test_get_native_token_price_ethereum() {
1346        let mut server = mockito::Server::new_async().await;
1347        let _mock = server
1348            .mock("GET", mockito::Matcher::Any)
1349            .with_status(200)
1350            .with_header("content-type", "application/json")
1351            .with_body(r#"{"pairs":[{
1352                "chainId":"ethereum",
1353                "dexId":"uniswap",
1354                "pairAddress":"0xpair",
1355                "baseToken":{"address":"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2","name":"WETH","symbol":"WETH"},
1356                "quoteToken":{"address":"0xusdt","name":"USDT","symbol":"USDT"},
1357                "priceUsd":"3500.00"
1358            }]}"#)
1359            .create_async()
1360            .await;
1361
1362        let client = DexClient::with_base_url(&server.url());
1363        let price = client.get_native_token_price("ethereum").await;
1364        assert!(price.is_some());
1365        assert!((price.unwrap() - 3500.0).abs() < 0.01);
1366    }
1367
1368    #[tokio::test]
1369    async fn test_get_native_token_price_tron_returns_none() {
1370        let client = DexClient::with_base_url("http://localhost:1");
1371        let price = client.get_native_token_price("tron").await;
1372        assert!(price.is_none());
1373    }
1374
1375    #[tokio::test]
1376    async fn test_get_native_token_price_unknown_chain() {
1377        let client = DexClient::with_base_url("http://localhost:1");
1378        let price = client.get_native_token_price("unknownchain").await;
1379        assert!(price.is_none());
1380    }
1381
1382    #[tokio::test]
1383    async fn test_search_tokens_chain_filter_ethereum_only() {
1384        let mut server = mockito::Server::new_async().await;
1385        let _mock = server
1386            .mock("GET", mockito::Matcher::Any)
1387            .with_status(200)
1388            .with_header("content-type", "application/json")
1389            .with_body(
1390                r#"{"pairs":[
1391                {
1392                    "chainId":"ethereum",
1393                    "dexId":"uniswap",
1394                    "pairAddress":"0xpair1",
1395                    "baseToken":{"address":"0xtoken1","name":"USD Coin","symbol":"USDC"},
1396                    "quoteToken":{"address":"0xweth","name":"WETH","symbol":"WETH"},
1397                    "priceUsd":"1.00",
1398                    "liquidity":{"usd":5000000.0},
1399                    "volume":{"h24":1000000.0}
1400                },
1401                {
1402                    "chainId":"bsc",
1403                    "dexId":"pancakeswap",
1404                    "pairAddress":"0xpair2",
1405                    "baseToken":{"address":"0xtoken2","name":"Binance USD","symbol":"BUSD"},
1406                    "quoteToken":{"address":"0xbnb","name":"BNB","symbol":"BNB"},
1407                    "priceUsd":"1.00",
1408                    "liquidity":{"usd":2000000.0},
1409                    "volume":{"h24":500000.0}
1410                }
1411            ]}"#,
1412            )
1413            .create_async()
1414            .await;
1415
1416        let client = DexClient::with_base_url(&server.url());
1417        // Filter to ethereum only
1418        let results = client.search_tokens("USD", Some("ethereum")).await.unwrap();
1419        assert!(!results.is_empty());
1420        // All results should be on ethereum
1421        for r in &results {
1422            assert_eq!(r.chain.to_lowercase(), "ethereum");
1423        }
1424    }
1425
1426    #[tokio::test]
1427    async fn test_search_tokens_aggregates_volume_and_liquidity() {
1428        let mut server = mockito::Server::new_async().await;
1429        let _mock = server
1430            .mock("GET", mockito::Matcher::Any)
1431            .with_status(200)
1432            .with_header("content-type", "application/json")
1433            .with_body(
1434                r#"{"pairs":[
1435                {
1436                    "chainId":"ethereum",
1437                    "dexId":"uniswap",
1438                    "pairAddress":"0xpair1",
1439                    "baseToken":{"address":"0xSameToken","name":"Test Token","symbol":"TEST"},
1440                    "quoteToken":{"address":"0xweth","name":"WETH","symbol":"WETH"},
1441                    "priceUsd":"10.00",
1442                    "liquidity":{"usd":1000000.0},
1443                    "volume":{"h24":100000.0}
1444                },
1445                {
1446                    "chainId":"ethereum",
1447                    "dexId":"sushiswap",
1448                    "pairAddress":"0xpair2",
1449                    "baseToken":{"address":"0xSameToken","name":"Test Token","symbol":"TEST"},
1450                    "quoteToken":{"address":"0xusdc","name":"USDC","symbol":"USDC"},
1451                    "priceUsd":"10.05",
1452                    "liquidity":{"usd":500000.0},
1453                    "volume":{"h24":50000.0}
1454                }
1455            ]}"#,
1456            )
1457            .create_async()
1458            .await;
1459
1460        let client = DexClient::with_base_url(&server.url());
1461        let results = client.search_tokens("TEST", None).await.unwrap();
1462        assert_eq!(results.len(), 1); // Same token aggregated
1463        // Volume and liquidity should be summed
1464        assert!(results[0].volume_24h > 100000.0);
1465        assert!(results[0].liquidity_usd > 1000000.0);
1466    }
1467
1468    #[tokio::test]
1469    async fn test_dex_data_source_trait_methods() {
1470        let mut server = mockito::Server::new_async().await;
1471        let _mock = server
1472            .mock("GET", mockito::Matcher::Any)
1473            .with_status(200)
1474            .with_header("content-type", "application/json")
1475            .with_body(
1476                r#"{"pairs":[{
1477                "chainId":"ethereum",
1478                "dexId":"uniswap",
1479                "pairAddress":"0xpair",
1480                "baseToken":{"address":"0xtoken","name":"Token","symbol":"TKN"},
1481                "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1482                "priceUsd":"50.0",
1483                "liquidity":{"usd":1000000.0},
1484                "volume":{"h24":100000.0}
1485            }]}"#,
1486            )
1487            .create_async()
1488            .await;
1489
1490        let client = DexClient::with_base_url(&server.url());
1491        // Test through DexDataSource trait
1492        let trait_client: &dyn DexDataSource = &client;
1493        let price = trait_client.get_token_price("ethereum", "0xtoken").await;
1494        assert!(price.is_some());
1495    }
1496
1497    #[tokio::test]
1498    async fn test_dex_data_source_trait_get_native_token_price() {
1499        let mut server = mockito::Server::new_async().await;
1500        let _mock = server
1501            .mock("GET", mockito::Matcher::Any)
1502            .with_status(200)
1503            .with_header("content-type", "application/json")
1504            .with_body(
1505                r#"{"pairs":[{
1506                "chainId":"ethereum",
1507                "dexId":"uniswap",
1508                "pairAddress":"0xpair",
1509                "baseToken":{"address":"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2","name":"WETH","symbol":"WETH"},
1510                "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1511                "priceUsd":"3500.0",
1512                "liquidity":{"usd":10000000.0},
1513                "volume":{"h24":5000000.0}
1514            }]}"#,
1515            )
1516            .create_async()
1517            .await;
1518
1519        let client = DexClient::with_base_url(&server.url());
1520        let trait_client: &dyn DexDataSource = &client;
1521        let price = trait_client.get_native_token_price("ethereum").await;
1522        assert!(price.is_some());
1523    }
1524
1525    #[tokio::test]
1526    async fn test_dex_data_source_trait_get_token_data() {
1527        let mut server = mockito::Server::new_async().await;
1528        let pair_json = build_test_pair_json("ethereum", "TKN", "0xtoken", "50.0");
1529        let _mock = server
1530            .mock("GET", mockito::Matcher::Any)
1531            .with_status(200)
1532            .with_header("content-type", "application/json")
1533            .with_body(format!(r#"{{"pairs":[{}]}}"#, pair_json))
1534            .create_async()
1535            .await;
1536
1537        let client = DexClient::with_base_url(&server.url());
1538        let trait_client: &dyn DexDataSource = &client;
1539        let data = trait_client.get_token_data("ethereum", "0xtoken").await;
1540        assert!(data.is_ok());
1541    }
1542
1543    #[tokio::test]
1544    async fn test_dex_data_source_trait_search_tokens() {
1545        let mut server = mockito::Server::new_async().await;
1546        let pair_json = build_test_pair_json("ethereum", "TKN", "0xtoken", "50.0");
1547        let _mock = server
1548            .mock("GET", mockito::Matcher::Any)
1549            .with_status(200)
1550            .with_header("content-type", "application/json")
1551            .with_body(format!(r#"{{"pairs":[{}]}}"#, pair_json))
1552            .create_async()
1553            .await;
1554
1555        let client = DexClient::with_base_url(&server.url());
1556        let trait_client: &dyn DexDataSource = &client;
1557        let results = trait_client.search_tokens("TKN", None).await;
1558        assert!(results.is_ok());
1559    }
1560
1561    #[tokio::test]
1562    async fn test_get_token_data_quote_token() {
1563        let mut server = mockito::Server::new_async().await;
1564        // Token is the quote token, not the base
1565        let _mock = server
1566            .mock("GET", mockito::Matcher::Any)
1567            .with_status(200)
1568            .with_header("content-type", "application/json")
1569            .with_body(
1570                r#"{"pairs":[{
1571                "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair",
1572                "baseToken":{"address":"0xother","name":"Other","symbol":"OTH"},
1573                "quoteToken":{"address":"0xmytoken","name":"MyToken","symbol":"MTK"},
1574                "priceUsd":"25.0",
1575                "priceChange":{"h24":1.0,"h6":0.5,"h1":0.2,"m5":0.05},
1576                "volume":{"h24":500000,"h6":100000,"h1":20000,"m5":2000},
1577                "liquidity":{"usd":0,"base":0,"quote":0},
1578                "txns":{"h24":{"buys":50,"sells":40},"h6":{"buys":10,"sells":8},"h1":{"buys":2,"sells":1}},
1579                "pairCreatedAt":1690000000000,
1580                "url":"https://dexscreener.com/ethereum/0xpair"
1581            }]}"#,
1582            )
1583            .create_async()
1584            .await;
1585
1586        let client = DexClient::with_base_url(&server.url());
1587        let data = client
1588            .get_token_data("ethereum", "0xmytoken")
1589            .await
1590            .unwrap();
1591        // Should identify the quote token
1592        assert_eq!(data.symbol, "MTK");
1593        assert_eq!(data.name, "MyToken");
1594        // Zero liquidity fallback for price: should use priceUsd from first pair
1595        assert!(data.price_usd > 0.0);
1596    }
1597
1598    #[tokio::test]
1599    async fn test_get_token_data_with_socials() {
1600        let mut server = mockito::Server::new_async().await;
1601        let _mock = server
1602            .mock("GET", mockito::Matcher::Any)
1603            .with_status(200)
1604            .with_header("content-type", "application/json")
1605            .with_body(
1606                r#"{"pairs":[{
1607                "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair",
1608                "baseToken":{"address":"0xtoken","name":"Token","symbol":"TKN"},
1609                "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1610                "priceUsd":"50.0",
1611                "priceChange":{"h24":5.0,"h6":2.0,"h1":1.0,"m5":0.1},
1612                "volume":{"h24":1000000,"h6":250000,"h1":50000,"m5":5000},
1613                "liquidity":{"usd":1000000,"base":100,"quote":1000000},
1614                "txns":{"h24":{"buys":100,"sells":80},"h6":{"buys":20,"sells":15},"h1":{"buys":5,"sells":3}},
1615                "pairCreatedAt":1690000000000,
1616                "url":"https://dexscreener.com/ethereum/0xpair",
1617                "info":{
1618                    "imageUrl":"https://example.com/logo.png",
1619                    "websites":[{"url":"https://example.com"}],
1620                    "socials":[
1621                        {"type":"twitter","url":"https://twitter.com/token"},
1622                        {"type":"telegram","url":"https://t.me/token"}
1623                    ]
1624                }
1625            }]}"#,
1626            )
1627            .create_async()
1628            .await;
1629
1630        let client = DexClient::with_base_url(&server.url());
1631        let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1632        assert_eq!(data.symbol, "TKN");
1633        assert!(data.image_url.is_some());
1634        assert!(!data.websites.is_empty());
1635        assert!(!data.socials.is_empty());
1636        assert_eq!(data.socials[0].platform, "twitter");
1637    }
1638
1639    #[tokio::test]
1640    async fn test_search_tokens_quote_match_and_updates() {
1641        let mut server = mockito::Server::new_async().await;
1642        // Token matches as quote, not base
1643        let _mock = server
1644            .mock("GET", mockito::Matcher::Any)
1645            .with_status(200)
1646            .with_header("content-type", "application/json")
1647            .with_body(
1648                r#"{"pairs":[
1649                {
1650                    "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair1",
1651                    "baseToken":{"address":"0xother","name":"Other","symbol":"OTH"},
1652                    "quoteToken":{"address":"0xmytk","name":"MySearch","symbol":"MSR"},
1653                    "liquidity":{"usd":500000.0},
1654                    "volume":{"h24":100000.0},
1655                    "marketCap":5000000
1656                },
1657                {
1658                    "chainId":"ethereum","dexId":"sushi","pairAddress":"0xpair2",
1659                    "baseToken":{"address":"0xmytk","name":"MySearch","symbol":"MSR"},
1660                    "quoteToken":{"address":"0xweth","name":"WETH","symbol":"WETH"},
1661                    "priceUsd":"10.5",
1662                    "liquidity":{"usd":800000.0},
1663                    "volume":{"h24":200000.0}
1664                }
1665            ]}"#,
1666            )
1667            .create_async()
1668            .await;
1669
1670        let client = DexClient::with_base_url(&server.url());
1671        let results = client.search_tokens("MySearch", None).await.unwrap();
1672        assert_eq!(results.len(), 1); // Same token aggregated
1673        assert_eq!(results[0].symbol, "MSR");
1674        // Volume should be aggregated
1675        assert!(results[0].volume_24h >= 300000.0);
1676        // Liquidity should be aggregated
1677        assert!(results[0].liquidity_usd >= 1300000.0);
1678        // Price should be set from the second pair
1679        assert!(results[0].price_usd.is_some());
1680        // Market cap should be carried from first pair
1681        assert!(results[0].market_cap.is_some());
1682    }
1683
1684    #[test]
1685    fn test_interpolate_points_midpoint() {
1686        let mut history = vec![
1687            PricePoint {
1688                timestamp: 1000,
1689                price: 10.0,
1690            },
1691            PricePoint {
1692                timestamp: 2000,
1693                price: 20.0,
1694            },
1695        ];
1696        // Should not interpolate if already enough points
1697        DexClient::interpolate_points(&mut history, 2);
1698        assert_eq!(history.len(), 2);
1699
1700        // Should add midpoints
1701        DexClient::interpolate_points(&mut history, 5);
1702        assert!(history.len() > 2);
1703        // Check that a midpoint was added
1704        let midpoints: Vec<_> = history.iter().filter(|p| p.timestamp == 1500).collect();
1705        assert!(!midpoints.is_empty());
1706        assert!((midpoints[0].price - 15.0).abs() < 0.01);
1707    }
1708}