riglr_web_tools/
faster100x.rs

1//! Faster100x holder analysis integration for advanced on-chain analytics
2//!
3//! This module provides sophisticated holder analysis capabilities by integrating with Faster100x,
4//! enabling AI agents to analyze token holder distribution, whale activity, and holder trends
5//! for informed trading decisions.
6
7use crate::{client::WebClient, error::WebToolError};
8use chrono::{DateTime, Utc};
9use riglr_core::provider::ApplicationContext;
10use riglr_macros::tool;
11use schemars::JsonSchema;
12
13const FASTER100X_API_KEY: &str = "FASTER100X_API_KEY";
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use tracing::{debug, info};
17
18// Private module for raw API types
19mod api_types {
20    use serde::{Deserialize, Serialize};
21
22    #[derive(Debug, Deserialize, Serialize)]
23    pub struct ApiResponseRaw {
24        pub data: DataRaw,
25        pub success: Option<bool>,
26        pub error: Option<String>,
27    }
28
29    #[derive(Debug, Deserialize, Serialize)]
30    pub struct DataRaw {
31        pub symbol: Option<String>,
32        pub name: Option<String>,
33        pub total_holders: Option<u64>,
34        pub unique_holders: Option<u64>,
35        pub distribution: Option<DistributionRaw>,
36        pub top_holders: Option<Vec<HolderRaw>>,
37        pub concentration_risk: Option<ConcentrationRiskRaw>,
38        pub recent_activity: Option<ActivityRaw>,
39        pub transactions: Option<Vec<WhaleTransactionRaw>>,
40        pub total_buys_usd: Option<f64>,
41        pub total_sells_usd: Option<f64>,
42        pub active_whales: Option<u64>,
43        pub points: Option<Vec<TrendPointRaw>>,
44        pub trend_direction: Option<String>,
45        pub trend_strength: Option<u64>,
46        pub insights: Option<Vec<String>>,
47    }
48
49    #[derive(Debug, Deserialize, Serialize)]
50    pub struct DistributionRaw {
51        pub top_1_percent: Option<f64>,
52        pub top_5_percent: Option<f64>,
53        pub top_10_percent: Option<f64>,
54        pub whale_percentage: Option<f64>,
55        pub retail_percentage: Option<f64>,
56        pub gini_coefficient: Option<f64>,
57    }
58
59    #[derive(Debug, Deserialize, Serialize)]
60    pub struct HolderRaw {
61        pub address: Option<String>,
62        pub balance: Option<f64>,
63        pub percentage: Option<f64>,
64        pub usd_value: Option<f64>,
65        #[serde(rename = "type")]
66        pub wallet_type: Option<String>,
67        pub tx_count: Option<u64>,
68        pub first_acquired: Option<String>,
69        pub last_activity: Option<String>,
70    }
71
72    #[derive(Debug, Deserialize, Serialize)]
73    pub struct ConcentrationRiskRaw {
74        pub level: Option<String>,
75        pub score: Option<u64>,
76        pub wallets_50_percent: Option<u64>,
77        pub largest_holder: Option<f64>,
78        pub exchange_percentage: Option<f64>,
79        pub locked_percentage: Option<f64>,
80        pub risk_factors: Option<Vec<String>>,
81    }
82
83    #[derive(Debug, Deserialize, Serialize)]
84    pub struct ActivityRaw {
85        pub new_holders_24h: Option<u64>,
86        pub exited_holders_24h: Option<u64>,
87        pub net_change_24h: Option<i64>,
88        pub avg_buy_size_24h: Option<f64>,
89        pub avg_sell_size_24h: Option<f64>,
90        pub growth_rate_7d: Option<f64>,
91    }
92
93    #[derive(Debug, Deserialize, Serialize)]
94    pub struct WhaleTransactionRaw {
95        pub hash: Option<String>,
96        pub from: Option<String>,
97        #[serde(rename = "type")]
98        pub transaction_type: Option<String>,
99        pub token_amount: Option<f64>,
100        pub usd_value: Option<f64>,
101        pub timestamp: Option<String>,
102        pub price_impact: Option<f64>,
103        pub gas_fee: Option<f64>,
104    }
105
106    #[derive(Debug, Deserialize, Serialize)]
107    pub struct WhaleStatsRaw {
108        pub total_whale_count: Option<u64>,
109        pub whale_holding_percentage: Option<f64>,
110        pub whale_activity_score: Option<f64>,
111        pub whale_accumulation_trend: Option<String>,
112        pub recent_whale_trend: Option<String>,
113        pub average_whale_balance: Option<f64>,
114        pub whale_dominance: Option<f64>,
115    }
116
117    #[derive(Debug, Deserialize, Serialize)]
118    pub struct TrendPointRaw {
119        pub timestamp: Option<String>,
120        pub holders: Option<u64>,
121        pub change: Option<i64>,
122        pub price: Option<f64>,
123        pub whale_percentage: Option<f64>,
124        pub volume_24h: Option<f64>,
125    }
126
127    #[derive(Debug, Deserialize, Serialize)]
128    pub struct TrendSummaryRaw {
129        pub trend_direction: Option<String>,
130        pub growth_rate_7d: Option<f64>,
131        pub growth_rate_30d: Option<f64>,
132        pub volatility_score: Option<f64>,
133        pub health_score: Option<f64>,
134        pub recommendation: Option<String>,
135        pub risk_factors: Option<Vec<String>>,
136        pub positive_factors: Option<Vec<String>>,
137    }
138}
139
140/// Faster100x API configuration
141#[derive(Debug, Clone)]
142pub struct Faster100xConfig {
143    /// API key for authentication with Faster100x service
144    pub api_key: String,
145    /// API base URL (default: https://api.faster100x.com/v1)
146    pub base_url: String,
147    /// Rate limit per minute (default: 100)
148    pub rate_limit_per_minute: u32,
149}
150
151impl Default for Faster100xConfig {
152    fn default() -> Self {
153        Self {
154            api_key: std::env::var(FASTER100X_API_KEY).unwrap_or_default(),
155            base_url: "https://api.faster100x.com/v1".to_string(),
156            rate_limit_per_minute: 100,
157        }
158    }
159}
160
161/// Token holder analysis data
162#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
163pub struct TokenHolderAnalysis {
164    /// Token contract address
165    pub token_address: String,
166    /// Token symbol
167    pub token_symbol: String,
168    /// Token name
169    pub token_name: String,
170    /// Total number of holders
171    pub total_holders: u64,
172    /// Number of unique holders
173    pub unique_holders: u64,
174    /// Holder distribution metrics
175    pub distribution: HolderDistribution,
176    /// Top holder wallets (anonymized)
177    pub top_holders: Vec<WalletHolding>,
178    /// Concentration risk metrics
179    pub concentration_risk: ConcentrationRisk,
180    /// Recent holder activity
181    pub recent_activity: HolderActivity,
182    /// Analysis timestamp
183    pub timestamp: DateTime<Utc>,
184    /// Chain identifier (eth, bsc, polygon, etc.)
185    pub chain: String,
186}
187
188/// Holder distribution breakdown
189#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
190pub struct HolderDistribution {
191    /// Percentage held by top 1% of holders
192    pub top_1_percent: f64,
193    /// Percentage held by top 5% of holders
194    pub top_5_percent: f64,
195    /// Percentage held by top 10% of holders
196    pub top_10_percent: f64,
197    /// Percentage held by whale wallets (>1% of supply)
198    pub whale_percentage: f64,
199    /// Percentage held by retail holders (<0.1% of supply)
200    pub retail_percentage: f64,
201    /// Gini coefficient (wealth inequality measure, 0-1)
202    pub gini_coefficient: f64,
203    /// Number of holders by category
204    pub holder_categories: HashMap<String, u64>,
205}
206
207/// Individual wallet holding information
208#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
209pub struct WalletHolding {
210    /// Wallet address (potentially anonymized)
211    pub wallet_address: String,
212    /// Tokens held (in token units)
213    pub token_amount: f64,
214    /// Percentage of total supply
215    pub percentage_of_supply: f64,
216    /// USD value of holdings
217    pub usd_value: f64,
218    /// First acquisition date
219    pub first_acquired: DateTime<Utc>,
220    /// Last transaction date
221    pub last_activity: DateTime<Utc>,
222    /// Wallet type classification
223    pub wallet_type: String, // "whale", "institution", "retail", "contract", "exchange"
224    /// Number of transactions
225    pub transaction_count: u64,
226    /// Average holding time in days
227    pub avg_holding_time_days: f64,
228}
229
230/// Concentration risk analysis
231#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
232pub struct ConcentrationRisk {
233    /// Risk level (Low, Medium, High, Critical)
234    pub risk_level: String,
235    /// Risk score (0-100)
236    pub risk_score: u8,
237    /// Number of wallets that control 50% of supply
238    pub wallets_controlling_50_percent: u64,
239    /// Largest single holder percentage
240    pub largest_holder_percentage: f64,
241    /// Exchange holdings percentage
242    pub exchange_holdings_percentage: f64,
243    /// Contract/locked holdings percentage
244    pub locked_holdings_percentage: f64,
245    /// Risk factors
246    pub risk_factors: Vec<String>,
247}
248
249/// Recent holder activity metrics
250#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
251pub struct HolderActivity {
252    /// New holders in last 24h
253    pub new_holders_24h: u64,
254    /// Holders who sold in last 24h
255    pub exited_holders_24h: u64,
256    /// Net holder change in 24h
257    pub net_holder_change_24h: i64,
258    /// Average buy size in last 24h (USD)
259    pub avg_buy_size_24h: f64,
260    /// Average sell size in last 24h (USD)
261    pub avg_sell_size_24h: f64,
262    /// Holder growth rate (7-day)
263    pub holder_growth_rate_7d: f64,
264}
265
266/// Whale activity tracking data
267#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
268pub struct WhaleActivity {
269    /// Token being analyzed
270    pub token_address: String,
271    /// Whale transactions in the specified timeframe
272    pub whale_transactions: Vec<WhaleTransaction>,
273    /// Total whale buy volume
274    pub total_whale_buys: f64,
275    /// Total whale sell volume
276    pub total_whale_sells: f64,
277    /// Net whale flow (buys - sells)
278    pub net_whale_flow: f64,
279    /// Number of active whale wallets
280    pub active_whales: u64,
281    /// Analysis timeframe
282    pub timeframe: String,
283    /// Analysis timestamp
284    pub timestamp: DateTime<Utc>,
285}
286
287/// Individual whale transaction
288#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
289pub struct WhaleTransaction {
290    /// Transaction hash
291    pub tx_hash: String,
292    /// Whale wallet address
293    pub wallet_address: String,
294    /// Transaction type (buy/sell)
295    pub transaction_type: String,
296    /// Token amount
297    pub token_amount: f64,
298    /// USD value
299    pub usd_value: f64,
300    /// Transaction timestamp
301    pub timestamp: DateTime<Utc>,
302    /// Gas fee paid
303    pub gas_fee: f64,
304    /// Price impact percentage
305    pub price_impact: f64,
306}
307
308/// Holder trends over time
309#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
310pub struct HolderTrends {
311    /// Token address
312    pub token_address: String,
313    /// Time series data points
314    pub data_points: Vec<HolderTrendPoint>,
315    /// Overall trend direction
316    pub trend_direction: String, // "increasing", "decreasing", "stable"
317    /// Trend strength (0-100)
318    pub trend_strength: u8,
319    /// Analysis period
320    pub analysis_period: String,
321    /// Key insights
322    pub insights: Vec<String>,
323}
324
325/// Individual holder trend data point
326#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
327pub struct HolderTrendPoint {
328    /// Data point timestamp
329    pub timestamp: DateTime<Utc>,
330    /// Total holders at this time
331    pub total_holders: u64,
332    /// Holder change from previous point
333    pub holder_change: i64,
334    /// Token price at this time
335    pub token_price: f64,
336    /// Whale percentage at this time
337    pub whale_percentage: f64,
338    /// Trading volume at this time
339    pub volume_24h: f64,
340}
341
342// Conversion functions from Raw to Clean types
343
344/// Converts raw holder data to clean WalletHolding
345fn convert_raw_holder(raw: &api_types::HolderRaw) -> WalletHolding {
346    let first_acquired = raw
347        .first_acquired
348        .as_ref()
349        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
350        .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc));
351
352    let last_activity = raw
353        .last_activity
354        .as_ref()
355        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
356        .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc));
357
358    WalletHolding {
359        wallet_address: raw.address.clone().unwrap_or_else(|| "unknown".to_string()),
360        token_amount: raw.balance.unwrap_or(0.0),
361        percentage_of_supply: raw.percentage.unwrap_or(0.0),
362        usd_value: raw.usd_value.unwrap_or(0.0),
363        first_acquired,
364        last_activity,
365        wallet_type: raw
366            .wallet_type
367            .clone()
368            .unwrap_or_else(|| "unknown".to_string()),
369        transaction_count: raw.tx_count.unwrap_or(0),
370        avg_holding_time_days: 0.0, // Would be calculated separately
371    }
372}
373
374/// Converts raw distribution data to clean HolderDistribution
375fn convert_raw_distribution(raw: &api_types::DistributionRaw) -> HolderDistribution {
376    HolderDistribution {
377        top_1_percent: raw.top_1_percent.unwrap_or(0.0),
378        top_5_percent: raw.top_5_percent.unwrap_or(0.0),
379        top_10_percent: raw.top_10_percent.unwrap_or(0.0),
380        whale_percentage: raw.whale_percentage.unwrap_or(0.0),
381        retail_percentage: raw.retail_percentage.unwrap_or(0.0),
382        gini_coefficient: raw.gini_coefficient.unwrap_or(0.0),
383        holder_categories: HashMap::new(),
384    }
385}
386
387/// Converts raw concentration risk data to clean ConcentrationRisk
388fn convert_raw_concentration_risk(raw: &api_types::ConcentrationRiskRaw) -> ConcentrationRisk {
389    ConcentrationRisk {
390        risk_level: raw.level.clone().unwrap_or_else(|| "Medium".to_string()),
391        risk_score: raw.score.unwrap_or(50) as u8,
392        wallets_controlling_50_percent: raw.wallets_50_percent.unwrap_or(0),
393        largest_holder_percentage: raw.largest_holder.unwrap_or(0.0),
394        exchange_holdings_percentage: raw.exchange_percentage.unwrap_or(0.0),
395        locked_holdings_percentage: raw.locked_percentage.unwrap_or(0.0),
396        risk_factors: raw.risk_factors.clone().unwrap_or_default(),
397    }
398}
399
400/// Converts raw activity data to clean HolderActivity
401fn convert_raw_activity(raw: &api_types::ActivityRaw) -> HolderActivity {
402    HolderActivity {
403        new_holders_24h: raw.new_holders_24h.unwrap_or(0),
404        exited_holders_24h: raw.exited_holders_24h.unwrap_or(0),
405        net_holder_change_24h: raw.net_change_24h.unwrap_or(0),
406        avg_buy_size_24h: raw.avg_buy_size_24h.unwrap_or(0.0),
407        avg_sell_size_24h: raw.avg_sell_size_24h.unwrap_or(0.0),
408        holder_growth_rate_7d: raw.growth_rate_7d.unwrap_or(0.0),
409    }
410}
411
412/// Converts raw whale transaction to clean WhaleTransaction
413fn convert_raw_whale_transaction(raw: &api_types::WhaleTransactionRaw) -> WhaleTransaction {
414    let timestamp = raw
415        .timestamp
416        .as_ref()
417        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
418        .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc));
419
420    WhaleTransaction {
421        tx_hash: raw.hash.clone().unwrap_or_default(),
422        wallet_address: raw.from.clone().unwrap_or_default(),
423        transaction_type: raw
424            .transaction_type
425            .clone()
426            .unwrap_or_else(|| "unknown".to_string()),
427        token_amount: raw.token_amount.unwrap_or(0.0),
428        usd_value: raw.usd_value.unwrap_or(0.0),
429        timestamp,
430        price_impact: raw.price_impact.unwrap_or(0.0),
431        gas_fee: raw.gas_fee.unwrap_or(0.0),
432    }
433}
434
435/// Converts raw trend point to clean HolderTrendPoint
436fn convert_raw_trend_point(raw: &api_types::TrendPointRaw) -> HolderTrendPoint {
437    let timestamp = raw
438        .timestamp
439        .as_ref()
440        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
441        .map_or_else(Utc::now, |dt| dt.with_timezone(&Utc));
442
443    HolderTrendPoint {
444        timestamp,
445        total_holders: raw.holders.unwrap_or(0),
446        holder_change: raw.change.unwrap_or(0),
447        token_price: raw.price.unwrap_or(0.0),
448        whale_percentage: raw.whale_percentage.unwrap_or(0.0),
449        volume_24h: raw.volume_24h.unwrap_or(0.0),
450    }
451}
452
453/// Creates a Faster100x API client with proper authentication
454///
455/// Initializes a WebClient configured for the Faster100x API with the API key
456/// from the ApplicationContext or FASTER100X_API_KEY environment variable.
457///
458/// # Returns
459/// A configured WebClient instance for Faster100x API calls
460///
461/// Helper function to get Faster100x API key from ApplicationContext
462fn get_api_key_from_context(context: &ApplicationContext) -> Result<String, WebToolError> {
463    context
464        .config
465        .providers
466        .faster100x_api_key
467        .clone()
468        .ok_or_else(|| {
469            WebToolError::Config(
470                "Faster100x API key not configured. Set FASTER100X_API_KEY in your environment."
471                    .to_string(),
472            )
473        })
474}
475
476/// # Errors
477/// Returns WebToolError::Config if the FASTER100X_API_KEY is not found in context
478async fn create_faster100x_client_with_context(
479    context: &ApplicationContext,
480) -> Result<WebClient, WebToolError> {
481    let api_key = get_api_key_from_context(context)?;
482    let client = WebClient::default().with_api_key("faster100x", api_key);
483    Ok(client)
484}
485
486/// Creates a Faster100x API client with proper authentication
487///
488/// Initializes a WebClient configured for the Faster100x API with the API key
489/// from the FASTER100X_API_KEY environment variable.
490///
491/// # Returns
492/// A configured WebClient instance for Faster100x API calls
493///
494/// # Errors
495/// Returns WebToolError::Config if the FASTER100X_API_KEY environment variable is not set
496#[allow(dead_code)]
497async fn create_faster100x_client() -> Result<WebClient, WebToolError> {
498    let config = Faster100xConfig::default();
499
500    if config.api_key.is_empty() {
501        return Err(WebToolError::Config(
502            "FASTER100X_API_KEY environment variable not set".to_string(),
503        ));
504    }
505
506    let client = WebClient::default().with_api_key("faster100x", config.api_key);
507
508    Ok(client)
509}
510
511/// Validates and normalizes token address format
512///
513/// Ensures the provided token address follows the correct Ethereum-style format
514/// (42 characters starting with "0x") and normalizes it to lowercase.
515///
516/// # Arguments
517/// * `address` - The token contract address to validate and normalize
518///
519/// # Returns
520/// The normalized lowercase token address
521///
522/// # Errors
523/// Returns WebToolError::Config if the address format is invalid
524fn normalize_token_address(address: &str) -> Result<String, WebToolError> {
525    let address = address.trim();
526
527    // Basic validation for Ethereum-style addresses
528    if address.len() != 42 || !address.starts_with("0x") {
529        return Err(WebToolError::Config(
530            "Invalid token address format. Expected 42-character hex string starting with 0x"
531                .to_string(),
532        ));
533    }
534
535    // Convert to lowercase for consistency
536    Ok(address.to_lowercase())
537}
538
539#[tool]
540/// Analyze token holder distribution and concentration risks
541///
542/// This tool provides comprehensive analysis of a token's holder distribution,
543/// including concentration risks, whale analysis, and holder categorization.
544///
545/// # Arguments
546/// * `token_address` - Token contract address (must be valid hex address)
547/// * `chain` - Blockchain network: "eth", "bsc", "polygon", "arbitrum", "base". Defaults to "eth"
548///
549/// # Returns
550/// Detailed holder analysis including distribution metrics and risk assessment
551pub async fn analyze_token_holders(
552    context: &ApplicationContext,
553    token_address: String,
554    chain: Option<String>,
555) -> Result<TokenHolderAnalysis, WebToolError> {
556    let chain = chain.unwrap_or_else(|| "eth".to_string());
557    let normalized_address = normalize_token_address(&token_address)?;
558
559    info!(
560        "Analyzing token holders for {} on {}",
561        normalized_address, chain
562    );
563
564    // Validate chain parameter
565    if !["eth", "bsc", "polygon", "arbitrum", "base", "avalanche"].contains(&chain.as_str()) {
566        return Err(WebToolError::Config(
567            "Invalid chain. Must be one of: eth, bsc, polygon, arbitrum, base, avalanche"
568                .to_string(),
569        ));
570    }
571
572    let client = create_faster100x_client_with_context(context).await?;
573
574    let mut params = HashMap::new();
575    params.insert("chain".to_string(), chain.clone());
576    params.insert("include_distribution".to_string(), "true".to_string());
577    params.insert("include_top_holders".to_string(), "true".to_string());
578
579    let config = Faster100xConfig::default();
580    let url = format!("{}/tokens/{}/holders", config.base_url, normalized_address);
581
582    let response_text = client.get_with_params(&url, &params).await?;
583
584    // Deserialize response into raw API types
585    let response: api_types::ApiResponseRaw = serde_json::from_str(&response_text)
586        .map_err(|e| WebToolError::Parsing(format!("Invalid JSON response: {}", e)))?;
587
588    let data = response.data;
589
590    // Extract basic token info
591    let token_symbol = data.symbol.unwrap_or_else(|| "UNKNOWN".to_string());
592    let token_name = data.name.unwrap_or_else(|| "Unknown Token".to_string());
593    let total_holders = data.total_holders.unwrap_or(0);
594    let unique_holders = data.unique_holders.unwrap_or(total_holders);
595
596    // Convert distribution data
597    let distribution = data.distribution.as_ref().map_or_else(
598        || HolderDistribution {
599            top_1_percent: 0.0,
600            top_5_percent: 0.0,
601            top_10_percent: 0.0,
602            whale_percentage: 0.0,
603            retail_percentage: 0.0,
604            gini_coefficient: 0.0,
605            holder_categories: HashMap::new(),
606        },
607        convert_raw_distribution,
608    );
609
610    // Convert top holders
611    let top_holders = data
612        .top_holders
613        .as_ref()
614        .map(|holders| holders.iter().take(10).map(convert_raw_holder).collect())
615        .unwrap_or_default();
616
617    // Convert concentration risk
618    let concentration_risk = data.concentration_risk.as_ref().map_or_else(
619        || ConcentrationRisk {
620            risk_level: "Medium".to_string(),
621            risk_score: 50,
622            wallets_controlling_50_percent: 0,
623            largest_holder_percentage: 0.0,
624            exchange_holdings_percentage: 0.0,
625            locked_holdings_percentage: 0.0,
626            risk_factors: vec![],
627        },
628        convert_raw_concentration_risk,
629    );
630
631    // Convert recent activity
632    let recent_activity = data.recent_activity.as_ref().map_or_else(
633        || HolderActivity {
634            new_holders_24h: 0,
635            exited_holders_24h: 0,
636            net_holder_change_24h: 0,
637            avg_buy_size_24h: 0.0,
638            avg_sell_size_24h: 0.0,
639            holder_growth_rate_7d: 0.0,
640        },
641        convert_raw_activity,
642    );
643
644    debug!(
645        "Successfully analyzed {} holders for token {} with {}% whale concentration",
646        total_holders, token_symbol, distribution.whale_percentage
647    );
648
649    Ok(TokenHolderAnalysis {
650        token_address: normalized_address,
651        token_symbol,
652        token_name,
653        total_holders,
654        unique_holders,
655        distribution,
656        top_holders,
657        concentration_risk,
658        recent_activity,
659        timestamp: Utc::now(),
660        chain,
661    })
662}
663
664#[tool]
665/// Get whale activity for a specific token
666///
667/// This tool tracks large-holder (whale) transactions and movements,
668/// providing insights into institutional and high-net-worth trading activity.
669///
670/// # Arguments
671/// * `token_address` - Token contract address
672/// * `timeframe` - Analysis timeframe: "1h", "4h", "24h", "7d". Defaults to "24h"
673/// * `min_usd_value` - Minimum transaction value in USD to be considered whale activity. Defaults to 10000
674///
675/// # Returns
676/// Whale activity analysis with transaction details and flow metrics
677pub async fn get_whale_activity(
678    context: &ApplicationContext,
679    token_address: String,
680    timeframe: Option<String>,
681    min_usd_value: Option<f64>,
682) -> Result<WhaleActivity, WebToolError> {
683    let timeframe = timeframe.unwrap_or_else(|| "24h".to_string());
684    let min_usd_value = min_usd_value.unwrap_or(10000.0);
685    let normalized_address = normalize_token_address(&token_address)?;
686
687    info!(
688        "Analyzing whale activity for {} (timeframe: {}, min value: ${})",
689        normalized_address, timeframe, min_usd_value
690    );
691
692    // Validate timeframe
693    if !["1h", "4h", "24h", "7d"].contains(&timeframe.as_str()) {
694        return Err(WebToolError::Config(
695            "Invalid timeframe. Must be one of: 1h, 4h, 24h, 7d".to_string(),
696        ));
697    }
698
699    let client = create_faster100x_client_with_context(context).await?;
700
701    let mut params = HashMap::new();
702    params.insert("timeframe".to_string(), timeframe.clone());
703    params.insert("min_usd".to_string(), min_usd_value.to_string());
704    params.insert("include_details".to_string(), "true".to_string());
705
706    let config = Faster100xConfig::default();
707    let url = format!(
708        "{}/tokens/{}/whale-activity",
709        config.base_url, normalized_address
710    );
711
712    let response_text = client.get_with_params(&url, &params).await?;
713
714    // Deserialize response into raw API types
715    let response: api_types::ApiResponseRaw = serde_json::from_str(&response_text)
716        .map_err(|e| WebToolError::Parsing(format!("Invalid JSON response: {}", e)))?;
717
718    let data = response.data;
719
720    // Convert whale transactions
721    let whale_transactions: Vec<WhaleTransaction> = data
722        .transactions
723        .as_ref()
724        .map(|txs| txs.iter().map(convert_raw_whale_transaction).collect())
725        .unwrap_or_default();
726
727    // Extract summary metrics
728    let total_whale_buys = data.total_buys_usd.unwrap_or(0.0);
729    let total_whale_sells = data.total_sells_usd.unwrap_or(0.0);
730    let net_whale_flow = total_whale_buys - total_whale_sells;
731    let active_whales = data.active_whales.unwrap_or(0);
732
733    debug!(
734        "Found {} whale transactions with net flow of ${:.2} for token {}",
735        whale_transactions.len(),
736        net_whale_flow,
737        normalized_address
738    );
739
740    Ok(WhaleActivity {
741        token_address: normalized_address,
742        whale_transactions,
743        total_whale_buys,
744        total_whale_sells,
745        net_whale_flow,
746        active_whales,
747        timeframe,
748        timestamp: Utc::now(),
749    })
750}
751
752#[tool]
753/// Get holder trends over time for a token
754///
755/// This tool provides historical analysis of holder growth/decline patterns,
756/// correlating holder changes with price movements and market events.
757///
758/// # Arguments
759/// * `token_address` - Token contract address
760/// * `period` - Analysis period: "7d", "30d", "90d". Defaults to "30d"
761/// * `data_points` - Number of data points to return (10-100). Defaults to 30
762///
763/// # Returns
764/// Time series analysis of holder trends with insights and correlations
765pub async fn get_holder_trends(
766    context: &ApplicationContext,
767    token_address: String,
768    period: Option<String>,
769    data_points: Option<u32>,
770) -> Result<HolderTrends, WebToolError> {
771    let period = period.unwrap_or_else(|| "30d".to_string());
772    let data_points = data_points.unwrap_or(30).clamp(10, 100);
773    let normalized_address = normalize_token_address(&token_address)?;
774
775    info!(
776        "Analyzing holder trends for {} (period: {}, data points: {})",
777        normalized_address, period, data_points
778    );
779
780    // Validate period
781    if !["7d", "30d", "90d"].contains(&period.as_str()) {
782        return Err(WebToolError::Config(
783            "Invalid period. Must be one of: 7d, 30d, 90d".to_string(),
784        ));
785    }
786
787    let client = create_faster100x_client_with_context(context).await?;
788
789    let mut params = HashMap::new();
790    params.insert("period".to_string(), period.clone());
791    params.insert("points".to_string(), data_points.to_string());
792    params.insert("include_price".to_string(), "true".to_string());
793
794    let config = Faster100xConfig::default();
795    let url = format!(
796        "{}/tokens/{}/holder-trends",
797        config.base_url, normalized_address
798    );
799
800    let response_text = client.get_with_params(&url, &params).await?;
801
802    // Deserialize response into raw API types
803    let response: api_types::ApiResponseRaw = serde_json::from_str(&response_text)
804        .map_err(|e| WebToolError::Parsing(format!("Invalid JSON response: {}", e)))?;
805
806    let data = response.data;
807
808    // Convert trend data points
809    let trend_data_points: Vec<HolderTrendPoint> = data
810        .points
811        .as_ref()
812        .map(|points| points.iter().map(convert_raw_trend_point).collect())
813        .unwrap_or_default();
814
815    // Extract trend analysis
816    let trend_direction = data.trend_direction.unwrap_or_else(|| "stable".to_string());
817    let trend_strength = data.trend_strength.unwrap_or(50) as u8;
818    let insights = data.insights.unwrap_or_default();
819
820    debug!(
821        "Analyzed holder trends with {} data points, trend: {} (strength: {})",
822        trend_data_points.len(),
823        trend_direction,
824        trend_strength
825    );
826
827    Ok(HolderTrends {
828        token_address: normalized_address,
829        data_points: trend_data_points,
830        trend_direction,
831        trend_strength,
832        analysis_period: period,
833        insights,
834    })
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840
841    #[test]
842    fn test_faster100x_config_default() {
843        let config = Faster100xConfig::default();
844        assert_eq!(config.base_url, "https://api.faster100x.com/v1");
845        assert_eq!(config.rate_limit_per_minute, 100);
846    }
847
848    #[test]
849    fn test_normalize_token_address() {
850        // Valid address
851        let result = normalize_token_address("0x1234567890123456789012345678901234567890");
852        assert!(result.is_ok());
853        assert_eq!(
854            result.unwrap(),
855            "0x1234567890123456789012345678901234567890"
856        );
857
858        // Invalid address - too short
859        let result = normalize_token_address("0x123");
860        assert!(result.is_err());
861
862        // Invalid address - no 0x prefix
863        let result = normalize_token_address("1234567890123456789012345678901234567890");
864        assert!(result.is_err());
865    }
866
867    #[test]
868    fn test_token_holder_analysis_serialization() {
869        let analysis = TokenHolderAnalysis {
870            token_address: "0x1234567890123456789012345678901234567890".to_string(),
871            token_symbol: "TEST".to_string(),
872            token_name: "Test Token".to_string(),
873            total_holders: 1000,
874            unique_holders: 950,
875            distribution: HolderDistribution {
876                top_1_percent: 50.0,
877                top_5_percent: 75.0,
878                top_10_percent: 85.0,
879                whale_percentage: 25.0,
880                retail_percentage: 40.0,
881                gini_coefficient: 0.65,
882                holder_categories: HashMap::new(),
883            },
884            top_holders: Vec::new(),
885            concentration_risk: ConcentrationRisk {
886                risk_level: "Medium".to_string(),
887                risk_score: 60,
888                wallets_controlling_50_percent: 5,
889                largest_holder_percentage: 15.0,
890                exchange_holdings_percentage: 20.0,
891                locked_holdings_percentage: 10.0,
892                risk_factors: Vec::new(),
893            },
894            recent_activity: HolderActivity {
895                new_holders_24h: 50,
896                exited_holders_24h: 30,
897                net_holder_change_24h: 20,
898                avg_buy_size_24h: 500.0,
899                avg_sell_size_24h: 300.0,
900                holder_growth_rate_7d: 5.2,
901            },
902            timestamp: Utc::now(),
903            chain: "eth".to_string(),
904        };
905
906        let serialized = serde_json::to_string(&analysis).unwrap();
907        assert!(serialized.contains("TEST"));
908        assert!(serialized.contains("eth"));
909        assert!(serialized.contains("60"));
910    }
911
912    #[test]
913    fn test_faster100x_config_with_env_var() {
914        // Test when environment variable is set
915        std::env::set_var(FASTER100X_API_KEY, "test-api-key");
916        let config = Faster100xConfig::default();
917        assert_eq!(config.api_key, "test-api-key");
918        std::env::remove_var(FASTER100X_API_KEY);
919    }
920
921    #[test]
922    fn test_faster100x_config_without_env_var() {
923        // Test when environment variable is not set
924        std::env::remove_var(FASTER100X_API_KEY);
925        let config = Faster100xConfig::default();
926        assert_eq!(config.api_key, "");
927    }
928
929    #[test]
930    fn test_normalize_token_address_with_whitespace() {
931        // Test address with leading/trailing whitespace
932        let result = normalize_token_address("  0x1234567890123456789012345678901234567890  ");
933        assert!(result.is_ok());
934        assert_eq!(
935            result.unwrap(),
936            "0x1234567890123456789012345678901234567890"
937        );
938    }
939
940    #[test]
941    fn test_normalize_token_address_uppercase_to_lowercase() {
942        // Test uppercase address conversion to lowercase
943        let result = normalize_token_address("0X1234567890ABCDEF1234567890ABCDEF12345678");
944        assert!(result.is_ok());
945        assert_eq!(
946            result.unwrap(),
947            "0x1234567890abcdef1234567890abcdef12345678"
948        );
949    }
950
951    #[test]
952    fn test_normalize_token_address_invalid_length_too_long() {
953        // Test address that's too long
954        let result = normalize_token_address("0x12345678901234567890123456789012345678901");
955        assert!(result.is_err());
956        assert!(result
957            .unwrap_err()
958            .to_string()
959            .contains("Invalid token address format"));
960    }
961
962    #[test]
963    fn test_normalize_token_address_invalid_length_too_short() {
964        // Test address that's too short
965        let result = normalize_token_address("0x123456789012345678901234567890123456789");
966        assert!(result.is_err());
967        assert!(result
968            .unwrap_err()
969            .to_string()
970            .contains("Invalid token address format"));
971    }
972
973    #[test]
974    fn test_normalize_token_address_missing_0x_prefix() {
975        // Test address without 0x prefix
976        let result = normalize_token_address("1234567890123456789012345678901234567890");
977        assert!(result.is_err());
978        assert!(result
979            .unwrap_err()
980            .to_string()
981            .contains("Invalid token address format"));
982    }
983
984    #[test]
985    fn test_normalize_token_address_wrong_prefix() {
986        // Test address with wrong prefix
987        let result = normalize_token_address("0y1234567890123456789012345678901234567890");
988        assert!(result.is_err());
989        assert!(result
990            .unwrap_err()
991            .to_string()
992            .contains("Invalid token address format"));
993    }
994
995    #[test]
996    fn test_normalize_token_address_empty_string() {
997        // Test empty string
998        let result = normalize_token_address("");
999        assert!(result.is_err());
1000        assert!(result
1001            .unwrap_err()
1002            .to_string()
1003            .contains("Invalid token address format"));
1004    }
1005
1006    #[test]
1007    fn test_normalize_token_address_exact_42_chars() {
1008        // Test address with exactly 42 characters
1009        let result = normalize_token_address("0x1234567890123456789012345678901234567890");
1010        assert!(result.is_ok());
1011        assert_eq!(result.unwrap().len(), 42);
1012    }
1013
1014    #[test]
1015    fn test_whale_activity_serialization() {
1016        let whale_activity = WhaleActivity {
1017            token_address: "0x1234567890123456789012345678901234567890".to_string(),
1018            whale_transactions: vec![WhaleTransaction {
1019                tx_hash: "0xabc123".to_string(),
1020                wallet_address: "0x9876543210987654321098765432109876543210".to_string(),
1021                transaction_type: "buy".to_string(),
1022                token_amount: 1000.0,
1023                usd_value: 50000.0,
1024                timestamp: Utc::now(),
1025                gas_fee: 0.01,
1026                price_impact: 0.5,
1027            }],
1028            total_whale_buys: 100000.0,
1029            total_whale_sells: 50000.0,
1030            net_whale_flow: 50000.0,
1031            active_whales: 5,
1032            timeframe: "24h".to_string(),
1033            timestamp: Utc::now(),
1034        };
1035
1036        let serialized = serde_json::to_string(&whale_activity).unwrap();
1037        assert!(serialized.contains("buy"));
1038        assert!(serialized.contains("24h"));
1039        assert!(serialized.contains("50000"));
1040    }
1041
1042    #[test]
1043    fn test_holder_trends_serialization() {
1044        let holder_trends = HolderTrends {
1045            token_address: "0x1234567890123456789012345678901234567890".to_string(),
1046            data_points: vec![HolderTrendPoint {
1047                timestamp: Utc::now(),
1048                total_holders: 1000,
1049                holder_change: 50,
1050                token_price: 1.5,
1051                whale_percentage: 25.0,
1052                volume_24h: 100000.0,
1053            }],
1054            trend_direction: "increasing".to_string(),
1055            trend_strength: 80,
1056            analysis_period: "30d".to_string(),
1057            insights: vec!["Steady growth in holder count".to_string()],
1058        };
1059
1060        let serialized = serde_json::to_string(&holder_trends).unwrap();
1061        assert!(serialized.contains("increasing"));
1062        assert!(serialized.contains("30d"));
1063        assert!(serialized.contains("Steady growth"));
1064    }
1065
1066    #[test]
1067    fn test_holder_distribution_default_values() {
1068        let distribution = HolderDistribution {
1069            top_1_percent: 0.0,
1070            top_5_percent: 0.0,
1071            top_10_percent: 0.0,
1072            whale_percentage: 0.0,
1073            retail_percentage: 0.0,
1074            gini_coefficient: 0.0,
1075            holder_categories: HashMap::new(),
1076        };
1077
1078        assert_eq!(distribution.top_1_percent, 0.0);
1079        assert_eq!(distribution.gini_coefficient, 0.0);
1080        assert!(distribution.holder_categories.is_empty());
1081    }
1082
1083    #[test]
1084    fn test_wallet_holding_creation() {
1085        let now = Utc::now();
1086        let wallet = WalletHolding {
1087            wallet_address: "0x1234567890123456789012345678901234567890".to_string(),
1088            token_amount: 1000.0,
1089            percentage_of_supply: 5.0,
1090            usd_value: 50000.0,
1091            first_acquired: now,
1092            last_activity: now,
1093            wallet_type: "whale".to_string(),
1094            transaction_count: 25,
1095            avg_holding_time_days: 30.5,
1096        };
1097
1098        assert_eq!(wallet.token_amount, 1000.0);
1099        assert_eq!(wallet.wallet_type, "whale");
1100        assert_eq!(wallet.transaction_count, 25);
1101    }
1102
1103    #[test]
1104    fn test_concentration_risk_values() {
1105        let risk = ConcentrationRisk {
1106            risk_level: "High".to_string(),
1107            risk_score: 85,
1108            wallets_controlling_50_percent: 3,
1109            largest_holder_percentage: 30.0,
1110            exchange_holdings_percentage: 15.0,
1111            locked_holdings_percentage: 5.0,
1112            risk_factors: vec!["Large single holder".to_string()],
1113        };
1114
1115        assert_eq!(risk.risk_score, 85);
1116        assert_eq!(risk.wallets_controlling_50_percent, 3);
1117        assert!(!risk.risk_factors.is_empty());
1118    }
1119
1120    #[test]
1121    fn test_holder_activity_negative_change() {
1122        let activity = HolderActivity {
1123            new_holders_24h: 20,
1124            exited_holders_24h: 30,
1125            net_holder_change_24h: -10,
1126            avg_buy_size_24h: 250.0,
1127            avg_sell_size_24h: 500.0,
1128            holder_growth_rate_7d: -2.5,
1129        };
1130
1131        assert_eq!(activity.net_holder_change_24h, -10);
1132        assert_eq!(activity.holder_growth_rate_7d, -2.5);
1133    }
1134
1135    #[test]
1136    fn test_whale_transaction_values() {
1137        let now = Utc::now();
1138        let tx = WhaleTransaction {
1139            tx_hash: "0xabcdef123456".to_string(),
1140            wallet_address: "0x1234567890123456789012345678901234567890".to_string(),
1141            transaction_type: "sell".to_string(),
1142            token_amount: 5000.0,
1143            usd_value: 100000.0,
1144            timestamp: now,
1145            gas_fee: 0.05,
1146            price_impact: 2.5,
1147        };
1148
1149        assert_eq!(tx.transaction_type, "sell");
1150        assert_eq!(tx.usd_value, 100000.0);
1151        assert_eq!(tx.price_impact, 2.5);
1152    }
1153
1154    #[test]
1155    fn test_holder_trend_point_creation() {
1156        let now = Utc::now();
1157        let point = HolderTrendPoint {
1158            timestamp: now,
1159            total_holders: 1500,
1160            holder_change: 100,
1161            token_price: 2.5,
1162            whale_percentage: 20.0,
1163            volume_24h: 500000.0,
1164        };
1165
1166        assert_eq!(point.total_holders, 1500);
1167        assert_eq!(point.holder_change, 100);
1168        assert_eq!(point.token_price, 2.5);
1169    }
1170
1171    #[test]
1172    fn test_faster100x_config_clone() {
1173        let config = Faster100xConfig {
1174            api_key: "test-key".to_string(),
1175            base_url: "https://test.api.com".to_string(),
1176            rate_limit_per_minute: 50,
1177        };
1178
1179        let cloned = config.clone();
1180        assert_eq!(config.api_key, cloned.api_key);
1181        assert_eq!(config.base_url, cloned.base_url);
1182        assert_eq!(config.rate_limit_per_minute, cloned.rate_limit_per_minute);
1183    }
1184
1185    #[test]
1186    fn test_token_holder_analysis_clone() {
1187        let analysis = TokenHolderAnalysis {
1188            token_address: "0x1234567890123456789012345678901234567890".to_string(),
1189            token_symbol: "TEST".to_string(),
1190            token_name: "Test Token".to_string(),
1191            total_holders: 1000,
1192            unique_holders: 950,
1193            distribution: HolderDistribution {
1194                top_1_percent: 50.0,
1195                top_5_percent: 75.0,
1196                top_10_percent: 85.0,
1197                whale_percentage: 25.0,
1198                retail_percentage: 40.0,
1199                gini_coefficient: 0.65,
1200                holder_categories: HashMap::new(),
1201            },
1202            top_holders: Vec::new(),
1203            concentration_risk: ConcentrationRisk {
1204                risk_level: "Medium".to_string(),
1205                risk_score: 60,
1206                wallets_controlling_50_percent: 5,
1207                largest_holder_percentage: 15.0,
1208                exchange_holdings_percentage: 20.0,
1209                locked_holdings_percentage: 10.0,
1210                risk_factors: Vec::new(),
1211            },
1212            recent_activity: HolderActivity {
1213                new_holders_24h: 50,
1214                exited_holders_24h: 30,
1215                net_holder_change_24h: 20,
1216                avg_buy_size_24h: 500.0,
1217                avg_sell_size_24h: 300.0,
1218                holder_growth_rate_7d: 5.2,
1219            },
1220            timestamp: Utc::now(),
1221            chain: "eth".to_string(),
1222        };
1223
1224        let cloned = analysis.clone();
1225        assert_eq!(analysis.token_symbol, cloned.token_symbol);
1226        assert_eq!(analysis.total_holders, cloned.total_holders);
1227    }
1228
1229    #[test]
1230    fn test_debug_implementations() {
1231        let config = Faster100xConfig::default();
1232        let debug_str = format!("{:?}", config);
1233        assert!(debug_str.contains("Faster100xConfig"));
1234
1235        let distribution = HolderDistribution {
1236            top_1_percent: 50.0,
1237            top_5_percent: 75.0,
1238            top_10_percent: 85.0,
1239            whale_percentage: 25.0,
1240            retail_percentage: 40.0,
1241            gini_coefficient: 0.65,
1242            holder_categories: HashMap::new(),
1243        };
1244        let debug_str = format!("{:?}", distribution);
1245        assert!(debug_str.contains("HolderDistribution"));
1246    }
1247
1248    #[tokio::test]
1249    async fn test_create_faster100x_client_missing_api_key() {
1250        // Remove API key environment variable
1251        std::env::remove_var(FASTER100X_API_KEY);
1252
1253        let result = create_faster100x_client().await;
1254        assert!(result.is_err());
1255        assert!(result
1256            .unwrap_err()
1257            .to_string()
1258            .contains("FASTER100X_API_KEY environment variable not set"));
1259    }
1260
1261    #[tokio::test]
1262    async fn test_create_faster100x_client_with_api_key() {
1263        // Set API key environment variable
1264        std::env::set_var(FASTER100X_API_KEY, "test-api-key");
1265
1266        let result = create_faster100x_client().await;
1267        // This might fail due to WebClient::default() requiring actual network setup,
1268        // but we can at least test the API key validation path
1269        if result.is_err() {
1270            // Ensure the error is not about missing API key
1271            assert!(!result
1272                .unwrap_err()
1273                .to_string()
1274                .contains("FASTER100X_API_KEY environment variable not set"));
1275        }
1276
1277        std::env::remove_var(FASTER100X_API_KEY);
1278    }
1279}