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    match chain.to_lowercase().as_str() {
137        "ethereum" | "eth" => Some("1"),
138        "polygon" | "matic" => Some("137"),
139        "arbitrum" | "arb" => Some("42161"),
140        "optimism" | "op" => Some("10"),
141        "base" => Some("8453"),
142        "bsc" | "bnb" => Some("56"),
143        _ => None,
144    }
145}
146
147/// Etherscan getsourcecode response structures.
148#[derive(Deserialize)]
149struct EtherscanSourceResponse {
150    status: String,
151    #[allow(dead_code)]
152    message: String,
153    result: Vec<EtherscanSourceResult>,
154}
155
156#[derive(Deserialize)]
157struct EtherscanSourceResult {
158    #[serde(rename = "SourceCode")]
159    source_code: String,
160    #[serde(rename = "ABI")]
161    abi: String,
162    #[serde(rename = "ContractName")]
163    contract_name: String,
164    #[serde(rename = "CompilerVersion")]
165    compiler_version: String,
166    #[serde(rename = "OptimizationUsed")]
167    optimization_used: String,
168    #[serde(rename = "Runs")]
169    runs: String,
170    #[serde(rename = "ConstructorArguments")]
171    constructor_arguments: String,
172    #[serde(rename = "EVMVersion")]
173    evm_version: String,
174    #[serde(rename = "Library")]
175    library: String,
176    #[serde(rename = "LicenseType")]
177    license_type: String,
178    #[serde(rename = "Proxy")]
179    proxy: String,
180    #[serde(rename = "Implementation")]
181    implementation: String,
182    #[serde(rename = "SwarmSource")]
183    swarm_source: String,
184}
185
186/// Fetch full contract source code and metadata from Etherscan.
187///
188/// Uses the `getsourcecode` endpoint which is available on the free tier.
189/// Returns `Err` if the contract is not verified or the API call fails.
190pub async fn fetch_contract_source(
191    address: &str,
192    chain: &str,
193    http_client: &dyn crate::http::HttpClient,
194) -> Result<ContractSource> {
195    let chain_id = chain_to_etherscan_id(chain).ok_or_else(|| {
196        ScopeError::Chain(format!(
197            "Chain '{}' does not have Etherscan source code support",
198            chain
199        ))
200    })?;
201
202    let api_key = std::env::var("ETHERSCAN_API_KEY").unwrap_or_default();
203    let url = format!(
204        "{}?chainid={}&module=contract&action=getsourcecode&address={}&apikey={}",
205        ETHERSCAN_V2_API, chain_id, address, api_key
206    );
207
208    let response = http_client
209        .send(crate::http::Request::get(&url))
210        .await
211        .map_err(|e| ScopeError::Api(format!("Etherscan source fetch failed: {}", e)))?;
212
213    let text = &response.body;
214
215    let api_response: EtherscanSourceResponse = serde_json::from_str(text)
216        .map_err(|e| ScopeError::Api(format!("Failed to parse Etherscan response: {}", e)))?;
217
218    if api_response.status != "1" || api_response.result.is_empty() {
219        return Err(ScopeError::NotFound(format!(
220            "Contract source not verified for {} on {}",
221            address, chain
222        )));
223    }
224
225    let result = &api_response.result[0];
226
227    // Check if source is actually present (unverified contracts return empty)
228    if result.source_code.is_empty() || result.abi == "Contract source code not verified" {
229        return Err(ScopeError::NotFound(format!(
230            "Contract {} is not verified on {} Etherscan",
231            address, chain
232        )));
233    }
234
235    // Parse ABI JSON into structured entries
236    let parsed_abi: Vec<AbiEntry> = if result.abi.starts_with('[') {
237        serde_json::from_str(&result.abi).unwrap_or_default()
238    } else {
239        Vec::new()
240    };
241
242    let optimization_used = result.optimization_used == "1";
243    let optimization_runs: u32 = result.runs.parse().unwrap_or(200);
244
245    let implementation_address = if !result.implementation.is_empty() {
246        Some(result.implementation.clone())
247    } else {
248        None
249    };
250
251    Ok(ContractSource {
252        contract_name: result.contract_name.clone(),
253        source_code: result.source_code.clone(),
254        abi: result.abi.clone(),
255        compiler_version: result.compiler_version.clone(),
256        optimization_used,
257        optimization_runs,
258        evm_version: result.evm_version.clone(),
259        license_type: result.license_type.clone(),
260        is_proxy: result.proxy == "1",
261        implementation_address,
262        constructor_arguments: result.constructor_arguments.clone(),
263        library: result.library.clone(),
264        swarm_source: result.swarm_source.clone(),
265        parsed_abi,
266    })
267}
268
269/// Extract individual source files from Etherscan's source code response.
270///
271/// Etherscan returns source code in different formats:
272/// 1. Single file: plain Solidity source
273/// 2. Multi-file: JSON object wrapped in `{{...}}` with "sources" key
274/// 3. Standard JSON input: JSON with "language", "sources", "settings"
275pub fn extract_source_files(source: &ContractSource) -> Vec<SourceFile> {
276    let code = &source.source_code;
277
278    // Multi-file format: starts with {{ and ends with }}
279    if code.starts_with("{{") && code.ends_with("}}") {
280        // Strip outer braces (Etherscan wraps standard JSON input in extra braces)
281        let inner = &code[1..code.len() - 1];
282        if let Ok(standard_json) = serde_json::from_str::<serde_json::Value>(inner) {
283            return extract_from_standard_json(&standard_json);
284        }
285    }
286
287    // Single-file JSON (standard input format)
288    if code.starts_with('{')
289        && let Ok(json) = serde_json::from_str::<serde_json::Value>(code)
290    {
291        return extract_from_standard_json(&json);
292    }
293
294    // Single plain source file
295    vec![SourceFile {
296        path: format!("{}.sol", source.contract_name),
297        content: code.clone(),
298    }]
299}
300
301/// A source file extracted from multi-file source code.
302#[derive(Debug, Clone)]
303pub struct SourceFile {
304    /// File path (e.g., "contracts/Token.sol").
305    pub path: String,
306    /// File content.
307    pub content: String,
308}
309
310fn extract_from_standard_json(json: &serde_json::Value) -> Vec<SourceFile> {
311    let mut files = Vec::new();
312
313    if let Some(sources) = json.get("sources").and_then(|s| s.as_object()) {
314        for (path, source) in sources {
315            if let Some(content) = source.get("content").and_then(|c| c.as_str()) {
316                files.push(SourceFile {
317                    path: path.clone(),
318                    content: content.to_string(),
319                });
320            }
321        }
322    }
323
324    files
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn test_chain_to_etherscan_id() {
333        assert_eq!(chain_to_etherscan_id("ethereum"), Some("1"));
334        assert_eq!(chain_to_etherscan_id("polygon"), Some("137"));
335        assert_eq!(chain_to_etherscan_id("bsc"), Some("56"));
336        assert_eq!(chain_to_etherscan_id("solana"), None);
337    }
338
339    #[test]
340    fn test_abi_entry_signature() {
341        let entry = AbiEntry {
342            entry_type: "function".to_string(),
343            name: "transfer".to_string(),
344            inputs: vec![
345                AbiParam {
346                    name: "to".to_string(),
347                    param_type: "address".to_string(),
348                    indexed: false,
349                    components: vec![],
350                },
351                AbiParam {
352                    name: "amount".to_string(),
353                    param_type: "uint256".to_string(),
354                    indexed: false,
355                    components: vec![],
356                },
357            ],
358            outputs: vec![],
359            state_mutability: "nonpayable".to_string(),
360        };
361        assert_eq!(entry.signature(), "transfer(address,uint256)");
362    }
363
364    #[test]
365    fn test_abi_entry_is_state_changing() {
366        let view_fn = AbiEntry {
367            entry_type: "function".to_string(),
368            name: "balanceOf".to_string(),
369            inputs: vec![],
370            outputs: vec![],
371            state_mutability: "view".to_string(),
372        };
373        assert!(!view_fn.is_state_changing());
374
375        let write_fn = AbiEntry {
376            entry_type: "function".to_string(),
377            name: "transfer".to_string(),
378            inputs: vec![],
379            outputs: vec![],
380            state_mutability: "nonpayable".to_string(),
381        };
382        assert!(write_fn.is_state_changing());
383    }
384
385    #[test]
386    fn test_extract_single_file_source() {
387        let source = ContractSource {
388            contract_name: "TestToken".to_string(),
389            source_code: "pragma solidity ^0.8.0;\ncontract TestToken {}".to_string(),
390            abi: "[]".to_string(),
391            compiler_version: "v0.8.19".to_string(),
392            optimization_used: true,
393            optimization_runs: 200,
394            evm_version: "paris".to_string(),
395            license_type: "MIT".to_string(),
396            is_proxy: false,
397            implementation_address: None,
398            constructor_arguments: String::new(),
399            library: String::new(),
400            swarm_source: String::new(),
401            parsed_abi: vec![],
402        };
403        let files = extract_source_files(&source);
404        assert_eq!(files.len(), 1);
405        assert_eq!(files[0].path, "TestToken.sol");
406        assert!(files[0].content.contains("pragma solidity"));
407    }
408
409    #[test]
410    fn test_extract_multi_file_source() {
411        let json_source = r#"{"sources":{"contracts/Token.sol":{"content":"pragma solidity ^0.8.0;"},"contracts/Lib.sol":{"content":"library Lib {}"}}}"#;
412        let wrapped = format!("{{{}}}", json_source);
413        let source = ContractSource {
414            contract_name: "Token".to_string(),
415            source_code: wrapped,
416            abi: "[]".to_string(),
417            compiler_version: "v0.8.19".to_string(),
418            optimization_used: true,
419            optimization_runs: 200,
420            evm_version: "paris".to_string(),
421            license_type: "MIT".to_string(),
422            is_proxy: false,
423            implementation_address: None,
424            constructor_arguments: String::new(),
425            library: String::new(),
426            swarm_source: String::new(),
427            parsed_abi: vec![],
428        };
429        let files = extract_source_files(&source);
430        assert_eq!(files.len(), 2);
431    }
432
433    #[test]
434    fn test_chain_to_etherscan_id_all() {
435        assert_eq!(chain_to_etherscan_id("ethereum"), Some("1"));
436        assert_eq!(chain_to_etherscan_id("eth"), Some("1"));
437        assert_eq!(chain_to_etherscan_id("polygon"), Some("137"));
438        assert_eq!(chain_to_etherscan_id("matic"), Some("137"));
439        assert_eq!(chain_to_etherscan_id("arbitrum"), Some("42161"));
440        assert_eq!(chain_to_etherscan_id("arb"), Some("42161"));
441        assert_eq!(chain_to_etherscan_id("optimism"), Some("10"));
442        assert_eq!(chain_to_etherscan_id("op"), Some("10"));
443        assert_eq!(chain_to_etherscan_id("base"), Some("8453"));
444        assert_eq!(chain_to_etherscan_id("bsc"), Some("56"));
445        assert_eq!(chain_to_etherscan_id("bnb"), Some("56"));
446        assert_eq!(chain_to_etherscan_id("solana"), None);
447        assert_eq!(chain_to_etherscan_id("tron"), None);
448    }
449
450    #[test]
451    fn test_abi_entry_selector() {
452        let entry = AbiEntry {
453            entry_type: "function".to_string(),
454            name: "transfer".to_string(),
455            inputs: vec![
456                AbiParam {
457                    name: "to".to_string(),
458                    param_type: "address".to_string(),
459                    indexed: false,
460                    components: vec![],
461                },
462                AbiParam {
463                    name: "amount".to_string(),
464                    param_type: "uint256".to_string(),
465                    indexed: false,
466                    components: vec![],
467                },
468            ],
469            outputs: vec![],
470            state_mutability: "nonpayable".to_string(),
471        };
472        let selector = entry.selector();
473        assert!(selector.starts_with("0x"));
474        assert_eq!(selector.len(), 10);
475    }
476
477    #[test]
478    fn test_abi_entry_signature_no_params() {
479        let entry = AbiEntry {
480            entry_type: "function".to_string(),
481            name: "pause".to_string(),
482            inputs: vec![],
483            outputs: vec![],
484            state_mutability: "nonpayable".to_string(),
485        };
486        assert_eq!(entry.signature(), "pause()");
487    }
488
489    #[test]
490    fn test_abi_entry_pure_not_state_changing() {
491        let entry = AbiEntry {
492            entry_type: "function".to_string(),
493            name: "add".to_string(),
494            inputs: vec![],
495            outputs: vec![],
496            state_mutability: "pure".to_string(),
497        };
498        assert!(!entry.is_state_changing());
499    }
500
501    #[test]
502    fn test_abi_entry_payable_is_state_changing() {
503        let entry = AbiEntry {
504            entry_type: "function".to_string(),
505            name: "deposit".to_string(),
506            inputs: vec![],
507            outputs: vec![],
508            state_mutability: "payable".to_string(),
509        };
510        assert!(entry.is_state_changing());
511    }
512
513    #[test]
514    fn test_abi_entry_event_not_state_changing() {
515        let entry = AbiEntry {
516            entry_type: "event".to_string(),
517            name: "Transfer".to_string(),
518            inputs: vec![],
519            outputs: vec![],
520            state_mutability: String::new(),
521        };
522        assert!(!entry.is_state_changing());
523    }
524
525    #[test]
526    fn test_abi_param_canonical_type_tuple() {
527        let param = AbiParam {
528            name: "data".to_string(),
529            param_type: "tuple".to_string(),
530            indexed: false,
531            components: vec![
532                AbiParam {
533                    name: "a".to_string(),
534                    param_type: "address".to_string(),
535                    indexed: false,
536                    components: vec![],
537                },
538                AbiParam {
539                    name: "b".to_string(),
540                    param_type: "uint256".to_string(),
541                    indexed: false,
542                    components: vec![],
543                },
544            ],
545        };
546        assert_eq!(param.canonical_type(), "(address,uint256)");
547    }
548
549    #[test]
550    fn test_abi_param_canonical_type_nested_tuple() {
551        let param = AbiParam {
552            name: "nested".to_string(),
553            param_type: "tuple".to_string(),
554            indexed: false,
555            components: vec![AbiParam {
556                name: "inner".to_string(),
557                param_type: "tuple".to_string(),
558                indexed: false,
559                components: vec![AbiParam {
560                    name: "x".to_string(),
561                    param_type: "uint256".to_string(),
562                    indexed: false,
563                    components: vec![],
564                }],
565            }],
566        };
567        assert_eq!(param.canonical_type(), "((uint256))");
568    }
569
570    #[test]
571    fn test_extract_standard_json_source() {
572        let json_str = r#"{"sources":{"A.sol":{"content":"pragma solidity ^0.8.0;"}}}"#;
573        let source = ContractSource {
574            contract_name: "A".to_string(),
575            source_code: json_str.to_string(),
576            abi: "[]".to_string(),
577            compiler_version: "v0.8.19".to_string(),
578            optimization_used: false,
579            optimization_runs: 200,
580            evm_version: "paris".to_string(),
581            license_type: "MIT".to_string(),
582            is_proxy: false,
583            implementation_address: None,
584            constructor_arguments: String::new(),
585            library: String::new(),
586            swarm_source: String::new(),
587            parsed_abi: vec![],
588        };
589        let files = extract_source_files(&source);
590        assert_eq!(files.len(), 1);
591        assert_eq!(files[0].path, "A.sol");
592    }
593
594    #[test]
595    fn test_extract_invalid_json_fallback() {
596        let source = ContractSource {
597            contract_name: "Token".to_string(),
598            source_code: "{invalid json".to_string(),
599            abi: "[]".to_string(),
600            compiler_version: "v0.8.19".to_string(),
601            optimization_used: false,
602            optimization_runs: 200,
603            evm_version: "paris".to_string(),
604            license_type: "MIT".to_string(),
605            is_proxy: false,
606            implementation_address: None,
607            constructor_arguments: String::new(),
608            library: String::new(),
609            swarm_source: String::new(),
610            parsed_abi: vec![],
611        };
612        let files = extract_source_files(&source);
613        assert_eq!(files.len(), 1);
614        assert_eq!(files[0].path, "Token.sol");
615    }
616
617    #[test]
618    fn test_extract_standard_json_no_sources_key() {
619        let json_str = r#"{"settings":{"optimizer":{"enabled":true}}}"#;
620        let source = ContractSource {
621            contract_name: "Token".to_string(),
622            source_code: json_str.to_string(),
623            abi: "[]".to_string(),
624            compiler_version: "v0.8.19".to_string(),
625            optimization_used: false,
626            optimization_runs: 200,
627            evm_version: "paris".to_string(),
628            license_type: "MIT".to_string(),
629            is_proxy: false,
630            implementation_address: None,
631            constructor_arguments: String::new(),
632            library: String::new(),
633            swarm_source: String::new(),
634            parsed_abi: vec![],
635        };
636        let files = extract_source_files(&source);
637        assert_eq!(files.len(), 0);
638    }
639
640    #[test]
641    fn test_sha2_256_deterministic() {
642        let hash1 = sha2_256(b"hello");
643        let hash2 = sha2_256(b"hello");
644        assert_eq!(hash1, hash2);
645        let hash3 = sha2_256(b"world");
646        assert_ne!(hash1, hash3);
647    }
648
649    #[test]
650    fn test_contract_source_serialization() {
651        let source = ContractSource {
652            contract_name: "Test".to_string(),
653            source_code: "code".to_string(),
654            abi: "[]".to_string(),
655            compiler_version: "v0.8.19".to_string(),
656            optimization_used: true,
657            optimization_runs: 200,
658            evm_version: "paris".to_string(),
659            license_type: "MIT".to_string(),
660            is_proxy: true,
661            implementation_address: Some("0x123".to_string()),
662            constructor_arguments: "0xdeadbeef".to_string(),
663            library: "SafeMath:0xabc".to_string(),
664            swarm_source: "ipfs://Qm123".to_string(),
665            parsed_abi: vec![],
666        };
667        let json = serde_json::to_string(&source).unwrap();
668        let deserialized: ContractSource = serde_json::from_str(&json).unwrap();
669        assert_eq!(deserialized.contract_name, "Test");
670        assert!(deserialized.is_proxy);
671        assert_eq!(
672            deserialized.implementation_address,
673            Some("0x123".to_string())
674        );
675    }
676
677    #[test]
678    fn test_extract_wrapped_invalid_inner_json() {
679        let source = ContractSource {
680            contract_name: "Token".to_string(),
681            source_code: "{{not valid json}}".to_string(),
682            abi: "[]".to_string(),
683            compiler_version: "v0.8.19".to_string(),
684            optimization_used: false,
685            optimization_runs: 200,
686            evm_version: "paris".to_string(),
687            license_type: "MIT".to_string(),
688            is_proxy: false,
689            implementation_address: None,
690            constructor_arguments: String::new(),
691            library: String::new(),
692            swarm_source: String::new(),
693            parsed_abi: vec![],
694        };
695        let files = extract_source_files(&source);
696        assert_eq!(files.len(), 1);
697        assert_eq!(files[0].path, "Token.sol");
698    }
699
700    #[test]
701    fn test_extract_standard_json_source_missing_content() {
702        let json_str = r#"{"sources":{"A.sol":{},"B.sol":{"content":"valid"}}}"#;
703        let source = ContractSource {
704            contract_name: "Token".to_string(),
705            source_code: json_str.to_string(),
706            abi: "[]".to_string(),
707            compiler_version: "v0.8.19".to_string(),
708            optimization_used: false,
709            optimization_runs: 200,
710            evm_version: "paris".to_string(),
711            license_type: "MIT".to_string(),
712            is_proxy: false,
713            implementation_address: None,
714            constructor_arguments: String::new(),
715            library: String::new(),
716            swarm_source: String::new(),
717            parsed_abi: vec![],
718        };
719        let files = extract_source_files(&source);
720        assert_eq!(files.len(), 1);
721        assert_eq!(files[0].path, "B.sol");
722        assert_eq!(files[0].content, "valid");
723    }
724
725    #[test]
726    fn test_extract_standard_json_empty_sources_object() {
727        let json_str = r#"{"sources":{}}"#;
728        let source = ContractSource {
729            contract_name: "Token".to_string(),
730            source_code: json_str.to_string(),
731            abi: "[]".to_string(),
732            compiler_version: "v0.8.19".to_string(),
733            optimization_used: false,
734            optimization_runs: 200,
735            evm_version: "paris".to_string(),
736            license_type: "MIT".to_string(),
737            is_proxy: false,
738            implementation_address: None,
739            constructor_arguments: String::new(),
740            library: String::new(),
741            swarm_source: String::new(),
742            parsed_abi: vec![],
743        };
744        let files = extract_source_files(&source);
745        assert_eq!(files.len(), 0);
746    }
747
748    #[test]
749    fn test_extract_standard_json_sources_not_object() {
750        let json_str = r#"{"sources":["file1.sol","file2.sol"]}"#;
751        let source = ContractSource {
752            contract_name: "Token".to_string(),
753            source_code: json_str.to_string(),
754            abi: "[]".to_string(),
755            compiler_version: "v0.8.19".to_string(),
756            optimization_used: false,
757            optimization_runs: 200,
758            evm_version: "paris".to_string(),
759            license_type: "MIT".to_string(),
760            is_proxy: false,
761            implementation_address: None,
762            constructor_arguments: String::new(),
763            library: String::new(),
764            swarm_source: String::new(),
765            parsed_abi: vec![],
766        };
767        let files = extract_source_files(&source);
768        assert_eq!(files.len(), 0);
769    }
770
771    #[test]
772    fn test_source_file_struct() {
773        let sf = SourceFile {
774            path: "contracts/Token.sol".to_string(),
775            content: "pragma solidity ^0.8.0;".to_string(),
776        };
777        assert_eq!(sf.path, "contracts/Token.sol");
778        let cloned = sf.clone();
779        assert_eq!(cloned.content, sf.content);
780    }
781
782    #[test]
783    fn test_abi_entry_constructor_not_state_changing() {
784        let entry = AbiEntry {
785            entry_type: "constructor".to_string(),
786            name: String::new(),
787            inputs: vec![],
788            outputs: vec![],
789            state_mutability: String::new(),
790        };
791        assert!(!entry.is_state_changing());
792    }
793
794    #[test]
795    fn test_abi_entry_fallback_not_state_changing() {
796        let entry = AbiEntry {
797            entry_type: "fallback".to_string(),
798            name: String::new(),
799            inputs: vec![],
800            outputs: vec![],
801            state_mutability: "payable".to_string(),
802        };
803        assert!(!entry.is_state_changing());
804    }
805
806    #[test]
807    fn test_abi_entry_selector_consistency() {
808        let entry = AbiEntry {
809            entry_type: "function".to_string(),
810            name: "transfer".to_string(),
811            inputs: vec![
812                AbiParam {
813                    name: "to".to_string(),
814                    param_type: "address".to_string(),
815                    indexed: false,
816                    components: vec![],
817                },
818                AbiParam {
819                    name: "value".to_string(),
820                    param_type: "uint256".to_string(),
821                    indexed: false,
822                    components: vec![],
823                },
824            ],
825            outputs: vec![],
826            state_mutability: "nonpayable".to_string(),
827        };
828        let sig = entry.signature();
829        let sel = entry.selector();
830        assert_eq!(sig, "transfer(address,uint256)");
831        assert!(sel.starts_with("0x"));
832        assert_eq!(sel.len(), 10);
833    }
834}