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    /// Fetch on-chain bytecode using `eth_getCode` RPC call.
351    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
377/// Strip the Solidity metadata hash from the end of a bytecode string.
378///
379/// Solidity appends a CBOR-encoded metadata hash (ipfs/Swarm) to the end of
380/// deployed bytecode. This hash differs between compilations even when the
381/// source code is identical, so we strip it before comparing.
382///
383/// The metadata is encoded in the last 53 bytes (106 hex chars) of bytecode
384/// and always ends with the CBOR tag `0xa264` for IPFS or `0xa265` for Swarm.
385fn strip_metadata(bytecode: &str) -> &str {
386    // The metadata section is at the end. Solidity 0.8+ uses last 53 bytes.
387    // We look for the CBOR metadata marker at the tail.
388    let len = bytecode.len();
389    if len < 106 {
390        return bytecode;
391    }
392
393    // Check for the CBOR end-of-encoding marker patterns
394    // The last byte of valid bytecode before metadata is the end of the contract code.
395    // Metadata starts with 0xa2 (IPFS) or 0xa2 (CBOR map of 2 items) or 0xa3 (CBOR map of 3 items)
396    let metadata_start_candidates = [106, 110, 114, 118]; // common metadata lengths
397
398    for &offset in &metadata_start_candidates {
399        if offset > len {
400            continue;
401        }
402        let start = len - offset;
403        // Check if this looks like metadata by examining the tail pattern
404        // Valid metadata ends with 0x00 0x29 (empty string CBOR)
405        if bytecode[start..].ends_with("0029") {
406            return &bytecode[..start];
407        }
408    }
409
410    // Fallback: strip the last 106 hex chars (53 bytes)
411    &bytecode[..len - 106]
412}
413
414/// Check whether `forge` CLI is available on the system.
415pub 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
423/// Get the RPC URL for a chain from environment or return a default.
424pub fn rpc_url_for_chain(chain: &str) -> String {
425    // Check chain-specific env vars first
426    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// ── Tests ────────────────────────────────────────────────────
442#[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        // This will fail because forge isn't available or the address doesn't exist,
508        // but it proves the API key was used.
509        let result = verifier.forge_verify("0x1234", "MyContract", "ethereum", None);
510        // It should try to run forge, not fail on API key
511        assert!(result.details.contains("failed") || result.details.contains("forge"));
512    }
513
514    #[test]
515    fn test_strip_metadata_short_bytecode() {
516        // Bytecode shorter than metadata section — return as-is
517        let short = "0x1234";
518        assert_eq!(strip_metadata(short), short);
519    }
520
521    #[test]
522    fn test_strip_metadata_with_cbor_tail() {
523        // Simulated bytecode of sufficient length
524        let mut bytecode = "608060405260008054".to_string();
525        // Pad to 200 chars
526        while bytecode.len() < 200 {
527            bytecode.push_str("00");
528        }
529        // Add CBOR tail ending with 0029
530        bytecode.push_str("a2646970667358221220");
531        while bytecode.len() % 2 != 0 {
532            bytecode.push('0');
533        }
534        // End with 0029
535        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        // Should return default when no env vars are set
572        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        // This test verifies forge detection — forge might not be installed
580        // in the test environment, so we just check it doesn't panic
581        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        // Should fail gracefully, not panic
593        assert!(!result.verified, "should fail without forge build");
594        assert!(!result.details.is_empty());
595    }
596}