(feat) gas oracle support (#56)

* (feat) gas oracle support

* (refactor) make a separate module | fix clippy warning

* add gas oracle to client using dynamic dispatch

* fix doc build in multicall module

* gas oracle returns U256

* support gas price fetching from client

* avoid querying for unsupported gas categories

* changes based on PR review

* add support for gasnow API, refactor gwei to wei
This commit is contained in:
Rohit Narurkar 2020-08-19 00:17:56 +05:30 committed by GitHub
parent ca2ec0aadd
commit 237f011259
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 542 additions and 22 deletions

43
Cargo.lock generated
View File

@ -244,6 +244,17 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
[[package]]
name = "chrono"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c74d84029116787153e02106bf53e66828452a4b325cc8652b788b5967c0a0b6"
dependencies = [
"num-integer",
"num-traits",
"time",
]
[[package]]
name = "concurrent-queue"
version = "1.1.1"
@ -497,6 +508,7 @@ dependencies = [
"reqwest",
"rustc-hex",
"serde",
"serde-aux",
"serde_json",
"thiserror",
"tokio",
@ -1058,6 +1070,25 @@ dependencies = [
"winapi 0.3.8",
]
[[package]]
name = "num-integer"
version = "0.1.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d59457e662d541ba17869cf51cf177c0b5f0cbf476c66bdc90bf1edac4f875b"
dependencies = [
"autocfg",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac267bcc07f48ee5f8935ab0d24f316fb722d7a1292e2913f0cc196b29ffd611"
dependencies = [
"autocfg",
]
[[package]]
name = "num_cpus"
version = "1.13.0"
@ -1447,6 +1478,18 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-aux"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae50f53d4b01e854319c1f5b854cd59471f054ea7e554988850d3f36ca1dc852"
dependencies = [
"chrono",
"serde",
"serde_derive",
"serde_json",
]
[[package]]
name = "serde_derive"
version = "1.0.112"

View File

@ -81,7 +81,8 @@ pub static ADDRESS_BOOK: Lazy<HashMap<U256, Address>> = Lazy::new(|| {
/// .parse::<Wallet>()?.connect(provider);
///
/// // create the contract object. This will be used to construct the calls for multicall
/// let contract = Contract::new(address, abi, client.clone());
/// let client = Arc::new(client);
/// let contract = Contract::new(address, abi, Arc::clone(&client));
///
/// // note that these [`ContractCall`]s are futures, and need to be `.await`ed to resolve.
/// // But we will let `Multicall` to take care of that for us
@ -92,7 +93,7 @@ pub static ADDRESS_BOOK: Lazy<HashMap<U256, Address>> = Lazy::new(|| {
/// // the Multicall contract and we set that to `None`. If you wish to provide the address
/// // for the Multicall contract, you can pass the `Some(multicall_addr)` argument.
/// // Construction of the `Multicall` instance follows the builder pattern
/// let multicall = Multicall::new(client.clone(), None)
/// let multicall = Multicall::new(Arc::clone(&client), None)
/// .await?
/// .add_call(first_call)
/// .add_call(second_call);

View File

@ -38,6 +38,9 @@ tokio = { version = "0.2.21", default-features = false, optional = true }
real-tokio-native-tls = { package = "tokio-native-tls", version = "0.1.0", optional = true }
async-tls = { version = "0.7.0", optional = true }
# needed for parsing while deserialization in gas oracles
serde-aux = "0.6.1"
[dev-dependencies]
ethers = { version = "0.1.3", path = "../ethers" }

View File

@ -0,0 +1,72 @@
use ethers_core::types::U256;
use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;
use url::Url;
use crate::gas_oracle::{GasCategory, GasOracle, GasOracleError, GWEI_TO_WEI};
const ETH_GAS_STATION_URL_PREFIX: &str = "https://ethgasstation.info/api/ethgasAPI.json";
/// A client over HTTP for the [EthGasStation](https://ethgasstation.info/api/ethgasAPI.json) gas tracker API
/// that implements the `GasOracle` trait
#[derive(Debug)]
pub struct EthGasStation {
client: Client,
url: Url,
gas_category: GasCategory,
}
#[derive(Deserialize)]
struct EthGasStationResponse {
#[serde(rename = "safeLow")]
safe_low: u64,
average: u64,
fast: u64,
fastest: u64,
}
impl EthGasStation {
pub fn new(api_key: Option<&'static str>) -> Self {
let url = match api_key {
Some(key) => format!("{}?api-key={}", ETH_GAS_STATION_URL_PREFIX, key),
None => ETH_GAS_STATION_URL_PREFIX.to_string(),
};
let url = Url::parse(&url).expect("invalid url");
EthGasStation {
client: Client::new(),
url,
gas_category: GasCategory::Standard,
}
}
pub fn category(mut self, gas_category: GasCategory) -> Self {
self.gas_category = gas_category;
self
}
}
#[async_trait]
impl GasOracle for EthGasStation {
async fn fetch(&self) -> Result<U256, GasOracleError> {
let res = self
.client
.get(self.url.as_ref())
.send()
.await?
.json::<EthGasStationResponse>()
.await?;
let gas_price = match self.gas_category {
GasCategory::SafeLow => U256::from((res.safe_low * GWEI_TO_WEI) / 10),
GasCategory::Standard => U256::from((res.average * GWEI_TO_WEI) / 10),
GasCategory::Fast => U256::from((res.fast * GWEI_TO_WEI) / 10),
GasCategory::Fastest => U256::from((res.fastest * GWEI_TO_WEI) / 10),
};
Ok(gas_price)
}
}

View File

@ -0,0 +1,78 @@
use ethers_core::types::U256;
use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;
use serde_aux::prelude::*;
use url::Url;
use crate::gas_oracle::{GasCategory, GasOracle, GasOracleError, GWEI_TO_WEI};
const ETHERCHAIN_URL: &str = "https://www.etherchain.org/api/gasPriceOracle";
/// A client over HTTP for the [Etherchain](https://www.etherchain.org/api/gasPriceOracle) gas tracker API
/// that implements the `GasOracle` trait
#[derive(Debug)]
pub struct Etherchain {
client: Client,
url: Url,
gas_category: GasCategory,
}
impl Default for Etherchain {
fn default() -> Self {
Self::new()
}
}
#[derive(Deserialize)]
struct EtherchainResponse {
#[serde(deserialize_with = "deserialize_number_from_string")]
#[serde(rename = "safeLow")]
safe_low: f32,
#[serde(deserialize_with = "deserialize_number_from_string")]
standard: f32,
#[serde(deserialize_with = "deserialize_number_from_string")]
fast: f32,
#[serde(deserialize_with = "deserialize_number_from_string")]
fastest: f32,
}
impl Etherchain {
pub fn new() -> Self {
let url = Url::parse(ETHERCHAIN_URL).expect("invalid url");
Etherchain {
client: Client::new(),
url,
gas_category: GasCategory::Standard,
}
}
pub fn category(mut self, gas_category: GasCategory) -> Self {
self.gas_category = gas_category;
self
}
}
#[async_trait]
impl GasOracle for Etherchain {
async fn fetch(&self) -> Result<U256, GasOracleError> {
let res = self
.client
.get(self.url.as_ref())
.send()
.await?
.json::<EtherchainResponse>()
.await?;
let gas_price = match self.gas_category {
GasCategory::SafeLow => U256::from((res.safe_low as u64) * GWEI_TO_WEI),
GasCategory::Standard => U256::from((res.standard as u64) * GWEI_TO_WEI),
GasCategory::Fast => U256::from((res.fast as u64) * GWEI_TO_WEI),
GasCategory::Fastest => U256::from((res.fastest as u64) * GWEI_TO_WEI),
};
Ok(gas_price)
}
}

View File

@ -0,0 +1,81 @@
use ethers_core::types::U256;
use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;
use serde_aux::prelude::*;
use url::Url;
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(Debug)]
pub struct Etherscan {
client: Client,
url: Url,
gas_category: GasCategory,
}
#[derive(Deserialize)]
struct EtherscanResponse {
result: EtherscanResponseInner,
}
#[derive(Deserialize)]
struct EtherscanResponseInner {
#[serde(deserialize_with = "deserialize_number_from_string")]
#[serde(rename = "SafeGasPrice")]
safe_gas_price: u64,
#[serde(deserialize_with = "deserialize_number_from_string")]
#[serde(rename = "ProposeGasPrice")]
propose_gas_price: u64,
}
impl Etherscan {
pub fn new(api_key: Option<&'static 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 category(mut self, gas_category: GasCategory) -> Self {
self.gas_category = gas_category;
self
}
}
#[async_trait]
impl GasOracle for Etherscan {
async fn fetch(&self) -> Result<U256, GasOracleError> {
if matches!(self.gas_category, GasCategory::Fast | GasCategory::Fastest) {
return Err(GasOracleError::GasCategoryNotSupported);
}
let res = self
.client
.get(self.url.as_ref())
.send()
.await?
.json::<EtherscanResponse>()
.await?;
match self.gas_category {
GasCategory::SafeLow => Ok(U256::from(res.result.safe_gas_price * GWEI_TO_WEI)),
GasCategory::Standard => Ok(U256::from(res.result.propose_gas_price * GWEI_TO_WEI)),
_ => Err(GasOracleError::GasCategoryNotSupported),
}
}
}

View File

@ -0,0 +1,78 @@
use ethers_core::types::U256;
use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;
use url::Url;
use crate::gas_oracle::{GasCategory, GasOracle, GasOracleError};
const GAS_NOW_URL: &str = "https://www.gasnow.org/api/v1/gas/price";
/// A client over HTTP for the [GasNow](https://www.gasnow.org/api/v1/gas/price) gas tracker API
/// that implements the `GasOracle` trait
#[derive(Debug)]
pub struct GasNow {
client: Client,
url: Url,
gas_category: GasCategory,
}
impl Default for GasNow {
fn default() -> Self {
Self::new()
}
}
#[derive(Deserialize)]
struct GasNowResponse {
data: GasNowResponseInner,
}
#[derive(Deserialize)]
struct GasNowResponseInner {
#[serde(rename = "top50")]
top_50: u64,
#[serde(rename = "top200")]
top_200: u64,
#[serde(rename = "top400")]
top_400: u64,
}
impl GasNow {
pub fn new() -> Self {
let url = Url::parse(GAS_NOW_URL).expect("invalid url");
Self {
client: Client::new(),
url,
gas_category: GasCategory::Standard,
}
}
pub fn category(mut self, gas_category: GasCategory) -> Self {
self.gas_category = gas_category;
self
}
}
#[async_trait]
impl GasOracle for GasNow {
async fn fetch(&self) -> Result<U256, GasOracleError> {
let res = self
.client
.get(self.url.as_ref())
.send()
.await?
.json::<GasNowResponse>()
.await?;
let gas_price = match self.gas_category {
GasCategory::SafeLow => U256::from(res.data.top_400),
GasCategory::Standard => U256::from(res.data.top_200),
_ => U256::from(res.data.top_50),
};
Ok(gas_price)
}
}

View File

@ -0,0 +1,80 @@
mod eth_gas_station;
pub use eth_gas_station::EthGasStation;
mod etherchain;
pub use etherchain::Etherchain;
mod etherscan;
pub use etherscan::Etherscan;
mod gas_now;
pub use gas_now::GasNow;
use ethers_core::types::U256;
use async_trait::async_trait;
use reqwest::Error as ReqwestError;
use thiserror::Error;
const GWEI_TO_WEI: u64 = 1000000000;
/// Various gas price categories. Choose one of the available
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum GasCategory {
SafeLow,
Standard,
Fast,
Fastest,
}
#[derive(Error, Debug)]
/// Error thrown when fetching data from the `GasOracle`
pub enum GasOracleError {
/// An internal error in the HTTP request made from the underlying
/// gas oracle
#[error(transparent)]
HttpClientError(#[from] ReqwestError),
/// An internal error thrown when the required gas category is not
/// supported by the gas oracle API
#[error("gas category not supported")]
GasCategoryNotSupported,
}
/// `GasOracle` is a trait that an underlying gas oracle needs to implement.
///
/// # Example
///
/// ```no_run
/// use ethers::providers::{
/// gas_oracle::{EthGasStation, Etherscan, GasCategory, GasOracle},
/// };
///
/// # async fn foo() -> Result<(), Box<dyn std::error::Error>> {
/// let eth_gas_station_oracle = EthGasStation::new(Some("my-api-key"));
/// let etherscan_oracle = EthGasStation::new(None).category(GasCategory::SafeLow);
///
/// let data_1 = eth_gas_station_oracle.fetch().await?;
/// let data_2 = etherscan_oracle.fetch().await?;
/// # Ok(())
/// # }
/// ```
#[async_trait]
pub trait GasOracle: Send + Sync + std::fmt::Debug {
/// Makes an asynchronous HTTP query to the underlying `GasOracle`
///
/// # Example
///
/// ```
/// use ethers::providers::{
/// gas_oracle::{Etherchain, GasCategory, GasOracle},
/// };
///
/// # async fn foo() -> Result<(), Box<dyn std::error::Error>> {
/// let etherchain_oracle = Etherchain::new().category(GasCategory::Fastest);
/// let data = etherchain_oracle.fetch().await?;
/// # Ok(())
/// # }
/// ```
async fn fetch(&self) -> Result<U256, GasOracleError>;
}

View File

@ -107,6 +107,8 @@ mod provider;
// ENS support
mod ens;
pub mod gas_oracle;
mod pending_transaction;
pub use pending_transaction::PendingTransaction;

View File

@ -1,5 +1,8 @@
#![allow(unused_braces)]
use ethers::providers::{Http, Provider};
use ethers::providers::{
gas_oracle::{EthGasStation, Etherchain, Etherscan, GasCategory, GasNow, GasOracle},
Http, Provider,
};
use std::{convert::TryFrom, time::Duration};
#[cfg(not(feature = "celo"))]
@ -73,6 +76,36 @@ mod eth_tests {
generic_pending_txs_test(provider).await;
}
#[tokio::test]
async fn gas_oracle() {
// initialize and fetch gas estimates from EthGasStation
let eth_gas_station_oracle = EthGasStation::new(None);
let data_1 = eth_gas_station_oracle.fetch().await;
assert!(data_1.is_ok());
// initialize and fetch gas estimates from Etherscan
// since etherscan does not support `fastest` category, we expect an error
let etherscan_oracle = Etherscan::new(None).category(GasCategory::Fastest);
let data_2 = etherscan_oracle.fetch().await;
assert!(data_2.is_err());
// but fetching the `standard` gas price should work fine
let etherscan_oracle_2 = Etherscan::new(None).category(GasCategory::SafeLow);
let data_3 = etherscan_oracle_2.fetch().await;
assert!(data_3.is_ok());
// initialize and fetch gas estimates from Etherchain
let etherchain_oracle = Etherchain::new().category(GasCategory::Fast);
let data_4 = etherchain_oracle.fetch().await;
assert!(data_4.is_ok());
// initialize and fetch gas estimates from Etherchain
let gas_now_oracle = GasNow::new().category(GasCategory::Fastest);
let data_5 = gas_now_oracle.fetch().await;
assert!(data_5.is_ok());
}
async fn generic_pending_txs_test<P: JsonRpcClient>(provider: Provider<P>) {
let accounts = provider.get_accounts().await.unwrap();

View File

@ -3,13 +3,16 @@ use crate::Signer;
use ethers_core::types::{
Address, BlockNumber, Bytes, NameOrAddress, Signature, TransactionRequest, TxHash,
};
use ethers_providers::{JsonRpcClient, Provider, ProviderError};
use ethers_providers::{
gas_oracle::{GasOracle, GasOracleError},
JsonRpcClient, Provider, ProviderError,
};
use futures_util::{future::ok, join};
use std::{future::Future, ops::Deref, time::Duration};
use thiserror::Error;
#[derive(Clone, Debug)]
#[derive(Debug)]
/// A client provides an interface for signing and broadcasting locally signed transactions
/// It Derefs to [`Provider`], which allows interacting with the Ethereum JSON-RPC provider
/// via the same API. Sending transactions also supports using [ENS](https://ens.domains/) as a receiver. If you will
@ -70,6 +73,7 @@ pub struct Client<P, S> {
pub(crate) provider: Provider<P>,
pub(crate) signer: Option<S>,
pub(crate) address: Address,
pub(crate) gas_oracle: Option<Box<dyn GasOracle>>,
}
#[derive(Debug, Error)]
@ -79,6 +83,10 @@ pub enum ClientError {
/// Throw when the call to the provider fails
ProviderError(#[from] ProviderError),
#[error(transparent)]
/// Throw when a call to the gas oracle fails
GasOracleError(#[from] GasOracleError),
#[error(transparent)]
/// Thrown when the internal call to the signer fails
SignerError(#[from] Box<dyn std::error::Error + Send + Sync>),
@ -91,8 +99,8 @@ pub enum ClientError {
// Helper functions for locally signing transactions
impl<P, S> Client<P, S>
where
S: Signer,
P: JsonRpcClient,
S: Signer,
{
/// Creates a new client from the provider and signer.
pub fn new(provider: Provider<P>, signer: S) -> Self {
@ -101,6 +109,7 @@ where
provider,
signer: Some(signer),
address,
gas_oracle: None,
}
}
@ -151,6 +160,13 @@ where
tx.from = Some(self.address());
}
// assign gas price if a gas oracle has been provided
if let Some(gas_oracle) = &self.gas_oracle {
if let Ok(gas_price) = gas_oracle.fetch().await {
tx.gas_price = Some(gas_price);
}
}
// will poll and await the futures concurrently
let (gas_price, gas, nonce) = join!(
maybe(tx.gas_price, self.provider.get_gas_price()),
@ -186,27 +202,19 @@ where
/// calls.
///
/// Clones internally.
pub fn with_signer(&self, signer: S) -> Self
where
P: Clone,
{
let mut this = self.clone();
this.address = signer.address();
this.signer = Some(signer);
this
pub fn with_signer(&mut self, signer: S) -> &Self {
self.address = signer.address();
self.signer = Some(signer);
self
}
/// Sets the provider and returns a mutable reference to self so that it can be used in chained
/// calls.
///
/// Clones internally.
pub fn with_provider(&self, provider: Provider<P>) -> Self
where
P: Clone,
{
let mut this = self.clone();
this.provider = provider;
this
pub fn with_provider(&mut self, provider: Provider<P>) -> &Self {
self.provider = provider;
self
}
/// Sets the address which will be used for interacting with the blockchain.
@ -236,6 +244,12 @@ where
self.provider = provider;
self
}
/// Sets the gas oracle to query for gas estimates while broadcasting transactions
pub fn gas_oracle(mut self, gas_oracle: Box<dyn GasOracle>) -> Self {
self.gas_oracle = Some(gas_oracle);
self
}
}
/// Calls the future if `item` is None, otherwise returns a `futures::ok`
@ -267,6 +281,7 @@ impl<P: JsonRpcClient, S> From<Provider<P>> for Client<P, S> {
provider,
signer: None,
address: Address::zero(),
gas_oracle: None,
}
}
}

View File

@ -121,6 +121,7 @@ impl Wallet {
address,
signer: Some(self),
provider,
gas_oracle: None,
}
}

View File

@ -1,5 +1,8 @@
use ethers::{
providers::{Http, Provider},
providers::{
gas_oracle::{Etherchain, GasCategory, GasOracle},
Http, Provider,
},
signers::Wallet,
types::TransactionRequest,
};
@ -71,6 +74,36 @@ mod eth_tests {
assert!(balance_before > balance_after);
}
#[tokio::test]
async fn using_gas_oracle() {
let ganache = Ganache::new().spawn();
// this private key belongs to the above mnemonic
let wallet: Wallet = ganache.keys()[0].clone().into();
let wallet2: Wallet = ganache.keys()[1].clone().into();
// connect to the network
let provider = Provider::<Http>::try_from(ganache.endpoint())
.unwrap()
.interval(Duration::from_millis(10u64));
// connect the wallet to the provider
let client = wallet.connect(provider);
// assign a gas oracle to use
let gas_oracle = Etherchain::new().category(GasCategory::Fastest);
let expected_gas_price = gas_oracle.fetch().await.unwrap();
let client = client.gas_oracle(Box::new(gas_oracle));
// broadcast a transaction
let tx = TransactionRequest::new().to(wallet2.address()).value(10000);
let tx_hash = client.send_transaction(tx, None).await.unwrap();
let tx = client.get_transaction(tx_hash).await.unwrap();
assert_eq!(tx.gas_price, expected_gas_price);
}
}
#[cfg(feature = "celo")]