forge-guard 0.3.4

Pre-deployment smart contract auditing framework for Foundry
Documentation
//! `forge-guard verify` — verify contract deployments on block explorers.

use super::VerifyArgs;
use crate::deployment::verifier::{
    explorer_api_key_env, explorer_api_url, explorer_base_url, forge_available, rpc_url_for_chain,
    ContractVerifier,
};
use anyhow::Result;
use colored::*;

/// Verify contract deployments on block explorers.
pub fn run(args: &VerifyArgs) -> Result<()> {
    eprintln!("{}\n", "🔍 Forge Guard — Contract Verification".bold());

    if !forge_available() {
        anyhow::bail!(
            "{} `forge` CLI not found. Install Foundry: https://book.getfoundry.sh/getting-started/installation",
            "".red()
        );
    }

    if args.all {
        return verify_all_deployments(args);
    }

    let address = args
        .address
        .as_deref()
        .ok_or_else(|| anyhow::anyhow!("Provide --address or use --all"))?;
    let name = args
        .name
        .as_deref()
        .ok_or_else(|| anyhow::anyhow!("Provide --name or use --all"))?;

    let verifier = ContractVerifier::new(&args.shared.chain, args.api_key.clone());

    // Show what we're doing
    let api_url = explorer_api_url(&args.shared.chain);
    let base_url = explorer_base_url(&args.shared.chain);

    println!("  📄 Contract:  {}", name.bold());
    println!("  📍 Address:   {}", address);
    println!("  ⛓️  Chain:     {}", args.shared.chain);
    if let Some(url) = api_url {
        println!("  🔗 Explorer:  {}", url.dimmed());
    }
    if let Some(url) = base_url {
        println!("  🔍 Address:   {}/address/{}", url, address);
    }
    println!();

    // Attempt 1: Explorer verification via forge verify-contract
    eprintln!("  🔄 Verifying via block explorer...");
    let result = verifier.forge_verify(
        address,
        name,
        &args.shared.chain,
        args.constructor_args.as_deref(),
    );

    if result.verified {
        println!("  {}  {}", "✅ VERIFIED".green().bold(), result.details);
        if let Some(url) = base_url {
            println!("     🔍 {}/address/{}#code", url, address);
        }
        return Ok(());
    }

    // Attempt 2: Bytecode match via RPC
    eprintln!("  ⚠️  Explorer verification: {}", result.details);
    eprintln!("  🔄 Checking bytecode match via RPC...");

    let rpc = rpc_url_for_chain(&args.shared.chain);
    let bytecode_result = verifier.verify_bytecode_match(address, name, &rpc);

    if bytecode_result.verified {
        println!(
            "  {}  {}",
            "✅ BYTECODE MATCH".green().bold(),
            bytecode_result.details
        );
        println!(
            "     {} Try: forge verify-contract {} {} --chain {}",
            "💡".dimmed(),
            address,
            name,
            args.shared.chain
        );
        // Suggest setting up the API key
        let env = explorer_api_key_env(&args.shared.chain);
        println!(
            "     {} Set {} to enable explorer verification",
            "💡".dimmed(),
            env
        );
        return Ok(());
    }

    // Both failed
    eprintln!("{}", bytecode_result.details);
    eprintln!();

    if let Some(env) = Some(explorer_api_key_env(&args.shared.chain)) {
        eprintln!(
            "  {} Set the {} environment variable or pass --api-key",
            "💡".dimmed(),
            env
        );
    }

    anyhow::bail!("Verification failed for {} on {}", name, args.shared.chain);
}

/// Verify all contracts by reading the deployment artifact.
fn verify_all_deployments(args: &VerifyArgs) -> Result<()> {
    eprintln!("  🔍 Checking all deployments...\n");

    // Look for broadcast/ directory with deployment receipts
    let broadcast_dir = args.shared.project.join("broadcast");
    if !broadcast_dir.exists() {
        anyhow::bail!(
            "No broadcast directory found at {}. Run `forge script` first.",
            broadcast_dir.display()
        );
    }

    let verifier = ContractVerifier::new(&args.shared.chain, args.api_key.clone());
    let mut verified = 0u32;
    let mut failed = 0u32;

    // Walk broadcast directory for deployment JSON files
    for entry in walkdir::WalkDir::new(&broadcast_dir)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| {
            e.file_name().to_string_lossy().ends_with(".json")
                && !e.file_name().to_string_lossy().contains("run-latest")
        })
    {
        let content = match std::fs::read_to_string(entry.path()) {
            Ok(c) => c,
            Err(_) => continue,
        };

        let parsed: serde_json::Value = match serde_json::from_str(&content) {
            Ok(v) => v,
            Err(_) => continue,
        };

        // Extract transactions from the deployment artifact
        let transactions = parsed["transactions"].as_array();
        let receipts = parsed["receipts"].as_array();

        // Process deployments from transactions
        if let Some(txs) = transactions {
            for tx in txs {
                let contract_address = tx["contractAddress"].as_str();
                let contract_name = tx["contractName"].as_str();

                if let (Some(addr), Some(name)) = (contract_address, contract_name) {
                    if addr.is_empty() || addr == "0x0000000000000000000000000000000000000000" {
                        continue;
                    }

                    print!("  📄 {} at {} ... ", name, addr);
                    std::io::Write::flush(&mut std::io::stdout()).ok();

                    let result = verifier.forge_verify(addr, name, &args.shared.chain, None);

                    if result.verified {
                        println!("{}", "".green());
                        verified += 1;
                    } else {
                        println!("{} {}", "".red(), result.details);
                        failed += 1;
                    }
                }
            }
        }

        // Also check receipts
        if let Some(recs) = receipts {
            for rec in recs {
                let contract_address = rec["contractAddress"].as_str();
                if let Some(addr) = contract_address {
                    if addr.is_empty() || addr == "0x0000000000000000000000000000000000000000" {
                        continue;
                    }

                    // Check if we already verified this address from transactions
                    let already_verified = transactions
                        .map(|txs| {
                            txs.iter()
                                .any(|tx| tx["contractAddress"].as_str() == Some(addr))
                        })
                        .unwrap_or(false);

                    if !already_verified {
                        print!("  📄 Contract at {} ... ", addr);
                        std::io::Write::flush(&mut std::io::stdout()).ok();

                        let result =
                            verifier.forge_verify(addr, "unknown", &args.shared.chain, None);

                        if result.verified {
                            println!("{}", "".green());
                            verified += 1;
                        } else {
                            println!("{} {}", "".red(), result.details);
                            failed += 1;
                        }
                    }
                }
            }
        }
    }

    if verified == 0 && failed == 0 {
        eprintln!("  ⚠️  No deployments found in broadcast directory.");
    } else {
        println!(
            "\n  {} {} verified, {} failed",
            if failed == 0 {
                "".green()
            } else {
                "⚠️".yellow()
            },
            verified,
            failed
        );
    }

    if failed > 0 {
        anyhow::bail!("{failed} contract(s) failed verification");
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_verify_arg_defaults() {
        let args = VerifyArgs {
            shared: super::super::SharedFlags {
                chain: "ethereum".into(),
                project: std::path::PathBuf::from("."),
                json: false,
                markdown: false,
                html: false,
                strict: false,
                offline: false,
                production: false,
                report: false,
                parallelism: 4,
            },
            address: None,
            name: None,
            api_key: None,
            constructor_args: None,
            all: false,
        };
        assert!(args.address.is_none());
        assert!(args.name.is_none());
        assert!(args.api_key.is_none());
        assert!(args.constructor_args.is_none());
        assert!(!args.all);
    }

    #[test]
    fn test_verify_with_address_and_name() {
        let args = VerifyArgs {
            shared: super::super::SharedFlags::default(),
            address: Some("0x1234".into()),
            name: Some("MyContract".into()),
            api_key: Some("test-api-key".into()),
            constructor_args: Some("0x0001".into()),
            all: false,
        };
        assert_eq!(args.address.as_deref(), Some("0x1234"));
        assert_eq!(args.name.as_deref(), Some("MyContract"));
        assert_eq!(args.api_key.as_deref(), Some("test-api-key"));
        assert_eq!(args.constructor_args.as_deref(), Some("0x0001"));
    }

    #[test]
    fn test_verify_all_flag() {
        let args = VerifyArgs {
            shared: super::super::SharedFlags::default(),
            address: None,
            name: None,
            api_key: None,
            constructor_args: None,
            all: true,
        };
        assert!(args.all);
    }

    #[test]
    fn test_explorer_api_url_known_chain() {
        let url = explorer_api_url("ethereum");
        assert!(url.is_some());
        let url_str = url.unwrap();
        assert!(url_str.contains("etherscan"));
    }

    #[test]
    fn test_explorer_api_url_unknown_chain_is_none() {
        let url = explorer_api_url("nonexistent-chain");
        assert!(url.is_none());
    }

    #[test]
    fn test_explorer_base_url_known_chain() {
        let url = explorer_base_url("base");
        assert!(url.is_some());
        let url_str = url.unwrap();
        assert!(url_str.contains("basescan"));
    }

    #[test]
    fn test_rpc_url_for_chain_returns_url() {
        let url = rpc_url_for_chain("ethereum");
        assert!(
            url.contains("http"),
            "Expected URL containing http, got: {url}"
        );
    }

    #[test]
    fn test_explorer_api_key_env_known_chain() {
        let env = explorer_api_key_env("ethereum");
        assert!(env.contains("API_KEY"));
    }

    #[test]
    fn test_forge_available_returns_false_in_test() {
        // In a CI/test environment, forge is likely not installed
        let available = forge_available();
        // Just verify it returns a bool
        assert!(available == true || available == false);
    }

    #[test]
    fn test_contract_verifier_creation() {
        let verifier = ContractVerifier::new("ethereum", None);
        // Verify it was created — we can't actually run verification without forge
        assert!(verifier.forge_available() == true || verifier.forge_available() == false);
    }

    #[test]
    fn test_contract_verifier_with_api_key() {
        let verifier = ContractVerifier::new("base", Some("test-key".into()));
        // Should construct without error
        // We can't verify in tests, but the constructor should work
        assert!(verifier.forge_available() == true || verifier.forge_available() == false);
    }

    #[test]
    fn test_verify_address_empty_check() {
        // Test the logic for checking empty address in verify_all_deployments
        let empty_addr = "0x0000000000000000000000000000000000000000";
        assert!(
            empty_addr.is_empty() || empty_addr == "0x0000000000000000000000000000000000000000"
        );

        let valid_addr = "0x1234";
        assert!(
            !valid_addr.is_empty() && valid_addr != "0x0000000000000000000000000000000000000000"
        );
    }

    #[test]
    fn test_explorer_base_url_arbitrum() {
        let url = explorer_base_url("arbitrum");
        assert!(url.is_some());
        let url_str = url.unwrap();
        assert!(url_str.contains("arbiscan"));
    }

    #[test]
    fn test_rpc_url_unknown_chain_fallback() {
        let url = rpc_url_for_chain("unknown-chain");
        assert!(!url.is_empty());
    }
}