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