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 mut results: Vec<TokenSearchResult> = token_map.into_values().collect();
822 results.sort_by(|a, b| {
823 b.liquidity_usd
824 .partial_cmp(&a.liquidity_usd)
825 .unwrap_or(std::cmp::Ordering::Equal)
826 });
827
828 results.truncate(20);
830
831 Ok(results)
832 }
833
834 pub async fn get_token_profiles(&self) -> Result<Vec<DiscoverToken>> {
836 let url = format!("{}/token-profiles/latest/v1", self.base_url);
837 self.fetch_discover_tokens(&url).await
838 }
839
840 pub async fn get_token_boosts(&self) -> Result<Vec<DiscoverToken>> {
842 let url = format!("{}/token-boosts/latest/v1", self.base_url);
843 self.fetch_discover_tokens(&url).await
844 }
845
846 pub async fn get_token_boosts_top(&self) -> Result<Vec<DiscoverToken>> {
848 let url = format!("{}/token-boosts/top/v1", self.base_url);
849 self.fetch_discover_tokens(&url).await
850 }
851
852 async fn fetch_discover_tokens(&self, url: &str) -> Result<Vec<DiscoverToken>> {
853 let response = self
854 .http
855 .get(url)
856 .send()
857 .await
858 .map_err(|e| ScopeError::Network(e.to_string()))?;
859
860 if !response.status().is_success() {
861 return Err(ScopeError::Api(format!(
862 "DexScreener API error: {}",
863 response.status()
864 )));
865 }
866
867 #[derive(Deserialize)]
868 struct TokenProfileRaw {
869 url: Option<String>,
870 #[serde(rename = "chainId")]
871 chain_id: Option<String>,
872 #[serde(rename = "tokenAddress")]
873 token_address: Option<String>,
874 description: Option<String>,
875 links: Option<Vec<LinkRaw>>,
876 }
877
878 #[derive(Deserialize)]
879 struct LinkRaw {
880 label: Option<String>,
881 #[serde(rename = "type")]
882 link_type: Option<String>,
883 url: Option<String>,
884 }
885
886 let raw: Vec<TokenProfileRaw> = response
887 .json()
888 .await
889 .map_err(|e| ScopeError::Api(format!("Failed to parse response: {}", e)))?;
890
891 let tokens: Vec<DiscoverToken> = raw
892 .into_iter()
893 .filter_map(|r| {
894 let token_address = r.token_address?;
895 let chain_id = r.chain_id.clone().unwrap_or_else(|| "unknown".to_string());
896 let url = r.url.clone().unwrap_or_else(|| {
897 format!("https://dexscreener.com/{}/{}", chain_id, token_address)
898 });
899 let links: Vec<DiscoverLink> = r
900 .links
901 .unwrap_or_default()
902 .into_iter()
903 .filter_map(|l| {
904 let url = l.url?;
905 Some(DiscoverLink {
906 label: l.label,
907 link_type: l.link_type,
908 url,
909 })
910 })
911 .collect();
912
913 Some(DiscoverToken {
914 chain_id,
915 token_address,
916 url,
917 description: r.description,
918 links,
919 })
920 })
921 .collect();
922
923 Ok(tokens)
924 }
925
926 fn generate_price_history(
928 current_price: f64,
929 pair: &DexScreenerPair,
930 now: i64,
931 ) -> Vec<PricePoint> {
932 let mut history = Vec::new();
933
934 let changes = pair.price_change.as_ref();
936 let change_24h = changes.and_then(|c| c.h24).unwrap_or(0.0) / 100.0;
937 let change_6h = changes.and_then(|c| c.h6).unwrap_or(0.0) / 100.0;
938 let change_1h = changes.and_then(|c| c.h1).unwrap_or(0.0) / 100.0;
939 let change_5m = changes.and_then(|c| c.m5).unwrap_or(0.0) / 100.0;
940
941 let price_24h_ago = current_price / (1.0 + change_24h);
943 let price_6h_ago = current_price / (1.0 + change_6h);
944 let price_1h_ago = current_price / (1.0 + change_1h);
945 let price_5m_ago = current_price / (1.0 + change_5m);
946
947 history.push(PricePoint {
949 timestamp: now - 86400, price: price_24h_ago,
951 });
952 history.push(PricePoint {
953 timestamp: now - 21600, price: price_6h_ago,
955 });
956 history.push(PricePoint {
957 timestamp: now - 3600, price: price_1h_ago,
959 });
960 history.push(PricePoint {
961 timestamp: now - 300, price: price_5m_ago,
963 });
964 history.push(PricePoint {
965 timestamp: now,
966 price: current_price,
967 });
968
969 Self::interpolate_points(&mut history, 24);
971
972 history.sort_by_key(|p| p.timestamp);
973 history
974 }
975
976 fn generate_volume_history(
978 volume_24h: f64,
979 volume_6h: f64,
980 volume_1h: f64,
981 now: i64,
982 ) -> Vec<VolumePoint> {
983 let mut history = Vec::new();
984
985 let hourly_avg = volume_24h / 24.0;
987
988 for i in 0..24 {
989 let timestamp = now - (23 - i) * 3600;
990 let hours_ago = 24 - i;
991
992 let volume = if hours_ago <= 1 {
994 volume_1h
995 } else if hours_ago <= 6 {
996 volume_6h / 6.0
997 } else {
998 hourly_avg * (0.8 + (i as f64 / 24.0) * 0.4)
1000 };
1001
1002 history.push(VolumePoint { timestamp, volume });
1003 }
1004
1005 history
1006 }
1007
1008 fn interpolate_points(history: &mut Vec<PricePoint>, target_count: usize) {
1010 if history.len() >= target_count {
1011 return;
1012 }
1013
1014 history.sort_by_key(|p| p.timestamp);
1015
1016 let mut interpolated = Vec::new();
1017 for window in history.windows(2) {
1018 let p1 = &window[0];
1019 let p2 = &window[1];
1020
1021 interpolated.push(p1.clone());
1022
1023 let mid_timestamp = (p1.timestamp + p2.timestamp) / 2;
1025 let mid_price = (p1.price + p2.price) / 2.0;
1026 interpolated.push(PricePoint {
1027 timestamp: mid_timestamp,
1028 price: mid_price,
1029 });
1030 }
1031
1032 if let Some(last) = history.last() {
1033 interpolated.push(last.clone());
1034 }
1035
1036 *history = interpolated;
1037 }
1038
1039 pub fn estimate_7d_volume(volume_24h: f64) -> f64 {
1044 volume_24h * 7.0
1046 }
1047}
1048
1049impl Default for DexClient {
1050 fn default() -> Self {
1051 Self::new()
1052 }
1053}
1054
1055#[async_trait]
1060impl DexDataSource for DexClient {
1061 async fn get_token_price(&self, chain: &str, address: &str) -> Option<f64> {
1062 self.get_token_price(chain, address).await
1063 }
1064
1065 async fn get_native_token_price(&self, chain: &str) -> Option<f64> {
1066 self.get_native_token_price(chain).await
1067 }
1068
1069 async fn get_token_data(&self, chain: &str, address: &str) -> Result<DexTokenData> {
1070 self.get_token_data(chain, address).await
1071 }
1072
1073 async fn search_tokens(
1074 &self,
1075 query: &str,
1076 chain: Option<&str>,
1077 ) -> Result<Vec<TokenSearchResult>> {
1078 self.search_tokens(query, chain).await
1079 }
1080}
1081
1082#[cfg(test)]
1084fn build_test_pair_json(chain_id: &str, base_symbol: &str, base_addr: &str, price: &str) -> String {
1085 format!(
1086 r#"{{
1087 "chainId":"{}","dexId":"uniswap","pairAddress":"0xpair",
1088 "baseToken":{{"address":"{}","name":"{}","symbol":"{}"}},
1089 "quoteToken":{{"address":"0xquote","name":"USDC","symbol":"USDC"}},
1090 "priceUsd":"{}",
1091 "priceChange":{{"h24":5.2,"h6":2.1,"h1":0.5,"m5":0.1}},
1092 "volume":{{"h24":1000000,"h6":250000,"h1":50000,"m5":5000}},
1093 "liquidity":{{"usd":500000,"base":100,"quote":500000}},
1094 "fdv":10000000,"marketCap":8000000,
1095 "txns":{{"h24":{{"buys":100,"sells":80}},"h6":{{"buys":20,"sells":15}},"h1":{{"buys":5,"sells":3}}}},
1096 "pairCreatedAt":1690000000000,
1097 "url":"https://dexscreener.com/ethereum/0xpair"
1098 }}"#,
1099 chain_id, base_addr, base_symbol, base_symbol, price
1100 )
1101}
1102
1103#[cfg(test)]
1108mod tests {
1109 use super::*;
1110
1111 #[test]
1112 fn test_chain_mapping() {
1113 assert_eq!(
1114 DexClient::map_chain_to_dexscreener("ethereum"),
1115 "ethereum".to_string()
1116 );
1117 assert_eq!(
1118 DexClient::map_chain_to_dexscreener("ETH"),
1119 "ethereum".to_string()
1120 );
1121 assert_eq!(
1122 DexClient::map_chain_to_dexscreener("bsc"),
1123 "bsc".to_string()
1124 );
1125 assert_eq!(
1126 DexClient::map_chain_to_dexscreener("BNB"),
1127 "bsc".to_string()
1128 );
1129 assert_eq!(
1130 DexClient::map_chain_to_dexscreener("polygon"),
1131 "polygon".to_string()
1132 );
1133 assert_eq!(
1134 DexClient::map_chain_to_dexscreener("solana"),
1135 "solana".to_string()
1136 );
1137 }
1138
1139 #[test]
1140 fn test_estimate_7d_volume() {
1141 assert_eq!(DexClient::estimate_7d_volume(1_000_000.0), 7_000_000.0);
1142 assert_eq!(DexClient::estimate_7d_volume(0.0), 0.0);
1143 }
1144
1145 #[test]
1146 fn test_generate_volume_history() {
1147 let now = 1700000000;
1148 let history = DexClient::generate_volume_history(24000.0, 6000.0, 1000.0, now);
1149
1150 assert_eq!(history.len(), 24);
1151 assert!(history.iter().all(|v| v.volume >= 0.0));
1152 assert!(history.iter().all(|v| v.timestamp <= now));
1153 }
1154
1155 #[test]
1156 fn test_dex_client_default() {
1157 let _client = DexClient::default();
1158 }
1160
1161 #[test]
1162 fn test_interpolate_points() {
1163 let mut history = vec![
1164 PricePoint {
1165 timestamp: 0,
1166 price: 1.0,
1167 },
1168 PricePoint {
1169 timestamp: 100,
1170 price: 2.0,
1171 },
1172 ];
1173
1174 DexClient::interpolate_points(&mut history, 10);
1175
1176 assert!(history.len() > 2);
1177 assert!(history.iter().any(|p| p.timestamp == 50));
1179 }
1180
1181 #[tokio::test]
1186 async fn test_get_token_data_success() {
1187 let mut server = mockito::Server::new_async().await;
1188 let pair = build_test_pair_json("ethereum", "WETH", "0xtoken", "2500.50");
1189 let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1190 let _mock = server
1191 .mock(
1192 "GET",
1193 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1194 )
1195 .with_status(200)
1196 .with_header("content-type", "application/json")
1197 .with_body(&body)
1198 .create_async()
1199 .await;
1200
1201 let client = DexClient::with_base_url(&server.url());
1202 let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1203 assert_eq!(data.symbol, "WETH");
1204 assert!((data.price_usd - 2500.50).abs() < 0.01);
1205 assert!(data.volume_24h > 0.0);
1206 assert!(data.liquidity_usd > 0.0);
1207 assert_eq!(data.pairs.len(), 1);
1208 assert!(data.total_buys_24h > 0);
1209 assert!(data.total_sells_24h > 0);
1210 assert!(!data.price_history.is_empty());
1211 assert!(!data.volume_history.is_empty());
1212 }
1213
1214 #[tokio::test]
1215 async fn test_get_token_data_no_pairs() {
1216 let mut server = mockito::Server::new_async().await;
1217 let _mock = server
1218 .mock(
1219 "GET",
1220 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1221 )
1222 .with_status(200)
1223 .with_header("content-type", "application/json")
1224 .with_body(r#"{"pairs":[]}"#)
1225 .create_async()
1226 .await;
1227
1228 let client = DexClient::with_base_url(&server.url());
1229 let result = client.get_token_data("ethereum", "0xunknown").await;
1230 assert!(result.is_err());
1231 assert!(result.unwrap_err().to_string().contains("No DEX pairs"));
1232 }
1233
1234 #[tokio::test]
1235 async fn test_get_token_data_api_error() {
1236 let mut server = mockito::Server::new_async().await;
1237 let _mock = server
1238 .mock(
1239 "GET",
1240 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1241 )
1242 .with_status(500)
1243 .create_async()
1244 .await;
1245
1246 let client = DexClient::with_base_url(&server.url());
1247 let result = client.get_token_data("ethereum", "0xtoken").await;
1248 assert!(result.is_err());
1249 }
1250
1251 #[tokio::test]
1252 async fn test_get_token_data_fallback_to_all_pairs() {
1253 let mut server = mockito::Server::new_async().await;
1255 let pair = build_test_pair_json("bsc", "TOKEN", "0xtoken", "1.00");
1256 let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1257 let _mock = server
1258 .mock(
1259 "GET",
1260 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1261 )
1262 .with_status(200)
1263 .with_header("content-type", "application/json")
1264 .with_body(&body)
1265 .create_async()
1266 .await;
1267
1268 let client = DexClient::with_base_url(&server.url());
1269 let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1271 assert_eq!(data.symbol, "TOKEN");
1272 }
1273
1274 #[tokio::test]
1275 async fn test_get_token_data_multiple_pairs() {
1276 let mut server = mockito::Server::new_async().await;
1277 let pair1 = build_test_pair_json("ethereum", "WETH", "0xtoken", "2500.00");
1278 let pair2 = build_test_pair_json("ethereum", "WETH", "0xtoken", "2501.00");
1279 let body = format!(r#"{{"pairs":[{},{}]}}"#, pair1, pair2);
1280 let _mock = server
1281 .mock(
1282 "GET",
1283 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1284 )
1285 .with_status(200)
1286 .with_header("content-type", "application/json")
1287 .with_body(&body)
1288 .create_async()
1289 .await;
1290
1291 let client = DexClient::with_base_url(&server.url());
1292 let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1293 assert_eq!(data.pairs.len(), 2);
1294 assert!(data.price_usd > 2499.0 && data.price_usd < 2502.0);
1296 }
1297
1298 #[tokio::test]
1299 async fn test_get_token_price() {
1300 let mut server = mockito::Server::new_async().await;
1301 let pair = build_test_pair_json("ethereum", "WETH", "0xtoken", "2500.50");
1302 let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1303 let _mock = server
1304 .mock(
1305 "GET",
1306 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1307 )
1308 .with_status(200)
1309 .with_header("content-type", "application/json")
1310 .with_body(&body)
1311 .create_async()
1312 .await;
1313
1314 let client = DexClient::with_base_url(&server.url());
1315 let price = client.get_token_price("ethereum", "0xtoken").await;
1316 assert!(price.is_some());
1317 assert!((price.unwrap() - 2500.50).abs() < 0.01);
1318 }
1319
1320 #[tokio::test]
1321 async fn test_get_token_price_not_found() {
1322 let mut server = mockito::Server::new_async().await;
1323 let _mock = server
1324 .mock(
1325 "GET",
1326 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1327 )
1328 .with_status(200)
1329 .with_header("content-type", "application/json")
1330 .with_body(r#"{"pairs":null}"#)
1331 .create_async()
1332 .await;
1333
1334 let client = DexClient::with_base_url(&server.url());
1335 let price = client.get_token_price("ethereum", "0xunknown").await;
1336 assert!(price.is_none());
1337 }
1338
1339 #[tokio::test]
1340 async fn test_search_tokens_success() {
1341 let mut server = mockito::Server::new_async().await;
1342 let pair = build_test_pair_json("ethereum", "USDC", "0xusdc", "1.00");
1343 let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1344 let _mock = server
1345 .mock(
1346 "GET",
1347 mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1348 )
1349 .with_status(200)
1350 .with_header("content-type", "application/json")
1351 .with_body(&body)
1352 .create_async()
1353 .await;
1354
1355 let client = DexClient::with_base_url(&server.url());
1356 let results = client.search_tokens("USDC", None).await.unwrap();
1357 assert!(!results.is_empty());
1358 assert_eq!(results[0].symbol, "USDC");
1359 }
1360
1361 #[tokio::test]
1362 async fn test_search_tokens_with_chain_filter() {
1363 let mut server = mockito::Server::new_async().await;
1364 let pair_eth = build_test_pair_json("ethereum", "USDC", "0xusdc_eth", "1.00");
1365 let pair_bsc = build_test_pair_json("bsc", "USDC", "0xusdc_bsc", "1.00");
1366 let body = format!(r#"{{"pairs":[{},{}]}}"#, pair_eth, pair_bsc);
1367 let _mock = server
1368 .mock(
1369 "GET",
1370 mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1371 )
1372 .with_status(200)
1373 .with_header("content-type", "application/json")
1374 .with_body(&body)
1375 .create_async()
1376 .await;
1377
1378 let client = DexClient::with_base_url(&server.url());
1379 let results = client
1380 .search_tokens("USDC", Some("ethereum"))
1381 .await
1382 .unwrap();
1383 assert_eq!(results.len(), 1);
1384 assert_eq!(results[0].chain, "ethereum");
1385 }
1386
1387 #[tokio::test]
1388 async fn test_search_tokens_empty() {
1389 let mut server = mockito::Server::new_async().await;
1390 let _mock = server
1391 .mock(
1392 "GET",
1393 mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1394 )
1395 .with_status(200)
1396 .with_header("content-type", "application/json")
1397 .with_body(r#"{"pairs":[]}"#)
1398 .create_async()
1399 .await;
1400
1401 let client = DexClient::with_base_url(&server.url());
1402 let results = client.search_tokens("XYZNONEXIST", None).await.unwrap();
1403 assert!(results.is_empty());
1404 }
1405
1406 #[tokio::test]
1407 async fn test_search_tokens_api_error() {
1408 let mut server = mockito::Server::new_async().await;
1409 let _mock = server
1410 .mock(
1411 "GET",
1412 mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1413 )
1414 .with_status(429)
1415 .create_async()
1416 .await;
1417
1418 let client = DexClient::with_base_url(&server.url());
1419 let result = client.search_tokens("USDC", None).await;
1420 assert!(result.is_err());
1421 }
1422
1423 #[test]
1424 fn test_generate_price_history() {
1425 let pair_json = r#"{
1426 "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair",
1427 "baseToken":{"address":"0xtoken","name":"Token","symbol":"TKN"},
1428 "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1429 "priceUsd":"100.0",
1430 "priceChange":{"h24":10.0,"h6":5.0,"h1":1.0,"m5":0.5}
1431 }"#;
1432 let pair: DexScreenerPair = serde_json::from_str(pair_json).unwrap();
1433 let history = DexClient::generate_price_history(100.0, &pair, 1700000000);
1434 assert!(!history.is_empty());
1435 assert!(history.iter().any(|p| (p.price - 100.0).abs() < 0.001));
1437 }
1438
1439 #[test]
1440 fn test_chain_mapping_all_variants() {
1441 assert_eq!(DexClient::map_chain_to_dexscreener("eth"), "ethereum");
1443 assert_eq!(DexClient::map_chain_to_dexscreener("matic"), "polygon");
1444 assert_eq!(DexClient::map_chain_to_dexscreener("arb"), "arbitrum");
1445 assert_eq!(DexClient::map_chain_to_dexscreener("op"), "optimism");
1446 assert_eq!(DexClient::map_chain_to_dexscreener("base"), "base");
1447 assert_eq!(DexClient::map_chain_to_dexscreener("bnb"), "bsc");
1448 assert_eq!(DexClient::map_chain_to_dexscreener("sol"), "solana");
1449 assert_eq!(DexClient::map_chain_to_dexscreener("avax"), "avalanche");
1450 assert_eq!(DexClient::map_chain_to_dexscreener("unknown"), "unknown");
1451 }
1452
1453 #[tokio::test]
1454 async fn test_get_native_token_price_ethereum() {
1455 let mut server = mockito::Server::new_async().await;
1456 let _mock = server
1457 .mock("GET", mockito::Matcher::Any)
1458 .with_status(200)
1459 .with_header("content-type", "application/json")
1460 .with_body(r#"{"pairs":[{
1461 "chainId":"ethereum",
1462 "dexId":"uniswap",
1463 "pairAddress":"0xpair",
1464 "baseToken":{"address":"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2","name":"WETH","symbol":"WETH"},
1465 "quoteToken":{"address":"0xusdt","name":"USDT","symbol":"USDT"},
1466 "priceUsd":"3500.00"
1467 }]}"#)
1468 .create_async()
1469 .await;
1470
1471 let client = DexClient::with_base_url(&server.url());
1472 let price = client.get_native_token_price("ethereum").await;
1473 assert!(price.is_some());
1474 assert!((price.unwrap() - 3500.0).abs() < 0.01);
1475 }
1476
1477 #[tokio::test]
1478 async fn test_get_native_token_price_tron_returns_none() {
1479 let client = DexClient::with_base_url("http://localhost:1");
1480 let price = client.get_native_token_price("tron").await;
1481 assert!(price.is_none());
1482 }
1483
1484 #[tokio::test]
1485 async fn test_get_native_token_price_unknown_chain() {
1486 let client = DexClient::with_base_url("http://localhost:1");
1487 let price = client.get_native_token_price("unknownchain").await;
1488 assert!(price.is_none());
1489 }
1490
1491 #[tokio::test]
1492 async fn test_search_tokens_chain_filter_ethereum_only() {
1493 let mut server = mockito::Server::new_async().await;
1494 let _mock = server
1495 .mock("GET", mockito::Matcher::Any)
1496 .with_status(200)
1497 .with_header("content-type", "application/json")
1498 .with_body(
1499 r#"{"pairs":[
1500 {
1501 "chainId":"ethereum",
1502 "dexId":"uniswap",
1503 "pairAddress":"0xpair1",
1504 "baseToken":{"address":"0xtoken1","name":"USD Coin","symbol":"USDC"},
1505 "quoteToken":{"address":"0xweth","name":"WETH","symbol":"WETH"},
1506 "priceUsd":"1.00",
1507 "liquidity":{"usd":5000000.0},
1508 "volume":{"h24":1000000.0}
1509 },
1510 {
1511 "chainId":"bsc",
1512 "dexId":"pancakeswap",
1513 "pairAddress":"0xpair2",
1514 "baseToken":{"address":"0xtoken2","name":"Binance USD","symbol":"BUSD"},
1515 "quoteToken":{"address":"0xbnb","name":"BNB","symbol":"BNB"},
1516 "priceUsd":"1.00",
1517 "liquidity":{"usd":2000000.0},
1518 "volume":{"h24":500000.0}
1519 }
1520 ]}"#,
1521 )
1522 .create_async()
1523 .await;
1524
1525 let client = DexClient::with_base_url(&server.url());
1526 let results = client.search_tokens("USD", Some("ethereum")).await.unwrap();
1528 assert!(!results.is_empty());
1529 for r in &results {
1531 assert_eq!(r.chain.to_lowercase(), "ethereum");
1532 }
1533 }
1534
1535 #[tokio::test]
1536 async fn test_search_tokens_aggregates_volume_and_liquidity() {
1537 let mut server = mockito::Server::new_async().await;
1538 let _mock = server
1539 .mock("GET", mockito::Matcher::Any)
1540 .with_status(200)
1541 .with_header("content-type", "application/json")
1542 .with_body(
1543 r#"{"pairs":[
1544 {
1545 "chainId":"ethereum",
1546 "dexId":"uniswap",
1547 "pairAddress":"0xpair1",
1548 "baseToken":{"address":"0xSameToken","name":"Test Token","symbol":"TEST"},
1549 "quoteToken":{"address":"0xweth","name":"WETH","symbol":"WETH"},
1550 "priceUsd":"10.00",
1551 "liquidity":{"usd":1000000.0},
1552 "volume":{"h24":100000.0}
1553 },
1554 {
1555 "chainId":"ethereum",
1556 "dexId":"sushiswap",
1557 "pairAddress":"0xpair2",
1558 "baseToken":{"address":"0xSameToken","name":"Test Token","symbol":"TEST"},
1559 "quoteToken":{"address":"0xusdc","name":"USDC","symbol":"USDC"},
1560 "priceUsd":"10.05",
1561 "liquidity":{"usd":500000.0},
1562 "volume":{"h24":50000.0}
1563 }
1564 ]}"#,
1565 )
1566 .create_async()
1567 .await;
1568
1569 let client = DexClient::with_base_url(&server.url());
1570 let results = client.search_tokens("TEST", None).await.unwrap();
1571 assert_eq!(results.len(), 1); assert!(results[0].volume_24h > 100000.0);
1574 assert!(results[0].liquidity_usd > 1000000.0);
1575 }
1576
1577 #[tokio::test]
1578 async fn test_dex_data_source_trait_methods() {
1579 let mut server = mockito::Server::new_async().await;
1580 let _mock = server
1581 .mock("GET", mockito::Matcher::Any)
1582 .with_status(200)
1583 .with_header("content-type", "application/json")
1584 .with_body(
1585 r#"{"pairs":[{
1586 "chainId":"ethereum",
1587 "dexId":"uniswap",
1588 "pairAddress":"0xpair",
1589 "baseToken":{"address":"0xtoken","name":"Token","symbol":"TKN"},
1590 "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1591 "priceUsd":"50.0",
1592 "liquidity":{"usd":1000000.0},
1593 "volume":{"h24":100000.0}
1594 }]}"#,
1595 )
1596 .create_async()
1597 .await;
1598
1599 let client = DexClient::with_base_url(&server.url());
1600 let trait_client: &dyn DexDataSource = &client;
1602 let price = trait_client.get_token_price("ethereum", "0xtoken").await;
1603 assert!(price.is_some());
1604 }
1605
1606 #[tokio::test]
1607 async fn test_dex_data_source_trait_get_native_token_price() {
1608 let mut server = mockito::Server::new_async().await;
1609 let _mock = server
1610 .mock("GET", mockito::Matcher::Any)
1611 .with_status(200)
1612 .with_header("content-type", "application/json")
1613 .with_body(
1614 r#"{"pairs":[{
1615 "chainId":"ethereum",
1616 "dexId":"uniswap",
1617 "pairAddress":"0xpair",
1618 "baseToken":{"address":"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2","name":"WETH","symbol":"WETH"},
1619 "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1620 "priceUsd":"3500.0",
1621 "liquidity":{"usd":10000000.0},
1622 "volume":{"h24":5000000.0}
1623 }]}"#,
1624 )
1625 .create_async()
1626 .await;
1627
1628 let client = DexClient::with_base_url(&server.url());
1629 let trait_client: &dyn DexDataSource = &client;
1630 let price = trait_client.get_native_token_price("ethereum").await;
1631 assert!(price.is_some());
1632 }
1633
1634 #[tokio::test]
1635 async fn test_dex_data_source_trait_get_token_data() {
1636 let mut server = mockito::Server::new_async().await;
1637 let pair_json = build_test_pair_json("ethereum", "TKN", "0xtoken", "50.0");
1638 let _mock = server
1639 .mock("GET", mockito::Matcher::Any)
1640 .with_status(200)
1641 .with_header("content-type", "application/json")
1642 .with_body(format!(r#"{{"pairs":[{}]}}"#, pair_json))
1643 .create_async()
1644 .await;
1645
1646 let client = DexClient::with_base_url(&server.url());
1647 let trait_client: &dyn DexDataSource = &client;
1648 let data = trait_client.get_token_data("ethereum", "0xtoken").await;
1649 assert!(data.is_ok());
1650 }
1651
1652 #[tokio::test]
1653 async fn test_dex_data_source_trait_search_tokens() {
1654 let mut server = mockito::Server::new_async().await;
1655 let pair_json = build_test_pair_json("ethereum", "TKN", "0xtoken", "50.0");
1656 let _mock = server
1657 .mock("GET", mockito::Matcher::Any)
1658 .with_status(200)
1659 .with_header("content-type", "application/json")
1660 .with_body(format!(r#"{{"pairs":[{}]}}"#, pair_json))
1661 .create_async()
1662 .await;
1663
1664 let client = DexClient::with_base_url(&server.url());
1665 let trait_client: &dyn DexDataSource = &client;
1666 let results = trait_client.search_tokens("TKN", None).await;
1667 assert!(results.is_ok());
1668 }
1669
1670 #[tokio::test]
1671 async fn test_get_token_data_quote_token() {
1672 let mut server = mockito::Server::new_async().await;
1673 let _mock = server
1675 .mock("GET", mockito::Matcher::Any)
1676 .with_status(200)
1677 .with_header("content-type", "application/json")
1678 .with_body(
1679 r#"{"pairs":[{
1680 "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair",
1681 "baseToken":{"address":"0xother","name":"Other","symbol":"OTH"},
1682 "quoteToken":{"address":"0xmytoken","name":"MyToken","symbol":"MTK"},
1683 "priceUsd":"25.0",
1684 "priceChange":{"h24":1.0,"h6":0.5,"h1":0.2,"m5":0.05},
1685 "volume":{"h24":500000,"h6":100000,"h1":20000,"m5":2000},
1686 "liquidity":{"usd":0,"base":0,"quote":0},
1687 "txns":{"h24":{"buys":50,"sells":40},"h6":{"buys":10,"sells":8},"h1":{"buys":2,"sells":1}},
1688 "pairCreatedAt":1690000000000,
1689 "url":"https://dexscreener.com/ethereum/0xpair"
1690 }]}"#,
1691 )
1692 .create_async()
1693 .await;
1694
1695 let client = DexClient::with_base_url(&server.url());
1696 let data = client
1697 .get_token_data("ethereum", "0xmytoken")
1698 .await
1699 .unwrap();
1700 assert_eq!(data.symbol, "MTK");
1702 assert_eq!(data.name, "MyToken");
1703 assert!(data.price_usd > 0.0);
1705 }
1706
1707 #[tokio::test]
1708 async fn test_get_token_data_with_socials() {
1709 let mut server = mockito::Server::new_async().await;
1710 let _mock = server
1711 .mock("GET", mockito::Matcher::Any)
1712 .with_status(200)
1713 .with_header("content-type", "application/json")
1714 .with_body(
1715 r#"{"pairs":[{
1716 "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair",
1717 "baseToken":{"address":"0xtoken","name":"Token","symbol":"TKN"},
1718 "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1719 "priceUsd":"50.0",
1720 "priceChange":{"h24":5.0,"h6":2.0,"h1":1.0,"m5":0.1},
1721 "volume":{"h24":1000000,"h6":250000,"h1":50000,"m5":5000},
1722 "liquidity":{"usd":1000000,"base":100,"quote":1000000},
1723 "txns":{"h24":{"buys":100,"sells":80},"h6":{"buys":20,"sells":15},"h1":{"buys":5,"sells":3}},
1724 "pairCreatedAt":1690000000000,
1725 "url":"https://dexscreener.com/ethereum/0xpair",
1726 "info":{
1727 "imageUrl":"https://example.com/logo.png",
1728 "websites":[{"url":"https://example.com"}],
1729 "socials":[
1730 {"type":"twitter","url":"https://twitter.com/token"},
1731 {"type":"telegram","url":"https://t.me/token"}
1732 ]
1733 }
1734 }]}"#,
1735 )
1736 .create_async()
1737 .await;
1738
1739 let client = DexClient::with_base_url(&server.url());
1740 let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1741 assert_eq!(data.symbol, "TKN");
1742 assert!(data.image_url.is_some());
1743 assert!(!data.websites.is_empty());
1744 assert!(!data.socials.is_empty());
1745 assert_eq!(data.socials[0].platform, "twitter");
1746 }
1747
1748 #[tokio::test]
1749 async fn test_search_tokens_quote_match_and_updates() {
1750 let mut server = mockito::Server::new_async().await;
1751 let _mock = server
1753 .mock("GET", mockito::Matcher::Any)
1754 .with_status(200)
1755 .with_header("content-type", "application/json")
1756 .with_body(
1757 r#"{"pairs":[
1758 {
1759 "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair1",
1760 "baseToken":{"address":"0xother","name":"Other","symbol":"OTH"},
1761 "quoteToken":{"address":"0xmytk","name":"MySearch","symbol":"MSR"},
1762 "liquidity":{"usd":500000.0},
1763 "volume":{"h24":100000.0},
1764 "marketCap":5000000
1765 },
1766 {
1767 "chainId":"ethereum","dexId":"sushi","pairAddress":"0xpair2",
1768 "baseToken":{"address":"0xmytk","name":"MySearch","symbol":"MSR"},
1769 "quoteToken":{"address":"0xweth","name":"WETH","symbol":"WETH"},
1770 "priceUsd":"10.5",
1771 "liquidity":{"usd":800000.0},
1772 "volume":{"h24":200000.0}
1773 }
1774 ]}"#,
1775 )
1776 .create_async()
1777 .await;
1778
1779 let client = DexClient::with_base_url(&server.url());
1780 let results = client.search_tokens("MySearch", None).await.unwrap();
1781 assert_eq!(results.len(), 1); assert_eq!(results[0].symbol, "MSR");
1783 assert!(results[0].volume_24h >= 300000.0);
1785 assert!(results[0].liquidity_usd >= 1300000.0);
1787 assert!(results[0].price_usd.is_some());
1789 assert!(results[0].market_cap.is_some());
1791 }
1792
1793 #[test]
1794 fn test_interpolate_points_midpoint() {
1795 let mut history = vec![
1796 PricePoint {
1797 timestamp: 1000,
1798 price: 10.0,
1799 },
1800 PricePoint {
1801 timestamp: 2000,
1802 price: 20.0,
1803 },
1804 ];
1805 DexClient::interpolate_points(&mut history, 2);
1807 assert_eq!(history.len(), 2);
1808
1809 DexClient::interpolate_points(&mut history, 5);
1811 assert!(history.len() > 2);
1812 let midpoints: Vec<_> = history.iter().filter(|p| p.timestamp == 1500).collect();
1814 assert!(!midpoints.is_empty());
1815 assert!((midpoints[0].price - 15.0).abs() < 0.01);
1816 }
1817
1818 fn discover_token_json() -> &'static str {
1819 r#"[
1820 {"chainId":"ethereum","tokenAddress":"0xabc","url":"https://dexscreener.com/ethereum/0xabc","description":"Test token","links":[{"label":"Twitter","type":"twitter","url":"https://twitter.com/test"}]},
1821 {"chainId":"solana","tokenAddress":"So11111111111111111111111111111111111111112","url":"https://dexscreener.com/solana/So11","links":[]}
1822 ]"#
1823 }
1824
1825 #[tokio::test]
1826 async fn test_get_token_profiles() {
1827 let mut server = mockito::Server::new_async().await;
1828 let _mock = server
1829 .mock("GET", "/token-profiles/latest/v1")
1830 .with_status(200)
1831 .with_header("content-type", "application/json")
1832 .with_body(discover_token_json())
1833 .create_async()
1834 .await;
1835
1836 let client = DexClient::with_base_url(&server.url());
1837 let tokens = client.get_token_profiles().await.unwrap();
1838 assert_eq!(tokens.len(), 2);
1839 assert_eq!(tokens[0].chain_id, "ethereum");
1840 assert_eq!(tokens[0].token_address, "0xabc");
1841 assert_eq!(tokens[0].description.as_deref(), Some("Test token"));
1842 assert_eq!(tokens[0].links.len(), 1);
1843 assert_eq!(tokens[1].chain_id, "solana");
1844 }
1845
1846 #[tokio::test]
1847 async fn test_get_token_boosts() {
1848 let mut server = mockito::Server::new_async().await;
1849 let _mock = server
1850 .mock("GET", "/token-boosts/latest/v1")
1851 .with_status(200)
1852 .with_header("content-type", "application/json")
1853 .with_body(discover_token_json())
1854 .create_async()
1855 .await;
1856
1857 let client = DexClient::with_base_url(&server.url());
1858 let tokens = client.get_token_boosts().await.unwrap();
1859 assert_eq!(tokens.len(), 2);
1860 }
1861
1862 #[tokio::test]
1863 async fn test_get_token_boosts_top() {
1864 let mut server = mockito::Server::new_async().await;
1865 let _mock = server
1866 .mock("GET", "/token-boosts/top/v1")
1867 .with_status(200)
1868 .with_header("content-type", "application/json")
1869 .with_body(discover_token_json())
1870 .create_async()
1871 .await;
1872
1873 let client = DexClient::with_base_url(&server.url());
1874 let tokens = client.get_token_boosts_top().await.unwrap();
1875 assert_eq!(tokens.len(), 2);
1876 }
1877
1878 #[tokio::test]
1879 async fn test_fetch_discover_tokens_api_error() {
1880 let mut server = mockito::Server::new_async().await;
1881 let _mock = server
1882 .mock("GET", mockito::Matcher::Any)
1883 .with_status(500)
1884 .create_async()
1885 .await;
1886
1887 let client = DexClient::with_base_url(&server.url());
1888 let result = client.get_token_profiles().await;
1889 assert!(result.is_err());
1890 }
1891
1892 #[tokio::test]
1893 async fn test_fetch_discover_tokens_empty_array() {
1894 let mut server = mockito::Server::new_async().await;
1895 let _mock = server
1896 .mock("GET", "/token-profiles/latest/v1")
1897 .with_status(200)
1898 .with_header("content-type", "application/json")
1899 .with_body("[]")
1900 .create_async()
1901 .await;
1902
1903 let client = DexClient::with_base_url(&server.url());
1904 let tokens = client.get_token_profiles().await.unwrap();
1905 assert!(tokens.is_empty());
1906 }
1907
1908 #[tokio::test]
1909 async fn test_fetch_discover_tokens_filters_invalid_entries() {
1910 let body = r#"[{"chainId":"ethereum","url":"https://example.com"},{"chainId":"solana","tokenAddress":"0xvalid","url":"https://dexscreener.com/solana/0xvalid"}]"#;
1912 let mut server = mockito::Server::new_async().await;
1913 let _mock = server
1914 .mock("GET", "/token-profiles/latest/v1")
1915 .with_status(200)
1916 .with_header("content-type", "application/json")
1917 .with_body(body)
1918 .create_async()
1919 .await;
1920
1921 let client = DexClient::with_base_url(&server.url());
1922 let tokens = client.get_token_profiles().await.unwrap();
1923 assert_eq!(tokens.len(), 1);
1924 assert_eq!(tokens[0].token_address, "0xvalid");
1925 }
1926}