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 pub fn forge_available(&self) -> bool {
353 forge_available()
354 }
355
356 fn fetch_onchain_bytecode(&self, rpc_url: &str, address: &str) -> Option<String> {
358 let client = reqwest::blocking::Client::builder()
359 .timeout(Duration::from_secs(30))
360 .build()
361 .ok()?;
362
363 let body = serde_json::json!({
364 "jsonrpc": "2.0",
365 "method": "eth_getCode",
366 "params": [address, "latest"],
367 "id": 1
368 });
369
370 let resp = client
371 .post(rpc_url)
372 .header("Content-Type", "application/json")
373 .json(&body)
374 .send()
375 .ok()?;
376
377 let text = resp.text().ok()?;
378 let parsed: serde_json::Value = serde_json::from_str(&text).ok()?;
379 parsed["result"].as_str().map(String::from)
380 }
381}
382
383fn strip_metadata(bytecode: &str) -> &str {
392 let len = bytecode.len();
395 if len < 106 {
396 return bytecode;
397 }
398
399 let metadata_start_candidates = [106, 110, 114, 118]; for &offset in &metadata_start_candidates {
405 if offset > len {
406 continue;
407 }
408 let start = len - offset;
409 if bytecode[start..].ends_with("0029") {
412 return &bytecode[..start];
413 }
414 }
415
416 &bytecode[..len - 106]
418}
419
420pub fn forge_available() -> bool {
422 std::process::Command::new("forge")
423 .arg("--version")
424 .output()
425 .map(|o| o.status.success())
426 .unwrap_or(false)
427}
428
429pub fn rpc_url_for_chain(chain: &str) -> String {
431 let env_var = match chain.to_lowercase().as_str() {
433 "ethereum" | "eth" | "mainnet" => "ETH_RPC_URL",
434 "base" => "BASE_RPC_URL",
435 "arbitrum" | "arb" => "ARBITRUM_RPC_URL",
436 "optimism" | "op" => "OPTIMISM_RPC_URL",
437 "polygon" | "matic" => "POLYGON_RPC_URL",
438 "bnb" | "bsc" => "BSC_RPC_URL",
439 _ => "ETH_RPC_URL",
440 };
441
442 std::env::var(env_var)
443 .or_else(|_| std::env::var("ETH_RPC_URL"))
444 .unwrap_or_else(|_| "http://localhost:8545".into())
445}
446
447#[cfg(test)]
449mod tests {
450 use super::*;
451 use std::time::Duration;
452
453 #[test]
454 fn test_explorer_api_url_ethereum() {
455 assert_eq!(
456 explorer_api_url("ethereum"),
457 Some("https://api.etherscan.io/api")
458 );
459 assert_eq!(
460 explorer_api_url("eth"),
461 Some("https://api.etherscan.io/api")
462 );
463 assert_eq!(
464 explorer_api_url("mainnet"),
465 Some("https://api.etherscan.io/api")
466 );
467 }
468
469 #[test]
470 fn test_explorer_api_url_base() {
471 assert_eq!(
472 explorer_api_url("base"),
473 Some("https://api.basescan.org/api")
474 );
475 }
476
477 #[test]
478 fn test_explorer_api_url_arbitrum() {
479 assert_eq!(explorer_api_url("arb"), Some("https://api.arbiscan.io/api"));
480 }
481
482 #[test]
483 fn test_explorer_api_url_unknown() {
484 assert_eq!(explorer_api_url("unknown-chain"), None);
485 }
486
487 #[test]
488 fn test_explorer_base_url() {
489 assert_eq!(explorer_base_url("ethereum"), Some("https://etherscan.io"));
490 assert_eq!(explorer_base_url("base"), Some("https://basescan.org"));
491 assert_eq!(explorer_base_url("arbitrum"), Some("https://arbiscan.io"));
492 }
493
494 #[test]
495 fn test_explorer_api_key_env() {
496 assert_eq!(explorer_api_key_env("ethereum"), "ETHERSCAN_API_KEY");
497 assert_eq!(explorer_api_key_env("base"), "BASESCAN_API_KEY");
498 assert_eq!(explorer_api_key_env("arb"), "ARBISCAN_API_KEY");
499 assert_eq!(explorer_api_key_env("polygon"), "POLYGONSCAN_API_KEY");
500 }
501
502 #[test]
503 fn test_verifier_no_api_key() {
504 let verifier = ContractVerifier::new("ethereum", None);
505 let result = verifier.forge_verify("0x1234", "MyContract", "ethereum", None);
506 assert!(!result.verified);
507 assert!(result.details.contains("API key"));
508 }
509
510 #[test]
511 fn test_verifier_explicit_api_key() {
512 let verifier = ContractVerifier::new("ethereum", Some("test_key".into()));
513 let result = verifier.forge_verify("0x1234", "MyContract", "ethereum", None);
516 assert!(result.details.contains("failed") || result.details.contains("forge"));
518 }
519
520 #[test]
521 fn test_strip_metadata_short_bytecode() {
522 let short = "0x1234";
524 assert_eq!(strip_metadata(short), short);
525 }
526
527 #[test]
528 fn test_strip_metadata_with_cbor_tail() {
529 let mut bytecode = "608060405260008054".to_string();
531 while bytecode.len() < 200 {
533 bytecode.push_str("00");
534 }
535 bytecode.push_str("a2646970667358221220");
537 while bytecode.len() % 2 != 0 {
538 bytecode.push('0');
539 }
540 if !bytecode.ends_with("0029") {
542 bytecode.push_str("0029");
543 }
544
545 let stripped = strip_metadata(&bytecode);
546 assert!(
547 stripped.len() < bytecode.len(),
548 "should strip metadata bytes"
549 );
550 assert!(stripped.ends_with("00"), "should end with valid opcode");
551 }
552
553 #[test]
554 fn test_verification_method_display() {
555 assert_eq!(VerificationMethod::Explorer.to_string(), "Block Explorer");
556 assert_eq!(
557 VerificationMethod::BytecodeMatch.to_string(),
558 "Bytecode Match"
559 );
560 assert_eq!(VerificationMethod::Pending.to_string(), "Pending");
561 }
562
563 #[test]
564 fn test_verification_result_creation() {
565 let result = VerificationResult {
566 verified: true,
567 method: VerificationMethod::Explorer,
568 details: "Success".into(),
569 duration: Duration::from_secs(2),
570 };
571 assert!(result.verified);
572 assert_eq!(result.method, VerificationMethod::Explorer);
573 }
574
575 #[test]
576 fn test_rpc_url_from_env() {
577 let url = rpc_url_for_chain("ethereum");
579 assert!(!url.is_empty());
580 assert!(url.contains("localhost") || url.contains("http"));
581 }
582
583 #[test]
584 fn test_forge_available() {
585 let _available = forge_available();
588 }
589
590 #[test]
591 fn test_bytecode_verify_no_forge() {
592 let verifier = ContractVerifier::new("ethereum", None);
593 let result = verifier.verify_bytecode_match(
594 "0xdead000000000000000000000000000000000000",
595 "Nonexistent",
596 "http://localhost:8545",
597 );
598 assert!(!result.verified, "should fail without forge build");
600 assert!(!result.details.is_empty());
601 }
602}