Skip to main content

scope/cli/
contract.rs

1//! # Contract Analysis Command
2//!
3//! Performs comprehensive smart contract analysis including source code
4//! retrieval, proxy detection, access control mapping, vulnerability scanning,
5//! DeFi protocol checks, and external intelligence gathering.
6
7use crate::chains::ChainClientFactory;
8use crate::config::Config;
9use crate::contract;
10use crate::error::Result;
11use clap::Args;
12
13/// Arguments for the contract analysis command.
14#[derive(Debug, Args)]
15#[command(
16    after_help = "\x1b[1mExamples:\x1b[0m
17  scope contract 0xdAC17F958D2ee523a2206206994597C13D831ec7
18  scope ct @usdt-contract                                 \x1b[2m# address book shortcut\x1b[0m
19  scope ct 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --chain polygon
20  scope contract 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D --json",
21    after_long_help = "\x1b[1mExamples:\x1b[0m
22
23  \x1b[1m$ scope contract 0xdAC17F958D2ee523a2206206994597C13D831ec7\x1b[0m
24
25  ========================================================================
26    CONTRACT ANALYSIS: 0xdAC17F958D2ee523a2206206994597C13D831ec7
27    Chain: ethereum | Verified: Yes
28  ========================================================================
29
30    Security Score: [################----] 80/100
31
32  --- Source Code ---
33    Contract Name: TetherToken
34    Compiler: v0.4.18+commit.9cf6e910
35    Optimization: No
36
37  --- Proxy Detection ---
38    Not a proxy contract
39
40  --- Access Control ---
41    Ownership: Ownable
42    Renounced: No
43    Privileged functions:
44      - pause (High): Can pause transfers
45      - addBlacklist (High): Can blacklist addresses
46
47  --- Vulnerability Findings ---
48    [. ] SC-TX-ORIGIN - tx.origin authorization (Low)
49
50  --- DeFi Analysis ---
51    Protocol Type: Token
52    Token Standards: ERC-20
53
54  --- External Intelligence ---
55    Explorer: https://etherscan.io/address/0xdAC17...
56    Sourcify: Verified
57    Audit Reports:
58      - Trail of Bits (TetherToken)
59
60  ========================================================================
61
62  \x1b[1m$ scope ct 0xA0b86991... --json\x1b[0m
63
64  {
65    \"address\": \"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48\",
66    \"chain\": \"ethereum\",
67    \"is_verified\": true,
68    \"security_score\": 85,
69    \"security_summary\": \"Verified contract with ...\",
70    \"source_info\": { ... },
71    \"proxy_info\": { ... },
72    \"vulnerabilities\": [ ... ],
73    ...
74  }"
75)]
76pub struct ContractArgs {
77    /// Contract address to analyze.
78    ///
79    /// Must be a valid address on the target chain. The address must be
80    /// a deployed smart contract (not an externally owned account).
81    /// Use @label to resolve from the address book (e.g., @usdt-contract).
82    #[arg(value_name = "ADDRESS")]
83    pub address: String,
84
85    /// Target blockchain network.
86    ///
87    /// EVM chains with Etherscan-compatible APIs:
88    /// ethereum, polygon, arbitrum, optimism, base, bsc
89    #[arg(long, short, default_value = "ethereum")]
90    pub chain: String,
91
92    /// Output raw JSON instead of formatted report.
93    ///
94    /// Useful for piping to `jq` or feeding to other tools.
95    #[arg(long)]
96    pub json: bool,
97}
98
99/// Run the contract analysis command.
100pub async fn run(
101    args: &ContractArgs,
102    _config: &Config,
103    clients: &dyn ChainClientFactory,
104) -> Result<()> {
105    let spinner = crate::cli::progress::Spinner::new("Analyzing contract...");
106
107    let client = clients.create_chain_client(&args.chain)?;
108    let http_client = reqwest::Client::new();
109
110    let analysis =
111        contract::analyze_contract(&args.address, &args.chain, client.as_ref(), &http_client)
112            .await?;
113
114    spinner.finish("Contract analysis complete");
115
116    if args.json {
117        println!(
118            "{}",
119            serde_json::to_string_pretty(&analysis)
120                .unwrap_or_else(|_| "Failed to serialize".to_string())
121        );
122    } else {
123        print_contract_report(&analysis);
124    }
125
126    Ok(())
127}
128
129/// Print a formatted contract analysis report to the terminal.
130fn print_contract_report(analysis: &contract::ContractAnalysis) {
131    println!("\n{}", "=".repeat(72));
132    println!("  CONTRACT ANALYSIS: {}", analysis.address);
133    println!(
134        "  Chain: {} | Verified: {}",
135        analysis.chain,
136        if analysis.is_verified { "Yes" } else { "No" }
137    );
138    println!("{}", "=".repeat(72));
139
140    // Security Score
141    let score_bar = format!(
142        "[{}{}] {}/100",
143        "#".repeat((analysis.security_score as usize) / 5),
144        "-".repeat(20 - (analysis.security_score as usize) / 5),
145        analysis.security_score
146    );
147    println!("\n  Security Score: {}", score_bar);
148    println!("  {}", analysis.security_summary);
149
150    // Source Info
151    if let Some(src) = &analysis.source_info {
152        println!("\n--- Source Code ---");
153        println!("  Contract Name: {}", src.contract_name);
154        println!("  Compiler: {}", src.compiler_version);
155        println!("  EVM Version: {}", src.evm_version);
156        println!("  License: {}", src.license_type);
157        println!(
158            "  Optimization: {}",
159            if src.optimization_used {
160                format!("Yes ({} runs)", src.optimization_runs)
161            } else {
162                "No".to_string()
163            }
164        );
165        println!("  ABI Functions: {}", src.parsed_abi.len());
166    }
167
168    // Proxy Info
169    if let Some(proxy) = &analysis.proxy_info {
170        println!("\n--- Proxy Detection ---");
171        if proxy.is_proxy {
172            println!("  Type: {}", proxy.proxy_type);
173            if let Some(impl_addr) = &proxy.implementation_address {
174                println!("  Implementation: {}", impl_addr);
175            }
176            if let Some(admin) = &proxy.admin_address {
177                println!("  Admin: {}", admin);
178            }
179        } else {
180            println!("  Not a proxy contract");
181        }
182        for detail in &proxy.details {
183            println!("  - {}", detail);
184        }
185    }
186
187    // Access Control
188    if let Some(ac) = &analysis.access_control {
189        println!("\n--- Access Control ---");
190        if let Some(pattern) = &ac.ownership_pattern {
191            println!("  Ownership: {}", pattern);
192        }
193        println!(
194            "  Renounced: {}",
195            if ac.has_renounced_ownership {
196                "Yes"
197            } else {
198                "No"
199            }
200        );
201        println!(
202            "  Role-based: {}",
203            if ac.has_role_based_access {
204                "Yes"
205            } else {
206                "No"
207            }
208        );
209        if ac.uses_tx_origin {
210            println!("  WARNING: Uses tx.origin for authorization");
211        }
212        if !ac.roles.is_empty() {
213            println!("  Roles: {}", ac.roles.join(", "));
214        }
215        if !ac.privileged_functions.is_empty() {
216            println!("  Privileged functions:");
217            for pf in &ac.privileged_functions {
218                println!("    - {} ({:?}): {}", pf.name, pf.risk, pf.capability);
219            }
220        }
221        println!("\n  Auth: {}", ac.auth_analysis.summary);
222    }
223
224    // Vulnerabilities
225    if !analysis.vulnerabilities.is_empty() {
226        println!("\n--- Vulnerability Findings ---");
227        for vuln in &analysis.vulnerabilities {
228            let severity_indicator = match vuln.severity {
229                contract::vulnerability::Severity::Critical => "[!!]",
230                contract::vulnerability::Severity::High => "[! ]",
231                contract::vulnerability::Severity::Medium => "[* ]",
232                contract::vulnerability::Severity::Low => "[. ]",
233                contract::vulnerability::Severity::Informational => "[i ]",
234            };
235            println!(
236                "  {} {} - {} ({})",
237                severity_indicator, vuln.id, vuln.title, vuln.severity
238            );
239            println!("      {}", vuln.description);
240            println!("      Fix: {}", vuln.recommendation);
241        }
242    } else {
243        println!("\n--- Vulnerability Findings ---");
244        println!("  No heuristic findings triggered.");
245    }
246
247    // DeFi Analysis
248    if let Some(defi) = &analysis.defi_analysis {
249        println!("\n--- DeFi Analysis ---");
250        println!("  Protocol Type: {}", defi.protocol_type);
251        if !defi.token_standards.is_empty() {
252            let standards: Vec<String> =
253                defi.token_standards.iter().map(|s| s.to_string()).collect();
254            println!("  Token Standards: {}", standards.join(", "));
255        }
256        if defi.has_oracle_dependency {
257            for oracle in &defi.oracle_info {
258                println!("  Oracle: {} ({})", oracle.provider, oracle.usage);
259            }
260        }
261        if defi.has_flash_loan_risk {
262            println!("  Flash Loan Risk: Yes");
263        }
264        for dex in &defi.dex_integrations {
265            println!(
266                "  DEX: {} - slippage: {}, deadline: {}",
267                dex.dex,
268                if dex.has_slippage_protection {
269                    "Yes"
270                } else {
271                    "NO"
272                },
273                if dex.has_deadline_protection {
274                    "Yes"
275                } else {
276                    "NO"
277                }
278            );
279        }
280        if !defi.risk_factors.is_empty() {
281            println!("  Risk Factors:");
282            for rf in &defi.risk_factors {
283                println!(
284                    "    - {} (severity {}/10): {}",
285                    rf.name, rf.severity, rf.description
286                );
287            }
288        }
289    }
290
291    // External Info
292    if let Some(ext) = &analysis.external_info {
293        println!("\n--- External Intelligence ---");
294        println!("  Explorer: {}", ext.explorer_url);
295        if let Some(repo) = &ext.github_repo {
296            println!("  GitHub: {}", repo);
297        }
298        if let Some(verified) = &ext.sourcify_verified {
299            println!(
300                "  Sourcify: {}",
301                if *verified {
302                    "Verified"
303                } else {
304                    "Not verified"
305                }
306            );
307        }
308        if !ext.audit_reports.is_empty() {
309            println!("  Audit Reports:");
310            for report in &ext.audit_reports {
311                println!("    - {} ({})", report.auditor, report.scope);
312                if !report.url.is_empty() {
313                    println!("      {}", report.url);
314                }
315            }
316        }
317    }
318
319    println!("\n{}", "=".repeat(72));
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::contract::ContractAnalysis;
326
327    fn minimal_analysis() -> ContractAnalysis {
328        ContractAnalysis {
329            address: "0xtest".to_string(),
330            chain: "ethereum".to_string(),
331            is_verified: false,
332            source_info: None,
333            proxy_info: None,
334            access_control: None,
335            vulnerabilities: vec![],
336            defi_analysis: None,
337            external_info: None,
338            security_score: 30,
339            security_summary: "Unverified contract".to_string(),
340        }
341    }
342
343    #[test]
344    fn test_print_report_minimal() {
345        print_contract_report(&minimal_analysis());
346    }
347
348    #[test]
349    fn test_print_report_verified_with_source() {
350        let mut a = minimal_analysis();
351        a.is_verified = true;
352        a.security_score = 75;
353        a.source_info = Some(crate::contract::source::ContractSource {
354            contract_name: "TestToken".to_string(),
355            source_code: "contract T {}".to_string(),
356            abi: "[]".to_string(),
357            compiler_version: "v0.8.19".to_string(),
358            optimization_used: true,
359            optimization_runs: 200,
360            evm_version: "paris".to_string(),
361            license_type: "MIT".to_string(),
362            is_proxy: false,
363            implementation_address: None,
364            constructor_arguments: String::new(),
365            library: String::new(),
366            swarm_source: String::new(),
367            parsed_abi: vec![],
368        });
369        print_contract_report(&a);
370    }
371
372    #[test]
373    fn test_print_report_source_no_optimization() {
374        let mut a = minimal_analysis();
375        a.is_verified = true;
376        a.source_info = Some(crate::contract::source::ContractSource {
377            contract_name: "T".to_string(),
378            source_code: String::new(),
379            abi: "[]".to_string(),
380            compiler_version: "v0.8.19".to_string(),
381            optimization_used: false,
382            optimization_runs: 0,
383            evm_version: "paris".to_string(),
384            license_type: "MIT".to_string(),
385            is_proxy: false,
386            implementation_address: None,
387            constructor_arguments: String::new(),
388            library: String::new(),
389            swarm_source: String::new(),
390            parsed_abi: vec![],
391        });
392        print_contract_report(&a);
393    }
394
395    #[test]
396    fn test_print_report_with_proxy() {
397        let mut a = minimal_analysis();
398        a.proxy_info = Some(crate::contract::proxy::ProxyInfo {
399            is_proxy: true,
400            proxy_type: "EIP-1967".to_string(),
401            implementation_address: Some("0ximpl".to_string()),
402            admin_address: Some("0xadmin".to_string()),
403            beacon_address: None,
404            details: vec!["Proxy detected".to_string()],
405        });
406        print_contract_report(&a);
407    }
408
409    #[test]
410    fn test_print_report_not_proxy() {
411        let mut a = minimal_analysis();
412        a.proxy_info = Some(crate::contract::proxy::ProxyInfo {
413            is_proxy: false,
414            proxy_type: "None".to_string(),
415            implementation_address: None,
416            admin_address: None,
417            beacon_address: None,
418            details: vec![],
419        });
420        print_contract_report(&a);
421    }
422
423    #[test]
424    fn test_print_report_access_control() {
425        let mut a = minimal_analysis();
426        a.access_control = Some(crate::contract::access::AccessControlMap {
427            ownership_pattern: Some("Ownable".to_string()),
428            has_renounced_ownership: true,
429            has_role_based_access: true,
430            uses_tx_origin: true,
431            tx_origin_locations: vec![],
432            modifiers: vec![],
433            privileged_functions: vec![crate::contract::access::PrivilegedFunction {
434                name: "mint".to_string(),
435                modifiers: vec!["onlyOwner".to_string()],
436                capability: "Mint tokens".to_string(),
437                risk: crate::contract::access::PrivilegeRisk::Critical,
438            }],
439            roles: vec!["MINTER_ROLE".to_string()],
440            auth_analysis: crate::contract::access::AuthAnalysis {
441                msg_sender_checks: 1,
442                tx_origin_checks: 1,
443                has_origin_sender_comparison: false,
444                summary: "Mixed auth".to_string(),
445            },
446        });
447        print_contract_report(&a);
448    }
449
450    #[test]
451    fn test_print_report_vulns() {
452        let mut a = minimal_analysis();
453        a.vulnerabilities = vec![
454            contract::vulnerability::VulnerabilityFinding {
455                id: "V-1".to_string(),
456                title: "Critical issue".to_string(),
457                severity: contract::vulnerability::Severity::Critical,
458                category: contract::vulnerability::VulnCategory::Reentrancy,
459                description: "desc".to_string(),
460                source_location: None,
461                recommendation: "fix".to_string(),
462            },
463            contract::vulnerability::VulnerabilityFinding {
464                id: "V-2".to_string(),
465                title: "High issue".to_string(),
466                severity: contract::vulnerability::Severity::High,
467                category: contract::vulnerability::VulnCategory::UncheckedCall,
468                description: "desc".to_string(),
469                source_location: None,
470                recommendation: "fix".to_string(),
471            },
472            contract::vulnerability::VulnerabilityFinding {
473                id: "V-3".to_string(),
474                title: "Medium".to_string(),
475                severity: contract::vulnerability::Severity::Medium,
476                category: contract::vulnerability::VulnCategory::Delegatecall,
477                description: "desc".to_string(),
478                source_location: None,
479                recommendation: "fix".to_string(),
480            },
481            contract::vulnerability::VulnerabilityFinding {
482                id: "V-4".to_string(),
483                title: "Low".to_string(),
484                severity: contract::vulnerability::Severity::Low,
485                category: contract::vulnerability::VulnCategory::TxOrigin,
486                description: "desc".to_string(),
487                source_location: None,
488                recommendation: "fix".to_string(),
489            },
490            contract::vulnerability::VulnerabilityFinding {
491                id: "V-5".to_string(),
492                title: "Info".to_string(),
493                severity: contract::vulnerability::Severity::Informational,
494                category: contract::vulnerability::VulnCategory::Informational,
495                description: "desc".to_string(),
496                source_location: None,
497                recommendation: "fix".to_string(),
498            },
499        ];
500        print_contract_report(&a);
501    }
502
503    #[test]
504    fn test_print_report_defi() {
505        let mut a = minimal_analysis();
506        a.defi_analysis = Some(crate::contract::defi::DefiAnalysis {
507            protocol_type: crate::contract::defi::ProtocolType::DEX,
508            has_oracle_dependency: true,
509            oracle_info: vec![crate::contract::defi::OracleInfo {
510                provider: "Chainlink".to_string(),
511                usage: "Price feed".to_string(),
512                risks: vec![],
513            }],
514            has_flash_loan_risk: true,
515            flash_loan_info: vec!["Flash loan detected".to_string()],
516            dex_integrations: vec![crate::contract::defi::DexIntegration {
517                dex: "Uniswap".to_string(),
518                integration_type: "Swap".to_string(),
519                has_slippage_protection: false,
520                has_deadline_protection: true,
521            }],
522            lending_patterns: vec![],
523            token_standards: vec![crate::contract::defi::TokenStandard::ERC20],
524            staking_patterns: vec![],
525            risk_factors: vec![crate::contract::defi::DefiRiskFactor {
526                name: "Test risk".to_string(),
527                description: "A risk".to_string(),
528                severity: 7,
529            }],
530        });
531        print_contract_report(&a);
532    }
533
534    #[test]
535    fn test_print_report_external() {
536        let mut a = minimal_analysis();
537        a.external_info = Some(crate::contract::external::ExternalInfo {
538            explorer_url: "https://etherscan.io/address/0xtest".to_string(),
539            github_repo: Some("https://github.com/test/repo".to_string()),
540            sourcify_verified: Some(true),
541            deployer: None,
542            audit_reports: vec![crate::contract::external::AuditReport {
543                auditor: "Trail of Bits".to_string(),
544                scope: "Token".to_string(),
545                url: "https://audit.com".to_string(),
546                date: None,
547            }],
548            metadata: vec![],
549        });
550        print_contract_report(&a);
551    }
552
553    #[test]
554    fn test_print_report_external_sourcify_false() {
555        let mut a = minimal_analysis();
556        a.external_info = Some(crate::contract::external::ExternalInfo {
557            explorer_url: "https://etherscan.io/address/0xtest".to_string(),
558            github_repo: None,
559            sourcify_verified: Some(false),
560            deployer: None,
561            audit_reports: vec![],
562            metadata: vec![],
563        });
564        print_contract_report(&a);
565    }
566
567    #[test]
568    fn test_print_report_access_control_empty_roles() {
569        let mut a = minimal_analysis();
570        a.access_control = Some(crate::contract::access::AccessControlMap {
571            ownership_pattern: Some("Ownable".to_string()),
572            has_renounced_ownership: false,
573            has_role_based_access: false,
574            uses_tx_origin: false,
575            tx_origin_locations: vec![],
576            modifiers: vec![],
577            privileged_functions: vec![],
578            roles: vec![],
579            auth_analysis: crate::contract::access::AuthAnalysis {
580                msg_sender_checks: 0,
581                tx_origin_checks: 0,
582                has_origin_sender_comparison: false,
583                summary: "No auth checks".to_string(),
584            },
585        });
586        print_contract_report(&a);
587    }
588
589    #[test]
590    fn test_print_report_external_audit_with_url() {
591        let mut a = minimal_analysis();
592        a.external_info = Some(crate::contract::external::ExternalInfo {
593            explorer_url: "https://etherscan.io/address/0xtest".to_string(),
594            github_repo: None,
595            sourcify_verified: None,
596            deployer: None,
597            audit_reports: vec![crate::contract::external::AuditReport {
598                auditor: "CertiK".to_string(),
599                scope: "Full".to_string(),
600                url: "https://certik.com/audit.pdf".to_string(),
601                date: None,
602            }],
603            metadata: vec![],
604        });
605        print_contract_report(&a);
606    }
607
608    #[test]
609    fn test_print_report_access_control_with_roles() {
610        let mut a = minimal_analysis();
611        a.access_control = Some(crate::contract::access::AccessControlMap {
612            ownership_pattern: None,
613            has_renounced_ownership: false,
614            has_role_based_access: true,
615            uses_tx_origin: false,
616            tx_origin_locations: vec![],
617            modifiers: vec![],
618            privileged_functions: vec![],
619            roles: vec!["ADMIN_ROLE".to_string(), "MINTER_ROLE".to_string()],
620            auth_analysis: crate::contract::access::AuthAnalysis {
621                msg_sender_checks: 2,
622                tx_origin_checks: 0,
623                has_origin_sender_comparison: false,
624                summary: "Role-based".to_string(),
625            },
626        });
627        print_contract_report(&a);
628    }
629
630    #[test]
631    fn test_print_report_defi_empty_token_standards() {
632        let mut a = minimal_analysis();
633        a.defi_analysis = Some(crate::contract::defi::DefiAnalysis {
634            protocol_type: crate::contract::defi::ProtocolType::Other,
635            has_oracle_dependency: false,
636            oracle_info: vec![],
637            has_flash_loan_risk: false,
638            flash_loan_info: vec![],
639            dex_integrations: vec![],
640            lending_patterns: vec![],
641            token_standards: vec![],
642            staking_patterns: vec![],
643            risk_factors: vec![],
644        });
645        print_contract_report(&a);
646    }
647
648    #[test]
649    fn test_print_report_proxy_no_impl_or_admin() {
650        let mut a = minimal_analysis();
651        a.proxy_info = Some(crate::contract::proxy::ProxyInfo {
652            is_proxy: true,
653            proxy_type: "Minimal Proxy".to_string(),
654            implementation_address: None,
655            admin_address: None,
656            beacon_address: None,
657            details: vec!["Minimal proxy".to_string()],
658        });
659        print_contract_report(&a);
660    }
661}