From b24982768ec50820143a172e9418fe8b75d77d93 Mon Sep 17 00:00:00 2001 From: Artur Puzio Date: Wed, 2 Oct 2024 08:52:55 +0000 Subject: [PATCH] test: Base token price client, fix: ratio calculation multiplicative inverse error (#2970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What ❔ - I introduce tests to external_price_api crate. Some work was taken from https://github.com/matter-labs/zksync-era/pull/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`. --- Cargo.lock | 355 +++++++++++++++++- Cargo.toml | 1 + core/lib/external_price_api/Cargo.toml | 1 + .../external_price_api/src/coingecko_api.rs | 166 +++++++- .../src/forced_price_client.rs | 2 +- core/lib/external_price_api/src/lib.rs | 4 + core/lib/external_price_api/src/tests.rs | 56 +++ core/lib/external_price_api/src/utils.rs | 77 +++- deny.toml | 1 + 9 files changed, 646 insertions(+), 17 deletions(-) create mode 100644 core/lib/external_price_api/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 079793ab983..127921ba3e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -234,12 +234,52 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ascii-canvas" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" +dependencies = [ + "term", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "assert_matches" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" +[[package]] +name = "async-attributes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote 1.0.37", + "syn 1.0.109", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + [[package]] name = "async-channel" version = "2.3.1" @@ -276,6 +316,21 @@ dependencies = [ "futures-lite", ] +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.3.1", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + [[package]] name = "async-io" version = "2.3.4" @@ -317,13 +372,22 @@ dependencies = [ "futures-lite", ] +[[package]] +name = "async-object-pool" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333c456b97c3f2d50604e8b2624253b7f787208cb72eb75e64b0ad11b221652c" +dependencies = [ + "async-std", +] + [[package]] name = "async-process" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" dependencies = [ - "async-channel", + "async-channel 2.3.1", "async-io", "async-lock", "async-signal", @@ -365,6 +429,34 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "async-std" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c634475f29802fde2b8f0b505b1bd00dfe4df7d4a000f0b36f7671197d5c3615" +dependencies = [ + "async-attributes", + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers 0.3.0", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + [[package]] name = "async-stream" version = "0.3.5" @@ -594,6 +686,17 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "basic-cookies" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67bd8fd42c16bdb08688243dc5f0cc117a3ca9efeeaba3a345a18a6159ad96f7" +dependencies = [ + "lalrpop", + "lalrpop-util", + "regex", +] + [[package]] name = "beef" version = "0.5.2" @@ -680,6 +783,15 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + [[package]] name = "bit-vec" version = "0.6.3" @@ -853,7 +965,7 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" dependencies = [ - "async-channel", + "async-channel 2.3.1", "async-task", "futures-io", "futures-lite", @@ -1959,6 +2071,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -2107,6 +2240,15 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "ena" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" +dependencies = [ + "log", +] + [[package]] name = "encode_unicode" version = "0.3.6" @@ -2244,6 +2386,12 @@ dependencies = [ "uint", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + [[package]] name = "event-listener" version = "4.0.3" @@ -2586,7 +2734,7 @@ version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" dependencies = [ - "gloo-timers", + "gloo-timers 0.2.6", "send_wrapper", ] @@ -2722,6 +2870,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gloo-utils" version = "0.2.0" @@ -3115,6 +3275,34 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "httpmock" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ec9586ee0910472dec1a1f0f8acf52f0fdde93aea74d70d4a3107b4be0fd5b" +dependencies = [ + "assert-json-diff", + "async-object-pool", + "async-std", + "async-trait", + "base64 0.21.7", + "basic-cookies", + "crossbeam-utils", + "form_urlencoded", + "futures-util", + "hyper 0.14.30", + "lazy_static", + "levenshtein", + "log", + "regex", + "serde", + "serde_json", + "serde_regex", + "similar", + "tokio", + "url", +] + [[package]] name = "hyper" version = "0.14.30" @@ -3439,6 +3627,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -3816,6 +4013,46 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lalrpop" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools 0.11.0", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax 0.8.4", + "string_cache", + "term", + "tiny-keccak 2.0.2", + "unicode-xid 0.2.6", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" +dependencies = [ + "regex-automata 0.4.7", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3837,6 +4074,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" +[[package]] +name = "levenshtein" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" + [[package]] name = "libc" version = "0.2.159" @@ -3859,6 +4102,16 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.6.0", + "libc", +] + [[package]] name = "librocksdb-sys" version = "0.11.0+8.1.1" @@ -4004,6 +4257,9 @@ name = "log" version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +dependencies = [ + "value-bag", +] [[package]] name = "logos" @@ -4256,6 +4512,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.29.0" @@ -4865,6 +5127,21 @@ dependencies = [ "indexmap 2.5.0", ] +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.5" @@ -5026,6 +5303,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -5478,6 +5761,17 @@ dependencies = [ "bitflags 2.6.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.10.6" @@ -6458,6 +6752,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_regex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" +dependencies = [ + "regex", + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.8" @@ -6675,6 +6979,12 @@ dependencies = [ "time", ] +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "siphasher" version = "1.0.1" @@ -6730,7 +7040,7 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" dependencies = [ - "async-channel", + "async-channel 2.3.1", "async-executor", "async-fs", "async-io", @@ -6786,7 +7096,7 @@ dependencies = [ "serde_json", "sha2 0.10.8", "sha3 0.10.8", - "siphasher", + "siphasher 1.0.1", "slab", "smallvec", "soketto 0.7.1", @@ -6802,7 +7112,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5496f2d116b7019a526b1039ec2247dd172b8670633b1a64a614c9ea12c9d8c7" dependencies = [ - "async-channel", + "async-channel 2.3.1", "async-lock", "base64 0.21.7", "blake2-rfc", @@ -6825,7 +7135,7 @@ dependencies = [ "rand_chacha", "serde", "serde_json", - "siphasher", + "siphasher 1.0.1", "slab", "smol", "smoldot", @@ -7189,6 +7499,19 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" +dependencies = [ + "new_debug_unreachable", + "once_cell", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -7534,6 +7857,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -8303,6 +8637,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "value-bag" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a84c137d37ab0142f0f2ddfe332651fdbf252e7b7dbb4e67b6c1f1b2e925101" + [[package]] name = "vcpkg" version = "0.2.15" @@ -9901,6 +10241,7 @@ dependencies = [ "bigdecimal", "chrono", "fraction", + "httpmock", "rand 0.8.5", "reqwest 0.12.7", "serde", diff --git a/Cargo.toml b/Cargo.toml index d5ccff6eb1c..94fadb25968 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/core/lib/external_price_api/Cargo.toml b/core/lib/external_price_api/Cargo.toml index 9539aa3fdc3..3eee675b4e6 100644 --- a/core/lib/external_price_api/Cargo.toml +++ b/core/lib/external_price_api/Cargo.toml @@ -24,3 +24,4 @@ rand.workspace = true zksync_config.workspace = true zksync_types.workspace = true tokio.workspace = true +httpmock.workspace = true diff --git a/core/lib/external_price_api/src/coingecko_api.rs b/core/lib/external_price_api/src/coingecko_api.rs index 8fa7514b368..cc882db95c3 100644 --- a/core/lib/external_price_api/src/coingecko_api.rs +++ b/core/lib/external_price_api/src/coingecko_api.rs @@ -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 { let address_str = address_to_string(&address); let price_url = self @@ -87,11 +88,13 @@ impl CoinGeckoPriceAPIClient { impl PriceAPIClient for CoinGeckoPriceAPIClient { async fn fetch_ratio(&self, token_address: Address) -> anyhow::Result { 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(), }); } @@ -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, + api_key: Option, + ) { + 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) -> 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, + 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 + ) + } +} diff --git a/core/lib/external_price_api/src/forced_price_client.rs b/core/lib/external_price_api/src/forced_price_client.rs index fd166cdfd2d..a18c03fd8ca 100644 --- a/core/lib/external_price_api/src/forced_price_client.rs +++ b/core/lib/external_price_api/src/forced_price_client.rs @@ -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, diff --git a/core/lib/external_price_api/src/lib.rs b/core/lib/external_price_api/src/lib.rs index e86279dbe85..7a068f9b1cb 100644 --- a/core/lib/external_price_api/src/lib.rs +++ b/core/lib/external_price_api/src/lib.rs @@ -1,5 +1,7 @@ pub mod coingecko_api; pub mod forced_price_client; +#[cfg(test)] +mod tests; mod utils; use std::fmt; @@ -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; } diff --git a/core/lib/external_price_api/src/tests.rs b/core/lib/external_price_api/src/tests.rs new file mode 100644 index 00000000000..bb2af866cf5 --- /dev/null +++ b/core/lib/external_price_api/src/tests.rs @@ -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, +} + +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() +} diff --git a/core/lib/external_price_api/src/utils.rs b/core/lib/external_price_api/src/utils.rs index 879be44e173..4b8abc39dff 100644 --- a/core/lib/external_price_api/src/utils.rs +++ b/core/lib/external_price_api/src/utils.rs @@ -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" + ); + } } diff --git a/deny.toml b/deny.toml index 431ac30efc5..dc5a32c2c07 100644 --- a/deny.toml +++ b/deny.toml @@ -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`