use crate::{
resolve::{interpolate, UnresolvedEnvVarError, RE_PLACEHOLDER},
Chain, Config, NamedChain,
};
use figment::{
providers::Env,
value::{Dict, Map},
Error, Metadata, Profile, Provider,
};
use inflector::Inflector;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::{
collections::BTreeMap,
fmt,
ops::{Deref, DerefMut},
time::Duration,
};
pub const ETHERSCAN_USER_AGENT: &str = concat!("foundry/", env!("CARGO_PKG_VERSION"));
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub(crate) struct EtherscanEnvProvider;
impl Provider for EtherscanEnvProvider {
fn metadata(&self) -> Metadata {
Env::raw().metadata()
}
fn data(&self) -> Result<Map<Profile, Dict>, Error> {
let mut dict = Dict::default();
let env_provider = Env::raw().only(&["ETHERSCAN_API_KEY"]);
if let Some((key, value)) = env_provider.iter().next() {
if !value.trim().is_empty() {
dict.insert(key.as_str().to_string(), value.into());
}
}
Ok(Map::from([(Config::selected_profile(), dict)]))
}
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum EtherscanConfigError {
#[error(transparent)]
Unresolved(#[from] UnresolvedEnvVarError),
#[error("No known Etherscan API URL for config{0} with chain `{1}`. Please specify a `url`")]
UnknownChain(String, Chain),
#[error("At least one of `url` or `chain` must be present{0}")]
MissingUrlOrChain(String),
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct EtherscanConfigs {
configs: BTreeMap<String, EtherscanConfig>,
}
impl EtherscanConfigs {
pub fn new(configs: impl IntoIterator<Item = (impl Into<String>, EtherscanConfig)>) -> Self {
Self { configs: configs.into_iter().map(|(name, config)| (name.into(), config)).collect() }
}
pub fn is_empty(&self) -> bool {
self.configs.is_empty()
}
pub fn find_chain(&self, chain: Chain) -> Option<&EtherscanConfig> {
self.configs.values().find(|config| config.chain == Some(chain))
}
pub fn resolved(self) -> ResolvedEtherscanConfigs {
ResolvedEtherscanConfigs {
configs: self
.configs
.into_iter()
.map(|(name, e)| {
let resolved = e.resolve(Some(&name));
(name, resolved)
})
.collect(),
}
}
}
impl Deref for EtherscanConfigs {
type Target = BTreeMap<String, EtherscanConfig>;
fn deref(&self) -> &Self::Target {
&self.configs
}
}
impl DerefMut for EtherscanConfigs {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.configs
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ResolvedEtherscanConfigs {
configs: BTreeMap<String, Result<ResolvedEtherscanConfig, EtherscanConfigError>>,
}
impl ResolvedEtherscanConfigs {
pub fn new(
configs: impl IntoIterator<Item = (impl Into<String>, ResolvedEtherscanConfig)>,
) -> Self {
Self {
configs: configs.into_iter().map(|(name, config)| (name.into(), Ok(config))).collect(),
}
}
pub fn find_chain(
self,
chain: Chain,
) -> Option<Result<ResolvedEtherscanConfig, EtherscanConfigError>> {
for (_, config) in self.configs.into_iter() {
match config {
Ok(c) if c.chain == Some(chain) => return Some(Ok(c)),
Err(e) => return Some(Err(e)),
_ => continue,
}
}
None
}
pub fn has_unresolved(&self) -> bool {
self.configs.values().any(|val| val.is_err())
}
}
impl Deref for ResolvedEtherscanConfigs {
type Target = BTreeMap<String, Result<ResolvedEtherscanConfig, EtherscanConfigError>>;
fn deref(&self) -> &Self::Target {
&self.configs
}
}
impl DerefMut for ResolvedEtherscanConfigs {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.configs
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EtherscanConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chain: Option<Chain>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
pub key: EtherscanApiKey,
}
impl EtherscanConfig {
pub fn resolve(
self,
alias: Option<&str>,
) -> Result<ResolvedEtherscanConfig, EtherscanConfigError> {
let Self { chain, mut url, key } = self;
if let Some(url) = &mut url {
*url = interpolate(url)?;
}
let (chain, alias) = match (chain, alias) {
(Some(chain), None) => (Some(chain), Some(chain.to_string())),
(None, Some(alias)) => {
(
alias.to_kebab_case().parse().ok().or_else(|| {
serde_json::from_str::<NamedChain>(&format!("\"{alias}\""))
.map(Into::into)
.ok()
}),
Some(alias.into()),
)
}
(Some(chain), Some(alias)) => (Some(chain), Some(alias.into())),
(None, None) => (None, None),
};
let key = key.resolve()?;
match (chain, url) {
(Some(chain), Some(api_url)) => Ok(ResolvedEtherscanConfig {
api_url,
browser_url: chain.etherscan_urls().map(|(_, url)| url.to_string()),
key,
chain: Some(chain),
}),
(Some(chain), None) => ResolvedEtherscanConfig::create(key, chain).ok_or_else(|| {
let msg = alias.map(|a| format!(" `{a}`")).unwrap_or_default();
EtherscanConfigError::UnknownChain(msg, chain)
}),
(None, Some(api_url)) => {
Ok(ResolvedEtherscanConfig { api_url, browser_url: None, key, chain: None })
}
(None, None) => {
let msg = alias
.map(|a| format!(" for Etherscan config with unknown alias `{a}`"))
.unwrap_or_default();
Err(EtherscanConfigError::MissingUrlOrChain(msg))
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedEtherscanConfig {
#[serde(rename = "url")]
pub api_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub browser_url: Option<String>,
pub key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chain: Option<Chain>,
}
impl ResolvedEtherscanConfig {
pub fn create(api_key: impl Into<String>, chain: impl Into<Chain>) -> Option<Self> {
let chain = chain.into();
let (api_url, browser_url) = chain.etherscan_urls()?;
Some(Self {
api_url: api_url.to_string(),
browser_url: Some(browser_url.to_string()),
key: api_key.into(),
chain: Some(chain),
})
}
pub fn with_chain(mut self, chain: impl Into<Chain>) -> Self {
self.set_chain(chain);
self
}
pub fn set_chain(&mut self, chain: impl Into<Chain>) -> &mut Self {
let chain = chain.into();
if let Some((api, browser)) = chain.etherscan_urls() {
self.api_url = api.to_string();
self.browser_url = Some(browser.to_string());
}
self.chain = Some(chain);
self
}
pub fn into_client(
self,
) -> Result<foundry_block_explorers::Client, foundry_block_explorers::errors::EtherscanError>
{
let Self { api_url, browser_url, key: api_key, chain } = self;
let (mainnet_api, mainnet_url) = NamedChain::Mainnet.etherscan_urls().expect("exist; qed");
let cache = chain
.or_else(|| (api_url == mainnet_api).then(Chain::mainnet))
.and_then(Config::foundry_etherscan_chain_cache_dir);
if let Some(cache_path) = &cache {
if let Err(err) = std::fs::create_dir_all(cache_path.join("sources")) {
warn!("could not create etherscan cache dir: {:?}", err);
}
}
let api_url = into_url(&api_url)?;
let client = reqwest::Client::builder()
.user_agent(ETHERSCAN_USER_AGENT)
.tls_built_in_root_certs(api_url.scheme() == "https")
.build()?;
foundry_block_explorers::Client::builder()
.with_client(client)
.with_api_key(api_key)
.with_api_url(api_url)?
.with_url(browser_url.as_deref().unwrap_or(mainnet_url))?
.with_cache(cache, Duration::from_secs(24 * 60 * 60))
.build()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EtherscanApiKey {
Key(String),
Env(String),
}
impl EtherscanApiKey {
pub fn as_key(&self) -> Option<&str> {
match self {
Self::Key(url) => Some(url),
Self::Env(_) => None,
}
}
pub fn as_env(&self) -> Option<&str> {
match self {
Self::Env(val) => Some(val),
Self::Key(_) => None,
}
}
pub fn resolve(self) -> Result<String, UnresolvedEnvVarError> {
match self {
Self::Key(key) => Ok(key),
Self::Env(val) => interpolate(&val),
}
}
}
impl Serialize for EtherscanApiKey {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for EtherscanApiKey {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let val = String::deserialize(deserializer)?;
let endpoint = if RE_PLACEHOLDER.is_match(&val) { Self::Env(val) } else { Self::Key(val) };
Ok(endpoint)
}
}
impl fmt::Display for EtherscanApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Key(key) => key.fmt(f),
Self::Env(var) => var.fmt(f),
}
}
}
#[inline]
fn into_url(url: impl reqwest::IntoUrl) -> std::result::Result<reqwest::Url, reqwest::Error> {
url.into_url()
}
#[cfg(test)]
mod tests {
use super::*;
use NamedChain::Mainnet;
#[test]
fn can_create_client_via_chain() {
let mut configs = EtherscanConfigs::default();
configs.insert(
"mainnet".to_string(),
EtherscanConfig {
chain: Some(Mainnet.into()),
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();
}
#[test]
fn can_create_client_via_url_and_chain() {
let mut configs = EtherscanConfigs::default();
configs.insert(
"mainnet".to_string(),
EtherscanConfig {
chain: Some(Mainnet.into()),
url: Some("https://api.etherscan.io/api".to_string()),
key: EtherscanApiKey::Key("ABCDEFG".to_string()),
},
);
let mut resolved = configs.resolved();
let config = resolved.remove("mainnet").unwrap().unwrap();
let _ = config.into_client().unwrap();
}
#[test]
fn can_create_client_via_url_and_chain_env_var() {
let mut configs = EtherscanConfigs::default();
let env = "_CONFIG_ETHERSCAN_API_KEY";
configs.insert(
"mainnet".to_string(),
EtherscanConfig {
chain: Some(Mainnet.into()),
url: Some("https://api.etherscan.io/api".to_string()),
key: EtherscanApiKey::Env(format!("${{{env}}}")),
},
);
let mut resolved = configs.clone().resolved();
let config = resolved.remove("mainnet").unwrap();
assert!(config.is_err());
std::env::set_var(env, "ABCDEFG");
let mut resolved = configs.resolved();
let config = resolved.remove("mainnet").unwrap().unwrap();
assert_eq!(config.key, "ABCDEFG");
let _ = config.into_client().unwrap();
std::env::remove_var(env);
}
#[test]
fn resolve_etherscan_alias_config() {
let mut configs = EtherscanConfigs::default();
configs.insert(
"blast_sepolia".to_string(),
EtherscanConfig {
chain: None,
url: Some("https://api.etherscan.io/api".to_string()),
key: EtherscanApiKey::Key("ABCDEFG".to_string()),
},
);
let mut resolved = configs.clone().resolved();
let config = resolved.remove("blast_sepolia").unwrap().unwrap();
assert_eq!(config.chain, Some(Chain::blast_sepolia()));
}
#[test]
fn resolve_etherscan_alias() {
let config = EtherscanConfig {
chain: None,
url: Some("https://api.etherscan.io/api".to_string()),
key: EtherscanApiKey::Key("ABCDEFG".to_string()),
};
let resolved = config.clone().resolve(Some("base_sepolia")).unwrap();
assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
let resolved = config.resolve(Some("base-sepolia")).unwrap();
assert_eq!(resolved.chain, Some(Chain::base_sepolia()));
}
}