Skip to main content

scope_cli/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::cli::address::{self, AddressArgs};
7use crate::cli::crawl::{Period, fetch_analytics_for_input};
8use crate::cli::output::Output;
9use crate::cli::tx::{fetch_transaction_report, format_tx_markdown};
10use crate::outln;
11use clap::Args;
12use scope::chains::{
13    ChainClientFactory, infer_chain_from_address, infer_chain_from_hash, native_symbol,
14};
15use scope::config::Config;
16
17use super::health_args::HealthArgs;
18use scope::display::report;
19use scope::error::Result;
20use scope::market::health::PegStatus;
21use scope::market::{MarketSummary, MarketVenue, VenueRegistry, fetch_token_summary};
22use scope::tokens::{TokenAliases, is_stablecoin_symbol};
23
24/// Target type inferred from user input.
25#[derive(Debug, Clone)]
26pub enum InferredTarget {
27    /// Blockchain address (EVM, Tron, or Solana).
28    Address { chain: String },
29    /// Transaction hash.
30    Transaction { chain: String },
31    /// Token symbol, name, or contract address.
32    Token { chain: String },
33}
34
35/// Arguments for the insights command.
36#[derive(Debug, Args)]
37#[command(after_help = "\x1b[1mExamples:\x1b[0m
38  scope insights 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2
39  scope insights @main-wallet                             \x1b[2m# address book shortcut\x1b[0m
40  scope insights 0xabc123def456... --decode --trace
41  scope insights USDC
42  scope insights DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy --chain solana")]
43pub struct InsightsArgs {
44    /// Target to analyze: address, transaction hash, or token (symbol/name/address).
45    ///
46    /// Scope infers the type and chain from format:
47    /// - `@label` = address book shortcut (e.g. @main-wallet)
48    /// - `0x...` (42 chars) = EVM address → ethereum
49    /// - `T...` (34 chars) = Tron address → tron
50    /// - Base58 (32–44 chars) = Solana address → solana
51    /// - `0x...` (66 chars) = EVM tx hash
52    /// - 64 hex chars = Tron tx hash
53    /// - Base58 (80–90 chars) = Solana signature
54    /// - Otherwise = token symbol/name (e.g. USDC, WETH)
55    pub target: String,
56
57    /// Override detected chain (ethereum, polygon, solana, tron, etc.).
58    #[arg(short, long)]
59    pub chain: Option<String>,
60
61    /// Include decoded transaction input (for tx targets).
62    #[arg(long)]
63    pub decode: bool,
64
65    /// Include internal transaction trace (for tx targets).
66    #[arg(long)]
67    pub trace: bool,
68
69    /// Health threshold overrides for the stablecoin market check.
70    #[command(flatten)]
71    pub health: HealthArgs,
72}
73
74/// Infers the target type and chain from the input string.
75pub fn infer_target(input: &str, chain_override: Option<&str>) -> InferredTarget {
76    let trimmed = input.trim();
77
78    if let Some(chain) = chain_override {
79        let chain = chain.to_lowercase();
80        // With override, we still need to infer type
81        if infer_chain_from_hash(trimmed).is_some() {
82            return InferredTarget::Transaction { chain };
83        }
84        if TokenAliases::is_address(trimmed) {
85            return InferredTarget::Address { chain };
86        }
87        return InferredTarget::Token { chain };
88    }
89
90    // Transaction hash (format implies chain)
91    if let Some(chain) = infer_chain_from_hash(trimmed) {
92        return InferredTarget::Transaction {
93            chain: chain.to_string(),
94        };
95    }
96
97    // Address (format implies chain)
98    if TokenAliases::is_address(trimmed) {
99        let chain = infer_chain_from_address(trimmed).unwrap_or(scope::chains::DEFAULT_CHAIN);
100        return InferredTarget::Address {
101            chain: chain.to_string(),
102        };
103    }
104
105    // Default: token (symbol or name)
106    InferredTarget::Token {
107        chain: scope::chains::DEFAULT_CHAIN.to_string(),
108    }
109}
110
111/// Runs the insights command.
112pub async fn run(
113    mut args: InsightsArgs,
114    config: &Config,
115    clients: &dyn ChainClientFactory,
116    out: &Output,
117) -> Result<()> {
118    // Resolve address book label → address + chain
119    if let Some((address, chain)) =
120        crate::cli::address_book::resolve_address_book_input(&args.target, config)?
121    {
122        args.target = address;
123        if args.chain.is_none() {
124            args.chain = Some(chain);
125        }
126    }
127
128    let chain_override = args.chain.as_deref();
129    let target = infer_target(&args.target, chain_override);
130
131    let sp = crate::cli::progress::Spinner::new(
132        &format!(
133            "Analyzing {} on {}...",
134            target_type_label(&target),
135            chain_label(&target)
136        ),
137        out,
138    );
139
140    let mut output = String::new();
141    output.push_str("# Scope Insights\n\n");
142    output.push_str(&format!("**Target:** `{}`\n\n", args.target));
143    output.push_str(&format!(
144        "**Detected:** {} on {}\n\n",
145        target_type_label(&target),
146        chain_label(&target)
147    ));
148    output.push_str("---\n\n");
149
150    match &target {
151        InferredTarget::Address { chain } => {
152            output.push_str("## Observations\n\n");
153            let addr_args = AddressArgs {
154                address: args.target.clone(),
155                chain: chain.clone(),
156                format: Some(scope::config::OutputFormat::Markdown),
157                include_txs: false,
158                include_tokens: true,
159                limit: 10,
160                report: None,
161                dossier: false,
162            };
163            let client = clients.create_chain_client(chain)?;
164            let report = address::analyze_address(&addr_args, client.as_ref(), out).await?;
165
166            // Contract vs EOA (EVM chains support get_code)
167            let code_result = client.get_code(&args.target).await;
168            let is_contract = code_result
169                .as_ref()
170                .is_ok_and(|c| !c.is_empty() && c != "0x");
171            if code_result.is_ok() {
172                output.push_str(&format!(
173                    "- **Type:** {}\n",
174                    if is_contract {
175                        "Contract"
176                    } else {
177                        "Externally Owned Account (EOA)"
178                    }
179                ));
180            }
181
182            output.push_str(&format!(
183                "- **Native balance:** {} ({})\n",
184                report.balance.formatted,
185                scope::chains::native_symbol(chain)
186            ));
187            if let Some(ref usd) = report.balance.usd {
188                output.push_str(&format!("- **USD value:** ${:.2}\n", usd));
189            }
190            output.push_str(&format!(
191                "- **Transaction count:** {}\n",
192                report.transaction_count
193            ));
194            if let Some(ref tokens) = report.tokens
195                && !tokens.is_empty()
196            {
197                output.push_str(&format!(
198                    "- **Token holdings:** {} different tokens\n",
199                    tokens.len()
200                ));
201                output.push_str("\n### Token Balances\n\n");
202                for tb in tokens.iter().take(10) {
203                    output.push_str(&format!(
204                        "- {}: {} ({})\n",
205                        tb.symbol, tb.formatted_balance, tb.contract_address
206                    ));
207                }
208                if tokens.len() > 10 {
209                    output.push_str(&format!("\n*...and {} more*\n", tokens.len() - 10));
210                }
211            }
212
213            // Risk assessment (compliance engine)
214            let risk_assessment = scope::compliance::risk::RiskEngine::from_env()
215                .assess_address(&args.target, chain)
216                .await
217                .ok();
218
219            if let Some(ref risk) = risk_assessment {
220                output.push_str(&risk_line(risk));
221            }
222
223            // Meta analysis
224            let meta = meta_analysis_address(
225                is_contract,
226                report.balance.usd,
227                report.tokens.as_ref().map(|t| t.len()).unwrap_or(0),
228                risk_assessment.as_ref().map(|r| r.overall_score),
229                risk_assessment.as_ref().map(|r| &r.risk_level),
230            );
231            output.push_str("\n### Synthesis\n\n");
232            output.push_str(&format!("{}\n\n", meta.synthesis));
233            output.push_str(&format!("**Key takeaway:** {}\n\n", meta.key_takeaway));
234            if !meta.recommendations.is_empty() {
235                output.push_str("**Consider:**\n");
236                for rec in &meta.recommendations {
237                    output.push_str(&format!("- {}\n", rec));
238                }
239            }
240            output.push_str("\n---\n\n");
241            let full_report = if let Some(ref risk) = risk_assessment {
242                crate::cli::address_report::generate_dossier_report(&report, risk)
243            } else {
244                crate::cli::address_report::generate_address_report(&report)
245            };
246            output.push_str(&full_report);
247        }
248        InferredTarget::Transaction { chain } => {
249            output.push_str("## Observations\n\n");
250            let tx_report =
251                fetch_transaction_report(&args.target, chain, args.decode, args.trace, clients)
252                    .await?;
253
254            let tx_type = classify_tx_type(
255                &tx_report.transaction.input,
256                tx_report.transaction.to.as_deref(),
257            );
258            output.push_str(&format!("- **Type:** {}\n", tx_type));
259
260            output.push_str(&format!(
261                "- **Status:** {}\n",
262                if tx_report.transaction.status {
263                    "Success"
264                } else {
265                    "Failed"
266                }
267            ));
268            output.push_str(&format!("- **From:** `{}`\n", tx_report.transaction.from));
269            output.push_str(&format!(
270                "- **To:** `{}`\n",
271                tx_report
272                    .transaction
273                    .to
274                    .as_deref()
275                    .unwrap_or("Contract Creation")
276            ));
277
278            let (formatted_value, high_value) =
279                format_tx_value(&tx_report.transaction.value, chain);
280            output.push_str(&format!("- **Value:** {}\n", formatted_value));
281            if high_value {
282                output.push_str("- ⚠️ **High-value transfer**\n");
283            }
284
285            output.push_str(&format!("- **Fee:** {}\n", tx_report.gas.transaction_fee));
286
287            // Meta analysis
288            let meta = meta_analysis_tx(
289                tx_type,
290                tx_report.transaction.status,
291                high_value,
292                &tx_report.transaction.from,
293                tx_report.transaction.to.as_deref(),
294            );
295            output.push_str("\n### Synthesis\n\n");
296            output.push_str(&format!("{}\n\n", meta.synthesis));
297            output.push_str(&format!("**Key takeaway:** {}\n\n", meta.key_takeaway));
298            if !meta.recommendations.is_empty() {
299                output.push_str("**Consider:**\n");
300                for rec in &meta.recommendations {
301                    output.push_str(&format!("- {}\n", rec));
302                }
303            }
304            output.push_str("\n---\n\n");
305            output.push_str(&format_tx_markdown(&tx_report));
306        }
307        InferredTarget::Token { chain } => {
308            output.push_str("## Observations\n\n");
309            let analytics = fetch_analytics_for_input(
310                &args.target,
311                chain,
312                Period::Hour24,
313                10,
314                clients,
315                Some(&sp),
316                out,
317            )
318            .await?;
319
320            // Token risk summary (interpretive bullets)
321            let risk_summary = report::token_risk_summary(&analytics);
322            output.push_str(&format!(
323                "- **Risk:** {} {}/10 ({})\n",
324                risk_summary.emoji, risk_summary.score, risk_summary.level
325            ));
326            if !risk_summary.concerns.is_empty() {
327                for c in &risk_summary.concerns {
328                    output.push_str(&format!("- ⚠️ {}\n", c));
329                }
330            }
331            if !risk_summary.positives.is_empty() {
332                for p in &risk_summary.positives {
333                    output.push_str(&format!("- ✅ {}\n", p));
334                }
335            }
336
337            output.push_str(&format!(
338                "- **Token:** {} ({})\n",
339                analytics.token.symbol, analytics.token.name
340            ));
341            output.push_str(&format!(
342                "- **Address:** `{}`\n",
343                analytics.token.contract_address
344            ));
345            output.push_str(&format!(
346                "- **Price:** {}\n",
347                scope::display::format::format_price_usd(analytics.price_usd)
348            ));
349            output.push_str(&format!(
350                "- **Liquidity (24h):** {}\n",
351                scope::display::format_usd(analytics.liquidity_usd)
352            ));
353            output.push_str(&format!(
354                "- **Volume (24h):** {}\n",
355                scope::display::format_usd(analytics.volume_24h)
356            ));
357
358            // Top holder context
359            if let Some(top) = analytics.holders.first() {
360                output.push_str(&format!(
361                    "- **Top holder:** `{}` ({:.1}%)\n",
362                    top.address, top.percentage
363                ));
364                if top.percentage > 30.0 {
365                    output.push_str("  - ⚠️ High concentration risk\n");
366                }
367            }
368            output.push_str(&format!(
369                "- **Holders displayed:** {}\n",
370                analytics.holders.len()
371            ));
372
373            // Stablecoin: auto-include market/peg via venue registry
374            let mut peg_status: Option<PegStatus> = None;
375            if is_stablecoin_symbol(&analytics.token.symbol)
376                && let Ok(registry) = VenueRegistry::load()
377            {
378                // Try the default venue first, fall back to any available CEX
379                let venue_id = if registry.contains(scope::market::DEFAULT_VENUE) {
380                    scope::market::DEFAULT_VENUE
381                } else {
382                    registry
383                        .list()
384                        .first()
385                        .copied()
386                        .unwrap_or(scope::market::DEFAULT_VENUE)
387                };
388                let thresholds = args.health.resolve(config);
389                if let (Some(desc), Ok(venue)) = (
390                    registry.get(venue_id),
391                    MarketVenue::resolve(venue_id, &registry),
392                ) && let Ok(summary) = fetch_token_summary(&venue, &analytics, &thresholds).await
393                {
394                    peg_status = Some(summary.peg_status());
395                    output.push_str(&peg_line(&desc.name, &summary.pair, &summary));
396                }
397            }
398
399            // Meta analysis
400            let top_holder_pct = analytics.holders.first().map(|h| h.percentage);
401            let meta = meta_analysis_token(
402                &risk_summary,
403                is_stablecoin_symbol(&analytics.token.symbol),
404                peg_status,
405                top_holder_pct,
406                analytics.liquidity_usd,
407            );
408            output.push_str("\n### Synthesis\n\n");
409            output.push_str(&format!("{}\n\n", meta.synthesis));
410            output.push_str(&format!("**Key takeaway:** {}\n\n", meta.key_takeaway));
411            if !meta.recommendations.is_empty() {
412                output.push_str("**Consider:**\n");
413                for rec in &meta.recommendations {
414                    output.push_str(&format!("- {}\n", rec));
415                }
416            }
417            output.push_str("\n---\n\n");
418            output.push_str(&report::generate_report(&analytics));
419        }
420    }
421
422    sp.finish("Insights complete.");
423    outln!(out, "{}", output)?;
424    Ok(())
425}
426
427fn target_type_label(target: &InferredTarget) -> &'static str {
428    match target {
429        InferredTarget::Address { .. } => "Address",
430        InferredTarget::Transaction { .. } => "Transaction",
431        InferredTarget::Token { .. } => "Token",
432    }
433}
434
435fn chain_label(target: &InferredTarget) -> &str {
436    match target {
437        InferredTarget::Address { chain } => chain,
438        InferredTarget::Transaction { chain } => chain,
439        InferredTarget::Token { chain } => chain,
440    }
441}
442
443/// Classifies EVM transaction from input data selector.
444fn classify_tx_type(input: &str, to: Option<&str>) -> &'static str {
445    if to.is_none() {
446        return "Contract Creation";
447    }
448    let selector = input
449        .trim_start_matches("0x")
450        .chars()
451        .take(8)
452        .collect::<String>();
453    let sel = selector.to_lowercase();
454    match sel.as_str() {
455        "a9059cbb" => "ERC-20 Transfer",
456        "095ea7b3" => "ERC-20 Approve",
457        "23b872dd" => "ERC-20 Transfer From",
458        "38ed1739" | "5c11d795" | "4a25d94a" | "8803dbee" | "7ff36ab5" | "18cbafe5"
459        | "fb3bdb41" | "b6f9de95" => "DEX Swap",
460        "ac9650d8" | "5ae401dc" => "Multicall",
461        _ if input.is_empty() || input == "0x" => "Native Transfer",
462        _ => "Contract Call",
463    }
464}
465
466/// Formats raw value to human-readable (e.g. wei → ETH).
467fn format_tx_value(value_str: &str, chain: &str) -> (String, bool) {
468    let wei: u128 = if value_str.starts_with("0x") {
469        let hex_part = value_str.trim_start_matches("0x");
470        if hex_part.is_empty() {
471            0
472        } else {
473            u128::from_str_radix(hex_part, 16).unwrap_or(0)
474        }
475    } else {
476        value_str.parse().unwrap_or(0)
477    };
478    let decimals =
479        scope::chains::registry::chain(chain).map_or(18, |c| i32::from(c.native_decimals));
480    let divisor = 10_f64.powi(decimals);
481    let human = wei as f64 / divisor;
482    let symbol = native_symbol(chain);
483    let formatted = format!("≈ {:.6} {}", human, symbol);
484    // "High value" threshold: > 10 native units
485    let high_value = human > 10.0;
486    (formatted, high_value)
487}
488
489/// The words for one peg status, written once. The market line, the
490/// executive summary, the key takeaway and the recommendation all read it.
491struct PegWords {
492    /// Label on the market line.
493    label: &'static str,
494    /// Sentence for the executive summary, when there is something to say.
495    synthesis: Option<&'static str>,
496    /// Key takeaway that replaces the generic one, when the peg is a concern.
497    takeaway: Option<&'static str>,
498    /// Whether to recommend verifying the peg on other venues.
499    verify_elsewhere: bool,
500}
501
502fn peg_words(status: PegStatus) -> PegWords {
503    const DEVIATION_SYNTHESIS: &str =
504        "Stablecoin peg deviation detected — verify on multiple venues.";
505    const DEVIATION_TAKEAWAY: &str =
506        "Stablecoin deviating from peg — check additional venues before trading.";
507    match status {
508        PegStatus::OnPeg => PegWords {
509            label: "Peg healthy",
510            synthesis: Some("Stablecoin peg is healthy on observed venue."),
511            takeaway: None,
512            verify_elsewhere: false,
513        },
514        PegStatus::BookUnhealthy => PegWords {
515            label: "Peg on target, but market health checks fail",
516            synthesis: Some(
517                "Stablecoin peg is on target, but the market's health checks fail — verify on multiple venues.",
518            ),
519            takeaway: Some(
520                "Stablecoin market health checks fail — check additional venues before trading.",
521            ),
522            verify_elsewhere: true,
523        },
524        PegStatus::SlightDeviation => PegWords {
525            label: "Slight peg deviation",
526            synthesis: Some(DEVIATION_SYNTHESIS),
527            takeaway: Some(DEVIATION_TAKEAWAY),
528            verify_elsewhere: true,
529        },
530        PegStatus::Deviation => PegWords {
531            label: "Peg deviation",
532            synthesis: Some(DEVIATION_SYNTHESIS),
533            takeaway: Some(DEVIATION_TAKEAWAY),
534            verify_elsewhere: true,
535        },
536        PegStatus::NoPrice => PegWords {
537            label: "Peg unknown (no mid price: a side has no valid level)",
538            synthesis: None,
539            takeaway: None,
540            verify_elsewhere: true,
541        },
542    }
543}
544
545/// The peg line of the insights report. The verdict is the market
546/// summary's own ([`MarketSummary::peg_status`]); this only words it.
547fn peg_line(venue: &str, pair: &str, summary: &MarketSummary) -> String {
548    let label = peg_words(summary.peg_status()).label;
549    let deviation = summary
550        .mid_price
551        .and_then(|mid| summary.peg_deviation_bps(mid))
552        .map(|bps| format!(" (deviation: {bps:.1} bps)"))
553        .unwrap_or_default();
554    format!("- **Market ({venue} {pair}):** {label}{deviation}\n")
555}
556
557/// Meta-analysis: synthesizes observations into an executive summary, key takeaway, and recommendations.
558struct MetaAnalysis {
559    synthesis: String,
560    key_takeaway: String,
561    recommendations: Vec<String>,
562}
563
564/// The report's risk line. A partial score is not a verdict, so an
565/// incomplete assessment lists what had no data instead of a score.
566fn risk_line(risk: &scope::compliance::risk::RiskAssessment) -> String {
567    let emoji = risk.risk_level.emoji();
568    if risk.not_assessed.is_empty() {
569        return format!(
570            "\n- **Risk:** {} {:.1}/10 ({:?})\n",
571            emoji, risk.overall_score, risk.risk_level
572        );
573    }
574    let mut line = format!(
575        "\n- **Risk:** {} not assessed in full. No data for:\n",
576        emoji
577    );
578    for reason in &risk.not_assessed {
579        line.push_str(&format!("  - {}\n", reason));
580    }
581    line
582}
583
584fn meta_analysis_address(
585    is_contract: bool,
586    usd_value: Option<f64>,
587    token_count: usize,
588    risk_score: Option<f32>,
589    risk_level: Option<&scope::compliance::risk::RiskLevel>,
590) -> MetaAnalysis {
591    let mut synthesis_parts = Vec::new();
592    let profile = if is_contract {
593        "contract"
594    } else {
595        "wallet (EOA)"
596    };
597    synthesis_parts.push(format!("A {} on chain.", profile));
598
599    if let Some(usd) = usd_value {
600        if usd > 1_000_000.0 {
601            synthesis_parts.push("Significant value held.".to_string());
602        } else if usd > 10_000.0 {
603            synthesis_parts.push("Moderate value.".to_string());
604        } else if usd < 1.0 {
605            synthesis_parts.push("Minimal value.".to_string());
606        }
607    }
608
609    if token_count > 5 {
610        synthesis_parts.push("Diversified token exposure.".to_string());
611    } else if token_count == 1 && token_count > 0 {
612        synthesis_parts.push("Concentrated in a single token.".to_string());
613    }
614
615    // An incomplete assessment gives no score to read.
616    let (risk_score, risk_level) = match risk_level {
617        Some(scope::compliance::risk::RiskLevel::Incomplete) => (None, None),
618        _ => (risk_score, risk_level),
619    };
620    if let (Some(score), Some(level)) = (risk_score, risk_level) {
621        if score >= 7.0 {
622            synthesis_parts.push(format!("Elevated risk ({:?}).", level));
623        } else if score <= 3.0 {
624            synthesis_parts.push("Low risk profile.".to_string());
625        }
626    }
627
628    let synthesis = if synthesis_parts.is_empty() {
629        "Address analyzed with available on-chain data.".to_string()
630    } else {
631        synthesis_parts.join(" ")
632    };
633
634    let key_takeaway = if let (Some(score), Some(level)) = (risk_score, risk_level) {
635        if score >= 7.0 {
636            format!(
637                "Risk assessment warrants closer scrutiny ({:.1}/10).",
638                score
639            )
640        } else {
641            format!("Overall risk: {:?} ({:.1}/10).", level, score)
642        }
643    } else if is_contract {
644        "Contract address — verify intended interaction before use.".to_string()
645    } else if usd_value.map(|u| u > 100_000.0).unwrap_or(false) {
646        "High-value wallet — standard due diligence applies.".to_string()
647    } else {
648        "Review full report for transaction and token details.".to_string()
649    };
650
651    let mut recommendations = Vec::new();
652    if risk_score.map(|s| s >= 6.0).unwrap_or(false) {
653        recommendations.push("Monitor for unusual transaction patterns.".to_string());
654    }
655    if token_count > 0 {
656        recommendations.push("Verify token contracts before large interactions.".to_string());
657    }
658    if is_contract {
659        recommendations.push("Confirm contract source and audit status.".to_string());
660    }
661
662    MetaAnalysis {
663        synthesis,
664        key_takeaway,
665        recommendations,
666    }
667}
668
669fn meta_analysis_tx(
670    tx_type: &str,
671    status: bool,
672    high_value: bool,
673    _from: &str,
674    _to: Option<&str>,
675) -> MetaAnalysis {
676    let mut synthesis_parts = Vec::new();
677
678    if !status {
679        synthesis_parts.push("Transaction failed.".to_string());
680    }
681
682    synthesis_parts.push(format!("{} between parties.", tx_type));
683
684    if high_value {
685        synthesis_parts.push("High-value transfer.".to_string());
686    }
687
688    let synthesis = synthesis_parts.join(" ");
689
690    let key_takeaway = if !status {
691        "Failed transaction — check revert reason and contract state.".to_string()
692    } else if high_value && tx_type == "Native Transfer" {
693        "Large native transfer — verify recipient and intent.".to_string()
694    } else if high_value {
695        "High-value operation — standard verification recommended.".to_string()
696    } else {
697        format!("Routine {} — review full details if needed.", tx_type)
698    };
699
700    let mut recommendations = Vec::new();
701    if !status {
702        recommendations.push("Inspect contract logs for revert reason.".to_string());
703    }
704    if high_value {
705        recommendations.push("Confirm recipient address and amount.".to_string());
706    }
707    if tx_type.contains("Approval") {
708        recommendations.push("Verify approved spender and allowance amount.".to_string());
709    }
710
711    MetaAnalysis {
712        synthesis,
713        key_takeaway,
714        recommendations,
715    }
716}
717
718fn meta_analysis_token(
719    risk_summary: &report::TokenRiskSummary,
720    is_stablecoin: bool,
721    peg: Option<PegStatus>,
722    top_holder_pct: Option<f64>,
723    liquidity_usd: f64,
724) -> MetaAnalysis {
725    let mut synthesis_parts = Vec::new();
726
727    if risk_summary.score <= 3 {
728        synthesis_parts.push("Low-risk token with healthy metrics.".to_string());
729    } else if risk_summary.score >= 7 {
730        synthesis_parts.push("Elevated risk — multiple concerns identified.".to_string());
731    } else {
732        synthesis_parts.push("Moderate risk — mixed signals.".to_string());
733    }
734
735    let peg_words = peg.filter(|_| is_stablecoin).map(peg_words);
736    if let Some(sentence) = peg_words.as_ref().and_then(|w| w.synthesis) {
737        synthesis_parts.push(sentence.to_string());
738    }
739
740    if top_holder_pct.map(|p| p > 30.0).unwrap_or(false) {
741        synthesis_parts.push("Concentration risk: top holder holds significant share.".to_string());
742    }
743
744    // The liquidity classes of the report and compliance (token_risk).
745    use scope::compliance::token_risk::{LiquidityTier, liquidity_tier};
746    let tier = liquidity_tier(liquidity_usd);
747    match tier {
748        LiquidityTier::Good => synthesis_parts.push("Strong liquidity depth.".to_string()),
749        LiquidityTier::VeryLow | LiquidityTier::Low => {
750            synthesis_parts.push("Limited liquidity — slippage risk for larger trades.".to_string())
751        }
752        LiquidityTier::Moderate => {}
753    }
754
755    let synthesis = synthesis_parts.join(" ");
756
757    let key_takeaway = if risk_summary.score >= 7 {
758        format!(
759            "High risk ({}): {} — exercise caution.",
760            risk_summary.score,
761            risk_summary
762                .concerns
763                .first()
764                .cloned()
765                .unwrap_or_else(|| "multiple factors".to_string())
766        )
767    } else if let Some(takeaway) = peg_words.as_ref().and_then(|w| w.takeaway) {
768        takeaway.to_string()
769    } else if !risk_summary.positives.is_empty() && risk_summary.concerns.is_empty() {
770        "Favorable risk profile — standard diligence applies.".to_string()
771    } else {
772        format!(
773            "Risk {}/10 ({}) — weigh concerns against use case.",
774            risk_summary.score, risk_summary.level
775        )
776    };
777
778    let mut recommendations = Vec::new();
779    if risk_summary.score >= 6 {
780        recommendations
781            .push("Consider smaller position sizes or avoid until risk clears.".to_string());
782    }
783    if top_holder_pct.map(|p| p > 25.0).unwrap_or(false) {
784        recommendations.push("Monitor top holder movements for distribution changes.".to_string());
785    }
786    if is_stablecoin && peg_words.as_ref().is_none_or(|w| w.verify_elsewhere) {
787        recommendations.push("Verify peg across multiple DEX/CEX venues.".to_string());
788    }
789    if matches!(tier, LiquidityTier::VeryLow | LiquidityTier::Low) && risk_summary.score <= 5 {
790        recommendations.push("Use limit orders or split trades to manage slippage.".to_string());
791    }
792
793    MetaAnalysis {
794        synthesis,
795        key_takeaway,
796        recommendations,
797    }
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803    use scope::market::{BookSource, HealthThresholds, OrderBook, OrderBookLevel};
804
805    fn summary_with_peg(peg_target: f64, mid_price: Option<f64>) -> MarketSummary {
806        let level = |price| OrderBookLevel {
807            price,
808            quantity: 1000.0,
809        };
810        let book = OrderBook {
811            pair: "X/USDT".to_string(),
812            source: BookSource::Exchange,
813            bids: vec![level(peg_target)],
814            asks: vec![level(peg_target)],
815        };
816        let thresholds = HealthThresholds {
817            peg_target,
818            ..HealthThresholds::default()
819        };
820        let mut summary = MarketSummary::from_order_book(&book, &thresholds, None);
821        summary.mid_price = mid_price;
822        // A one-level book fails the level checks; these tests judge the peg.
823        summary.healthy = true;
824        summary
825    }
826
827    #[test]
828    fn test_peg_line_uses_the_summary_peg_target_not_one() {
829        // 6 bps above a 0.5 peg is healthy; measured against 1.0 it would be 5000 bps.
830        let on_peg = summary_with_peg(0.5, Some(0.5003));
831        assert_eq!(on_peg.peg_status(), PegStatus::OnPeg);
832        let line = peg_line("v", "X/USDT", &on_peg);
833        assert!(
834            line.contains("Peg healthy") && line.contains("6.0 bps"),
835            "{line}"
836        );
837        assert_eq!(
838            summary_with_peg(0.5, Some(0.5020)).peg_status(),
839            PegStatus::SlightDeviation
840        );
841        assert_eq!(
842            summary_with_peg(0.5, Some(0.52)).peg_status(),
843            PegStatus::Deviation
844        );
845    }
846
847    #[test]
848    fn test_peg_line_never_says_healthy_without_a_mid() {
849        let summary = summary_with_peg(1.0, None);
850        assert_eq!(summary.peg_status(), PegStatus::NoPrice);
851        assert!(!peg_line("v", "X/USDT", &summary).contains("Peg healthy"));
852    }
853
854    #[test]
855    fn test_peg_line_never_says_healthy_when_the_market_summary_is_unhealthy() {
856        let mut summary = summary_with_peg(1.0, Some(1.0));
857        summary.healthy = false;
858        assert_eq!(summary.peg_status(), PegStatus::BookUnhealthy);
859        assert!(!peg_line("v", "X/USDT", &summary).contains("Peg healthy"));
860    }
861
862    #[test]
863    fn test_book_unhealthy_is_worded_the_same_on_every_line() {
864        let mut summary = summary_with_peg(1.0, Some(1.0));
865        summary.healthy = false;
866        let status = summary.peg_status();
867        let line = peg_line("v", "X/USDT", &summary);
868        assert!(line.contains("market health checks fail"), "{line}");
869        let risk = report::TokenRiskSummary {
870            score: 3,
871            level: "Low",
872            emoji: "🟢",
873            concerns: vec![],
874            positives: vec![],
875        };
876        let meta = meta_analysis_token(&risk, true, Some(status), None, 5_000_000.0);
877        assert!(
878            meta.synthesis.contains("health checks fail"),
879            "{}",
880            meta.synthesis
881        );
882        assert!(!meta.synthesis.contains("deviation detected"));
883        assert!(meta.key_takeaway.contains("health checks fail"));
884        assert!(meta.recommendations.iter().any(|r| r.contains("peg")));
885    }
886
887    #[test]
888    fn test_peg_bands_follow_the_configured_peg_range() {
889        // With a range of 0.01, a mid 0.4% off the peg is still on the peg.
890        let mut summary = summary_with_peg(1.0, Some(1.004));
891        summary.peg_range = 0.01;
892        assert_eq!(summary.peg_status(), PegStatus::OnPeg);
893        summary.peg_range = 0.001;
894        assert_eq!(summary.peg_status(), PegStatus::SlightDeviation);
895    }
896
897    use async_trait::async_trait;
898    use scope::chains::{
899        Balance as ChainBalance, ChainClient, ChainClientFactory, DexDataSource,
900        Token as ChainToken, TokenBalance as ChainTokenBalance, Transaction as ChainTransaction,
901    };
902
903    fn quiet() -> crate::cli::output::Output {
904        crate::cli::output::Output::capture().0
905    }
906
907    // ====================================================================
908    // Mock Chain Client for testing run() paths
909    // ====================================================================
910
911    struct MockChainClient;
912
913    #[async_trait]
914    impl ChainClient for MockChainClient {
915        fn chain_name(&self) -> &str {
916            "ethereum"
917        }
918        fn native_token_symbol(&self) -> &str {
919            "ETH"
920        }
921        async fn get_balance(&self, _address: &str) -> scope::error::Result<ChainBalance> {
922            Ok(ChainBalance {
923                raw: "1000000000000000000".to_string(),
924                formatted: "1.0 ETH".to_string(),
925                decimals: 18,
926                symbol: "ETH".to_string(),
927                usd_value: Some(2500.0),
928            })
929        }
930        async fn enrich_balance_usd(&self, balance: &mut ChainBalance) {
931            balance.usd_value = Some(2500.0);
932        }
933        async fn get_transaction(&self, _hash: &str) -> scope::error::Result<ChainTransaction> {
934            Ok(ChainTransaction {
935                hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd"
936                    .to_string(),
937                block_number: Some(12345678),
938                timestamp: Some(1700000000),
939                from: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
940                to: Some("0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string()),
941                value: "1000000000000000000".to_string(),
942                gas_limit: 21000,
943                gas_used: Some(21000),
944                gas_price: "20000000000".to_string(),
945                nonce: 42,
946                input: "0xa9059cbb0000000000000000000000001234".to_string(),
947                status: Some(true),
948            })
949        }
950        async fn get_transactions(
951            &self,
952            _address: &str,
953            _limit: u32,
954        ) -> scope::error::Result<Vec<ChainTransaction>> {
955            Ok(vec![])
956        }
957        async fn get_block_number(&self) -> scope::error::Result<u64> {
958            Ok(12345678)
959        }
960        async fn get_token_balances(
961            &self,
962            _address: &str,
963        ) -> scope::error::Result<Vec<ChainTokenBalance>> {
964            Ok(vec![
965                ChainTokenBalance {
966                    token: ChainToken {
967                        contract_address: "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string(),
968                        symbol: "USDT".to_string(),
969                        name: "Tether USD".to_string(),
970                        decimals: 6,
971                    },
972                    balance: "1000000".to_string(),
973                    formatted_balance: "1.0".to_string(),
974                    usd_value: Some(1.0),
975                },
976                ChainTokenBalance {
977                    token: ChainToken {
978                        contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
979                        symbol: "USDC".to_string(),
980                        name: "USD Coin".to_string(),
981                        decimals: 6,
982                    },
983                    balance: "5000000".to_string(),
984                    formatted_balance: "5.0".to_string(),
985                    usd_value: Some(5.0),
986                },
987            ])
988        }
989        async fn get_code(&self, _address: &str) -> scope::error::Result<String> {
990            Ok("0x".to_string()) // EOA
991        }
992    }
993
994    struct MockFactory;
995
996    impl ChainClientFactory for MockFactory {
997        fn create_chain_client(&self, _chain: &str) -> scope::error::Result<Box<dyn ChainClient>> {
998            Ok(Box::new(MockChainClient))
999        }
1000        fn create_dex_client(&self) -> Box<dyn DexDataSource> {
1001            let http: std::sync::Arc<dyn scope::http::HttpClient> =
1002                std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1003            scope::chains::DefaultClientFactory {
1004                chains_config: Default::default(),
1005                http,
1006            }
1007            .create_dex_client()
1008        }
1009    }
1010
1011    // Mock that returns a contract address
1012    struct MockContractClient;
1013
1014    #[async_trait]
1015    impl ChainClient for MockContractClient {
1016        fn chain_name(&self) -> &str {
1017            "ethereum"
1018        }
1019        fn native_token_symbol(&self) -> &str {
1020            "ETH"
1021        }
1022        async fn get_balance(&self, _address: &str) -> scope::error::Result<ChainBalance> {
1023            Ok(ChainBalance {
1024                raw: "0".to_string(),
1025                formatted: "0.0 ETH".to_string(),
1026                decimals: 18,
1027                symbol: "ETH".to_string(),
1028                usd_value: Some(0.0),
1029            })
1030        }
1031        async fn enrich_balance_usd(&self, _balance: &mut ChainBalance) {}
1032        async fn get_transaction(&self, hash: &str) -> scope::error::Result<ChainTransaction> {
1033            Ok(ChainTransaction {
1034                hash: hash.to_string(),
1035                block_number: Some(100),
1036                timestamp: Some(1700000000),
1037                from: "0xfrom".to_string(),
1038                to: None, // contract creation
1039                value: "0".to_string(),
1040                gas_limit: 100000,
1041                gas_used: Some(80000),
1042                gas_price: "10000000000".to_string(),
1043                nonce: 0,
1044                input: "0x60806040".to_string(),
1045                status: Some(false), // failed tx
1046            })
1047        }
1048        async fn get_transactions(
1049            &self,
1050            _address: &str,
1051            _limit: u32,
1052        ) -> scope::error::Result<Vec<ChainTransaction>> {
1053            Ok(vec![])
1054        }
1055        async fn get_block_number(&self) -> scope::error::Result<u64> {
1056            Ok(100)
1057        }
1058        async fn get_token_balances(
1059            &self,
1060            _address: &str,
1061        ) -> scope::error::Result<Vec<ChainTokenBalance>> {
1062            Ok(vec![])
1063        }
1064        async fn get_code(&self, _address: &str) -> scope::error::Result<String> {
1065            Ok("0x6080604052".to_string()) // contract
1066        }
1067    }
1068
1069    struct MockContractFactory;
1070
1071    impl ChainClientFactory for MockContractFactory {
1072        fn create_chain_client(&self, _chain: &str) -> scope::error::Result<Box<dyn ChainClient>> {
1073            Ok(Box::new(MockContractClient))
1074        }
1075        fn create_dex_client(&self) -> Box<dyn DexDataSource> {
1076            let http: std::sync::Arc<dyn scope::http::HttpClient> =
1077                std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1078            scope::chains::DefaultClientFactory {
1079                chains_config: Default::default(),
1080                http,
1081            }
1082            .create_dex_client()
1083        }
1084    }
1085
1086    // Mock DexDataSource for token tests
1087    struct MockDexDataSource;
1088
1089    #[async_trait]
1090    impl DexDataSource for MockDexDataSource {
1091        async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
1092            Some(1.0)
1093        }
1094
1095        async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
1096            Some(2500.0)
1097        }
1098
1099        async fn get_token_data(
1100            &self,
1101            _chain: &str,
1102            address: &str,
1103        ) -> scope::error::Result<scope::chains::dex::DexTokenData> {
1104            use scope::chains::{DexPair, PricePoint, VolumePoint};
1105            Ok(scope::chains::dex::DexTokenData {
1106                address: address.to_string(),
1107                symbol: "TEST".to_string(),
1108                name: "Test Token".to_string(),
1109                price_usd: 1.5,
1110                price_change_24h: 5.2,
1111                price_change_6h: 2.1,
1112                price_change_1h: 0.5,
1113                price_change_5m: 0.1,
1114                volume_24h: 1_000_000.0,
1115                volume_6h: 250_000.0,
1116                volume_1h: 50_000.0,
1117                liquidity_usd: 500_000.0,
1118                market_cap: Some(10_000_000.0),
1119                fdv: Some(12_000_000.0),
1120                pairs: vec![DexPair {
1121                    dex_name: "Uniswap V3".to_string(),
1122                    pair_address: "0xpair123".to_string(),
1123                    base_token: "TEST".to_string(),
1124                    quote_token: "USDC".to_string(),
1125                    price_usd: 1.5,
1126                    liquidity_usd: 500_000.0,
1127                    liquidity_base: None,
1128                    volume_24h: 1_000_000.0,
1129                    price_change_24h: 5.2,
1130                    buys_24h: 100,
1131                    sells_24h: 80,
1132                    buys_6h: 20,
1133                    sells_6h: 15,
1134                    buys_1h: 5,
1135                    sells_1h: 3,
1136                    pair_created_at: Some(1690000000),
1137                    url: Some("https://dexscreener.com/ethereum/0xpair123".to_string()),
1138                }],
1139                price_history: vec![PricePoint {
1140                    timestamp: 1690000000,
1141                    price: 1.5,
1142                }],
1143                volume_history: vec![VolumePoint {
1144                    timestamp: 1690000000,
1145                    volume: 1_000_000.0,
1146                }],
1147                total_buys_24h: 100,
1148                total_sells_24h: 80,
1149                total_buys_6h: 20,
1150                total_sells_6h: 15,
1151                total_buys_1h: 5,
1152                total_sells_1h: 3,
1153                earliest_pair_created_at: Some(1690000000),
1154                image_url: None,
1155                websites: Vec::new(),
1156                socials: Vec::new(),
1157                dexscreener_url: Some("https://dexscreener.com/ethereum/test".to_string()),
1158            })
1159        }
1160
1161        async fn search_tokens(
1162            &self,
1163            _query: &str,
1164            _chain: Option<&str>,
1165        ) -> scope::error::Result<Vec<scope::chains::TokenSearchResult>> {
1166            Ok(vec![scope::chains::TokenSearchResult {
1167                address: "0xTEST1234567890123456789012345678901234567".to_string(),
1168                symbol: "TEST".to_string(),
1169                name: "Test Token".to_string(),
1170                chain: "ethereum".to_string(),
1171                price_usd: Some(1.5),
1172                volume_24h: 1_000_000.0,
1173                liquidity_usd: 500_000.0,
1174                market_cap: Some(10_000_000.0),
1175            }])
1176        }
1177    }
1178
1179    // Mock ChainClient that returns holders with high concentration
1180    struct MockTokenChainClient;
1181
1182    #[async_trait]
1183    impl ChainClient for MockTokenChainClient {
1184        fn chain_name(&self) -> &str {
1185            "ethereum"
1186        }
1187        fn native_token_symbol(&self) -> &str {
1188            "ETH"
1189        }
1190        async fn get_balance(&self, _address: &str) -> scope::error::Result<ChainBalance> {
1191            Ok(ChainBalance {
1192                raw: "1000000000000000000".to_string(),
1193                formatted: "1.0 ETH".to_string(),
1194                decimals: 18,
1195                symbol: "ETH".to_string(),
1196                usd_value: Some(2500.0),
1197            })
1198        }
1199        async fn enrich_balance_usd(&self, balance: &mut ChainBalance) {
1200            balance.usd_value = Some(2500.0);
1201        }
1202        async fn get_transaction(&self, _hash: &str) -> scope::error::Result<ChainTransaction> {
1203            Ok(ChainTransaction {
1204                hash: "0xabc123".to_string(),
1205                block_number: Some(12345678),
1206                timestamp: Some(1700000000),
1207                from: "0xfrom".to_string(),
1208                to: Some("0xto".to_string()),
1209                value: "0".to_string(),
1210                gas_limit: 21000,
1211                gas_used: Some(21000),
1212                gas_price: "20000000000".to_string(),
1213                nonce: 42,
1214                input: "0x".to_string(),
1215                status: Some(true),
1216            })
1217        }
1218        async fn get_transactions(
1219            &self,
1220            _address: &str,
1221            _limit: u32,
1222        ) -> scope::error::Result<Vec<ChainTransaction>> {
1223            Ok(vec![])
1224        }
1225        async fn get_block_number(&self) -> scope::error::Result<u64> {
1226            Ok(12345678)
1227        }
1228        async fn get_token_balances(
1229            &self,
1230            _address: &str,
1231        ) -> scope::error::Result<Vec<ChainTokenBalance>> {
1232            Ok(vec![])
1233        }
1234        async fn get_code(&self, _address: &str) -> scope::error::Result<String> {
1235            Ok("0x".to_string())
1236        }
1237        async fn get_token_holders(
1238            &self,
1239            _address: &str,
1240            _limit: u32,
1241        ) -> scope::error::Result<Vec<scope::chains::TokenHolder>> {
1242            // Return holders with high concentration (>30%) to trigger warning
1243            Ok(vec![
1244                scope::chains::TokenHolder {
1245                    address: "0x1111111111111111111111111111111111111111".to_string(),
1246                    balance: "3500000000000000000000000".to_string(),
1247                    formatted_balance: "3500000.0".to_string(),
1248                    percentage: 35.0, // >30% triggers concentration warning
1249                    rank: 1,
1250                },
1251                scope::chains::TokenHolder {
1252                    address: "0x2222222222222222222222222222222222222222".to_string(),
1253                    balance: "1500000000000000000000000".to_string(),
1254                    formatted_balance: "1500000.0".to_string(),
1255                    percentage: 15.0,
1256                    rank: 2,
1257                },
1258                scope::chains::TokenHolder {
1259                    address: "0x3333333333333333333333333333333333333333".to_string(),
1260                    balance: "1000000000000000000000000".to_string(),
1261                    formatted_balance: "1000000.0".to_string(),
1262                    percentage: 10.0,
1263                    rank: 3,
1264                },
1265            ])
1266        }
1267    }
1268
1269    // Factory for token tests with mocks
1270    struct MockTokenFactory;
1271
1272    impl ChainClientFactory for MockTokenFactory {
1273        fn create_chain_client(&self, _chain: &str) -> scope::error::Result<Box<dyn ChainClient>> {
1274            Ok(Box::new(MockTokenChainClient))
1275        }
1276        fn create_dex_client(&self) -> Box<dyn DexDataSource> {
1277            Box::new(MockDexDataSource)
1278        }
1279    }
1280
1281    // ====================================================================
1282    // run() function tests with mocks
1283    // ====================================================================
1284
1285    #[tokio::test]
1286    async fn test_run_address_eoa() {
1287        let config = Config::default();
1288        let factory = MockFactory;
1289        let args = InsightsArgs {
1290            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1291            chain: None,
1292            decode: false,
1293            trace: false,
1294            health: Default::default(),
1295        };
1296        let result = run(args, &config, &factory, &quiet()).await;
1297        assert!(result.is_ok());
1298    }
1299
1300    #[tokio::test]
1301    async fn test_run_address_contract() {
1302        let config = Config::default();
1303        let factory = MockContractFactory;
1304        let args = InsightsArgs {
1305            target: "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string(),
1306            chain: None,
1307            decode: false,
1308            trace: false,
1309            health: Default::default(),
1310        };
1311        let result = run(args, &config, &factory, &quiet()).await;
1312        assert!(result.is_ok());
1313    }
1314
1315    #[tokio::test]
1316    async fn test_run_transaction() {
1317        let config = Config::default();
1318        let factory = MockFactory;
1319        let args = InsightsArgs {
1320            target: "0xabc123def456789012345678901234567890123456789012345678901234abcd"
1321                .to_string(),
1322            chain: None,
1323            decode: false,
1324            trace: false,
1325            health: Default::default(),
1326        };
1327        let result = run(args, &config, &factory, &quiet()).await;
1328        assert!(result.is_ok());
1329    }
1330
1331    #[tokio::test]
1332    async fn test_run_transaction_failed() {
1333        let config = Config::default();
1334        let factory = MockContractFactory;
1335        let args = InsightsArgs {
1336            target: "0xabc123def456789012345678901234567890123456789012345678901234abcd"
1337                .to_string(),
1338            chain: Some("ethereum".to_string()),
1339            decode: true,
1340            trace: false,
1341            health: Default::default(),
1342        };
1343        let result = run(args, &config, &factory, &quiet()).await;
1344        assert!(result.is_ok());
1345    }
1346
1347    #[tokio::test]
1348    async fn test_run_address_with_chain_override() {
1349        let config = Config::default();
1350        let factory = MockFactory;
1351        let args = InsightsArgs {
1352            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1353            chain: Some("polygon".to_string()),
1354            decode: false,
1355            trace: false,
1356            health: Default::default(),
1357        };
1358        let result = run(args, &config, &factory, &quiet()).await;
1359        assert!(result.is_ok());
1360    }
1361
1362    #[tokio::test]
1363    async fn test_insights_run_token() {
1364        let config = Config::default();
1365        let factory = MockTokenFactory;
1366        let args = InsightsArgs {
1367            target: "TEST".to_string(),
1368            chain: Some("ethereum".to_string()),
1369            decode: false,
1370            trace: false,
1371            health: Default::default(),
1372        };
1373        let (out, cap) = crate::cli::output::Output::capture();
1374        run(args, &config, &factory, &out).await.unwrap();
1375        // format_usd adds the "$" itself; a "$" in the format string printed "$$500.00K".
1376        let text = cap.out();
1377        assert!(text.contains("**Liquidity (24h):** $500.00K"), "{}", text);
1378        assert!(text.contains("**Volume (24h):** $1.00M"), "{}", text);
1379        assert!(!text.contains("$$"), "{}", text);
1380    }
1381
1382    #[tokio::test]
1383    async fn test_insights_run_token_with_concentration_warning() {
1384        let config = Config::default();
1385        let factory = MockTokenFactory;
1386        let args = InsightsArgs {
1387            target: "0xTEST1234567890123456789012345678901234567".to_string(),
1388            chain: Some("ethereum".to_string()),
1389            decode: false,
1390            trace: false,
1391            health: Default::default(),
1392        };
1393        let result = run(args, &config, &factory, &quiet()).await;
1394        assert!(result.is_ok());
1395    }
1396
1397    // ====================================================================
1398    // Existing tests below
1399    // ====================================================================
1400
1401    #[test]
1402    fn test_infer_target_evm_address() {
1403        let t = infer_target("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", None);
1404        assert!(matches!(t, InferredTarget::Address { chain } if chain == "ethereum"));
1405    }
1406
1407    #[test]
1408    fn test_infer_target_tron_address() {
1409        let t = infer_target("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", None);
1410        assert!(matches!(t, InferredTarget::Address { chain } if chain == "tron"));
1411    }
1412
1413    #[test]
1414    fn test_infer_target_solana_address() {
1415        let t = infer_target("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy", None);
1416        assert!(matches!(t, InferredTarget::Address { chain } if chain == "solana"));
1417    }
1418
1419    #[test]
1420    fn test_infer_target_evm_tx_hash() {
1421        let t = infer_target(
1422            "0xabc123def456789012345678901234567890123456789012345678901234abcd",
1423            None,
1424        );
1425        assert!(matches!(t, InferredTarget::Transaction { chain } if chain == "ethereum"));
1426    }
1427
1428    #[test]
1429    fn test_infer_target_tron_tx_hash() {
1430        let t = infer_target(
1431            "abc123def456789012345678901234567890123456789012345678901234abcd",
1432            None,
1433        );
1434        assert!(matches!(t, InferredTarget::Transaction { chain } if chain == "tron"));
1435    }
1436
1437    #[test]
1438    fn test_infer_target_token_symbol() {
1439        let t = infer_target("USDC", None);
1440        assert!(matches!(t, InferredTarget::Token { chain } if chain == "ethereum"));
1441    }
1442
1443    #[test]
1444    fn test_infer_target_chain_override() {
1445        let t = infer_target(
1446            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1447            Some("polygon"),
1448        );
1449        assert!(matches!(t, InferredTarget::Address { chain } if chain == "polygon"));
1450    }
1451
1452    #[test]
1453    fn test_infer_target_token_with_chain_override() {
1454        let t = infer_target("USDC", Some("solana"));
1455        assert!(matches!(t, InferredTarget::Token { chain } if chain == "solana"));
1456    }
1457
1458    #[test]
1459    fn test_classify_tx_type() {
1460        assert_eq!(
1461            classify_tx_type("0xa9059cbb1234...", Some("0xto")),
1462            "ERC-20 Transfer"
1463        );
1464        assert_eq!(
1465            classify_tx_type("0x095ea7b3abcd...", Some("0xto")),
1466            "ERC-20 Approve"
1467        );
1468        assert_eq!(classify_tx_type("0x", Some("0xto")), "Native Transfer");
1469        assert_eq!(classify_tx_type("", None), "Contract Creation");
1470    }
1471
1472    #[test]
1473    fn test_format_tx_value() {
1474        let (fmt, high) = format_tx_value("0xDE0B6B3A7640000", "ethereum"); // 1 ETH
1475        assert!(fmt.contains("1.0") && fmt.contains("ETH"));
1476        assert!(!high);
1477        let (_, high2) = format_tx_value("0x52B7D2DCC80CD2E4000000", "ethereum"); // 100 ETH
1478        assert!(high2);
1479    }
1480
1481    #[test]
1482    fn test_is_stablecoin() {
1483        assert!(is_stablecoin_symbol("USDC"));
1484        assert!(is_stablecoin_symbol("usdt"));
1485        assert!(is_stablecoin_symbol("DAI"));
1486        assert!(is_stablecoin_symbol("BUSD"));
1487        assert!(is_stablecoin_symbol("TUSD"));
1488        assert!(is_stablecoin_symbol("USDP"));
1489        assert!(is_stablecoin_symbol("FRAX"));
1490        assert!(is_stablecoin_symbol("LUSD"));
1491        assert!(is_stablecoin_symbol("GUSD"));
1492        assert!(!is_stablecoin_symbol("ETH"));
1493        assert!(!is_stablecoin_symbol("PEPE"));
1494        assert!(!is_stablecoin_symbol("WBTC"));
1495    }
1496
1497    #[test]
1498    fn test_is_stablecoin_empty_string() {
1499        assert!(!is_stablecoin_symbol(""));
1500    }
1501
1502    #[test]
1503    fn test_is_stablecoin_case_insensitive() {
1504        // to_uppercase() makes comparison case-insensitive
1505        assert!(is_stablecoin_symbol("UsDc"));
1506        assert!(is_stablecoin_symbol("FraX"));
1507        assert!(!is_stablecoin_symbol("SOL")); // SOL is not a stablecoin
1508    }
1509
1510    // ====================================================================
1511    // target_type_label and chain_label tests
1512    // ====================================================================
1513
1514    #[test]
1515    fn test_target_type_label_address() {
1516        let t = InferredTarget::Address {
1517            chain: "ethereum".to_string(),
1518        };
1519        assert_eq!(target_type_label(&t), "Address");
1520    }
1521
1522    #[test]
1523    fn test_target_type_label_transaction() {
1524        let t = InferredTarget::Transaction {
1525            chain: "ethereum".to_string(),
1526        };
1527        assert_eq!(target_type_label(&t), "Transaction");
1528    }
1529
1530    #[test]
1531    fn test_target_type_label_token() {
1532        let t = InferredTarget::Token {
1533            chain: "ethereum".to_string(),
1534        };
1535        assert_eq!(target_type_label(&t), "Token");
1536    }
1537
1538    #[test]
1539    fn test_chain_label_address() {
1540        let t = InferredTarget::Address {
1541            chain: "polygon".to_string(),
1542        };
1543        assert_eq!(chain_label(&t), "polygon");
1544    }
1545
1546    #[test]
1547    fn test_chain_label_transaction() {
1548        let t = InferredTarget::Transaction {
1549            chain: "tron".to_string(),
1550        };
1551        assert_eq!(chain_label(&t), "tron");
1552    }
1553
1554    #[test]
1555    fn test_chain_label_token() {
1556        let t = InferredTarget::Token {
1557            chain: "solana".to_string(),
1558        };
1559        assert_eq!(chain_label(&t), "solana");
1560    }
1561
1562    // ====================================================================
1563    // classify_tx_type — expanded edge cases
1564    // ====================================================================
1565
1566    #[test]
1567    fn test_classify_tx_type_dex_swaps() {
1568        assert_eq!(
1569            classify_tx_type("0x38ed173900000...", Some("0xrouter")),
1570            "DEX Swap"
1571        );
1572        assert_eq!(
1573            classify_tx_type("0x5c11d79500000...", Some("0xrouter")),
1574            "DEX Swap"
1575        );
1576        assert_eq!(
1577            classify_tx_type("0x4a25d94a00000...", Some("0xrouter")),
1578            "DEX Swap"
1579        );
1580        assert_eq!(
1581            classify_tx_type("0x8803dbee00000...", Some("0xrouter")),
1582            "DEX Swap"
1583        );
1584        assert_eq!(
1585            classify_tx_type("0x7ff36ab500000...", Some("0xrouter")),
1586            "DEX Swap"
1587        );
1588        assert_eq!(
1589            classify_tx_type("0x18cbafe500000...", Some("0xrouter")),
1590            "DEX Swap"
1591        );
1592        assert_eq!(
1593            classify_tx_type("0xfb3bdb4100000...", Some("0xrouter")),
1594            "DEX Swap"
1595        );
1596        assert_eq!(
1597            classify_tx_type("0xb6f9de9500000...", Some("0xrouter")),
1598            "DEX Swap"
1599        );
1600    }
1601
1602    #[test]
1603    fn test_classify_tx_type_multicall() {
1604        assert_eq!(
1605            classify_tx_type("0xac9650d800000...", Some("0xcontract")),
1606            "Multicall"
1607        );
1608        assert_eq!(
1609            classify_tx_type("0x5ae401dc00000...", Some("0xcontract")),
1610            "Multicall"
1611        );
1612    }
1613
1614    #[test]
1615    fn test_classify_tx_type_transfer_from() {
1616        assert_eq!(
1617            classify_tx_type("0x23b872dd00000...", Some("0xtoken")),
1618            "ERC-20 Transfer From"
1619        );
1620    }
1621
1622    #[test]
1623    fn test_classify_tx_type_contract_call() {
1624        assert_eq!(
1625            classify_tx_type("0xdeadbeef00000...", Some("0xcontract")),
1626            "Contract Call"
1627        );
1628    }
1629
1630    #[test]
1631    fn test_classify_tx_type_native_transfer_empty() {
1632        assert_eq!(classify_tx_type("", Some("0xrecipient")), "Native Transfer");
1633    }
1634
1635    // ====================================================================
1636    // format_tx_value — expanded edge cases
1637    // ====================================================================
1638
1639    #[test]
1640    fn test_format_tx_value_zero() {
1641        let (fmt, high) = format_tx_value("0x0", "ethereum");
1642        assert!(fmt.contains("0.000000"));
1643        assert!(fmt.contains("ETH"));
1644        assert!(!high);
1645    }
1646
1647    #[test]
1648    fn test_format_tx_value_empty_hex() {
1649        let (fmt, high) = format_tx_value("0x", "ethereum");
1650        assert!(fmt.contains("0.000000"));
1651        assert!(!high);
1652    }
1653
1654    #[test]
1655    fn test_format_tx_value_decimal_string() {
1656        let (fmt, high) = format_tx_value("1000000000000000000", "ethereum"); // 1 ETH
1657        assert!(fmt.contains("1.0"));
1658        assert!(fmt.contains("ETH"));
1659        assert!(!high);
1660    }
1661
1662    #[test]
1663    fn test_format_tx_value_solana() {
1664        let (fmt, high) = format_tx_value("1000000000", "solana"); // 1 SOL (9 decimals)
1665        assert!(fmt.contains("1.0"));
1666        assert!(fmt.contains("SOL"));
1667        assert!(!high);
1668    }
1669
1670    #[test]
1671    fn test_format_tx_value_tron() {
1672        let (fmt, high) = format_tx_value("1000000", "tron"); // 1 TRX (6 decimals)
1673        assert!(fmt.contains("1.0"));
1674        assert!(fmt.contains("TRX"));
1675        assert!(!high);
1676    }
1677
1678    #[test]
1679    fn test_format_tx_value_polygon() {
1680        let (fmt, _) = format_tx_value("1000000000000000000", "polygon");
1681        assert!(fmt.contains("MATIC") || fmt.contains("POL"));
1682    }
1683
1684    #[test]
1685    fn test_format_tx_value_bsc() {
1686        let (fmt, _) = format_tx_value("1000000000000000000", "bsc");
1687        assert!(fmt.contains("BNB"));
1688    }
1689
1690    #[test]
1691    fn test_format_tx_value_arbitrum() {
1692        let (fmt, _) = format_tx_value("1000000000000000000", "arbitrum");
1693        assert!(fmt.contains("ETH"));
1694    }
1695
1696    #[test]
1697    fn test_format_tx_value_optimism() {
1698        let (fmt, _) = format_tx_value("1000000000000000000", "optimism");
1699        assert!(fmt.contains("ETH"));
1700    }
1701
1702    #[test]
1703    fn test_format_tx_value_base() {
1704        let (fmt, _) = format_tx_value("1000000000000000000", "base");
1705        assert!(fmt.contains("ETH"));
1706    }
1707
1708    #[test]
1709    fn test_format_tx_value_aegis() {
1710        // aegis uses 18 decimals; native_symbol returns "???" for unknown chains
1711        let (fmt, _) = format_tx_value("1000000000000000000", "aegis");
1712        assert!(fmt.contains("1.0") || fmt.contains("1.000000"));
1713    }
1714
1715    #[test]
1716    fn test_format_tx_value_unknown_chain_defaults_18_decimals() {
1717        let (fmt, _) = format_tx_value("1000000000000000000", "unknown_chain");
1718        assert!(fmt.contains("1") || fmt.contains("ETH"));
1719    }
1720
1721    #[test]
1722    fn test_format_tx_value_invalid_hex_parse() {
1723        let (fmt, high) = format_tx_value("0xZZZZ", "ethereum");
1724        assert!(fmt.contains("0.000000"));
1725        assert!(!high);
1726    }
1727
1728    #[test]
1729    fn test_format_tx_value_high_value_threshold() {
1730        // > 10 native units = high value
1731        let (_, high) = format_tx_value("11000000000000000000", "ethereum"); // 11 ETH
1732        assert!(high);
1733        let (_, high2) = format_tx_value("10000000000000000000", "ethereum"); // 10 ETH
1734        assert!(!high2); // exactly 10 is not > 10
1735    }
1736
1737    // ====================================================================
1738    // meta_analysis_address tests
1739    // ====================================================================
1740
1741    #[test]
1742    fn test_meta_analysis_address_contract_high_value() {
1743        let meta = meta_analysis_address(true, Some(2_000_000.0), 10, None, None);
1744        assert!(meta.synthesis.contains("contract"));
1745        assert!(meta.synthesis.contains("Significant value"));
1746        assert!(meta.synthesis.contains("Diversified"));
1747        assert!(meta.recommendations.iter().any(|r| r.contains("contract")));
1748    }
1749
1750    #[test]
1751    fn test_risk_line_gives_a_score_only_for_a_complete_assessment() {
1752        use scope::compliance::risk::{RiskAssessment, RiskLevel};
1753        let mut risk = RiskAssessment {
1754            address: "0x1".into(),
1755            chain: "ethereum".into(),
1756            overall_score: 2.4,
1757            risk_level: RiskLevel::Low,
1758            factors: Vec::new(),
1759            not_assessed: Vec::new(),
1760            assessed_at: chrono::Utc::now(),
1761            recommendations: Vec::new(),
1762        };
1763        assert!(
1764            risk_line(&risk).contains("2.4/10 (Low)"),
1765            "{}",
1766            risk_line(&risk)
1767        );
1768
1769        risk.risk_level = RiskLevel::Incomplete;
1770        risk.not_assessed = vec!["Source of Funds: no Etherscan API key".into()];
1771        let line = risk_line(&risk);
1772        assert!(!line.contains("/10"), "{line}");
1773        assert!(line.contains("not assessed in full"), "{line}");
1774        assert!(
1775            line.contains("  - Source of Funds: no Etherscan API key"),
1776            "{line}"
1777        );
1778    }
1779
1780    #[test]
1781    fn test_meta_analysis_does_not_call_an_incomplete_assessment_low() {
1782        // A score from factors without data is not a verdict.
1783        let level = scope::compliance::risk::RiskLevel::Incomplete;
1784        let meta = meta_analysis_address(false, Some(50.0), 0, Some(2.0), Some(&level));
1785        assert!(!meta.synthesis.contains("Low risk"), "{}", meta.synthesis);
1786        assert!(
1787            !meta.key_takeaway.contains("Overall risk"),
1788            "{}",
1789            meta.key_takeaway
1790        );
1791
1792        let low = scope::compliance::risk::RiskLevel::Low;
1793        let meta = meta_analysis_address(false, Some(50.0), 0, Some(2.0), Some(&low));
1794        assert!(meta.synthesis.contains("Low risk"), "{}", meta.synthesis);
1795    }
1796
1797    #[test]
1798    fn test_meta_analysis_address_eoa_moderate_value() {
1799        let meta = meta_analysis_address(false, Some(50_000.0), 3, None, None);
1800        assert!(meta.synthesis.contains("wallet (EOA)"));
1801        assert!(meta.synthesis.contains("Moderate value"));
1802    }
1803
1804    #[test]
1805    fn test_meta_analysis_address_minimal_value() {
1806        let meta = meta_analysis_address(false, Some(0.5), 0, None, None);
1807        assert!(meta.synthesis.contains("Minimal value"));
1808    }
1809
1810    #[test]
1811    fn test_meta_analysis_address_single_token() {
1812        let meta = meta_analysis_address(false, None, 1, None, None);
1813        assert!(meta.synthesis.contains("Concentrated in a single token"));
1814    }
1815
1816    #[test]
1817    fn test_meta_analysis_address_high_risk() {
1818        use scope::compliance::risk::RiskLevel;
1819        let level = RiskLevel::High;
1820        let meta = meta_analysis_address(false, None, 0, Some(8.5), Some(&level));
1821        assert!(meta.synthesis.contains("Elevated risk"));
1822        assert!(meta.key_takeaway.contains("scrutiny"));
1823        assert!(
1824            meta.recommendations
1825                .iter()
1826                .any(|r| r.contains("unusual transaction"))
1827        );
1828    }
1829
1830    #[test]
1831    fn test_meta_analysis_address_low_risk() {
1832        use scope::compliance::risk::RiskLevel;
1833        let level = RiskLevel::Low;
1834        let meta = meta_analysis_address(false, None, 0, Some(2.0), Some(&level));
1835        assert!(meta.synthesis.contains("Low risk"));
1836    }
1837
1838    #[test]
1839    fn test_meta_analysis_address_contract_no_value() {
1840        let meta = meta_analysis_address(true, None, 0, None, None);
1841        assert!(meta.key_takeaway.contains("Contract address"));
1842        assert!(
1843            meta.recommendations
1844                .iter()
1845                .any(|r| r.contains("Confirm contract"))
1846        );
1847    }
1848
1849    #[test]
1850    fn test_meta_analysis_address_high_value_wallet() {
1851        let meta = meta_analysis_address(false, Some(150_000.0), 0, None, None);
1852        assert!(meta.key_takeaway.contains("High-value wallet"));
1853    }
1854
1855    #[test]
1856    fn test_meta_analysis_address_default_takeaway() {
1857        let meta = meta_analysis_address(false, Some(5_000.0), 0, None, None);
1858        assert!(meta.key_takeaway.contains("Review full report"));
1859    }
1860
1861    #[test]
1862    fn test_meta_analysis_address_empty_synthesis() {
1863        let meta = meta_analysis_address(
1864            false,
1865            Some(5_000.0), // moderate value, not significant/minimal
1866            2,             // 2 tokens, not 1, not >5
1867            None,
1868            None,
1869        );
1870        assert!(meta.synthesis.contains("wallet (EOA)"));
1871    }
1872
1873    #[test]
1874    fn test_meta_analysis_address_synthesis_parts_joined() {
1875        let meta = meta_analysis_address(false, None, 0, None, None);
1876        assert!(!meta.synthesis.is_empty());
1877        assert!(meta.synthesis.contains("Address analyzed") || meta.synthesis.contains("wallet"));
1878    }
1879
1880    #[test]
1881    fn test_meta_analysis_address_with_tokens_recommendation() {
1882        let meta = meta_analysis_address(false, None, 3, None, None);
1883        assert!(
1884            meta.recommendations
1885                .iter()
1886                .any(|r| r.contains("Verify token contracts"))
1887        );
1888    }
1889
1890    // ====================================================================
1891    // meta_analysis_tx tests
1892    // ====================================================================
1893
1894    #[test]
1895    fn test_meta_analysis_tx_successful_native_transfer() {
1896        let meta = meta_analysis_tx("Native Transfer", true, false, "0xfrom", Some("0xto"));
1897        assert!(meta.synthesis.contains("Native Transfer"));
1898        assert!(meta.key_takeaway.contains("Routine"));
1899        assert!(meta.recommendations.is_empty());
1900    }
1901
1902    #[test]
1903    fn test_meta_analysis_tx_failed() {
1904        let meta = meta_analysis_tx("Contract Call", false, false, "0xfrom", Some("0xto"));
1905        assert!(meta.synthesis.contains("failed"));
1906        assert!(meta.key_takeaway.contains("Failed transaction"));
1907        assert!(meta.recommendations.iter().any(|r| r.contains("revert")));
1908    }
1909
1910    #[test]
1911    fn test_meta_analysis_tx_high_value_native() {
1912        let meta = meta_analysis_tx("Native Transfer", true, true, "0xfrom", Some("0xto"));
1913        assert!(meta.synthesis.contains("High-value"));
1914        assert!(meta.key_takeaway.contains("Large native transfer"));
1915        assert!(meta.recommendations.iter().any(|r| r.contains("recipient")));
1916    }
1917
1918    #[test]
1919    fn test_meta_analysis_tx_high_value_contract_call() {
1920        let meta = meta_analysis_tx("DEX Swap", true, true, "0xfrom", Some("0xto"));
1921        assert!(meta.key_takeaway.contains("High-value operation"));
1922    }
1923
1924    #[test]
1925    fn test_meta_analysis_tx_erc20_approve() {
1926        let meta = meta_analysis_tx("ERC-20 Approval", true, false, "0xfrom", Some("0xto"));
1927        assert!(meta.recommendations.iter().any(|r| r.contains("spender")));
1928    }
1929
1930    #[test]
1931    fn test_meta_analysis_tx_failed_high_value() {
1932        let meta = meta_analysis_tx("Contract Call", false, true, "0xfrom", Some("0xto"));
1933        assert!(meta.synthesis.contains("failed"));
1934        assert!(meta.synthesis.contains("High-value"));
1935        assert!(meta.recommendations.len() >= 2);
1936    }
1937
1938    // ====================================================================
1939    // meta_analysis_token tests
1940    // ====================================================================
1941
1942    #[test]
1943    fn test_meta_analysis_token_low_risk() {
1944        let summary = report::TokenRiskSummary {
1945            score: 2,
1946            level: "Low",
1947            emoji: "🟢",
1948            concerns: vec![],
1949            positives: vec!["Good liquidity".to_string()],
1950        };
1951        let meta = meta_analysis_token(&summary, false, None, None, 2_000_000.0);
1952        assert!(meta.synthesis.contains("Low-risk"));
1953        assert!(meta.synthesis.contains("Strong liquidity"));
1954        assert!(meta.key_takeaway.contains("Favorable"));
1955    }
1956
1957    #[test]
1958    fn test_meta_analysis_token_high_risk() {
1959        let summary = report::TokenRiskSummary {
1960            score: 8,
1961            level: "High",
1962            emoji: "🔴",
1963            concerns: vec!["Low liquidity".to_string()],
1964            positives: vec![],
1965        };
1966        let meta = meta_analysis_token(&summary, false, None, None, 10_000.0);
1967        assert!(meta.synthesis.contains("Elevated risk"));
1968        assert!(meta.synthesis.contains("Limited liquidity"));
1969        assert!(meta.key_takeaway.contains("High risk"));
1970        assert!(
1971            meta.recommendations
1972                .iter()
1973                .any(|r| r.contains("smaller position"))
1974        );
1975    }
1976
1977    #[test]
1978    fn test_meta_analysis_token_moderate_risk() {
1979        let summary = report::TokenRiskSummary {
1980            score: 5,
1981            level: "Medium",
1982            emoji: "🟡",
1983            concerns: vec!["Some concern".to_string()],
1984            positives: vec!["Some positive".to_string()],
1985        };
1986        let meta = meta_analysis_token(&summary, false, None, None, 500_000.0);
1987        assert!(meta.synthesis.contains("Moderate risk"));
1988        assert!(meta.key_takeaway.contains("Risk 5/10"));
1989    }
1990
1991    #[test]
1992    fn test_meta_analysis_token_stablecoin_healthy_peg() {
1993        let summary = report::TokenRiskSummary {
1994            score: 2,
1995            level: "Low",
1996            emoji: "🟢",
1997            concerns: vec![],
1998            positives: vec!["Stable peg".to_string()],
1999        };
2000        let meta = meta_analysis_token(&summary, true, Some(PegStatus::OnPeg), None, 5_000_000.0);
2001        assert!(meta.synthesis.contains("Stablecoin peg is healthy"));
2002    }
2003
2004    #[test]
2005    fn test_meta_analysis_token_stablecoin_unhealthy_peg() {
2006        let summary = report::TokenRiskSummary {
2007            score: 4,
2008            level: "Medium",
2009            emoji: "🟡",
2010            concerns: vec![],
2011            positives: vec![],
2012        };
2013        let meta = meta_analysis_token(&summary, true, Some(PegStatus::Deviation), None, 500_000.0);
2014        assert!(meta.synthesis.contains("peg deviation"));
2015        assert!(meta.key_takeaway.contains("deviating from peg"));
2016        assert!(meta.recommendations.iter().any(|r| r.contains("peg")));
2017    }
2018
2019    #[test]
2020    fn test_meta_analysis_token_concentration_risk() {
2021        let summary = report::TokenRiskSummary {
2022            score: 5,
2023            level: "Medium",
2024            emoji: "🟡",
2025            concerns: vec![],
2026            positives: vec![],
2027        };
2028        let meta = meta_analysis_token(&summary, false, None, Some(45.0), 500_000.0);
2029        assert!(meta.synthesis.contains("Concentration risk"));
2030        assert!(
2031            meta.recommendations
2032                .iter()
2033                .any(|r| r.contains("top holder"))
2034        );
2035    }
2036
2037    // M7: $75K is low liquidity in the report; the summary says so too.
2038    #[test]
2039    fn test_meta_analysis_names_low_liquidity_as_the_report_does() {
2040        let summary = report::TokenRiskSummary {
2041            score: 3,
2042            level: "Low",
2043            emoji: "🟢",
2044            concerns: vec![],
2045            positives: vec![],
2046        };
2047        let meta = meta_analysis_token(&summary, false, None, None, 75_000.0);
2048        assert!(
2049            meta.synthesis.contains("Limited liquidity"),
2050            "{}",
2051            meta.synthesis
2052        );
2053    }
2054
2055    #[test]
2056    fn test_meta_analysis_token_low_liquidity_low_risk() {
2057        let summary = report::TokenRiskSummary {
2058            score: 3,
2059            level: "Low",
2060            emoji: "🟢",
2061            concerns: vec![],
2062            positives: vec![],
2063        };
2064        let meta = meta_analysis_token(&summary, false, None, None, 50_000.0);
2065        assert!(
2066            meta.recommendations
2067                .iter()
2068                .any(|r| r.contains("limit orders") || r.contains("slippage"))
2069        );
2070    }
2071
2072    #[test]
2073    fn test_meta_analysis_token_stablecoin_no_peg_data() {
2074        let summary = report::TokenRiskSummary {
2075            score: 3,
2076            level: "Low",
2077            emoji: "🟢",
2078            concerns: vec![],
2079            positives: vec![],
2080        };
2081        let meta = meta_analysis_token(&summary, true, None, None, 1_000_000.0);
2082        // When there is no peg status, recommendation should still suggest verifying peg
2083        assert!(meta.recommendations.iter().any(|r| r.contains("peg")));
2084    }
2085
2086    #[test]
2087    fn test_meta_analysis_token_mixed_signals_key_takeaway() {
2088        let summary = report::TokenRiskSummary {
2089            score: 5,
2090            level: "Medium",
2091            emoji: "🟡",
2092            concerns: vec!["Some concern".to_string()],
2093            positives: vec!["Some positive".to_string()],
2094        };
2095        let meta = meta_analysis_token(&summary, false, None, None, 500_000.0);
2096        assert!(meta.key_takeaway.contains("Risk 5/10"));
2097        assert!(meta.key_takeaway.contains("Medium"));
2098    }
2099
2100    // ====================================================================
2101    // infer_target — additional edge cases
2102    // ====================================================================
2103
2104    #[test]
2105    fn test_infer_target_tx_hash_with_chain_override() {
2106        let t = infer_target(
2107            "0xabc123def456789012345678901234567890123456789012345678901234abcd",
2108            Some("polygon"),
2109        );
2110        assert!(matches!(t, InferredTarget::Transaction { chain } if chain == "polygon"));
2111    }
2112
2113    #[test]
2114    fn test_infer_target_whitespace_trimming() {
2115        let t = infer_target("  USDC  ", None);
2116        assert!(matches!(t, InferredTarget::Token { .. }));
2117    }
2118
2119    #[test]
2120    fn test_infer_target_long_token_name() {
2121        let t = infer_target("some-random-token-name", None);
2122        assert!(matches!(t, InferredTarget::Token { chain } if chain == "ethereum"));
2123    }
2124
2125    // ====================================================================
2126    // InsightsArgs struct validation
2127    // ====================================================================
2128
2129    #[test]
2130    fn test_insights_args_debug() {
2131        let args = InsightsArgs {
2132            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
2133            chain: Some("ethereum".to_string()),
2134            decode: true,
2135            trace: false,
2136            health: Default::default(),
2137        };
2138        let debug_str = format!("{:?}", args);
2139        assert!(debug_str.contains("InsightsArgs"));
2140        assert!(debug_str.contains("0x742d"));
2141    }
2142
2143    // ====================================================================
2144    // Additional tests for classify_tx_type — selector matches and edge cases
2145    // ====================================================================
2146
2147    #[test]
2148    fn test_classify_tx_type_contract_creation() {
2149        assert_eq!(classify_tx_type("0xa9059cbb...", None), "Contract Creation");
2150    }
2151
2152    #[test]
2153    fn test_classify_tx_type_erc20_transfer() {
2154        assert_eq!(
2155            classify_tx_type("0xa9059cbb00000000", Some("0x1234")),
2156            "ERC-20 Transfer"
2157        );
2158    }
2159
2160    #[test]
2161    fn test_classify_tx_type_erc20_approve() {
2162        assert_eq!(
2163            classify_tx_type("0x095ea7b3...", Some("0x1234")),
2164            "ERC-20 Approve"
2165        );
2166    }
2167
2168    #[test]
2169    fn test_classify_tx_type_erc20_transfer_from() {
2170        assert_eq!(
2171            classify_tx_type("0x23b872dd...", Some("0x1234")),
2172            "ERC-20 Transfer From"
2173        );
2174    }
2175
2176    #[test]
2177    fn test_classify_tx_type_dex_swap() {
2178        assert_eq!(
2179            classify_tx_type("0x38ed1739...", Some("0x1234")),
2180            "DEX Swap"
2181        );
2182        assert_eq!(
2183            classify_tx_type("0x7ff36ab5...", Some("0x1234")),
2184            "DEX Swap"
2185        );
2186    }
2187
2188    #[test]
2189    fn test_classify_tx_type_native_transfer() {
2190        assert_eq!(classify_tx_type("0x", Some("0x1234")), "Native Transfer");
2191        assert_eq!(classify_tx_type("", Some("0x1234")), "Native Transfer");
2192    }
2193
2194    #[test]
2195    fn test_classify_tx_type_unknown_contract_call() {
2196        assert_eq!(
2197            classify_tx_type("0xdeadbeef12345678", Some("0x1234")),
2198            "Contract Call"
2199        );
2200    }
2201
2202    // ====================================================================
2203    // Additional tests for format_tx_value
2204    // ====================================================================
2205
2206    #[test]
2207    fn test_format_tx_value_ethereum_wei() {
2208        let (fmt, high) = format_tx_value("1000000000000000000", "ethereum");
2209        assert!(fmt.contains("1.000000"));
2210        assert!(fmt.contains("ETH"));
2211        assert!(!high); // 1 ETH < 10 threshold
2212    }
2213
2214    #[test]
2215    fn test_format_tx_value_hex() {
2216        let (fmt, _) = format_tx_value("0xde0b6b3a7640000", "ethereum");
2217        // 0xde0b6b3a7640000 = 10^18 = 1 ETH
2218        assert!(fmt.contains("ETH"));
2219    }
2220
2221    #[test]
2222    fn test_format_tx_value_high_value() {
2223        // 100 ETH in wei = 100000000000000000000
2224        let (_, high) = format_tx_value("100000000000000000000", "ethereum");
2225        assert!(high); // 100 ETH > 10
2226    }
2227
2228    #[test]
2229    fn test_format_tx_value_zero_decimal() {
2230        let (fmt, high) = format_tx_value("0", "ethereum");
2231        assert!(fmt.contains("0.000000"));
2232        assert!(!high);
2233    }
2234
2235    #[test]
2236    fn test_format_tx_value_solana_additional() {
2237        let (fmt, _) = format_tx_value("1000000000", "solana"); // 1 SOL
2238        assert!(fmt.contains("SOL"));
2239    }
2240
2241    #[test]
2242    fn test_format_tx_value_tron_additional() {
2243        let (fmt, _) = format_tx_value("1000000", "tron"); // 1 TRX
2244        assert!(fmt.contains("TRX"));
2245    }
2246
2247    #[test]
2248    fn test_format_tx_value_empty_hex_additional() {
2249        let (fmt, _) = format_tx_value("0x", "ethereum");
2250        assert!(fmt.contains("0.000000"));
2251    }
2252
2253    // ====================================================================
2254    // Combined tests for target_type_label and chain_label
2255    // ====================================================================
2256
2257    #[test]
2258    fn test_target_type_label_combined() {
2259        assert_eq!(
2260            target_type_label(&InferredTarget::Address {
2261                chain: "eth".to_string()
2262            }),
2263            "Address"
2264        );
2265        assert_eq!(
2266            target_type_label(&InferredTarget::Transaction {
2267                chain: "eth".to_string()
2268            }),
2269            "Transaction"
2270        );
2271        assert_eq!(
2272            target_type_label(&InferredTarget::Token {
2273                chain: "eth".to_string()
2274            }),
2275            "Token"
2276        );
2277    }
2278
2279    #[test]
2280    fn test_chain_label_combined() {
2281        assert_eq!(
2282            chain_label(&InferredTarget::Address {
2283                chain: "ethereum".to_string()
2284            }),
2285            "ethereum"
2286        );
2287        assert_eq!(
2288            chain_label(&InferredTarget::Transaction {
2289                chain: "polygon".to_string()
2290            }),
2291            "polygon"
2292        );
2293        assert_eq!(
2294            chain_label(&InferredTarget::Token {
2295                chain: "solana".to_string()
2296            }),
2297            "solana"
2298        );
2299    }
2300
2301    // ====================================================================
2302    // Additional tests for meta_analysis_address
2303    // ====================================================================
2304
2305    #[test]
2306    fn test_meta_analysis_address_contract_high_risk() {
2307        use scope::compliance::risk::RiskLevel;
2308        let meta = meta_analysis_address(
2309            true,
2310            Some(2_000_000.0),
2311            10,
2312            Some(8.0),
2313            Some(&RiskLevel::High),
2314        );
2315        assert!(meta.synthesis.contains("contract"));
2316        assert!(meta.synthesis.contains("Significant value"));
2317        assert!(meta.key_takeaway.contains("scrutiny"));
2318        assert!(!meta.recommendations.is_empty());
2319    }
2320
2321    #[test]
2322    fn test_meta_analysis_address_wallet_low_risk() {
2323        use scope::compliance::risk::RiskLevel;
2324        let meta = meta_analysis_address(false, Some(0.5), 0, Some(2.0), Some(&RiskLevel::Low));
2325        assert!(meta.synthesis.contains("wallet"));
2326        assert!(meta.synthesis.contains("Minimal value"));
2327    }
2328
2329    #[test]
2330    fn test_meta_analysis_address_no_risk_data() {
2331        let meta = meta_analysis_address(false, None, 0, None, None);
2332        assert!(!meta.synthesis.is_empty());
2333        assert!(meta.key_takeaway.contains("Review full report"));
2334    }
2335
2336    // ====================================================================
2337    // Additional tests for meta_analysis_tx
2338    // ====================================================================
2339
2340    #[test]
2341    fn test_meta_analysis_tx_failed_additional() {
2342        let meta = meta_analysis_tx("Contract Call", false, false, "0x...", Some("0x..."));
2343        assert!(meta.synthesis.contains("failed"));
2344        assert!(meta.key_takeaway.contains("Failed"));
2345    }
2346
2347    #[test]
2348    fn test_meta_analysis_tx_high_value_native_additional() {
2349        let meta = meta_analysis_tx("Native Transfer", true, true, "0x...", Some("0x..."));
2350        assert!(meta.synthesis.contains("High-value"));
2351        assert!(meta.key_takeaway.contains("Large native transfer"));
2352    }
2353
2354    #[test]
2355    fn test_meta_analysis_tx_routine() {
2356        let meta = meta_analysis_tx("ERC-20 Transfer", true, false, "0x...", Some("0x..."));
2357        assert!(meta.key_takeaway.contains("Routine"));
2358    }
2359
2360    // ====================================================================
2361    // Additional tests for meta_analysis_token
2362    // ====================================================================
2363
2364    #[test]
2365    fn test_meta_analysis_token_high_risk_additional() {
2366        let risk = report::TokenRiskSummary {
2367            score: 8,
2368            level: "High",
2369            emoji: "🔴",
2370            concerns: vec!["Low liquidity".to_string()],
2371            positives: vec![],
2372        };
2373        let meta = meta_analysis_token(&risk, false, None, None, 10_000.0);
2374        assert!(meta.synthesis.contains("Elevated risk"));
2375        assert!(meta.key_takeaway.contains("High risk"));
2376    }
2377
2378    #[test]
2379    fn test_meta_analysis_token_stablecoin_peg_healthy() {
2380        let risk = report::TokenRiskSummary {
2381            score: 2,
2382            level: "Low",
2383            emoji: "🟢",
2384            concerns: vec![],
2385            positives: vec!["Strong liquidity".to_string()],
2386        };
2387        let meta = meta_analysis_token(&risk, true, Some(PegStatus::OnPeg), Some(5.0), 5_000_000.0);
2388        assert!(meta.synthesis.contains("peg is healthy"));
2389        assert!(meta.synthesis.contains("Strong liquidity"));
2390    }
2391
2392    #[test]
2393    fn test_meta_analysis_token_stablecoin_peg_unhealthy() {
2394        let risk = report::TokenRiskSummary {
2395            score: 5,
2396            level: "Medium",
2397            emoji: "🟡",
2398            concerns: vec!["Peg deviation".to_string()],
2399            positives: vec![],
2400        };
2401        let meta = meta_analysis_token(
2402            &risk,
2403            true,
2404            Some(PegStatus::Deviation),
2405            Some(40.0),
2406            100_000.0,
2407        );
2408        assert!(meta.synthesis.contains("peg deviation"));
2409        assert!(meta.synthesis.contains("Concentration risk"));
2410    }
2411
2412    #[test]
2413    fn test_meta_analysis_token_high_risk_empty_concerns_uses_multiple_factors() {
2414        let risk = report::TokenRiskSummary {
2415            score: 8,
2416            level: "High",
2417            emoji: "🔴",
2418            concerns: vec![],
2419            positives: vec![],
2420        };
2421        let meta = meta_analysis_token(&risk, false, None, None, 50_000.0);
2422        assert!(
2423            meta.key_takeaway.contains("multiple factors")
2424                || meta.key_takeaway.contains("High risk")
2425        );
2426    }
2427
2428    #[test]
2429    fn test_infer_target_chain_override_with_address() {
2430        let t = infer_target(
2431            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2432            Some("arbitrum"),
2433        );
2434        assert!(matches!(t, InferredTarget::Address { chain } if chain == "arbitrum"));
2435    }
2436
2437    #[test]
2438    fn test_meta_analysis_tx_approval_contains_approval_in_match() {
2439        let meta = meta_analysis_tx("ERC-20 Approval", true, false, "0xfrom", Some("0xto"));
2440        assert!(
2441            meta.recommendations
2442                .iter()
2443                .any(|r| r.contains("spender") || r.contains("allowance"))
2444        );
2445    }
2446}