Skip to content
This repository has been archived by the owner on Oct 19, 2024. It is now read-only.

Robust gas oracles #1222

Merged
merged 13 commits into from
May 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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