Skip to content
Merged
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
2 changes: 2 additions & 0 deletions crates/block-explorers/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ pub enum EtherscanError {
MissingSolcVersion(String),
#[error("Invalid API Key")]
InvalidApiKey,
#[error("Invalid API Version")]
InvalidApiVersion,
#[error("Sorry, you have been blocked by Cloudflare, See also https://community.cloudflare.com/t/sorry-you-have-been-blocked/110790")]
BlockedByCloudflare,
#[error("The Requested prompted a cloudflare captcha security challenge to review the security of your connection before proceeding.")]
Expand Down
169 changes: 124 additions & 45 deletions crates/block-explorers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,36 @@ pub const ETHERSCAN_V2_API_BASE_URL: &str = "https://api.etherscan.io/v2/api";

/// The Etherscan.io API version 1 - classic verifier, one API per chain, 2 - new multichain
/// verifier
#[derive(Clone, Default, Debug, PartialEq, Copy)]
#[derive(Clone, Default, Debug, PartialEq, Copy, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum EtherscanApiVersion {
#[default]
V1,
#[default]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@grandizzy can we make this default already?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another option would be to not specify a default here and specify a default on the forge library.

V2,
}

impl std::fmt::Display for EtherscanApiVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EtherscanApiVersion::V1 => write!(f, "v1"),
EtherscanApiVersion::V2 => write!(f, "v2"),
}
}
}

impl TryFrom<String> for EtherscanApiVersion {
type Error = EtherscanError;

#[inline]
fn try_from(value: String) -> Result<Self, Self::Error> {
match value.as_str() {
"v1" => Ok(EtherscanApiVersion::V1),
"v2" => Ok(EtherscanApiVersion::V2),
_ => Err(EtherscanError::InvalidApiVersion),
}
}
}

/// The Etherscan.io API client.
#[derive(Clone, Debug)]
pub struct Client {
Expand Down Expand Up @@ -114,50 +137,32 @@ impl Client {
Client::builder().with_api_key(api_key).chain(chain)?.build()
}

/// Create a new client with the correct endpoint with the chain and chosen API v2 version
pub fn new_v2_from_env(chain: Chain) -> Result<Self> {
Comment on lines -117 to -118
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we keep this so that this doesn't break?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure — it only makes sense imo if the default is V1 and maybe it should still be. Not sure about other tools using this package.

let api_key = std::env::var("ETHERSCAN_API_KEY")?;
Client::builder()
.with_api_version(EtherscanApiVersion::V2)
.with_api_key(api_key)
.chain(chain)?
.build()
/// Create a new client for the given [`EtherscanApiVersion`].
pub fn new_with_api_version(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs a one liner doc

chain: Chain,
api_key: impl Into<String>,
api_version: EtherscanApiVersion,
) -> Result<Self> {
Client::builder().with_api_key(api_key).with_api_version(api_version).chain(chain)?.build()
}

/// Create a new client with the correct endpoints based on the chain and API key
/// from the default environment variable defined in [`Chain`].
/// Create a new client with the correct endpoint with the chain
pub fn new_from_env(chain: Chain) -> Result<Self> {
let api_key = match chain.kind() {
ChainKind::Named(named) => match named {
// Extra aliases
NamedChain::Fantom | NamedChain::FantomTestnet => std::env::var("FMTSCAN_API_KEY")
.or_else(|_| std::env::var("FANTOMSCAN_API_KEY"))
.map_err(Into::into),

// Backwards compatibility, ideally these should return an error.
NamedChain::Gnosis
| NamedChain::Chiado
| NamedChain::Sepolia
| NamedChain::Rsk
| NamedChain::Sokol
| NamedChain::Poa
| NamedChain::Oasis
| NamedChain::Emerald
| NamedChain::EmeraldTestnet
| NamedChain::Evmos
| NamedChain::EvmosTestnet => Ok(String::new()),
NamedChain::AnvilHardhat | NamedChain::Dev => {
Err(EtherscanError::LocalNetworksNotSupported)
}
Self::new_with_api_version(
chain,
get_api_key_from_chain(chain, EtherscanApiVersion::V2)?,
EtherscanApiVersion::V2,
)
}

_ => named
.etherscan_api_key_name()
.ok_or_else(|| EtherscanError::ChainNotSupported(chain))
.and_then(|key_name| std::env::var(key_name).map_err(Into::into)),
},
ChainKind::Id(_) => Err(EtherscanError::ChainNotSupported(chain)),
}?;
Self::new(chain, api_key)
/// Create a new client with the correct endpoints based on the chain and API key
/// from the default environment variable defined in [`Chain`].
pub fn new_v1_from_env(chain: Chain) -> Result<Self> {
Self::new_with_api_version(
chain,
get_api_key_from_chain(chain, EtherscanApiVersion::V1)?,
EtherscanApiVersion::V1,
)
}

/// Create a new client with the correct endpoints based on the chain and API key
Expand Down Expand Up @@ -193,6 +198,11 @@ impl Client {
&self.etherscan_url
}

/// Returns the configured API key, if any
pub fn api_key(&self) -> Option<&str> {
self.api_key.as_deref()
}

/// Return the URL for the given block number
pub fn block_url(&self, block: u64) -> String {
self.etherscan_url.join(&format!("block/{block}")).unwrap().to_string()
Expand Down Expand Up @@ -587,9 +597,53 @@ fn into_url(url: impl IntoUrl) -> std::result::Result<Url, reqwest::Error> {
url.into_url()
}

fn get_api_key_from_chain(
chain: Chain,
api_version: EtherscanApiVersion,
) -> Result<String, EtherscanError> {
match chain.kind() {
ChainKind::Named(named) => match named {
// Fantom is special and doesn't support etherscan api v2
NamedChain::Fantom | NamedChain::FantomTestnet => std::env::var("FMTSCAN_API_KEY")
.or_else(|_| std::env::var("FANTOMSCAN_API_KEY"))
.map_err(Into::into),

// Backwards compatibility, ideally these should return an error.
NamedChain::Gnosis
| NamedChain::Chiado
| NamedChain::Sepolia
| NamedChain::Rsk
| NamedChain::Sokol
| NamedChain::Poa
| NamedChain::Oasis
| NamedChain::Emerald
| NamedChain::EmeraldTestnet
| NamedChain::Evmos
| NamedChain::EvmosTestnet => Ok(String::new()),
NamedChain::AnvilHardhat | NamedChain::Dev => {
Err(EtherscanError::LocalNetworksNotSupported)
}

// Rather than get special ENV vars here, normal case is to pull overall
// ETHERSCAN_API_KEY
_ => {
if api_version == EtherscanApiVersion::V1 {
named
.etherscan_api_key_name()
.ok_or_else(|| EtherscanError::ChainNotSupported(chain))
.and_then(|key_name| std::env::var(key_name).map_err(Into::into))
} else {
std::env::var("ETHERSCAN_API_KEY").map_err(Into::into)
}
}
},
ChainKind::Id(_) => Err(EtherscanError::ChainNotSupported(chain)),
}
}

#[cfg(test)]
mod tests {
use crate::{Client, EtherscanError, ResponseData};
use crate::{Client, EtherscanApiVersion, EtherscanError, ResponseData};
use alloy_chains::Chain;
use alloy_primitives::{Address, B256};

Expand All @@ -602,13 +656,23 @@ mod tests {
}

#[test]
fn test_api_paths() {
let client = Client::new(Chain::goerli(), "").unwrap();
fn test_api_paths_v1() {
let client =
Client::new_with_api_version(Chain::goerli(), "", EtherscanApiVersion::V1).unwrap();
assert_eq!(client.etherscan_api_url.as_str(), "https://api-goerli.etherscan.io/api");

assert_eq!(client.block_url(100), "https://goerli.etherscan.io/block/100");
}

#[test]
fn test_api_paths_v2() {
let client =
Client::new_with_api_version(Chain::goerli(), "", EtherscanApiVersion::V2).unwrap();
assert_eq!(client.etherscan_api_url.as_str(), "https://api.etherscan.io/v2/api");

assert_eq!(client.block_url(100), "https://goerli.etherscan.io/block/100");
}

#[test]
fn stringifies_block_url() {
let etherscan = Client::new(Chain::mainnet(), "").unwrap();
Expand Down Expand Up @@ -657,4 +721,19 @@ mod tests {
let resp: ResponseData<Address> = serde_json::from_value(err).unwrap();
assert!(matches!(resp, ResponseData::Error { .. }));
}

#[test]
fn can_parse_api_version() {
assert_eq!(
EtherscanApiVersion::try_from("v1".to_string()).unwrap(),
EtherscanApiVersion::V1
);
assert_eq!(
EtherscanApiVersion::try_from("v2".to_string()).unwrap(),
EtherscanApiVersion::V2
);

let parse_err = EtherscanApiVersion::try_from("fail".to_string()).unwrap_err();
assert!(matches!(parse_err, EtherscanError::InvalidApiVersion));
}
}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"expiry":1742116310,"data":[{"type":"constructor","inputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"deposit","inputs":[{"name":"pubkey","type":"bytes","internalType":"bytes"},{"name":"withdrawal_credentials","type":"bytes","internalType":"bytes"},{"name":"signature","type":"bytes","internalType":"bytes"},{"name":"deposit_data_root","type":"bytes32","internalType":"bytes32"}],"outputs":[],"stateMutability":"payable"},{"type":"function","name":"get_deposit_count","inputs":[],"outputs":[{"name":"","type":"bytes","internalType":"bytes"}],"stateMutability":"view"},{"type":"function","name":"get_deposit_root","inputs":[],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"supportsInterface","inputs":[{"name":"interfaceId","type":"bytes4","internalType":"bytes4"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"pure"},{"type":"event","name":"DepositEvent","inputs":[{"name":"pubkey","type":"bytes","indexed":false,"internalType":"bytes"},{"name":"withdrawal_credentials","type":"bytes","indexed":false,"internalType":"bytes"},{"name":"amount","type":"bytes","indexed":false,"internalType":"bytes"},{"name":"signature","type":"bytes","indexed":false,"internalType":"bytes"},{"name":"index","type":"bytes","indexed":false,"internalType":"bytes"}],"anonymous":false}]}
{"expiry":1746070810,"data":[{"type":"constructor","inputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"deposit","inputs":[{"name":"pubkey","type":"bytes","internalType":"bytes"},{"name":"withdrawal_credentials","type":"bytes","internalType":"bytes"},{"name":"signature","type":"bytes","internalType":"bytes"},{"name":"deposit_data_root","type":"bytes32","internalType":"bytes32"}],"outputs":[],"stateMutability":"payable"},{"type":"function","name":"get_deposit_count","inputs":[],"outputs":[{"name":"","type":"bytes","internalType":"bytes"}],"stateMutability":"view"},{"type":"function","name":"get_deposit_root","inputs":[],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"supportsInterface","inputs":[{"name":"interfaceId","type":"bytes4","internalType":"bytes4"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"pure"},{"type":"event","name":"DepositEvent","inputs":[{"name":"pubkey","type":"bytes","indexed":false,"internalType":"bytes"},{"name":"withdrawal_credentials","type":"bytes","indexed":false,"internalType":"bytes"},{"name":"amount","type":"bytes","indexed":false,"internalType":"bytes"},{"name":"signature","type":"bytes","indexed":false,"internalType":"bytes"},{"name":"index","type":"bytes","indexed":false,"internalType":"bytes"}],"anonymous":false}]}
Loading
Loading