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 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
146fn find_github_from_source(source: &ContractSource) -> Option<String> {
148 let code = &source.source_code;
149
150 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 if source.swarm_source.contains("ipfs") {
166 }
168
169 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
193async 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 Ok(response.body.contains("perfect") || response.body.contains("partial"))
222 } else {
223 Ok(false)
224 }
225}
226
227async 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 if let Some(src) = source {
238 let code_lower = src.source_code.to_lowercase();
239
240 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 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 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}