1use 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
50const DEXSCREENER_API_BASE: &str = "https://api.dexscreener.com";
52
53#[async_trait]
57pub trait DexDataSource: Send + Sync {
58 async fn get_token_price(&self, chain: &str, address: &str) -> Option<f64>;
60
61 async fn get_native_token_price(&self, chain: &str) -> Option<f64>;
63
64 async fn get_token_data(&self, chain: &str, address: &str) -> Result<DexTokenData>;
66
67 async fn search_tokens(
69 &self,
70 query: &str,
71 chain: Option<&str>,
72 ) -> Result<Vec<TokenSearchResult>>;
73}
74
75#[derive(Debug, Clone)]
77pub struct DexClient {
78 http: Client,
79 base_url: String,
80}
81
82#[derive(Debug, Deserialize)]
84struct DexScreenerTokenResponse {
85 pairs: Option<Vec<DexScreenerPair>>,
86}
87
88#[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 #[serde(default)]
111 url: Option<String>,
112 #[serde(default)]
114 pair_created_at: Option<i64>,
115 #[serde(default)]
117 txns: Option<DexScreenerTxns>,
118 #[serde(default)]
120 info: Option<DexScreenerInfo>,
121}
122
123#[derive(Debug, Deserialize)]
125struct DexScreenerToken {
126 address: String,
127 name: String,
128 symbol: String,
129}
130
131#[derive(Debug, Deserialize)]
133struct DexScreenerPriceChange {
134 h24: Option<f64>,
135 h6: Option<f64>,
136 h1: Option<f64>,
137 m5: Option<f64>,
138}
139
140#[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#[derive(Debug, Deserialize)]
152#[allow(dead_code)]
153struct DexScreenerLiquidity {
154 usd: Option<f64>,
155 base: Option<f64>,
156 quote: Option<f64>,
157}
158
159#[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#[derive(Debug, Deserialize, Clone, Default)]
175struct TxnCounts {
176 #[serde(default)]
177 buys: u64,
178 #[serde(default)]
179 sells: u64,
180}
181
182#[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#[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#[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#[derive(Debug, Clone)]
215pub struct DexTokenData {
216 pub address: String,
218
219 pub symbol: String,
221
222 pub name: String,
224
225 pub price_usd: f64,
227
228 pub price_change_24h: f64,
230
231 pub price_change_6h: f64,
233
234 pub price_change_1h: f64,
236
237 pub price_change_5m: f64,
239
240 pub volume_24h: f64,
242
243 pub volume_6h: f64,
245
246 pub volume_1h: f64,
248
249 pub liquidity_usd: f64,
251
252 pub market_cap: Option<f64>,
254
255 pub fdv: Option<f64>,
257
258 pub pairs: Vec<DexPair>,
260
261 pub price_history: Vec<PricePoint>,
263
264 pub volume_history: Vec<VolumePoint>,
266
267 pub total_buys_24h: u64,
269
270 pub total_sells_24h: u64,
272
273 pub total_buys_6h: u64,
275
276 pub total_sells_6h: u64,
278
279 pub total_buys_1h: u64,
281
282 pub total_sells_1h: u64,
284
285 pub earliest_pair_created_at: Option<i64>,
287
288 pub image_url: Option<String>,
290
291 pub websites: Vec<String>,
293
294 pub socials: Vec<TokenSocial>,
296
297 pub dexscreener_url: Option<String>,
299}
300
301#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
303pub struct TokenSocial {
304 pub platform: String,
306 pub url: String,
308}
309
310#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
312pub struct TokenSearchResult {
313 pub address: String,
315
316 pub symbol: String,
318
319 pub name: String,
321
322 pub chain: String,
324
325 pub price_usd: Option<f64>,
327
328 pub volume_24h: f64,
330
331 pub liquidity_usd: f64,
333
334 pub market_cap: Option<f64>,
336}
337
338#[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#[derive(Debug, Deserialize)]
357struct DexScreenerSearchResponse {
358 pairs: Option<Vec<DexScreenerPair>>,
359}
360
361impl DexClient {
362 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 #[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 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 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 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"), "polygon" | "matic" => ("polygon", "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270"), "arbitrum" | "arb" => ("arbitrum", "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), "optimism" | "op" => ("optimism", "0x4200000000000000000000000000000000000006"), "base" => ("base", "0x4200000000000000000000000000000000000006"), "bsc" | "bnb" => ("bsc", "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c"), "solana" | "sol" => ("solana", "So11111111111111111111111111111111111111112"), "tron" | "trx" => return None, _ => return None,
433 };
434
435 self.get_token_price(search_chain, token_address).await
436 }
437
438 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 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 let relevant_pairs = if chain_pairs.is_empty() {
490 pairs.iter().collect()
491 } else {
492 chain_pairs
493 };
494
495 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 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 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 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 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 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 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 let earliest_pair_created_at = dex_pairs.iter().filter_map(|p| p.pair_created_at).min();
625
626 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 let now = chrono::Utc::now().timestamp();
653 let price_history = Self::generate_price_history(avg_price, best_pair, now);
654
655 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 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 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 let mut token_map: std::collections::HashMap<String, TokenSearchResult> =
752 std::collections::HashMap::new();
753
754 for pair in filtered_pairs {
755 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 &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 entry.volume_24h += pair_volume;
807 entry.liquidity_usd += pair_liquidity;
808
809 if entry.price_usd.is_none() && pair_price.is_some() {
811 entry.price_usd = pair_price;
812 }
813
814 if entry.market_cap.is_none() && pair.market_cap.is_some() {
816 entry.market_cap = pair.market_cap;
817 }
818 }
819
820 let query_lower = query.to_lowercase();
822 let mut results: Vec<TokenSearchResult> = token_map.into_values().collect();
823 results.sort_by(|a, b| {
824 let a_exact = a.symbol.to_lowercase() == query_lower;
825 let b_exact = b.symbol.to_lowercase() == query_lower;
826 b_exact.cmp(&a_exact).then(
827 b.liquidity_usd
828 .partial_cmp(&a.liquidity_usd)
829 .unwrap_or(std::cmp::Ordering::Equal),
830 )
831 });
832
833 results.truncate(20);
835
836 Ok(results)
837 }
838
839 pub async fn get_token_profiles(&self) -> Result<Vec<DiscoverToken>> {
841 let url = format!("{}/token-profiles/latest/v1", self.base_url);
842 self.fetch_discover_tokens(&url).await
843 }
844
845 pub async fn get_token_boosts(&self) -> Result<Vec<DiscoverToken>> {
847 let url = format!("{}/token-boosts/latest/v1", self.base_url);
848 self.fetch_discover_tokens(&url).await
849 }
850
851 pub async fn get_token_boosts_top(&self) -> Result<Vec<DiscoverToken>> {
853 let url = format!("{}/token-boosts/top/v1", self.base_url);
854 self.fetch_discover_tokens(&url).await
855 }
856
857 async fn fetch_discover_tokens(&self, url: &str) -> Result<Vec<DiscoverToken>> {
858 let response = self
859 .http
860 .get(url)
861 .send()
862 .await
863 .map_err(|e| ScopeError::Network(e.to_string()))?;
864
865 if !response.status().is_success() {
866 return Err(ScopeError::Api(format!(
867 "DexScreener API error: {}",
868 response.status()
869 )));
870 }
871
872 #[derive(Deserialize)]
873 struct TokenProfileRaw {
874 url: Option<String>,
875 #[serde(rename = "chainId")]
876 chain_id: Option<String>,
877 #[serde(rename = "tokenAddress")]
878 token_address: Option<String>,
879 description: Option<String>,
880 links: Option<Vec<LinkRaw>>,
881 }
882
883 #[derive(Deserialize)]
884 struct LinkRaw {
885 label: Option<String>,
886 #[serde(rename = "type")]
887 link_type: Option<String>,
888 url: Option<String>,
889 }
890
891 let raw: Vec<TokenProfileRaw> = response
892 .json()
893 .await
894 .map_err(|e| ScopeError::Api(format!("Failed to parse response: {}", e)))?;
895
896 let tokens: Vec<DiscoverToken> = raw
897 .into_iter()
898 .filter_map(|r| {
899 let token_address = r.token_address?;
900 let chain_id = r.chain_id.clone().unwrap_or_else(|| "unknown".to_string());
901 let url = r.url.clone().unwrap_or_else(|| {
902 format!("https://dexscreener.com/{}/{}", chain_id, token_address)
903 });
904 let links: Vec<DiscoverLink> = r
905 .links
906 .unwrap_or_default()
907 .into_iter()
908 .filter_map(|l| {
909 let url = l.url?;
910 Some(DiscoverLink {
911 label: l.label,
912 link_type: l.link_type,
913 url,
914 })
915 })
916 .collect();
917
918 Some(DiscoverToken {
919 chain_id,
920 token_address,
921 url,
922 description: r.description,
923 links,
924 })
925 })
926 .collect();
927
928 Ok(tokens)
929 }
930
931 fn generate_price_history(
933 current_price: f64,
934 pair: &DexScreenerPair,
935 now: i64,
936 ) -> Vec<PricePoint> {
937 let mut history = Vec::new();
938
939 let changes = pair.price_change.as_ref();
941 let change_24h = changes.and_then(|c| c.h24).unwrap_or(0.0) / 100.0;
942 let change_6h = changes.and_then(|c| c.h6).unwrap_or(0.0) / 100.0;
943 let change_1h = changes.and_then(|c| c.h1).unwrap_or(0.0) / 100.0;
944 let change_5m = changes.and_then(|c| c.m5).unwrap_or(0.0) / 100.0;
945
946 let price_24h_ago = current_price / (1.0 + change_24h);
948 let price_6h_ago = current_price / (1.0 + change_6h);
949 let price_1h_ago = current_price / (1.0 + change_1h);
950 let price_5m_ago = current_price / (1.0 + change_5m);
951
952 history.push(PricePoint {
954 timestamp: now - 86400, price: price_24h_ago,
956 });
957 history.push(PricePoint {
958 timestamp: now - 21600, price: price_6h_ago,
960 });
961 history.push(PricePoint {
962 timestamp: now - 3600, price: price_1h_ago,
964 });
965 history.push(PricePoint {
966 timestamp: now - 300, price: price_5m_ago,
968 });
969 history.push(PricePoint {
970 timestamp: now,
971 price: current_price,
972 });
973
974 Self::interpolate_points(&mut history, 24);
976
977 history.sort_by_key(|p| p.timestamp);
978 history
979 }
980
981 fn generate_volume_history(
983 volume_24h: f64,
984 volume_6h: f64,
985 volume_1h: f64,
986 now: i64,
987 ) -> Vec<VolumePoint> {
988 let mut history = Vec::new();
989
990 let hourly_avg = volume_24h / 24.0;
992
993 for i in 0..24 {
994 let timestamp = now - (23 - i) * 3600;
995 let hours_ago = 24 - i;
996
997 let volume = if hours_ago <= 1 {
999 volume_1h
1000 } else if hours_ago <= 6 {
1001 volume_6h / 6.0
1002 } else {
1003 hourly_avg * (0.8 + (i as f64 / 24.0) * 0.4)
1005 };
1006
1007 history.push(VolumePoint { timestamp, volume });
1008 }
1009
1010 history
1011 }
1012
1013 fn interpolate_points(history: &mut Vec<PricePoint>, target_count: usize) {
1015 if history.len() >= target_count {
1016 return;
1017 }
1018
1019 history.sort_by_key(|p| p.timestamp);
1020
1021 let mut interpolated = Vec::new();
1022 for window in history.windows(2) {
1023 let p1 = &window[0];
1024 let p2 = &window[1];
1025
1026 interpolated.push(p1.clone());
1027
1028 let mid_timestamp = (p1.timestamp + p2.timestamp) / 2;
1030 let mid_price = (p1.price + p2.price) / 2.0;
1031 interpolated.push(PricePoint {
1032 timestamp: mid_timestamp,
1033 price: mid_price,
1034 });
1035 }
1036
1037 if let Some(last) = history.last() {
1038 interpolated.push(last.clone());
1039 }
1040
1041 *history = interpolated;
1042 }
1043
1044 pub fn estimate_7d_volume(volume_24h: f64) -> f64 {
1049 volume_24h * 7.0
1051 }
1052}
1053
1054impl Default for DexClient {
1055 fn default() -> Self {
1056 Self::new()
1057 }
1058}
1059
1060#[async_trait]
1065impl DexDataSource for DexClient {
1066 async fn get_token_price(&self, chain: &str, address: &str) -> Option<f64> {
1067 self.get_token_price(chain, address).await
1068 }
1069
1070 async fn get_native_token_price(&self, chain: &str) -> Option<f64> {
1071 self.get_native_token_price(chain).await
1072 }
1073
1074 async fn get_token_data(&self, chain: &str, address: &str) -> Result<DexTokenData> {
1075 self.get_token_data(chain, address).await
1076 }
1077
1078 async fn search_tokens(
1079 &self,
1080 query: &str,
1081 chain: Option<&str>,
1082 ) -> Result<Vec<TokenSearchResult>> {
1083 self.search_tokens(query, chain).await
1084 }
1085}
1086
1087#[cfg(test)]
1089fn build_test_pair_json(chain_id: &str, base_symbol: &str, base_addr: &str, price: &str) -> String {
1090 format!(
1091 r#"{{
1092 "chainId":"{}","dexId":"uniswap","pairAddress":"0xpair",
1093 "baseToken":{{"address":"{}","name":"{}","symbol":"{}"}},
1094 "quoteToken":{{"address":"0xquote","name":"USDC","symbol":"USDC"}},
1095 "priceUsd":"{}",
1096 "priceChange":{{"h24":5.2,"h6":2.1,"h1":0.5,"m5":0.1}},
1097 "volume":{{"h24":1000000,"h6":250000,"h1":50000,"m5":5000}},
1098 "liquidity":{{"usd":500000,"base":100,"quote":500000}},
1099 "fdv":10000000,"marketCap":8000000,
1100 "txns":{{"h24":{{"buys":100,"sells":80}},"h6":{{"buys":20,"sells":15}},"h1":{{"buys":5,"sells":3}}}},
1101 "pairCreatedAt":1690000000000,
1102 "url":"https://dexscreener.com/ethereum/0xpair"
1103 }}"#,
1104 chain_id, base_addr, base_symbol, base_symbol, price
1105 )
1106}
1107
1108#[cfg(test)]
1113mod tests {
1114 use super::*;
1115
1116 #[test]
1117 fn test_chain_mapping() {
1118 assert_eq!(
1119 DexClient::map_chain_to_dexscreener("ethereum"),
1120 "ethereum".to_string()
1121 );
1122 assert_eq!(
1123 DexClient::map_chain_to_dexscreener("ETH"),
1124 "ethereum".to_string()
1125 );
1126 assert_eq!(
1127 DexClient::map_chain_to_dexscreener("bsc"),
1128 "bsc".to_string()
1129 );
1130 assert_eq!(
1131 DexClient::map_chain_to_dexscreener("BNB"),
1132 "bsc".to_string()
1133 );
1134 assert_eq!(
1135 DexClient::map_chain_to_dexscreener("polygon"),
1136 "polygon".to_string()
1137 );
1138 assert_eq!(
1139 DexClient::map_chain_to_dexscreener("solana"),
1140 "solana".to_string()
1141 );
1142 }
1143
1144 #[test]
1145 fn test_estimate_7d_volume() {
1146 assert_eq!(DexClient::estimate_7d_volume(1_000_000.0), 7_000_000.0);
1147 assert_eq!(DexClient::estimate_7d_volume(0.0), 0.0);
1148 }
1149
1150 #[test]
1151 fn test_generate_volume_history() {
1152 let now = 1700000000;
1153 let history = DexClient::generate_volume_history(24000.0, 6000.0, 1000.0, now);
1154
1155 assert_eq!(history.len(), 24);
1156 assert!(history.iter().all(|v| v.volume >= 0.0));
1157 assert!(history.iter().all(|v| v.timestamp <= now));
1158 }
1159
1160 #[test]
1161 fn test_dex_client_default() {
1162 let _client = DexClient::default();
1163 }
1165
1166 #[test]
1167 fn test_interpolate_points() {
1168 let mut history = vec![
1169 PricePoint {
1170 timestamp: 0,
1171 price: 1.0,
1172 },
1173 PricePoint {
1174 timestamp: 100,
1175 price: 2.0,
1176 },
1177 ];
1178
1179 DexClient::interpolate_points(&mut history, 10);
1180
1181 assert!(history.len() > 2);
1182 assert!(history.iter().any(|p| p.timestamp == 50));
1184 }
1185
1186 #[tokio::test]
1191 async fn test_get_token_data_success() {
1192 let mut server = mockito::Server::new_async().await;
1193 let pair = build_test_pair_json("ethereum", "WETH", "0xtoken", "2500.50");
1194 let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1195 let _mock = server
1196 .mock(
1197 "GET",
1198 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1199 )
1200 .with_status(200)
1201 .with_header("content-type", "application/json")
1202 .with_body(&body)
1203 .create_async()
1204 .await;
1205
1206 let client = DexClient::with_base_url(&server.url());
1207 let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1208 assert_eq!(data.symbol, "WETH");
1209 assert!((data.price_usd - 2500.50).abs() < 0.01);
1210 assert!(data.volume_24h > 0.0);
1211 assert!(data.liquidity_usd > 0.0);
1212 assert_eq!(data.pairs.len(), 1);
1213 assert!(data.total_buys_24h > 0);
1214 assert!(data.total_sells_24h > 0);
1215 assert!(!data.price_history.is_empty());
1216 assert!(!data.volume_history.is_empty());
1217 }
1218
1219 #[tokio::test]
1220 async fn test_get_token_data_no_pairs() {
1221 let mut server = mockito::Server::new_async().await;
1222 let _mock = server
1223 .mock(
1224 "GET",
1225 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1226 )
1227 .with_status(200)
1228 .with_header("content-type", "application/json")
1229 .with_body(r#"{"pairs":[]}"#)
1230 .create_async()
1231 .await;
1232
1233 let client = DexClient::with_base_url(&server.url());
1234 let result = client.get_token_data("ethereum", "0xunknown").await;
1235 assert!(result.is_err());
1236 assert!(result.unwrap_err().to_string().contains("No DEX pairs"));
1237 }
1238
1239 #[tokio::test]
1240 async fn test_get_token_data_api_error() {
1241 let mut server = mockito::Server::new_async().await;
1242 let _mock = server
1243 .mock(
1244 "GET",
1245 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1246 )
1247 .with_status(500)
1248 .create_async()
1249 .await;
1250
1251 let client = DexClient::with_base_url(&server.url());
1252 let result = client.get_token_data("ethereum", "0xtoken").await;
1253 assert!(result.is_err());
1254 }
1255
1256 #[tokio::test]
1257 async fn test_get_token_data_fallback_to_all_pairs() {
1258 let mut server = mockito::Server::new_async().await;
1260 let pair = build_test_pair_json("bsc", "TOKEN", "0xtoken", "1.00");
1261 let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1262 let _mock = server
1263 .mock(
1264 "GET",
1265 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1266 )
1267 .with_status(200)
1268 .with_header("content-type", "application/json")
1269 .with_body(&body)
1270 .create_async()
1271 .await;
1272
1273 let client = DexClient::with_base_url(&server.url());
1274 let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1276 assert_eq!(data.symbol, "TOKEN");
1277 }
1278
1279 #[tokio::test]
1280 async fn test_get_token_data_multiple_pairs() {
1281 let mut server = mockito::Server::new_async().await;
1282 let pair1 = build_test_pair_json("ethereum", "WETH", "0xtoken", "2500.00");
1283 let pair2 = build_test_pair_json("ethereum", "WETH", "0xtoken", "2501.00");
1284 let body = format!(r#"{{"pairs":[{},{}]}}"#, pair1, pair2);
1285 let _mock = server
1286 .mock(
1287 "GET",
1288 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1289 )
1290 .with_status(200)
1291 .with_header("content-type", "application/json")
1292 .with_body(&body)
1293 .create_async()
1294 .await;
1295
1296 let client = DexClient::with_base_url(&server.url());
1297 let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1298 assert_eq!(data.pairs.len(), 2);
1299 assert!(data.price_usd > 2499.0 && data.price_usd < 2502.0);
1301 }
1302
1303 #[tokio::test]
1304 async fn test_get_token_price() {
1305 let mut server = mockito::Server::new_async().await;
1306 let pair = build_test_pair_json("ethereum", "WETH", "0xtoken", "2500.50");
1307 let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1308 let _mock = server
1309 .mock(
1310 "GET",
1311 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1312 )
1313 .with_status(200)
1314 .with_header("content-type", "application/json")
1315 .with_body(&body)
1316 .create_async()
1317 .await;
1318
1319 let client = DexClient::with_base_url(&server.url());
1320 let price = client.get_token_price("ethereum", "0xtoken").await;
1321 assert!(price.is_some());
1322 assert!((price.unwrap() - 2500.50).abs() < 0.01);
1323 }
1324
1325 #[tokio::test]
1326 async fn test_get_token_price_not_found() {
1327 let mut server = mockito::Server::new_async().await;
1328 let _mock = server
1329 .mock(
1330 "GET",
1331 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1332 )
1333 .with_status(200)
1334 .with_header("content-type", "application/json")
1335 .with_body(r#"{"pairs":null}"#)
1336 .create_async()
1337 .await;
1338
1339 let client = DexClient::with_base_url(&server.url());
1340 let price = client.get_token_price("ethereum", "0xunknown").await;
1341 assert!(price.is_none());
1342 }
1343
1344 #[tokio::test]
1345 async fn test_search_tokens_success() {
1346 let mut server = mockito::Server::new_async().await;
1347 let pair = build_test_pair_json("ethereum", "USDC", "0xusdc", "1.00");
1348 let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1349 let _mock = server
1350 .mock(
1351 "GET",
1352 mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1353 )
1354 .with_status(200)
1355 .with_header("content-type", "application/json")
1356 .with_body(&body)
1357 .create_async()
1358 .await;
1359
1360 let client = DexClient::with_base_url(&server.url());
1361 let results = client.search_tokens("USDC", None).await.unwrap();
1362 assert!(!results.is_empty());
1363 assert_eq!(results[0].symbol, "USDC");
1364 }
1365
1366 #[tokio::test]
1367 async fn test_search_tokens_with_chain_filter() {
1368 let mut server = mockito::Server::new_async().await;
1369 let pair_eth = build_test_pair_json("ethereum", "USDC", "0xusdc_eth", "1.00");
1370 let pair_bsc = build_test_pair_json("bsc", "USDC", "0xusdc_bsc", "1.00");
1371 let body = format!(r#"{{"pairs":[{},{}]}}"#, pair_eth, pair_bsc);
1372 let _mock = server
1373 .mock(
1374 "GET",
1375 mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1376 )
1377 .with_status(200)
1378 .with_header("content-type", "application/json")
1379 .with_body(&body)
1380 .create_async()
1381 .await;
1382
1383 let client = DexClient::with_base_url(&server.url());
1384 let results = client
1385 .search_tokens("USDC", Some("ethereum"))
1386 .await
1387 .unwrap();
1388 assert_eq!(results.len(), 1);
1389 assert_eq!(results[0].chain, "ethereum");
1390 }
1391
1392 #[tokio::test]
1393 async fn test_search_tokens_empty() {
1394 let mut server = mockito::Server::new_async().await;
1395 let _mock = server
1396 .mock(
1397 "GET",
1398 mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1399 )
1400 .with_status(200)
1401 .with_header("content-type", "application/json")
1402 .with_body(r#"{"pairs":[]}"#)
1403 .create_async()
1404 .await;
1405
1406 let client = DexClient::with_base_url(&server.url());
1407 let results = client.search_tokens("XYZNONEXIST", None).await.unwrap();
1408 assert!(results.is_empty());
1409 }
1410
1411 #[tokio::test]
1412 async fn test_search_tokens_api_error() {
1413 let mut server = mockito::Server::new_async().await;
1414 let _mock = server
1415 .mock(
1416 "GET",
1417 mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1418 )
1419 .with_status(429)
1420 .create_async()
1421 .await;
1422
1423 let client = DexClient::with_base_url(&server.url());
1424 let result = client.search_tokens("USDC", None).await;
1425 assert!(result.is_err());
1426 }
1427
1428 #[test]
1429 fn test_generate_price_history() {
1430 let pair_json = r#"{
1431 "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair",
1432 "baseToken":{"address":"0xtoken","name":"Token","symbol":"TKN"},
1433 "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1434 "priceUsd":"100.0",
1435 "priceChange":{"h24":10.0,"h6":5.0,"h1":1.0,"m5":0.5}
1436 }"#;
1437 let pair: DexScreenerPair = serde_json::from_str(pair_json).unwrap();
1438 let history = DexClient::generate_price_history(100.0, &pair, 1700000000);
1439 assert!(!history.is_empty());
1440 assert!(history.iter().any(|p| (p.price - 100.0).abs() < 0.001));
1442 }
1443
1444 #[test]
1445 fn test_chain_mapping_all_variants() {
1446 assert_eq!(DexClient::map_chain_to_dexscreener("eth"), "ethereum");
1448 assert_eq!(DexClient::map_chain_to_dexscreener("matic"), "polygon");
1449 assert_eq!(DexClient::map_chain_to_dexscreener("arb"), "arbitrum");
1450 assert_eq!(DexClient::map_chain_to_dexscreener("op"), "optimism");
1451 assert_eq!(DexClient::map_chain_to_dexscreener("base"), "base");
1452 assert_eq!(DexClient::map_chain_to_dexscreener("bnb"), "bsc");
1453 assert_eq!(DexClient::map_chain_to_dexscreener("sol"), "solana");
1454 assert_eq!(DexClient::map_chain_to_dexscreener("avax"), "avalanche");
1455 assert_eq!(DexClient::map_chain_to_dexscreener("unknown"), "unknown");
1456 }
1457
1458 #[tokio::test]
1459 async fn test_get_native_token_price_ethereum() {
1460 let mut server = mockito::Server::new_async().await;
1461 let _mock = server
1462 .mock("GET", mockito::Matcher::Any)
1463 .with_status(200)
1464 .with_header("content-type", "application/json")
1465 .with_body(r#"{"pairs":[{
1466 "chainId":"ethereum",
1467 "dexId":"uniswap",
1468 "pairAddress":"0xpair",
1469 "baseToken":{"address":"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2","name":"WETH","symbol":"WETH"},
1470 "quoteToken":{"address":"0xusdt","name":"USDT","symbol":"USDT"},
1471 "priceUsd":"3500.00"
1472 }]}"#)
1473 .create_async()
1474 .await;
1475
1476 let client = DexClient::with_base_url(&server.url());
1477 let price = client.get_native_token_price("ethereum").await;
1478 assert!(price.is_some());
1479 assert!((price.unwrap() - 3500.0).abs() < 0.01);
1480 }
1481
1482 #[tokio::test]
1483 async fn test_get_native_token_price_tron_returns_none() {
1484 let client = DexClient::with_base_url("http://localhost:1");
1485 let price = client.get_native_token_price("tron").await;
1486 assert!(price.is_none());
1487 }
1488
1489 #[tokio::test]
1490 async fn test_get_native_token_price_unknown_chain() {
1491 let client = DexClient::with_base_url("http://localhost:1");
1492 let price = client.get_native_token_price("unknownchain").await;
1493 assert!(price.is_none());
1494 }
1495
1496 #[tokio::test]
1497 async fn test_search_tokens_chain_filter_ethereum_only() {
1498 let mut server = mockito::Server::new_async().await;
1499 let _mock = server
1500 .mock("GET", mockito::Matcher::Any)
1501 .with_status(200)
1502 .with_header("content-type", "application/json")
1503 .with_body(
1504 r#"{"pairs":[
1505 {
1506 "chainId":"ethereum",
1507 "dexId":"uniswap",
1508 "pairAddress":"0xpair1",
1509 "baseToken":{"address":"0xtoken1","name":"USD Coin","symbol":"USDC"},
1510 "quoteToken":{"address":"0xweth","name":"WETH","symbol":"WETH"},
1511 "priceUsd":"1.00",
1512 "liquidity":{"usd":5000000.0},
1513 "volume":{"h24":1000000.0}
1514 },
1515 {
1516 "chainId":"bsc",
1517 "dexId":"pancakeswap",
1518 "pairAddress":"0xpair2",
1519 "baseToken":{"address":"0xtoken2","name":"Binance USD","symbol":"BUSD"},
1520 "quoteToken":{"address":"0xbnb","name":"BNB","symbol":"BNB"},
1521 "priceUsd":"1.00",
1522 "liquidity":{"usd":2000000.0},
1523 "volume":{"h24":500000.0}
1524 }
1525 ]}"#,
1526 )
1527 .create_async()
1528 .await;
1529
1530 let client = DexClient::with_base_url(&server.url());
1531 let results = client.search_tokens("USD", Some("ethereum")).await.unwrap();
1533 assert!(!results.is_empty());
1534 for r in &results {
1536 assert_eq!(r.chain.to_lowercase(), "ethereum");
1537 }
1538 }
1539
1540 #[tokio::test]
1541 async fn test_search_tokens_exact_match_sorts_before_partial() {
1542 let mut server = mockito::Server::new_async().await;
1544 let pair_usdc = build_test_pair_json("ethereum", "USDC", "0xusdc", "1.00");
1545 let pair_syrup = r#"{
1546 "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair2",
1547 "baseToken":{"address":"0xsyrupusdc","name":"Syrup USDC","symbol":"syrupUSDC"},
1548 "quoteToken":{"address":"0xquote","name":"USDT","symbol":"USDT"},
1549 "priceUsd":"1.00",
1550 "priceChange":{"h24":0.0,"h6":0.0,"h1":0.0,"m5":0.0},
1551 "volume":{"h24":500000,"h6":125000,"h1":25000,"m5":2500},
1552 "liquidity":{"usd":5000000,"base":5000000,"quote":5000000},
1553 "fdv":null,"marketCap":null,
1554 "txns":{"h24":{"buys":100,"sells":80},"h6":{"buys":20,"sells":15},"h1":{"buys":5,"sells":3}},
1555 "pairCreatedAt":1690000000000,
1556 "url":"https://dexscreener.com/ethereum/0xpair2"
1557 }"#;
1558 let body = format!(r#"{{"pairs":[{},{}]}}"#, pair_usdc, pair_syrup);
1559 let _mock = server
1560 .mock(
1561 "GET",
1562 mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1563 )
1564 .with_status(200)
1565 .with_header("content-type", "application/json")
1566 .with_body(&body)
1567 .create_async()
1568 .await;
1569
1570 let client = DexClient::with_base_url(&server.url());
1571 let results = client.search_tokens("USDC", None).await.unwrap();
1572 assert!(results.len() >= 2);
1573 assert_eq!(results[0].symbol, "USDC");
1575 let syrup_pos = results
1577 .iter()
1578 .position(|r| r.symbol == "syrupUSDC")
1579 .unwrap();
1580 assert!(syrup_pos > 0);
1581 assert!(results[syrup_pos].liquidity_usd > results[0].liquidity_usd);
1582 }
1583
1584 #[tokio::test]
1585 async fn test_search_tokens_multiple_exact_matches_sort_by_liquidity() {
1586 let mut server = mockito::Server::new_async().await;
1588 let pair_eth = r#"{
1589 "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair1",
1590 "baseToken":{"address":"0xusdc_eth","name":"USD Coin","symbol":"USDC"},
1591 "quoteToken":{"address":"0xweth","name":"WETH","symbol":"WETH"},
1592 "priceUsd":"1.00",
1593 "priceChange":{"h24":0.0,"h6":0.0,"h1":0.0,"m5":0.0},
1594 "volume":{"h24":1000000,"h6":250000,"h1":50000,"m5":5000},
1595 "liquidity":{"usd":1000000,"base":1000000,"quote":1000000},
1596 "fdv":null,"marketCap":null,
1597 "txns":{"h24":{"buys":100,"sells":80},"h6":{"buys":20,"sells":15},"h1":{"buys":5,"sells":3}},
1598 "pairCreatedAt":1690000000000,
1599 "url":"https://dexscreener.com/ethereum/0xpair1"
1600 }"#;
1601 let pair_bsc = r#"{
1602 "chainId":"bsc","dexId":"pancakeswap","pairAddress":"0xpair2",
1603 "baseToken":{"address":"0xusdc_bsc","name":"USD Coin","symbol":"USDC"},
1604 "quoteToken":{"address":"0xbnb","name":"BNB","symbol":"BNB"},
1605 "priceUsd":"1.00",
1606 "priceChange":{"h24":0.0,"h6":0.0,"h1":0.0,"m5":0.0},
1607 "volume":{"h24":2000000,"h6":500000,"h1":100000,"m5":10000},
1608 "liquidity":{"usd":3000000,"base":3000000,"quote":3000000},
1609 "fdv":null,"marketCap":null,
1610 "txns":{"h24":{"buys":200,"sells":150},"h6":{"buys":50,"sells":30},"h1":{"buys":10,"sells":6}},
1611 "pairCreatedAt":1690000000000,
1612 "url":"https://dexscreener.com/bsc/0xpair2"
1613 }"#;
1614 let body = format!(r#"{{"pairs":[{},{}]}}"#, pair_eth, pair_bsc);
1615 let _mock = server
1616 .mock(
1617 "GET",
1618 mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1619 )
1620 .with_status(200)
1621 .with_header("content-type", "application/json")
1622 .with_body(&body)
1623 .create_async()
1624 .await;
1625
1626 let client = DexClient::with_base_url(&server.url());
1627 let results = client.search_tokens("USDC", None).await.unwrap();
1628 assert_eq!(results.len(), 2);
1629 assert_eq!(results[0].symbol, "USDC");
1631 assert_eq!(results[1].symbol, "USDC");
1632 assert!(results[0].liquidity_usd >= results[1].liquidity_usd);
1633 assert_eq!(results[0].chain, "bsc"); assert_eq!(results[1].chain, "ethereum"); }
1636
1637 #[tokio::test]
1638 async fn test_search_tokens_aggregates_volume_and_liquidity() {
1639 let mut server = mockito::Server::new_async().await;
1640 let _mock = server
1641 .mock("GET", mockito::Matcher::Any)
1642 .with_status(200)
1643 .with_header("content-type", "application/json")
1644 .with_body(
1645 r#"{"pairs":[
1646 {
1647 "chainId":"ethereum",
1648 "dexId":"uniswap",
1649 "pairAddress":"0xpair1",
1650 "baseToken":{"address":"0xSameToken","name":"Test Token","symbol":"TEST"},
1651 "quoteToken":{"address":"0xweth","name":"WETH","symbol":"WETH"},
1652 "priceUsd":"10.00",
1653 "liquidity":{"usd":1000000.0},
1654 "volume":{"h24":100000.0}
1655 },
1656 {
1657 "chainId":"ethereum",
1658 "dexId":"sushiswap",
1659 "pairAddress":"0xpair2",
1660 "baseToken":{"address":"0xSameToken","name":"Test Token","symbol":"TEST"},
1661 "quoteToken":{"address":"0xusdc","name":"USDC","symbol":"USDC"},
1662 "priceUsd":"10.05",
1663 "liquidity":{"usd":500000.0},
1664 "volume":{"h24":50000.0}
1665 }
1666 ]}"#,
1667 )
1668 .create_async()
1669 .await;
1670
1671 let client = DexClient::with_base_url(&server.url());
1672 let results = client.search_tokens("TEST", None).await.unwrap();
1673 assert_eq!(results.len(), 1); assert!(results[0].volume_24h > 100000.0);
1676 assert!(results[0].liquidity_usd > 1000000.0);
1677 }
1678
1679 #[tokio::test]
1680 async fn test_dex_data_source_trait_methods() {
1681 let mut server = mockito::Server::new_async().await;
1682 let _mock = server
1683 .mock("GET", mockito::Matcher::Any)
1684 .with_status(200)
1685 .with_header("content-type", "application/json")
1686 .with_body(
1687 r#"{"pairs":[{
1688 "chainId":"ethereum",
1689 "dexId":"uniswap",
1690 "pairAddress":"0xpair",
1691 "baseToken":{"address":"0xtoken","name":"Token","symbol":"TKN"},
1692 "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1693 "priceUsd":"50.0",
1694 "liquidity":{"usd":1000000.0},
1695 "volume":{"h24":100000.0}
1696 }]}"#,
1697 )
1698 .create_async()
1699 .await;
1700
1701 let client = DexClient::with_base_url(&server.url());
1702 let trait_client: &dyn DexDataSource = &client;
1704 let price = trait_client.get_token_price("ethereum", "0xtoken").await;
1705 assert!(price.is_some());
1706 }
1707
1708 #[tokio::test]
1709 async fn test_dex_data_source_trait_get_native_token_price() {
1710 let mut server = mockito::Server::new_async().await;
1711 let _mock = server
1712 .mock("GET", mockito::Matcher::Any)
1713 .with_status(200)
1714 .with_header("content-type", "application/json")
1715 .with_body(
1716 r#"{"pairs":[{
1717 "chainId":"ethereum",
1718 "dexId":"uniswap",
1719 "pairAddress":"0xpair",
1720 "baseToken":{"address":"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2","name":"WETH","symbol":"WETH"},
1721 "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1722 "priceUsd":"3500.0",
1723 "liquidity":{"usd":10000000.0},
1724 "volume":{"h24":5000000.0}
1725 }]}"#,
1726 )
1727 .create_async()
1728 .await;
1729
1730 let client = DexClient::with_base_url(&server.url());
1731 let trait_client: &dyn DexDataSource = &client;
1732 let price = trait_client.get_native_token_price("ethereum").await;
1733 assert!(price.is_some());
1734 }
1735
1736 #[tokio::test]
1737 async fn test_dex_data_source_trait_get_token_data() {
1738 let mut server = mockito::Server::new_async().await;
1739 let pair_json = build_test_pair_json("ethereum", "TKN", "0xtoken", "50.0");
1740 let _mock = server
1741 .mock("GET", mockito::Matcher::Any)
1742 .with_status(200)
1743 .with_header("content-type", "application/json")
1744 .with_body(format!(r#"{{"pairs":[{}]}}"#, pair_json))
1745 .create_async()
1746 .await;
1747
1748 let client = DexClient::with_base_url(&server.url());
1749 let trait_client: &dyn DexDataSource = &client;
1750 let data = trait_client.get_token_data("ethereum", "0xtoken").await;
1751 assert!(data.is_ok());
1752 }
1753
1754 #[tokio::test]
1755 async fn test_dex_data_source_trait_search_tokens() {
1756 let mut server = mockito::Server::new_async().await;
1757 let pair_json = build_test_pair_json("ethereum", "TKN", "0xtoken", "50.0");
1758 let _mock = server
1759 .mock("GET", mockito::Matcher::Any)
1760 .with_status(200)
1761 .with_header("content-type", "application/json")
1762 .with_body(format!(r#"{{"pairs":[{}]}}"#, pair_json))
1763 .create_async()
1764 .await;
1765
1766 let client = DexClient::with_base_url(&server.url());
1767 let trait_client: &dyn DexDataSource = &client;
1768 let results = trait_client.search_tokens("TKN", None).await;
1769 assert!(results.is_ok());
1770 }
1771
1772 #[tokio::test]
1773 async fn test_get_token_data_quote_token() {
1774 let mut server = mockito::Server::new_async().await;
1775 let _mock = server
1777 .mock("GET", mockito::Matcher::Any)
1778 .with_status(200)
1779 .with_header("content-type", "application/json")
1780 .with_body(
1781 r#"{"pairs":[{
1782 "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair",
1783 "baseToken":{"address":"0xother","name":"Other","symbol":"OTH"},
1784 "quoteToken":{"address":"0xmytoken","name":"MyToken","symbol":"MTK"},
1785 "priceUsd":"25.0",
1786 "priceChange":{"h24":1.0,"h6":0.5,"h1":0.2,"m5":0.05},
1787 "volume":{"h24":500000,"h6":100000,"h1":20000,"m5":2000},
1788 "liquidity":{"usd":0,"base":0,"quote":0},
1789 "txns":{"h24":{"buys":50,"sells":40},"h6":{"buys":10,"sells":8},"h1":{"buys":2,"sells":1}},
1790 "pairCreatedAt":1690000000000,
1791 "url":"https://dexscreener.com/ethereum/0xpair"
1792 }]}"#,
1793 )
1794 .create_async()
1795 .await;
1796
1797 let client = DexClient::with_base_url(&server.url());
1798 let data = client
1799 .get_token_data("ethereum", "0xmytoken")
1800 .await
1801 .unwrap();
1802 assert_eq!(data.symbol, "MTK");
1804 assert_eq!(data.name, "MyToken");
1805 assert!(data.price_usd > 0.0);
1807 }
1808
1809 #[tokio::test]
1810 async fn test_get_token_data_with_socials() {
1811 let mut server = mockito::Server::new_async().await;
1812 let _mock = server
1813 .mock("GET", mockito::Matcher::Any)
1814 .with_status(200)
1815 .with_header("content-type", "application/json")
1816 .with_body(
1817 r#"{"pairs":[{
1818 "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair",
1819 "baseToken":{"address":"0xtoken","name":"Token","symbol":"TKN"},
1820 "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1821 "priceUsd":"50.0",
1822 "priceChange":{"h24":5.0,"h6":2.0,"h1":1.0,"m5":0.1},
1823 "volume":{"h24":1000000,"h6":250000,"h1":50000,"m5":5000},
1824 "liquidity":{"usd":1000000,"base":100,"quote":1000000},
1825 "txns":{"h24":{"buys":100,"sells":80},"h6":{"buys":20,"sells":15},"h1":{"buys":5,"sells":3}},
1826 "pairCreatedAt":1690000000000,
1827 "url":"https://dexscreener.com/ethereum/0xpair",
1828 "info":{
1829 "imageUrl":"https://example.com/logo.png",
1830 "websites":[{"url":"https://example.com"}],
1831 "socials":[
1832 {"type":"twitter","url":"https://twitter.com/token"},
1833 {"type":"telegram","url":"https://t.me/token"}
1834 ]
1835 }
1836 }]}"#,
1837 )
1838 .create_async()
1839 .await;
1840
1841 let client = DexClient::with_base_url(&server.url());
1842 let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1843 assert_eq!(data.symbol, "TKN");
1844 assert!(data.image_url.is_some());
1845 assert!(!data.websites.is_empty());
1846 assert!(!data.socials.is_empty());
1847 assert_eq!(data.socials[0].platform, "twitter");
1848 }
1849
1850 #[tokio::test]
1851 async fn test_search_tokens_quote_match_and_updates() {
1852 let mut server = mockito::Server::new_async().await;
1853 let _mock = server
1855 .mock("GET", mockito::Matcher::Any)
1856 .with_status(200)
1857 .with_header("content-type", "application/json")
1858 .with_body(
1859 r#"{"pairs":[
1860 {
1861 "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair1",
1862 "baseToken":{"address":"0xother","name":"Other","symbol":"OTH"},
1863 "quoteToken":{"address":"0xmytk","name":"MySearch","symbol":"MSR"},
1864 "liquidity":{"usd":500000.0},
1865 "volume":{"h24":100000.0},
1866 "marketCap":5000000
1867 },
1868 {
1869 "chainId":"ethereum","dexId":"sushi","pairAddress":"0xpair2",
1870 "baseToken":{"address":"0xmytk","name":"MySearch","symbol":"MSR"},
1871 "quoteToken":{"address":"0xweth","name":"WETH","symbol":"WETH"},
1872 "priceUsd":"10.5",
1873 "liquidity":{"usd":800000.0},
1874 "volume":{"h24":200000.0}
1875 }
1876 ]}"#,
1877 )
1878 .create_async()
1879 .await;
1880
1881 let client = DexClient::with_base_url(&server.url());
1882 let results = client.search_tokens("MySearch", None).await.unwrap();
1883 assert_eq!(results.len(), 1); assert_eq!(results[0].symbol, "MSR");
1885 assert!(results[0].volume_24h >= 300000.0);
1887 assert!(results[0].liquidity_usd >= 1300000.0);
1889 assert!(results[0].price_usd.is_some());
1891 assert!(results[0].market_cap.is_some());
1893 }
1894
1895 #[test]
1896 fn test_interpolate_points_midpoint() {
1897 let mut history = vec![
1898 PricePoint {
1899 timestamp: 1000,
1900 price: 10.0,
1901 },
1902 PricePoint {
1903 timestamp: 2000,
1904 price: 20.0,
1905 },
1906 ];
1907 DexClient::interpolate_points(&mut history, 2);
1909 assert_eq!(history.len(), 2);
1910
1911 DexClient::interpolate_points(&mut history, 5);
1913 assert!(history.len() > 2);
1914 let midpoints: Vec<_> = history.iter().filter(|p| p.timestamp == 1500).collect();
1916 assert!(!midpoints.is_empty());
1917 assert!((midpoints[0].price - 15.0).abs() < 0.01);
1918 }
1919
1920 fn discover_token_json() -> &'static str {
1921 r#"[
1922 {"chainId":"ethereum","tokenAddress":"0xabc","url":"https://dexscreener.com/ethereum/0xabc","description":"Test token","links":[{"label":"Twitter","type":"twitter","url":"https://twitter.com/test"}]},
1923 {"chainId":"solana","tokenAddress":"So11111111111111111111111111111111111111112","url":"https://dexscreener.com/solana/So11","links":[]}
1924 ]"#
1925 }
1926
1927 #[tokio::test]
1928 async fn test_get_token_profiles() {
1929 let mut server = mockito::Server::new_async().await;
1930 let _mock = server
1931 .mock("GET", "/token-profiles/latest/v1")
1932 .with_status(200)
1933 .with_header("content-type", "application/json")
1934 .with_body(discover_token_json())
1935 .create_async()
1936 .await;
1937
1938 let client = DexClient::with_base_url(&server.url());
1939 let tokens = client.get_token_profiles().await.unwrap();
1940 assert_eq!(tokens.len(), 2);
1941 assert_eq!(tokens[0].chain_id, "ethereum");
1942 assert_eq!(tokens[0].token_address, "0xabc");
1943 assert_eq!(tokens[0].description.as_deref(), Some("Test token"));
1944 assert_eq!(tokens[0].links.len(), 1);
1945 assert_eq!(tokens[1].chain_id, "solana");
1946 }
1947
1948 #[tokio::test]
1949 async fn test_get_token_boosts() {
1950 let mut server = mockito::Server::new_async().await;
1951 let _mock = server
1952 .mock("GET", "/token-boosts/latest/v1")
1953 .with_status(200)
1954 .with_header("content-type", "application/json")
1955 .with_body(discover_token_json())
1956 .create_async()
1957 .await;
1958
1959 let client = DexClient::with_base_url(&server.url());
1960 let tokens = client.get_token_boosts().await.unwrap();
1961 assert_eq!(tokens.len(), 2);
1962 }
1963
1964 #[tokio::test]
1965 async fn test_get_token_boosts_top() {
1966 let mut server = mockito::Server::new_async().await;
1967 let _mock = server
1968 .mock("GET", "/token-boosts/top/v1")
1969 .with_status(200)
1970 .with_header("content-type", "application/json")
1971 .with_body(discover_token_json())
1972 .create_async()
1973 .await;
1974
1975 let client = DexClient::with_base_url(&server.url());
1976 let tokens = client.get_token_boosts_top().await.unwrap();
1977 assert_eq!(tokens.len(), 2);
1978 }
1979
1980 #[tokio::test]
1981 async fn test_fetch_discover_tokens_api_error() {
1982 let mut server = mockito::Server::new_async().await;
1983 let _mock = server
1984 .mock("GET", mockito::Matcher::Any)
1985 .with_status(500)
1986 .create_async()
1987 .await;
1988
1989 let client = DexClient::with_base_url(&server.url());
1990 let result = client.get_token_profiles().await;
1991 assert!(result.is_err());
1992 }
1993
1994 #[tokio::test]
1995 async fn test_fetch_discover_tokens_empty_array() {
1996 let mut server = mockito::Server::new_async().await;
1997 let _mock = server
1998 .mock("GET", "/token-profiles/latest/v1")
1999 .with_status(200)
2000 .with_header("content-type", "application/json")
2001 .with_body("[]")
2002 .create_async()
2003 .await;
2004
2005 let client = DexClient::with_base_url(&server.url());
2006 let tokens = client.get_token_profiles().await.unwrap();
2007 assert!(tokens.is_empty());
2008 }
2009
2010 #[tokio::test]
2011 async fn test_fetch_discover_tokens_filters_invalid_entries() {
2012 let body = r#"[{"chainId":"ethereum","url":"https://example.com"},{"chainId":"solana","tokenAddress":"0xvalid","url":"https://dexscreener.com/solana/0xvalid"}]"#;
2014 let mut server = mockito::Server::new_async().await;
2015 let _mock = server
2016 .mock("GET", "/token-profiles/latest/v1")
2017 .with_status(200)
2018 .with_header("content-type", "application/json")
2019 .with_body(body)
2020 .create_async()
2021 .await;
2022
2023 let client = DexClient::with_base_url(&server.url());
2024 let tokens = client.get_token_profiles().await.unwrap();
2025 assert_eq!(tokens.len(), 1);
2026 assert_eq!(tokens[0].token_address, "0xvalid");
2027 }
2028
2029 #[tokio::test]
2034 async fn test_search_tokens_exact_match_sorted_first() {
2035 let pair_syrup = build_test_pair_json("ethereum", "syrupUSDC", "0xsyrup", "1.0");
2038 let pair_usdc = build_test_pair_json("ethereum", "USDC", "0xusdc", "1.0");
2039
2040 let body = format!(
2041 r#"{{"schemaVersion":"1.0.0","pairs":[{},{}]}}"#,
2042 pair_syrup, pair_usdc
2043 );
2044
2045 let mut server = mockito::Server::new_async().await;
2046 let _mock = server
2047 .mock("GET", "/latest/dex/search")
2048 .match_query(mockito::Matcher::UrlEncoded("q".into(), "USDC".into()))
2049 .with_status(200)
2050 .with_header("content-type", "application/json")
2051 .with_body(&body)
2052 .create_async()
2053 .await;
2054
2055 let client = DexClient::with_base_url(&server.url());
2056 let results = client.search_tokens("USDC", None).await.unwrap();
2057
2058 assert!(
2059 results.len() >= 2,
2060 "expected at least 2 results, got {}",
2061 results.len()
2062 );
2063 assert_eq!(
2065 results[0].symbol, "USDC",
2066 "exact symbol match should be sorted first"
2067 );
2068 assert_eq!(results[1].symbol, "syrupUSDC");
2069 }
2070
2071 #[tokio::test]
2072 async fn test_search_tokens_no_results() {
2073 let body = r#"{"schemaVersion":"1.0.0","pairs":[]}"#;
2074 let mut server = mockito::Server::new_async().await;
2075 let _mock = server
2076 .mock("GET", "/latest/dex/search")
2077 .match_query(mockito::Matcher::UrlEncoded("q".into(), "ZZZZZ".into()))
2078 .with_status(200)
2079 .with_header("content-type", "application/json")
2080 .with_body(body)
2081 .create_async()
2082 .await;
2083
2084 let client = DexClient::with_base_url(&server.url());
2085 let results = client.search_tokens("ZZZZZ", None).await.unwrap();
2086 assert!(results.is_empty());
2087 }
2088
2089 #[tokio::test]
2090 async fn test_search_tokens_api_error_500() {
2091 let mut server = mockito::Server::new_async().await;
2092 let _mock = server
2093 .mock("GET", "/latest/dex/search")
2094 .match_query(mockito::Matcher::UrlEncoded("q".into(), "ERR".into()))
2095 .with_status(500)
2096 .create_async()
2097 .await;
2098
2099 let client = DexClient::with_base_url(&server.url());
2100 let result = client.search_tokens("ERR", None).await;
2101 assert!(result.is_err());
2102 }
2103}