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: &reqwest::Client,
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(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 Ok(body.contains("perfect") || body.contains("partial"))
223 } else {
224 Ok(false)
225 }
226}
227
228async 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 if let Some(src) = source {
239 let code_lower = src.source_code.to_lowercase();
240
241 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 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 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}