1use 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#[derive(Debug, Clone)]
26pub enum InferredTarget {
27 Address { chain: String },
29 Transaction { chain: String },
31 Token { chain: String },
33}
34
35#[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 pub target: String,
56
57 #[arg(short, long)]
59 pub chain: Option<String>,
60
61 #[arg(long)]
63 pub decode: bool,
64
65 #[arg(long)]
67 pub trace: bool,
68
69 #[command(flatten)]
71 pub health: HealthArgs,
72}
73
74pub 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 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 if let Some(chain) = infer_chain_from_hash(trimmed) {
92 return InferredTarget::Transaction {
93 chain: chain.to_string(),
94 };
95 }
96
97 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 InferredTarget::Token {
107 chain: scope::chains::DEFAULT_CHAIN.to_string(),
108 }
109}
110
111pub async fn run(
113 mut args: InsightsArgs,
114 config: &Config,
115 clients: &dyn ChainClientFactory,
116 out: &Output,
117) -> Result<()> {
118 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 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 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 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 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 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 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 let mut peg_status: Option<PegStatus> = None;
375 if is_stablecoin_symbol(&analytics.token.symbol)
376 && let Ok(registry) = VenueRegistry::load()
377 {
378 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, ®istry),
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 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
443fn 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
466fn 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 let high_value = human > 10.0;
486 (formatted, high_value)
487}
488
489struct PegWords {
492 label: &'static str,
494 synthesis: Option<&'static str>,
496 takeaway: Option<&'static str>,
498 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
545fn 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
557struct MetaAnalysis {
559 synthesis: String,
560 key_takeaway: String,
561 recommendations: Vec<String>,
562}
563
564fn 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 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 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 summary.healthy = true;
824 summary
825 }
826
827 #[test]
828 fn test_peg_line_uses_the_summary_peg_target_not_one() {
829 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 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 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()) }
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 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, 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), })
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()) }
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 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 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 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, 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 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 #[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 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 #[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"); assert!(fmt.contains("1.0") && fmt.contains("ETH"));
1476 assert!(!high);
1477 let (_, high2) = format_tx_value("0x52B7D2DCC80CD2E4000000", "ethereum"); 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 assert!(is_stablecoin_symbol("UsDc"));
1506 assert!(is_stablecoin_symbol("FraX"));
1507 assert!(!is_stablecoin_symbol("SOL")); }
1509
1510 #[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 #[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 #[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"); 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"); 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"); 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 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 let (_, high) = format_tx_value("11000000000000000000", "ethereum"); assert!(high);
1733 let (_, high2) = format_tx_value("10000000000000000000", "ethereum"); assert!(!high2); }
1736
1737 #[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 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), 2, 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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); }
2213
2214 #[test]
2215 fn test_format_tx_value_hex() {
2216 let (fmt, _) = format_tx_value("0xde0b6b3a7640000", "ethereum");
2217 assert!(fmt.contains("ETH"));
2219 }
2220
2221 #[test]
2222 fn test_format_tx_value_high_value() {
2223 let (_, high) = format_tx_value("100000000000000000000", "ethereum");
2225 assert!(high); }
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"); assert!(fmt.contains("SOL"));
2239 }
2240
2241 #[test]
2242 fn test_format_tx_value_tron_additional() {
2243 let (fmt, _) = format_tx_value("1000000", "tron"); 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 #[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 #[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 #[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 #[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}