Skip to content

Commit

Permalink
test: Base token price client, fix: ratio calculation multiplicative …
Browse files Browse the repository at this point in the history
…inverse error (#2970)

## What ❔

- I introduce tests to external_price_api crate. Some work was taken
from #2315
- fix multiplicative inverse ratio calculation bug
- fix missing checks in get_fraction

## Why ❔

- We want to make sure the flow of data from API response to token ratio
is correct

## Checklist

- [X] PR title corresponds to the body of PR (we generate changelog
entries from PRs).
- [X] Tests for the changes have been added / updated.
- [X] Documentation comments have been added / updated.
- [X] Code has been formatted via `zk fmt` and `zk lint`.
  • Loading branch information
cytadela8 authored Oct 2, 2024
1 parent 3b69e37 commit b249827
Show file tree
Hide file tree
Showing 9 changed files with 646 additions and 17 deletions.
355 changes: 348 additions & 7 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ google-cloud-storage = "0.20.0"
governor = "0.4.2"
hex = "0.4"
http = "1.1"
httpmock = "0.7.0"
hyper = "1.3"
iai = "0.1"
insta = "1.29.0"
Expand Down
1 change: 1 addition & 0 deletions core/lib/external_price_api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ rand.workspace = true
zksync_config.workspace = true
zksync_types.workspace = true
tokio.workspace = true
httpmock.workspace = true
166 changes: 163 additions & 3 deletions core/lib/external_price_api/src/coingecko_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ impl CoinGeckoPriceAPIClient {
}
}

/// returns token price in ETH by token address. Returned value is X such that 1 TOKEN = X ETH.
async fn get_token_price_by_address(&self, address: Address) -> anyhow::Result<f64> {
let address_str = address_to_string(&address);
let price_url = self
Expand Down Expand Up @@ -87,11 +88,13 @@ impl CoinGeckoPriceAPIClient {
impl PriceAPIClient for CoinGeckoPriceAPIClient {
async fn fetch_ratio(&self, token_address: Address) -> anyhow::Result<BaseTokenAPIRatio> {
let base_token_in_eth = self.get_token_price_by_address(token_address).await?;
let (numerator, denominator) = get_fraction(base_token_in_eth);
let (num_in_eth, denom_in_eth) = get_fraction(base_token_in_eth)?;
// take reciprocal of price as returned price is ETH/BaseToken and BaseToken/ETH is needed
let (num_in_base, denom_in_base) = (denom_in_eth, num_in_eth);

return Ok(BaseTokenAPIRatio {
numerator,
denominator,
numerator: num_in_base,
denominator: denom_in_base,
ratio_timestamp: Utc::now(),
});
}
Expand All @@ -110,3 +113,160 @@ impl CoinGeckoPriceResponse {
.and_then(|price| price.get(currency))
}
}

#[cfg(test)]
mod test {
use httpmock::MockServer;
use zksync_config::configs::external_price_api_client::DEFAULT_TIMEOUT_MS;

use super::*;
use crate::tests::*;

fn get_mock_response(address: &str, price: f64) -> String {
format!("{{\"{}\":{{\"eth\":{}}}}}", address, price)
}

#[test]
fn test_mock_response() {
// curl "https://api.coingecko.com/api/v3/simple/token_price/ethereum?contract_addresses=0x1f9840a85d5af5bf1d1762f925bdaddc4201f984&vs_currencies=eth"
// {"0x1f9840a85d5af5bf1d1762f925bdaddc4201f984":{"eth":0.00269512}}
assert_eq!(
get_mock_response("0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", 0.00269512),
r#"{"0x1f9840a85d5af5bf1d1762f925bdaddc4201f984":{"eth":0.00269512}}"#
)
}

fn add_mock_by_address(
server: &MockServer,
// use string explicitly to verify that conversion of the address to string works as expected
address: String,
price: Option<f64>,
api_key: Option<String>,
) {
server.mock(|mut when, then| {
when = when
.method(httpmock::Method::GET)
.path("/api/v3/simple/token_price/ethereum");

when = when.query_param("contract_addresses", address.clone());
when = when.query_param("vs_currencies", ETH_ID);
api_key.map(|key| when.header(COINGECKO_AUTH_HEADER, key));

if let Some(p) = price {
then.status(200).body(get_mock_response(&address, p));
} else {
// requesting with invalid/unknown address results in empty json
// example:
// $ curl "https://api.coingecko.com/api/v3/simple/token_price/ethereum?contract_addresses=0x000000000000000000000000000000000000dead&vs_currencies=eth"
// {}
then.status(200).body("{}");
};
});
}

fn get_config(base_url: String, api_key: Option<String>) -> ExternalPriceApiClientConfig {
ExternalPriceApiClientConfig {
base_url: Some(base_url),
api_key,
source: "coingecko".to_string(),
client_timeout_ms: DEFAULT_TIMEOUT_MS,
forced: None,
}
}

fn happy_day_setup(
api_key: Option<String>,
server: &MockServer,
address: Address,
base_token_price: f64,
) -> SetupResult {
add_mock_by_address(
server,
address_to_string(&address),
Some(base_token_price),
api_key.clone(),
);
SetupResult {
client: Box::new(CoinGeckoPriceAPIClient::new(get_config(
server.url(""),
api_key,
))),
}
}

#[tokio::test]
async fn test_happy_day_with_api_key() {
happy_day_test(
|server: &MockServer, address: Address, base_token_price: f64| {
happy_day_setup(
Some("test-key".to_string()),
server,
address,
base_token_price,
)
},
)
.await
}

#[tokio::test]
async fn test_happy_day_with_no_api_key() {
happy_day_test(
|server: &MockServer, address: Address, base_token_price: f64| {
happy_day_setup(None, server, address, base_token_price)
},
)
.await
}

fn error_404_setup(
server: &MockServer,
_address: Address,
_base_token_price: f64,
) -> SetupResult {
// just don't add mock
SetupResult {
client: Box::new(CoinGeckoPriceAPIClient::new(get_config(
server.url(""),
Some("FILLER".to_string()),
))),
}
}

#[tokio::test]
async fn test_error_404() {
let error_string = error_test(error_404_setup).await.to_string();
assert!(
error_string
.starts_with("Http error while fetching token price. Status: 404 Not Found"),
"Error was: {}",
&error_string
)
}

fn error_missing_setup(
server: &MockServer,
address: Address,
_base_token_price: f64,
) -> SetupResult {
let api_key = Some("FILLER".to_string());

add_mock_by_address(server, address_to_string(&address), None, api_key.clone());
SetupResult {
client: Box::new(CoinGeckoPriceAPIClient::new(get_config(
server.url(""),
api_key,
))),
}
}

#[tokio::test]
async fn test_error_missing() {
let error_string = error_test(error_missing_setup).await.to_string();
assert!(
error_string.starts_with("Price not found for token"),
"Error was: {}",
error_string
)
}
}
2 changes: 1 addition & 1 deletion core/lib/external_price_api/src/forced_price_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use zksync_types::{base_token_ratio::BaseTokenAPIRatio, Address};

use crate::PriceAPIClient;

// Struct for a a forced price "client" (conversion ratio is always a configured "forced" ratio).
// Struct for a forced price "client" (conversion ratio is always a configured "forced" ratio).
#[derive(Debug, Clone)]
pub struct ForcedPriceClient {
ratio: BaseTokenAPIRatio,
Expand Down
4 changes: 4 additions & 0 deletions core/lib/external_price_api/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
pub mod coingecko_api;
pub mod forced_price_client;
#[cfg(test)]
mod tests;
mod utils;

use std::fmt;
Expand All @@ -11,6 +13,8 @@ use zksync_types::{base_token_ratio::BaseTokenAPIRatio, Address};
#[async_trait]
pub trait PriceAPIClient: Sync + Send + fmt::Debug + 'static {
/// Returns the BaseToken<->ETH ratio for the input token address.
/// The returned value is rational number X such that X BaseToken = 1 ETH.
/// Example if 1 BaseToken = 0.002 ETH, then ratio is 500/1 (500 BaseToken = 1ETH)
async fn fetch_ratio(&self, token_address: Address) -> anyhow::Result<BaseTokenAPIRatio>;
}

Expand Down
56 changes: 56 additions & 0 deletions core/lib/external_price_api/src/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
use std::str::FromStr;

use chrono::Utc;
use httpmock::MockServer;
use zksync_types::Address;

use crate::PriceAPIClient;

const TIME_TOLERANCE_MS: i64 = 100;
/// Uniswap (UNI)
const TEST_TOKEN_ADDRESS: &str = "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984";
/// 1UNI = 0.00269ETH
const TEST_TOKEN_PRICE_ETH: f64 = 0.00269;
/// 1ETH = 371.74UNI; When converting gas price from ETH to UNI
/// you need to multiply by this value. Thus, this should be equal to the ratio.
const TEST_BASE_PRICE: f64 = 371.74;
const PRICE_FLOAT_COMPARE_TOLERANCE: f64 = 0.1;

pub(crate) struct SetupResult {
pub(crate) client: Box<dyn PriceAPIClient>,
}

pub(crate) type SetupFn =
fn(server: &MockServer, address: Address, base_token_price: f64) -> SetupResult;

pub(crate) async fn happy_day_test(setup: SetupFn) {
let server = MockServer::start();
let address_str = TEST_TOKEN_ADDRESS;
let address = Address::from_str(address_str).unwrap();

// APIs return token price in ETH (ETH per 1 token)
let SetupResult { client } = setup(&server, address, TEST_TOKEN_PRICE_ETH);
let api_price = client.fetch_ratio(address).await.unwrap();

// we expect the returned ratio to be such that when multiplying gas price in ETH you get gas
// price in base token. So we expect such ratio X that X Base = 1ETH
assert!(
((api_price.numerator.get() as f64) / (api_price.denominator.get() as f64)
- TEST_BASE_PRICE)
.abs()
< PRICE_FLOAT_COMPARE_TOLERANCE
);
assert!((Utc::now() - api_price.ratio_timestamp).num_milliseconds() <= TIME_TOLERANCE_MS);
}

pub(crate) async fn error_test(setup: SetupFn) -> anyhow::Error {
let server = MockServer::start();
let address_str = TEST_TOKEN_ADDRESS;
let address = Address::from_str(address_str).unwrap();

let SetupResult { client } = setup(&server, address, 1.0);
let api_price = client.fetch_ratio(address).await;

assert!(api_price.is_err());
api_price.err().unwrap()
}
77 changes: 71 additions & 6 deletions core/lib/external_price_api/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,78 @@ use std::num::NonZeroU64;
use fraction::Fraction;

/// Using the base token price and eth price, calculate the fraction of the base token to eth.
pub fn get_fraction(ratio_f64: f64) -> (NonZeroU64, NonZeroU64) {
pub fn get_fraction(ratio_f64: f64) -> anyhow::Result<(NonZeroU64, NonZeroU64)> {
let rate_fraction = Fraction::from(ratio_f64);
if rate_fraction.sign() == Some(fraction::Sign::Minus) {
return Err(anyhow::anyhow!("number is negative"));
}

let numerator = NonZeroU64::new(*rate_fraction.numer().expect("numerator is empty"))
.expect("numerator is zero");
let denominator = NonZeroU64::new(*rate_fraction.denom().expect("denominator is empty"))
.expect("denominator is zero");
let numerator = NonZeroU64::new(
*rate_fraction
.numer()
.ok_or(anyhow::anyhow!("number is not rational"))?,
)
.ok_or(anyhow::anyhow!("numerator is zero"))?;
let denominator = NonZeroU64::new(
*rate_fraction
.denom()
.ok_or(anyhow::anyhow!("number is not rational"))?,
)
.ok_or(anyhow::anyhow!("denominator is zero"))?;

(numerator, denominator)
Ok((numerator, denominator))
}

#[cfg(test)]
pub(crate) mod tests {
use super::*;

fn assert_get_fraction_value(f: f64, num: u64, denum: u64) {
assert_eq!(
get_fraction(f).unwrap(),
(
NonZeroU64::try_from(num).unwrap(),
NonZeroU64::try_from(denum).unwrap()
)
);
}

#[allow(clippy::approx_constant)]
#[test]
fn test_float_to_fraction_conversion_as_expected() {
assert_get_fraction_value(1.0, 1, 1);
assert_get_fraction_value(1337.0, 1337, 1);
assert_get_fraction_value(0.1, 1, 10);
assert_get_fraction_value(3.141, 3141, 1000);
assert_get_fraction_value(1_000_000.0, 1_000_000, 1);
assert_get_fraction_value(3123.47, 312347, 100);
// below tests assume some not necessarily required behaviour of get_fraction
assert_get_fraction_value(0.2, 1, 5);
assert_get_fraction_value(0.5, 1, 2);
assert_get_fraction_value(3.1415, 6283, 2000);
}

#[test]
fn test_to_fraction_bad_inputs() {
assert_eq!(
get_fraction(0.0).expect_err("did not error").to_string(),
"numerator is zero"
);
assert_eq!(
get_fraction(-1.0).expect_err("did not error").to_string(),
"number is negative"
);
assert_eq!(
get_fraction(f64::NAN)
.expect_err("did not error")
.to_string(),
"number is not rational"
);
assert_eq!(
get_fraction(f64::INFINITY)
.expect_err("did not error")
.to_string(),
"number is not rational"
);
}
}
1 change: 1 addition & 0 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ feature-depth = 1

[advisories]
ignore = [
"RUSTSEC-2024-0375", # atty dependency being unmaintained, dependency of clap and criterion, we would need to update to newer major of dependencies
"RUSTSEC-2024-0320", # yaml_rust dependency being unmaintained, dependency in core, we should consider moving to yaml_rust2 fork
"RUSTSEC-2020-0168", # mach dependency being unmaintained, dependency in consensus, we should consider moving to mach2 fork
"RUSTSEC-2024-0370", # `cs_derive` needs to be updated to not rely on `proc-macro-error`
Expand Down

0 comments on commit b249827

Please sign in to comment.