diff --git a/Cargo.lock b/Cargo.lock index 714b376..9341b19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3276,6 +3276,18 @@ dependencies = [ "syn", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.13" @@ -3366,6 +3378,8 @@ dependencies = [ "keccak-hash", "rand 0.9.2", "rayon", + "regex", + "reqwest", "rex-sdk", "secp256k1", "serde", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index d020056..a9bdbce 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -38,12 +38,16 @@ coins-bip39 = "0.12" # Utils hex.workspace = true itertools = "0.14.0" +regex = "1" toml = "0.8.19" dirs = "6.0.0" rand = "0.9.1" rayon.workspace = true url = "2.5.7" +# HTTP +reqwest = { version = "0.12.7", features = ["json"] } + # Serde serde = "1.0.218" serde_json = "1.0.139" diff --git a/cli/README.md b/cli/README.md index 4021110..2420731 100644 --- a/cli/README.md +++ b/cli/README.md @@ -425,12 +425,99 @@ Options: Remove downloaded dependencies after compilation --salt Salt for deploying CREATE2 contracts. If it is provided, the contract will be deployed using CREATE2. + --optimizations + Number of optimization runs for the Solidity compiler + --verify-contract + Verify the contract on Etherscan after deployment + --etherscan-api-key + Etherscan API key for contract verification [env: ETHERSCAN_API_KEY=] + --contract-name + Contract name (defaults to filename stem). Required when file contains multiple contracts. --rpc-url [env: RPC_URL=] [default: http://localhost:8545] -h, --help Print help ``` +#### Contract verification + +The `--verify-contract` flag automatically verifies the contract source code on Etherscan after deployment. This makes the contract's source code publicly readable and its ABI available on Etherscan. + +**Requirements:** +- Must use `--contract-path` (not `--bytecode`) +- Requires an Etherscan API key via `--etherscan-api-key` or the `ETHERSCAN_API_KEY` environment variable +- Incompatible with `--cast` (verification needs to wait for the transaction receipt) +- Requires `solc` installed locally (the same version used to compile will be reported to Etherscan) + +**Supported networks:** Ethereum Mainnet, Sepolia, and Holesky (uses [Etherscan API V2](https://docs.etherscan.io/etherscan-v2)). + +**Basic example:** + +```shell +rex deploy \ + --contract-path MyContract.sol \ + --remappings "" \ + --verify-contract \ + --etherscan-api-key $ETHERSCAN_API_KEY \ + --private-key $PRIVATE_KEY \ + --rpc-url https://ethereum-sepolia-rpc.publicnode.com +``` + +**With optimizer enabled:** + +```shell +rex deploy \ + --contract-path MyContract.sol \ + --remappings "" \ + --optimizations 200 \ + --verify-contract \ + --etherscan-api-key $ETHERSCAN_API_KEY \ + --private-key $PRIVATE_KEY \ + --rpc-url https://ethereum-sepolia-rpc.publicnode.com +``` + +**With imports and remappings:** + +```shell +rex deploy \ + --contract-path MyToken.sol \ + --remappings "@openzeppelin/contracts=https://github.com/OpenZeppelin/openzeppelin-contracts.git" \ + --verify-contract \ + --etherscan-api-key $ETHERSCAN_API_KEY \ + --private-key $PRIVATE_KEY \ + --rpc-url https://ethereum-sepolia-rpc.publicnode.com +``` + +**With constructor arguments:** + +```shell +rex deploy \ + --contract-path MyToken.sol \ + --remappings "" \ + --verify-contract \ + --etherscan-api-key $ETHERSCAN_API_KEY \ + --private-key $PRIVATE_KEY \ + --rpc-url https://ethereum-sepolia-rpc.publicnode.com \ + -- "constructor(string,uint256)" "MyToken" 1000000 +``` + +**Output:** + +``` +Compiler run successful. Artifact(s) can be found in directory "./solc_out". +Contract deployed in tx: 0x1234... +Contract address: 0x5678... + +Verifying contract on Etherscan... + Compiler version: v0.8.31+commit.fd3a2265 + Contract name: MyContract + Submitting verification request... + Verification submitted (GUID: abc123...) + [1/60] Checking verification status... + Contract verified successfully! + https://sepolia.etherscan.io/address/0x5678...#code +``` + ### `rex encode-calldata` ```Shell diff --git a/cli/src/cli.rs b/cli/src/cli.rs index 1125387..d49b8ba 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -3,6 +3,7 @@ use crate::common::{AuthorizeArgs, BlockOverrideArgs, StateOverrideArgs}; use crate::utils::{ encode_constructor_args, parse_contract_creation, parse_func_call, parse_hex, parse_hex_string, }; +use crate::verify::{VerifyParams, verify_contract_on_etherscan}; use crate::{ commands::autocomplete, common::{CallArgs, DeployArgs, SendArgs, TransferArgs}, @@ -645,23 +646,44 @@ impl Command { println!("{result}"); } - Command::Deploy { args, rpc_url } => { + Command::Deploy { mut args, rpc_url } => { if args.explorer_url { todo!("Display transaction URL in the explorer") } + if args.verify_contract { + if args.bytecode.is_some() { + return Err(eyre::eyre!( + "--verify-contract cannot be used with --bytecode. Use --contract-path instead." + )); + } + if args.etherscan_api_key.is_none() { + return Err(eyre::eyre!( + "--verify-contract requires --etherscan-api-key or ETHERSCAN_API_KEY env var." + )); + } + if args.cast { + return Err(eyre::eyre!( + "--verify-contract is incompatible with --cast (verification needs to wait for the receipt)." + )); + } + } + let deployer = Signer::Local(LocalSigner::new(args.private_key)); let client = EthClient::new(rpc_url)?; - let bytecode = if let Some(bytecode) = args.bytecode { - bytecode - } else { - compile_contract_from_path(args.clone()).await? + let (bytecode, compilation_info) = match args.bytecode.take() { + Some(bytecode) => (bytecode, None), + None => { + let info = compile_contract_from_path(&args)?; + let bytecode = info.bytecode.clone(); + (bytecode, Some(info)) + } }; let init_args = if !args.constructor_args.is_empty() { encode_constructor_args(&args.constructor_args)? } else if !args._args.is_empty() { - parse_contract_creation(args._args)? + parse_contract_creation(std::mem::take(&mut args._args))? } else { Bytes::new() }; @@ -676,7 +698,7 @@ impl Command { ) .await? } else { - let init_code = [bytecode, init_args].concat().into(); + let init_code = [bytecode, init_args.clone()].concat().into(); deploy( &client, &deployer, @@ -706,6 +728,35 @@ impl Command { if !args.cast { wait_for_transaction_receipt(tx_hash, &client, 100, silent).await?; } + + if args.verify_contract { + // Unwraps are safe: validation above rejects bytecode and missing API key. + let info = compilation_info + .as_ref() + .expect("compilation_info is always set when verify_contract is true"); + + let chain_id: u64 = client + .get_chain_id() + .await? + .try_into() + .map_err(|_| eyre::eyre!("Chain ID too large to fit in u64"))?; + + verify_contract_on_etherscan(VerifyParams { + contract_address: deployed_contract_address, + contract_path: args.contract_path.unwrap(), + contract_name: args.contract_name, + constructor_args: init_args.to_vec(), + remappings: info.remappings.clone(), + optimize_runs: info.optimize_runs, + etherscan_api_key: args.etherscan_api_key.unwrap(), + chain_id, + }) + .await?; + } + + if let Some(info) = compilation_info.filter(|i| i.should_clean) { + cleanup_compilation_artifacts(info.cloned_dirs); + } } Command::ChainId { hex, rpc_url } => { let eth_client = EthClient::new(rpc_url)?; @@ -960,16 +1011,24 @@ fn print_calldata(depth: usize, data: Value) { } } -async fn compile_contract_from_path(args: DeployArgs) -> eyre::Result { +struct CompilationInfo { + bytecode: Bytes, + remappings: Vec<(String, PathBuf)>, + optimize_runs: Option, + cloned_dirs: Vec, + should_clean: bool, +} + +fn compile_contract_from_path(args: &DeployArgs) -> eyre::Result { let contract_path = args .contract_path .as_ref() .ok_or_else(|| eyre::eyre!("Contract path is required when bytecode is not provided"))?; - let clean = !args.keep_deps; + let should_clean = !args.keep_deps; let output_dir = Path::new("."); let deps_dir = Path::new("rex_deps"); - let mut solc_remappings = Vec::new(); + let mut solc_remappings: Vec<(String, PathBuf)> = Vec::new(); let mut cloned_dirs = Vec::new(); std::fs::create_dir_all(deps_dir).ok(); @@ -1033,7 +1092,7 @@ async fn compile_contract_from_path(args: DeployArgs) -> eyre::Result { false, Some(&solc_remappings_ref), &include_paths, - None, + args.optimizations, ) .map_err(|e| eyre::eyre!("Failed to compile contract: {}", e))?; @@ -1062,16 +1121,21 @@ async fn compile_contract_from_path(args: DeployArgs) -> eyre::Result { })? .into(); - if clean { - for dir in cloned_dirs { - if dir.exists() { - std::fs::remove_dir_all(&dir) - .map_err(|e| eyre::eyre!("Failed to clean up {}: {}", dir.display(), e))?; - } + Ok(CompilationInfo { + bytecode, + remappings: solc_remappings, + optimize_runs: args.optimizations, + cloned_dirs, + should_clean, + }) +} + +fn cleanup_compilation_artifacts(cloned_dirs: Vec) { + for dir in cloned_dirs { + if dir.exists() { + std::fs::remove_dir_all(&dir).ok(); } - std::fs::remove_dir_all("rex_deps").ok(); - std::fs::remove_dir_all("solc_out").ok(); } - - Ok(bytecode) + std::fs::remove_dir_all("rex_deps").ok(); + std::fs::remove_dir_all("solc_out").ok(); } diff --git a/cli/src/common.rs b/cli/src/common.rs index dc19723..707098e 100644 --- a/cli/src/common.rs +++ b/cli/src/common.rs @@ -447,6 +447,32 @@ pub struct DeployArgs { required = false )] pub salt: Option, + #[arg( + long, + required = false, + help = "Number of optimization runs for the Solidity compiler" + )] + pub optimizations: Option, + #[arg( + long, + help = "Verify the contract on Etherscan after deployment", + default_value_t = false, + requires = "source" + )] + pub verify_contract: bool, + #[arg( + long, + env = "ETHERSCAN_API_KEY", + required = false, + help = "Etherscan API key for contract verification" + )] + pub etherscan_api_key: Option, + #[arg( + long, + required = false, + help = "Contract name (defaults to filename stem). Required when file contains multiple contracts." + )] + pub contract_name: Option, #[arg(last = true, hide = true)] pub _args: Vec, #[arg( diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 2bfae0f..3fa8b74 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -6,3 +6,4 @@ pub mod cli; mod commands; mod common; mod utils; +mod verify; diff --git a/cli/src/verify.rs b/cli/src/verify.rs new file mode 100644 index 0000000..63f70f8 --- /dev/null +++ b/cli/src/verify.rs @@ -0,0 +1,340 @@ +use std::collections::{BTreeMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::LazyLock; +use std::time::Duration; + +use ethrex_common::Address; +use eyre::ContextCompat; +use regex::Regex; +use serde::Deserialize; + +pub struct VerifyParams { + pub contract_address: Address, + pub contract_path: PathBuf, + pub contract_name: Option, + pub constructor_args: Vec, + pub remappings: Vec<(String, PathBuf)>, + pub optimize_runs: Option, + pub etherscan_api_key: String, + pub chain_id: u64, +} + +#[derive(Deserialize)] +struct EtherscanResponse { + status: String, + result: String, + message: String, +} + +pub async fn verify_contract_on_etherscan(params: VerifyParams) -> eyre::Result<()> { + let solc_version = get_solc_version()?; + + let source_file_key = file_name_str(¶ms.contract_path); + + let contract_name = params.contract_name.unwrap_or_else(|| { + params + .contract_path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string() + }); + + let standard_json = build_standard_json_input( + ¶ms.contract_path, + ¶ms.remappings, + params.optimize_runs, + )?; + let standard_json_str = serde_json::to_string(&standard_json)?; + + let constructor_args_hex = hex::encode(¶ms.constructor_args); + + println!("\nVerifying contract on Etherscan..."); + println!(" Compiler version: {solc_version}"); + println!(" Contract name: {contract_name}"); + + let client = reqwest::Client::new(); + + println!(" Submitting verification request..."); + + let chain_id_str = params.chain_id.to_string(); + let contract_address_str = format!("{:#x}", params.contract_address); + let contract_name_str = format!("{source_file_key}:{contract_name}"); + + let form = [ + ("apikey", params.etherscan_api_key.as_str()), + ("module", "contract"), + ("action", "verifysourcecode"), + ("contractaddress", &contract_address_str), + ("sourceCode", &standard_json_str), + ("codeformat", "solidity-standard-json-input"), + ("contractname", &contract_name_str), + ("compilerversion", &solc_version), + ("constructorArguements", &constructor_args_hex), + ]; + + let url = format!("{ETHERSCAN_API_V2}?chainid={chain_id_str}"); + + // Etherscan may not have indexed the contract yet, retry submission + let guid = { + const MAX_SUBMIT_RETRIES: u32 = 12; + const SUBMIT_INTERVAL: Duration = Duration::from_secs(10); + let mut guid = None; + + for attempt in 1..=MAX_SUBMIT_RETRIES { + let resp = client + .post(&url) + .form(&form) + .send() + .await? + .json::() + .await?; + + if resp.status == "1" { + println!(" Verification submitted (GUID: {})", resp.result); + guid = Some(resp.result); + break; + } + + if resp.result.contains("Unable to locate") { + println!( + " [{attempt}/{MAX_SUBMIT_RETRIES}] Waiting for Etherscan to index the contract..." + ); + tokio::time::sleep(SUBMIT_INTERVAL).await; + continue; + } + + return Err(eyre::eyre!( + "Etherscan verification submission failed: {} ({})", + resp.result, + resp.message, + )); + } + + guid.ok_or_else(|| { + eyre::eyre!("Etherscan did not index the contract after {MAX_SUBMIT_RETRIES} retries") + })? + }; + + poll_verification_status(&client, ¶ms.etherscan_api_key, &chain_id_str, &guid).await?; + + let explorer_base = etherscan_explorer_url(params.chain_id); + println!( + " {explorer_base}/address/{:#x}#code", + params.contract_address + ); + + Ok(()) +} + +fn build_standard_json_input( + contract_path: &Path, + remappings: &[(String, PathBuf)], + optimize_runs: Option, +) -> eyre::Result { + let sources = resolve_sources(contract_path, remappings)?; + + let remappings_list: Vec = remappings + .iter() + .map(|(prefix, path)| format!("{prefix}={}", path.display())) + .collect(); + + let optimizer = match optimize_runs { + Some(runs) => serde_json::json!({ "enabled": true, "runs": runs }), + None => serde_json::json!({ "enabled": false }), + }; + + Ok(serde_json::json!({ + "language": "Solidity", + "sources": sources.into_iter().map(|(k, v)| { + (k, serde_json::json!({ "content": v })) + }).collect::>(), + "settings": { + "viaIR": true, + "metadata": { "appendCBOR": false }, + "optimizer": optimizer, + "remappings": remappings_list, + "outputSelection": { + "*": { "*": ["evm.bytecode"] } + } + } + })) +} + +fn resolve_sources( + contract_path: &Path, + remappings: &[(String, PathBuf)], +) -> eyre::Result> { + let mut sources = BTreeMap::new(); + let mut visited = HashSet::new(); + + let canonical = contract_path.canonicalize().map_err(|e| { + eyre::eyre!( + "Failed to resolve contract path {}: {}", + contract_path.display(), + e + ) + })?; + + resolve_sources_recursive(&canonical, remappings, &mut sources, &mut visited)?; + + Ok(sources) +} + +fn resolve_sources_recursive( + file_path: &Path, + remappings: &[(String, PathBuf)], + sources: &mut BTreeMap, + visited: &mut HashSet, +) -> eyre::Result<()> { + if !visited.insert(file_path.to_path_buf()) { + return Ok(()); + } + + let content = std::fs::read_to_string(file_path) + .map_err(|e| eyre::eyre!("Failed to read source file {}: {}", file_path.display(), e))?; + + let key = file_name_str(file_path); + + static IMPORT_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"import\s+(?:\{[^}]*\}\s+from\s+)?["']([^"']+)["']"#).expect("valid regex") + }); + + for cap in IMPORT_RE.captures_iter(&content) { + let import_path_str = &cap[1]; + let resolved = resolve_import_path(import_path_str, file_path, remappings)?; + resolve_sources_recursive(&resolved, remappings, sources, visited)?; + } + + sources.insert(key, content); + + Ok(()) +} + +fn resolve_import_path( + import_str: &str, + importing_file: &Path, + remappings: &[(String, PathBuf)], +) -> eyre::Result { + // Try remappings first + for (prefix, target_path) in remappings { + if let Some(rest) = import_str.strip_prefix(prefix.as_str()) { + let rest = rest.strip_prefix('/').unwrap_or(rest); + let resolved = target_path.join(rest); + return resolved.canonicalize().map_err(|e| { + eyre::eyre!( + "Failed to resolve remapped import '{}' -> {}: {}", + import_str, + resolved.display(), + e, + ) + }); + } + } + + // Relative import + let parent = importing_file + .parent() + .context("importing file has no parent directory")?; + let resolved = parent.join(import_str); + resolved.canonicalize().map_err(|e| { + eyre::eyre!( + "Failed to resolve import '{}' relative to {}: {}", + import_str, + parent.display(), + e, + ) + }) +} + +/// Parses `solc --version` output like "Version: 0.8.28+commit.7893614a.Linux.g++" +/// into "v0.8.28+commit.7893614a". +fn get_solc_version() -> eyre::Result { + let output = Command::new("solc").arg("--version").output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + + static SOLC_VERSION_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(\d+\.\d+\.\d+\+commit\.[0-9a-f]+)").expect("valid regex")); + let version = SOLC_VERSION_RE + .find(&stdout) + .context("Could not parse solc version from output")? + .as_str(); + + Ok(format!("v{version}")) +} + +fn file_name_str(path: &Path) -> String { + path.file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() +} + +const ETHERSCAN_API_V2: &str = "https://api.etherscan.io/v2/api"; + +fn etherscan_explorer_url(chain_id: u64) -> &'static str { + match chain_id { + 1 => "https://etherscan.io", + 11155111 => "https://sepolia.etherscan.io", + 17000 => "https://holesky.etherscan.io", + _ => "https://etherscan.io", + } +} + +async fn poll_verification_status( + client: &reqwest::Client, + api_key: &str, + chain_id: &str, + guid: &str, +) -> eyre::Result<()> { + const MAX_RETRIES: u32 = 60; + const POLL_INTERVAL: Duration = Duration::from_secs(5); + + for attempt in 1..=MAX_RETRIES { + tokio::time::sleep(POLL_INTERVAL).await; + + println!(" [{attempt}/{MAX_RETRIES}] Checking verification status..."); + + let resp = client + .get(ETHERSCAN_API_V2) + .query(&[ + ("chainid", chain_id), + ("module", "contract"), + ("action", "checkverifystatus"), + ("guid", guid), + ("apikey", api_key), + ]) + .send() + .await? + .json::() + .await?; + + if resp.status == "1" { + println!(" Contract verified successfully!"); + return Ok(()); + } + + if resp.result.contains("Already Verified") { + println!(" Contract is already verified."); + return Ok(()); + } + + // "Pending in queue" and "Unable to locate" are normal in-progress states + if resp.result.contains("Pending in queue") || resp.result.contains("Unable to locate") { + continue; + } + + // Any other non-pending result is a failure + return Err(eyre::eyre!( + "Etherscan verification failed: {} ({})", + resp.result, + resp.message, + )); + } + + Err(eyre::eyre!( + "Etherscan verification timed out after {} seconds", + MAX_RETRIES * POLL_INTERVAL.as_secs() as u32 + )) +}