use std::time::{Duration, Instant};
pub fn explorer_api_url(chain: &str) -> Option<&'static str> {
match chain.to_lowercase().as_str() {
"ethereum" | "eth" | "mainnet" => Some("https://api.etherscan.io/api"),
"base" => Some("https://api.basescan.org/api"),
"arbitrum" | "arb" => Some("https://api.arbiscan.io/api"),
"optimism" | "op" => Some("https://api-optimistic.etherscan.io/api"),
"polygon" | "matic" => Some("https://api.polygonscan.com/api"),
"bnb" | "bsc" => Some("https://api.bscscan.com/api"),
"avalanche" | "avax" => Some("https://api.snowtrace.io/api"),
"scroll" => Some("https://api.scrollscan.com/api"),
"linea" => Some("https://api.lineascan.build/api"),
"zksync" | "zk" => Some("https://api-era.zksync.network/api"),
"blast" => Some("https://api.blastscan.io/api"),
"mantle" => Some("https://api.mantlescan.xyz/api"),
"robinhood" => Some("https://api.robinhoodscan.com/api"),
_ => None,
}
}
pub fn explorer_base_url(chain: &str) -> Option<&'static str> {
match chain.to_lowercase().as_str() {
"ethereum" | "eth" | "mainnet" => Some("https://etherscan.io"),
"base" => Some("https://basescan.org"),
"arbitrum" | "arb" => Some("https://arbiscan.io"),
"optimism" | "op" => Some("https://optimistic.etherscan.io"),
"polygon" | "matic" => Some("https://polygonscan.com"),
"bnb" | "bsc" => Some("https://bscscan.com"),
"avalanche" | "avax" => Some("https://snowtrace.io"),
"scroll" => Some("https://scrollscan.com"),
"linea" => Some("https://lineascan.build"),
"unichain" => Some("https://uniscan.xyz"),
"zksync" | "zk" => Some("https://explorer.zksync.io"),
"hyperevm" | "hyperliquid" => Some("https://hyperscan.xyz"),
"monad" => Some("https://monadscan.xyz"),
"sonic" => Some("https://sonicscan.org"),
"blast" => Some("https://blastscan.io"),
"mantle" => Some("https://mantlescan.xyz"),
"robinhood" => Some("https://robinhoodscan.com"),
_ => None,
}
}
pub fn explorer_api_key_env(chain: &str) -> &'static str {
match chain.to_lowercase().as_str() {
"ethereum" | "eth" | "mainnet" => "ETHERSCAN_API_KEY",
"base" => "BASESCAN_API_KEY",
"arbitrum" | "arb" => "ARBISCAN_API_KEY",
"optimism" | "op" => "OPTIMISTIC_ETHERSCAN_API_KEY",
"polygon" | "matic" => "POLYGONSCAN_API_KEY",
"bnb" | "bsc" => "BSCSCAN_API_KEY",
"avalanche" | "avax" => "SNOWTRACE_API_KEY",
"scroll" => "SCROLLSCAN_API_KEY",
"linea" => "LINEASCAN_API_KEY",
"zksync" | "zk" => "ZKSYNC_API_KEY",
"blast" => "BLASTSCAN_API_KEY",
"mantle" => "MANTLESCAN_API_KEY",
_ => "ETHERSCAN_API_KEY",
}
}
#[derive(Debug, Clone)]
pub struct VerificationResult {
pub verified: bool,
pub method: VerificationMethod,
pub details: String,
pub duration: Duration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerificationMethod {
Explorer,
BytecodeMatch,
Sourcify,
Pending,
}
impl std::fmt::Display for VerificationMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Explorer => write!(f, "Block Explorer"),
Self::BytecodeMatch => write!(f, "Bytecode Match"),
Self::Sourcify => write!(f, "Sourcify"),
Self::Pending => write!(f, "Pending"),
}
}
}
pub struct ContractVerifier {
api_key: String,
}
impl ContractVerifier {
pub fn new(chain: &str, api_key: Option<String>) -> Self {
let key = api_key
.filter(|k| !k.is_empty())
.or_else(|| std::env::var(explorer_api_key_env(chain)).ok())
.unwrap_or_default();
Self { api_key: key }
}
pub fn forge_verify(
&self,
address: &str,
contract_name: &str,
chain: &str,
constructor_args: Option<&str>,
) -> VerificationResult {
let start = Instant::now();
if self.api_key.is_empty() {
return VerificationResult {
verified: false,
method: VerificationMethod::Explorer,
details: format!(
"No API key set. Set {} or pass --explorer-api-key.",
explorer_api_key_env(chain)
),
duration: start.elapsed(),
};
}
let mut cmd = std::process::Command::new("forge");
cmd.arg("verify-contract")
.arg(address)
.arg(contract_name)
.arg("--chain")
.arg(chain)
.arg("--etherscan-api-key")
.arg(&self.api_key);
if let Some(args) = constructor_args {
cmd.arg("--constructor-args").arg(args);
}
match cmd.output() {
Ok(output) => {
let _stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.success() {
VerificationResult {
verified: true,
method: VerificationMethod::Explorer,
details: "Contract verified successfully on block explorer.".into(),
duration: start.elapsed(),
}
} else {
let msg = if stderr.contains("Already Verified") {
"Contract already verified on block explorer.".to_string()
} else {
format!("Verification failed: {}", stderr.trim())
};
VerificationResult {
verified: msg.contains("Already Verified"),
method: VerificationMethod::Explorer,
details: msg,
duration: start.elapsed(),
}
}
}
Err(e) => VerificationResult {
verified: false,
method: VerificationMethod::Explorer,
details: format!("Failed to run forge verify-contract: {e}"),
duration: start.elapsed(),
},
}
}
pub fn verify_bytecode_match(
&self,
address: &str,
contract_name: &str,
rpc_url: &str,
) -> VerificationResult {
let start = Instant::now();
let _build_output = match std::process::Command::new("forge")
.arg("build")
.arg("--silent")
.output()
{
Ok(o) if o.status.success() => o,
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
return VerificationResult {
verified: false,
method: VerificationMethod::BytecodeMatch,
details: format!("Compilation failed: {}", stderr.trim()),
duration: start.elapsed(),
};
}
Err(e) => {
return VerificationResult {
verified: false,
method: VerificationMethod::BytecodeMatch,
details: format!("Failed to run forge build: {e}"),
duration: start.elapsed(),
};
}
};
let out_path = format!("out/{contract_name}.sol/{contract_name}.json");
let bytecode = match std::fs::read_to_string(&out_path) {
Ok(content) => {
let parsed: serde_json::Value =
serde_json::from_str(&content).unwrap_or(serde_json::Value::Null);
parsed["deployedBytecode"]["object"]
.as_str()
.map(|s| s.trim_start_matches("0x").to_lowercase())
.unwrap_or_default()
}
Err(_) => {
let alt_path = format!("out/{contract_name}.json");
match std::fs::read_to_string(&alt_path) {
Ok(content) => {
let parsed: serde_json::Value =
serde_json::from_str(&content).unwrap_or(serde_json::Value::Null);
parsed["deployedBytecode"]["object"]
.as_str()
.map(|s| s.trim_start_matches("0x").to_lowercase())
.unwrap_or_default()
}
Err(_) => String::new(),
}
}
};
if bytecode.is_empty() {
return VerificationResult {
verified: false,
method: VerificationMethod::BytecodeMatch,
details: format!(
"Could not find compiled bytecode at out/{contract_name}.sol/{contract_name}.json"
),
duration: start.elapsed(),
};
}
let onchain_bytecode = match self.fetch_onchain_bytecode(rpc_url, address) {
Some(code) => code.trim_start_matches("0x").to_lowercase(),
None => {
return VerificationResult {
verified: false,
method: VerificationMethod::BytecodeMatch,
details: format!("Could not fetch on-chain bytecode for {address}"),
duration: start.elapsed(),
};
}
};
let local_stripped = strip_metadata(&bytecode);
let onchain_stripped = strip_metadata(&onchain_bytecode);
if local_stripped == onchain_stripped {
VerificationResult {
verified: true,
method: VerificationMethod::BytecodeMatch,
details: format!(
"Local bytecode matches on-chain bytecode at {address} ({} bytes matched)",
local_stripped.len() / 2
),
duration: start.elapsed(),
}
} else {
let diff_pos = local_stripped
.chars()
.zip(onchain_stripped.chars())
.position(|(a, b)| a != b);
VerificationResult {
verified: false,
method: VerificationMethod::BytecodeMatch,
details: format!(
"Bytecode mismatch at address {address}. Local: {} bytes, On-chain: {} bytes{}",
local_stripped.len() / 2,
onchain_stripped.len() / 2,
diff_pos
.map(|p| format!(", first difference at hex position {p}"))
.unwrap_or_default()
),
duration: start.elapsed(),
}
}
}
pub fn forge_available(&self) -> bool {
forge_available()
}
fn fetch_onchain_bytecode(&self, rpc_url: &str, address: &str) -> Option<String> {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.ok()?;
let body = serde_json::json!({
"jsonrpc": "2.0",
"method": "eth_getCode",
"params": [address, "latest"],
"id": 1
});
let resp = client
.post(rpc_url)
.header("Content-Type", "application/json")
.json(&body)
.send()
.ok()?;
let text = resp.text().ok()?;
let parsed: serde_json::Value = serde_json::from_str(&text).ok()?;
parsed["result"].as_str().map(String::from)
}
}
fn strip_metadata(bytecode: &str) -> &str {
let len = bytecode.len();
if len < 106 {
return bytecode;
}
let metadata_start_candidates = [106, 110, 114, 118];
for &offset in &metadata_start_candidates {
if offset > len {
continue;
}
let start = len - offset;
if bytecode[start..].ends_with("0029") {
return &bytecode[..start];
}
}
&bytecode[..len - 106]
}
pub fn forge_available() -> bool {
std::process::Command::new("forge")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn rpc_url_for_chain(chain: &str) -> String {
let env_var = match chain.to_lowercase().as_str() {
"ethereum" | "eth" | "mainnet" => "ETH_RPC_URL",
"base" => "BASE_RPC_URL",
"arbitrum" | "arb" => "ARBITRUM_RPC_URL",
"optimism" | "op" => "OPTIMISM_RPC_URL",
"polygon" | "matic" => "POLYGON_RPC_URL",
"bnb" | "bsc" => "BSC_RPC_URL",
_ => "ETH_RPC_URL",
};
std::env::var(env_var)
.or_else(|_| std::env::var("ETH_RPC_URL"))
.unwrap_or_else(|_| "http://localhost:8545".into())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn test_explorer_api_url_ethereum() {
assert_eq!(
explorer_api_url("ethereum"),
Some("https://api.etherscan.io/api")
);
assert_eq!(
explorer_api_url("eth"),
Some("https://api.etherscan.io/api")
);
assert_eq!(
explorer_api_url("mainnet"),
Some("https://api.etherscan.io/api")
);
}
#[test]
fn test_explorer_api_url_base() {
assert_eq!(
explorer_api_url("base"),
Some("https://api.basescan.org/api")
);
}
#[test]
fn test_explorer_api_url_arbitrum() {
assert_eq!(explorer_api_url("arb"), Some("https://api.arbiscan.io/api"));
}
#[test]
fn test_explorer_api_url_unknown() {
assert_eq!(explorer_api_url("unknown-chain"), None);
}
#[test]
fn test_explorer_base_url() {
assert_eq!(explorer_base_url("ethereum"), Some("https://etherscan.io"));
assert_eq!(explorer_base_url("base"), Some("https://basescan.org"));
assert_eq!(explorer_base_url("arbitrum"), Some("https://arbiscan.io"));
}
#[test]
fn test_explorer_api_key_env() {
assert_eq!(explorer_api_key_env("ethereum"), "ETHERSCAN_API_KEY");
assert_eq!(explorer_api_key_env("base"), "BASESCAN_API_KEY");
assert_eq!(explorer_api_key_env("arb"), "ARBISCAN_API_KEY");
assert_eq!(explorer_api_key_env("polygon"), "POLYGONSCAN_API_KEY");
}
#[test]
fn test_verifier_no_api_key() {
let verifier = ContractVerifier::new("ethereum", None);
let result = verifier.forge_verify("0x1234", "MyContract", "ethereum", None);
assert!(!result.verified);
assert!(result.details.contains("API key"));
}
#[test]
fn test_verifier_explicit_api_key() {
let verifier = ContractVerifier::new("ethereum", Some("test_key".into()));
let result = verifier.forge_verify("0x1234", "MyContract", "ethereum", None);
assert!(result.details.contains("failed") || result.details.contains("forge"));
}
#[test]
fn test_strip_metadata_short_bytecode() {
let short = "0x1234";
assert_eq!(strip_metadata(short), short);
}
#[test]
fn test_strip_metadata_with_cbor_tail() {
let mut bytecode = "608060405260008054".to_string();
while bytecode.len() < 200 {
bytecode.push_str("00");
}
bytecode.push_str("a2646970667358221220");
while bytecode.len() % 2 != 0 {
bytecode.push('0');
}
if !bytecode.ends_with("0029") {
bytecode.push_str("0029");
}
let stripped = strip_metadata(&bytecode);
assert!(
stripped.len() < bytecode.len(),
"should strip metadata bytes"
);
assert!(stripped.ends_with("00"), "should end with valid opcode");
}
#[test]
fn test_verification_method_display() {
assert_eq!(VerificationMethod::Explorer.to_string(), "Block Explorer");
assert_eq!(
VerificationMethod::BytecodeMatch.to_string(),
"Bytecode Match"
);
assert_eq!(VerificationMethod::Pending.to_string(), "Pending");
}
#[test]
fn test_verification_result_creation() {
let result = VerificationResult {
verified: true,
method: VerificationMethod::Explorer,
details: "Success".into(),
duration: Duration::from_secs(2),
};
assert!(result.verified);
assert_eq!(result.method, VerificationMethod::Explorer);
}
#[test]
fn test_rpc_url_from_env() {
let url = rpc_url_for_chain("ethereum");
assert!(!url.is_empty());
assert!(url.contains("localhost") || url.contains("http"));
}
#[test]
fn test_forge_available() {
let _available = forge_available();
}
#[test]
fn test_bytecode_verify_no_forge() {
let verifier = ContractVerifier::new("ethereum", None);
let result = verifier.verify_bytecode_match(
"0xdead000000000000000000000000000000000000",
"Nonexistent",
"http://localhost:8545",
);
assert!(!result.verified, "should fail without forge build");
assert!(!result.details.is_empty());
}
}