From 691f8408a915f9d278b720408dcc1fb6f27341c0 Mon Sep 17 00:00:00 2001 From: gregorydemay <112856886+gregorydemay@users.noreply.github.com> Date: Tue, 24 Sep 2024 11:37:04 +0200 Subject: [PATCH] feat: implement threshold strategy (#287) Follow-up on #286 to implement the threshold strategy to aggregate responses from multiple providers: - Introduce a top-level `MultiCallResults::reduce` method which takes as parameter a `ConsensusStrategy` to aggregate the various responses. All existing methods, including `eth_fee_history`, were refactor to use the new `reduce` method. - In case of `ConsensusStrategy::Threshold`, the reduction strategy simply consists of returning the most frequent (non-error) response if it appeared at least the specified threshold number of times. - Refactor `MultiCallResults` to stored ok results and errors separately. --- Cargo.lock | 10 + Cargo.toml | 1 + candid/evm_rpc.did | 7 +- evm_rpc_types/src/result/mod.rs | 10 +- evm_rpc_types/src/rpc_client/mod.rs | 8 +- src/candid_rpc.rs | 33 +-- src/rpc_client/eth_rpc/mod.rs | 10 - src/rpc_client/mod.rs | 424 ++++++++++++++++------------ src/rpc_client/providers.rs | 41 --- src/rpc_client/requests.rs | 19 -- src/rpc_client/responses.rs | 93 ------ src/rpc_client/tests.rs | 353 +++++++++++------------ tests/tests.rs | 94 +++++- 13 files changed, 544 insertions(+), 559 deletions(-) delete mode 100644 src/rpc_client/providers.rs delete mode 100644 src/rpc_client/requests.rs delete mode 100644 src/rpc_client/responses.rs diff --git a/Cargo.lock b/Cargo.lock index ddc52313..09d1ad83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1548,6 +1548,7 @@ dependencies = [ "ic-stable-structures", "ic-state-machine-tests", "ic-test-utilities-load-wasm", + "itertools 0.13.0", "maplit", "minicbor", "minicbor-derive", @@ -4228,6 +4229,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.11" diff --git a/Cargo.toml b/Cargo.toml index b988fb99..68a038be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ ic-config = { git = "https://github.com/dfinity/ic", rev = "release-2023-09-27_2 ic-crypto-test-utils-reproducible-rng = { git = "https://github.com/dfinity/ic", rev = "release-2024-09-12_01-30-base" } ic-state-machine-tests = { git = "https://github.com/dfinity/ic", rev = "release-2023-09-27_23-01" } ic-test-utilities-load-wasm = { git = "https://github.com/dfinity/ic", rev = "release-2023-09-27_23-01" } +itertools = "0.13" maplit = "1" proptest = { workspace = true } serde_bytes = { workspace = true } diff --git a/candid/evm_rpc.did b/candid/evm_rpc.did index d699730b..93cfddd5 100644 --- a/candid/evm_rpc.did +++ b/candid/evm_rpc.did @@ -182,7 +182,12 @@ type RequestCostResult = variant { Ok : nat; Err : RpcError }; type RpcConfig = record { responseSizeEstimate : opt nat64; responseConsensus : opt ConsensusStrategy }; type ConsensusStrategy = variant { Equality; - Threshold : record { num_providers : opt nat; min_num_ok : nat }; + Threshold : record { + // Total number of providers to be queried. Can be omitted, if that number can be inferred (e.g., providers are specified in the request). + total : opt nat; + // Minimum number of providers that must return the same (non-error) result. + min : nat; + }; }; type RpcError = variant { JsonRpcError : JsonRpcError; diff --git a/evm_rpc_types/src/result/mod.rs b/evm_rpc_types/src/result/mod.rs index 6abdb7e7..0ccd36e0 100644 --- a/evm_rpc_types/src/result/mod.rs +++ b/evm_rpc_types/src/result/mod.rs @@ -63,7 +63,7 @@ impl From> for MultiRpcResult { } } -#[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, CandidType, Deserialize)] pub enum RpcError { ProviderError(ProviderError), HttpOutcallError(HttpOutcallError), @@ -71,7 +71,7 @@ pub enum RpcError { ValidationError(ValidationError), } -#[derive(Clone, Debug, PartialEq, Eq, CandidType, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, CandidType, Deserialize)] pub enum ProviderError { NoPermission, TooFewCycles { expected: u128, received: u128 }, @@ -80,7 +80,7 @@ pub enum ProviderError { InvalidRpcConfig(String), } -#[derive(Clone, Debug, Eq, PartialEq, CandidType, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord, CandidType, Deserialize)] pub enum HttpOutcallError { /// Error from the IC system API. IcError { @@ -98,13 +98,13 @@ pub enum HttpOutcallError { }, } -#[derive(Clone, Debug, Eq, PartialEq, CandidType, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord, CandidType, Deserialize)] pub struct JsonRpcError { pub code: i64, pub message: String, } -#[derive(Clone, Debug, Eq, PartialEq, CandidType, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord, CandidType, Deserialize)] pub enum ValidationError { Custom(String), InvalidHex(String), diff --git a/evm_rpc_types/src/rpc_client/mod.rs b/evm_rpc_types/src/rpc_client/mod.rs index efc610c1..15d53137 100644 --- a/evm_rpc_types/src/rpc_client/mod.rs +++ b/evm_rpc_types/src/rpc_client/mod.rs @@ -19,15 +19,17 @@ pub enum ConsensusStrategy { /// All providers must return the same non-error result. #[default] Equality, + + /// A subset of providers must return the same non-error result. Threshold { - /// Number of providers to be queried: + /// Total number of providers to be queried: /// * If `None`, will be set to the number of providers manually specified in `RpcServices`. /// * If `Some`, must correspond to the number of manually specified providers in `RpcServices`; /// or if they are none indicating that default providers should be used, select the corresponding number of providers. - num_providers: Option, + total: Option, /// Minimum number of providers that must return the same (non-error) result. - min_num_ok: u8, + min: u8, }, } diff --git a/src/candid_rpc.rs b/src/candid_rpc.rs index ca4eaba7..65dc99da 100644 --- a/src/candid_rpc.rs +++ b/src/candid_rpc.rs @@ -17,7 +17,8 @@ fn process_result(method: RpcMethod, result: Result>) -> Err(err) => match err { MultiCallError::ConsistentError(err) => MultiRpcResult::Consistent(Err(err)), MultiCallError::InconsistentResults(multi_call_results) => { - multi_call_results.results.iter().for_each(|(service, _)| { + let results = multi_call_results.into_vec(); + results.iter().for_each(|(service, _service_result)| { if let Ok(ResolvedRpcService::Provider(provider)) = resolve_rpc_service(service.clone()) { @@ -35,7 +36,7 @@ fn process_result(method: RpcMethod, result: Result>) -> ) } }); - MultiRpcResult::Inconsistent(multi_call_results.results.into_iter().collect()) + MultiRpcResult::Inconsistent(results) } }, } @@ -121,8 +122,7 @@ impl CandidRpcClient { RpcMethod::EthGetTransactionCount, self.client .eth_get_transaction_count(into_get_transaction_count_params(args)) - .await - .reduce_with_equality(), + .await, ) .map(Nat256::from) } @@ -193,9 +193,7 @@ mod test { process_result( method, Err(MultiCallError::<()>::InconsistentResults( - MultiCallResults { - results: Default::default() - } + MultiCallResults::default() )) ), MultiRpcResult::Inconsistent(vec![]) @@ -203,11 +201,12 @@ mod test { assert_eq!( process_result( method, - Err(MultiCallError::InconsistentResults(MultiCallResults { - results: vec![(RpcService::EthMainnet(EthMainnetService::Ankr), Ok(5))] - .into_iter() - .collect(), - })) + Err(MultiCallError::InconsistentResults( + MultiCallResults::from_non_empty_iter(vec![( + RpcService::EthMainnet(EthMainnetService::Ankr), + Ok(5) + )]) + )) ), MultiRpcResult::Inconsistent(vec![( RpcService::EthMainnet(EthMainnetService::Ankr), @@ -217,17 +216,15 @@ mod test { assert_eq!( process_result( method, - Err(MultiCallError::InconsistentResults(MultiCallResults { - results: vec![ + Err(MultiCallError::InconsistentResults( + MultiCallResults::from_non_empty_iter(vec![ (RpcService::EthMainnet(EthMainnetService::Ankr), Ok(5)), ( RpcService::EthMainnet(EthMainnetService::Cloudflare), Err(RpcError::ProviderError(ProviderError::NoPermission)) ) - ] - .into_iter() - .collect(), - })) + ]) + )) ), MultiRpcResult::Inconsistent(vec![ (RpcService::EthMainnet(EthMainnetService::Ankr), Ok(5)), diff --git a/src/rpc_client/eth_rpc/mod.rs b/src/rpc_client/eth_rpc/mod.rs index 14707c0e..53b1a517 100644 --- a/src/rpc_client/eth_rpc/mod.rs +++ b/src/rpc_client/eth_rpc/mod.rs @@ -133,16 +133,6 @@ pub fn is_response_too_large(code: &RejectionCode, message: &str) -> bool { && (message.contains("size limit") || message.contains("length limit")) } -pub fn are_errors_consistent( - left: &Result, - right: &Result, -) -> bool { - match (left, right) { - (Ok(_), _) | (_, Ok(_)) => true, - _ => left == right, - } -} - #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct ResponseSizeEstimate(u64); diff --git a/src/rpc_client/mod.rs b/src/rpc_client/mod.rs index 13b27d99..ed24c139 100644 --- a/src/rpc_client/mod.rs +++ b/src/rpc_client/mod.rs @@ -1,13 +1,12 @@ use crate::logs::{DEBUG, INFO}; -use crate::rpc_client::eth_rpc::{ - are_errors_consistent, HttpResponsePayload, ResponseSizeEstimate, HEADER_SIZE_LIMIT, -}; +use crate::rpc_client::eth_rpc::{HttpResponsePayload, ResponseSizeEstimate, HEADER_SIZE_LIMIT}; use crate::rpc_client::numeric::TransactionCount; use evm_rpc_types::{ ConsensusStrategy, EthMainnetService, EthSepoliaService, L2MainnetService, ProviderError, - RpcConfig, RpcError, RpcService, RpcServices, + RpcConfig, RpcError, RpcResult, RpcService, RpcServices, }; use ic_canister_log::log; +use ic_crypto_sha3::Keccak256; use json::requests::{ BlockSpec, FeeHistoryParams, GetBlockByNumberParams, GetLogsParam, GetTransactionCountParams, }; @@ -178,67 +177,58 @@ where .unwrap_or_else(|| default_providers.to_vec()) .into_iter() .collect()), - ConsensusStrategy::Threshold { - num_providers, - min_num_ok, - } => { + ConsensusStrategy::Threshold { total, min } => { // Ensure that - // 0 < min_num_ok <= num_providers <= all_providers.len() - if min_num_ok == 0 { + // 0 < min <= total <= all_providers.len() + if min == 0 { return Err(ProviderError::InvalidRpcConfig( - "min_num_ok must be greater than 0".to_string(), + "min must be greater than 0".to_string(), )); } match user_input { None => { let all_providers_len = default_providers.len() + non_default_providers.len(); - let num_providers = num_providers.ok_or_else(|| { + let total = total.ok_or_else(|| { ProviderError::InvalidRpcConfig( - "num_providers must be specified when using default providers" - .to_string(), + "total must be specified when using default providers".to_string(), ) })?; - if min_num_ok > num_providers { + if min > total { return Err(ProviderError::InvalidRpcConfig(format!( - "min_num_ok {} is greater than num_providers {}", - min_num_ok, num_providers + "min {} is greater than total {}", + min, total ))); } - if num_providers > all_providers_len as u8 { + if total > all_providers_len as u8 { return Err(ProviderError::InvalidRpcConfig(format!( - "num_providers {} is greater than the number of all supported providers {}", - num_providers, - all_providers_len + "total {} is greater than the number of all supported providers {}", + total, all_providers_len ))); } let providers: BTreeSet<_> = default_providers .iter() .chain(non_default_providers.iter()) - .take(num_providers as usize) + .take(total as usize) .cloned() .collect(); - assert_eq!( - providers.len(), - num_providers as usize, - "BUG: duplicate providers" - ); + assert_eq!(providers.len(), total as usize, "BUG: duplicate providers"); Ok(providers) } Some(providers) => { - if min_num_ok > providers.len() as u8 { + if min > providers.len() as u8 { return Err(ProviderError::InvalidRpcConfig(format!( - "min_num_ok {} is greater than the number of specified providers {}", - min_num_ok, + "min {} is greater than the number of specified providers {}", + min, providers.len() ))); } - if let Some(num_providers) = num_providers { - if num_providers != providers.len() as u8 { + if let Some(total) = total { + if total != providers.len() as u8 { return Err(ProviderError::InvalidRpcConfig(format!( - "num_providers {} is different than the number of specified providers {}", - num_providers, + "total {} is different than the number of specified providers {}", + total, providers.len() ))); } @@ -278,6 +268,14 @@ impl EthRpcClient { ResponseSizeEstimate::new(self.config.response_size_estimate.unwrap_or(estimate)) } + fn consensus_strategy(&self) -> ConsensusStrategy { + self.config + .response_consensus + .as_ref() + .cloned() + .unwrap_or_default() + } + /// 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). @@ -317,14 +315,13 @@ impl EthRpcClient { &self, params: GetLogsParam, ) -> Result, MultiCallError>> { - let results: MultiCallResults> = self - .parallel_call( - "eth_getLogs", - vec![params], - self.response_size_estimate(1024 + HEADER_SIZE_LIMIT), - ) - .await; - results.reduce_with_equality() + self.parallel_call( + "eth_getLogs", + vec![params], + self.response_size_estimate(1024 + HEADER_SIZE_LIMIT), + ) + .await + .reduce(self.consensus_strategy()) } pub async fn eth_get_block_by_number( @@ -337,31 +334,29 @@ impl EthRpcClient { _ => 24 * 1024, // Default for unknown networks }; - let results: MultiCallResults = self - .parallel_call( - "eth_getBlockByNumber", - GetBlockByNumberParams { - block, - include_full_transactions: false, - }, - self.response_size_estimate(expected_block_size + HEADER_SIZE_LIMIT), - ) - .await; - results.reduce_with_equality() + self.parallel_call( + "eth_getBlockByNumber", + GetBlockByNumberParams { + block, + include_full_transactions: false, + }, + self.response_size_estimate(expected_block_size + HEADER_SIZE_LIMIT), + ) + .await + .reduce(self.consensus_strategy()) } pub async fn eth_get_transaction_receipt( &self, tx_hash: Hash, ) -> Result, MultiCallError>> { - let results: MultiCallResults> = self - .parallel_call( - "eth_getTransactionReceipt", - vec![tx_hash], - self.response_size_estimate(700 + HEADER_SIZE_LIMIT), - ) - .await; - results.reduce_with_equality() + self.parallel_call( + "eth_getTransactionReceipt", + vec![tx_hash], + self.response_size_estimate(700 + HEADER_SIZE_LIMIT), + ) + .await + .reduce(self.consensus_strategy()) } pub async fn eth_fee_history( @@ -369,14 +364,13 @@ impl EthRpcClient { params: FeeHistoryParams, ) -> Result> { // A typical response is slightly above 300 bytes. - let results: MultiCallResults = self - .parallel_call( - "eth_feeHistory", - params, - self.response_size_estimate(512 + HEADER_SIZE_LIMIT), - ) - .await; - results.reduce_with_strict_majority_by_key(|fee_history| fee_history.oldest_block) + self.parallel_call( + "eth_feeHistory", + params, + self.response_size_estimate(512 + HEADER_SIZE_LIMIT), + ) + .await + .reduce(self.consensus_strategy()) } pub async fn eth_send_raw_transaction( @@ -391,19 +385,20 @@ impl EthRpcClient { self.response_size_estimate(256 + HEADER_SIZE_LIMIT), ) .await - .reduce_with_equality() + .reduce(self.consensus_strategy()) } pub async fn eth_get_transaction_count( &self, params: GetTransactionCountParams, - ) -> MultiCallResults { + ) -> Result> { self.parallel_call( "eth_getTransactionCount", params, self.response_size_estimate(50 + HEADER_SIZE_LIMIT), ) .await + .reduce(self.consensus_strategy()) } } @@ -411,18 +406,52 @@ impl EthRpcClient { /// Guaranteed to be non-empty. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MultiCallResults { - pub results: BTreeMap>, + ok_results: BTreeMap, + errors: BTreeMap, +} + +impl Default for MultiCallResults { + fn default() -> Self { + Self::new() + } } impl MultiCallResults { - fn from_non_empty_iter)>>( + pub fn new() -> Self { + Self { + ok_results: BTreeMap::new(), + errors: BTreeMap::new(), + } + } + + pub fn from_non_empty_iter)>>( iter: I, ) -> Self { - let results = BTreeMap::from_iter(iter); + let mut results = Self::new(); + for (provider, result) in iter { + results.insert_once(provider, result); + } if results.is_empty() { panic!("BUG: MultiCallResults cannot be empty!") } - Self { results } + results + } + + fn is_empty(&self) -> bool { + self.ok_results.is_empty() && self.errors.is_empty() + } + + fn insert_once(&mut self, provider: RpcService, result: RpcResult) { + match result { + Ok(value) => { + assert!(!self.errors.contains_key(&provider)); + assert!(self.ok_results.insert(provider, value).is_none()); + } + Err(error) => { + assert!(!self.ok_results.contains_key(&provider)); + assert!(self.errors.insert(provider, error).is_none()); + } + } } #[cfg(test)] @@ -454,52 +483,52 @@ impl MultiCallResults { ) })) } + + pub fn into_vec(self) -> Vec<(RpcService, RpcResult)> { + self.ok_results + .into_iter() + .map(|(provider, result)| (provider, Ok(result))) + .chain( + self.errors + .into_iter() + .map(|(provider, error)| (provider, Err(error))), + ) + .collect() + } + + fn group_errors(&self) -> BTreeMap<&RpcError, BTreeSet<&RpcService>> { + let mut errors: BTreeMap<_, _> = BTreeMap::new(); + for (provider, error) in self.errors.iter() { + errors + .entry(error) + .or_insert_with(BTreeSet::new) + .insert(provider); + } + errors + } } impl MultiCallResults { /// Expects all results to be ok or return the following error: - /// * MultiCallError::ConsistentJsonRpcError: all errors are the same JSON-RPC error. - /// * MultiCallError::ConsistentHttpOutcallError: all errors are the same HTTP outcall error. - /// * MultiCallError::InconsistentResults if there are different errors. + /// * MultiCallError::ConsistentError: all errors are the same and there is no ok results. + /// * MultiCallError::InconsistentResults: in all other cases. fn all_ok(self) -> Result, MultiCallError> { - let mut has_ok = false; - let mut first_error: Option<(RpcService, &Result)> = None; - for (provider, result) in self.results.iter() { - match result { - Ok(_value) => { - has_ok = true; - } - _ => match first_error { - None => { - first_error = Some((provider.clone(), result)); - } - Some((first_error_provider, error)) => { - if !are_errors_consistent(error, result) { - return Err(MultiCallError::InconsistentResults(self)); - } - first_error = Some((first_error_provider, error)); - } - }, - } + if self.errors.is_empty() { + return Ok(self.ok_results); } - match first_error { - None => Ok(self - .results - .into_iter() - .map(|(provider, result)| { - (provider, result.expect("BUG: all results should be ok")) - }) - .collect()), - Some((_, Err(error))) => { - if has_ok { - Err(MultiCallError::InconsistentResults(self)) - } else { - Err(MultiCallError::ConsistentError(error.clone())) - } + Err(self.expect_error()) + } + + fn expect_error(self) -> MultiCallError { + let errors = self.group_errors(); + match errors.len() { + 0 => { + panic!("BUG: errors should be non-empty") } - Some((_, Ok(_))) => { - panic!("BUG: first_error should be an error type") + 1 if self.ok_results.is_empty() => { + MultiCallError::ConsistentError(errors.into_keys().next().unwrap().clone()) } + _ => MultiCallError::InconsistentResults(self), } } } @@ -510,8 +539,15 @@ pub enum MultiCallError { InconsistentResults(MultiCallResults), } -impl MultiCallResults { - pub fn reduce_with_equality(self) -> Result> { +impl MultiCallResults { + pub fn reduce(self, strategy: ConsensusStrategy) -> Result> { + match strategy { + ConsensusStrategy::Equality => self.reduce_with_equality(), + ConsensusStrategy::Threshold { total: _, min } => self.reduce_with_threshold(min), + } + } + + fn reduce_with_equality(self) -> Result> { let mut results = self.all_ok()?.into_iter(); let (base_node_provider, base_result) = results .next() @@ -535,77 +571,97 @@ impl MultiCallResults { Ok(base_result) } - pub fn reduce_with_strict_majority_by_key K, K: Ord>( - self, - extractor: F, - ) -> Result> { - let mut votes_by_key: BTreeMap> = BTreeMap::new(); - for (provider, result) in self.all_ok()?.into_iter() { - let key = extractor(&result); - match votes_by_key.remove(&key) { - Some(mut votes_for_same_key) => { - let (_other_provider, other_result) = votes_for_same_key - .last_key_value() - .expect("BUG: results_with_same_key is non-empty"); - if &result != other_result { - let error = MultiCallError::InconsistentResults( - MultiCallResults::from_non_empty_iter( - votes_for_same_key - .into_iter() - .chain(std::iter::once((provider, result))) - .map(|(provider, result)| (provider, Ok(result))), - ), - ); - log!( - INFO, - "[reduce_with_strict_majority_by_key]: inconsistent results {error:?}" - ); - return Err(error); - } - votes_for_same_key.insert(provider, result); - votes_by_key.insert(key, votes_for_same_key); - } - None => { - let _ = votes_by_key.insert(key, BTreeMap::from([(provider, result)])); - } - } + fn reduce_with_threshold(self, min: u8) -> Result> { + assert!(min > 0, "BUG: min must be greater than 0"); + if self.ok_results.len() < min as usize { + // At least total >= min were queried, + // so there is at least one error + return Err(self.expect_error()); } + let distribution = ResponseDistribution::from_non_empty_iter(self.ok_results.clone()); + let (most_likely_response, providers) = distribution + .most_frequent() + .expect("BUG: distribution should be non-empty"); + if providers.len() >= min as usize { + Ok(most_likely_response.clone()) + } else { + log!( + INFO, + "[reduce_with_threshold]: too many inconsistent ok responses to reach threshold of {min}, results: {self:?}" + ); + Err(MultiCallError::InconsistentResults(self)) + } + } +} - let mut tally: Vec<(K, BTreeMap)> = Vec::from_iter(votes_by_key); - tally.sort_unstable_by(|(_left_key, left_ballot), (_right_key, right_ballot)| { - left_ballot.len().cmp(&right_ballot.len()) - }); - match tally.len() { - 0 => panic!("BUG: tally should be non-empty"), - 1 => Ok(tally - .pop() - .and_then(|(_key, mut ballot)| ballot.pop_last()) - .expect("BUG: tally is non-empty") - .1), - _ => { - let mut first = tally.pop().expect("BUG: tally has at least 2 elements"); - let second = tally.pop().expect("BUG: tally has at least 2 elements"); - if first.1.len() > second.1.len() { - Ok(first - .1 - .pop_last() - .expect("BUG: tally should be non-empty") - .1) - } else { - let error = - MultiCallError::InconsistentResults(MultiCallResults::from_non_empty_iter( - first - .1 - .into_iter() - .chain(second.1) - .map(|(provider, result)| (provider, Ok(result))), - )); - log!( - INFO, - "[reduce_with_strict_majority_by_key]: no strict majority {error:?}" - ); - Err(error) - } +/// Distribution of responses observed from different providers. +/// +/// From the API point of view, it emulates a map from a response instance to a set of providers that returned it. +/// At the implementation level, to avoid requiring `T` to have a total order (i.e., must implements `Ord` if it were to be used as keys in a `BTreeMap`) which might not always be meaningful, +/// we use as key the hash of the serialized response instance. +struct ResponseDistribution { + hashes: BTreeMap<[u8; 32], T>, + responses: BTreeMap<[u8; 32], BTreeSet>, +} + +impl Default for ResponseDistribution { + fn default() -> Self { + Self::new() + } +} + +impl ResponseDistribution { + pub fn new() -> Self { + Self { + hashes: BTreeMap::new(), + responses: BTreeMap::new(), + } + } + + /// Returns the most frequent response and the set of providers that returned it. + pub fn most_frequent(&self) -> Option<(&T, &BTreeSet)> { + self.responses + .iter() + .max_by_key(|(_hash, providers)| providers.len()) + .map(|(hash, providers)| { + ( + self.hashes.get(hash).expect("BUG: hash should be present"), + providers, + ) + }) + } +} + +impl ResponseDistribution { + pub fn from_non_empty_iter>(iter: I) -> Self { + let mut distribution = Self::new(); + for (provider, result) in iter { + distribution.insert_once(provider, result); + } + distribution + } + + pub fn insert_once(&mut self, provider: RpcService, result: T) { + let hash = Keccak256::hash(serde_json::to_vec(&result).expect("BUG: failed to serialize")); + match self.hashes.get(&hash) { + Some(existing_result) => { + assert_eq!( + existing_result, &result, + "BUG: different results once serialized have the same hash" + ); + let providers = self + .responses + .get_mut(&hash) + .expect("BUG: hash is guaranteed to be present"); + assert!( + providers.insert(provider), + "BUG: provider is already present" + ); + } + None => { + assert_eq!(self.hashes.insert(hash, result), None); + let providers = BTreeSet::from_iter(std::iter::once(provider)); + assert_eq!(self.responses.insert(hash, providers), None); } } } diff --git a/src/rpc_client/providers.rs b/src/rpc_client/providers.rs deleted file mode 100644 index 7a05114c..00000000 --- a/src/rpc_client/providers.rs +++ /dev/null @@ -1,41 +0,0 @@ -use evm_rpc_types::{EthMainnetService, EthSepoliaService, L2MainnetService, RpcService}; - -pub(crate) const MAINNET_PROVIDERS: &[RpcService] = &[ - RpcService::EthMainnet(EthMainnetService::Alchemy), - RpcService::EthMainnet(EthMainnetService::Ankr), - RpcService::EthMainnet(EthMainnetService::PublicNode), - RpcService::EthMainnet(EthMainnetService::Cloudflare), - RpcService::EthMainnet(EthMainnetService::Llama), -]; - -pub(crate) const SEPOLIA_PROVIDERS: &[RpcService] = &[ - RpcService::EthSepolia(EthSepoliaService::Alchemy), - RpcService::EthSepolia(EthSepoliaService::Ankr), - RpcService::EthSepolia(EthSepoliaService::BlockPi), - RpcService::EthSepolia(EthSepoliaService::PublicNode), - RpcService::EthSepolia(EthSepoliaService::Sepolia), -]; - -pub(crate) const ARBITRUM_PROVIDERS: &[RpcService] = &[ - RpcService::ArbitrumOne(L2MainnetService::Alchemy), - RpcService::ArbitrumOne(L2MainnetService::Ankr), - RpcService::ArbitrumOne(L2MainnetService::PublicNode), - RpcService::ArbitrumOne(L2MainnetService::Llama), -]; - -pub(crate) const BASE_PROVIDERS: &[RpcService] = &[ - RpcService::BaseMainnet(L2MainnetService::Alchemy), - RpcService::BaseMainnet(L2MainnetService::Ankr), - RpcService::BaseMainnet(L2MainnetService::PublicNode), - RpcService::BaseMainnet(L2MainnetService::Llama), -]; - -pub(crate) const OPTIMISM_PROVIDERS: &[RpcService] = &[ - RpcService::OptimismMainnet(L2MainnetService::Alchemy), - RpcService::OptimismMainnet(L2MainnetService::Ankr), - RpcService::OptimismMainnet(L2MainnetService::PublicNode), - RpcService::OptimismMainnet(L2MainnetService::Llama), -]; - -// Default RPC services for unknown EVM network -pub(crate) const UNKNOWN_PROVIDERS: &[RpcService] = &[]; diff --git a/src/rpc_client/requests.rs b/src/rpc_client/requests.rs deleted file mode 100644 index 2d8ea399..00000000 --- a/src/rpc_client/requests.rs +++ /dev/null @@ -1,19 +0,0 @@ -use crate::rpc_client::eth_rpc::BlockSpec; -use ic_ethereum_types::Address; -use serde::Serialize; - -/// Parameters of the [`eth_getTransactionCount`](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_gettransactioncount) call. -#[derive(Debug, Serialize, Clone)] -#[serde(into = "(Address, BlockSpec)")] -pub struct GetTransactionCountParams { - /// The address for which the transaction count is requested. - pub address: Address, - /// Integer block number, or "latest" for the last mined block or "pending", "earliest" for not yet mined transactions. - pub block: BlockSpec, -} - -impl From for (Address, BlockSpec) { - fn from(params: GetTransactionCountParams) -> Self { - (params.address, params.block) - } -} diff --git a/src/rpc_client/responses.rs b/src/rpc_client/responses.rs deleted file mode 100644 index 1917c070..00000000 --- a/src/rpc_client/responses.rs +++ /dev/null @@ -1,93 +0,0 @@ -use crate::rpc_client::checked_amount::CheckedAmountOf; -use crate::rpc_client::eth_rpc::{Hash, HttpResponsePayload, LogEntry, ResponseTransform}; -use crate::rpc_client::numeric::{BlockNumber, GasAmount, WeiPerGas}; -use serde::{Deserialize, Serialize}; -use std::fmt::{Display, Formatter}; - -#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] -pub struct TransactionReceipt { - /// The hash of the block containing the transaction. - #[serde(rename = "blockHash")] - pub block_hash: Hash, - - /// The number of the block containing the transaction. - #[serde(rename = "blockNumber")] - pub block_number: BlockNumber, - - /// The total base charge plus tip paid for each unit of gas - #[serde(rename = "effectiveGasPrice")] - pub effective_gas_price: WeiPerGas, - - /// The amount of gas used by this specific transaction alone - #[serde(rename = "gasUsed")] - pub gas_used: GasAmount, - - /// Status of the transaction. - pub status: TransactionStatus, - - /// The hash of the transaction - #[serde(rename = "transactionHash")] - pub transaction_hash: Hash, - - #[serde(rename = "contractAddress")] - pub contract_address: Option, - - pub from: String, - pub logs: Vec, - #[serde(rename = "logsBloom")] - pub logs_bloom: String, - pub to: String, - #[serde(rename = "transactionIndex")] - pub transaction_index: CheckedAmountOf<()>, - pub r#type: String, -} - -impl HttpResponsePayload for TransactionReceipt { - fn response_transform() -> Option { - Some(ResponseTransform::TransactionReceipt) - } -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)] -#[serde(try_from = "ethnum::u256", into = "ethnum::u256")] -pub enum TransactionStatus { - /// Transaction was mined and executed successfully. - Success, - - /// Transaction was mined but execution failed (e.g., out-of-gas error). - /// The amount of the transaction is returned to the sender but gas is consumed. - /// Note that this is different from a transaction that is not mined at all: a failed transaction - /// is part of the blockchain and the next transaction from the same sender should have an incremented - /// transaction nonce. - Failure, -} - -impl From for ethnum::u256 { - fn from(value: TransactionStatus) -> Self { - match value { - TransactionStatus::Success => ethnum::u256::ONE, - TransactionStatus::Failure => ethnum::u256::ZERO, - } - } -} - -impl TryFrom for TransactionStatus { - type Error = String; - - fn try_from(value: ethnum::u256) -> Result { - match value { - ethnum::u256::ZERO => Ok(TransactionStatus::Failure), - ethnum::u256::ONE => Ok(TransactionStatus::Success), - _ => Err(format!("invalid transaction status: {}", value)), - } - } -} - -impl Display for TransactionStatus { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - TransactionStatus::Success => write!(f, "Success"), - TransactionStatus::Failure => write!(f, "Failure"), - } - } -} diff --git a/src/rpc_client/tests.rs b/src/rpc_client/tests.rs index 9d00907c..de57b589 100644 --- a/src/rpc_client/tests.rs +++ b/src/rpc_client/tests.rs @@ -59,8 +59,11 @@ mod eth_rpc_client { } mod multi_call_results { + use crate::rpc_client::json::responses::FeeHistory; + use crate::rpc_client::numeric::{BlockNumber, WeiPerGas}; use evm_rpc_types::{EthMainnetService, RpcService}; + const ALCHEMY: RpcService = RpcService::EthMainnet(EthMainnetService::Alchemy); const ANKR: RpcService = RpcService::EthMainnet(EthMainnetService::Ankr); const PUBLIC_NODE: RpcService = RpcService::EthMainnet(EthMainnetService::PublicNode); const CLOUDFLARE: RpcService = RpcService::EthMainnet(EthMainnetService::Cloudflare); @@ -215,209 +218,193 @@ mod multi_call_results { } } - mod reduce_with_stable_majority_by_key { + mod reduce_with_threshold { use crate::rpc_client::json::responses::FeeHistory; - use crate::rpc_client::json::responses::JsonRpcResult; - use crate::rpc_client::numeric::{BlockNumber, WeiPerGas}; - use crate::rpc_client::tests::multi_call_results::{ANKR, CLOUDFLARE, PUBLIC_NODE}; + use crate::rpc_client::tests::multi_call_results::{ + fee_history, other_fee_history, ALCHEMY, ANKR, CLOUDFLARE, PUBLIC_NODE, + }; use crate::rpc_client::{MultiCallError, MultiCallResults}; + use evm_rpc_types::{ConsensusStrategy, JsonRpcError, RpcError}; #[test] fn should_get_unanimous_fee_history() { - let results: MultiCallResults = - MultiCallResults::from_json_rpc_result(vec![ - (ANKR, Ok(JsonRpcResult::Result(fee_history()))), - (PUBLIC_NODE, Ok(JsonRpcResult::Result(fee_history()))), - (CLOUDFLARE, Ok(JsonRpcResult::Result(fee_history()))), - ]); + let results = MultiCallResults::from_non_empty_iter(vec![ + (ALCHEMY, Ok(fee_history())), + (ANKR, Ok(fee_history())), + (PUBLIC_NODE, Ok(fee_history())), + (CLOUDFLARE, Ok(fee_history())), + ]); - let reduced = - results.reduce_with_strict_majority_by_key(|fee_history| fee_history.oldest_block); + let reduced = results.reduce(ConsensusStrategy::Threshold { + total: Some(4), + min: 3, + }); assert_eq!(reduced, Ok(fee_history())); } #[test] - fn should_get_fee_history_with_2_out_of_3() { - for index_non_majority in 0..3_usize { - let index_majority = (index_non_majority + 1) % 3; - let mut fees = [fee_history(), fee_history(), fee_history()]; - fees[index_non_majority].oldest_block = BlockNumber::new(0x10f73fd); - assert_ne!( - fees[index_non_majority].oldest_block, - fees[index_majority].oldest_block - ); - let majority_fee = fees[index_majority].clone(); - let [ankr_fee_history, cloudflare_fee_history, public_node_fee_history] = fees; - let results: MultiCallResults = - MultiCallResults::from_json_rpc_result(vec![ - (ANKR, Ok(JsonRpcResult::Result(ankr_fee_history))), - ( - CLOUDFLARE, - Ok(JsonRpcResult::Result(cloudflare_fee_history)), - ), - ( - PUBLIC_NODE, - Ok(JsonRpcResult::Result(public_node_fee_history)), - ), + fn should_get_fee_history_with_3_out_of_4() { + for inconsistent_result in [ + Ok(other_fee_history()), + Err(RpcError::JsonRpcError(JsonRpcError { + code: 500, + message: "offline".to_string(), + })), + ] { + for index_inconsistent in 0..4_usize { + let mut fees = [ + Ok(fee_history()), + Ok(fee_history()), + Ok(fee_history()), + Ok(fee_history()), + ]; + fees[index_inconsistent] = inconsistent_result.clone(); + let [alchemy_fee_history, ankr_fee_history, cloudflare_fee_history, public_node_fee_history] = + fees; + let results = MultiCallResults::from_non_empty_iter(vec![ + (ALCHEMY, alchemy_fee_history), + (ANKR, ankr_fee_history), + (PUBLIC_NODE, public_node_fee_history), + (CLOUDFLARE, cloudflare_fee_history), ]); - let reduced = results - .reduce_with_strict_majority_by_key(|fee_history| fee_history.oldest_block); + let reduced = results.reduce(ConsensusStrategy::Threshold { + total: Some(4), + min: 3, + }); - assert_eq!(reduced, Ok(majority_fee)); + assert_eq!(reduced, Ok(fee_history())); + } } } #[test] - fn should_fail_when_no_strict_majority() { - let ankr_fee_history = FeeHistory { - oldest_block: BlockNumber::new(0x10f73fd), - ..fee_history() - }; - let cloudflare_fee_history = FeeHistory { - oldest_block: BlockNumber::new(0x10f73fc), - ..fee_history() - }; - let public_node_fee_history = FeeHistory { - oldest_block: BlockNumber::new(0x10f73fe), - ..fee_history() - }; - let three_distinct_results: MultiCallResults = - MultiCallResults::from_json_rpc_result(vec![ - (ANKR, Ok(JsonRpcResult::Result(ankr_fee_history.clone()))), - ( - PUBLIC_NODE, - Ok(JsonRpcResult::Result(public_node_fee_history.clone())), - ), - ]); - - let reduced = three_distinct_results + fn should_fail_when_two_errors_or_inconsistencies() { + use itertools::Itertools; + + let inconsistent_results = [ + Ok(other_fee_history()), + Err(RpcError::JsonRpcError(JsonRpcError { + code: 500, + message: "offline".to_string(), + })), + ]; + + for (inconsistent_res_1, inconsistent_res_2) in inconsistent_results .clone() - .reduce_with_strict_majority_by_key(|fee_history| fee_history.oldest_block); - - assert_eq!( - reduced, - Err(MultiCallError::InconsistentResults( - MultiCallResults::from_json_rpc_result(vec![ - (ANKR, Ok(JsonRpcResult::Result(ankr_fee_history.clone()))), - ( - PUBLIC_NODE, - Ok(JsonRpcResult::Result(public_node_fee_history)) - ), - ]) - )) - ); + .iter() + .cartesian_product(inconsistent_results) + { + for indexes in (0..4_usize).permutations(2) { + let mut fees = [ + Ok(fee_history()), + Ok(fee_history()), + Ok(fee_history()), + Ok(fee_history()), + ]; + fees[indexes[0]] = inconsistent_res_1.clone(); + fees[indexes[1]] = inconsistent_res_2.clone(); + let [alchemy_fee_history, ankr_fee_history, cloudflare_fee_history, public_node_fee_history] = + fees; + let results = MultiCallResults::from_non_empty_iter(vec![ + (ALCHEMY, alchemy_fee_history), + (ANKR, ankr_fee_history), + (PUBLIC_NODE, public_node_fee_history), + (CLOUDFLARE, cloudflare_fee_history), + ]); - let two_distinct_results: MultiCallResults = - MultiCallResults::from_json_rpc_result(vec![ - (ANKR, Ok(JsonRpcResult::Result(ankr_fee_history.clone()))), - ( - PUBLIC_NODE, - Ok(JsonRpcResult::Result(cloudflare_fee_history.clone())), - ), - ]); + let reduced = results.clone().reduce(ConsensusStrategy::Threshold { + total: Some(4), + min: 3, + }); - let reduced = two_distinct_results - .clone() - .reduce_with_strict_majority_by_key(|fee_history| fee_history.oldest_block); - - assert_eq!( - reduced, - Err(MultiCallError::InconsistentResults( - MultiCallResults::from_json_rpc_result(vec![ - (ANKR, Ok(JsonRpcResult::Result(ankr_fee_history))), - ( - PUBLIC_NODE, - Ok(JsonRpcResult::Result(cloudflare_fee_history)) - ), - ]) - )) - ); + assert_eq!(reduced, Err(MultiCallError::InconsistentResults(results))); + } + } } #[test] - fn should_fail_when_fee_history_inconsistent_for_same_oldest_block() { - let (fee, inconsistent_fee) = { - let fee = fee_history(); - let mut inconsistent_fee = fee.clone(); - inconsistent_fee.base_fee_per_gas[0] = WeiPerGas::new(0x729d3f3b4); - assert_ne!(fee, inconsistent_fee); - (fee, inconsistent_fee) - }; - - let results: MultiCallResults = - MultiCallResults::from_json_rpc_result(vec![ - (ANKR, Ok(JsonRpcResult::Result(fee.clone()))), - ( - PUBLIC_NODE, - Ok(JsonRpcResult::Result(inconsistent_fee.clone())), - ), + fn should_fail_when_too_many_errors() { + let error = RpcError::JsonRpcError(JsonRpcError { + code: 500, + message: "offline".to_string(), + }); + for ok_index in 0..4_usize { + let mut fees = [ + Err(error.clone()), + Err(error.clone()), + Err(error.clone()), + Err(error.clone()), + ]; + fees[ok_index] = Ok(fee_history()); + let [alchemy_fee_history, ankr_fee_history, cloudflare_fee_history, public_node_fee_history] = + fees; + let results = MultiCallResults::from_non_empty_iter(vec![ + (ALCHEMY, alchemy_fee_history), + (ANKR, ankr_fee_history), + (PUBLIC_NODE, public_node_fee_history), + (CLOUDFLARE, cloudflare_fee_history), ]); - let reduced = - results.reduce_with_strict_majority_by_key(|fee_history| fee_history.oldest_block); + let reduced = results.clone().reduce(ConsensusStrategy::Threshold { + total: Some(4), + min: 3, + }); - assert_eq!( - reduced, - Err(MultiCallError::InconsistentResults( - MultiCallResults::from_json_rpc_result(vec![ - (ANKR, Ok(JsonRpcResult::Result(fee.clone()))), - (PUBLIC_NODE, Ok(JsonRpcResult::Result(inconsistent_fee))), - ]) - )) - ); - } + assert_eq!(reduced, Err(MultiCallError::InconsistentResults(results))); + } - #[test] - fn should_fail_upon_any_error() { let results: MultiCallResults = - MultiCallResults::from_json_rpc_result(vec![ - (ANKR, Ok(JsonRpcResult::Result(fee_history()))), - ( - PUBLIC_NODE, - Ok(JsonRpcResult::Error { - code: -32700, - message: "error".to_string(), - }), - ), + MultiCallResults::from_non_empty_iter(vec![ + (ALCHEMY, Err(error.clone())), + (ANKR, Err(error.clone())), + (PUBLIC_NODE, Err(error.clone())), + (CLOUDFLARE, Err(error.clone())), ]); - - let reduced = results - .clone() - .reduce_with_strict_majority_by_key(|fee_history| fee_history.oldest_block); - - assert_eq!(reduced, Err(MultiCallError::InconsistentResults(results))); + let reduced = results.clone().reduce(ConsensusStrategy::Threshold { + total: Some(4), + min: 3, + }); + assert_eq!(reduced, Err(MultiCallError::ConsistentError(error))); } + } - fn fee_history() -> FeeHistory { - FeeHistory { - oldest_block: BlockNumber::new(0x10f73fc), - base_fee_per_gas: vec![ - WeiPerGas::new(0x729d3f3b3), - WeiPerGas::new(0x766e503ea), - WeiPerGas::new(0x75b51b620), - WeiPerGas::new(0x74094f2b4), - WeiPerGas::new(0x716724f03), - WeiPerGas::new(0x73b467f76), - ], - gas_used_ratio: vec![ - 0.6332004, - 0.47556506666666665, - 0.4432122666666667, - 0.4092196, - 0.5811903, - ], - reward: vec![ - vec![WeiPerGas::new(0x5f5e100)], - vec![WeiPerGas::new(0x55d4a80)], - vec![WeiPerGas::new(0x5f5e100)], - vec![WeiPerGas::new(0x5f5e100)], - vec![WeiPerGas::new(0x5f5e100)], - ], - } + fn fee_history() -> FeeHistory { + FeeHistory { + oldest_block: BlockNumber::new(0x10f73fc), + base_fee_per_gas: vec![ + WeiPerGas::new(0x729d3f3b3), + WeiPerGas::new(0x766e503ea), + WeiPerGas::new(0x75b51b620), + WeiPerGas::new(0x74094f2b4), + WeiPerGas::new(0x716724f03), + WeiPerGas::new(0x73b467f76), + ], + gas_used_ratio: vec![ + 0.6332004, + 0.47556506666666665, + 0.4432122666666667, + 0.4092196, + 0.5811903, + ], + reward: vec![ + vec![WeiPerGas::new(0x5f5e100)], + vec![WeiPerGas::new(0x55d4a80)], + vec![WeiPerGas::new(0x5f5e100)], + vec![WeiPerGas::new(0x5f5e100)], + vec![WeiPerGas::new(0x5f5e100)], + ], } } + + fn other_fee_history() -> FeeHistory { + let mut fee = fee_history(); + let original_oldest_block = fee.oldest_block; + fee.oldest_block = BlockNumber::new(0x10f73fd); + assert_ne!(fee.oldest_block, original_oldest_block); + fee + } } mod eth_get_transaction_receipt { @@ -597,8 +584,8 @@ mod providers { too_many_custom_providers in arb_custom_rpc_services(5..=10) ) { let strategy = ConsensusStrategy::Threshold { - num_providers: Some(4), - min_num_ok: 3, + total: Some(4), + min: 3, }; let providers = Providers::new( @@ -623,8 +610,8 @@ mod providers { #[test] fn should_choose_default_providers_first() { let strategy = ConsensusStrategy::Threshold { - num_providers: Some(4), - min_num_ok: 3, + total: Some(4), + min: 3, }; let providers = Providers::new(RpcServices::EthMainnet(None), strategy.clone()).unwrap(); @@ -673,8 +660,8 @@ mod providers { #[test] fn should_fail_when_threshold_unspecified_with_default_providers() { let strategy = ConsensusStrategy::Threshold { - num_providers: None, - min_num_ok: 3, + total: None, + min: 3, }; for default_services in [ @@ -691,8 +678,8 @@ mod providers { proptest! { #[test] - fn should_fail_when_threshold_larger_than_number_of_supported_providers(min_num_ok in any::()) { - for (default_services, max_num_providers) in [ + fn should_fail_when_threshold_larger_than_number_of_supported_providers(min in any::()) { + for (default_services, max_total) in [ ( RpcServices::EthMainnet(None), EthMainnetService::all().len(), @@ -715,8 +702,8 @@ mod providers { ), ] { let strategy = ConsensusStrategy::Threshold { - num_providers: Some((max_num_providers + 1) as u8), - min_num_ok, + total: Some((max_total + 1) as u8), + min, }; let providers = Providers::new(default_services, strategy); assert_matches!(providers, Err(ProviderError::InvalidRpcConfig(_))); @@ -728,15 +715,15 @@ mod providers { #[test] fn should_fail_when_threshold_invalid(services in arb_rpc_services()) { let strategy = ConsensusStrategy::Threshold { - num_providers: Some(4), - min_num_ok: 5, + total: Some(4), + min: 5, }; let providers = Providers::new(services.clone(), strategy.clone()); assert_matches!(providers, Err(ProviderError::InvalidRpcConfig(_))); let strategy = ConsensusStrategy::Threshold { - num_providers: Some(4), - min_num_ok: 0, + total: Some(4), + min: 0, }; let providers = Providers::new(services, strategy.clone()); assert_matches!(providers, Err(ProviderError::InvalidRpcConfig(_))); diff --git a/tests/tests.rs b/tests/tests.rs index e875cfae..91bda4ce 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -9,8 +9,9 @@ use evm_rpc::{ types::{InstallArgs, Metrics, ProviderId, RpcAccess, RpcMethod}, }; use evm_rpc_types::{ - EthMainnetService, EthSepoliaService, Hex, Hex20, Hex32, HttpOutcallError, JsonRpcError, - MultiRpcResult, Nat256, ProviderError, RpcApi, RpcError, RpcResult, RpcService, RpcServices, + ConsensusStrategy, EthMainnetService, EthSepoliaService, Hex, Hex20, Hex32, HttpOutcallError, + JsonRpcError, MultiRpcResult, Nat256, ProviderError, RpcApi, RpcConfig, RpcError, RpcResult, + RpcService, RpcServices, }; use ic_base_types::{CanisterId, PrincipalId}; use ic_canisters_http_types::{HttpRequest, HttpResponse}; @@ -1118,6 +1119,95 @@ fn candid_rpc_should_return_inconsistent_results() { ); } +#[test] +fn candid_rpc_should_return_3_out_of_4_transaction_count() { + let setup = EvmRpcSetup::new().mock_api_keys(); + + fn eth_get_transaction_count_with_3_out_of_4( + setup: &EvmRpcSetup, + ) -> CallFlow> { + setup.eth_get_transaction_count( + RpcServices::EthMainnet(None), + Some(RpcConfig { + response_consensus: Some(ConsensusStrategy::Threshold { + total: Some(4), + min: 3, + }), + ..Default::default() + }), + evm_rpc_types::GetTransactionCountArgs { + address: "0xdAC17F958D2ee523a2206206994597C13D831ec7" + .parse() + .unwrap(), + block: evm_rpc_types::BlockTag::Latest, + }, + ) + } + + for successful_mocks in [ + [ + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + ], + [ + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + MockOutcallBuilder::new(500, r#"OFFLINE"#), + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + ], + [ + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x2"}"#), + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + ], + ] { + let result = eth_get_transaction_count_with_3_out_of_4(&setup) + .mock_http_once(successful_mocks[0].clone()) + .mock_http_once(successful_mocks[1].clone()) + .mock_http_once(successful_mocks[2].clone()) + .mock_http_once(successful_mocks[3].clone()) + .wait() + .expect_consistent() + .unwrap(); + + assert_eq!(result, 1_u8.into()); + } + + for error_mocks in [ + [ + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + MockOutcallBuilder::new(500, r#"OFFLINE"#), + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x2"}"#), + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + ], + [ + MockOutcallBuilder::new(403, r#"FORBIDDEN"#), + MockOutcallBuilder::new(500, r#"OFFLINE"#), + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + ], + [ + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x3"}"#), + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x2"}"#), + MockOutcallBuilder::new(200, r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), + ], + ] { + let result = eth_get_transaction_count_with_3_out_of_4(&setup) + .mock_http_once(error_mocks[0].clone()) + .mock_http_once(error_mocks[1].clone()) + .mock_http_once(error_mocks[2].clone()) + .mock_http_once(error_mocks[3].clone()) + .wait() + .expect_inconsistent(); + + assert_eq!(result.len(), 4); + } +} + #[test] fn candid_rpc_should_return_inconsistent_results_with_error() { let setup = EvmRpcSetup::new().mock_api_keys();