From 84a074505e6d5fca2814085a89dc60a7d4b85f70 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 8 Sep 2025 17:03:47 +0200 Subject: [PATCH 01/12] XC-412: Add support for `eth_getTransactionCount` --- evm_rpc_client/src/lib.rs | 166 +++++++++++------------------- evm_rpc_client/src/request/mod.rs | 53 +++++++++- evm_rpc_types/src/request/mod.rs | 13 +++ evm_rpc_types/src/result/alloy.rs | 8 +- tests/mock_http_runtime/mod.rs | 7 +- tests/tests.rs | 61 ++++++----- 6 files changed, 176 insertions(+), 132 deletions(-) diff --git a/evm_rpc_client/src/lib.rs b/evm_rpc_client/src/lib.rs index 12d1b811..f205fb0b 100644 --- a/evm_rpc_client/src/lib.rs +++ b/evm_rpc_client/src/lib.rs @@ -31,63 +31,30 @@ //! actually send *more* cycles than required, since *unused cycles will be refunded*. //! //! ```rust -//! # // TODO XC-412: Use simpler example e.g. `eth_getBalance` -//! use alloy_primitives::{address, b256, bytes}; +//! use alloy_primitives::{address, U256}; +//! use alloy_rpc_types::BlockNumberOrTag; +//! use evm_rpc_client::EvmRpcClient; +//! use evm_rpc_types::{MultiRpcResult, Nat256}; //! use evm_rpc_client::EvmRpcClient; //! -//! # use evm_rpc_types::{Hex, Hex20, Hex32, MultiRpcResult}; -//! # use std::str::FromStr; +//! # use evm_rpc_types::{MultiRpcResult, Nat256}; //! # #[tokio::main] //! # async fn main() -> Result<(), Box> { //! let client = EvmRpcClient::builder_for_ic() -//! # .with_default_stub_response(MultiRpcResult::Consistent(Ok(vec![ -//! # evm_rpc_types::LogEntry { -//! # address: Hex20::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(), -//! # topics: vec![ -//! # Hex32::from_str("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef").unwrap(), -//! # Hex32::from_str("0x000000000000000000000000000000000004444c5dc75cb358380d2e3de08a90").unwrap(), -//! # Hex32::from_str("0x0000000000000000000000000000000aa232009084bd71a5797d089aa4edfad4").unwrap(), -//! # ], -//! # data: Hex::from_str("0x00000000000000000000000000000000000000000000000000000000cd566ae8").unwrap(), -//! # block_number: Some(0x161bd70_u64.into()), -//! # transaction_hash: Some(Hex32::from_str("0xfe5bc88d0818b66a67b0619b1b4d81bfe38029e3799c7f0eb86b33ca7dc4c811").unwrap()), -//! # transaction_index: Some(0x0_u64.into()), -//! # block_hash: Some(Hex32::from_str("0x0bbd9b12140e674cdd55e63539a25df8280a70cee3676c94d8e05fa5f868a914").unwrap()), -//! # log_index: Some(0x0_u64.into()), -//! # removed: false, -//! # } -//! # ]))) +//! # .with_default_stub_response(MultiRpcResult::Consistent(Ok(Nat256::from(1_u64)))) //! .build(); //! //! let result = client -//! .get_logs(vec![address!("0xdac17f958d2ee523a2206206994597c13d831ec7")]) -//! .with_cycles(10_000_000_000) +//! .get_transaction_count(( +//! address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), +//! BlockNumberOrTag::Latest, +//! )) +//! .with_cycles(20_000_000_000) //! .send() //! .await //! .expect_consistent(); //! -//! assert_eq!(result.unwrap().first(), Some( -//! &alloy_rpc_types::Log { -//! inner: alloy_primitives::Log { -//! address: address!("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"), -//! data: alloy_primitives::LogData::new( -//! vec![ -//! b256!("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"), -//! b256!("0x000000000000000000000000000000000004444c5dc75cb358380d2e3de08a90"), -//! b256!("0x0000000000000000000000000000000aa232009084bd71a5797d089aa4edfad4"), -//! ], -//! bytes!("0x00000000000000000000000000000000000000000000000000000000cd566ae8"), -//! ).unwrap(), -//! }, -//! block_hash: Some(b256!("0x0bbd9b12140e674cdd55e63539a25df8280a70cee3676c94d8e05fa5f868a914")), -//! block_number: Some(0x161bd70_u64), -//! block_timestamp: None, -//! transaction_hash: Some(b256!("0xfe5bc88d0818b66a67b0619b1b4d81bfe38029e3799c7f0eb86b33ca7dc4c811")), -//! transaction_index: Some(0x0_u64), -//! log_index: Some(0x0_u64), -//! removed: false, -//! }, -//! )); +//! assert_eq!(result, Ok(U256::ONE)); //! # Ok(()) //! # } //! ``` @@ -102,33 +69,16 @@ //! your application requires a higher threshold and more robustness with a 3-out-of-5 : //! //! ```rust -//! # // TODO XC-412: Use simpler example e.g. `eth_getBalance` -//! use alloy_primitives::{address, b256, bytes}; +//! use alloy_primitives::{address, U256}; +//! use alloy_rpc_types::BlockNumberOrTag; //! use evm_rpc_client::EvmRpcClient; -//! use evm_rpc_types::{ConsensusStrategy, GetLogsRpcConfig , RpcServices}; +//! use evm_rpc_types::{ConsensusStrategy, RpcServices}; //! -//! # use evm_rpc_types::{Hex, Hex20, Hex32, MultiRpcResult}; -//! # use std::str::FromStr; +//! # use evm_rpc_types::{MultiRpcResult, Nat256}; //! # #[tokio::main] //! # async fn main() -> Result<(), Box> { //! let client = EvmRpcClient::builder_for_ic() -//! # .with_default_stub_response(MultiRpcResult::Consistent(Ok(vec![ -//! # evm_rpc_types::LogEntry { -//! # address: Hex20::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(), -//! # topics: vec![ -//! # Hex32::from_str("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef").unwrap(), -//! # Hex32::from_str("0x000000000000000000000000000000000004444c5dc75cb358380d2e3de08a90").unwrap(), -//! # Hex32::from_str("0x0000000000000000000000000000000aa232009084bd71a5797d089aa4edfad4").unwrap(), -//! # ], -//! # data: Hex::from_str("0x00000000000000000000000000000000000000000000000000000000cd566ae8").unwrap(), -//! # block_number: Some(0x161bd70_u64.into()), -//! # transaction_hash: Some(Hex32::from_str("0xfe5bc88d0818b66a67b0619b1b4d81bfe38029e3799c7f0eb86b33ca7dc4c811").unwrap()), -//! # transaction_index: Some(0x0_u64.into()), -//! # block_hash: Some(Hex32::from_str("0x0bbd9b12140e674cdd55e63539a25df8280a70cee3676c94d8e05fa5f868a914").unwrap()), -//! # log_index: Some(0x0_u64.into()), -//! # removed: false, -//! # } -//! # ]))) +//! # .with_default_stub_response(MultiRpcResult::Consistent(Ok(Nat256::from(1_u64)))) //! .with_rpc_sources(RpcServices::EthMainnet(None)) //! .with_consensus_strategy(ConsensusStrategy::Threshold { //! total: Some(3), @@ -137,40 +87,16 @@ //! .build(); //! //! let result = client -//! .get_logs(vec![address!("0xdac17f958d2ee523a2206206994597c13d831ec7")]) -//! .with_rpc_config(GetLogsRpcConfig { -//! response_consensus: Some(ConsensusStrategy::Threshold { -//! total: Some(5), -//! min: 3, -//! }), -//! ..Default::default() -//! }) +//! .get_transaction_count(( +//! address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), +//! BlockNumberOrTag::Latest, +//! )) +//! .with_cycles(20_000_000_000) //! .send() //! .await //! .expect_consistent(); //! -//! assert_eq!(result.unwrap().first(), Some( -//! &alloy_rpc_types::Log { -//! inner: alloy_primitives::Log { -//! address: address!("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"), -//! data: alloy_primitives::LogData::new( -//! vec![ -//! b256!("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"), -//! b256!("0x000000000000000000000000000000000004444c5dc75cb358380d2e3de08a90"), -//! b256!("0x0000000000000000000000000000000aa232009084bd71a5797d089aa4edfad4"), -//! ], -//! bytes!("0x00000000000000000000000000000000000000000000000000000000cd566ae8"), -//! ).unwrap(), -//! }, -//! block_hash: Some(b256!("0x0bbd9b12140e674cdd55e63539a25df8280a70cee3676c94d8e05fa5f868a914")), -//! block_number: Some(0x161bd70_u64), -//! block_timestamp: None, -//! transaction_hash: Some(b256!("0xfe5bc88d0818b66a67b0619b1b4d81bfe38029e3799c7f0eb86b33ca7dc4c811")), -//! transaction_index: Some(0x0_u64), -//! log_index: Some(0x0_u64), -//! removed: false, -//! }, -//! )); +//! assert_eq!(result, Ok(U256::ONE)); //! # Ok(()) //! # } //! ``` @@ -183,14 +109,9 @@ pub mod fixtures; mod request; mod runtime; -use crate::request::{ - FeeHistoryRequest, FeeHistoryRequestBuilder, GetBlockByNumberRequest, - GetBlockByNumberRequestBuilder, Request, RequestBuilder, -}; +use crate::request::{FeeHistoryRequest, FeeHistoryRequestBuilder, GetBlockByNumberRequest, GetBlockByNumberRequestBuilder, GetTransactionCountRequest, GetTransactionCountRequestBuilder, Request, RequestBuilder}; use candid::{CandidType, Principal}; -use evm_rpc_types::{ - BlockTag, ConsensusStrategy, FeeHistoryArgs, GetLogsArgs, RpcConfig, RpcServices, -}; +use evm_rpc_types::{BlockTag, ConsensusStrategy, FeeHistoryArgs, GetLogsArgs, GetTransactionCountArgs, RpcConfig, RpcServices}; use ic_error_types::RejectCode; use request::{GetLogsRequest, GetLogsRequestBuilder}; pub use runtime::{IcRuntime, Runtime}; @@ -529,6 +450,43 @@ impl EvmRpcClient { 10_000_000_000, ) } + + /// Call `eth_getTransactionCount` on the EVM RPC canister. + /// + /// # Examples + /// + /// ```rust + /// use alloy_primitives::{address, U256}; + /// use alloy_rpc_types::BlockNumberOrTag; + /// use evm_rpc_client::EvmRpcClient; + /// + /// # use evm_rpc_types::{MultiRpcResult, Nat256}; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let client = EvmRpcClient::builder_for_ic() + /// # .with_default_stub_response(MultiRpcResult::Consistent(Ok(Nat256::from(1_u64)))) + /// .build(); + /// + /// let result = client + /// .get_transaction_count(( + /// address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + /// BlockNumberOrTag::Latest, + /// )) + /// .send() + /// .await + /// .expect_consistent(); + /// + /// assert_eq!(result, Ok(U256::ONE)); + /// # Ok(()) + /// # } + /// ``` + pub fn get_transaction_count(&self, params: impl Into) -> GetTransactionCountRequestBuilder { + RequestBuilder::new( + self.clone(), + GetTransactionCountRequest::new(params.into()), + 10_000_000_000, + ) + } } impl EvmRpcClient { diff --git a/evm_rpc_client/src/request/mod.rs b/evm_rpc_client/src/request/mod.rs index e4fc1b5c..4febf2ad 100644 --- a/evm_rpc_client/src/request/mod.rs +++ b/evm_rpc_client/src/request/mod.rs @@ -1,8 +1,8 @@ use crate::{EvmRpcClient, Runtime}; use candid::CandidType; use evm_rpc_types::{ - BlockTag, FeeHistoryArgs, GetLogsArgs, GetLogsRpcConfig, Hex20, Hex32, MultiRpcResult, Nat256, - RpcConfig, RpcServices, + BlockTag, FeeHistoryArgs, GetLogsArgs, GetLogsRpcConfig, GetTransactionCountArgs, Hex20, Hex32, + MultiRpcResult, Nat256, RpcConfig, RpcServices, }; use ic_error_types::RejectCode; use serde::de::DeserializeOwned; @@ -156,6 +156,52 @@ impl GetLogsRequestBuilder { } } +#[derive(Debug, Clone)] +pub struct GetTransactionCountRequest(GetTransactionCountArgs); + +impl GetTransactionCountRequest { + pub fn new(params: GetTransactionCountArgs) -> Self { + Self(params) + } +} + +impl EvmRpcRequest for GetTransactionCountRequest { + type Config = RpcConfig; + type Params = GetTransactionCountArgs; + type CandidOutput = MultiRpcResult; + type Output = MultiRpcResult; + + fn endpoint(&self) -> EvmRpcEndpoint { + EvmRpcEndpoint::GetTransactionCount + } + + fn params(self) -> Self::Params { + self.0 + } +} + +pub type GetTransactionCountRequestBuilder = RequestBuilder< + R, + RpcConfig, + GetTransactionCountArgs, + MultiRpcResult, + MultiRpcResult, +>; + +impl GetTransactionCountRequestBuilder { + /// Change the `address` parameter for an `eth_getTransactionCount` request. + pub fn with_address(mut self, address: impl Into) -> Self { + self.request.params.address = address.into(); + self + } + + /// Change the `block` parameter for an `eth_getTransactionCount` request. + pub fn with_block(mut self, block: impl Into) -> Self { + self.request.params.block = block.into(); + self + } +} + /// Ethereum RPC endpoint supported by the EVM RPC canister. pub trait EvmRpcRequest { /// Type of RPC config for that request. @@ -183,6 +229,8 @@ pub enum EvmRpcEndpoint { GetBlockByNumber, /// `eth_getLogs` endpoint. GetLogs, + /// `eth_getTransactionCount` endpoint. + GetTransactionCount, } impl EvmRpcEndpoint { @@ -192,6 +240,7 @@ impl EvmRpcEndpoint { Self::FeeHistory => "eth_feeHistory", Self::GetBlockByNumber => "eth_getBlockByNumber", Self::GetLogs => "eth_getLogs", + Self::GetTransactionCount => "eth_getTransactionCount", } } } diff --git a/evm_rpc_types/src/request/mod.rs b/evm_rpc_types/src/request/mod.rs index d6216c54..7596be29 100644 --- a/evm_rpc_types/src/request/mod.rs +++ b/evm_rpc_types/src/request/mod.rs @@ -75,6 +75,19 @@ pub struct GetTransactionCountArgs { pub block: BlockTag, } +impl From<(T, U)> for GetTransactionCountArgs +where + T: Into, + U: Into, +{ + fn from((address, block): (T, U)) -> Self { + Self { + address: address.into(), + block: block.into(), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] pub struct CallArgs { pub transaction: TransactionRequest, diff --git a/evm_rpc_types/src/result/alloy.rs b/evm_rpc_types/src/result/alloy.rs index a1fc4ba8..f4e0aff9 100644 --- a/evm_rpc_types/src/result/alloy.rs +++ b/evm_rpc_types/src/result/alloy.rs @@ -1,4 +1,4 @@ -use crate::{Block, FeeHistory, LogEntry, MultiRpcResult}; +use crate::{Block, FeeHistory, LogEntry, MultiRpcResult, Nat256}; impl From>> for MultiRpcResult> { fn from(result: MultiRpcResult>) -> Self { @@ -21,3 +21,9 @@ impl From> for MultiRpcResult> for MultiRpcResult { + fn from(result: MultiRpcResult) -> Self { + result.map(alloy_primitives::U256::from) + } +} diff --git a/tests/mock_http_runtime/mod.rs b/tests/mock_http_runtime/mod.rs index a44831b3..5c4e562b 100644 --- a/tests/mock_http_runtime/mod.rs +++ b/tests/mock_http_runtime/mod.rs @@ -19,6 +19,7 @@ use std::{ sync::{Arc, Mutex}, time::Duration, }; +use serde_json::Value; pub struct MockHttpRuntime { pub env: Arc, @@ -91,7 +92,11 @@ impl MockHttpRuntime { self.env.mock_canister_http_response(mock_response).await; } None => { - panic!("No mocks matching the request: {:?}", request); + let CanisterHttpRequest { ref body, .. } = request; + if let Ok(body) = serde_json::from_slice::(body) { + panic!("No mocks matching the request: {request:?} with body {body:?}"); + } + panic!("No mocks matching the request: {request:?}"); } } } else { diff --git a/tests/tests.rs b/tests/tests.rs index 09010d10..fe6ab566 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -10,7 +10,7 @@ use crate::{ }, setup::EvmRpcNonblockingSetup, }; -use alloy_primitives::{address, b256, bloom, bytes}; +use alloy_primitives::{address, b256, bloom, bytes, U256}; use alloy_rpc_types::{BlockNumberOrTag, BlockTransactions}; use assert_matches::assert_matches; use candid::{CandidType, Decode, Encode, Nat, Principal}; @@ -1133,30 +1133,43 @@ fn eth_get_transaction_receipt_should_succeed() { } } -#[test] -fn eth_get_transaction_count_should_succeed() { - let [response_0, response_1, response_2] = - json_rpc_sequential_id(json!({"jsonrpc":"2.0","id":0,"result":"0x1"})); - for source in RPC_SERVICES { - let setup = EvmRpcSetup::new().mock_api_keys(); +#[tokio::test] +async fn eth_get_transaction_count_should_succeed() { + fn mock_request() -> JsonRpcRequestMatcher { + JsonRpcRequestMatcher::with_method("eth_getTransactionCount").with_params(json!([ + "0xdac17f958d2ee523a2206206994597c13d831ec7", + "latest" + ])) + } + + fn mock_response() -> JsonRpcResponse { + JsonRpcResponse::from(json!({ "jsonrpc" : "2.0", "id" : 0, "result" : "0x1" })) + } + + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; + + for (source, offset) in iter::zip(RPC_SERVICES, (0_u64..).step_by(3)) { + let mocks = MockHttpOutcallsBuilder::new() + .given(mock_request().with_id(offset)) + .respond_with(mock_response().with_id(offset)) + .given(mock_request().with_id(offset + 1)) + .respond_with(mock_response().with_id(offset + 1)) + .given(mock_request().with_id(offset + 2)) + .respond_with(mock_response().with_id(offset + 2)); + let response = setup - .eth_get_transaction_count( - source.clone(), - None, - evm_rpc_types::GetTransactionCountArgs { - address: "0xdAC17F958D2ee523a2206206994597C13D831ec7" - .parse() - .unwrap(), - block: evm_rpc_types::BlockTag::Latest, - }, - ) - .mock_http_once(MockOutcallBuilder::new(200, response_0.clone())) - .mock_http_once(MockOutcallBuilder::new(200, response_1.clone())) - .mock_http_once(MockOutcallBuilder::new(200, response_2.clone())) - .wait() - .expect_consistent() - .unwrap(); - assert_eq!(response, 1_u8.into()); + .client(mocks) + .with_rpc_sources(source.clone()) + .build() + .get_transaction_count(( + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + BlockNumberOrTag::Latest, + )) + .send() + .await + .expect_consistent(); + + assert_eq!(response, Ok(U256::ONE)); } } From aaccc36da33afe73fd6d988967fb7a07b81415aa Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 8 Sep 2025 17:06:22 +0200 Subject: [PATCH 02/12] XC-412: Formatting --- evm_rpc_client/src/lib.rs | 16 +++++++++++++--- tests/mock_http_runtime/mod.rs | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/evm_rpc_client/src/lib.rs b/evm_rpc_client/src/lib.rs index f205fb0b..c73e7230 100644 --- a/evm_rpc_client/src/lib.rs +++ b/evm_rpc_client/src/lib.rs @@ -109,9 +109,16 @@ pub mod fixtures; mod request; mod runtime; -use crate::request::{FeeHistoryRequest, FeeHistoryRequestBuilder, GetBlockByNumberRequest, GetBlockByNumberRequestBuilder, GetTransactionCountRequest, GetTransactionCountRequestBuilder, Request, RequestBuilder}; +use crate::request::{ + FeeHistoryRequest, FeeHistoryRequestBuilder, GetBlockByNumberRequest, + GetBlockByNumberRequestBuilder, GetTransactionCountRequest, GetTransactionCountRequestBuilder, + Request, RequestBuilder, +}; use candid::{CandidType, Principal}; -use evm_rpc_types::{BlockTag, ConsensusStrategy, FeeHistoryArgs, GetLogsArgs, GetTransactionCountArgs, RpcConfig, RpcServices}; +use evm_rpc_types::{ + BlockTag, ConsensusStrategy, FeeHistoryArgs, GetLogsArgs, GetTransactionCountArgs, RpcConfig, + RpcServices, +}; use ic_error_types::RejectCode; use request::{GetLogsRequest, GetLogsRequestBuilder}; pub use runtime::{IcRuntime, Runtime}; @@ -480,7 +487,10 @@ impl EvmRpcClient { /// # Ok(()) /// # } /// ``` - pub fn get_transaction_count(&self, params: impl Into) -> GetTransactionCountRequestBuilder { + pub fn get_transaction_count( + &self, + params: impl Into, + ) -> GetTransactionCountRequestBuilder { RequestBuilder::new( self.clone(), GetTransactionCountRequest::new(params.into()), diff --git a/tests/mock_http_runtime/mod.rs b/tests/mock_http_runtime/mod.rs index 5c4e562b..8128e474 100644 --- a/tests/mock_http_runtime/mod.rs +++ b/tests/mock_http_runtime/mod.rs @@ -15,11 +15,11 @@ use pocket_ic::{ RejectResponse, }; use serde::de::DeserializeOwned; +use serde_json::Value; use std::{ sync::{Arc, Mutex}, time::Duration, }; -use serde_json::Value; pub struct MockHttpRuntime { pub env: Arc, From 6904c8548da04f5121fa5109cc75cc1b543485f6 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Tue, 9 Sep 2025 12:28:49 +0200 Subject: [PATCH 03/12] XC-412: Integration tests --- tests/mock_http_runtime/mock/json/mod.rs | 16 + tests/mock_http_runtime/mock/mod.rs | 24 ++ tests/setup/mod.rs | 14 + tests/tests.rs | 409 +++++++++++++---------- 4 files changed, 278 insertions(+), 185 deletions(-) diff --git a/tests/mock_http_runtime/mock/json/mod.rs b/tests/mock_http_runtime/mock/json/mod.rs index 525d5aac..bb4e8bbd 100644 --- a/tests/mock_http_runtime/mock/json/mod.rs +++ b/tests/mock_http_runtime/mock/json/mod.rs @@ -32,6 +32,13 @@ impl JsonRpcRequestMatcher { } } + pub fn with_host(self, host: &str) -> Self { + Self { + host: Some(Host::parse(host).expect("BUG: invalid host for a URL")), + ..self + } + } + pub fn with_params(self, params: impl Into) -> Self { Self { params: Some(params.into()), @@ -130,6 +137,15 @@ impl From for JsonRpcResponse { } impl JsonRpcResponse { + pub fn id(&self) -> Id { + match self.body.get("id") { + Some(id) => { + serde_json::from_value(id.clone()).expect("Unable to serialize response ID") + } + None => panic!("Response has no `id` field"), + } + } + 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 diff --git a/tests/mock_http_runtime/mock/mod.rs b/tests/mock_http_runtime/mock/mod.rs index cefaf40b..46a29224 100644 --- a/tests/mock_http_runtime/mock/mod.rs +++ b/tests/mock_http_runtime/mock/mod.rs @@ -1,4 +1,5 @@ use pocket_ic::common::rest::{CanisterHttpRequest, CanisterHttpResponse}; +use serde_json::Value; use std::fmt::Debug; pub mod json; @@ -105,3 +106,26 @@ impl MockHttpOutcallBuilder { pub trait CanisterHttpRequestMatcher: Send + Debug { fn matches(&self, request: &CanisterHttpRequest) -> bool; } + +pub struct CanisterHttpReply(pocket_ic::common::rest::CanisterHttpReply); + +impl CanisterHttpReply { + pub fn with_status(status: u16) -> Self { + Self(pocket_ic::common::rest::CanisterHttpReply { + status, + headers: vec![], + body: vec![], + }) + } + + pub fn with_body(mut self, body: impl Into) -> Self { + self.0.body = serde_json::to_vec(&body.into()).unwrap(); + self + } +} + +impl From for CanisterHttpResponse { + fn from(value: CanisterHttpReply) -> Self { + CanisterHttpResponse::CanisterHttpReply(value.0) + } +} diff --git a/tests/setup/mod.rs b/tests/setup/mod.rs index ba2e5029..19ca6017 100644 --- a/tests/setup/mod.rs +++ b/tests/setup/mod.rs @@ -5,6 +5,7 @@ use crate::{ }; use candid::{Decode, Encode, Principal}; use canlog::{Log, LogEntry}; +use evm_rpc::types::Metrics; use evm_rpc::{ logs::Priority, providers::PROVIDERS, @@ -147,4 +148,17 @@ impl EvmRpcNonblockingSetup { .expect("failed to parse EVM_RPC minter log") .entries } + + pub async fn get_metrics(&self) -> Metrics { + let response = self + .env + .query_call( + self.canister_id, + Principal::anonymous(), + "getMetrics", + Encode!().unwrap(), + ) + .await; + Decode!(&assert_reply(response), Metrics).unwrap() + } } diff --git a/tests/tests.rs b/tests/tests.rs index fe6ab566..5c75861b 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -6,7 +6,7 @@ use crate::{ mock::MockJsonRequestBody, mock_http_runtime::mock::{ json::{JsonRpcRequestMatcher, JsonRpcResponse}, - MockHttpOutcalls, MockHttpOutcallsBuilder, + CanisterHttpReply, MockHttpOutcalls, MockHttpOutcallsBuilder, }, setup::EvmRpcNonblockingSetup, }; @@ -1135,27 +1135,16 @@ fn eth_get_transaction_receipt_should_succeed() { #[tokio::test] async fn eth_get_transaction_count_should_succeed() { - fn mock_request() -> JsonRpcRequestMatcher { - JsonRpcRequestMatcher::with_method("eth_getTransactionCount").with_params(json!([ - "0xdac17f958d2ee523a2206206994597c13d831ec7", - "latest" - ])) - } - - fn mock_response() -> JsonRpcResponse { - JsonRpcResponse::from(json!({ "jsonrpc" : "2.0", "id" : 0, "result" : "0x1" })) - } - let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; for (source, offset) in iter::zip(RPC_SERVICES, (0_u64..).step_by(3)) { let mocks = MockHttpOutcallsBuilder::new() - .given(mock_request().with_id(offset)) - .respond_with(mock_response().with_id(offset)) - .given(mock_request().with_id(offset + 1)) - .respond_with(mock_response().with_id(offset + 1)) - .given(mock_request().with_id(offset + 2)) - .respond_with(mock_response().with_id(offset + 2)); + .given(get_transaction_count_request().with_id(offset)) + .respond_with(get_transaction_count_response().with_id(offset)) + .given(get_transaction_count_request().with_id(offset + 1)) + .respond_with(get_transaction_count_response().with_id(offset + 1)) + .given(get_transaction_count_request().with_id(offset + 2)) + .respond_with(get_transaction_count_response().with_id(offset + 2)); let response = setup .client(mocks) @@ -1581,128 +1570,148 @@ fn candid_rpc_should_return_inconsistent_results() { ); } -#[test] -fn candid_rpc_should_return_3_out_of_4_transaction_count() { - let setup = EvmRpcSetup::new().mock_api_keys(); - - fn eth_get_transaction_count_with_3_out_of_4( - setup: &EvmRpcSetup, - ) -> CallFlow> { - setup.eth_get_transaction_count( - RpcServices::EthMainnet(None), - Some(RpcConfig { - response_consensus: Some(ConsensusStrategy::Threshold { - total: Some(4), - min: 3, - }), - ..Default::default() - }), - evm_rpc_types::GetTransactionCountArgs { - address: "0xdAC17F958D2ee523a2206206994597C13D831ec7" - .parse() - .unwrap(), - block: evm_rpc_types::BlockTag::Latest, - }, +#[tokio::test] +async fn candid_rpc_should_return_3_out_of_4_transaction_count() { + fn get_transaction_count_response(result: u64) -> JsonRpcResponse { + JsonRpcResponse::from( + json!({ "jsonrpc" : "2.0", "id" : 0, "result" : format!("0x{result:x}") }), ) } - for successful_mocks in [ + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; + + async fn eth_get_transaction_count_with_3_out_of_4( + setup: &EvmRpcNonblockingSetup, + offset: u64, + [response0, response1, response2, response3]: [CanisterHttpResponse; 4], + ) -> MultiRpcResult { + let mocks = MockHttpOutcallsBuilder::new() + .given(get_transaction_count_request().with_id(offset)) + .respond_with(response0) + .given(get_transaction_count_request().with_id(offset + 1)) + .respond_with(response1) + .given(get_transaction_count_request().with_id(offset + 2)) + .respond_with(response2) + .given(get_transaction_count_request().with_id(offset + 3)) + .respond_with(response3); + + setup + .client(mocks) + .with_rpc_sources(RpcServices::EthMainnet(None)) + .with_consensus_strategy(ConsensusStrategy::Threshold { + total: Some(4), + min: 3, + }) + .build() + .get_transaction_count(( + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + BlockNumberOrTag::Latest, + )) + .send() + .await + } + + for (successful_mocks, offset) in [ [ - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":1,"result":"0x1"}"#), - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":2,"result":"0x1"}"#), - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":3,"result":"0x1"}"#), + get_transaction_count_response(1).with_id(0_u64).into(), + get_transaction_count_response(1).with_id(1_u64).into(), + get_transaction_count_response(1).with_id(2_u64).into(), + get_transaction_count_response(1).with_id(3_u64).into(), ], [ - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":4,"result":"0x1"}"#), - MockOutcallBuilder::new(500, r#"OFFLINE"#), - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":6,"result":"0x1"}"#), - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":7,"result":"0x1"}"#), + get_transaction_count_response(1).with_id(4_u64).into(), + CanisterHttpReply::with_status(500) + .with_body("OFFLINE") + .into(), + get_transaction_count_response(1).with_id(6_u64).into(), + get_transaction_count_response(1).with_id(7_u64).into(), ], [ - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":8,"result":"0x1"}"#), - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":9,"result":"0x1"}"#), - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":10,"result":"0x2"}"#), - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":11,"result":"0x1"}"#), + get_transaction_count_response(1).with_id(8_u64).into(), + get_transaction_count_response(1).with_id(9_u64).into(), + get_transaction_count_response(2).with_id(10_u64).into(), + get_transaction_count_response(1).with_id(11_u64).into(), ], - ] { - let result = eth_get_transaction_count_with_3_out_of_4(&setup) - .mock_http_once(successful_mocks[0].clone()) - .mock_http_once(successful_mocks[1].clone()) - .mock_http_once(successful_mocks[2].clone()) - .mock_http_once(successful_mocks[3].clone()) - .wait() + ] + .into_iter() + .zip((0_u64..).step_by(4)) + { + let result = eth_get_transaction_count_with_3_out_of_4(&setup, offset, successful_mocks) + .await .expect_consistent() .unwrap(); - assert_eq!(result, 1_u8.into()); + assert_eq!(result, U256::ONE); } - for error_mocks in [ + for (error_mocks, offset) in [ [ - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), - MockOutcallBuilder::new(500, r#"OFFLINE"#), - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x2"}"#), - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + get_transaction_count_response(1).with_id(12_u64).into(), + CanisterHttpReply::with_status(500) + .with_body("OFFLINE") + .into(), + get_transaction_count_response(2).into(), + get_transaction_count_response(1).with_id(15_u64).into(), ], [ - MockOutcallBuilder::new(403, r#"FORBIDDEN"#), - MockOutcallBuilder::new(500, r#"OFFLINE"#), - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + CanisterHttpReply::with_status(500) + .with_body("FORBIDDEN") + .into(), + CanisterHttpReply::with_status(500) + .with_body("OFFLINE") + .into(), + get_transaction_count_response(1).with_id(18_u64).into(), + get_transaction_count_response(1).with_id(19_u64).into(), ], [ - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x3"}"#), - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x2"}"#), - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + get_transaction_count_response(1).with_id(20_u64).into(), + get_transaction_count_response(3).with_id(21_u64).into(), + get_transaction_count_response(2).with_id(22_u64).into(), + get_transaction_count_response(1).with_id(23_u64).into(), ], - ] { - let result = eth_get_transaction_count_with_3_out_of_4(&setup) - .mock_http_once(error_mocks[0].clone()) - .mock_http_once(error_mocks[1].clone()) - .mock_http_once(error_mocks[2].clone()) - .mock_http_once(error_mocks[3].clone()) - .wait() + ] + .into_iter() + .zip((12_u64..).step_by(4)) + { + let result = eth_get_transaction_count_with_3_out_of_4(&setup, offset, error_mocks) + .await .expect_inconsistent(); assert_eq!(result.len(), 4); } } -#[test] -fn candid_rpc_should_return_inconsistent_results_with_error() { - let setup = EvmRpcSetup::new().mock_api_keys(); +#[tokio::test] +async fn candid_rpc_should_return_inconsistent_results_with_error() { + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; + + let mocks = MockHttpOutcallsBuilder::new() + .given(get_transaction_count_request().with_id(0_u64)) + .respond_with(get_transaction_count_response().with_id(0_u64)) + .given(get_transaction_count_request().with_id(1_u64)) + .respond_with(JsonRpcResponse::from(json!({"jsonrpc": "2.0", "id": 1, "error" : { "code": 123, "message": "Unexpected"} }))); + let result = setup - .eth_get_transaction_count( - RpcServices::EthMainnet(Some(vec![ - EthMainnetService::Alchemy, - EthMainnetService::Ankr, - ])), - None, - evm_rpc_types::GetTransactionCountArgs { - address: "0xdAC17F958D2ee523a2206206994597C13D831ec7" - .parse() - .unwrap(), - block: evm_rpc_types::BlockTag::Latest, - }, - ) - .mock_http_once(MockOutcallBuilder::new( - 200, - r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#, - )) - .mock_http_once(MockOutcallBuilder::new( - 200, - r#"{"jsonrpc":"2.0","id":1,"error":{"code":123,"message":"Unexpected"}}"#, + .client(mocks) + .with_rpc_sources(RpcServices::EthMainnet(Some(vec![ + EthMainnetService::Alchemy, + EthMainnetService::Ankr, + ]))) + .build() + .get_transaction_count(( + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + BlockNumberOrTag::Latest, )) - .wait() + .send() + .await .expect_inconsistent(); + assert_eq!( result, vec![ ( RpcService::EthMainnet(EthMainnetService::Alchemy), - Ok(1_u8.into()) + Ok(U256::ONE) ), ( RpcService::EthMainnet(EthMainnetService::Ankr), @@ -1715,7 +1724,7 @@ fn candid_rpc_should_return_inconsistent_results_with_error() { ); let rpc_method = || RpcMethod::EthGetTransactionCount.into(); assert_eq!( - setup.get_metrics(), + setup.get_metrics().await, Metrics { requests: hashmap! { (rpc_method(), ALCHEMY_ETH_MAINNET_HOSTNAME.into()) => 1, @@ -2630,98 +2639,128 @@ async fn should_log_request() { assert!(logs[1].message.contains("response for request with id `0`. Response with status 200 OK: JsonRpcResponse { jsonrpc: V2, id: Number(0), result: Ok(FeeHistory")); } -#[test] -fn should_change_default_provider_when_one_keep_failing() { - let [response_0, _response_1, response_2] = - json_rpc_sequential_id(json!({"jsonrpc":"2.0","id":0,"result":"0x1"})); - let setup = EvmRpcSetup::new().mock_api_keys(); +#[tokio::test] +async fn should_change_default_provider_when_one_keeps_failing() { + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; + let response = setup - .eth_get_transaction_count( - RpcServices::EthMainnet(None), - Some(RpcConfig { - response_consensus: Some(ConsensusStrategy::Threshold { - total: Some(3), - min: 2, - }), - ..Default::default() - }), - evm_rpc_types::GetTransactionCountArgs { - address: "0xdAC17F958D2ee523a2206206994597C13D831ec7" - .parse() - .unwrap(), - block: evm_rpc_types::BlockTag::Latest, - }, - ) - .mock_http_once(MockOutcallBuilder::new(200, response_0.clone()).with_host("rpc.ankr.com")) - .mock_http_once(MockOutcallBuilder::new(500, "error").with_host("ethereum.blockpi.network")) - .mock_http_once( - MockOutcallBuilder::new(200, response_2.clone()) - .with_host("ethereum-rpc.publicnode.com"), + .client( + MockHttpOutcallsBuilder::new() + .given( + get_transaction_count_request() + .with_id(0_u64) + .with_host(ANKR_HOSTNAME), + ) + .respond_with(get_transaction_count_response().with_id(0_u64)) + .given( + get_transaction_count_request() + .with_id(1_u64) + .with_host(BLOCKPI_ETH_HOSTNAME), + ) + .respond_with(CanisterHttpReply::with_status(500).with_body("Error!")) + .given( + get_transaction_count_request() + .with_id(2_u64) + .with_host(PUBLICNODE_ETH_MAINNET_HOSTNAME), + ) + .respond_with(get_transaction_count_response().with_id(2_u64)), ) - .wait() + .with_rpc_sources(RpcServices::EthMainnet(None)) + .with_consensus_strategy(ConsensusStrategy::Threshold { + total: Some(3), + min: 2, + }) + .build() + .get_transaction_count(( + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + BlockNumberOrTag::Latest, + )) + .send() + .await .expect_consistent() .unwrap(); - assert_eq!(response, 1_u8.into()); + assert_eq!(response, U256::ONE); - let [response_3, response_4] = - json_rpc_sequential_id(json!({"jsonrpc":"2.0","id":3,"result":"0x1"})); let response = setup - .eth_get_transaction_count( - RpcServices::EthMainnet(Some(vec![ - EthMainnetService::Ankr, - EthMainnetService::Alchemy, - ])), - Some(RpcConfig { - response_consensus: Some(ConsensusStrategy::Equality), - ..Default::default() - }), - evm_rpc_types::GetTransactionCountArgs { - address: "0xdAC17F958D2ee523a2206206994597C13D831ec7" - .parse() - .unwrap(), - block: evm_rpc_types::BlockTag::Latest, - }, - ) - .mock_http_once( - MockOutcallBuilder::new(200, response_3.clone()).with_host("eth-mainnet.g.alchemy.com"), + .client( + MockHttpOutcallsBuilder::new() + .given( + get_transaction_count_request() + .with_id(3_u64) + .with_host(ALCHEMY_ETH_MAINNET_HOSTNAME), + ) + .respond_with(get_transaction_count_response().with_id(3_u64)) + .given( + get_transaction_count_request() + .with_id(4_u64) + .with_host(ANKR_HOSTNAME), + ) + .respond_with(get_transaction_count_response().with_id(4_u64)), ) - .mock_http_once(MockOutcallBuilder::new(200, response_4.clone()).with_host("rpc.ankr.com")) - .wait() + .with_rpc_sources(RpcServices::EthMainnet(Some(vec![ + EthMainnetService::Ankr, + EthMainnetService::Alchemy, + ]))) + .with_consensus_strategy(ConsensusStrategy::Equality) + .build() + .get_transaction_count(( + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + BlockNumberOrTag::Latest, + )) + .send() + .await .expect_consistent() .unwrap(); - assert_eq!(response, 1_u8.into()); + assert_eq!(response, U256::ONE); - let [response_5, response_6, response_7] = - json_rpc_sequential_id(json!({"jsonrpc":"2.0","id":5,"result":"0x1"})); let response = setup - .eth_get_transaction_count( - RpcServices::EthMainnet(None), - Some(RpcConfig { - response_consensus: Some(ConsensusStrategy::Threshold { - total: Some(3), - min: 2, - }), - ..Default::default() - }), - evm_rpc_types::GetTransactionCountArgs { - address: "0xdAC17F958D2ee523a2206206994597C13D831ec7" - .parse() - .unwrap(), - block: evm_rpc_types::BlockTag::Latest, - }, - ) - .mock_http_once( - MockOutcallBuilder::new(200, response_5.clone()).with_host("eth-mainnet.g.alchemy.com"), - ) - .mock_http_once(MockOutcallBuilder::new(200, response_6.clone()).with_host("rpc.ankr.com")) - .mock_http_once( - MockOutcallBuilder::new(200, response_7.clone()) - .with_host("ethereum-rpc.publicnode.com"), + .client( + MockHttpOutcallsBuilder::new() + .given( + get_transaction_count_request() + .with_id(5_u64) + .with_host(ALCHEMY_ETH_MAINNET_HOSTNAME), + ) + .respond_with(get_transaction_count_response().with_id(5_u64)) + .given( + get_transaction_count_request() + .with_id(6_u64) + .with_host(ANKR_HOSTNAME), + ) + .respond_with(get_transaction_count_response().with_id(6_u64)) + .given( + get_transaction_count_request() + .with_id(7_u64) + .with_host(PUBLICNODE_ETH_MAINNET_HOSTNAME), + ) + .respond_with(get_transaction_count_response().with_id(7_u64)), ) - .wait() + .with_rpc_sources(RpcServices::EthMainnet(None)) + .with_consensus_strategy(ConsensusStrategy::Threshold { + total: Some(3), + min: 2, + }) + .build() + .get_transaction_count(( + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + BlockNumberOrTag::Latest, + )) + .send() + .await .expect_consistent() .unwrap(); - assert_eq!(response, 1_u8.into()); + assert_eq!(response, U256::ONE); +} + +fn get_transaction_count_request() -> JsonRpcRequestMatcher { + JsonRpcRequestMatcher::with_method("eth_getTransactionCount").with_params(json!([ + "0xdac17f958d2ee523a2206206994597c13d831ec7", + "latest" + ])) +} + +fn get_transaction_count_response() -> JsonRpcResponse { + JsonRpcResponse::from(json!({ "jsonrpc" : "2.0", "id" : 0, "result" : "0x1" })) } pub fn multi_logs_for_single_transaction(num_logs: usize) -> serde_json::Value { From 5107d089e9d8ffad2f0c2bfec0f057a3ab2ca1d3 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Tue, 9 Sep 2025 13:27:29 +0200 Subject: [PATCH 04/12] XC-412: Integration tests part 2 --- tests/mock_http_runtime/mock/json/mod.rs | 30 +- tests/mock_http_runtime/mock/mod.rs | 22 + tests/setup/mod.rs | 44 +- tests/tests.rs | 567 ++++++++++++----------- 4 files changed, 371 insertions(+), 292 deletions(-) diff --git a/tests/mock_http_runtime/mock/json/mod.rs b/tests/mock_http_runtime/mock/json/mod.rs index bb4e8bbd..64fea047 100644 --- a/tests/mock_http_runtime/mock/json/mod.rs +++ b/tests/mock_http_runtime/mock/json/mod.rs @@ -32,9 +32,9 @@ impl JsonRpcRequestMatcher { } } - pub fn with_host(self, host: &str) -> Self { + pub fn with_id(self, id: impl Into) -> Self { Self { - host: Some(Host::parse(host).expect("BUG: invalid host for a URL")), + id: Some(id.into()), ..self } } @@ -46,9 +46,31 @@ impl JsonRpcRequestMatcher { } } - pub fn with_id(self, id: impl Into) -> Self { + pub fn with_url(self, url: &str) -> Self { Self { - id: Some(id.into()), + url: Some(Url::parse(url).expect("BUG: invalid URL")), + ..self + } + } + + pub fn with_host(self, host: &str) -> Self { + Self { + host: Some(Host::parse(host).expect("BUG: invalid host for a URL")), + ..self + } + } + + pub fn with_request_headers(self, headers: Vec<(impl ToString, impl ToString)>) -> Self { + Self { + request_headers: Some( + headers + .into_iter() + .map(|(name, value)| CanisterHttpHeader { + name: name.to_string(), + value: value.to_string(), + }) + .collect(), + ), ..self } } diff --git a/tests/mock_http_runtime/mock/mod.rs b/tests/mock_http_runtime/mock/mod.rs index 46a29224..ad3c446c 100644 --- a/tests/mock_http_runtime/mock/mod.rs +++ b/tests/mock_http_runtime/mock/mod.rs @@ -129,3 +129,25 @@ impl From for CanisterHttpResponse { CanisterHttpResponse::CanisterHttpReply(value.0) } } + +pub struct CanisterHttpReject(pocket_ic::common::rest::CanisterHttpReject); + +impl CanisterHttpReject { + pub fn with_reject_code(reject_code: ic_error_types::RejectCode) -> Self { + Self(pocket_ic::common::rest::CanisterHttpReject { + reject_code: reject_code as u64, + message: "".to_string(), + }) + } + + pub fn with_message(mut self, message: impl Into) -> Self { + self.0.message = message.into(); + self + } +} + +impl From for CanisterHttpResponse { + fn from(value: CanisterHttpReject) -> Self { + CanisterHttpResponse::CanisterHttpReject(value.0) + } +} diff --git a/tests/setup/mod.rs b/tests/setup/mod.rs index 19ca6017..23ab40df 100644 --- a/tests/setup/mod.rs +++ b/tests/setup/mod.rs @@ -1,5 +1,5 @@ use crate::{ - assert_reply, + assert_reply, evm_rpc_wasm, mock_http_runtime::{mock::MockHttpOutcalls, MockHttpRuntime}, DEFAULT_CALLER_TEST_ID, DEFAULT_CONTROLLER_TEST_ID, INITIAL_CYCLES, MOCK_API_KEY, }; @@ -16,8 +16,9 @@ use evm_rpc_types::InstallArgs; use ic_cdk::api::management_canister::main::CanisterId; use ic_http_types::{HttpRequest, HttpResponse}; use ic_management_canister_types::CanisterSettings; -use pocket_ic::{nonblocking, PocketIcBuilder}; +use pocket_ic::{nonblocking, ErrorCode, PocketIcBuilder}; use std::sync::{Arc, Mutex}; +use std::time::Duration; #[derive(Clone)] pub struct EvmRpcNonblockingSetup { @@ -79,16 +80,39 @@ impl EvmRpcNonblockingSetup { } } - pub fn client(&self, mocks: impl Into) -> ClientBuilder { - EvmRpcClient::builder(self.new_mock_http_runtime(mocks.into()), self.canister_id) + pub async fn upgrade_canister(&self, args: InstallArgs) { + for _ in 0..100 { + self.env.tick().await; + // Avoid `CanisterInstallCodeRateLimited` error + self.env.advance_time(Duration::from_secs(600)).await; + self.env.tick().await; + match self + .env + .upgrade_canister( + self.canister_id, + evm_rpc_wasm(), + Encode!(&args).unwrap(), + Some(self.controller), + ) + .await + { + Ok(_) => return, + Err(e) if e.error_code == ErrorCode::CanisterInstallCodeRateLimited => continue, + Err(e) => panic!("Error while upgrading canister: {e:?}"), + } + } + panic!("Failed to upgrade canister after many trials!") } - fn new_mock_http_runtime(&self, mocks: MockHttpOutcalls) -> MockHttpRuntime { - MockHttpRuntime { - env: self.env.clone(), - caller: self.caller, - mocks: Mutex::new(mocks), - } + pub fn client(&self, mocks: impl Into) -> ClientBuilder { + EvmRpcClient::builder( + MockHttpRuntime { + env: self.env.clone(), + caller: self.caller, + mocks: Mutex::new(mocks.into()), + }, + self.canister_id, + ) } pub async fn update_api_keys(&self, api_keys: &[(ProviderId, Option)]) { diff --git a/tests/tests.rs b/tests/tests.rs index 5c75861b..a05d2489 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -2,6 +2,7 @@ mod mock; mod mock_http_runtime; mod setup; +use crate::mock_http_runtime::mock::CanisterHttpReject; use crate::{ mock::MockJsonRequestBody, mock_http_runtime::mock::{ @@ -25,18 +26,18 @@ use evm_rpc::{ use evm_rpc_types::{ BlockTag, ConsensusStrategy, EthMainnetService, EthSepoliaService, GetLogsRpcConfig, Hex, Hex20, Hex32, HttpOutcallError, InstallArgs, JsonRpcError, LegacyRejectionCode, MultiRpcResult, - Nat256, Provider, ProviderError, RpcApi, RpcConfig, RpcError, RpcResult, RpcService, - RpcServices, ValidationError, + Nat256, Provider, ProviderError, RpcApi, RpcError, RpcResult, RpcService, RpcServices, + ValidationError, }; -use ic_cdk::api::{call::RejectionCode, management_canister::main::CanisterId}; +use ic_cdk::api::management_canister::main::CanisterId; +use ic_error_types::RejectCode; use ic_http_types::{HttpRequest, HttpResponse}; use ic_management_canister_types::{CanisterSettings, HttpHeader}; use ic_test_utilities_load_wasm::load_wasm; use maplit::hashmap; use mock::{MockOutcall, MockOutcallBuilder}; use pocket_ic::common::rest::{ - CanisterHttpMethod, CanisterHttpReject, CanisterHttpResponse, MockCanisterHttpResponse, - RawMessageId, + CanisterHttpMethod, CanisterHttpResponse, MockCanisterHttpResponse, RawMessageId, }; use pocket_ic::{ErrorCode, PocketIc, PocketIcBuilder, RejectResponse}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -254,18 +255,6 @@ impl EvmRpcSetup { ) } - pub fn eth_get_transaction_count( - &self, - source: RpcServices, - config: Option, - args: evm_rpc_types::GetTransactionCountArgs, - ) -> CallFlow> { - self.call_update( - "eth_getTransactionCount", - Encode!(&source, &config, &args).unwrap(), - ) - } - pub fn eth_send_raw_transaction( &self, source: RpcServices, @@ -408,13 +397,15 @@ impl CallFlow { .unwrap_or(DEFAULT_MAX_RESPONSE_BYTES); if reply.body.len() as u64 > max_response_bytes { //approximate replica behaviour since headers are not accounted for. - CanisterHttpResponse::CanisterHttpReject(CanisterHttpReject { - reject_code: 1, //SYS_FATAL - message: format!( - "Http body exceeds size limit of {} bytes.", - max_response_bytes - ), - }) + CanisterHttpResponse::CanisterHttpReject( + pocket_ic::common::rest::CanisterHttpReject { + reject_code: 1, //SYS_FATAL + message: format!( + "Http body exceeds size limit of {} bytes.", + max_response_bytes + ), + }, + ) } else { CanisterHttpResponse::CanisterHttpReply(reply) } @@ -1689,7 +1680,9 @@ async fn candid_rpc_should_return_inconsistent_results_with_error() { .given(get_transaction_count_request().with_id(0_u64)) .respond_with(get_transaction_count_response().with_id(0_u64)) .given(get_transaction_count_request().with_id(1_u64)) - .respond_with(JsonRpcResponse::from(json!({"jsonrpc": "2.0", "id": 1, "error" : { "code": 123, "message": "Unexpected"} }))); + .respond_with(JsonRpcResponse::from( + json!({"jsonrpc": "2.0", "id": 1, "error" : { "code": 123, "message": "Unexpected"} }), + )); let result = setup .client(mocks) @@ -1743,42 +1736,41 @@ async fn candid_rpc_should_return_inconsistent_results_with_error() { ); } -#[test] -fn candid_rpc_should_return_inconsistent_results_with_consensus_error() { +#[tokio::test] +async fn candid_rpc_should_return_inconsistent_results_with_consensus_error() { const CONSENSUS_ERROR: &str = "No consensus could be reached. Replicas had different responses."; - let setup = EvmRpcSetup::new().mock_api_keys(); - let result = setup - .eth_get_transaction_count( - RpcServices::EthMainnet(None), - Some(RpcConfig { - response_consensus: Some(ConsensusStrategy::Threshold { - total: Some(3), - min: 2, - }), - ..Default::default() - }), - evm_rpc_types::GetTransactionCountArgs { - address: "0xdAC17F958D2ee523a2206206994597C13D831ec7" - .parse() - .unwrap(), - block: evm_rpc_types::BlockTag::Latest, - }, + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; + + let mocks = MockHttpOutcallsBuilder::new() + .given(get_transaction_count_request().with_id(0_u64)) + .respond_with( + CanisterHttpReject::with_reject_code(RejectCode::SysTransient) + .with_message(CONSENSUS_ERROR), ) - .mock_http_once(MockOutcallBuilder::new_error( - RejectionCode::SysTransient, - CONSENSUS_ERROR, - )) - .mock_http_once(MockOutcallBuilder::new( - 200, - r#"{"jsonrpc":"2.0","id":1,"result":"0x1"}"#, - )) - .mock_http_once(MockOutcallBuilder::new_error( - RejectionCode::SysTransient, - CONSENSUS_ERROR, + .given(get_transaction_count_request().with_id(1_u64)) + .respond_with(get_transaction_count_response().with_id(1_u64)) + .given(get_transaction_count_request().with_id(2_u64)) + .respond_with( + CanisterHttpReject::with_reject_code(RejectCode::SysTransient) + .with_message(CONSENSUS_ERROR), + ); + + let result = setup + .client(mocks) + .with_rpc_sources(RpcServices::EthMainnet(None)) + .with_consensus_strategy(ConsensusStrategy::Threshold { + total: Some(3), + min: 2, + }) + .build() + .get_transaction_count(( + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + BlockNumberOrTag::Latest, )) - .wait() + .send() + .await .expect_inconsistent(); assert_eq!( @@ -1786,7 +1778,7 @@ fn candid_rpc_should_return_inconsistent_results_with_consensus_error() { vec![ ( RpcService::EthMainnet(EthMainnetService::BlockPi), - Ok(1_u8.into()) + Ok(U256::ONE) ), ( RpcService::EthMainnet(EthMainnetService::Ankr), @@ -1806,7 +1798,7 @@ fn candid_rpc_should_return_inconsistent_results_with_consensus_error() { ); let rpc_method = || RpcMethod::EthGetTransactionCount.into(); - let err_http_outcall = setup.get_metrics().err_http_outcall; + let err_http_outcall = setup.get_metrics().await.err_http_outcall; assert_eq!( err_http_outcall, hashmap! { @@ -1849,45 +1841,44 @@ fn should_have_metrics_for_generic_request() { ); } -#[test] -fn candid_rpc_should_return_inconsistent_results_with_unexpected_http_status() { - let setup = EvmRpcSetup::new().mock_api_keys(); +#[tokio::test] +async fn candid_rpc_should_return_inconsistent_results_with_unexpected_http_status() { + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; + + let mocks = MockHttpOutcallsBuilder::new() + .given(get_transaction_count_request().with_id(0_u64)) + .respond_with(get_transaction_count_response().with_id(0_u64)) + .given(get_transaction_count_request().with_id(1_u64)) + .respond_with(CanisterHttpReply::with_status(400).with_body( + json!({"jsonrpc": "2.0", "id": 1, "error": {"code": 123, "message": "Error message"}}), + )); + let result = setup - .eth_get_transaction_count( - RpcServices::EthMainnet(Some(vec![ - EthMainnetService::Alchemy, - EthMainnetService::Ankr, - ])), - None, - evm_rpc_types::GetTransactionCountArgs { - address: "0xdAC17F958D2ee523a2206206994597C13D831ec7" - .parse() - .unwrap(), - block: evm_rpc_types::BlockTag::Latest, - }, - ) - .mock_http_once(MockOutcallBuilder::new( - 200, - r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#, - )) - .mock_http_once(MockOutcallBuilder::new( - 400, - r#"{"jsonrpc":"2.0","id":0,"error":{"code":123,"message":"Error message"}}"#, + .client(mocks) + .with_rpc_sources(RpcServices::EthMainnet(Some(vec![ + EthMainnetService::Alchemy, + EthMainnetService::Ankr, + ]))) + .build() + .get_transaction_count(( + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + BlockNumberOrTag::Latest, )) - .wait() + .send() + .await .expect_inconsistent(); assert_eq!( result, vec![ ( RpcService::EthMainnet(EthMainnetService::Alchemy), - Ok(1_u8.into()) + Ok(U256::ONE) ), ( RpcService::EthMainnet(EthMainnetService::Ankr), Err(RpcError::HttpOutcallError(HttpOutcallError::InvalidHttpJsonRpcResponse { status: 400, - body: "{\"jsonrpc\":\"2.0\",\"id\":0,\"error\":{\"code\":123,\"message\":\"Error message\"}}".to_string(), + body: "{\"error\":{\"code\":123,\"message\":\"Error message\"},\"id\":1,\"jsonrpc\":\"2.0\"}".to_string(), parsing_error: None, })), ), @@ -1895,7 +1886,7 @@ fn candid_rpc_should_return_inconsistent_results_with_unexpected_http_status() { ); let rpc_method = || RpcMethod::EthGetTransactionCount.into(); assert_eq!( - setup.get_metrics(), + setup.get_metrics().await, Metrics { requests: hashmap! { (rpc_method(), ALCHEMY_ETH_MAINNET_HOSTNAME.into()) => 1, @@ -2035,155 +2026,161 @@ async fn should_use_custom_response_size_estimate() { assert_matches!(response, Ok(_)); } -#[test] -fn should_use_fallback_public_url() { - let authorized_caller = ADDITIONAL_TEST_ID; - let setup = EvmRpcSetup::with_args(InstallArgs { - demo: Some(true), - manage_api_keys: Some(vec![authorized_caller]), - ..Default::default() - }); +#[tokio::test] +async fn should_use_fallback_public_url() { + let setup = EvmRpcNonblockingSetup::new().await; let response = setup - .eth_get_transaction_count( - RpcServices::EthMainnet(Some(vec![EthMainnetService::Ankr])), - None, - evm_rpc_types::GetTransactionCountArgs { - address: Hex20::from_str("0xdAC17F958D2ee523a2206206994597C13D831ec7").unwrap(), - block: evm_rpc_types::BlockTag::Latest, - }, - ) - .mock_http( - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#) - .with_url("https://rpc.ankr.com/eth"), + .client( + MockHttpOutcallsBuilder::new() + .given(get_transaction_count_request().with_url("https://rpc.ankr.com/eth")) + .respond_with(get_transaction_count_response()), ) - .wait() + .with_rpc_sources(RpcServices::EthMainnet(Some(vec![EthMainnetService::Ankr]))) + .build() + .get_transaction_count(( + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + BlockNumberOrTag::Latest, + )) + .send() + .await .expect_consistent() .unwrap(); - assert_eq!(response, 1u32.into()); + assert_eq!(response, U256::ONE); } -#[test] -fn should_insert_api_keys() { - let authorized_caller = ADDITIONAL_TEST_ID; - let setup = EvmRpcSetup::with_args(InstallArgs { +#[tokio::test] +async fn should_insert_api_keys() { + let setup = EvmRpcNonblockingSetup::with_args(InstallArgs { demo: Some(true), - manage_api_keys: Some(vec![authorized_caller]), + manage_api_keys: Some(vec![DEFAULT_CALLER_TEST_ID]), ..Default::default() - }); + }) + .await; let provider_id = 1; setup - .clone() - .as_caller(authorized_caller) - .update_api_keys(&[(provider_id, Some("test-api-key".to_string()))]); + .update_api_keys(&[(provider_id, Some("test-api-key".to_string()))]) + .await; let response = setup - .eth_get_transaction_count( - RpcServices::EthMainnet(Some(vec![EthMainnetService::Ankr])), - None, - evm_rpc_types::GetTransactionCountArgs { - address: "0xdAC17F958D2ee523a2206206994597C13D831ec7" - .parse() - .unwrap(), - block: evm_rpc_types::BlockTag::Latest, - }, - ) - .mock_http( - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#) - .with_url("https://rpc.ankr.com/eth/test-api-key"), + .client( + MockHttpOutcallsBuilder::new() + .given( + get_transaction_count_request() + .with_url("https://rpc.ankr.com/eth/test-api-key"), + ) + .respond_with(get_transaction_count_response()), ) - .wait() + .with_rpc_sources(RpcServices::EthMainnet(Some(vec![EthMainnetService::Ankr]))) + .build() + .get_transaction_count(( + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + BlockNumberOrTag::Latest, + )) + .send() + .await .expect_consistent() .unwrap(); - assert_eq!(response, 1_u8.into()); + assert_eq!(response, U256::ONE); } -#[test] -fn should_update_api_key() { - let authorized_caller = ADDITIONAL_TEST_ID; - let setup = EvmRpcSetup::with_args(InstallArgs { +#[tokio::test] +async fn should_update_api_key() { + let setup = EvmRpcNonblockingSetup::with_args(InstallArgs { demo: Some(true), - manage_api_keys: Some(vec![authorized_caller]), + manage_api_keys: Some(vec![DEFAULT_CALLER_TEST_ID]), ..Default::default() }) - .as_caller(authorized_caller); + .await; let provider_id = 1; // Ankr / mainnet let api_key = "test-api-key"; - let [response_0, response_1] = - json_rpc_sequential_id(json!({"jsonrpc":"2.0","id":0,"result":"0x1"})); - - setup.update_api_keys(&[(provider_id, Some(api_key.to_string()))]); + setup + .update_api_keys(&[(provider_id, Some(api_key.to_string()))]) + .await; let response = setup - .eth_get_transaction_count( - RpcServices::EthMainnet(Some(vec![EthMainnetService::Ankr])), - None, - evm_rpc_types::GetTransactionCountArgs { - address: Hex20::from_str("0xdAC17F958D2ee523a2206206994597C13D831ec7").unwrap(), - block: evm_rpc_types::BlockTag::Latest, - }, - ) - .mock_http_once( - MockOutcallBuilder::new(200, response_0) - .with_url(format!("https://rpc.ankr.com/eth/{api_key}")), + .client( + MockHttpOutcallsBuilder::new() + .given( + get_transaction_count_request() + .with_id(0_u64) + .with_url(&format!("https://rpc.ankr.com/eth/{api_key}")), + ) + .respond_with(get_transaction_count_response().with_id(0_u64)), ) - .wait() + .with_rpc_sources(RpcServices::EthMainnet(Some(vec![EthMainnetService::Ankr]))) + .build() + .get_transaction_count(( + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + BlockNumberOrTag::Latest, + )) + .send() + .await .expect_consistent() .unwrap(); - assert_eq!(response, 1u32.into()); + assert_eq!(response, U256::ONE); - setup.update_api_keys(&[(provider_id, None)]); - let response_public = setup - .eth_get_transaction_count( - RpcServices::EthMainnet(Some(vec![EthMainnetService::Ankr])), - None, - evm_rpc_types::GetTransactionCountArgs { - address: Hex20::from_str("0xdAC17F958D2ee523a2206206994597C13D831ec7").unwrap(), - block: evm_rpc_types::BlockTag::Latest, - }, - ) - .mock_http_once( - MockOutcallBuilder::new(200, response_1).with_url("https://rpc.ankr.com/eth"), + setup.update_api_keys(&[(provider_id, None)]).await; + let response = setup + .client( + MockHttpOutcallsBuilder::new() + .given( + get_transaction_count_request() + .with_id(1_u64) + .with_url("https://rpc.ankr.com/eth"), + ) + .respond_with(get_transaction_count_response().with_id(1_u64)), ) - .wait() + .with_rpc_sources(RpcServices::EthMainnet(Some(vec![EthMainnetService::Ankr]))) + .build() + .get_transaction_count(( + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + BlockNumberOrTag::Latest, + )) + .send() + .await .expect_consistent() .unwrap(); - assert_eq!(response_public, 1u32.into()); + assert_eq!(response, U256::ONE); } -#[test] -fn should_update_bearer_token() { - let authorized_caller = ADDITIONAL_TEST_ID; - let setup = EvmRpcSetup::with_args(InstallArgs { +#[tokio::test] +async fn should_update_bearer_token() { + let setup = EvmRpcNonblockingSetup::with_args(InstallArgs { demo: Some(true), - manage_api_keys: Some(vec![authorized_caller]), + manage_api_keys: Some(vec![DEFAULT_CALLER_TEST_ID]), ..Default::default() - }); + }) + .await; let provider_id = 8; // Alchemy / mainnet let api_key = "test-api-key"; setup - .clone() - .as_caller(authorized_caller) - .update_api_keys(&[(provider_id, Some(api_key.to_string()))]); + .update_api_keys(&[(provider_id, Some(api_key.to_string()))]) + .await; let response = setup - .eth_get_transaction_count( - RpcServices::EthMainnet(Some(vec![EthMainnetService::Alchemy])), - None, - evm_rpc_types::GetTransactionCountArgs { - address: Hex20::from_str("0xdAC17F958D2ee523a2206206994597C13D831ec7").unwrap(), - block: evm_rpc_types::BlockTag::Latest, - }, - ) - .mock_http( - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#) - .with_url("https://eth-mainnet.g.alchemy.com/v2") - .with_request_headers(vec![ - ("Content-Type", "application/json"), - ("Authorization", &format!("Bearer {api_key}")), - ]), + .client( + MockHttpOutcallsBuilder::new() + .given( + get_transaction_count_request() + .with_url("https://eth-mainnet.g.alchemy.com/v2") + .with_request_headers(vec![ + ("Content-Type", "application/json"), + ("Authorization", &format!("Bearer {api_key}")), + ]), + ) + .respond_with(get_transaction_count_response()), ) - .wait() + .with_rpc_sources(RpcServices::EthMainnet(Some(vec![ + EthMainnetService::Alchemy, + ]))) + .build() + .get_transaction_count(( + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + BlockNumberOrTag::Latest, + )) + .send() + .await .expect_consistent() .unwrap(); - assert_eq!(response, 1u32.into()); + assert_eq!(response, U256::ONE); } #[test] @@ -2235,52 +2232,62 @@ fn should_get_providers_and_get_service_provider_map_be_consistent() { } } -#[test] -fn upgrade_should_keep_api_keys() { - let setup = EvmRpcSetup::new(); +#[tokio::test] +async fn upgrade_should_keep_api_keys() { + let setup = EvmRpcNonblockingSetup::with_args(InstallArgs { + demo: Some(true), + manage_api_keys: Some(vec![DEFAULT_CALLER_TEST_ID]), + ..Default::default() + }) + .await; let provider_id = 1; // Ankr / mainnet let api_key = "test-api-key"; setup - .clone() - .as_controller() - .update_api_keys(&[(provider_id, Some(api_key.to_string()))]); + .update_api_keys(&[(provider_id, Some(api_key.to_string()))]) + .await; let response = setup - .eth_get_transaction_count( - RpcServices::EthMainnet(Some(vec![EthMainnetService::Ankr])), - None, - evm_rpc_types::GetTransactionCountArgs { - address: Hex20::from_str("0xdAC17F958D2ee523a2206206994597C13D831ec7").unwrap(), - block: evm_rpc_types::BlockTag::Latest, - }, - ) - .mock_http( - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#) - .with_url(format!("https://rpc.ankr.com/eth/{api_key}")), + .client( + MockHttpOutcallsBuilder::new() + .given( + get_transaction_count_request() + .with_url(&format!("https://rpc.ankr.com/eth/{api_key}")), + ) + .respond_with(get_transaction_count_response()), ) - .wait() + .with_rpc_sources(RpcServices::EthMainnet(Some(vec![EthMainnetService::Ankr]))) + .build() + .get_transaction_count(( + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + BlockNumberOrTag::Latest, + )) + .send() + .await .expect_consistent() .unwrap(); - assert_eq!(response, 1u32.into()); + assert_eq!(response, U256::ONE); - setup.upgrade_canister(InstallArgs::default()); + setup.upgrade_canister(InstallArgs::default()).await; let response_post_upgrade = setup - .eth_get_transaction_count( - RpcServices::EthMainnet(Some(vec![EthMainnetService::Ankr])), - None, - evm_rpc_types::GetTransactionCountArgs { - address: Hex20::from_str("0xdAC17F958D2ee523a2206206994597C13D831ec7").unwrap(), - block: evm_rpc_types::BlockTag::Latest, - }, - ) - .mock_http( - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#) - .with_url(format!("https://rpc.ankr.com/eth/{api_key}")), + .client( + MockHttpOutcallsBuilder::new() + .given( + get_transaction_count_request() + .with_url(&format!("https://rpc.ankr.com/eth/{api_key}")), + ) + .respond_with(get_transaction_count_response()), ) - .wait() + .with_rpc_sources(RpcServices::EthMainnet(Some(vec![EthMainnetService::Ankr]))) + .build() + .get_transaction_count(( + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + BlockNumberOrTag::Latest, + )) + .send() + .await .expect_consistent() .unwrap(); - assert_eq!(response_post_upgrade, 1u32.into()); + assert_eq!(response_post_upgrade, U256::ONE); } #[test] @@ -2496,43 +2503,44 @@ async fn should_retry_when_response_too_large() { ); } -#[test] -fn should_have_different_request_ids_when_retrying_because_response_too_big() { - let setup = EvmRpcSetup::new().mock_api_keys(); +#[tokio::test] +async fn should_have_different_request_ids_when_retrying_because_response_too_big() { + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; - let response = setup - .eth_get_transaction_count( - RpcServices::EthMainnet(Some(vec![EthMainnetService::Cloudflare])), - Some(evm_rpc_types::RpcConfig { - response_size_estimate: Some(1), - response_consensus: None, - }), - evm_rpc_types::GetTransactionCountArgs { - address: "0xdAC17F958D2ee523a2206206994597C13D831ec7" - .parse() - .unwrap(), - block: evm_rpc_types::BlockTag::Latest, - }, - ) - .mock_http_once( - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#) - .with_raw_request_body(r#"{"jsonrpc":"2.0","method":"eth_getTransactionCount","params":["0xdac17f958d2ee523a2206206994597c13d831ec7","latest"],"id":0}"#) - .with_max_response_bytes(1), + let mocks = MockHttpOutcallsBuilder::new() + .given( + get_transaction_count_request() + .with_id(0_u64) + .with_max_response_bytes(1_u64), ) - .mock_http_once( - MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":1,"result":"0x1"}"#) - .with_raw_request_body(r#"{"jsonrpc":"2.0","method":"eth_getTransactionCount","params":["0xdac17f958d2ee523a2206206994597c13d831ec7","latest"],"id":1}"#) - .with_max_response_bytes(2048), + .respond_with(get_transaction_count_response().with_id(0_u64)) + .given( + get_transaction_count_request() + .with_id(1_u64) + .with_max_response_bytes(2048_u64), ) - .wait() - .expect_consistent() - .unwrap(); + .respond_with(get_transaction_count_response().with_id(1_u64)); - assert_eq!(response, 1_u8.into()); + let response = setup + .client(mocks) + .with_rpc_sources(RpcServices::EthMainnet(Some(vec![ + EthMainnetService::Cloudflare, + ]))) + .with_response_size_estimate(1) + .build() + .get_transaction_count(( + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + BlockNumberOrTag::Latest, + )) + .send() + .await + .expect_consistent(); + + assert_eq!(response, Ok(U256::ONE)); let rpc_method = || RpcMethod::EthGetTransactionCount.into(); assert_eq!( - setup.get_metrics(), + setup.get_metrics().await, Metrics { requests: hashmap! { (rpc_method(), CLOUDFLARE_HOSTNAME.into()) => 2, @@ -2548,29 +2556,30 @@ fn should_have_different_request_ids_when_retrying_because_response_too_big() { ); } -#[test] -fn should_fail_when_response_id_inconsistent_with_request_id() { - let setup = EvmRpcSetup::new().mock_api_keys(); +#[tokio::test] +async fn should_fail_when_response_id_inconsistent_with_request_id() { + let setup = EvmRpcNonblockingSetup::new().await.mock_api_keys().await; - let request_id = 0; - let response_id = 1; + let request_id = 0_u64; + let response_id = 1_u64; assert_ne!(request_id, response_id); - let request = json!({"jsonrpc":"2.0", "id": request_id, "method":"eth_getTransactionCount","params":["0xdac17f958d2ee523a2206206994597c13d831ec7","latest"]}); - let response = json!({"jsonrpc":"2.0", "id": response_id, "result":"0x1"}); let error = setup - .eth_get_transaction_count( - RpcServices::EthMainnet(Some(vec![EthMainnetService::Cloudflare])), - None, - evm_rpc_types::GetTransactionCountArgs { - address: "0xdAC17F958D2ee523a2206206994597C13D831ec7" - .parse() - .unwrap(), - block: evm_rpc_types::BlockTag::Latest, - }, + .client( + MockHttpOutcallsBuilder::new() + .given(get_transaction_count_request().with_id(request_id)) + .respond_with(get_transaction_count_response().with_id(response_id)), ) - .mock_http_once(MockOutcallBuilder::new(200, response).with_json_request_body(request)) - .wait() + .with_rpc_sources(RpcServices::EthMainnet(Some(vec![ + EthMainnetService::Cloudflare, + ]))) + .build() + .get_transaction_count(( + address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), + BlockNumberOrTag::Latest, + )) + .send() + .await .expect_consistent() .expect_err("should fail when ID mismatch"); @@ -2753,10 +2762,12 @@ async fn should_change_default_provider_when_one_keeps_failing() { } fn get_transaction_count_request() -> JsonRpcRequestMatcher { - JsonRpcRequestMatcher::with_method("eth_getTransactionCount").with_params(json!([ - "0xdac17f958d2ee523a2206206994597c13d831ec7", - "latest" - ])) + JsonRpcRequestMatcher::with_method("eth_getTransactionCount") + .with_params(json!([ + "0xdac17f958d2ee523a2206206994597c13d831ec7", + "latest" + ])) + .with_id(0_u64) } fn get_transaction_count_response() -> JsonRpcResponse { From f51740edd56c4520128280af311c61dca08c00e0 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Tue, 9 Sep 2025 13:59:12 +0200 Subject: [PATCH 05/12] XC-412: Add `call_query` method --- tests/mock_http_runtime/mock/json/mod.rs | 9 ----- tests/setup/mod.rs | 42 ++++++++++-------------- 2 files changed, 17 insertions(+), 34 deletions(-) diff --git a/tests/mock_http_runtime/mock/json/mod.rs b/tests/mock_http_runtime/mock/json/mod.rs index 64fea047..68738244 100644 --- a/tests/mock_http_runtime/mock/json/mod.rs +++ b/tests/mock_http_runtime/mock/json/mod.rs @@ -159,15 +159,6 @@ impl From for JsonRpcResponse { } impl JsonRpcResponse { - pub fn id(&self) -> Id { - match self.body.get("id") { - Some(id) => { - serde_json::from_value(id.clone()).expect("Unable to serialize response ID") - } - None => panic!("Response has no `id` field"), - } - } - 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 diff --git a/tests/setup/mod.rs b/tests/setup/mod.rs index 23ab40df..d91d360b 100644 --- a/tests/setup/mod.rs +++ b/tests/setup/mod.rs @@ -3,7 +3,7 @@ use crate::{ mock_http_runtime::{mock::MockHttpOutcalls, MockHttpRuntime}, DEFAULT_CALLER_TEST_ID, DEFAULT_CONTROLLER_TEST_ID, INITIAL_CYCLES, MOCK_API_KEY, }; -use candid::{Decode, Encode, Principal}; +use candid::{CandidType, Decode, Encode, Principal}; use canlog::{Log, LogEntry}; use evm_rpc::types::Metrics; use evm_rpc::{ @@ -17,6 +17,7 @@ use ic_cdk::api::management_canister::main::CanisterId; use ic_http_types::{HttpRequest, HttpResponse}; use ic_management_canister_types::CanisterSettings; use pocket_ic::{nonblocking, ErrorCode, PocketIcBuilder}; +use serde::de::DeserializeOwned; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -154,35 +155,26 @@ impl EvmRpcNonblockingSetup { headers: vec![], body: serde_bytes::ByteBuf::new(), }; - let response = Decode!( - &assert_reply( - self.env - .query_call( - self.canister_id, - Principal::anonymous(), - "http_request", - Encode!(&request).unwrap() - ) - .await - ), - HttpResponse - ) - .unwrap(); + let response: HttpResponse = self.call_query("http_request", Encode!(&request).unwrap()); serde_json::from_slice::>(&response.body) .expect("failed to parse EVM_RPC minter log") .entries } pub async fn get_metrics(&self) -> Metrics { - let response = self - .env - .query_call( - self.canister_id, - Principal::anonymous(), - "getMetrics", - Encode!().unwrap(), - ) - .await; - Decode!(&assert_reply(response), Metrics).unwrap() + self.call_query("getMetrics", Encode!().unwrap()).await + } + + async fn call_query( + &self, + method: &str, + input: Vec, + ) -> R { + let candid = &assert_reply( + self.env + .query_call(self.canister_id, self.caller, method, input) + .await, + ); + Decode!(candid, R).expect("error while decoding Candid response from query call") } } From c9b30f3ce6f374ae394aa68d72d5d6cd3836036b Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Tue, 9 Sep 2025 14:00:40 +0200 Subject: [PATCH 06/12] XC-412: Fix imports in doc --- evm_rpc_client/src/lib.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/evm_rpc_client/src/lib.rs b/evm_rpc_client/src/lib.rs index c73e7230..ebd574b6 100644 --- a/evm_rpc_client/src/lib.rs +++ b/evm_rpc_client/src/lib.rs @@ -34,8 +34,6 @@ //! use alloy_primitives::{address, U256}; //! use alloy_rpc_types::BlockNumberOrTag; //! use evm_rpc_client::EvmRpcClient; -//! use evm_rpc_types::{MultiRpcResult, Nat256}; -//! use evm_rpc_client::EvmRpcClient; //! //! # use evm_rpc_types::{MultiRpcResult, Nat256}; //! # #[tokio::main] From f1a3bdb3c166ddc2ba31687212eea3f458f04e25 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Tue, 9 Sep 2025 14:01:24 +0200 Subject: [PATCH 07/12] XC-412: Add missing `.await` --- tests/setup/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/setup/mod.rs b/tests/setup/mod.rs index d91d360b..c822a99d 100644 --- a/tests/setup/mod.rs +++ b/tests/setup/mod.rs @@ -155,7 +155,9 @@ impl EvmRpcNonblockingSetup { headers: vec![], body: serde_bytes::ByteBuf::new(), }; - let response: HttpResponse = self.call_query("http_request", Encode!(&request).unwrap()); + let response: HttpResponse = self + .call_query("http_request", Encode!(&request).unwrap()) + .await; serde_json::from_slice::>(&response.body) .expect("failed to parse EVM_RPC minter log") .entries From 8c924a09b848b69ac4e3ced61550ea9eed39d434 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 15 Sep 2025 12:00:27 +0200 Subject: [PATCH 08/12] XC-412: Revert JSON debugging for mock body --- tests/mock_http_runtime/mod.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/mock_http_runtime/mod.rs b/tests/mock_http_runtime/mod.rs index 8128e474..a44831b3 100644 --- a/tests/mock_http_runtime/mod.rs +++ b/tests/mock_http_runtime/mod.rs @@ -15,7 +15,6 @@ use pocket_ic::{ RejectResponse, }; use serde::de::DeserializeOwned; -use serde_json::Value; use std::{ sync::{Arc, Mutex}, time::Duration, @@ -92,11 +91,7 @@ impl MockHttpRuntime { self.env.mock_canister_http_response(mock_response).await; } None => { - let CanisterHttpRequest { ref body, .. } = request; - if let Ok(body) = serde_json::from_slice::(body) { - panic!("No mocks matching the request: {request:?} with body {body:?}"); - } - panic!("No mocks matching the request: {request:?}"); + panic!("No mocks matching the request: {:?}", request); } } } else { From f99440d859bf11d61ff50482737fe222eb0a0ed9 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 15 Sep 2025 12:01:52 +0200 Subject: [PATCH 09/12] XC-412: Try fewer times to upgrade canister --- tests/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tests.rs b/tests/tests.rs index a05d2489..cb30b7cb 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -145,7 +145,7 @@ impl EvmRpcSetup { } pub fn upgrade_canister(&self, args: InstallArgs) { - for _ in 0..100 { + for _ in 0..10 { self.env.tick(); // Avoid `CanisterInstallCodeRateLimited` error self.env.advance_time(Duration::from_secs(600)); From 00a85279126616647bbb8491d0f5a7f6ce8521b3 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 15 Sep 2025 14:42:25 +0200 Subject: [PATCH 10/12] XC-412: Call `getMetrics` and `http_request` with anonymous caller --- tests/setup/mod.rs | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/tests/setup/mod.rs b/tests/setup/mod.rs index c822a99d..de058a4c 100644 --- a/tests/setup/mod.rs +++ b/tests/setup/mod.rs @@ -129,22 +129,21 @@ impl EvmRpcNonblockingSetup { } 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.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 } @@ -174,7 +173,7 @@ impl EvmRpcNonblockingSetup { ) -> R { let candid = &assert_reply( self.env - .query_call(self.canister_id, self.caller, method, input) + .query_call(self.canister_id, Principal::anonymous(), method, input) .await, ); Decode!(candid, R).expect("error while decoding Candid response from query call") From 859bca862ec170f840d9f345c44686c53a3d0543 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 15 Sep 2025 14:42:47 +0200 Subject: [PATCH 11/12] XC-412: Default to update API keys with default caller --- tests/setup/mod.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/setup/mod.rs b/tests/setup/mod.rs index de058a4c..6ae403b6 100644 --- a/tests/setup/mod.rs +++ b/tests/setup/mod.rs @@ -117,10 +117,19 @@ impl EvmRpcNonblockingSetup { } pub async fn update_api_keys(&self, api_keys: &[(ProviderId, Option)]) { + self.update_api_keys_with_caller(api_keys, self.caller) + .await; + } + + pub async fn update_api_keys_with_caller( + &self, + api_keys: &[(ProviderId, Option)], + caller: Principal, + ) { self.env .update_call( self.canister_id, - self.controller, + caller, "updateApiKeys", Encode!(&api_keys).expect("Failed to encode arguments."), ) @@ -129,7 +138,7 @@ impl EvmRpcNonblockingSetup { } pub async fn mock_api_keys(self) -> Self { - self.update_api_keys( + self.update_api_keys_with_caller( &PROVIDERS .iter() .filter_map(|provider| { @@ -142,6 +151,7 @@ impl EvmRpcNonblockingSetup { )) }) .collect::>(), + self.controller, ) .await; self From 414c727b2e039854ef54ef4b2f81d175fa167c68 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 15 Sep 2025 16:20:39 +0200 Subject: [PATCH 12/12] XC-412: Inline `update_api_keys` --- tests/setup/mod.rs | 9 ++------- tests/tests.rs | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/tests/setup/mod.rs b/tests/setup/mod.rs index 6ae403b6..0ffdb8c6 100644 --- a/tests/setup/mod.rs +++ b/tests/setup/mod.rs @@ -116,12 +116,7 @@ impl EvmRpcNonblockingSetup { ) } - pub async fn update_api_keys(&self, api_keys: &[(ProviderId, Option)]) { - self.update_api_keys_with_caller(api_keys, self.caller) - .await; - } - - pub async fn update_api_keys_with_caller( + pub async fn update_api_keys( &self, api_keys: &[(ProviderId, Option)], caller: Principal, @@ -138,7 +133,7 @@ impl EvmRpcNonblockingSetup { } pub async fn mock_api_keys(self) -> Self { - self.update_api_keys_with_caller( + self.update_api_keys( &PROVIDERS .iter() .filter_map(|provider| { diff --git a/tests/tests.rs b/tests/tests.rs index cb30b7cb..966f1736 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -2057,8 +2057,9 @@ async fn should_insert_api_keys() { }) .await; let provider_id = 1; + let api_keys = &[(provider_id, Some("test-api-key".to_string()))]; setup - .update_api_keys(&[(provider_id, Some("test-api-key".to_string()))]) + .update_api_keys(api_keys, DEFAULT_CALLER_TEST_ID) .await; let response = setup .client( @@ -2093,8 +2094,9 @@ async fn should_update_api_key() { let provider_id = 1; // Ankr / mainnet let api_key = "test-api-key"; + let api_keys = &[(provider_id, Some(api_key.to_string()))]; setup - .update_api_keys(&[(provider_id, Some(api_key.to_string()))]) + .update_api_keys(api_keys, DEFAULT_CALLER_TEST_ID) .await; let response = setup .client( @@ -2118,7 +2120,10 @@ async fn should_update_api_key() { .unwrap(); assert_eq!(response, U256::ONE); - setup.update_api_keys(&[(provider_id, None)]).await; + let api_keys = &[(provider_id, None)]; + setup + .update_api_keys(api_keys, DEFAULT_CALLER_TEST_ID) + .await; let response = setup .client( MockHttpOutcallsBuilder::new() @@ -2152,8 +2157,9 @@ async fn should_update_bearer_token() { .await; let provider_id = 8; // Alchemy / mainnet let api_key = "test-api-key"; + let api_keys = &[(provider_id, Some(api_key.to_string()))]; setup - .update_api_keys(&[(provider_id, Some(api_key.to_string()))]) + .update_api_keys(api_keys, DEFAULT_CALLER_TEST_ID) .await; let response = setup .client( @@ -2242,8 +2248,9 @@ async fn upgrade_should_keep_api_keys() { .await; let provider_id = 1; // Ankr / mainnet let api_key = "test-api-key"; + let api_keys = &[(provider_id, Some(api_key.to_string()))]; setup - .update_api_keys(&[(provider_id, Some(api_key.to_string()))]) + .update_api_keys(api_keys, DEFAULT_CALLER_TEST_ID) .await; let response = setup .client(