diff --git a/Cargo.lock b/Cargo.lock index 9cdeea8d..ca179d5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1073,6 +1073,7 @@ dependencies = [ "ethers-core", "reqwest", "serde", + "serde-aux", "serde_json", "thiserror", "tokio", @@ -1085,6 +1086,7 @@ dependencies = [ "async-trait", "ethers-contract", "ethers-core", + "ethers-etherscan", "ethers-providers", "ethers-signers", "ethers-solc", diff --git a/ethers-core/src/types/chain.rs b/ethers-core/src/types/chain.rs index 77353c20..28ad23c3 100644 --- a/ethers-core/src/types/chain.rs +++ b/ethers-core/src/types/chain.rs @@ -14,6 +14,7 @@ pub enum Chain { PolygonMumbai, Avalanche, AvalancheFuji, + Sepolia, } impl fmt::Display for Chain { @@ -35,6 +36,7 @@ impl From for u32 { Chain::PolygonMumbai => 80001, Chain::Avalanche => 43114, Chain::AvalancheFuji => 43113, + Chain::Sepolia => 11155111, } } } diff --git a/ethers-etherscan/Cargo.toml b/ethers-etherscan/Cargo.toml index f7c327d9..9139dfe6 100644 --- a/ethers-etherscan/Cargo.toml +++ b/ethers-etherscan/Cargo.toml @@ -18,6 +18,7 @@ ethers-core = { version = "^0.6.0", path = "../ethers-core", default-features = reqwest = { version = "0.11.6", features = ["json"] } serde = { version = "1.0.124", default-features = false, features = ["derive"] } serde_json = { version = "1.0.64", default-features = false } +serde-aux = { version = "3.0.1", default-features = false } thiserror = "1.0.29" [dev-dependencies] diff --git a/ethers-etherscan/src/errors.rs b/ethers-etherscan/src/errors.rs index 59db8874..8a53ca7a 100644 --- a/ethers-etherscan/src/errors.rs +++ b/ethers-etherscan/src/errors.rs @@ -9,6 +9,8 @@ pub enum EtherscanError { ExecutionFailed(String), #[error("tx receipt failed")] TransactionReceiptFailed, + #[error("gas estimation failed")] + GasEstimationFailed, #[error("bad status code {0}")] BadStatusCode(String), #[error(transparent)] diff --git a/ethers-etherscan/src/gas.rs b/ethers-etherscan/src/gas.rs new file mode 100644 index 00000000..f3284e4b --- /dev/null +++ b/ethers-etherscan/src/gas.rs @@ -0,0 +1,107 @@ +use std::{collections::HashMap, str::FromStr}; + +use ethers_core::types::U256; +use serde::{de, Deserialize}; +use serde_aux::prelude::*; + +use crate::{Client, EtherscanError, Response, Result}; + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct GasOracle { + #[serde(deserialize_with = "deserialize_number_from_string")] + pub safe_gas_price: u64, + #[serde(deserialize_with = "deserialize_number_from_string")] + pub propose_gas_price: u64, + #[serde(deserialize_with = "deserialize_number_from_string")] + pub fast_gas_price: u64, + #[serde(deserialize_with = "deserialize_number_from_string")] + pub last_block: u64, + #[serde(deserialize_with = "deserialize_number_from_string")] + #[serde(rename = "suggestBaseFee")] + pub suggested_base_fee: f64, + #[serde(deserialize_with = "deserialize_f64_vec")] + #[serde(rename = "gasUsedRatio")] + pub gas_used_ratio: Vec, +} + +fn deserialize_f64_vec<'de, D>(deserializer: D) -> core::result::Result, D::Error> +where + D: de::Deserializer<'de>, +{ + let str_sequence = String::deserialize(deserializer)?; + str_sequence + .split(',') + .map(|item| f64::from_str(item).map_err(|err| de::Error::custom(err.to_string()))) + .collect() +} + +impl Client { + /// Returns the estimated time, in seconds, for a transaction to be confirmed on the blockchain + /// for the specified gas price + pub async fn gas_estimate(&self, gas_price: U256) -> Result { + let query = self.create_query( + "gastracker", + "gasestimate", + HashMap::from([("gasprice", gas_price.to_string())]), + ); + let response: Response = self.get_json(&query).await?; + + if response.status == "1" { + Ok(u32::from_str(&response.result).map_err(|_| EtherscanError::GasEstimationFailed)?) + } else { + Err(EtherscanError::GasEstimationFailed) + } + } + + /// Returns the gas oracle + pub async fn gas_oracle(&self) -> Result { + let query = self.create_query("gastracker", "gasoracle", serde_json::Value::Null); + let response: Response = self.get_json(&query).await?; + + Ok(response.result) + } +} + +#[cfg(test)] +mod tests { + use ethers_core::types::Chain; + + use super::*; + + #[tokio::test] + async fn gas_estimate_success() { + let client = Client::new_from_env(Chain::Mainnet).unwrap(); + + let result = client.gas_estimate(2000000000u32.into()).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn gas_estimate_error() { + let client = Client::new_from_env(Chain::Mainnet).unwrap(); + + let err = client.gas_estimate(7123189371829732819379218u128.into()).await.unwrap_err(); + + assert!(matches!(err, EtherscanError::GasEstimationFailed)); + } + + #[tokio::test] + async fn gas_oracle_success() { + let client = Client::new_from_env(Chain::Mainnet).unwrap(); + + let result = client.gas_oracle().await; + + assert!(result.is_ok()); + + let oracle = result.unwrap(); + + assert!(oracle.safe_gas_price > 0); + assert!(oracle.propose_gas_price > 0); + assert!(oracle.fast_gas_price > 0); + assert!(oracle.last_block > 0); + assert!(oracle.suggested_base_fee > 0.0); + assert!(oracle.gas_used_ratio.len() > 0); + } +} diff --git a/ethers-etherscan/src/lib.rs b/ethers-etherscan/src/lib.rs index b7985573..4c8d8e13 100644 --- a/ethers-etherscan/src/lib.rs +++ b/ethers-etherscan/src/lib.rs @@ -2,7 +2,8 @@ pub mod contract; pub mod errors; -mod transaction; +pub mod gas; +pub mod transaction; use errors::EtherscanError; use ethers_core::{abi::Address, types::Chain}; @@ -76,6 +77,7 @@ impl Client { std::env::var("ETHERSCAN_API_KEY")? } Chain::XDai => String::default(), + chain => return Err(EtherscanError::ChainNotSupported(chain)), }; Self::new(chain, api_key) } diff --git a/ethers-etherscan/src/transaction.rs b/ethers-etherscan/src/transaction.rs index ee0a70ea..260f70f3 100644 --- a/ethers-etherscan/src/transaction.rs +++ b/ethers-etherscan/src/transaction.rs @@ -19,9 +19,6 @@ struct TransactionReceiptStatus { impl Client { /// Returns the status of a contract execution pub async fn check_contract_execution_status(&self, tx_hash: impl AsRef) -> Result<()> { - let mut map = HashMap::new(); - map.insert("txhash", tx_hash.as_ref()); - let query = self.create_query( "transaction", "getstatus", diff --git a/ethers-middleware/Cargo.toml b/ethers-middleware/Cargo.toml index 3facf35a..a1ea8bfc 100644 --- a/ethers-middleware/Cargo.toml +++ b/ethers-middleware/Cargo.toml @@ -16,6 +16,7 @@ rustdoc-args = ["--cfg", "docsrs"] [dependencies] ethers-contract = { version = "^0.6.0", path = "../ethers-contract", default-features = false, features = ["abigen"] } ethers-core = { version = "^0.6.0", path = "../ethers-core", default-features = false } +ethers-etherscan = { version = "^0.2.0", path = "../ethers-etherscan", default-features = false } ethers-providers = { version = "^0.6.0", path = "../ethers-providers", default-features = false } ethers-signers = { version = "^0.6.0", path = "../ethers-signers", default-features = false } diff --git a/ethers-middleware/src/gas_oracle/etherscan.rs b/ethers-middleware/src/gas_oracle/etherscan.rs index de606a18..828f5f92 100644 --- a/ethers-middleware/src/gas_oracle/etherscan.rs +++ b/ethers-middleware/src/gas_oracle/etherscan.rs @@ -1,73 +1,22 @@ -use ethers_core::types::U256; - use async_trait::async_trait; -use reqwest::Client; -use serde::Deserialize; -use serde_aux::prelude::*; -use url::Url; + +use ethers_core::types::U256; +use ethers_etherscan::Client; use crate::gas_oracle::{GasCategory, GasOracle, GasOracleError, GWEI_TO_WEI}; -const ETHERSCAN_URL_PREFIX: &str = - "https://api.etherscan.io/api?module=gastracker&action=gasoracle"; - /// A client over HTTP for the [Etherscan](https://api.etherscan.io/api?module=gastracker&action=gasoracle) gas tracker API /// that implements the `GasOracle` trait #[derive(Clone, Debug)] pub struct Etherscan { client: Client, - url: Url, gas_category: GasCategory, } -#[derive(Deserialize)] -struct EtherscanResponseWrapper { - result: EtherscanResponse, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, PartialOrd)] -#[serde(rename_all = "PascalCase")] -pub struct EtherscanResponse { - #[serde(deserialize_with = "deserialize_number_from_string")] - pub safe_gas_price: u64, - #[serde(deserialize_with = "deserialize_number_from_string")] - pub propose_gas_price: u64, - #[serde(deserialize_with = "deserialize_number_from_string")] - pub fast_gas_price: u64, - #[serde(deserialize_with = "deserialize_number_from_string")] - pub last_block: u64, - #[serde(deserialize_with = "deserialize_number_from_string")] - #[serde(rename = "suggestBaseFee")] - pub suggested_base_fee: f64, - #[serde(deserialize_with = "deserialize_f64_vec")] - #[serde(rename = "gasUsedRatio")] - pub gas_used_ratio: Vec, -} - -use serde::de; -use std::str::FromStr; -fn deserialize_f64_vec<'de, D>(deserializer: D) -> Result, D::Error> -where - D: de::Deserializer<'de>, -{ - let str_sequence = String::deserialize(deserializer)?; - str_sequence - .split(',') - .map(|item| f64::from_str(item).map_err(|err| de::Error::custom(err.to_string()))) - .collect() -} - impl Etherscan { /// Creates a new [Etherscan](https://etherscan.io/gastracker) gas price oracle. - pub fn new(api_key: Option<&str>) -> Self { - let url = match api_key { - Some(key) => format!("{}&apikey={}", ETHERSCAN_URL_PREFIX, key), - None => ETHERSCAN_URL_PREFIX.to_string(), - }; - - let url = Url::parse(&url).expect("invalid url"); - - Etherscan { client: Client::new(), url, gas_category: GasCategory::Standard } + pub fn new(client: Client) -> Self { + Etherscan { client, gas_category: GasCategory::Standard } } /// Sets the gas price category to be used when fetching the gas price. @@ -75,17 +24,6 @@ impl Etherscan { self.gas_category = gas_category; self } - - pub async fn query(&self) -> Result { - let res = self - .client - .get(self.url.as_ref()) - .send() - .await? - .json::() - .await?; - Ok(res.result) - } } #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] @@ -96,12 +34,12 @@ impl GasOracle for Etherscan { return Err(GasOracleError::GasCategoryNotSupported) } - let res = self.query().await?; + let result = self.client.gas_oracle().await?; match self.gas_category { - GasCategory::SafeLow => Ok(U256::from(res.safe_gas_price * GWEI_TO_WEI)), - GasCategory::Standard => Ok(U256::from(res.propose_gas_price * GWEI_TO_WEI)), - GasCategory::Fast => Ok(U256::from(res.fast_gas_price * GWEI_TO_WEI)), + GasCategory::SafeLow => Ok(U256::from(result.safe_gas_price * GWEI_TO_WEI)), + GasCategory::Standard => Ok(U256::from(result.propose_gas_price * GWEI_TO_WEI)), + GasCategory::Fast => Ok(U256::from(result.fast_gas_price * GWEI_TO_WEI)), _ => Err(GasOracleError::GasCategoryNotSupported), } } diff --git a/ethers-middleware/src/gas_oracle/mod.rs b/ethers-middleware/src/gas_oracle/mod.rs index 6bb0ddd9..517de01b 100644 --- a/ethers-middleware/src/gas_oracle/mod.rs +++ b/ethers-middleware/src/gas_oracle/mod.rs @@ -35,6 +35,11 @@ pub enum GasOracleError { #[error(transparent)] HttpClientError(#[from] ReqwestError), + /// An internal error in the Etherscan client request made from the underlying + /// gas oracle + #[error(transparent)] + EtherscanError(#[from] ethers_etherscan::errors::EtherscanError), + /// An internal error thrown when the required gas category is not /// supported by the gas oracle API #[error("gas category not supported")] diff --git a/ethers-middleware/tests/gas_oracle.rs b/ethers-middleware/tests/gas_oracle.rs index 618484dd..959fdbf5 100644 --- a/ethers-middleware/tests/gas_oracle.rs +++ b/ethers-middleware/tests/gas_oracle.rs @@ -61,17 +61,16 @@ async fn eth_gas_station() { #[tokio::test] async fn etherscan() { - let api_key = std::env::var("ETHERSCAN_API_KEY").unwrap(); - let api_key = Some(api_key.as_str()); + let etherscan_client = ethers_etherscan::Client::new_from_env(Chain::Mainnet).unwrap(); // initialize and fetch gas estimates from Etherscan // since etherscan does not support `fastest` category, we expect an error - let etherscan_oracle = Etherscan::new(api_key).category(GasCategory::Fastest); + let etherscan_oracle = Etherscan::new(etherscan_client.clone()).category(GasCategory::Fastest); let data = etherscan_oracle.fetch().await; assert!(data.is_err()); // but fetching the `standard` gas price should work fine - let etherscan_oracle_2 = Etherscan::new(api_key).category(GasCategory::SafeLow); + let etherscan_oracle_2 = Etherscan::new(etherscan_client).category(GasCategory::SafeLow); let data = etherscan_oracle_2.fetch().await; assert!(data.is_ok());