1use std::time::{Duration, Instant};
11
12pub fn explorer_api_url(chain: &str) -> Option<&'static str> {
21 match chain.to_lowercase().as_str() {
22 "ethereum" | "eth" | "mainnet" => Some("https://api.etherscan.io/api"),
23 "base" => Some("https://api.basescan.org/api"),
24 "arbitrum" | "arb" => Some("https://api.arbiscan.io/api"),
25 "optimism" | "op" => Some("https://api-optimistic.etherscan.io/api"),
26 "polygon" | "matic" => Some("https://api.polygonscan.com/api"),
27 "bnb" | "bsc" => Some("https://api.bscscan.com/api"),
28 "avalanche" | "avax" => Some("https://api.snowtrace.io/api"),
29 "scroll" => Some("https://api.scrollscan.com/api"),
30 "linea" => Some("https://api.lineascan.build/api"),
31 "zksync" | "zk" => Some("https://api-era.zksync.network/api"),
32 "blast" => Some("https://api.blastscan.io/api"),
33 "mantle" => Some("https://api.mantlescan.xyz/api"),
34 "robinhood" => Some("https://api.robinhoodscan.com/api"),
35 _ => None,
36 }
37}
38
39pub fn explorer_base_url(chain: &str) -> Option<&'static str> {
42 match chain.to_lowercase().as_str() {
43 "ethereum" | "eth" | "mainnet" => Some("https://etherscan.io"),
44 "base" => Some("https://basescan.org"),
45 "arbitrum" | "arb" => Some("https://arbiscan.io"),
46 "optimism" | "op" => Some("https://optimistic.etherscan.io"),
47 "polygon" | "matic" => Some("https://polygonscan.com"),
48 "bnb" | "bsc" => Some("https://bscscan.com"),
49 "avalanche" | "avax" => Some("https://snowtrace.io"),
50 "scroll" => Some("https://scrollscan.com"),
51 "linea" => Some("https://lineascan.build"),
52 "unichain" => Some("https://uniscan.xyz"),
53 "zksync" | "zk" => Some("https://explorer.zksync.io"),
54 "hyperevm" | "hyperliquid" => Some("https://hyperscan.xyz"),
55 "monad" => Some("https://monadscan.xyz"),
56 "sonic" => Some("https://sonicscan.org"),
57 "blast" => Some("https://blastscan.io"),
58 "mantle" => Some("https://mantlescan.xyz"),
59 "robinhood" => Some("https://robinhoodscan.com"),
60 _ => None,
61 }
62}
63
64pub fn explorer_api_key_env(chain: &str) -> &'static str {
66 match chain.to_lowercase().as_str() {
67 "ethereum" | "eth" | "mainnet" => "ETHERSCAN_API_KEY",
68 "base" => "BASESCAN_API_KEY",
69 "arbitrum" | "arb" => "ARBISCAN_API_KEY",
70 "optimism" | "op" => "OPTIMISTIC_ETHERSCAN_API_KEY",
71 "polygon" | "matic" => "POLYGONSCAN_API_KEY",
72 "bnb" | "bsc" => "BSCSCAN_API_KEY",
73 "avalanche" | "avax" => "SNOWTRACE_API_KEY",
74 "scroll" => "SCROLLSCAN_API_KEY",
75 "linea" => "LINEASCAN_API_KEY",
76 "zksync" | "zk" => "ZKSYNC_API_KEY",
77 "blast" => "BLASTSCAN_API_KEY",
78 "mantle" => "MANTLESCAN_API_KEY",
79 _ => "ETHERSCAN_API_KEY",
80 }
81}
82
83#[derive(Debug, Clone)]
89pub struct VerificationResult {
90 pub verified: bool,
92 pub method: VerificationMethod,
94 pub details: String,
96 pub duration: Duration,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum VerificationMethod {
103 Explorer,
105 BytecodeMatch,
107 Sourcify,
109 Pending,
111}
112
113impl std::fmt::Display for VerificationMethod {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 match self {
116 Self::Explorer => write!(f, "Block Explorer"),
117 Self::BytecodeMatch => write!(f, "Bytecode Match"),
118 Self::Sourcify => write!(f, "Sourcify"),
119 Self::Pending => write!(f, "Pending"),
120 }
121 }
122}
123
124pub struct ContractVerifier {
130 api_key: String,
132}
133
134impl ContractVerifier {
135 pub fn new(chain: &str, api_key: Option<String>) -> Self {
139 let key = api_key
140 .filter(|k| !k.is_empty())
141 .or_else(|| std::env::var(explorer_api_key_env(chain)).ok())
142 .unwrap_or_default();
143
144 Self { api_key: key }
145 }
146
147 pub fn forge_verify(
151 &self,
152 address: &str,
153 contract_name: &str,
154 chain: &str,
155 constructor_args: Option<&str>,
156 ) -> VerificationResult {
157 let start = Instant::now();
158
159 if self.api_key.is_empty() {
160 return VerificationResult {
161 verified: false,
162 method: VerificationMethod::Explorer,
163 details: format!(
164 "No API key set. Set {} or pass --explorer-api-key.",
165 explorer_api_key_env(chain)
166 ),
167 duration: start.elapsed(),
168 };
169 }
170
171 let mut cmd = std::process::Command::new("forge");
172 cmd.arg("verify-contract")
173 .arg(address)
174 .arg(contract_name)
175 .arg("--chain")
176 .arg(chain)
177 .arg("--etherscan-api-key")
178 .arg(&self.api_key);
179
180 if let Some(args) = constructor_args {
181 cmd.arg("--constructor-args").arg(args);
182 }
183
184 match cmd.output() {
185 Ok(output) => {
186 let _stdout = String::from_utf8_lossy(&output.stdout);
187 let stderr = String::from_utf8_lossy(&output.stderr);
188
189 if output.status.success() {
190 VerificationResult {
191 verified: true,
192 method: VerificationMethod::Explorer,
193 details: "Contract verified successfully on block explorer.".into(),
194 duration: start.elapsed(),
195 }
196 } else {
197 let msg = if stderr.contains("Already Verified") {
198 "Contract already verified on block explorer.".to_string()
199 } else {
200 format!("Verification failed: {}", stderr.trim())
201 };
202 VerificationResult {
203 verified: msg.contains("Already Verified"),
204 method: VerificationMethod::Explorer,
205 details: msg,
206 duration: start.elapsed(),
207 }
208 }
209 }
210 Err(e) => VerificationResult {
211 verified: false,
212 method: VerificationMethod::Explorer,
213 details: format!("Failed to run forge verify-contract: {e}"),
214 duration: start.elapsed(),
215 },
216 }
217 }
218
219 pub fn verify_bytecode_match(
227 &self,
228 address: &str,
229 contract_name: &str,
230 rpc_url: &str,
231 ) -> VerificationResult {
232 let start = Instant::now();
233
234 let _build_output = match std::process::Command::new("forge")
236 .arg("build")
237 .arg("--silent")
238 .output()
239 {
240 Ok(o) if o.status.success() => o,
241 Ok(o) => {
242 let stderr = String::from_utf8_lossy(&o.stderr);
243 return VerificationResult {
244 verified: false,
245 method: VerificationMethod::BytecodeMatch,
246 details: format!("Compilation failed: {}", stderr.trim()),
247 duration: start.elapsed(),
248 };
249 }
250 Err(e) => {
251 return VerificationResult {
252 verified: false,
253 method: VerificationMethod::BytecodeMatch,
254 details: format!("Failed to run forge build: {e}"),
255 duration: start.elapsed(),
256 };
257 }
258 };
259
260 let out_path = format!("out/{contract_name}.sol/{contract_name}.json");
263 let bytecode = match std::fs::read_to_string(&out_path) {
264 Ok(content) => {
265 let parsed: serde_json::Value =
266 serde_json::from_str(&content).unwrap_or(serde_json::Value::Null);
267 parsed["deployedBytecode"]["object"]
268 .as_str()
269 .map(|s| s.trim_start_matches("0x").to_lowercase())
270 .unwrap_or_default()
271 }
272 Err(_) => {
273 let alt_path = format!("out/{contract_name}.json");
275 match std::fs::read_to_string(&alt_path) {
276 Ok(content) => {
277 let parsed: serde_json::Value =
278 serde_json::from_str(&content).unwrap_or(serde_json::Value::Null);
279 parsed["deployedBytecode"]["object"]
280 .as_str()
281 .map(|s| s.trim_start_matches("0x").to_lowercase())
282 .unwrap_or_default()
283 }
284 Err(_) => String::new(),
285 }
286 }
287 };
288
289 if bytecode.is_empty() {
290 return VerificationResult {
291 verified: false,
292 method: VerificationMethod::BytecodeMatch,
293 details: format!(
294 "Could not find compiled bytecode at out/{contract_name}.sol/{contract_name}.json"
295 ),
296 duration: start.elapsed(),
297 };
298 }
299
300 let onchain_bytecode = match self.fetch_onchain_bytecode(rpc_url, address) {
302 Some(code) => code.trim_start_matches("0x").to_lowercase(),
303 None => {
304 return VerificationResult {
305 verified: false,
306 method: VerificationMethod::BytecodeMatch,
307 details: format!("Could not fetch on-chain bytecode for {address}"),
308 duration: start.elapsed(),
309 };
310 }
311 };
312
313 let local_stripped = strip_metadata(&bytecode);
315 let onchain_stripped = strip_metadata(&onchain_bytecode);
316
317 if local_stripped == onchain_stripped {
318 VerificationResult {
319 verified: true,
320 method: VerificationMethod::BytecodeMatch,
321 details: format!(
322 "Local bytecode matches on-chain bytecode at {address} ({} bytes matched)",
323 local_stripped.len() / 2
324 ),
325 duration: start.elapsed(),
326 }
327 } else {
328 let diff_pos = local_stripped
330 .chars()
331 .zip(onchain_stripped.chars())
332 .position(|(a, b)| a != b);
333
334 VerificationResult {
335 verified: false,
336 method: VerificationMethod::BytecodeMatch,
337 details: format!(
338 "Bytecode mismatch at address {address}. Local: {} bytes, On-chain: {} bytes{}",
339 local_stripped.len() / 2,
340 onchain_stripped.len() / 2,
341 diff_pos
342 .map(|p| format!(", first difference at hex position {p}"))
343 .unwrap_or_default()
344 ),
345 duration: start.elapsed(),
346 }
347 }
348 }
349
350 fn fetch_onchain_bytecode(&self, rpc_url: &str, address: &str) -> Option<String> {
352 let client = reqwest::blocking::Client::builder()
353 .timeout(Duration::from_secs(30))
354 .build()
355 .ok()?;
356
357 let body = serde_json::json!({
358 "jsonrpc": "2.0",
359 "method": "eth_getCode",
360 "params": [address, "latest"],
361 "id": 1
362 });
363
364 let resp = client
365 .post(rpc_url)
366 .header("Content-Type", "application/json")
367 .json(&body)
368 .send()
369 .ok()?;
370
371 let text = resp.text().ok()?;
372 let parsed: serde_json::Value = serde_json::from_str(&text).ok()?;
373 parsed["result"].as_str().map(String::from)
374 }
375}
376
377fn strip_metadata(bytecode: &str) -> &str {
386 let len = bytecode.len();
389 if len < 106 {
390 return bytecode;
391 }
392
393 let metadata_start_candidates = [106, 110, 114, 118]; for &offset in &metadata_start_candidates {
399 if offset > len {
400 continue;
401 }
402 let start = len - offset;
403 if bytecode[start..].ends_with("0029") {
406 return &bytecode[..start];
407 }
408 }
409
410 &bytecode[..len - 106]
412}
413
414pub fn forge_available() -> bool {
416 std::process::Command::new("forge")
417 .arg("--version")
418 .output()
419 .map(|o| o.status.success())
420 .unwrap_or(false)
421}
422
423pub fn rpc_url_for_chain(chain: &str) -> String {
425 let env_var = match chain.to_lowercase().as_str() {
427 "ethereum" | "eth" | "mainnet" => "ETH_RPC_URL",
428 "base" => "BASE_RPC_URL",
429 "arbitrum" | "arb" => "ARBITRUM_RPC_URL",
430 "optimism" | "op" => "OPTIMISM_RPC_URL",
431 "polygon" | "matic" => "POLYGON_RPC_URL",
432 "bnb" | "bsc" => "BSC_RPC_URL",
433 _ => "ETH_RPC_URL",
434 };
435
436 std::env::var(env_var)
437 .or_else(|_| std::env::var("ETH_RPC_URL"))
438 .unwrap_or_else(|_| "http://localhost:8545".into())
439}
440
441#[cfg(test)]
443mod tests {
444 use super::*;
445 use std::time::Duration;
446
447 #[test]
448 fn test_explorer_api_url_ethereum() {
449 assert_eq!(
450 explorer_api_url("ethereum"),
451 Some("https://api.etherscan.io/api")
452 );
453 assert_eq!(
454 explorer_api_url("eth"),
455 Some("https://api.etherscan.io/api")
456 );
457 assert_eq!(
458 explorer_api_url("mainnet"),
459 Some("https://api.etherscan.io/api")
460 );
461 }
462
463 #[test]
464 fn test_explorer_api_url_base() {
465 assert_eq!(
466 explorer_api_url("base"),
467 Some("https://api.basescan.org/api")
468 );
469 }
470
471 #[test]
472 fn test_explorer_api_url_arbitrum() {
473 assert_eq!(explorer_api_url("arb"), Some("https://api.arbiscan.io/api"));
474 }
475
476 #[test]
477 fn test_explorer_api_url_unknown() {
478 assert_eq!(explorer_api_url("unknown-chain"), None);
479 }
480
481 #[test]
482 fn test_explorer_base_url() {
483 assert_eq!(explorer_base_url("ethereum"), Some("https://etherscan.io"));
484 assert_eq!(explorer_base_url("base"), Some("https://basescan.org"));
485 assert_eq!(explorer_base_url("arbitrum"), Some("https://arbiscan.io"));
486 }
487
488 #[test]
489 fn test_explorer_api_key_env() {
490 assert_eq!(explorer_api_key_env("ethereum"), "ETHERSCAN_API_KEY");
491 assert_eq!(explorer_api_key_env("base"), "BASESCAN_API_KEY");
492 assert_eq!(explorer_api_key_env("arb"), "ARBISCAN_API_KEY");
493 assert_eq!(explorer_api_key_env("polygon"), "POLYGONSCAN_API_KEY");
494 }
495
496 #[test]
497 fn test_verifier_no_api_key() {
498 let verifier = ContractVerifier::new("ethereum", None);
499 let result = verifier.forge_verify("0x1234", "MyContract", "ethereum", None);
500 assert!(!result.verified);
501 assert!(result.details.contains("API key"));
502 }
503
504 #[test]
505 fn test_verifier_explicit_api_key() {
506 let verifier = ContractVerifier::new("ethereum", Some("test_key".into()));
507 let result = verifier.forge_verify("0x1234", "MyContract", "ethereum", None);
510 assert!(result.details.contains("failed") || result.details.contains("forge"));
512 }
513
514 #[test]
515 fn test_strip_metadata_short_bytecode() {
516 let short = "0x1234";
518 assert_eq!(strip_metadata(short), short);
519 }
520
521 #[test]
522 fn test_strip_metadata_with_cbor_tail() {
523 let mut bytecode = "608060405260008054".to_string();
525 while bytecode.len() < 200 {
527 bytecode.push_str("00");
528 }
529 bytecode.push_str("a2646970667358221220");
531 while bytecode.len() % 2 != 0 {
532 bytecode.push('0');
533 }
534 if !bytecode.ends_with("0029") {
536 bytecode.push_str("0029");
537 }
538
539 let stripped = strip_metadata(&bytecode);
540 assert!(
541 stripped.len() < bytecode.len(),
542 "should strip metadata bytes"
543 );
544 assert!(stripped.ends_with("00"), "should end with valid opcode");
545 }
546
547 #[test]
548 fn test_verification_method_display() {
549 assert_eq!(VerificationMethod::Explorer.to_string(), "Block Explorer");
550 assert_eq!(
551 VerificationMethod::BytecodeMatch.to_string(),
552 "Bytecode Match"
553 );
554 assert_eq!(VerificationMethod::Pending.to_string(), "Pending");
555 }
556
557 #[test]
558 fn test_verification_result_creation() {
559 let result = VerificationResult {
560 verified: true,
561 method: VerificationMethod::Explorer,
562 details: "Success".into(),
563 duration: Duration::from_secs(2),
564 };
565 assert!(result.verified);
566 assert_eq!(result.method, VerificationMethod::Explorer);
567 }
568
569 #[test]
570 fn test_rpc_url_from_env() {
571 let url = rpc_url_for_chain("ethereum");
573 assert!(!url.is_empty());
574 assert!(url.contains("localhost") || url.contains("http"));
575 }
576
577 #[test]
578 fn test_forge_available() {
579 let _available = forge_available();
582 }
583
584 #[test]
585 fn test_bytecode_verify_no_forge() {
586 let verifier = ContractVerifier::new("ethereum", None);
587 let result = verifier.verify_bytecode_match(
588 "0xdead000000000000000000000000000000000000",
589 "Nonexistent",
590 "http://localhost:8545",
591 );
592 assert!(!result.verified, "should fail without forge build");
594 assert!(!result.details.is_empty());
595 }
596}