riglr_web_tools/
pocketuniverse.rs

1//! PocketUniverse integration for Solana token rug pull detection
2//!
3//! This module provides tools for accessing PocketUniverse API to analyze Solana tokens
4//! and pools for potential rug pull risks based on wallet history and trading patterns.
5
6use crate::{client::WebClient, error::WebToolError};
7use riglr_core::provider::ApplicationContext;
8use riglr_macros::tool;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use std::env;
12use tracing::{debug, info};
13
14const POCKET_UNIVERSE_API_KEY_ENV: &str = "POCKET_UNIVERSE_API_KEY";
15
16/// Configuration for PocketUniverse API access
17#[derive(Debug, Clone)]
18pub struct PocketUniverseConfig {
19    /// API base URL (default: https://api.pocketuniverse.app)
20    pub base_url: String,
21    /// API key for authentication
22    pub api_key: Option<String>,
23    /// Rate limit requests per minute (default: 60)
24    pub rate_limit_per_minute: u32,
25    /// Timeout for API requests in seconds (default: 30)
26    pub request_timeout: u64,
27}
28
29impl Default for PocketUniverseConfig {
30    fn default() -> Self {
31        Self {
32            base_url: "https://api.pocketuniverse.app".to_string(),
33            api_key: env::var(POCKET_UNIVERSE_API_KEY_ENV).ok(),
34            rate_limit_per_minute: 60,
35            request_timeout: 30,
36        }
37    }
38}
39
40/// Helper function to get PocketUniverse API key from ApplicationContext
41fn get_api_key_from_context(context: &ApplicationContext) -> Result<String, WebToolError> {
42    context.config.providers.pocket_universe_api_key
43        .clone()
44        .ok_or_else(|| WebToolError::Config(
45            "PocketUniverse API key not configured. Set POCKET_UNIVERSE_API_KEY in your environment.".to_string()
46        ))
47}
48
49/// Main rug check API response from PocketUniverse
50#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
51#[serde(tag = "status", rename_all = "snake_case")]
52pub enum RugApiResponse {
53    /// Token has not been processed yet
54    #[serde(rename = "not_processed")]
55    NotProcessed {
56        /// Explanation message
57        message: String,
58    },
59    /// Token has been processed and analyzed
60    #[serde(rename = "processed")]
61    Processed {
62        /// Human-readable analysis summary
63        message: String,
64        /// Whether the token is identified as a scam
65        is_scam: bool,
66        /// Percentage of volume from past rug pullers (0.0 to 1.0)
67        rug_percent: f64,
68        /// Percentage of volume from fresh wallets (0.0 to 1.0)
69        fresh_percent: f64,
70    },
71}
72
73/// Error detail structure
74#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
75pub struct ErrorDetail {
76    /// Error type
77    #[serde(rename = "type")]
78    pub error_type: String,
79    /// Error message
80    pub message: String,
81}
82
83/// Error response structure
84#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
85pub struct ErrorResponse {
86    /// Error details
87    pub error: ErrorDetail,
88}
89
90/// Simplified rug check result for easier consumption
91#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
92pub struct RugCheckResult {
93    /// Token/pool address that was checked
94    pub address: String,
95    /// Whether the analysis is available
96    pub is_processed: bool,
97    /// Whether the token is identified as a scam (None if not processed)
98    pub is_scam: Option<bool>,
99    /// Percentage of volume from rug pullers (0-100, None if not processed)
100    pub rug_percentage: Option<f64>,
101    /// Percentage of volume from fresh wallets (0-100, None if not processed)
102    pub fresh_percentage: Option<f64>,
103    /// Risk level classification
104    pub risk_level: RiskLevel,
105    /// Human-readable message
106    pub message: String,
107    /// Summary recommendation
108    pub recommendation: String,
109}
110
111/// Risk level classification for tokens
112#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
113pub enum RiskLevel {
114    /// Not enough data to assess risk
115    #[serde(rename = "unknown")]
116    Unknown,
117    /// Low risk - Minimal rug puller activity
118    #[serde(rename = "low")]
119    Low,
120    /// Medium risk - Some concerning patterns
121    #[serde(rename = "medium")]
122    Medium,
123    /// High risk - Significant rug puller activity
124    #[serde(rename = "high")]
125    High,
126    /// Extreme risk - Confirmed scam or very high rug puller percentage
127    #[serde(rename = "extreme")]
128    Extreme,
129}
130
131/// Detailed analysis result with additional insights
132#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
133pub struct DetailedRugAnalysis {
134    /// Token/pool address
135    pub address: String,
136    /// Processing status
137    pub status: ProcessingStatus,
138    /// Scam detection result
139    pub scam_detection: Option<ScamDetection>,
140    /// Volume analysis
141    pub volume_analysis: Option<VolumeAnalysis>,
142    /// Risk assessment
143    pub risk_assessment: RiskAssessment,
144    /// Key warnings
145    pub warnings: Vec<String>,
146    /// Actionable recommendation
147    pub recommendation: String,
148}
149
150/// Processing status of the token
151#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
152pub enum ProcessingStatus {
153    /// Token has been fully processed
154    #[serde(rename = "processed")]
155    Processed,
156    /// Token not yet processed (insufficient data)
157    #[serde(rename = "not_processed")]
158    NotProcessed,
159    /// Error occurred during processing
160    #[serde(rename = "error")]
161    Error(String),
162}
163
164/// Scam detection results
165#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
166pub struct ScamDetection {
167    /// Whether identified as scam
168    pub is_scam: bool,
169    /// Confidence level (0-100)
170    pub confidence: f64,
171    /// Reason for classification
172    pub reason: String,
173}
174
175/// Volume analysis breakdown
176#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
177pub struct VolumeAnalysis {
178    /// Percentage from rug pullers (0-100)
179    pub rug_puller_percentage: f64,
180    /// Percentage from fresh wallets (0-100)
181    pub fresh_wallet_percentage: f64,
182    /// Percentage from regular traders (0-100)
183    pub regular_trader_percentage: f64,
184    /// Volume concentration assessment
185    pub concentration: VolumeConcentration,
186}
187
188/// Volume concentration level
189#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
190pub enum VolumeConcentration {
191    /// Volume well distributed
192    #[serde(rename = "distributed")]
193    Distributed,
194    /// Moderate concentration
195    #[serde(rename = "moderate")]
196    Moderate,
197    /// High concentration in few wallets
198    #[serde(rename = "concentrated")]
199    Concentrated,
200    /// Extreme concentration (potential manipulation)
201    #[serde(rename = "extreme")]
202    Extreme,
203}
204
205/// Risk assessment summary
206#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
207pub struct RiskAssessment {
208    /// Overall risk level
209    pub level: RiskLevel,
210    /// Risk score (0-100)
211    pub score: f64,
212    /// Main risk factors
213    pub factors: Vec<String>,
214    /// Suggested action
215    pub action: String,
216}
217
218/// Check a Solana token or pool for rug pull risk using PocketUniverse.
219/// This is the raw API call that returns the direct response.
220#[tool]
221pub async fn check_rug_pull_raw(
222    context: &ApplicationContext,
223    address: String,
224) -> crate::error::Result<RugApiResponse> {
225    debug!("Checking rug pull risk for address: {}", address);
226
227    let config = PocketUniverseConfig::default();
228    let client = WebClient::default();
229
230    // Get API key from ApplicationContext
231    let api_key = get_api_key_from_context(context)?;
232
233    let url = format!(
234        "{}/rug_check/{}?address={}",
235        config.base_url, api_key, address
236    );
237
238    info!("Requesting rug check from PocketUniverse for: {}", address);
239
240    let response_text = client
241        .get(&url)
242        .await
243        .map_err(|e| WebToolError::Network(format!("Failed to fetch rug check: {}", e)))?;
244
245    let response: RugApiResponse = serde_json::from_str(&response_text).map_err(|e| {
246        WebToolError::Parsing(format!("Failed to parse PocketUniverse response: {}", e))
247    })?;
248
249    match &response {
250        RugApiResponse::NotProcessed { message } => {
251            info!("Token {} not processed: {}", address, message);
252        }
253        RugApiResponse::Processed {
254            is_scam,
255            rug_percent,
256            ..
257        } => {
258            info!(
259                "Token {} analyzed - Scam: {}, Rug percentage: {:.1}%",
260                address,
261                is_scam,
262                rug_percent * 100.0
263            );
264        }
265    }
266
267    Ok(response)
268}
269
270/// Check a Solana token or pool for rug pull risk with simplified results.
271/// Provides an easy-to-use risk assessment based on PocketUniverse data.
272#[tool]
273pub async fn check_rug_pull(
274    context: &ApplicationContext,
275    address: String,
276) -> crate::error::Result<RugCheckResult> {
277    debug!("Performing simplified rug check for: {}", address);
278
279    let raw_response = check_rug_pull_raw(context, address.clone()).await?;
280
281    let (is_processed, is_scam, rug_percentage, fresh_percentage, message) = match raw_response {
282        RugApiResponse::NotProcessed { message } => (false, None, None, None, message),
283        RugApiResponse::Processed {
284            message,
285            is_scam,
286            rug_percent,
287            fresh_percent,
288        } => (
289            true,
290            Some(is_scam),
291            Some(rug_percent * 100.0),
292            Some(fresh_percent * 100.0),
293            message,
294        ),
295    };
296
297    // Determine risk level
298    let risk_level = if !is_processed {
299        RiskLevel::Unknown
300    } else if is_scam.unwrap_or(false) {
301        RiskLevel::Extreme
302    } else if let Some(rug_pct) = rug_percentage {
303        if rug_pct > 70.0 {
304            RiskLevel::Extreme
305        } else if rug_pct > 50.0 {
306            RiskLevel::High
307        } else if rug_pct > 25.0 {
308            RiskLevel::Medium
309        } else {
310            RiskLevel::Low
311        }
312    } else {
313        RiskLevel::Unknown
314    };
315
316    // Generate recommendation
317    let recommendation = match risk_level {
318        RiskLevel::Unknown => {
319            "Unable to assess risk. Token may be too new or have insufficient trading data."
320                .to_string()
321        }
322        RiskLevel::Low => {
323            "Low risk detected. Token appears relatively safe but always DYOR.".to_string()
324        }
325        RiskLevel::Medium => {
326            "Moderate risk. Some concerning patterns detected. Proceed with caution.".to_string()
327        }
328        RiskLevel::High => {
329            "HIGH RISK: Significant rug puller activity detected. Strong caution advised."
330                .to_string()
331        }
332        RiskLevel::Extreme => {
333            "EXTREME RISK: Token identified as scam or has very high rug puller percentage. AVOID."
334                .to_string()
335        }
336    };
337
338    Ok(RugCheckResult {
339        address,
340        is_processed,
341        is_scam,
342        rug_percentage,
343        fresh_percentage,
344        risk_level,
345        message,
346        recommendation,
347    })
348}
349
350/// Helper function to build scam detection from processed data
351fn build_scam_detection(is_scam: bool, rug_percent: f64, message: String) -> ScamDetection {
352    ScamDetection {
353        is_scam,
354        confidence: if is_scam {
355            rug_percent * 100.0
356        } else {
357            (1.0 - rug_percent) * 100.0
358        },
359        reason: message,
360    }
361}
362
363/// Helper function to determine volume concentration level
364fn determine_volume_concentration(rug_percent: f64) -> VolumeConcentration {
365    if rug_percent > 0.7 {
366        VolumeConcentration::Extreme
367    } else if rug_percent > 0.5 {
368        VolumeConcentration::Concentrated
369    } else if rug_percent > 0.3 {
370        VolumeConcentration::Moderate
371    } else {
372        VolumeConcentration::Distributed
373    }
374}
375
376/// Helper function to build volume analysis from processed data
377fn build_volume_analysis(rug_percent: f64, fresh_percent: f64) -> VolumeAnalysis {
378    let regular_percent = 1.0 - rug_percent - fresh_percent;
379    let regular_percentage = if regular_percent > 0.0 {
380        regular_percent * 100.0
381    } else {
382        0.0
383    };
384
385    VolumeAnalysis {
386        rug_puller_percentage: rug_percent * 100.0,
387        fresh_wallet_percentage: fresh_percent * 100.0,
388        regular_trader_percentage: regular_percentage,
389        concentration: determine_volume_concentration(rug_percent),
390    }
391}
392
393/// Helper function to add warnings based on volume analysis
394fn add_volume_warnings(
395    warnings: &mut Vec<String>,
396    is_scam: bool,
397    rug_percent: f64,
398    fresh_percent: f64,
399    regular_percentage: f64,
400) {
401    if is_scam {
402        warnings.push("Token identified as SCAM by PocketUniverse".to_string());
403    }
404
405    if rug_percent > 0.7 {
406        warnings.push(format!(
407            "{:.1}% of volume from known rug pullers",
408            rug_percent * 100.0
409        ));
410    } else if rug_percent > 0.5 {
411        warnings.push(format!(
412            "High rug puller activity: {:.1}%",
413            rug_percent * 100.0
414        ));
415    }
416
417    if fresh_percent > 0.5 {
418        warnings.push(format!(
419            "High fresh wallet activity: {:.1}%",
420            fresh_percent * 100.0
421        ));
422    }
423
424    if regular_percentage < 20.0 {
425        warnings.push(format!(
426            "Low regular trader participation: {:.1}%",
427            regular_percentage
428        ));
429    }
430}
431
432/// Helper function to build risk assessment from scam detection and volume analysis
433fn build_risk_assessment(
434    scam_detection: &Option<ScamDetection>,
435    volume_analysis: &Option<VolumeAnalysis>,
436    status: &ProcessingStatus,
437) -> RiskAssessment {
438    let mut risk_factors = Vec::new();
439    let mut risk_score: f64 = 0.0;
440
441    // Check scam detection
442    if let Some(scam) = scam_detection {
443        if scam.is_scam {
444            risk_factors.push("Identified as scam".to_string());
445            risk_score = 100.0;
446        }
447    }
448
449    // Analyze volume patterns
450    if let Some(vol) = volume_analysis {
451        if vol.rug_puller_percentage > 50.0 {
452            risk_factors.push("Majority volume from rug pullers".to_string());
453            risk_score = risk_score.max(80.0 + (vol.rug_puller_percentage - 50.0) * 0.4);
454        } else if vol.rug_puller_percentage > 25.0 {
455            risk_factors.push("Significant rug puller presence".to_string());
456            risk_score = risk_score.max(40.0 + (vol.rug_puller_percentage - 25.0) * 1.6);
457        }
458
459        if vol.fresh_wallet_percentage > 40.0 {
460            risk_factors.push("High fresh wallet activity".to_string());
461            risk_score = risk_score.max(risk_score + 10.0);
462        }
463
464        if matches!(
465            vol.concentration,
466            VolumeConcentration::Extreme | VolumeConcentration::Concentrated
467        ) {
468            risk_factors.push("Volume highly concentrated".to_string());
469            risk_score = risk_score.max(risk_score + 15.0);
470        }
471    }
472
473    if risk_factors.is_empty() && matches!(status, ProcessingStatus::Processed) {
474        risk_factors.push("No major risk factors identified".to_string());
475    }
476
477    // Determine risk level
478    let risk_level = if risk_score >= 80.0 {
479        RiskLevel::Extreme
480    } else if risk_score >= 60.0 {
481        RiskLevel::High
482    } else if risk_score >= 30.0 {
483        RiskLevel::Medium
484    } else if matches!(status, ProcessingStatus::Processed) {
485        RiskLevel::Low
486    } else {
487        RiskLevel::Unknown
488    };
489
490    let action = match risk_level {
491        RiskLevel::Unknown => "Wait for more trading data before investing".to_string(),
492        RiskLevel::Low => "Can consider investment with standard precautions".to_string(),
493        RiskLevel::Medium => {
494            "Exercise caution, invest only what you can afford to lose".to_string()
495        }
496        RiskLevel::High => "Avoid investment, high risk of loss".to_string(),
497        RiskLevel::Extreme => "DO NOT INVEST - Extreme risk or confirmed scam".to_string(),
498    };
499
500    RiskAssessment {
501        level: risk_level,
502        score: risk_score,
503        factors: risk_factors,
504        action,
505    }
506}
507
508/// Helper function to generate recommendation based on status and risk level
509fn generate_recommendation(status: &ProcessingStatus, risk_level: &RiskLevel) -> String {
510    match (status, risk_level) {
511        (ProcessingStatus::NotProcessed, _) => {
512            "Token has insufficient data for analysis. Wait for more trading activity before making investment decisions.".to_string()
513        }
514        (_, RiskLevel::Extreme) => {
515            "EXTREME DANGER: This token shows clear signs of being a scam or rug pull. Do not invest under any circumstances.".to_string()
516        }
517        (_, RiskLevel::High) => {
518            "HIGH RISK: Significant red flags detected. This token has high probability of being a rug pull. Strongly recommend avoiding.".to_string()
519        }
520        (_, RiskLevel::Medium) => {
521            "MODERATE RISK: Some concerning patterns detected. If you choose to invest, use extreme caution and only risk what you can afford to lose.".to_string()
522        }
523        (_, RiskLevel::Low) => {
524            "LOW RISK: Token appears relatively safe based on wallet analysis, but always do your own research and invest responsibly.".to_string()
525        }
526        (_, RiskLevel::Unknown) => {
527            "UNKNOWN RISK: Unable to determine risk level. More data needed for proper assessment.".to_string()
528        }
529    }
530}
531
532/// Perform detailed analysis of a token's rug pull risk with comprehensive insights.
533/// Provides volume breakdown, risk factors, and actionable recommendations.
534#[tool]
535pub async fn analyze_rug_risk(
536    context: &ApplicationContext,
537    address: String,
538) -> crate::error::Result<DetailedRugAnalysis> {
539    debug!("Performing detailed rug analysis for: {}", address);
540
541    let raw_response = check_rug_pull_raw(context, address.clone()).await?;
542    let mut warnings = Vec::new();
543
544    let (status, scam_detection, volume_analysis) = match raw_response {
545        RugApiResponse::NotProcessed { message } => {
546            warnings.push(message);
547            (ProcessingStatus::NotProcessed, None, None)
548        }
549        RugApiResponse::Processed {
550            message,
551            is_scam,
552            rug_percent,
553            fresh_percent,
554        } => {
555            let scam_detection = Some(build_scam_detection(is_scam, rug_percent, message));
556            let volume_analysis = Some(build_volume_analysis(rug_percent, fresh_percent));
557
558            // Add warnings based on thresholds
559            let regular_percentage = (1.0 - rug_percent - fresh_percent).max(0.0) * 100.0;
560            add_volume_warnings(
561                &mut warnings,
562                is_scam,
563                rug_percent,
564                fresh_percent,
565                regular_percentage,
566            );
567
568            (ProcessingStatus::Processed, scam_detection, volume_analysis)
569        }
570    };
571
572    let risk_assessment = build_risk_assessment(&scam_detection, &volume_analysis, &status);
573    let recommendation = generate_recommendation(&status, &risk_assessment.level);
574
575    Ok(DetailedRugAnalysis {
576        address,
577        status,
578        scam_detection,
579        volume_analysis,
580        risk_assessment,
581        warnings,
582        recommendation,
583    })
584}
585
586/// Quick safety check for a Solana token - returns a simple safe/unsafe verdict.
587/// Best for quick filtering of tokens before deeper analysis.
588#[tool]
589pub async fn is_token_safe(
590    context: &ApplicationContext,
591    address: String,
592    risk_tolerance: Option<RiskTolerance>,
593) -> crate::error::Result<SafetyCheck> {
594    debug!("Performing quick safety check for: {}", address);
595
596    let risk_tolerance = risk_tolerance.unwrap_or(RiskTolerance::Low);
597    let result = check_rug_pull(context, address.clone()).await?;
598
599    let is_safe = match (&result.risk_level, &risk_tolerance) {
600        (RiskLevel::Unknown, _) => false, // Unknown is unsafe by default
601        (RiskLevel::Low, _) => true,
602        (RiskLevel::Medium, RiskTolerance::Medium | RiskTolerance::High) => true,
603        (RiskLevel::Medium, RiskTolerance::Low) => false,
604        (RiskLevel::High, RiskTolerance::High) => true,
605        (RiskLevel::High, _) => false,
606        (RiskLevel::Extreme, _) => false, // Extreme is never safe
607    };
608
609    let safety_score = match result.risk_level {
610        RiskLevel::Unknown => 0.0,
611        RiskLevel::Low => 80.0 - result.rug_percentage.unwrap_or(0.0) * 0.8,
612        RiskLevel::Medium => 60.0 - result.rug_percentage.unwrap_or(25.0) * 0.6,
613        RiskLevel::High => 30.0 - result.rug_percentage.unwrap_or(50.0) * 0.3,
614        RiskLevel::Extreme => 0.0,
615    };
616
617    let verdict = if !result.is_processed {
618        "UNVERIFIED: Insufficient data"
619    } else if result.is_scam.unwrap_or(false) {
620        "UNSAFE: Confirmed scam"
621    } else if is_safe {
622        "SAFE: Acceptable risk level"
623    } else {
624        "UNSAFE: Risk exceeds tolerance"
625    };
626
627    Ok(SafetyCheck {
628        address,
629        is_safe,
630        risk_level: result.risk_level,
631        safety_score,
632        verdict: verdict.to_string(),
633        details: result.message,
634    })
635}
636
637/// Risk tolerance levels for safety checks
638#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
639pub enum RiskTolerance {
640    /// Only accept low risk tokens
641    #[serde(rename = "low")]
642    Low,
643    /// Accept low and medium risk tokens
644    #[serde(rename = "medium")]
645    Medium,
646    /// Accept all except extreme risk tokens
647    #[serde(rename = "high")]
648    High,
649}
650
651/// Simple safety check result
652#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
653pub struct SafetyCheck {
654    /// Token address
655    pub address: String,
656    /// Whether token is considered safe given risk tolerance
657    pub is_safe: bool,
658    /// Risk level
659    pub risk_level: RiskLevel,
660    /// Safety score (0-100, higher is safer)
661    pub safety_score: f64,
662    /// Simple verdict
663    pub verdict: String,
664    /// Additional details
665    pub details: String,
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    #[test]
673    fn test_pocketuniverse_config_default() {
674        let config = PocketUniverseConfig::default();
675        assert_eq!(config.base_url, "https://api.pocketuniverse.app");
676        assert_eq!(config.rate_limit_per_minute, 60);
677        assert_eq!(config.request_timeout, 30);
678    }
679
680    #[test]
681    fn test_risk_level_serialization() {
682        let risk = RiskLevel::High;
683        let json = serde_json::to_string(&risk).unwrap();
684        assert_eq!(json, "\"high\"");
685
686        let risk: RiskLevel = serde_json::from_str("\"extreme\"").unwrap();
687        assert!(matches!(risk, RiskLevel::Extreme));
688    }
689
690    #[test]
691    fn test_rug_api_response_not_processed() {
692        let json = r#"{
693            "status": "not_processed",
694            "message": "Token has not been processed"
695        }"#;
696
697        let response: RugApiResponse = serde_json::from_str(json).unwrap();
698        assert!(matches!(response, RugApiResponse::NotProcessed { .. }));
699
700        if let RugApiResponse::NotProcessed { message } = response {
701            assert_eq!(message, "Token has not been processed");
702        }
703    }
704
705    #[test]
706    fn test_rug_api_response_processed() {
707        let json = r#"{
708            "status": "processed",
709            "message": "88% of volume is from past rug pullers",
710            "is_scam": true,
711            "rug_percent": 0.88,
712            "fresh_percent": 0.11
713        }"#;
714
715        let response: RugApiResponse = serde_json::from_str(json).unwrap();
716        assert!(matches!(response, RugApiResponse::Processed { .. }));
717
718        if let RugApiResponse::Processed {
719            message,
720            is_scam,
721            rug_percent,
722            fresh_percent,
723        } = response
724        {
725            assert_eq!(message, "88% of volume is from past rug pullers");
726            assert!(is_scam);
727            assert!((rug_percent - 0.88).abs() < 0.001);
728            assert!((fresh_percent - 0.11).abs() < 0.001);
729        }
730    }
731
732    #[test]
733    fn test_risk_tolerance_serialization() {
734        let tolerance = RiskTolerance::Medium;
735        let json = serde_json::to_string(&tolerance).unwrap();
736        assert_eq!(json, "\"medium\"");
737
738        let tolerance: RiskTolerance = serde_json::from_str("\"high\"").unwrap();
739        assert!(matches!(tolerance, RiskTolerance::High));
740    }
741
742    #[test]
743    fn test_volume_concentration_serialization() {
744        let concentration = VolumeConcentration::Extreme;
745        let json = serde_json::to_string(&concentration).unwrap();
746        assert_eq!(json, "\"extreme\"");
747
748        let concentration: VolumeConcentration = serde_json::from_str("\"distributed\"").unwrap();
749        assert!(matches!(concentration, VolumeConcentration::Distributed));
750    }
751
752    #[test]
753    fn test_processing_status_serialization() {
754        let status = ProcessingStatus::Processed;
755        let json = serde_json::to_string(&status).unwrap();
756        assert_eq!(json, "\"processed\"");
757
758        let status = ProcessingStatus::Error("test error".to_string());
759        let json = serde_json::to_string(&status).unwrap();
760        assert!(json.contains("error"));
761        assert!(json.contains("test error"));
762    }
763}