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