1use crate::contract::source::ContractSource;
11use crate::error::{Result, ScopeError};
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ExternalInfo {
17 pub github_repo: Option<String>,
19 pub audit_reports: Vec<AuditReport>,
21 pub sourcify_verified: Option<bool>,
23 pub deployer: Option<String>,
25 pub explorer_url: String,
27 pub metadata: Vec<MetadataEntry>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct AuditReport {
34 pub auditor: String,
36 pub url: String,
38 pub date: Option<String>,
40 pub scope: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct MetadataEntry {
47 pub key: String,
48 pub value: String,
49}
50
51const 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
74pub 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 let github_repo = if let Some(src) = source {
85 find_github_from_source(src)
86 } else {
87 None
88 };
89
90 let sourcify_verified = check_sourcify(address, chain, http_client).await.ok();
92
93 let audit_reports = discover_audits(address, chain, source, http_client).await;
95
96 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
133fn build_explorer_url(address: &str, chain: &str) -> String {
135 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
142fn find_github_from_source(source: &ContractSource) -> Option<String> {
144 let code = &source.source_code;
145
146 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 if source.swarm_source.contains("ipfs") {
162 }
164
165 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
189async 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 Ok(response.body.contains("perfect") || response.body.contains("partial"))
212 } else {
213 Ok(false)
214 }
215}
216
217async 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 if let Some(src) = source {
228 let code_lower = src.source_code.to_lowercase();
229
230 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 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 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}