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    // A chain with no explorer falls back to Etherscan, as before.
136    let base = crate::chains::registry::chain(chain)
137        .and_then(|c| c.explorer_address_base)
138        .unwrap_or("https://etherscan.io/address");
139    format!("{base}/{address}")
140}
141
142/// Extract GitHub repository URL from contract source code or metadata.
143fn find_github_from_source(source: &ContractSource) -> Option<String> {
144    let code = &source.source_code;
145
146    // Check for GitHub URLs in source comments
147    let github_patterns = [
148        r"https?://github\.com/[\w\-]+/[\w\-]+",
149        r"@dev\s+.*github\.com/[\w\-]+/[\w\-]+",
150    ];
151
152    for pattern in &github_patterns {
153        if let Ok(re) = regex::Regex::new(pattern)
154            && let Some(mat) = re.find(code)
155        {
156            return Some(mat.as_str().to_string());
157        }
158    }
159
160    // Check SwarmSource for IPFS content (can lead to repo via metadata)
161    if source.swarm_source.contains("ipfs") {
162        // IPFS hash doesn't directly give us a repo, but note it
163    }
164
165    // Check for well-known contract names that map to repos
166    let known_contracts: &[(&str, &str)] = &[
167        ("UniswapV2", "https://github.com/Uniswap/v2-core"),
168        ("UniswapV3", "https://github.com/Uniswap/v3-core"),
169        (
170            "Ownable",
171            "https://github.com/OpenZeppelin/openzeppelin-contracts",
172        ),
173        (
174            "Compound",
175            "https://github.com/compound-finance/compound-protocol",
176        ),
177        ("Aave", "https://github.com/aave/aave-v3-core"),
178    ];
179
180    for (name, repo) in known_contracts {
181        if code.contains(name) || source.contract_name.contains(name) {
182            return Some(repo.to_string());
183        }
184    }
185
186    None
187}
188
189/// Check if contract is verified on Sourcify.
190async fn check_sourcify(
191    address: &str,
192    chain: &str,
193    http_client: &dyn crate::http::HttpClient,
194) -> Result<bool> {
195    let Some(chain_id) = crate::chains::registry::chain(chain).and_then(|c| c.evm_chain_id) else {
196        return Ok(false);
197    };
198
199    let url = format!(
200        "https://sourcify.dev/server/check-all-by-addresses?addresses={}&chainIds={}",
201        address, chain_id
202    );
203
204    let response = http_client
205        .send(crate::http::Request::get(&url))
206        .await
207        .map_err(|e| ScopeError::Api(format!("Sourcify check failed: {}", e)))?;
208
209    if response.is_success() {
210        // Sourcify returns status "perfect" or "partial" for verified contracts
211        Ok(response.body.contains("perfect") || response.body.contains("partial"))
212    } else {
213        Ok(false)
214    }
215}
216
217/// Discover known audit reports for the contract.
218async fn discover_audits(
219    address: &str,
220    chain: &str,
221    source: Option<&ContractSource>,
222    _http_client: &dyn crate::http::HttpClient,
223) -> Vec<AuditReport> {
224    let mut reports = Vec::new();
225
226    // Check source code for audit references
227    if let Some(src) = source {
228        let code_lower = src.source_code.to_lowercase();
229
230        // Check for audit mentions in comments
231        for (auditor, url) in AUDIT_DATABASES {
232            let auditor_lower = auditor.to_lowercase();
233            if code_lower.contains(&auditor_lower) {
234                reports.push(AuditReport {
235                    auditor: auditor.to_string(),
236                    url: url.to_string(),
237                    date: None,
238                    scope: format!(
239                        "Referenced in {} source code ({})",
240                        src.contract_name, chain
241                    ),
242                });
243            }
244        }
245
246        // Check for @audit or @security tags in NatSpec
247        if code_lower.contains("@audit") || code_lower.contains("audited by") {
248            let re = regex::Regex::new(r"(?i)(?:@audit|audited\s+by)\s*:?\s*([\w\s]+)");
249            if let Ok(re) = re {
250                for cap in re.captures_iter(&src.source_code) {
251                    let auditor_name = cap[1].trim().to_string();
252                    if !reports.iter().any(|r| r.auditor == auditor_name) {
253                        reports.push(AuditReport {
254                            auditor: auditor_name,
255                            url: String::new(),
256                            date: None,
257                            scope: format!("Mentioned in {} source comments", src.contract_name),
258                        });
259                    }
260                }
261            }
262        }
263    }
264
265    // Provide audit database links even if no specific report is found
266    if reports.is_empty() {
267        reports.push(AuditReport {
268            auditor: "No audit reports found".to_string(),
269            url: format!("https://etherscan.io/address/{}#code", address),
270            date: None,
271            scope: "Check block explorer and auditor databases manually".to_string(),
272        });
273    }
274
275    reports
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::contract::source::ContractSource;
282
283    fn make_source(code: &str) -> ContractSource {
284        ContractSource {
285            contract_name: "TestContract".to_string(),
286            source_code: code.to_string(),
287            abi: "[]".to_string(),
288            compiler_version: "v0.8.19".to_string(),
289            optimization_used: true,
290            optimization_runs: 200,
291            evm_version: "paris".to_string(),
292            license_type: "MIT".to_string(),
293            is_proxy: false,
294            implementation_address: None,
295            constructor_arguments: String::new(),
296            library: String::new(),
297            swarm_source: String::new(),
298            parsed_abi: vec![],
299        }
300    }
301
302    #[test]
303    fn test_build_explorer_url() {
304        let url = build_explorer_url("0xabc", "ethereum");
305        assert_eq!(url, "https://etherscan.io/address/0xabc");
306
307        let url = build_explorer_url("0xabc", "polygon");
308        assert_eq!(url, "https://polygonscan.com/address/0xabc");
309
310        let url = build_explorer_url("0xabc", "bsc");
311        assert_eq!(url, "https://bscscan.com/address/0xabc");
312    }
313
314    #[test]
315    fn test_find_github_from_uniswap() {
316        let src = make_source("import UniswapV2Router;");
317        let repo = find_github_from_source(&src);
318        assert!(repo.is_some());
319        assert!(repo.unwrap().contains("Uniswap"));
320    }
321
322    #[test]
323    fn test_find_github_from_url() {
324        let src = make_source("// Source: https://github.com/my-org/my-contract");
325        let repo = find_github_from_source(&src);
326        assert_eq!(
327            repo,
328            Some("https://github.com/my-org/my-contract".to_string())
329        );
330    }
331
332    #[test]
333    fn test_find_github_none() {
334        let src = make_source("contract SimpleToken {}");
335        let repo = find_github_from_source(&src);
336        assert!(repo.is_none());
337    }
338
339    #[test]
340    fn test_discover_audits_from_source() {
341        let rt = tokio::runtime::Runtime::new().unwrap();
342        let src = make_source("// Audited by Trail of Bits in 2023");
343        let reports = rt.block_on(async {
344            let client = crate::http::NativeHttpClient::new().unwrap();
345            discover_audits("0xabc", "ethereum", Some(&src), &client).await
346        });
347        assert!(reports.iter().any(|r| r.auditor.contains("Trail of Bits")));
348    }
349
350    #[test]
351    fn test_build_explorer_url_all_chains() {
352        assert_eq!(
353            build_explorer_url("0xabc", "eth"),
354            "https://etherscan.io/address/0xabc"
355        );
356        assert_eq!(
357            build_explorer_url("0xabc", "matic"),
358            "https://polygonscan.com/address/0xabc"
359        );
360        assert_eq!(
361            build_explorer_url("0xabc", "arbitrum"),
362            "https://arbiscan.io/address/0xabc"
363        );
364        assert_eq!(
365            build_explorer_url("0xabc", "arb"),
366            "https://arbiscan.io/address/0xabc"
367        );
368        assert_eq!(
369            build_explorer_url("0xabc", "optimism"),
370            "https://optimistic.etherscan.io/address/0xabc"
371        );
372        assert_eq!(
373            build_explorer_url("0xabc", "op"),
374            "https://optimistic.etherscan.io/address/0xabc"
375        );
376        assert_eq!(
377            build_explorer_url("0xabc", "base"),
378            "https://basescan.org/address/0xabc"
379        );
380        assert_eq!(
381            build_explorer_url("0xabc", "bnb"),
382            "https://bscscan.com/address/0xabc"
383        );
384        assert_eq!(
385            build_explorer_url("0xabc", "unknown_chain"),
386            "https://etherscan.io/address/0xabc"
387        );
388    }
389
390    #[test]
391    fn test_find_github_from_source_ownable() {
392        let src = make_source("import Ownable; contract Token is Ownable {}");
393        let repo = find_github_from_source(&src);
394        assert!(repo.is_some());
395        assert!(repo.unwrap().contains("OpenZeppelin"));
396    }
397
398    #[test]
399    fn test_find_github_from_source_compound() {
400        let src = make_source("import Compound from './Compound.sol';");
401        let repo = find_github_from_source(&src);
402        assert!(repo.is_some());
403        assert!(repo.unwrap().contains("compound"));
404    }
405
406    #[test]
407    fn test_find_github_from_source_aave() {
408        let src = make_source("import Aave from './lending/Aave.sol';");
409        let repo = find_github_from_source(&src);
410        assert!(repo.is_some());
411        assert!(repo.unwrap().contains("aave"));
412    }
413
414    #[test]
415    fn test_find_github_from_source_uniswapv3() {
416        let src = make_source("contract Token { UniswapV3Pool pool; }");
417        let repo = find_github_from_source(&src);
418        assert!(repo.is_some());
419        assert!(repo.unwrap().contains("v3-core"));
420    }
421
422    #[test]
423    fn test_find_github_from_contract_name_match() {
424        let mut src = make_source("contract SimpleToken {}");
425        src.contract_name = "AavePoolV3".to_string();
426        let repo = find_github_from_source(&src);
427        assert!(repo.is_some());
428        assert!(repo.unwrap().contains("aave"));
429    }
430
431    #[test]
432    fn test_find_github_with_ipfs_swarm() {
433        let mut src = make_source("contract SimpleToken {}");
434        src.swarm_source = "ipfs://Qm1234567890".to_string();
435        let repo = find_github_from_source(&src);
436        assert!(repo.is_none());
437    }
438
439    #[test]
440    fn test_discover_audits_no_source() {
441        let rt = tokio::runtime::Runtime::new().unwrap();
442        let reports = rt.block_on(async {
443            let client = crate::http::NativeHttpClient::new().unwrap();
444            discover_audits("0xabc", "ethereum", None, &client).await
445        });
446        assert_eq!(reports.len(), 1);
447        assert!(reports[0].auditor.contains("No audit"));
448    }
449
450    #[test]
451    fn test_discover_audits_multiple_auditors() {
452        let rt = tokio::runtime::Runtime::new().unwrap();
453        let src = make_source("// Reviewed by Trail of Bits and OpenZeppelin");
454        let reports = rt.block_on(async {
455            let client = crate::http::NativeHttpClient::new().unwrap();
456            discover_audits("0xabc", "ethereum", Some(&src), &client).await
457        });
458        assert!(reports.len() >= 2);
459    }
460
461    #[test]
462    fn test_discover_audits_audit_tag() {
463        let rt = tokio::runtime::Runtime::new().unwrap();
464        let src = make_source("/// @audit: Spearbit\ncontract Token {}");
465        let reports = rt.block_on(async {
466            let client = crate::http::NativeHttpClient::new().unwrap();
467            discover_audits("0xabc", "ethereum", Some(&src), &client).await
468        });
469        assert!(reports.iter().any(|r| r.auditor.contains("Spearbit")));
470    }
471
472    #[test]
473    fn test_discover_audits_audited_by_tag() {
474        let rt = tokio::runtime::Runtime::new().unwrap();
475        let src = make_source("// audited by CustomAuditor\ncontract Token {}");
476        let reports = rt.block_on(async {
477            let client = crate::http::NativeHttpClient::new().unwrap();
478            discover_audits("0xabc", "ethereum", Some(&src), &client).await
479        });
480        assert!(reports.iter().any(|r| r.auditor.contains("CustomAuditor")));
481    }
482
483    #[test]
484    fn test_discover_audits_no_duplicate() {
485        let rt = tokio::runtime::Runtime::new().unwrap();
486        let src = make_source("// audited by Trail of Bits\n// Trail of Bits reviewed");
487        let reports = rt.block_on(async {
488            let client = crate::http::NativeHttpClient::new().unwrap();
489            discover_audits("0xabc", "ethereum", Some(&src), &client).await
490        });
491        let tob_count = reports
492            .iter()
493            .filter(|r| r.auditor.contains("Trail of Bits"))
494            .count();
495        assert!(tob_count >= 1);
496    }
497
498    #[test]
499    fn test_external_info_struct() {
500        let info = ExternalInfo {
501            github_repo: Some("https://github.com/test/repo".to_string()),
502            audit_reports: vec![],
503            sourcify_verified: Some(true),
504            deployer: Some("0xdead".to_string()),
505            explorer_url: "https://etherscan.io/address/0x1".to_string(),
506            metadata: vec![MetadataEntry {
507                key: "k".to_string(),
508                value: "v".to_string(),
509            }],
510        };
511        assert!(info.github_repo.is_some());
512        assert!(info.deployer.is_some());
513        assert_eq!(info.metadata.len(), 1);
514    }
515
516    #[test]
517    fn test_audit_report_struct() {
518        let report = AuditReport {
519            auditor: "ToB".to_string(),
520            url: "https://tob.com".to_string(),
521            date: Some("2024-01-01".to_string()),
522            scope: "Full protocol".to_string(),
523        };
524        assert_eq!(report.auditor, "ToB");
525        assert!(report.date.is_some());
526    }
527
528    #[test]
529    fn test_find_github_from_dev_natspec() {
530        let src = make_source(
531            "/** @dev See https://github.com/MyOrg/MyContract for full source */\ncontract C {}",
532        );
533        let repo = find_github_from_source(&src);
534        assert_eq!(
535            repo,
536            Some("https://github.com/MyOrg/MyContract".to_string())
537        );
538    }
539
540    #[test]
541    fn test_metadata_entry_struct() {
542        let entry = MetadataEntry {
543            key: "chain".to_string(),
544            value: "ethereum".to_string(),
545        };
546        assert_eq!(entry.key, "chain");
547        let cloned = entry.clone();
548        assert_eq!(format!("{:?}", cloned), format!("{:?}", entry));
549    }
550
551    #[tokio::test]
552    async fn test_gather_external_info_with_source() {
553        let src = make_source("contract Test {}");
554        let client = crate::http::NativeHttpClient::new().unwrap();
555        let result = gather_external_info("0xabc", "ethereum", Some(&src), &client)
556            .await
557            .unwrap();
558        assert!(result.explorer_url.contains("etherscan"));
559        assert!(result.metadata.iter().any(|m| m.key == "Contract Name"));
560        assert!(result.metadata.iter().any(|m| m.key == "Compiler"));
561        assert!(result.metadata.iter().any(|m| m.key == "License"));
562        assert!(result.metadata.iter().any(|m| m.key == "EVM Version"));
563        assert!(result.metadata.iter().any(|m| m.key == "Optimization"));
564    }
565
566    #[tokio::test]
567    async fn test_gather_external_info_without_optimization() {
568        let mut src = make_source("contract Test {}");
569        src.optimization_used = false;
570        let client = crate::http::NativeHttpClient::new().unwrap();
571        let result = gather_external_info("0xabc", "polygon", Some(&src), &client)
572            .await
573            .unwrap();
574        let has_opt = result.metadata.iter().any(|m| m.key == "Optimization");
575        assert!(!has_opt);
576        assert!(result.explorer_url.contains("polygonscan"));
577    }
578
579    #[tokio::test]
580    async fn test_gather_external_info_no_source() {
581        let client = crate::http::NativeHttpClient::new().unwrap();
582        let result = gather_external_info("0xabc", "ethereum", None, &client)
583            .await
584            .unwrap();
585        assert!(result.metadata.is_empty());
586        assert!(result.github_repo.is_none());
587    }
588}