Skip to main content

scope/cli/
insights.rs

1//! # Insights Command
2//!
3//! Infers the type of blockchain target (address, token, transaction) from input,
4//! auto-detects chain, and runs relevant Scope analyses to produce unified insights.
5
6use crate::chains::{
7    ChainClientFactory, infer_chain_from_address, infer_chain_from_hash, native_symbol,
8};
9use crate::cli::address::{self, AddressArgs};
10use crate::cli::crawl::{Period, fetch_analytics_for_input};
11use crate::cli::tx::{fetch_transaction_report, format_tx_markdown};
12use crate::config::Config;
13use crate::display::report;
14use crate::error::Result;
15use crate::market::{HealthThresholds, MarketSummary, VenueRegistry};
16use crate::tokens::TokenAliases;
17use clap::Args;
18
19/// Target type inferred from user input.
20#[derive(Debug, Clone)]
21pub enum InferredTarget {
22    /// Blockchain address (EVM, Tron, or Solana).
23    Address { chain: String },
24    /// Transaction hash.
25    Transaction { chain: String },
26    /// Token symbol, name, or contract address.
27    Token { chain: String },
28}
29
30/// Arguments for the insights command.
31#[derive(Debug, Args)]
32pub struct InsightsArgs {
33    /// Target to analyze: address, transaction hash, or token (symbol/name/address).
34    ///
35    /// Scope infers the type and chain from format:
36    /// - `0x...` (42 chars) = EVM address → ethereum
37    /// - `T...` (34 chars) = Tron address → tron
38    /// - Base58 (32–44 chars) = Solana address → solana
39    /// - `0x...` (66 chars) = EVM tx hash
40    /// - 64 hex chars = Tron tx hash
41    /// - Base58 (80–90 chars) = Solana signature
42    /// - Otherwise = token symbol/name (e.g. USDC, WETH)
43    pub target: String,
44
45    /// Override detected chain (ethereum, polygon, solana, tron, etc.).
46    #[arg(short, long)]
47    pub chain: Option<String>,
48
49    /// Include decoded transaction input (for tx targets).
50    #[arg(long)]
51    pub decode: bool,
52
53    /// Include internal transaction trace (for tx targets).
54    #[arg(long)]
55    pub trace: bool,
56}
57
58/// Infers the target type and chain from the input string.
59pub fn infer_target(input: &str, chain_override: Option<&str>) -> InferredTarget {
60    let trimmed = input.trim();
61
62    if let Some(chain) = chain_override {
63        let chain = chain.to_lowercase();
64        // With override, we still need to infer type
65        if infer_chain_from_hash(trimmed).is_some() {
66            return InferredTarget::Transaction { chain };
67        }
68        if TokenAliases::is_address(trimmed) {
69            return InferredTarget::Address { chain };
70        }
71        return InferredTarget::Token { chain };
72    }
73
74    // Transaction hash (format implies chain)
75    if let Some(chain) = infer_chain_from_hash(trimmed) {
76        return InferredTarget::Transaction {
77            chain: chain.to_string(),
78        };
79    }
80
81    // Address (format implies chain)
82    if TokenAliases::is_address(trimmed) {
83        let chain = infer_chain_from_address(trimmed).unwrap_or("ethereum");
84        return InferredTarget::Address {
85            chain: chain.to_string(),
86        };
87    }
88
89    // Default: token (symbol or name)
90    InferredTarget::Token {
91        chain: "ethereum".to_string(),
92    }
93}
94
95/// Runs the insights command.
96pub async fn run(
97    mut args: InsightsArgs,
98    config: &Config,
99    clients: &dyn ChainClientFactory,
100) -> Result<()> {
101    // Resolve address book label → address + chain
102    if let Some((address, chain)) =
103        crate::cli::address_book::resolve_address_book_input(&args.target, config)?
104    {
105        args.target = address;
106        if args.chain.is_none() {
107            args.chain = Some(chain);
108        }
109    }
110
111    let chain_override = args.chain.as_deref();
112    let target = infer_target(&args.target, chain_override);
113
114    let sp = crate::cli::progress::Spinner::new(&format!(
115        "Analyzing {} on {}...",
116        target_type_label(&target),
117        chain_label(&target)
118    ));
119
120    let mut output = String::new();
121    output.push_str("# Scope Insights\n\n");
122    output.push_str(&format!("**Target:** `{}`\n\n", args.target));
123    output.push_str(&format!(
124        "**Detected:** {} on {}\n\n",
125        target_type_label(&target),
126        chain_label(&target)
127    ));
128    output.push_str("---\n\n");
129
130    match &target {
131        InferredTarget::Address { chain } => {
132            output.push_str("## Observations\n\n");
133            let addr_args = AddressArgs {
134                address: args.target.clone(),
135                chain: chain.clone(),
136                format: Some(crate::config::OutputFormat::Markdown),
137                include_txs: false,
138                include_tokens: true,
139                limit: 10,
140                report: None,
141                dossier: false,
142            };
143            let client = clients.create_chain_client(chain)?;
144            let report = address::analyze_address(&addr_args, client.as_ref()).await?;
145
146            // Contract vs EOA (EVM chains support get_code)
147            let code_result = client.get_code(&args.target).await;
148            let is_contract = code_result
149                .as_ref()
150                .is_ok_and(|c| !c.is_empty() && c != "0x");
151            if code_result.is_ok() {
152                output.push_str(&format!(
153                    "- **Type:** {}\n",
154                    if is_contract {
155                        "Contract"
156                    } else {
157                        "Externally Owned Account (EOA)"
158                    }
159                ));
160            }
161
162            output.push_str(&format!(
163                "- **Native balance:** {} ({})\n",
164                report.balance.formatted,
165                crate::chains::native_symbol(chain)
166            ));
167            if let Some(ref usd) = report.balance.usd {
168                output.push_str(&format!("- **USD value:** ${:.2}\n", usd));
169            }
170            output.push_str(&format!(
171                "- **Transaction count:** {}\n",
172                report.transaction_count
173            ));
174            if let Some(ref tokens) = report.tokens
175                && !tokens.is_empty()
176            {
177                output.push_str(&format!(
178                    "- **Token holdings:** {} different tokens\n",
179                    tokens.len()
180                ));
181                output.push_str("\n### Token Balances\n\n");
182                for tb in tokens.iter().take(10) {
183                    output.push_str(&format!(
184                        "- {}: {} ({})\n",
185                        tb.symbol, tb.formatted_balance, tb.contract_address
186                    ));
187                }
188                if tokens.len() > 10 {
189                    output.push_str(&format!("\n*...and {} more*\n", tokens.len() - 10));
190                }
191            }
192
193            // Risk assessment (compliance engine)
194            let risk_assessment =
195                match crate::compliance::datasource::BlockchainDataClient::from_env_opt() {
196                    Some(data_client) => {
197                        crate::compliance::risk::RiskEngine::with_data_client(data_client)
198                            .assess_address(&args.target, chain)
199                            .await
200                            .ok()
201                    }
202                    None => crate::compliance::risk::RiskEngine::new()
203                        .assess_address(&args.target, chain)
204                        .await
205                        .ok(),
206                };
207
208            if let Some(ref risk) = risk_assessment {
209                output.push_str(&format!(
210                    "\n- **Risk:** {} {:.1}/10 ({:?})\n",
211                    risk.risk_level.emoji(),
212                    risk.overall_score,
213                    risk.risk_level
214                ));
215            }
216
217            // Meta analysis
218            let meta = meta_analysis_address(
219                is_contract,
220                report.balance.usd,
221                report.tokens.as_ref().map(|t| t.len()).unwrap_or(0),
222                risk_assessment.as_ref().map(|r| r.overall_score),
223                risk_assessment.as_ref().map(|r| &r.risk_level),
224            );
225            output.push_str("\n### Synthesis\n\n");
226            output.push_str(&format!("{}\n\n", meta.synthesis));
227            output.push_str(&format!("**Key takeaway:** {}\n\n", meta.key_takeaway));
228            if !meta.recommendations.is_empty() {
229                output.push_str("**Consider:**\n");
230                for rec in &meta.recommendations {
231                    output.push_str(&format!("- {}\n", rec));
232                }
233            }
234            output.push_str("\n---\n\n");
235            let full_report = if let Some(ref risk) = risk_assessment {
236                crate::cli::address_report::generate_dossier_report(&report, risk)
237            } else {
238                crate::cli::address_report::generate_address_report(&report)
239            };
240            output.push_str(&full_report);
241        }
242        InferredTarget::Transaction { chain } => {
243            output.push_str("## Observations\n\n");
244            let tx_report =
245                fetch_transaction_report(&args.target, chain, args.decode, args.trace, clients)
246                    .await?;
247
248            let tx_type = classify_tx_type(
249                &tx_report.transaction.input,
250                tx_report.transaction.to.as_deref(),
251            );
252            output.push_str(&format!("- **Type:** {}\n", tx_type));
253
254            output.push_str(&format!(
255                "- **Status:** {}\n",
256                if tx_report.transaction.status {
257                    "Success"
258                } else {
259                    "Failed"
260                }
261            ));
262            output.push_str(&format!("- **From:** `{}`\n", tx_report.transaction.from));
263            output.push_str(&format!(
264                "- **To:** `{}`\n",
265                tx_report
266                    .transaction
267                    .to
268                    .as_deref()
269                    .unwrap_or("Contract Creation")
270            ));
271
272            let (formatted_value, high_value) =
273                format_tx_value(&tx_report.transaction.value, chain);
274            output.push_str(&format!("- **Value:** {}\n", formatted_value));
275            if high_value {
276                output.push_str("- ⚠️ **High-value transfer**\n");
277            }
278
279            output.push_str(&format!("- **Fee:** {}\n", tx_report.gas.transaction_fee));
280
281            // Meta analysis
282            let meta = meta_analysis_tx(
283                tx_type,
284                tx_report.transaction.status,
285                high_value,
286                &tx_report.transaction.from,
287                tx_report.transaction.to.as_deref(),
288            );
289            output.push_str("\n### Synthesis\n\n");
290            output.push_str(&format!("{}\n\n", meta.synthesis));
291            output.push_str(&format!("**Key takeaway:** {}\n\n", meta.key_takeaway));
292            if !meta.recommendations.is_empty() {
293                output.push_str("**Consider:**\n");
294                for rec in &meta.recommendations {
295                    output.push_str(&format!("- {}\n", rec));
296                }
297            }
298            output.push_str("\n---\n\n");
299            output.push_str(&format_tx_markdown(&tx_report));
300        }
301        InferredTarget::Token { chain } => {
302            output.push_str("## Observations\n\n");
303            let analytics =
304                fetch_analytics_for_input(&args.target, chain, Period::Hour24, 10, clients).await?;
305
306            // Token risk summary (interpretive bullets)
307            let risk_summary = report::token_risk_summary(&analytics);
308            output.push_str(&format!(
309                "- **Risk:** {} {}/10 ({})\n",
310                risk_summary.emoji, risk_summary.score, risk_summary.level
311            ));
312            if !risk_summary.concerns.is_empty() {
313                for c in &risk_summary.concerns {
314                    output.push_str(&format!("- ⚠️ {}\n", c));
315                }
316            }
317            if !risk_summary.positives.is_empty() {
318                for p in &risk_summary.positives {
319                    output.push_str(&format!("- ✅ {}\n", p));
320                }
321            }
322
323            output.push_str(&format!(
324                "- **Token:** {} ({})\n",
325                analytics.token.symbol, analytics.token.name
326            ));
327            output.push_str(&format!(
328                "- **Address:** `{}`\n",
329                analytics.token.contract_address
330            ));
331            output.push_str(&format!("- **Price:** ${:.6}\n", analytics.price_usd));
332            output.push_str(&format!(
333                "- **Liquidity (24h):** ${}\n",
334                crate::display::format_usd(analytics.liquidity_usd)
335            ));
336            output.push_str(&format!(
337                "- **Volume (24h):** ${}\n",
338                crate::display::format_usd(analytics.volume_24h)
339            ));
340
341            // Top holder context
342            if let Some(top) = analytics.holders.first() {
343                output.push_str(&format!(
344                    "- **Top holder:** `{}` ({:.1}%)\n",
345                    top.address, top.percentage
346                ));
347                if top.percentage > 30.0 {
348                    output.push_str("  - ⚠️ High concentration risk\n");
349                }
350            }
351            output.push_str(&format!(
352                "- **Holders displayed:** {}\n",
353                analytics.holders.len()
354            ));
355
356            // Stablecoin: auto-include market/peg via venue registry
357            let mut peg_healthy: Option<bool> = None;
358            if is_stablecoin(&analytics.token.symbol) {
359                if let Ok(registry) = VenueRegistry::load() {
360                    // Try binance first, fall back to any available CEX
361                    let venue_id = if registry.contains("binance") {
362                        "binance"
363                    } else {
364                        registry.list().first().copied().unwrap_or("binance")
365                    };
366                    if let Ok(exchange) = registry.create_exchange_client(venue_id) {
367                        let pair = exchange.format_pair(&analytics.token.symbol);
368                        if let Ok(book) = exchange.fetch_order_book(&pair).await {
369                            let thresholds = HealthThresholds {
370                                peg_target: 1.0,
371                                peg_range: 0.001,
372                                min_levels: 6,
373                                min_depth: 3000.0,
374                                min_bid_ask_ratio: 0.2,
375                                max_bid_ask_ratio: 5.0,
376                            };
377                            let volume_24h = if exchange.has_ticker() {
378                                exchange
379                                    .fetch_ticker(&pair)
380                                    .await
381                                    .ok()
382                                    .and_then(|t| t.quote_volume_24h.or(t.volume_24h))
383                            } else {
384                                None
385                            };
386                            let summary =
387                                MarketSummary::from_order_book(&book, 1.0, &thresholds, volume_24h);
388                            let deviation_bps = summary
389                                .mid_price
390                                .map(|m| (m - 1.0) * 10_000.0)
391                                .unwrap_or(0.0);
392                            peg_healthy = Some(deviation_bps.abs() < 10.0);
393                            let peg_status = if peg_healthy.unwrap_or(false) {
394                                "Peg healthy"
395                            } else if deviation_bps.abs() < 50.0 {
396                                "Slight peg deviation"
397                            } else {
398                                "Peg deviation"
399                            };
400                            output.push_str(&format!(
401                                "- **Market ({} {}):** {} (deviation: {:.1} bps)\n",
402                                exchange.venue_name(),
403                                pair,
404                                peg_status,
405                                deviation_bps
406                            ));
407                        }
408                    }
409                }
410            }
411
412            // Meta analysis
413            let top_holder_pct = analytics.holders.first().map(|h| h.percentage);
414            let meta = meta_analysis_token(
415                &risk_summary,
416                is_stablecoin(&analytics.token.symbol),
417                peg_healthy,
418                top_holder_pct,
419                analytics.liquidity_usd,
420            );
421            output.push_str("\n### Synthesis\n\n");
422            output.push_str(&format!("{}\n\n", meta.synthesis));
423            output.push_str(&format!("**Key takeaway:** {}\n\n", meta.key_takeaway));
424            if !meta.recommendations.is_empty() {
425                output.push_str("**Consider:**\n");
426                for rec in &meta.recommendations {
427                    output.push_str(&format!("- {}\n", rec));
428                }
429            }
430            output.push_str("\n---\n\n");
431            output.push_str(&report::generate_report(&analytics));
432        }
433    }
434
435    sp.finish("Insights complete.");
436    println!("{}", output);
437    Ok(())
438}
439
440fn target_type_label(target: &InferredTarget) -> &'static str {
441    match target {
442        InferredTarget::Address { .. } => "Address",
443        InferredTarget::Transaction { .. } => "Transaction",
444        InferredTarget::Token { .. } => "Token",
445    }
446}
447
448fn chain_label(target: &InferredTarget) -> &str {
449    match target {
450        InferredTarget::Address { chain } => chain,
451        InferredTarget::Transaction { chain } => chain,
452        InferredTarget::Token { chain } => chain,
453    }
454}
455
456/// Classifies EVM transaction from input data selector.
457fn classify_tx_type(input: &str, to: Option<&str>) -> &'static str {
458    if to.is_none() {
459        return "Contract Creation";
460    }
461    let selector = input
462        .trim_start_matches("0x")
463        .chars()
464        .take(8)
465        .collect::<String>();
466    let sel = selector.to_lowercase();
467    match sel.as_str() {
468        "a9059cbb" => "ERC-20 Transfer",
469        "095ea7b3" => "ERC-20 Approve",
470        "23b872dd" => "ERC-20 Transfer From",
471        "38ed1739" | "5c11d795" | "4a25d94a" | "8803dbee" | "7ff36ab5" | "18cbafe5"
472        | "fb3bdb41" | "b6f9de95" => "DEX Swap",
473        "ac9650d8" | "5ae401dc" => "Multicall",
474        _ if input.is_empty() || input == "0x" => "Native Transfer",
475        _ => "Contract Call",
476    }
477}
478
479/// Formats raw value to human-readable (e.g. wei → ETH).
480fn format_tx_value(value_str: &str, chain: &str) -> (String, bool) {
481    let wei: u128 = if value_str.starts_with("0x") {
482        let hex_part = value_str.trim_start_matches("0x");
483        if hex_part.is_empty() {
484            0
485        } else {
486            u128::from_str_radix(hex_part, 16).unwrap_or(0)
487        }
488    } else {
489        value_str.parse().unwrap_or(0)
490    };
491    let decimals = match chain.to_lowercase().as_str() {
492        "ethereum" | "polygon" | "arbitrum" | "optimism" | "base" | "bsc" | "aegis" => 18,
493        "solana" => 9,
494        "tron" => 6,
495        _ => 18,
496    };
497    let divisor = 10_f64.powi(decimals);
498    let human = wei as f64 / divisor;
499    let symbol = native_symbol(chain);
500    let formatted = format!("≈ {:.6} {}", human, symbol);
501    // "High value" threshold: > 10 native units
502    let high_value = human > 10.0;
503    (formatted, high_value)
504}
505
506/// Common stablecoin symbols for auto-including market/peg analysis.
507fn is_stablecoin(symbol: &str) -> bool {
508    matches!(
509        symbol.to_uppercase().as_str(),
510        "USDC" | "USDT" | "DAI" | "BUSD" | "TUSD" | "USDP" | "FRAX" | "LUSD" | "PUSD" | "GUSD"
511    )
512}
513
514/// Meta-analysis: synthesizes observations into an executive summary, key takeaway, and recommendations.
515struct MetaAnalysis {
516    synthesis: String,
517    key_takeaway: String,
518    recommendations: Vec<String>,
519}
520
521fn meta_analysis_address(
522    is_contract: bool,
523    usd_value: Option<f64>,
524    token_count: usize,
525    risk_score: Option<f32>,
526    risk_level: Option<&crate::compliance::risk::RiskLevel>,
527) -> MetaAnalysis {
528    let mut synthesis_parts = Vec::new();
529    let profile = if is_contract {
530        "contract"
531    } else {
532        "wallet (EOA)"
533    };
534    synthesis_parts.push(format!("A {} on chain.", profile));
535
536    if let Some(usd) = usd_value {
537        if usd > 1_000_000.0 {
538            synthesis_parts.push("Significant value held.".to_string());
539        } else if usd > 10_000.0 {
540            synthesis_parts.push("Moderate value.".to_string());
541        } else if usd < 1.0 {
542            synthesis_parts.push("Minimal value.".to_string());
543        }
544    }
545
546    if token_count > 5 {
547        synthesis_parts.push("Diversified token exposure.".to_string());
548    } else if token_count == 1 && token_count > 0 {
549        synthesis_parts.push("Concentrated in a single token.".to_string());
550    }
551
552    if let (Some(score), Some(level)) = (risk_score, risk_level) {
553        if score >= 7.0 {
554            synthesis_parts.push(format!("Elevated risk ({:?}).", level));
555        } else if score <= 3.0 {
556            synthesis_parts.push("Low risk profile.".to_string());
557        }
558    }
559
560    let synthesis = if synthesis_parts.is_empty() {
561        "Address analyzed with available on-chain data.".to_string()
562    } else {
563        synthesis_parts.join(" ")
564    };
565
566    let key_takeaway = if let (Some(score), Some(level)) = (risk_score, risk_level) {
567        if score >= 7.0 {
568            format!(
569                "Risk assessment warrants closer scrutiny ({:.1}/10).",
570                score
571            )
572        } else {
573            format!("Overall risk: {:?} ({:.1}/10).", level, score)
574        }
575    } else if is_contract {
576        "Contract address — verify intended interaction before use.".to_string()
577    } else if usd_value.map(|u| u > 100_000.0).unwrap_or(false) {
578        "High-value wallet — standard due diligence applies.".to_string()
579    } else {
580        "Review full report for transaction and token details.".to_string()
581    };
582
583    let mut recommendations = Vec::new();
584    if risk_score.map(|s| s >= 6.0).unwrap_or(false) {
585        recommendations.push("Monitor for unusual transaction patterns.".to_string());
586    }
587    if token_count > 0 {
588        recommendations.push("Verify token contracts before large interactions.".to_string());
589    }
590    if is_contract {
591        recommendations.push("Confirm contract source and audit status.".to_string());
592    }
593
594    MetaAnalysis {
595        synthesis,
596        key_takeaway,
597        recommendations,
598    }
599}
600
601fn meta_analysis_tx(
602    tx_type: &str,
603    status: bool,
604    high_value: bool,
605    _from: &str,
606    _to: Option<&str>,
607) -> MetaAnalysis {
608    let mut synthesis_parts = Vec::new();
609
610    if !status {
611        synthesis_parts.push("Transaction failed.".to_string());
612    }
613
614    synthesis_parts.push(format!("{} between parties.", tx_type));
615
616    if high_value {
617        synthesis_parts.push("High-value transfer.".to_string());
618    }
619
620    let synthesis = synthesis_parts.join(" ");
621
622    let key_takeaway = if !status {
623        "Failed transaction — check revert reason and contract state.".to_string()
624    } else if high_value && tx_type == "Native Transfer" {
625        "Large native transfer — verify recipient and intent.".to_string()
626    } else if high_value {
627        "High-value operation — standard verification recommended.".to_string()
628    } else {
629        format!("Routine {} — review full details if needed.", tx_type)
630    };
631
632    let mut recommendations = Vec::new();
633    if !status {
634        recommendations.push("Inspect contract logs for revert reason.".to_string());
635    }
636    if high_value {
637        recommendations.push("Confirm recipient address and amount.".to_string());
638    }
639    if tx_type.contains("Approval") {
640        recommendations.push("Verify approved spender and allowance amount.".to_string());
641    }
642
643    MetaAnalysis {
644        synthesis,
645        key_takeaway,
646        recommendations,
647    }
648}
649
650fn meta_analysis_token(
651    risk_summary: &report::TokenRiskSummary,
652    is_stablecoin: bool,
653    peg_healthy: Option<bool>,
654    top_holder_pct: Option<f64>,
655    liquidity_usd: f64,
656) -> MetaAnalysis {
657    let mut synthesis_parts = Vec::new();
658
659    if risk_summary.score <= 3 {
660        synthesis_parts.push("Low-risk token with healthy metrics.".to_string());
661    } else if risk_summary.score >= 7 {
662        synthesis_parts.push("Elevated risk — multiple concerns identified.".to_string());
663    } else {
664        synthesis_parts.push("Moderate risk — mixed signals.".to_string());
665    }
666
667    if is_stablecoin && let Some(healthy) = peg_healthy {
668        if healthy {
669            synthesis_parts.push("Stablecoin peg is healthy on observed venue.".to_string());
670        } else {
671            synthesis_parts
672                .push("Stablecoin peg deviation detected — verify on multiple venues.".to_string());
673        }
674    }
675
676    if top_holder_pct.map(|p| p > 30.0).unwrap_or(false) {
677        synthesis_parts.push("Concentration risk: top holder holds significant share.".to_string());
678    }
679
680    if liquidity_usd > 1_000_000.0 {
681        synthesis_parts.push("Strong liquidity depth.".to_string());
682    } else if liquidity_usd < 50_000.0 {
683        synthesis_parts.push("Limited liquidity — slippage risk for larger trades.".to_string());
684    }
685
686    let synthesis = synthesis_parts.join(" ");
687
688    let key_takeaway = if risk_summary.score >= 7 {
689        format!(
690            "High risk ({}): {} — exercise caution.",
691            risk_summary.score,
692            risk_summary
693                .concerns
694                .first()
695                .cloned()
696                .unwrap_or_else(|| "multiple factors".to_string())
697        )
698    } else if is_stablecoin && peg_healthy == Some(false) {
699        "Stablecoin deviating from peg — check additional venues before trading.".to_string()
700    } else if !risk_summary.positives.is_empty() && risk_summary.concerns.is_empty() {
701        "Favorable risk profile — standard diligence applies.".to_string()
702    } else {
703        format!(
704            "Risk {}/10 ({}) — weigh concerns against use case.",
705            risk_summary.score, risk_summary.level
706        )
707    };
708
709    let mut recommendations = Vec::new();
710    if risk_summary.score >= 6 {
711        recommendations
712            .push("Consider smaller position sizes or avoid until risk clears.".to_string());
713    }
714    if top_holder_pct.map(|p| p > 25.0).unwrap_or(false) {
715        recommendations.push("Monitor top holder movements for distribution changes.".to_string());
716    }
717    if is_stablecoin && peg_healthy != Some(true) {
718        recommendations.push("Verify peg across multiple DEX/CEX venues.".to_string());
719    }
720    if liquidity_usd < 100_000.0 && risk_summary.score <= 5 {
721        recommendations.push("Use limit orders or split trades to manage slippage.".to_string());
722    }
723
724    MetaAnalysis {
725        synthesis,
726        key_takeaway,
727        recommendations,
728    }
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734    use crate::chains::{
735        Balance as ChainBalance, ChainClient, ChainClientFactory, DexDataSource,
736        Token as ChainToken, TokenBalance as ChainTokenBalance, Transaction as ChainTransaction,
737    };
738    use async_trait::async_trait;
739
740    // ====================================================================
741    // Mock Chain Client for testing run() paths
742    // ====================================================================
743
744    struct MockChainClient;
745
746    #[async_trait]
747    impl ChainClient for MockChainClient {
748        fn chain_name(&self) -> &str {
749            "ethereum"
750        }
751        fn native_token_symbol(&self) -> &str {
752            "ETH"
753        }
754        async fn get_balance(&self, _address: &str) -> crate::error::Result<ChainBalance> {
755            Ok(ChainBalance {
756                raw: "1000000000000000000".to_string(),
757                formatted: "1.0 ETH".to_string(),
758                decimals: 18,
759                symbol: "ETH".to_string(),
760                usd_value: Some(2500.0),
761            })
762        }
763        async fn enrich_balance_usd(&self, balance: &mut ChainBalance) {
764            balance.usd_value = Some(2500.0);
765        }
766        async fn get_transaction(&self, _hash: &str) -> crate::error::Result<ChainTransaction> {
767            Ok(ChainTransaction {
768                hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd"
769                    .to_string(),
770                block_number: Some(12345678),
771                timestamp: Some(1700000000),
772                from: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
773                to: Some("0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string()),
774                value: "1000000000000000000".to_string(),
775                gas_limit: 21000,
776                gas_used: Some(21000),
777                gas_price: "20000000000".to_string(),
778                nonce: 42,
779                input: "0xa9059cbb0000000000000000000000001234".to_string(),
780                status: Some(true),
781            })
782        }
783        async fn get_transactions(
784            &self,
785            _address: &str,
786            _limit: u32,
787        ) -> crate::error::Result<Vec<ChainTransaction>> {
788            Ok(vec![])
789        }
790        async fn get_block_number(&self) -> crate::error::Result<u64> {
791            Ok(12345678)
792        }
793        async fn get_token_balances(
794            &self,
795            _address: &str,
796        ) -> crate::error::Result<Vec<ChainTokenBalance>> {
797            Ok(vec![
798                ChainTokenBalance {
799                    token: ChainToken {
800                        contract_address: "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string(),
801                        symbol: "USDT".to_string(),
802                        name: "Tether USD".to_string(),
803                        decimals: 6,
804                    },
805                    balance: "1000000".to_string(),
806                    formatted_balance: "1.0".to_string(),
807                    usd_value: Some(1.0),
808                },
809                ChainTokenBalance {
810                    token: ChainToken {
811                        contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
812                        symbol: "USDC".to_string(),
813                        name: "USD Coin".to_string(),
814                        decimals: 6,
815                    },
816                    balance: "5000000".to_string(),
817                    formatted_balance: "5.0".to_string(),
818                    usd_value: Some(5.0),
819                },
820            ])
821        }
822        async fn get_code(&self, _address: &str) -> crate::error::Result<String> {
823            Ok("0x".to_string()) // EOA
824        }
825    }
826
827    struct MockFactory;
828
829    impl ChainClientFactory for MockFactory {
830        fn create_chain_client(&self, _chain: &str) -> crate::error::Result<Box<dyn ChainClient>> {
831            Ok(Box::new(MockChainClient))
832        }
833        fn create_dex_client(&self) -> Box<dyn DexDataSource> {
834            crate::chains::DefaultClientFactory {
835                chains_config: Default::default(),
836            }
837            .create_dex_client()
838        }
839    }
840
841    // Mock that returns a contract address
842    struct MockContractClient;
843
844    #[async_trait]
845    impl ChainClient for MockContractClient {
846        fn chain_name(&self) -> &str {
847            "ethereum"
848        }
849        fn native_token_symbol(&self) -> &str {
850            "ETH"
851        }
852        async fn get_balance(&self, _address: &str) -> crate::error::Result<ChainBalance> {
853            Ok(ChainBalance {
854                raw: "0".to_string(),
855                formatted: "0.0 ETH".to_string(),
856                decimals: 18,
857                symbol: "ETH".to_string(),
858                usd_value: Some(0.0),
859            })
860        }
861        async fn enrich_balance_usd(&self, _balance: &mut ChainBalance) {}
862        async fn get_transaction(&self, hash: &str) -> crate::error::Result<ChainTransaction> {
863            Ok(ChainTransaction {
864                hash: hash.to_string(),
865                block_number: Some(100),
866                timestamp: Some(1700000000),
867                from: "0xfrom".to_string(),
868                to: None, // contract creation
869                value: "0".to_string(),
870                gas_limit: 100000,
871                gas_used: Some(80000),
872                gas_price: "10000000000".to_string(),
873                nonce: 0,
874                input: "0x60806040".to_string(),
875                status: Some(false), // failed tx
876            })
877        }
878        async fn get_transactions(
879            &self,
880            _address: &str,
881            _limit: u32,
882        ) -> crate::error::Result<Vec<ChainTransaction>> {
883            Ok(vec![])
884        }
885        async fn get_block_number(&self) -> crate::error::Result<u64> {
886            Ok(100)
887        }
888        async fn get_token_balances(
889            &self,
890            _address: &str,
891        ) -> crate::error::Result<Vec<ChainTokenBalance>> {
892            Ok(vec![])
893        }
894        async fn get_code(&self, _address: &str) -> crate::error::Result<String> {
895            Ok("0x6080604052".to_string()) // contract
896        }
897    }
898
899    struct MockContractFactory;
900
901    impl ChainClientFactory for MockContractFactory {
902        fn create_chain_client(&self, _chain: &str) -> crate::error::Result<Box<dyn ChainClient>> {
903            Ok(Box::new(MockContractClient))
904        }
905        fn create_dex_client(&self) -> Box<dyn DexDataSource> {
906            crate::chains::DefaultClientFactory {
907                chains_config: Default::default(),
908            }
909            .create_dex_client()
910        }
911    }
912
913    // Mock DexDataSource for token tests
914    struct MockDexDataSource;
915
916    #[async_trait]
917    impl DexDataSource for MockDexDataSource {
918        async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
919            Some(1.0)
920        }
921
922        async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
923            Some(2500.0)
924        }
925
926        async fn get_token_data(
927            &self,
928            _chain: &str,
929            address: &str,
930        ) -> crate::error::Result<crate::chains::dex::DexTokenData> {
931            use crate::chains::{DexPair, PricePoint, VolumePoint};
932            Ok(crate::chains::dex::DexTokenData {
933                address: address.to_string(),
934                symbol: "TEST".to_string(),
935                name: "Test Token".to_string(),
936                price_usd: 1.5,
937                price_change_24h: 5.2,
938                price_change_6h: 2.1,
939                price_change_1h: 0.5,
940                price_change_5m: 0.1,
941                volume_24h: 1_000_000.0,
942                volume_6h: 250_000.0,
943                volume_1h: 50_000.0,
944                liquidity_usd: 500_000.0,
945                market_cap: Some(10_000_000.0),
946                fdv: Some(12_000_000.0),
947                pairs: vec![DexPair {
948                    dex_name: "Uniswap V3".to_string(),
949                    pair_address: "0xpair123".to_string(),
950                    base_token: "TEST".to_string(),
951                    quote_token: "USDC".to_string(),
952                    price_usd: 1.5,
953                    liquidity_usd: 500_000.0,
954                    volume_24h: 1_000_000.0,
955                    price_change_24h: 5.2,
956                    buys_24h: 100,
957                    sells_24h: 80,
958                    buys_6h: 20,
959                    sells_6h: 15,
960                    buys_1h: 5,
961                    sells_1h: 3,
962                    pair_created_at: Some(1690000000),
963                    url: Some("https://dexscreener.com/ethereum/0xpair123".to_string()),
964                }],
965                price_history: vec![PricePoint {
966                    timestamp: 1690000000,
967                    price: 1.5,
968                }],
969                volume_history: vec![VolumePoint {
970                    timestamp: 1690000000,
971                    volume: 1_000_000.0,
972                }],
973                total_buys_24h: 100,
974                total_sells_24h: 80,
975                total_buys_6h: 20,
976                total_sells_6h: 15,
977                total_buys_1h: 5,
978                total_sells_1h: 3,
979                earliest_pair_created_at: Some(1690000000),
980                image_url: None,
981                websites: Vec::new(),
982                socials: Vec::new(),
983                dexscreener_url: Some("https://dexscreener.com/ethereum/test".to_string()),
984            })
985        }
986
987        async fn search_tokens(
988            &self,
989            _query: &str,
990            _chain: Option<&str>,
991        ) -> crate::error::Result<Vec<crate::chains::TokenSearchResult>> {
992            Ok(vec![crate::chains::TokenSearchResult {
993                address: "0xTEST1234567890123456789012345678901234567".to_string(),
994                symbol: "TEST".to_string(),
995                name: "Test Token".to_string(),
996                chain: "ethereum".to_string(),
997                price_usd: Some(1.5),
998                volume_24h: 1_000_000.0,
999                liquidity_usd: 500_000.0,
1000                market_cap: Some(10_000_000.0),
1001            }])
1002        }
1003    }
1004
1005    // Mock ChainClient that returns holders with high concentration
1006    struct MockTokenChainClient;
1007
1008    #[async_trait]
1009    impl ChainClient for MockTokenChainClient {
1010        fn chain_name(&self) -> &str {
1011            "ethereum"
1012        }
1013        fn native_token_symbol(&self) -> &str {
1014            "ETH"
1015        }
1016        async fn get_balance(&self, _address: &str) -> crate::error::Result<ChainBalance> {
1017            Ok(ChainBalance {
1018                raw: "1000000000000000000".to_string(),
1019                formatted: "1.0 ETH".to_string(),
1020                decimals: 18,
1021                symbol: "ETH".to_string(),
1022                usd_value: Some(2500.0),
1023            })
1024        }
1025        async fn enrich_balance_usd(&self, balance: &mut ChainBalance) {
1026            balance.usd_value = Some(2500.0);
1027        }
1028        async fn get_transaction(&self, _hash: &str) -> crate::error::Result<ChainTransaction> {
1029            Ok(ChainTransaction {
1030                hash: "0xabc123".to_string(),
1031                block_number: Some(12345678),
1032                timestamp: Some(1700000000),
1033                from: "0xfrom".to_string(),
1034                to: Some("0xto".to_string()),
1035                value: "0".to_string(),
1036                gas_limit: 21000,
1037                gas_used: Some(21000),
1038                gas_price: "20000000000".to_string(),
1039                nonce: 42,
1040                input: "0x".to_string(),
1041                status: Some(true),
1042            })
1043        }
1044        async fn get_transactions(
1045            &self,
1046            _address: &str,
1047            _limit: u32,
1048        ) -> crate::error::Result<Vec<ChainTransaction>> {
1049            Ok(vec![])
1050        }
1051        async fn get_block_number(&self) -> crate::error::Result<u64> {
1052            Ok(12345678)
1053        }
1054        async fn get_token_balances(
1055            &self,
1056            _address: &str,
1057        ) -> crate::error::Result<Vec<ChainTokenBalance>> {
1058            Ok(vec![])
1059        }
1060        async fn get_code(&self, _address: &str) -> crate::error::Result<String> {
1061            Ok("0x".to_string())
1062        }
1063        async fn get_token_holders(
1064            &self,
1065            _address: &str,
1066            _limit: u32,
1067        ) -> crate::error::Result<Vec<crate::chains::TokenHolder>> {
1068            // Return holders with high concentration (>30%) to trigger warning
1069            Ok(vec![
1070                crate::chains::TokenHolder {
1071                    address: "0x1111111111111111111111111111111111111111".to_string(),
1072                    balance: "3500000000000000000000000".to_string(),
1073                    formatted_balance: "3500000.0".to_string(),
1074                    percentage: 35.0, // >30% triggers concentration warning
1075                    rank: 1,
1076                },
1077                crate::chains::TokenHolder {
1078                    address: "0x2222222222222222222222222222222222222222".to_string(),
1079                    balance: "1500000000000000000000000".to_string(),
1080                    formatted_balance: "1500000.0".to_string(),
1081                    percentage: 15.0,
1082                    rank: 2,
1083                },
1084                crate::chains::TokenHolder {
1085                    address: "0x3333333333333333333333333333333333333333".to_string(),
1086                    balance: "1000000000000000000000000".to_string(),
1087                    formatted_balance: "1000000.0".to_string(),
1088                    percentage: 10.0,
1089                    rank: 3,
1090                },
1091            ])
1092        }
1093    }
1094
1095    // Factory for token tests with mocks
1096    struct MockTokenFactory;
1097
1098    impl ChainClientFactory for MockTokenFactory {
1099        fn create_chain_client(&self, _chain: &str) -> crate::error::Result<Box<dyn ChainClient>> {
1100            Ok(Box::new(MockTokenChainClient))
1101        }
1102        fn create_dex_client(&self) -> Box<dyn DexDataSource> {
1103            Box::new(MockDexDataSource)
1104        }
1105    }
1106
1107    // ====================================================================
1108    // run() function tests with mocks
1109    // ====================================================================
1110
1111    #[tokio::test]
1112    async fn test_run_address_eoa() {
1113        let config = Config::default();
1114        let factory = MockFactory;
1115        let args = InsightsArgs {
1116            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1117            chain: None,
1118            decode: false,
1119            trace: false,
1120        };
1121        let result = run(args, &config, &factory).await;
1122        assert!(result.is_ok());
1123    }
1124
1125    #[tokio::test]
1126    async fn test_run_address_contract() {
1127        let config = Config::default();
1128        let factory = MockContractFactory;
1129        let args = InsightsArgs {
1130            target: "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string(),
1131            chain: None,
1132            decode: false,
1133            trace: false,
1134        };
1135        let result = run(args, &config, &factory).await;
1136        assert!(result.is_ok());
1137    }
1138
1139    #[tokio::test]
1140    async fn test_run_transaction() {
1141        let config = Config::default();
1142        let factory = MockFactory;
1143        let args = InsightsArgs {
1144            target: "0xabc123def456789012345678901234567890123456789012345678901234abcd"
1145                .to_string(),
1146            chain: None,
1147            decode: false,
1148            trace: false,
1149        };
1150        let result = run(args, &config, &factory).await;
1151        assert!(result.is_ok());
1152    }
1153
1154    #[tokio::test]
1155    async fn test_run_transaction_failed() {
1156        let config = Config::default();
1157        let factory = MockContractFactory;
1158        let args = InsightsArgs {
1159            target: "0xabc123def456789012345678901234567890123456789012345678901234abcd"
1160                .to_string(),
1161            chain: Some("ethereum".to_string()),
1162            decode: true,
1163            trace: false,
1164        };
1165        let result = run(args, &config, &factory).await;
1166        assert!(result.is_ok());
1167    }
1168
1169    #[tokio::test]
1170    async fn test_run_address_with_chain_override() {
1171        let config = Config::default();
1172        let factory = MockFactory;
1173        let args = InsightsArgs {
1174            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1175            chain: Some("polygon".to_string()),
1176            decode: false,
1177            trace: false,
1178        };
1179        let result = run(args, &config, &factory).await;
1180        assert!(result.is_ok());
1181    }
1182
1183    #[tokio::test]
1184    async fn test_insights_run_token() {
1185        let config = Config::default();
1186        let factory = MockTokenFactory;
1187        let args = InsightsArgs {
1188            target: "TEST".to_string(),
1189            chain: Some("ethereum".to_string()),
1190            decode: false,
1191            trace: false,
1192        };
1193        let result = run(args, &config, &factory).await;
1194        assert!(result.is_ok());
1195    }
1196
1197    #[tokio::test]
1198    async fn test_insights_run_token_with_concentration_warning() {
1199        let config = Config::default();
1200        let factory = MockTokenFactory;
1201        let args = InsightsArgs {
1202            target: "0xTEST1234567890123456789012345678901234567".to_string(),
1203            chain: Some("ethereum".to_string()),
1204            decode: false,
1205            trace: false,
1206        };
1207        let result = run(args, &config, &factory).await;
1208        assert!(result.is_ok());
1209    }
1210
1211    // ====================================================================
1212    // Existing tests below
1213    // ====================================================================
1214
1215    #[test]
1216    fn test_infer_target_evm_address() {
1217        let t = infer_target("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", None);
1218        assert!(matches!(t, InferredTarget::Address { chain } if chain == "ethereum"));
1219    }
1220
1221    #[test]
1222    fn test_infer_target_tron_address() {
1223        let t = infer_target("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", None);
1224        assert!(matches!(t, InferredTarget::Address { chain } if chain == "tron"));
1225    }
1226
1227    #[test]
1228    fn test_infer_target_solana_address() {
1229        let t = infer_target("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy", None);
1230        assert!(matches!(t, InferredTarget::Address { chain } if chain == "solana"));
1231    }
1232
1233    #[test]
1234    fn test_infer_target_evm_tx_hash() {
1235        let t = infer_target(
1236            "0xabc123def456789012345678901234567890123456789012345678901234abcd",
1237            None,
1238        );
1239        assert!(matches!(t, InferredTarget::Transaction { chain } if chain == "ethereum"));
1240    }
1241
1242    #[test]
1243    fn test_infer_target_tron_tx_hash() {
1244        let t = infer_target(
1245            "abc123def456789012345678901234567890123456789012345678901234abcd",
1246            None,
1247        );
1248        assert!(matches!(t, InferredTarget::Transaction { chain } if chain == "tron"));
1249    }
1250
1251    #[test]
1252    fn test_infer_target_token_symbol() {
1253        let t = infer_target("USDC", None);
1254        assert!(matches!(t, InferredTarget::Token { chain } if chain == "ethereum"));
1255    }
1256
1257    #[test]
1258    fn test_infer_target_chain_override() {
1259        let t = infer_target(
1260            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1261            Some("polygon"),
1262        );
1263        assert!(matches!(t, InferredTarget::Address { chain } if chain == "polygon"));
1264    }
1265
1266    #[test]
1267    fn test_infer_target_token_with_chain_override() {
1268        let t = infer_target("USDC", Some("solana"));
1269        assert!(matches!(t, InferredTarget::Token { chain } if chain == "solana"));
1270    }
1271
1272    #[test]
1273    fn test_classify_tx_type() {
1274        assert_eq!(
1275            classify_tx_type("0xa9059cbb1234...", Some("0xto")),
1276            "ERC-20 Transfer"
1277        );
1278        assert_eq!(
1279            classify_tx_type("0x095ea7b3abcd...", Some("0xto")),
1280            "ERC-20 Approve"
1281        );
1282        assert_eq!(classify_tx_type("0x", Some("0xto")), "Native Transfer");
1283        assert_eq!(classify_tx_type("", None), "Contract Creation");
1284    }
1285
1286    #[test]
1287    fn test_format_tx_value() {
1288        let (fmt, high) = format_tx_value("0xDE0B6B3A7640000", "ethereum"); // 1 ETH
1289        assert!(fmt.contains("1.0") && fmt.contains("ETH"));
1290        assert!(!high);
1291        let (_, high2) = format_tx_value("0x52B7D2DCC80CD2E4000000", "ethereum"); // 100 ETH
1292        assert!(high2);
1293    }
1294
1295    #[test]
1296    fn test_is_stablecoin() {
1297        assert!(is_stablecoin("USDC"));
1298        assert!(is_stablecoin("usdt"));
1299        assert!(is_stablecoin("DAI"));
1300        assert!(is_stablecoin("BUSD"));
1301        assert!(is_stablecoin("TUSD"));
1302        assert!(is_stablecoin("USDP"));
1303        assert!(is_stablecoin("FRAX"));
1304        assert!(is_stablecoin("LUSD"));
1305        assert!(is_stablecoin("PUSD"));
1306        assert!(is_stablecoin("GUSD"));
1307        assert!(!is_stablecoin("ETH"));
1308        assert!(!is_stablecoin("PEPE"));
1309        assert!(!is_stablecoin("WBTC"));
1310    }
1311
1312    // ====================================================================
1313    // target_type_label and chain_label tests
1314    // ====================================================================
1315
1316    #[test]
1317    fn test_target_type_label_address() {
1318        let t = InferredTarget::Address {
1319            chain: "ethereum".to_string(),
1320        };
1321        assert_eq!(target_type_label(&t), "Address");
1322    }
1323
1324    #[test]
1325    fn test_target_type_label_transaction() {
1326        let t = InferredTarget::Transaction {
1327            chain: "ethereum".to_string(),
1328        };
1329        assert_eq!(target_type_label(&t), "Transaction");
1330    }
1331
1332    #[test]
1333    fn test_target_type_label_token() {
1334        let t = InferredTarget::Token {
1335            chain: "ethereum".to_string(),
1336        };
1337        assert_eq!(target_type_label(&t), "Token");
1338    }
1339
1340    #[test]
1341    fn test_chain_label_address() {
1342        let t = InferredTarget::Address {
1343            chain: "polygon".to_string(),
1344        };
1345        assert_eq!(chain_label(&t), "polygon");
1346    }
1347
1348    #[test]
1349    fn test_chain_label_transaction() {
1350        let t = InferredTarget::Transaction {
1351            chain: "tron".to_string(),
1352        };
1353        assert_eq!(chain_label(&t), "tron");
1354    }
1355
1356    #[test]
1357    fn test_chain_label_token() {
1358        let t = InferredTarget::Token {
1359            chain: "solana".to_string(),
1360        };
1361        assert_eq!(chain_label(&t), "solana");
1362    }
1363
1364    // ====================================================================
1365    // classify_tx_type — expanded edge cases
1366    // ====================================================================
1367
1368    #[test]
1369    fn test_classify_tx_type_dex_swaps() {
1370        assert_eq!(
1371            classify_tx_type("0x38ed173900000...", Some("0xrouter")),
1372            "DEX Swap"
1373        );
1374        assert_eq!(
1375            classify_tx_type("0x5c11d79500000...", Some("0xrouter")),
1376            "DEX Swap"
1377        );
1378        assert_eq!(
1379            classify_tx_type("0x4a25d94a00000...", Some("0xrouter")),
1380            "DEX Swap"
1381        );
1382        assert_eq!(
1383            classify_tx_type("0x8803dbee00000...", Some("0xrouter")),
1384            "DEX Swap"
1385        );
1386        assert_eq!(
1387            classify_tx_type("0x7ff36ab500000...", Some("0xrouter")),
1388            "DEX Swap"
1389        );
1390        assert_eq!(
1391            classify_tx_type("0x18cbafe500000...", Some("0xrouter")),
1392            "DEX Swap"
1393        );
1394        assert_eq!(
1395            classify_tx_type("0xfb3bdb4100000...", Some("0xrouter")),
1396            "DEX Swap"
1397        );
1398        assert_eq!(
1399            classify_tx_type("0xb6f9de9500000...", Some("0xrouter")),
1400            "DEX Swap"
1401        );
1402    }
1403
1404    #[test]
1405    fn test_classify_tx_type_multicall() {
1406        assert_eq!(
1407            classify_tx_type("0xac9650d800000...", Some("0xcontract")),
1408            "Multicall"
1409        );
1410        assert_eq!(
1411            classify_tx_type("0x5ae401dc00000...", Some("0xcontract")),
1412            "Multicall"
1413        );
1414    }
1415
1416    #[test]
1417    fn test_classify_tx_type_transfer_from() {
1418        assert_eq!(
1419            classify_tx_type("0x23b872dd00000...", Some("0xtoken")),
1420            "ERC-20 Transfer From"
1421        );
1422    }
1423
1424    #[test]
1425    fn test_classify_tx_type_contract_call() {
1426        assert_eq!(
1427            classify_tx_type("0xdeadbeef00000...", Some("0xcontract")),
1428            "Contract Call"
1429        );
1430    }
1431
1432    #[test]
1433    fn test_classify_tx_type_native_transfer_empty() {
1434        assert_eq!(classify_tx_type("", Some("0xrecipient")), "Native Transfer");
1435    }
1436
1437    // ====================================================================
1438    // format_tx_value — expanded edge cases
1439    // ====================================================================
1440
1441    #[test]
1442    fn test_format_tx_value_zero() {
1443        let (fmt, high) = format_tx_value("0x0", "ethereum");
1444        assert!(fmt.contains("0.000000"));
1445        assert!(fmt.contains("ETH"));
1446        assert!(!high);
1447    }
1448
1449    #[test]
1450    fn test_format_tx_value_empty_hex() {
1451        let (fmt, high) = format_tx_value("0x", "ethereum");
1452        assert!(fmt.contains("0.000000"));
1453        assert!(!high);
1454    }
1455
1456    #[test]
1457    fn test_format_tx_value_decimal_string() {
1458        let (fmt, high) = format_tx_value("1000000000000000000", "ethereum"); // 1 ETH
1459        assert!(fmt.contains("1.0"));
1460        assert!(fmt.contains("ETH"));
1461        assert!(!high);
1462    }
1463
1464    #[test]
1465    fn test_format_tx_value_solana() {
1466        let (fmt, high) = format_tx_value("1000000000", "solana"); // 1 SOL (9 decimals)
1467        assert!(fmt.contains("1.0"));
1468        assert!(fmt.contains("SOL"));
1469        assert!(!high);
1470    }
1471
1472    #[test]
1473    fn test_format_tx_value_tron() {
1474        let (fmt, high) = format_tx_value("1000000", "tron"); // 1 TRX (6 decimals)
1475        assert!(fmt.contains("1.0"));
1476        assert!(fmt.contains("TRX"));
1477        assert!(!high);
1478    }
1479
1480    #[test]
1481    fn test_format_tx_value_polygon() {
1482        let (fmt, _) = format_tx_value("1000000000000000000", "polygon");
1483        assert!(fmt.contains("MATIC") || fmt.contains("POL"));
1484    }
1485
1486    #[test]
1487    fn test_format_tx_value_bsc() {
1488        let (fmt, _) = format_tx_value("1000000000000000000", "bsc");
1489        assert!(fmt.contains("BNB"));
1490    }
1491
1492    #[test]
1493    fn test_format_tx_value_high_value_threshold() {
1494        // > 10 native units = high value
1495        let (_, high) = format_tx_value("11000000000000000000", "ethereum"); // 11 ETH
1496        assert!(high);
1497        let (_, high2) = format_tx_value("10000000000000000000", "ethereum"); // 10 ETH
1498        assert!(!high2); // exactly 10 is not > 10
1499    }
1500
1501    // ====================================================================
1502    // meta_analysis_address tests
1503    // ====================================================================
1504
1505    #[test]
1506    fn test_meta_analysis_address_contract_high_value() {
1507        let meta = meta_analysis_address(true, Some(2_000_000.0), 10, None, None);
1508        assert!(meta.synthesis.contains("contract"));
1509        assert!(meta.synthesis.contains("Significant value"));
1510        assert!(meta.synthesis.contains("Diversified"));
1511        assert!(meta.recommendations.iter().any(|r| r.contains("contract")));
1512    }
1513
1514    #[test]
1515    fn test_meta_analysis_address_eoa_moderate_value() {
1516        let meta = meta_analysis_address(false, Some(50_000.0), 3, None, None);
1517        assert!(meta.synthesis.contains("wallet (EOA)"));
1518        assert!(meta.synthesis.contains("Moderate value"));
1519    }
1520
1521    #[test]
1522    fn test_meta_analysis_address_minimal_value() {
1523        let meta = meta_analysis_address(false, Some(0.5), 0, None, None);
1524        assert!(meta.synthesis.contains("Minimal value"));
1525    }
1526
1527    #[test]
1528    fn test_meta_analysis_address_single_token() {
1529        let meta = meta_analysis_address(false, None, 1, None, None);
1530        assert!(meta.synthesis.contains("Concentrated in a single token"));
1531    }
1532
1533    #[test]
1534    fn test_meta_analysis_address_high_risk() {
1535        use crate::compliance::risk::RiskLevel;
1536        let level = RiskLevel::High;
1537        let meta = meta_analysis_address(false, None, 0, Some(8.5), Some(&level));
1538        assert!(meta.synthesis.contains("Elevated risk"));
1539        assert!(meta.key_takeaway.contains("scrutiny"));
1540        assert!(
1541            meta.recommendations
1542                .iter()
1543                .any(|r| r.contains("unusual transaction"))
1544        );
1545    }
1546
1547    #[test]
1548    fn test_meta_analysis_address_low_risk() {
1549        use crate::compliance::risk::RiskLevel;
1550        let level = RiskLevel::Low;
1551        let meta = meta_analysis_address(false, None, 0, Some(2.0), Some(&level));
1552        assert!(meta.synthesis.contains("Low risk"));
1553    }
1554
1555    #[test]
1556    fn test_meta_analysis_address_contract_no_value() {
1557        let meta = meta_analysis_address(true, None, 0, None, None);
1558        assert!(meta.key_takeaway.contains("Contract address"));
1559        assert!(
1560            meta.recommendations
1561                .iter()
1562                .any(|r| r.contains("Confirm contract"))
1563        );
1564    }
1565
1566    #[test]
1567    fn test_meta_analysis_address_high_value_wallet() {
1568        let meta = meta_analysis_address(false, Some(150_000.0), 0, None, None);
1569        assert!(meta.key_takeaway.contains("High-value wallet"));
1570    }
1571
1572    #[test]
1573    fn test_meta_analysis_address_default_takeaway() {
1574        let meta = meta_analysis_address(false, Some(5_000.0), 0, None, None);
1575        assert!(meta.key_takeaway.contains("Review full report"));
1576    }
1577
1578    #[test]
1579    fn test_meta_analysis_address_with_tokens_recommendation() {
1580        let meta = meta_analysis_address(false, None, 3, None, None);
1581        assert!(
1582            meta.recommendations
1583                .iter()
1584                .any(|r| r.contains("Verify token contracts"))
1585        );
1586    }
1587
1588    // ====================================================================
1589    // meta_analysis_tx tests
1590    // ====================================================================
1591
1592    #[test]
1593    fn test_meta_analysis_tx_successful_native_transfer() {
1594        let meta = meta_analysis_tx("Native Transfer", true, false, "0xfrom", Some("0xto"));
1595        assert!(meta.synthesis.contains("Native Transfer"));
1596        assert!(meta.key_takeaway.contains("Routine"));
1597        assert!(meta.recommendations.is_empty());
1598    }
1599
1600    #[test]
1601    fn test_meta_analysis_tx_failed() {
1602        let meta = meta_analysis_tx("Contract Call", false, false, "0xfrom", Some("0xto"));
1603        assert!(meta.synthesis.contains("failed"));
1604        assert!(meta.key_takeaway.contains("Failed transaction"));
1605        assert!(meta.recommendations.iter().any(|r| r.contains("revert")));
1606    }
1607
1608    #[test]
1609    fn test_meta_analysis_tx_high_value_native() {
1610        let meta = meta_analysis_tx("Native Transfer", true, true, "0xfrom", Some("0xto"));
1611        assert!(meta.synthesis.contains("High-value"));
1612        assert!(meta.key_takeaway.contains("Large native transfer"));
1613        assert!(meta.recommendations.iter().any(|r| r.contains("recipient")));
1614    }
1615
1616    #[test]
1617    fn test_meta_analysis_tx_high_value_contract_call() {
1618        let meta = meta_analysis_tx("DEX Swap", true, true, "0xfrom", Some("0xto"));
1619        assert!(meta.key_takeaway.contains("High-value operation"));
1620    }
1621
1622    #[test]
1623    fn test_meta_analysis_tx_erc20_approve() {
1624        let meta = meta_analysis_tx("ERC-20 Approval", true, false, "0xfrom", Some("0xto"));
1625        assert!(meta.recommendations.iter().any(|r| r.contains("spender")));
1626    }
1627
1628    #[test]
1629    fn test_meta_analysis_tx_failed_high_value() {
1630        let meta = meta_analysis_tx("Contract Call", false, true, "0xfrom", Some("0xto"));
1631        assert!(meta.synthesis.contains("failed"));
1632        assert!(meta.synthesis.contains("High-value"));
1633        assert!(meta.recommendations.len() >= 2);
1634    }
1635
1636    // ====================================================================
1637    // meta_analysis_token tests
1638    // ====================================================================
1639
1640    #[test]
1641    fn test_meta_analysis_token_low_risk() {
1642        let summary = report::TokenRiskSummary {
1643            score: 2,
1644            level: "Low",
1645            emoji: "🟢",
1646            concerns: vec![],
1647            positives: vec!["Good liquidity".to_string()],
1648        };
1649        let meta = meta_analysis_token(&summary, false, None, None, 2_000_000.0);
1650        assert!(meta.synthesis.contains("Low-risk"));
1651        assert!(meta.synthesis.contains("Strong liquidity"));
1652        assert!(meta.key_takeaway.contains("Favorable"));
1653    }
1654
1655    #[test]
1656    fn test_meta_analysis_token_high_risk() {
1657        let summary = report::TokenRiskSummary {
1658            score: 8,
1659            level: "High",
1660            emoji: "🔴",
1661            concerns: vec!["Low liquidity".to_string()],
1662            positives: vec![],
1663        };
1664        let meta = meta_analysis_token(&summary, false, None, None, 10_000.0);
1665        assert!(meta.synthesis.contains("Elevated risk"));
1666        assert!(meta.synthesis.contains("Limited liquidity"));
1667        assert!(meta.key_takeaway.contains("High risk"));
1668        assert!(
1669            meta.recommendations
1670                .iter()
1671                .any(|r| r.contains("smaller position"))
1672        );
1673    }
1674
1675    #[test]
1676    fn test_meta_analysis_token_moderate_risk() {
1677        let summary = report::TokenRiskSummary {
1678            score: 5,
1679            level: "Medium",
1680            emoji: "🟡",
1681            concerns: vec!["Some concern".to_string()],
1682            positives: vec!["Some positive".to_string()],
1683        };
1684        let meta = meta_analysis_token(&summary, false, None, None, 500_000.0);
1685        assert!(meta.synthesis.contains("Moderate risk"));
1686        assert!(meta.key_takeaway.contains("Risk 5/10"));
1687    }
1688
1689    #[test]
1690    fn test_meta_analysis_token_stablecoin_healthy_peg() {
1691        let summary = report::TokenRiskSummary {
1692            score: 2,
1693            level: "Low",
1694            emoji: "🟢",
1695            concerns: vec![],
1696            positives: vec!["Stable peg".to_string()],
1697        };
1698        let meta = meta_analysis_token(&summary, true, Some(true), None, 5_000_000.0);
1699        assert!(meta.synthesis.contains("Stablecoin peg is healthy"));
1700    }
1701
1702    #[test]
1703    fn test_meta_analysis_token_stablecoin_unhealthy_peg() {
1704        let summary = report::TokenRiskSummary {
1705            score: 4,
1706            level: "Medium",
1707            emoji: "🟡",
1708            concerns: vec![],
1709            positives: vec![],
1710        };
1711        let meta = meta_analysis_token(&summary, true, Some(false), None, 500_000.0);
1712        assert!(meta.synthesis.contains("peg deviation"));
1713        assert!(meta.key_takeaway.contains("deviating from peg"));
1714        assert!(meta.recommendations.iter().any(|r| r.contains("peg")));
1715    }
1716
1717    #[test]
1718    fn test_meta_analysis_token_concentration_risk() {
1719        let summary = report::TokenRiskSummary {
1720            score: 5,
1721            level: "Medium",
1722            emoji: "🟡",
1723            concerns: vec![],
1724            positives: vec![],
1725        };
1726        let meta = meta_analysis_token(&summary, false, None, Some(45.0), 500_000.0);
1727        assert!(meta.synthesis.contains("Concentration risk"));
1728        assert!(
1729            meta.recommendations
1730                .iter()
1731                .any(|r| r.contains("top holder"))
1732        );
1733    }
1734
1735    #[test]
1736    fn test_meta_analysis_token_low_liquidity_low_risk() {
1737        let summary = report::TokenRiskSummary {
1738            score: 3,
1739            level: "Low",
1740            emoji: "🟢",
1741            concerns: vec![],
1742            positives: vec![],
1743        };
1744        let meta = meta_analysis_token(&summary, false, None, None, 50_000.0);
1745        assert!(
1746            meta.recommendations
1747                .iter()
1748                .any(|r| r.contains("limit orders") || r.contains("slippage"))
1749        );
1750    }
1751
1752    #[test]
1753    fn test_meta_analysis_token_stablecoin_no_peg_data() {
1754        let summary = report::TokenRiskSummary {
1755            score: 3,
1756            level: "Low",
1757            emoji: "🟢",
1758            concerns: vec![],
1759            positives: vec![],
1760        };
1761        let meta = meta_analysis_token(&summary, true, None, None, 1_000_000.0);
1762        // When peg_healthy is None, recommendation should still suggest verifying peg
1763        assert!(meta.recommendations.iter().any(|r| r.contains("peg")));
1764    }
1765
1766    // ====================================================================
1767    // infer_target — additional edge cases
1768    // ====================================================================
1769
1770    #[test]
1771    fn test_infer_target_tx_hash_with_chain_override() {
1772        let t = infer_target(
1773            "0xabc123def456789012345678901234567890123456789012345678901234abcd",
1774            Some("polygon"),
1775        );
1776        assert!(matches!(t, InferredTarget::Transaction { chain } if chain == "polygon"));
1777    }
1778
1779    #[test]
1780    fn test_infer_target_whitespace_trimming() {
1781        let t = infer_target("  USDC  ", None);
1782        assert!(matches!(t, InferredTarget::Token { .. }));
1783    }
1784
1785    #[test]
1786    fn test_infer_target_long_token_name() {
1787        let t = infer_target("some-random-token-name", None);
1788        assert!(matches!(t, InferredTarget::Token { chain } if chain == "ethereum"));
1789    }
1790
1791    // ====================================================================
1792    // InsightsArgs struct validation
1793    // ====================================================================
1794
1795    #[test]
1796    fn test_insights_args_debug() {
1797        let args = InsightsArgs {
1798            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1799            chain: Some("ethereum".to_string()),
1800            decode: true,
1801            trace: false,
1802        };
1803        let debug_str = format!("{:?}", args);
1804        assert!(debug_str.contains("InsightsArgs"));
1805        assert!(debug_str.contains("0x742d"));
1806    }
1807}