use crate::error::{Result, ScopeError};
use serde::{Deserialize, Serialize};
const ETHERSCAN_V2_API: &str = "https://api.etherscan.io/v2/api";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractSource {
pub contract_name: String,
pub source_code: String,
pub abi: String,
pub compiler_version: String,
pub optimization_used: bool,
pub optimization_runs: u32,
pub evm_version: String,
pub license_type: String,
pub is_proxy: bool,
pub implementation_address: Option<String>,
pub constructor_arguments: String,
pub library: String,
pub swarm_source: String,
pub parsed_abi: Vec<AbiEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AbiEntry {
#[serde(rename = "type")]
pub entry_type: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub inputs: Vec<AbiParam>,
#[serde(default)]
pub outputs: Vec<AbiParam>,
#[serde(default, rename = "stateMutability")]
pub state_mutability: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AbiParam {
pub name: String,
#[serde(rename = "type")]
pub param_type: String,
#[serde(default)]
pub indexed: bool,
#[serde(default)]
pub components: Vec<AbiParam>,
}
impl AbiEntry {
pub fn signature(&self) -> String {
let params: Vec<String> = self.inputs.iter().map(|p| p.canonical_type()).collect();
format!("{}({})", self.name, params.join(","))
}
pub fn selector(&self) -> String {
let sig = self.signature();
let hash = sha2_256(sig.as_bytes());
format!("0x{}", hex::encode(&hash[..4]))
}
pub fn is_state_changing(&self) -> bool {
self.entry_type == "function"
&& self.state_mutability != "view"
&& self.state_mutability != "pure"
}
}
impl AbiParam {
fn canonical_type(&self) -> String {
if self.param_type == "tuple" {
let components: Vec<String> =
self.components.iter().map(|c| c.canonical_type()).collect();
format!("({})", components.join(","))
} else {
self.param_type.clone()
}
}
}
fn sha2_256(data: &[u8]) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(data);
let result = hasher.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&result);
out
}
fn chain_to_etherscan_id(chain: &str) -> Option<&'static str> {
crate::chains::registry::chain(chain)?.evm_chain_id
}
#[derive(Deserialize)]
struct EtherscanSourceResponse {
status: String,
#[allow(dead_code)]
message: String,
result: Vec<EtherscanSourceResult>,
}
#[derive(Deserialize)]
struct EtherscanSourceResult {
#[serde(rename = "SourceCode")]
source_code: String,
#[serde(rename = "ABI")]
abi: String,
#[serde(rename = "ContractName")]
contract_name: String,
#[serde(rename = "CompilerVersion")]
compiler_version: String,
#[serde(rename = "OptimizationUsed")]
optimization_used: String,
#[serde(rename = "Runs")]
runs: String,
#[serde(rename = "ConstructorArguments")]
constructor_arguments: String,
#[serde(rename = "EVMVersion")]
evm_version: String,
#[serde(rename = "Library")]
library: String,
#[serde(rename = "LicenseType")]
license_type: String,
#[serde(rename = "Proxy")]
proxy: String,
#[serde(rename = "Implementation")]
implementation: String,
#[serde(rename = "SwarmSource")]
swarm_source: String,
}
pub async fn fetch_contract_source(
address: &str,
chain: &str,
http_client: &dyn crate::http::HttpClient,
) -> Result<ContractSource> {
let chain_id = chain_to_etherscan_id(chain).ok_or_else(|| {
ScopeError::Chain(format!(
"Chain '{}' does not have Etherscan source code support",
chain
))
})?;
let api_key = crate::compliance::etherscan::etherscan_key().unwrap_or_default();
let url = format!(
"{}?chainid={}&module=contract&action=getsourcecode&address={}&apikey={}",
ETHERSCAN_V2_API, chain_id, address, api_key
);
let response = http_client
.send(crate::http::Request::get(&url))
.await
.map_err(|e| ScopeError::Api(format!("Etherscan source fetch failed: {}", e)))?;
let text = &response.body;
let api_response: EtherscanSourceResponse = serde_json::from_str(text)
.map_err(|e| ScopeError::Api(format!("Failed to parse Etherscan response: {}", e)))?;
if api_response.status != "1" || api_response.result.is_empty() {
return Err(ScopeError::NotFound(format!(
"Contract source not verified for {} on {}",
address, chain
)));
}
let result = &api_response.result[0];
if result.source_code.is_empty() || result.abi == "Contract source code not verified" {
return Err(ScopeError::NotFound(format!(
"Contract {} is not verified on {} Etherscan",
address, chain
)));
}
let parsed_abi: Vec<AbiEntry> = if result.abi.starts_with('[') {
serde_json::from_str(&result.abi).unwrap_or_default()
} else {
Vec::new()
};
let optimization_used = result.optimization_used == "1";
let optimization_runs: u32 = result.runs.parse().unwrap_or(200);
let implementation_address = if !result.implementation.is_empty() {
Some(result.implementation.clone())
} else {
None
};
Ok(ContractSource {
contract_name: result.contract_name.clone(),
source_code: result.source_code.clone(),
abi: result.abi.clone(),
compiler_version: result.compiler_version.clone(),
optimization_used,
optimization_runs,
evm_version: result.evm_version.clone(),
license_type: result.license_type.clone(),
is_proxy: result.proxy == "1",
implementation_address,
constructor_arguments: result.constructor_arguments.clone(),
library: result.library.clone(),
swarm_source: result.swarm_source.clone(),
parsed_abi,
})
}
pub fn extract_source_files(source: &ContractSource) -> Vec<SourceFile> {
let code = &source.source_code;
if code.starts_with("{{") && code.ends_with("}}") {
let inner = &code[1..code.len() - 1];
if let Ok(standard_json) = serde_json::from_str::<serde_json::Value>(inner) {
return extract_from_standard_json(&standard_json);
}
}
if code.starts_with('{')
&& let Ok(json) = serde_json::from_str::<serde_json::Value>(code)
{
return extract_from_standard_json(&json);
}
vec![SourceFile {
path: format!("{}.sol", source.contract_name),
content: code.clone(),
}]
}
#[derive(Debug, Clone)]
pub struct SourceFile {
pub path: String,
pub content: String,
}
fn extract_from_standard_json(json: &serde_json::Value) -> Vec<SourceFile> {
let mut files = Vec::new();
if let Some(sources) = json.get("sources").and_then(|s| s.as_object()) {
for (path, source) in sources {
if let Some(content) = source.get("content").and_then(|c| c.as_str()) {
files.push(SourceFile {
path: path.clone(),
content: content.to_string(),
});
}
}
}
files
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chain_to_etherscan_id() {
assert_eq!(chain_to_etherscan_id("ethereum"), Some("1"));
assert_eq!(chain_to_etherscan_id("polygon"), Some("137"));
assert_eq!(chain_to_etherscan_id("bsc"), Some("56"));
assert_eq!(chain_to_etherscan_id("solana"), None);
}
#[test]
fn test_abi_entry_signature() {
let entry = AbiEntry {
entry_type: "function".to_string(),
name: "transfer".to_string(),
inputs: vec![
AbiParam {
name: "to".to_string(),
param_type: "address".to_string(),
indexed: false,
components: vec![],
},
AbiParam {
name: "amount".to_string(),
param_type: "uint256".to_string(),
indexed: false,
components: vec![],
},
],
outputs: vec![],
state_mutability: "nonpayable".to_string(),
};
assert_eq!(entry.signature(), "transfer(address,uint256)");
}
#[test]
fn test_abi_entry_is_state_changing() {
let view_fn = AbiEntry {
entry_type: "function".to_string(),
name: "balanceOf".to_string(),
inputs: vec![],
outputs: vec![],
state_mutability: "view".to_string(),
};
assert!(!view_fn.is_state_changing());
let write_fn = AbiEntry {
entry_type: "function".to_string(),
name: "transfer".to_string(),
inputs: vec![],
outputs: vec![],
state_mutability: "nonpayable".to_string(),
};
assert!(write_fn.is_state_changing());
}
#[test]
fn test_extract_single_file_source() {
let source = ContractSource {
contract_name: "TestToken".to_string(),
source_code: "pragma solidity ^0.8.0;\ncontract TestToken {}".to_string(),
abi: "[]".to_string(),
compiler_version: "v0.8.19".to_string(),
optimization_used: true,
optimization_runs: 200,
evm_version: "paris".to_string(),
license_type: "MIT".to_string(),
is_proxy: false,
implementation_address: None,
constructor_arguments: String::new(),
library: String::new(),
swarm_source: String::new(),
parsed_abi: vec![],
};
let files = extract_source_files(&source);
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "TestToken.sol");
assert!(files[0].content.contains("pragma solidity"));
}
#[test]
fn test_extract_multi_file_source() {
let json_source = r#"{"sources":{"contracts/Token.sol":{"content":"pragma solidity ^0.8.0;"},"contracts/Lib.sol":{"content":"library Lib {}"}}}"#;
let wrapped = format!("{{{}}}", json_source);
let source = ContractSource {
contract_name: "Token".to_string(),
source_code: wrapped,
abi: "[]".to_string(),
compiler_version: "v0.8.19".to_string(),
optimization_used: true,
optimization_runs: 200,
evm_version: "paris".to_string(),
license_type: "MIT".to_string(),
is_proxy: false,
implementation_address: None,
constructor_arguments: String::new(),
library: String::new(),
swarm_source: String::new(),
parsed_abi: vec![],
};
let files = extract_source_files(&source);
assert_eq!(files.len(), 2);
}
#[test]
fn test_chain_to_etherscan_id_all() {
assert_eq!(chain_to_etherscan_id("ethereum"), Some("1"));
assert_eq!(chain_to_etherscan_id("eth"), Some("1"));
assert_eq!(chain_to_etherscan_id("polygon"), Some("137"));
assert_eq!(chain_to_etherscan_id("matic"), Some("137"));
assert_eq!(chain_to_etherscan_id("arbitrum"), Some("42161"));
assert_eq!(chain_to_etherscan_id("arb"), Some("42161"));
assert_eq!(chain_to_etherscan_id("optimism"), Some("10"));
assert_eq!(chain_to_etherscan_id("op"), Some("10"));
assert_eq!(chain_to_etherscan_id("base"), Some("8453"));
assert_eq!(chain_to_etherscan_id("bsc"), Some("56"));
assert_eq!(chain_to_etherscan_id("bnb"), Some("56"));
assert_eq!(chain_to_etherscan_id("solana"), None);
assert_eq!(chain_to_etherscan_id("tron"), None);
}
#[test]
fn test_abi_entry_selector() {
let entry = AbiEntry {
entry_type: "function".to_string(),
name: "transfer".to_string(),
inputs: vec![
AbiParam {
name: "to".to_string(),
param_type: "address".to_string(),
indexed: false,
components: vec![],
},
AbiParam {
name: "amount".to_string(),
param_type: "uint256".to_string(),
indexed: false,
components: vec![],
},
],
outputs: vec![],
state_mutability: "nonpayable".to_string(),
};
let selector = entry.selector();
assert!(selector.starts_with("0x"));
assert_eq!(selector.len(), 10);
}
#[test]
fn test_abi_entry_signature_no_params() {
let entry = AbiEntry {
entry_type: "function".to_string(),
name: "pause".to_string(),
inputs: vec![],
outputs: vec![],
state_mutability: "nonpayable".to_string(),
};
assert_eq!(entry.signature(), "pause()");
}
#[test]
fn test_abi_entry_pure_not_state_changing() {
let entry = AbiEntry {
entry_type: "function".to_string(),
name: "add".to_string(),
inputs: vec![],
outputs: vec![],
state_mutability: "pure".to_string(),
};
assert!(!entry.is_state_changing());
}
#[test]
fn test_abi_entry_payable_is_state_changing() {
let entry = AbiEntry {
entry_type: "function".to_string(),
name: "deposit".to_string(),
inputs: vec![],
outputs: vec![],
state_mutability: "payable".to_string(),
};
assert!(entry.is_state_changing());
}
#[test]
fn test_abi_entry_event_not_state_changing() {
let entry = AbiEntry {
entry_type: "event".to_string(),
name: "Transfer".to_string(),
inputs: vec![],
outputs: vec![],
state_mutability: String::new(),
};
assert!(!entry.is_state_changing());
}
#[test]
fn test_abi_param_canonical_type_tuple() {
let param = AbiParam {
name: "data".to_string(),
param_type: "tuple".to_string(),
indexed: false,
components: vec![
AbiParam {
name: "a".to_string(),
param_type: "address".to_string(),
indexed: false,
components: vec![],
},
AbiParam {
name: "b".to_string(),
param_type: "uint256".to_string(),
indexed: false,
components: vec![],
},
],
};
assert_eq!(param.canonical_type(), "(address,uint256)");
}
#[test]
fn test_abi_param_canonical_type_nested_tuple() {
let param = AbiParam {
name: "nested".to_string(),
param_type: "tuple".to_string(),
indexed: false,
components: vec![AbiParam {
name: "inner".to_string(),
param_type: "tuple".to_string(),
indexed: false,
components: vec![AbiParam {
name: "x".to_string(),
param_type: "uint256".to_string(),
indexed: false,
components: vec![],
}],
}],
};
assert_eq!(param.canonical_type(), "((uint256))");
}
#[test]
fn test_extract_standard_json_source() {
let json_str = r#"{"sources":{"A.sol":{"content":"pragma solidity ^0.8.0;"}}}"#;
let source = ContractSource {
contract_name: "A".to_string(),
source_code: json_str.to_string(),
abi: "[]".to_string(),
compiler_version: "v0.8.19".to_string(),
optimization_used: false,
optimization_runs: 200,
evm_version: "paris".to_string(),
license_type: "MIT".to_string(),
is_proxy: false,
implementation_address: None,
constructor_arguments: String::new(),
library: String::new(),
swarm_source: String::new(),
parsed_abi: vec![],
};
let files = extract_source_files(&source);
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "A.sol");
}
#[test]
fn test_extract_invalid_json_fallback() {
let source = ContractSource {
contract_name: "Token".to_string(),
source_code: "{invalid json".to_string(),
abi: "[]".to_string(),
compiler_version: "v0.8.19".to_string(),
optimization_used: false,
optimization_runs: 200,
evm_version: "paris".to_string(),
license_type: "MIT".to_string(),
is_proxy: false,
implementation_address: None,
constructor_arguments: String::new(),
library: String::new(),
swarm_source: String::new(),
parsed_abi: vec![],
};
let files = extract_source_files(&source);
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "Token.sol");
}
#[test]
fn test_extract_standard_json_no_sources_key() {
let json_str = r#"{"settings":{"optimizer":{"enabled":true}}}"#;
let source = ContractSource {
contract_name: "Token".to_string(),
source_code: json_str.to_string(),
abi: "[]".to_string(),
compiler_version: "v0.8.19".to_string(),
optimization_used: false,
optimization_runs: 200,
evm_version: "paris".to_string(),
license_type: "MIT".to_string(),
is_proxy: false,
implementation_address: None,
constructor_arguments: String::new(),
library: String::new(),
swarm_source: String::new(),
parsed_abi: vec![],
};
let files = extract_source_files(&source);
assert_eq!(files.len(), 0);
}
#[test]
fn test_sha2_256_deterministic() {
let hash1 = sha2_256(b"hello");
let hash2 = sha2_256(b"hello");
assert_eq!(hash1, hash2);
let hash3 = sha2_256(b"world");
assert_ne!(hash1, hash3);
}
#[test]
fn test_contract_source_serialization() {
let source = ContractSource {
contract_name: "Test".to_string(),
source_code: "code".to_string(),
abi: "[]".to_string(),
compiler_version: "v0.8.19".to_string(),
optimization_used: true,
optimization_runs: 200,
evm_version: "paris".to_string(),
license_type: "MIT".to_string(),
is_proxy: true,
implementation_address: Some("0x123".to_string()),
constructor_arguments: "0xdeadbeef".to_string(),
library: "SafeMath:0xabc".to_string(),
swarm_source: "ipfs://Qm123".to_string(),
parsed_abi: vec![],
};
let json = serde_json::to_string(&source).unwrap();
let deserialized: ContractSource = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.contract_name, "Test");
assert!(deserialized.is_proxy);
assert_eq!(
deserialized.implementation_address,
Some("0x123".to_string())
);
}
#[test]
fn test_extract_wrapped_invalid_inner_json() {
let source = ContractSource {
contract_name: "Token".to_string(),
source_code: "{{not valid json}}".to_string(),
abi: "[]".to_string(),
compiler_version: "v0.8.19".to_string(),
optimization_used: false,
optimization_runs: 200,
evm_version: "paris".to_string(),
license_type: "MIT".to_string(),
is_proxy: false,
implementation_address: None,
constructor_arguments: String::new(),
library: String::new(),
swarm_source: String::new(),
parsed_abi: vec![],
};
let files = extract_source_files(&source);
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "Token.sol");
}
#[test]
fn test_extract_standard_json_source_missing_content() {
let json_str = r#"{"sources":{"A.sol":{},"B.sol":{"content":"valid"}}}"#;
let source = ContractSource {
contract_name: "Token".to_string(),
source_code: json_str.to_string(),
abi: "[]".to_string(),
compiler_version: "v0.8.19".to_string(),
optimization_used: false,
optimization_runs: 200,
evm_version: "paris".to_string(),
license_type: "MIT".to_string(),
is_proxy: false,
implementation_address: None,
constructor_arguments: String::new(),
library: String::new(),
swarm_source: String::new(),
parsed_abi: vec![],
};
let files = extract_source_files(&source);
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "B.sol");
assert_eq!(files[0].content, "valid");
}
#[test]
fn test_extract_standard_json_empty_sources_object() {
let json_str = r#"{"sources":{}}"#;
let source = ContractSource {
contract_name: "Token".to_string(),
source_code: json_str.to_string(),
abi: "[]".to_string(),
compiler_version: "v0.8.19".to_string(),
optimization_used: false,
optimization_runs: 200,
evm_version: "paris".to_string(),
license_type: "MIT".to_string(),
is_proxy: false,
implementation_address: None,
constructor_arguments: String::new(),
library: String::new(),
swarm_source: String::new(),
parsed_abi: vec![],
};
let files = extract_source_files(&source);
assert_eq!(files.len(), 0);
}
#[test]
fn test_extract_standard_json_sources_not_object() {
let json_str = r#"{"sources":["file1.sol","file2.sol"]}"#;
let source = ContractSource {
contract_name: "Token".to_string(),
source_code: json_str.to_string(),
abi: "[]".to_string(),
compiler_version: "v0.8.19".to_string(),
optimization_used: false,
optimization_runs: 200,
evm_version: "paris".to_string(),
license_type: "MIT".to_string(),
is_proxy: false,
implementation_address: None,
constructor_arguments: String::new(),
library: String::new(),
swarm_source: String::new(),
parsed_abi: vec![],
};
let files = extract_source_files(&source);
assert_eq!(files.len(), 0);
}
#[test]
fn test_source_file_struct() {
let sf = SourceFile {
path: "contracts/Token.sol".to_string(),
content: "pragma solidity ^0.8.0;".to_string(),
};
assert_eq!(sf.path, "contracts/Token.sol");
let cloned = sf.clone();
assert_eq!(cloned.content, sf.content);
}
#[test]
fn test_abi_entry_constructor_not_state_changing() {
let entry = AbiEntry {
entry_type: "constructor".to_string(),
name: String::new(),
inputs: vec![],
outputs: vec![],
state_mutability: String::new(),
};
assert!(!entry.is_state_changing());
}
#[test]
fn test_abi_entry_fallback_not_state_changing() {
let entry = AbiEntry {
entry_type: "fallback".to_string(),
name: String::new(),
inputs: vec![],
outputs: vec![],
state_mutability: "payable".to_string(),
};
assert!(!entry.is_state_changing());
}
#[test]
fn test_abi_entry_selector_consistency() {
let entry = AbiEntry {
entry_type: "function".to_string(),
name: "transfer".to_string(),
inputs: vec![
AbiParam {
name: "to".to_string(),
param_type: "address".to_string(),
indexed: false,
components: vec![],
},
AbiParam {
name: "value".to_string(),
param_type: "uint256".to_string(),
indexed: false,
components: vec![],
},
],
outputs: vec![],
state_mutability: "nonpayable".to_string(),
};
let sig = entry.signature();
let sel = entry.selector();
assert_eq!(sig, "transfer(address,uint256)");
assert!(sel.starts_with("0x"));
assert_eq!(sel.len(), 10);
}
struct CannedHttp(String);
#[async_trait::async_trait]
impl crate::http::HttpClient for CannedHttp {
async fn send(&self, _req: crate::http::Request) -> Result<crate::http::Response> {
Ok(crate::http::Response {
status_code: 200,
headers: Default::default(),
body: self.0.clone(),
})
}
}
fn etherscan_body(
status: &str,
source: &str,
abi: &str,
proxy: &str,
impl_addr: &str,
) -> String {
serde_json::json!({
"status": status,
"message": "OK",
"result": [{
"SourceCode": source,
"ABI": abi,
"ContractName": "Token",
"CompilerVersion": "v0.8.20",
"OptimizationUsed": "1",
"Runs": "999",
"ConstructorArguments": "",
"EVMVersion": "paris",
"Library": "",
"LicenseType": "MIT",
"Proxy": proxy,
"Implementation": impl_addr,
"SwarmSource": ""
}]
})
.to_string()
}
const ADDR: &str = "0x0000000000000000000000000000000000000001";
#[tokio::test]
async fn test_fetch_source_verified_proxy() {
let abi = r#"[{"type":"function","name":"transfer","inputs":[],"outputs":[]}]"#;
let http = CannedHttp(etherscan_body("1", "contract Token {}", abi, "1", "0xImpl"));
let src = fetch_contract_source(ADDR, "ethereum", &http)
.await
.unwrap();
assert_eq!(src.contract_name, "Token");
assert!(src.optimization_used);
assert_eq!(src.optimization_runs, 999);
assert!(src.is_proxy);
assert_eq!(src.implementation_address.as_deref(), Some("0xImpl"));
assert_eq!(src.parsed_abi.len(), 1);
}
#[tokio::test]
async fn test_fetch_source_non_json_abi_and_bad_runs_use_fallbacks() {
let mut body: serde_json::Value = serde_json::from_str(ðerscan_body(
"1",
"contract Token {}",
"not-json",
"0",
"",
))
.unwrap();
body["result"][0]["Runs"] = "x".into();
let http = CannedHttp(body.to_string());
let src = fetch_contract_source(ADDR, "ethereum", &http)
.await
.unwrap();
assert!(src.parsed_abi.is_empty());
assert_eq!(src.optimization_runs, 200);
assert!(!src.is_proxy);
assert_eq!(src.implementation_address, None);
}
#[tokio::test]
async fn test_fetch_source_unverified_is_not_found() {
let http = CannedHttp(etherscan_body(
"1",
"",
"Contract source code not verified",
"0",
"",
));
let err = fetch_contract_source(ADDR, "ethereum", &http)
.await
.unwrap_err();
assert!(matches!(err, ScopeError::NotFound(_)), "{err}");
}
#[tokio::test]
async fn test_fetch_source_status_zero_is_not_found() {
let http = CannedHttp(r#"{"status":"0","message":"NOTOK","result":[]}"#.to_string());
let err = fetch_contract_source(ADDR, "ethereum", &http)
.await
.unwrap_err();
assert!(matches!(err, ScopeError::NotFound(_)), "{err}");
}
#[tokio::test]
async fn test_fetch_source_unsupported_chain_makes_no_request() {
struct PanicHttp;
#[async_trait::async_trait]
impl crate::http::HttpClient for PanicHttp {
async fn send(&self, _req: crate::http::Request) -> Result<crate::http::Response> {
panic!("no request expected for an unsupported chain");
}
}
let err = fetch_contract_source(ADDR, "solana", &PanicHttp)
.await
.unwrap_err();
assert!(err.to_string().contains("does not have Etherscan"), "{err}");
}
}