Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 81 additions & 11 deletions crates/config/src/etherscan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,9 @@ pub struct EtherscanConfig {
/// Etherscan API URL
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
/// Etherscan browser URL (for viewing contracts in browser)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub browser_url: Option<String>,
/// The etherscan API KEY that's required to make requests
pub key: EtherscanApiKey,
}
Expand All @@ -192,11 +195,14 @@ impl EtherscanConfig {
self,
alias: Option<&str>,
) -> Result<ResolvedEtherscanConfig, EtherscanConfigError> {
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
Expand All @@ -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),
}),
Expand All @@ -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
Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -422,6 +442,7 @@ mod tests {
EtherscanConfig {
chain: Some(Mainnet.into()),
url: None,
browser_url: None,
key: EtherscanApiKey::Key("ABCDEFG".to_string()),
},
);
Expand All @@ -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]
Expand All @@ -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}}}")),
},
);
Expand All @@ -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);
Expand All @@ -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()),
},
);
Expand All @@ -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();
Expand All @@ -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);
}
}
4 changes: 2 additions & 2 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion crates/forge/src/cmd/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down
158 changes: 148 additions & 10 deletions crates/script/src/broadcast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ 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::{
TransactionMaybeSigned,
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;

Expand Down Expand Up @@ -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<ResolvedEtherscanConfig> {
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());
}
}
Loading
Loading