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