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