feat(etherscan, middleware): implement gas-related endpoints and use them in gas oracle
This commit is contained in:
parent
3a768b9c99
commit
3aa77308eb
|
@ -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",
|
||||
|
|
|
@ -14,6 +14,7 @@ pub enum Chain {
|
|||
PolygonMumbai,
|
||||
Avalanche,
|
||||
AvalancheFuji,
|
||||
Sepolia,
|
||||
}
|
||||
|
||||
impl fmt::Display for Chain {
|
||||
|
@ -35,6 +36,7 @@ impl From<Chain> for u32 {
|
|||
Chain::PolygonMumbai => 80001,
|
||||
Chain::Avalanche => 43114,
|
||||
Chain::AvalancheFuji => 43113,
|
||||
Chain::Sepolia => 11155111,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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<f64>,
|
||||
}
|
||||
|
||||
fn deserialize_f64_vec<'de, D>(deserializer: D) -> core::result::Result<Vec<f64>, 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<u32> {
|
||||
let query = self.create_query(
|
||||
"gastracker",
|
||||
"gasestimate",
|
||||
HashMap::from([("gasprice", gas_price.to_string())]),
|
||||
);
|
||||
let response: Response<String> = 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<GasOracle> {
|
||||
let query = self.create_query("gastracker", "gasoracle", serde_json::Value::Null);
|
||||
let response: Response<GasOracle> = 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);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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<str>) -> Result<()> {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("txhash", tx_hash.as_ref());
|
||||
|
||||
let query = self.create_query(
|
||||
"transaction",
|
||||
"getstatus",
|
||||
|
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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<f64>,
|
||||
}
|
||||
|
||||
use serde::de;
|
||||
use std::str::FromStr;
|
||||
fn deserialize_f64_vec<'de, D>(deserializer: D) -> Result<Vec<f64>, 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<EtherscanResponse, GasOracleError> {
|
||||
let res = self
|
||||
.client
|
||||
.get(self.url.as_ref())
|
||||
.send()
|
||||
.await?
|
||||
.json::<EtherscanResponseWrapper>()
|
||||
.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),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")]
|
||||
|
|
|
@ -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());
|
||||
|
|
Loading…
Reference in New Issue