diff --git a/Cargo.lock b/Cargo.lock index 4985af4d..5a683ac3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -159,7 +159,7 @@ dependencies = [ "derive_more 2.0.1", "foldhash", "hashbrown 0.15.5", - "indexmap 2.10.0", + "indexmap 2.11.0", "itoa", "k256", "keccak-asm", @@ -281,7 +281,7 @@ dependencies = [ "alloy-sol-macro-input", "const-hex", "heck", - "indexmap 2.10.0", + "indexmap 2.11.0", "proc-macro-error2", "proc-macro2", "quote", @@ -699,9 +699,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.9.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29" +checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" [[package]] name = "bitvec" @@ -915,9 +915,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.33" +version = "1.2.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ee0f8803222ba5a7e2777dd72ca451868909b1ac410621b676adf07280e9b5f" +checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" dependencies = [ "shlex", ] @@ -1493,7 +1493,10 @@ dependencies = [ name = "evm_rpc" version = "2.4.0" dependencies = [ + "alloy-primitives", + "alloy-rpc-types", "assert_matches", + "async-trait", "candid", "candid_parser", "canhttp", @@ -1501,6 +1504,7 @@ dependencies = [ "derive_more 2.0.1", "ethers-core", "ethnum", + "evm_rpc_client", "evm_rpc_types", "getrandom 0.2.16", "hex", @@ -1509,6 +1513,7 @@ dependencies = [ "ic-cdk", "ic-cdk-macros", "ic-crypto-test-utils-reproducible-rng", + "ic-error-types", "ic-ethereum-types", "ic-http-types", "ic-management-canister-types", @@ -1527,6 +1532,7 @@ dependencies = [ "sha2", "thiserror 2.0.16", "thousands", + "tokio", "tower", "tower-http", "url", @@ -1797,7 +1803,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.10.0", + "indexmap 2.11.0", "slab", "tokio", "tokio-util", @@ -2374,9 +2380,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" dependencies = [ "equivalent", "hashbrown 0.15.5", @@ -2394,9 +2400,9 @@ dependencies = [ [[package]] name = "io-uring" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ "bitflags", "cfg-if", @@ -2524,7 +2530,7 @@ dependencies = [ "petgraph", "pico-args", "regex", - "regex-syntax 0.8.5", + "regex-syntax 0.8.6", "string_cache", "term", "tiny-keccak", @@ -2538,7 +2544,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" dependencies = [ - "regex-automata 0.4.9", + "regex-automata 0.4.10", ] [[package]] @@ -2990,7 +2996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap 2.10.0", + "indexmap 2.11.0", ] [[package]] @@ -3192,7 +3198,7 @@ dependencies = [ "rand 0.9.2", "rand_chacha 0.9.0", "rand_xorshift", - "regex-syntax 0.8.5", + "regex-syntax 0.8.6", "rusty-fork", "tempfile", "unarray", @@ -3402,14 +3408,14 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata 0.4.10", + "regex-syntax 0.8.6", ] [[package]] @@ -3423,13 +3429,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax 0.8.6", ] [[package]] @@ -3440,9 +3446,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "reqwest" @@ -3977,7 +3983,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.10.0", + "indexmap 2.11.0", "schemars 0.9.0", "schemars 1.0.4", "serde", @@ -4526,7 +4532,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.11.0", "toml_datetime", "winnow", ] @@ -4743,13 +4749,14 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.5" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec961601b32b6f5d14ae8dabd35ff2ff2e2c6cb4c0e6641845ff105abe96d958" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -5174,9 +5181,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" -version = "0.7.12" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index dc06a54e..bf6eb012 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ inherits = "release" [dependencies] candid = { workspace = true } canlog = { workspace = true } -canhttp = { version = "0.2.0", features = ["json", "multi"] } +canhttp = { workspace = true } derive_more = { workspace = true } ethnum = { workspace = true } evm_rpc_types = { path = "evm_rpc_types" } @@ -50,15 +50,21 @@ zeroize = { version = "1.8", features = ["zeroize_derive"] } regex = "1.11" [dev-dependencies] +alloy-primitives = { workspace = true } +alloy-rpc-types = { workspace = true } assert_matches = { workspace = true } +async-trait = { workspace = true } candid_parser = { workspace = true } +evm_rpc_client = { path = "evm_rpc_client" } ic-crypto-test-utils-reproducible-rng = { git = "https://github.com/dfinity/ic", rev = "release-2024-09-26_01-31-base" } +ic-error-types = { workspace = true } ic-management-canister-types = { workspace = true } ic-test-utilities-load-wasm = { git = "https://github.com/dfinity/ic", rev = "release-2024-09-26_01-31-base" } maplit = "1" -pocket-ic = "9.0.0" +pocket-ic = { workspace = true } proptest = { workspace = true } rand = "0.8" +tokio = "1.44.1" [workspace.dependencies] alloy-primitives = "1.3.0" @@ -66,6 +72,7 @@ alloy-rpc-types = "1.0.23" assert_matches = "1.5.0" async-trait = "0.1.88" candid = { version = "0.10.13" } +canhttp = { version = "0.2.0", features = ["json", "multi"] } canlog = { version = "0.2.0", features = ["derive"] } candid_parser = { version = "0.1.4" } ciborium = "0.2.2" @@ -92,6 +99,7 @@ minicbor = { version = "1.0.0", features = ["alloc", "derive"] } num-bigint = "0.4.6" num-traits = "0.2.19" pin-project = "1.1.10" +pocket-ic = "9.0.0" proptest = "1.6.0" serde = "1.0" serde_json = "1.0" diff --git a/evm_rpc_client/src/lib.rs b/evm_rpc_client/src/lib.rs index a9fc55db..da9136c1 100644 --- a/evm_rpc_client/src/lib.rs +++ b/evm_rpc_client/src/lib.rs @@ -181,15 +181,14 @@ #[cfg(not(target_arch = "wasm32"))] pub mod fixtures; mod request; +mod runtime; use crate::request::{Request, RequestBuilder}; -use async_trait::async_trait; -use candid::utils::ArgumentEncoder; use candid::{CandidType, Principal}; use evm_rpc_types::{ConsensusStrategy, GetLogsArgs, RpcConfig, RpcServices}; -use ic_cdk::api::call::RejectionCode as IcCdkRejectionCode; use ic_error_types::RejectCode; use request::{GetLogsRequest, GetLogsRequestBuilder}; +pub use runtime::{IcRuntime, Runtime}; use serde::de::DeserializeOwned; use std::sync::Arc; @@ -203,42 +202,19 @@ use std::sync::Arc; /// ``` pub const EVM_RPC_CANISTER: Principal = Principal::from_slice(&[0, 0, 0, 0, 2, 48, 0, 204, 1, 1]); -/// Abstract the canister runtime so that the client code can be reused: -/// * in production using `ic_cdk`, -/// * in unit tests by mocking this trait, -/// * in integration tests by implementing this trait for `PocketIc`. -#[async_trait] -pub trait Runtime { - /// Defines how asynchronous inter-canister update calls are made. - async fn update_call( - &self, - id: Principal, - method: &str, - args: In, - cycles: u128, - ) -> Result - where - In: ArgumentEncoder + Send, - Out: CandidType + DeserializeOwned; - - /// Defines how asynchronous inter-canister query calls are made. - async fn query_call( - &self, - id: Principal, - method: &str, - args: In, - ) -> Result - where - In: ArgumentEncoder + Send, - Out: CandidType + DeserializeOwned; -} - /// Client to interact with the EVM RPC canister. #[derive(Debug)] pub struct EvmRpcClient { config: Arc>, } +impl EvmRpcClient { + /// Creates a [`ClientBuilder`] to configure a [`EvmRpcClient`]. + pub fn builder(runtime: R, evm_rpc_canister: Principal) -> ClientBuilder { + ClientBuilder::new(runtime, evm_rpc_canister) + } +} + impl Clone for EvmRpcClient { fn clone(&self) -> Self { Self { @@ -270,6 +246,14 @@ pub struct ClientBuilder { config: ClientConfig, } +impl Clone for ClientBuilder { + fn clone(&self) -> Self { + ClientBuilder { + config: self.config.clone(), + } + } +} + impl ClientBuilder { fn new(runtime: R, evm_rpc_canister: Principal) -> Self { Self { @@ -445,63 +429,3 @@ impl EvmRpcClient { .map(Into::into) } } - -/// Runtime when interacting with a canister running on the Internet Computer. -#[derive(Copy, Clone, Eq, PartialEq, Debug)] -pub struct IcRuntime; - -#[async_trait] -impl Runtime for IcRuntime { - async fn update_call( - &self, - id: Principal, - method: &str, - args: In, - cycles: u128, - ) -> Result - where - In: ArgumentEncoder + Send, - Out: CandidType + DeserializeOwned, - { - ic_cdk::api::call::call_with_payment128(id, method, args, cycles) - .await - .map(|(res,)| res) - .map_err(|(code, message)| (convert_reject_code(code), message)) - } - - async fn query_call( - &self, - id: Principal, - method: &str, - args: In, - ) -> Result - where - In: ArgumentEncoder + Send, - Out: CandidType + DeserializeOwned, - { - ic_cdk::api::call::call(id, method, args) - .await - .map(|(res,)| res) - .map_err(|(code, message)| (convert_reject_code(code), message)) - } -} - -fn convert_reject_code(code: IcCdkRejectionCode) -> RejectCode { - match code { - IcCdkRejectionCode::SysFatal => RejectCode::SysFatal, - IcCdkRejectionCode::SysTransient => RejectCode::SysTransient, - IcCdkRejectionCode::DestinationInvalid => RejectCode::DestinationInvalid, - IcCdkRejectionCode::CanisterReject => RejectCode::CanisterReject, - IcCdkRejectionCode::CanisterError => RejectCode::CanisterError, - IcCdkRejectionCode::Unknown => { - // This can only happen if there is a new error code on ICP that the CDK is not aware of. - // We map it to SysFatal since none of the other error codes apply. - // In particular, note that RejectCode::SysUnknown is only applicable to inter-canister - // calls that used ic0.call_with_best_effort_response. - RejectCode::SysFatal - } - IcCdkRejectionCode::NoError => { - unreachable!("inter-canister calls should never produce a RejectionCode::NoError error") - } - } -} diff --git a/evm_rpc_client/src/request/mod.rs b/evm_rpc_client/src/request/mod.rs index 47c36a1f..fc828953 100644 --- a/evm_rpc_client/src/request/mod.rs +++ b/evm_rpc_client/src/request/mod.rs @@ -42,26 +42,31 @@ pub type GetLogsRequestBuilder = RequestBuilder< impl GetLogsRequestBuilder { /// Change the `from_block` parameter for an `eth_getLogs` request. - pub fn with_from_block(mut self, from_block: BlockTag) -> Self { - self.request.params.from_block = Some(from_block); + pub fn with_from_block(mut self, from_block: impl Into) -> Self { + self.request.params.from_block = Some(from_block.into()); self } /// Change the `to_block` parameter for an `eth_getLogs` request. - pub fn with_to_block(mut self, to_block: BlockTag) -> Self { - self.request.params.to_block = Some(to_block); + pub fn with_to_block(mut self, to_block: impl Into) -> Self { + self.request.params.to_block = Some(to_block.into()); self } /// Change the `addresses` parameter for an `eth_getLogs` request. - pub fn with_addresses(mut self, addresses: Vec) -> Self { - self.request.params.addresses = addresses; + pub fn with_addresses(mut self, addresses: Vec>) -> Self { + self.request.params.addresses = addresses.into_iter().map(Into::into).collect(); self } /// Change the `topics` parameter for an `eth_getLogs` request. - pub fn with_topics(mut self, topics: Vec>) -> Self { - self.request.params.topics = Some(topics); + pub fn with_topics(mut self, topics: Vec>>) -> Self { + self.request.params.topics = Some( + topics + .into_iter() + .map(|array| array.into_iter().map(Into::into).collect()) + .collect(), + ); self } } diff --git a/evm_rpc_client/src/runtime/mod.rs b/evm_rpc_client/src/runtime/mod.rs new file mode 100644 index 00000000..2cd89d29 --- /dev/null +++ b/evm_rpc_client/src/runtime/mod.rs @@ -0,0 +1,96 @@ +use async_trait::async_trait; +use candid::utils::ArgumentEncoder; +use candid::{CandidType, Principal}; +use ic_cdk::api::call::RejectionCode as IcCdkRejectionCode; +use ic_error_types::RejectCode; +use serde::de::DeserializeOwned; + +/// Abstract the canister runtime so that the client code can be reused: +/// * in production using `ic_cdk`, +/// * in unit tests by mocking this trait, +/// * in integration tests by implementing this trait for `PocketIc`. +#[async_trait] +pub trait Runtime { + /// Defines how asynchronous inter-canister update calls are made. + async fn update_call( + &self, + id: Principal, + method: &str, + args: In, + cycles: u128, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned; + + /// Defines how asynchronous inter-canister query calls are made. + async fn query_call( + &self, + id: Principal, + method: &str, + args: In, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned; +} + +/// Runtime when interacting with a canister running on the Internet Computer. +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub struct IcRuntime; + +#[async_trait] +impl Runtime for IcRuntime { + async fn update_call( + &self, + id: Principal, + method: &str, + args: In, + cycles: u128, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + ic_cdk::api::call::call_with_payment128(id, method, args, cycles) + .await + .map(|(res,)| res) + .map_err(|(code, message)| (convert_reject_code(code), message)) + } + + async fn query_call( + &self, + id: Principal, + method: &str, + args: In, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + ic_cdk::api::call::call(id, method, args) + .await + .map(|(res,)| res) + .map_err(|(code, message)| (convert_reject_code(code), message)) + } +} + +fn convert_reject_code(code: IcCdkRejectionCode) -> RejectCode { + match code { + IcCdkRejectionCode::SysFatal => RejectCode::SysFatal, + IcCdkRejectionCode::SysTransient => RejectCode::SysTransient, + IcCdkRejectionCode::DestinationInvalid => RejectCode::DestinationInvalid, + IcCdkRejectionCode::CanisterReject => RejectCode::CanisterReject, + IcCdkRejectionCode::CanisterError => RejectCode::CanisterError, + IcCdkRejectionCode::Unknown => { + // This can only happen if there is a new error code on ICP that the CDK is not aware of. + // We map it to SysFatal since none of the other error codes apply. + // In particular, note that RejectCode::SysUnknown is only applicable to inter-canister + // calls that used ic0.call_with_best_effort_response. + RejectCode::SysFatal + } + IcCdkRejectionCode::NoError => { + unreachable!("inter-canister calls should never produce a RejectionCode::NoError error") + } + } +} diff --git a/src/http.rs b/src/http.rs index 1328574e..190cd544 100644 --- a/src/http.rs +++ b/src/http.rs @@ -135,12 +135,19 @@ where }) .on_error( |req_data: MetricData, error: &HttpClientError| match error { - HttpClientError::IcError(IcError { code, message: _ }) => { + HttpClientError::IcError(IcError { code, message }) => { add_metric_entry!( err_http_outcall, (req_data.method, req_data.host, LegacyRejectionCode::from(*code)), 1 ); + log!( + Priority::TraceHttp, + "IC Error for request with id `{}` with code `{}` and message `{}`", + req_data.request_id, + code, + message, + ); } HttpClientError::UnsuccessfulHttpResponse( FilterNonSuccessfulHttpResponseError::UnsuccessfulResponse(response), diff --git a/tests/mock_http_runtime/mock/json/mod.rs b/tests/mock_http_runtime/mock/json/mod.rs new file mode 100644 index 00000000..525d5aac --- /dev/null +++ b/tests/mock_http_runtime/mock/json/mod.rs @@ -0,0 +1,165 @@ +use crate::mock_http_runtime::mock::CanisterHttpRequestMatcher; +use canhttp::http::json::{Id, JsonRpcRequest}; +use pocket_ic::common::rest::{ + CanisterHttpHeader, CanisterHttpMethod, CanisterHttpReply, CanisterHttpRequest, + CanisterHttpResponse, +}; +use serde_json::Value; +use std::{collections::BTreeSet, str::FromStr}; +use url::{Host, Url}; + +#[derive(Clone, Debug)] +pub struct JsonRpcRequestMatcher { + pub method: String, + pub id: Option, + pub params: Option, + pub url: Option, + pub host: Option, + pub request_headers: Option>, + pub max_response_bytes: Option, +} + +impl JsonRpcRequestMatcher { + pub fn with_method(method: impl Into) -> Self { + Self { + method: method.into(), + id: None, + params: None, + url: None, + host: None, + request_headers: None, + max_response_bytes: None, + } + } + + pub fn with_params(self, params: impl Into) -> Self { + Self { + params: Some(params.into()), + ..self + } + } + + pub fn with_id(self, id: impl Into) -> Self { + Self { + id: Some(id.into()), + ..self + } + } + + pub fn with_max_response_bytes(self, max_response_bytes: impl Into) -> Self { + Self { + max_response_bytes: Some(max_response_bytes.into()), + ..self + } + } + + pub fn request_body(&self) -> JsonRpcRequest { + let mut request_body = + JsonRpcRequest::new(&self.method, self.params.clone().unwrap_or(Value::Null)); + if let Some(id) = &self.id { + request_body.set_id(id.clone()); + } + request_body + } +} + +impl CanisterHttpRequestMatcher for JsonRpcRequestMatcher { + fn matches(&self, request: &CanisterHttpRequest) -> bool { + let req_url = Url::from_str(&request.url).expect("BUG: invalid URL"); + if let Some(ref mock_url) = self.url { + if mock_url != &req_url { + return false; + } + } + if let Some(ref host) = self.host { + match req_url.host() { + Some(ref req_host) if req_host == host => {} + _ => return false, + } + } + if CanisterHttpMethod::POST != request.http_method { + return false; + } + if let Some(ref headers) = self.request_headers { + fn lower_case_header_name( + CanisterHttpHeader { name, value }: &CanisterHttpHeader, + ) -> CanisterHttpHeader { + CanisterHttpHeader { + name: name.to_lowercase(), + value: value.clone(), + } + } + let expected: BTreeSet<_> = headers.iter().map(lower_case_header_name).collect(); + let actual: BTreeSet<_> = request.headers.iter().map(lower_case_header_name).collect(); + if expected != actual { + return false; + } + } + match serde_json::from_slice(&request.body) { + Ok(actual_body) => { + if self.request_body() != actual_body { + return false; + } + } + // Not a JSON-RPC request + Err(_) => return false, + } + if let Some(max_response_bytes) = self.max_response_bytes { + if Some(max_response_bytes) != request.max_response_bytes { + return false; + } + } + true + } +} + +pub struct JsonRpcResponse { + pub status: u16, + pub headers: Vec, + pub body: Value, +} + +impl From for JsonRpcResponse { + fn from(body: Value) -> Self { + Self { + status: 200, + headers: vec![], + body, + } + } +} + +impl JsonRpcResponse { + pub fn with_id(mut self, id: impl Into) -> JsonRpcResponse { + self.body["id"] = serde_json::to_value(id.into()).expect("BUG: cannot serialize ID"); + self + } +} + +impl From<&Value> for JsonRpcResponse { + fn from(body: &Value) -> Self { + Self::from(body.clone()) + } +} + +impl From for JsonRpcResponse { + fn from(body: String) -> Self { + Self::from(Value::from_str(&body).expect("BUG: invalid JSON-RPC response")) + } +} + +impl From<&str> for JsonRpcResponse { + fn from(body: &str) -> Self { + Self::from(body.to_string()) + } +} + +impl From for CanisterHttpResponse { + fn from(response: JsonRpcResponse) -> Self { + CanisterHttpResponse::CanisterHttpReply(CanisterHttpReply { + status: response.status, + headers: response.headers, + body: serde_json::to_vec(&response.body).unwrap(), + }) + } +} diff --git a/tests/mock_http_runtime/mock/mod.rs b/tests/mock_http_runtime/mock/mod.rs new file mode 100644 index 00000000..cefaf40b --- /dev/null +++ b/tests/mock_http_runtime/mock/mod.rs @@ -0,0 +1,107 @@ +use pocket_ic::common::rest::{CanisterHttpRequest, CanisterHttpResponse}; +use std::fmt::Debug; + +pub mod json; + +#[derive(Debug, Default)] +pub struct MockHttpOutcalls(Vec); + +impl MockHttpOutcalls { + pub const NEVER: MockHttpOutcalls = Self(Vec::new()); + + pub fn push(&mut self, mock: MockHttpOutcall) { + self.0.push(mock); + } + + pub fn pop_matching(&mut self, request: &CanisterHttpRequest) -> Option { + let matching_positions = self + .0 + .iter() + .enumerate() + .filter_map(|(i, mock)| { + if mock.request.matches(request) { + Some(i) + } else { + None + } + }) + .collect::>(); + + match matching_positions.len() { + 0 => None, + 1 => Some(self.0.swap_remove(matching_positions[0])), + _ => panic!("Multiple mocks match the request: {:?}", request), + } + } +} + +impl Drop for MockHttpOutcalls { + fn drop(&mut self) { + if !self.0.is_empty() { + panic!( + "MockHttpOutcalls dropped but {} mocks were not consumed: {:?}", + self.0.len(), + self.0 + ); + } + } +} + +#[derive(Debug)] +#[must_use] +pub struct MockHttpOutcall { + pub request: Box, + pub response: CanisterHttpResponse, +} + +#[derive(Debug, Default)] +pub struct MockHttpOutcallsBuilder(MockHttpOutcalls); + +impl MockHttpOutcallsBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn given( + self, + request: impl CanisterHttpRequestMatcher + 'static, + ) -> MockHttpOutcallBuilder { + MockHttpOutcallBuilder { + parent: self, + request: Box::new(request), + } + } + + pub fn build(self) -> MockHttpOutcalls { + self.0 + } +} + +impl From for MockHttpOutcalls { + fn from(builder: MockHttpOutcallsBuilder) -> Self { + builder.build() + } +} + +#[must_use] +pub struct MockHttpOutcallBuilder { + parent: MockHttpOutcallsBuilder, + request: Box, +} + +impl MockHttpOutcallBuilder { + pub fn respond_with( + mut self, + response: impl Into, + ) -> MockHttpOutcallsBuilder { + self.parent.0.push(MockHttpOutcall { + request: self.request, + response: response.into(), + }); + self.parent + } +} + +pub trait CanisterHttpRequestMatcher: Send + Debug { + fn matches(&self, request: &CanisterHttpRequest) -> bool; +} diff --git a/tests/mock_http_runtime/mod.rs b/tests/mock_http_runtime/mod.rs new file mode 100644 index 00000000..a44831b3 --- /dev/null +++ b/tests/mock_http_runtime/mod.rs @@ -0,0 +1,167 @@ +pub mod mock; + +use crate::MAX_TICKS; +use async_trait::async_trait; +use candid::{decode_args, utils::ArgumentEncoder, CandidType, Principal}; +use evm_rpc::constants::DEFAULT_MAX_RESPONSE_BYTES; +use evm_rpc_client::Runtime; +use ic_error_types::RejectCode; +use mock::MockHttpOutcalls; +use pocket_ic::{ + common::rest::{ + CanisterHttpReject, CanisterHttpRequest, CanisterHttpResponse, MockCanisterHttpResponse, + }, + nonblocking::PocketIc, + RejectResponse, +}; +use serde::de::DeserializeOwned; +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; + +pub struct MockHttpRuntime { + pub env: Arc, + pub caller: Principal, + pub mocks: Mutex, +} + +#[async_trait] +impl Runtime for MockHttpRuntime { + async fn update_call( + &self, + id: Principal, + method: &str, + args: In, + _cycles: u128, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + // Forward the call through the wallet canister to attach cycles + let message_id = self + .env + .submit_call(id, self.caller, method, encode_args(args)) + .await + .unwrap(); + self.execute_mocks().await; + self.env + .await_call(message_id) + .await + .map(decode_call_response) + .map_err(parse_reject_response)? + } + + async fn query_call( + &self, + id: Principal, + method: &str, + args: In, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + self.env + .query_call(id, self.caller, method, encode_args(args)) + .await + .map(decode_call_response) + .map_err(parse_reject_response)? + } +} + +impl MockHttpRuntime { + async fn execute_mocks(&self) { + loop { + let pending_requests = tick_until_http_requests(self.env.as_ref()).await; + if let Some(request) = pending_requests.first() { + let maybe_mock = { + let mut mocks = self.mocks.lock().unwrap(); + mocks.pop_matching(request) + }; + match maybe_mock { + Some(mock) => { + let mock_response = MockCanisterHttpResponse { + subnet_id: request.subnet_id, + request_id: request.request_id, + response: check_response_size(request, mock.response), + additional_responses: vec![], + }; + self.env.mock_canister_http_response(mock_response).await; + } + None => { + panic!("No mocks matching the request: {:?}", request); + } + } + } else { + return; + } + } + } +} + +fn check_response_size( + request: &CanisterHttpRequest, + response: CanisterHttpResponse, +) -> CanisterHttpResponse { + if let CanisterHttpResponse::CanisterHttpReply(reply) = &response { + let max_response_bytes = request + .max_response_bytes + .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES); + if reply.body.len() as u64 > max_response_bytes { + // Approximate replica behavior since headers are not accounted for. + return CanisterHttpResponse::CanisterHttpReject(CanisterHttpReject { + reject_code: RejectCode::SysFatal as u64, + message: format!("Http body exceeds size limit of {max_response_bytes} bytes.",), + }); + } + } + response +} + +fn parse_reject_response(response: RejectResponse) -> (RejectCode, String) { + use pocket_ic::RejectCode as PocketIcRejectCode; + let rejection_code = match response.reject_code { + PocketIcRejectCode::SysFatal => RejectCode::SysFatal, + PocketIcRejectCode::SysTransient => RejectCode::SysTransient, + PocketIcRejectCode::DestinationInvalid => RejectCode::DestinationInvalid, + PocketIcRejectCode::CanisterReject => RejectCode::CanisterReject, + PocketIcRejectCode::CanisterError => RejectCode::CanisterError, + PocketIcRejectCode::SysUnknown => RejectCode::SysUnknown, + }; + (rejection_code, response.reject_message) +} + +pub fn encode_args(args: In) -> Vec { + candid::encode_args(args).expect("Failed to encode arguments.") +} + +pub fn decode_call_response(bytes: Vec) -> Result +where + Out: CandidType + DeserializeOwned, +{ + decode_args(&bytes).map(|(res,)| res).map_err(|e| { + ( + RejectCode::CanisterError, + format!( + "failed to decode canister response as {}: {}", + std::any::type_name::(), + e + ), + ) + }) +} + +async fn tick_until_http_requests(env: &PocketIc) -> Vec { + let mut requests = Vec::new(); + for _ in 0..MAX_TICKS { + requests = env.get_canister_http().await; + if !requests.is_empty() { + break; + } + env.tick().await; + env.advance_time(Duration::from_nanos(1)).await; + } + requests +} diff --git a/tests/setup/mod.rs b/tests/setup/mod.rs new file mode 100644 index 00000000..d15cf991 --- /dev/null +++ b/tests/setup/mod.rs @@ -0,0 +1,117 @@ +use crate::mock_http_runtime::mock::MockHttpOutcalls; +use crate::mock_http_runtime::MockHttpRuntime; +use crate::{DEFAULT_CALLER_TEST_ID, DEFAULT_CONTROLLER_TEST_ID, INITIAL_CYCLES, MOCK_API_KEY}; +use candid::{Encode, Principal}; +use evm_rpc::providers::PROVIDERS; +use evm_rpc::types::{ProviderId, RpcAccess}; +use evm_rpc_client::{ClientBuilder, EvmRpcClient}; +use evm_rpc_types::InstallArgs; +use ic_cdk::api::management_canister::main::CanisterId; +use ic_management_canister_types::CanisterSettings; +use pocket_ic::{nonblocking, PocketIcBuilder}; +use std::sync::{Arc, Mutex}; + +#[derive(Clone)] +pub struct EvmRpcNonblockingSetup { + pub env: Arc, + pub caller: Principal, + pub controller: Principal, + pub canister_id: CanisterId, +} + +impl EvmRpcNonblockingSetup { + pub async fn new() -> Self { + Self::with_args(InstallArgs { + demo: Some(true), + ..Default::default() + }) + .await + } + + pub async fn with_args(args: InstallArgs) -> Self { + // The `with_fiduciary_subnet` setup below requires that `nodes_in_subnet` + // setting (part of InstallArgs) to be set appropriately. Otherwise + // http outcall will fail due to insufficient cycles, even when `demo` is + // enabled (which is the default above). + // + // As of writing, the default value of `nodes_in_subnet` is 34, which is + // also the node count in fiduciary subnet. + let pocket_ic = PocketIcBuilder::new() + .with_fiduciary_subnet() + .build_async() + .await; + let env = Arc::new(pocket_ic); + + let controller = DEFAULT_CONTROLLER_TEST_ID; + let canister_id = env + .create_canister_with_settings( + None, + Some(CanisterSettings { + controllers: Some(vec![controller]), + ..CanisterSettings::default() + }), + ) + .await; + env.add_cycles(canister_id, INITIAL_CYCLES).await; + env.install_canister( + canister_id, + crate::evm_rpc_wasm(), + Encode!(&args).unwrap(), + Some(controller), + ) + .await; + + let caller = DEFAULT_CALLER_TEST_ID; + + Self { + env, + caller, + controller, + canister_id, + } + } + + pub fn client(&self, mocks: impl Into) -> ClientBuilder { + EvmRpcClient::builder(self.new_mock_http_runtime(mocks.into()), self.canister_id) + } + + fn new_mock_http_runtime(&self, mocks: MockHttpOutcalls) -> MockHttpRuntime { + MockHttpRuntime { + env: self.env.clone(), + caller: self.caller, + mocks: Mutex::new(mocks), + } + } + + pub async fn update_api_keys(&self, api_keys: &[(ProviderId, Option)]) { + self.env + .update_call( + self.canister_id, + self.controller, + "updateApiKeys", + Encode!(&api_keys).expect("Failed to encode arguments."), + ) + .await + .expect("BUG: Failed to call updateApiKeys"); + } + + pub async fn mock_api_keys(self) -> Self { + self.clone() + .update_api_keys( + &PROVIDERS + .iter() + .filter_map(|provider| { + Some(( + provider.provider_id, + match provider.access { + RpcAccess::Authenticated { .. } => Some(MOCK_API_KEY.to_string()), + RpcAccess::Unauthenticated { .. } => None?, + }, + )) + }) + .collect::>(), + ) + .await; + self + } +} diff --git a/tests/tests.rs b/tests/tests.rs index 87a88330..d78d6b4a 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1,6 +1,17 @@ mod mock; - -use crate::mock::MockJsonRequestBody; +mod mock_http_runtime; +mod setup; + +use crate::{ + mock::MockJsonRequestBody, + mock_http_runtime::mock::{ + json::{JsonRpcRequestMatcher, JsonRpcResponse}, + MockHttpOutcalls, MockHttpOutcallsBuilder, + }, + setup::EvmRpcNonblockingSetup, +}; +use alloy_primitives::{address, b256, bytes}; +use alloy_rpc_types::BlockNumberOrTag; use assert_matches::assert_matches; use candid::{CandidType, Decode, Encode, Nat, Principal}; use canlog::{Log, LogEntry}; @@ -17,11 +28,9 @@ use evm_rpc_types::{ Nat256, Provider, ProviderError, RpcApi, RpcConfig, RpcError, RpcResult, RpcService, RpcServices, ValidationError, }; -use ic_cdk::api::call::RejectionCode; -use ic_cdk::api::management_canister::main::CanisterId; +use ic_cdk::api::{call::RejectionCode, management_canister::main::CanisterId}; use ic_http_types::{HttpRequest, HttpResponse}; -use ic_management_canister_types::CanisterSettings; -use ic_management_canister_types::HttpHeader; +use ic_management_canister_types::{CanisterSettings, HttpHeader}; use ic_test_utilities_load_wasm::load_wasm; use maplit::hashmap; use mock::{MockOutcall, MockOutcallBuilder}; @@ -32,8 +41,7 @@ use pocket_ic::common::rest::{ use pocket_ic::{ErrorCode, PocketIc, PocketIcBuilder, RejectResponse}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::json; -use std::sync::Arc; -use std::{marker::PhantomData, mem, str::FromStr, time::Duration}; +use std::{iter, marker::PhantomData, str::FromStr, sync::Arc, time::Duration}; const DEFAULT_CALLER_TEST_ID: Principal = Principal::from_slice(&[0x9d, 0xf7, 0x01]); const DEFAULT_CONTROLLER_TEST_ID: Principal = Principal::from_slice(&[0x9d, 0xf7, 0x02]); @@ -234,15 +242,6 @@ impl EvmRpcSetup { ) } - pub fn eth_get_logs( - &self, - source: RpcServices, - config: Option, - args: evm_rpc_types::GetLogsArgs, - ) -> CallFlow>> { - self.call_update("eth_getLogs", Encode!(&source, &config, &args).unwrap()) - } - pub fn eth_get_block_by_number( &self, source: RpcServices, @@ -665,87 +664,107 @@ fn should_decode_transaction_receipt() { ); } -#[test] -fn eth_get_logs_should_succeed() { - fn mock_responses() -> [serde_json::Value; 3] { - json_rpc_sequential_id( - json!({"id":0,"jsonrpc":"2.0","result":[{"address":"0xdac17f958d2ee523a2206206994597c13d831ec7","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x000000000000000000000000a9d1e08c7793af67e9d92fe308d5697fb81d3e43","0x00000000000000000000000078cccfb3d517cd4ed6d045e263e134712288ace2"],"data":"0x000000000000000000000000000000000000000000000000000000003b9c6433","blockNumber":"0x11dc77e","transactionHash":"0xf3ed91a03ddf964281ac7a24351573efd535b80fc460a5c2ad2b9d23153ec678","transactionIndex":"0x65","blockHash":"0xd5c72ad752b2f0144a878594faf8bd9f570f2f72af8e7f0940d3545a6388f629","logIndex":"0xe8","removed":false}]}), - ) +#[tokio::test] +async fn eth_get_logs_should_succeed() { + fn mock_request( + from_block: BlockNumberOrTag, + to_block: BlockNumberOrTag, + ) -> JsonRpcRequestMatcher { + JsonRpcRequestMatcher::with_method("eth_getLogs").with_params(json!([{ + "address" : ["0xdac17f958d2ee523a2206206994597c13d831ec7"], + "fromBlock" : from_block, + "toBlock" : to_block, + }])) } - fn expected_logs() -> Vec { - vec![evm_rpc_types::LogEntry { - address: "0xdac17f958d2ee523a2206206994597c13d831ec7" - .parse() - .unwrap(), - topics: vec![ - "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", - "0x000000000000000000000000a9d1e08c7793af67e9d92fe308d5697fb81d3e43", - "0x00000000000000000000000078cccfb3d517cd4ed6d045e263e134712288ace2", + fn mock_response() -> JsonRpcResponse { + JsonRpcResponse::from(json!({ + "id" : 0, + "jsonrpc" : "2.0", + "result" : [ + { + "address" : "0xdac17f958d2ee523a2206206994597c13d831ec7", + "topics" : [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000a9d1e08c7793af67e9d92fe308d5697fb81d3e43", + "0x00000000000000000000000078cccfb3d517cd4ed6d045e263e134712288ace2" + ], + "data" : "0x000000000000000000000000000000000000000000000000000000003b9c6433", + "blockNumber" : "0x11dc77e", + "transactionHash" : "0xf3ed91a03ddf964281ac7a24351573efd535b80fc460a5c2ad2b9d23153ec678", + "transactionIndex" : "0x65", + "blockHash" : "0xd5c72ad752b2f0144a878594faf8bd9f570f2f72af8e7f0940d3545a6388f629", + "logIndex" : "0xe8", + "removed" : false + } ] - .into_iter() - .map(|hex| hex.parse().unwrap()) - .collect(), - data: "0x000000000000000000000000000000000000000000000000000000003b9c6433" - .parse() - .unwrap(), - block_number: Some(0x11dc77e_u32.into()), - transaction_hash: Some( + })) + } + + fn expected_logs() -> Vec { + vec![alloy_rpc_types::Log { + inner: alloy_primitives::Log::new( + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + vec![ + b256!("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"), + b256!("0x000000000000000000000000a9d1e08c7793af67e9d92fe308d5697fb81d3e43"), + b256!("0x00000000000000000000000078cccfb3d517cd4ed6d045e263e134712288ace2"), + ], + bytes!("0x000000000000000000000000000000000000000000000000000000003b9c6433"), + ) + .unwrap(), + block_number: Some(0x11dc77e_u64), + transaction_hash: Some(b256!( "0xf3ed91a03ddf964281ac7a24351573efd535b80fc460a5c2ad2b9d23153ec678" - .parse() - .unwrap(), - ), - transaction_index: Some(0x65_u32.into()), - block_hash: Some( + )), + transaction_index: Some(0x65_u64), + block_hash: Some(b256!( "0xd5c72ad752b2f0144a878594faf8bd9f570f2f72af8e7f0940d3545a6388f629" - .parse() - .unwrap(), - ), - log_index: Some(0xe8_u32.into()), + )), + log_index: Some(0xe8_u64), removed: false, + block_timestamp: None, }] } - let setup = EvmRpcSetup::new().mock_api_keys(); + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; let mut offset = 0_u64; for source in RPC_SERVICES { for (config, from_block, to_block) in [ // default block range ( - None, - Some(BlockTag::Number(0_u8.into())), - Some(BlockTag::Number(500_u16.into())), + GetLogsRpcConfig::default(), + BlockNumberOrTag::Number(0_u8.into()), + BlockNumberOrTag::Number(500_u16.into()), ), // large block range ( - Some(GetLogsRpcConfig { + GetLogsRpcConfig { max_block_range: Some(1_000), ..Default::default() - }), - Some(BlockTag::Number(0_u8.into())), - Some(BlockTag::Number(501_u16.into())), + }, + BlockNumberOrTag::Number(0_u8.into()), + BlockNumberOrTag::Number(501_u16.into()), ), ] { - let mut responses: [serde_json::Value; 3] = mock_responses(); - add_offset_json_rpc_id(responses.as_mut_slice(), offset); + let mocks = MockHttpOutcallsBuilder::new() + .given(mock_request(from_block, to_block).with_id(offset)) + .respond_with(mock_response().with_id(offset)) + .given(mock_request(from_block, to_block).with_id(1 + offset)) + .respond_with(mock_response().with_id(1 + offset)) + .given(mock_request(from_block, to_block).with_id(2 + offset)) + .respond_with(mock_response().with_id(2 + offset)); let response = setup - .eth_get_logs( - source.clone(), - config, - evm_rpc_types::GetLogsArgs { - addresses: vec!["0xdAC17F958D2ee523a2206206994597C13D831ec7" - .parse() - .unwrap()], - from_block, - to_block, - topics: None, - }, - ) - .mock_http_once(MockOutcallBuilder::new(200, responses[0].clone())) - .mock_http_once(MockOutcallBuilder::new(200, responses[1].clone())) - .mock_http_once(MockOutcallBuilder::new(200, responses[2].clone())) - .wait() + .client(mocks) + .with_rpc_sources(source.clone()) + .build() + .get_logs(vec![address!("0xdac17f958d2ee523a2206206994597c13d831ec7")]) + .with_from_block(from_block) + .with_to_block(to_block) + .with_rpc_config(config) + .send() + .await .expect_consistent() .unwrap(); offset += 3; @@ -755,9 +774,9 @@ fn eth_get_logs_should_succeed() { } } -#[test] -fn eth_get_logs_should_fail_when_block_range_too_large() { - let setup = EvmRpcSetup::new().mock_api_keys(); +#[tokio::test] +async fn eth_get_logs_should_fail_when_block_range_too_large() { + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; let error_msg_regex = regex::Regex::new("Requested [0-9_]+ blocks; limited to [0-9_]+").unwrap(); @@ -765,34 +784,32 @@ fn eth_get_logs_should_fail_when_block_range_too_large() { for (config, from_block, to_block) in [ // default block range ( - None, - Some(BlockTag::Number(0_u8.into())), - Some(BlockTag::Number(501_u16.into())), + GetLogsRpcConfig::default(), + BlockTag::Number(0_u8.into()), + BlockTag::Number(501_u16.into()), ), // large block range ( - Some(GetLogsRpcConfig { + GetLogsRpcConfig { max_block_range: Some(1_000), ..Default::default() - }), - Some(BlockTag::Number(0_u8.into())), - Some(BlockTag::Number(1001_u16.into())), + }, + BlockTag::Number(0_u8.into()), + BlockTag::Number(1001_u16.into()), ), ] { - let response = setup - .eth_get_logs( - source.clone(), - config, - evm_rpc_types::GetLogsArgs { - addresses: vec!["0xdAC17F958D2ee523a2206206994597C13D831ec7" - .parse() - .unwrap()], - from_block, - to_block, - topics: None, - }, - ) - .wait() + let client = setup + .client(MockHttpOutcalls::NEVER) + .with_rpc_sources(source.clone()) + .build(); + + let response = client + .get_logs(vec![address!("0xdAC17F958D2ee523a2206206994597C13D831ec7")]) + .with_from_block(from_block) + .with_to_block(to_block) + .with_rpc_config(config) + .send() + .await .expect_consistent() .unwrap_err(); @@ -1836,32 +1853,36 @@ fn candid_rpc_should_recognize_rate_limit() { ); } -#[test] -fn should_use_custom_response_size_estimate() { - let setup = EvmRpcSetup::new().mock_api_keys(); +#[tokio::test] +async fn should_use_custom_response_size_estimate() { + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; let max_response_bytes = 1234; let expected_response = r#"{"id":0,"jsonrpc":"2.0","result":[{"address":"0xdac17f958d2ee523a2206206994597c13d831ec7","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x000000000000000000000000a9d1e08c7793af67e9d92fe308d5697fb81d3e43","0x00000000000000000000000078cccfb3d517cd4ed6d045e263e134712288ace2"],"data":"0x000000000000000000000000000000000000000000000000000000003b9c6433","blockNumber":"0x11dc77e","transactionHash":"0xf3ed91a03ddf964281ac7a24351573efd535b80fc460a5c2ad2b9d23153ec678","transactionIndex":"0x65","blockHash":"0xd5c72ad752b2f0144a878594faf8bd9f570f2f72af8e7f0940d3545a6388f629","logIndex":"0xe8","removed":false}]}"#; - let response = setup - .eth_get_logs( - RpcServices::EthMainnet(Some(vec![EthMainnetService::Cloudflare])), - Some(evm_rpc_types::GetLogsRpcConfig { - response_size_estimate: Some(max_response_bytes), - ..Default::default() - }), - evm_rpc_types::GetLogsArgs { - addresses: vec!["0xdAC17F958D2ee523a2206206994597C13D831ec7" - .parse() - .unwrap()], - from_block: None, - to_block: None, - topics: None, - }, - ) - .mock_http_once( - MockOutcallBuilder::new(200, expected_response) + + let mocks = MockHttpOutcallsBuilder::new() + .given( + JsonRpcRequestMatcher::with_method("eth_getLogs") + .with_id(0_u64) + .with_params(json!([{ + "address" : ["0xdac17f958d2ee523a2206206994597c13d831ec7"], + "fromBlock": "latest", + "toBlock": "latest", + }])) .with_max_response_bytes(max_response_bytes), ) - .wait() + .respond_with(JsonRpcResponse::from(expected_response)); + + let client = setup + .client(mocks) + .with_rpc_sources(RpcServices::EthMainnet(Some(vec![ + EthMainnetService::Cloudflare, + ]))) + .with_response_size_estimate(max_response_bytes) + .build(); + let response = client + .get_logs(vec![address!("0xdAC17F958D2ee523a2206206994597C13D831ec7")]) + .send() + .await .expect_consistent(); assert_matches!(response, Ok(_)); } @@ -2247,43 +2268,43 @@ fn should_retrieve_logs() { .contains("Updating API keys")); } -#[test] -fn should_retry_when_response_too_large() { - let setup = EvmRpcSetup::new().mock_api_keys(); +#[tokio::test] +async fn should_retry_when_response_too_large() { + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; + + let rpc_services = RpcServices::EthMainnet(Some(vec![EthMainnetService::Cloudflare])); + // around 600 bytes per log // we need at least 3334 logs to reach the 2MB limit - let large_amount_of_logs: [serde_json::Value; 12] = - json_rpc_sequential_id(multi_logs_for_single_transaction(3_500)); - let mut mocks = MockOutcallBuilder::new_array(200, large_amount_of_logs); + let response_body = multi_logs_for_single_transaction(3_500); + let max_response_bytes = iter::once(1_u64) + .chain((1..=10).map(|i| 1024_u64 << i)) + .chain(iter::once(2_000_000_u64)); + + let mut mocks = MockHttpOutcallsBuilder::new(); + for (id, max_response_bytes) in max_response_bytes.enumerate() { + mocks = mocks + .given( + JsonRpcRequestMatcher::with_method("eth_getLogs") + .with_id(id as u64) + .with_params(json!([{ + "address" : ["0xdac17f958d2ee523a2206206994597c13d831ec7"], + "fromBlock": "latest", + "toBlock": "latest", + }])) + .with_max_response_bytes(max_response_bytes), + ) + .respond_with(JsonRpcResponse::from(&response_body).with_id(id as u64)); + } + let response = setup - .eth_get_logs( - RpcServices::EthMainnet(Some(vec![EthMainnetService::Cloudflare])), - Some(evm_rpc_types::GetLogsRpcConfig { - response_size_estimate: Some(1), - ..Default::default() - }), - evm_rpc_types::GetLogsArgs { - addresses: vec!["0xdAC17F958D2ee523a2206206994597C13D831ec7" - .parse() - .unwrap()], - from_block: None, - to_block: None, - topics: None, - }, - ) - .mock_http_once(mem::take(&mut mocks[0]).with_max_response_bytes(1)) - .mock_http_once(mem::take(&mut mocks[1]).with_max_response_bytes(1024 << 1)) - .mock_http_once(mem::take(&mut mocks[2]).with_max_response_bytes(1024 << 2)) - .mock_http_once(mem::take(&mut mocks[3]).with_max_response_bytes(1024 << 3)) - .mock_http_once(mem::take(&mut mocks[4]).with_max_response_bytes(1024 << 4)) - .mock_http_once(mem::take(&mut mocks[5]).with_max_response_bytes(1024 << 5)) - .mock_http_once(mem::take(&mut mocks[6]).with_max_response_bytes(1024 << 6)) - .mock_http_once(mem::take(&mut mocks[7]).with_max_response_bytes(1024 << 7)) - .mock_http_once(mem::take(&mut mocks[8]).with_max_response_bytes(1024 << 8)) - .mock_http_once(mem::take(&mut mocks[9]).with_max_response_bytes(1024 << 9)) - .mock_http_once(mem::take(&mut mocks[10]).with_max_response_bytes(1024 << 10)) - .mock_http_once(mem::take(&mut mocks[11]).with_max_response_bytes(2_000_000)) - .wait() + .client(mocks) + .with_rpc_sources(rpc_services.clone()) + .with_response_size_estimate(1) + .build() + .get_logs(vec![address!("0xdAC17F958D2ee523a2206206994597C13D831ec7")]) + .send() + .await .expect_consistent(); assert_matches!( @@ -2292,38 +2313,33 @@ fn should_retry_when_response_too_large() { if code == LegacyRejectionCode::SysFatal && message.contains("body exceeds size limit") ); - let mut large_amount_of_logs: [serde_json::Value; 11] = - json_rpc_sequential_id(multi_logs_for_single_transaction(1_000)); - add_offset_json_rpc_id(large_amount_of_logs.as_mut_slice(), 12); - let mut mocks = MockOutcallBuilder::new_array(200, large_amount_of_logs); + let response_body = multi_logs_for_single_transaction(1_000); + let max_response_bytes = iter::once(1_u64).chain((1..=10).map(|i| 1024_u64 << i)); + + let mut mocks = MockHttpOutcallsBuilder::new(); + for (id, max_response_bytes) in max_response_bytes.enumerate() { + mocks = mocks + .given( + JsonRpcRequestMatcher::with_method("eth_getLogs") + .with_id(id as u64 + 12) + .with_params(json!([{ + "address" : ["0xdac17f958d2ee523a2206206994597c13d831ec7"], + "fromBlock": "latest", + "toBlock": "latest", + }])) + .with_max_response_bytes(max_response_bytes), + ) + .respond_with(JsonRpcResponse::from(&response_body).with_id(id as u64 + 12)); + } + let response = setup - .eth_get_logs( - RpcServices::EthMainnet(Some(vec![EthMainnetService::Cloudflare])), - Some(evm_rpc_types::GetLogsRpcConfig { - response_size_estimate: Some(1), - ..Default::default() - }), - evm_rpc_types::GetLogsArgs { - addresses: vec!["0xdAC17F958D2ee523a2206206994597C13D831ec7" - .parse() - .unwrap()], - from_block: None, - to_block: None, - topics: None, - }, - ) - .mock_http_once(mem::take(&mut mocks[0]).with_max_response_bytes(1)) - .mock_http_once(mem::take(&mut mocks[1]).with_max_response_bytes(1024 << 1)) - .mock_http_once(mem::take(&mut mocks[2]).with_max_response_bytes(1024 << 2)) - .mock_http_once(mem::take(&mut mocks[3]).with_max_response_bytes(1024 << 3)) - .mock_http_once(mem::take(&mut mocks[4]).with_max_response_bytes(1024 << 4)) - .mock_http_once(mem::take(&mut mocks[5]).with_max_response_bytes(1024 << 5)) - .mock_http_once(mem::take(&mut mocks[6]).with_max_response_bytes(1024 << 6)) - .mock_http_once(mem::take(&mut mocks[7]).with_max_response_bytes(1024 << 7)) - .mock_http_once(mem::take(&mut mocks[8]).with_max_response_bytes(1024 << 8)) - .mock_http_once(mem::take(&mut mocks[9]).with_max_response_bytes(1024 << 9)) - .mock_http_once(mem::take(&mut mocks[10]).with_max_response_bytes(1024 << 10)) - .wait() + .client(mocks) + .with_rpc_sources(rpc_services.clone()) + .with_response_size_estimate(1) + .build() + .get_logs(vec![address!("0xdAC17F958D2ee523a2206206994597C13D831ec7")]) + .send() + .await .expect_consistent(); assert_matches!(