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