1use crate::chains::{DexPair, PricePoint, VolumePoint};
44use crate::error::{Result, ScopeError};
45use async_trait::async_trait;
46use reqwest::Client;
47use serde::Deserialize;
48use std::time::Duration;
49
50const DEXSCREENER_API_BASE: &str = "https://api.dexscreener.com";
52
53#[async_trait]
57pub trait DexDataSource: Send + Sync {
58 async fn get_token_price(&self, chain: &str, address: &str) -> Option<f64>;
60
61 async fn get_native_token_price(&self, chain: &str) -> Option<f64>;
63
64 async fn get_token_data(&self, chain: &str, address: &str) -> Result<DexTokenData>;
66
67 async fn search_tokens(
69 &self,
70 query: &str,
71 chain: Option<&str>,
72 ) -> Result<Vec<TokenSearchResult>>;
73}
74
75#[derive(Debug, Clone)]
77pub struct DexClient {
78 http: Client,
79 base_url: String,
80}
81
82#[derive(Debug, Deserialize)]
84struct DexScreenerTokenResponse {
85 pairs: Option<Vec<DexScreenerPair>>,
86}
87
88#[derive(Debug, Deserialize)]
90#[serde(rename_all = "camelCase")]
91struct DexScreenerPair {
92 chain_id: String,
93 dex_id: String,
94 pair_address: String,
95 base_token: DexScreenerToken,
96 quote_token: DexScreenerToken,
97 #[serde(default)]
98 price_usd: Option<String>,
99 #[serde(default)]
100 price_change: Option<DexScreenerPriceChange>,
101 #[serde(default)]
102 volume: Option<DexScreenerVolume>,
103 #[serde(default)]
104 liquidity: Option<DexScreenerLiquidity>,
105 #[serde(default)]
106 fdv: Option<f64>,
107 #[serde(default)]
108 market_cap: Option<f64>,
109 #[serde(default)]
111 url: Option<String>,
112 #[serde(default)]
114 pair_created_at: Option<i64>,
115 #[serde(default)]
117 txns: Option<DexScreenerTxns>,
118 #[serde(default)]
120 info: Option<DexScreenerInfo>,
121}
122
123#[derive(Debug, Deserialize)]
125struct DexScreenerToken {
126 address: String,
127 name: String,
128 symbol: String,
129}
130
131#[derive(Debug, Deserialize)]
133struct DexScreenerPriceChange {
134 h24: Option<f64>,
135 h6: Option<f64>,
136 h1: Option<f64>,
137 m5: Option<f64>,
138}
139
140#[derive(Debug, Deserialize)]
142#[allow(dead_code)]
143struct DexScreenerVolume {
144 h24: Option<f64>,
145 h6: Option<f64>,
146 h1: Option<f64>,
147 m5: Option<f64>,
148}
149
150#[derive(Debug, Deserialize)]
152#[allow(dead_code)]
153struct DexScreenerLiquidity {
154 usd: Option<f64>,
155 base: Option<f64>,
156 quote: Option<f64>,
157}
158
159#[derive(Debug, Deserialize, Default)]
161#[allow(dead_code)]
162struct DexScreenerTxns {
163 #[serde(default)]
164 h24: Option<TxnCounts>,
165 #[serde(default)]
166 h6: Option<TxnCounts>,
167 #[serde(default)]
168 h1: Option<TxnCounts>,
169 #[serde(default)]
170 m5: Option<TxnCounts>,
171}
172
173#[derive(Debug, Deserialize, Clone, Default)]
175struct TxnCounts {
176 #[serde(default)]
177 buys: u64,
178 #[serde(default)]
179 sells: u64,
180}
181
182#[derive(Debug, Deserialize, Default)]
184#[serde(rename_all = "camelCase")]
185struct DexScreenerInfo {
186 #[serde(default)]
187 image_url: Option<String>,
188 #[serde(default)]
189 websites: Option<Vec<DexScreenerWebsite>>,
190 #[serde(default)]
191 socials: Option<Vec<DexScreenerSocial>>,
192}
193
194#[derive(Debug, Deserialize, Clone)]
196#[allow(dead_code)]
197struct DexScreenerWebsite {
198 #[serde(default)]
199 label: Option<String>,
200 #[serde(default)]
201 url: Option<String>,
202}
203
204#[derive(Debug, Deserialize, Clone)]
206struct DexScreenerSocial {
207 #[serde(rename = "type", default)]
208 platform: Option<String>,
209 #[serde(default)]
210 url: Option<String>,
211}
212
213#[derive(Debug, Clone)]
215pub struct DexTokenData {
216 pub address: String,
218
219 pub symbol: String,
221
222 pub name: String,
224
225 pub price_usd: f64,
227
228 pub price_change_24h: f64,
230
231 pub price_change_6h: f64,
233
234 pub price_change_1h: f64,
236
237 pub price_change_5m: f64,
239
240 pub volume_24h: f64,
242
243 pub volume_6h: f64,
245
246 pub volume_1h: f64,
248
249 pub liquidity_usd: f64,
251
252 pub market_cap: Option<f64>,
254
255 pub fdv: Option<f64>,
257
258 pub pairs: Vec<DexPair>,
260
261 pub price_history: Vec<PricePoint>,
263
264 pub volume_history: Vec<VolumePoint>,
266
267 pub total_buys_24h: u64,
269
270 pub total_sells_24h: u64,
272
273 pub total_buys_6h: u64,
275
276 pub total_sells_6h: u64,
278
279 pub total_buys_1h: u64,
281
282 pub total_sells_1h: u64,
284
285 pub earliest_pair_created_at: Option<i64>,
287
288 pub image_url: Option<String>,
290
291 pub websites: Vec<String>,
293
294 pub socials: Vec<TokenSocial>,
296
297 pub dexscreener_url: Option<String>,
299}
300
301#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
303pub struct TokenSocial {
304 pub platform: String,
306 pub url: String,
308}
309
310#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
312pub struct TokenSearchResult {
313 pub address: String,
315
316 pub symbol: String,
318
319 pub name: String,
321
322 pub chain: String,
324
325 pub price_usd: Option<f64>,
327
328 pub volume_24h: f64,
330
331 pub liquidity_usd: f64,
333
334 pub market_cap: Option<f64>,
336}
337
338#[derive(Debug, Deserialize)]
340struct DexScreenerSearchResponse {
341 pairs: Option<Vec<DexScreenerPair>>,
342}
343
344impl DexClient {
345 pub fn new() -> Self {
347 let http = Client::builder()
348 .timeout(Duration::from_secs(30))
349 .build()
350 .expect("Failed to build HTTP client");
351
352 Self {
353 http,
354 base_url: DEXSCREENER_API_BASE.to_string(),
355 }
356 }
357
358 #[cfg(test)]
360 fn with_base_url(base_url: &str) -> Self {
361 Self {
362 http: Client::new(),
363 base_url: base_url.to_string(),
364 }
365 }
366
367 fn map_chain_to_dexscreener(chain: &str) -> String {
369 match chain.to_lowercase().as_str() {
370 "ethereum" | "eth" => "ethereum".to_string(),
371 "polygon" | "matic" => "polygon".to_string(),
372 "arbitrum" | "arb" => "arbitrum".to_string(),
373 "optimism" | "op" => "optimism".to_string(),
374 "base" => "base".to_string(),
375 "bsc" | "bnb" => "bsc".to_string(),
376 "solana" | "sol" => "solana".to_string(),
377 "avalanche" | "avax" => "avalanche".to_string(),
378 _ => chain.to_lowercase(),
379 }
380 }
381
382 pub async fn get_token_price(&self, chain: &str, token_address: &str) -> Option<f64> {
386 let url = format!("{}/latest/dex/tokens/{}", self.base_url, token_address);
387
388 let response = self.http.get(&url).send().await.ok()?;
389 let dex_response: DexScreenerTokenResponse = response.json().await.ok()?;
390
391 let dex_chain = Self::map_chain_to_dexscreener(chain);
392
393 dex_response
394 .pairs
395 .as_ref()?
396 .iter()
397 .filter(|p| p.chain_id.to_lowercase() == dex_chain)
398 .filter_map(|p| p.price_usd.as_ref()?.parse::<f64>().ok())
399 .next()
400 }
401
402 pub async fn get_native_token_price(&self, chain: &str) -> Option<f64> {
406 let (search_chain, token_address) = match chain.to_lowercase().as_str() {
407 "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,
416 };
417
418 self.get_token_price(search_chain, token_address).await
419 }
420
421 pub async fn get_token_data(&self, chain: &str, token_address: &str) -> Result<DexTokenData> {
432 let url = format!("{}/latest/dex/tokens/{}", self.base_url, token_address);
433
434 tracing::debug!(url = %url, "Fetching token data from DexScreener");
435
436 let response = self
437 .http
438 .get(&url)
439 .send()
440 .await
441 .map_err(|e| ScopeError::Network(e.to_string()))?;
442
443 if !response.status().is_success() {
444 return Err(ScopeError::Api(format!(
445 "DexScreener API error: {}",
446 response.status()
447 )));
448 }
449
450 let data: DexScreenerTokenResponse = response
451 .json()
452 .await
453 .map_err(|e| ScopeError::Api(format!("Failed to parse DexScreener response: {}", e)))?;
454
455 let pairs = data.pairs.unwrap_or_default();
456
457 if pairs.is_empty() {
458 return Err(ScopeError::NotFound(format!(
459 "No DEX pairs found for token {}",
460 token_address
461 )));
462 }
463
464 let chain_id = Self::map_chain_to_dexscreener(chain);
466 let chain_pairs: Vec<_> = pairs
467 .iter()
468 .filter(|p| p.chain_id.to_lowercase() == chain_id)
469 .collect();
470
471 let relevant_pairs = if chain_pairs.is_empty() {
473 pairs.iter().collect()
474 } else {
475 chain_pairs
476 };
477
478 let first_pair = &relevant_pairs[0];
480 let is_base_token =
481 first_pair.base_token.address.to_lowercase() == token_address.to_lowercase();
482 let token_info = if is_base_token {
483 &first_pair.base_token
484 } else {
485 &first_pair.quote_token
486 };
487
488 let mut total_volume_24h = 0.0;
490 let mut total_volume_6h = 0.0;
491 let mut total_volume_1h = 0.0;
492 let mut total_liquidity = 0.0;
493 let mut weighted_price_sum = 0.0;
494 let mut liquidity_weight_sum = 0.0;
495 let mut dex_pairs = Vec::new();
496
497 for pair in &relevant_pairs {
498 let pair_liquidity = pair.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0);
499
500 let pair_price = pair
501 .price_usd
502 .as_ref()
503 .and_then(|p| p.parse::<f64>().ok())
504 .unwrap_or(0.0);
505
506 if let Some(vol) = &pair.volume {
507 total_volume_24h += vol.h24.unwrap_or(0.0);
508 total_volume_6h += vol.h6.unwrap_or(0.0);
509 total_volume_1h += vol.h1.unwrap_or(0.0);
510 }
511
512 total_liquidity += pair_liquidity;
513
514 if pair_liquidity > 0.0 && pair_price > 0.0 {
516 weighted_price_sum += pair_price * pair_liquidity;
517 liquidity_weight_sum += pair_liquidity;
518 }
519
520 let price_change = pair
521 .price_change
522 .as_ref()
523 .and_then(|pc| pc.h24)
524 .unwrap_or(0.0);
525
526 let txn_counts_24h = pair.txns.as_ref().and_then(|t| t.h24.clone());
528 let txn_counts_6h = pair.txns.as_ref().and_then(|t| t.h6.clone());
529 let txn_counts_1h = pair.txns.as_ref().and_then(|t| t.h1.clone());
530
531 dex_pairs.push(DexPair {
532 dex_name: pair.dex_id.clone(),
533 pair_address: pair.pair_address.clone(),
534 base_token: pair.base_token.symbol.clone(),
535 quote_token: pair.quote_token.symbol.clone(),
536 price_usd: pair_price,
537 volume_24h: pair.volume.as_ref().and_then(|v| v.h24).unwrap_or(0.0),
538 liquidity_usd: pair_liquidity,
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 response = self
698 .http
699 .get(&url)
700 .send()
701 .await
702 .map_err(|e| ScopeError::Network(e.to_string()))?;
703
704 if !response.status().is_success() {
705 return Err(ScopeError::Api(format!(
706 "DexScreener search API error: {}",
707 response.status()
708 )));
709 }
710
711 let data: DexScreenerSearchResponse = response
712 .json()
713 .await
714 .map_err(|e| ScopeError::Api(format!("Failed to parse search response: {}", e)))?;
715
716 let pairs = data.pairs.unwrap_or_default();
717
718 if pairs.is_empty() {
719 return Ok(Vec::new());
720 }
721
722 let chain_id = chain.map(Self::map_chain_to_dexscreener);
724 let filtered_pairs: Vec<_> = if let Some(ref cid) = chain_id {
725 pairs
726 .iter()
727 .filter(|p| p.chain_id.to_lowercase() == *cid)
728 .collect()
729 } else {
730 pairs.iter().collect()
731 };
732
733 let mut token_map: std::collections::HashMap<String, TokenSearchResult> =
735 std::collections::HashMap::new();
736
737 for pair in filtered_pairs {
738 let base_matches = pair
740 .base_token
741 .symbol
742 .to_lowercase()
743 .contains(&query.to_lowercase())
744 || pair
745 .base_token
746 .name
747 .to_lowercase()
748 .contains(&query.to_lowercase());
749 let quote_matches = pair
750 .quote_token
751 .symbol
752 .to_lowercase()
753 .contains(&query.to_lowercase())
754 || pair
755 .quote_token
756 .name
757 .to_lowercase()
758 .contains(&query.to_lowercase());
759
760 let token_info = if base_matches {
761 &pair.base_token
762 } else if quote_matches {
763 &pair.quote_token
764 } else {
765 &pair.base_token
767 };
768
769 let key = format!("{}:{}", pair.chain_id, token_info.address.to_lowercase());
770
771 let pair_liquidity = pair.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0);
772
773 let pair_volume = pair.volume.as_ref().and_then(|v| v.h24).unwrap_or(0.0);
774
775 let pair_price = pair.price_usd.as_ref().and_then(|p| p.parse::<f64>().ok());
776
777 let entry = token_map.entry(key).or_insert_with(|| TokenSearchResult {
778 address: token_info.address.clone(),
779 symbol: token_info.symbol.clone(),
780 name: token_info.name.clone(),
781 chain: pair.chain_id.clone(),
782 price_usd: pair_price,
783 volume_24h: 0.0,
784 liquidity_usd: 0.0,
785 market_cap: pair.market_cap,
786 });
787
788 entry.volume_24h += pair_volume;
790 entry.liquidity_usd += pair_liquidity;
791
792 if entry.price_usd.is_none() && pair_price.is_some() {
794 entry.price_usd = pair_price;
795 }
796
797 if entry.market_cap.is_none() && pair.market_cap.is_some() {
799 entry.market_cap = pair.market_cap;
800 }
801 }
802
803 let mut results: Vec<TokenSearchResult> = token_map.into_values().collect();
805 results.sort_by(|a, b| {
806 b.liquidity_usd
807 .partial_cmp(&a.liquidity_usd)
808 .unwrap_or(std::cmp::Ordering::Equal)
809 });
810
811 results.truncate(20);
813
814 Ok(results)
815 }
816
817 fn generate_price_history(
819 current_price: f64,
820 pair: &DexScreenerPair,
821 now: i64,
822 ) -> Vec<PricePoint> {
823 let mut history = Vec::new();
824
825 let changes = pair.price_change.as_ref();
827 let change_24h = changes.and_then(|c| c.h24).unwrap_or(0.0) / 100.0;
828 let change_6h = changes.and_then(|c| c.h6).unwrap_or(0.0) / 100.0;
829 let change_1h = changes.and_then(|c| c.h1).unwrap_or(0.0) / 100.0;
830 let change_5m = changes.and_then(|c| c.m5).unwrap_or(0.0) / 100.0;
831
832 let price_24h_ago = current_price / (1.0 + change_24h);
834 let price_6h_ago = current_price / (1.0 + change_6h);
835 let price_1h_ago = current_price / (1.0 + change_1h);
836 let price_5m_ago = current_price / (1.0 + change_5m);
837
838 history.push(PricePoint {
840 timestamp: now - 86400, price: price_24h_ago,
842 });
843 history.push(PricePoint {
844 timestamp: now - 21600, price: price_6h_ago,
846 });
847 history.push(PricePoint {
848 timestamp: now - 3600, price: price_1h_ago,
850 });
851 history.push(PricePoint {
852 timestamp: now - 300, price: price_5m_ago,
854 });
855 history.push(PricePoint {
856 timestamp: now,
857 price: current_price,
858 });
859
860 Self::interpolate_points(&mut history, 24);
862
863 history.sort_by_key(|p| p.timestamp);
864 history
865 }
866
867 fn generate_volume_history(
869 volume_24h: f64,
870 volume_6h: f64,
871 volume_1h: f64,
872 now: i64,
873 ) -> Vec<VolumePoint> {
874 let mut history = Vec::new();
875
876 let hourly_avg = volume_24h / 24.0;
878
879 for i in 0..24 {
880 let timestamp = now - (23 - i) * 3600;
881 let hours_ago = 24 - i;
882
883 let volume = if hours_ago <= 1 {
885 volume_1h
886 } else if hours_ago <= 6 {
887 volume_6h / 6.0
888 } else {
889 hourly_avg * (0.8 + (i as f64 / 24.0) * 0.4)
891 };
892
893 history.push(VolumePoint { timestamp, volume });
894 }
895
896 history
897 }
898
899 fn interpolate_points(history: &mut Vec<PricePoint>, target_count: usize) {
901 if history.len() >= target_count {
902 return;
903 }
904
905 history.sort_by_key(|p| p.timestamp);
906
907 let mut interpolated = Vec::new();
908 for window in history.windows(2) {
909 let p1 = &window[0];
910 let p2 = &window[1];
911
912 interpolated.push(p1.clone());
913
914 let mid_timestamp = (p1.timestamp + p2.timestamp) / 2;
916 let mid_price = (p1.price + p2.price) / 2.0;
917 interpolated.push(PricePoint {
918 timestamp: mid_timestamp,
919 price: mid_price,
920 });
921 }
922
923 if let Some(last) = history.last() {
924 interpolated.push(last.clone());
925 }
926
927 *history = interpolated;
928 }
929
930 pub fn estimate_7d_volume(volume_24h: f64) -> f64 {
935 volume_24h * 7.0
937 }
938}
939
940impl Default for DexClient {
941 fn default() -> Self {
942 Self::new()
943 }
944}
945
946#[async_trait]
951impl DexDataSource for DexClient {
952 async fn get_token_price(&self, chain: &str, address: &str) -> Option<f64> {
953 self.get_token_price(chain, address).await
954 }
955
956 async fn get_native_token_price(&self, chain: &str) -> Option<f64> {
957 self.get_native_token_price(chain).await
958 }
959
960 async fn get_token_data(&self, chain: &str, address: &str) -> Result<DexTokenData> {
961 self.get_token_data(chain, address).await
962 }
963
964 async fn search_tokens(
965 &self,
966 query: &str,
967 chain: Option<&str>,
968 ) -> Result<Vec<TokenSearchResult>> {
969 self.search_tokens(query, chain).await
970 }
971}
972
973#[cfg(test)]
975fn build_test_pair_json(chain_id: &str, base_symbol: &str, base_addr: &str, price: &str) -> String {
976 format!(
977 r#"{{
978 "chainId":"{}","dexId":"uniswap","pairAddress":"0xpair",
979 "baseToken":{{"address":"{}","name":"{}","symbol":"{}"}},
980 "quoteToken":{{"address":"0xquote","name":"USDC","symbol":"USDC"}},
981 "priceUsd":"{}",
982 "priceChange":{{"h24":5.2,"h6":2.1,"h1":0.5,"m5":0.1}},
983 "volume":{{"h24":1000000,"h6":250000,"h1":50000,"m5":5000}},
984 "liquidity":{{"usd":500000,"base":100,"quote":500000}},
985 "fdv":10000000,"marketCap":8000000,
986 "txns":{{"h24":{{"buys":100,"sells":80}},"h6":{{"buys":20,"sells":15}},"h1":{{"buys":5,"sells":3}}}},
987 "pairCreatedAt":1690000000000,
988 "url":"https://dexscreener.com/ethereum/0xpair"
989 }}"#,
990 chain_id, base_addr, base_symbol, base_symbol, price
991 )
992}
993
994#[cfg(test)]
999mod tests {
1000 use super::*;
1001
1002 #[test]
1003 fn test_chain_mapping() {
1004 assert_eq!(
1005 DexClient::map_chain_to_dexscreener("ethereum"),
1006 "ethereum".to_string()
1007 );
1008 assert_eq!(
1009 DexClient::map_chain_to_dexscreener("ETH"),
1010 "ethereum".to_string()
1011 );
1012 assert_eq!(
1013 DexClient::map_chain_to_dexscreener("bsc"),
1014 "bsc".to_string()
1015 );
1016 assert_eq!(
1017 DexClient::map_chain_to_dexscreener("BNB"),
1018 "bsc".to_string()
1019 );
1020 assert_eq!(
1021 DexClient::map_chain_to_dexscreener("polygon"),
1022 "polygon".to_string()
1023 );
1024 assert_eq!(
1025 DexClient::map_chain_to_dexscreener("solana"),
1026 "solana".to_string()
1027 );
1028 }
1029
1030 #[test]
1031 fn test_estimate_7d_volume() {
1032 assert_eq!(DexClient::estimate_7d_volume(1_000_000.0), 7_000_000.0);
1033 assert_eq!(DexClient::estimate_7d_volume(0.0), 0.0);
1034 }
1035
1036 #[test]
1037 fn test_generate_volume_history() {
1038 let now = 1700000000;
1039 let history = DexClient::generate_volume_history(24000.0, 6000.0, 1000.0, now);
1040
1041 assert_eq!(history.len(), 24);
1042 assert!(history.iter().all(|v| v.volume >= 0.0));
1043 assert!(history.iter().all(|v| v.timestamp <= now));
1044 }
1045
1046 #[test]
1047 fn test_dex_client_default() {
1048 let _client = DexClient::default();
1049 }
1051
1052 #[test]
1053 fn test_interpolate_points() {
1054 let mut history = vec![
1055 PricePoint {
1056 timestamp: 0,
1057 price: 1.0,
1058 },
1059 PricePoint {
1060 timestamp: 100,
1061 price: 2.0,
1062 },
1063 ];
1064
1065 DexClient::interpolate_points(&mut history, 10);
1066
1067 assert!(history.len() > 2);
1068 assert!(history.iter().any(|p| p.timestamp == 50));
1070 }
1071
1072 #[tokio::test]
1077 async fn test_get_token_data_success() {
1078 let mut server = mockito::Server::new_async().await;
1079 let pair = build_test_pair_json("ethereum", "WETH", "0xtoken", "2500.50");
1080 let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1081 let _mock = server
1082 .mock(
1083 "GET",
1084 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1085 )
1086 .with_status(200)
1087 .with_header("content-type", "application/json")
1088 .with_body(&body)
1089 .create_async()
1090 .await;
1091
1092 let client = DexClient::with_base_url(&server.url());
1093 let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1094 assert_eq!(data.symbol, "WETH");
1095 assert!((data.price_usd - 2500.50).abs() < 0.01);
1096 assert!(data.volume_24h > 0.0);
1097 assert!(data.liquidity_usd > 0.0);
1098 assert_eq!(data.pairs.len(), 1);
1099 assert!(data.total_buys_24h > 0);
1100 assert!(data.total_sells_24h > 0);
1101 assert!(!data.price_history.is_empty());
1102 assert!(!data.volume_history.is_empty());
1103 }
1104
1105 #[tokio::test]
1106 async fn test_get_token_data_no_pairs() {
1107 let mut server = mockito::Server::new_async().await;
1108 let _mock = server
1109 .mock(
1110 "GET",
1111 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1112 )
1113 .with_status(200)
1114 .with_header("content-type", "application/json")
1115 .with_body(r#"{"pairs":[]}"#)
1116 .create_async()
1117 .await;
1118
1119 let client = DexClient::with_base_url(&server.url());
1120 let result = client.get_token_data("ethereum", "0xunknown").await;
1121 assert!(result.is_err());
1122 assert!(result.unwrap_err().to_string().contains("No DEX pairs"));
1123 }
1124
1125 #[tokio::test]
1126 async fn test_get_token_data_api_error() {
1127 let mut server = mockito::Server::new_async().await;
1128 let _mock = server
1129 .mock(
1130 "GET",
1131 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1132 )
1133 .with_status(500)
1134 .create_async()
1135 .await;
1136
1137 let client = DexClient::with_base_url(&server.url());
1138 let result = client.get_token_data("ethereum", "0xtoken").await;
1139 assert!(result.is_err());
1140 }
1141
1142 #[tokio::test]
1143 async fn test_get_token_data_fallback_to_all_pairs() {
1144 let mut server = mockito::Server::new_async().await;
1146 let pair = build_test_pair_json("bsc", "TOKEN", "0xtoken", "1.00");
1147 let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1148 let _mock = server
1149 .mock(
1150 "GET",
1151 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1152 )
1153 .with_status(200)
1154 .with_header("content-type", "application/json")
1155 .with_body(&body)
1156 .create_async()
1157 .await;
1158
1159 let client = DexClient::with_base_url(&server.url());
1160 let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1162 assert_eq!(data.symbol, "TOKEN");
1163 }
1164
1165 #[tokio::test]
1166 async fn test_get_token_data_multiple_pairs() {
1167 let mut server = mockito::Server::new_async().await;
1168 let pair1 = build_test_pair_json("ethereum", "WETH", "0xtoken", "2500.00");
1169 let pair2 = build_test_pair_json("ethereum", "WETH", "0xtoken", "2501.00");
1170 let body = format!(r#"{{"pairs":[{},{}]}}"#, pair1, pair2);
1171 let _mock = server
1172 .mock(
1173 "GET",
1174 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1175 )
1176 .with_status(200)
1177 .with_header("content-type", "application/json")
1178 .with_body(&body)
1179 .create_async()
1180 .await;
1181
1182 let client = DexClient::with_base_url(&server.url());
1183 let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1184 assert_eq!(data.pairs.len(), 2);
1185 assert!(data.price_usd > 2499.0 && data.price_usd < 2502.0);
1187 }
1188
1189 #[tokio::test]
1190 async fn test_get_token_price() {
1191 let mut server = mockito::Server::new_async().await;
1192 let pair = build_test_pair_json("ethereum", "WETH", "0xtoken", "2500.50");
1193 let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1194 let _mock = server
1195 .mock(
1196 "GET",
1197 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1198 )
1199 .with_status(200)
1200 .with_header("content-type", "application/json")
1201 .with_body(&body)
1202 .create_async()
1203 .await;
1204
1205 let client = DexClient::with_base_url(&server.url());
1206 let price = client.get_token_price("ethereum", "0xtoken").await;
1207 assert!(price.is_some());
1208 assert!((price.unwrap() - 2500.50).abs() < 0.01);
1209 }
1210
1211 #[tokio::test]
1212 async fn test_get_token_price_not_found() {
1213 let mut server = mockito::Server::new_async().await;
1214 let _mock = server
1215 .mock(
1216 "GET",
1217 mockito::Matcher::Regex(r"/latest/dex/tokens/.*".to_string()),
1218 )
1219 .with_status(200)
1220 .with_header("content-type", "application/json")
1221 .with_body(r#"{"pairs":null}"#)
1222 .create_async()
1223 .await;
1224
1225 let client = DexClient::with_base_url(&server.url());
1226 let price = client.get_token_price("ethereum", "0xunknown").await;
1227 assert!(price.is_none());
1228 }
1229
1230 #[tokio::test]
1231 async fn test_search_tokens_success() {
1232 let mut server = mockito::Server::new_async().await;
1233 let pair = build_test_pair_json("ethereum", "USDC", "0xusdc", "1.00");
1234 let body = format!(r#"{{"pairs":[{}]}}"#, pair);
1235 let _mock = server
1236 .mock(
1237 "GET",
1238 mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1239 )
1240 .with_status(200)
1241 .with_header("content-type", "application/json")
1242 .with_body(&body)
1243 .create_async()
1244 .await;
1245
1246 let client = DexClient::with_base_url(&server.url());
1247 let results = client.search_tokens("USDC", None).await.unwrap();
1248 assert!(!results.is_empty());
1249 assert_eq!(results[0].symbol, "USDC");
1250 }
1251
1252 #[tokio::test]
1253 async fn test_search_tokens_with_chain_filter() {
1254 let mut server = mockito::Server::new_async().await;
1255 let pair_eth = build_test_pair_json("ethereum", "USDC", "0xusdc_eth", "1.00");
1256 let pair_bsc = build_test_pair_json("bsc", "USDC", "0xusdc_bsc", "1.00");
1257 let body = format!(r#"{{"pairs":[{},{}]}}"#, pair_eth, pair_bsc);
1258 let _mock = server
1259 .mock(
1260 "GET",
1261 mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1262 )
1263 .with_status(200)
1264 .with_header("content-type", "application/json")
1265 .with_body(&body)
1266 .create_async()
1267 .await;
1268
1269 let client = DexClient::with_base_url(&server.url());
1270 let results = client
1271 .search_tokens("USDC", Some("ethereum"))
1272 .await
1273 .unwrap();
1274 assert_eq!(results.len(), 1);
1275 assert_eq!(results[0].chain, "ethereum");
1276 }
1277
1278 #[tokio::test]
1279 async fn test_search_tokens_empty() {
1280 let mut server = mockito::Server::new_async().await;
1281 let _mock = server
1282 .mock(
1283 "GET",
1284 mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1285 )
1286 .with_status(200)
1287 .with_header("content-type", "application/json")
1288 .with_body(r#"{"pairs":[]}"#)
1289 .create_async()
1290 .await;
1291
1292 let client = DexClient::with_base_url(&server.url());
1293 let results = client.search_tokens("XYZNONEXIST", None).await.unwrap();
1294 assert!(results.is_empty());
1295 }
1296
1297 #[tokio::test]
1298 async fn test_search_tokens_api_error() {
1299 let mut server = mockito::Server::new_async().await;
1300 let _mock = server
1301 .mock(
1302 "GET",
1303 mockito::Matcher::Regex(r"/latest/dex/search.*".to_string()),
1304 )
1305 .with_status(429)
1306 .create_async()
1307 .await;
1308
1309 let client = DexClient::with_base_url(&server.url());
1310 let result = client.search_tokens("USDC", None).await;
1311 assert!(result.is_err());
1312 }
1313
1314 #[test]
1315 fn test_generate_price_history() {
1316 let pair_json = r#"{
1317 "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair",
1318 "baseToken":{"address":"0xtoken","name":"Token","symbol":"TKN"},
1319 "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1320 "priceUsd":"100.0",
1321 "priceChange":{"h24":10.0,"h6":5.0,"h1":1.0,"m5":0.5}
1322 }"#;
1323 let pair: DexScreenerPair = serde_json::from_str(pair_json).unwrap();
1324 let history = DexClient::generate_price_history(100.0, &pair, 1700000000);
1325 assert!(!history.is_empty());
1326 assert!(history.iter().any(|p| (p.price - 100.0).abs() < 0.001));
1328 }
1329
1330 #[test]
1331 fn test_chain_mapping_all_variants() {
1332 assert_eq!(DexClient::map_chain_to_dexscreener("eth"), "ethereum");
1334 assert_eq!(DexClient::map_chain_to_dexscreener("matic"), "polygon");
1335 assert_eq!(DexClient::map_chain_to_dexscreener("arb"), "arbitrum");
1336 assert_eq!(DexClient::map_chain_to_dexscreener("op"), "optimism");
1337 assert_eq!(DexClient::map_chain_to_dexscreener("base"), "base");
1338 assert_eq!(DexClient::map_chain_to_dexscreener("bnb"), "bsc");
1339 assert_eq!(DexClient::map_chain_to_dexscreener("sol"), "solana");
1340 assert_eq!(DexClient::map_chain_to_dexscreener("avax"), "avalanche");
1341 assert_eq!(DexClient::map_chain_to_dexscreener("unknown"), "unknown");
1342 }
1343
1344 #[tokio::test]
1345 async fn test_get_native_token_price_ethereum() {
1346 let mut server = mockito::Server::new_async().await;
1347 let _mock = server
1348 .mock("GET", mockito::Matcher::Any)
1349 .with_status(200)
1350 .with_header("content-type", "application/json")
1351 .with_body(r#"{"pairs":[{
1352 "chainId":"ethereum",
1353 "dexId":"uniswap",
1354 "pairAddress":"0xpair",
1355 "baseToken":{"address":"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2","name":"WETH","symbol":"WETH"},
1356 "quoteToken":{"address":"0xusdt","name":"USDT","symbol":"USDT"},
1357 "priceUsd":"3500.00"
1358 }]}"#)
1359 .create_async()
1360 .await;
1361
1362 let client = DexClient::with_base_url(&server.url());
1363 let price = client.get_native_token_price("ethereum").await;
1364 assert!(price.is_some());
1365 assert!((price.unwrap() - 3500.0).abs() < 0.01);
1366 }
1367
1368 #[tokio::test]
1369 async fn test_get_native_token_price_tron_returns_none() {
1370 let client = DexClient::with_base_url("http://localhost:1");
1371 let price = client.get_native_token_price("tron").await;
1372 assert!(price.is_none());
1373 }
1374
1375 #[tokio::test]
1376 async fn test_get_native_token_price_unknown_chain() {
1377 let client = DexClient::with_base_url("http://localhost:1");
1378 let price = client.get_native_token_price("unknownchain").await;
1379 assert!(price.is_none());
1380 }
1381
1382 #[tokio::test]
1383 async fn test_search_tokens_chain_filter_ethereum_only() {
1384 let mut server = mockito::Server::new_async().await;
1385 let _mock = server
1386 .mock("GET", mockito::Matcher::Any)
1387 .with_status(200)
1388 .with_header("content-type", "application/json")
1389 .with_body(
1390 r#"{"pairs":[
1391 {
1392 "chainId":"ethereum",
1393 "dexId":"uniswap",
1394 "pairAddress":"0xpair1",
1395 "baseToken":{"address":"0xtoken1","name":"USD Coin","symbol":"USDC"},
1396 "quoteToken":{"address":"0xweth","name":"WETH","symbol":"WETH"},
1397 "priceUsd":"1.00",
1398 "liquidity":{"usd":5000000.0},
1399 "volume":{"h24":1000000.0}
1400 },
1401 {
1402 "chainId":"bsc",
1403 "dexId":"pancakeswap",
1404 "pairAddress":"0xpair2",
1405 "baseToken":{"address":"0xtoken2","name":"Binance USD","symbol":"BUSD"},
1406 "quoteToken":{"address":"0xbnb","name":"BNB","symbol":"BNB"},
1407 "priceUsd":"1.00",
1408 "liquidity":{"usd":2000000.0},
1409 "volume":{"h24":500000.0}
1410 }
1411 ]}"#,
1412 )
1413 .create_async()
1414 .await;
1415
1416 let client = DexClient::with_base_url(&server.url());
1417 let results = client.search_tokens("USD", Some("ethereum")).await.unwrap();
1419 assert!(!results.is_empty());
1420 for r in &results {
1422 assert_eq!(r.chain.to_lowercase(), "ethereum");
1423 }
1424 }
1425
1426 #[tokio::test]
1427 async fn test_search_tokens_aggregates_volume_and_liquidity() {
1428 let mut server = mockito::Server::new_async().await;
1429 let _mock = server
1430 .mock("GET", mockito::Matcher::Any)
1431 .with_status(200)
1432 .with_header("content-type", "application/json")
1433 .with_body(
1434 r#"{"pairs":[
1435 {
1436 "chainId":"ethereum",
1437 "dexId":"uniswap",
1438 "pairAddress":"0xpair1",
1439 "baseToken":{"address":"0xSameToken","name":"Test Token","symbol":"TEST"},
1440 "quoteToken":{"address":"0xweth","name":"WETH","symbol":"WETH"},
1441 "priceUsd":"10.00",
1442 "liquidity":{"usd":1000000.0},
1443 "volume":{"h24":100000.0}
1444 },
1445 {
1446 "chainId":"ethereum",
1447 "dexId":"sushiswap",
1448 "pairAddress":"0xpair2",
1449 "baseToken":{"address":"0xSameToken","name":"Test Token","symbol":"TEST"},
1450 "quoteToken":{"address":"0xusdc","name":"USDC","symbol":"USDC"},
1451 "priceUsd":"10.05",
1452 "liquidity":{"usd":500000.0},
1453 "volume":{"h24":50000.0}
1454 }
1455 ]}"#,
1456 )
1457 .create_async()
1458 .await;
1459
1460 let client = DexClient::with_base_url(&server.url());
1461 let results = client.search_tokens("TEST", None).await.unwrap();
1462 assert_eq!(results.len(), 1); assert!(results[0].volume_24h > 100000.0);
1465 assert!(results[0].liquidity_usd > 1000000.0);
1466 }
1467
1468 #[tokio::test]
1469 async fn test_dex_data_source_trait_methods() {
1470 let mut server = mockito::Server::new_async().await;
1471 let _mock = server
1472 .mock("GET", mockito::Matcher::Any)
1473 .with_status(200)
1474 .with_header("content-type", "application/json")
1475 .with_body(
1476 r#"{"pairs":[{
1477 "chainId":"ethereum",
1478 "dexId":"uniswap",
1479 "pairAddress":"0xpair",
1480 "baseToken":{"address":"0xtoken","name":"Token","symbol":"TKN"},
1481 "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1482 "priceUsd":"50.0",
1483 "liquidity":{"usd":1000000.0},
1484 "volume":{"h24":100000.0}
1485 }]}"#,
1486 )
1487 .create_async()
1488 .await;
1489
1490 let client = DexClient::with_base_url(&server.url());
1491 let trait_client: &dyn DexDataSource = &client;
1493 let price = trait_client.get_token_price("ethereum", "0xtoken").await;
1494 assert!(price.is_some());
1495 }
1496
1497 #[tokio::test]
1498 async fn test_dex_data_source_trait_get_native_token_price() {
1499 let mut server = mockito::Server::new_async().await;
1500 let _mock = server
1501 .mock("GET", mockito::Matcher::Any)
1502 .with_status(200)
1503 .with_header("content-type", "application/json")
1504 .with_body(
1505 r#"{"pairs":[{
1506 "chainId":"ethereum",
1507 "dexId":"uniswap",
1508 "pairAddress":"0xpair",
1509 "baseToken":{"address":"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2","name":"WETH","symbol":"WETH"},
1510 "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1511 "priceUsd":"3500.0",
1512 "liquidity":{"usd":10000000.0},
1513 "volume":{"h24":5000000.0}
1514 }]}"#,
1515 )
1516 .create_async()
1517 .await;
1518
1519 let client = DexClient::with_base_url(&server.url());
1520 let trait_client: &dyn DexDataSource = &client;
1521 let price = trait_client.get_native_token_price("ethereum").await;
1522 assert!(price.is_some());
1523 }
1524
1525 #[tokio::test]
1526 async fn test_dex_data_source_trait_get_token_data() {
1527 let mut server = mockito::Server::new_async().await;
1528 let pair_json = build_test_pair_json("ethereum", "TKN", "0xtoken", "50.0");
1529 let _mock = server
1530 .mock("GET", mockito::Matcher::Any)
1531 .with_status(200)
1532 .with_header("content-type", "application/json")
1533 .with_body(format!(r#"{{"pairs":[{}]}}"#, pair_json))
1534 .create_async()
1535 .await;
1536
1537 let client = DexClient::with_base_url(&server.url());
1538 let trait_client: &dyn DexDataSource = &client;
1539 let data = trait_client.get_token_data("ethereum", "0xtoken").await;
1540 assert!(data.is_ok());
1541 }
1542
1543 #[tokio::test]
1544 async fn test_dex_data_source_trait_search_tokens() {
1545 let mut server = mockito::Server::new_async().await;
1546 let pair_json = build_test_pair_json("ethereum", "TKN", "0xtoken", "50.0");
1547 let _mock = server
1548 .mock("GET", mockito::Matcher::Any)
1549 .with_status(200)
1550 .with_header("content-type", "application/json")
1551 .with_body(format!(r#"{{"pairs":[{}]}}"#, pair_json))
1552 .create_async()
1553 .await;
1554
1555 let client = DexClient::with_base_url(&server.url());
1556 let trait_client: &dyn DexDataSource = &client;
1557 let results = trait_client.search_tokens("TKN", None).await;
1558 assert!(results.is_ok());
1559 }
1560
1561 #[tokio::test]
1562 async fn test_get_token_data_quote_token() {
1563 let mut server = mockito::Server::new_async().await;
1564 let _mock = server
1566 .mock("GET", mockito::Matcher::Any)
1567 .with_status(200)
1568 .with_header("content-type", "application/json")
1569 .with_body(
1570 r#"{"pairs":[{
1571 "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair",
1572 "baseToken":{"address":"0xother","name":"Other","symbol":"OTH"},
1573 "quoteToken":{"address":"0xmytoken","name":"MyToken","symbol":"MTK"},
1574 "priceUsd":"25.0",
1575 "priceChange":{"h24":1.0,"h6":0.5,"h1":0.2,"m5":0.05},
1576 "volume":{"h24":500000,"h6":100000,"h1":20000,"m5":2000},
1577 "liquidity":{"usd":0,"base":0,"quote":0},
1578 "txns":{"h24":{"buys":50,"sells":40},"h6":{"buys":10,"sells":8},"h1":{"buys":2,"sells":1}},
1579 "pairCreatedAt":1690000000000,
1580 "url":"https://dexscreener.com/ethereum/0xpair"
1581 }]}"#,
1582 )
1583 .create_async()
1584 .await;
1585
1586 let client = DexClient::with_base_url(&server.url());
1587 let data = client
1588 .get_token_data("ethereum", "0xmytoken")
1589 .await
1590 .unwrap();
1591 assert_eq!(data.symbol, "MTK");
1593 assert_eq!(data.name, "MyToken");
1594 assert!(data.price_usd > 0.0);
1596 }
1597
1598 #[tokio::test]
1599 async fn test_get_token_data_with_socials() {
1600 let mut server = mockito::Server::new_async().await;
1601 let _mock = server
1602 .mock("GET", mockito::Matcher::Any)
1603 .with_status(200)
1604 .with_header("content-type", "application/json")
1605 .with_body(
1606 r#"{"pairs":[{
1607 "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair",
1608 "baseToken":{"address":"0xtoken","name":"Token","symbol":"TKN"},
1609 "quoteToken":{"address":"0xquote","name":"USDC","symbol":"USDC"},
1610 "priceUsd":"50.0",
1611 "priceChange":{"h24":5.0,"h6":2.0,"h1":1.0,"m5":0.1},
1612 "volume":{"h24":1000000,"h6":250000,"h1":50000,"m5":5000},
1613 "liquidity":{"usd":1000000,"base":100,"quote":1000000},
1614 "txns":{"h24":{"buys":100,"sells":80},"h6":{"buys":20,"sells":15},"h1":{"buys":5,"sells":3}},
1615 "pairCreatedAt":1690000000000,
1616 "url":"https://dexscreener.com/ethereum/0xpair",
1617 "info":{
1618 "imageUrl":"https://example.com/logo.png",
1619 "websites":[{"url":"https://example.com"}],
1620 "socials":[
1621 {"type":"twitter","url":"https://twitter.com/token"},
1622 {"type":"telegram","url":"https://t.me/token"}
1623 ]
1624 }
1625 }]}"#,
1626 )
1627 .create_async()
1628 .await;
1629
1630 let client = DexClient::with_base_url(&server.url());
1631 let data = client.get_token_data("ethereum", "0xtoken").await.unwrap();
1632 assert_eq!(data.symbol, "TKN");
1633 assert!(data.image_url.is_some());
1634 assert!(!data.websites.is_empty());
1635 assert!(!data.socials.is_empty());
1636 assert_eq!(data.socials[0].platform, "twitter");
1637 }
1638
1639 #[tokio::test]
1640 async fn test_search_tokens_quote_match_and_updates() {
1641 let mut server = mockito::Server::new_async().await;
1642 let _mock = server
1644 .mock("GET", mockito::Matcher::Any)
1645 .with_status(200)
1646 .with_header("content-type", "application/json")
1647 .with_body(
1648 r#"{"pairs":[
1649 {
1650 "chainId":"ethereum","dexId":"uniswap","pairAddress":"0xpair1",
1651 "baseToken":{"address":"0xother","name":"Other","symbol":"OTH"},
1652 "quoteToken":{"address":"0xmytk","name":"MySearch","symbol":"MSR"},
1653 "liquidity":{"usd":500000.0},
1654 "volume":{"h24":100000.0},
1655 "marketCap":5000000
1656 },
1657 {
1658 "chainId":"ethereum","dexId":"sushi","pairAddress":"0xpair2",
1659 "baseToken":{"address":"0xmytk","name":"MySearch","symbol":"MSR"},
1660 "quoteToken":{"address":"0xweth","name":"WETH","symbol":"WETH"},
1661 "priceUsd":"10.5",
1662 "liquidity":{"usd":800000.0},
1663 "volume":{"h24":200000.0}
1664 }
1665 ]}"#,
1666 )
1667 .create_async()
1668 .await;
1669
1670 let client = DexClient::with_base_url(&server.url());
1671 let results = client.search_tokens("MySearch", None).await.unwrap();
1672 assert_eq!(results.len(), 1); assert_eq!(results[0].symbol, "MSR");
1674 assert!(results[0].volume_24h >= 300000.0);
1676 assert!(results[0].liquidity_usd >= 1300000.0);
1678 assert!(results[0].price_usd.is_some());
1680 assert!(results[0].market_cap.is_some());
1682 }
1683
1684 #[test]
1685 fn test_interpolate_points_midpoint() {
1686 let mut history = vec![
1687 PricePoint {
1688 timestamp: 1000,
1689 price: 10.0,
1690 },
1691 PricePoint {
1692 timestamp: 2000,
1693 price: 20.0,
1694 },
1695 ];
1696 DexClient::interpolate_points(&mut history, 2);
1698 assert_eq!(history.len(), 2);
1699
1700 DexClient::interpolate_points(&mut history, 5);
1702 assert!(history.len() > 2);
1703 let midpoints: Vec<_> = history.iter().filter(|p| p.timestamp == 1500).collect();
1705 assert!(!midpoints.is_empty());
1706 assert!((midpoints[0].price - 15.0).abs() < 0.01);
1707 }
1708}