diff --git a/crates/config/src/etherscan.rs b/crates/config/src/etherscan.rs index 5999825e94681..861beb2fd4f89 100644 --- a/crates/config/src/etherscan.rs +++ b/crates/config/src/etherscan.rs @@ -177,6 +177,9 @@ pub struct EtherscanConfig { /// Etherscan API URL #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, + /// Etherscan browser URL (for viewing contracts in browser) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub browser_url: Option, /// The etherscan API KEY that's required to make requests pub key: EtherscanApiKey, } @@ -192,11 +195,14 @@ impl EtherscanConfig { self, alias: Option<&str>, ) -> Result { - let Self { chain, mut url, key } = self; + let Self { chain, mut url, mut browser_url, key } = self; if let Some(url) = &mut url { *url = interpolate(url)?; } + if let Some(browser_url) = &mut browser_url { + *browser_url = interpolate(browser_url)?; + } let (chain, alias) = match (chain, alias) { // fill one with the other @@ -223,7 +229,9 @@ impl EtherscanConfig { match (chain, url) { (Some(chain), Some(api_url)) => Ok(ResolvedEtherscanConfig { api_url, - browser_url: chain.etherscan_urls().map(|(_, url)| url.to_string()), + // Use explicitly configured browser_url, or derive from chain + browser_url: browser_url + .or_else(|| chain.etherscan_urls().map(|(_, url)| url.to_string())), key, chain: Some(chain), }), @@ -232,7 +240,7 @@ impl EtherscanConfig { EtherscanConfigError::UnknownChain(msg, chain) }), (None, Some(api_url)) => { - Ok(ResolvedEtherscanConfig { api_url, browser_url: None, key, chain: None }) + Ok(ResolvedEtherscanConfig { api_url, browser_url, key, chain: None }) } (None, None) => { let msg = alias @@ -318,11 +326,23 @@ impl ResolvedEtherscanConfig { let mut client_builder = foundry_block_explorers::Client::builder() .with_client(client) .with_api_key(api_key) + .with_api_url(api_url.clone())? .with_cache(cache, Duration::from_secs(24 * 60 * 60)); - if let Some(browser_url) = browser_url { - client_builder = client_builder.with_url(browser_url)?; + // Set browser URL - use explicitly configured one, or fallback to api_url + // The block explorers library requires a browser URL to be set + let browser_url = browser_url.unwrap_or_else(|| api_url.to_string()); + client_builder = client_builder.with_url(browser_url)?; + // Only call chain() when using the chain's default URL (not a custom URL). + // This adds v2 API support and chainid parameters for known chains. + // When a custom URL is provided, we skip chain() to respect the exact URL specified. + let using_default_url = chain + .etherscan_urls() + .map(|(default_api, _)| default_api == api_url.as_str().trim_end_matches('/')) + .unwrap_or(false); + if using_default_url { + client_builder = client_builder.chain(chain)?; } - client_builder.chain(chain)?.build() + client_builder.build() } } @@ -422,6 +442,7 @@ mod tests { EtherscanConfig { chain: Some(Mainnet.into()), url: None, + browser_url: None, key: EtherscanApiKey::Key("ABCDEFG".to_string()), }, ); @@ -444,13 +465,15 @@ mod tests { EtherscanConfig { chain: Some(Mainnet.into()), url: Some("https://api.etherscan.io/api".to_string()), + browser_url: None, key: EtherscanApiKey::Key("ABCDEFG".to_string()), }, ); let mut resolved = configs.resolved(); let config = resolved.remove("mainnet").unwrap().unwrap(); - let _ = config.into_client().unwrap(); + let client = config.into_client().unwrap(); + assert_eq!(client.etherscan_api_url().as_str(), "https://api.etherscan.io/api"); } #[test] @@ -462,6 +485,7 @@ mod tests { EtherscanConfig { chain: Some(Mainnet.into()), url: Some("https://api.etherscan.io/api".to_string()), + browser_url: None, key: EtherscanApiKey::Env(format!("${{{env}}}")), }, ); @@ -478,10 +502,7 @@ mod tests { let config = resolved.remove("mainnet").unwrap().unwrap(); assert_eq!(config.key, "ABCDEFG"); let client = config.into_client().unwrap(); - assert_eq!( - client.etherscan_api_url().as_str(), - "https://api.etherscan.io/v2/api?chainid=1" - ); + assert_eq!(client.etherscan_api_url().as_str(), "https://api.etherscan.io/api"); unsafe { std::env::remove_var(env); @@ -496,6 +517,7 @@ mod tests { EtherscanConfig { chain: None, url: Some("https://api.etherscan.io/api".to_string()), + browser_url: None, key: EtherscanApiKey::Key("ABCDEFG".to_string()), }, ); @@ -510,6 +532,7 @@ mod tests { let config = EtherscanConfig { chain: None, url: Some("https://api.etherscan.io/api".to_string()), + browser_url: None, key: EtherscanApiKey::Key("ABCDEFG".to_string()), }; let resolved = config.clone().resolve(Some("base_sepolia")).unwrap(); @@ -518,4 +541,51 @@ mod tests { let resolved = config.resolve(Some("base-sepolia")).unwrap(); assert_eq!(resolved.chain, Some(Chain::base_sepolia())); } + + #[test] + fn can_create_client_for_custom_chain() { + let mut configs = EtherscanConfigs::default(); + let custom_url = "https://custom.api.etherscan.io/api"; + let custom_browser_url = "https://custom.etherscan.io"; + configs.insert( + "custom_chain".to_string(), + EtherscanConfig { + chain: Some(Chain::from_id(123456)), // Random chain ID + url: Some(custom_url.to_string()), + browser_url: Some(custom_browser_url.to_string()), + key: EtherscanApiKey::Key("ABCDEFG".to_string()), + }, + ); + + let mut resolved = configs.resolved(); + let config = resolved.remove("custom_chain").unwrap().unwrap(); + + // This should NOT fail + let client = config.into_client().unwrap(); + assert_eq!(client.etherscan_api_url().as_str(), custom_url); + } + + #[test] + fn can_create_client_for_custom_chain_without_browser_url() { + let mut configs = EtherscanConfigs::default(); + let custom_url = "https://explorer.imuachain.com/api"; + configs.insert( + "imuachain_testnet".to_string(), + EtherscanConfig { + chain: Some(Chain::from_id(233)), // Custom chain ID + url: Some(custom_url.to_string()), + browser_url: None, // No browser URL specified + key: EtherscanApiKey::Key("test_api_key".to_string()), + }, + ); + + let mut resolved = configs.resolved(); + let config = resolved.remove("imuachain_testnet").unwrap().unwrap(); + + // Should work even without browser_url - it will use api_url as fallback + let client = config.into_client().unwrap(); + assert_eq!(client.etherscan_api_url().as_str(), custom_url); + // Browser URL should fallback to api_url + assert_eq!(client.etherscan_url().as_str(), custom_url); + } } diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 44c0c3620a25d..767f0ac3a0624 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -62,8 +62,8 @@ pub use endpoints::{ }; mod etherscan; -pub use etherscan::EtherscanConfigError; -use etherscan::{EtherscanConfigs, EtherscanEnvProvider, ResolvedEtherscanConfig}; +pub use etherscan::{EtherscanConfigError, ResolvedEtherscanConfig}; +use etherscan::{EtherscanConfigs, EtherscanEnvProvider}; pub mod resolve; pub use resolve::UnresolvedEnvVarError; diff --git a/crates/forge/src/cmd/create.rs b/crates/forge/src/cmd/create.rs index 0187020721af1..a45da0a7a9f0a 100644 --- a/crates/forge/src/cmd/create.rs +++ b/crates/forge/src/cmd/create.rs @@ -262,7 +262,7 @@ impl CreateArgs { let context = verify.resolve_context().await?; - verify.verification_provider()?.preflight_verify_check(verify, context).await?; + verify.verification_provider(&config)?.preflight_verify_check(verify, context).await?; Ok(()) } diff --git a/crates/script/src/broadcast.rs b/crates/script/src/broadcast.rs index 76b8302fd3c92..d241c3a7dce09 100644 --- a/crates/script/src/broadcast.rs +++ b/crates/script/src/broadcast.rs @@ -13,7 +13,7 @@ use alloy_provider::{Provider, utils::Eip1559Estimation}; use alloy_rpc_types::TransactionRequest; use alloy_serde::WithOtherFields; use eyre::{Context, Result, bail}; -use forge_verify::provider::VerificationProviderType; +use forge_verify::{VerifierArgs, provider::VerificationProviderType}; use foundry_cheatcodes::Wallets; use foundry_cli::utils::{has_batch_support, has_different_gas_calc}; use foundry_common::{ @@ -21,7 +21,7 @@ use foundry_common::{ provider::{RetryProvider, get_http_provider, try_get_http_provider}, shell, }; -use foundry_config::Config; +use foundry_config::{Config, ResolvedEtherscanConfig}; use futures::{FutureExt, StreamExt, future::join_all, stream::FuturesUnordered}; use itertools::Itertools; @@ -536,17 +536,155 @@ impl BundledState { pub fn verify_preflight_check(&self) -> Result<()> { for sequence in self.sequence.sequences() { - if self.args.verifier.verifier == VerificationProviderType::Etherscan - && self - .script_config - .config - .get_etherscan_api_key(Some(sequence.chain.into())) - .is_none() + verify_chain_preflight( + &self.script_config.config, + &self.args.verifier, + sequence.chain, + )?; + } + + Ok(()) + } +} + +fn verify_chain_preflight(config: &Config, verifier: &VerifierArgs, chain_id: u64) -> Result<()> { + if verifier.verifier != VerificationProviderType::Etherscan { + return Ok(()); + } + + if verifier.verifier_url.is_some() { + return Ok(()); + } + + let chain = Chain::from_id(chain_id); + + match config.get_etherscan_config_with_chain(Some(chain)) { + Ok(Some(resolved)) => { + if resolved.api_url.is_empty() && resolved.key.is_empty() { + eyre::bail!("Missing etherscan key for chain {chain_id}"); + } + Ok(()) + } + Ok(None) | Err(_) => { + if let Some(custom) = first_resolved_etherscan_config(config) + && !custom.api_url.is_empty() + && !custom.key.is_empty() { - eyre::bail!("Missing etherscan key for chain {}", sequence.chain); + return Ok(()); + } + if config.get_etherscan_api_key(Some(chain)).is_none() { + eyre::bail!("Missing etherscan key for chain {chain_id}"); } + Ok(()) } + } +} - Ok(()) +fn first_resolved_etherscan_config(config: &Config) -> Option { + let resolved = config.etherscan.clone().resolved(); + resolved.iter().find_map(|(_, entry)| entry.clone().ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + fn load_config(contents: &str) -> Config { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("foundry.toml"), contents).unwrap(); + Config::load_with_root(dir.path()).unwrap() + } + + fn etherscan_args(verifier_url: Option<&str>) -> VerifierArgs { + VerifierArgs { + verifier: VerificationProviderType::Etherscan, + verifier_api_key: None, + verifier_url: verifier_url.map(|url| url.to_string()), + } + } + + #[test] + fn verify_preflight_allows_custom_verifier_url() { + let config = Config::default(); + let args = etherscan_args(Some("https://custom-verifier.api")); + + assert!(verify_chain_preflight(&config, &args, 233).is_ok()); + } + + #[test] + fn verify_preflight_uses_custom_config_url_with_explicit_chain() { + let config = load_config( + r#" + [profile.default] + src = "src" + + [etherscan] + imuachain_testnet = { key = "test-key", chain = 233, url = "https://exoscan.org/api" } + "#, + ); + let args = etherscan_args(None); + + assert!(verify_chain_preflight(&config, &args, 233).is_ok()); + } + + #[test] + fn verify_preflight_uses_custom_config_url_without_chain() { + let config = load_config( + r#" + [profile.default] + src = "src" + + [etherscan] + imuachain_testnet = { key = "test-key", url = "https://exoscan.org/api" } + "#, + ); + let args = etherscan_args(None); + + assert!(verify_chain_preflight(&config, &args, 233).is_ok()); + } + + #[test] + fn verify_preflight_errors_without_config() { + let config = load_config( + r#" + [profile.default] + src = "src" + "#, + ); + let args = etherscan_args(None); + + let err = verify_chain_preflight(&config, &args, 233).unwrap_err(); + assert!(err.to_string().contains("Missing etherscan key for chain 233")); + } + + #[test] + fn verify_preflight_uses_global_api_key() { + let config = load_config( + r#" + [profile.default] + src = "src" + + etherscan_api_key = "mainnet-key" + "#, + ); + let args = etherscan_args(None); + + assert!(verify_chain_preflight(&config, &args, Chain::mainnet().id()).is_ok()); + } + + #[test] + fn verify_preflight_skips_non_etherscan_provider() { + let config = load_config( + r#" + [profile.default] + src = "src" + "#, + ); + let mut args = etherscan_args(None); + args.verifier = VerificationProviderType::Sourcify; + + assert!(verify_chain_preflight(&config, &args, 233).is_ok()); } } diff --git a/crates/verify/src/etherscan/mod.rs b/crates/verify/src/etherscan/mod.rs index 91a21aa72d06e..5f5d086e743a3 100644 --- a/crates/verify/src/etherscan/mod.rs +++ b/crates/verify/src/etherscan/mod.rs @@ -21,7 +21,7 @@ use foundry_cli::{ }; use foundry_common::{abi::encode_function_args, retry::RetryError}; use foundry_compilers::{Artifact, artifacts::BytecodeObject}; -use foundry_config::Config; +use foundry_config::{Config, ResolvedEtherscanConfig}; use foundry_evm::constants::DEFAULT_CREATE2_DEPLOYER; use regex::Regex; use semver::BuildMetadata; @@ -263,9 +263,14 @@ impl EtherscanVerificationProvider { // API key passed. let is_etherscan = verifier_type.is_etherscan() || (verifier_type.is_sourcify() && etherscan_key.is_some()); - let etherscan_config = config.get_etherscan_config_with_chain(Some(chain))?; + let mut etherscan_config = config.get_etherscan_config_with_chain(Some(chain))?; + if etherscan_config.is_none() { + etherscan_config = first_resolved_etherscan_config(config); + } - let etherscan_api_url = verifier_url.or(None).map(str::to_owned); + let etherscan_api_url = verifier_url + .map(str::to_owned) + .or_else(|| etherscan_config.as_ref().map(|c| c.api_url.clone())); let api_url = etherscan_api_url.as_deref(); let base_url = etherscan_config @@ -452,6 +457,11 @@ impl EtherscanVerificationProvider { } } +fn first_resolved_etherscan_config(config: &Config) -> Option { + let resolved = config.etherscan.clone().resolved(); + resolved.iter().find_map(|(_, entry)| entry.clone().ok()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/verify/src/provider.rs b/crates/verify/src/provider.rs index b57415b4c925e..cabe30c0eb9ed 100644 --- a/crates/verify/src/provider.rs +++ b/crates/verify/src/provider.rs @@ -189,6 +189,7 @@ impl VerificationProviderType { if self.is_etherscan() { if let Some(chain) = chain && chain.etherscan_urls().is_none() + && !has_url { eyre::bail!(EtherscanConfigError::UnknownChain(String::new(), chain)) } diff --git a/crates/verify/src/verify.rs b/crates/verify/src/verify.rs index f9ce32a415633..8e8d68adc5c30 100644 --- a/crates/verify/src/verify.rs +++ b/crates/verify/src/verify.rs @@ -16,7 +16,9 @@ use foundry_cli::{ }; use foundry_common::{ContractsByArtifact, compile::ProjectCompiler}; use foundry_compilers::{artifacts::EvmVersion, compilers::solc::Solc, info::ContractInfo}; -use foundry_config::{Config, SolcReq, figment, impl_figment_convert, impl_figment_convert_cast}; +use foundry_config::{ + Chain, Config, SolcReq, figment, impl_figment_convert, impl_figment_convert_cast, +}; use itertools::Itertools; use reqwest::Url; use semver::BuildMetadata; @@ -272,7 +274,13 @@ impl VerifyArgs { { sh_println!("Constructor args: {args}")? } - self.verifier.verifier.client(self.etherscan.key().as_deref(), self.etherscan.chain, self.verifier.verifier_url.is_some())?.verify(self, context).await.map_err(|err| { + let has_custom_url = has_custom_etherscan_url(&context.config, self.etherscan.chain); + + self.verifier.verifier.client( + self.etherscan.key().as_deref(), + self.etherscan.chain, + self.verifier.verifier_url.is_some() || has_custom_url, + )?.verify(self, context).await.map_err(|err| { if let Some(verifier_url) = verifier_url { match Url::parse(&verifier_url) { Ok(url) => { @@ -295,11 +303,12 @@ impl VerifyArgs { } /// Returns the configured verification provider - pub fn verification_provider(&self) -> Result> { + pub fn verification_provider(&self, config: &Config) -> Result> { + let has_custom_url = has_custom_etherscan_url(config, self.etherscan.chain); self.verifier.verifier.client( self.etherscan.key().as_deref(), self.etherscan.chain, - self.verifier.verifier_url.is_some(), + self.verifier.verifier_url.is_some() || has_custom_url, ) } @@ -470,6 +479,17 @@ impl VerifyArgs { } } +fn has_custom_etherscan_url(config: &Config, chain: Option) -> bool { + if let Ok(Some(resolved)) = config.get_etherscan_config_with_chain(chain) { + return !resolved.api_url.is_empty(); + } + + let resolved = config.etherscan.clone().resolved(); + resolved + .iter() + .any(|(_, entry)| entry.as_ref().map(|cfg| !cfg.api_url.is_empty()).unwrap_or(false)) +} + /// Check verification status arguments #[derive(Clone, Debug, Parser)] pub struct VerifyCheckArgs {