Skip to main content

scope/contract/
source.rs

1//! # Contract Source Code Retrieval
2//!
3//! Fetches verified contract source code, ABI, compiler settings, and
4//! metadata from block explorer APIs (Etherscan `getsourcecode`).
5//!
6//! ## Etherscan Free Tier
7//!
8//! The `getsourcecode` endpoint is available on the free tier (5 calls/sec).
9//! It returns: SourceCode, ABI, ContractName, CompilerVersion,
10//! OptimizationUsed, Runs, ConstructorArguments, EVMVersion,
11//! Library, LicenseType, Proxy, Implementation, SwarmSource.
12
13use crate::error::{Result, ScopeError};
14use serde::{Deserialize, Serialize};
15
16/// Etherscan V2 API base URL (same as in ethereum.rs).
17const ETHERSCAN_V2_API: &str = "https://api.etherscan.io/v2/api";
18
19/// Full contract source information from a block explorer.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ContractSource {
22    /// Contract name as registered on the explorer.
23    pub contract_name: String,
24    /// Solidity (or Vyper) source code. May be a single file or JSON of multiple files.
25    pub source_code: String,
26    /// Contract ABI as a JSON string.
27    pub abi: String,
28    /// Compiler version used (e.g., "v0.8.19+commit.7dd6d404").
29    pub compiler_version: String,
30    /// Whether optimization was enabled.
31    pub optimization_used: bool,
32    /// Optimization runs count.
33    pub optimization_runs: u32,
34    /// EVM version targeted (e.g., "paris", "london").
35    pub evm_version: String,
36    /// SPDX license identifier.
37    pub license_type: String,
38    /// Whether Etherscan flagged this as a proxy contract.
39    pub is_proxy: bool,
40    /// Implementation address if proxy detected by Etherscan.
41    pub implementation_address: Option<String>,
42    /// Constructor arguments (hex-encoded).
43    pub constructor_arguments: String,
44    /// Library addresses used.
45    pub library: String,
46    /// Swarm/IPFS source hash.
47    pub swarm_source: String,
48    /// Parsed ABI entries for programmatic access.
49    pub parsed_abi: Vec<AbiEntry>,
50}
51
52/// A single ABI entry (function, event, or constructor).
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct AbiEntry {
55    /// Entry type: "function", "event", "constructor", "fallback", "receive".
56    #[serde(rename = "type")]
57    pub entry_type: String,
58    /// Function/event name (empty for constructor/fallback/receive).
59    #[serde(default)]
60    pub name: String,
61    /// Input parameters.
62    #[serde(default)]
63    pub inputs: Vec<AbiParam>,
64    /// Output parameters (functions only).
65    #[serde(default)]
66    pub outputs: Vec<AbiParam>,
67    /// State mutability: "pure", "view", "nonpayable", "payable".
68    #[serde(default, rename = "stateMutability")]
69    pub state_mutability: String,
70}
71
72/// An ABI parameter (input or output).
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct AbiParam {
75    /// Parameter name.
76    pub name: String,
77    /// Solidity type (e.g., "uint256", "address", "bytes32").
78    #[serde(rename = "type")]
79    pub param_type: String,
80    /// Whether this is an indexed event parameter.
81    #[serde(default)]
82    pub indexed: bool,
83    /// Tuple components (for tuple types).
84    #[serde(default)]
85    pub components: Vec<AbiParam>,
86}
87
88impl AbiEntry {
89    /// Returns the canonical function signature (e.g., "transfer(address,uint256)").
90    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    /// Returns the 4-byte selector hex string for this function.
96    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    /// Whether this is a state-changing function (not view/pure).
103    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    /// Returns the canonical Solidity type string for ABI encoding.
112    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
123/// Compute SHA-256 hash (reuse sha2 crate already in deps).
124fn 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
134/// Map chain name to Etherscan V2 chain ID parameter.
135fn chain_to_etherscan_id(chain: &str) -> Option<&'static str> {
136    crate::chains::registry::chain(chain)?.evm_chain_id
137}
138
139/// Etherscan getsourcecode response structures.
140#[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
178/// Fetch full contract source code and metadata from Etherscan.
179///
180/// Uses the `getsourcecode` endpoint which is available on the free tier.
181/// Returns `Err` if the contract is not verified or the API call fails.
182pub 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    // Check if source is actually present (unverified contracts return empty)
220    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    // Parse ABI JSON into structured entries
228    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
261/// Extract individual source files from Etherscan's source code response.
262///
263/// Etherscan returns source code in different formats:
264/// 1. Single file: plain Solidity source
265/// 2. Multi-file: JSON object wrapped in `{{...}}` with "sources" key
266/// 3. Standard JSON input: JSON with "language", "sources", "settings"
267pub fn extract_source_files(source: &ContractSource) -> Vec<SourceFile> {
268    let code = &source.source_code;
269
270    // Multi-file format: starts with {{ and ends with }}
271    if code.starts_with("{{") && code.ends_with("}}") {
272        // Strip outer braces (Etherscan wraps standard JSON input in extra braces)
273        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    // Single-file JSON (standard input format)
280    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    // Single plain source file
287    vec![SourceFile {
288        path: format!("{}.sol", source.contract_name),
289        content: code.clone(),
290    }]
291}
292
293/// A source file extracted from multi-file source code.
294#[derive(Debug, Clone)]
295pub struct SourceFile {
296    /// File path (e.g., "contracts/Token.sol").
297    pub path: String,
298    /// File content.
299    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    // ------------------------------------------------------------------
828    // fetch_contract_source with a canned HTTP response (no network)
829    // ------------------------------------------------------------------
830
831    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(&etherscan_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}