Skip to content

Commit

Permalink
Robust gas oracles (gakonst#1222)
Browse files Browse the repository at this point in the history
* Pass reqwest Client to constructors

* Add Median oracle aggregator

* DRY

* Weighted median

* Add cache layer

* Simplify lifetimes

* Add with_client constructors

* Update GasNow urls

* Add u256_from_f64_saturating

* Add polygon oracle

* Fixes

* Fix lints

* Remove dbg statements
  • Loading branch information
Remco Bloemen authored May 6, 2022
1 parent ce3ebae commit 18b4ef4
Show file tree
Hide file tree
Showing 13 changed files with 503 additions and 38 deletions.
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions ethers-core/src/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ pub use address_or_bytes::AddressOrBytes;
mod path_or_string;
pub use path_or_string::PathOrString;

mod u256;
pub use u256::*;

mod i256;
pub use i256::{Sign, I256};

Expand Down
121 changes: 121 additions & 0 deletions ethers-core/src/types/u256.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
use ethabi::ethereum_types::U256;

/// Convert a floating point value to its nearest f64 integer.
///
/// It is saturating, so values $\ge 2^{256}$ will be rounded
/// to [`U256::max_value()`] and values $< 0$ to zero. This includes
/// positive and negative infinity.
///
/// TODO: Move to ethabi::ethereum_types::U256.
/// TODO: Add [`super::I256`] version.
///
/// # Panics
///
/// Panics if `f` is NaN.
pub fn u256_from_f64_saturating(mut f: f64) -> U256 {
if f.is_nan() {
panic!("NaN is not a valid value for U256");
}
if f < 0.5 {
return U256::zero()
}
if f >= 1.157_920_892_373_162e77_f64 {
return U256::max_value()
}
// All non-normal cases should have been handled above
assert!(f.is_normal());
// Turn nearest rounding into truncated rounding
f += 0.5;

// Parse IEEE-754 double into U256
// Sign should be zero, exponent should be >= 0.
let bits = f.to_bits();
let sign = bits >> 63;
assert!(sign == 0);
let biased_exponent = (bits >> 52) & 0x7ff;
assert!(biased_exponent >= 1023);
let exponent = biased_exponent - 1023;
let fraction = bits & 0xfffffffffffff;
let mantissa = 0x10000000000000 | fraction;
if exponent > 255 {
U256::max_value()
} else if exponent < 52 {
// Truncate mantissa
U256([mantissa, 0, 0, 0]) >> (52 - exponent)
} else {
U256([mantissa, 0, 0, 0]) << (exponent - 52)
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::f64;

#[test]
fn test_small_integers() {
for i in 0..=255 {
let f = i as f64;
let u = u256_from_f64_saturating(f);
assert_eq!(u, U256::from(i));
}
}

#[test]
fn test_small_integers_round_down() {
for i in 0..=255 {
let f = (i as f64) + 0.499;
let u = u256_from_f64_saturating(f);
assert_eq!(u, U256::from(i));
}
}

#[test]
fn test_small_integers_round_up() {
for i in 0..=255 {
let f = (i as f64) - 0.5;
let u = u256_from_f64_saturating(f);
assert_eq!(u, U256::from(i));
}
}

#[test]
fn test_infinities() {
assert_eq!(u256_from_f64_saturating(f64::INFINITY), U256::max_value());
assert_eq!(u256_from_f64_saturating(f64::NEG_INFINITY), U256::zero());
}

#[test]
fn test_saturating() {
assert_eq!(u256_from_f64_saturating(-1.0), U256::zero());
assert_eq!(u256_from_f64_saturating(1e90_f64), U256::max_value());
}

#[test]
fn test_large() {
// Check with e.g. `python3 -c 'print(int(1.0e36))'`
assert_eq!(
u256_from_f64_saturating(1.0e36_f64),
U256::from_dec_str("1000000000000000042420637374017961984").unwrap()
);
assert_eq!(
u256_from_f64_saturating(f64::consts::PI * 2.0e60_f64),
U256::from_dec_str("6283185307179586084560863929317662625677330590403879287914496")
.unwrap()
);
assert_eq!(
u256_from_f64_saturating(5.78960446186581e76_f64),
U256::from_dec_str(
"57896044618658097711785492504343953926634992332820282019728792003956564819968"
)
.unwrap()
);
assert_eq!(
u256_from_f64_saturating(1.157920892373161e77_f64),
U256::from_dec_str(
"115792089237316105435040506505232477503392813560534822796089932171514352762880"
)
.unwrap()
);
}
}
1 change: 1 addition & 0 deletions ethers-middleware/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ async-trait = { version = "0.1.50", default-features = false }
serde = { version = "1.0.124", default-features = false, features = ["derive"] }
thiserror = { version = "1.0.31", default-features = false }
futures-util = { version = "^0.3" }
futures-locks = { version = "0.7" }
tracing = { version = "0.1.34", default-features = false }
tracing-futures = { version = "0.2.5", default-features = false }

Expand Down
31 changes: 20 additions & 11 deletions ethers-middleware/src/gas_oracle/blocknative.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
use crate::gas_oracle::{GasCategory, GasOracle, GasOracleError, GWEI_TO_WEI};
use async_trait::async_trait;
use ethers_core::types::U256;
use reqwest::{
header::{HeaderMap, HeaderValue, AUTHORIZATION},
Client, ClientBuilder,
};
use reqwest::{header::AUTHORIZATION, Client};
use serde::Deserialize;
use std::{collections::HashMap, convert::TryInto, iter::FromIterator};
use std::{collections::HashMap, convert::TryInto};
use url::Url;

const BLOCKNATIVE_GAS_PRICE_ENDPOINT: &str = "https://api.blocknative.com/gasprices/blockprices";
Expand All @@ -26,6 +23,7 @@ fn gas_category_to_confidence(gas_category: &GasCategory) -> u64 {
pub struct BlockNative {
client: Client,
url: Url,
api_key: String,
gas_category: GasCategory,
}

Expand Down Expand Up @@ -84,13 +82,16 @@ pub struct BaseFeeEstimate {
}

impl BlockNative {
/// Creates a new [BlockNative](https://www.blocknative.com/gas-estimator) gas oracle
pub fn new(api_key: &str) -> Self {
let header_value = HeaderValue::from_str(api_key).unwrap();
let headers = HeaderMap::from_iter([(AUTHORIZATION, header_value)]);
let client = ClientBuilder::new().default_headers(headers).build().unwrap();
/// Creates a new [BlockNative](https://www.blocknative.com/gas-estimator) gas oracle.
pub fn new(api_key: String) -> Self {
Self::with_client(Client::new(), api_key)
}

/// Same as [`Self::new`] but with a custom [`Client`].
pub fn with_client(client: Client, api_key: String) -> Self {
Self {
client,
api_key,
url: BLOCKNATIVE_GAS_PRICE_ENDPOINT.try_into().unwrap(),
gas_category: GasCategory::Standard,
}
Expand All @@ -105,7 +106,15 @@ impl BlockNative {

/// Perform request to Blocknative, decode response
pub async fn request(&self) -> Result<BlockNativeGasResponse, GasOracleError> {
Ok(self.client.get(self.url.as_ref()).send().await?.error_for_status()?.json().await?)
self.client
.get(self.url.as_ref())
.header(AUTHORIZATION, &self.api_key)
.send()
.await?
.error_for_status()?
.json()
.await
.map_err(GasOracleError::HttpClientError)
}
}

Expand Down
70 changes: 70 additions & 0 deletions ethers-middleware/src/gas_oracle/cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
use crate::gas_oracle::{GasOracle, GasOracleError};
use async_trait::async_trait;
use ethers_core::types::U256;
use futures_locks::RwLock;
use std::{
fmt::Debug,
future::Future,
time::{Duration, Instant},
};

#[derive(Debug)]
pub struct Cache<T: GasOracle> {
inner: T,
validity: Duration,
fee: Cached<U256>,
eip1559: Cached<(U256, U256)>,
}

#[derive(Default, Debug)]
struct Cached<T: Clone>(RwLock<Option<(Instant, T)>>);

impl<T: Clone> Cached<T> {
async fn get<F, E, Fut>(&self, validity: Duration, fetch: F) -> Result<T, E>
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<T, E>>,
{
// Try with a read lock
{
let lock = self.0.read().await;
if let Some((last_fetch, value)) = lock.as_ref() {
if Instant::now().duration_since(*last_fetch) < validity {
return Ok(value.clone())
}
}
}
// Acquire a write lock
{
let mut lock = self.0.write().await;
// Check again, a concurrent thread may have raced us to the write.
if let Some((last_fetch, value)) = lock.as_ref() {
if Instant::now().duration_since(*last_fetch) < validity {
return Ok(value.clone())
}
}
// Set a fresh value
let value = fetch().await?;
*lock = Some((Instant::now(), value.clone()));
Ok(value)
}
}
}

impl<T: GasOracle> Cache<T> {
pub fn new(validity: Duration, inner: T) -> Self {
Self { inner, validity, fee: Cached::default(), eip1559: Cached::default() }
}
}

#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl<T: GasOracle> GasOracle for Cache<T> {
async fn fetch(&self) -> Result<U256, GasOracleError> {
self.fee.get(self.validity, || self.inner.fetch()).await
}

async fn estimate_eip1559_fees(&self) -> Result<(U256, U256), GasOracleError> {
self.eip1559.get(self.validity, || self.inner.estimate_eip1559_fees()).await
}
}
24 changes: 16 additions & 8 deletions ethers-middleware/src/gas_oracle/eth_gas_station.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,17 @@ pub struct EthGasStationResponse {

impl EthGasStation {
/// Creates a new [EthGasStation](https://docs.ethgasstation.info/) gas oracle
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");
pub fn new(api_key: Option<&str>) -> Self {
Self::with_client(Client::new(), api_key)
}

EthGasStation { client: Client::new(), url, gas_category: GasCategory::Standard }
/// Same as [`Self::new`] but with a custom [`Client`].
pub fn with_client(client: Client, api_key: Option<&str>) -> Self {
let mut url = Url::parse(ETH_GAS_STATION_URL_PREFIX).expect("invalid url");
if let Some(key) = api_key {
url.query_pairs_mut().append_pair("api-key", key);
}
EthGasStation { client, url, gas_category: GasCategory::Standard }
}

/// Sets the gas price category to be used when fetching the gas price.
Expand All @@ -84,6 +86,12 @@ impl EthGasStation {
}
}

impl Default for EthGasStation {
fn default() -> Self {
Self::new(None)
}
}

#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl GasOracle for EthGasStation {
Expand Down
19 changes: 12 additions & 7 deletions ethers-middleware/src/gas_oracle/etherchain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,6 @@ pub struct Etherchain {
gas_category: GasCategory,
}

impl Default for Etherchain {
fn default() -> Self {
Self::new()
}
}

#[derive(Clone, Debug, Deserialize, PartialEq, PartialOrd)]
#[serde(rename_all = "camelCase")]
pub struct EtherchainResponse {
Expand All @@ -38,9 +32,14 @@ pub struct EtherchainResponse {
impl Etherchain {
/// Creates a new [Etherchain](https://etherchain.org/tools/gasPriceOracle) gas price oracle.
pub fn new() -> Self {
Self::with_client(Client::new())
}

/// Same as [`Self::new`] but with a custom [`Client`].
pub fn with_client(client: Client) -> Self {
let url = Url::parse(ETHERCHAIN_URL).expect("invalid url");

Etherchain { client: Client::new(), url, gas_category: GasCategory::Standard }
Etherchain { client, url, gas_category: GasCategory::Standard }
}

/// Sets the gas price category to be used when fetching the gas price.
Expand All @@ -55,6 +54,12 @@ impl Etherchain {
}
}

impl Default for Etherchain {
fn default() -> Self {
Self::new()
}
}

#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl GasOracle for Etherchain {
Expand Down
Loading

0 comments on commit 18b4ef4

Please sign in to comment.