1use crate::error::{Result, ScopeError};
14use serde::{Deserialize, Serialize};
15
16const ETHERSCAN_V2_API: &str = "https://api.etherscan.io/v2/api";
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ContractSource {
22 pub contract_name: String,
24 pub source_code: String,
26 pub abi: String,
28 pub compiler_version: String,
30 pub optimization_used: bool,
32 pub optimization_runs: u32,
34 pub evm_version: String,
36 pub license_type: String,
38 pub is_proxy: bool,
40 pub implementation_address: Option<String>,
42 pub constructor_arguments: String,
44 pub library: String,
46 pub swarm_source: String,
48 pub parsed_abi: Vec<AbiEntry>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct AbiEntry {
55 #[serde(rename = "type")]
57 pub entry_type: String,
58 #[serde(default)]
60 pub name: String,
61 #[serde(default)]
63 pub inputs: Vec<AbiParam>,
64 #[serde(default)]
66 pub outputs: Vec<AbiParam>,
67 #[serde(default, rename = "stateMutability")]
69 pub state_mutability: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct AbiParam {
75 pub name: String,
77 #[serde(rename = "type")]
79 pub param_type: String,
80 #[serde(default)]
82 pub indexed: bool,
83 #[serde(default)]
85 pub components: Vec<AbiParam>,
86}
87
88impl AbiEntry {
89 pub fn signature(&self) -> String {
91 let params: Vec<String> = self.inputs.iter().map(|p| p.canonical_type()).collect();
92 format!("{}({})", self.name, params.join(","))
93 }
94
95 pub fn selector(&self) -> String {
97 let sig = self.signature();
98 let hash = sha2_256(sig.as_bytes());
99 format!("0x{}", hex::encode(&hash[..4]))
100 }
101
102 pub fn is_state_changing(&self) -> bool {
104 self.entry_type == "function"
105 && self.state_mutability != "view"
106 && self.state_mutability != "pure"
107 }
108}
109
110impl AbiParam {
111 fn canonical_type(&self) -> String {
113 if self.param_type == "tuple" {
114 let components: Vec<String> =
115 self.components.iter().map(|c| c.canonical_type()).collect();
116 format!("({})", components.join(","))
117 } else {
118 self.param_type.clone()
119 }
120 }
121}
122
123fn sha2_256(data: &[u8]) -> [u8; 32] {
125 use sha2::{Digest, Sha256};
126 let mut hasher = Sha256::new();
127 hasher.update(data);
128 let result = hasher.finalize();
129 let mut out = [0u8; 32];
130 out.copy_from_slice(&result);
131 out
132}
133
134fn chain_to_etherscan_id(chain: &str) -> Option<&'static str> {
136 crate::chains::registry::chain(chain)?.evm_chain_id
137}
138
139#[derive(Deserialize)]
141struct EtherscanSourceResponse {
142 status: String,
143 #[allow(dead_code)]
144 message: String,
145 result: Vec<EtherscanSourceResult>,
146}
147
148#[derive(Deserialize)]
149struct EtherscanSourceResult {
150 #[serde(rename = "SourceCode")]
151 source_code: String,
152 #[serde(rename = "ABI")]
153 abi: String,
154 #[serde(rename = "ContractName")]
155 contract_name: String,
156 #[serde(rename = "CompilerVersion")]
157 compiler_version: String,
158 #[serde(rename = "OptimizationUsed")]
159 optimization_used: String,
160 #[serde(rename = "Runs")]
161 runs: String,
162 #[serde(rename = "ConstructorArguments")]
163 constructor_arguments: String,
164 #[serde(rename = "EVMVersion")]
165 evm_version: String,
166 #[serde(rename = "Library")]
167 library: String,
168 #[serde(rename = "LicenseType")]
169 license_type: String,
170 #[serde(rename = "Proxy")]
171 proxy: String,
172 #[serde(rename = "Implementation")]
173 implementation: String,
174 #[serde(rename = "SwarmSource")]
175 swarm_source: String,
176}
177
178pub async fn fetch_contract_source(
183 address: &str,
184 chain: &str,
185 http_client: &dyn crate::http::HttpClient,
186) -> Result<ContractSource> {
187 let chain_id = chain_to_etherscan_id(chain).ok_or_else(|| {
188 ScopeError::Chain(format!(
189 "Chain '{}' does not have Etherscan source code support",
190 chain
191 ))
192 })?;
193
194 let api_key = crate::compliance::etherscan::etherscan_key().unwrap_or_default();
195 let url = format!(
196 "{}?chainid={}&module=contract&action=getsourcecode&address={}&apikey={}",
197 ETHERSCAN_V2_API, chain_id, address, api_key
198 );
199
200 let response = http_client
201 .send(crate::http::Request::get(&url))
202 .await
203 .map_err(|e| ScopeError::Api(format!("Etherscan source fetch failed: {}", e)))?;
204
205 let text = &response.body;
206
207 let api_response: EtherscanSourceResponse = serde_json::from_str(text)
208 .map_err(|e| ScopeError::Api(format!("Failed to parse Etherscan response: {}", e)))?;
209
210 if api_response.status != "1" || api_response.result.is_empty() {
211 return Err(ScopeError::NotFound(format!(
212 "Contract source not verified for {} on {}",
213 address, chain
214 )));
215 }
216
217 let result = &api_response.result[0];
218
219 if result.source_code.is_empty() || result.abi == "Contract source code not verified" {
221 return Err(ScopeError::NotFound(format!(
222 "Contract {} is not verified on {} Etherscan",
223 address, chain
224 )));
225 }
226
227 let parsed_abi: Vec<AbiEntry> = if result.abi.starts_with('[') {
229 serde_json::from_str(&result.abi).unwrap_or_default()
230 } else {
231 Vec::new()
232 };
233
234 let optimization_used = result.optimization_used == "1";
235 let optimization_runs: u32 = result.runs.parse().unwrap_or(200);
236
237 let implementation_address = if !result.implementation.is_empty() {
238 Some(result.implementation.clone())
239 } else {
240 None
241 };
242
243 Ok(ContractSource {
244 contract_name: result.contract_name.clone(),
245 source_code: result.source_code.clone(),
246 abi: result.abi.clone(),
247 compiler_version: result.compiler_version.clone(),
248 optimization_used,
249 optimization_runs,
250 evm_version: result.evm_version.clone(),
251 license_type: result.license_type.clone(),
252 is_proxy: result.proxy == "1",
253 implementation_address,
254 constructor_arguments: result.constructor_arguments.clone(),
255 library: result.library.clone(),
256 swarm_source: result.swarm_source.clone(),
257 parsed_abi,
258 })
259}
260
261pub fn extract_source_files(source: &ContractSource) -> Vec<SourceFile> {
268 let code = &source.source_code;
269
270 if code.starts_with("{{") && code.ends_with("}}") {
272 let inner = &code[1..code.len() - 1];
274 if let Ok(standard_json) = serde_json::from_str::<serde_json::Value>(inner) {
275 return extract_from_standard_json(&standard_json);
276 }
277 }
278
279 if code.starts_with('{')
281 && let Ok(json) = serde_json::from_str::<serde_json::Value>(code)
282 {
283 return extract_from_standard_json(&json);
284 }
285
286 vec![SourceFile {
288 path: format!("{}.sol", source.contract_name),
289 content: code.clone(),
290 }]
291}
292
293#[derive(Debug, Clone)]
295pub struct SourceFile {
296 pub path: String,
298 pub content: String,
300}
301
302fn extract_from_standard_json(json: &serde_json::Value) -> Vec<SourceFile> {
303 let mut files = Vec::new();
304
305 if let Some(sources) = json.get("sources").and_then(|s| s.as_object()) {
306 for (path, source) in sources {
307 if let Some(content) = source.get("content").and_then(|c| c.as_str()) {
308 files.push(SourceFile {
309 path: path.clone(),
310 content: content.to_string(),
311 });
312 }
313 }
314 }
315
316 files
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn test_chain_to_etherscan_id() {
325 assert_eq!(chain_to_etherscan_id("ethereum"), Some("1"));
326 assert_eq!(chain_to_etherscan_id("polygon"), Some("137"));
327 assert_eq!(chain_to_etherscan_id("bsc"), Some("56"));
328 assert_eq!(chain_to_etherscan_id("solana"), None);
329 }
330
331 #[test]
332 fn test_abi_entry_signature() {
333 let entry = AbiEntry {
334 entry_type: "function".to_string(),
335 name: "transfer".to_string(),
336 inputs: vec![
337 AbiParam {
338 name: "to".to_string(),
339 param_type: "address".to_string(),
340 indexed: false,
341 components: vec![],
342 },
343 AbiParam {
344 name: "amount".to_string(),
345 param_type: "uint256".to_string(),
346 indexed: false,
347 components: vec![],
348 },
349 ],
350 outputs: vec![],
351 state_mutability: "nonpayable".to_string(),
352 };
353 assert_eq!(entry.signature(), "transfer(address,uint256)");
354 }
355
356 #[test]
357 fn test_abi_entry_is_state_changing() {
358 let view_fn = AbiEntry {
359 entry_type: "function".to_string(),
360 name: "balanceOf".to_string(),
361 inputs: vec![],
362 outputs: vec![],
363 state_mutability: "view".to_string(),
364 };
365 assert!(!view_fn.is_state_changing());
366
367 let write_fn = AbiEntry {
368 entry_type: "function".to_string(),
369 name: "transfer".to_string(),
370 inputs: vec![],
371 outputs: vec![],
372 state_mutability: "nonpayable".to_string(),
373 };
374 assert!(write_fn.is_state_changing());
375 }
376
377 #[test]
378 fn test_extract_single_file_source() {
379 let source = ContractSource {
380 contract_name: "TestToken".to_string(),
381 source_code: "pragma solidity ^0.8.0;\ncontract TestToken {}".to_string(),
382 abi: "[]".to_string(),
383 compiler_version: "v0.8.19".to_string(),
384 optimization_used: true,
385 optimization_runs: 200,
386 evm_version: "paris".to_string(),
387 license_type: "MIT".to_string(),
388 is_proxy: false,
389 implementation_address: None,
390 constructor_arguments: String::new(),
391 library: String::new(),
392 swarm_source: String::new(),
393 parsed_abi: vec![],
394 };
395 let files = extract_source_files(&source);
396 assert_eq!(files.len(), 1);
397 assert_eq!(files[0].path, "TestToken.sol");
398 assert!(files[0].content.contains("pragma solidity"));
399 }
400
401 #[test]
402 fn test_extract_multi_file_source() {
403 let json_source = r#"{"sources":{"contracts/Token.sol":{"content":"pragma solidity ^0.8.0;"},"contracts/Lib.sol":{"content":"library Lib {}"}}}"#;
404 let wrapped = format!("{{{}}}", json_source);
405 let source = ContractSource {
406 contract_name: "Token".to_string(),
407 source_code: wrapped,
408 abi: "[]".to_string(),
409 compiler_version: "v0.8.19".to_string(),
410 optimization_used: true,
411 optimization_runs: 200,
412 evm_version: "paris".to_string(),
413 license_type: "MIT".to_string(),
414 is_proxy: false,
415 implementation_address: None,
416 constructor_arguments: String::new(),
417 library: String::new(),
418 swarm_source: String::new(),
419 parsed_abi: vec![],
420 };
421 let files = extract_source_files(&source);
422 assert_eq!(files.len(), 2);
423 }
424
425 #[test]
426 fn test_chain_to_etherscan_id_all() {
427 assert_eq!(chain_to_etherscan_id("ethereum"), Some("1"));
428 assert_eq!(chain_to_etherscan_id("eth"), Some("1"));
429 assert_eq!(chain_to_etherscan_id("polygon"), Some("137"));
430 assert_eq!(chain_to_etherscan_id("matic"), Some("137"));
431 assert_eq!(chain_to_etherscan_id("arbitrum"), Some("42161"));
432 assert_eq!(chain_to_etherscan_id("arb"), Some("42161"));
433 assert_eq!(chain_to_etherscan_id("optimism"), Some("10"));
434 assert_eq!(chain_to_etherscan_id("op"), Some("10"));
435 assert_eq!(chain_to_etherscan_id("base"), Some("8453"));
436 assert_eq!(chain_to_etherscan_id("bsc"), Some("56"));
437 assert_eq!(chain_to_etherscan_id("bnb"), Some("56"));
438 assert_eq!(chain_to_etherscan_id("solana"), None);
439 assert_eq!(chain_to_etherscan_id("tron"), None);
440 }
441
442 #[test]
443 fn test_abi_entry_selector() {
444 let entry = AbiEntry {
445 entry_type: "function".to_string(),
446 name: "transfer".to_string(),
447 inputs: vec![
448 AbiParam {
449 name: "to".to_string(),
450 param_type: "address".to_string(),
451 indexed: false,
452 components: vec![],
453 },
454 AbiParam {
455 name: "amount".to_string(),
456 param_type: "uint256".to_string(),
457 indexed: false,
458 components: vec![],
459 },
460 ],
461 outputs: vec![],
462 state_mutability: "nonpayable".to_string(),
463 };
464 let selector = entry.selector();
465 assert!(selector.starts_with("0x"));
466 assert_eq!(selector.len(), 10);
467 }
468
469 #[test]
470 fn test_abi_entry_signature_no_params() {
471 let entry = AbiEntry {
472 entry_type: "function".to_string(),
473 name: "pause".to_string(),
474 inputs: vec![],
475 outputs: vec![],
476 state_mutability: "nonpayable".to_string(),
477 };
478 assert_eq!(entry.signature(), "pause()");
479 }
480
481 #[test]
482 fn test_abi_entry_pure_not_state_changing() {
483 let entry = AbiEntry {
484 entry_type: "function".to_string(),
485 name: "add".to_string(),
486 inputs: vec![],
487 outputs: vec![],
488 state_mutability: "pure".to_string(),
489 };
490 assert!(!entry.is_state_changing());
491 }
492
493 #[test]
494 fn test_abi_entry_payable_is_state_changing() {
495 let entry = AbiEntry {
496 entry_type: "function".to_string(),
497 name: "deposit".to_string(),
498 inputs: vec![],
499 outputs: vec![],
500 state_mutability: "payable".to_string(),
501 };
502 assert!(entry.is_state_changing());
503 }
504
505 #[test]
506 fn test_abi_entry_event_not_state_changing() {
507 let entry = AbiEntry {
508 entry_type: "event".to_string(),
509 name: "Transfer".to_string(),
510 inputs: vec![],
511 outputs: vec![],
512 state_mutability: String::new(),
513 };
514 assert!(!entry.is_state_changing());
515 }
516
517 #[test]
518 fn test_abi_param_canonical_type_tuple() {
519 let param = AbiParam {
520 name: "data".to_string(),
521 param_type: "tuple".to_string(),
522 indexed: false,
523 components: vec![
524 AbiParam {
525 name: "a".to_string(),
526 param_type: "address".to_string(),
527 indexed: false,
528 components: vec![],
529 },
530 AbiParam {
531 name: "b".to_string(),
532 param_type: "uint256".to_string(),
533 indexed: false,
534 components: vec![],
535 },
536 ],
537 };
538 assert_eq!(param.canonical_type(), "(address,uint256)");
539 }
540
541 #[test]
542 fn test_abi_param_canonical_type_nested_tuple() {
543 let param = AbiParam {
544 name: "nested".to_string(),
545 param_type: "tuple".to_string(),
546 indexed: false,
547 components: vec![AbiParam {
548 name: "inner".to_string(),
549 param_type: "tuple".to_string(),
550 indexed: false,
551 components: vec![AbiParam {
552 name: "x".to_string(),
553 param_type: "uint256".to_string(),
554 indexed: false,
555 components: vec![],
556 }],
557 }],
558 };
559 assert_eq!(param.canonical_type(), "((uint256))");
560 }
561
562 #[test]
563 fn test_extract_standard_json_source() {
564 let json_str = r#"{"sources":{"A.sol":{"content":"pragma solidity ^0.8.0;"}}}"#;
565 let source = ContractSource {
566 contract_name: "A".to_string(),
567 source_code: json_str.to_string(),
568 abi: "[]".to_string(),
569 compiler_version: "v0.8.19".to_string(),
570 optimization_used: false,
571 optimization_runs: 200,
572 evm_version: "paris".to_string(),
573 license_type: "MIT".to_string(),
574 is_proxy: false,
575 implementation_address: None,
576 constructor_arguments: String::new(),
577 library: String::new(),
578 swarm_source: String::new(),
579 parsed_abi: vec![],
580 };
581 let files = extract_source_files(&source);
582 assert_eq!(files.len(), 1);
583 assert_eq!(files[0].path, "A.sol");
584 }
585
586 #[test]
587 fn test_extract_invalid_json_fallback() {
588 let source = ContractSource {
589 contract_name: "Token".to_string(),
590 source_code: "{invalid json".to_string(),
591 abi: "[]".to_string(),
592 compiler_version: "v0.8.19".to_string(),
593 optimization_used: false,
594 optimization_runs: 200,
595 evm_version: "paris".to_string(),
596 license_type: "MIT".to_string(),
597 is_proxy: false,
598 implementation_address: None,
599 constructor_arguments: String::new(),
600 library: String::new(),
601 swarm_source: String::new(),
602 parsed_abi: vec![],
603 };
604 let files = extract_source_files(&source);
605 assert_eq!(files.len(), 1);
606 assert_eq!(files[0].path, "Token.sol");
607 }
608
609 #[test]
610 fn test_extract_standard_json_no_sources_key() {
611 let json_str = r#"{"settings":{"optimizer":{"enabled":true}}}"#;
612 let source = ContractSource {
613 contract_name: "Token".to_string(),
614 source_code: json_str.to_string(),
615 abi: "[]".to_string(),
616 compiler_version: "v0.8.19".to_string(),
617 optimization_used: false,
618 optimization_runs: 200,
619 evm_version: "paris".to_string(),
620 license_type: "MIT".to_string(),
621 is_proxy: false,
622 implementation_address: None,
623 constructor_arguments: String::new(),
624 library: String::new(),
625 swarm_source: String::new(),
626 parsed_abi: vec![],
627 };
628 let files = extract_source_files(&source);
629 assert_eq!(files.len(), 0);
630 }
631
632 #[test]
633 fn test_sha2_256_deterministic() {
634 let hash1 = sha2_256(b"hello");
635 let hash2 = sha2_256(b"hello");
636 assert_eq!(hash1, hash2);
637 let hash3 = sha2_256(b"world");
638 assert_ne!(hash1, hash3);
639 }
640
641 #[test]
642 fn test_contract_source_serialization() {
643 let source = ContractSource {
644 contract_name: "Test".to_string(),
645 source_code: "code".to_string(),
646 abi: "[]".to_string(),
647 compiler_version: "v0.8.19".to_string(),
648 optimization_used: true,
649 optimization_runs: 200,
650 evm_version: "paris".to_string(),
651 license_type: "MIT".to_string(),
652 is_proxy: true,
653 implementation_address: Some("0x123".to_string()),
654 constructor_arguments: "0xdeadbeef".to_string(),
655 library: "SafeMath:0xabc".to_string(),
656 swarm_source: "ipfs://Qm123".to_string(),
657 parsed_abi: vec![],
658 };
659 let json = serde_json::to_string(&source).unwrap();
660 let deserialized: ContractSource = serde_json::from_str(&json).unwrap();
661 assert_eq!(deserialized.contract_name, "Test");
662 assert!(deserialized.is_proxy);
663 assert_eq!(
664 deserialized.implementation_address,
665 Some("0x123".to_string())
666 );
667 }
668
669 #[test]
670 fn test_extract_wrapped_invalid_inner_json() {
671 let source = ContractSource {
672 contract_name: "Token".to_string(),
673 source_code: "{{not valid json}}".to_string(),
674 abi: "[]".to_string(),
675 compiler_version: "v0.8.19".to_string(),
676 optimization_used: false,
677 optimization_runs: 200,
678 evm_version: "paris".to_string(),
679 license_type: "MIT".to_string(),
680 is_proxy: false,
681 implementation_address: None,
682 constructor_arguments: String::new(),
683 library: String::new(),
684 swarm_source: String::new(),
685 parsed_abi: vec![],
686 };
687 let files = extract_source_files(&source);
688 assert_eq!(files.len(), 1);
689 assert_eq!(files[0].path, "Token.sol");
690 }
691
692 #[test]
693 fn test_extract_standard_json_source_missing_content() {
694 let json_str = r#"{"sources":{"A.sol":{},"B.sol":{"content":"valid"}}}"#;
695 let source = ContractSource {
696 contract_name: "Token".to_string(),
697 source_code: json_str.to_string(),
698 abi: "[]".to_string(),
699 compiler_version: "v0.8.19".to_string(),
700 optimization_used: false,
701 optimization_runs: 200,
702 evm_version: "paris".to_string(),
703 license_type: "MIT".to_string(),
704 is_proxy: false,
705 implementation_address: None,
706 constructor_arguments: String::new(),
707 library: String::new(),
708 swarm_source: String::new(),
709 parsed_abi: vec![],
710 };
711 let files = extract_source_files(&source);
712 assert_eq!(files.len(), 1);
713 assert_eq!(files[0].path, "B.sol");
714 assert_eq!(files[0].content, "valid");
715 }
716
717 #[test]
718 fn test_extract_standard_json_empty_sources_object() {
719 let json_str = r#"{"sources":{}}"#;
720 let source = ContractSource {
721 contract_name: "Token".to_string(),
722 source_code: json_str.to_string(),
723 abi: "[]".to_string(),
724 compiler_version: "v0.8.19".to_string(),
725 optimization_used: false,
726 optimization_runs: 200,
727 evm_version: "paris".to_string(),
728 license_type: "MIT".to_string(),
729 is_proxy: false,
730 implementation_address: None,
731 constructor_arguments: String::new(),
732 library: String::new(),
733 swarm_source: String::new(),
734 parsed_abi: vec![],
735 };
736 let files = extract_source_files(&source);
737 assert_eq!(files.len(), 0);
738 }
739
740 #[test]
741 fn test_extract_standard_json_sources_not_object() {
742 let json_str = r#"{"sources":["file1.sol","file2.sol"]}"#;
743 let source = ContractSource {
744 contract_name: "Token".to_string(),
745 source_code: json_str.to_string(),
746 abi: "[]".to_string(),
747 compiler_version: "v0.8.19".to_string(),
748 optimization_used: false,
749 optimization_runs: 200,
750 evm_version: "paris".to_string(),
751 license_type: "MIT".to_string(),
752 is_proxy: false,
753 implementation_address: None,
754 constructor_arguments: String::new(),
755 library: String::new(),
756 swarm_source: String::new(),
757 parsed_abi: vec![],
758 };
759 let files = extract_source_files(&source);
760 assert_eq!(files.len(), 0);
761 }
762
763 #[test]
764 fn test_source_file_struct() {
765 let sf = SourceFile {
766 path: "contracts/Token.sol".to_string(),
767 content: "pragma solidity ^0.8.0;".to_string(),
768 };
769 assert_eq!(sf.path, "contracts/Token.sol");
770 let cloned = sf.clone();
771 assert_eq!(cloned.content, sf.content);
772 }
773
774 #[test]
775 fn test_abi_entry_constructor_not_state_changing() {
776 let entry = AbiEntry {
777 entry_type: "constructor".to_string(),
778 name: String::new(),
779 inputs: vec![],
780 outputs: vec![],
781 state_mutability: String::new(),
782 };
783 assert!(!entry.is_state_changing());
784 }
785
786 #[test]
787 fn test_abi_entry_fallback_not_state_changing() {
788 let entry = AbiEntry {
789 entry_type: "fallback".to_string(),
790 name: String::new(),
791 inputs: vec![],
792 outputs: vec![],
793 state_mutability: "payable".to_string(),
794 };
795 assert!(!entry.is_state_changing());
796 }
797
798 #[test]
799 fn test_abi_entry_selector_consistency() {
800 let entry = AbiEntry {
801 entry_type: "function".to_string(),
802 name: "transfer".to_string(),
803 inputs: vec![
804 AbiParam {
805 name: "to".to_string(),
806 param_type: "address".to_string(),
807 indexed: false,
808 components: vec![],
809 },
810 AbiParam {
811 name: "value".to_string(),
812 param_type: "uint256".to_string(),
813 indexed: false,
814 components: vec![],
815 },
816 ],
817 outputs: vec![],
818 state_mutability: "nonpayable".to_string(),
819 };
820 let sig = entry.signature();
821 let sel = entry.selector();
822 assert_eq!(sig, "transfer(address,uint256)");
823 assert!(sel.starts_with("0x"));
824 assert_eq!(sel.len(), 10);
825 }
826
827 struct CannedHttp(String);
832
833 #[async_trait::async_trait]
834 impl crate::http::HttpClient for CannedHttp {
835 async fn send(&self, _req: crate::http::Request) -> Result<crate::http::Response> {
836 Ok(crate::http::Response {
837 status_code: 200,
838 headers: Default::default(),
839 body: self.0.clone(),
840 })
841 }
842 }
843
844 fn etherscan_body(
845 status: &str,
846 source: &str,
847 abi: &str,
848 proxy: &str,
849 impl_addr: &str,
850 ) -> String {
851 serde_json::json!({
852 "status": status,
853 "message": "OK",
854 "result": [{
855 "SourceCode": source,
856 "ABI": abi,
857 "ContractName": "Token",
858 "CompilerVersion": "v0.8.20",
859 "OptimizationUsed": "1",
860 "Runs": "999",
861 "ConstructorArguments": "",
862 "EVMVersion": "paris",
863 "Library": "",
864 "LicenseType": "MIT",
865 "Proxy": proxy,
866 "Implementation": impl_addr,
867 "SwarmSource": ""
868 }]
869 })
870 .to_string()
871 }
872
873 const ADDR: &str = "0x0000000000000000000000000000000000000001";
874
875 #[tokio::test]
876 async fn test_fetch_source_verified_proxy() {
877 let abi = r#"[{"type":"function","name":"transfer","inputs":[],"outputs":[]}]"#;
878 let http = CannedHttp(etherscan_body("1", "contract Token {}", abi, "1", "0xImpl"));
879 let src = fetch_contract_source(ADDR, "ethereum", &http)
880 .await
881 .unwrap();
882 assert_eq!(src.contract_name, "Token");
883 assert!(src.optimization_used);
884 assert_eq!(src.optimization_runs, 999);
885 assert!(src.is_proxy);
886 assert_eq!(src.implementation_address.as_deref(), Some("0xImpl"));
887 assert_eq!(src.parsed_abi.len(), 1);
888 }
889
890 #[tokio::test]
891 async fn test_fetch_source_non_json_abi_and_bad_runs_use_fallbacks() {
892 let mut body: serde_json::Value = serde_json::from_str(ðerscan_body(
893 "1",
894 "contract Token {}",
895 "not-json",
896 "0",
897 "",
898 ))
899 .unwrap();
900 body["result"][0]["Runs"] = "x".into();
901 let http = CannedHttp(body.to_string());
902 let src = fetch_contract_source(ADDR, "ethereum", &http)
903 .await
904 .unwrap();
905 assert!(src.parsed_abi.is_empty());
906 assert_eq!(src.optimization_runs, 200);
907 assert!(!src.is_proxy);
908 assert_eq!(src.implementation_address, None);
909 }
910
911 #[tokio::test]
912 async fn test_fetch_source_unverified_is_not_found() {
913 let http = CannedHttp(etherscan_body(
914 "1",
915 "",
916 "Contract source code not verified",
917 "0",
918 "",
919 ));
920 let err = fetch_contract_source(ADDR, "ethereum", &http)
921 .await
922 .unwrap_err();
923 assert!(matches!(err, ScopeError::NotFound(_)), "{err}");
924 }
925
926 #[tokio::test]
927 async fn test_fetch_source_status_zero_is_not_found() {
928 let http = CannedHttp(r#"{"status":"0","message":"NOTOK","result":[]}"#.to_string());
929 let err = fetch_contract_source(ADDR, "ethereum", &http)
930 .await
931 .unwrap_err();
932 assert!(matches!(err, ScopeError::NotFound(_)), "{err}");
933 }
934
935 #[tokio::test]
936 async fn test_fetch_source_unsupported_chain_makes_no_request() {
937 struct PanicHttp;
938 #[async_trait::async_trait]
939 impl crate::http::HttpClient for PanicHttp {
940 async fn send(&self, _req: crate::http::Request) -> Result<crate::http::Response> {
941 panic!("no request expected for an unsupported chain");
942 }
943 }
944 let err = fetch_contract_source(ADDR, "solana", &PanicHttp)
945 .await
946 .unwrap_err();
947 assert!(err.to_string().contains("does not have Etherscan"), "{err}");
948 }
949}