1use crate::{client::WebClient, error::WebToolError};
7use chrono::{DateTime, Utc};
8use riglr_macros::tool;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use tracing::{debug, info};
13
14#[derive(Debug, Clone)]
16pub struct DexScreenerConfig {
17 pub base_url: String,
19 pub rate_limit_per_minute: u32,
21 pub request_timeout: u64,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
27pub struct TokenInfo {
28 pub address: String,
30 pub name: String,
32 pub symbol: String,
34 pub decimals: u32,
36 pub price_usd: Option<f64>,
38 pub market_cap: Option<f64>,
40 pub volume_24h: Option<f64>,
42 pub price_change_24h: Option<f64>,
44 pub price_change_1h: Option<f64>,
46 pub price_change_5m: Option<f64>,
48 pub circulating_supply: Option<f64>,
50 pub total_supply: Option<f64>,
52 pub pair_count: u32,
54 pub pairs: Vec<TokenPair>,
56 pub chain: ChainInfo,
58 pub security: SecurityInfo,
60 pub socials: Vec<SocialLink>,
62 pub updated_at: DateTime<Utc>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
68pub struct TokenPair {
69 pub pair_id: String,
71 pub dex: DexInfo,
73 pub base_token: PairToken,
75 pub quote_token: PairToken,
77 pub price_usd: Option<f64>,
79 pub price_native: Option<f64>,
81 pub volume_24h: Option<f64>,
83 pub price_change_24h: Option<f64>,
85 pub liquidity_usd: Option<f64>,
87 pub fdv: Option<f64>,
89 pub created_at: Option<DateTime<Utc>>,
91 pub last_trade_at: DateTime<Utc>,
93 pub txns_24h: TransactionStats,
95 pub url: String,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
101pub struct DexInfo {
102 pub id: String,
104 pub name: String,
106 pub url: Option<String>,
108 pub logo: Option<String>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
114pub struct PairToken {
115 pub address: String,
117 pub name: String,
119 pub symbol: String,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
125pub struct TransactionStats {
126 pub buys: Option<u32>,
128 pub sells: Option<u32>,
130 pub total: Option<u32>,
132 pub buy_volume_usd: Option<f64>,
134 pub sell_volume_usd: Option<f64>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
140pub struct ChainInfo {
141 pub id: String,
143 pub name: String,
145 pub logo: Option<String>,
147 pub native_token: String,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
153pub struct SecurityInfo {
154 pub is_verified: bool,
156 pub liquidity_locked: Option<bool>,
158 pub audit_status: Option<String>,
160 pub honeypot_status: Option<String>,
162 pub ownership_status: Option<String>,
164 pub risk_score: Option<u32>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
170pub struct SocialLink {
171 pub platform: String,
173 pub url: String,
175 pub followers: Option<u32>,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
181pub struct MarketAnalysis {
182 pub token: TokenInfo,
184 pub trend_analysis: TrendAnalysis,
186 pub volume_analysis: VolumeAnalysis,
188 pub liquidity_analysis: LiquidityAnalysis,
190 pub price_levels: PriceLevelAnalysis,
192 pub risk_assessment: RiskAssessment,
194 pub analyzed_at: DateTime<Utc>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
200pub struct TrendAnalysis {
201 pub direction: String,
203 pub strength: u32,
205 pub momentum: f64,
207 pub velocity: f64,
209 pub support_levels: Vec<f64>,
211 pub resistance_levels: Vec<f64>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
217pub struct VolumeAnalysis {
218 pub volume_rank: Option<u32>,
220 pub volume_trend: String,
222 pub volume_mcap_ratio: Option<f64>,
224 pub avg_volume_7d: Option<f64>,
226 pub spike_factor: Option<f64>,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
232pub struct LiquidityAnalysis {
233 pub total_liquidity_usd: f64,
235 pub dex_distribution: HashMap<String, f64>,
237 pub price_impact: HashMap<String, f64>, pub depth_score: u32,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
245pub struct PriceLevelAnalysis {
246 pub ath: Option<f64>,
248 pub atl: Option<f64>,
250 pub ath_distance_pct: Option<f64>,
252 pub atl_distance_pct: Option<f64>,
254 pub high_24h: Option<f64>,
256 pub low_24h: Option<f64>,
258 pub range_position: Option<f64>,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
264pub struct RiskAssessment {
265 pub risk_level: String,
267 pub risk_factors: Vec<RiskFactor>,
269 pub liquidity_risk: u32,
271 pub volatility_risk: u32,
273 pub contract_risk: u32,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
279pub struct RiskFactor {
280 pub category: String,
282 pub description: String,
284 pub severity: String,
286 pub impact: u32,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
292pub struct TokenSearchResult {
293 pub query: String,
295 pub tokens: Vec<TokenInfo>,
297 pub metadata: SearchMetadata,
299 pub searched_at: DateTime<Utc>,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
305pub struct SearchMetadata {
306 pub result_count: u32,
308 pub execution_time_ms: u32,
310 pub limited: bool,
312 pub suggestions: Vec<String>,
314}
315
316impl Default for DexScreenerConfig {
317 fn default() -> Self {
318 Self {
319 base_url: "https://api.dexscreener.com".to_string(),
320 rate_limit_per_minute: 300,
321 request_timeout: 30,
322 }
323 }
324}
325
326#[tool]
331pub async fn get_token_info(
332 _context: &riglr_core::provider::ApplicationContext,
333 token_address: String,
334 chain_id: Option<String>,
335 include_pairs: Option<bool>,
336 include_security: Option<bool>,
337) -> crate::error::Result<TokenInfo> {
338 debug!(
339 "Fetching token info for address: {} on chain: {:?}",
340 token_address,
341 chain_id.as_deref().unwrap_or("auto-detect")
342 );
343
344 let config = DexScreenerConfig::default();
345 let client = WebClient::default();
346
347 let chain = chain_id.unwrap_or_else(|| "ethereum".to_string());
349 let url = if include_pairs.unwrap_or(true) {
350 format!("{}/tokens/v1/{}/{}", config.base_url, chain, token_address)
351 } else {
352 format!(
353 "{}/tokens/v1/{}/{}?fields=basic",
354 config.base_url, chain, token_address
355 )
356 };
357
358 let response = client.get(&url).await.map_err(|e| {
360 if e.to_string().contains("timeout") || e.to_string().contains("connection") {
361 WebToolError::Network(format!("Failed to fetch token info: {}", e))
362 } else {
363 WebToolError::Api(format!("Failed to fetch token info: {}", e))
364 }
365 })?;
366
367 let token_info = parse_token_response(&response, &token_address, &chain, include_security)
369 .await
370 .map_err(|e| WebToolError::Api(format!("Failed to parse token response: {}", e)))?;
371
372 info!(
373 "Retrieved token info for {} ({}): ${:.6}",
374 token_info.symbol,
375 token_info.name,
376 token_info.price_usd.unwrap_or(0.0)
377 );
378
379 Ok(token_info)
380}
381
382#[tool]
387pub async fn search_tokens(
388 _context: &riglr_core::provider::ApplicationContext,
389 query: String,
390 chain_filter: Option<String>,
391 min_market_cap: Option<f64>,
392 min_liquidity: Option<f64>,
393 limit: Option<u32>,
394) -> crate::error::Result<TokenSearchResult> {
395 debug!("Searching tokens for query: '{}' with filters", query);
396
397 let config = DexScreenerConfig::default();
398 let client = WebClient::default();
399
400 let mut params = HashMap::new();
402 params.insert("q".to_string(), query.clone());
403
404 if let Some(chain) = chain_filter {
405 params.insert("chain".to_string(), chain);
406 }
407
408 if let Some(min_mc) = min_market_cap {
409 params.insert("min_market_cap".to_string(), min_mc.to_string());
410 }
411
412 if let Some(min_liq) = min_liquidity {
413 params.insert("min_liquidity".to_string(), min_liq.to_string());
414 }
415
416 params.insert("limit".to_string(), limit.unwrap_or(20).to_string());
417
418 let url = format!("{}/dex/search", config.base_url);
420 let response = client
421 .get_with_params(&url, ¶ms)
422 .await
423 .map_err(|e| WebToolError::Network(format!("Search request failed: {}", e)))?;
424
425 let tokens = parse_search_results(&response)
427 .await
428 .map_err(|e| WebToolError::Api(format!("Failed to parse search results: {}", e)))?;
429
430 let result = TokenSearchResult {
431 query: query.clone(),
432 tokens: tokens.clone(),
433 metadata: SearchMetadata {
434 result_count: tokens.len() as u32,
435 execution_time_ms: 150, limited: tokens.len() >= limit.unwrap_or(20) as usize,
437 suggestions: vec![], },
439 searched_at: Utc::now(),
440 };
441
442 info!(
443 "Token search completed: {} results for '{}'",
444 result.tokens.len(),
445 query
446 );
447
448 Ok(result)
449}
450
451#[tool]
456pub async fn get_trending_tokens(
457 _context: &riglr_core::provider::ApplicationContext,
458 time_window: Option<String>, chain_filter: Option<String>,
460 min_volume: Option<f64>,
461 limit: Option<u32>,
462) -> crate::error::Result<Vec<TokenInfo>> {
463 debug!(
464 "Fetching trending tokens for window: {:?}",
465 time_window.as_deref().unwrap_or("1h")
466 );
467
468 let config = DexScreenerConfig::default();
469 let client = WebClient::default();
470
471 let window = time_window.unwrap_or_else(|| "1h".to_string());
473 let mut params = HashMap::new();
474 params.insert("window".to_string(), window);
475 params.insert("limit".to_string(), limit.unwrap_or(50).to_string());
476
477 if let Some(chain) = chain_filter {
478 params.insert("chain".to_string(), chain);
479 }
480
481 if let Some(min_vol) = min_volume {
482 params.insert("min_volume".to_string(), min_vol.to_string());
483 }
484
485 let url = format!("{}/dex/tokens/trending", config.base_url);
486 let response = client
487 .get_with_params(&url, ¶ms)
488 .await
489 .map_err(|e| WebToolError::Network(format!("Failed to fetch trending tokens: {}", e)))?;
490
491 let trending_tokens = parse_trending_response(&response)
492 .await
493 .map_err(|e| WebToolError::Api(format!("Failed to parse trending response: {}", e)))?;
494
495 info!("Retrieved {} trending tokens", trending_tokens.len());
496
497 Ok(trending_tokens)
498}
499
500#[tool]
511pub async fn analyze_token_market(
512 _context: &riglr_core::provider::ApplicationContext,
513 token_address: String,
514 chain_id: Option<String>,
515 _include_technical: Option<bool>,
516 include_risk: Option<bool>,
517) -> crate::error::Result<MarketAnalysis> {
518 debug!("Performing market analysis for token: {}", token_address);
519
520 let token_info = get_token_info(
522 _context,
523 token_address.clone(),
524 chain_id,
525 Some(true),
526 include_risk,
527 )
528 .await?;
529
530 let trend_analysis = analyze_price_trends(&token_info)
532 .await
533 .map_err(|e| WebToolError::Api(format!("Trend analysis failed: {}", e)))?;
534
535 let volume_analysis = analyze_volume_patterns(&token_info)
537 .await
538 .map_err(|e| WebToolError::Api(format!("Volume analysis failed: {}", e)))?;
539
540 let liquidity_analysis = analyze_liquidity(&token_info)
542 .await
543 .map_err(|e| WebToolError::Api(format!("Liquidity analysis failed: {}", e)))?;
544
545 let price_levels = analyze_price_levels(&token_info)
547 .await
548 .map_err(|e| WebToolError::Api(format!("Price level analysis failed: {}", e)))?;
549
550 let risk_assessment = if include_risk.unwrap_or(true) {
552 assess_token_risks(&token_info)
553 .await
554 .map_err(|e| WebToolError::Api(format!("Risk assessment failed: {}", e)))?
555 } else {
556 RiskAssessment {
557 risk_level: "Unknown".to_string(),
558 risk_factors: vec![],
559 liquidity_risk: 50,
560 volatility_risk: 50,
561 contract_risk: 50,
562 }
563 };
564
565 let analysis = MarketAnalysis {
566 token: token_info.clone(),
567 trend_analysis,
568 volume_analysis,
569 liquidity_analysis,
570 price_levels,
571 risk_assessment,
572 analyzed_at: Utc::now(),
573 };
574
575 info!(
576 "Market analysis completed for {} - Risk: {}, Trend: {}",
577 token_info.symbol, analysis.risk_assessment.risk_level, analysis.trend_analysis.direction
578 );
579
580 Ok(analysis)
581}
582
583#[tool]
588pub async fn get_top_pairs(
589 _context: &riglr_core::provider::ApplicationContext,
590 time_window: Option<String>, chain_filter: Option<String>,
592 dex_filter: Option<String>,
593 min_liquidity: Option<f64>,
594 limit: Option<u32>,
595) -> crate::error::Result<Vec<TokenPair>> {
596 debug!(
597 "Fetching top pairs for window: {:?}",
598 time_window.as_deref().unwrap_or("24h")
599 );
600
601 let config = DexScreenerConfig::default();
602 let client = WebClient::default();
603
604 let mut params = HashMap::new();
605 params.insert("sort".to_string(), "volume".to_string());
606 params.insert(
607 "window".to_string(),
608 time_window.unwrap_or_else(|| "24h".to_string()),
609 );
610 params.insert("limit".to_string(), limit.unwrap_or(100).to_string());
611
612 if let Some(chain) = chain_filter {
613 params.insert("chain".to_string(), chain);
614 }
615
616 if let Some(dex) = dex_filter {
617 params.insert("dex".to_string(), dex);
618 }
619
620 if let Some(min_liq) = min_liquidity {
621 params.insert("min_liquidity".to_string(), min_liq.to_string());
622 }
623
624 let url = format!("{}/dex/pairs/top", config.base_url);
625 let response = client
626 .get_with_params(&url, ¶ms)
627 .await
628 .map_err(|e| WebToolError::Network(format!("Failed to fetch top pairs: {}", e)))?;
629
630 let pairs = parse_pairs_response(&response)
631 .await
632 .map_err(|e| WebToolError::Api(format!("Failed to parse pairs response: {}", e)))?;
633
634 info!("Retrieved {} top trading pairs", pairs.len());
635
636 Ok(pairs)
637}
638
639#[tool]
644pub async fn get_latest_token_profiles(
645 _context: &riglr_core::provider::ApplicationContext,
646 limit: Option<u32>,
647) -> crate::error::Result<Vec<crate::dexscreener_api::TokenProfile>> {
648 debug!("Fetching latest token profiles");
649
650 let profiles = crate::dexscreener_api::get_latest_token_profiles()
651 .await
652 .map_err(|e| WebToolError::Api(format!("Failed to fetch token profiles: {}", e)))?;
653
654 let limited_profiles = if let Some(limit) = limit {
655 profiles.into_iter().take(limit as usize).collect()
656 } else {
657 profiles
658 };
659
660 info!("Retrieved {} token profiles", limited_profiles.len());
661
662 Ok(limited_profiles)
663}
664
665#[tool]
670pub async fn get_latest_boosted_tokens(
671 _context: &riglr_core::provider::ApplicationContext,
672 limit: Option<u32>,
673) -> crate::error::Result<Vec<crate::dexscreener_api::BoostsResponse>> {
674 debug!("Fetching latest boosted tokens");
675
676 let boosts = crate::dexscreener_api::get_latest_token_boosts()
677 .await
678 .map_err(|e| WebToolError::Api(format!("Failed to fetch latest boosts: {}", e)))?;
679
680 let limited_boosts = if let Some(limit) = limit {
681 boosts.into_iter().take(limit as usize).collect()
682 } else {
683 boosts
684 };
685
686 info!("Retrieved {} boosted tokens", limited_boosts.len());
687
688 Ok(limited_boosts)
689}
690
691#[tool]
696pub async fn get_top_boosted_tokens(
697 _context: &riglr_core::provider::ApplicationContext,
698 limit: Option<u32>,
699) -> crate::error::Result<Vec<crate::dexscreener_api::BoostsResponse>> {
700 debug!("Fetching top boosted tokens");
701
702 let boosts = crate::dexscreener_api::get_top_token_boosts()
703 .await
704 .map_err(|e| WebToolError::Api(format!("Failed to fetch top boosts: {}", e)))?;
705
706 let limited_boosts = if let Some(limit) = limit {
707 boosts.into_iter().take(limit as usize).collect()
708 } else {
709 boosts
710 };
711
712 info!("Retrieved {} top boosted tokens", limited_boosts.len());
713
714 Ok(limited_boosts)
715}
716
717#[tool]
722pub async fn check_token_orders(
723 _context: &riglr_core::provider::ApplicationContext,
724 chain_id: String,
725 token_address: String,
726) -> crate::error::Result<Vec<crate::dexscreener_api::Order>> {
727 debug!(
728 "Checking orders for token {} on chain {}",
729 token_address, chain_id
730 );
731
732 let orders = crate::dexscreener_api::get_token_orders(&chain_id, &token_address)
733 .await
734 .map_err(|e| WebToolError::Api(format!("Failed to fetch token orders: {}", e)))?;
735
736 info!(
737 "Retrieved {} orders for token {} on {}",
738 orders.len(),
739 token_address,
740 chain_id
741 );
742
743 Ok(orders)
744}
745
746async fn parse_token_response(
747 response: &str,
748 token_address: &str,
749 chain: &str,
750 _include_security: Option<bool>,
751) -> crate::error::Result<TokenInfo> {
752 let dex_response_raw: crate::dexscreener_api::DexScreenerResponseRaw =
754 serde_json::from_str(response).map_err(|e| {
755 crate::error::WebToolError::Parsing(format!(
756 "Failed to parse DexScreener response: {}",
757 e
758 ))
759 })?;
760
761 let dex_response: crate::dexscreener_api::DexScreenerResponse = dex_response_raw.into();
763
764 debug!("Parsed response with {} pairs", dex_response.pairs.len());
765
766 let token_info_opt =
768 crate::dexscreener_api::aggregate_token_info(dex_response.pairs, token_address);
769
770 if let Some(mut token_info) = token_info_opt {
771 token_info.chain = ChainInfo {
773 id: chain.to_string(),
774 name: format_chain_name(chain),
775 logo: None,
776 native_token: get_native_token(chain),
777 };
778 return Ok(token_info);
779 }
780
781 Err(crate::error::WebToolError::Api(format!(
782 "No pairs found for token address: {}",
783 token_address
784 )))
785}
786
787pub fn format_dex_name(dex_id: &str) -> String {
789 match dex_id {
790 "uniswap" => "Uniswap V2".to_string(),
791 "uniswapv3" => "Uniswap V3".to_string(),
792 "pancakeswap" => "PancakeSwap".to_string(),
793 "sushiswap" => "SushiSwap".to_string(),
794 "curve" => "Curve".to_string(),
795 "balancer" => "Balancer".to_string(),
796 "quickswap" => "QuickSwap".to_string(),
797 "raydium" => "Raydium".to_string(),
798 "orca" => "Orca".to_string(),
799 _ => dex_id.to_string(),
800 }
801}
802
803pub fn format_chain_name(chain_id: &str) -> String {
805 match chain_id {
806 "ethereum" => "Ethereum".to_string(),
807 "bsc" => "Binance Smart Chain".to_string(),
808 "polygon" => "Polygon".to_string(),
809 "arbitrum" => "Arbitrum".to_string(),
810 "optimism" => "Optimism".to_string(),
811 "avalanche" => "Avalanche".to_string(),
812 "fantom" => "Fantom".to_string(),
813 "solana" => "Solana".to_string(),
814 _ => chain_id.to_string(),
815 }
816}
817
818pub fn get_native_token(chain_id: &str) -> String {
820 match chain_id {
821 "ethereum" => "ETH".to_string(),
822 "bsc" => "BNB".to_string(),
823 "polygon" => "MATIC".to_string(),
824 "arbitrum" => "ETH".to_string(),
825 "optimism" => "ETH".to_string(),
826 "avalanche" => "AVAX".to_string(),
827 "fantom" => "FTM".to_string(),
828 "solana" => "SOL".to_string(),
829 _ => "NATIVE".to_string(),
830 }
831}
832
833async fn parse_search_results(response: &str) -> crate::error::Result<Vec<TokenInfo>> {
835 let dex_response_raw: crate::dexscreener_api::DexScreenerResponseRaw =
837 serde_json::from_str(response)
838 .map_err(|e| crate::error::WebToolError::Parsing(e.to_string()))?;
839 let dex_response: crate::dexscreener_api::DexScreenerResponse = dex_response_raw.into();
840
841 let mut tokens_map = std::collections::HashMap::new();
843
844 for pair in dex_response.pairs {
845 let token_address = pair.base_token.address.clone();
846 let entry = tokens_map
847 .entry(token_address.clone())
848 .or_insert(TokenInfo {
849 address: token_address,
850 name: pair.base_token.name.clone(),
851 symbol: pair.base_token.symbol.clone(),
852 decimals: 18, price_usd: pair.price_usd,
854 market_cap: pair.market_cap,
855 volume_24h: pair.volume.as_ref().and_then(|v| v.h24),
856 price_change_24h: pair.price_change.as_ref().and_then(|pc| pc.h24),
857 price_change_1h: pair.price_change.as_ref().and_then(|pc| pc.h1),
858 price_change_5m: pair.price_change.as_ref().and_then(|pc| pc.m5),
859 circulating_supply: None,
860 total_supply: None,
861 pair_count: 0,
862 pairs: vec![],
863 chain: ChainInfo {
864 id: pair.chain_id.clone(),
865 name: pair.chain_id.clone(), logo: None,
867 native_token: "ETH".to_string(), },
869 security: SecurityInfo {
870 is_verified: false,
871 liquidity_locked: None,
872 audit_status: None,
873 honeypot_status: None,
874 ownership_status: None,
875 risk_score: None,
876 },
877 socials: vec![],
878 updated_at: chrono::Utc::now(),
879 });
880
881 entry.pairs.push(TokenPair {
883 pair_id: pair.pair_address.clone(),
884 dex: DexInfo {
885 id: pair.dex_id.clone(),
886 name: pair.dex_id.clone(), url: Some(pair.url.clone()),
888 logo: None,
889 },
890 base_token: PairToken {
891 address: pair.base_token.address.clone(),
892 name: pair.base_token.name.clone(),
893 symbol: pair.base_token.symbol.clone(),
894 },
895 quote_token: PairToken {
896 address: pair.quote_token.address.clone(),
897 name: pair.quote_token.name.clone(),
898 symbol: pair.quote_token.symbol.clone(),
899 },
900 price_usd: pair.price_usd,
901 price_native: Some(pair.price_native),
902 volume_24h: pair.volume.and_then(|v| v.h24),
903 price_change_24h: pair.price_change.and_then(|pc| pc.h24),
904 liquidity_usd: pair.liquidity.and_then(|l| l.usd),
905 fdv: pair.fdv,
906 created_at: None,
907 last_trade_at: chrono::Utc::now(),
908 txns_24h: TransactionStats {
909 buys: pair
910 .txns
911 .as_ref()
912 .and_then(|t| t.h24.as_ref().and_then(|h| h.buys.map(|b| b as u32))),
913 sells: pair
914 .txns
915 .as_ref()
916 .and_then(|t| t.h24.as_ref().and_then(|h| h.sells.map(|s| s as u32))),
917 total: None,
918 buy_volume_usd: None,
919 sell_volume_usd: None,
920 },
921 url: pair.url,
922 });
923 entry.pair_count += 1;
924 }
925
926 Ok(tokens_map.into_values().collect())
927}
928
929async fn parse_trending_response(response: &str) -> crate::error::Result<Vec<TokenInfo>> {
930 parse_search_results(response).await
932}
933
934async fn parse_pairs_response(response: &str) -> crate::error::Result<Vec<TokenPair>> {
936 let dex_response_raw: crate::dexscreener_api::DexScreenerResponseRaw =
938 serde_json::from_str(response)
939 .map_err(|e| crate::error::WebToolError::Parsing(e.to_string()))?;
940 let dex_response: crate::dexscreener_api::DexScreenerResponse = dex_response_raw.into();
941
942 let pairs: Vec<TokenPair> = dex_response
943 .pairs
944 .into_iter()
945 .map(|pair| TokenPair {
946 pair_id: pair.pair_address.clone(),
947 dex: DexInfo {
948 id: pair.dex_id.clone(),
949 name: pair.dex_id.clone(),
950 url: Some(pair.url.clone()),
951 logo: None,
952 },
953 base_token: PairToken {
954 address: pair.base_token.address.clone(),
955 name: pair.base_token.name.clone(),
956 symbol: pair.base_token.symbol.clone(),
957 },
958 quote_token: PairToken {
959 address: pair.quote_token.address.clone(),
960 name: pair.quote_token.name.clone(),
961 symbol: pair.quote_token.symbol.clone(),
962 },
963 price_usd: pair.price_usd,
964 price_native: Some(pair.price_native),
965 volume_24h: pair.volume.and_then(|v| v.h24),
966 price_change_24h: pair.price_change.and_then(|pc| pc.h24),
967 liquidity_usd: pair.liquidity.and_then(|l| l.usd),
968 fdv: pair.fdv,
969 created_at: None,
970 last_trade_at: chrono::Utc::now(),
971 txns_24h: TransactionStats {
972 buys: pair
973 .txns
974 .as_ref()
975 .and_then(|t| t.h24.as_ref().and_then(|h| h.buys.map(|b| b as u32))),
976 sells: pair
977 .txns
978 .as_ref()
979 .and_then(|t| t.h24.as_ref().and_then(|h| h.sells.map(|s| s as u32))),
980 total: None,
981 buy_volume_usd: None,
982 sell_volume_usd: None,
983 },
984 url: pair.url,
985 })
986 .collect();
987
988 Ok(pairs)
989}
990
991async fn analyze_price_trends(token: &TokenInfo) -> crate::error::Result<TrendAnalysis> {
992 let price_change_24h = token.price_change_24h;
994 let price_change_1h = token.price_change_1h;
995
996 let direction = match price_change_24h {
997 Some(change) if change > 5.0 => "Bullish",
998 Some(change) if change < -5.0 => "Bearish",
999 Some(_) => "Neutral",
1000 None => "Unknown",
1001 }
1002 .to_string();
1003
1004 let strength =
1005 price_change_24h.map_or(5, |change| ((change.abs() / 10.0).clamp(1.0, 10.0)) as u32); let momentum = match (price_change_1h, price_change_24h) {
1009 (Some(h1), Some(h24)) => h1 * 24.0 - h24, _ => 0.0,
1011 };
1012
1013 let velocity = price_change_24h.map_or(0.0, |c| c / 24.0);
1014
1015 let (support_levels, resistance_levels) = if let Some(price) = token.price_usd {
1017 (vec![price * 0.95], vec![price * 1.05])
1019 } else {
1020 (vec![], vec![])
1021 };
1022
1023 Ok(TrendAnalysis {
1024 direction,
1025 strength,
1026 momentum,
1027 velocity,
1028 support_levels,
1029 resistance_levels,
1030 })
1031}
1032
1033async fn analyze_volume_patterns(token: &TokenInfo) -> crate::error::Result<VolumeAnalysis> {
1034 let volume_mcap_ratio = match (token.volume_24h, token.market_cap) {
1036 (Some(vol), Some(mcap)) if mcap > 0.0 => Some(vol / mcap),
1037 _ => None,
1038 };
1039
1040 Ok(VolumeAnalysis {
1043 volume_rank: None, volume_trend: "Unknown".to_string(), volume_mcap_ratio,
1046 avg_volume_7d: None, spike_factor: None, })
1049}
1050
1051async fn analyze_liquidity(token: &TokenInfo) -> crate::error::Result<LiquidityAnalysis> {
1052 let total_liquidity = token
1053 .pairs
1054 .iter()
1055 .map(|p| p.liquidity_usd.unwrap_or(0.0))
1056 .sum();
1057
1058 let mut dex_distribution = HashMap::new();
1059 for pair in &token.pairs {
1060 let current = dex_distribution.get(&pair.dex.name).unwrap_or(&0.0);
1061 dex_distribution.insert(
1062 pair.dex.name.clone(),
1063 current + pair.liquidity_usd.unwrap_or(0.0),
1064 );
1065 }
1066
1067 let mut price_impact = HashMap::new();
1068 price_impact.insert("1k".to_string(), 0.1);
1069 price_impact.insert("10k".to_string(), 0.5);
1070 price_impact.insert("100k".to_string(), 2.0);
1071
1072 Ok(LiquidityAnalysis {
1073 total_liquidity_usd: total_liquidity,
1074 dex_distribution,
1075 price_impact,
1076 depth_score: if total_liquidity > 1_000_000.0 {
1077 85
1078 } else {
1079 60
1080 },
1081 })
1082}
1083
1084async fn analyze_price_levels(token: &TokenInfo) -> crate::error::Result<PriceLevelAnalysis> {
1085 let (high_24h, low_24h, range_position) = if let Some(price) = token.price_usd {
1090 match token.price_change_24h {
1092 Some(change_pct) => {
1093 let change_factor = 1.0 + (change_pct / 100.0);
1095 if change_pct > 0.0 {
1096 let low = price / change_factor;
1097 (Some(price), Some(low), Some(1.0)) } else {
1099 let high = price / change_factor;
1100 (Some(high), Some(price), Some(0.0)) }
1102 }
1103 None => (None, None, None),
1104 }
1105 } else {
1106 (None, None, None)
1107 };
1108
1109 Ok(PriceLevelAnalysis {
1110 ath: None, atl: None, ath_distance_pct: None,
1113 atl_distance_pct: None,
1114 high_24h,
1115 low_24h,
1116 range_position,
1117 })
1118}
1119
1120async fn assess_token_risks(token: &TokenInfo) -> crate::error::Result<RiskAssessment> {
1121 let mut risk_factors = vec![];
1122 let mut total_risk = 0;
1123
1124 let liquidity_score = if token
1126 .pairs
1127 .iter()
1128 .map(|p| p.liquidity_usd.unwrap_or(0.0))
1129 .sum::<f64>()
1130 < 100_000.0
1131 {
1132 risk_factors.push(RiskFactor {
1133 category: "Liquidity".to_string(),
1134 description: "Low liquidity may cause high price impact".to_string(),
1135 severity: "High".to_string(),
1136 impact: 75,
1137 });
1138 75
1139 } else {
1140 25
1141 };
1142 total_risk += liquidity_score;
1143
1144 let contract_score = if !token.security.is_verified {
1146 risk_factors.push(RiskFactor {
1147 category: "Contract".to_string(),
1148 description: "Contract is not verified".to_string(),
1149 severity: "High".to_string(),
1150 impact: 80,
1151 });
1152 80
1153 } else {
1154 20
1155 };
1156 total_risk += contract_score;
1157
1158 let volatility_score = match token.price_change_24h {
1160 Some(change) if change.abs() > 20.0 => {
1161 risk_factors.push(RiskFactor {
1162 category: "Volatility".to_string(),
1163 description: "High price volatility detected".to_string(),
1164 severity: "Medium".to_string(),
1165 impact: 60,
1166 });
1167 60
1168 }
1169 Some(_) => 30, None => {
1171 risk_factors.push(RiskFactor {
1172 category: "Data".to_string(),
1173 description: "Volatility data unavailable".to_string(),
1174 severity: "Low".to_string(),
1175 impact: 20,
1176 });
1177 20
1178 }
1179 };
1180 total_risk += volatility_score;
1181
1182 let avg_risk = total_risk / 3;
1183 let risk_level = match avg_risk {
1184 0..=25 => "Low",
1185 26..=50 => "Medium",
1186 51..=75 => "High",
1187 _ => "Extreme",
1188 }
1189 .to_string();
1190
1191 Ok(RiskAssessment {
1192 risk_level,
1193 risk_factors,
1194 liquidity_risk: liquidity_score as u32,
1195 volatility_risk: volatility_score as u32,
1196 contract_risk: contract_score as u32,
1197 })
1198}
1199
1200#[cfg(test)]
1201mod tests {
1202 use super::*;
1203
1204 #[test]
1205 fn test_dexscreener_config_default() {
1206 let config = DexScreenerConfig::default();
1207 assert_eq!(config.base_url, "https://api.dexscreener.com");
1208 assert_eq!(config.rate_limit_per_minute, 300);
1209 }
1210
1211 #[test]
1212 fn test_token_info_serialization() {
1213 let token = TokenInfo {
1214 address: "0x123".to_string(),
1215 name: "Test Token".to_string(),
1216 symbol: "TEST".to_string(),
1217 decimals: 18,
1218 price_usd: Some(1.0),
1219 market_cap: Some(1000000.0),
1220 volume_24h: Some(50000.0),
1221 price_change_24h: Some(5.0),
1222 price_change_1h: Some(-1.0),
1223 price_change_5m: Some(0.5),
1224 circulating_supply: Some(1000000.0),
1225 total_supply: Some(10000000.0),
1226 pair_count: 1,
1227 pairs: vec![],
1228 chain: ChainInfo {
1229 id: "ethereum".to_string(),
1230 name: "Ethereum".to_string(),
1231 logo: None,
1232 native_token: "ETH".to_string(),
1233 },
1234 security: SecurityInfo {
1235 is_verified: true,
1236 liquidity_locked: Some(true),
1237 audit_status: None,
1238 honeypot_status: None,
1239 ownership_status: None,
1240 risk_score: Some(25),
1241 },
1242 socials: vec![],
1243 updated_at: Utc::now(),
1244 };
1245
1246 let json = serde_json::to_string(&token).unwrap();
1247 assert!(json.contains("Test Token"));
1248 }
1249
1250 #[test]
1251 fn test_dexscreener_config_custom_values() {
1252 let config = DexScreenerConfig {
1253 base_url: "https://custom.api.com".to_string(),
1254 rate_limit_per_minute: 100,
1255 request_timeout: 60,
1256 };
1257 assert_eq!(config.base_url, "https://custom.api.com");
1258 assert_eq!(config.rate_limit_per_minute, 100);
1259 assert_eq!(config.request_timeout, 60);
1260 }
1261
1262 #[test]
1263 fn test_format_dex_name_when_known_dex_should_return_formatted_name() {
1264 assert_eq!(format_dex_name("uniswap"), "Uniswap V2");
1265 assert_eq!(format_dex_name("uniswapv3"), "Uniswap V3");
1266 assert_eq!(format_dex_name("pancakeswap"), "PancakeSwap");
1267 assert_eq!(format_dex_name("sushiswap"), "SushiSwap");
1268 assert_eq!(format_dex_name("curve"), "Curve");
1269 assert_eq!(format_dex_name("balancer"), "Balancer");
1270 assert_eq!(format_dex_name("quickswap"), "QuickSwap");
1271 assert_eq!(format_dex_name("raydium"), "Raydium");
1272 assert_eq!(format_dex_name("orca"), "Orca");
1273 }
1274
1275 #[test]
1276 fn test_format_dex_name_when_unknown_dex_should_return_original() {
1277 assert_eq!(format_dex_name("unknown-dex"), "unknown-dex");
1278 assert_eq!(format_dex_name("custom_dex"), "custom_dex");
1279 assert_eq!(format_dex_name(""), "");
1280 }
1281
1282 #[test]
1283 fn test_format_chain_name_when_known_chain_should_return_formatted_name() {
1284 assert_eq!(format_chain_name("ethereum"), "Ethereum");
1285 assert_eq!(format_chain_name("bsc"), "Binance Smart Chain");
1286 assert_eq!(format_chain_name("polygon"), "Polygon");
1287 assert_eq!(format_chain_name("arbitrum"), "Arbitrum");
1288 assert_eq!(format_chain_name("optimism"), "Optimism");
1289 assert_eq!(format_chain_name("avalanche"), "Avalanche");
1290 assert_eq!(format_chain_name("fantom"), "Fantom");
1291 assert_eq!(format_chain_name("solana"), "Solana");
1292 }
1293
1294 #[test]
1295 fn test_format_chain_name_when_unknown_chain_should_return_original() {
1296 assert_eq!(format_chain_name("unknown-chain"), "unknown-chain");
1297 assert_eq!(format_chain_name("custom_chain"), "custom_chain");
1298 assert_eq!(format_chain_name(""), "");
1299 }
1300
1301 #[test]
1302 fn test_get_native_token_when_known_chain_should_return_correct_token() {
1303 assert_eq!(get_native_token("ethereum"), "ETH");
1304 assert_eq!(get_native_token("bsc"), "BNB");
1305 assert_eq!(get_native_token("polygon"), "MATIC");
1306 assert_eq!(get_native_token("arbitrum"), "ETH");
1307 assert_eq!(get_native_token("optimism"), "ETH");
1308 assert_eq!(get_native_token("avalanche"), "AVAX");
1309 assert_eq!(get_native_token("fantom"), "FTM");
1310 assert_eq!(get_native_token("solana"), "SOL");
1311 }
1312
1313 #[test]
1314 fn test_get_native_token_when_unknown_chain_should_return_native() {
1315 assert_eq!(get_native_token("unknown-chain"), "NATIVE");
1316 assert_eq!(get_native_token("custom_chain"), "NATIVE");
1317 assert_eq!(get_native_token(""), "NATIVE");
1318 }
1319
1320 #[tokio::test]
1321 async fn test_analyze_price_trends_when_bullish_data_should_return_bullish_trend() {
1322 let token = create_test_token_info(Some(10.0), Some(2.0), Some(1.0));
1323 let result = analyze_price_trends(&token).await.unwrap();
1324
1325 assert_eq!(result.direction, "Bullish");
1326 assert_eq!(result.strength, 1); assert_eq!(result.momentum, 26.0); assert_eq!(result.velocity, 10.0 / 24.0);
1329 assert_eq!(result.support_levels.len(), 1);
1330 assert_eq!(result.resistance_levels.len(), 1);
1331 }
1332
1333 #[tokio::test]
1334 async fn test_analyze_price_trends_when_bearish_data_should_return_bearish_trend() {
1335 let token = create_test_token_info(Some(-10.0), Some(-1.0), Some(1.0));
1336 let result = analyze_price_trends(&token).await.unwrap();
1337
1338 assert_eq!(result.direction, "Bearish");
1339 assert_eq!(result.strength, 1); assert_eq!(result.momentum, -14.0); assert_eq!(result.velocity, -10.0 / 24.0);
1342 }
1343
1344 #[tokio::test]
1345 async fn test_analyze_price_trends_when_neutral_data_should_return_neutral_trend() {
1346 let token = create_test_token_info(Some(2.0), Some(0.5), Some(1.0));
1347 let result = analyze_price_trends(&token).await.unwrap();
1348
1349 assert_eq!(result.direction, "Neutral");
1350 assert_eq!(result.strength, 1); assert_eq!(result.momentum, 10.0); }
1353
1354 #[tokio::test]
1355 async fn test_analyze_price_trends_when_no_data_should_return_unknown_trend() {
1356 let token = create_test_token_info(None, None, Some(1.0));
1357 let result = analyze_price_trends(&token).await.unwrap();
1358
1359 assert_eq!(result.direction, "Unknown");
1360 assert_eq!(result.strength, 5); assert_eq!(result.momentum, 0.0);
1362 assert_eq!(result.velocity, 0.0);
1363 assert_eq!(result.support_levels.len(), 0);
1364 assert_eq!(result.resistance_levels.len(), 0);
1365 }
1366
1367 #[tokio::test]
1368 async fn test_analyze_price_trends_when_no_price_should_return_empty_levels() {
1369 let token = create_test_token_info(Some(5.0), Some(1.0), None);
1370 let result = analyze_price_trends(&token).await.unwrap();
1371
1372 assert_eq!(result.support_levels.len(), 0);
1373 assert_eq!(result.resistance_levels.len(), 0);
1374 }
1375
1376 #[tokio::test]
1377 async fn test_analyze_volume_patterns_when_valid_data_should_calculate_ratio() {
1378 let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1379 token.volume_24h = Some(50000.0);
1380 token.market_cap = Some(1000000.0);
1381
1382 let result = analyze_volume_patterns(&token).await.unwrap();
1383
1384 assert_eq!(result.volume_mcap_ratio, Some(0.05)); assert_eq!(result.volume_trend, "Unknown");
1386 assert_eq!(result.volume_rank, None);
1387 assert_eq!(result.avg_volume_7d, None);
1388 assert_eq!(result.spike_factor, None);
1389 }
1390
1391 #[tokio::test]
1392 async fn test_analyze_volume_patterns_when_zero_market_cap_should_return_none_ratio() {
1393 let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1394 token.volume_24h = Some(50000.0);
1395 token.market_cap = Some(0.0);
1396
1397 let result = analyze_volume_patterns(&token).await.unwrap();
1398
1399 assert_eq!(result.volume_mcap_ratio, None);
1400 }
1401
1402 #[tokio::test]
1403 async fn test_analyze_volume_patterns_when_missing_data_should_return_none_ratio() {
1404 let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1405 token.volume_24h = None;
1406 token.market_cap = None;
1407
1408 let result = analyze_volume_patterns(&token).await.unwrap();
1409
1410 assert_eq!(result.volume_mcap_ratio, None);
1411 }
1412
1413 #[tokio::test]
1414 async fn test_analyze_liquidity_when_multiple_pairs_should_aggregate_liquidity() {
1415 let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1416 token.pairs = vec![
1417 create_test_token_pair("dex1", 100000.0),
1418 create_test_token_pair("dex2", 200000.0),
1419 ];
1420
1421 let result = analyze_liquidity(&token).await.unwrap();
1422
1423 assert_eq!(result.total_liquidity_usd, 300000.0);
1424 assert_eq!(result.dex_distribution.len(), 2);
1425 assert_eq!(result.dex_distribution.get("dex1"), Some(&100000.0));
1426 assert_eq!(result.dex_distribution.get("dex2"), Some(&200000.0));
1427 assert_eq!(result.depth_score, 60); }
1429
1430 #[tokio::test]
1431 async fn test_analyze_liquidity_when_high_liquidity_should_return_high_depth_score() {
1432 let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1433 token.pairs = vec![create_test_token_pair("dex1", 2000000.0)];
1434
1435 let result = analyze_liquidity(&token).await.unwrap();
1436
1437 assert_eq!(result.total_liquidity_usd, 2000000.0);
1438 assert_eq!(result.depth_score, 85); }
1440
1441 #[tokio::test]
1442 async fn test_analyze_liquidity_when_no_pairs_should_return_zero_liquidity() {
1443 let token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1444
1445 let result = analyze_liquidity(&token).await.unwrap();
1446
1447 assert_eq!(result.total_liquidity_usd, 0.0);
1448 assert_eq!(result.dex_distribution.len(), 0);
1449 assert_eq!(result.depth_score, 60);
1450 }
1451
1452 #[tokio::test]
1453 async fn test_analyze_price_levels_when_positive_change_should_estimate_at_high() {
1454 let token = create_test_token_info(Some(10.0), Some(1.0), Some(100.0));
1455
1456 let result = analyze_price_levels(&token).await.unwrap();
1457
1458 assert_eq!(result.high_24h, Some(100.0));
1459 assert!(result.low_24h.is_some());
1460 assert!(result.low_24h.unwrap() < 100.0);
1461 assert_eq!(result.range_position, Some(1.0)); assert_eq!(result.ath, None);
1463 assert_eq!(result.atl, None);
1464 }
1465
1466 #[tokio::test]
1467 async fn test_analyze_price_levels_when_negative_change_should_estimate_at_low() {
1468 let token = create_test_token_info(Some(-10.0), Some(1.0), Some(90.0));
1469
1470 let result = analyze_price_levels(&token).await.unwrap();
1471
1472 assert!(result.high_24h.is_some());
1473 assert!(result.high_24h.unwrap() > 90.0);
1474 assert_eq!(result.low_24h, Some(90.0));
1475 assert_eq!(result.range_position, Some(0.0)); }
1477
1478 #[tokio::test]
1479 async fn test_analyze_price_levels_when_no_price_change_should_return_none() {
1480 let mut token = create_test_token_info(None, Some(1.0), Some(100.0));
1481 token.price_change_24h = None;
1482
1483 let result = analyze_price_levels(&token).await.unwrap();
1484
1485 assert_eq!(result.high_24h, None);
1486 assert_eq!(result.low_24h, None);
1487 assert_eq!(result.range_position, None);
1488 }
1489
1490 #[tokio::test]
1491 async fn test_analyze_price_levels_when_no_price_should_return_none() {
1492 let token = create_test_token_info(Some(10.0), Some(1.0), None);
1493
1494 let result = analyze_price_levels(&token).await.unwrap();
1495
1496 assert_eq!(result.high_24h, None);
1497 assert_eq!(result.low_24h, None);
1498 assert_eq!(result.range_position, None);
1499 }
1500
1501 #[tokio::test]
1502 async fn test_assess_token_risks_when_low_liquidity_should_add_liquidity_risk() {
1503 let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1504 token.pairs = vec![create_test_token_pair("dex1", 50000.0)]; let result = assess_token_risks(&token).await.unwrap();
1507
1508 assert_eq!(result.liquidity_risk, 75);
1509 assert!(result
1510 .risk_factors
1511 .iter()
1512 .any(|r| r.category == "Liquidity"));
1513 assert!(matches!(result.risk_level.as_str(), "High" | "Extreme"));
1514 }
1515
1516 #[tokio::test]
1517 async fn test_assess_token_risks_when_high_liquidity_should_have_low_liquidity_risk() {
1518 let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1519 token.pairs = vec![create_test_token_pair("dex1", 500000.0)]; let result = assess_token_risks(&token).await.unwrap();
1522
1523 assert_eq!(result.liquidity_risk, 25);
1524 }
1525
1526 #[tokio::test]
1527 async fn test_assess_token_risks_when_unverified_contract_should_add_contract_risk() {
1528 let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1529 token.security.is_verified = false;
1530 token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
1531
1532 let result = assess_token_risks(&token).await.unwrap();
1533
1534 assert_eq!(result.contract_risk, 80);
1535 assert!(result.risk_factors.iter().any(|r| r.category == "Contract"));
1536 }
1537
1538 #[tokio::test]
1539 async fn test_assess_token_risks_when_verified_contract_should_have_low_contract_risk() {
1540 let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1541 token.security.is_verified = true;
1542 token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
1543
1544 let result = assess_token_risks(&token).await.unwrap();
1545
1546 assert_eq!(result.contract_risk, 20);
1547 }
1548
1549 #[tokio::test]
1550 async fn test_assess_token_risks_when_high_volatility_should_add_volatility_risk() {
1551 let mut token = create_test_token_info(Some(25.0), Some(1.0), Some(1.0)); token.security.is_verified = true;
1553 token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
1554
1555 let result = assess_token_risks(&token).await.unwrap();
1556
1557 assert_eq!(result.volatility_risk, 60);
1558 assert!(result
1559 .risk_factors
1560 .iter()
1561 .any(|r| r.category == "Volatility"));
1562 }
1563
1564 #[tokio::test]
1565 async fn test_assess_token_risks_when_normal_volatility_should_have_medium_volatility_risk() {
1566 let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0)); token.security.is_verified = true;
1568 token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
1569
1570 let result = assess_token_risks(&token).await.unwrap();
1571
1572 assert_eq!(result.volatility_risk, 30);
1573 }
1574
1575 #[tokio::test]
1576 async fn test_assess_token_risks_when_no_volatility_data_should_add_data_risk() {
1577 let mut token = create_test_token_info(None, Some(1.0), Some(1.0));
1578 token.security.is_verified = true;
1579 token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
1580
1581 let result = assess_token_risks(&token).await.unwrap();
1582
1583 assert_eq!(result.volatility_risk, 20);
1584 assert!(result.risk_factors.iter().any(|r| r.category == "Data"));
1585 }
1586
1587 #[tokio::test]
1588 async fn test_assess_token_risks_when_low_risk_should_return_low_risk_level() {
1589 let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1590 token.security.is_verified = true;
1591 token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
1592
1593 let result = assess_token_risks(&token).await.unwrap();
1594
1595 assert_eq!(result.risk_level, "Low");
1596 }
1597
1598 #[tokio::test]
1599 async fn test_parse_search_results_when_invalid_json_should_return_error() {
1600 let invalid_json = "invalid json";
1601 let result = parse_search_results(invalid_json).await;
1602
1603 assert!(result.is_err());
1604 if let Err(crate::error::WebToolError::Parsing(msg)) = result {
1605 assert!(msg.contains("expected"));
1606 }
1607 }
1608
1609 #[tokio::test]
1610 async fn test_parse_trending_response_when_invalid_json_should_return_error() {
1611 let invalid_json = "invalid json";
1612 let result = parse_trending_response(invalid_json).await;
1613
1614 assert!(result.is_err());
1615 }
1616
1617 #[tokio::test]
1618 async fn test_parse_pairs_response_when_invalid_json_should_return_error() {
1619 let invalid_json = "invalid json";
1620 let result = parse_pairs_response(invalid_json).await;
1621
1622 assert!(result.is_err());
1623 }
1624
1625 #[tokio::test]
1626 async fn test_parse_token_response_when_invalid_json_should_return_parsing_error() {
1627 let invalid_json = "invalid json";
1628 let result = parse_token_response(invalid_json, "0x123", "ethereum", Some(true)).await;
1629
1630 assert!(result.is_err());
1631 if let Err(crate::error::WebToolError::Parsing(msg)) = result {
1632 assert!(msg.contains("Failed to parse DexScreener response"));
1633 }
1634 }
1635
1636 #[tokio::test]
1637 async fn test_parse_token_response_when_no_pairs_found_should_return_api_error() {
1638 let valid_json_no_pairs = r#"{"pairs": []}"#;
1639 let result =
1640 parse_token_response(valid_json_no_pairs, "0x123", "ethereum", Some(true)).await;
1641
1642 assert!(result.is_err());
1643 if let Err(crate::error::WebToolError::Api(msg)) = result {
1644 assert!(msg.contains("No pairs found for token address"));
1645 }
1646 }
1647
1648 #[tokio::test]
1649 async fn test_parse_token_response_when_no_valid_pairs_should_return_api_error() {
1650 let json_with_unmatched_pairs = r#"{"pairs": [{"base_token": {"address": "0x456", "name": "Other Token", "symbol": "OTHER"}, "quote_token": {"address": "0x789", "name": "USDC", "symbol": "USDC"}, "pair_address": "0xabc", "dex_id": "uniswap", "url": "https://example.com", "price_usd": "1.0", "price_native": "1.0"}]}"#;
1651 let result =
1652 parse_token_response(json_with_unmatched_pairs, "0x123", "ethereum", Some(true)).await;
1653
1654 assert!(result.is_err());
1655 if let Err(crate::error::WebToolError::Api(msg)) = result {
1656 assert!(msg.contains("No pairs found for token address"));
1657 }
1658 }
1659
1660 #[tokio::test]
1661 async fn test_parse_token_response_when_valid_data_should_return_token_info() {
1662 let valid_json = create_valid_dexscreener_response();
1663 let result = parse_token_response(&valid_json, "0x123", "ethereum", Some(true)).await;
1664
1665 assert!(result.is_ok());
1666 let token = result.unwrap();
1667 assert_eq!(token.address, "0x123");
1668 assert_eq!(token.symbol, "TEST");
1669 assert_eq!(token.name, "Test Token");
1670 assert_eq!(token.chain.id, "ethereum");
1671 assert_eq!(token.chain.name, "Ethereum");
1672 assert_eq!(token.chain.native_token, "ETH");
1673 assert_eq!(token.pair_count, 1);
1674 assert!(!token.pairs.is_empty());
1675 }
1676
1677 #[tokio::test]
1678 async fn test_parse_token_response_when_multiple_pairs_should_use_highest_liquidity() {
1679 let json_with_multiple_pairs = create_dexscreener_response_with_multiple_pairs();
1680 let result =
1681 parse_token_response(&json_with_multiple_pairs, "0x123", "ethereum", Some(true)).await;
1682
1683 assert!(result.is_ok());
1684 let token = result.unwrap();
1685 assert_eq!(token.pair_count, 2);
1686 assert!(token.volume_24h.unwrap() > 10000.0);
1688 assert_eq!(token.price_usd, Some(2.0)); }
1691
1692 #[tokio::test]
1693 async fn test_parse_token_response_when_case_insensitive_address_should_match() {
1694 let valid_json = create_valid_dexscreener_response();
1695 let result = parse_token_response(&valid_json, "0X123", "ethereum", Some(true)).await; assert!(result.is_ok());
1698 let token = result.unwrap();
1699 assert_eq!(token.address, "0X123"); }
1701
1702 #[tokio::test]
1703 async fn test_parse_token_response_when_no_liquidity_data_should_handle_gracefully() {
1704 let json_no_liquidity = create_dexscreener_response_without_liquidity();
1705 let result =
1706 parse_token_response(&json_no_liquidity, "0x123", "ethereum", Some(true)).await;
1707
1708 assert!(result.is_ok());
1709 let token = result.unwrap();
1710 assert_eq!(token.pairs[0].liquidity_usd, None);
1711 }
1712
1713 #[tokio::test]
1714 async fn test_parse_token_response_when_transaction_data_available_should_calculate_totals() {
1715 let json_with_txns = create_dexscreener_response_with_transaction_data();
1716 let result = parse_token_response(&json_with_txns, "0x123", "ethereum", Some(true)).await;
1717
1718 assert!(result.is_ok());
1719 let token = result.unwrap();
1720 let pair = &token.pairs[0];
1721 assert_eq!(pair.txns_24h.buys, Some(100));
1722 assert_eq!(pair.txns_24h.sells, Some(50));
1723 assert_eq!(pair.txns_24h.total, Some(150)); assert!(pair.txns_24h.buy_volume_usd.is_some());
1725 assert!(pair.txns_24h.sell_volume_usd.is_some());
1726 }
1727
1728 #[tokio::test]
1729 async fn test_parse_token_response_when_unknown_chain_should_use_default_formatting() {
1730 let valid_json = create_valid_dexscreener_response();
1731 let result = parse_token_response(&valid_json, "0x123", "unknown-chain", Some(true)).await;
1732
1733 assert!(result.is_ok());
1734 let token = result.unwrap();
1735 assert_eq!(token.chain.id, "unknown-chain");
1736 assert_eq!(token.chain.name, "unknown-chain");
1737 assert_eq!(token.chain.native_token, "NATIVE");
1738 }
1739
1740 #[tokio::test]
1741 async fn test_parse_token_response_when_price_parsing_fails_should_handle_gracefully() {
1742 let json_invalid_price = create_dexscreener_response_with_invalid_price();
1743 let result =
1744 parse_token_response(&json_invalid_price, "0x123", "ethereum", Some(true)).await;
1745
1746 assert!(result.is_ok());
1747 let token = result.unwrap();
1748 assert_eq!(token.price_usd, None); }
1750
1751 fn create_valid_dexscreener_response() -> String {
1753 r#"{
1754 "schemaVersion": "1.0.0",
1755 "pairs": [{
1756 "chainId": "ethereum",
1757 "baseToken": {
1758 "address": "0x123",
1759 "name": "Test Token",
1760 "symbol": "TEST"
1761 },
1762 "quoteToken": {
1763 "address": "0x456",
1764 "name": "USD Coin",
1765 "symbol": "USDC"
1766 },
1767 "pairAddress": "0xabc",
1768 "dexId": "uniswap",
1769 "url": "https://example.com",
1770 "priceUsd": "1.5",
1771 "priceNative": "1.5",
1772 "marketCap": 1000000.0,
1773 "liquidity": {"usd": 500000.0},
1774 "volume": {"h24": 50000.0},
1775 "priceChange": {"h24": 5.0, "h1": 1.0, "m5": 0.5},
1776 "fdv": 2000000.0
1777 }]
1778 }"#
1779 .to_string()
1780 }
1781
1782 fn create_dexscreener_response_with_multiple_pairs() -> String {
1783 r#"{
1784 "schemaVersion": "1.0.0",
1785 "pairs": [{
1786 "chainId": "ethereum",
1787 "baseToken": {
1788 "address": "0x123",
1789 "name": "Test Token",
1790 "symbol": "TEST"
1791 },
1792 "quoteToken": {
1793 "address": "0x456",
1794 "name": "USD Coin",
1795 "symbol": "USDC"
1796 },
1797 "pairAddress": "0xabc",
1798 "dexId": "uniswap",
1799 "url": "https://example.com",
1800 "priceUsd": "1.0",
1801 "priceNative": "1.0",
1802 "marketCap": 1000000.0,
1803 "liquidity": {"usd": 300000.0},
1804 "volume": {"h24": 30000.0},
1805 "priceChange": {"h24": 3.0}
1806 }, {
1807 "chainId": "ethereum",
1808 "baseToken": {
1809 "address": "0x123",
1810 "name": "Test Token",
1811 "symbol": "TEST"
1812 },
1813 "quoteToken": {
1814 "address": "0x789",
1815 "name": "Ethereum",
1816 "symbol": "ETH"
1817 },
1818 "pairAddress": "0xdef",
1819 "dexId": "sushiswap",
1820 "url": "https://sushi.example.com",
1821 "priceUsd": "2.0",
1822 "priceNative": "2.0",
1823 "marketCap": 1500000.0,
1824 "liquidity": {"usd": 800000.0},
1825 "volume": {"h24": 80000.0},
1826 "priceChange": {"h24": 8.0}
1827 }]
1828 }"#
1829 .to_string()
1830 }
1831
1832 fn create_dexscreener_response_without_liquidity() -> String {
1833 r#"{
1834 "schemaVersion": "1.0.0",
1835 "pairs": [{
1836 "chainId": "ethereum",
1837 "baseToken": {
1838 "address": "0x123",
1839 "name": "Test Token",
1840 "symbol": "TEST"
1841 },
1842 "quoteToken": {
1843 "address": "0x456",
1844 "name": "USD Coin",
1845 "symbol": "USDC"
1846 },
1847 "pairAddress": "0xabc",
1848 "dexId": "uniswap",
1849 "url": "https://example.com",
1850 "priceUsd": "1.5",
1851 "priceNative": "1.5"
1852 }]
1853 }"#
1854 .to_string()
1855 }
1856
1857 fn create_dexscreener_response_with_transaction_data() -> String {
1858 r#"{
1859 "schemaVersion": "1.0.0",
1860 "pairs": [{
1861 "chainId": "ethereum",
1862 "baseToken": {
1863 "address": "0x123",
1864 "name": "Test Token",
1865 "symbol": "TEST"
1866 },
1867 "quoteToken": {
1868 "address": "0x456",
1869 "name": "USD Coin",
1870 "symbol": "USDC"
1871 },
1872 "pairAddress": "0xabc",
1873 "dexId": "uniswap",
1874 "url": "https://example.com",
1875 "priceUsd": "1.5",
1876 "priceNative": "1.5",
1877 "volume": {"h24": 60000.0},
1878 "txns": {
1879 "h24": {
1880 "buys": 100,
1881 "sells": 50
1882 }
1883 }
1884 }]
1885 }"#
1886 .to_string()
1887 }
1888
1889 fn create_dexscreener_response_with_invalid_price() -> String {
1890 r#"{
1891 "schemaVersion": "1.0.0",
1892 "pairs": [{
1893 "chainId": "ethereum",
1894 "baseToken": {
1895 "address": "0x123",
1896 "name": "Test Token",
1897 "symbol": "TEST"
1898 },
1899 "quoteToken": {
1900 "address": "0x456",
1901 "name": "USD Coin",
1902 "symbol": "USDC"
1903 },
1904 "pairAddress": "0xabc",
1905 "dexId": "uniswap",
1906 "url": "https://example.com",
1907 "priceUsd": "invalid_price",
1908 "priceNative": "invalid_price"
1909 }]
1910 }"#
1911 .to_string()
1912 }
1913
1914 fn create_test_token_info(
1916 price_change_24h: Option<f64>,
1917 price_change_1h: Option<f64>,
1918 price_usd: Option<f64>,
1919 ) -> TokenInfo {
1920 TokenInfo {
1921 address: "0x123".to_string(),
1922 name: "Test Token".to_string(),
1923 symbol: "TEST".to_string(),
1924 decimals: 18,
1925 price_usd,
1926 market_cap: Some(1000000.0),
1927 volume_24h: Some(50000.0),
1928 price_change_24h,
1929 price_change_1h,
1930 price_change_5m: Some(0.5),
1931 circulating_supply: Some(1000000.0),
1932 total_supply: Some(10000000.0),
1933 pair_count: 0,
1934 pairs: vec![],
1935 chain: ChainInfo {
1936 id: "ethereum".to_string(),
1937 name: "Ethereum".to_string(),
1938 logo: None,
1939 native_token: "ETH".to_string(),
1940 },
1941 security: SecurityInfo {
1942 is_verified: true,
1943 liquidity_locked: Some(true),
1944 audit_status: None,
1945 honeypot_status: None,
1946 ownership_status: None,
1947 risk_score: Some(25),
1948 },
1949 socials: vec![],
1950 updated_at: Utc::now(),
1951 }
1952 }
1953
1954 fn create_test_token_pair(dex_name: &str, liquidity: f64) -> TokenPair {
1955 TokenPair {
1956 pair_id: "pair123".to_string(),
1957 dex: DexInfo {
1958 id: dex_name.to_string(),
1959 name: dex_name.to_string(),
1960 url: Some("https://dex.com".to_string()),
1961 logo: None,
1962 },
1963 base_token: PairToken {
1964 address: "0x123".to_string(),
1965 name: "Test Token".to_string(),
1966 symbol: "TEST".to_string(),
1967 },
1968 quote_token: PairToken {
1969 address: "0x456".to_string(),
1970 name: "USD Coin".to_string(),
1971 symbol: "USDC".to_string(),
1972 },
1973 price_usd: Some(1.0),
1974 price_native: Some(1.0),
1975 volume_24h: Some(10000.0),
1976 price_change_24h: Some(5.0),
1977 liquidity_usd: Some(liquidity),
1978 fdv: Some(2000000.0),
1979 created_at: None,
1980 last_trade_at: Utc::now(),
1981 txns_24h: TransactionStats {
1982 buys: Some(100),
1983 sells: Some(80),
1984 total: Some(180),
1985 buy_volume_usd: Some(6000.0),
1986 sell_volume_usd: Some(4000.0),
1987 },
1988 url: "https://dex.com/pair/123".to_string(),
1989 }
1990 }
1991}