diff --git a/Cargo.lock b/Cargo.lock index 5ab5e718..196fcdb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/ethers-contract/src/multicall/mod.rs b/ethers-contract/src/multicall/mod.rs index 975bf331..f11946a3 100644 --- a/ethers-contract/src/multicall/mod.rs +++ b/ethers-contract/src/multicall/mod.rs @@ -81,7 +81,8 @@ pub static ADDRESS_BOOK: Lazy> = Lazy::new(|| { /// .parse::()?.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> = 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); diff --git a/ethers-providers/Cargo.toml b/ethers-providers/Cargo.toml index 906d5be3..5be4e37d 100644 --- a/ethers-providers/Cargo.toml +++ b/ethers-providers/Cargo.toml @@ -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" } diff --git a/ethers-providers/src/gas_oracle/eth_gas_station.rs b/ethers-providers/src/gas_oracle/eth_gas_station.rs new file mode 100644 index 00000000..de87a2c1 --- /dev/null +++ b/ethers-providers/src/gas_oracle/eth_gas_station.rs @@ -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 { + let res = self + .client + .get(self.url.as_ref()) + .send() + .await? + .json::() + .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) + } +} diff --git a/ethers-providers/src/gas_oracle/etherchain.rs b/ethers-providers/src/gas_oracle/etherchain.rs new file mode 100644 index 00000000..79e38600 --- /dev/null +++ b/ethers-providers/src/gas_oracle/etherchain.rs @@ -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 { + let res = self + .client + .get(self.url.as_ref()) + .send() + .await? + .json::() + .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) + } +} diff --git a/ethers-providers/src/gas_oracle/etherscan.rs b/ethers-providers/src/gas_oracle/etherscan.rs new file mode 100644 index 00000000..5ff55623 --- /dev/null +++ b/ethers-providers/src/gas_oracle/etherscan.rs @@ -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 { + 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::() + .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), + } + } +} diff --git a/ethers-providers/src/gas_oracle/gas_now.rs b/ethers-providers/src/gas_oracle/gas_now.rs new file mode 100644 index 00000000..e878d56d --- /dev/null +++ b/ethers-providers/src/gas_oracle/gas_now.rs @@ -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 { + let res = self + .client + .get(self.url.as_ref()) + .send() + .await? + .json::() + .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) + } +} diff --git a/ethers-providers/src/gas_oracle/mod.rs b/ethers-providers/src/gas_oracle/mod.rs new file mode 100644 index 00000000..e40d4ef3 --- /dev/null +++ b/ethers-providers/src/gas_oracle/mod.rs @@ -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> { +/// 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> { + /// let etherchain_oracle = Etherchain::new().category(GasCategory::Fastest); + /// let data = etherchain_oracle.fetch().await?; + /// # Ok(()) + /// # } + /// ``` + async fn fetch(&self) -> Result; +} diff --git a/ethers-providers/src/lib.rs b/ethers-providers/src/lib.rs index afa57339..528c7ce6 100644 --- a/ethers-providers/src/lib.rs +++ b/ethers-providers/src/lib.rs @@ -107,6 +107,8 @@ mod provider; // ENS support mod ens; +pub mod gas_oracle; + mod pending_transaction; pub use pending_transaction::PendingTransaction; diff --git a/ethers-providers/tests/provider.rs b/ethers-providers/tests/provider.rs index f78259b6..a6ad5d7f 100644 --- a/ethers-providers/tests/provider.rs +++ b/ethers-providers/tests/provider.rs @@ -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(provider: Provider

) { let accounts = provider.get_accounts().await.unwrap(); diff --git a/ethers-signers/src/client.rs b/ethers-signers/src/client.rs index a6858a69..15818166 100644 --- a/ethers-signers/src/client.rs +++ b/ethers-signers/src/client.rs @@ -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 { pub(crate) provider: Provider

, pub(crate) signer: Option, pub(crate) address: Address, + pub(crate) gas_oracle: Option>, } #[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), @@ -91,8 +99,8 @@ pub enum ClientError { // Helper functions for locally signing transactions impl Client where - S: Signer, P: JsonRpcClient, + S: Signer, { /// Creates a new client from the provider and signer. pub fn new(provider: Provider

, 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

) -> Self - where - P: Clone, - { - let mut this = self.clone(); - this.provider = provider; - this + pub fn with_provider(&mut self, provider: Provider

) -> &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) -> 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 From> for Client { provider, signer: None, address: Address::zero(), + gas_oracle: None, } } } diff --git a/ethers-signers/src/wallet.rs b/ethers-signers/src/wallet.rs index 5a2cddca..4377e3c8 100644 --- a/ethers-signers/src/wallet.rs +++ b/ethers-signers/src/wallet.rs @@ -121,6 +121,7 @@ impl Wallet { address, signer: Some(self), provider, + gas_oracle: None, } } diff --git a/ethers-signers/tests/signer.rs b/ethers-signers/tests/signer.rs index 6c624de4..9b67a9f1 100644 --- a/ethers-signers/tests/signer.rs +++ b/ethers-signers/tests/signer.rs @@ -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::::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")]