1use 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#[derive(Debug, Clone)]
18pub struct PocketUniverseConfig {
19 pub base_url: String,
21 pub api_key: Option<String>,
23 pub rate_limit_per_minute: u32,
25 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
40fn 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
51#[serde(tag = "status", rename_all = "snake_case")]
52pub enum RugApiResponse {
53 #[serde(rename = "not_processed")]
55 NotProcessed {
56 message: String,
58 },
59 #[serde(rename = "processed")]
61 Processed {
62 message: String,
64 is_scam: bool,
66 rug_percent: f64,
68 fresh_percent: f64,
70 },
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
75pub struct ErrorDetail {
76 #[serde(rename = "type")]
78 pub error_type: String,
79 pub message: String,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
85pub struct ErrorResponse {
86 pub error: ErrorDetail,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
92pub struct RugCheckResult {
93 pub address: String,
95 pub is_processed: bool,
97 pub is_scam: Option<bool>,
99 pub rug_percentage: Option<f64>,
101 pub fresh_percentage: Option<f64>,
103 pub risk_level: RiskLevel,
105 pub message: String,
107 pub recommendation: String,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
113pub enum RiskLevel {
114 #[serde(rename = "unknown")]
116 Unknown,
117 #[serde(rename = "low")]
119 Low,
120 #[serde(rename = "medium")]
122 Medium,
123 #[serde(rename = "high")]
125 High,
126 #[serde(rename = "extreme")]
128 Extreme,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
133pub struct DetailedRugAnalysis {
134 pub address: String,
136 pub status: ProcessingStatus,
138 pub scam_detection: Option<ScamDetection>,
140 pub volume_analysis: Option<VolumeAnalysis>,
142 pub risk_assessment: RiskAssessment,
144 pub warnings: Vec<String>,
146 pub recommendation: String,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
152pub enum ProcessingStatus {
153 #[serde(rename = "processed")]
155 Processed,
156 #[serde(rename = "not_processed")]
158 NotProcessed,
159 #[serde(rename = "error")]
161 Error(String),
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
166pub struct ScamDetection {
167 pub is_scam: bool,
169 pub confidence: f64,
171 pub reason: String,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
177pub struct VolumeAnalysis {
178 pub rug_puller_percentage: f64,
180 pub fresh_wallet_percentage: f64,
182 pub regular_trader_percentage: f64,
184 pub concentration: VolumeConcentration,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
190pub enum VolumeConcentration {
191 #[serde(rename = "distributed")]
193 Distributed,
194 #[serde(rename = "moderate")]
196 Moderate,
197 #[serde(rename = "concentrated")]
199 Concentrated,
200 #[serde(rename = "extreme")]
202 Extreme,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
207pub struct RiskAssessment {
208 pub level: RiskLevel,
210 pub score: f64,
212 pub factors: Vec<String>,
214 pub action: String,
216}
217
218#[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 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#[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 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 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
350fn 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
363fn 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
376fn 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
393fn 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
432fn 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 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 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 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
508fn 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#[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 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#[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, (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, };
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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
639pub enum RiskTolerance {
640 #[serde(rename = "low")]
642 Low,
643 #[serde(rename = "medium")]
645 Medium,
646 #[serde(rename = "high")]
648 High,
649}
650
651#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
653pub struct SafetyCheck {
654 pub address: String,
656 pub is_safe: bool,
658 pub risk_level: RiskLevel,
660 pub safety_score: f64,
662 pub verdict: String,
664 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}