Skip to main content

forge_guard/deployment/
verifier.rs

1//! On-chain contract verification — verifies deployed contracts against
2//! their source code via block explorers (Etherscan, Basescan, etc.) and
3//! RPC bytecode matching.
4//!
5//! # Explorer URL Registry
6//!
7//! Maps chain names to their block explorer API URLs for all 17 supported chains.
8//! Used by [`forge verify-contract`] and direct API calls.
9
10use std::time::{Duration, Instant};
11
12// ─────────────────────────────────────────────────────────────
13// Explorer API URL Registry
14// ─────────────────────────────────────────────────────────────
15
16/// Returns the block explorer API base URL for a given chain name.
17///
18/// These are the standard Etherscan-compatible API endpoints used by
19/// `forge verify-contract`. Returns `None` for unknown chains.
20pub 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
39/// Returns the block explorer front-end URL for a given chain name.
40/// Used for providing clickable links in reports.
41pub 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
64/// Get the environment variable name for the explorer API key.
65pub 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// ─────────────────────────────────────────────────────────────
84// Verification Types
85// ─────────────────────────────────────────────────────────────
86
87/// The result of a verification attempt.
88#[derive(Debug, Clone)]
89pub struct VerificationResult {
90    /// Whether the contract was successfully verified.
91    pub verified: bool,
92    /// The method used for verification.
93    pub method: VerificationMethod,
94    /// Human-readable details about the result.
95    pub details: String,
96    /// Duration of the verification attempt.
97    pub duration: Duration,
98}
99
100/// The method used for contract verification.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum VerificationMethod {
103    /// Verified via block explorer API (Etherscan, Basescan, etc.)
104    Explorer,
105    /// Verified by comparing local bytecode with on-chain bytecode via RPC
106    BytecodeMatch,
107    /// Verified via Sourcify
108    Sourcify,
109    /// Not yet verified
110    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
124// ─────────────────────────────────────────────────────────────
125// Verification Engine
126// ─────────────────────────────────────────────────────────────
127
128/// Handles on-chain contract verification via explorer APIs and RPC bytecode matching.
129pub struct ContractVerifier {
130    /// Explorer API key.
131    api_key: String,
132}
133
134impl ContractVerifier {
135    /// Create a new verifier for the given chain.
136    ///
137    /// `api_key` — optional explorer API key. Reads the chain-specific env var when empty.
138    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    /// Verify a contract using `forge verify-contract`.
148    ///
149    /// This delegates to Foundry's built-in Etherscan integration.
150    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    /// Compare locally compiled bytecode with on-chain bytecode via RPC.
220    ///
221    /// This method:
222    /// 1. Runs `forge build` to compile the contract
223    /// 2. Extracts the deployed bytecode from `out/`
224    /// 3. Calls `eth_getCode` via RPC
225    /// 4. Compares the two (stripping metadata hash)
226    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        // Step 1: Compile the contract
235        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        // Step 2: Parse bytecode from compilation output
261        // Forge outputs to out/{contract}.sol/{contract}.json with deployedBytecode field
262        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                // Try alternate path: flattened contract name
274                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        // Step 3: Fetch on-chain bytecode via RPC
301        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        // Step 4: Compare (strip metadata hash — last 53 bytes / 106 hex chars)
314        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            // Find where they diverge
329            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    /// Check whether the `forge` CLI is available on the system.
351    /// Delegates to the free function [`forge_available()`].
352    pub fn forge_available(&self) -> bool {
353        forge_available()
354    }
355
356    /// Fetch on-chain bytecode using `eth_getCode` RPC call.
357    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
383/// Strip the Solidity metadata hash from the end of a bytecode string.
384///
385/// Solidity appends a CBOR-encoded metadata hash (ipfs/Swarm) to the end of
386/// deployed bytecode. This hash differs between compilations even when the
387/// source code is identical, so we strip it before comparing.
388///
389/// The metadata is encoded in the last 53 bytes (106 hex chars) of bytecode
390/// and always ends with the CBOR tag `0xa264` for IPFS or `0xa265` for Swarm.
391fn strip_metadata(bytecode: &str) -> &str {
392    // The metadata section is at the end. Solidity 0.8+ uses last 53 bytes.
393    // We look for the CBOR metadata marker at the tail.
394    let len = bytecode.len();
395    if len < 106 {
396        return bytecode;
397    }
398
399    // Check for the CBOR end-of-encoding marker patterns
400    // The last byte of valid bytecode before metadata is the end of the contract code.
401    // Metadata starts with 0xa2 (IPFS) or 0xa2 (CBOR map of 2 items) or 0xa3 (CBOR map of 3 items)
402    let metadata_start_candidates = [106, 110, 114, 118]; // common metadata lengths
403
404    for &offset in &metadata_start_candidates {
405        if offset > len {
406            continue;
407        }
408        let start = len - offset;
409        // Check if this looks like metadata by examining the tail pattern
410        // Valid metadata ends with 0x00 0x29 (empty string CBOR)
411        if bytecode[start..].ends_with("0029") {
412            return &bytecode[..start];
413        }
414    }
415
416    // Fallback: strip the last 106 hex chars (53 bytes)
417    &bytecode[..len - 106]
418}
419
420/// Check whether `forge` CLI is available on the system.
421pub 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
429/// Get the RPC URL for a chain from environment or return a default.
430pub fn rpc_url_for_chain(chain: &str) -> String {
431    // Check chain-specific env vars first
432    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// ── Tests ────────────────────────────────────────────────────
448#[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        // This will fail because forge isn't available or the address doesn't exist,
514        // but it proves the API key was used.
515        let result = verifier.forge_verify("0x1234", "MyContract", "ethereum", None);
516        // It should try to run forge, not fail on API key
517        assert!(result.details.contains("failed") || result.details.contains("forge"));
518    }
519
520    #[test]
521    fn test_strip_metadata_short_bytecode() {
522        // Bytecode shorter than metadata section — return as-is
523        let short = "0x1234";
524        assert_eq!(strip_metadata(short), short);
525    }
526
527    #[test]
528    fn test_strip_metadata_with_cbor_tail() {
529        // Simulated bytecode of sufficient length
530        let mut bytecode = "608060405260008054".to_string();
531        // Pad to 200 chars
532        while bytecode.len() < 200 {
533            bytecode.push_str("00");
534        }
535        // Add CBOR tail ending with 0029
536        bytecode.push_str("a2646970667358221220");
537        while bytecode.len() % 2 != 0 {
538            bytecode.push('0');
539        }
540        // End with 0029
541        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        // Should return default when no env vars are set
578        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        // This test verifies forge detection — forge might not be installed
586        // in the test environment, so we just check it doesn't panic
587        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        // Should fail gracefully, not panic
599        assert!(!result.verified, "should fail without forge build");
600        assert!(!result.details.is_empty());
601    }
602}