1use crate::{client::WebClient, error::WebToolError};
7use riglr_macros::tool;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use tracing::{debug, info};
12
13#[derive(Debug, Clone)]
15pub struct TrenchBotConfig {
16 pub base_url: String,
18 pub rate_limit_per_minute: u32,
20 pub request_timeout: u64,
22}
23
24impl Default for TrenchBotConfig {
25 fn default() -> Self {
26 Self {
27 base_url: "https://trench.bot/api".to_string(),
28 rate_limit_per_minute: 60,
29 request_timeout: 30,
30 }
31 }
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
36pub struct BundleResponse {
37 pub bonded: Option<bool>,
39 pub bundles: Option<HashMap<String, BundleDetails>>,
41 pub creator_analysis: Option<CreatorAnalysis>,
43 pub distributed_amount: Option<i64>,
45 pub distributed_percentage: Option<f64>,
47 pub distributed_wallets: Option<i32>,
49 pub ticker: Option<String>,
51 pub total_bundles: Option<i32>,
53 pub total_holding_amount: Option<i64>,
55 pub total_holding_percentage: Option<f64>,
57 pub total_percentage_bundled: Option<f64>,
59 pub total_sol_spent: Option<f64>,
61 pub total_tokens_bundled: Option<i64>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
67pub struct BundleDetails {
68 pub bundle_analysis: Option<BundleAnalysis>,
70 pub holding_amount: Option<i64>,
72 pub holding_percentage: Option<f64>,
74 pub token_percentage: Option<f64>,
76 pub total_sol: Option<f64>,
78 pub total_tokens: Option<i64>,
80 pub unique_wallets: Option<i32>,
82 pub wallet_categories: Option<HashMap<String, String>>,
84 pub wallet_info: Option<HashMap<String, WalletInfo>>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
90pub struct BundleAnalysis {
91 pub category_breakdown: Option<HashMap<String, i32>>,
93 pub is_likely_bundle: Option<bool>,
95 pub primary_category: Option<String>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
101pub struct WalletInfo {
102 pub sol: Option<f64>,
104 pub sol_percentage: Option<f64>,
106 pub token_percentage: Option<f64>,
108 pub tokens: Option<i64>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
114pub struct CreatorAnalysis {
115 pub address: Option<String>,
117 pub current_holdings: Option<i64>,
119 pub history: Option<CreatorHistory>,
121 pub holding_percentage: Option<f64>,
123 pub risk_level: Option<RiskLevel>,
125 pub warning_flags: Option<Vec<String>>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
131pub struct CreatorHistory {
132 pub average_market_cap: Option<i64>,
134 pub high_risk: Option<bool>,
136 pub previous_coins: Option<Vec<PreviousCoin>>,
138 pub recent_rugs: Option<i64>,
140 pub rug_count: Option<i64>,
142 pub rug_percentage: Option<f64>,
144 pub total_coins_created: Option<i64>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
150pub struct PreviousCoin {
151 pub created_at: Option<i64>,
153 pub is_rug: Option<bool>,
155 pub market_cap: Option<i64>,
157 pub mint: Option<String>,
159 pub symbol: Option<String>,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
165pub enum RiskLevel {
166 #[serde(rename = "LOW")]
168 Low,
169 #[serde(rename = "MEDIUM")]
171 Medium,
172 #[serde(rename = "HIGH")]
174 High,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
179pub struct BundleAnalysisResult {
180 pub token: String,
182 pub ticker: Option<String>,
184 pub is_bundled: bool,
186 pub bundle_percentage: f64,
188 pub bundle_count: i32,
190 pub total_sol_spent: f64,
192 pub holder_count: i32,
194 pub creator_risk: CreatorRiskAssessment,
196 pub wallet_categories: WalletCategoryBreakdown,
198 pub warnings: Vec<String>,
200 pub recommendation: String,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
206pub struct CreatorRiskAssessment {
207 pub address: Option<String>,
209 pub risk_level: Option<RiskLevel>,
211 pub rug_percentage: f64,
213 pub total_coins: i64,
215 pub rug_count: i64,
217 pub holding_percentage: f64,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
223pub struct WalletCategoryBreakdown {
224 pub snipers: i32,
226 pub regular: i32,
228 pub sniper_percentage: f64,
230 pub primary_category: String,
232}
233
234#[tool]
237pub async fn get_bundle_info(
238 _context: &riglr_core::provider::ApplicationContext,
239 token: String,
240) -> crate::error::Result<BundleResponse> {
241 debug!("Fetching bundle info for token: {}", token);
242
243 let config = TrenchBotConfig::default();
244 let client = WebClient::default();
245
246 let url = format!("{}/bundle/bundle_advanced/{}", config.base_url, token);
247
248 info!("Requesting bundle info from: {}", url);
249
250 let response_text = client
251 .get(&url)
252 .await
253 .map_err(|e| WebToolError::Network(format!("Failed to fetch bundle info: {}", e)))?;
254
255 let bundle_response: BundleResponse = serde_json::from_str(&response_text)
256 .map_err(|e| WebToolError::Parsing(format!("Failed to parse TrenchBot response: {}", e)))?;
257
258 info!(
259 "Successfully fetched bundle info for {} - Bundles: {:?}, Bundled: {:?}%",
260 token, bundle_response.total_bundles, bundle_response.total_percentage_bundled
261 );
262
263 Ok(bundle_response)
264}
265
266fn calculate_wallet_categories(
268 bundles: &HashMap<String, BundleDetails>,
269) -> (i32, i32, f64, String) {
270 let mut sniper_count = 0;
271 let mut regular_count = 0;
272 let mut sniper_tokens = 0i64;
273 let mut total_tokens = 0i64;
274
275 for bundle in bundles.values() {
276 if let Some(categories) = &bundle.wallet_categories {
277 for category in categories.values() {
278 match category.as_str() {
279 "sniper" => sniper_count += 1,
280 "regular" => regular_count += 1,
281 _ => {}
282 }
283 }
284 }
285
286 if let Some(wallet_info) = &bundle.wallet_info {
287 for (addr, info) in wallet_info {
288 if let Some(tokens) = info.tokens {
289 total_tokens += tokens;
290 if let Some(categories) = &bundle.wallet_categories {
291 if categories.get(addr).map_or(false, |c| c == "sniper") {
292 sniper_tokens += tokens;
293 }
294 }
295 }
296 }
297 }
298 }
299
300 let sniper_percentage = if total_tokens > 0 {
301 (sniper_tokens as f64 / total_tokens as f64) * 100.0
302 } else {
303 0.0
304 };
305
306 let primary_category = if sniper_count > regular_count {
307 "sniper".to_string()
308 } else {
309 "regular".to_string()
310 };
311
312 (
313 sniper_count,
314 regular_count,
315 sniper_percentage,
316 primary_category,
317 )
318}
319
320fn build_creator_risk_assessment(
322 creator_analysis: Option<&CreatorAnalysis>,
323) -> CreatorRiskAssessment {
324 if let Some(creator) = creator_analysis {
325 CreatorRiskAssessment {
326 address: creator.address.clone(),
327 risk_level: creator.risk_level.clone(),
328 rug_percentage: creator
329 .history
330 .as_ref()
331 .and_then(|h| h.rug_percentage)
332 .unwrap_or(0.0),
333 total_coins: creator
334 .history
335 .as_ref()
336 .and_then(|h| h.total_coins_created)
337 .unwrap_or(0),
338 rug_count: creator
339 .history
340 .as_ref()
341 .and_then(|h| h.rug_count)
342 .unwrap_or(0),
343 holding_percentage: creator.holding_percentage.unwrap_or(0.0),
344 }
345 } else {
346 CreatorRiskAssessment {
347 address: None,
348 risk_level: None,
349 rug_percentage: 0.0,
350 total_coins: 0,
351 rug_count: 0,
352 holding_percentage: 0.0,
353 }
354 }
355}
356
357fn collect_warnings(bundle_data: &BundleResponse, sniper_percentage: f64) -> Vec<String> {
359 let mut warnings = Vec::new();
360
361 if bundle_data.total_percentage_bundled.unwrap_or(0.0) > 50.0 {
362 warnings.push("High bundle concentration (>50% bundled)".to_string());
363 }
364
365 if sniper_percentage > 30.0 {
366 warnings.push("High sniper wallet presence".to_string());
367 }
368
369 if let Some(creator) = &bundle_data.creator_analysis {
370 if let Some(flags) = &creator.warning_flags {
371 warnings.extend(flags.clone());
372 }
373
374 if matches!(creator.risk_level, Some(RiskLevel::High)) {
375 warnings.push("Creator has high risk profile".to_string());
376 }
377
378 if creator.holding_percentage.unwrap_or(0.0) > 20.0 {
379 warnings.push("Creator holds >20% of supply".to_string());
380 }
381 }
382
383 warnings
384}
385
386fn generate_recommendation(bundle_percentage: f64) -> String {
388 if bundle_percentage > 70.0 {
389 "EXTREME CAUTION: Very high bundle concentration detected. High risk of coordinated dump."
390 } else if bundle_percentage > 50.0 {
391 "HIGH RISK: Significant bundling detected. Potential for price manipulation."
392 } else if bundle_percentage > 30.0 {
393 "MODERATE RISK: Notable bundling present. Monitor closely for unusual activity."
394 } else if bundle_percentage > 10.0 {
395 "LOW-MODERATE RISK: Some bundling detected but within acceptable range."
396 } else {
397 "LOW RISK: Minimal bundling detected. Distribution appears organic."
398 }
399 .to_string()
400}
401
402#[tool]
405pub async fn analyze_token_bundles(
406 context: &riglr_core::provider::ApplicationContext,
407 token: String,
408) -> crate::error::Result<BundleAnalysisResult> {
409 debug!("Analyzing token bundles for: {}", token);
410
411 let bundle_data = get_bundle_info(context, token.clone()).await?;
412
413 let (sniper_count, regular_count, sniper_percentage, primary_category) =
414 if let Some(bundles) = &bundle_data.bundles {
415 calculate_wallet_categories(bundles)
416 } else {
417 (0, 0, 0.0, "regular".to_string())
418 };
419
420 let creator_risk = build_creator_risk_assessment(bundle_data.creator_analysis.as_ref());
421 let warnings = collect_warnings(&bundle_data, sniper_percentage);
422 let bundle_percentage = bundle_data.total_percentage_bundled.unwrap_or(0.0);
423 let recommendation = generate_recommendation(bundle_percentage);
424
425 Ok(BundleAnalysisResult {
426 token,
427 ticker: bundle_data.ticker,
428 is_bundled: bundle_data.total_bundles.unwrap_or(0) > 0,
429 bundle_percentage,
430 bundle_count: bundle_data.total_bundles.unwrap_or(0),
431 total_sol_spent: bundle_data.total_sol_spent.unwrap_or(0.0),
432 holder_count: bundle_data.distributed_wallets.unwrap_or(0),
433 creator_risk,
434 wallet_categories: WalletCategoryBreakdown {
435 snipers: sniper_count,
436 regular: regular_count,
437 sniper_percentage,
438 primary_category,
439 },
440 warnings,
441 recommendation,
442 })
443}
444
445#[tool]
448pub async fn check_bundle_risk(
449 context: &riglr_core::provider::ApplicationContext,
450 token: String,
451) -> crate::error::Result<BundleRiskCheck> {
452 debug!("Checking bundle risk for token: {}", token);
453
454 let bundle_data = get_bundle_info(context, token.clone()).await?;
455
456 let bundle_percentage = bundle_data.total_percentage_bundled.unwrap_or(0.0);
457 let bundle_count = bundle_data.total_bundles.unwrap_or(0);
458
459 let risk_level = if bundle_percentage > 70.0 {
460 "EXTREME"
461 } else if bundle_percentage > 50.0 {
462 "HIGH"
463 } else if bundle_percentage > 30.0 {
464 "MODERATE"
465 } else if bundle_percentage > 10.0 {
466 "LOW-MODERATE"
467 } else {
468 "LOW"
469 };
470
471 let is_high_risk = bundle_percentage > 50.0;
472
473 let message = format!(
474 "{} risk: {:.1}% of supply is bundled across {} bundles",
475 risk_level, bundle_percentage, bundle_count
476 );
477
478 Ok(BundleRiskCheck {
479 token,
480 is_bundled: bundle_count > 0,
481 bundle_percentage,
482 bundle_count,
483 risk_level: risk_level.to_string(),
484 is_high_risk,
485 message,
486 })
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
491pub struct BundleRiskCheck {
492 pub token: String,
494 pub is_bundled: bool,
496 pub bundle_percentage: f64,
498 pub bundle_count: i32,
500 pub risk_level: String,
502 pub is_high_risk: bool,
504 pub message: String,
506}
507
508#[tool]
511pub async fn analyze_creator_risk(
512 context: &riglr_core::provider::ApplicationContext,
513 token: String,
514) -> crate::error::Result<CreatorAnalysisResult> {
515 debug!("Analyzing creator risk for token: {}", token);
516
517 let bundle_data = get_bundle_info(context, token.clone()).await?;
518
519 if let Some(creator) = bundle_data.creator_analysis {
520 let mut red_flags = Vec::new();
521
522 if let Some(history) = &creator.history {
524 if history.rug_count.unwrap_or(0) > 0 {
525 red_flags.push(format!(
526 "Creator has {} previous rug pulls",
527 history.rug_count.unwrap_or(0)
528 ));
529 }
530
531 if history.rug_percentage.unwrap_or(0.0) > 20.0 {
532 red_flags.push(format!(
533 "{:.1}% of creator's tokens were rugs",
534 history.rug_percentage.unwrap_or(0.0)
535 ));
536 }
537
538 if history.high_risk.unwrap_or(false) {
539 red_flags.push("Creator flagged as high risk".to_string());
540 }
541
542 if history.recent_rugs.unwrap_or(0) > 0 {
543 red_flags.push(format!(
544 "{} recent rug pulls detected",
545 history.recent_rugs.unwrap_or(0)
546 ));
547 }
548 }
549
550 if creator.holding_percentage.unwrap_or(0.0) > 30.0 {
551 red_flags.push(format!(
552 "Creator holds {:.1}% of supply",
553 creator.holding_percentage.unwrap_or(0.0)
554 ));
555 }
556
557 let risk_assessment = match &creator.risk_level {
558 Some(RiskLevel::High) => "HIGH RISK: Creator has concerning history",
559 Some(RiskLevel::Medium) => "MODERATE RISK: Some red flags in creator history",
560 Some(RiskLevel::Low) => "LOW RISK: Creator appears legitimate",
561 None => "UNKNOWN: Unable to assess creator risk",
562 }
563 .to_string();
564
565 Ok(CreatorAnalysisResult {
566 token,
567 creator_address: creator.address,
568 risk_level: creator.risk_level,
569 current_holdings: creator.current_holdings,
570 holding_percentage: creator.holding_percentage.unwrap_or(0.0),
571 total_coins_created: creator
572 .history
573 .as_ref()
574 .and_then(|h| h.total_coins_created)
575 .unwrap_or(0),
576 rug_count: creator
577 .history
578 .as_ref()
579 .and_then(|h| h.rug_count)
580 .unwrap_or(0),
581 rug_percentage: creator
582 .history
583 .as_ref()
584 .and_then(|h| h.rug_percentage)
585 .unwrap_or(0.0),
586 average_market_cap: creator.history.as_ref().and_then(|h| h.average_market_cap),
587 previous_coins: creator
588 .history
589 .and_then(|h| h.previous_coins)
590 .unwrap_or_default(),
591 red_flags,
592 risk_assessment,
593 })
594 } else {
595 Ok(CreatorAnalysisResult {
596 token,
597 creator_address: None,
598 risk_level: None,
599 current_holdings: None,
600 holding_percentage: 0.0,
601 total_coins_created: 0,
602 rug_count: 0,
603 rug_percentage: 0.0,
604 average_market_cap: None,
605 previous_coins: Vec::new(),
606 red_flags: vec!["No creator analysis available".to_string()],
607 risk_assessment: "UNKNOWN: Creator information not available".to_string(),
608 })
609 }
610}
611
612#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
614pub struct CreatorAnalysisResult {
615 pub token: String,
617 pub creator_address: Option<String>,
619 pub risk_level: Option<RiskLevel>,
621 pub current_holdings: Option<i64>,
623 pub holding_percentage: f64,
625 pub total_coins_created: i64,
627 pub rug_count: i64,
629 pub rug_percentage: f64,
631 pub average_market_cap: Option<i64>,
633 pub previous_coins: Vec<PreviousCoin>,
635 pub red_flags: Vec<String>,
637 pub risk_assessment: String,
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 #[test]
646 fn test_trenchbot_config_default() {
647 let config = TrenchBotConfig::default();
648 assert_eq!(config.base_url, "https://trench.bot/api");
649 assert_eq!(config.rate_limit_per_minute, 60);
650 assert_eq!(config.request_timeout, 30);
651 }
652
653 #[test]
654 fn test_risk_level_serialization() {
655 let risk = RiskLevel::High;
656 let json = serde_json::to_string(&risk).unwrap();
657 assert_eq!(json, "\"HIGH\"");
658
659 let risk: RiskLevel = serde_json::from_str("\"MEDIUM\"").unwrap();
660 assert!(matches!(risk, RiskLevel::Medium));
661 }
662
663 #[test]
664 fn test_bundle_response_deserialization() {
665 let json = r#"{
666 "bonded": true,
667 "ticker": "TEST",
668 "total_bundles": 5,
669 "total_percentage_bundled": 25.5
670 }"#;
671
672 let response: BundleResponse = serde_json::from_str(json).unwrap();
673 assert_eq!(response.bonded, Some(true));
674 assert_eq!(response.ticker, Some("TEST".to_string()));
675 assert_eq!(response.total_bundles, Some(5));
676 assert_eq!(response.total_percentage_bundled, Some(25.5));
677 }
678
679 #[test]
680 fn test_creator_analysis_deserialization() {
681 let json = r#"{
682 "address": "SomeWalletAddress",
683 "risk_level": "HIGH",
684 "holding_percentage": 15.5,
685 "history": {
686 "rug_count": 3,
687 "total_coins_created": 10,
688 "rug_percentage": 30.0
689 }
690 }"#;
691
692 let creator: CreatorAnalysis = serde_json::from_str(json).unwrap();
693 assert_eq!(creator.address, Some("SomeWalletAddress".to_string()));
694 assert!(matches!(creator.risk_level, Some(RiskLevel::High)));
695 assert_eq!(creator.holding_percentage, Some(15.5));
696 assert!(creator.history.is_some());
697
698 let history = creator.history.unwrap();
699 assert_eq!(history.rug_count, Some(3));
700 assert_eq!(history.total_coins_created, Some(10));
701 assert_eq!(history.rug_percentage, Some(30.0));
702 }
703}