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