From 8fd5e1f9ff5781c3b88258a7c3668e44baa2ee3b Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 24 Oct 2025 07:48:02 +0200 Subject: [PATCH 01/19] Move validation to `main.rs` --- src/candid_rpc/mod.rs | 41 ++++++++++++++++++++++------------------- src/main.rs | 5 ++++- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/candid_rpc/mod.rs b/src/candid_rpc/mod.rs index cf7b7898..7b1edcbe 100644 --- a/src/candid_rpc/mod.rs +++ b/src/candid_rpc/mod.rs @@ -13,7 +13,9 @@ use candid::Nat; use canhttp::http::json::JsonRpcRequest; use canhttp::multi::{ReductionError, Timestamp}; use ethers_core::{types::Transaction, utils::rlp}; -use evm_rpc_types::{Hex, Hex32, MultiRpcResult, Nat256, RpcError, RpcResult, ValidationError}; +use evm_rpc_types::{ + BlockTag, GetLogsArgs, Hex, Hex32, MultiRpcResult, Nat256, RpcError, RpcResult, ValidationError, +}; fn process_result( method: impl Into + Clone, @@ -71,23 +73,6 @@ impl CandidRpcClient { max_block_range: u32, ) -> MultiRpcResult> { use crate::candid_rpc::cketh_conversion::{from_log_entries, into_get_logs_param}; - - if let ( - Some(evm_rpc_types::BlockTag::Number(from)), - Some(evm_rpc_types::BlockTag::Number(to)), - ) = (&args.from_block, &args.to_block) - { - let from = Nat::from(from.clone()); - let to = Nat::from(to.clone()); - let block_count = if to > from { to - from } else { from - to }; - if block_count > max_block_range { - return MultiRpcResult::Consistent(Err(ValidationError::Custom(format!( - "Requested {} blocks; limited to {} when specifying a start and end block", - block_count, max_block_range - )) - .into())); - } - } process_result( RpcMethod::EthGetLogs, self.client.eth_get_logs(into_get_logs_param(args)).await, @@ -97,7 +82,7 @@ impl CandidRpcClient { pub async fn eth_get_block_by_number( &self, - block: evm_rpc_types::BlockTag, + block: BlockTag, ) -> MultiRpcResult { use crate::candid_rpc::cketh_conversion::{from_block, into_block_spec}; process_result( @@ -208,3 +193,21 @@ fn get_transaction_hash(raw_signed_transaction_hex: &Hex) -> Option { let transaction: Transaction = rlp::decode(raw_signed_transaction_hex.as_ref()).ok()?; Some(Hex32::from(transaction.hash.0)) } + +pub fn validate_get_logs_block_range(args: &GetLogsArgs, max_block_range: u32) -> RpcResult<()> { + if let (Some(BlockTag::Number(from)), Some(BlockTag::Number(to))) = + (&args.from_block, &args.to_block) + { + let from = Nat::from(from.clone()); + let to = Nat::from(to.clone()); + let block_count = if to > from { to - from } else { from - to }; + if block_count > max_block_range { + return Err(ValidationError::Custom(format!( + "Requested {} blocks; limited to {} when specifying a start and end block", + block_count, max_block_range + )) + .into()); + } + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 7d01a920..be9d8988 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use canhttp::{cycles::CyclesChargingPolicy, multi::Timestamp}; use canlog::{log, Log, Sort}; use evm_rpc::{ - candid_rpc::CandidRpcClient, + candid_rpc::{validate_get_logs_block_range, CandidRpcClient}, http::{ charging_policy_with_collateral, json_rpc_request, json_rpc_request_arg, service_request_builder, transform_http_request, @@ -43,6 +43,9 @@ pub async fn eth_get_logs( ) -> MultiRpcResult> { let config = config.unwrap_or_default(); let max_block_range = config.max_block_range_or_default(); + if let Err(err) = validate_get_logs_block_range(&args, max_block_range) { + return MultiRpcResult::Consistent(Err(err)); + } match CandidRpcClient::new(source, Some(RpcConfig::from(config)), now()) { Ok(source) => source.eth_get_logs(args, max_block_range).await, Err(err) => Err(err).into(), From 24b69e2b3776b1c9eed9a14637ecd33b0dd41223 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 24 Oct 2025 10:57:41 +0200 Subject: [PATCH 02/19] Introduce `MultiRpcRequest` --- src/candid_rpc/mod.rs | 149 ++++++------------ src/candid_rpc/tests.rs | 70 --------- src/main.rs | 2 +- src/rpc_client/mod.rs | 334 ++++++++++++++++++++++++++-------------- src/rpc_client/tests.rs | 70 +++++++++ 5 files changed, 333 insertions(+), 292 deletions(-) delete mode 100644 src/candid_rpc/tests.rs diff --git a/src/candid_rpc/mod.rs b/src/candid_rpc/mod.rs index 7b1edcbe..c6cb891a 100644 --- a/src/candid_rpc/mod.rs +++ b/src/candid_rpc/mod.rs @@ -1,56 +1,15 @@ mod cketh_conversion; -#[cfg(test)] -mod tests; -use crate::rpc_client::{EthRpcClient, ReducedResult}; -use crate::types::MetricRpcMethod; -use crate::{ - add_metric_entry, - providers::resolve_rpc_service, - types::{MetricRpcHost, ResolvedRpcService, RpcMethod}, -}; +use crate::rpc_client::EthRpcClient; +use crate::types::RpcMethod; use candid::Nat; use canhttp::http::json::JsonRpcRequest; -use canhttp::multi::{ReductionError, Timestamp}; +use canhttp::multi::Timestamp; use ethers_core::{types::Transaction, utils::rlp}; use evm_rpc_types::{ BlockTag, GetLogsArgs, Hex, Hex32, MultiRpcResult, Nat256, RpcError, RpcResult, ValidationError, }; -fn process_result( - method: impl Into + Clone, - result: ReducedResult, -) -> MultiRpcResult { - match result { - Ok(value) => MultiRpcResult::Consistent(Ok(value)), - Err(err) => match err { - ReductionError::ConsistentError(err) => MultiRpcResult::Consistent(Err(err)), - ReductionError::InconsistentResults(multi_call_results) => { - let results: Vec<_> = multi_call_results.into_iter().collect(); - results.iter().for_each(|(service, _service_result)| { - if let Ok(ResolvedRpcService::Provider(provider)) = - resolve_rpc_service(service.clone()) - { - add_metric_entry!( - inconsistent_responses, - ( - method.clone().into(), - MetricRpcHost( - provider - .hostname() - .unwrap_or_else(|| "(unknown)".to_string()) - ) - ), - 1 - ) - } - }); - MultiRpcResult::Inconsistent(results) - } - }, - } -} - /// Adapt the `EthRpcClient` to the `Candid` interface used by the EVM-RPC canister. pub struct CandidRpcClient { client: EthRpcClient, @@ -70,14 +29,13 @@ impl CandidRpcClient { pub async fn eth_get_logs( &self, args: evm_rpc_types::GetLogsArgs, - max_block_range: u32, ) -> MultiRpcResult> { use crate::candid_rpc::cketh_conversion::{from_log_entries, into_get_logs_param}; - process_result( - RpcMethod::EthGetLogs, - self.client.eth_get_logs(into_get_logs_param(args)).await, - ) - .map(from_log_entries) + self.client + .eth_get_logs(into_get_logs_param(args)) + .send_and_reduce() + .await + .map(from_log_entries) } pub async fn eth_get_block_by_number( @@ -85,13 +43,11 @@ impl CandidRpcClient { block: BlockTag, ) -> MultiRpcResult { use crate::candid_rpc::cketh_conversion::{from_block, into_block_spec}; - process_result( - RpcMethod::EthGetBlockByNumber, - self.client - .eth_get_block_by_number(into_block_spec(block)) - .await, - ) - .map(from_block) + self.client + .eth_get_block_by_number(into_block_spec(block)) + .send_and_reduce() + .await + .map(from_block) } pub async fn eth_get_transaction_receipt( @@ -99,13 +55,11 @@ impl CandidRpcClient { hash: Hex32, ) -> MultiRpcResult> { use crate::candid_rpc::cketh_conversion::{from_transaction_receipt, into_hash}; - process_result( - RpcMethod::EthGetTransactionReceipt, - self.client - .eth_get_transaction_receipt(into_hash(hash)) - .await, - ) - .map(|option| option.map(from_transaction_receipt)) + self.client + .eth_get_transaction_receipt(into_hash(hash)) + .send_and_reduce() + .await + .map(|option| option.map(from_transaction_receipt)) } pub async fn eth_get_transaction_count( @@ -113,13 +67,11 @@ impl CandidRpcClient { args: evm_rpc_types::GetTransactionCountArgs, ) -> MultiRpcResult { use crate::candid_rpc::cketh_conversion::into_get_transaction_count_params; - process_result( - RpcMethod::EthGetTransactionCount, - self.client - .eth_get_transaction_count(into_get_transaction_count_params(args)) - .await, - ) - .map(Nat256::from) + self.client + .eth_get_transaction_count(into_get_transaction_count_params(args)) + .send_and_reduce() + .await + .map(Nat256::from) } pub async fn eth_fee_history( @@ -127,13 +79,11 @@ impl CandidRpcClient { args: evm_rpc_types::FeeHistoryArgs, ) -> MultiRpcResult { use crate::candid_rpc::cketh_conversion::{from_fee_history, into_fee_history_params}; - process_result( - RpcMethod::EthFeeHistory, - self.client - .eth_fee_history(into_fee_history_params(args)) - .await, - ) - .map(from_fee_history) + self.client + .eth_fee_history(into_fee_history_params(args)) + .send_and_reduce() + .await + .map(from_fee_history) } pub async fn eth_send_raw_transaction( @@ -142,13 +92,11 @@ impl CandidRpcClient { ) -> MultiRpcResult { use crate::candid_rpc::cketh_conversion::from_send_raw_transaction_result; let transaction_hash = get_transaction_hash(&raw_signed_transaction_hex); - process_result( - RpcMethod::EthSendRawTransaction, - self.client - .eth_send_raw_transaction(raw_signed_transaction_hex.to_string()) - .await, - ) - .map(|result| from_send_raw_transaction_result(transaction_hash.clone(), result)) + self.client + .eth_send_raw_transaction(raw_signed_transaction_hex.to_string()) + .send_and_reduce() + .await + .map(|result| from_send_raw_transaction_result(transaction_hash.clone(), result)) } pub async fn eth_call( @@ -156,11 +104,11 @@ impl CandidRpcClient { args: evm_rpc_types::CallArgs, ) -> MultiRpcResult { use crate::candid_rpc::cketh_conversion::{from_data, into_eth_call_params}; - process_result( - RpcMethod::EthCall, - self.client.eth_call(into_eth_call_params(args)).await, - ) - .map(from_data) + self.client + .eth_call(into_eth_call_params(args)) + .send_and_reduce() + .await + .map(from_data) } pub async fn multi_request(&self, json_rpc_payload: String) -> MultiRpcResult { @@ -173,19 +121,14 @@ impl CandidRpcClient { ))) } }; - process_result( - MetricRpcMethod { - method: request.method().to_string(), - is_manual_request: true, - }, - self.client - .multi_request( - RpcMethod::Custom(request.method().to_string()), - request.params(), - ) - .await, - ) - .map(String::from) + self.client + .multi_request( + RpcMethod::Custom(request.method().to_string()), + request.params(), + ) + .send_and_reduce() + .await + .map(String::from) } } diff --git a/src/candid_rpc/tests.rs b/src/candid_rpc/tests.rs deleted file mode 100644 index 7bbc59dc..00000000 --- a/src/candid_rpc/tests.rs +++ /dev/null @@ -1,70 +0,0 @@ -use crate::candid_rpc::process_result; -use crate::types::RpcMethod; -use canhttp::multi::MultiResults; -use evm_rpc_types::MultiRpcResult; -use evm_rpc_types::{ProviderError, RpcError}; - -#[test] -fn test_process_result_mapping() { - use evm_rpc_types::{EthMainnetService, RpcService}; - type ReductionError = canhttp::multi::ReductionError; - - assert_eq!( - process_result(RpcMethod::EthGetTransactionCount, Ok(5)), - MultiRpcResult::Consistent(Ok(5)) - ); - assert_eq!( - process_result( - RpcMethod::EthGetTransactionCount, - Err(ReductionError::ConsistentError(RpcError::ProviderError( - ProviderError::MissingRequiredProvider - ))) - ), - MultiRpcResult::Consistent(Err(RpcError::ProviderError( - ProviderError::MissingRequiredProvider - ))) - ); - assert_eq!( - process_result( - RpcMethod::EthGetTransactionCount, - Err(ReductionError::InconsistentResults(MultiResults::default())) - ), - MultiRpcResult::Inconsistent(vec![]) - ); - assert_eq!( - process_result( - RpcMethod::EthGetTransactionCount, - Err(ReductionError::InconsistentResults( - MultiResults::from_non_empty_iter(vec![( - RpcService::EthMainnet(EthMainnetService::Ankr), - Ok(5) - )]) - )) - ), - MultiRpcResult::Inconsistent(vec![( - RpcService::EthMainnet(EthMainnetService::Ankr), - Ok(5) - )]) - ); - assert_eq!( - process_result( - RpcMethod::EthGetTransactionCount, - Err(ReductionError::InconsistentResults( - MultiResults::from_non_empty_iter(vec![ - (RpcService::EthMainnet(EthMainnetService::Ankr), Ok(5)), - ( - RpcService::EthMainnet(EthMainnetService::Cloudflare), - Err(RpcError::ProviderError(ProviderError::NoPermission)) - ) - ]) - )) - ), - MultiRpcResult::Inconsistent(vec![ - (RpcService::EthMainnet(EthMainnetService::Ankr), Ok(5)), - ( - RpcService::EthMainnet(EthMainnetService::Cloudflare), - Err(RpcError::ProviderError(ProviderError::NoPermission)) - ) - ]) - ); -} diff --git a/src/main.rs b/src/main.rs index be9d8988..14b25536 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,7 +47,7 @@ pub async fn eth_get_logs( return MultiRpcResult::Consistent(Err(err)); } match CandidRpcClient::new(source, Some(RpcConfig::from(config)), now()) { - Ok(source) => source.eth_get_logs(args, max_block_range).await, + Ok(source) => source.eth_get_logs(args).await, Err(err) => Err(err).into(), } } diff --git a/src/rpc_client/mod.rs b/src/rpc_client/mod.rs index dcce4b31..40c60cb2 100644 --- a/src/rpc_client/mod.rs +++ b/src/rpc_client/mod.rs @@ -1,4 +1,6 @@ +use crate::types::{MetricRpcHost, ResolvedRpcService}; use crate::{ + add_metric_entry, http::http_client, memory::{get_override_provider, rank_providers, record_ok_result}, providers::{resolve_rpc_service, SupportedRpcService}, @@ -11,12 +13,17 @@ use crate::{ }; use canhttp::{ http::json::JsonRpcRequest, - multi::{MultiResults, Reduce, ReduceWithEquality, ReduceWithThreshold, Timestamp}, + multi::{ + MultiResults, Reduce, ReduceWithEquality, ReduceWithThreshold, ReducedResult, + ReductionError, Timestamp, + }, MaxResponseBytesRequestExtension, TransformContextRequestExtension, }; use evm_rpc_types::{ - ConsensusStrategy, JsonRpcError, ProviderError, RpcConfig, RpcError, RpcService, RpcServices, + ConsensusStrategy, JsonRpcError, MultiRpcResult, ProviderError, RpcConfig, RpcError, + RpcService, RpcServices, }; +use http::Request; use ic_management_canister_types::{TransformContext, TransformFunc}; use json::{ requests::{ @@ -263,182 +270,239 @@ impl EthRpcClient { ) } - /// Query all providers in parallel and return all results. - /// It's up to the caller to decide how to handle the results, which could be inconsistent - /// (e.g., if different providers gave different responses). - /// This method is useful for querying data that is critical for the system to ensure that there is no single point of failure, - /// e.g., ethereum logs upon which ckETH will be minted. - async fn parallel_call( + pub fn eth_get_logs( &self, - method: RpcMethod, - params: I, - response_size_estimate: ResponseSizeEstimate, - ) -> MultiCallResults - where - I: Serialize + Clone + Debug, - O: Debug + DeserializeOwned + HttpResponsePayload, - { - let providers = self.providers(); - let transform_op = O::response_transform() - .as_ref() - .map(|t| { - let mut buf = vec![]; - minicbor::encode(t, &mut buf).unwrap(); - buf - }) - .unwrap_or_default(); - let effective_size_estimate = response_size_estimate.get(); - let mut requests = MultiResults::default(); - for provider in providers { - let request = resolve_rpc_service(provider.clone()) - .map_err(RpcError::from) - .and_then(|rpc_service| rpc_service.post(&get_override_provider())) - .map(|builder| { - builder - .max_response_bytes(effective_size_estimate) - .transform_context(TransformContext { - function: TransformFunc(candid::Func { - method: "cleanup_response".to_string(), - principal: ic_cdk::api::canister_self(), - }), - context: transform_op.clone(), - }) - .body(JsonRpcRequest::new(method.clone().name(), params.clone())) - .expect("BUG: invalid request") - }); - requests.insert_once(provider.clone(), request); - } - - let client = http_client(MetricRpcMethod::from(method), true).map_result(|r| { - match r?.into_body().into_result() { - Ok(value) => Ok(value), - Err(json_rpc_error) => Err(RpcError::JsonRpcError(JsonRpcError { - code: json_rpc_error.code, - message: json_rpc_error.message, - })), - } - }); - - let (requests, errors) = requests.into_inner(); - let (_client, mut results) = canhttp::multi::parallel_call(client, requests).await; - results.add_errors(errors); - let now = Timestamp::from_nanos_since_unix_epoch(ic_cdk::api::time()); - results - .ok_results() - .keys() - .filter_map(SupportedRpcService::new) - .for_each(|service| record_ok_result(service, now)); - assert_eq!( - results.len(), - providers.len(), - "BUG: expected 1 result per provider" - ); - results - } - - pub async fn eth_get_logs(&self, params: GetLogsParam) -> ReducedResult> { - self.parallel_call( + params: GetLogsParam, + ) -> MultiRpcRequest<(GetLogsParam,), Vec> { + MultiRpcRequest::new( + self.providers(), RpcMethod::EthGetLogs, - vec![params], + (params,), self.response_size_estimate(1024 + HEADER_SIZE_LIMIT), + self.consensus_strategy(), ) - .await - .reduce(self.consensus_strategy()) } - pub async fn eth_get_block_by_number(&self, block: BlockSpec) -> ReducedResult { + pub fn eth_get_block_by_number( + &self, + block: BlockSpec, + ) -> MultiRpcRequest { let expected_block_size = match self.chain() { EthereumNetwork::SEPOLIA => 12 * 1024, EthereumNetwork::MAINNET => 24 * 1024, _ => 24 * 1024, // Default for unknown networks }; - - self.parallel_call( + MultiRpcRequest::new( + self.providers(), RpcMethod::EthGetBlockByNumber, GetBlockByNumberParams { block, include_full_transactions: false, }, self.response_size_estimate(expected_block_size + HEADER_SIZE_LIMIT), + self.consensus_strategy(), ) - .await - .reduce(self.consensus_strategy()) } - pub async fn eth_get_transaction_receipt( + pub fn eth_get_transaction_receipt( &self, tx_hash: Hash, - ) -> ReducedResult> { - self.parallel_call( + ) -> MultiRpcRequest<(Hash,), Option> { + MultiRpcRequest::new( + self.providers(), RpcMethod::EthGetTransactionReceipt, - vec![tx_hash], + (tx_hash,), self.response_size_estimate(700 + HEADER_SIZE_LIMIT), + self.consensus_strategy(), ) - .await - .reduce(self.consensus_strategy()) } - pub async fn eth_fee_history(&self, params: FeeHistoryParams) -> ReducedResult { + pub fn eth_fee_history( + &self, + params: FeeHistoryParams, + ) -> MultiRpcRequest { // A typical response is slightly above 300 bytes. - self.parallel_call( + MultiRpcRequest::new( + self.providers(), RpcMethod::EthFeeHistory, params, self.response_size_estimate(512 + HEADER_SIZE_LIMIT), + self.consensus_strategy(), ) - .await - .reduce(self.consensus_strategy()) } - pub async fn eth_send_raw_transaction( + pub fn eth_send_raw_transaction( &self, raw_signed_transaction_hex: String, - ) -> ReducedResult { + ) -> MultiRpcRequest<(String,), SendRawTransactionResult> { // A successful reply is under 256 bytes, but we expect most calls to end with an error // since we submit the same transaction from multiple nodes. - self.parallel_call( + MultiRpcRequest::new( + self.providers(), RpcMethod::EthSendRawTransaction, - vec![raw_signed_transaction_hex], + (raw_signed_transaction_hex,), self.response_size_estimate(256 + HEADER_SIZE_LIMIT), + self.consensus_strategy(), ) - .await - .reduce(self.consensus_strategy()) } - pub async fn eth_get_transaction_count( + pub fn eth_get_transaction_count( &self, params: GetTransactionCountParams, - ) -> ReducedResult { - self.parallel_call( + ) -> MultiRpcRequest { + MultiRpcRequest::new( + self.providers(), RpcMethod::EthGetTransactionCount, params, self.response_size_estimate(50 + HEADER_SIZE_LIMIT), + self.consensus_strategy(), ) - .await - .reduce(self.consensus_strategy()) } - pub async fn eth_call(&self, params: EthCallParams) -> ReducedResult { - self.parallel_call( + pub fn eth_call(&self, params: EthCallParams) -> MultiRpcRequest { + MultiRpcRequest::new( + self.providers(), RpcMethod::EthCall, params, self.response_size_estimate(256 + HEADER_SIZE_LIMIT), + self.consensus_strategy(), ) - .await - .reduce(self.consensus_strategy()) } - pub async fn multi_request( + pub fn multi_request<'a>( &self, method: RpcMethod, - params: Option<&Value>, - ) -> ReducedResult { - self.parallel_call( + params: Option<&'a Value>, + ) -> MultiRpcRequest, RawJson> { + MultiRpcRequest::new( + self.providers(), method, params, self.response_size_estimate(256 + HEADER_SIZE_LIMIT), + self.consensus_strategy(), ) - .await - .reduce(self.consensus_strategy()) + } +} + +pub struct MultiRpcRequest<'a, Params, Output> { + providers: &'a BTreeSet, + method: RpcMethod, + params: Params, + response_size_estimate: ResponseSizeEstimate, + reduction_strategy: ReductionStrategy, + _marker: std::marker::PhantomData, +} + +impl<'a, Params, Output> MultiRpcRequest<'a, Params, Output> { + pub fn new( + providers: &'a BTreeSet, + method: RpcMethod, + params: Params, + response_size_estimate: ResponseSizeEstimate, + reduction_strategy: ReductionStrategy, + ) -> MultiRpcRequest<'a, Params, Output> { + MultiRpcRequest { + providers, + method, + params, + response_size_estimate, + reduction_strategy, + _marker: Default::default(), + } + } +} + +impl MultiRpcRequest<'_, Params, Output> { + pub async fn send_and_reduce(self) -> MultiRpcResult + where + Params: Serialize + Clone + Debug, + Output: Debug + Serialize + DeserializeOwned + HttpResponsePayload + PartialEq, + { + let result = self.parallel_call().await.reduce(self.reduction_strategy); + process_result(self.method, result) + } + + /// Query all providers in parallel and return all results. + /// It's up to the caller to decide how to handle the results, which could be inconsistent + /// (e.g., if different providers gave different responses). + /// This method is useful for querying data that is critical for the system to ensure that there is no single point of failure, + /// e.g., ethereum logs upon which ckETH will be minted. + async fn parallel_call(&self) -> MultiResults + where + Params: Serialize + Clone + Debug, + Output: Debug + DeserializeOwned + HttpResponsePayload, + { + let requests = self.create_json_rpc_requests(); + + let client = + http_client(MetricRpcMethod::from(self.method.clone()), true).map_result(|r| match r? + .into_body() + .into_result() + { + Ok(value) => Ok(value), + Err(json_rpc_error) => Err(RpcError::JsonRpcError(JsonRpcError { + code: json_rpc_error.code, + message: json_rpc_error.message, + })), + }); + + let (requests, errors) = requests.into_inner(); + let (_client, mut results) = canhttp::multi::parallel_call(client, requests).await; + results.add_errors(errors); + let now = Timestamp::from_nanos_since_unix_epoch(ic_cdk::api::time()); + results + .ok_results() + .keys() + .filter_map(SupportedRpcService::new) + .for_each(|service| record_ok_result(service, now)); + assert_eq!( + results.len(), + self.providers.len(), + "BUG: expected 1 result per provider" + ); + results + } + + fn create_json_rpc_requests( + &self, + ) -> MultiResults>, RpcError> + where + Params: Clone, + Output: HttpResponsePayload, + { + let transform_op = Output::response_transform() + .as_ref() + .map(|t| { + let mut buf = vec![]; + minicbor::encode(t, &mut buf).unwrap(); + buf + }) + .unwrap_or_default(); + let effective_size_estimate = self.response_size_estimate.get(); + let mut requests = MultiResults::default(); + for provider in self.providers { + let request = resolve_rpc_service(provider.clone()) + .map_err(RpcError::from) + .and_then(|rpc_service| rpc_service.post(&get_override_provider())) + .map(|builder| { + builder + .max_response_bytes(effective_size_estimate) + .transform_context(TransformContext { + function: TransformFunc(candid::Func { + method: "cleanup_response".to_string(), + principal: ic_cdk::api::canister_self(), + }), + context: transform_op.clone(), + }) + .body(JsonRpcRequest::new( + self.method.clone().name(), + self.params.clone(), + )) + .expect("BUG: invalid request") + }); + requests.insert_once(provider.clone(), request); + } + requests } } @@ -459,7 +523,10 @@ impl From for ReductionStrategy { } impl Reduce for ReductionStrategy { - fn reduce(&self, results: MultiResults) -> ReducedResult { + fn reduce( + &self, + results: MultiResults, + ) -> ReducedResult { match self { ReductionStrategy::ByEquality(r) => r.reduce(results), ReductionStrategy::ByThreshold(r) => r.reduce(results), @@ -467,5 +534,36 @@ impl Reduce for ReductionStra } } -pub type MultiCallResults = MultiResults; -pub type ReducedResult = canhttp::multi::ReducedResult; +fn process_result( + method: impl Into + Clone, + result: ReducedResult, +) -> MultiRpcResult { + match result { + Ok(value) => MultiRpcResult::Consistent(Ok(value)), + Err(err) => match err { + ReductionError::ConsistentError(err) => MultiRpcResult::Consistent(Err(err)), + ReductionError::InconsistentResults(multi_call_results) => { + let results: Vec<_> = multi_call_results.into_iter().collect(); + results.iter().for_each(|(service, _service_result)| { + if let Ok(ResolvedRpcService::Provider(provider)) = + resolve_rpc_service(service.clone()) + { + add_metric_entry!( + inconsistent_responses, + ( + method.clone().into(), + MetricRpcHost( + provider + .hostname() + .unwrap_or_else(|| "(unknown)".to_string()) + ) + ), + 1 + ) + } + }); + MultiRpcResult::Inconsistent(results) + } + }, + } +} diff --git a/src/rpc_client/tests.rs b/src/rpc_client/tests.rs index e2e0a2de..bad2843a 100644 --- a/src/rpc_client/tests.rs +++ b/src/rpc_client/tests.rs @@ -1,3 +1,8 @@ +use crate::rpc_client::process_result; +use crate::types::RpcMethod; +use canhttp::multi::MultiResults; +use evm_rpc_types::{MultiRpcResult, ProviderError, RpcError}; + mod eth_rpc_client { use crate::rpc_client::EthRpcClient; use canhttp::multi::Timestamp; @@ -365,3 +370,68 @@ mod providers { } } } + +#[test] +fn test_process_result_mapping() { + use evm_rpc_types::{EthMainnetService, RpcService}; + type ReductionError = canhttp::multi::ReductionError; + + assert_eq!( + process_result(RpcMethod::EthGetTransactionCount, Ok(5)), + MultiRpcResult::Consistent(Ok(5)) + ); + assert_eq!( + process_result( + RpcMethod::EthGetTransactionCount, + Err(ReductionError::ConsistentError(RpcError::ProviderError( + ProviderError::MissingRequiredProvider + ))) + ), + MultiRpcResult::Consistent(Err(RpcError::ProviderError( + ProviderError::MissingRequiredProvider + ))) + ); + assert_eq!( + process_result( + RpcMethod::EthGetTransactionCount, + Err(ReductionError::InconsistentResults(MultiResults::default())) + ), + MultiRpcResult::Inconsistent(vec![]) + ); + assert_eq!( + process_result( + RpcMethod::EthGetTransactionCount, + Err(ReductionError::InconsistentResults( + MultiResults::from_non_empty_iter(vec![( + RpcService::EthMainnet(EthMainnetService::Ankr), + Ok(5) + )]) + )) + ), + MultiRpcResult::Inconsistent(vec![( + RpcService::EthMainnet(EthMainnetService::Ankr), + Ok(5) + )]) + ); + assert_eq!( + process_result( + RpcMethod::EthGetTransactionCount, + Err(ReductionError::InconsistentResults( + MultiResults::from_non_empty_iter(vec![ + (RpcService::EthMainnet(EthMainnetService::Ankr), Ok(5)), + ( + RpcService::EthMainnet(EthMainnetService::Cloudflare), + Err(RpcError::ProviderError(ProviderError::NoPermission)) + ) + ]) + )) + ), + MultiRpcResult::Inconsistent(vec![ + (RpcService::EthMainnet(EthMainnetService::Ankr), Ok(5)), + ( + RpcService::EthMainnet(EthMainnetService::Cloudflare), + Err(RpcError::ProviderError(ProviderError::NoPermission)) + ) + ]) + ); +} From bb612a67e08ba21797666b7662881e3160975227 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 24 Oct 2025 11:16:56 +0200 Subject: [PATCH 03/19] Add `*_request_cost` methods to `CandidRpcClient` --- src/candid_rpc/cketh_conversion.rs | 15 +++- src/candid_rpc/mod.rs | 113 ++++++++++++++++++++++++----- src/main.rs | 18 +++++ src/rpc_client/mod.rs | 66 +++++++++++++++-- 4 files changed, 185 insertions(+), 27 deletions(-) diff --git a/src/candid_rpc/cketh_conversion.rs b/src/candid_rpc/cketh_conversion.rs index 3d3b99c7..5cf8349b 100644 --- a/src/candid_rpc/cketh_conversion.rs +++ b/src/candid_rpc/cketh_conversion.rs @@ -8,7 +8,10 @@ use crate::rpc_client::{ Hash, JsonByte, StorageKey, }, }; -use evm_rpc_types::{BlockTag, Hex, Hex20, Hex256, Hex32, HexByte, Nat256}; +use canhttp::http::json::JsonRpcRequest; +use evm_rpc_types::{ + BlockTag, Hex, Hex20, Hex256, Hex32, HexByte, Nat256, RpcError, RpcResult, ValidationError, +}; use ic_ethereum_types::Address; pub(super) fn into_block_spec(value: BlockTag) -> BlockSpec { @@ -269,3 +272,13 @@ pub(super) fn from_data(value: Data) -> Hex { fn into_data(value: Hex) -> Data { Data::from(Vec::::from(value)) } + +pub(super) fn into_json_request( + json_rpc_payload: String, +) -> RpcResult> { + serde_json::from_str(&json_rpc_payload).map_err(|e| { + RpcError::ValidationError(ValidationError::Custom(format!( + "Invalid JSON RPC request: {e}" + ))) + }) +} diff --git a/src/candid_rpc/mod.rs b/src/candid_rpc/mod.rs index c6cb891a..1ea5e626 100644 --- a/src/candid_rpc/mod.rs +++ b/src/candid_rpc/mod.rs @@ -1,13 +1,13 @@ mod cketh_conversion; -use crate::rpc_client::EthRpcClient; -use crate::types::RpcMethod; +use crate::{ + candid_rpc::cketh_conversion::into_json_request, rpc_client::EthRpcClient, types::RpcMethod, +}; use candid::Nat; -use canhttp::http::json::JsonRpcRequest; use canhttp::multi::Timestamp; use ethers_core::{types::Transaction, utils::rlp}; use evm_rpc_types::{ - BlockTag, GetLogsArgs, Hex, Hex32, MultiRpcResult, Nat256, RpcError, RpcResult, ValidationError, + BlockTag, GetLogsArgs, Hex, Hex32, MultiRpcResult, Nat256, RpcResult, ValidationError, }; /// Adapt the `EthRpcClient` to the `Candid` interface used by the EVM-RPC canister. @@ -30,7 +30,7 @@ impl CandidRpcClient { &self, args: evm_rpc_types::GetLogsArgs, ) -> MultiRpcResult> { - use crate::candid_rpc::cketh_conversion::{from_log_entries, into_get_logs_param}; + use cketh_conversion::{from_log_entries, into_get_logs_param}; self.client .eth_get_logs(into_get_logs_param(args)) .send_and_reduce() @@ -38,11 +38,22 @@ impl CandidRpcClient { .map(from_log_entries) } + pub async fn eth_get_logs_request_cost( + &self, + args: evm_rpc_types::GetLogsArgs, + ) -> RpcResult { + use cketh_conversion::into_get_logs_param; + self.client + .eth_get_logs(into_get_logs_param(args)) + .cycles_cost() + .await + } + pub async fn eth_get_block_by_number( &self, block: BlockTag, ) -> MultiRpcResult { - use crate::candid_rpc::cketh_conversion::{from_block, into_block_spec}; + use cketh_conversion::{from_block, into_block_spec}; self.client .eth_get_block_by_number(into_block_spec(block)) .send_and_reduce() @@ -50,11 +61,19 @@ impl CandidRpcClient { .map(from_block) } + pub async fn eth_get_block_by_number_request_cost(&self, block: BlockTag) -> RpcResult { + use cketh_conversion::into_block_spec; + self.client + .eth_get_block_by_number(into_block_spec(block)) + .cycles_cost() + .await + } + pub async fn eth_get_transaction_receipt( &self, hash: Hex32, ) -> MultiRpcResult> { - use crate::candid_rpc::cketh_conversion::{from_transaction_receipt, into_hash}; + use cketh_conversion::{from_transaction_receipt, into_hash}; self.client .eth_get_transaction_receipt(into_hash(hash)) .send_and_reduce() @@ -62,11 +81,19 @@ impl CandidRpcClient { .map(|option| option.map(from_transaction_receipt)) } + pub async fn eth_get_transaction_receipt_request_cost(&self, hash: Hex32) -> RpcResult { + use cketh_conversion::into_hash; + self.client + .eth_get_transaction_receipt(into_hash(hash)) + .cycles_cost() + .await + } + pub async fn eth_get_transaction_count( &self, args: evm_rpc_types::GetTransactionCountArgs, ) -> MultiRpcResult { - use crate::candid_rpc::cketh_conversion::into_get_transaction_count_params; + use cketh_conversion::into_get_transaction_count_params; self.client .eth_get_transaction_count(into_get_transaction_count_params(args)) .send_and_reduce() @@ -74,11 +101,22 @@ impl CandidRpcClient { .map(Nat256::from) } + pub async fn eth_get_transaction_count_request_cost( + &self, + args: evm_rpc_types::GetTransactionCountArgs, + ) -> RpcResult { + use cketh_conversion::into_get_transaction_count_params; + self.client + .eth_get_transaction_count(into_get_transaction_count_params(args)) + .cycles_cost() + .await + } + pub async fn eth_fee_history( &self, args: evm_rpc_types::FeeHistoryArgs, ) -> MultiRpcResult { - use crate::candid_rpc::cketh_conversion::{from_fee_history, into_fee_history_params}; + use cketh_conversion::{from_fee_history, into_fee_history_params}; self.client .eth_fee_history(into_fee_history_params(args)) .send_and_reduce() @@ -86,11 +124,22 @@ impl CandidRpcClient { .map(from_fee_history) } + pub async fn eth_fee_history_request_cost( + &self, + args: evm_rpc_types::FeeHistoryArgs, + ) -> RpcResult { + use cketh_conversion::into_fee_history_params; + self.client + .eth_fee_history(into_fee_history_params(args)) + .cycles_cost() + .await + } + pub async fn eth_send_raw_transaction( &self, raw_signed_transaction_hex: Hex, ) -> MultiRpcResult { - use crate::candid_rpc::cketh_conversion::from_send_raw_transaction_result; + use cketh_conversion::from_send_raw_transaction_result; let transaction_hash = get_transaction_hash(&raw_signed_transaction_hex); self.client .eth_send_raw_transaction(raw_signed_transaction_hex.to_string()) @@ -99,11 +148,21 @@ impl CandidRpcClient { .map(|result| from_send_raw_transaction_result(transaction_hash.clone(), result)) } + pub async fn eth_send_raw_transaction_request_cost( + &self, + raw_signed_transaction_hex: Hex, + ) -> RpcResult { + self.client + .eth_send_raw_transaction(raw_signed_transaction_hex.to_string()) + .cycles_cost() + .await + } + pub async fn eth_call( &self, args: evm_rpc_types::CallArgs, ) -> MultiRpcResult { - use crate::candid_rpc::cketh_conversion::{from_data, into_eth_call_params}; + use cketh_conversion::{from_data, into_eth_call_params}; self.client .eth_call(into_eth_call_params(args)) .send_and_reduce() @@ -111,16 +170,19 @@ impl CandidRpcClient { .map(from_data) } + pub async fn eth_call_request_cost(&self, args: evm_rpc_types::CallArgs) -> RpcResult { + use cketh_conversion::into_eth_call_params; + self.client + .eth_call(into_eth_call_params(args)) + .cycles_cost() + .await + } + pub async fn multi_request(&self, json_rpc_payload: String) -> MultiRpcResult { - let request: JsonRpcRequest = - match serde_json::from_str(&json_rpc_payload) { - Ok(req) => req, - Err(e) => { - return MultiRpcResult::Consistent(Err(RpcError::ValidationError( - ValidationError::Custom(format!("Invalid JSON RPC request: {e}")), - ))) - } - }; + let request = match into_json_request(json_rpc_payload) { + Ok(request) => request, + Err(err) => return MultiRpcResult::Consistent(Err(err)), + }; self.client .multi_request( RpcMethod::Custom(request.method().to_string()), @@ -130,6 +192,17 @@ impl CandidRpcClient { .await .map(String::from) } + + pub async fn multi_request_cost(&self, json_rpc_payload: String) -> RpcResult { + let request = into_json_request(json_rpc_payload)?; + self.client + .multi_request( + RpcMethod::Custom(request.method().to_string()), + request.params(), + ) + .cycles_cost() + .await + } } fn get_transaction_hash(raw_signed_transaction_hex: &Hex) -> Option { diff --git a/src/main.rs b/src/main.rs index 14b25536..8aa79e44 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,6 +52,24 @@ pub async fn eth_get_logs( } } +#[update(name = "eth_getLogsRequestCost")] +pub async fn eth_get_logs_request_cost( + source: RpcServices, + config: Option, + args: evm_rpc_types::GetLogsArgs, +) -> RpcResult { + let config = config.unwrap_or_default(); + let max_block_range = config.max_block_range_or_default(); + validate_get_logs_block_range(&args, max_block_range)?; + if is_demo_active() { + return Ok(0); + } + match CandidRpcClient::new(source, Some(RpcConfig::from(config)), now()) { + Ok(source) => source.eth_get_logs_request_cost(args).await, + Err(err) => Err(err), + } +} + #[update(name = "eth_getBlockByNumber")] pub async fn eth_get_block_by_number( source: RpcServices, diff --git a/src/rpc_client/mod.rs b/src/rpc_client/mod.rs index 40c60cb2..af6fbf7c 100644 --- a/src/rpc_client/mod.rs +++ b/src/rpc_client/mod.rs @@ -1,7 +1,8 @@ -use crate::types::{MetricRpcHost, ResolvedRpcService}; use crate::{ add_metric_entry, - http::http_client, + http::{ + charging_policy_with_collateral, http_client, service_request_builder, HttpClientError, + }, memory::{get_override_provider, rank_providers, record_ok_result}, providers::{resolve_rpc_service, SupportedRpcService}, rpc_client::{ @@ -9,9 +10,10 @@ use crate::{ json::responses::RawJson, numeric::TransactionCount, }, - types::{MetricRpcMethod, RpcMethod}, + types::{MetricRpcHost, MetricRpcMethod, ResolvedRpcService, RpcMethod}, }; use canhttp::{ + cycles::CyclesChargingPolicy, http::json::JsonRpcRequest, multi::{ MultiResults, Reduce, ReduceWithEquality, ReduceWithThreshold, ReducedResult, @@ -20,11 +22,13 @@ use canhttp::{ MaxResponseBytesRequestExtension, TransformContextRequestExtension, }; use evm_rpc_types::{ - ConsensusStrategy, JsonRpcError, MultiRpcResult, ProviderError, RpcConfig, RpcError, + ConsensusStrategy, JsonRpcError, MultiRpcResult, ProviderError, RpcConfig, RpcError, RpcResult, RpcService, RpcServices, }; -use http::Request; -use ic_management_canister_types::{TransformContext, TransformFunc}; +use http::{Request, Response}; +use ic_management_canister_types::{ + HttpRequestArgs as IcHttpRequest, TransformContext, TransformFunc, +}; use json::{ requests::{ BlockSpec, EthCallParams, FeeHistoryParams, GetBlockByNumberParams, GetLogsParam, @@ -463,6 +467,56 @@ impl MultiRpcRequest<'_, Params, Output> { results } + /// Estimate the exact cycles cost for the given request. + /// + /// *IMPORTANT*: the method is *synchronous* in a canister environment. + pub async fn cycles_cost(&self) -> RpcResult + where + Params: Serialize + Clone + Debug, + Output: HttpResponsePayload, + { + async fn extract_request( + request: IcHttpRequest, + ) -> Result, HttpClientError> { + Ok(Response::new(request)) + } + + let requests = self.create_json_rpc_requests(); + + let client = service_request_builder() + .service_fn(extract_request) + .map_err(RpcError::from) + .map_response(Response::into_body); + + let (requests, errors) = requests.into_inner(); + if let Some(error) = errors.into_values().next() { + return Err(error); + } + + let (_client, results) = canhttp::multi::parallel_call(client, requests).await; + let (requests, errors) = results.into_inner(); + if !errors.is_empty() { + return Err(errors + .into_values() + .next() + .expect("BUG: errors is not empty")); + } + assert_eq!( + requests.len(), + self.providers.len(), + "BUG: expected 1 result per provider" + ); + + let mut cycles_to_attach = 0_u128; + + let policy = charging_policy_with_collateral(); + for request in requests.into_values() { + let request_cycles_cost = ic_cdk::management_canister::cost_http_request(&request); + cycles_to_attach += policy.cycles_to_charge(&request, request_cycles_cost) + } + Ok(cycles_to_attach) + } + fn create_json_rpc_requests( &self, ) -> MultiResults>, RpcError> From a101ab5531811aaa29ac3eb9bc00fbe833100ee7 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 24 Oct 2025 14:06:44 +0200 Subject: [PATCH 04/19] Add `CyclesCost` endpoints --- candid/evm_rpc.did | 25 ++++++++ evm_rpc_client/src/lib.rs | 54 ++++++++++++++--- evm_rpc_client/src/request/mod.rs | 52 +++++++++++++++- src/candid_rpc/mod.rs | 16 ++--- src/main.rs | 98 ++++++++++++++++++++++++++++++- 5 files changed, 224 insertions(+), 21 deletions(-) diff --git a/candid/evm_rpc.did b/candid/evm_rpc.did index bd34aa11..88e03624 100644 --- a/candid/evm_rpc.did +++ b/candid/evm_rpc.did @@ -300,19 +300,44 @@ type MultiJsonRequestResult = variant { Inconsistent : vec record { RpcService; JsonRequestResult }; }; service : (InstallArgs) -> { + // Call the `eth_feeHistory` RPC method and return the resulting fee history. eth_feeHistory : (RpcServices, opt RpcConfig, FeeHistoryArgs) -> (MultiFeeHistoryResult); + eth_feeHistoryCyclesCost : (RpcServices, opt RpcConfig, FeeHistoryArgs) -> (RequestCostResult) query; + + // Call the `eth_getBlockByNumber` RPC method and return the resulting block. eth_getBlockByNumber : (RpcServices, opt RpcConfig, BlockTag) -> (MultiGetBlockByNumberResult); + eth_getBlockByNumberCyclesCost : (RpcServices, opt RpcConfig, BlockTag) -> (RequestCostResult) query; + + // Call the `eth_getLogs` RPC method and return the resulting logs. eth_getLogs : (RpcServices, opt GetLogsRpcConfig, GetLogsArgs) -> (MultiGetLogsResult); + eth_getLogsCyclesCost : (RpcServices, opt GetLogsRpcConfig, GetLogsArgs) -> (RequestCostResult) query; + + // Call the `eth_getTransactionCount` RPC method and return the resulting transaction count. eth_getTransactionCount : (RpcServices, opt RpcConfig, GetTransactionCountArgs) -> (MultiGetTransactionCountResult); + eth_getTransactionCountCyclesCost : (RpcServices, opt RpcConfig, GetTransactionCountArgs) -> (RequestCostResult) query; + + // Call the `eth_getTransactionReceipt` RPC method and return the resulting transaction receipt. eth_getTransactionReceipt : (RpcServices, opt RpcConfig, hash : text) -> (MultiGetTransactionReceiptResult); + eth_getTransactionReceiptCyclesCost : (RpcServices, opt RpcConfig, hash : text) -> (RequestCostResult) query; + + // Call the `eth_sendRawTransaction` RPC method and return the resulting transaction hash. eth_sendRawTransaction : (RpcServices, opt RpcConfig, rawSignedTransactionHex : text) -> (MultiSendRawTransactionResult); + eth_sendRawTransactionCyclesCost : (RpcServices, opt RpcConfig, rawSignedTransactionHex : text) -> (RequestCostResult) query; + + // Call the `eth_call` RPC method and return the resulting output. eth_call : (RpcServices, opt RpcConfig, CallArgs) -> (MultiCallResult); + eth_callCyclesCost : (RpcServices, opt RpcConfig, CallArgs) -> (RequestCostResult) query; + + // Make a raw JSON-RPC request that sends the given JSON-RPC payload. // This endpoint should be used instead of the `request` endpoint as it allows aggregating the responses from // multiple providers. It also takes parameters consistent with `eth_*` endpoints, i.e. an `RpcServices` instead // of an `RpcService`. multi_request : (RpcServices, opt RpcConfig, json : text) -> (MultiJsonRequestResult); + multi_requestCyclesCost : (RpcServices, opt RpcConfig, json : text) -> (RequestCostResult) query; + // DEPRECATED: Use `multi_request` instead. request : (RpcService, json : text, maxResponseBytes : nat64) -> (RequestResult); + // DEPRECATED: Use the specific `*RequestCost` endpoints instead (e.g. `eth_feeHistoryRequestCost`). requestCost : (RpcService, json : text, maxResponseBytes : nat64) -> (RequestCostResult) query; getNodesInSubnet : () -> (numberOfNodes : nat32) query; diff --git a/evm_rpc_client/src/lib.rs b/evm_rpc_client/src/lib.rs index 2756c1c4..5f923a20 100644 --- a/evm_rpc_client/src/lib.rs +++ b/evm_rpc_client/src/lib.rs @@ -33,14 +33,19 @@ //! .build(); //! ``` //! -//! ## Specifying the amount of cycles to send +//! ## Estimating the amount of cycles to send //! //! Every call made to the EVM RPC canister that triggers HTTPs outcalls (e.g., `eth_getLogs`) //! needs to attach some cycles to pay for the call. //! By default, the client will attach some amount of cycles that should be sufficient for most cases. //! -//! If this is not the case, the amount of cycles to be sent can be overridden. It's advisable to -//! actually send *more* cycles than required, since *unused cycles will be refunded*. +//! If this is not the case, the amount of cycles to be sent can be changed as follows: +//! 1. Determine the required amount of cycles to send for a particular request. +//! The EVM RPC canister offers some query endpoints (e.g., `eth_getLogsCyclesCost`) for that purpose. +//! This could help establishing a baseline so that the estimated cycles cost for similar requests +//! can be extrapolated from it instead of making additional queries to the EVM RPC canister. +//! 2. Override the amount of cycles to send for that particular request. +//! It's advisable to actually send *more* cycles than required, since *unused cycles will be refunded*. //! //! ```rust //! use alloy_primitives::{address, U256}; @@ -48,20 +53,26 @@ //! use evm_rpc_client::EvmRpcClient; //! use evm_rpc_types::MultiRpcResult; //! -//! # use evm_rpc_types::Nat256; +//! # use evm_rpc_types::{Nat256, RpcError}; //! # #[tokio::main] //! # async fn main() -> Result<(), Box> { //! let client = EvmRpcClient::builder_for_ic() //! .with_alloy() -//! # .with_default_stub_response(MultiRpcResult::Consistent(Ok(Nat256::from(1_u64)))) +//! # .with_stub_responses() +//! # .with_response_for_method("eth_getTransactionCountCyclesCost", Ok::(100_000_000_000)) +//! # .with_response_for_method("eth_getTransactionCount", MultiRpcResult::Consistent(Ok(Nat256::from(1_u64)))) //! .build(); //! -//! let result = client +//! let request = client //! .get_transaction_count(( //! address!("0xdac17f958d2ee523a2206206994597c13d831ec7"), //! BlockNumberOrTag::Latest, -//! )) -//! .with_cycles(20_000_000_000) +//! )); +//! +//! let minimum_required_cycles_amount = request.clone().request_cost().send().await.unwrap(); +//! +//! let result = request +//! .with_cycles(minimum_required_cycles_amount) //! .send() //! .await //! .expect_consistent(); @@ -122,10 +133,11 @@ pub mod fixtures; mod request; mod runtime; +use crate::request::RequestCost; use candid::{CandidType, Principal}; use evm_rpc_types::{ BlockTag, CallArgs, ConsensusStrategy, FeeHistoryArgs, GetLogsArgs, GetTransactionCountArgs, - Hex, Hex32, RpcConfig, RpcServices, + Hex, Hex32, RpcConfig, RpcResult, RpcServices, }; #[cfg(feature = "alloy")] pub use request::alloy::AlloyResponseConverter; @@ -799,4 +811,28 @@ impl EvmRpcClient { .await .map(Into::into) } + + async fn execute_cycles_cost_request( + &self, + request: RequestCost, + ) -> RpcResult + where + Config: CandidType + Send, + Params: CandidType + Send, + { + self.config + .runtime + .query_call::<(RpcServices, Option, Params), RpcResult>( + self.config.evm_rpc_canister, + request.endpoint.cycles_cost_method(), + (request.rpc_services, request.rpc_config, request.params), + ) + .await + .unwrap_or_else(|e| { + panic!( + "Client error: failed to call `{}`: {e:?}", + request.endpoint.cycles_cost_method() + ) + }) + } } diff --git a/evm_rpc_client/src/request/mod.rs b/evm_rpc_client/src/request/mod.rs index 0f09789d..e0b2b362 100644 --- a/evm_rpc_client/src/request/mod.rs +++ b/evm_rpc_client/src/request/mod.rs @@ -6,7 +6,8 @@ use crate::{EvmRpcClient, Runtime}; use candid::CandidType; use evm_rpc_types::{ BlockTag, CallArgs, ConsensusStrategy, FeeHistoryArgs, GetLogsArgs, GetLogsRpcConfig, - GetTransactionCountArgs, Hex, Hex20, Hex32, MultiRpcResult, Nat256, RpcConfig, RpcServices, + GetTransactionCountArgs, Hex, Hex20, Hex32, MultiRpcResult, Nat256, RpcConfig, RpcResult, + RpcServices, }; use serde::de::DeserializeOwned; use std::fmt::{Debug, Formatter}; @@ -396,6 +397,20 @@ impl EvmRpcEndpoint { Self::SendRawTransaction => "eth_sendRawTransaction", } } + + /// Method name on the EVM RPC canister to estimate the amount of cycles for that request. + pub fn cycles_cost_method(&self) -> &'static str { + match &self { + Self::Call => "eth_callCyclesCost", + Self::FeeHistory => "eth_feeHistoryCyclesCost", + Self::GetBlockByNumber => "eth_getBlockByNumberCyclesCost", + Self::GetLogs => "eth_getLogsCyclesCost", + Self::GetTransactionCount => "eth_getTransactionCountCyclesCost", + Self::GetTransactionReceipt => "eth_getTransactionReceiptCyclesCost", + Self::MultiRequest => "multi_requestCyclesCost", + Self::SendRawTransaction => "eth_sendRawTransactionCyclesCost", + } + } } /// A builder to construct a [`Request`]. @@ -459,6 +474,22 @@ impl } } + /// Query the cycles cost for that request + pub fn request_cost(self) -> RequestCostBuilder { + RequestCostBuilder { + client: self.client, + request: RequestCost { + endpoint: self.request.endpoint, + rpc_services: self.request.rpc_services, + rpc_config: self.request.rpc_config, + params: self.request.params, + cycles: 0, + _candid_marker: Default::default(), + _output_marker: Default::default(), + }, + } + } + /// Change the amount of cycles to send for that request. pub fn with_cycles(mut self, cycles: u128) -> Self { *self.request.cycles_mut() = cycles; @@ -695,6 +726,25 @@ impl Request = Request, RpcResult>; + +#[must_use = "RequestCostBuilder does nothing until you 'send' it"] +pub struct RequestCostBuilder { + client: EvmRpcClient, + request: RequestCost, +} + +impl RequestCostBuilder { + /// Constructs the [`Request`] and send it using the [`SolRpcClient`]. + pub async fn send(self) -> RpcResult + where + Config: CandidType + Send, + Params: CandidType + Send, + { + self.client.execute_cycles_cost_request(self.request).await + } +} + // This trait is not public, otherwise adding a new endpoint to the EVM RPC canister would be // a breaking change since it would add a new associated type to this trait. pub trait EvmRpcResponseConverter { diff --git a/src/candid_rpc/mod.rs b/src/candid_rpc/mod.rs index 1ea5e626..7cba3969 100644 --- a/src/candid_rpc/mod.rs +++ b/src/candid_rpc/mod.rs @@ -38,7 +38,7 @@ impl CandidRpcClient { .map(from_log_entries) } - pub async fn eth_get_logs_request_cost( + pub async fn eth_get_logs_cycles_cost( &self, args: evm_rpc_types::GetLogsArgs, ) -> RpcResult { @@ -61,7 +61,7 @@ impl CandidRpcClient { .map(from_block) } - pub async fn eth_get_block_by_number_request_cost(&self, block: BlockTag) -> RpcResult { + pub async fn eth_get_block_by_number_cycles_cost(&self, block: BlockTag) -> RpcResult { use cketh_conversion::into_block_spec; self.client .eth_get_block_by_number(into_block_spec(block)) @@ -81,7 +81,7 @@ impl CandidRpcClient { .map(|option| option.map(from_transaction_receipt)) } - pub async fn eth_get_transaction_receipt_request_cost(&self, hash: Hex32) -> RpcResult { + pub async fn eth_get_transaction_receipt_cycles_cost(&self, hash: Hex32) -> RpcResult { use cketh_conversion::into_hash; self.client .eth_get_transaction_receipt(into_hash(hash)) @@ -101,7 +101,7 @@ impl CandidRpcClient { .map(Nat256::from) } - pub async fn eth_get_transaction_count_request_cost( + pub async fn eth_get_transaction_count_cycles_cost( &self, args: evm_rpc_types::GetTransactionCountArgs, ) -> RpcResult { @@ -124,7 +124,7 @@ impl CandidRpcClient { .map(from_fee_history) } - pub async fn eth_fee_history_request_cost( + pub async fn eth_fee_history_cycles_cost( &self, args: evm_rpc_types::FeeHistoryArgs, ) -> RpcResult { @@ -148,7 +148,7 @@ impl CandidRpcClient { .map(|result| from_send_raw_transaction_result(transaction_hash.clone(), result)) } - pub async fn eth_send_raw_transaction_request_cost( + pub async fn eth_send_raw_transaction_cycles_cost( &self, raw_signed_transaction_hex: Hex, ) -> RpcResult { @@ -170,7 +170,7 @@ impl CandidRpcClient { .map(from_data) } - pub async fn eth_call_request_cost(&self, args: evm_rpc_types::CallArgs) -> RpcResult { + pub async fn eth_call_cycles_cost(&self, args: evm_rpc_types::CallArgs) -> RpcResult { use cketh_conversion::into_eth_call_params; self.client .eth_call(into_eth_call_params(args)) @@ -193,7 +193,7 @@ impl CandidRpcClient { .map(String::from) } - pub async fn multi_request_cost(&self, json_rpc_payload: String) -> RpcResult { + pub async fn multi_cycles_cost(&self, json_rpc_payload: String) -> RpcResult { let request = into_json_request(json_rpc_payload)?; self.client .multi_request( diff --git a/src/main.rs b/src/main.rs index 8aa79e44..2410d7e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,8 +52,8 @@ pub async fn eth_get_logs( } } -#[update(name = "eth_getLogsRequestCost")] -pub async fn eth_get_logs_request_cost( +#[query(name = "eth_getLogsCyclesCost")] +pub async fn eth_get_logs_cycles_cost( source: RpcServices, config: Option, args: evm_rpc_types::GetLogsArgs, @@ -65,7 +65,7 @@ pub async fn eth_get_logs_request_cost( return Ok(0); } match CandidRpcClient::new(source, Some(RpcConfig::from(config)), now()) { - Ok(source) => source.eth_get_logs_request_cost(args).await, + Ok(source) => source.eth_get_logs_cycles_cost(args).await, Err(err) => Err(err), } } @@ -82,6 +82,18 @@ pub async fn eth_get_block_by_number( } } +#[query(name = "eth_getBlockByNumberCyclesCost")] +pub async fn eth_get_block_by_number_cycles_cost( + source: RpcServices, + config: Option, + block: evm_rpc_types::BlockTag, +) -> RpcResult { + match CandidRpcClient::new(source, config, now()) { + Ok(source) => source.eth_get_block_by_number_cycles_cost(block).await, + Err(err) => Err(err), + } +} + #[update(name = "eth_getTransactionReceipt")] pub async fn eth_get_transaction_receipt( source: RpcServices, @@ -94,6 +106,22 @@ pub async fn eth_get_transaction_receipt( } } +#[query(name = "eth_getTransactionReceiptCyclesCost")] +pub async fn eth_get_transaction_receipt_cycles_cost( + source: RpcServices, + config: Option, + tx_hash: Hex32, +) -> RpcResult { + match CandidRpcClient::new(source, config, now()) { + Ok(source) => { + source + .eth_get_transaction_receipt_cycles_cost(tx_hash) + .await + } + Err(err) => Err(err), + } +} + #[update(name = "eth_getTransactionCount")] pub async fn eth_get_transaction_count( source: RpcServices, @@ -106,6 +134,18 @@ pub async fn eth_get_transaction_count( } } +#[query(name = "eth_getTransactionCountCyclesCost")] +pub async fn eth_get_transaction_count_cycles_cost( + source: RpcServices, + config: Option, + args: evm_rpc_types::GetTransactionCountArgs, +) -> RpcResult { + match CandidRpcClient::new(source, config, now()) { + Ok(source) => source.eth_get_transaction_count_cycles_cost(args).await, + Err(err) => Err(err), + } +} + #[update(name = "eth_feeHistory")] pub async fn eth_fee_history( source: RpcServices, @@ -118,6 +158,18 @@ pub async fn eth_fee_history( } } +#[query(name = "eth_feeHistoryCyclesCost")] +pub async fn eth_fee_history_cycles_cost( + source: RpcServices, + config: Option, + args: evm_rpc_types::FeeHistoryArgs, +) -> RpcResult { + match CandidRpcClient::new(source, config, now()) { + Ok(source) => source.eth_fee_history_cycles_cost(args).await, + Err(err) => Err(err), + } +} + #[update(name = "eth_sendRawTransaction")] pub async fn eth_send_raw_transaction( source: RpcServices, @@ -134,6 +186,22 @@ pub async fn eth_send_raw_transaction( } } +#[query(name = "eth_sendRawTransactionCyclesCost")] +pub async fn eth_send_raw_transaction_cycles_cost( + source: RpcServices, + config: Option, + raw_signed_transaction_hex: evm_rpc_types::Hex, +) -> RpcResult { + match CandidRpcClient::new(source, config, now()) { + Ok(source) => { + source + .eth_send_raw_transaction_cycles_cost(raw_signed_transaction_hex) + .await + } + Err(err) => Err(err), + } +} + #[update(name = "eth_call")] pub async fn eth_call( source: RpcServices, @@ -146,6 +214,18 @@ pub async fn eth_call( } } +#[query(name = "eth_callCyclesCost")] +pub async fn eth_call_cycles_cost( + source: RpcServices, + config: Option, + args: evm_rpc_types::CallArgs, +) -> RpcResult { + match CandidRpcClient::new(source, config, now()) { + Ok(source) => source.eth_call_cycles_cost(args).await, + Err(err) => Err(err), + } +} + #[update(name = "multi_request")] pub async fn multi_request( source: RpcServices, @@ -158,6 +238,18 @@ pub async fn multi_request( } } +#[query(name = "multi_requestCyclesCost")] +pub async fn multi_cycles_cost( + source: RpcServices, + config: Option, + args: String, +) -> RpcResult { + match CandidRpcClient::new(source, config, now()) { + Ok(source) => source.multi_cycles_cost(args).await, + Err(err) => Err(err), + } +} + #[update] async fn request( service: evm_rpc_types::RpcService, From d7a8a66d05325f351572db0996c6698b2acda78b Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 24 Oct 2025 15:57:40 +0200 Subject: [PATCH 05/19] Add integration tests --- Cargo.lock | 2 + Cargo.toml | 3 + evm_rpc_client/src/lib.rs | 4 +- tests/mock_http_runtime/mock/mod.rs | 9 ++ tests/tests.rs | 181 ++++++++++++++++++++++++++-- 5 files changed, 185 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f6b9d841..4a707c46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1644,6 +1644,7 @@ dependencies = [ "ic-test-utilities-load-wasm", "maplit", "minicbor 1.1.0", + "num-traits", "pocket-ic", "proptest", "rand 0.8.5", @@ -1652,6 +1653,7 @@ dependencies = [ "serde_bytes", "serde_json", "sha2", + "strum 0.27.2", "thiserror 2.0.17", "thousands", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 2d6b5c53..3eae25b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,9 +61,11 @@ ic-management-canister-types = { workspace = true } ic-metrics-assert = { workspace = true } ic-test-utilities-load-wasm = { git = "https://github.com/dfinity/ic", rev = "release-2024-09-26_01-31-base" } maplit = { workspace = true } +num-traits = { workspace = true } pocket-ic = { workspace = true } proptest = { workspace = true } rand = { workspace = true } +strum = { workspace = true } tokio = { workspace = true } [workspace.dependencies] @@ -97,6 +99,7 @@ ic-stable-structures = "0.6.8" maplit = "1.0.2" minicbor = { version = "1.0.0", features = ["alloc", "derive"] } num-bigint = "0.4.6" +num-traits = "0.2.19" pocket-ic = "9.0.0" proptest = "1.6.0" rand = "0.8.0" diff --git a/evm_rpc_client/src/lib.rs b/evm_rpc_client/src/lib.rs index 5f923a20..1faf27d8 100644 --- a/evm_rpc_client/src/lib.rs +++ b/evm_rpc_client/src/lib.rs @@ -146,10 +146,10 @@ use request::{ FeeHistoryRequestBuilder, GetBlockByNumberRequest, GetBlockByNumberRequestBuilder, GetLogsRequest, GetLogsRequestBuilder, GetTransactionCountRequest, GetTransactionCountRequestBuilder, GetTransactionReceiptRequest, - GetTransactionReceiptRequestBuilder, JsonRequest, JsonRequestBuilder, Request, RequestBuilder, + GetTransactionReceiptRequestBuilder, JsonRequest, JsonRequestBuilder, SendRawTransactionRequest, SendRawTransactionRequestBuilder, }; -pub use request::{CandidResponseConverter, EvmRpcConfig}; +pub use request::{CandidResponseConverter, EvmRpcConfig, EvmRpcEndpoint, Request, RequestBuilder}; pub use runtime::{IcError, IcRuntime, Runtime}; use serde::de::DeserializeOwned; use std::sync::Arc; diff --git a/tests/mock_http_runtime/mock/mod.rs b/tests/mock_http_runtime/mock/mod.rs index ad3c446c..2e24b7bd 100644 --- a/tests/mock_http_runtime/mock/mod.rs +++ b/tests/mock_http_runtime/mock/mod.rs @@ -107,6 +107,15 @@ pub trait CanisterHttpRequestMatcher: Send + Debug { fn matches(&self, request: &CanisterHttpRequest) -> bool; } +#[derive(Debug)] +pub struct AnyRequestMatcher; + +impl CanisterHttpRequestMatcher for AnyRequestMatcher { + fn matches(&self, _request: &CanisterHttpRequest) -> bool { + true + } +} + pub struct CanisterHttpReply(pocket_ic::common::rest::CanisterHttpReply); impl CanisterHttpReply { diff --git a/tests/tests.rs b/tests/tests.rs index a1535f0f..6b570dbc 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -2,17 +2,21 @@ mod mock_http_runtime; mod setup; use crate::{ - mock_http_runtime::mock::{ - json::{JsonRpcRequestMatcher, JsonRpcResponse}, - CanisterHttpReject, CanisterHttpReply, MockHttpOutcalls, MockHttpOutcallsBuilder, + mock_http_runtime::{ + mock::{ + json::{JsonRpcRequestMatcher, JsonRpcResponse}, + CanisterHttpReject, CanisterHttpReply, MockHttpOutcalls, MockHttpOutcallsBuilder, + }, + MockHttpRuntime, }, setup::EvmRpcSetup, }; use alloy_primitives::{address, b256, bloom, bytes, Address, Bytes, FixedBytes, B256, B64, U256}; use alloy_rpc_types::{BlockNumberOrTag, BlockTransactions}; use assert_matches::assert_matches; -use candid::{Encode, Principal}; +use candid::{CandidType, Encode, Principal}; use canhttp::http::json::Id; +use evm_rpc_client::{EvmRpcEndpoint, RequestBuilder}; use evm_rpc_types::{ BlockTag, ConsensusStrategy, EthMainnetService, EthSepoliaService, GetLogsRpcConfig, Hex, Hex32, HttpOutcallError, InstallArgs, JsonRpcError, LegacyRejectionCode, MultiRpcResult, @@ -24,6 +28,7 @@ use pocket_ic::common::rest::CanisterHttpResponse; use pocket_ic::ErrorCode; use serde_json::{json, Value}; use std::iter; +use strum::IntoEnumIterator; const DEFAULT_CALLER_TEST_ID: Principal = Principal::from_slice(&[0x9d, 0xf7, 0x01]); const DEFAULT_CONTROLLER_TEST_ID: Principal = Principal::from_slice(&[0x9d, 0xf7, 0x02]); @@ -47,8 +52,8 @@ const MOCK_TRANSACTION: Bytes = bytes!("0xf86c098504a817c80082520894353535353535 const MOCK_TRANSACTION_HASH: B256 = b256!("0x33469b22e9f636356c4160a87eb19df52b7412e8eac32a4a55ffe88ea8350788"); -const ADDRESS: Address = address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"); -const INPUT_DATA: Bytes = +const MOCK_ADDRESS: Address = address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"); +const MOCK_INPUT_DATA: Bytes = bytes!("0x70a08231000000000000000000000000b25eA1D493B49a1DeD42aC5B1208cC618f9A9B80"); const RPC_SERVICES: &[RpcServices] = &[ @@ -898,8 +903,8 @@ async fn eth_call_should_succeed() { .build() .call( alloy_rpc_types::TransactionRequest::default() - .to(ADDRESS) - .input(alloy_rpc_types::TransactionInput::from(INPUT_DATA)), + .to(MOCK_ADDRESS) + .input(alloy_rpc_types::TransactionInput::from(MOCK_INPUT_DATA)), ); if let Some(block) = block.clone() { request = request.with_block(block) @@ -913,8 +918,8 @@ async fn eth_call_should_succeed() { .build() .call( alloy_rpc_types::TransactionRequest::default() - .to(ADDRESS) - .input(alloy_rpc_types::TransactionInput::from(INPUT_DATA)), + .to(MOCK_ADDRESS) + .input(alloy_rpc_types::TransactionInput::from(MOCK_INPUT_DATA)), ); if let Some(block) = block.clone() { request = request.with_block(block) @@ -2405,12 +2410,164 @@ async fn should_change_default_provider_when_one_keeps_failing() { assert_eq!(response, U256::ONE); } +mod cycles_cost_tests { + use super::*; + + #[tokio::test] + async fn should_be_idempotent() { + async fn check( + request: RequestBuilder< + MockHttpRuntime, + Converter, + Config, + Params, + CandidOutput, + Output, + >, + ) where + Config: CandidType + Clone + Send, + Params: CandidType + Clone + Send, + { + let cycles_cost_1 = request.clone().request_cost().send().await.unwrap(); + let cycles_cost_2 = request.request_cost().send().await.unwrap(); + assert_eq!(cycles_cost_1, cycles_cost_2); + assert!(cycles_cost_1 > 0); + } + + let setup = EvmRpcSetup::with_args(InstallArgs { + demo: Some(false), + ..Default::default() + }) + .await + .mock_api_keys() + .await; + let client = setup.client(MockHttpOutcalls::NEVER).build(); + + for endpoint in EvmRpcEndpoint::iter() { + match endpoint { + EvmRpcEndpoint::Call => { + check( + client.call( + alloy_rpc_types::TransactionRequest::default() + .to(MOCK_ADDRESS) + .input(alloy_rpc_types::TransactionInput::from(MOCK_INPUT_DATA)), + ), + ) + .await; + } + EvmRpcEndpoint::FeeHistory => { + check(client.fee_history((3_u64, BlockNumberOrTag::Latest))).await + } + EvmRpcEndpoint::GetBlockByNumber => { + check(client.get_block_by_number(BlockNumberOrTag::Latest)).await + } + EvmRpcEndpoint::GetLogs => check(client.get_logs(vec![MOCK_ADDRESS])).await, + EvmRpcEndpoint::GetTransactionCount => { + check(client.get_transaction_count((MOCK_ADDRESS, BlockNumberOrTag::Latest))) + .await + } + EvmRpcEndpoint::GetTransactionReceipt => { + check(client.get_transaction_receipt(MOCK_TRANSACTION_HASH)).await + } + EvmRpcEndpoint::MultiRequest => { + check(client.multi_request(json!({ + "id": 0, + "jsonrpc": "2.0", + "method": "eth_gasPrice", + }))) + .await + } + EvmRpcEndpoint::SendRawTransaction => { + check(client.send_raw_transaction(MOCK_TRANSACTION)).await + } + } + } + } + + #[tokio::test] + async fn should_be_zero_when_in_demo_mode() { + async fn check( + request: RequestBuilder< + MockHttpRuntime, + Converter, + Config, + Params, + CandidOutput, + Output, + >, + ) where + Config: CandidType + Clone + Send, + Params: CandidType + Clone + Send, + { + let cycles_cost = request.request_cost().send().await; + assert_eq!(cycles_cost, Ok(0)); + } + + let setup = EvmRpcSetup::with_args(InstallArgs { + demo: Some(true), + ..Default::default() + }) + .await + .mock_api_keys() + .await; + let client = setup.client(MockHttpOutcalls::NEVER).build(); + + for endpoint in EvmRpcEndpoint::iter() { + match endpoint { + EvmRpcEndpoint::Call => { + check( + client.call( + alloy_rpc_types::TransactionRequest::default() + .to(MOCK_ADDRESS) + .input(alloy_rpc_types::TransactionInput::from(MOCK_INPUT_DATA)), + ), + ) + .await; + } + EvmRpcEndpoint::FeeHistory => { + check(client.fee_history((3_u64, BlockNumberOrTag::Latest))).await + } + EvmRpcEndpoint::GetBlockByNumber => { + check(client.get_block_by_number(BlockNumberOrTag::Latest)).await + } + EvmRpcEndpoint::GetLogs => check(client.get_logs(vec![MOCK_ADDRESS])).await, + EvmRpcEndpoint::GetTransactionCount => { + check(client.get_transaction_count((MOCK_ADDRESS, BlockNumberOrTag::Latest))) + .await + } + EvmRpcEndpoint::GetTransactionReceipt => { + check(client.get_transaction_receipt(MOCK_TRANSACTION_HASH)).await + } + EvmRpcEndpoint::MultiRequest => { + check(client.multi_request(json!({ + "id": 0, + "jsonrpc": "2.0", + "method": "eth_gasPrice", + }))) + .await + } + EvmRpcEndpoint::SendRawTransaction => { + check(client.send_raw_transaction(MOCK_TRANSACTION)).await + } + } + } + } + + #[tokio::test] + async fn should_get_exact_cycles_cost() { + // TODO: Test that requests are successful with the estimated number of cycles, but + // unsuccessful with 1 less cycle. + // This requires setting up a wallet canister to attach cycles to the requests to the + // EVM RPC canister and will be done in a follow-up PR. + } +} + fn call_request() -> JsonRpcRequestMatcher { JsonRpcRequestMatcher::with_method("eth_call") .with_params(json!([ { - "to": ADDRESS, - "input": INPUT_DATA + "to": MOCK_ADDRESS, + "input": MOCK_INPUT_DATA }, "latest" ])) From 93ed21ab44bf21d3ecb8bbe56f19ced7e86f1aff Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Tue, 28 Oct 2025 17:04:30 +0100 Subject: [PATCH 06/19] Fix runtime --- tests/tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/tests.rs b/tests/tests.rs index bbf0daec..4068617c 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -7,7 +7,7 @@ use crate::{ json::{JsonRpcRequestMatcher, JsonRpcResponse}, CanisterHttpReject, CanisterHttpReply, MockHttpOutcalls, MockHttpOutcallsBuilder, }, - MockHttpRuntime, + wallet::MockHttpRuntimeWithWallet, }, setup::EvmRpcSetup, }; @@ -2408,7 +2408,7 @@ mod cycles_cost_tests { async fn should_be_idempotent() { async fn check( request: RequestBuilder< - MockHttpRuntime, + MockHttpRuntimeWithWallet, Converter, Config, Params, @@ -2479,7 +2479,7 @@ mod cycles_cost_tests { async fn should_be_zero_when_in_demo_mode() { async fn check( request: RequestBuilder< - MockHttpRuntime, + MockHttpRuntimeWithWallet, Converter, Config, Params, From dea236dd90b28a665f48cda03219de023c3c3ad8 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Wed, 29 Oct 2025 13:58:43 +0100 Subject: [PATCH 07/19] Add `should_get_exact_cycles_cost` --- src/memory.rs | 4 +- tests/mock_http_runtime/mock/json/mod.rs | 32 ++-- tests/mock_http_runtime/mock/json/tests.rs | 3 +- tests/mock_http_runtime/mock/mod.rs | 9 - tests/tests.rs | 196 ++++++++++++++++++--- 5 files changed, 194 insertions(+), 50 deletions(-) diff --git a/src/memory.rs b/src/memory.rs index 56dc8bb6..fc2442fe 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -4,7 +4,7 @@ use crate::{ types::{ApiKey, Metrics, OverrideProvider, ProviderId, StorableLogFilter}, }; use candid::Principal; -use canhttp::http::json::Id; +use canhttp::http::json::{ConstantSizeId, Id}; use canhttp::multi::Timestamp; use canlog::LogFilter; use ic_stable_structures::memory_manager::VirtualMemory; @@ -117,7 +117,7 @@ pub fn next_request_id() -> Id { // overflow is not an issue here because we only use `next_request_id` to correlate // requests and responses in logs. *counter = counter.wrapping_add(1); - Id::from(current_request_id) + Id::from(ConstantSizeId::from(current_request_id)) }) } diff --git a/tests/mock_http_runtime/mock/json/mod.rs b/tests/mock_http_runtime/mock/json/mod.rs index 960691a4..bee0ed52 100644 --- a/tests/mock_http_runtime/mock/json/mod.rs +++ b/tests/mock_http_runtime/mock/json/mod.rs @@ -2,12 +2,12 @@ mod tests; use crate::mock_http_runtime::mock::CanisterHttpRequestMatcher; -use canhttp::http::json::{Id, JsonRpcRequest}; +use canhttp::http::json::{ConstantSizeId, Id, JsonRpcRequest}; use pocket_ic::common::rest::{ CanisterHttpHeader, CanisterHttpMethod, CanisterHttpReply, CanisterHttpRequest, CanisterHttpResponse, }; -use serde_json::{json, Value}; +use serde_json::Value; use std::{collections::BTreeSet, str::FromStr}; use url::{Host, Url}; @@ -35,9 +35,9 @@ impl JsonRpcRequestMatcher { } } - pub fn with_id(self, id: impl Into) -> Self { + pub fn with_id(self, id: impl Into) -> Self { Self { - id: Some(id.into()), + id: Some(Id::from(id.into())), ..self } } @@ -84,16 +84,6 @@ impl JsonRpcRequestMatcher { ..self } } - - pub fn request_body(&self) -> JsonRpcRequest { - serde_json::from_value(json!({ - "jsonrpc": "2.0", - "method": &self.method, - "params": self.params.clone().unwrap_or(Value::Null), - "id": self.id.clone().unwrap_or(Id::Null), - })) - .unwrap() - } } impl CanisterHttpRequestMatcher for JsonRpcRequestMatcher { @@ -128,11 +118,21 @@ impl CanisterHttpRequestMatcher for JsonRpcRequestMatcher { return false; } } - match serde_json::from_slice(&request.body) { + match serde_json::from_slice::>(&request.body) { Ok(actual_body) => { - if self.request_body() != actual_body { + if self.method != actual_body.method() { return false; } + if let Some(ref id) = self.id { + if id != actual_body.id() { + return false; + } + } + if let Some(ref params) = self.params { + if Some(params) != actual_body.params() { + return false; + } + } } // Not a JSON-RPC request Err(_) => return false, diff --git a/tests/mock_http_runtime/mock/json/tests.rs b/tests/mock_http_runtime/mock/json/tests.rs index 379c42cd..5b15852b 100644 --- a/tests/mock_http_runtime/mock/json/tests.rs +++ b/tests/mock_http_runtime/mock/json/tests.rs @@ -1,6 +1,5 @@ use crate::mock_http_runtime::mock::{json::JsonRpcRequestMatcher, CanisterHttpRequestMatcher}; use candid::Principal; -use canhttp::http::json::Id; use evm_rpc::constants::{CONTENT_TYPE_HEADER_LOWERCASE, CONTENT_TYPE_VALUE}; use pocket_ic::common::rest::{CanisterHttpHeader, CanisterHttpMethod, CanisterHttpRequest}; use serde_json::{json, Value}; @@ -32,7 +31,7 @@ mod json_rpc_request_matcher_tests { #[test] fn should_not_match_wrong_id() { - assert!(!request_matcher().with_id(Id::Null).matches(&request())); + assert!(!request_matcher().with_id(999_u64).matches(&request())); } #[test] diff --git a/tests/mock_http_runtime/mock/mod.rs b/tests/mock_http_runtime/mock/mod.rs index 2e24b7bd..ad3c446c 100644 --- a/tests/mock_http_runtime/mock/mod.rs +++ b/tests/mock_http_runtime/mock/mod.rs @@ -107,15 +107,6 @@ pub trait CanisterHttpRequestMatcher: Send + Debug { fn matches(&self, request: &CanisterHttpRequest) -> bool; } -#[derive(Debug)] -pub struct AnyRequestMatcher; - -impl CanisterHttpRequestMatcher for AnyRequestMatcher { - fn matches(&self, _request: &CanisterHttpRequest) -> bool { - true - } -} - pub struct CanisterHttpReply(pocket_ic::common::rest::CanisterHttpReply); impl CanisterHttpReply { diff --git a/tests/tests.rs b/tests/tests.rs index 4068617c..e6733e61 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -15,7 +15,6 @@ use alloy_primitives::{address, b256, bloom, bytes, Address, Bytes, FixedBytes, use alloy_rpc_types::{BlockNumberOrTag, BlockTransactions}; use assert_matches::assert_matches; use candid::{CandidType, Encode, Principal}; -use canhttp::http::json::Id; use evm_rpc_client::{EvmRpcEndpoint, RequestBuilder}; use evm_rpc_types::{ BlockTag, ConsensusStrategy, EthMainnetService, EthSepoliaService, GetLogsRpcConfig, Hex, @@ -26,8 +25,9 @@ use ic_error_types::RejectCode; use ic_http_types::HttpRequest; use pocket_ic::common::rest::CanisterHttpResponse; use pocket_ic::ErrorCode; +use serde::de::DeserializeOwned; use serde_json::{json, Value}; -use std::iter; +use std::{fmt::Debug, iter}; use strum::IntoEnumIterator; const DEFAULT_CALLER_TEST_ID: Principal = @@ -40,7 +40,7 @@ const INITIAL_CYCLES: u128 = 100_000_000_000_000_000; const MAX_TICKS: usize = 10; const MOCK_REQUEST_METHOD: &str = "eth_gasPrice"; -const MOCK_REQUEST_ID: Id = Id::Number(1); +const MOCK_REQUEST_ID: u64 = 1; const MOCK_REQUEST_PARAMS: Value = Value::Array(vec![]); const MOCK_REQUEST_URL: &str = "https://cloudflare-eth.com"; const MOCK_REQUEST_PAYLOAD: &str = @@ -2545,11 +2545,165 @@ mod cycles_cost_tests { } #[tokio::test] - async fn should_get_exact_cycles_cost() { - // TODO: Test that requests are successful with the estimated number of cycles, but - // unsuccessful with 1 less cycle. - // This requires setting up a wallet canister to attach cycles to the requests to the - // EVM RPC canister and will be done in a follow-up PR. + async fn should_get_exact_cycles_cost_() { + async fn check( + setup: &EvmRpcSetup, + request: RequestBuilder< + MockHttpRuntimeWithWallet, + Converter, + Config, + Params, + MultiRpcResult, + MultiRpcResult, + >, + expected_cycles_cost: u128, + ) where + Config: CandidType + Clone + Send, + Params: CandidType + Clone + Send, + CandidOutput: CandidType + DeserializeOwned, + Output: Debug, + MultiRpcResult: Into>, + { + let five_percents = 5_u8; + + let cycles_cost = request.clone().request_cost().send().await.unwrap(); + assert_within(cycles_cost, expected_cycles_cost, five_percents); + + let cycles_before = setup.evm_rpc_canister_cycles_balance().await; + // Request with exact cycles amount should succeed + let result = request + .clone() + .with_cycles(cycles_cost) + .send() + .await + .expect_consistent(); + if let Err(RpcError::ProviderError(ProviderError::TooFewCycles { .. })) = result { + panic!("BUG: estimated cycles cost was insufficient!: {result:?}"); + } + let cycles_after = setup.evm_rpc_canister_cycles_balance().await; + let cycles_consumed = cycles_before + cycles_cost - cycles_after; + + assert!( + cycles_after > cycles_before, + "BUG: not enough cycles requested. Requested {cycles_cost} cycles, but consumed {cycles_consumed} cycles" + ); + + // The same request with fewer cycles should fail. + let results = request + .with_cycles(cycles_cost - 1) + .send() + .await + .expect_inconsistent(); + + assert!( + results.iter().any(|(_provider, result)| matches!( + result, + &Err(RpcError::ProviderError(ProviderError::TooFewCycles { + expected: _, + received: _ + })) + )), + "BUG: Expected at least one TooFewCycles error, but got {results:?}" + ); + } + + let setup = EvmRpcSetup::new().await.mock_api_keys().await; + // The exact cycles cost of an HTTPs outcall is independent of the response, + // so we always return a dummy response so that individual responses + // do not need to be mocked. + let mut mocks = MockHttpOutcallsBuilder::new(); + let mut ids = 0_u64..; + for endpoint in EvmRpcEndpoint::iter() { + let rpc_method = if endpoint == EvmRpcEndpoint::MultiRequest { + MOCK_REQUEST_METHOD + } else { + endpoint.rpc_method() + }; + for id in ids.by_ref().take(5) { + mocks = mocks + .given(JsonRpcRequestMatcher::with_method(rpc_method).with_id(id)) + .respond_with(CanisterHttpReply::with_status(403)); + } + // Advance ID by 1 to account for the call with insufficient cycles, for which only the + // call to the last provider does not result in an HTTP outcall + for _ in ids.by_ref().take(1) {} + } + + let client = setup.client(mocks).build(); + + for endpoint in EvmRpcEndpoint::iter() { + // To find out the expected_cycles_cost for a new endpoint, set the amount to 0 + // and run the test. It should fail and report the amount of cycles needed. + match endpoint { + EvmRpcEndpoint::Call => { + check( + &setup, + client.call( + alloy_rpc_types::TransactionRequest::default() + .to(MOCK_ADDRESS) + .input(alloy_rpc_types::TransactionInput::from(MOCK_INPUT_DATA)), + ), + 1_734_639_200, + ) + .await; + } + EvmRpcEndpoint::FeeHistory => { + check( + &setup, + client.fee_history((3_u64, BlockNumberOrTag::Latest)), + 1_750_673_600, + ) + .await + } + EvmRpcEndpoint::GetBlockByNumber => { + check( + &setup, + client.get_block_by_number(BlockNumberOrTag::Latest), + 3_714_418_400, + ) + .await + } + EvmRpcEndpoint::GetLogs => { + check(&setup, client.get_logs(vec![MOCK_ADDRESS]), 1_795_635_200).await + } + EvmRpcEndpoint::GetTransactionCount => { + check( + &setup, + client.get_transaction_count((MOCK_ADDRESS, BlockNumberOrTag::Latest)), + 1_714_688_000, + ) + .await + } + EvmRpcEndpoint::GetTransactionReceipt => { + check( + &setup, + client.get_transaction_receipt(MOCK_TRANSACTION_HASH), + 1_768_421_600, + ) + .await + } + EvmRpcEndpoint::MultiRequest => { + check( + &setup, + client.multi_request(json!({ + "id": 0, + "jsonrpc": "2.0", + "method": "eth_gasPrice", + })), + 1_729_090_400, + ) + .await + } + EvmRpcEndpoint::SendRawTransaction => { + check( + &setup, + client.send_raw_transaction(MOCK_TRANSACTION), + 1_738_556_000, + ) + .await + } + } + } } } @@ -2686,20 +2840,20 @@ mod request_cost_tests { "BUG: Expected TooFewCycles error, but got {result:?}" ); } +} - fn assert_within(actual: u128, expected: u128, percentage_error: u8) { - assert!(percentage_error <= 100); - let error_margin = expected.saturating_mul(percentage_error as u128) / 100; - let lower_bound = expected.saturating_sub(error_margin); - let upper_bound = expected.saturating_add(error_margin); - assert!( - lower_bound <= actual && actual <= upper_bound, - "Expected {} <= {} <= {}", - lower_bound, - actual, - upper_bound - ); - } +fn assert_within(actual: u128, expected: u128, percentage_error: u8) { + assert!(percentage_error <= 100); + let error_margin = expected.saturating_mul(percentage_error as u128) / 100; + let lower_bound = expected.saturating_sub(error_margin); + let upper_bound = expected.saturating_add(error_margin); + assert!( + lower_bound <= actual && actual <= upper_bound, + "Expected {} <= {} <= {}", + lower_bound, + actual, + upper_bound + ); } fn fee_history_request() -> JsonRpcRequestMatcher { From ba4f8ccd5d7aa52010dae2331ea804977590ff60 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Wed, 29 Oct 2025 14:36:15 +0100 Subject: [PATCH 08/19] Fix ID in legacy `request` endpoint test --- Cargo.toml | 1 + tests/mock_http_runtime/mock/json/mod.rs | 12 +++++++++++- tests/tests.rs | 5 +++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3eae25b3..fe2fbd90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ alloy-rpc-types = { workspace = true } assert_matches = { workspace = true } async-trait = { workspace = true } candid_parser = { workspace = true } +derive_more = {workspace = true, features = ["from", "into"]} evm_rpc_client = { path = "evm_rpc_client", features = ["alloy"] } 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 } diff --git a/tests/mock_http_runtime/mock/json/mod.rs b/tests/mock_http_runtime/mock/json/mod.rs index bee0ed52..5f56b199 100644 --- a/tests/mock_http_runtime/mock/json/mod.rs +++ b/tests/mock_http_runtime/mock/json/mod.rs @@ -3,6 +3,7 @@ mod tests; use crate::mock_http_runtime::mock::CanisterHttpRequestMatcher; use canhttp::http::json::{ConstantSizeId, Id, JsonRpcRequest}; +use derive_more::{From, Into}; use pocket_ic::common::rest::{ CanisterHttpHeader, CanisterHttpMethod, CanisterHttpReply, CanisterHttpRequest, CanisterHttpResponse, @@ -11,6 +12,15 @@ use serde_json::Value; use std::{collections::BTreeSet, str::FromStr}; use url::{Host, Url}; +#[derive(From, Into)] +pub struct JsonRpcRequestId(Id); + +impl From for JsonRpcRequestId { + fn from(id: u64) -> Self { + Self(Id::from(ConstantSizeId::from(id))) + } +} + #[derive(Clone, Debug)] pub struct JsonRpcRequestMatcher { pub method: String, @@ -35,7 +45,7 @@ impl JsonRpcRequestMatcher { } } - pub fn with_id(self, id: impl Into) -> Self { + pub fn with_id(self, id: impl Into) -> Self { Self { id: Some(Id::from(id.into())), ..self diff --git a/tests/tests.rs b/tests/tests.rs index e6733e61..08004278 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -15,6 +15,7 @@ use alloy_primitives::{address, b256, bloom, bytes, Address, Bytes, FixedBytes, use alloy_rpc_types::{BlockNumberOrTag, BlockTransactions}; use assert_matches::assert_matches; use candid::{CandidType, Encode, Principal}; +use canhttp::http::json::Id; use evm_rpc_client::{EvmRpcEndpoint, RequestBuilder}; use evm_rpc_types::{ BlockTag, ConsensusStrategy, EthMainnetService, EthSepoliaService, GetLogsRpcConfig, Hex, @@ -40,7 +41,7 @@ const INITIAL_CYCLES: u128 = 100_000_000_000_000_000; const MAX_TICKS: usize = 10; const MOCK_REQUEST_METHOD: &str = "eth_gasPrice"; -const MOCK_REQUEST_ID: u64 = 1; +const MOCK_REQUEST_ID: Id = Id::Number(1); const MOCK_REQUEST_PARAMS: Value = Value::Array(vec![]); const MOCK_REQUEST_URL: &str = "https://cloudflare-eth.com"; const MOCK_REQUEST_PAYLOAD: &str = @@ -2545,7 +2546,7 @@ mod cycles_cost_tests { } #[tokio::test] - async fn should_get_exact_cycles_cost_() { + async fn should_get_exact_cycles_cost() { async fn check( setup: &EvmRpcSetup, request: RequestBuilder< From 77b4688987d67bf25981c290cfc7ea0dcf37c5ee Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Wed, 29 Oct 2025 14:44:53 +0100 Subject: [PATCH 09/19] Revert unnecessary test change --- tests/mock_http_runtime/mock/json/tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/mock_http_runtime/mock/json/tests.rs b/tests/mock_http_runtime/mock/json/tests.rs index 5b15852b..379c42cd 100644 --- a/tests/mock_http_runtime/mock/json/tests.rs +++ b/tests/mock_http_runtime/mock/json/tests.rs @@ -1,5 +1,6 @@ use crate::mock_http_runtime::mock::{json::JsonRpcRequestMatcher, CanisterHttpRequestMatcher}; use candid::Principal; +use canhttp::http::json::Id; use evm_rpc::constants::{CONTENT_TYPE_HEADER_LOWERCASE, CONTENT_TYPE_VALUE}; use pocket_ic::common::rest::{CanisterHttpHeader, CanisterHttpMethod, CanisterHttpRequest}; use serde_json::{json, Value}; @@ -31,7 +32,7 @@ mod json_rpc_request_matcher_tests { #[test] fn should_not_match_wrong_id() { - assert!(!request_matcher().with_id(999_u64).matches(&request())); + assert!(!request_matcher().with_id(Id::Null).matches(&request())); } #[test] From a09a7a64d672fb05bccc28cc1ff33318c5ef8d9f Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Wed, 29 Oct 2025 14:46:46 +0100 Subject: [PATCH 10/19] Fix `request` endpoint test --- tests/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tests.rs b/tests/tests.rs index 08004278..44f93417 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -114,7 +114,7 @@ async fn should_not_modify_json_rpc_request_from_request_endpoint() { let mock_request = r#"{"id":123,"jsonrpc":"2.0","method":"eth_gasPrice"}"#; let mock_response = r#"{"jsonrpc":"2.0","id":123,"result":"0x00112233"}"#; let mocks = MockHttpOutcallsBuilder::new() - .given(JsonRpcRequestMatcher::with_method("eth_gasPrice").with_id(123_u64)) + .given(JsonRpcRequestMatcher::with_method("eth_gasPrice").with_id(Id::Number(123))) .respond_with(JsonRpcResponse::from(mock_response)); let setup = EvmRpcSetup::new().await.mock_api_keys().await; From 57455a140720fcb52594edfdf55dd8e9f0983110 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Wed, 29 Oct 2025 15:08:21 +0100 Subject: [PATCH 11/19] Analogous change for response ID --- tests/mock_http_runtime/mock/json/mod.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/mock_http_runtime/mock/json/mod.rs b/tests/mock_http_runtime/mock/json/mod.rs index 5f56b199..cec0d6c7 100644 --- a/tests/mock_http_runtime/mock/json/mod.rs +++ b/tests/mock_http_runtime/mock/json/mod.rs @@ -13,9 +13,9 @@ use std::{collections::BTreeSet, str::FromStr}; use url::{Host, Url}; #[derive(From, Into)] -pub struct JsonRpcRequestId(Id); +pub struct JsonRpcId(Id); -impl From for JsonRpcRequestId { +impl From for JsonRpcId { fn from(id: u64) -> Self { Self(Id::from(ConstantSizeId::from(id))) } @@ -45,7 +45,7 @@ impl JsonRpcRequestMatcher { } } - pub fn with_id(self, id: impl Into) -> Self { + pub fn with_id(self, id: impl Into) -> Self { Self { id: Some(Id::from(id.into())), ..self @@ -174,8 +174,9 @@ impl From for JsonRpcResponse { } 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"); + pub fn with_id(mut self, id: impl Into) -> JsonRpcResponse { + self.body["id"] = + serde_json::to_value(Id::from(id.into())).expect("BUG: cannot serialize ID"); self } } From aeb0131166a23a155ec104a9f37b49f7d683889b Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Wed, 29 Oct 2025 15:44:33 +0100 Subject: [PATCH 12/19] Fix remaining tests --- tests/mock_http_runtime/mock/json/tests.rs | 4 +- tests/tests.rs | 98 +++++++++++++--------- 2 files changed, 62 insertions(+), 40 deletions(-) diff --git a/tests/mock_http_runtime/mock/json/tests.rs b/tests/mock_http_runtime/mock/json/tests.rs index 379c42cd..dfd8aac8 100644 --- a/tests/mock_http_runtime/mock/json/tests.rs +++ b/tests/mock_http_runtime/mock/json/tests.rs @@ -1,6 +1,6 @@ use crate::mock_http_runtime::mock::{json::JsonRpcRequestMatcher, CanisterHttpRequestMatcher}; use candid::Principal; -use canhttp::http::json::Id; +use canhttp::http::json::{ConstantSizeId, Id}; use evm_rpc::constants::{CONTENT_TYPE_HEADER_LOWERCASE, CONTENT_TYPE_VALUE}; use pocket_ic::common::rest::{CanisterHttpHeader, CanisterHttpMethod, CanisterHttpRequest}; use serde_json::{json, Value}; @@ -128,7 +128,7 @@ fn request() -> CanisterHttpRequest { body: serde_json::to_vec(&json!({ "jsonrpc": "2.0", "method": DEFAULT_RPC_METHOD, - "id": DEFAULT_RPC_ID, + "id": ConstantSizeId::from(DEFAULT_RPC_ID).to_string(), "params": DEFAULT_RPC_PARAMS, })) .unwrap(), diff --git a/tests/tests.rs b/tests/tests.rs index 44f93417..19eea818 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -15,7 +15,7 @@ use alloy_primitives::{address, b256, bloom, bytes, Address, Bytes, FixedBytes, use alloy_rpc_types::{BlockNumberOrTag, BlockTransactions}; use assert_matches::assert_matches; use candid::{CandidType, Encode, Principal}; -use canhttp::http::json::Id; +use canhttp::http::json::{ConstantSizeId, Id}; use evm_rpc_client::{EvmRpcEndpoint, RequestBuilder}; use evm_rpc_types::{ BlockTag, ConsensusStrategy, EthMainnetService, EthSepoliaService, GetLogsRpcConfig, Hex, @@ -163,7 +163,7 @@ async fn multi_request_should_succeed() { .with_candid() .build() .multi_request(json!({ - "id": 0, + "id" : ConstantSizeId::from(0_u64).to_string(), "jsonrpc": "2.0", "method": "eth_gasPrice", })) @@ -177,7 +177,7 @@ async fn multi_request_should_succeed() { .with_rpc_sources(source.clone()) .build() .multi_request(json!({ - "id": 0, + "id" : ConstantSizeId::from(0_u64).to_string(), "jsonrpc": "2.0", "method": "eth_gasPrice", })) @@ -939,7 +939,7 @@ async fn candid_rpc_should_allow_unexpected_response_fields() { fn mock_response() -> JsonRpcResponse { JsonRpcResponse::from(json!({ "jsonrpc":"2.0", - "id": 0, + "id" : ConstantSizeId::from(0_u64).to_string(), "result":{ "unexpectedKey":"unexpectedValue", "blockHash": "0x5115c07eb1f20a9d6410db0916ed3df626cfdab161d3904f45c8c8b65c90d0be", @@ -1132,13 +1132,17 @@ async fn candid_rpc_should_reject_empty_service_list() { async fn candid_rpc_should_return_inconsistent_results() { let mocks = MockHttpOutcallsBuilder::new() .given(send_raw_transaction_request().with_id(0_u64)) - .respond_with(JsonRpcResponse::from( - json!({ "id": 0, "jsonrpc": "2.0", "result": MOCK_TRANSACTION_HASH }), - )) + .respond_with(JsonRpcResponse::from(json!({ + "id": ConstantSizeId::from(0_u64).to_string(), + "jsonrpc": "2.0", + "result": MOCK_TRANSACTION_HASH + }))) .given(send_raw_transaction_request().with_id(1_u64)) - .respond_with(JsonRpcResponse::from( - json!({ "id": 1, "jsonrpc": "2.0", "result": "NonceTooLow" }), - )); + .respond_with(JsonRpcResponse::from(json!({ + "id": ConstantSizeId::from(1_u64).to_string(), + "jsonrpc": "2.0", + "result": "NonceTooLow" + }))); let setup = EvmRpcSetup::new().await.mock_api_keys().await; let results = setup @@ -1183,9 +1187,11 @@ async fn candid_rpc_should_return_inconsistent_results() { #[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}") }), - ) + JsonRpcResponse::from(json!({ + "jsonrpc": "2.0", + "id" : ConstantSizeId::ZERO.to_string(), + "result" : format!("0x{result:x}") + })) } let setup = EvmRpcSetup::new().await.mock_api_keys().await; @@ -1299,9 +1305,11 @@ 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": ConstantSizeId::from(1_u64).to_string(), + "error" : { "code": 123, "message": "Unexpected"} + }))); let result = setup .client(mocks) @@ -1466,7 +1474,7 @@ async fn should_have_metrics_for_multi_request_endpoint() { ]))) .build() .multi_request(json!({ - "id": 0, + "id" : ConstantSizeId::from(0_u64).to_string(), "jsonrpc": "2.0", "method": "eth_gasPrice", })) @@ -1546,14 +1554,18 @@ async fn candid_rpc_should_return_inconsistent_results_with_unexpected_http_stat #[tokio::test] async fn candid_rpc_should_handle_already_known() { let mocks = MockHttpOutcallsBuilder::new() - .given(send_raw_transaction_request().with_id(0_u64)) - .respond_with(JsonRpcResponse::from( - json!({ "id": 0, "jsonrpc": "2.0", "result": MOCK_TRANSACTION_HASH }), - )) - .given(send_raw_transaction_request().with_id(1_u64)) - .respond_with(JsonRpcResponse::from( - json!({ "id": 1, "jsonrpc": "2.0", "error": {"code": -32000, "message": "already known"} }), - )); + .given(send_raw_transaction_request().with_id(0)) + .respond_with(JsonRpcResponse::from(json!({ + "id": ConstantSizeId::from(0_u64).to_string(), + "jsonrpc": "2.0", + "result": MOCK_TRANSACTION_HASH + }))) + .given(send_raw_transaction_request().with_id(1)) + .respond_with(JsonRpcResponse::from(json!({ + "id": ConstantSizeId::from(1_u64).to_string(), + "jsonrpc": "2.0", + "error": {"code": -32000, "message": "already known"} + }))); let setup = EvmRpcSetup::new().await.mock_api_keys().await; let result = setup @@ -2285,8 +2297,8 @@ async fn should_log_request() { let logs = setup.http_get_logs("TRACE_HTTP").await; assert_eq!(logs.len(), 2, "Unexpected amount of logs {logs:?}"); - assert!(logs[0].message.contains("JSON-RPC request with id `0` to eth-mainnet.g.alchemy.com: JsonRpcRequest { jsonrpc: V2, method: \"eth_feeHistory\"")); - 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")); + assert!(logs[0].message.contains("JSON-RPC request with id `00000000000000000000` to eth-mainnet.g.alchemy.com: JsonRpcRequest { jsonrpc: V2, method: \"eth_feeHistory\"")); + assert!(logs[1].message.contains("response for request with id `00000000000000000000`. Response with status 200 OK: JsonRpcResponse { jsonrpc: V2, id: String(\"00000000000000000000\"), result: Ok(FeeHistory")); } #[tokio::test] @@ -2463,7 +2475,7 @@ mod cycles_cost_tests { } EvmRpcEndpoint::MultiRequest => { check(client.multi_request(json!({ - "id": 0, + "id" : ConstantSizeId::from(0_u64).to_string(), "jsonrpc": "2.0", "method": "eth_gasPrice", }))) @@ -2532,7 +2544,7 @@ mod cycles_cost_tests { } EvmRpcEndpoint::MultiRequest => { check(client.multi_request(json!({ - "id": 0, + "id" : ConstantSizeId::from(0_u64).to_string(), "jsonrpc": "2.0", "method": "eth_gasPrice", }))) @@ -2687,7 +2699,7 @@ mod cycles_cost_tests { check( &setup, client.multi_request(json!({ - "id": 0, + "id" : ConstantSizeId::from(0_u64).to_string(), "jsonrpc": "2.0", "method": "eth_gasPrice", })), @@ -2904,14 +2916,16 @@ fn send_raw_transaction_request() -> JsonRpcRequestMatcher { } fn call_response() -> JsonRpcResponse { - JsonRpcResponse::from( - json!({ "jsonrpc": "2.0", "id": 0, "result": "0x0000000000000000000000000000000000000000000000000000013c3ee36e89" }), - ) + JsonRpcResponse::from(json!({ + "jsonrpc": "2.0", + "id" : ConstantSizeId::from(0_u64).to_string(), + "result": "0x0000000000000000000000000000000000000000000000000000013c3ee36e89" + })) } fn fee_history_response() -> JsonRpcResponse { JsonRpcResponse::from(json!({ - "id" : 0, + "id" : ConstantSizeId::from(0_u64).to_string(), "jsonrpc" : "2.0", "result" : { "oldestBlock" : "0x11e57f5", @@ -2945,13 +2959,13 @@ fn get_block_by_number_response() -> JsonRpcResponse { "withdrawalsRoot": "0xecae44b2c53871003c5cc75285995764034c9b5978a904229d36c1280b141d48", "transactionsRoot": "0x93a1ad3d067009259b508cc95fde63b5efd7e9d8b55754314c173fdde8c0826a", }, - "id": 0 + "id" : ConstantSizeId::from(0_u64).to_string(), })) } fn get_logs_response() -> JsonRpcResponse { JsonRpcResponse::from(json!({ - "id" : 0, + "id" : ConstantSizeId::from(0_u64).to_string(), "jsonrpc" : "2.0", "result" : [ { @@ -2974,11 +2988,19 @@ fn get_logs_response() -> JsonRpcResponse { } fn get_transaction_count_response() -> JsonRpcResponse { - JsonRpcResponse::from(json!({ "jsonrpc" : "2.0", "id" : 0, "result" : "0x1" })) + JsonRpcResponse::from(json!({ + "jsonrpc": "2.0", + "id": ConstantSizeId::ZERO.to_string(), + "result": "0x1" + })) } fn send_raw_transaction_response() -> JsonRpcResponse { - JsonRpcResponse::from(json!({ "id": 0, "jsonrpc": "2.0", "result": MOCK_TRANSACTION_HASH })) + JsonRpcResponse::from(json!({ + "jsonrpc": "2.0", + "id": ConstantSizeId::ZERO.to_string(), + "result": MOCK_TRANSACTION_HASH + })) } pub fn multi_logs_for_single_transaction(num_logs: usize) -> Value { From 15e2636952156881ffd4e1391afcc9c57ac57228 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 31 Oct 2025 13:32:10 +0100 Subject: [PATCH 13/19] Clean-up lifetimes --- src/candid_rpc/mod.rs | 26 ++++---- src/rpc_client/mod.rs | 134 ++++++++++++++++++++++------------------ src/rpc_client/tests.rs | 6 +- 3 files changed, 90 insertions(+), 76 deletions(-) diff --git a/src/candid_rpc/mod.rs b/src/candid_rpc/mod.rs index c6cb891a..48484022 100644 --- a/src/candid_rpc/mod.rs +++ b/src/candid_rpc/mod.rs @@ -7,7 +7,7 @@ use canhttp::http::json::JsonRpcRequest; use canhttp::multi::Timestamp; use ethers_core::{types::Transaction, utils::rlp}; use evm_rpc_types::{ - BlockTag, GetLogsArgs, Hex, Hex32, MultiRpcResult, Nat256, RpcError, RpcResult, ValidationError, + BlockTag, Hex, Hex32, MultiRpcResult, Nat256, RpcError, RpcResult, ValidationError, }; /// Adapt the `EthRpcClient` to the `Candid` interface used by the EVM-RPC canister. @@ -27,7 +27,7 @@ impl CandidRpcClient { } pub async fn eth_get_logs( - &self, + self, args: evm_rpc_types::GetLogsArgs, ) -> MultiRpcResult> { use crate::candid_rpc::cketh_conversion::{from_log_entries, into_get_logs_param}; @@ -39,7 +39,7 @@ impl CandidRpcClient { } pub async fn eth_get_block_by_number( - &self, + self, block: BlockTag, ) -> MultiRpcResult { use crate::candid_rpc::cketh_conversion::{from_block, into_block_spec}; @@ -51,7 +51,7 @@ impl CandidRpcClient { } pub async fn eth_get_transaction_receipt( - &self, + self, hash: Hex32, ) -> MultiRpcResult> { use crate::candid_rpc::cketh_conversion::{from_transaction_receipt, into_hash}; @@ -63,7 +63,7 @@ impl CandidRpcClient { } pub async fn eth_get_transaction_count( - &self, + self, args: evm_rpc_types::GetTransactionCountArgs, ) -> MultiRpcResult { use crate::candid_rpc::cketh_conversion::into_get_transaction_count_params; @@ -75,7 +75,7 @@ impl CandidRpcClient { } pub async fn eth_fee_history( - &self, + self, args: evm_rpc_types::FeeHistoryArgs, ) -> MultiRpcResult { use crate::candid_rpc::cketh_conversion::{from_fee_history, into_fee_history_params}; @@ -87,7 +87,7 @@ impl CandidRpcClient { } pub async fn eth_send_raw_transaction( - &self, + self, raw_signed_transaction_hex: Hex, ) -> MultiRpcResult { use crate::candid_rpc::cketh_conversion::from_send_raw_transaction_result; @@ -99,10 +99,7 @@ impl CandidRpcClient { .map(|result| from_send_raw_transaction_result(transaction_hash.clone(), result)) } - pub async fn eth_call( - &self, - args: evm_rpc_types::CallArgs, - ) -> MultiRpcResult { + pub async fn eth_call(self, args: evm_rpc_types::CallArgs) -> MultiRpcResult { use crate::candid_rpc::cketh_conversion::{from_data, into_eth_call_params}; self.client .eth_call(into_eth_call_params(args)) @@ -111,7 +108,7 @@ impl CandidRpcClient { .map(from_data) } - pub async fn multi_request(&self, json_rpc_payload: String) -> MultiRpcResult { + pub async fn multi_request(self, json_rpc_payload: String) -> MultiRpcResult { let request: JsonRpcRequest = match serde_json::from_str(&json_rpc_payload) { Ok(req) => req, @@ -137,7 +134,10 @@ fn get_transaction_hash(raw_signed_transaction_hex: &Hex) -> Option { Some(Hex32::from(transaction.hash.0)) } -pub fn validate_get_logs_block_range(args: &GetLogsArgs, max_block_range: u32) -> RpcResult<()> { +pub fn validate_get_logs_block_range( + args: &evm_rpc_types::GetLogsArgs, + max_block_range: u32, +) -> RpcResult<()> { if let (Some(BlockTag::Number(from)), Some(BlockTag::Number(to))) = (&args.from_block, &args.to_block) { diff --git a/src/rpc_client/mod.rs b/src/rpc_client/mod.rs index 40c60cb2..12df1c6e 100644 --- a/src/rpc_client/mod.rs +++ b/src/rpc_client/mod.rs @@ -12,7 +12,7 @@ use crate::{ types::{MetricRpcMethod, RpcMethod}, }; use canhttp::{ - http::json::JsonRpcRequest, + http::json::{HttpJsonRpcResponse, JsonRpcRequest}, multi::{ MultiResults, Reduce, ReduceWithEquality, ReduceWithThreshold, ReducedResult, ReductionError, Timestamp, @@ -20,7 +20,7 @@ use canhttp::{ MaxResponseBytesRequestExtension, TransformContextRequestExtension, }; use evm_rpc_types::{ - ConsensusStrategy, JsonRpcError, MultiRpcResult, ProviderError, RpcConfig, RpcError, + ConsensusStrategy, JsonRpcError, MultiRpcResult, ProviderError, RpcConfig, RpcError, RpcResult, RpcService, RpcServices, }; use http::Request; @@ -252,15 +252,11 @@ impl EthRpcClient { self.providers.chain } - fn providers(&self) -> &BTreeSet { - &self.providers.services - } - fn response_size_estimate(&self, estimate: u64) -> ResponseSizeEstimate { ResponseSizeEstimate::new(self.config.response_size_estimate.unwrap_or(estimate)) } - fn consensus_strategy(&self) -> ReductionStrategy { + fn reduction_strategy(&self) -> ReductionStrategy { ReductionStrategy::from( self.config .response_consensus @@ -271,20 +267,22 @@ impl EthRpcClient { } pub fn eth_get_logs( - &self, + self, params: GetLogsParam, ) -> MultiRpcRequest<(GetLogsParam,), Vec> { + let response_size_estimate = self.response_size_estimate(1024 + HEADER_SIZE_LIMIT); + let reduction = self.reduction_strategy(); MultiRpcRequest::new( - self.providers(), + self.providers.services, RpcMethod::EthGetLogs, (params,), - self.response_size_estimate(1024 + HEADER_SIZE_LIMIT), - self.consensus_strategy(), + response_size_estimate, + reduction, ) } pub fn eth_get_block_by_number( - &self, + self, block: BlockSpec, ) -> MultiRpcRequest { let expected_block_size = match self.chain() { @@ -292,100 +290,115 @@ impl EthRpcClient { EthereumNetwork::MAINNET => 24 * 1024, _ => 24 * 1024, // Default for unknown networks }; + let response_size_estimate = + self.response_size_estimate(expected_block_size + HEADER_SIZE_LIMIT); + let reduction_strategy = self.reduction_strategy(); MultiRpcRequest::new( - self.providers(), + self.providers.services, RpcMethod::EthGetBlockByNumber, GetBlockByNumberParams { block, include_full_transactions: false, }, - self.response_size_estimate(expected_block_size + HEADER_SIZE_LIMIT), - self.consensus_strategy(), + response_size_estimate, + reduction_strategy, ) } pub fn eth_get_transaction_receipt( - &self, + self, tx_hash: Hash, ) -> MultiRpcRequest<(Hash,), Option> { + let response_size_estimate = self.response_size_estimate(700 + HEADER_SIZE_LIMIT); + let reduction_strategy = self.reduction_strategy(); MultiRpcRequest::new( - self.providers(), + self.providers.services, RpcMethod::EthGetTransactionReceipt, (tx_hash,), - self.response_size_estimate(700 + HEADER_SIZE_LIMIT), - self.consensus_strategy(), + response_size_estimate, + reduction_strategy, ) } pub fn eth_fee_history( - &self, + self, params: FeeHistoryParams, ) -> MultiRpcRequest { // A typical response is slightly above 300 bytes. + let response_size_estimate = self.response_size_estimate(512 + HEADER_SIZE_LIMIT); + let reduction_strategy = self.reduction_strategy(); MultiRpcRequest::new( - self.providers(), + self.providers.services, RpcMethod::EthFeeHistory, params, - self.response_size_estimate(512 + HEADER_SIZE_LIMIT), - self.consensus_strategy(), + response_size_estimate, + reduction_strategy, ) } pub fn eth_send_raw_transaction( - &self, + self, raw_signed_transaction_hex: String, ) -> MultiRpcRequest<(String,), SendRawTransactionResult> { // A successful reply is under 256 bytes, but we expect most calls to end with an error // since we submit the same transaction from multiple nodes. + let response_size_estimate = self.response_size_estimate(256 + HEADER_SIZE_LIMIT); + let reduction_strategy = self.reduction_strategy(); MultiRpcRequest::new( - self.providers(), + self.providers.services, RpcMethod::EthSendRawTransaction, (raw_signed_transaction_hex,), - self.response_size_estimate(256 + HEADER_SIZE_LIMIT), - self.consensus_strategy(), + response_size_estimate, + reduction_strategy, ) } pub fn eth_get_transaction_count( - &self, + self, params: GetTransactionCountParams, ) -> MultiRpcRequest { + let response_size_estimate = self.response_size_estimate(50 + HEADER_SIZE_LIMIT); + let reduction_strategy = self.reduction_strategy(); MultiRpcRequest::new( - self.providers(), + self.providers.services, RpcMethod::EthGetTransactionCount, params, - self.response_size_estimate(50 + HEADER_SIZE_LIMIT), - self.consensus_strategy(), + response_size_estimate, + reduction_strategy, ) } - pub fn eth_call(&self, params: EthCallParams) -> MultiRpcRequest { + pub fn eth_call(self, params: EthCallParams) -> MultiRpcRequest { + let response_size_estimate = self.response_size_estimate(256 + HEADER_SIZE_LIMIT); + let reduction_strategy = self.reduction_strategy(); MultiRpcRequest::new( - self.providers(), + self.providers.services, RpcMethod::EthCall, params, - self.response_size_estimate(256 + HEADER_SIZE_LIMIT), - self.consensus_strategy(), + response_size_estimate, + reduction_strategy, ) } - pub fn multi_request<'a>( - &self, + pub fn multi_request( + self, method: RpcMethod, - params: Option<&'a Value>, - ) -> MultiRpcRequest, RawJson> { + params: Option<&Value>, + ) -> MultiRpcRequest, RawJson> { + let response_size_estimate = self.response_size_estimate(256 + HEADER_SIZE_LIMIT); + let reduction_strategy = self.reduction_strategy(); MultiRpcRequest::new( - self.providers(), + self.providers.services, method, params, - self.response_size_estimate(256 + HEADER_SIZE_LIMIT), - self.consensus_strategy(), + response_size_estimate, + reduction_strategy, ) } } -pub struct MultiRpcRequest<'a, Params, Output> { - providers: &'a BTreeSet, +pub struct MultiRpcRequest { + providers: BTreeSet, method: RpcMethod, params: Params, response_size_estimate: ResponseSizeEstimate, @@ -393,14 +406,14 @@ pub struct MultiRpcRequest<'a, Params, Output> { _marker: std::marker::PhantomData, } -impl<'a, Params, Output> MultiRpcRequest<'a, Params, Output> { +impl MultiRpcRequest { pub fn new( - providers: &'a BTreeSet, + providers: BTreeSet, method: RpcMethod, params: Params, response_size_estimate: ResponseSizeEstimate, reduction_strategy: ReductionStrategy, - ) -> MultiRpcRequest<'a, Params, Output> { + ) -> MultiRpcRequest { MultiRpcRequest { providers, method, @@ -412,7 +425,7 @@ impl<'a, Params, Output> MultiRpcRequest<'a, Params, Output> { } } -impl MultiRpcRequest<'_, Params, Output> { +impl MultiRpcRequest { pub async fn send_and_reduce(self) -> MultiRpcResult where Params: Serialize + Clone + Debug, @@ -434,17 +447,8 @@ impl MultiRpcRequest<'_, Params, Output> { { let requests = self.create_json_rpc_requests(); - let client = - http_client(MetricRpcMethod::from(self.method.clone()), true).map_result(|r| match r? - .into_body() - .into_result() - { - Ok(value) => Ok(value), - Err(json_rpc_error) => Err(RpcError::JsonRpcError(JsonRpcError { - code: json_rpc_error.code, - message: json_rpc_error.message, - })), - }); + let client = http_client(MetricRpcMethod::from(self.method.clone()), true) + .map_result(extract_json_rpc_response); let (requests, errors) = requests.into_inner(); let (_client, mut results) = canhttp::multi::parallel_call(client, requests).await; @@ -480,7 +484,7 @@ impl MultiRpcRequest<'_, Params, Output> { .unwrap_or_default(); let effective_size_estimate = self.response_size_estimate.get(); let mut requests = MultiResults::default(); - for provider in self.providers { + for provider in self.providers.iter() { let request = resolve_rpc_service(provider.clone()) .map_err(RpcError::from) .and_then(|rpc_service| rpc_service.post(&get_override_provider())) @@ -506,6 +510,16 @@ impl MultiRpcRequest<'_, Params, Output> { } } +fn extract_json_rpc_response(result: RpcResult>) -> RpcResult { + match result?.into_body().into_result() { + Ok(value) => Ok(value), + Err(json_rpc_error) => Err(RpcError::JsonRpcError(JsonRpcError { + code: json_rpc_error.code, + message: json_rpc_error.message, + })), + } +} + pub enum ReductionStrategy { ByEquality(ReduceWithEquality), ByThreshold(ReduceWithThreshold), diff --git a/src/rpc_client/tests.rs b/src/rpc_client/tests.rs index bad2843a..06bfee83 100644 --- a/src/rpc_client/tests.rs +++ b/src/rpc_client/tests.rs @@ -39,7 +39,7 @@ mod eth_rpc_client { RpcServices::OptimismMainnet(None), ] { let client = EthRpcClient::new(empty_source, None, Timestamp::default()).unwrap(); - assert!(!client.providers().is_empty()); + assert!(!client.providers.services.is_empty()); } } @@ -56,8 +56,8 @@ mod eth_rpc_client { .unwrap(); assert_eq!( - client.providers(), - &btreeset! { + client.providers.services, + btreeset! { RpcService::EthMainnet(provider1), RpcService::EthMainnet(provider2) } From 6fa2d3f2a4fb64afd5586645e2d6ea1af6bab77f Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 31 Oct 2025 15:23:47 +0100 Subject: [PATCH 14/19] Fix merge --- Cargo.toml | 1 - src/candid_rpc/mod.rs | 16 ++++++++-------- src/memory.rs | 2 +- tests/mock_http_runtime/mock/json/mod.rs | 3 +-- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fe2fbd90..3eae25b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,6 @@ alloy-rpc-types = { workspace = true } assert_matches = { workspace = true } async-trait = { workspace = true } candid_parser = { workspace = true } -derive_more = {workspace = true, features = ["from", "into"]} evm_rpc_client = { path = "evm_rpc_client", features = ["alloy"] } 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 } diff --git a/src/candid_rpc/mod.rs b/src/candid_rpc/mod.rs index c1a041a8..68c65e1c 100644 --- a/src/candid_rpc/mod.rs +++ b/src/candid_rpc/mod.rs @@ -37,7 +37,7 @@ impl CandidRpcClient { } pub async fn eth_get_logs_cycles_cost( - &self, + self, args: evm_rpc_types::GetLogsArgs, ) -> RpcResult { use cketh_conversion::into_get_logs_param; @@ -59,7 +59,7 @@ impl CandidRpcClient { .map(from_block) } - pub async fn eth_get_block_by_number_cycles_cost(&self, block: BlockTag) -> RpcResult { + pub async fn eth_get_block_by_number_cycles_cost(self, block: BlockTag) -> RpcResult { use cketh_conversion::into_block_spec; self.client .eth_get_block_by_number(into_block_spec(block)) @@ -79,7 +79,7 @@ impl CandidRpcClient { .map(|option| option.map(from_transaction_receipt)) } - pub async fn eth_get_transaction_receipt_cycles_cost(&self, hash: Hex32) -> RpcResult { + pub async fn eth_get_transaction_receipt_cycles_cost(self, hash: Hex32) -> RpcResult { use cketh_conversion::into_hash; self.client .eth_get_transaction_receipt(into_hash(hash)) @@ -100,7 +100,7 @@ impl CandidRpcClient { } pub async fn eth_get_transaction_count_cycles_cost( - &self, + self, args: evm_rpc_types::GetTransactionCountArgs, ) -> RpcResult { use cketh_conversion::into_get_transaction_count_params; @@ -123,7 +123,7 @@ impl CandidRpcClient { } pub async fn eth_fee_history_cycles_cost( - &self, + self, args: evm_rpc_types::FeeHistoryArgs, ) -> RpcResult { use cketh_conversion::into_fee_history_params; @@ -147,7 +147,7 @@ impl CandidRpcClient { } pub async fn eth_send_raw_transaction_cycles_cost( - &self, + self, raw_signed_transaction_hex: Hex, ) -> RpcResult { self.client @@ -165,7 +165,7 @@ impl CandidRpcClient { .map(from_data) } - pub async fn eth_call_cycles_cost(&self, args: evm_rpc_types::CallArgs) -> RpcResult { + pub async fn eth_call_cycles_cost(self, args: evm_rpc_types::CallArgs) -> RpcResult { use cketh_conversion::into_eth_call_params; self.client .eth_call(into_eth_call_params(args)) @@ -188,7 +188,7 @@ impl CandidRpcClient { .map(String::from) } - pub async fn multi_cycles_cost(&self, json_rpc_payload: String) -> RpcResult { + pub async fn multi_cycles_cost(self, json_rpc_payload: String) -> RpcResult { let request = into_json_request(json_rpc_payload)?; self.client .multi_request( diff --git a/src/memory.rs b/src/memory.rs index fff6a79b..993e5644 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -114,7 +114,7 @@ pub fn set_override_provider(provider: OverrideProvider) { pub fn next_request_id() -> Id { UNSTABLE_HTTP_REQUEST_COUNTER.with_borrow_mut(|counter| { let current_request_id = counter.get_and_increment(); - Id::from(ConstantSizeId::from(current_request_id)) + Id::from(current_request_id) }) } diff --git a/tests/mock_http_runtime/mock/json/mod.rs b/tests/mock_http_runtime/mock/json/mod.rs index 40b49a93..81c09781 100644 --- a/tests/mock_http_runtime/mock/json/mod.rs +++ b/tests/mock_http_runtime/mock/json/mod.rs @@ -3,7 +3,6 @@ mod tests; use crate::mock_http_runtime::mock::CanisterHttpRequestMatcher; use canhttp::http::json::{ConstantSizeId, Id, JsonRpcRequest}; -use derive_more::{From, Into}; use pocket_ic::common::rest::{ CanisterHttpHeader, CanisterHttpMethod, CanisterHttpReply, CanisterHttpRequest, CanisterHttpResponse, @@ -42,7 +41,7 @@ impl JsonRpcRequestMatcher { pub fn with_raw_id(self, id: Id) -> Self { Self { - id: Some(Id::from(id)), + id: Some(id), ..self } } From 8c8b82e3e313a992233a4477388a35765c464e77 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 3 Nov 2025 10:14:25 +0100 Subject: [PATCH 15/19] Remove redundant check for demo mode --- evm_rpc_client/src/request/mod.rs | 2 +- src/candid_rpc/mod.rs | 6 +++--- src/main.rs | 3 --- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/evm_rpc_client/src/request/mod.rs b/evm_rpc_client/src/request/mod.rs index 9d9103be..32de5415 100644 --- a/evm_rpc_client/src/request/mod.rs +++ b/evm_rpc_client/src/request/mod.rs @@ -761,7 +761,7 @@ pub struct RequestCostBuilder { } impl RequestCostBuilder { - /// Constructs the [`Request`] and send it using the [`SolRpcClient`]. + /// Constructs the [`Request`] and send it using the [`EvmRpcClient`]. pub async fn send(self) -> RpcResult where Config: CandidType + Send, diff --git a/src/candid_rpc/mod.rs b/src/candid_rpc/mod.rs index 68c65e1c..2805719b 100644 --- a/src/candid_rpc/mod.rs +++ b/src/candid_rpc/mod.rs @@ -1,8 +1,6 @@ mod cketh_conversion; -use crate::{ - candid_rpc::cketh_conversion::into_json_request, rpc_client::EthRpcClient, types::RpcMethod, -}; +use crate::{rpc_client::EthRpcClient, types::RpcMethod}; use candid::Nat; use canhttp::multi::Timestamp; use ethers_core::{types::Transaction, utils::rlp}; @@ -174,6 +172,7 @@ impl CandidRpcClient { } pub async fn multi_request(self, json_rpc_payload: String) -> MultiRpcResult { + use cketh_conversion::into_json_request; let request = match into_json_request(json_rpc_payload) { Ok(request) => request, Err(err) => return MultiRpcResult::Consistent(Err(err)), @@ -189,6 +188,7 @@ impl CandidRpcClient { } pub async fn multi_cycles_cost(self, json_rpc_payload: String) -> RpcResult { + use cketh_conversion::into_json_request; let request = into_json_request(json_rpc_payload)?; self.client .multi_request( diff --git a/src/main.rs b/src/main.rs index 2410d7e7..fc3248bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,9 +61,6 @@ pub async fn eth_get_logs_cycles_cost( let config = config.unwrap_or_default(); let max_block_range = config.max_block_range_or_default(); validate_get_logs_block_range(&args, max_block_range)?; - if is_demo_active() { - return Ok(0); - } match CandidRpcClient::new(source, Some(RpcConfig::from(config)), now()) { Ok(source) => source.eth_get_logs_cycles_cost(args).await, Err(err) => Err(err), From f7bd7c155671b7e320b3189813bcba613d90eb28 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 3 Nov 2025 10:23:35 +0100 Subject: [PATCH 16/19] Add shared method to validate `eth_get_logs` RPC config --- src/main.rs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/main.rs b/src/main.rs index fc3248bd..c3e381e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,11 +41,10 @@ pub async fn eth_get_logs( config: Option, args: evm_rpc_types::GetLogsArgs, ) -> MultiRpcResult> { - let config = config.unwrap_or_default(); - let max_block_range = config.max_block_range_or_default(); - if let Err(err) = validate_get_logs_block_range(&args, max_block_range) { - return MultiRpcResult::Consistent(Err(err)); - } + let config = match eth_get_logs_rpc_config(config, &args) { + Ok(config) => config, + Err(err) => return MultiRpcResult::from(err), + }; match CandidRpcClient::new(source, Some(RpcConfig::from(config)), now()) { Ok(source) => source.eth_get_logs(args).await, Err(err) => Err(err).into(), @@ -58,15 +57,22 @@ pub async fn eth_get_logs_cycles_cost( config: Option, args: evm_rpc_types::GetLogsArgs, ) -> RpcResult { - let config = config.unwrap_or_default(); - let max_block_range = config.max_block_range_or_default(); - validate_get_logs_block_range(&args, max_block_range)?; - match CandidRpcClient::new(source, Some(RpcConfig::from(config)), now()) { + match CandidRpcClient::new(source, Some(eth_get_logs_rpc_config(config, &args)?), now()) { Ok(source) => source.eth_get_logs_cycles_cost(args).await, Err(err) => Err(err), } } +fn eth_get_logs_rpc_config( + config: Option, + args: &evm_rpc_types::GetLogsArgs, +) -> Result { + let config = config.unwrap_or_default(); + let max_block_range = config.max_block_range_or_default(); + validate_get_logs_block_range(&args, max_block_range)?; + Ok(RpcConfig::from(config)) +} + #[update(name = "eth_getBlockByNumber")] pub async fn eth_get_block_by_number( source: RpcServices, From 7c78374ebf3bf36b009f8ceb356cb68626eeaeee Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 3 Nov 2025 11:01:41 +0100 Subject: [PATCH 17/19] Clippy --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index c3e381e3..70dfdc4d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,7 +43,7 @@ pub async fn eth_get_logs( ) -> MultiRpcResult> { let config = match eth_get_logs_rpc_config(config, &args) { Ok(config) => config, - Err(err) => return MultiRpcResult::from(err), + Err(err) => return MultiRpcResult::from(Err(err)), }; match CandidRpcClient::new(source, Some(RpcConfig::from(config)), now()) { Ok(source) => source.eth_get_logs(args).await, From 95b70748ee14342efc45c396c20ee2fb0e44af05 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 3 Nov 2025 11:10:54 +0100 Subject: [PATCH 18/19] Clippy --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 70dfdc4d..9b58530f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -69,7 +69,7 @@ fn eth_get_logs_rpc_config( ) -> Result { let config = config.unwrap_or_default(); let max_block_range = config.max_block_range_or_default(); - validate_get_logs_block_range(&args, max_block_range)?; + validate_get_logs_block_range(args, max_block_range)?; Ok(RpcConfig::from(config)) } From 03e6e9ba794edbde8ba81c945a42f5a5d9b9ac1a Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Mon, 3 Nov 2025 11:14:36 +0100 Subject: [PATCH 19/19] Clippy --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 9b58530f..a43f01bc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,7 +45,7 @@ pub async fn eth_get_logs( Ok(config) => config, Err(err) => return MultiRpcResult::from(Err(err)), }; - match CandidRpcClient::new(source, Some(RpcConfig::from(config)), now()) { + match CandidRpcClient::new(source, Some(config), now()) { Ok(source) => source.eth_get_logs(args).await, Err(err) => Err(err).into(), }