Skip to main content

scope/contract/
external.rs

1//! # External Intelligence
2//!
3//! Gathers external information about smart contracts including:
4//!
5//! - **GitHub repository linking** - Finds associated source code repos
6//! - **Audit report discovery** - Checks known audit databases
7//! - **Sourcify verification** - Cross-references with Sourcify
8//! - **Contract metadata** - License, creation date, deployer
9
10use crate::contract::source::ContractSource;
11use crate::error::{Result, ScopeError};
12use serde::{Deserialize, Serialize};
13
14/// Aggregated external intelligence for a contract.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ExternalInfo {
17    /// GitHub repository URL (if found).
18    pub github_repo: Option<String>,
19    /// Known audit reports.
20    pub audit_reports: Vec<AuditReport>,
21    /// Sourcify verification status.
22    pub sourcify_verified: Option<bool>,
23    /// Contract deployer address.
24    pub deployer: Option<String>,
25    /// Block explorer URL.
26    pub explorer_url: String,
27    /// Additional metadata.
28    pub metadata: Vec<MetadataEntry>,
29}
30
31/// An audit report reference.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct AuditReport {
34    /// Auditor name.
35    pub auditor: String,
36    /// Report URL or reference.
37    pub url: String,
38    /// Date of audit (if known).
39    pub date: Option<String>,
40    /// Scope of the audit.
41    pub scope: String,
42}
43
44/// A generic metadata entry.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct MetadataEntry {
47    pub key: String,
48    pub value: String,
49}
50
51/// Known audit firms and their public report databases.
52const AUDIT_DATABASES: &[(&str, &str)] = &[
53    (
54        "Trail of Bits",
55        "https://github.com/trailofbits/publications/tree/master/reviews",
56    ),
57    (
58        "OpenZeppelin",
59        "https://blog.openzeppelin.com/security-audits",
60    ),
61    (
62        "Consensys Diligence",
63        "https://consensys.io/diligence/audits",
64    ),
65    ("CertiK", "https://www.certik.com/projects"),
66    ("PeckShield", "https://github.com/peckshield/publications"),
67    ("Quantstamp", "https://certificate.quantstamp.com"),
68    ("Halborn", "https://www.halborn.com/audits"),
69    ("Spearbit", "https://github.com/spearbit/portfolio"),
70    ("Code4rena", "https://code4rena.com/reports"),
71    ("Sherlock", "https://audits.sherlock.xyz/contests"),
72];
73
74/// Gather external intelligence for a contract.
75pub async fn gather_external_info(
76    address: &str,
77    chain: &str,
78    source: Option<&ContractSource>,
79    http_client: &dyn crate::http::HttpClient,
80) -> Result<ExternalInfo> {
81    let explorer_url = build_explorer_url(address, chain);
82
83    // Try to find GitHub repo from source metadata
84    let github_repo = if let Some(src) = source {
85        find_github_from_source(src)
86    } else {
87        None
88    };
89
90    // Check Sourcify verification
91    let sourcify_verified = check_sourcify(address, chain, http_client).await.ok();
92
93    // Discover audit reports
94    let audit_reports = discover_audits(address, chain, source, http_client).await;
95
96    // Build metadata
97    let mut metadata = Vec::new();
98    if let Some(src) = source {
99        metadata.push(MetadataEntry {
100            key: "Contract Name".to_string(),
101            value: src.contract_name.clone(),
102        });
103        metadata.push(MetadataEntry {
104            key: "Compiler".to_string(),
105            value: src.compiler_version.clone(),
106        });
107        metadata.push(MetadataEntry {
108            key: "License".to_string(),
109            value: src.license_type.clone(),
110        });
111        if src.optimization_used {
112            metadata.push(MetadataEntry {
113                key: "Optimization".to_string(),
114                value: format!("Enabled ({} runs)", src.optimization_runs),
115            });
116        }
117        metadata.push(MetadataEntry {
118            key: "EVM Version".to_string(),
119            value: src.evm_version.clone(),
120        });
121    }
122
123    Ok(ExternalInfo {
124        github_repo,
125        audit_reports,
126        sourcify_verified,
127        deployer: None,
128        explorer_url,
129        metadata,
130    })
131}
132
133/// Build a block explorer URL for the contract.
134fn build_explorer_url(address: &str, chain: &str) -> String {
135    match chain.to_lowercase().as_str() {
136        "ethereum" | "eth" => format!("https://etherscan.io/address/{}", address),
137        "polygon" | "matic" => format!("https://polygonscan.com/address/{}", address),
138        "arbitrum" | "arb" => format!("https://arbiscan.io/address/{}", address),
139        "optimism" | "op" => format!("https://optimistic.etherscan.io/address/{}", address),
140        "base" => format!("https://basescan.org/address/{}", address),
141        "bsc" | "bnb" => format!("https://bscscan.com/address/{}", address),
142        _ => format!("https://etherscan.io/address/{}", address),
143    }
144}
145
146/// Extract GitHub repository URL from contract source code or metadata.
147fn find_github_from_source(source: &ContractSource) -> Option<String> {
148    let code = &source.source_code;
149
150    // Check for GitHub URLs in source comments
151    let github_patterns = [
152        r"https?://github\.com/[\w\-]+/[\w\-]+",
153        r"@dev\s+.*github\.com/[\w\-]+/[\w\-]+",
154    ];
155
156    for pattern in &github_patterns {
157        if let Ok(re) = regex::Regex::new(pattern)
158            && let Some(mat) = re.find(code)
159        {
160            return Some(mat.as_str().to_string());
161        }
162    }
163
164    // Check SwarmSource for IPFS content (can lead to repo via metadata)
165    if source.swarm_source.contains("ipfs") {
166        // IPFS hash doesn't directly give us a repo, but note it
167    }
168
169    // Check for well-known contract names that map to repos
170    let known_contracts: &[(&str, &str)] = &[
171        ("UniswapV2", "https://github.com/Uniswap/v2-core"),
172        ("UniswapV3", "https://github.com/Uniswap/v3-core"),
173        (
174            "Ownable",
175            "https://github.com/OpenZeppelin/openzeppelin-contracts",
176        ),
177        (
178            "Compound",
179            "https://github.com/compound-finance/compound-protocol",
180        ),
181        ("Aave", "https://github.com/aave/aave-v3-core"),
182    ];
183
184    for (name, repo) in known_contracts {
185        if code.contains(name) || source.contract_name.contains(name) {
186            return Some(repo.to_string());
187        }
188    }
189
190    None
191}
192
193/// Check if contract is verified on Sourcify.
194async fn check_sourcify(
195    address: &str,
196    chain: &str,
197    http_client: &dyn crate::http::HttpClient,
198) -> Result<bool> {
199    let chain_id = match chain.to_lowercase().as_str() {
200        "ethereum" | "eth" => "1",
201        "polygon" | "matic" => "137",
202        "arbitrum" | "arb" => "42161",
203        "optimism" | "op" => "10",
204        "base" => "8453",
205        "bsc" | "bnb" => "56",
206        _ => return Ok(false),
207    };
208
209    let url = format!(
210        "https://sourcify.dev/server/check-all-by-addresses?addresses={}&chainIds={}",
211        address, chain_id
212    );
213
214    let response = http_client
215        .send(crate::http::Request::get(&url))
216        .await
217        .map_err(|e| ScopeError::Api(format!("Sourcify check failed: {}", e)))?;
218
219    if response.is_success() {
220        // Sourcify returns status "perfect" or "partial" for verified contracts
221        Ok(response.body.contains("perfect") || response.body.contains("partial"))
222    } else {
223        Ok(false)
224    }
225}
226
227/// Discover known audit reports for the contract.
228async fn discover_audits(
229    address: &str,
230    chain: &str,
231    source: Option<&ContractSource>,
232    _http_client: &dyn crate::http::HttpClient,
233) -> Vec<AuditReport> {
234    let mut reports = Vec::new();
235
236    // Check source code for audit references
237    if let Some(src) = source {
238        let code_lower = src.source_code.to_lowercase();
239
240        // Check for audit mentions in comments
241        for (auditor, url) in AUDIT_DATABASES {
242            let auditor_lower = auditor.to_lowercase();
243            if code_lower.contains(&auditor_lower) {
244                reports.push(AuditReport {
245                    auditor: auditor.to_string(),
246                    url: url.to_string(),
247                    date: None,
248                    scope: format!(
249                        "Referenced in {} source code ({})",
250                        src.contract_name, chain
251                    ),
252                });
253            }
254        }
255
256        // Check for @audit or @security tags in NatSpec
257        if code_lower.contains("@audit") || code_lower.contains("audited by") {
258            let re = regex::Regex::new(r"(?i)(?:@audit|audited\s+by)\s*:?\s*([\w\s]+)");
259            if let Ok(re) = re {
260                for cap in re.captures_iter(&src.source_code) {
261                    let auditor_name = cap[1].trim().to_string();
262                    if !reports.iter().any(|r| r.auditor == auditor_name) {
263                        reports.push(AuditReport {
264                            auditor: auditor_name,
265                            url: String::new(),
266                            date: None,
267                            scope: format!("Mentioned in {} source comments", src.contract_name),
268                        });
269                    }
270                }
271            }
272        }
273    }
274
275    // Provide audit database links even if no specific report is found
276    if reports.is_empty() {
277        reports.push(AuditReport {
278            auditor: "No audit reports found".to_string(),
279            url: format!("https://etherscan.io/address/{}#code", address),
280            date: None,
281            scope: "Check block explorer and auditor databases manually".to_string(),
282        });
283    }
284
285    reports
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::contract::source::ContractSource;
292
293    fn make_source(code: &str) -> ContractSource {
294        ContractSource {
295            contract_name: "TestContract".to_string(),
296            source_code: code.to_string(),
297            abi: "[]".to_string(),
298            compiler_version: "v0.8.19".to_string(),
299            optimization_used: true,
300            optimization_runs: 200,
301            evm_version: "paris".to_string(),
302            license_type: "MIT".to_string(),
303            is_proxy: false,
304            implementation_address: None,
305            constructor_arguments: String::new(),
306            library: String::new(),
307            swarm_source: String::new(),
308            parsed_abi: vec![],
309        }
310    }
311
312    #[test]
313    fn test_build_explorer_url() {
314        let url = build_explorer_url("0xabc", "ethereum");
315        assert_eq!(url, "https://etherscan.io/address/0xabc");
316
317        let url = build_explorer_url("0xabc", "polygon");
318        assert_eq!(url, "https://polygonscan.com/address/0xabc");
319
320        let url = build_explorer_url("0xabc", "bsc");
321        assert_eq!(url, "https://bscscan.com/address/0xabc");
322    }
323
324    #[test]
325    fn test_find_github_from_uniswap() {
326        let src = make_source("import UniswapV2Router;");
327        let repo = find_github_from_source(&src);
328        assert!(repo.is_some());
329        assert!(repo.unwrap().contains("Uniswap"));
330    }
331
332    #[test]
333    fn test_find_github_from_url() {
334        let src = make_source("// Source: https://github.com/my-org/my-contract");
335        let repo = find_github_from_source(&src);
336        assert_eq!(
337            repo,
338            Some("https://github.com/my-org/my-contract".to_string())
339        );
340    }
341
342    #[test]
343    fn test_find_github_none() {
344        let src = make_source("contract SimpleToken {}");
345        let repo = find_github_from_source(&src);
346        assert!(repo.is_none());
347    }
348
349    #[test]
350    fn test_discover_audits_from_source() {
351        let rt = tokio::runtime::Runtime::new().unwrap();
352        let src = make_source("// Audited by Trail of Bits in 2023");
353        let reports = rt.block_on(async {
354            let client = crate::http::NativeHttpClient::new().unwrap();
355            discover_audits("0xabc", "ethereum", Some(&src), &client).await
356        });
357        assert!(reports.iter().any(|r| r.auditor.contains("Trail of Bits")));
358    }
359
360    #[test]
361    fn test_build_explorer_url_all_chains() {
362        assert_eq!(
363            build_explorer_url("0xabc", "eth"),
364            "https://etherscan.io/address/0xabc"
365        );
366        assert_eq!(
367            build_explorer_url("0xabc", "matic"),
368            "https://polygonscan.com/address/0xabc"
369        );
370        assert_eq!(
371            build_explorer_url("0xabc", "arbitrum"),
372            "https://arbiscan.io/address/0xabc"
373        );
374        assert_eq!(
375            build_explorer_url("0xabc", "arb"),
376            "https://arbiscan.io/address/0xabc"
377        );
378        assert_eq!(
379            build_explorer_url("0xabc", "optimism"),
380            "https://optimistic.etherscan.io/address/0xabc"
381        );
382        assert_eq!(
383            build_explorer_url("0xabc", "op"),
384            "https://optimistic.etherscan.io/address/0xabc"
385        );
386        assert_eq!(
387            build_explorer_url("0xabc", "base"),
388            "https://basescan.org/address/0xabc"
389        );
390        assert_eq!(
391            build_explorer_url("0xabc", "bnb"),
392            "https://bscscan.com/address/0xabc"
393        );
394        assert_eq!(
395            build_explorer_url("0xabc", "unknown_chain"),
396            "https://etherscan.io/address/0xabc"
397        );
398    }
399
400    #[test]
401    fn test_find_github_from_source_ownable() {
402        let src = make_source("import Ownable; contract Token is Ownable {}");
403        let repo = find_github_from_source(&src);
404        assert!(repo.is_some());
405        assert!(repo.unwrap().contains("OpenZeppelin"));
406    }
407
408    #[test]
409    fn test_find_github_from_source_compound() {
410        let src = make_source("import Compound from './Compound.sol';");
411        let repo = find_github_from_source(&src);
412        assert!(repo.is_some());
413        assert!(repo.unwrap().contains("compound"));
414    }
415
416    #[test]
417    fn test_find_github_from_source_aave() {
418        let src = make_source("import Aave from './lending/Aave.sol';");
419        let repo = find_github_from_source(&src);
420        assert!(repo.is_some());
421        assert!(repo.unwrap().contains("aave"));
422    }
423
424    #[test]
425    fn test_find_github_from_source_uniswapv3() {
426        let src = make_source("contract Token { UniswapV3Pool pool; }");
427        let repo = find_github_from_source(&src);
428        assert!(repo.is_some());
429        assert!(repo.unwrap().contains("v3-core"));
430    }
431
432    #[test]
433    fn test_find_github_from_contract_name_match() {
434        let mut src = make_source("contract SimpleToken {}");
435        src.contract_name = "AavePoolV3".to_string();
436        let repo = find_github_from_source(&src);
437        assert!(repo.is_some());
438        assert!(repo.unwrap().contains("aave"));
439    }
440
441    #[test]
442    fn test_find_github_with_ipfs_swarm() {
443        let mut src = make_source("contract SimpleToken {}");
444        src.swarm_source = "ipfs://Qm1234567890".to_string();
445        let repo = find_github_from_source(&src);
446        assert!(repo.is_none());
447    }
448
449    #[test]
450    fn test_discover_audits_no_source() {
451        let rt = tokio::runtime::Runtime::new().unwrap();
452        let reports = rt.block_on(async {
453            let client = crate::http::NativeHttpClient::new().unwrap();
454            discover_audits("0xabc", "ethereum", None, &client).await
455        });
456        assert_eq!(reports.len(), 1);
457        assert!(reports[0].auditor.contains("No audit"));
458    }
459
460    #[test]
461    fn test_discover_audits_multiple_auditors() {
462        let rt = tokio::runtime::Runtime::new().unwrap();
463        let src = make_source("// Reviewed by Trail of Bits and OpenZeppelin");
464        let reports = rt.block_on(async {
465            let client = crate::http::NativeHttpClient::new().unwrap();
466            discover_audits("0xabc", "ethereum", Some(&src), &client).await
467        });
468        assert!(reports.len() >= 2);
469    }
470
471    #[test]
472    fn test_discover_audits_audit_tag() {
473        let rt = tokio::runtime::Runtime::new().unwrap();
474        let src = make_source("/// @audit: Spearbit\ncontract Token {}");
475        let reports = rt.block_on(async {
476            let client = crate::http::NativeHttpClient::new().unwrap();
477            discover_audits("0xabc", "ethereum", Some(&src), &client).await
478        });
479        assert!(reports.iter().any(|r| r.auditor.contains("Spearbit")));
480    }
481
482    #[test]
483    fn test_discover_audits_audited_by_tag() {
484        let rt = tokio::runtime::Runtime::new().unwrap();
485        let src = make_source("// audited by CustomAuditor\ncontract Token {}");
486        let reports = rt.block_on(async {
487            let client = crate::http::NativeHttpClient::new().unwrap();
488            discover_audits("0xabc", "ethereum", Some(&src), &client).await
489        });
490        assert!(reports.iter().any(|r| r.auditor.contains("CustomAuditor")));
491    }
492
493    #[test]
494    fn test_discover_audits_no_duplicate() {
495        let rt = tokio::runtime::Runtime::new().unwrap();
496        let src = make_source("// audited by Trail of Bits\n// Trail of Bits reviewed");
497        let reports = rt.block_on(async {
498            let client = crate::http::NativeHttpClient::new().unwrap();
499            discover_audits("0xabc", "ethereum", Some(&src), &client).await
500        });
501        let tob_count = reports
502            .iter()
503            .filter(|r| r.auditor.contains("Trail of Bits"))
504            .count();
505        assert!(tob_count >= 1);
506    }
507
508    #[test]
509    fn test_external_info_struct() {
510        let info = ExternalInfo {
511            github_repo: Some("https://github.com/test/repo".to_string()),
512            audit_reports: vec![],
513            sourcify_verified: Some(true),
514            deployer: Some("0xdead".to_string()),
515            explorer_url: "https://etherscan.io/address/0x1".to_string(),
516            metadata: vec![MetadataEntry {
517                key: "k".to_string(),
518                value: "v".to_string(),
519            }],
520        };
521        assert!(info.github_repo.is_some());
522        assert!(info.deployer.is_some());
523        assert_eq!(info.metadata.len(), 1);
524    }
525
526    #[test]
527    fn test_audit_report_struct() {
528        let report = AuditReport {
529            auditor: "ToB".to_string(),
530            url: "https://tob.com".to_string(),
531            date: Some("2024-01-01".to_string()),
532            scope: "Full protocol".to_string(),
533        };
534        assert_eq!(report.auditor, "ToB");
535        assert!(report.date.is_some());
536    }
537
538    #[test]
539    fn test_find_github_from_dev_natspec() {
540        let src = make_source(
541            "/** @dev See https://github.com/MyOrg/MyContract for full source */\ncontract C {}",
542        );
543        let repo = find_github_from_source(&src);
544        assert_eq!(
545            repo,
546            Some("https://github.com/MyOrg/MyContract".to_string())
547        );
548    }
549
550    #[test]
551    fn test_metadata_entry_struct() {
552        let entry = MetadataEntry {
553            key: "chain".to_string(),
554            value: "ethereum".to_string(),
555        };
556        assert_eq!(entry.key, "chain");
557        let cloned = entry.clone();
558        assert_eq!(format!("{:?}", cloned), format!("{:?}", entry));
559    }
560
561    #[tokio::test]
562    async fn test_gather_external_info_with_source() {
563        let src = make_source("contract Test {}");
564        let client = crate::http::NativeHttpClient::new().unwrap();
565        let result = gather_external_info("0xabc", "ethereum", Some(&src), &client)
566            .await
567            .unwrap();
568        assert!(result.explorer_url.contains("etherscan"));
569        assert!(result.metadata.iter().any(|m| m.key == "Contract Name"));
570        assert!(result.metadata.iter().any(|m| m.key == "Compiler"));
571        assert!(result.metadata.iter().any(|m| m.key == "License"));
572        assert!(result.metadata.iter().any(|m| m.key == "EVM Version"));
573        assert!(result.metadata.iter().any(|m| m.key == "Optimization"));
574    }
575
576    #[tokio::test]
577    async fn test_gather_external_info_without_optimization() {
578        let mut src = make_source("contract Test {}");
579        src.optimization_used = false;
580        let client = crate::http::NativeHttpClient::new().unwrap();
581        let result = gather_external_info("0xabc", "polygon", Some(&src), &client)
582            .await
583            .unwrap();
584        let has_opt = result.metadata.iter().any(|m| m.key == "Optimization");
585        assert!(!has_opt);
586        assert!(result.explorer_url.contains("polygonscan"));
587    }
588
589    #[tokio::test]
590    async fn test_gather_external_info_no_source() {
591        let client = crate::http::NativeHttpClient::new().unwrap();
592        let result = gather_external_info("0xabc", "ethereum", None, &client)
593            .await
594            .unwrap();
595        assert!(result.metadata.is_empty());
596        assert!(result.github_repo.is_none());
597    }
598}